diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 3c03e9db49..6028ac055d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -4877,7 +4877,14 @@ class GizmoDimension(GizmoMovable): 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) self._snap_cache_built = True @@ -4919,11 +4926,18 @@ class GizmoDimension(GizmoMovable): if not region or not rv3d: 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: - build_snap_cache(context, self.active_obj) - self._snap_cache_built = True + if snap_supported: + tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + + 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) @@ -4947,7 +4961,7 @@ class GizmoDimension(GizmoMovable): 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 # Calculate where the dimension tip would be with current 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) 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) === # Icons are positioned in a horizontal row above the element: # [Validate] [Cancel] [Cycle] @@ -6580,6 +6601,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup): # list and become no-ops. The schematic equivalents below take their place. 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`` # and ``update_schematic_dimensions``. Each config produces one # ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index b30fb17896..08806ad8fb 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -246,9 +246,13 @@ classes = ( railing.CopyRailingParameters, railing.AddRailing, railing.CancelEditingRailing, + railing.CycleRailingType, railing.FinishEditingRailing, + railing.PickRailingTerminalType, railing.FlipRailingPathOrder, railing.EnableEditingRailing, + railing.GizmoRailingSchematic, + railing.ToggleRailingUseManualSupports, railing.CancelEditingRailingPath, railing.FinishEditingRailingPath, railing.EnableEditingRailingPath, diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index b9709c067a..ebafafda6d 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -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: """Regenerate railing mesh when property changes.""" if self.is_editing: - # Only FRAMELESS_PANEL can update live via bmesh. - # 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) + _get_updater("railing", "update_railing_modifier_bmesh")(context) def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None: diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 6f3697d51d..825cc339ac 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -18,6 +18,7 @@ import json +import math from typing import Any import bmesh @@ -27,14 +28,24 @@ import ifcopenshell.api.geometry import ifcopenshell.api.pset import ifcopenshell.util.representation import ifcopenshell.util.unit -from mathutils import Vector +from mathutils import Matrix, Vector import bonsai.core.geometry import bonsai.core.root 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.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: # 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}) +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: """before using should make sure that Data contains up-to-date information. 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"] 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 # 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) @@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: thickness = props.thickness spacing = props.spacing - # spacing - # split each edge in 3 segments by 0.5 * spacing by x-y plane main_edges = bm.edges[:] for main_edge in main_edges: 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_verts(bm, verts=verts_to_dissolve) # 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[:]) @@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]: segments.append((i - 1, 0)) break - # skip path verts if they just go vertical to avoid errors - if (v.co.xy - prev_v.co.xy).length <= 0.0001: + # 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 <= WELD_TOLERANCE: continue points.append(v.co) @@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): class _RailingEditMixin(PathPreservingEditMixin): - """Type-specific hooks for railing parametric-edit operators. Single-object - (active_object). ``path_data`` is preserved through the edit; the separate - ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing.""" + """Single-object (active_object) railing-edit hooks; path_data is preserved + through the edit (path editing is a separate operator family).""" pset_name = "BBIM_Railing" @@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin): update_railing_modifier_ifc_data(context) @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) @@ -467,6 +546,554 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera 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): bl_idname = "bim.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] assert 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"] # required since we could load pset from .ifc and BIMRoofProperties won't be set props.set_props_kwargs_from_ifc_data(data) diff --git a/src/bonsai/test/bim/conftest.py b/src/bonsai/test/bim/conftest.py index 2d69fe415a..a957b479ba 100644 --- a/src/bonsai/test/bim/conftest.py +++ b/src/bonsai/test/bim/conftest.py @@ -1,5 +1,46 @@ 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. diff --git a/src/bonsai/test/bim/module/drawing/test_gizmos.py b/src/bonsai/test/bim/module/drawing/test_gizmos.py index cc781cd118..18f640549b 100644 --- a/src/bonsai/test/bim/module/drawing/test_gizmos.py +++ b/src/bonsai/test/bim/module/drawing/test_gizmos.py @@ -24,7 +24,11 @@ from types import SimpleNamespace import bpy import pytest -from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.module.drawing.gizmos import ( + BaseParametricGizmoGroup, + BaseSchematicGizmoGroup, + DimensionGizmoConfig, +) 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) props = SimpleNamespace(label="L") 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 diff --git a/src/bonsai/test/bim/module/model/test_railing_lifecycle.py b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py new file mode 100644 index 0000000000..0aad3534b7 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py @@ -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 . +# +# 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. +# --------------------------------------------------------------------------- diff --git a/src/bonsai/test/bim/module/model/test_railing_schematic.py b/src/bonsai/test/bim/module/model/test_railing_schematic.py new file mode 100644 index 0000000000..7ba12a7a70 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_schematic.py @@ -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 . +# +# 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()