Merge pull request #8172 from Gorgious56/bonsai/railing-edit-gizmos

Bonsai/railing edit gizmos
This commit is contained in:
Gorgious56
2026-06-15 10:23:16 +02:00
committed by GitHub
8 changed files with 1317 additions and 23 deletions
+32 -6
View File
@@ -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
@@ -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,
+1 -5
View File
@@ -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:
+648 -11
View File
@@ -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)
+41
View File
@@ -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.
@@ -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
@@ -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
(railcorner, cornerwall) × 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()