mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 15:08:51 +00:00
Merge pull request #8172 from Gorgious56/bonsai/railing-edit-gizmos
Bonsai/railing edit gizmos
This commit is contained in:
@@ -4877,7 +4877,14 @@ class GizmoDimension(GizmoMovable):
|
|||||||
|
|
||||||
self.init_value = click_distance
|
self.init_value = click_distance
|
||||||
|
|
||||||
if self.initial_snap_state and self.active_obj:
|
# Schematic gizmos opt out of dimension snap. Force the header
|
||||||
|
# indicator to ``off`` for the drag's duration so the user sees the
|
||||||
|
# state matches behaviour; ``exit`` restores ``initial_snap_state``.
|
||||||
|
# Skipping the snap cache here also avoids the per-drag mesh probe.
|
||||||
|
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
|
||||||
|
if not snap_supported:
|
||||||
|
context.scene.tool_settings.use_snap = False
|
||||||
|
elif self.initial_snap_state and self.active_obj:
|
||||||
build_snap_cache(context, self.active_obj)
|
build_snap_cache(context, self.active_obj)
|
||||||
self._snap_cache_built = True
|
self._snap_cache_built = True
|
||||||
|
|
||||||
@@ -4919,11 +4926,18 @@ class GizmoDimension(GizmoMovable):
|
|||||||
if not region or not rv3d:
|
if not region or not rv3d:
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
|
# Group-level opt-out: schematic gizmos float in viewport space, so
|
||||||
|
# global-snap-to-scene-vertices would produce spurious value jumps.
|
||||||
|
# The fallback (``True``) covers any gizmo whose group is not a
|
||||||
|
# ``BaseParametricGizmoGroup``.
|
||||||
|
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
|
||||||
|
|
||||||
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
|
if snap_supported:
|
||||||
build_snap_cache(context, self.active_obj)
|
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
|
||||||
self._snap_cache_built = True
|
|
||||||
|
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
|
||||||
|
build_snap_cache(context, self.active_obj)
|
||||||
|
self._snap_cache_built = True
|
||||||
|
|
||||||
current_coord = (event.mouse_region_x, event.mouse_region_y)
|
current_coord = (event.mouse_region_x, event.mouse_region_y)
|
||||||
|
|
||||||
@@ -4947,7 +4961,7 @@ class GizmoDimension(GizmoMovable):
|
|||||||
|
|
||||||
delta = (current_3d - self.start_location).dot(axis_direction)
|
delta = (current_3d - self.start_location).dot(axis_direction)
|
||||||
|
|
||||||
if tool_settings.use_snap and self.active_obj:
|
if snap_supported and tool_settings.use_snap and self.active_obj:
|
||||||
# Snap the dimension tip (not mouse position) to target
|
# Snap the dimension tip (not mouse position) to target
|
||||||
# Calculate where the dimension tip would be with current delta
|
# Calculate where the dimension tip would be with current delta
|
||||||
# The tip is at: gizmo_origin + axis * (init_value + delta)
|
# The tip is at: gizmo_origin + axis * (init_value + delta)
|
||||||
@@ -5320,6 +5334,13 @@ class BaseParametricGizmoGroup:
|
|||||||
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
|
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
|
||||||
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
|
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
|
||||||
|
|
||||||
|
# Default: dimension drags respect Blender's global snap (Ctrl-toggleable
|
||||||
|
# during drag). Subclasses whose dimensions float in viewport space rather
|
||||||
|
# than aligning to real-world geometry should override to ``False`` —
|
||||||
|
# snapping to scene vertices in that case produces spurious value jumps
|
||||||
|
# as the mouse crosses unrelated meshes.
|
||||||
|
snap_enabled_on_dimensions: bool = True
|
||||||
|
|
||||||
# === Icon Gizmo Layout (meters) ===
|
# === Icon Gizmo Layout (meters) ===
|
||||||
# Icons are positioned in a horizontal row above the element:
|
# Icons are positioned in a horizontal row above the element:
|
||||||
# [Validate] [Cancel] [Cycle]
|
# [Validate] [Cancel] [Cycle]
|
||||||
@@ -6580,6 +6601,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
|
|||||||
# list and become no-ops. The schematic equivalents below take their place.
|
# list and become no-ops. The schematic equivalents below take their place.
|
||||||
dimension_gizmo_props: list[DimensionGizmoConfig] = []
|
dimension_gizmo_props: list[DimensionGizmoConfig] = []
|
||||||
|
|
||||||
|
# Schematic dimensions float in billboarded viewport space, not aligned to
|
||||||
|
# real-world geometry. Snapping the dragged tip to scene vertices would
|
||||||
|
# produce nonsensical value jumps as the mouse crosses unrelated meshes.
|
||||||
|
snap_enabled_on_dimensions: bool = False
|
||||||
|
|
||||||
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
|
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
|
||||||
# and ``update_schematic_dimensions``. Each config produces one
|
# and ``update_schematic_dimensions``. Each config produces one
|
||||||
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
|
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
|
||||||
|
|||||||
@@ -246,9 +246,13 @@ classes = (
|
|||||||
railing.CopyRailingParameters,
|
railing.CopyRailingParameters,
|
||||||
railing.AddRailing,
|
railing.AddRailing,
|
||||||
railing.CancelEditingRailing,
|
railing.CancelEditingRailing,
|
||||||
|
railing.CycleRailingType,
|
||||||
railing.FinishEditingRailing,
|
railing.FinishEditingRailing,
|
||||||
|
railing.PickRailingTerminalType,
|
||||||
railing.FlipRailingPathOrder,
|
railing.FlipRailingPathOrder,
|
||||||
railing.EnableEditingRailing,
|
railing.EnableEditingRailing,
|
||||||
|
railing.GizmoRailingSchematic,
|
||||||
|
railing.ToggleRailingUseManualSupports,
|
||||||
railing.CancelEditingRailingPath,
|
railing.CancelEditingRailingPath,
|
||||||
railing.FinishEditingRailingPath,
|
railing.FinishEditingRailingPath,
|
||||||
railing.EnableEditingRailingPath,
|
railing.EnableEditingRailingPath,
|
||||||
|
|||||||
@@ -228,11 +228,7 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co
|
|||||||
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
|
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
|
||||||
"""Regenerate railing mesh when property changes."""
|
"""Regenerate railing mesh when property changes."""
|
||||||
if self.is_editing:
|
if self.is_editing:
|
||||||
# Only FRAMELESS_PANEL can update live via bmesh.
|
_get_updater("railing", "update_railing_modifier_bmesh")(context)
|
||||||
# WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
|
|
||||||
# so it only updates on "Finish Editing" to avoid modifying IFC during preview.
|
|
||||||
if self.railing_type == "FRAMELESS_PANEL":
|
|
||||||
_get_updater("railing", "update_railing_modifier_bmesh")(context)
|
|
||||||
|
|
||||||
|
|
||||||
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import bmesh
|
import bmesh
|
||||||
@@ -27,14 +28,24 @@ import ifcopenshell.api.geometry
|
|||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
import ifcopenshell.util.representation
|
import ifcopenshell.util.representation
|
||||||
import ifcopenshell.util.unit
|
import ifcopenshell.util.unit
|
||||||
from mathutils import Vector
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.core.root
|
import bonsai.core.root
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
|
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||||
|
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||||
|
from bonsai.bim.module.model import prop
|
||||||
from bonsai.bim.module.model.data import RailingData, refresh
|
from bonsai.bim.module.model.data import RailingData, refresh
|
||||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
from bonsai.bim.parametric_lifecycle import (
|
||||||
|
CycleTypeMixin,
|
||||||
|
PathPreservingEditMixin,
|
||||||
|
PickTypeMixin,
|
||||||
|
)
|
||||||
|
from bonsai.tool.cad import WELD_TOLERANCE
|
||||||
|
|
||||||
|
V_ = tool.Blender.V_
|
||||||
|
|
||||||
# reference:
|
# reference:
|
||||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
|
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
|
||||||
@@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
|
|||||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
|
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wall_mounted_handrail_preview(
|
||||||
|
obj: bpy.types.Object,
|
||||||
|
props: "BIMRailingProperties",
|
||||||
|
path_data: dict[str, Any],
|
||||||
|
si_conversion: float,
|
||||||
|
) -> None:
|
||||||
|
"""Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same
|
||||||
|
geometry helper the IFC representation builder uses, without writing any IFC."""
|
||||||
|
railing_path = [Vector(v) * si_conversion for v in path_data["verts"]]
|
||||||
|
looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0]
|
||||||
|
|
||||||
|
geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry(
|
||||||
|
railing_path=railing_path,
|
||||||
|
support_spacing=props.support_spacing,
|
||||||
|
railing_diameter=props.railing_diameter,
|
||||||
|
clear_width=props.clear_width,
|
||||||
|
height=props.height,
|
||||||
|
use_manual_supports=props.use_manual_supports,
|
||||||
|
terminal_type=props.terminal_type,
|
||||||
|
looped_path=looped_path,
|
||||||
|
unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion
|
||||||
|
)
|
||||||
|
|
||||||
|
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
|
||||||
|
|
||||||
|
tool.Cad.sweep_disk_along_polyline(
|
||||||
|
bm,
|
||||||
|
[Vector(p) for p in geom.handrail_polyline],
|
||||||
|
geom.handrail_radius,
|
||||||
|
arc_indices=geom.handrail_arc_point_indices,
|
||||||
|
)
|
||||||
|
|
||||||
|
for support in geom.supports:
|
||||||
|
tool.Cad.sweep_disk_along_polyline(
|
||||||
|
bm,
|
||||||
|
[Vector(p) for p in support.arc_polyline],
|
||||||
|
support.arc_radius,
|
||||||
|
)
|
||||||
|
tool.Cad.add_disk_extrusion(
|
||||||
|
bm,
|
||||||
|
Vector(support.disk_position),
|
||||||
|
support.disk_radius,
|
||||||
|
support.disk_depth,
|
||||||
|
support.disk_z_rotation,
|
||||||
|
)
|
||||||
|
|
||||||
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||||
|
tool.Blender.apply_bmesh(obj.data, bm)
|
||||||
|
|
||||||
|
|
||||||
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
||||||
"""before using should make sure that Data contains up-to-date information.
|
"""before using should make sure that Data contains up-to-date information.
|
||||||
If BBIM Pset just changed should call refresh() before updating bmesh
|
If BBIM Pset just changed should call refresh() before updating bmesh
|
||||||
@@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
|||||||
path_data = RailingData.data["path_data"]
|
path_data = RailingData.data["path_data"]
|
||||||
|
|
||||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||||
|
|
||||||
|
# WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays
|
||||||
|
# untouched until Finish Editing rebuilds the representation.
|
||||||
|
if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL":
|
||||||
|
generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion)
|
||||||
|
return
|
||||||
|
|
||||||
# need to make sure we support edit mode
|
# need to make sure we support edit mode
|
||||||
# since users will probably be in edit mode when they'll be changing railing path
|
# since users will probably be in edit mode when they'll be changing railing path
|
||||||
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
|
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
|
||||||
@@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
|||||||
thickness = props.thickness
|
thickness = props.thickness
|
||||||
spacing = props.spacing
|
spacing = props.spacing
|
||||||
|
|
||||||
# spacing
|
|
||||||
# split each edge in 3 segments by 0.5 * spacing by x-y plane
|
|
||||||
main_edges = bm.edges[:]
|
main_edges = bm.edges[:]
|
||||||
for main_edge in main_edges:
|
for main_edge in main_edges:
|
||||||
bm_split_edge_at_offset(main_edge, spacing)
|
bm_split_edge_at_offset(main_edge, spacing)
|
||||||
@@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
|
|||||||
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
|
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
|
||||||
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
|
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
|
||||||
# to remove unnecessary verts in 0 spacing case
|
# to remove unnecessary verts in 0 spacing case
|
||||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
|
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
|
||||||
|
|
||||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||||
|
|
||||||
@@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
|
|||||||
segments.append((i - 1, 0))
|
segments.append((i - 1, 0))
|
||||||
break
|
break
|
||||||
|
|
||||||
# skip path verts if they just go vertical to avoid errors
|
# Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream.
|
||||||
if (v.co.xy - prev_v.co.xy).length <= 0.0001:
|
if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
points.append(v.co)
|
points.append(v.co)
|
||||||
@@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
|
|
||||||
|
|
||||||
class _RailingEditMixin(PathPreservingEditMixin):
|
class _RailingEditMixin(PathPreservingEditMixin):
|
||||||
"""Type-specific hooks for railing parametric-edit operators. Single-object
|
"""Single-object (active_object) railing-edit hooks; path_data is preserved
|
||||||
(active_object). ``path_data`` is preserved through the edit; the separate
|
through the edit (path editing is a separate operator family)."""
|
||||||
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
|
|
||||||
|
|
||||||
pset_name = "BBIM_Railing"
|
pset_name = "BBIM_Railing"
|
||||||
|
|
||||||
@@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin):
|
|||||||
update_railing_modifier_ifc_data(context)
|
update_railing_modifier_ifc_data(context)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||||
|
"""WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
|
||||||
|
props = tool.Model.get_railing_props(obj)
|
||||||
|
if props.railing_type == "WALL_MOUNTED_HANDRAIL":
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
assert element
|
||||||
|
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||||
|
if body:
|
||||||
|
bonsai.core.geometry.switch_representation(
|
||||||
|
tool.Ifc,
|
||||||
|
tool.Geometry,
|
||||||
|
obj=obj,
|
||||||
|
representation=body,
|
||||||
|
)
|
||||||
|
return
|
||||||
update_railing_modifier_bmesh(context)
|
update_railing_modifier_bmesh(context)
|
||||||
|
|
||||||
|
|
||||||
@@ -467,6 +546,554 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
|
|||||||
return self._finish_targets(context)
|
return self._finish_targets(context)
|
||||||
|
|
||||||
|
|
||||||
|
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||||
|
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
|
||||||
|
|
||||||
|
bl_idname = "bim.cycle_railing_type"
|
||||||
|
bl_label = "Cycle Railing Type"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
element_checker = tool.Parametric.is_railing
|
||||||
|
props_getter = tool.Model.get_railing_props
|
||||||
|
type_literal = tool.Model.RailingType
|
||||||
|
type_attr = "railing_type"
|
||||||
|
|
||||||
|
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||||
|
return self._cycle_type(context)
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleRailingUseManualSupports(bpy.types.Operator):
|
||||||
|
"""Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
|
||||||
|
|
||||||
|
No-op unless a parametric edit is active and the railing is wall-mounted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bl_idname = "bim.toggle_railing_use_manual_supports"
|
||||||
|
bl_label = "Toggle Railing Manual Supports"
|
||||||
|
bl_description = "Switch between automatic support spacing and manual per-vertex placement"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
resolved = tool.Model.resolve_active_props_for_edit(
|
||||||
|
context,
|
||||||
|
tool.Model.get_railing_props,
|
||||||
|
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
|
||||||
|
)
|
||||||
|
if resolved is None:
|
||||||
|
return {"CANCELLED"}
|
||||||
|
_obj, props = resolved
|
||||||
|
props.use_manual_supports = not props.use_manual_supports
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||||
|
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
|
||||||
|
|
||||||
|
bl_idname = "bim.pick_railing_terminal_type"
|
||||||
|
bl_label = "Pick Railing Terminal Type"
|
||||||
|
bl_description = "Pick the cap geometry applied at the rail ends"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
skip_element_check = True
|
||||||
|
props_getter = tool.Model.get_railing_props
|
||||||
|
type_literal = prop.CapType
|
||||||
|
type_attr = "terminal_type"
|
||||||
|
|
||||||
|
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||||
|
if (
|
||||||
|
tool.Model.resolve_active_props_for_edit(
|
||||||
|
context,
|
||||||
|
tool.Model.get_railing_props,
|
||||||
|
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
return {"CANCELLED"}
|
||||||
|
return self._pick_type(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_attr_distance(attr_name: str):
|
||||||
|
"""text_formatter that renders the named property as a distance, ignoring the
|
||||||
|
dimension's visible-length argument (which is fixed for schematic gizmos)."""
|
||||||
|
return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
|
||||||
|
|
||||||
|
|
||||||
|
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
|
||||||
|
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
|
||||||
|
|
||||||
|
bl_idname = "OBJECT_GGT_bim_railing_edition"
|
||||||
|
bl_label = "Railing Editing Gizmo"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
bl_region_type = "WINDOW"
|
||||||
|
bl_options = {"3D", "PERSISTENT"}
|
||||||
|
|
||||||
|
enable_editing_operator = "bim.enable_editing_railing"
|
||||||
|
finish_editing_operator = "bim.finish_editing_railing"
|
||||||
|
cancel_editing_operator = "bim.cancel_editing_railing"
|
||||||
|
cycle_type_operator = "bim.cycle_railing_type"
|
||||||
|
|
||||||
|
props_getter = tool.Model.get_railing_props
|
||||||
|
gizmo_pref_name = "railing"
|
||||||
|
|
||||||
|
# Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
|
||||||
|
# (post billboard rotation). Each dimension is anchored alongside the feature it
|
||||||
|
# measures so the label, not the bar length, carries the value.
|
||||||
|
SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
|
||||||
|
SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
|
||||||
|
SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
|
||||||
|
SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
|
||||||
|
# WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
|
||||||
|
# scale with the host group's box size.
|
||||||
|
SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
|
||||||
|
SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
|
||||||
|
SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def schematic_rail_radius(cls) -> float:
|
||||||
|
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def schematic_rail_clear(cls) -> float:
|
||||||
|
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
|
||||||
|
|
||||||
|
# Axonometric 3/4 view: +Z projects down-and-left so the depth axis
|
||||||
|
# is visibly separated from the back face. Without the X tilt, panel
|
||||||
|
# thickness (schematic-local Z) collapses to a near-horizontal bar.
|
||||||
|
schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
|
||||||
|
|
||||||
|
# Hover a dimension → highlight the schematic edges tagged with the matching feature.
|
||||||
|
# Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
|
||||||
|
# absent from this map and gracefully no-ops on hover.
|
||||||
|
schematic_attr_to_feature = {
|
||||||
|
"height": "panel_height",
|
||||||
|
"thickness": "panel_thickness",
|
||||||
|
"railing_diameter": "rail_tube",
|
||||||
|
"clear_width": "bracket",
|
||||||
|
"support_spacing": "bracket",
|
||||||
|
}
|
||||||
|
|
||||||
|
schematic_dimension_props = [
|
||||||
|
# ── FRAMELESS_PANEL ─────────────────────────────────────────────
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="height",
|
||||||
|
axis=(0, 1, 0),
|
||||||
|
min_value=0.01,
|
||||||
|
# Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
|
||||||
|
# feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
|
||||||
|
# is a no-op under the default "180" terminal.
|
||||||
|
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||||
|
matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
|
||||||
|
schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
|
||||||
|
text_formatter=_format_attr_distance("height"),
|
||||||
|
),
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="thickness",
|
||||||
|
axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
|
||||||
|
min_value=0.005,
|
||||||
|
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||||
|
matrix_position=lambda p: Vector(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||||
|
- GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
|
||||||
|
)
|
||||||
|
/ 2,
|
||||||
|
GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
|
||||||
|
-GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
schematic_visible_length=0.4, # longer than default to survive depth foreshortening
|
||||||
|
text_formatter=_format_attr_distance("thickness"),
|
||||||
|
),
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="spacing",
|
||||||
|
axis=(1, 0, 0),
|
||||||
|
min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
|
||||||
|
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
|
||||||
|
matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
|
||||||
|
text_formatter=_format_attr_distance("spacing"),
|
||||||
|
),
|
||||||
|
# ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="railing_diameter",
|
||||||
|
axis=(0, 1, 0),
|
||||||
|
min_value=0.001,
|
||||||
|
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
|
||||||
|
matrix_position=lambda p: Vector(
|
||||||
|
(
|
||||||
|
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
|
||||||
|
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
|
||||||
|
GizmoRailingSchematic.schematic_rail_clear(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
text_formatter=_format_attr_distance("railing_diameter"),
|
||||||
|
),
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="clear_width",
|
||||||
|
axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
|
||||||
|
min_value=0.001,
|
||||||
|
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
|
||||||
|
matrix_position=lambda p: Vector(
|
||||||
|
(
|
||||||
|
0.0,
|
||||||
|
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
|
||||||
|
text_formatter=_format_attr_distance("clear_width"),
|
||||||
|
),
|
||||||
|
DimensionGizmoConfig(
|
||||||
|
attr_name="support_spacing",
|
||||||
|
axis=(1, 0, 0),
|
||||||
|
min_value=0.05,
|
||||||
|
visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
|
||||||
|
matrix_position=lambda p: Vector(
|
||||||
|
(
|
||||||
|
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||||
|
+ GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
|
||||||
|
-0.18,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
# Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
|
||||||
|
schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
|
||||||
|
text_formatter=_format_attr_distance("support_spacing"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||||
|
return tool.Parametric.is_railing(element)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def schematic_cache_key(cls, props) -> tuple:
|
||||||
|
"""Cache the schematic mesh by ``railing_type`` — proportions are fixed
|
||||||
|
per type, so the bmesh build runs at most twice across a session
|
||||||
|
(once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
|
||||||
|
rather than once per draw call."""
|
||||||
|
return (props.railing_type,)
|
||||||
|
|
||||||
|
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||||
|
"""Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
|
||||||
|
|
||||||
|
Two static lock glyphs (open/closed) for toggling
|
||||||
|
``use_manual_supports``: instantiate both and let the per-frame state
|
||||||
|
query pick which one to show. State-aware icons use a static pair
|
||||||
|
rather than a single dynamic gizmo to avoid ``prop_path`` resolution
|
||||||
|
in the render path.
|
||||||
|
|
||||||
|
Plus a cycle-glyph at the rail end that opens the ``terminal_type``
|
||||||
|
popup when clicked.
|
||||||
|
"""
|
||||||
|
default_color, highlight_color = self.get_decoration_colors()
|
||||||
|
|
||||||
|
self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||||
|
"bim.toggle_railing_use_manual_supports",
|
||||||
|
open_color=default_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
|
||||||
|
self.terminal_gizmo.color = default_color
|
||||||
|
self.terminal_gizmo.color_highlight = highlight_color
|
||||||
|
self.terminal_gizmo.use_draw_scale = False
|
||||||
|
self.terminal_gizmo.alpha = 0.8
|
||||||
|
self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
|
||||||
|
|
||||||
|
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
|
||||||
|
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
|
||||||
|
|
||||||
|
- Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
|
||||||
|
``lock_open`` when ``use_manual_supports`` is True, the closed
|
||||||
|
padlock when False ("auto-spacing is locked to support_spacing").
|
||||||
|
- Terminal gizmo: same gating, positioned just past the right rail
|
||||||
|
end so it reads as "configure the rail's end cap".
|
||||||
|
"""
|
||||||
|
super()._refresh_element_specific(context, mw, props)
|
||||||
|
|
||||||
|
# ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
|
||||||
|
# before ``setup_element_specific_gizmos`` has populated the lock /
|
||||||
|
# terminal attributes (Blender 5.x recreates per-region groups on
|
||||||
|
# reload). Bail out cheaply; the next refresh after setup completes
|
||||||
|
# will reposition them correctly.
|
||||||
|
if not hasattr(self, "lock_open_gizmo"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Single gate for all WALL_MOUNTED_HANDRAIL extras.
|
||||||
|
active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
self.lock_open_gizmo.hide = True
|
||||||
|
self.lock_closed_gizmo.hide = True
|
||||||
|
self.terminal_gizmo.hide = True
|
||||||
|
return
|
||||||
|
|
||||||
|
billboard_rot = self._frame_billboard_rot
|
||||||
|
view_rotation = self.schematic_view_rotation
|
||||||
|
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
|
||||||
|
|
||||||
|
# ── Lock glyphs for use_manual_supports ──────────────────────────
|
||||||
|
# Sit just above the wall's bottom line, near the centre of the
|
||||||
|
# schematic — visually grouped with the dimension it controls
|
||||||
|
# (support_spacing) without overlapping the arrow tail below.
|
||||||
|
is_manual = bool(props.use_manual_supports)
|
||||||
|
self.lock_open_gizmo.hide = not is_manual
|
||||||
|
self.lock_closed_gizmo.hide = is_manual
|
||||||
|
lock_local = Vector((0.0, 0.05, 0.0))
|
||||||
|
lock_world = anchor + billboard_rot @ view_rotation @ lock_local
|
||||||
|
lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
|
||||||
|
self.lock_open_gizmo.matrix_basis = lock_matrix
|
||||||
|
self.lock_closed_gizmo.matrix_basis = lock_matrix
|
||||||
|
|
||||||
|
# ── Terminal-type popup gizmo at the right rail end ──────────────
|
||||||
|
# Pushed well past the right wall edge so the icon doesn't crowd
|
||||||
|
# the wall outline or the bracket attach point. At rail height and
|
||||||
|
# rail depth so it reads as "attached to the rail terminal".
|
||||||
|
self.terminal_gizmo.hide = False
|
||||||
|
terminal_local = Vector(
|
||||||
|
(
|
||||||
|
self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
|
||||||
|
self.SCHEMATIC_MESH_RAIL_Y_FRAC,
|
||||||
|
self.schematic_rail_clear(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
|
||||||
|
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
|
||||||
|
|
||||||
|
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
|
||||||
|
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
|
||||||
|
|
||||||
|
The base class shows the pen gizmo whenever ``is_editing`` is False,
|
||||||
|
which is the case during path-edit too. Allowing the user to click
|
||||||
|
through into parametric edit while the polyline mesh is open in EDIT
|
||||||
|
mode mixes two distinct editing states and leaves a stale draft if
|
||||||
|
they cancel out — block the entry point instead. The operator itself
|
||||||
|
is intentionally not guarded (callers via scripting can still invoke
|
||||||
|
it); this is the UX-level enforcement.
|
||||||
|
|
||||||
|
The cycle icon defaults to the editing icon row (next to validate /
|
||||||
|
cancel) via the parent's positioning. We move it to just above the
|
||||||
|
schematic mesh so it reads as "cycle the railing type *shown here*"
|
||||||
|
— associated with the preview the user is interacting with, not a
|
||||||
|
generic editing button at the bottom of the schematic.
|
||||||
|
"""
|
||||||
|
super().update_editing_gizmos(context, mw, props)
|
||||||
|
if props.is_editing_path:
|
||||||
|
self.pen_gizmo.hide = True
|
||||||
|
|
||||||
|
if props.is_editing and not props.is_editing_path:
|
||||||
|
billboard_rot = self._frame_billboard_rot
|
||||||
|
view_rotation = self.schematic_view_rotation
|
||||||
|
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
|
||||||
|
# Comfortably above the mesh top edge so the icon doesn't crowd
|
||||||
|
# the ``thickness`` / ``clear_width`` dimension callouts that
|
||||||
|
# already sit just above the panel/wall.
|
||||||
|
cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
|
||||||
|
world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
|
||||||
|
# 30% smaller than the editing-icon-row default (0.30 → 0.21):
|
||||||
|
# the cycle is a tertiary affordance compared to pen/validate/cancel.
|
||||||
|
self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
|
||||||
|
"""Build a wireframe preview of the railing in schematic-local coordinates.
|
||||||
|
|
||||||
|
FRAMELESS_PANEL renders as a box whose proportions track the bound
|
||||||
|
properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
|
||||||
|
renders as a horizontal tube with two L-shaped supports whose
|
||||||
|
proportions track railing_diameter / clear_width / support_spacing.
|
||||||
|
Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
|
||||||
|
on each axis so the schematic reads the same regardless of absolute
|
||||||
|
property values.
|
||||||
|
|
||||||
|
The mesh is decorative — clicks land on the labeled sliders, not on
|
||||||
|
the preview geometry. See ``BaseSchematicGizmoGroup`` for the
|
||||||
|
draw-handler lifecycle.
|
||||||
|
"""
|
||||||
|
bm = bmesh.new()
|
||||||
|
if props.railing_type == "FRAMELESS_PANEL":
|
||||||
|
cls._build_frameless_panel_schematic(bm, props)
|
||||||
|
else:
|
||||||
|
cls._build_wall_mounted_handrail_schematic(bm, props)
|
||||||
|
return bm
|
||||||
|
|
||||||
|
# Schematic-local half-width of the visible gap between the two panel boxes.
|
||||||
|
# Conveys the "spacing" semantic at a glance — the user sees two pickets
|
||||||
|
# separated by air, with the spacing dimension emerging from that gap.
|
||||||
|
SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
|
||||||
|
"""Stylised panel: two wireframe boxes with a visible gap between them.
|
||||||
|
|
||||||
|
The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
|
||||||
|
(matching where the dimension gizmos anchor), so each dimension line
|
||||||
|
visually starts at the geometry feature it measures. Internal
|
||||||
|
proportions are stable across drags — the actual values are shown
|
||||||
|
through the dimension labels, while the schematic communicates
|
||||||
|
which feature each label refers to. The gap between the two boxes
|
||||||
|
(set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
|
||||||
|
dimension a real visual referent.
|
||||||
|
|
||||||
|
Edges are tagged on a string layer so hover-highlight can colour
|
||||||
|
the geometric feature being measured: vertical edges → height,
|
||||||
|
depth edges → thickness. The X-aligned edges along the panel
|
||||||
|
width are untagged (they don't correspond to a single dimension).
|
||||||
|
"""
|
||||||
|
hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||||
|
hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
|
||||||
|
h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
|
||||||
|
gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
|
||||||
|
|
||||||
|
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
|
||||||
|
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
|
||||||
|
|
||||||
|
# Edge index → feature tag for one box. Order matches the (a, b)
|
||||||
|
# tuple order below: bottom ring (4) + top ring (4) + verticals (4).
|
||||||
|
edge_tags_per_box = (
|
||||||
|
b"", # (0,1) bottom-back, X-aligned
|
||||||
|
b"panel_thickness", # (1,2) bottom-right, Z-aligned
|
||||||
|
b"", # (2,3) bottom-front, X-aligned
|
||||||
|
b"panel_thickness", # (3,0) bottom-left, Z-aligned
|
||||||
|
b"", # (4,5) top-back, X-aligned
|
||||||
|
b"panel_thickness", # (5,6) top-right, Z-aligned
|
||||||
|
b"", # (6,7) top-front, X-aligned
|
||||||
|
b"panel_thickness", # (7,4) top-left, Z-aligned
|
||||||
|
b"panel_height", # (0,4) vertical back-left
|
||||||
|
b"panel_height", # (1,5) vertical back-right
|
||||||
|
b"panel_height", # (2,6) vertical front-right
|
||||||
|
b"panel_height", # (3,7) vertical front-left
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build two separate wireframe boxes — one on each side of the central
|
||||||
|
# gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
|
||||||
|
# but split the X range so the gap from -gap to +gap stays empty.
|
||||||
|
for x_left, x_right in ((-hw, -gap), (gap, hw)):
|
||||||
|
corners = [
|
||||||
|
bm.verts.new((x_left, 0.0, -hd)),
|
||||||
|
bm.verts.new((x_right, 0.0, -hd)),
|
||||||
|
bm.verts.new((x_right, 0.0, hd)),
|
||||||
|
bm.verts.new((x_left, 0.0, hd)),
|
||||||
|
bm.verts.new((x_left, h_top, -hd)),
|
||||||
|
bm.verts.new((x_right, h_top, -hd)),
|
||||||
|
bm.verts.new((x_right, h_top, hd)),
|
||||||
|
bm.verts.new((x_left, h_top, hd)),
|
||||||
|
]
|
||||||
|
for tag, (a, b) in zip(
|
||||||
|
edge_tags_per_box,
|
||||||
|
(
|
||||||
|
(0, 1),
|
||||||
|
(1, 2),
|
||||||
|
(2, 3),
|
||||||
|
(3, 0), # bottom ring
|
||||||
|
(4, 5),
|
||||||
|
(5, 6),
|
||||||
|
(6, 7),
|
||||||
|
(7, 4), # top ring
|
||||||
|
(0, 4),
|
||||||
|
(1, 5),
|
||||||
|
(2, 6),
|
||||||
|
(3, 7), # vertical edges
|
||||||
|
),
|
||||||
|
):
|
||||||
|
edge = bm.edges.new((corners[a], corners[b]))
|
||||||
|
if tag:
|
||||||
|
edge[feat_layer] = tag
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
|
||||||
|
"""Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
|
||||||
|
|
||||||
|
Three visual elements convey "rail mounted on a wall":
|
||||||
|
|
||||||
|
- **Wall outline** — a wireframe rectangle in the YZ plane at ``z=0``,
|
||||||
|
extending slightly past the rail ends so the wall reads as a
|
||||||
|
surface the rail is *attached to* rather than a coincident frame.
|
||||||
|
- **Handrail tube** — a hexagonal cross-section extruded along ±X
|
||||||
|
at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
|
||||||
|
- **L-shaped brackets** at each rail end — from the rail centreline
|
||||||
|
drop a short distance, then run perpendicular back to the wall
|
||||||
|
plane. Mirrors the standard wall-mount bracket geometry: a
|
||||||
|
horizontal arm holding the rail off the wall, a vertical drop
|
||||||
|
attaching to the rail.
|
||||||
|
|
||||||
|
Like ``_build_frameless_panel_schematic``, the schematic uses fixed
|
||||||
|
proportions so the dimension gizmos' anchor points stay aligned
|
||||||
|
with the geometry features regardless of property values.
|
||||||
|
"""
|
||||||
|
half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
|
||||||
|
wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
|
||||||
|
rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
|
||||||
|
radius_s = cls.schematic_rail_radius()
|
||||||
|
clear_s = cls.schematic_rail_clear()
|
||||||
|
|
||||||
|
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
|
||||||
|
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
|
||||||
|
|
||||||
|
# ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
|
||||||
|
# Spans the full schematic height; the rail attaches in the middle,
|
||||||
|
# so the wall reads as "continuing past the rail above and below".
|
||||||
|
# Wall edges stay untagged — they're background context, not a
|
||||||
|
# feature any dimension measures.
|
||||||
|
wall_extra = 0.08
|
||||||
|
wall_x_left = -half_len - wall_extra
|
||||||
|
wall_x_right = half_len + wall_extra
|
||||||
|
wall_corners = [
|
||||||
|
bm.verts.new((wall_x_left, 0.0, 0.0)),
|
||||||
|
bm.verts.new((wall_x_right, 0.0, 0.0)),
|
||||||
|
bm.verts.new((wall_x_right, wall_top, 0.0)),
|
||||||
|
bm.verts.new((wall_x_left, wall_top, 0.0)),
|
||||||
|
]
|
||||||
|
for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
|
||||||
|
bm.edges.new((wall_corners[a], wall_corners[b]))
|
||||||
|
|
||||||
|
# ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
|
||||||
|
# Centred on the rail centreline at (±(half_len - rail_inset),
|
||||||
|
# rail_y, +clear_s) — in front of the wall plane at z=0. The tube
|
||||||
|
# is shorter than the wall so the wall visibly extends past it on
|
||||||
|
# both sides; the L-brackets sit at the tube ends, so the leftmost
|
||||||
|
# bracket no longer coincides with the wall's left edge.
|
||||||
|
rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
|
||||||
|
rail_x_left = -half_len + rail_inset
|
||||||
|
rail_x_right = half_len - rail_inset
|
||||||
|
segments = 6
|
||||||
|
ring_left, ring_right = [], []
|
||||||
|
for i in range(segments):
|
||||||
|
theta = 2 * math.pi * i / segments
|
||||||
|
dy = math.cos(theta) * radius_s
|
||||||
|
dz = math.sin(theta) * radius_s
|
||||||
|
ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
|
||||||
|
ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
|
||||||
|
# All hex-tube edges tagged "rail_tube" so they highlight together
|
||||||
|
# when the railing_diameter dimension is hovered.
|
||||||
|
for i in range(segments):
|
||||||
|
j = (i + 1) % segments
|
||||||
|
e_left = bm.edges.new((ring_left[i], ring_left[j]))
|
||||||
|
e_right = bm.edges.new((ring_right[i], ring_right[j]))
|
||||||
|
e_axial = bm.edges.new((ring_left[i], ring_right[i]))
|
||||||
|
e_left[feat_layer] = b"rail_tube"
|
||||||
|
e_right[feat_layer] = b"rail_tube"
|
||||||
|
e_axial[feat_layer] = b"rail_tube"
|
||||||
|
|
||||||
|
# ── L-brackets at each rail end (rail → drop → wall) ───────────────
|
||||||
|
# Bracket attach points follow the rail ends, so they're pulled
|
||||||
|
# inward by ``rail_inset`` from the wall edges. From the rail
|
||||||
|
# centreline, drop ``bracket_drop`` in Y, then run perpendicular
|
||||||
|
# back to the wall plane (z=0). The L shape reads as a wall-mount
|
||||||
|
# bracket under the 3/4 tilt. Both bracket segments tagged
|
||||||
|
# "bracket" so they highlight when clear_width OR support_spacing
|
||||||
|
# is hovered (both dimensions measure features of the supports).
|
||||||
|
bracket_drop = 0.06
|
||||||
|
for x in (rail_x_left, rail_x_right):
|
||||||
|
v_rail = bm.verts.new((x, rail_y, clear_s))
|
||||||
|
v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
|
||||||
|
v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
|
||||||
|
e1 = bm.edges.new((v_rail, v_corner))
|
||||||
|
e2 = bm.edges.new((v_corner, v_wall))
|
||||||
|
e1[feat_layer] = b"bracket"
|
||||||
|
e2[feat_layer] = b"bracket"
|
||||||
|
|
||||||
|
|
||||||
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
bl_idname = "bim.flip_railing_path_order"
|
bl_idname = "bim.flip_railing_path_order"
|
||||||
bl_label = "Flip Railing Path Order"
|
bl_label = "Flip Railing Path Order"
|
||||||
@@ -510,6 +1137,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
[o.select_set(False) for o in context.selected_objects if o != obj]
|
[o.select_set(False) for o in context.selected_objects if o != obj]
|
||||||
assert obj
|
assert obj
|
||||||
props = tool.Model.get_railing_props(obj)
|
props = tool.Model.get_railing_props(obj)
|
||||||
|
|
||||||
|
# Auto-commit any in-progress parametric draft before switching to
|
||||||
|
# path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below
|
||||||
|
# overwrites props with the pset's stored values — without committing
|
||||||
|
# first, anything the user dragged on a dimension gizmo (height,
|
||||||
|
# diameter, …) would be silently discarded the moment path-edit
|
||||||
|
# starts.
|
||||||
|
if props.is_editing:
|
||||||
|
tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing")
|
||||||
|
|
||||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||||
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
||||||
props.set_props_kwargs_from_ifc_data(data)
|
props.set_props_kwargs_from_ifc_data(data)
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePropsBase:
|
||||||
|
"""Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
|
||||||
|
|
||||||
|
The parametric-edit lifecycle mixins read/write a common contract:
|
||||||
|
``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last
|
||||||
|
data written via ``set_props_kwargs_from_ifc_data``),
|
||||||
|
``set_props_kwargs_from_ifc_data(data)``, and
|
||||||
|
``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
|
||||||
|
(door, railing, roof) subclass this and add their own kwargs accessors
|
||||||
|
and per-type fields."""
|
||||||
|
|
||||||
|
def __init__(self, general: dict | None = None):
|
||||||
|
self.is_editing = False
|
||||||
|
self.last_kwargs: dict | None = None
|
||||||
|
self.general = dict(general) if general is not None else {}
|
||||||
|
|
||||||
|
def set_props_kwargs_from_ifc_data(self, data):
|
||||||
|
self.last_kwargs = dict(data)
|
||||||
|
|
||||||
|
def get_general_kwargs(self, convert_to_project_units=True):
|
||||||
|
return dict(self.general)
|
||||||
|
|
||||||
|
|
||||||
|
def make_lifecycle_obj(props, *, name="obj"):
|
||||||
|
"""Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
|
||||||
|
|
||||||
|
The mixin code under test reads ``obj.props`` (the PropertyGroup
|
||||||
|
stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
|
||||||
|
catches typo'd attribute access at test time. ``bpy`` is imported inside
|
||||||
|
the function so this conftest stays importable when bpy is absent."""
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
obj = mock.Mock(spec=bpy.types.Object, name=name)
|
||||||
|
obj.props = props
|
||||||
|
obj.name = name
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
# pytest by default doesn't print steps and where it failed. Let's fix that.
|
# pytest by default doesn't print steps and where it failed. Let's fix that.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ from types import SimpleNamespace
|
|||||||
import bpy
|
import bpy
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
from bonsai.bim.module.drawing.gizmos import (
|
||||||
|
BaseParametricGizmoGroup,
|
||||||
|
BaseSchematicGizmoGroup,
|
||||||
|
DimensionGizmoConfig,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.drawing
|
pytestmark = pytest.mark.drawing
|
||||||
|
|
||||||
@@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value():
|
|||||||
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
|
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
|
||||||
props = SimpleNamespace(label="L")
|
props = SimpleNamespace(label="L")
|
||||||
assert config.text_formatter(props, 3.14) == "L=3.14"
|
assert config.text_formatter(props, 3.14) == "L=3.14"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parametric_base_enables_dimension_snap_by_default():
|
||||||
|
"""In-place parametric gizmos align to real-world geometry, so dragging
|
||||||
|
must respect the global snap toggle (Ctrl-flip during drag) — same
|
||||||
|
contract every door / window / wall / stair / roof / mep dimension
|
||||||
|
has shipped with."""
|
||||||
|
assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_schematic_base_disables_dimension_snap():
|
||||||
|
"""Schematic dimensions float in viewport space; snapping the dragged
|
||||||
|
tip to scene vertices would produce spurious value jumps as the
|
||||||
|
mouse crosses unrelated geometry. The opt-out lives on the base so
|
||||||
|
every schematic subclass inherits it without per-class wiring."""
|
||||||
|
assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
|
||||||
|
behaviour railing inherits from ``PathPreservingEditMixin``.
|
||||||
|
|
||||||
|
The parent short-circuit (skip the IFC commit / viewport rebuild when the
|
||||||
|
draft is identical to the stored pset) lives in
|
||||||
|
``PathPreservingEditMixin``; the tests below verify railing's subclass
|
||||||
|
honours that contract by inheritance, then pin the railing-specific
|
||||||
|
viewport-restore dispatch:
|
||||||
|
|
||||||
|
- Finish / Cancel no-op short-circuit: inherited from the parent — verified
|
||||||
|
here because railing was the original consumer that motivated the
|
||||||
|
optimisation.
|
||||||
|
- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
|
||||||
|
reloads the high-poly Body representation via ``switch_representation``;
|
||||||
|
FRAMELESS_PANEL rebuilds the bmesh preview via
|
||||||
|
``update_railing_modifier_bmesh``. This is the per-type branch that used
|
||||||
|
to live in ``_cancel_one`` and now lives in the viewport-restore hook the
|
||||||
|
parent's ``_cancel_one`` calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test.bim.conftest import _FakePropsBase
|
||||||
|
from test.bim.conftest import make_lifecycle_obj as _make_obj
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRailingProps(_FakePropsBase):
|
||||||
|
"""Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
|
||||||
|
the shared parametric-edit contract. Starts in ``is_editing=True`` because
|
||||||
|
the railing-specific overrides under test only fire on Finish / Cancel,
|
||||||
|
not on Enable."""
|
||||||
|
|
||||||
|
def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
|
||||||
|
super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
|
||||||
|
self.railing_type = railing_type
|
||||||
|
self.is_editing = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_railing():
|
||||||
|
"""Patch the railing module's external references for unit testing.
|
||||||
|
|
||||||
|
``_RailingEditMixin`` and the parent lifecycle reach for
|
||||||
|
``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
|
||||||
|
``ifcopenshell.util.representation.get_representation``,
|
||||||
|
``bonsai.core.geometry.switch_representation``, and the module-level
|
||||||
|
``update_railing_modifier_bmesh`` — each looked up through the railing
|
||||||
|
module's own bindings, so we patch them there.
|
||||||
|
|
||||||
|
``parametric_lifecycle.tool`` is patched separately so the parent's
|
||||||
|
``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
|
||||||
|
without falling through to the real Blender bindings.
|
||||||
|
|
||||||
|
Uses ``mock.patch.object`` with a direct module reference rather than
|
||||||
|
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
|
||||||
|
needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
|
||||||
|
which fails at the ``bonsai.bim`` step until that subpackage has been
|
||||||
|
imported elsewhere. The direct-object form sidesteps the resolution.
|
||||||
|
|
||||||
|
Returns a dict for tests to seed return values and assert call sites.
|
||||||
|
"""
|
||||||
|
from bonsai.bim import parametric_lifecycle
|
||||||
|
from bonsai.bim.module.model import railing
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(railing, "tool") as mock_tool,
|
||||||
|
mock.patch.object(railing, "ifcopenshell") as mock_ifc,
|
||||||
|
mock.patch.object(railing, "bonsai") as mock_bonsai,
|
||||||
|
mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
|
||||||
|
mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
|
||||||
|
):
|
||||||
|
# _resolve will be overridden on the test subclass below so the
|
||||||
|
# parametric_lifecycle.tool patch isn't needed for that path, but the
|
||||||
|
# parent's _cancel_one / _finish_one still call
|
||||||
|
# tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
|
||||||
|
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
|
||||||
|
yield {
|
||||||
|
"tool": mock_tool,
|
||||||
|
"ifcopenshell": mock_ifc,
|
||||||
|
"bonsai": mock_bonsai,
|
||||||
|
"update_bmesh": mock_update_bmesh,
|
||||||
|
"pl_tool": mock_pl_tool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _railing_test_subclass(props):
|
||||||
|
"""Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
|
||||||
|
|
||||||
|
The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
|
||||||
|
``parametric_lifecycle.tool`` (a separate import from the railing
|
||||||
|
module's ``tool``). Overriding it here keeps the test patches local
|
||||||
|
to the railing module and the hook closures local to the test."""
|
||||||
|
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||||
|
|
||||||
|
test_element = mock.Mock(name="ifc_element")
|
||||||
|
|
||||||
|
class _TestRailingMixin(_RailingEditMixin):
|
||||||
|
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
|
||||||
|
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
|
||||||
|
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve(cls, obj):
|
||||||
|
return test_element, props
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _update_pset(cls, element, data):
|
||||||
|
cls.pset_updates(element, data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _update_modifier_ifc_data(cls, obj, context):
|
||||||
|
cls.ifc_data_updates(obj, context)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _restore_viewport_after_cancel(cls, obj, context):
|
||||||
|
cls.bmesh_updates(obj, context)
|
||||||
|
|
||||||
|
# The base _post_load_data JSON-serialises path_data; bypass that
|
||||||
|
# here so the round-trip stays a plain dict and tests can compare
|
||||||
|
# by reference / equality without re-parsing.
|
||||||
|
@classmethod
|
||||||
|
def _post_load_data(cls, data):
|
||||||
|
return dict(data)
|
||||||
|
|
||||||
|
return _TestRailingMixin, test_element
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _RailingEditMixin._finish_one
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||||
|
"""Enable → Finish without any property edit must NOT write to IFC.
|
||||||
|
|
||||||
|
Without this, every "open Edit, click Validate immediately" cycle
|
||||||
|
would create a fresh ``IfcShapeRepresentation``, pollute the file's
|
||||||
|
representation list, and burn an undo entry — the user-visible
|
||||||
|
regression that motivated the short-circuit.
|
||||||
|
|
||||||
|
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||||
|
the coverage as the original consumer of the contract.
|
||||||
|
"""
|
||||||
|
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||||
|
props = _FakeRailingProps(general=dict(stored))
|
||||||
|
obj = _make_obj(props)
|
||||||
|
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||||
|
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||||
|
}
|
||||||
|
|
||||||
|
cls, _element = _railing_test_subclass(props)
|
||||||
|
cls._finish_one(obj, mock.Mock(name="context"))
|
||||||
|
|
||||||
|
assert props.is_editing is False, "is_editing must still flip even on no-op"
|
||||||
|
cls.pset_updates.assert_not_called()
|
||||||
|
cls.ifc_data_updates.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_one_writes_when_draft_differs(patched_railing):
|
||||||
|
"""The complement of the short-circuit: a real property change must
|
||||||
|
flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
|
||||||
|
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||||
|
# Draft height differs: simulating a user edit.
|
||||||
|
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
|
||||||
|
obj = _make_obj(props)
|
||||||
|
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||||
|
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||||
|
}
|
||||||
|
|
||||||
|
cls, element = _railing_test_subclass(props)
|
||||||
|
cls._finish_one(obj, mock.Mock(name="context"))
|
||||||
|
|
||||||
|
assert props.is_editing is False
|
||||||
|
cls.pset_updates.assert_called_once()
|
||||||
|
# The pset must receive the DRAFT data, not the stored data — that's the
|
||||||
|
# whole point of Finish committing the user's edits.
|
||||||
|
written = cls.pset_updates.call_args[0][1]
|
||||||
|
assert written["height"] == 1.5
|
||||||
|
cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _RailingEditMixin._cancel_one
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||||
|
"""Cancel-without-changes is asymmetrically expensive without this guard:
|
||||||
|
``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
|
||||||
|
visibly slow on a long handrail. When nothing changed, the mesh on
|
||||||
|
screen is still the committed IFC representation (the preview only
|
||||||
|
builds on a property change) — skip the reload entirely.
|
||||||
|
|
||||||
|
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||||
|
the coverage as the original consumer of the contract.
|
||||||
|
"""
|
||||||
|
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||||
|
props = _FakeRailingProps(general=dict(stored))
|
||||||
|
obj = _make_obj(props)
|
||||||
|
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||||
|
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||||
|
}
|
||||||
|
|
||||||
|
cls, _element = _railing_test_subclass(props)
|
||||||
|
cls._cancel_one(obj, mock.Mock(name="context"))
|
||||||
|
|
||||||
|
assert props.is_editing is False
|
||||||
|
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||||
|
patched_railing["update_bmesh"].assert_not_called()
|
||||||
|
cls.bmesh_updates.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
|
||||||
|
#
|
||||||
|
# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
|
||||||
|
# the draft differs from the stored pset. Railing's override branches on
|
||||||
|
# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
|
||||||
|
# representation rather than rebuilding the low-poly cylinder-segment preview.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
|
||||||
|
"""WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
|
||||||
|
the Body representation — the preview is viewport-only (low-poly cylinder)
|
||||||
|
and would persist visibly without the reload."""
|
||||||
|
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||||
|
|
||||||
|
props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
|
||||||
|
obj = _make_obj(props)
|
||||||
|
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||||
|
body_repr = mock.Mock(name="body_representation")
|
||||||
|
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
|
||||||
|
|
||||||
|
_RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
|
||||||
|
|
||||||
|
patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
|
||||||
|
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
|
||||||
|
assert kwargs["obj"] is obj
|
||||||
|
assert kwargs["representation"] is body_repr
|
||||||
|
# Must NOT fall through to the FRAMELESS bmesh-rebuild path.
|
||||||
|
patched_railing["update_bmesh"].assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
|
||||||
|
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
|
||||||
|
swept-disk solid to reload. The restore must delegate to the module-level
|
||||||
|
``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
|
||||||
|
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||||
|
|
||||||
|
props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
|
||||||
|
obj = _make_obj(props)
|
||||||
|
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||||
|
ctx = mock.Mock(name="context")
|
||||||
|
|
||||||
|
_RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
|
||||||
|
|
||||||
|
patched_railing["update_bmesh"].assert_called_once_with(ctx)
|
||||||
|
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _get_railing_path_anchor: tests removed.
|
||||||
|
#
|
||||||
|
# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
|
||||||
|
# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
|
||||||
|
# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
|
||||||
|
# helper for the old anchor strategy and has been deleted along with the
|
||||||
|
# old gizmo group. If schematic-mode gains a similar path-derived helper,
|
||||||
|
# new tests should land here.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import bmesh
|
||||||
|
import bpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bonsai import tool
|
||||||
|
from bonsai.bim.module.drawing.gizmos import (
|
||||||
|
BaseSchematicGizmoGroup,
|
||||||
|
DimensionGizmoConfig,
|
||||||
|
)
|
||||||
|
from bonsai.bim.module.model.railing import GizmoRailingSchematic
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _require_real_bpy():
|
||||||
|
"""Skip the file when ``bpy`` is mocked or absent.
|
||||||
|
|
||||||
|
Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
|
||||||
|
invoked outside Blender) crash at module-collection time on the chain of
|
||||||
|
``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
|
||||||
|
"""
|
||||||
|
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||||
|
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Class shape ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_inherits_base():
|
||||||
|
"""GizmoRailingSchematic plugs into the schematic framework, not the
|
||||||
|
in-place dimension framework. If a future refactor breaks this lineage
|
||||||
|
the schematic-specific machinery (sliders, draw handler) silently goes
|
||||||
|
dormant."""
|
||||||
|
assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_bl_idname_preserved():
|
||||||
|
"""``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
|
||||||
|
is referenced by keymaps and persistence. Preserve it across the class
|
||||||
|
rename — see the migration note in the class docstring."""
|
||||||
|
assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_props_getter_pairing():
|
||||||
|
"""``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
|
||||||
|
are the pairing test_parametric_registry depends on. If either drifts,
|
||||||
|
the addon-preferences gizmo toggle silently stops controlling this group."""
|
||||||
|
assert GizmoRailingSchematic.gizmo_pref_name == "railing"
|
||||||
|
assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_disables_in_place_dimension_props():
|
||||||
|
"""The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
|
||||||
|
assert GizmoRailingSchematic.dimension_gizmo_props == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dimension configuration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_has_six_dimensions():
|
||||||
|
"""One dimension per parametric property — three for each railing_type."""
|
||||||
|
assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_dimension_attr_names_complete():
|
||||||
|
"""The six bound attributes match the parametric properties that
|
||||||
|
``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
|
||||||
|
attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
|
||||||
|
assert attr_names == {
|
||||||
|
"height",
|
||||||
|
"thickness",
|
||||||
|
"spacing",
|
||||||
|
"railing_diameter",
|
||||||
|
"clear_width",
|
||||||
|
"support_spacing",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_dimensions_are_dimension_configs():
|
||||||
|
"""The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
|
||||||
|
arrows + label), not the abstract slider widget."""
|
||||||
|
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||||
|
assert isinstance(config, DimensionGizmoConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_dimensions_have_text_formatters():
|
||||||
|
"""Each dimension must format the label from the actual property value,
|
||||||
|
not from the visually-scaled value the gizmo's getter returns. Without a
|
||||||
|
formatter the label would show the schematic-scaled length, which is
|
||||||
|
meaningless to the user."""
|
||||||
|
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||||
|
assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"attr_name,railing_type,expected",
|
||||||
|
[
|
||||||
|
("height", "FRAMELESS_PANEL", True),
|
||||||
|
("height", "WALL_MOUNTED_HANDRAIL", False),
|
||||||
|
("thickness", "FRAMELESS_PANEL", True),
|
||||||
|
("spacing", "FRAMELESS_PANEL", True),
|
||||||
|
("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
|
||||||
|
("railing_diameter", "FRAMELESS_PANEL", False),
|
||||||
|
("clear_width", "WALL_MOUNTED_HANDRAIL", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
|
||||||
|
"""The two railing types are mutually exclusive — height/thickness/spacing
|
||||||
|
belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
|
||||||
|
belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
|
||||||
|
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
|
||||||
|
props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
|
||||||
|
assert config.visibility_condition(props) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_railing_schematic_support_spacing_hidden_for_manual_supports():
|
||||||
|
"""``support_spacing`` only drives auto-positioned supports — when the
|
||||||
|
user has switched to manual supports the dimension should disappear."""
|
||||||
|
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
|
||||||
|
auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
|
||||||
|
manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
|
||||||
|
assert config.visibility_condition(auto) is True
|
||||||
|
assert config.visibility_condition(manual) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixed-length tag rendering ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_schematic_dim_visible_length_is_constant():
|
||||||
|
"""Every schematic dimension tag renders at the same width — the bar is a
|
||||||
|
UI affordance, not a proportional measurement. The constant ratio keeps
|
||||||
|
tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
|
||||||
|
real value lives in the dimension label.
|
||||||
|
|
||||||
|
Regression guard: if value-proportional scaling is reintroduced, this
|
||||||
|
contract breaks silently — small dimensions start collapsing into stacked
|
||||||
|
arrows again.
|
||||||
|
"""
|
||||||
|
cls = GizmoRailingSchematic
|
||||||
|
ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
|
||||||
|
assert ratio > 0
|
||||||
|
assert ratio <= 1.0 # bar must fit within the schematic box
|
||||||
|
|
||||||
|
|
||||||
|
def test_schematic_no_compute_schematic_scale_override():
|
||||||
|
"""The constant-length schematic must not reintroduce scale-based
|
||||||
|
proportional sizing via a ``_compute_schematic_scale`` override."""
|
||||||
|
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
# ── Path-edit guard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_editing_gizmos_override_defined_on_subclass():
|
||||||
|
"""``GizmoRailingSchematic`` must own the override that hides the pen
|
||||||
|
icon during path-edit. The parent's version shows the pen whenever
|
||||||
|
``is_editing`` is False, which includes path-edit; that would let the
|
||||||
|
user open two editing modes at once."""
|
||||||
|
assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schematic mesh building ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
|
||||||
|
"""FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
|
||||||
|
per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
|
||||||
|
gap between the two boxes is the "spacing" semantic made literal.
|
||||||
|
|
||||||
|
The mesh proportions are fixed (independent of property values) so the
|
||||||
|
dimension gizmos can anchor to known feature positions; the property
|
||||||
|
values are shown through dimension labels, not the mesh size."""
|
||||||
|
props = SimpleNamespace(
|
||||||
|
railing_type="FRAMELESS_PANEL",
|
||||||
|
height=1.0,
|
||||||
|
thickness=0.05,
|
||||||
|
spacing=0.5,
|
||||||
|
)
|
||||||
|
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||||
|
try:
|
||||||
|
assert isinstance(bm, bmesh.types.BMesh)
|
||||||
|
assert len(bm.verts) == 16
|
||||||
|
assert len(bm.edges) == 24
|
||||||
|
finally:
|
||||||
|
bm.free()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
|
||||||
|
"""WALL_MOUNTED_HANDRAIL renders as three visual elements:
|
||||||
|
|
||||||
|
- **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
|
||||||
|
- **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
|
||||||
|
(6 left ring + 6 right ring + 6 axial).
|
||||||
|
- **L-brackets** at each rail end — 3 verts per bracket (rail centre,
|
||||||
|
corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
|
||||||
|
(rail→corner, corner→wall) × 2 = 4 edges.
|
||||||
|
|
||||||
|
Total: 22 verts, 26 edges.
|
||||||
|
"""
|
||||||
|
props = SimpleNamespace(
|
||||||
|
railing_type="WALL_MOUNTED_HANDRAIL",
|
||||||
|
railing_diameter=0.05,
|
||||||
|
clear_width=0.04,
|
||||||
|
support_spacing=1.0,
|
||||||
|
)
|
||||||
|
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||||
|
try:
|
||||||
|
assert isinstance(bm, bmesh.types.BMesh)
|
||||||
|
assert len(bm.verts) == 22
|
||||||
|
assert len(bm.edges) == 26
|
||||||
|
finally:
|
||||||
|
bm.free()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_schematic_mesh_proportions_independent_of_props():
|
||||||
|
"""The mesh uses fixed proportions so dimension gizmo anchor points stay
|
||||||
|
aligned with the geometry — extreme prop ratios don't change the mesh."""
|
||||||
|
small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05)
|
||||||
|
large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0)
|
||||||
|
bm_small = GizmoRailingSchematic.build_schematic_mesh(small)
|
||||||
|
bm_large = GizmoRailingSchematic.build_schematic_mesh(large)
|
||||||
|
try:
|
||||||
|
# Same vert count regardless of prop magnitude.
|
||||||
|
assert len(bm_small.verts) == len(bm_large.verts)
|
||||||
|
# Same bounding box in each axis (within floating-point noise).
|
||||||
|
for axis in range(3):
|
||||||
|
small_coords = [v.co[axis] for v in bm_small.verts]
|
||||||
|
large_coords = [v.co[axis] for v in bm_large.verts]
|
||||||
|
assert min(small_coords) == pytest.approx(min(large_coords))
|
||||||
|
assert max(small_coords) == pytest.approx(max(large_coords))
|
||||||
|
finally:
|
||||||
|
bm_small.free()
|
||||||
|
bm_large.free()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_schematic_mesh_panel_top_matches_height_frac():
|
||||||
|
"""The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``,
|
||||||
|
which is also where the ``thickness`` dimension anchors above the box.
|
||||||
|
If this drifts, the dimension labels float disconnected from the mesh."""
|
||||||
|
props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3)
|
||||||
|
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||||
|
try:
|
||||||
|
max_y = max(v.co.y for v in bm.verts)
|
||||||
|
assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC)
|
||||||
|
finally:
|
||||||
|
bm.free()
|
||||||
Reference in New Issue
Block a user