From d1d1e1d4a2080d9518eb92844653bf4fa3f521f5 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 10 Jun 2026 20:40:33 +0200 Subject: [PATCH 01/35] Add railing parametric edit + schematic preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port gizmos-8088's railing gizmo block to v0.8.0: - _RailingEditMixin (PathPreservingEditMixin specialisation) + EnableEditingRailing / CancelEditingRailing / FinishEditingRailing edit triad - CycleRailingType (2-value type cycler) + ToggleRailingUseManualSupports one-shot + EditRailingTerminalType - FlipRailingPathOrder + EnableEditingRailingPath / CancelEditingRailingPath / FinishEditingRailingPath path-edit operators (mutually exclusive with the schematic frame) - GizmoRailingSchematic (BaseSchematicGizmoGroup specialisation) — axonometric schematic frame with per-attribute dimension gizmos for FRAMELESS_PANEL + WALL_MOUNTED_HANDRAIL railing types; hover-on-attr highlights the schematic edges tagged with the matching feature Tests: test_railing_lifecycle.py (280 LOC) + test_railing_schematic.py (272 LOC). Drops the per-feature GizmoPreferences{Door,Window,Stair,Wall,Roof, Railing} PropertyGroups that the source commit added to bim/ui.py — that finer-grained per-attribute toggle model was deliberately collapsed to flat per-feature bools in the PR5b prefs sweep, and GizmoRailingSchematic gates on the flat ``prefs.gizmos.railing`` bool via ``gizmo_pref_name`` so no functionality is lost. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 4 + src/bonsai/bonsai/bim/module/model/railing.py | 720 +++++++++++++++++- .../module/model/test_railing_lifecycle.py | 280 +++++++ .../module/model/test_railing_schematic.py | 272 +++++++ 4 files changed, 1266 insertions(+), 10 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_railing_lifecycle.py create mode 100644 src/bonsai/test/bim/module/model/test_railing_schematic.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index b30fb17896..edac430309 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.EditRailingTerminalType, railing.FinishEditingRailing, railing.FlipRailingPathOrder, railing.EnableEditingRailing, + railing.GizmoRailingSchematic, + railing.ToggleRailingUseManualSupports, railing.CancelEditingRailingPath, railing.FinishEditingRailingPath, railing.EnableEditingRailingPath, diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 6f3697d51d..072b7d562d 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -18,7 +18,8 @@ import json -from typing import Any +import math +from typing import Any, get_args import bmesh import bpy @@ -27,14 +28,20 @@ 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.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 +132,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 +197,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 +229,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 +273,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 +333,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 +469,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" @@ -439,6 +500,66 @@ class _RailingEditMixin(PathPreservingEditMixin): def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: update_railing_modifier_bmesh(context) + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Skip the IFC commit when the draft matches the stored pset (no-op edit).""" + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) + stored = pset_data["data_dict"] + path_data = stored["path_data"] + draft = props.get_general_kwargs(convert_to_project_units=True) + draft["path_data"] = path_data + + if draft == stored: + props.is_editing = False + return + + cls._update_pset(element, draft) + cls._update_modifier_ifc_data(obj, context) + props.is_editing = False + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """WALL_MOUNTED_HANDRAIL switches the representation back to Body on cancel + (the cylinder preview is lower-poly than the committed swept-disk solid). + Skip the switch when the draft matches the stored pset.""" + resolved = cls._resolve(obj) + if resolved is None: + return + _element, props = resolved + pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) + stored = pset_data["data_dict"] + + draft = props.get_general_kwargs(convert_to_project_units=True) + draft["path_data"] = stored["path_data"] + nothing_changed = draft == stored + + data = cls._post_load_data(stored) + props.set_props_kwargs_from_ifc_data(data) + + if nothing_changed: + props.is_editing = False + return + + 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, + ) + else: + cls._update_modifier_bmesh(obj, context) + + props.is_editing = False + class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_railing" @@ -467,6 +588,575 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera return self._finish_targets(context) +class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, gizmo.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.Blender.Modifier.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): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_railing_props(obj) + if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL": + return {"CANCELLED"} + props.use_manual_supports = not props.use_manual_supports + return {"FINISHED"} + + +class EditRailingTerminalType(bpy.types.Operator): + """Popup menu for terminal_type; writes the picked value via a HIDDEN string property.""" + + bl_idname = "bim.edit_railing_terminal_type" + bl_label = "Choose Railing Terminal Type" + bl_description = "Pick the cap geometry applied at the rail ends" + bl_options = {"REGISTER", "UNDO"} + + terminal_type: bpy.props.StringProperty(name="Terminal Type", default="", options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_railing_props(obj) + if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL": + return {"CANCELLED"} + + choices = [v for v in get_args(prop.CapType)] + + def draw(menu_self, _menu_context): + layout = menu_self.layout + for v in choices: + op = layout.operator(self.bl_idname, text=v) + op.terminal_type = v + + context.window_manager.popup_menu(draw, title="Terminal Type", icon="MOD_LATTICE") + return {"FINISHED"} + + def execute(self, context: bpy.types.Context) -> set[str]: + # Re-open the popup if called without a value (e.g. from the command palette). + if not self.terminal_type: + return self.invoke(context, None) # type: ignore[arg-type] + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_railing_props(obj) + if self.terminal_type not in get_args(prop.CapType): + self.report({"ERROR"}, f"Unknown terminal_type: {self.terminal_type!r}") + return {"CANCELLED"} + props.terminal_type = self.terminal_type # type: ignore[assignment] + return {"FINISHED"} + + +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.Blender.Modifier.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() + + for slot in ("lock_open_gizmo", "lock_closed_gizmo"): + bl_idname = "VIEW3D_GT_lock_open" if slot == "lock_open_gizmo" else "VIEW3D_GT_lock_closed" + gz = self.gizmos.new(bl_idname) + gz.color = default_color + gz.color_highlight = highlight_color + gz.use_draw_scale = False + gz.alpha = 0.8 + gz.target_set_operator("bim.toggle_railing_use_manual_supports") + setattr(self, slot, gz) + + self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_cycle") + 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.edit_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 +1200,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/module/model/test_railing_lifecycle.py b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py new file mode 100644 index 0000000000..bcc7005bf9 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py @@ -0,0 +1,280 @@ +# 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`` lifecycle overrides. + +The generic ``PathPreservingEditMixin`` lifecycle is tested in +``test_parametric_lifecycle.py``. This file pins the **railing-specific +overrides** that subclass it: + +- ``_RailingEditMixin._finish_one`` short-circuit: when the draft equals + the stored pset, ``_update_pset`` and ``_update_modifier_ifc_data`` are + skipped so an Enable → Finish-without-changes cycle creates no new + ``IfcShapeRepresentation``. +- ``_RailingEditMixin._cancel_one`` short-circuit: same logic guards the + expensive ``bonsai.core.geometry.switch_representation`` call (which + re-tessellates the swept-disk solid) when nothing actually changed. +- ``_RailingEditMixin._cancel_one`` WALL_MOUNTED_HANDRAIL branch: when + changes WERE made, the cancel reloads the IFC body via + ``switch_representation`` instead of running ``update_modifier_bmesh`` + (which would leave the low-poly cylinder-segment preview on screen). +""" + +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`` calls ``tool.Model.get_modeling_bbim_pset_data``, + ``tool.Ifc.get_entity``, ``ifcopenshell.util.representation.get_representation``, + and ``bonsai.core.geometry.switch_representation`` — each looked up + through the railing module's own bindings, so we patch them there. + + 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.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, + ): + # _resolve will be overridden on the test subclass below so the + # parametric_lifecycle.tool patch isn't needed. + mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity") + yield {"tool": mock_tool, "ifcopenshell": mock_ifc, "bonsai": mock_bonsai} + + +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="_update_modifier_bmesh") + + @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 _update_modifier_bmesh(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. + """ + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + props = _FakeRailingProps(general=dict(stored)) + obj = _make_obj(props) + patched_railing["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["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. + """ + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + props = _FakeRailingProps(general=dict(stored)) + obj = _make_obj(props) + patched_railing["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() + cls.bmesh_updates.assert_not_called() + + +def test_cancel_one_wall_mounted_handrail_switches_representation(patched_railing): + """Cancel after a real edit on a WALL_MOUNTED_HANDRAIL must reload the + committed Body representation (high-poly, IFC-derived) rather than + re-running the low-poly bmesh preview — that preview is a viewport-only + approximation and would persist visibly after Cancel without this. + """ + stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} + # Differs → not a no-op → cancel must take the real branch. + props = _FakeRailingProps( + railing_type="WALL_MOUNTED_HANDRAIL", + general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5}, + ) + obj = _make_obj(props) + patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, + } + body_repr = mock.Mock(name="body_representation") + patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr + + cls, _element = _railing_test_subclass(props) + cls._cancel_one(obj, mock.Mock(name="context")) + + assert props.is_editing is False + # Must call switch_representation with the Body representation; must NOT + # call _update_modifier_bmesh (that's the FRAMELESS branch). + 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 + cls.bmesh_updates.assert_not_called() + + +def test_cancel_one_frameless_panel_runs_bmesh_preview(patched_railing): + """FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC + swept-disk solid to reload. Cancel must run the bmesh rebuild instead + of switch_representation, which would no-op or worse.""" + stored = {"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.05} + props = _FakeRailingProps( + railing_type="FRAMELESS_PANEL", + general={"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.08}, + ) + obj = _make_obj(props) + patched_railing["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 + cls.bmesh_updates.assert_called_once_with(obj, mock.ANY) + 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..270df8db78 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_railing_schematic.py @@ -0,0 +1,272 @@ +# 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.railing + + +@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 design has no need for a scale factor. If a + subclass redefines ``_compute_schematic_scale``, it indicates the + scale-based proportional sizing was reintroduced — which is the design + we deliberately stepped away from.""" + 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() From 05c9df74f9e1015e57ee5a3e51cbbb535d6f48de Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 11 Jun 2026 20:49:27 +0200 Subject: [PATCH 02/35] Migrate railing terminal type to PickType menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the IfcRailingType terminal-type selector from cycle-on-click to a popup menu of all terminal-type literals — 5+ values trip the §2.8 menu-pick threshold. Updates classes registration; removes EditRailingTerminalType in favour of PickRailingTerminalType which inherits PickTypeMixin. Adapts the cherry-pick from db016d881 to post-PR5 framework state: - Imports CycleTypeMixin / PickTypeMixin / PathPreservingEditMixin from bim.parametric_lifecycle (PR5 moved them off gizmos.py). - Routes is_railing through tool.Parametric (predicates moved off tool.Blender.Modifier between PR3-PR5). Skips the parametric_lifecycle.py framework refactor the source commit shipped — HEAD has the more-evolved post-PR5 framework that already covers it. Adds the _FakePropsBase + make_lifecycle_obj test helpers to test/bim/conftest.py so the new test_railing_lifecycle.py can exercise the edit triad without a real bpy.types.Object. Brings the test_railing_schematic.py marker in line with the rest of the model lane. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 2 +- src/bonsai/bonsai/bim/module/model/railing.py | 153 ++++++------------ src/bonsai/test/bim/conftest.py | 41 +++++ .../module/model/test_railing_lifecycle.py | 144 ++++++++++------- .../module/model/test_railing_schematic.py | 8 +- 5 files changed, 172 insertions(+), 176 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index edac430309..08806ad8fb 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -247,8 +247,8 @@ classes = ( railing.AddRailing, railing.CancelEditingRailing, railing.CycleRailingType, - railing.EditRailingTerminalType, railing.FinishEditingRailing, + railing.PickRailingTerminalType, railing.FlipRailingPathOrder, railing.EnableEditingRailing, railing.GizmoRailingSchematic, diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 072b7d562d..825cc339ac 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -19,7 +19,7 @@ import json import math -from typing import Any, get_args +from typing import Any import bmesh import bpy @@ -38,7 +38,11 @@ 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_ @@ -497,53 +501,9 @@ class _RailingEditMixin(PathPreservingEditMixin): update_railing_modifier_ifc_data(context) @classmethod - def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: - update_railing_modifier_bmesh(context) - - @classmethod - def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: - """Skip the IFC commit when the draft matches the stored pset (no-op edit).""" - resolved = cls._resolve(obj) - if resolved is None: - return - element, props = resolved - pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) - stored = pset_data["data_dict"] - path_data = stored["path_data"] - draft = props.get_general_kwargs(convert_to_project_units=True) - draft["path_data"] = path_data - - if draft == stored: - props.is_editing = False - return - - cls._update_pset(element, draft) - cls._update_modifier_ifc_data(obj, context) - props.is_editing = False - - @classmethod - def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: - """WALL_MOUNTED_HANDRAIL switches the representation back to Body on cancel - (the cylinder preview is lower-poly than the committed swept-disk solid). - Skip the switch when the draft matches the stored pset.""" - resolved = cls._resolve(obj) - if resolved is None: - return - _element, props = resolved - pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) - stored = pset_data["data_dict"] - - draft = props.get_general_kwargs(convert_to_project_units=True) - draft["path_data"] = stored["path_data"] - nothing_changed = draft == stored - - data = cls._post_load_data(stored) - props.set_props_kwargs_from_ifc_data(data) - - if nothing_changed: - props.is_editing = False - return - + 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 @@ -555,10 +515,8 @@ class _RailingEditMixin(PathPreservingEditMixin): obj=obj, representation=body, ) - else: - cls._update_modifier_bmesh(obj, context) - - props.is_editing = False + return + update_railing_modifier_bmesh(context) class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -588,14 +546,14 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera return self._finish_targets(context) -class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin): +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.Blender.Modifier.is_railing + element_checker = tool.Parametric.is_railing props_getter = tool.Model.get_railing_props type_literal = tool.Model.RailingType type_attr = "railing_type" @@ -616,58 +574,42 @@ class ToggleRailingUseManualSupports(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - obj = context.active_object - if not obj: - return {"CANCELLED"} - props = tool.Model.get_railing_props(obj) - if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL": + 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 EditRailingTerminalType(bpy.types.Operator): - """Popup menu for terminal_type; writes the picked value via a HIDDEN string property.""" +class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin): + """Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing.""" - bl_idname = "bim.edit_railing_terminal_type" - bl_label = "Choose Railing Terminal Type" + 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"} - terminal_type: bpy.props.StringProperty(name="Terminal Type", default="", options={"HIDDEN", "SKIP_SAVE"}) + skip_element_check = True + props_getter = tool.Model.get_railing_props + type_literal = prop.CapType + type_attr = "terminal_type" - def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: - obj = context.active_object - if not obj: + 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"} - props = tool.Model.get_railing_props(obj) - if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL": - return {"CANCELLED"} - - choices = [v for v in get_args(prop.CapType)] - - def draw(menu_self, _menu_context): - layout = menu_self.layout - for v in choices: - op = layout.operator(self.bl_idname, text=v) - op.terminal_type = v - - context.window_manager.popup_menu(draw, title="Terminal Type", icon="MOD_LATTICE") - return {"FINISHED"} - - def execute(self, context: bpy.types.Context) -> set[str]: - # Re-open the popup if called without a value (e.g. from the command palette). - if not self.terminal_type: - return self.invoke(context, None) # type: ignore[arg-type] - obj = context.active_object - if not obj: - return {"CANCELLED"} - props = tool.Model.get_railing_props(obj) - if self.terminal_type not in get_args(prop.CapType): - self.report({"ERROR"}, f"Unknown terminal_type: {self.terminal_type!r}") - return {"CANCELLED"} - props.terminal_type = self.terminal_type # type: ignore[assignment] - return {"FINISHED"} + return self._pick_type(context) def _format_attr_distance(attr_name: str): @@ -822,7 +764,7 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup) @classmethod def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: - return tool.Blender.Modifier.is_railing(element) + return tool.Parametric.is_railing(element) @classmethod def schematic_cache_key(cls, props) -> tuple: @@ -846,22 +788,17 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup) """ default_color, highlight_color = self.get_decoration_colors() - for slot in ("lock_open_gizmo", "lock_closed_gizmo"): - bl_idname = "VIEW3D_GT_lock_open" if slot == "lock_open_gizmo" else "VIEW3D_GT_lock_closed" - gz = self.gizmos.new(bl_idname) - gz.color = default_color - gz.color_highlight = highlight_color - gz.use_draw_scale = False - gz.alpha = 0.8 - gz.target_set_operator("bim.toggle_railing_use_manual_supports") - setattr(self, slot, gz) + 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_cycle") + 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.edit_railing_terminal_type") + 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. 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/model/test_railing_lifecycle.py b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py index bcc7005bf9..0aad3534b7 100644 --- a/src/bonsai/test/bim/module/model/test_railing_lifecycle.py +++ b/src/bonsai/test/bim/module/model/test_railing_lifecycle.py @@ -18,23 +18,24 @@ # # This file was generated with the assistance of an AI coding tool. -"""Unit coverage for the ``_RailingEditMixin`` lifecycle overrides. +"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle +behaviour railing inherits from ``PathPreservingEditMixin``. -The generic ``PathPreservingEditMixin`` lifecycle is tested in -``test_parametric_lifecycle.py``. This file pins the **railing-specific -overrides** that subclass it: +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: -- ``_RailingEditMixin._finish_one`` short-circuit: when the draft equals - the stored pset, ``_update_pset`` and ``_update_modifier_ifc_data`` are - skipped so an Enable → Finish-without-changes cycle creates no new - ``IfcShapeRepresentation``. -- ``_RailingEditMixin._cancel_one`` short-circuit: same logic guards the - expensive ``bonsai.core.geometry.switch_representation`` call (which - re-tessellates the swept-disk solid) when nothing actually changed. -- ``_RailingEditMixin._cancel_one`` WALL_MOUNTED_HANDRAIL branch: when - changes WERE made, the cancel reloads the IFC body via - ``switch_representation`` instead of running ``update_modifier_bmesh`` - (which would leave the low-poly cylinder-segment preview on screen). +- 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 @@ -68,10 +69,16 @@ class _FakeRailingProps(_FakePropsBase): def patched_railing(): """Patch the railing module's external references for unit testing. - ``_RailingEditMixin`` calls ``tool.Model.get_modeling_bbim_pset_data``, - ``tool.Ifc.get_entity``, ``ifcopenshell.util.representation.get_representation``, - and ``bonsai.core.geometry.switch_representation`` — each looked up - through the railing module's own bindings, so we patch them there. + ``_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")`` @@ -81,17 +88,28 @@ def patched_railing(): 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. + # 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} + 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): @@ -108,7 +126,7 @@ def _railing_test_subclass(props): 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="_update_modifier_bmesh") + bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel") @classmethod def _resolve(cls, obj): @@ -123,7 +141,7 @@ def _railing_test_subclass(props): cls.ifc_data_updates(obj, context) @classmethod - def _update_modifier_bmesh(cls, obj, context): + def _restore_viewport_after_cancel(cls, obj, context): cls.bmesh_updates(obj, context) # The base _post_load_data JSON-serialises path_data; bypass that @@ -148,11 +166,14 @@ def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing): 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["tool"].Model.get_modeling_bbim_pset_data.return_value = { + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, } @@ -171,7 +192,7 @@ def test_finish_one_writes_when_draft_differs(patched_railing): # Draft height differs: simulating a user edit. props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5}) obj = _make_obj(props) - patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, } @@ -198,11 +219,14 @@ def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing): 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["tool"].Model.get_modeling_bbim_pset_data.return_value = { + patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = { "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, } @@ -211,60 +235,56 @@ def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing): 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() -def test_cancel_one_wall_mounted_handrail_switches_representation(patched_railing): - """Cancel after a real edit on a WALL_MOUNTED_HANDRAIL must reload the - committed Body representation (high-poly, IFC-derived) rather than - re-running the low-poly bmesh preview — that preview is a viewport-only - approximation and would persist visibly after Cancel without this. - """ - stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} - # Differs → not a no-op → cancel must take the real branch. - props = _FakeRailingProps( - railing_type="WALL_MOUNTED_HANDRAIL", - general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5}, - ) +# --------------------------------------------------------------------------- +# _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_modeling_bbim_pset_data.return_value = { - "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, - } + 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 - cls, _element = _railing_test_subclass(props) - cls._cancel_one(obj, mock.Mock(name="context")) + _RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context")) - assert props.is_editing is False - # Must call switch_representation with the Body representation; must NOT - # call _update_modifier_bmesh (that's the FRAMELESS branch). 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 - cls.bmesh_updates.assert_not_called() + # Must NOT fall through to the FRAMELESS bmesh-rebuild path. + patched_railing["update_bmesh"].assert_not_called() -def test_cancel_one_frameless_panel_runs_bmesh_preview(patched_railing): +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. Cancel must run the bmesh rebuild instead - of switch_representation, which would no-op or worse.""" - stored = {"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.05} - props = _FakeRailingProps( - railing_type="FRAMELESS_PANEL", - general={"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.08}, - ) + 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_modeling_bbim_pset_data.return_value = { - "data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, - } + patched_railing["tool"].Model.get_railing_props.return_value = props + ctx = mock.Mock(name="context") - cls, _element = _railing_test_subclass(props) - cls._cancel_one(obj, mock.Mock(name="context")) + _RailingEditMixin._restore_viewport_after_cancel(obj, ctx) - assert props.is_editing is False - cls.bmesh_updates.assert_called_once_with(obj, mock.ANY) + patched_railing["update_bmesh"].assert_called_once_with(ctx) patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called() diff --git a/src/bonsai/test/bim/module/model/test_railing_schematic.py b/src/bonsai/test/bim/module/model/test_railing_schematic.py index 270df8db78..7ba12a7a70 100644 --- a/src/bonsai/test/bim/module/model/test_railing_schematic.py +++ b/src/bonsai/test/bim/module/model/test_railing_schematic.py @@ -32,7 +32,7 @@ from bonsai.bim.module.drawing.gizmos import ( ) from bonsai.bim.module.model.railing import GizmoRailingSchematic -pytestmark = pytest.mark.railing +pytestmark = pytest.mark.model @pytest.fixture(autouse=True) @@ -167,10 +167,8 @@ def test_schematic_dim_visible_length_is_constant(): def test_schematic_no_compute_schematic_scale_override(): - """The constant-length design has no need for a scale factor. If a - subclass redefines ``_compute_schematic_scale``, it indicates the - scale-based proportional sizing was reintroduced — which is the design - we deliberately stepped away from.""" + """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__ From 92aa890add29edafac0cd2e91ca311536bbdd266 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 11 Jun 2026 20:49:41 +0200 Subject: [PATCH 03/35] Refresh railing preview on every gizmo edit update_railing skipped the bmesh rebuild for WALL_MOUNTED_HANDRAIL railings because the only mesh source available at the time mutated IFC. The viewport-only preview helper that lands with the parametric gizmo work (generate_wall_mounted_handrail_preview) sidesteps IFC entirely, so the WALL_MOUNTED_HANDRAIL branch can join the FRAMELESS_PANEL path and trigger update_railing_modifier_bmesh on every property write. Gizmo drag now repaints the viewport in real time instead of waiting for Finish Editing. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/prop.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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: From f6590d8be25680d74a204145f17f0b1480e3a2eb Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 08:02:50 +0200 Subject: [PATCH 04/35] Add tool.Wall slab-connection helpers + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four classmethods enable the new wall-slab connection gizmo work: - iter_wall_slab_connections(wall): yields (slab, rel) tuples for every IfcRelConnectsElements(TOP) on wall.ConnectedFrom — the rel kind extend_walls_to_underside creates. - iter_slab_wall_connections(slab): mirror, walks slab.ConnectedTo so a slab-side gizmo can enumerate every wall clipped to its underside. - find_wall_slab_rel(wall, slab): locates the specific rel between a wall + slab pair so a disconnect operator knows what to remove. - wall_slab_connection_location_world(wall_obj, slab_obj): returns the world-space icon anchor — wall axis midpoint X/Y lifted to the slab's mesh-bbox underside Z. Approximate (uses slab bbox vs reconstructing the slab's clip plane) but adequate for icon placement on a wall whose top meets the slab; returns None when the wall has no IFC Axis representation. Tests (11) pin the rel-shape contract (class + Description=="TOP", non-TOP and non-IfcRelConnectsElements rels skipped, None relating defensively skipped) plus the icon-anchor math (axis-mid lifted to slab-bbox bottom; None for axisless walls). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/wall.py | 56 +++++ .../model/test_wall_slab_connections.py | 211 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_wall_slab_connections.py diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index c982b15371..6f471e0c8a 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -242,6 +242,62 @@ class Wall(bonsai.core.tool.Wall): local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0)) return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2 + @classmethod + def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance): + """Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)`` + connecting a slab to this wall — the rel kind ``extend_walls_to_underside`` + creates. Walks ``wall.ConnectedFrom`` because the slab is the relating + side of the TOP rel.""" + for rel in getattr(wall, "ConnectedFrom", []) or (): + if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP": + continue + slab = rel.RelatingElement + if slab is None: + continue + yield slab, rel + + @classmethod + def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance): + """Yield ``(wall, rel)`` tuples for every wall clipped to this slab's + underside. Mirror of ``iter_wall_slab_connections`` from the slab side + — walks ``slab.ConnectedTo``.""" + for rel in getattr(slab, "ConnectedTo", []) or (): + if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP": + continue + wall = rel.RelatedElement + if wall is None: + continue + yield wall, rel + + @classmethod + def find_wall_slab_rel( + cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance + ) -> ifcopenshell.entity_instance | None: + """Return the single ``IfcRelConnectsElements(TOP)`` between ``wall`` + and ``slab``, or ``None`` if none exists. Used by the disconnect + operator to find the specific rel to remove.""" + for s, rel in cls.iter_wall_slab_connections(wall): + if s == slab: + return rel + return None + + @classmethod + def wall_slab_connection_location_world( + cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object + ) -> Vector | None: + """World-space point where a wall is clipped by a slab — the wall's + axis midpoint lifted to the slab's underside Z. Approximate: uses the + slab's mesh bbox bottom in world space rather than reconstructing the + slab's clip plane. Adequate for icon placement on a wall whose top + meets the slab; returns ``None`` when the wall has no reference line.""" + ref = cls.get_world_reference_line(wall_obj) + if ref is None: + return None + axis_mid_world = (ref[0] + ref[1]) * 0.5 + slab_bottom_local_z = min(c[2] for c in slab_obj.bound_box) + slab_bottom_world_z = (slab_obj.matrix_world @ Vector((0.0, 0.0, slab_bottom_local_z))).z + return Vector((axis_mid_world.x, axis_mid_world.y, slab_bottom_world_z)) + @classmethod def walk_connected_walls( cls, diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py new file mode 100644 index 0000000000..5bd9704e3c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py @@ -0,0 +1,211 @@ +# 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. + +"""Behaviour tests for the wall-slab connection helpers on tool.Wall. + +Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP") +the underside-extension feature creates, and the icon placement contract the +new wall-slab connection gizmo group reads.""" + +from unittest.mock import Mock, patch + +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None): + rel = Mock() + rel.is_a = lambda c: c == klass + rel.Description = description + rel.RelatingElement = relating + rel.RelatedElement = related + return rel + + +def _wall_with_rels(*rels) -> Mock: + wall = Mock() + wall.ConnectedFrom = list(rels) + return wall + + +def _slab_with_rels(*rels) -> Mock: + slab = Mock() + slab.ConnectedTo = list(rels) + return slab + + +# --------------------------------------------------------------------------- +# iter_wall_slab_connections — yields (slab, rel) for TOP rels +# --------------------------------------------------------------------------- + + +def test_iter_wall_slab_connections_yields_top_rels(): + slab_a = Mock(name="slab_a") + slab_b = Mock(name="slab_b") + wall = _wall_with_rels( + _rel(relating=slab_a), + _rel(relating=slab_b), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])] + + +def test_iter_wall_slab_connections_skips_non_top_description(): + """Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are + skipped so other RelConnectsElements semantics aren't confused with the + underside-extension contract.""" + slab = Mock() + wall = _wall_with_rels( + _rel(description="BOTTOM", relating=slab), + _rel(description="TOP", relating=slab), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert len(result) == 1 + assert result[0][0] is slab + + +def test_iter_wall_slab_connections_skips_non_connectselements_rels(): + """Path-connections to other walls show up on ConnectedFrom too — the + helper must filter on rel class, not just presence.""" + slab = Mock() + wall = _wall_with_rels( + _rel(klass="IfcRelConnectsPathElements", relating=slab), + _rel(klass="IfcRelConnectsElements", relating=slab), + ) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert len(result) == 1 + + +def test_iter_wall_slab_connections_handles_none_relating(): + """A malformed rel with RelatingElement=None is skipped rather than + raising — defensive against partially-loaded IFC files.""" + wall = _wall_with_rels(_rel(relating=None)) + + result = list(tool.Wall.iter_wall_slab_connections(wall)) + + assert result == [] + + +def test_iter_wall_slab_connections_empty_when_no_connectedfrom(): + wall = Mock() + wall.ConnectedFrom = [] + + assert list(tool.Wall.iter_wall_slab_connections(wall)) == [] + + +# --------------------------------------------------------------------------- +# iter_slab_wall_connections — mirror, walks slab.ConnectedTo +# --------------------------------------------------------------------------- + + +def test_iter_slab_wall_connections_yields_top_rels(): + wall_a = Mock() + wall_b = Mock() + slab = _slab_with_rels( + _rel(related=wall_a), + _rel(related=wall_b), + ) + + result = list(tool.Wall.iter_slab_wall_connections(slab)) + + assert [w for w, _ in result] == [wall_a, wall_b] + + +def test_iter_slab_wall_connections_skips_non_top(): + wall = Mock() + slab = _slab_with_rels( + _rel(description="BOTTOM", related=wall), + _rel(description="TOP", related=wall), + ) + + result = list(tool.Wall.iter_slab_wall_connections(slab)) + + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# find_wall_slab_rel — locate specific rel between wall + slab +# --------------------------------------------------------------------------- + + +def test_find_wall_slab_rel_returns_match(): + slab_a = Mock(name="slab_a") + slab_b = Mock(name="slab_b") + rel_a = _rel(relating=slab_a) + rel_b = _rel(relating=slab_b) + wall = _wall_with_rels(rel_a, rel_b) + + assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b + + +def test_find_wall_slab_rel_returns_none_when_unconnected(): + slab_a = Mock(name="slab_a") + other_slab = Mock(name="other_slab") + wall = _wall_with_rels(_rel(relating=slab_a)) + + assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None + + +# --------------------------------------------------------------------------- +# wall_slab_connection_location_world — icon anchor point +# --------------------------------------------------------------------------- + + +def test_wall_slab_connection_location_lifts_axis_mid_to_slab_underside(): + """The icon sits at the wall's axis midpoint X/Y lifted to the slab's + underside Z so it reads as a marker on the slab cut line.""" + wall_obj = Mock() + slab_obj = Mock() + slab_obj.matrix_world = Matrix.Translation(Vector((0.0, 0.0, 3.0))) + slab_obj.bound_box = [ + (-1.0, -1.0, 0.0), + (1.0, -1.0, 0.0), + (-1.0, 1.0, 0.0), + (1.0, 1.0, 0.0), + (-1.0, -1.0, 0.2), + (1.0, -1.0, 0.2), + (-1.0, 1.0, 0.2), + (1.0, 1.0, 0.2), + ] + + ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0))) + with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line): + loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + + assert loc == Vector((2.0, 0.0, 3.0)) + + +def test_wall_slab_connection_location_returns_none_for_axisless_wall(): + """A wall without an IFC Axis representation has no reference line; the + helper returns None so callers can skip rather than guess a location.""" + wall_obj = Mock() + slab_obj = Mock() + with patch.object(tool.Wall, "get_world_reference_line", return_value=None): + assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None From b0eb55cc3897471ab7f2fa2a631e66c50221c84f Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 08:19:04 +0200 Subject: [PATCH 05/35] Add bim.disconnect_wall_slab operator Counterpart to UnjoinWallPathConnection on the wall-slab side: takes a wall + slab GlobalId pair, locates the IfcRelConnectsElements(TOP) between them via tool.Wall.find_wall_slab_rel, removes it via ifcopenshell.api.geometry.disconnect_element, then re-runs core.regenerate_wall_to_underside so the wall re-clips against any remaining connected slabs (the disconnected slab is excluded naturally because the helper walks tool.Model.get_connected_slab_objs which filters by the rel set). Defensive reports replace silent CANCELLED on three error paths the UI can hit when the gizmo dispatches against stale state: unknown GlobalIds, wall entity without a Blender object, no rel found between the resolved pair. Tests cover all four control flows (happy path + three error paths) plus a registration smoke that catches a forgotten classes-tuple update. A follow-up commit will retrofit this + UnjoinWallPathConnection + the MEP port disconnects through a unified bim.disconnect_elements dispatcher with a small connection-type registry; that lands as a separate single-concern commit so the typed operator can be reviewed first. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/wall.py | 51 ++++++ .../module/model/test_disconnect_wall_slab.py | 164 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 08806ad8fb..35922acf5e 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -120,6 +120,7 @@ classes = ( wall.RotateWall90, wall.SplitWall, wall.SplitWallAtCursor, + wall.DisconnectWallSlab, wall.UnjoinWallPathConnection, wall.UnjoinWalls, wall.EnableWallFilletPreview, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index dd9d21697c..e11f89940b 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -349,6 +349,57 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, _resync_walls_after_mutation([active, other]) +class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + """Disconnect the wall from one specific underside slab — counterpart to + UnjoinWallPathConnection on the wall-slab side. Both endpoints are + identified by IFC GlobalId so the dispatch survives rename / undo / save. + + Drops the IfcRelConnectsElements(TOP) rel + all underside booleans on the + wall, then re-runs regenerate_wall_to_underside which re-clips the wall + to whatever slabs remain connected. The all-booleans-then-regenerate + approach is safe with HEAD's flat BBIM_Boolean pset (no per-slab id + storage); switches to a per-slab boolean removal when PR #8147's + dict-with-slab-guid pset migration lands.""" + + bl_idname = "bim.disconnect_wall_slab" + bl_label = "Disconnect Wall From Slab" + bl_description = "Remove the TOP connection between a wall and one slab and re-clip the wall to remaining slabs" + bl_options = {"REGISTER", "UNDO"} + + wall_guid: bpy.props.StringProperty(name="Wall GlobalId") + slab_guid: bpy.props.StringProperty(name="Slab GlobalId") + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + if _poll_reject_array_children(cls): + return False + return True + + def _perform(self, context): + ifc_file = tool.Ifc.get() + try: + wall = ifc_file.by_guid(self.wall_guid) if self.wall_guid else None + slab = ifc_file.by_guid(self.slab_guid) if self.slab_guid else None + except RuntimeError: + wall = slab = None + if wall is None or slab is None: + self.report({"ERROR"}, "Could not resolve wall and slab from supplied GlobalIds.") + return + wall_obj = tool.Ifc.get_object(wall) + if wall_obj is None: + self.report({"ERROR"}, "Wall has no Blender object.") + return + rel = tool.Wall.find_wall_slab_rel(wall, slab) + if rel is None: + self.report({"ERROR"}, "No TOP connection between this wall and slab.") + return + ifcopenshell.api.geometry.disconnect_element(ifc_file, relating_element=slab, related_element=wall) + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) + + class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_walls_to_underside" bl_label = "Extend Walls To Underside" diff --git a/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py b/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py new file mode 100644 index 0000000000..5c342be644 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py @@ -0,0 +1,164 @@ +# 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. + +"""Behaviour tests for ``bim.disconnect_wall_slab``. + +Pins the dispatch contract: resolves the wall + slab from GlobalIds, finds the +specific ``IfcRelConnectsElements(TOP)`` rel, removes it via the IFC API, then +delegates to ``core.regenerate_wall_to_underside`` to re-clip the wall against +any remaining slab connections.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +pytestmark = pytest.mark.model + + +def _make_op(*, wall_guid="WALL-GUID", slab_guid="SLAB-GUID"): + op = Mock() + op.wall_guid = wall_guid + op.slab_guid = slab_guid + op.report = Mock() + return op + + +def _ifc_file_with(*, walls: dict | None = None, slabs: dict | None = None): + ifc = MagicMock(name="ifc_file") + walls = walls or {} + slabs = slabs or {} + + def _by_guid(guid): + if guid in walls: + return walls[guid] + if guid in slabs: + return slabs[guid] + raise RuntimeError(f"no entity with guid {guid}") + + ifc.by_guid.side_effect = _by_guid + return ifc + + +def test_disconnect_removes_rel_then_regenerates(): + """Happy path: resolve both endpoints, find rel, call disconnect_element, + then regenerate so remaining slabs re-clip cleanly.""" + from bonsai.bim.module.model.wall import DisconnectWallSlab + + wall = Mock(name="wall") + slab = Mock(name="slab") + rel = Mock(name="rel") + wall_obj = Mock(name="wall_obj") + ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj + ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=rel), patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" + ) as disconnect, patch( + "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" + ) as regen: + DisconnectWallSlab._perform(op, context=MagicMock()) + + disconnect.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) + regen.assert_called_once() + args, _ = regen.call_args + assert args[3] == [wall_obj] + op.report.assert_not_called() + + +def test_disconnect_reports_when_guids_unknown(): + """Stale UI state can dispatch with guids no longer in the file — surface + an ERROR rather than crashing on RuntimeError from by_guid.""" + from bonsai.bim.module.model.wall import DisconnectWallSlab + + ifc_file = _ifc_file_with() + op = _make_op(wall_guid="MISSING", slab_guid="ALSO-MISSING") + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" + ) as disconnect, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: + DisconnectWallSlab._perform(op, context=MagicMock()) + + disconnect.assert_not_called() + regen.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"ERROR"} + + +def test_disconnect_reports_when_rel_missing(): + """find_wall_slab_rel returns None when the rel doesn't exist (UI was + showing a stale icon). Operator reports + skips the mutation.""" + from bonsai.bim.module.model.wall import DisconnectWallSlab + + wall = Mock(name="wall") + slab = Mock(name="slab") + wall_obj = Mock(name="wall_obj") + ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj + ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=None), patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" + ) as disconnect, patch( + "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" + ) as regen: + DisconnectWallSlab._perform(op, context=MagicMock()) + + disconnect.assert_not_called() + regen.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"ERROR"} + + +def test_disconnect_reports_when_wall_obj_missing(): + """The wall entity exists but has no Blender object — surface ERROR + rather than silently no-op (or crash trying to pass None to regen).""" + from bonsai.bim.module.model.wall import DisconnectWallSlab + + wall = Mock(name="wall") + slab = Mock(name="slab") + ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=None + ), patch("bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element") as disconnect, patch( + "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" + ) as regen: + DisconnectWallSlab._perform(op, context=MagicMock()) + + disconnect.assert_not_called() + regen.assert_not_called() + op.report.assert_called_once() + + +def test_disconnect_operator_is_registered(): + """Catches a forgotten classes-tuple update — the operator file can be + saved cleanly but the class never reaches Blender's registry without + the __init__.py entry.""" + from bonsai.bim.module import model + + assert any( + getattr(cls, "bl_idname", None) == "bim.disconnect_wall_slab" for cls in model.classes + ), "DisconnectWallSlab is not in the model classes tuple" From a3593ed58b53859bd00b438282dcb7e7b43fb29b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 09:06:09 +0200 Subject: [PATCH 06/35] Unify wall disconnect ops via bim.disconnect_elements Single generic dispatcher replaces UnjoinWallPathConnection + DisconnectWallSlab. Takes two GlobalIds, looks up every supported rel between them via tool.Connection.find_rels, dispatches the right cleanup by rel kind: - path (IfcRelConnectsPathElements): remove_connection on every rel in both orientations + recreate both walls + resync drafts. - element-top (IfcRelConnectsElements with Description=="TOP"): disconnect_element + regenerate_wall_to_underside on the wall side via orient_element_top to recover which input is wall vs slab. - element (other IfcRelConnectsElements): plain disconnect_element. tool.Connection lands as a new tool module with two helpers: - find_rels(a, b): every supported rel between two elements, walking both ConnectedTo + ConnectedFrom (catches both authoring orientations and dedups by id). - find_rel(a, b): first-match convenience. - orient_element_top(rel, a, b): recovers (wall, slab) from a TOP rel regardless of which input came first. Updates GizmoWallUnjoinSingle to target bim.disconnect_elements with both element_a_guid + element_b_guid pre-filled per icon. Adds the single registration in tool/__init__.py and the classes-tuple entry in bim/module/model/__init__.py. Drops the two retired classes. Tests cover both cleanup branches (path + element-top), missing endpoints, no-rel-found, and registration smoke. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 3 +- src/bonsai/bonsai/bim/module/model/wall.py | 163 ++++------- src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/connection.py | 113 ++++++++ .../module/model/test_disconnect_elements.py | 265 ++++++++++++++++++ .../module/model/test_disconnect_wall_slab.py | 164 ----------- 6 files changed, 442 insertions(+), 267 deletions(-) create mode 100644 src/bonsai/bonsai/tool/connection.py create mode 100644 src/bonsai/test/bim/module/model/test_disconnect_elements.py delete mode 100644 src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 35922acf5e..4028520634 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -120,8 +120,7 @@ classes = ( wall.RotateWall90, wall.SplitWall, wall.SplitWallAtCursor, - wall.DisconnectWallSlab, - wall.UnjoinWallPathConnection, + wall.DisconnectElements, wall.UnjoinWalls, wall.EnableWallFilletPreview, wall.FinishWallFilletPreview, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index e11f89940b..1058de7151 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -285,89 +285,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper _resync_walls_after_mutation(tool.Blender.get_selected_objects()) -class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): - """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one - specific partner wall, leaving the active wall's other connections intact. The - partner is identified by IFC GlobalId — invariant under Blender-object renames, - file save/reload, and the undo stack — set on the operator properties by the - single-wall unjoin gizmo at click time.""" +class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + """Disconnect two IFC elements given their GlobalIds — generic dispatcher + that infers the connection rel kind via tool.Connection.find_rels and runs + the right post-disconnect cleanup: - bl_idname = "bim.unjoin_wall_path_connection" - bl_label = "Unjoin Wall Connection" - bl_description = "Disconnect the active wall from a single specific partner wall" + - ``"path"`` (IfcRelConnectsPathElements) → removes every rel between + the pair (catches both orientations) via remove_connection + recreates + both walls + resyncs drafts. + - ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") → + disconnect_element + regenerate_wall_to_underside on the wall side. + - ``"element"`` (other IfcRelConnectsElements) → disconnect_element only. + + Both endpoints by GlobalId so the dispatch survives rename / undo / save. + Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab + operators with one entry-point gizmos and shortcuts can bind to.""" + + bl_idname = "bim.disconnect_elements" + bl_label = "Disconnect Elements" + bl_description = "Remove the connection between two IFC elements identified by GlobalId" bl_options = {"REGISTER", "UNDO"} - other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId") - - @classmethod - def poll(cls, context): - if not tool.Model.has_selected_ifc_objects(): - cls.poll_message_set("No IFC objects selected.") - return False - if _poll_reject_array_children(cls): - return False - return True - - def _perform(self, context): - active = tool.Blender.get_active_object(is_selected=True) - if not active: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") - return - elem_active = tool.Ifc.get_entity(active) - if not elem_active: - self.report({"ERROR"}, "Active object is not bound to an IFC entity.") - return - elem_other = None - if self.other_wall_guid: - try: - elem_other = tool.Ifc.get().by_guid(self.other_wall_guid) - except RuntimeError: - elem_other = None - other = tool.Ifc.get_object(elem_other) if elem_other else None - if not elem_other or not other: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") - return - # Walk the inverse graph for the specific IfcRelConnectsPathElements joining - # these two walls and remove only that one. `disconnect_path`'s - # (relating, related) mode only inspects `relating.ConnectedTo`, so a single - # call misses the rel when it was authored with the opposite orientation. - rels = [ - rel - for rel in getattr(elem_active, "ConnectedTo", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other - ] + [ - rel - for rel in getattr(elem_active, "ConnectedFrom", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other - ] - for rel in rels: - bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) - # Recreate body+axis on both walls so the mesh state matches the IFC mutation - # and stale miter cuts are dropped. - tool.Model.recreate_wall(elem_active, active) - tool.Model.recreate_wall(elem_other, other) - _resync_walls_after_mutation([active, other]) - - -class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): - """Disconnect the wall from one specific underside slab — counterpart to - UnjoinWallPathConnection on the wall-slab side. Both endpoints are - identified by IFC GlobalId so the dispatch survives rename / undo / save. - - Drops the IfcRelConnectsElements(TOP) rel + all underside booleans on the - wall, then re-runs regenerate_wall_to_underside which re-clips the wall - to whatever slabs remain connected. The all-booleans-then-regenerate - approach is safe with HEAD's flat BBIM_Boolean pset (no per-slab id - storage); switches to a per-slab boolean removal when PR #8147's - dict-with-slab-guid pset migration lands.""" - - bl_idname = "bim.disconnect_wall_slab" - bl_label = "Disconnect Wall From Slab" - bl_description = "Remove the TOP connection between a wall and one slab and re-clip the wall to remaining slabs" - bl_options = {"REGISTER", "UNDO"} - - wall_guid: bpy.props.StringProperty(name="Wall GlobalId") - slab_guid: bpy.props.StringProperty(name="Slab GlobalId") + element_a_guid: bpy.props.StringProperty(name="Element A GlobalId") + element_b_guid: bpy.props.StringProperty(name="Element B GlobalId") @classmethod def poll(cls, context): @@ -381,23 +321,42 @@ class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I def _perform(self, context): ifc_file = tool.Ifc.get() try: - wall = ifc_file.by_guid(self.wall_guid) if self.wall_guid else None - slab = ifc_file.by_guid(self.slab_guid) if self.slab_guid else None + elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None + elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None except RuntimeError: - wall = slab = None - if wall is None or slab is None: - self.report({"ERROR"}, "Could not resolve wall and slab from supplied GlobalIds.") + elem_a = elem_b = None + if elem_a is None or elem_b is None: + self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.") return - wall_obj = tool.Ifc.get_object(wall) - if wall_obj is None: - self.report({"ERROR"}, "Wall has no Blender object.") + rels = tool.Connection.find_rels(elem_a, elem_b) + if not rels: + self.report({"ERROR"}, "No connection found between elements.") return - rel = tool.Wall.find_wall_slab_rel(wall, slab) - if rel is None: - self.report({"ERROR"}, "No TOP connection between this wall and slab.") - return - ifcopenshell.api.geometry.disconnect_element(ifc_file, relating_element=slab, related_element=wall) - core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) + # All rels between a single pair should share a kind in practice; pick + # the first kind for the cleanup dispatch and remove every rel below. + kind = rels[0][1] + if kind == "path": + for rel, _ in rels: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + obj_a = tool.Ifc.get_object(elem_a) + obj_b = tool.Ifc.get_object(elem_b) + if obj_a is not None and obj_b is not None: + tool.Model.recreate_wall(elem_a, obj_a) + tool.Model.recreate_wall(elem_b, obj_b) + _resync_walls_after_mutation([obj_a, obj_b]) + elif kind in ("element-top", "element"): + for rel, _ in rels: + wall, slab = tool.Connection.orient_element_top(rel, elem_a, elem_b) + ifcopenshell.api.geometry.disconnect_element( + ifc_file, relating_element=slab, related_element=wall + ) + if kind == "element-top": + # The TOP rel is what extend_walls_to_underside creates; the + # related side is always the wall. + wall = rels[0][0].RelatedElement + wall_obj = tool.Ifc.get_object(wall) + if wall_obj is not None: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -3912,9 +3871,10 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated and hidden on a per-frame basis based on the live connection set. - Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner - wall's GlobalId set on the bound operator properties, so a click removes only - the single rel under that icon — the other connections on the same wall survive. + Each visible icon dispatches `bim.disconnect_elements` with the active wall + + partner wall GlobalIds set on the bound operator properties, so a click removes + only the single rel under that icon — the other connections on the same wall + survive. Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group requires len(selected) == 2; this one requires 1).""" @@ -3962,11 +3922,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self.unjoin_op_props = [] for _ in range(self.POOL_SIZE): icon = self.setup_icon_gizmo( - "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" ) icon.hide = True self.unjoin_icons.append(icon) - self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection")) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) def position_gizmos(self, context: bpy.types.Context) -> None: # Default: hide every pool slot. The visible-set is rebuilt from the live @@ -4009,12 +3969,13 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.hide = False - # Only the partner-GlobalId property is rewritten per frame; the operator + # Only the GlobalId properties are rewritten per frame; the operator # binding itself is the long-lived handle set up at setup() time. GlobalId # (not Blender object name) keeps the binding stable across renames, file # save/reload, and any sit-in-the-undo-stack interlude between dispatch # and execute. - self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId + self.unjoin_op_props[slot_idx].element_a_guid = elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = other_elem.GlobalId # Mirror the partner reference onto the icon itself so its draw() # can outline the partner on hover without a Gizmo-side getter on # the bound operator (the API exposes target_set_operator with diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 03716236e1..afdec36b84 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -31,6 +31,7 @@ from bonsai.tool.cad import Cad from bonsai.tool.clash import Clash from bonsai.tool.classification import Classification from bonsai.tool.collector import Collector +from bonsai.tool.connection import Connection from bonsai.tool.context import Context from bonsai.tool.cost import Cost from bonsai.tool.covering import Covering diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py new file mode 100644 index 0000000000..055eb47a68 --- /dev/null +++ b/src/bonsai/bonsai/tool/connection.py @@ -0,0 +1,113 @@ +# 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. + +"""Generic discovery of the relation linking two IFC elements. + +Used by ``bim.disconnect_elements`` so the operator surface is one operator +per disconnect intent (active vs. partner, identified by GlobalId) rather +than one per rel class. The kind label returned alongside the rel lets the +operator dispatch the right post-disconnect cleanup: + +- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.) +- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` + (the rel kind ``extend_walls_to_underside`` creates) +- ``"element"`` for any other ``IfcRelConnectsElements`` + +Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect +operator's cleanup switch maps each kind to the right post-mutation calls.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ifcopenshell + + +class Connection: + @classmethod + def find_rels( + cls, + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "list[tuple[ifcopenshell.entity_instance, str]]": + """Return every supported rel linking ``elem_a`` to ``elem_b`` as a + list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and + ``ConnectedFrom`` because either side of the rel can be the relating + element, and the same pair may carry rels authored with opposite + orientations (``disconnect_path``'s ``(relating, related)`` mode only + inspects ``relating.ConnectedTo``, so a single call would miss the + opposite-orientation rel).""" + rels: list[tuple[ifcopenshell.entity_instance, str]] = [] + seen: set[int] = set() + + def _record(rel, kind): + if rel.id() not in seen: + seen.add(rel.id()) + rels.append((rel, kind)) + + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b: + _record(rel, "path") + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b: + _record(rel, "path") + + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + + return rels + + @classmethod + def find_rel( + cls, + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "tuple[ifcopenshell.entity_instance | None, str | None]": + """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than + ``find_rels`` when callers only need to know whether a connection + exists or what kind it is.""" + rels = cls.find_rels(elem_a, elem_b) + return rels[0] if rels else (None, None) + + @classmethod + def orient_element_top( + cls, + rel: "ifcopenshell.entity_instance", + elem_a: "ifcopenshell.entity_instance", + elem_b: "ifcopenshell.entity_instance", + ) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]": + """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel. + + The ``extend_walls_to_underside`` flow stores slab as the relating + side and wall as related — orientation is recovered by checking + which input matches which rel attribute. Callers pass any two + elements; this resolves which is the wall and which is the slab so + post-disconnect cleanup (regenerate-wall-to-underside) targets the + right object.""" + if getattr(rel, "RelatingElement", None) == elem_a: + return elem_b, elem_a + return elem_a, elem_b diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py new file mode 100644 index 0000000000..02464e1625 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -0,0 +1,265 @@ +# 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. + +"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and +``tool.Connection.find_rels`` registry. + +Pin the dispatch contract: rels are found in either orientation; the kind +label drives cleanup (``path`` recreates both walls + resyncs drafts; +``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints +report ERROR rather than crashing.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0): + rel = Mock() + rel.is_a = lambda c: c == klass + rel.RelatingElement = relating + rel.RelatedElement = related + rel.Description = description + rel.id = lambda: rel_id + return rel + + +def _elem(*, connected_to=(), connected_from=()): + e = Mock() + e.ConnectedTo = list(connected_to) + e.ConnectedFrom = list(connected_from) + e.GlobalId = "GUID" + return e + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rels — registry behaviour +# --------------------------------------------------------------------------- + + +def test_find_rels_returns_path_rel_in_either_orientation(): + """The same wall pair can carry path rels authored with either orientation; + find_rels must catch both.""" + elem_a = _elem() + elem_b = _elem() + rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2) + elem_a.ConnectedTo = [rel_ab] + elem_a.ConnectedFrom = [rel_ba] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert {r.id() for r, _ in rels} == {1, 2} + assert all(k == "path" for _, k in rels) + + +def test_find_rels_classifies_top_element_rel_specifically(): + """IfcRelConnectsElements with Description=='TOP' is the rel kind + extend_walls_to_underside creates. Tag it ``element-top`` so the + operator can dispatch the regenerate-wall-to-underside cleanup.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1) + wall.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(wall, slab) + + assert rels == [(rel, "element-top")] + + +def test_find_rels_classifies_non_top_element_rel_generically(): + """Other IfcRelConnectsElements descriptions don't get the TOP-specific + cleanup. Tag as plain ``element`` so the operator just removes the rel.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1) + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert rels == [(rel, "element")] + + +def test_find_rels_returns_empty_when_disconnected(): + elem_a = _elem() + elem_b = _elem() + assert tool.Connection.find_rels(elem_a, elem_b) == [] + + +def test_find_rels_dedups_by_id(): + """A rel that surfaces on both ConnectedTo and ConnectedFrom (in + pathological IFC files) should not be returned twice.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert len(rels) == 1 + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rel — first-match convenience +# --------------------------------------------------------------------------- + + +def test_find_rel_returns_first_match_or_none_none(): + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + + assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path") + assert tool.Connection.find_rel(elem_a, _elem()) == (None, None) + + +# --------------------------------------------------------------------------- +# tool.Connection.orient_element_top — wall / slab orientation recovery +# --------------------------------------------------------------------------- + + +def test_orient_element_top_returns_wall_then_slab(): + """The TOP rel stores slab as relating + wall as related; orient_element_top + figures out which input is which regardless of argument order.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP") + + assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab) + assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab) + + +# --------------------------------------------------------------------------- +# bim.disconnect_elements — dispatch + cleanup +# --------------------------------------------------------------------------- + + +def _make_op(*, a_guid="A", b_guid="B"): + op = Mock() + op.element_a_guid = a_guid + op.element_b_guid = b_guid + op.report = Mock() + return op + + +def test_disconnect_path_removes_all_rels_then_recreates_walls(): + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + rel1 = Mock() + rel2 = Mock() + obj_a = Mock() + obj_b = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", + return_value=[(rel1, "path"), (rel2, "path")], + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e] + ), patch("bonsai.bim.module.model.wall.bonsai.core.geometry.remove_connection") as remove, patch( + "bonsai.bim.module.model.wall.tool.Model.recreate_wall" + ) as recreate, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync: + DisconnectElements._perform(op, context=MagicMock()) + + assert remove.call_count == 2 + assert recreate.call_count == 2 + resync.assert_called_once_with([obj_a, obj_b]) + op.report.assert_not_called() + + +def test_disconnect_element_top_calls_regenerate(): + from bonsai.bim.module.model.wall import DisconnectElements + + wall = Mock() + slab = Mock() + wall_obj = Mock() + rel = Mock() + rel.RelatedElement = wall + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": wall, "B": slab}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch( + "bonsai.bim.module.model.wall.tool.Connection.orient_element_top", return_value=(wall, slab) + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj), patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" + ) as disc, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: + DisconnectElements._perform(op, context=MagicMock()) + + disc.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) + regen.assert_called_once() + op.report.assert_not_called() + + +def test_disconnect_reports_on_unknown_guids(): + from bonsai.bim.module.model.wall import DisconnectElements + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = RuntimeError("missing") + op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B") + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels" + ) as find: + DisconnectElements._perform(op, context=MagicMock()) + + find.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"ERROR"} + + +def test_disconnect_reports_when_no_rel_found(): + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[] + ): + DisconnectElements._perform(op, context=MagicMock()) + + op.report.assert_called_once() + + +def test_disconnect_operator_is_registered(): + from bonsai.bim.module import model + + assert any( + getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes + ), "DisconnectElements is not in the model classes tuple" diff --git a/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py b/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py deleted file mode 100644 index 5c342be644..0000000000 --- a/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py +++ /dev/null @@ -1,164 +0,0 @@ -# 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. - -"""Behaviour tests for ``bim.disconnect_wall_slab``. - -Pins the dispatch contract: resolves the wall + slab from GlobalIds, finds the -specific ``IfcRelConnectsElements(TOP)`` rel, removes it via the IFC API, then -delegates to ``core.regenerate_wall_to_underside`` to re-clip the wall against -any remaining slab connections.""" - -from unittest.mock import MagicMock, Mock, patch - -import pytest - -pytestmark = pytest.mark.model - - -def _make_op(*, wall_guid="WALL-GUID", slab_guid="SLAB-GUID"): - op = Mock() - op.wall_guid = wall_guid - op.slab_guid = slab_guid - op.report = Mock() - return op - - -def _ifc_file_with(*, walls: dict | None = None, slabs: dict | None = None): - ifc = MagicMock(name="ifc_file") - walls = walls or {} - slabs = slabs or {} - - def _by_guid(guid): - if guid in walls: - return walls[guid] - if guid in slabs: - return slabs[guid] - raise RuntimeError(f"no entity with guid {guid}") - - ifc.by_guid.side_effect = _by_guid - return ifc - - -def test_disconnect_removes_rel_then_regenerates(): - """Happy path: resolve both endpoints, find rel, call disconnect_element, - then regenerate so remaining slabs re-clip cleanly.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - rel = Mock(name="rel") - wall_obj = Mock(name="wall_obj") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj - ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=rel), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) - regen.assert_called_once() - args, _ = regen.call_args - assert args[3] == [wall_obj] - op.report.assert_not_called() - - -def test_disconnect_reports_when_guids_unknown(): - """Stale UI state can dispatch with guids no longer in the file — surface - an ERROR rather than crashing on RuntimeError from by_guid.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - ifc_file = _ifc_file_with() - op = _make_op(wall_guid="MISSING", slab_guid="ALSO-MISSING") - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - args, _ = op.report.call_args - assert args[0] == {"ERROR"} - - -def test_disconnect_reports_when_rel_missing(): - """find_wall_slab_rel returns None when the rel doesn't exist (UI was - showing a stale icon). Operator reports + skips the mutation.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - wall_obj = Mock(name="wall_obj") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj - ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=None), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - args, _ = op.report.call_args - assert args[0] == {"ERROR"} - - -def test_disconnect_reports_when_wall_obj_missing(): - """The wall entity exists but has no Blender object — surface ERROR - rather than silently no-op (or crash trying to pass None to regen).""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=None - ), patch("bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element") as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - - -def test_disconnect_operator_is_registered(): - """Catches a forgotten classes-tuple update — the operator file can be - saved cleanly but the class never reaches Blender's registry without - the __init__.py entry.""" - from bonsai.bim.module import model - - assert any( - getattr(cls, "bl_idname", None) == "bim.disconnect_wall_slab" for cls in model.classes - ), "DisconnectWallSlab is not in the model classes tuple" From c7d5d6c498982dcf3e8729afddb13cd1804c84be Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 11:05:02 +0200 Subject: [PATCH 07/35] Gate slab disconnect gizmos behind parametric edit lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires slabs into the parametric edit framework (tool.Parametric .EDIT_TYPES) so the wall-slab disconnect UI gets ESC handling, red cancel icon, mutual exclusion with other parametric edits, and per-feature gizmo prefs — all from BaseParametricGizmoGroup — without duplicating the lifecycle. Adds: - ParametricObject("slab") registry entry + tool.Parametric.is_slab predicate (any IfcSlab). - BIMSlabProperties with is_editing flag; PointerProperty wired by the framework's register_object_properties. - bim.enable_editing_slab / bim.finish_editing_slab / bim.cancel_editing_slab operators on tool.Ifc.Operator so they flow through tool.Parametric.run_bim_op cleanly. No IFC mutation — slab edit is a pure UI gate; finish and cancel share the body. - tool.Model.get_slab_props accessor. - GizmoSlabEdition inheriting BaseParametricGizmoGroup with the pen / validate / cancel triad. is_element_type narrows to IfcSlab with at least one wall clipped to its underside. The disconnect-icon group GizmoSlabUnjoinWalls polls behind _slab_connection_gizmo_poll_gate(require_editing=True), which now reads is_editing through tool.Model.get_slab_props. Drops the standalone GizmoSlabConnectionAccess + the setup_pen_cancel_icons helper added earlier in this branch — both superseded by the framework integration. Also folds in the wall + multi-slab gizmo polish requested live: - Wall side: stack the per-slab unjoin icons vertically (up to 5) so multi-slab connections each get a distinct clickable icon; hover-highlight reveals which slab will disconnect. - GizmoPairDisconnect activates when 2 elements with an IfcRelConnectsElements(TOP) rel are selected, with the icon at the wall-slab connection world anchor. - Wall-slab anchor moved from slab clip Z to wall top + WALL_SLAB_CONNECTION_Z_CLEARANCE so the disconnect icon perches above the extend-vertical / slope gizmo instead of overlapping. - Shared _resolve_active_partner_pair helper for 2-selection gizmos; _slab_connection_gizmo_poll_gate added to _REQUIRED_CALLEES + GizmoSlabEdition added to the AST forward-compat allowlist. Build note: wall.py's DisconnectElements._perform imports bonsai.core.connection.disconnect_rel — that core module is being added in a parallel-session commit. Until that lands the addon import will fail. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 6 + src/bonsai/bonsai/bim/module/model/prop.py | 15 + src/bonsai/bonsai/bim/module/model/slab.py | 71 ++++ src/bonsai/bonsai/bim/module/model/wall.py | 349 +++++++++++++++--- src/bonsai/bonsai/tool/model.py | 5 + src/bonsai/bonsai/tool/parametric.py | 11 + src/bonsai/bonsai/tool/wall.py | 29 +- ..._wall_array_child_filter_forward_compat.py | 8 +- .../model/test_wall_slab_connections.py | 32 +- 9 files changed, 452 insertions(+), 74 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 4028520634..6583144c89 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -110,6 +110,8 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallFilletToggleOpenings, + wall.GizmoSlabEdition, + wall.GizmoSlabUnjoinWalls, wall.GizmoWallJoinIntersection, wall.GizmoWallLinkToggle, wall.GizmoWallUnjoinSingle, @@ -154,11 +156,14 @@ classes = ( slab.DisableEditingExtrusionProfile, slab.DisableEditingSketchExtrusionProfile, slab.AddSlabFromWall, + slab.CancelEditingSlab, slab.DrawPolylineSlab, slab.EditExtrusionProfile, slab.EditSketchExtrusionProfile, slab.EnableEditingExtrusionProfile, slab.EnableEditingSketchExtrusionProfile, + slab.EnableEditingSlab, + slab.FinishEditingSlab, slab.RecalculateSlab, slab.ResetVertex, slab.SetArcIndex, @@ -185,6 +190,7 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + prop.BIMSlabProperties, prop.BIMWallProperties, prop.BIMPipeSegmentProperties, prop.BIMDuctSegmentProperties, diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index ebafafda6d..e2f8a2a9a8 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1689,6 +1689,21 @@ class BIMRoofProperties(PropertyGroup): setattr(target_props, prop_name, prop_value) +class BIMSlabProperties(PropertyGroup): + """Transient state for the slab disconnect-access gizmo. + + ``is_editing`` flips True when the user clicks the pen icon on a slab + that has wall connections — gating the per-wall disconnect icons in + ``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No + IFC draft state lives here: the disconnect operator commits directly, + so this PropertyGroup carries only the UI gate.""" + + is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"}) + + if TYPE_CHECKING: + is_editing: bool + + class BIMWallProperties(PropertyGroup): """Transient draft state for parametric wall gizmo editing. diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 58a353ab28..516dd04233 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -991,3 +991,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator): tool.Model.recalculate_walls(walls) return {"FINISHED"} + + +class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Open the slab disconnect-access mode. Pure UI toggle: flips + ``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect + gizmos surface on the slab. ``tool.Ifc.Operator`` base because the + parametric framework's universal dispatcher routes through + ``tool.Parametric.run_bim_op``, which only accepts that subclass for + undo-safe lifecycle. No IFC mutation.""" + + bl_idname = "bim.enable_editing_slab" + bl_label = "Edit Slab Connections" + bl_description = "Show disconnect icons for every wall clipped to this slab" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = True + return {"FINISHED"} + + +class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode.""" + + bl_idname = "bim.cancel_editing_slab" + bl_label = "Close Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} + + +class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode. Same body as Cancel — slab + edit is a pure UI gate with no IFC draft to commit; the framework + requires both ``bim.finish_editing_`` and + ``bim.cancel_editing_`` to exist by name convention.""" + + bl_idname = "bim.finish_editing_slab" + bl_label = "Finish Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 1058de7151..33443b8fdd 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -48,6 +48,7 @@ import mathutils.geometry import numpy as np from mathutils import Matrix, Vector +import bonsai.core.connection import bonsai.core.geometry import bonsai.core.model as core import bonsai.core.root @@ -108,6 +109,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool: return True +def _resolve_active_partner_pair( + context: bpy.types.Context, +) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None": + """Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a + selection of exactly two IFC-bound objects with the active one named, + else ``None``. Used by every 2-selection gizmo to skip the standard + "resolve active + partner + IFC entities" preamble.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return None + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + partner = next((o for o in selected if o != active), None) + if partner is None: + return None + active_elem = tool.Ifc.get_entity(active) + partner_elem = tool.Ifc.get_entity(partner) + if active_elem is None or partner_elem is None: + return None + return active, partner, active_elem, partner_elem + + +def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool: + """Shared gate for slab-side connection gizmos: exactly 1 IfcSlab + selected, not an array child, has at least one wall clipped to its + underside. With ``require_editing=True`` additionally requires the + slab's parametric edit lifecycle to be active (pen icon clicked) so + the gizmo only surfaces after explicit opt-in.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + if len(tool.Blender.get_selected_objects()) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcSlab"): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + if require_editing and not tool.Model.get_slab_props(active).is_editing: + return False + return any(True for _ in tool.Wall.iter_slab_wall_connections(element)) + + def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool: """Tighter gate for wall topology gizmos (merge / join / extend / unjoin / fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter. @@ -332,31 +377,27 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I if not rels: self.report({"ERROR"}, "No connection found between elements.") return - # All rels between a single pair should share a kind in practice; pick - # the first kind for the cleanup dispatch and remove every rel below. - kind = rels[0][1] - if kind == "path": - for rel, _ in rels: - bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) - obj_a = tool.Ifc.get_object(elem_a) - obj_b = tool.Ifc.get_object(elem_b) - if obj_a is not None and obj_b is not None: - tool.Model.recreate_wall(elem_a, obj_a) - tool.Model.recreate_wall(elem_b, obj_b) - _resync_walls_after_mutation([obj_a, obj_b]) - elif kind in ("element-top", "element"): - for rel, _ in rels: - wall, slab = tool.Connection.orient_element_top(rel, elem_a, elem_b) - ifcopenshell.api.geometry.disconnect_element( - ifc_file, relating_element=slab, related_element=wall - ) - if kind == "element-top": - # The TOP rel is what extend_walls_to_underside creates; the - # related side is always the wall. - wall = rels[0][0].RelatedElement - wall_obj = tool.Ifc.get_object(wall) - if wall_obj is not None: - core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) + path_objs: list[bpy.types.Object] = [] + for rel, kind in rels: + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=rel, + kind=kind, + elem=elem_a, + partner=elem_b, + ) + if kind == "path": + obj_a = tool.Ifc.get_object(elem_a) + obj_b = tool.Ifc.get_object(elem_b) + if obj_a is not None and obj_a not in path_objs: + path_objs.append(obj_a) + if obj_b is not None and obj_b not in path_objs: + path_objs.append(obj_b) + if path_objs: + _resync_walls_after_mutation(path_objs) class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -3865,16 +3906,17 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at - every join location inferred from the wall's IfcRelConnectsPathElements inverse - graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall - unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND - by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated - and hidden on a per-frame basis based on the live connection set. + every connection location on the wall — wall-wall path connections via + IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements + with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART + + 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped + slab), so a pool of icons is preallocated and hidden on a per-frame basis based + on the live connection set. Each visible icon dispatches `bim.disconnect_elements` with the active wall + - partner wall GlobalIds set on the bound operator properties, so a click removes - only the single rel under that icon — the other connections on the same wall - survive. + partner element GlobalIds set on the bound operator properties, so a click + removes only the single rel under that icon — the other connections on the + same wall survive. Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group requires len(selected) == 2; this one requires 1).""" @@ -3892,6 +3934,8 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix # creation is forbidden — so the pool must be sized upfront for the worst case. POOL_SIZE = 16 ICON_SCALE = 0.35 + SLAB_STACK_MAX = 5 + SLAB_STACK_OFFSET_Z = 0.5 @classmethod def poll(cls, context: bpy.types.Context) -> bool: @@ -3947,15 +3991,27 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix billboard_rot = gizmo.get_billboard_rotation(context) clearance = gizmo.top_down_clearance(context, billboard_rot) - connections = _get_wall_connections_cached(self, elem) - if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + path_connections = _get_wall_connections_cached(self, elem) + slab_connections = list(tool.Wall.iter_wall_slab_connections(elem)) + slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX) + if slab_overflow and not getattr(self, "_slab_cap_warned", False): print( - f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; " + f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab " + f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked." + ) + self._slab_cap_warned = True + slab_connections = slab_connections[: self.SLAB_STACK_MAX] + total = len(path_connections) + len(slab_connections) + if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections " + f"({len(path_connections)} path + {len(slab_connections)} slab); " f"only the first {self.POOL_SIZE} unjoin gizmos are shown." ) self._pool_cap_warned = True - for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections): + slot_idx = 0 + for other_elem, self_ct, other_ct in path_connections: if slot_idx >= self.POOL_SIZE: break other_obj = tool.Ifc.get_object(other_elem) @@ -3966,21 +4022,216 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix continue seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) + self._bind_unjoin_icon(slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj) + slot_idx += 1 + + for stack_idx, (slab_elem, _rel) in enumerate(slab_connections): + if slot_idx >= self.POOL_SIZE: + break + slab_obj = tool.Ifc.get_object(slab_elem) + if slab_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue + # Stack vertically so each slab gets a distinct clickable icon; + # hover-highlight then shows the user which slab they're about to + # disconnect from. + stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z)) + self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj) + slot_idx += 1 + + def _bind_unjoin_icon(self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj): + """Place + bind one pool icon to a (active, partner) GlobalId pair. + + Only the GlobalId properties are rewritten per frame; the operator + binding itself is the long-lived handle set up at setup() time. GlobalId + (not Blender object name) keeps the binding stable across renames, file + save/reload, and any sit-in-the-undo-stack interlude between dispatch + and execute. The partner Blender object is mirrored onto the icon for + its hover-outline draw, since the Gizmo API exposes + ``target_set_operator`` but no symmetric reader.""" + icon = self.unjoin_icons[slot_idx] + icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE) + icon.hide = False + self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId + icon.partner_obj = partner_obj + + +class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is + selected and at least one wall is clipped to its underside, surface an + unjoin icon at each connection point. The icons resolve at the same + world location as the wall-side gizmo (via the symmetric + tool.Wall.wall_slab_connection_location_world) so the same connection + has a single visual marker reachable from either selection. + + Each visible icon dispatches bim.disconnect_elements with the slab + + wall GlobalIds, so a click removes the single rel under that icon and + re-clips the wall to whatever remaining slabs it's connected to.""" + + bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls" + bl_label = "Slab Unjoin Walls Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + POOL_SIZE = 16 + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return _slab_connection_gizmo_poll_gate(context, require_editing=True) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.unjoin_icons = [] + self.unjoin_op_props = [] + for _ in range(self.POOL_SIZE): + icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + icon.hide = True + self.unjoin_icons.append(icon) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) + + def position_gizmos(self, context: bpy.types.Context) -> None: + for icon in self.unjoin_icons: + icon.hide = True + + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return + slab_obj = selected[0] + slab_elem = tool.Ifc.get_entity(slab_obj) + if slab_elem is None: + return + + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + connections = list(tool.Wall.iter_slab_wall_connections(slab_elem)) + if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; " + f"only the first {self.POOL_SIZE} unjoin gizmos are shown." + ) + self._pool_cap_warned = True + + slot_idx = 0 + for wall_elem, _rel in connections: + if slot_idx >= self.POOL_SIZE: + break + wall_obj = tool.Ifc.get_object(wall_elem) + if wall_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.hide = False - # Only the GlobalId properties are rewritten per frame; the operator - # binding itself is the long-lived handle set up at setup() time. GlobalId - # (not Blender object name) keeps the binding stable across renames, file - # save/reload, and any sit-in-the-undo-stack interlude between dispatch - # and execute. - self.unjoin_op_props[slot_idx].element_a_guid = elem.GlobalId - self.unjoin_op_props[slot_idx].element_b_guid = other_elem.GlobalId - # Mirror the partner reference onto the icon itself so its draw() - # can outline the partner on hover without a Gizmo-side getter on - # the bound operator (the API exposes target_set_operator with - # no symmetric reader). - icon.partner_obj = other_obj + self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId + icon.partner_obj = wall_obj + slot_idx += 1 + + +class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + """Pen / validate / cancel triad for slab disconnect-access mode. + + Polls on a single IfcSlab with at least one wall clipped to its underside. + Pen routes through the universal ``bim.enable_editing_parametric`` + dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation — + the framework requires the triad to exist by name convention even for a + pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with + other active parametric edits, gizmo prefs gating — all handled by the + base class.""" + + bl_idname = "OBJECT_GGT_bim_slab_edition" + bl_label = "Slab Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_slab" + finish_editing_operator = "bim.finish_editing_slab" + cancel_editing_operator = "bim.cancel_editing_slab" + cycle_type_operator = "" + + props_getter = tool.Model.get_slab_props + gizmo_pref_name = "slab" + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Parametric.is_slab(element) and any( + True for _ in tool.Wall.iter_slab_wall_connections(element) + ) + + +class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Surfaces a disconnect icon when exactly 2 IFC elements are selected + and they share a supported rel — currently the wall + slab pair joined + by an ``IfcRelConnectsElements(TOP)``. Click dispatches + ``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs, + ``GizmoWallJoinIntersection``'s unjoin icon already exposes the same + affordance via ``bim.unjoin_walls``.""" + + bl_idname = "OBJECT_GGT_bim_pair_disconnect" + bl_label = "Disconnect Pair Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + if elem_a is None or elem_b is None: + return False + rels = tool.Connection.find_rels(elem_a, elem_b) + return any(kind == "element-top" for _, kind in rels) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.disconnect_icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + self.disconnect_icon.hide = True + self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements") + + def position_gizmos(self, context: bpy.types.Context) -> None: + self.disconnect_icon.hide = True + pair = _resolve_active_partner_pair(context) + if pair is None: + return + active, partner_obj, active_elem, partner_elem = pair + # Helper expects wall + slab regardless of which the user marked active. + if active_elem.is_a("IfcWall"): + wall_obj, slab_obj = active, partner_obj + elif partner_elem.is_a("IfcWall"): + wall_obj, slab_obj = partner_obj, active + else: + return + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + return + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + self.disconnect_icon.matrix_basis = gizmo.billboarded_at( + location + clearance, billboard_rot, scale=self.ICON_SCALE + ) + self.disconnect_icon.hide = False + self.disconnect_op.element_a_guid = active_elem.GlobalId + self.disconnect_op.element_b_guid = partner_elem.GlobalId + self.disconnect_icon.partner_obj = partner_obj class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2ea1678479..d126657e36 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -82,6 +82,7 @@ if TYPE_CHECKING: BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, + BIMSlabProperties, BIMStairProperties, BIMSverchokProperties, BIMWallProperties, @@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model): def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties: + return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 3c3fba66f5..313133f192 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric): ParametricObject("pipe_segment", supports_build_edit_lifecycle=True), ParametricObject("duct_segment", supports_build_edit_lifecycle=True), ParametricObject("wall"), + ParametricObject("slab"), ] # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by @@ -171,6 +172,7 @@ class Parametric(bonsai.core.tool.Parametric): PIPE_SEGMENT: ClassVar[ParametricObject] DUCT_SEGMENT: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject] + SLAB: ClassVar[ParametricObject] _geom_generation: int = 0 @@ -459,6 +461,15 @@ class Parametric(bonsai.core.tool.Parametric): return False return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None + @classmethod + def is_slab(cls, element: entity_instance) -> bool: + """``True`` for any ``IfcSlab``. The slab edit lifecycle only gates + the connection-disconnect UI — no IFC mutation — so we don't narrow + further (e.g. by checking for wall connections). Per-gizmo polls + layer the "has wall connections" check on top via + ``tool.Wall.iter_slab_wall_connections``.""" + return element is not None and element.is_a("IfcSlab") + @classmethod def is_wall(cls, element: entity_instance) -> bool: """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index 6f471e0c8a..b3c850e790 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -281,22 +281,35 @@ class Wall(bonsai.core.tool.Wall): return rel return None + WALL_SLAB_CONNECTION_Z_CLEARANCE = 0.5 + """Lift above the wall top so the disconnect icon sits above the + extend-vertical / slope gizmo and reads as "the thing above the wall = + the slab connection".""" + @classmethod def wall_slab_connection_location_world( cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object ) -> Vector | None: - """World-space point where a wall is clipped by a slab — the wall's - axis midpoint lifted to the slab's underside Z. Approximate: uses the - slab's mesh bbox bottom in world space rather than reconstructing the - slab's clip plane. Adequate for icon placement on a wall whose top - meets the slab; returns ``None`` when the wall has no reference line.""" + """World-space anchor for the wall-slab disconnect icon. + + X / Y come from the wall axis midpoint (so the icon sits in the + middle of the wall horizontally); Z is the wall's top in world space + plus ``WALL_SLAB_CONNECTION_Z_CLEARANCE`` so the icon perches above + the slope gizmo. The slab-side gizmo calls this with the same + arguments so both sides of the same connection render a single + visual marker. ``slab_obj`` is kept on the signature for the + symmetric call shape; the helper's body no longer reads from it. + Returns ``None`` when the wall has no reference line.""" ref = cls.get_world_reference_line(wall_obj) if ref is None: return None axis_mid_world = (ref[0] + ref[1]) * 0.5 - slab_bottom_local_z = min(c[2] for c in slab_obj.bound_box) - slab_bottom_world_z = (slab_obj.matrix_world @ Vector((0.0, 0.0, slab_bottom_local_z))).z - return Vector((axis_mid_world.x, axis_mid_world.y, slab_bottom_world_z)) + if wall_obj.bound_box: + wall_top_local_z = max(c[2] for c in wall_obj.bound_box) + wall_top_world_z = (wall_obj.matrix_world @ Vector((0.0, 0.0, wall_top_local_z))).z + else: + wall_top_world_z = axis_mid_world.z + return Vector((axis_mid_world.x, axis_mid_world.y, wall_top_world_z + cls.WALL_SLAB_CONNECTION_Z_CLEARANCE)) @classmethod def walk_connected_walls( diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py index 6c1b367b7c..6e4a7fdca9 100644 --- a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py +++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py @@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule): - ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base parametric poll already filters array children. +- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits + ``BaseParametricGizmoGroup`` whose poll filters array children). - ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire WHILE its own preview is active; routing it through the topology gate would self-block it. @@ -47,9 +49,11 @@ pytestmark = pytest.mark.model # Wall gizmo groups intentionally outside the rule. Add a new entry only # with the in-code reasoning above. -_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"}) +_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"}) -_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"}) +_REQUIRED_CALLEES = frozenset( + {"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"} +) def _wall_module_source(): diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py index 5bd9704e3c..5fff484943 100644 --- a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py +++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py @@ -178,28 +178,30 @@ def test_find_wall_slab_rel_returns_none_when_unconnected(): # --------------------------------------------------------------------------- -def test_wall_slab_connection_location_lifts_axis_mid_to_slab_underside(): - """The icon sits at the wall's axis midpoint X/Y lifted to the slab's - underside Z so it reads as a marker on the slab cut line.""" +def test_wall_slab_connection_location_perches_above_wall_top(): + """Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh + bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the + icon sits above the extend-vertical / slope gizmo at the wall top.""" wall_obj = Mock() - slab_obj = Mock() - slab_obj.matrix_world = Matrix.Translation(Vector((0.0, 0.0, 3.0))) - slab_obj.bound_box = [ - (-1.0, -1.0, 0.0), - (1.0, -1.0, 0.0), - (-1.0, 1.0, 0.0), - (1.0, 1.0, 0.0), - (-1.0, -1.0, 0.2), - (1.0, -1.0, 0.2), - (-1.0, 1.0, 0.2), - (1.0, 1.0, 0.2), + wall_obj.matrix_world = Matrix.Identity(4) + wall_obj.bound_box = [ + (-0.1, -0.1, 0.0), + (0.1, -0.1, 0.0), + (-0.1, 0.1, 0.0), + (0.1, 0.1, 0.0), + (-0.1, -0.1, 3.0), + (0.1, -0.1, 3.0), + (-0.1, 0.1, 3.0), + (0.1, 0.1, 3.0), ] + slab_obj = Mock() ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0))) with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line): loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) - assert loc == Vector((2.0, 0.0, 3.0)) + expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE + assert loc == Vector((2.0, 0.0, expected_z)) def test_wall_slab_connection_location_returns_none_for_axisless_wall(): From bb8681a9545c22d174d0b6f3a2ac7e6c388a0663 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 11:11:07 +0200 Subject: [PATCH 08/35] Cascade connection cleanup on element delete Deleting a slab that was connected to a wall via IfcRelConnectsElements(TOP) left the wall holding orphan IfcBooleanResult items + a stale BBIM_Boolean pset. The disconnect operator already runs the right cleanup; element delete just never invoked it. Extract the per-kind cleanup into core.connection.disconnect_rel so the operator (bim.disconnect_elements) and a new cascade in tool.Geometry.delete_ifc_object share one dispatch table. Adding a future rel kind to tool.Connection.find_rels now flows into both call sites automatically; an AST forward-compat guard enforces coverage. Other adjustments: - regenerate_wall_to_underside zero-slab branch now removes stale clip booleans instead of silently skipping, so disconnecting the last TOP slab also reverts the wall correctly. - duplicate_ifc_objects (Shift+D) calls strip_underside_booleans on copied walls so the duplicate doesn't carry over the source's slab trim, then reloads the body representation when something was stripped so the viewport reflects the change without waiting on Shift+G. - batch_being_deleted_ids threads through OverrideDelete so the cascade can suppress partner-side regenerate when both endpoints are queued for deletion in the same batch. This file was generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/geometry/operator.py | 12 +- src/bonsai/bonsai/core/connection.py | 99 ++++++++ src/bonsai/bonsai/core/model.py | 17 +- src/bonsai/bonsai/core/tool.py | 10 + src/bonsai/bonsai/tool/connection.py | 37 +++ src/bonsai/bonsai/tool/geometry.py | 41 +++- src/bonsai/bonsai/tool/model.py | 42 ++++ .../module/model/test_disconnect_elements.py | 168 +++++++++++--- src/bonsai/test/core/bootstrap.py | 7 + src/bonsai/test/core/test_connection.py | 218 ++++++++++++++++++ .../tool/test_connection_forward_compat.py | 123 ++++++++++ 11 files changed, 742 insertions(+), 32 deletions(-) create mode 100644 src/bonsai/bonsai/core/connection.py create mode 100644 src/bonsai/test/core/test_connection.py create mode 100644 src/bonsai/test/tool/test_connection_forward_compat.py diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 05a7b43625..e21668679b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -890,6 +890,16 @@ class OverrideDelete(bpy.types.Operator): # Track aggregates before deleting their parts aggregates_to_check = self.track_aggregates(objects_to_remove) + # Snapshot the set of IFC entity ids being deleted in this batch so the + # connection-rel cascade inside `delete_ifc_object` can suppress + # partner-side regenerate when the partner is also about to vanish. + batch_being_deleted_ids: set[int] = set() + for obj in objects_to_remove: + if not tool.Blender.is_valid_data_block(obj): + continue + if (entity := tool.Ifc.get_entity(obj)) is not None: + batch_being_deleted_ids.add(entity.id()) + clear_active_object = True for i, obj in enumerate(objects_to_remove, 1): @@ -931,7 +941,7 @@ class OverrideDelete(bpy.types.Operator): if tool.Drawing.is_auto_annotation(element): self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.") continue - tool.Geometry.delete_ifc_object(obj) + tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids) elif tool.Geometry.is_representation_item(obj): tool.Geometry.delete_ifc_item(obj) else: diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py new file mode 100644 index 0000000000..71a2bb871c --- /dev/null +++ b/src/bonsai/bonsai/core/connection.py @@ -0,0 +1,99 @@ +# 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. + +"""Shared post-disconnect cleanup dispatch. + +Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the +connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit +disconnect-on-delete). Each rel kind returned by +:py:meth:`bonsai.tool.connection.Connection.find_rels` / +:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new +rel kind means extending one dispatch table — both call sites benefit +automatically and the AST forward-compat guard enforces coverage. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bonsai.core.geometry +from bonsai.core.model import regenerate_wall_to_underside + +if TYPE_CHECKING: + import bpy + import ifcopenshell + + import bonsai.tool as tool + + +def disconnect_rel( + ifc: "type[tool.Ifc]", + geometry: "type[tool.Geometry]", + model: "type[tool.Model]", + connection: "type[tool.Connection]", + rel: "ifcopenshell.entity_instance", + kind: str, + elem: "ifcopenshell.entity_instance", + partner: "ifcopenshell.entity_instance", + skip_elem_recreate: bool = False, + skip_partner_recreate: bool = False, +) -> None: + """Run the post-disconnect cleanup for one rel. + + ``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate`` + flags suppress per-side regenerate / recreate work — used by the + cascade-on-delete to avoid re-extruding entities that are about to be + removed by ``remove_product``. For the disconnect operator (where neither + endpoint is being deleted), both flags stay False and the full cleanup + runs on both sides. + """ + if kind == "path": + bonsai.core.geometry.remove_connection(geometry, connection=rel) + if not skip_elem_recreate: + elem_obj = ifc.get_object(elem) + if elem_obj is not None: + model.recreate_wall(elem, elem_obj) + if not skip_partner_recreate: + partner_obj = ifc.get_object(partner) + if partner_obj is not None: + model.recreate_wall(partner, partner_obj) + elif kind == "element-top": + wall, _slab = connection.orient_element_top(rel, elem, partner) + ifc.run( + "geometry.disconnect_element", + relating_element=rel.RelatingElement, + related_element=rel.RelatedElement, + ) + # Skip the wall-side regenerate when the wall is itself being deleted — + # either it's the elem of this cascade pass, or it's the partner that + # was queued earlier in the same batch. + if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate): + return + wall_obj = ifc.get_object(wall) + if wall_obj is not None: + regenerate_wall_to_underside(ifc, geometry, model, [wall_obj]) + elif kind == "element": + ifc.run( + "geometry.disconnect_element", + relating_element=rel.RelatingElement, + related_element=rel.RelatedElement, + ) + else: + raise ValueError(f"Unknown rel kind: {kind!r}") diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 874675ea7f..30b1c9b515 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -167,12 +167,22 @@ def regenerate_wall_to_underside( model: type[tool.Model], wall_objs: list[bpy.types.Object], ) -> None: - """Re-clip walls to their connected underside objects after the slab has moved.""" + """Re-clip walls to their connected underside objects after the slab has moved. + + When a wall has no remaining slab connections — the case reached after the + last TOP rel is severed (via disconnect or via cascade-on-slab-delete) — the + stale trim booleans are cleaned up so the wall reverts to its pre-clip + extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead + ``BBIM_Boolean`` pset. + """ clipped_objs = [] + reverted_objs = [] for obj in wall_objs: wall = ifc.get_entity(obj) slab_objs = model.get_connected_slab_objs(wall) if not slab_objs: + model.remove_wall_to_underside_booleans(wall) + reverted_objs.append(obj) continue if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) @@ -185,8 +195,9 @@ def regenerate_wall_to_underside( if clip: model.clip_wall_to_slab(wall, clip) clipped_objs.append(obj) - if clipped_objs: - model.reload_body_representation(clipped_objs) + refresh_objs = clipped_objs + reverted_objs + if refresh_objs: + model.reload_body_representation(refresh_objs) def extend_wall_to_slab( diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f48165bd4c..91003174ad 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -195,6 +195,14 @@ class Collector: def assign(cls, obj, should_clean_users_collection=False): pass +@interface +class Connection: + def find_rel(cls, elem_a, elem_b): pass + def find_rels(cls, elem_a, elem_b): pass + def find_rels_for_element(cls, elem): pass + def orient_element_top(cls, rel, elem_a, elem_b): pass + + @interface class Context: def clear_context(cls): pass @@ -694,11 +702,13 @@ class Model: def load_openings(cls, openings): pass def purge_scene_openings(cls): pass def recalculate_walls(cls, objs): pass + def recreate_wall(cls, element, obj): pass def regenerate_array(cls, parent, data): pass def regenerate_profile(cls, obj): pass def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass def remove_wall_to_underside_booleans(cls, wall): pass + def strip_underside_booleans(cls, wall): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py index 055eb47a68..4ec154b573 100644 --- a/src/bonsai/bonsai/tool/connection.py +++ b/src/bonsai/bonsai/tool/connection.py @@ -93,6 +93,43 @@ class Connection: rels = cls.find_rels(elem_a, elem_b) return rels[0] if rels else (None, None) + @classmethod + def find_rels_for_element( + cls, + elem: "ifcopenshell.entity_instance", + ) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]": + """Return every supported rel touching ``elem`` as ``(rel, kind, partner)`` + triples. ``partner`` is the *other* element on the rel — the side cascade + cleanup must operate on when ``elem`` is being deleted. + + Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry + point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object`` + enumerate everything the disconnect operator would handle pairwise. + """ + result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = [] + seen: set[int] = set() + + def _record(rel, kind, partner): + if partner is None or rel.id() in seen: + return + seen.add(rel.id()) + result.append((rel, kind, partner)) + + for rel in getattr(elem, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + _record(rel, "path", getattr(rel, "RelatedElement", None)) + elif rel.is_a("IfcRelConnectsElements"): + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind, getattr(rel, "RelatedElement", None)) + for rel in getattr(elem, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + _record(rel, "path", getattr(rel, "RelatingElement", None)) + elif rel.is_a("IfcRelConnectsElements"): + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind, getattr(rel, "RelatingElement", None)) + + return result + @classmethod def orient_element_top( cls, diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index e0b719f065..e7de9c1f6a 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -65,6 +65,7 @@ from typing_extensions import TypeIs import bonsai.bim.helper import bonsai.bim.import_ifc +import bonsai.core.connection import bonsai.core.drawing import bonsai.core.geometry import bonsai.core.root @@ -271,12 +272,38 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(obj) @classmethod - def delete_ifc_object(cls, obj: bpy.types.Object) -> None: + def delete_ifc_object( + cls, + obj: bpy.types.Object, + batch_being_deleted_ids: Optional[set[int]] = None, + ) -> None: ifc_file = tool.Ifc.get() element = tool.Ifc.get_entity(obj) if not element: return - elif element.is_a("IfcAnnotation"): + # Cascade connection-rel teardown — symmetric to bim.disconnect_elements. + # When a slab connected to a wall via IfcRelConnectsElements(TOP) is deleted, + # the wall's trim booleans + BBIM_Boolean pset would otherwise be orphaned. + # skip_elem_recreate is always True here because we're inside delete: the + # element is about to vanish, so re-extruding it would be wasted work. + # skip_partner_recreate fires only when the partner is also queued in the + # same OverrideDelete batch. + if element.is_a("IfcRoot"): + skip_ids = batch_being_deleted_ids or set() + for rel, kind, partner in tool.Connection.find_rels_for_element(element): + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=rel, + kind=kind, + elem=element, + partner=partner, + skip_elem_recreate=True, + skip_partner_recreate=(partner.id() in skip_ids), + ) + if element.is_a("IfcAnnotation"): if element.ObjectType == "DRAWING": return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) elif tool.Drawing.is_auto_annotation(element): @@ -2348,6 +2375,16 @@ class Geometry(bonsai.core.tool.Geometry): old_to_new[element] = [new] if new.is_a("IfcRelSpaceBoundary"): tool.Boundary.decorate_boundary(new_obj) + # Slab-trim booleans (from extend_walls_to_underside) belong to + # the source wall's connection, not the copy. Strip them so the + # duplicate reverts to its pre-clip extrusion — mirrors the way + # filling rels are dropped while manual booleans persist on copy. + # Reload the body when something was stripped so the viewport + # immediately shows the unclipped geometry; otherwise the user + # sees a stale mesh until they Shift+G, which is easy to miss. + if new.is_a("IfcWall"): + if tool.Model.strip_underside_booleans(new): + tool.Model.reload_body_representation(new_obj) # Remap Blender parent relationships for duplicated objects for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items(): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index d126657e36..8882d0c65f 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -909,6 +909,48 @@ class Model(bonsai.core.tool.Model): """Return True if element has an IfcRelConnectsElements(TOP) relationship.""" return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom) + @classmethod + def strip_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> bool: + """Remove slab-trim ``IfcBooleanResult`` items from a wall's body chain. + + Returns ``True`` if any boolean was removed, so the caller knows whether + a Blender-side body reload is needed to surface the geometry change. + + Hook for the duplicate path (Shift+D): the source wall's clip booleans + don't make sense on a copy pulled away from the slab. Booleans whose + ``SecondOperand.is_a("IfcTessellatedFaceSet")`` are removed — same + imprecise discriminator the rest of the wall-to-underside machinery + uses (manual cuts authored from tessellated meshes would also be + stripped, but most manual cuts use ``IfcExtrudedAreaSolid`` / CSG + primitives and are unaffected). + + Cannot reuse ``remove_wall_to_underside_booleans`` here because the + duplicate's ``BBIM_Boolean.Data`` holds the source wall's stale ids — + ``get_manual_booleans`` returns empty on the copy and the helper + early-returns. The duplicate hook works directly off the chain. + """ + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return False + chain = cls.get_booleans(wall, representation) + to_remove = [ + b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet") + ] + for b in to_remove: + tool.Geometry.remove_representation_item(b.SecondOperand, wall) + # Sweep the now-stale BBIM_Boolean entries on the copy (their ids point + # at booleans that were never in this wall's chain — they survived the + # ifcopenshell deep copy as JSON text in the pset payload). + pset_data = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + if pset_data: + representation = tool.Geometry.get_body_representation(wall) + chain_ids = {b.id() for b in cls.get_booleans(wall, representation)} if representation else set() + stored_ids = set(json.loads(pset_data["Data"])) + stale_ids = stored_ids - chain_ids + if stale_ids: + cls.unmark_manual_booleans(wall, list(stale_ids)) + return bool(to_remove) + @classmethod def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index 02464e1625..9eb37481de 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -121,6 +121,52 @@ def test_find_rels_dedups_by_id(): assert len(rels) == 1 +# --------------------------------------------------------------------------- +# tool.Connection.find_rels_for_element — single-element entry point +# --------------------------------------------------------------------------- + + +def test_find_rels_for_element_returns_kind_and_partner_per_rel(): + """Cascade-on-delete needs every rel touching one element plus the partner + element on the other side of each rel — that's the cleanup target.""" + elem = _elem() + partner_a = _elem() + partner_b = _elem() + rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1) + rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2) + elem.ConnectedTo = [rel_path] + elem.ConnectedFrom = [rel_top] + + result = tool.Connection.find_rels_for_element(elem) + + assert (rel_path, "path", partner_a) in result + assert (rel_top, "element-top", partner_b) in result + assert len(result) == 2 + + +def test_find_rels_for_element_dedups_by_rel_id(): + elem = _elem() + partner = _elem() + rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1) + elem.ConnectedTo = [rel] + elem.ConnectedFrom = [rel] + + result = tool.Connection.find_rels_for_element(elem) + + assert len(result) == 1 + + +def test_find_rels_for_element_skips_rels_without_partner(): + """Defensive: a malformed rel missing the opposite-side attribute should not + crash — record nothing for it rather than emit a (rel, kind, None) triple + that would later trip a None-deref in the dispatch.""" + elem = _elem() + bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1) + elem.ConnectedTo = [bad] + + assert tool.Connection.find_rels_for_element(elem) == [] + + # --------------------------------------------------------------------------- # tool.Connection.find_rel — first-match convenience # --------------------------------------------------------------------------- @@ -165,15 +211,16 @@ def _make_op(*, a_guid="A", b_guid="B"): return op -def test_disconnect_path_removes_all_rels_then_recreates_walls(): +def test_disconnect_dispatches_one_call_per_rel(): + """Operator forwards every rel returned by find_rels to disconnect_rel, + in order — the operator is a thin wrapper; per-kind cleanup logic lives + in core.connection.disconnect_rel and is tested separately.""" from bonsai.bim.module.model.wall import DisconnectElements elem_a = Mock() elem_b = Mock() rel1 = Mock() rel2 = Mock() - obj_a = Mock() - obj_b = Mock() ifc_file = MagicMock() ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] @@ -181,45 +228,114 @@ def test_disconnect_path_removes_all_rels_then_recreates_walls(): with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( "bonsai.bim.module.model.wall.tool.Connection.find_rels", - return_value=[(rel1, "path"), (rel2, "path")], - ), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e] - ), patch("bonsai.bim.module.model.wall.bonsai.core.geometry.remove_connection") as remove, patch( - "bonsai.bim.module.model.wall.tool.Model.recreate_wall" - ) as recreate, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync: + return_value=[(rel1, "path"), (rel2, "element-top")], + ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() + ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): DisconnectElements._perform(op, context=MagicMock()) - assert remove.call_count == 2 - assert recreate.call_count == 2 - resync.assert_called_once_with([obj_a, obj_b]) + assert dispatch.call_count == 2 + # Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation + # — orient_element_top inside disconnect_rel recovers the wall/slab roles. + for call, expected_rel, expected_kind in zip( + dispatch.call_args_list, [rel1, rel2], ["path", "element-top"] + ): + kw = call.kwargs + assert kw["rel"] is expected_rel + assert kw["kind"] == expected_kind + assert kw["elem"] is elem_a + assert kw["partner"] is elem_b op.report.assert_not_called() -def test_disconnect_element_top_calls_regenerate(): +def test_disconnect_resyncs_path_objs_once_for_path_kind(): + """For path rels the operator collects both endpoint objects and resyncs + drafts once at the end — a Blender-side concern that doesn't belong in + the core dispatch.""" from bonsai.bim.module.model.wall import DisconnectElements - wall = Mock() - slab = Mock() - wall_obj = Mock() + elem_a = Mock() + elem_b = Mock() + obj_a = Mock() + obj_b = Mock() rel = Mock() - rel.RelatedElement = wall ifc_file = MagicMock() - ifc_file.by_guid.side_effect = lambda g: {"A": wall, "B": slab}[g] + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")] + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", + side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e], + ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ) as resync: + DisconnectElements._perform(op, context=MagicMock()) + + resync.assert_called_once_with([obj_a, obj_b]) + + +def test_disconnect_skips_resync_for_non_path_kind(): + """element-top / element kinds don't need wall-draft resync — that's a + path-specific concern (DumbWallJoiner geometry refresh).""" + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] op = _make_op() with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] - ), patch( - "bonsai.bim.module.model.wall.tool.Connection.orient_element_top", return_value=(wall, slab) - ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disc, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync: DisconnectElements._perform(op, context=MagicMock()) - disc.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) - regen.assert_called_once() - op.report.assert_not_called() + resync.assert_not_called() + + +def test_disconnect_gizmo_direction_symmetry(): + """The wall-selected gizmo dispatches with element_a=wall, element_b=slab. + The slab-selected gizmo dispatches with element_a=slab, element_b=wall. + Both routes hit disconnect_rel with the same (rel, kind) pair — orientation + recovery happens inside the dispatch, not at the operator layer.""" + from bonsai.bim.module.model.wall import DisconnectElements + + wall = Mock(name="wall") + slab = Mock(name="slab") + rel = Mock() + + ifc_file = MagicMock() + op = _make_op() + + def _run_with_guids(a, b): + ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g] + op.element_a_guid = a + op.element_b_guid = b + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): + DisconnectElements._perform(op, context=MagicMock()) + return dispatch.call_args.kwargs + + wall_first = _run_with_guids("WALL", "SLAB") + slab_first = _run_with_guids("SLAB", "WALL") + + # disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap + # by argument order but orient_element_top inside disconnect_rel resolves + # the wall/slab roles symmetrically. + assert wall_first["rel"] is rel and slab_first["rel"] is rel + assert wall_first["kind"] == slab_first["kind"] == "element-top" + assert {wall_first["elem"], wall_first["partner"]} == {wall, slab} + assert {slab_first["elem"], slab_first["partner"]} == {wall, slab} def test_disconnect_reports_on_unknown_guids(): diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index cd8371e1c3..6715fe8a94 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -60,6 +60,13 @@ def collector(): prophet.verify() +@pytest.fixture +def connection(): + prophet = Prophecy(bonsai.core.tool.Connection) + yield prophet + prophet.verify() + + @pytest.fixture def context(): prophet = Prophecy(bonsai.core.tool.Context) diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py new file mode 100644 index 0000000000..5b09bc4dfa --- /dev/null +++ b/src/bonsai/test/core/test_connection.py @@ -0,0 +1,218 @@ +# 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. + +"""Dispatch tests for ``core.connection.disconnect_rel``. + +The dispatch is the single source of truth for per-kind cleanup shared by the +explicit ``bim.disconnect_elements`` operator and the implicit cascade in +``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which +helpers must be called; the AST forward-compat guard in +``test_connection_forward_compat.py`` then asserts the dispatch table covers +every kind ``Connection.find_rels`` can emit. + +Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because +the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``) +that Prophecy's JSON call recorder can't serialize. +""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +import bonsai.core.connection as subject + + +def _rel(relating="slab", related="wall"): + return SimpleNamespace(RelatingElement=relating, RelatedElement=related) + + +def _ifc_with_objects(mapping): + ifc = Mock() + ifc.get_object.side_effect = lambda e: mapping.get(e) + ifc.run = Mock() + return ifc + + +class TestDisconnectRelPath: + def test_removes_connection_and_recreates_both_walls(self): + ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem_a", partner="elem_b", + ) + + remove.assert_called_once_with(geometry, connection="rel") + model.recreate_wall.assert_any_call("elem_a", "obj_a") + model.recreate_wall.assert_any_call("elem_b", "obj_b") + assert model.recreate_wall.call_count == 2 + + def test_skip_elem_recreate_suppresses_elem_side(self): + """Cascade case: elem is being deleted — don't recreate it.""" + ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_elem_recreate=True, + ) + + model.recreate_wall.assert_called_once_with("partner", "partner_obj") + + def test_skip_partner_recreate_suppresses_partner_side(self): + ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_partner_recreate=True, + ) + + model.recreate_wall.assert_called_once_with("elem", "elem_obj") + + def test_both_skips_means_only_remove_rel(self): + ifc = _ifc_with_objects({}) + geometry = Mock() + model = Mock() + connection = Mock() + + with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel="rel", kind="path", elem="elem", partner="partner", + skip_elem_recreate=True, + skip_partner_recreate=True, + ) + + remove.assert_called_once() + model.recreate_wall.assert_not_called() + + +class TestDisconnectRelElementTop: + def test_disconnects_then_regenerates_wall(self): + """Operator case (no skip flags): both sides survive, so the wall gets + re-clipped against currently-connected slabs.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + geometry = Mock() + model = Mock() + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, geometry, model, connection, + rel=rel, kind="element-top", elem="elem", partner="partner", + ) + + ifc.run.assert_called_once_with( + "geometry.disconnect_element", relating_element="slab", related_element="wall" + ) + regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"]) + + def test_slab_delete_cascade_still_regenerates_wall(self): + """When slab is being deleted (elem=slab), wall survives and must + re-clip against remaining connections — the cascade's main purpose.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="slab", partner="wall", + skip_elem_recreate=True, # slab is being deleted + ) + + regen.assert_called_once() + + def test_wall_delete_cascade_skips_wall_regen(self): + """When the wall itself is being deleted, regenerating its body moments + before remove_product wipes it is wasted work — skip.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="wall", partner="slab", + skip_elem_recreate=True, # wall is being deleted + ) + + regen.assert_not_called() + ifc.run.assert_called_once() # rel still removed + + def test_both_in_batch_skips_wall_regen(self): + """Batch delete of both endpoints, processing slab first: partner (wall) + also queued for deletion → skip wall regen.""" + rel = _rel() + ifc = _ifc_with_objects({"wall": "wall_obj"}) + connection = Mock() + connection.orient_element_top.return_value = ("wall", "slab") + + with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: + subject.disconnect_rel( + ifc, Mock(), Mock(), connection, + rel=rel, kind="element-top", elem="slab", partner="wall", + skip_elem_recreate=True, + skip_partner_recreate=True, # wall also in batch + ) + + regen.assert_not_called() + + +class TestDisconnectRelElement: + def test_just_removes_the_rel(self): + rel = _rel(relating="A", related="B") + ifc = Mock() + + subject.disconnect_rel( + ifc, Mock(), Mock(), Mock(), + rel=rel, kind="element", elem="elem_a", partner="elem_b", + ) + + ifc.run.assert_called_once_with( + "geometry.disconnect_element", relating_element="A", related_element="B" + ) + + +class TestDisconnectRelUnknownKind: + def test_raises_value_error(self): + with pytest.raises(ValueError, match="Unknown rel kind"): + subject.disconnect_rel( + Mock(), Mock(), Mock(), Mock(), + rel="rel", kind="bogus", elem="a", partner="b", + ) diff --git a/src/bonsai/test/tool/test_connection_forward_compat.py b/src/bonsai/test/tool/test_connection_forward_compat.py new file mode 100644 index 0000000000..af830353b3 --- /dev/null +++ b/src/bonsai/test/tool/test_connection_forward_compat.py @@ -0,0 +1,123 @@ +# 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. + +"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a +branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups. + +Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to +``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel`` +would silently regress the disconnect operator and the cascade-on-delete: a new +kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")`` +fallback, and either crash the operator or leave the cascade half-done. This +guard makes the symmetry mandatory at test time.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py" +CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py" + + +def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + raise AssertionError(f"Function {name!r} not found") + + +def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == method_name: + return child + raise AssertionError(f"Method {class_name}.{method_name} not found") + + +def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]: + """Extract every kind label this method emits. + + Looks at exactly two narrow patterns to avoid false positives from + docstrings or type-annotation strings: + + - ``_record(rel, "", …)`` — positional string at index 1, the + conventional emit shape in ``find_rels`` / ``find_rels_for_element``. + - ``kind = "" if … else ""`` and chained variants — string + literals on either branch of an ``ast.IfExp`` assigned to ``kind``. + """ + kinds: set[str] = set() + for node in ast.walk(method): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2: + arg = node.args[1] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + kinds.add(arg.value) + elif isinstance(arg, ast.IfExp): + for branch in (arg.body, arg.orelse): + if isinstance(branch, ast.Constant) and isinstance(branch.value, str): + kinds.add(branch.value) + elif isinstance(node, ast.Assign): + targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"] + if not targets or not isinstance(node.value, ast.IfExp): + continue + for branch in (node.value.body, node.value.orelse): + if isinstance(branch, ast.Constant) and isinstance(branch.value, str): + kinds.add(branch.value) + return kinds + + +def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]: + """Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches.""" + fn = _find_function(tree, "disconnect_rel") + kinds: set[str] = set() + for node in ast.walk(fn): + if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq): + left = node.left + right = node.comparators[0] + if isinstance(left, ast.Name) and left.id == "kind": + if isinstance(right, ast.Constant) and isinstance(right.value, str): + kinds.add(right.value) + return kinds + + +def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None: + tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8")) + core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8")) + + emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by( + _find_method(tool_tree, "Connection", "find_rels_for_element") + ) + handled = _kind_branches_in_disconnect_rel(core_tree) + + assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed" + + missing = emitted - handled + assert not missing, ( + f"core.connection.disconnect_rel is missing branches for kinds {missing}. " + f"Every kind returned by Connection.find_rels / find_rels_for_element " + f"must have a matching if/elif branch in the dispatch." + ) From 671217d4948300d7398e101fd8d1ba45f6b184a1 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 12 Jun 2026 12:13:35 +0200 Subject: [PATCH 09/35] Commit remainder of fixes to IfcParseExamples --- src/examples/IfcParseExamples.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/examples/IfcParseExamples.cpp b/src/examples/IfcParseExamples.cpp index 8af17f7717..c4ae63b31d 100644 --- a/src/examples/IfcParseExamples.cpp +++ b/src/examples/IfcParseExamples.cpp @@ -58,9 +58,9 @@ struct is_ifc4_or_higher> : s typedef std::map> element_properties; #ifdef SCHEMA_HAS_IfcBuildingElement -typedef IfcSchema::IfcBuildingElement element_t +typedef IfcSchema::IfcBuildingElement element_t; #else -typedef IfcSchema::IfcBuiltElement element_t +typedef IfcSchema::IfcBuiltElement element_t; #endif std::string format_string(const AttributeValue& argument) { @@ -242,7 +242,7 @@ int main(int argc, char** argv) { // we need to cast them to IfcWindows. Since these properties // are optional we need to make sure the properties are // defined for the window in question before accessing them. - example_element_type::list::ptr elements = file.instances_by_type(); + auto elements = file.instances_by_type(); std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl; From 682bd0a4f75f3abdb73405b0fd5f754f42d92315 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 12 Jun 2026 14:08:21 -0500 Subject: [PATCH 10/35] closes #6235 - Add copy toggle to CAD offset (#8168) Add a "Copy" option to bim.cad_offset. When enabled (the default) it offsets a new copy of the selected edges as before; when disabled it moves the existing edges to the offset location instead. The toggle is exposed in the CAD tool's Offset panel and the operator redo panel. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/cad/operator.py | 31 ++++++++++++++++--- src/bonsai/bonsai/bim/module/cad/prop.py | 6 ++++ src/bonsai/bonsai/bim/module/cad/workspace.py | 4 ++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py index bef1d851af..5d74857822 100644 --- a/src/bonsai/bonsai/bim/module/cad/operator.py +++ b/src/bonsai/bonsai/bim/module/cad/operator.py @@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator): class CadOffset(bpy.types.Operator): bl_idname = "bim.cad_offset" bl_label = "CAD Offset" - bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle." + bl_description = ( + "Offset selected mesh geometry at provided distance, based on the current viewport angle. " + "Creates a copy by default, or moves the existing edges if Copy is disabled." + ) bl_options = {"REGISTER", "UNDO"} distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE") + copy: bpy.props.BoolProperty( + name="Copy", + description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location", + default=True, + ) @classmethod def poll(cls, context): @@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator): rotation = Matrix.Rotation(pi / 2, 2, "Z") rotation_i = Matrix.Rotation(-pi / 2, 2, "Z") + # When not copying, the offset positions are gathered here and applied to + # the existing verts only after all loops are processed, so that the + # original coordinates are still available while computing offsets. + moved_verts = [] + # Create loops from edges loop_edges = set(edges) loops = [] @@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator): offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2) offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d()) new_vert = v1.co + offset - new_verts.append(bm.verts.new(new_vert)) else: normal = (normals[0] * self.distance).to_3d() offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal) new_vert = v1.co + offset + + if self.copy: new_verts.append(bm.verts.new(new_vert)) + else: + moved_verts.append((v1, new_vert)) processed_verts.add(v1.index) @@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator): v1 = v2 - [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - if is_closed: - bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + if self.copy: + [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + if is_closed: + bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + + # Move the existing edges to the offset location. + for vert, new_co in moved_verts: + vert.co = new_co bm.verts.index_update() bm.edges.index_update() diff --git a/src/bonsai/bonsai/bim/module/cad/prop.py b/src/bonsai/bonsai/bim/module/cad/prop.py index 7dab36df91..aee4b9d55f 100644 --- a/src/bonsai/bonsai/bim/module/cad/prop.py +++ b/src/bonsai/bonsai/bim/module/cad/prop.py @@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup): resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1) radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE") distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE") + copy: bpy.props.BoolProperty( + name="Copy", + description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location", + default=True, + ) x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE") y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE") gable_roof_edge_angle: bpy.props.FloatProperty( @@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup): resolution: int radius: float distance: float + copy: bool x: float y: float gable_roof_edge_angle: float diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py index 24fb98ba3f..270c7f70b9 100644 --- a/src/bonsai/bonsai/bim/module/cad/workspace.py +++ b/src/bonsai/bonsai/bim/module/cad/workspace.py @@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator): elif self.hotkey == "S_O": row = self.layout.row() row.prop(props, "distance") + row = self.layout.row() + row.prop(props, "copy") elif self.hotkey == "S_R": if tool.Geometry.is_profile_object_active(): @@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator): bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius) def hotkey_S_O(self): - bpy.ops.bim.cad_offset(distance=self.props.distance) + bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy) def hotkey_S_Q(self): obj = bpy.context.active_object From 55428a0878739ceb289ab6ef6bb2e6886a41f220 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Sun, 14 Jun 2026 10:56:07 +0200 Subject: [PATCH 11/35] Add wall regen helper, fillet underside, bug sweep Wall body rebuild + slab underside re-clip are now unified behind tool.Model.regenerate_wall and called from split / merge / extend operators. Fillet corner walls accept extend-to-underside (poll + operator partition switched to is_path_connectable_wall) and surface the wall-unjoin gizmo without the parametric-edit gate, since fillets cannot enter that lifecycle. DumbWallJoiner.split strips the duplicate's inherited slab-trim booleans up front so wall2 lands at the cut point. regenerate_fillet_corner_wall re-clips after the body rewrite so a prior extend-to-slab survives neighbour recalcs. Drive-by bug sweep: tuple typo in hotkey_S_G's IfcSpace check, defensive .get() in draw_regen_operations for partial AuthoringData loads, and a try/except in get_active_representation matching the existing convention for stale mesh ifc_definition_ids after a representation rebuild. Tests cover the regenerate_wall branching, the get_active_representation stale-id contract, and the GizmoWallExtendVertically fillet acceptance. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/data.py | 4 + src/bonsai/bonsai/bim/module/model/wall.py | 51 +++++++++-- .../bonsai/bim/module/model/workspace.py | 8 +- src/bonsai/bonsai/tool/geometry.py | 13 ++- src/bonsai/bonsai/tool/model.py | 13 +++ .../bim/module/model/test_regenerate_wall.py | 85 +++++++++++++++++++ .../test/bim/module/model/test_wall_gizmos.py | 53 ++++++++++-- src/bonsai/test/tool/test_geometry.py | 31 +++++++ 8 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_regenerate_wall.py diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index 10553f1bed..36cde4eb0d 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -51,6 +51,10 @@ class AuthoringData: @classmethod def load(cls, ifc_element_type: Optional[str] = None): + # ``is_loaded`` is set first as a recursion guard: one of the data + # computations evaluates a PropertyGroup enum's ``items`` callback, + # which re-enters this method. Without the guard, load recurses to + # RecursionError. cls.is_loaded = True cls.props = tool.Model.get_model_props() cls.data["default_container"] = cls.default_container() diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 33443b8fdd..1941c6fd6e 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -291,6 +291,15 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N _maybe_resync_wall_props_from_ifc(obj) +def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None: + """Rebuild every wall in ``objs`` from current IFC state — extrusion, + openings, and any underside slab clip — so the caller doesn't carry + feature-specific dispatch.""" + for obj in objs: + if obj is not None: + tool.Model.regenerate_wall(obj) + + class _CommitWallDraftsFirstMixin: """Operator mixin that flushes any in-progress wall parametric drafts in the current selection before delegating to the subclass's ``_perform``. @@ -420,7 +429,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to element = tool.Ifc.get_entity(obj) if not element: continue - if tool.Model.get_usage_type(element) == "LAYER2": + if tool.Parametric.is_path_connectable_wall(element): walls.append(obj) else: slabs.append(obj) @@ -441,7 +450,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator): wall_objs = [ obj for obj in tool.Blender.get_selected_objects() - if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2" + if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element) ] if wall_objs: core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) @@ -693,9 +702,14 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat def _perform(self, context): selected_objs = tool.Model.get_selected_mesh_objects() + post_split_walls: list[bpy.types.Object] = [] for obj in selected_objs: - DumbWallJoiner().split(obj, context.scene.cursor.location) - _resync_walls_after_mutation(selected_objs) + new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location) + post_split_walls.append(obj) + if new_obj is not None and new_obj not in post_split_walls: + post_split_walls.append(new_obj) + _resync_walls_after_mutation(post_split_walls) + _regenerate_walls(post_split_walls) return {"FINISHED"} @@ -730,6 +744,7 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat surviving_obj = next(o for o in selected_objs if o != active_obj) DumbWallJoiner().merge(surviving_obj, active_obj) _maybe_resync_wall_props_from_ifc(surviving_obj) + _regenerate_walls([surviving_obj]) return {"FINISHED"} @@ -1514,7 +1529,7 @@ class DumbWallJoiner: body = copy.deepcopy(axis1["reference"]) tool.Model.recreate_wall(element1, wall1) - def split(self, wall1: bpy.types.Object, target: Vector) -> None: + def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None": unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) element1 = tool.Ifc.get_entity(wall1) @@ -1535,6 +1550,13 @@ class DumbWallJoiner: wall2 = self.duplicate_wall(wall1) element2 = tool.Ifc.get_entity(wall2) + # The duplicate inherits wall1's slab-trim boolean chain (copied by + # copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so + # ``get_manual_booleans(element2)`` returns empty and the regenerator + # rebuilds wall2's body without those clips. Strip them up front so + # wall2 starts clean before the axis + placement reshape. + tool.Model.strip_underside_booleans(element2) + # Get the ATEND connection from wall1 to use it in wall2 relating_element = None connections = element1.ConnectedTo @@ -1634,6 +1656,7 @@ class DumbWallJoiner: tool.Model.recreate_wall(element1, wall1) tool.Model.recreate_wall(element2, wall2) + return wall2 def flip(self, wall1: bpy.types.Object) -> None: if tool.Ifc.is_moved(wall1): @@ -2509,7 +2532,9 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator): tool.Model, context.scene.cursor.location, ) - _resync_walls_after_mutation(tool.Blender.get_selected_objects()) + affected = list(tool.Blender.get_selected_objects()) + _resync_walls_after_mutation(affected) + _regenerate_walls(affected) return {"FINISHED"} @@ -2542,6 +2567,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator): with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): bpy.ops.bim.change_extrusion_depth(depth=new_height) _maybe_resync_wall_props_from_ifc(obj) + _regenerate_walls([obj]) return {"FINISHED"} @@ -3168,6 +3194,12 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp # the banana body. If a neighbour moved, the new placement follows; if # neither moved, the new matrix equals the old within floating-point noise. _apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj) + # The body rebuild swaps the wall's representation, so any prior underside + # clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab + # applied to a fillet wall isn't silently wiped on the next neighbour + # recalc, ChangeExtrusionDepth, or split / merge call site. + if tool.Model.has_underside_connection(element): + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj]) class EnableWallFilletPreview(bpy.types.Operator): @@ -3640,7 +3672,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin return False other = next(o for o in selected if o is not active) other_element = tool.Ifc.get_entity(other) - if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2": + if not other_element or not tool.Parametric.is_path_connectable_wall(other_element): return False return True @@ -3950,6 +3982,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix element = tool.Ifc.get_entity(active) if not element or not tool.Parametric.is_path_connectable_wall(element): return False + # Fillet-corner walls have no LAYER2 usage and cannot enter the + # parametric edit lifecycle, so the ``is_editing`` gate is bypassed + # for them — otherwise their connection icons would never surface. + if tool.Parametric.is_fillet_corner_wall(element): + return True props = tool.Model.get_wall_props(active) if not props.is_editing: return False diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index e44dabd916..abe4f45113 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -963,7 +963,11 @@ class EditObjectUI: @classmethod def draw_regen_operations(cls, row, ui_context): - if AuthoringData.data["is_regenable_element"]: + # ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion + # guard, so a partial load (any computation along the way raising) + # leaves the tail keys unset. ``.get()`` keeps the header draw alive + # until the underlying failure is investigated. + if AuthoringData.data.get("is_regenable_element"): row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context) @@ -1317,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.recalculate_profile() elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"): bpy.ops.bim.recalculate_fill() - elif self.active_class in ("IfcSpace"): + elif self.active_class in ("IfcSpace",): bpy.ops.bim.generate_space() def hotkey_S_M(self): diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index e7de9c1f6a..14e4c5cfcc 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -668,7 +668,13 @@ class Geometry(bonsai.core.tool.Geometry): and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) ): - return tool.Ifc.get().by_id(ifc_id) + try: + return tool.Ifc.get().by_id(ifc_id) + except RuntimeError: + # Stale id: a representation rebuild freed the old entity + # while obj.data still tracks its id. Treated as "no active + # representation" — same contract as a mesh with id 0. + return None @classmethod def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None: @@ -2385,6 +2391,11 @@ class Geometry(bonsai.core.tool.Geometry): if new.is_a("IfcWall"): if tool.Model.strip_underside_booleans(new): tool.Model.reload_body_representation(new_obj) + # HasOpenings rels don't follow object duplication, so + # the duplicate's body must rebuild to match its current + # opening set. + else: + tool.Model.regenerate_wall(new_obj) # Remap Blender parent relationships for duplicated objects for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items(): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 8882d0c65f..87d8fd4210 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -3071,6 +3071,19 @@ class Model(bonsai.core.tool.Model): obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix) tool.Geometry.record_object_position(obj) + @classmethod + def regenerate_wall(cls, obj: bpy.types.Object) -> None: + """Rebuild a wall's body from current IFC state: extrusion + openings + first, then re-clip to any surviving ``IfcRelConnectsElements(TOP)`` + slab. Safe on walls with no openings and no slab connection — both + steps no-op against their preconditions.""" + element = tool.Ifc.get_entity(obj) + if element is None: + return + cls.recreate_wall(element, obj) + if cls.has_underside_connection(element): + bonsai.core.model.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, cls, [obj]) + @classmethod def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None: queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() diff --git a/src/bonsai/test/bim/module/model/test_regenerate_wall.py b/src/bonsai/test/bim/module/model/test_regenerate_wall.py new file mode 100644 index 0000000000..68ca4bcdf9 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_regenerate_wall.py @@ -0,0 +1,85 @@ +# 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. + +"""Pins the branching contract of ``tool.Model.regenerate_wall``. + +The body rebuild always runs (extrusion + openings); the slab re-clip only +runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without +either feature still completes without crashing.""" + +from unittest.mock import Mock, patch + +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def test_regenerate_wall_rebuilds_body_and_reclips_when_connected(): + """Wall with a TOP connection: body rebuilt first, then re-clipped.""" + element = Mock() + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_called_once_with(element, obj) + regen.assert_called_once() + args, _ = regen.call_args + assert args[3] == [obj] + + +def test_regenerate_wall_skips_reclip_when_no_top_rel(): + """Wall without a TOP connection: body rebuilt; re-clip skipped.""" + element = Mock() + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_called_once_with(element, obj) + regen.assert_not_called() + + +def test_regenerate_wall_noops_when_obj_has_no_ifc_entity(): + """Non-IFC objects (e.g. a freshly created Blender mesh before + `tool.Ifc.run("root.create_entity")` runs) return None from get_entity; + the helper must return without touching the body or any rels.""" + obj = Mock() + + with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object( + tool.Model, "recreate_wall" + ) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch( + "bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside" + ) as regen: + tool.Model.regenerate_wall(obj) + + recreate.assert_not_called() + has_top.assert_not_called() + regen.assert_not_called() diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py index c3b97e9466..4bdd78ed4a 100644 --- a/src/bonsai/test/bim/module/model/test_wall_gizmos.py +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py @@ -50,12 +50,15 @@ def _make_context(active, selected): return SimpleNamespace(active_object=active, selected_objects=list(selected)) -def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage): +def _patch_tools( + prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None +): """Return a stack of patches that simulate one selection / IFC state for poll(). ``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The - selection set, the IFC entity lookup, and the usage-type lookup are stubbed - so the test only depends on the predicate ordering in poll().""" + selection set, the IFC entity lookup, the usage-type lookup, and the + path-connectable-wall predicate are stubbed so the test only depends on + the predicate ordering in poll().""" prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on)) entity_map = {} @@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage usage_map[id(active_element)] = active_usage usage_map[id(other_element)] = other_usage + if other_is_path_connectable is None: + other_is_path_connectable = other_usage == "LAYER2" + def get_entity(obj): return entity_map.get(id(obj)) def get_usage_type(element): return usage_map.get(id(element)) + def is_path_connectable_wall(element): + return element is other_element and other_is_path_connectable + from bonsai import tool return [ @@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)), patch.object(tool.Ifc, "get_entity", side_effect=get_entity), patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type), + patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall), # The array-child filter is pinned by its own test file; stub it here # so these poll tests stay focused on the count / layer-usage gates # and don't have to scaffold the memoization cache key. @@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage ] -def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True): +def _run_poll( + prefs_on, + active_is_in_selected, + len_override, + active_usage, + other_usage, + active_has_entity=True, + other_is_path_connectable=None, +): from bonsai.bim.module.model.wall import GizmoWallExtendVertically slab_obj = _Obj("slab") @@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other slab_element = object() if active_has_entity else None wall_element = object() - patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage) + patches = _patch_tools( + prefs_on, + selected, + slab_element, + wall_element, + active_usage, + other_usage, + other_is_path_connectable=other_is_path_connectable, + ) for p in patches: p.start() try: @@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall(): ) +def test_poll_accepts_fillet_corner_wall_partner(): + # Fillet-corner walls carry no LAYER2 usage by spec but the extend-to- + # underside operator handles them just like a parametric LAYER2 wall — + # the gizmo must surface for the slab + fillet-corner selection too. + assert ( + _run_poll( + prefs_on=True, + active_is_in_selected=True, + len_override=None, + active_usage="LAYER3", + other_usage=None, + other_is_path_connectable=True, + ) + is True + ) + + # ---------------------------------------------------------------------------- # _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk # ---------------------------------------------------------------------------- diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py index 424e1fd876..5683ce03de 100644 --- a/src/bonsai/test/tool/test_geometry.py +++ b/src/bonsai/test/tool/test_geometry.py @@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile): assert subject.get_representation_data(representation) == data +class TestGetActiveRepresentation(NewFile): + def test_returns_representation_for_live_id(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + representation = ifc.createIfcShapeRepresentation() + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new("Object", mesh) + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id() + assert subject.get_active_representation(obj) == representation + + def test_returns_none_when_mesh_has_no_id(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh")) + assert subject.get_active_representation(obj) is None + + def test_returns_none_when_id_is_stale(self): + """A representation rebuild can free the old entity while obj.data + still tracks its id. Returning ``None`` keeps every UI redraw alive + instead of spamming ``RuntimeError`` from the by_id lookup.""" + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + representation = ifc.createIfcShapeRepresentation() + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new("Object", mesh) + stale_id = representation.id() + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id + ifc.remove(representation) + assert subject.get_active_representation(obj) is None + + class TestGetRepresentationId(NewFile): def test_run(self): ifc = ifcopenshell.file() From ca99ef3af79b02dbb1569fcb10767e7c8a549d59 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 14 Jun 2026 14:49:14 +0200 Subject: [PATCH 12/35] More changes to pass around logger to parse-related calls --- .../ifcopenshell/__init__.py | 31 +++-- src/ifcopenshell-python/ifcopenshell/draw.py | 7 +- .../ifcopenshell/geom/main.py | 21 ++- src/ifcparse/IfcLogger.cpp | 28 +++- src/ifcparse/IfcLogger.h | 26 ++-- src/ifcwrap/IfcGeomWrapper.i | 60 ++++----- src/ifcwrap/IfcParseWrapper.i | 9 +- src/svgfill/CMakeLists.txt | 2 +- src/svgfill/src/arrange_polygons.cpp | 120 ++++++++++++------ src/svgfill/src/svgfill.h | 4 +- 10 files changed, 203 insertions(+), 105 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 2c93e8175e..c50ff343e9 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -132,10 +132,20 @@ class SchemaError(Error): @overload def open( - path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False + path: Union[os.PathLike, str], + format: SupportedFormat = None, + *, + should_stream: Literal[False] = False, + logger: Optional[logger] = None, ) -> Union[_file, sqlite]: ... @overload -def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ... +def open( + path: Union[os.PathLike, str], + format: SupportedFormat = None, + *, + should_stream: Literal[True], + logger: Optional[logger] = None, +) -> _stream: ... @overload def open( path: Union[os.PathLike, str], @@ -143,6 +153,7 @@ def open( *, should_stream: bool = False, readonly: bool = False, + logger: Optional[logger] = None, ) -> Union[_file, sqlite, _stream]: ... def open( path: Union[os.PathLike, str], @@ -151,11 +162,13 @@ def open( readonly: bool = False, mmap: bool = False, bypass_types: Optional[Sequence[str]] = None, + logger: Optional[logger] = None, ) -> Union[_file, sqlite, _stream]: """Loads an IFC dataset from a filepath :param should_stream: Whether to open the file in streaming mode. Could be useful for reading large files. + :param logger: Logger that receives native parser messages. You can specify a file format. If no format is given, it is guessed from its extension. @@ -179,8 +192,10 @@ def open( raise FileNotFoundError(f"Path does not exist: '{path}'.") if format is None: format = guess_format(path) + if logger is None: + logger = ifcopenshell_wrapper.logger.Root() if format == ".ifcXML": - f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute())) + f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), logger) if f: return file(f) raise OSError(f"Failed to parse .ifcXML file from {path}") @@ -189,7 +204,7 @@ def open( with zipfile.ZipFile(path) as zf: for name in zf.namelist(): if Path(name).suffix.lower() in (".ifc", ".ifcxml"): - return open(zf.extract(name, unzipped_path)) + return open(zf.extract(name, unzipped_path), logger=logger) else: raise LookupError(f"No .ifc or .ifcXML file found in {path}") if format == ".ifcSQLite": @@ -197,9 +212,9 @@ def open( if should_stream: return stream(path) if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux. - f = ifcopenshell_wrapper.open(str(path.absolute()), readonly=readonly) + f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, logger) elif bypass_types: - f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag()) + f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag(), logger) for ty in bypass_types: f.bypass_type(ty) if mmap: @@ -209,9 +224,9 @@ def open( f.initialize(str(path.absolute())) elif mmap: # mmap parameter is only available for builds with USE_MMAP, not used in our main builds - f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument] + f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap, logger=logger) # ty: ignore[unknown-argument] else: - f = ifcopenshell_wrapper.open(str(path.absolute())) + f = ifcopenshell_wrapper.open(str(path.absolute()), False, logger) return file(f) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index f4ea932933..b7bc4db365 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -105,7 +105,10 @@ def main( iterators: Sequence[ifcopenshell.geom.iterator] = (), merge_projection: bool = True, progress_function: Callable = DO_NOTHING, + logger=None, ): + if logger is None: + logger = ifcopenshell.logger.Root() def by_guid(g): for f in files: @@ -147,7 +150,7 @@ def main( iterator_kwargs["include"] = list( filter(has_selected_parent, sum((f.by_type(x) for x in iterator_kwargs["include"]), [])) ) - return ifcopenshell.geom.iterator(geom_settings, f, **iterator_kwargs) + return ifcopenshell.geom.iterator(geom_settings, f, logger=logger, **iterator_kwargs) # We have to keep the iterator in memory because otherwise # the styles are cleared up. @@ -458,7 +461,6 @@ def main( g1.appendChild(g2) if settings.arrange_spaces or settings.arrange_zones: - if settings.storey_filter: # delete storey groups not selected by filter # sometimes happens in case of elements protruding multiple stories @@ -541,6 +543,7 @@ def main( arranged = W.arrange_polygons( *filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies, # ty: ignore[too-many-positional-arguments] + logger, ) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 574d19d904..8161512836 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -299,13 +299,16 @@ class iterator(ifcopenshell_wrapper.Iterator): include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ): self.settings = settings + if logger is None: + logger = ifcopenshell_wrapper.logger.Root() if isinstance(file_or_filename, file): self.file = file file_or_filename = file_or_filename.wrapped_data else: - file_or_filename = self.file = open(file_or_filename) + file_or_filename = self.file = open(file_or_filename, logger=logger) if include is not None and exclude is not None: raise ValueError("include and exclude cannot be specified simultaneously") @@ -334,11 +337,17 @@ class iterator(ifcopenshell_wrapper.Iterator): initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude self.this = initializer( - geometry_library, self.settings, file_or_filename, include_or_exclude, include is not None, num_threads + geometry_library, + self.settings, + file_or_filename, + include_or_exclude, + include is not None, + num_threads, + logger, ) else: self.this = ifcopenshell_wrapper.construct_iterator( - geometry_library, self.settings, file_or_filename, num_threads + geometry_library, self.settings, file_or_filename, num_threads, logger ) if has_occ: @@ -564,6 +573,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[IteratorOutput, None, None]: ... @overload def iterate( @@ -577,6 +587,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[tuple[int, IteratorOutput], None, None]: ... @overload def iterate( @@ -590,6 +601,7 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: ... def iterate( settings: settings, @@ -602,13 +614,14 @@ def iterate( cache: Optional[str] = None, serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", + logger=None, ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: """Get a geometry iterator for the provided file. :param cache: .h5 cache filepath (might not exist, will be created). :param serializer_settings: Settings for cache serializer. Required if `cache` is provided. """ - it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library) + it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library, logger) if cache: assert serializer_settings, "`serializer_settings` argument is not optional if `cache` is provided." hdf5_cache = serializers.hdf5(cache, settings, serializer_settings) diff --git a/src/ifcparse/IfcLogger.cpp b/src/ifcparse/IfcLogger.cpp index 0b454b8563..200d61e636 100644 --- a/src/ifcparse/IfcLogger.cpp +++ b/src/ifcparse/IfcLogger.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -131,6 +132,31 @@ void json_message(T& out, const IfcUtil::IfcBaseClass* current_product, Logger:: } } // namespace +log_message::log_message( + int severity, + const char (&code_prefix)[4], + uint16_t code_number, + const std::string& timestamp, + const std::string& message, + const IfcUtil::IfcBaseInterface* inst, + const IfcUtil::IfcBaseClass* current_product) + : severity(severity) + , timestamp(timestamp) + , message(message) +{ + snprintf(code, 7, "%s%03u", code_prefix, code_number); + if (inst) { + std::ostringstream oss; + inst->as()->toString(oss); + instance = oss.str(); + } + if (current_product) { + std::ostringstream oss; + current_product->toString(oss); + product = oss.str(); + } +} + Logger& Logger::Root() { static Logger logger; return logger; @@ -199,7 +225,7 @@ void Logger::Message(Logger::Severity type, const char (&code_prefix)[4], uint16 } if (format_ == FMT_INMEMORY) { - log_messages_.emplace_back(type, code_prefix, code_number, message, instance, current_product()); + log_messages_.emplace_back(type, code_prefix, code_number, get_time(), message, instance, current_product()); } else if (((log2_ != nullptr) || (wlog2_ != nullptr))) { if (format_ == FMT_PLAIN) { if (log2_ != nullptr) { diff --git a/src/ifcparse/IfcLogger.h b/src/ifcparse/IfcLogger.h index 19fcdd9738..280cfcd036 100644 --- a/src/ifcparse/IfcLogger.h +++ b/src/ifcparse/IfcLogger.h @@ -37,24 +37,16 @@ class IFC_PARSE_API log_message { public: char code[7]; int severity; - std::string message, instance, product; + std::string timestamp, message, instance, product; - log_message(int severity, const char (&code_prefix)[4], uint16_t code_number, const std::string& message, const IfcUtil::IfcBaseInterface* inst = 0, const IfcUtil::IfcBaseClass* current_product = 0) - : severity(severity) - , message(message) - { - snprintf(code, 7, "%s%03u", code_prefix, code_number); - if (inst) { - std::ostringstream oss; - inst->data().toString(nullptr, nullptr, 0, oss, true); - instance = oss.str(); - } - if (current_product) { - std::ostringstream oss; - current_product->toString(oss); - product = oss.str(); - } - } + log_message( + int severity, + const char (&code_prefix)[4], + uint16_t code_number, + const std::string& timestamp, + const std::string& message, + const IfcUtil::IfcBaseInterface* inst = 0, + const IfcUtil::IfcBaseClass* current_product = 0); }; class IFC_PARSE_API Logger { diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index a9cf372dc1..10d9083403 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -637,31 +637,31 @@ struct ShapeRTTI : public boost::static_visitor // I couldn't get the vector typemap to be applied when %extending Iterator constructor. // anyway it does not matter as SWIG generates C code without actual constructors %inline %{ - IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads) { - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::entity_filter ef{ include, false, elems_set }; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {ef}, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::attribute_filter af; - af.attribute_name = "GlobalId"; - af.populate(elems_set); - af.include = include; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads); - } - - IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads) { - std::set elems_set(elems.begin(), elems.end()); - IfcGeom::instance_id_filter af(include, false, elems_set); - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads); - } -%} + IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root()) { + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::entity_filter ef{ include, false, elems_set }; + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {ef}, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::attribute_filter af; + af.attribute_name = "GlobalId"; + af.populate(elems_set); + af.include = include; + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + } + + IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + std::set elems_set(elems.begin(), elems.end()); + IfcGeom::instance_id_filter af(include, false, elems_set); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + } +%} %extend IfcGeom::Representation::Triangulation { @@ -1288,11 +1288,11 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { - std::vector r; - if (svgfill::arrange_polygons(settings, polygons, r)) { - return r; - } else { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons, Logger& logger = Logger::Root()) { + std::vector r; + if (svgfill::arrange_polygons(settings, polygons, r, logger)) { + return r; + } else { throw std::runtime_error("Failed to arrange polygons"); } } diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index ab750543ef..9826c03da2 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -712,10 +712,10 @@ private: %newobject stream_from_string; %inline %{ - IfcParse::IfcFile* open(const std::string& fn, bool readonly=false) { + IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger& logger=Logger::Root()) { IfcParse::IfcFile* f; Py_BEGIN_ALLOW_THREADS; - f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly); + f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger); Py_END_ALLOW_THREADS; return f; } @@ -1238,8 +1238,11 @@ private: } %pythoncode %{ severity_string = property(severity_string) + def to_dict(self): + keys = ("timestamp", "severity", "code", "message", "instance", "product") + return dict(zip(keys, self.to_tuple())) def to_tuple(self): - return self.severity_string, self.code, self.message, self.instance + return self.timestamp, self.severity_string, self.code, self.message, self.instance, self.product def __eq__(self, other): return type(self) == type(other) and self.to_tuple() == other.to_tuple() def __hash__(self): diff --git a/src/svgfill/CMakeLists.txt b/src/svgfill/CMakeLists.txt index 0d9764013a..ea4e64636f 100644 --- a/src/svgfill/CMakeLists.txt +++ b/src/svgfill/CMakeLists.txt @@ -49,7 +49,7 @@ file(GLOB LIB_H_FILES src/*.h) file(GLOB LIB_CPP_FILES src/svgfill.cpp src/arrange_polygons.cpp) set(LIB_SRC_FILES ${LIB_H_FILES} ${LIB_CPP_FILES}) add_library(svgfill ${LIB_SRC_FILES}) -target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL) +target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL IfcParse) set_target_properties(svgfill PROPERTIES PUBLIC_HEADER "${LIB_H_FILES}") add_executable(svgfill_exe src/main.cpp) diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 260b22809e..5a99125adb 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -5,6 +5,8 @@ #include "svgfill.h" #endif +#include "../../ifcparse/IfcLogger.h" + #include #include #include @@ -1602,7 +1604,8 @@ std::map> snap_points_to_box_axes( DebugWriter& debug, const CenterLineGraphData& graph, const std::vector& boxes, - const K::FT& max_projection_distance) { + const K::FT& max_projection_distance, + Logger& logger) { std::vector snapped_points(graph.points.size()); for (size_t i = 0; i < graph.points.size(); ++i) { @@ -1685,7 +1688,11 @@ std::map> snap_points_to_box_axes( debug.write_segment(graph.points[i], best.projection, "snap_candidate_4"); } else { snapped_points[i] = graph.points[i]; - std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl; + std::ostringstream message; + message << "Snapping distance exceeds maximum distance: " + << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) + << " > " << max_projection_distance; + logger.Message(Logger::LOG_WARNING, "ARR", 1, message.str()); } } @@ -1711,7 +1718,8 @@ Graph2D join_segment_runs( DebugWriter& debug, const std::map>& line_graph, const std::map>& midpoint_to_segment, - const K::FT& max_projection_distance) { + const K::FT& max_projection_distance, + Logger& logger) { auto graph = make_center_line_graph_data(line_graph, midpoint_to_segment); auto runs = runs_from_graph(graph); runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) { @@ -1742,7 +1750,7 @@ Graph2D join_segment_runs( } debug.write_polygons(run_polygons, "merged_boxes"); - auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance); + auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance, logger); return Graph2D(snapped_graph); } @@ -2239,7 +2247,9 @@ extend_end_vertices_based_on_input_simple( DebugWriter& debug_output, const Graph2D& G, const Polygon_list& outer_perimiter, - const K::FT& max_projection_distance, int pass) + const K::FT& max_projection_distance, + int pass, + Logger& logger) { auto max_intersection_distance = max_projection_distance / 4; @@ -2389,9 +2399,9 @@ extend_end_vertices_based_on_input_simple( } } if (within_any_perimeter) { - std::cout << "Within boundary but still no solution given" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 2, "Within boundary but no projection or intersection solution was found"); } else { - std::cout << "Outside of all boundaries" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 3, "Point is outside all boundaries"); } return boost::optional{}; }; @@ -2404,13 +2414,18 @@ extend_end_vertices_based_on_input_simple( auto& M = it->first; if (auto result = process_point(M, *it->second.begin())) { if (*result == M) { - std::cout << "Point already on perimeter (" << M.x() << " " << M.y() << ")" << std::endl; + std::ostringstream message; + message << "Point is already on perimeter (" << M.x() << " " << M.y() << ")"; + logger.Message(Logger::LOG_NOTICE, "ARR", 4, message.str()); continue; } auto d = (M - *result).squared_length(); solutions.emplace_back(d, M, *it->second.begin()); } else { - std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 1] (" << M.x() << " " << M.y() << ")" << std::endl; + std::ostringstream message; + message << "Unable to find projection or intersection point for interior boundary pass " + << pass << " [round 1] (" << M.x() << " " << M.y() << ")"; + logger.Message(Logger::LOG_WARNING, "ARR", 5, message.str()); } } } @@ -2424,12 +2439,17 @@ extend_end_vertices_based_on_input_simple( debug_output.write_segment(point, *result, "exterior_constructed_segment"); auto d = CGAL::squared_distance(point, *result); - std::cout << "Distance: " << std::sqrt(CGAL::to_double(d)) << std::endl; + std::ostringstream message; + message << "Projection or intersection distance: " << std::sqrt(CGAL::to_double(d)); + logger.Message(Logger::LOG_DEBUG, "ARR", 6, message.str()); validation_segments.emplace_back(to_3d(point), to_3d(*result)); auto inserted_it = std::prev(validation_segments.end()); validation_tree.insert(inserted_it, validation_segments.end()); } else { - std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 2] (" << point.x() << " " << point.y() << ")" << std::endl; + std::ostringstream message; + message << "Unable to find projection or intersection point for interior boundary pass " + << pass << " [round 2] (" << point.x() << " " << point.y() << ")"; + logger.Message(Logger::LOG_WARNING, "ARR", 7, message.str()); } } @@ -2528,7 +2548,7 @@ class Segment_2_less { } }; -std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) { +std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right, Logger& logger) { using Walk_pl = CGAL::Arr_walk_along_line_point_location; Walk_pl walk_pl(right); @@ -2610,7 +2630,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 if (visited_faces_on_right.count(*v) > 0) { // Maybe we should be more permissive, try some other points etc. return_values.push_back(0); - std::cout << "Already visited face on right, skipping point\n"; + logger.Message(Logger::LOG_WARNING, "ARR", 8, "Already visited face on right; skipping point"); } else { // convert arr facet to polygon with holes auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); @@ -2648,7 +2668,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()}; } } else { - std::cout << "No intersection, skipping point\n"; + logger.Message(Logger::LOG_WARNING, "ARR", 9, "No intersection; skipping point"); return_values.push_back(0); } } @@ -2670,7 +2690,7 @@ std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2 return return_values; } -void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { +void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold, Logger& logger) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -2886,7 +2906,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo } } if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 10, "Unable to locate edge for removal; skipping"); } } @@ -2971,7 +2991,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo #else auto arr_copy = arr; process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); - auto ious = arrangement_cell_iou(arr, arr_copy); + auto ious = arrangement_cell_iou(debug_output, arr, arr_copy, logger); for (auto& iou : ious) { std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; } @@ -3303,28 +3323,36 @@ class timer { public: class entry { public: - entry() {} + entry() : logger_(nullptr) {} - entry(std::map::const_iterator start_it) - : start_it(start_it) {} + entry( + std::map::const_iterator start_it, + Logger& logger) + : start_it(start_it) + , logger_(&logger) {} void stop() { - if (start_it) { + if (start_it && logger_) { auto end = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration(end - start_it.value()->second).count(); - std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; + std::ostringstream message; + message << "Timing for " << start_it.value()->first << ": " << duration << " ms"; + logger_->Message(Logger::LOG_PERF, "ARR", 11, message.str()); } } private: std::optional::const_iterator> start_it; + Logger* logger_; }; - timer(bool enabled = true) : enabled_(enabled) {} + timer(Logger& logger, bool enabled = true) + : logger_(logger) + , enabled_(enabled) {} entry start(const std::string& name) { if (enabled_) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first, logger_); } else { return entry(); } @@ -3336,6 +3364,7 @@ class timer { std::chrono::high_resolution_clock::time_point> timings_; + Logger& logger_; bool enabled_; }; @@ -3351,7 +3380,12 @@ size_t delete_same_facet_edge_pairs(Arrangement_2& arr) { return n_deleted; } -void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +void arrange_cgal_polygons( + svgfill::arrange_polygon_settings settings, + const std::vector& input_polygons_, + std::vector& output_polygons, + Logger& logger, + double polygon_offset_distance = -1.) { static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied @@ -3371,7 +3405,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output = DebugWriter(false, ""); } - timer timer(settings.debug_output); + timer timer(logger, settings.debug_output); auto t0 = timer.start("input"); @@ -3578,7 +3612,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std for (int i = 0; i < 2; ++i) { auto it = line_graph.find(e.first); if (it == line_graph.end()) { - std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl; + logger.Message(Logger::LOG_WARNING, "ARR", 12, "Unable to locate vertex for elimination; skipping"); continue; } auto& neighbours = it->second; @@ -3607,7 +3641,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std }; if (settings.line_cleaning_algo == 0) { - G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4); + G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4, logger); Arrangement_2 arr; G.to_arrangement(arr); Graph2D G2; @@ -3629,8 +3663,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std bool fallback_to_line_cleaning_algo_1 = false; if (settings.line_cleaning_algo == 0) { - segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0); - segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1); + segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0, logger); + segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1, logger); Arrangement_2 arr_clean; G.to_arrangement(arr_clean); @@ -3668,7 +3702,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output.write_polygons(arr_clean, "iou_left"); debug_output.write_polygons(arr_orig, "iou_right"); - auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig); + auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig, logger); /* for (auto& iou : ious) { std::cout << " " << CGAL::to_double(iou - 1); @@ -3679,7 +3713,10 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std auto it = std::min_element(ious.begin(), ious.end()); if (it != ious.end() && (*it < 0.45)) { - std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl; + std::ostringstream message; + message << "Significant difference between cleaned and original arrangement; using original for topology reconstruction: " + << *it; + logger.Message(Logger::LOG_WARNING, "ARR", 13, message.str()); fallback_to_line_cleaning_algo_1 = true; apply_line_cleaning_algo_1(); } else { @@ -3754,7 +3791,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std if (settings.perform_cleanup && settings.line_cleaning_algo != 0) { remove_colinear_vertices(arr); double threshold; - clean_noisy_paths(debug_output, arr, segment_lookup, threshold); + clean_noisy_paths(debug_output, arr, segment_lookup, threshold, logger); remove_colinear_vertices(arr); // clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); } @@ -3773,7 +3810,11 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { +bool svgfill::arrange_polygons( + arrange_polygon_settings settings, + const std::vector& polygons, + std::vector& arranged, + Logger& logger) { std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -3782,7 +3823,7 @@ bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vec }); return result; }); - arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out, logger); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -3812,6 +3853,9 @@ Polygon_2 create_rectangle(T x_min, T y_min, T x_max, T y_max) { int main(int argc, char** argv) { std::vector input_polygons, output; + Logger logger; + logger.SetOutput(&std::cout, &std::cerr); + logger.Verbosity(Logger::LOG_PERF); if (argc == 2) { using json = nlohmann::json; @@ -3820,7 +3864,7 @@ int main(int argc, char** argv) { file >> jsonData; size_t i = 0; for (const auto& item : jsonData.items()) { - std::cout << "i " << i << std::endl; + logger.Message(Logger::LOG_NOTICE, "ARR", 14, "Processing arrangement " + std::to_string(i)); i++; input_polygons.clear(); const auto& polygonsData = item.value(); @@ -3832,7 +3876,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger); break; } return 0; @@ -3845,7 +3889,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger); return 0; } diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index 2588636a8d..5bc3fa39e9 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -40,6 +40,8 @@ #include #include +class Logger; + namespace svgfill { typedef std::array point_2; typedef std::array line_segment_2; @@ -133,7 +135,7 @@ namespace svgfill { double subdivision_factor = 16.; }; - SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged); + SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged, Logger& logger); } #endif From 3e7b739d8d2f05767803536475dc5224772d559d Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 14 Jun 2026 20:27:49 +0200 Subject: [PATCH 13/35] Don't rely on typeid() naming in VariantArray --- src/ifcparse/IfcEntityInstanceData.cpp | 17 +++++- src/ifcparse/IfcEntityInstanceData.h | 77 ++++++++++++++++++++++++++ src/ifcparse/variantarray.h | 52 +++++++++++------ 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/ifcparse/IfcEntityInstanceData.cpp b/src/ifcparse/IfcEntityInstanceData.cpp index e87c429545..88aa766086 100644 --- a/src/ifcparse/IfcEntityInstanceData.cpp +++ b/src/ifcparse/IfcEntityInstanceData.cpp @@ -34,7 +34,22 @@ namespace { inline T dispatch_get_(AttributeValue::pointer_type array_, uint8_t storage_model_, size_t instance_name_, const IfcParse::declaration* entity_or_type, uint8_t index_) { if (storage_model_ == 0) { - return array_.storage_ptr->get(index_); + try { + return array_.storage_ptr->get(index_); + } catch (const impl::storage_type_mismatch& e) { + throw IfcParse::IfcException( + // entity_or_type not passed, but in v0.9 this is beginning to make sense + (entity_or_type + ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ") + : std::string("")) + + "Requested type <" + e.requested() + "> does not match actual type <" + e.actual() + "> at index " + std::to_string(index_)); + } catch (const std::out_of_range& e) { + throw IfcParse::IfcException( + (entity_or_type + ? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ") + : std::string("")) + + e.what()); + } } #ifdef IFOPSH_WITH_ROCKSDB else { diff --git a/src/ifcparse/IfcEntityInstanceData.h b/src/ifcparse/IfcEntityInstanceData.h index f51345a25e..237ddb0d35 100644 --- a/src/ifcparse/IfcEntityInstanceData.h +++ b/src/ifcparse/IfcEntityInstanceData.h @@ -72,6 +72,83 @@ class IFC_PARSE_API Derived {}; class IFC_PARSE_API empty_aggregate_t {}; class IFC_PARSE_API empty_aggregate_of_aggregate_t {}; +namespace impl { + template <> + struct VariantTypeName { + static std::string get() { return "null"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "derived"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "int"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "bool"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "logical"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "real"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "string"; } + }; + + template <> + struct VariantTypeName> { + static std::string get() { return "binary"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "enumeration"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "instance"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate"; } + }; + + template + struct VariantTypeName> { + static std::string get() { return "aggregate of " + VariantTypeName::get(); } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of instance"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of aggregate"; } + }; + + template <> + struct VariantTypeName { + static std::string get() { return "aggregate of aggregate of instance"; } + }; +} + template struct parameter_pack { static constexpr size_t size = sizeof...(Args); diff --git a/src/ifcparse/variantarray.h b/src/ifcparse/variantarray.h index 1b00774bf7..b3fcb481b2 100644 --- a/src/ifcparse/variantarray.h +++ b/src/ifcparse/variantarray.h @@ -37,10 +37,28 @@ variant - which is the maximum size of its constituents - is reduced. #include #include #include - -#include "IfcException.h" +#include namespace impl { + class storage_type_mismatch : public std::exception { + private: + std::string requested_, actual__, message_; + + public: + storage_type_mismatch(const std::string& requested, const std::string& actual) + : requested_(requested), actual__(actual), message_("Requested type " + requested_ + " does not match actual type " + actual__) {} + + const char* what() const noexcept override { + return message_.c_str(); + } + + const std::string& requested() const { return requested_; } + const std::string& actual() const { return actual__; } + }; + + template + struct VariantTypeName; + // Trait to detect unique_ptr template struct is_unique_ptr : std::false_type {}; template @@ -166,14 +184,13 @@ public: using U = std::decay_t; static_assert(::impl::TypeIndex_v < sizeof...(Types), "Type not supported by variant"); if (index >= size()) { - throw std::out_of_range("Index out of range"); + throw std::out_of_range("Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())); } destroy_at_index(index); size_and_indices_[index + 1] = ::impl::TypeIndex_v; using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type; - // std::wcout << "setting " << index << " to " << typeid(V).name() << " (" << ::impl::TypeIndex_v << ")" << std::endl; if constexpr (::impl::is_unique_ptr::value) { new(&storage_[index]) V(new U(value)); } else { @@ -187,8 +204,8 @@ public: std::size_t index(std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } return size_and_indices_[index + 1]; @@ -197,8 +214,8 @@ public: template T& get(std::size_t index) { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } if (!has(index)) { @@ -220,17 +237,16 @@ public: template const T& get(std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } if (size_and_indices_[index + 1] != ::impl::TypeIndex::value) { // @todo this IfcException is silly. Figure out what // to do, but at the moment it is specifically caught // in various places. - throw IfcParse::IfcException( - "Type held at index " + std::to_string(index) + " is " + - get_type_name(size_and_indices_[index + 1]) + " and not " + typeid(T).name() + throw impl::storage_type_mismatch( + ::impl::VariantTypeName::get(), get_type_name(size_and_indices_[index + 1]) ); } using V = typename std::tuple_element<::impl::TypeIndex_v, ::impl::MapTypes_t>::type; @@ -244,8 +260,8 @@ public: template auto apply_visitor(Visitor&& visitor, std::size_t index) const { if (index >= size()) { - throw IfcParse::IfcException( - "Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size()) + throw std::out_of_range( + "Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()) ); } return apply_visitor_impl(std::forward(visitor), index, std::integral_constant{}); @@ -312,19 +328,19 @@ private: } template - const char* get_type_name_impl(size_t i) const { + std::string get_type_name_impl(size_t i) const { if constexpr (I == 0) { return ""; } else { if (i == I - 1) { - return typeid(std::tuple_element_t>).name(); + return ::impl::VariantTypeName>>::get(); } else { return get_type_name_impl(i); } } } - const char* get_type_name(size_t i) const { + std::string get_type_name(size_t i) const { return get_type_name_impl(i); } }; From 22707fa534666812da985999b5e03ba92181f1e6 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 14 Jun 2026 21:02:35 +0200 Subject: [PATCH 14/35] Bring back multiple schema includes in IfcParseExamples.cpp --- src/examples/IfcParseExamples.cpp | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/examples/IfcParseExamples.cpp b/src/examples/IfcParseExamples.cpp index c4ae63b31d..2951285a60 100644 --- a/src/examples/IfcParseExamples.cpp +++ b/src/examples/IfcParseExamples.cpp @@ -25,8 +25,6 @@ #include "ifcparse/IfcFile.h" #include "ifcparse/IfcLogger.h" -#include INCLUDE_SCHEMA(ifcparse, IfcSchema) -#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema) #include #include @@ -43,6 +41,30 @@ static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile."); #endif +// A macro cannot expand to an include directive, so unroll enough includes for +// the maximum number of schemas supported by the build configuration. +#define INCLUDE_SCHEMA_N(n) \ + BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), \ + BOOST_PP_STRINGIZE(ifcparse/BOOST_PP_CAT(Ifc, BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))), SCHEMA_SEQ)).h), \ + "ifcgeom/empty.h") + +#include INCLUDE_SCHEMA_N(0) +#include INCLUDE_SCHEMA_N(1) +#include INCLUDE_SCHEMA_N(2) +#include INCLUDE_SCHEMA_N(3) +#include INCLUDE_SCHEMA_N(4) +#include INCLUDE_SCHEMA_N(5) +#include INCLUDE_SCHEMA_N(6) +#include INCLUDE_SCHEMA_N(7) +#include INCLUDE_SCHEMA_N(8) +#include INCLUDE_SCHEMA_N(9) +#include INCLUDE_SCHEMA_N(10) +#include INCLUDE_SCHEMA_N(11) +#include INCLUDE_SCHEMA_N(12) +#include INCLUDE_SCHEMA_N(13) +#include INCLUDE_SCHEMA_N(14) +#include INCLUDE_SCHEMA_N(15) + #include #if USE_VLD From 6a6756de66bf6a2a552824195401138b176616e8 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Mon, 15 Jun 2026 09:56:36 +0200 Subject: [PATCH 15/35] Bump binary versions in makefiles; add backwards compatibility to logger usage in python #8167 --- src/bonsai/Makefile | 2 +- src/ifcopenshell-python/Makefile | 4 ++-- .../ifcopenshell/__init__.py | 23 ++++++++++++------- src/ifcopenshell-python/ifcopenshell/draw.py | 4 ++-- .../ifcopenshell/express/bootstrap.py | 7 ++++-- .../ifcopenshell/express/schema_class.py | 14 +++++++---- .../ifcopenshell/geom/app.py | 12 ++++++---- .../ifcopenshell/geom/main.py | 19 ++++++++------- .../ifcopenshell/util/cost.py | 6 +++-- .../ifcopenshell/util/selector.py | 18 ++++++++++----- 10 files changed, 70 insertions(+), 39 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 6fc51c463a..c3324178df 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -106,7 +106,7 @@ endif endif # def PLATFORM # Current build commit hash. -OLD:=1c5b825 +OLD:=3e7b739 .PHONY: bump bump: ifndef NEW diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 7d6592635d..350808ec25 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -54,8 +54,8 @@ ifeq ($(PLATFORM), win64) PLATFORMTAG:=win_amd64 endif -BINARY_VERSION:=0.8.5 -BUILD_COMMIT:=1c5b825 +BINARY_VERSION:=0.8.6 +BUILD_COMMIT:=3e7b739 IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index c50ff343e9..85b310b9ce 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -95,7 +95,9 @@ from .entity_instance import entity_instance, register_schema_attributes from .file import file, rocksdb_lazy_instance from .file import file as _file from .sql import sqlite, sqlite_entity -from .ifcopenshell_wrapper import get_log, logger + +get_log = ifcopenshell_wrapper.get_log +logger = getattr(ifcopenshell_wrapper, "logger", None) # explicitly specify available imported symbols # (it's a requirement for a typed library) @@ -192,10 +194,10 @@ def open( raise FileNotFoundError(f"Path does not exist: '{path}'.") if format is None: format = guess_format(path) - if logger is None: - logger = ifcopenshell_wrapper.logger.Root() + if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): + logger = logger_type.Root() if format == ".ifcXML": - f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), logger) + f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ())) if f: return file(f) raise OSError(f"Failed to parse .ifcXML file from {path}") @@ -212,9 +214,11 @@ def open( if should_stream: return stream(path) if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux. - f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, logger) + f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ())) elif bypass_types: - f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag(), logger) + f = ifcopenshell_wrapper.file( + ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ()) + ) for ty in bypass_types: f.bypass_type(ty) if mmap: @@ -224,9 +228,12 @@ def open( f.initialize(str(path.absolute())) elif mmap: # mmap parameter is only available for builds with USE_MMAP, not used in our main builds - f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap, logger=logger) # ty: ignore[unknown-argument] + kwargs = {"mmap": mmap} + if logger is not None: + kwargs["logger"] = logger + f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument] else: - f = ifcopenshell_wrapper.open(str(path.absolute()), False, logger) + f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ())) return file(f) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index b7bc4db365..147a23498d 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -107,7 +107,7 @@ def main( progress_function: Callable = DO_NOTHING, logger=None, ): - if logger is None: + if logger is None and ifcopenshell.logger is not None: logger = ifcopenshell.logger.Root() def by_guid(g): @@ -543,7 +543,7 @@ def main( arranged = W.arrange_polygons( *filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies, # ty: ignore[too-many-positional-arguments] - logger, + *((logger,) if logger is not None else ()), ) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) diff --git a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py index ef3c3c3ef0..578e561e79 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py +++ b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py @@ -217,7 +217,8 @@ for id in to_emit: statements.append("%s << %s" % (id, stmt)) if __name__ == "__main__": - print(r""" + print( + r""" # This file is generated by IfcOpenShell ifcexpressparser bootstrap.py from __future__ import annotations @@ -256,4 +257,6 @@ if __name__ == "__main__": mdl = importlib.import_module(output) mdl.Generator(m).emit() sys.stdout.write(m.schema.name) -""" % ("\n ".join(statements))) +""" + % ("\n ".join(statements)) + ) diff --git a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py index 3981dbc421..dd1e96c889 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py +++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py @@ -363,18 +363,24 @@ class EarlyBoundCodeWriter: ) ) - self.statements[self.statements.index("{factory_placeholder}")] = """ + self.statements[self.statements.index("{factory_placeholder}")] = ( + """ class %(schema_name)s_instance_factory : public IfcParse::instance_factory { virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const { %(instance_mapping)s } }; -""" % locals() +""" + % locals() + ) "" - self.statements[self.statements.index("{string_pool_placeholder}")] = """ + self.statements[self.statements.index("{string_pool_placeholder}")] = ( + """ const std::string strings[] = {%s}; -""" % ",".join(map(lambda s: '"%s"s' % s, self.strings)) +""" + % ",".join(map(lambda s: '"%s"s' % s, self.strings)) + ) def __str__(self): return "\n".join(self.statements) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/app.py b/src/ifcopenshell-python/ifcopenshell/geom/app.py index d6bab207f1..fa07f3f2b8 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/app.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/app.py @@ -145,7 +145,8 @@ class configuration: config.set( "snippets", "print all wall ids", - self.config_encode(""" + self.config_encode( + """ ########################################################################### # A simple script that iterates over all walls in the current model # # and prints their Globally unique IDs (GUIDS) to the console window # @@ -153,13 +154,15 @@ class configuration: for wall in model.by_type("IfcWall"): print ("wall with global id: "+str(wall.GlobalId)) -""".lstrip()), +""".lstrip() + ), ) config.set( "snippets", "print properties of current selection", - self.config_encode(""" + self.config_encode( + """ ########################################################################### # A simple script that iterates over all IfcPropertySets of the currently # # selected object and prints them to the console # @@ -177,7 +180,8 @@ if selection: for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties: print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue)) print ("\\n") -""".lstrip()), +""".lstrip() + ), ) with open(conf_file, "w") as configfile: config.write(configfile) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 8161512836..fc66264b95 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -302,8 +302,8 @@ class iterator(ifcopenshell_wrapper.Iterator): logger=None, ): self.settings = settings - if logger is None: - logger = ifcopenshell_wrapper.logger.Root() + if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): + logger = logger_type.Root() if isinstance(file_or_filename, file): self.file = file file_or_filename = file_or_filename.wrapped_data @@ -336,19 +336,18 @@ class iterator(ifcopenshell_wrapper.Iterator): else: initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude - self.this = initializer( + args = ( geometry_library, self.settings, file_or_filename, include_or_exclude, include is not None, num_threads, - logger, ) + self.this = initializer(*args, *((logger,) if logger is not None else ())) else: - self.this = ifcopenshell_wrapper.construct_iterator( - geometry_library, self.settings, file_or_filename, num_threads, logger - ) + args = (geometry_library, self.settings, file_or_filename, num_threads) + self.this = ifcopenshell_wrapper.construct_iterator(*args, *((logger,) if logger is not None else ())) if has_occ: @@ -517,7 +516,11 @@ def create_shape( return wrap_shape_creation( settings, ifcopenshell_wrapper.create_shape( - settings, inst.wrapped_data, repr.wrapped_data if repr is not None else None, geometry_library, *(filter(None, (logger,))) + settings, + inst.wrapped_data, + repr.wrapped_data if repr is not None else None, + geometry_library, + *((logger,) if logger is not None else ()), ), ) diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index 4354e49e90..fc3de44455 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -355,7 +355,8 @@ def get_cost_rate( class CostValueUnserialiser: def parse(self, formula: str): - l = lark.Lark("""start: formula + l = lark.Lark( + """start: formula formula: operand (operator operand)* operand: value | category "(" formula ")" value: NUMBER? @@ -392,7 +393,8 @@ class CostValueUnserialiser: NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text - """) + """ + ) start = l.parse(formula) return self.get_formula(start.children[0]) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index abaa4e4119..d67292fb1d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -39,7 +39,8 @@ import ifcopenshell.util.shape import ifcopenshell.util.system import ifcopenshell.util.unit -filter_elements_grammar = lark.Lark("""start: filter_group +filter_elements_grammar = lark.Lark( + """start: filter_group filter_group: facet_list ("+" facet_list)* facet_list: facet ("," facet)* @@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text -""") +""" +) -get_element_grammar = lark.Lark("""start: keys +get_element_grammar = lark.Lark( + """start: keys keys: key ("." key)* key: quoted_string | regex_string | unquoted_string @@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys WS: /[ \\t\\f\\r\\n]/+ %ignore WS // Disregard spaces in text - """) + """ +) -format_grammar = lark.Lark("""start: expression +format_grammar = lark.Lark( + """start: expression ?expression: add_sub ?add_sub: mul_div @@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression NEWLINE: (CR? LF)+ %ignore WS // Disregard spaces in text -""") +""" +) class FormatTransformer(lark.Transformer): From 2f067187eebe1bbe51ba82ec050bae3044adcc49 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 10:08:50 +0200 Subject: [PATCH 16/35] Disable snap on schematic gizmos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schematic dimensions float in billboarded viewport space; their labels carry the value, not the bar length. Snapping the dragged tip to scene vertices produces nonsensical value jumps when the mouse crosses unrelated meshes. Add an opt-out flag on the parametric gizmo group base and override it on the schematic base — every schematic subclass inherits no-snap behaviour, and in-place parametric gizmos (door, window, wall, stair, roof, mep) keep the existing Ctrl-toggleable snap because the default stays True. GizmoDimension.invoke also forces tool_settings.use_snap = False for schematic gizmos so the header magnet visibly switches off for the drag's duration. The existing exit path restores the user's previous setting on release. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 38 ++++++++++++++++--- .../test/bim/module/drawing/test_gizmos.py | 22 ++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) 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/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 From 3f8d7165e545581627f91c806ced3b3e5ce13c69 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 10:57:07 +0200 Subject: [PATCH 17/35] Preserve openings on wall merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DumbWallJoiner.merge cascade-deletes element2's HasOpenings via delete_ifc_object, which previously dropped every IfcOpeningElement (and any IfcDoor / IfcWindow filling) hosted by the discarded wall. Re-host each void rel onto the survivor BEFORE the delete fires, and re-apply the opening's captured world matrix via edit_object_placement so the void doesn't drift when the two walls have different placements — a PlacementRelTo swap alone would fail this when origins differ along the shared axis. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 19 ++ .../module/model/test_wall_merge_openings.py | 192 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_wall_merge_openings.py diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 1941c6fd6e..83946d4c0d 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1736,6 +1736,25 @@ class DumbWallJoiner: related_connection=rel.RelatedConnectionType, ) + # Re-host openings from the discarded wall to the survivor before + # ``delete_ifc_object`` cascade-removes element2's voids and any + # filling that depends on them. ``edit_object_placement`` preserves + # the opening's world position when element1 and element2 have + # different placements — a ``PlacementRelTo`` swap alone would + # shift the opening as the relative offset changes. + ifc_file = tool.Ifc.get() + for rel in list(element2.HasOpenings): + opening = rel.RelatedOpeningElement + world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) + rel.RelatingBuildingElement = element1 + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=opening, + matrix=world_matrix, + is_si=False, + should_transform_children=False, + ) + tool.Model.recreate_wall(element1, wall1) tool.Geometry.delete_ifc_object(wall2) diff --git a/src/bonsai/test/bim/module/model/test_wall_merge_openings.py b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py new file mode 100644 index 0000000000..ab90ed9a60 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py @@ -0,0 +1,192 @@ +# 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. + +"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from +the discarded wall to the survivor before the cascade delete tears down +``element2.HasOpenings`` and any filling that references them. + +``edit_object_placement`` preserves the opening's world position when the +two walls have different placements — a ``PlacementRelTo`` swap alone +would shift the opening as the relative offset changes.""" + +from unittest.mock import MagicMock, Mock, patch + +import numpy as np +import pytest + +pytestmark = pytest.mark.wall + + +def _opening_rel(opening_id: int, placement_matrix: np.ndarray): + """Build a stub ``IfcRelVoidsElement`` carrying an opening with a known + placement. ``RelatingBuildingElement`` is settable so the test can + observe the re-host.""" + opening = Mock(name=f"opening_{opening_id}") + opening.id.return_value = opening_id + opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}") + rel = Mock(name=f"voids_rel_{opening_id}") + rel.RelatedOpeningElement = opening + rel.RelatingBuildingElement = None + return rel, opening, placement_matrix + + +def _merge_inputs(*, has_openings): + """Stage the minimum wall1 + wall2 + element1 + element2 surface that + ``DumbWallJoiner.merge`` reads. The reference lines and placements are + rigged so the collinearity guard passes and execution reaches the + opening-migration loop.""" + wall1 = Mock(name="wall1") + wall2 = Mock(name="wall2") + element1 = Mock(name="element1") + element2 = Mock(name="element2") + element1.ObjectPlacement = Mock(name="elem1_placement") + element2.ObjectPlacement = Mock(name="elem2_placement") + element1.ConnectedTo = [] + element1.ConnectedFrom = [] + element2.ConnectedTo = [] + element2.ConnectedFrom = [] + element2.HasOpenings = list(has_openings) + return wall1, wall2, element1, element2 + + +def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls): + """Invoke ``DumbWallJoiner().merge`` against the staged inputs with + every heavy IFC / Blender side effect patched out. ``opening_matrices`` + maps an opening id to its captured world matrix; ``captured_edit_calls`` + is appended to whenever ``edit_object_placement`` fires.""" + from bonsai.bim.module.model.wall import DumbWallJoiner + + def fake_get_local_placement(placement): + for rel in element2.HasOpenings: + if rel.RelatedOpeningElement.ObjectPlacement is placement: + return opening_matrices[rel.RelatedOpeningElement.id()] + return np.eye(4) + + def fake_get_entity(obj): + return {wall1: element1, wall2: element2}[obj] + + def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children): + captured_edit_calls.append( + { + "product": product, + "matrix": matrix, + "is_si": is_si, + "should_transform_children": should_transform_children, + } + ) + + p1 = np.array([0.0, 0.0]) + p2 = np.array([5.0, 0.0]) + p3 = np.array([5.0, 0.0]) + p4 = np.array([10.0, 0.0]) + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False), + patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity), + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line", + side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement", + side_effect=fake_get_local_placement, + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement", + side_effect=fake_edit_object_placement, + ), + patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"), + patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object, + patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"), + ): + DumbWallJoiner().merge(wall1, wall2) + return delete_ifc_object + + +def test_merge_rehosts_each_opening_to_survivor(): + """Every void rel on the discarded wall is rebound to the survivor so + the cascade delete doesn't take them down with element2.""" + matrix_a = np.eye(4) + matrix_a[0, 3] = 1.0 + matrix_b = np.eye(4) + matrix_b[0, 3] = 3.0 + rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a) + rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b]) + + _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={101: matrix_a, 102: matrix_b}, + captured_edit_calls=[], + ) + + assert rel_a.RelatingBuildingElement is element1 + assert rel_b.RelatingBuildingElement is element1 + + +def test_merge_preserves_opening_world_placement(): + """``edit_object_placement`` re-applies the opening's pre-merge world + matrix so the void doesn't drift when the two walls have different + placements — the regression a ``PlacementRelTo`` swap alone would + fail.""" + matrix = np.eye(4) + matrix[:3, 3] = (2.5, 0.0, 0.0) + rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel]) + captured: list[dict] = [] + + _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={42: matrix}, + captured_edit_calls=captured, + ) + + edit_calls_for_opening = [call for call in captured if call["product"] is opening] + assert len(edit_calls_for_opening) == 1 + np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9) + assert edit_calls_for_opening[0]["should_transform_children"] is False + + +def test_merge_rehosts_before_delete(): + """Order matters: ``delete_ifc_object`` cascades through + ``element2.HasOpenings`` and would destroy the void if it ran before + the re-host. Assert the survivor was rebound before delete fires.""" + matrix = np.eye(4) + rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix) + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel]) + + delete_ifc_object = _run_merge( + wall1, + wall2, + element1, + element2, + opening_matrices={7: matrix}, + captured_edit_calls=[], + ) + + assert rel.RelatingBuildingElement is element1 + delete_ifc_object.assert_called_once_with(wall2) From 7dcf415ef144d3a1a4834939121886249003ab32 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 11:06:32 +0200 Subject: [PATCH 18/35] Resync wall props after dimension mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeExtrusionDepth, ChangeExtrusionXAngle, and ChangeLayerLength mutate IFC extrusion / axis but never re-prime BIMWallProperties from the post-mutation state. Gizmo icons that position from props.height then sit at the pre-mutation elevation even though the wall mesh shows the new one — visible asymmetry against the workspace header H field which redraws live. Add the existing _resync_walls_after_mutation call to each operator's epilogue. _maybe_resync_wall_props_from_ifc already skips non-walls and walls in edit mode, so calling on the raw selection list is safe. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 3 + .../test_wall_props_resync_on_dim_change.py | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 83946d4c0d..bc54335c91 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -811,6 +811,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + _resync_walls_after_mutation(layer2_objs) return {"FINISHED"} @@ -926,6 +927,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + _resync_walls_after_mutation(layer2_objs) return {"FINISHED"} @@ -948,6 +950,7 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator): selected_objs = tool.Model.get_selected_mesh_ifc_objects() for obj in selected_objs: joiner.set_length(obj, self.length) + _resync_walls_after_mutation(selected_objs) class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py new file mode 100644 index 0000000000..f9f05fb936 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py @@ -0,0 +1,57 @@ +# 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. + +"""Pins the contract that the three dimension-mutating wall operators — +``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``, +``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the +post-mutation IFC at the end of ``_execute``. + +Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle`` +stay at their pre-mutation values; gizmo icons that position from +``props.height`` then sit at the old elevation even though the wall mesh +shows the new one.""" + +import inspect + +import pytest + +pytestmark = pytest.mark.wall + + +def _execute_source(operator_cls): + return inspect.getsource(operator_cls._execute) + + +def test_change_extrusion_depth_resyncs_wall_props(): + from bonsai.bim.module.model.wall import ChangeExtrusionDepth + + assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth) + + +def test_change_extrusion_x_angle_resyncs_wall_props(): + from bonsai.bim.module.model.wall import ChangeExtrusionXAngle + + assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle) + + +def test_change_layer_length_resyncs_wall_props(): + from bonsai.bim.module.model.wall import ChangeLayerLength + + assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength) From bbe437adc847b8d34013fd12f8d39443d9ebe15f Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 11:41:01 +0200 Subject: [PATCH 19/35] Fix wall-split filled-opening classification + void copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in DumbWallJoiner.split's filled-opening branch: 1. Side classification read filling_obj.matrix_world.translation — flip-fragile because flip_object rotates the filler 180° + translates so the bbox stays visually in place, moving the door origin to the opposite bbox corner. A flipped door centred over the cut could be classified on the wrong side. Switch to the opening's axis-projected midpoint, which the unfilled-opening loop already uses. 2. When the void straddles the cut and the filling moves to element2, the void copy for element1 was taken from the rebound new_opening whose PlacementRelTo had been swapped to element2 — the new void on element1 then sat in element2's local frame. Reorder so the copy reads from the original opening (still hosted by element1) before remove_feature destroys it. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 26 +++++--- .../model/test_wall_split_filled_opening.py | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index bc54335c91..428c999274 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1619,13 +1619,16 @@ class DumbWallJoiner: r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings ]: rel = opening.HasFillings[0] - filling = rel.RelatedBuildingElement - filling_obj = tool.Ifc.get_object(filling) - filling_location = filling_obj.matrix_world.translation - _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d) min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) + # Use the opening's axis-projected midpoint to classify the side. + # The filling's ``matrix_world.translation`` is flip-fragile — + # ``flip_object`` rotates 180° + translates so the bbox stays + # visually in place, moving the door origin to the opposite + # corner, which would mis-classify a flipped door centred over + # the cut. + opening_midpoint = (min_t + max_t) / 2 void_straddles = min_t < cut_percentage < max_t - if filling_position > cut_percentage: + if opening_midpoint > cut_percentage: # The filling should be moved from element1 to element2. new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening) new_opening.VoidsElements[0].RelatingBuildingElement = element2 @@ -1640,13 +1643,16 @@ class DumbWallJoiner: rel.RelatingOpeningElement = new_opening - # Remove the old opening - ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) - if void_straddles: # Filling moved to element2, but void straddles — add a - # pure-void copy back to element1 so its body still gets cut. - _add_void_copy(element1, new_opening) + # pure-void copy back to element1. Read from the original + # ``opening`` whose ObjectPlacement still references + # element1; ``new_opening`` was rebound to element2 and + # would copy element2's frame instead. + _add_void_copy(element1, opening) + + # Remove the old opening + ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) elif void_straddles: # Filling stays on element1, but void straddles — add a pure-void # copy to element2 so its body gets cut. diff --git a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py new file mode 100644 index 0000000000..9bbfcbb88a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py @@ -0,0 +1,66 @@ +# 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. + +"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch: + +1. Side classification reads the opening's axis-projected midpoint, not + the filling's ``matrix_world.translation``. The filling origin is + flip-fragile — ``flip_object`` rotates the filler 180° + translates so + the bbox stays visually in place, which would mis-classify a flipped + door centred over the cut. +2. When the void straddles the cut and the filling moves to element2, + the void copy for element1 is taken from the ORIGINAL opening (whose + ``ObjectPlacement`` still references element1), not the rebound + ``new_opening`` (whose ``PlacementRelTo`` was swapped to element2).""" + +import inspect + +import pytest + +pytestmark = pytest.mark.wall + + +def _split_source(): + from bonsai.bim.module.model.wall import DumbWallJoiner + + return inspect.getsource(DumbWallJoiner.split) + + +def test_side_classification_uses_opening_midpoint_not_filling_origin(): + source = _split_source() + assert "opening_midpoint" in source + # The pre-fix code projected the filling's world translation onto the + # axis to classify; that path must be gone. + assert "filling_obj.matrix_world.translation" not in source + + +def test_void_copy_reads_from_original_opening_before_remove(): + source = _split_source() + # Locate the "filling moves to element2" branch via the opening + # midpoint check; the void-copy and the trailing remove_feature both + # live inside this branch, after the prior unfilled-opening loops. + branch_start = source.index("if opening_midpoint > cut_percentage:") + branch = source[branch_start:] + add_idx = branch.index("_add_void_copy(element1, opening)") + remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)") + # Read-from-original is the whole point — the rebound ``new_opening`` + # references element2's frame and would shift the void to element1's + # origin in element2's local coords. + assert add_idx < remove_idx From 6119f0045e58eae83d2f71efff9177574fad0682 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 12:37:24 +0200 Subject: [PATCH 20/35] Swap merge convention to active-is-survivor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bim.merge_wall now consumes the non-active selection into the active one — matching Blender's OBJECT_OT_join (Ctrl+J) and MESH_OT_merge "at last" convention. The wall the user clicks last absorbs the other; users following Blender muscle-memory get the result they expect. DumbWallJoiner.merge is already structurally asymmetric (wall1 = survivor); only the caller in MergeWall._perform needed flipping. Audit confirmed the previous call site was the sole caller of DumbWallJoiner.merge in production code. Drive-by tidies on adjacent code: collapse two over-length comprehensions under black's 120-char budget, and switch ``any(True for _ in gen)`` to ``any(gen)`` since the iterable yields tuples that are always truthy. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 19 ++-- src/bonsai/bonsai/tool/model.py | 4 +- .../model/test_merge_wall_convention.py | 87 +++++++++++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_merge_wall_convention.py diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 428c999274..5ab2233a08 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -150,7 +150,7 @@ def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_edit return False if require_editing and not tool.Model.get_slab_props(active).is_editing: return False - return any(True for _ in tool.Wall.iter_slab_wall_connections(element)) + return any(tool.Wall.iter_slab_wall_connections(element)) def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool: @@ -739,12 +739,13 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat active_obj = context.active_object assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() - # The merge deletes the second argument when the walls are collinear; - # only the first survives, so the resync targets the non-active wall. - surviving_obj = next(o for o in selected_objs if o != active_obj) - DumbWallJoiner().merge(surviving_obj, active_obj) - _maybe_resync_wall_props_from_ifc(surviving_obj) - _regenerate_walls([surviving_obj]) + # Active-is-survivor — matches Blender's Ctrl+J / "merge at last" + # convention so the wall a user clicks last absorbs the other. + # DumbWallJoiner.merge deletes its second argument. + other_obj = next(o for o in selected_objs if o != active_obj) + DumbWallJoiner().merge(active_obj, other_obj) + _maybe_resync_wall_props_from_ifc(active_obj) + _regenerate_walls([active_obj]) return {"FINISHED"} @@ -4229,9 +4230,7 @@ class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): @classmethod def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: - return tool.Parametric.is_slab(element) and any( - True for _ in tool.Wall.iter_slab_wall_connections(element) - ) + return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element)) class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 87d8fd4210..bc6f49063b 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -933,9 +933,7 @@ class Model(bonsai.core.tool.Model): if not representation: return False chain = cls.get_booleans(wall, representation) - to_remove = [ - b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet") - ] + to_remove = [b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet")] for b in to_remove: tool.Geometry.remove_representation_item(b.SecondOperand, wall) # Sweep the now-stale BBIM_Boolean entries on the copy (their ids point diff --git a/src/bonsai/test/bim/module/model/test_merge_wall_convention.py b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py new file mode 100644 index 0000000000..6447b8541b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_merge_wall_convention.py @@ -0,0 +1,87 @@ +# 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. + +"""Pins the active-is-survivor merge convention. + +``bim.merge_wall`` must consume the non-active selection into the active +one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at +last" convention. Users following Ctrl+J muscle-memory click the +surviving wall last; the operator must align with that expectation.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.wall + + +def _run_perform(active, other): + """Invoke ``MergeWall._perform`` as an unbound function with the + two wall stubs in the selection, patching the heavy IFC / Blender + side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually + passed to ``DumbWallJoiner.merge``.""" + from bonsai.bim.module.model.wall import MergeWall + + context = SimpleNamespace(active_object=active) + captured_call = {} + + def _capture_merge(self, a, b): + captured_call["wall1"] = a + captured_call["wall2"] = b + + fake_self = SimpleNamespace() + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge), + patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"), + patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls, + ): + result = MergeWall._perform(fake_self, context) + + return captured_call, regen_walls, result + + +def test_active_wall_is_passed_as_survivor_to_merge(): + """The first argument to ``DumbWallJoiner.merge`` is the survivor; + the active object must occupy that slot so the wall the user clicked + last absorbs the other.""" + active = SimpleNamespace(name="active") + other = SimpleNamespace(name="other") + + captured, _regen, _ = _run_perform(active, other) + + assert captured["wall1"] is active + assert captured["wall2"] is other + + +def test_post_merge_resync_targets_active_not_consumed(): + """After the merge ``_regenerate_walls`` rebuilds the survivor's + body. Targeting the consumed wall would crash on a freed ``bpy_struct``; + the survivor (active) is the only valid target.""" + active = SimpleNamespace(name="active") + other = SimpleNamespace(name="other") + + _, regen_walls, _ = _run_perform(active, other) + + regen_walls.assert_called_once_with([active]) From 13c89ace83cf4ae754c92c9cf078a454683bcc9f Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 14:32:29 +0200 Subject: [PATCH 21/35] Fix merge crash + surface/lock fillet preview connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DumbWallJoiner.merge previously crashed on walls with a slab underside clip because the ConnectedTo / ConnectedFrom migration loops assumed every rel was an IfcRelConnectsPathElements. The slab's IfcRelConnectsElements(TOP) rel has no RelatingConnectionType / RelatedConnectionType and raised AttributeError mid-migration. Filter on rel class; the slab rel dies with element2 via the trailing delete_ifc_object cascade. The fillet preview pen icon now also flips the corner's BIMWallProperties.is_editing so the connection-disconnect gizmos surface in parallel with the radius drag. CancelWallFilletPreview clears the flag before tearing the preview state down so both UIs hide together. GizmoWallUnjoinSingle.poll inlines the viewport + array-child guards from the topology gate so the gizmo can show during preview — its own is_editing check is the real gate. Fillet-to-source-wall path connection icons render in a muted gray (LOCKED_COLOR) instead of the active disconnect tone, and the bim.disconnect_elements operator early-returns with an INFO report ("Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.") when either side resolves to a fillet corner. The slab clip rel kind stays disconnect-able since its identity is separate from the fillet's chord-axis reference. Drive-by /improve polish on adjacent wall.py code: 3 comment tightenings dropping sibling-symbol names + a defensive ``if opening.ObjectPlacement:`` guard in the merge opening migration matching the pattern used elsewhere in the same file. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 116 ++++++++++++++---- .../module/model/test_disconnect_elements.py | 72 ++++++++++- .../module/model/test_wall_merge_openings.py | 84 +++++++++++++ 3 files changed, 242 insertions(+), 30 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5ab2233a08..b7f287c0d6 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -386,6 +386,20 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I if not rels: self.report({"ERROR"}, "No connection found between elements.") return + # The fillet corner's join with its source walls defines the fillet's + # identity — unjoining there would tear down the chord axis reference + # without rebuilding the source walls' miter cuts. Deleting the corner + # wall is the supported teardown, which cascades back to the source + # walls via the connection-cleanup handler. + either_is_fillet = tool.Parametric.is_fillet_corner_wall( + elem_a + ) or tool.Parametric.is_fillet_corner_wall(elem_b) + if either_is_fillet and any(k == "path" for _, k in rels): + self.report( + {"INFO"}, + "Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.", + ) + return path_objs: list[bpy.types.Object] = [] for rel, kind in rels: bonsai.core.connection.disconnect_rel( @@ -740,8 +754,8 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() # Active-is-survivor — matches Blender's Ctrl+J / "merge at last" - # convention so the wall a user clicks last absorbs the other. - # DumbWallJoiner.merge deletes its second argument. + # convention. The first argument survives, the second is consumed, + # so the active wall ends up absorbing the other. other_obj = next(o for o in selected_objs if o != active_obj) DumbWallJoiner().merge(active_obj, other_obj) _maybe_resync_wall_props_from_ifc(active_obj) @@ -1623,10 +1637,10 @@ class DumbWallJoiner: min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) # Use the opening's axis-projected midpoint to classify the side. # The filling's ``matrix_world.translation`` is flip-fragile — - # ``flip_object`` rotates 180° + translates so the bbox stays - # visually in place, moving the door origin to the opposite - # corner, which would mis-classify a flipped door centred over - # the cut. + # flipping rotates the filler 180° + translates so the bbox + # stays visually in place, moving the door origin to the + # opposite corner, which would mis-classify a flipped door + # centred over the cut. opening_midpoint = (min_t + max_t) / 2 void_straddles = min_t < cut_percentage < max_t if opening_midpoint > cut_percentage: @@ -1722,7 +1736,14 @@ class DumbWallJoiner: p2[0] = max(x_ordinates) self.set_axis(element1, p1, p2) + # ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements`` + # (the wall-wall joins this loop migrates) and + # ``IfcRelConnectsElements`` (the slab underside clip). Only the + # path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``; + # the element rels die with element2 via the trailing cascade delete. for rel in element2.ConnectedTo: + if not rel.is_a("IfcRelConnectsPathElements"): + continue ifcopenshell.api.geometry.disconnect_path( tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType ) @@ -1735,6 +1756,8 @@ class DumbWallJoiner: ) for rel in element2.ConnectedFrom: + if not rel.is_a("IfcRelConnectsPathElements"): + continue ifcopenshell.api.geometry.disconnect_path( tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType ) @@ -1747,23 +1770,24 @@ class DumbWallJoiner: ) # Re-host openings from the discarded wall to the survivor before - # ``delete_ifc_object`` cascade-removes element2's voids and any - # filling that depends on them. ``edit_object_placement`` preserves - # the opening's world position when element1 and element2 have + # the cascade delete tears down element2's voids and any filling + # that depends on them. ``edit_object_placement`` preserves the + # opening's world position when element1 and element2 have # different placements — a ``PlacementRelTo`` swap alone would # shift the opening as the relative offset changes. ifc_file = tool.Ifc.get() for rel in list(element2.HasOpenings): opening = rel.RelatedOpeningElement - world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) rel.RelatingBuildingElement = element1 - ifcopenshell.api.geometry.edit_object_placement( - ifc_file, - product=opening, - matrix=world_matrix, - is_si=False, - should_transform_children=False, - ) + if opening.ObjectPlacement: + world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=opening, + matrix=world_matrix, + is_si=False, + should_transform_children=False, + ) tool.Model.recreate_wall(element1, wall1) @@ -3348,6 +3372,19 @@ class CancelWallFilletPreview(bpy.types.Operator): props = preview_base.get_preview_props(context, "wall_fillet") if props is None or not props.is_active: return {"CANCELLED"} + # Clear the corner's edit flag so the connection disconnect gizmos + # disappear in lockstep with the radius preview when the user + # cancels. The id read happens BEFORE clear_preview_state wipes it. + corner_id = props.editing_corner_id + if corner_id: + ifc_file = tool.Ifc.get() + if ifc_file is not None: + try: + corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id)) + except RuntimeError: + corner_obj = None + if corner_obj is not None: + tool.Model.get_wall_props(corner_obj).is_editing = False preview_base.clear_preview_state(props) return {"FINISHED"} @@ -3421,6 +3458,11 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator): props.radius = float(radius) props.editing_corner_id = corner_elem.id() props.is_active = True + # Flag the corner as "in edit mode" so the wall-side connection + # disconnect gizmos surface in parallel with the fillet preview — + # one pen-icon click enters BOTH radius retune AND connection + # inspection. + tool.Model.get_wall_props(corner_obj).is_editing = True return {"FINISHED"} @@ -3997,10 +4039,23 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix ICON_SCALE = 0.35 SLAB_STACK_MAX = 5 SLAB_STACK_OFFSET_Z = 0.5 + # Muted gray used for connection icons that are visible (the connection + # exists) but inert (clicking dispatches a no-op + INFO report). Fillet + # corner ↔ source-wall joins use this — disconnecting them would tear + # down the fillet's chord axis reference, so the supported teardown is + # deleting the corner wall instead. + LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5) @classmethod def poll(cls, context: bpy.types.Context) -> bool: - if not _wall_topology_gizmo_poll_gate(context): + # Bypass the shared topology gate's ``any_preview_active`` block — + # ``BIMWallProperties.is_editing`` is the real gate for this gizmo + # group, and that flag is set both by the regular wall edit lifecycle + # AND by the fillet preview entry (so a fillet corner under preview + # surfaces its connections in parallel with the radius drag). + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): return False active = tool.Blender.get_active_object(is_selected=True) if active is None: @@ -4011,11 +4066,6 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix element = tool.Ifc.get_entity(active) if not element or not tool.Parametric.is_path_connectable_wall(element): return False - # Fillet-corner walls have no LAYER2 usage and cannot enter the - # parametric edit lifecycle, so the ``is_editing`` gate is bypassed - # for them — otherwise their connection icons would never surface. - if tool.Parametric.is_fillet_corner_wall(element): - return True props = tool.Model.get_wall_props(active) if not props.is_editing: return False @@ -4023,6 +4073,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix def setup(self, context: bpy.types.Context) -> None: default_color, highlight_color = self.get_decoration_colors() + # Stashed so per-frame ``_bind_unjoin_icon`` can restore the active + # tone when an icon was muted in a previous frame for fillet lock. + self._default_unjoin_color = default_color # Bind the operator on each pool icon ONCE at setup time and keep the returned # OperatorProperties handles. target_set_operator allocates a fresh handle on # every call, so calling it from position_gizmos (which fires every redraw @@ -4077,6 +4130,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self._pool_cap_warned = True slot_idx = 0 + self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem) for other_elem, self_ct, other_ct in path_connections: if slot_idx >= self.POOL_SIZE: break @@ -4088,7 +4142,10 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix continue seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) - self._bind_unjoin_icon(slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj) + is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem) + self._bind_unjoin_icon( + slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked + ) slot_idx += 1 for stack_idx, (slab_elem, _rel) in enumerate(slab_connections): @@ -4107,7 +4164,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj) slot_idx += 1 - def _bind_unjoin_icon(self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj): + def _bind_unjoin_icon( + self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False + ): """Place + bind one pool icon to a (active, partner) GlobalId pair. Only the GlobalId properties are rewritten per frame; the operator @@ -4116,10 +4175,15 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix save/reload, and any sit-in-the-undo-stack interlude between dispatch and execute. The partner Blender object is mirrored onto the icon for its hover-outline draw, since the Gizmo API exposes - ``target_set_operator`` but no symmetric reader.""" + ``target_set_operator`` but no symmetric reader. + + ``is_locked=True`` (fillet corner involvement) writes a muted color + instead of the active tone; the GUIDs still propagate so the bound + operator can surface a friendly INFO report on click.""" icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE) icon.hide = False + icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId icon.partner_obj = partner_obj diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index 9eb37481de..4aeb7ffbad 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -231,7 +231,9 @@ def test_disconnect_dispatches_one_call_per_rel(): return_value=[(rel1, "path"), (rel2, "element-top")], ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch( "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() - ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): + ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): DisconnectElements._perform(op, context=MagicMock()) assert dispatch.call_count == 2 @@ -271,7 +273,9 @@ def test_disconnect_resyncs_path_objs_once_for_path_kind(): side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e], ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"), patch( "bonsai.bim.module.model.wall._resync_walls_after_mutation" - ) as resync: + ) as resync, patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): DisconnectElements._perform(op, context=MagicMock()) resync.assert_called_once_with([obj_a, obj_b]) @@ -294,7 +298,9 @@ def test_disconnect_skips_resync_for_non_path_kind(): "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" - ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync: + ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync, patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): DisconnectElements._perform(op, context=MagicMock()) resync.assert_not_called() @@ -322,7 +328,9 @@ def test_disconnect_gizmo_direction_symmetry(): "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" - ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): + ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False + ): DisconnectElements._perform(op, context=MagicMock()) return dispatch.call_args.kwargs @@ -379,3 +387,59 @@ def test_disconnect_operator_is_registered(): assert any( getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes ), "DisconnectElements is not in the model classes tuple" + + +def test_disconnect_refuses_path_kind_when_either_side_is_fillet(): + """The fillet corner's join with its source walls defines its identity + — unjoining there would tear down the chord axis reference. The + operator reports an INFO directing the user to delete the corner + wall and skips the dispatch entirely.""" + from bonsai.bim.module.model.wall import DisconnectElements + + fillet = Mock(name="fillet_corner") + wall = Mock(name="source_wall") + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")] + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", + side_effect=lambda e: e is fillet, + ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch: + DisconnectElements._perform(op, context=MagicMock()) + + dispatch.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"INFO"} + + +def test_disconnect_allows_slab_kind_even_when_wall_is_fillet(): + """The fillet ↔ slab underside clip is a different relationship from + the fillet ↔ source-wall path join. Slab disconnect must remain + available while the corner is in preview.""" + from bonsai.bim.module.model.wall import DisconnectElements + + fillet = Mock(name="fillet_corner") + slab = Mock(name="slab") + rel = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": slab}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch( + "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", + side_effect=lambda e: e is fillet, + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): + DisconnectElements._perform(op, context=MagicMock()) + + dispatch.assert_called_once() diff --git a/src/bonsai/test/bim/module/model/test_wall_merge_openings.py b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py index ab90ed9a60..27f7779357 100644 --- a/src/bonsai/test/bim/module/model/test_wall_merge_openings.py +++ b/src/bonsai/test/bim/module/model/test_wall_merge_openings.py @@ -190,3 +190,87 @@ def test_merge_rehosts_before_delete(): assert rel.RelatingBuildingElement is element1 delete_ifc_object.assert_called_once_with(wall2) + + +def test_merge_skips_non_path_connection_rels(): + """``ConnectedTo`` / ``ConnectedFrom`` carry both + ``IfcRelConnectsPathElements`` (wall-wall joins) AND + ``IfcRelConnectsElements`` (slab underside clips). Only the path rels + expose ``RelatingConnectionType`` / ``RelatedConnectionType``; + accessing those attributes on an element rel raises ``AttributeError``. + The migration loop must filter on the rel class so a wall with a slab + clip can still be merged.""" + from bonsai.bim.module.model.wall import DumbWallJoiner + + wall1, wall2, element1, element2 = _merge_inputs(has_openings=[]) + + path_rel = Mock(name="path_rel") + path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements" + path_rel.RelatingElement = Mock(name="rel_relating") + path_rel.RelatedElement = Mock(name="rel_related") + path_rel.RelatingConnectionType = "ATSTART" + path_rel.RelatedConnectionType = "ATEND" + + slab_rel = Mock(name="slab_rel") + slab_rel.is_a = lambda c: c == "IfcRelConnectsElements" + slab_rel.Description = "TOP" + # ``RelatedConnectionType`` is what the merge loop reads from + # ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has + # no such attribute, so wire the stub to raise like ifcopenshell does. + type(slab_rel).RelatedConnectionType = property( + lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType")) + ) + type(slab_rel).RelatingConnectionType = property( + lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType")) + ) + element2.ConnectedFrom = [slab_rel, path_rel] + + captured_disconnects = [] + captured_connects = [] + + def fake_disconnect_path(*args, **kwargs): + captured_disconnects.append(kwargs) + + def fake_connect_path(*args, **kwargs): + captured_connects.append(kwargs) + + p1 = np.array([0.0, 0.0]) + p2 = np.array([5.0, 0.0]) + p3 = np.array([5.0, 0.0]) + p4 = np.array([10.0, 0.0]) + + def fake_get_entity(obj): + return {wall1: element1, wall2: element2}[obj] + + with ( + patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False), + patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity), + patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line", + side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement", + return_value=np.eye(4), + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path", + side_effect=fake_disconnect_path, + ), + patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path", + side_effect=fake_connect_path, + ), + patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"), + patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"), + patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"), + ): + # The bug pre-fix: the slab rel's ``RelatedConnectionType`` access + # raised AttributeError and crashed merge. With the filter, this + # call must complete cleanly. + DumbWallJoiner().merge(wall1, wall2) + + assert len(captured_disconnects) == 1 + assert len(captured_connects) == 1 + assert captured_disconnects[0]["connection_type"] == "ATEND" From 7cd7db0c2b079440e7f85c1d0518ec7df9202370 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 15 Jun 2026 14:56:46 +0200 Subject: [PATCH 22/35] Tidy: black formatting + PR7a test docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps three over-length lines black wanted on the merge-filter + fillet-lock commit (wall.py's ``either_is_fillet`` chain rewraps the right-hand ``or`` operand; test_disconnect_elements.py patch-stacks break each ``patch(`` onto its own continuation line). Adds per-test docstrings to test_wall_props_resync_on_dim_change.py and test_wall_split_filled_opening.py so the contract each pins is visible on grep / on test-run failure output without scrolling to the module-level docstring. Drops a flip_object sibling-symbol mention from the module docstring per CLAUDE.md §4a. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 6 ++-- .../module/model/test_disconnect_elements.py | 32 +++++++++++++------ .../test_wall_props_resync_on_dim_change.py | 7 ++++ .../model/test_wall_split_filled_opening.py | 23 ++++++------- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index b7f287c0d6..183d9d32e6 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -391,9 +391,9 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I # without rebuilding the source walls' miter cuts. Deleting the corner # wall is the supported teardown, which cascades back to the source # walls via the connection-cleanup handler. - either_is_fillet = tool.Parametric.is_fillet_corner_wall( - elem_a - ) or tool.Parametric.is_fillet_corner_wall(elem_b) + either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall( + elem_b + ) if either_is_fillet and any(k == "path" for _, k in rels): self.report( {"INFO"}, diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index 4aeb7ffbad..da668e604a 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -231,7 +231,9 @@ def test_disconnect_dispatches_one_call_per_rel(): return_value=[(rel1, "path"), (rel2, "element-top")], ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch( "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() - ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"), patch( + ), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ), patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False ): DisconnectElements._perform(op, context=MagicMock()) @@ -239,9 +241,7 @@ def test_disconnect_dispatches_one_call_per_rel(): assert dispatch.call_count == 2 # Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation # — orient_element_top inside disconnect_rel recovers the wall/slab roles. - for call, expected_rel, expected_kind in zip( - dispatch.call_args_list, [rel1, rel2], ["path", "element-top"] - ): + for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]): kw = call.kwargs assert kw["rel"] is expected_rel assert kw["kind"] == expected_kind @@ -271,7 +271,9 @@ def test_disconnect_resyncs_path_objs_once_for_path_kind(): ), patch( "bonsai.bim.module.model.wall.tool.Ifc.get_object", side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e], - ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"), patch( + ), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ), patch( "bonsai.bim.module.model.wall._resync_walls_after_mutation" ) as resync, patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False @@ -298,7 +300,9 @@ def test_disconnect_skips_resync_for_non_path_kind(): "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" - ), patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync, patch( + ), patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ) as resync, patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False ): DisconnectElements._perform(op, context=MagicMock()) @@ -328,7 +332,9 @@ def test_disconnect_gizmo_direction_symmetry(): "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" - ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"), patch( + ) as dispatch, patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ), patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False ): DisconnectElements._perform(op, context=MagicMock()) @@ -409,7 +415,9 @@ def test_disconnect_refuses_path_kind_when_either_side_is_fillet(): ), patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", side_effect=lambda e: e is fillet, - ), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch: + ), patch( + "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" + ) as dispatch: DisconnectElements._perform(op, context=MagicMock()) dispatch.assert_not_called() @@ -437,9 +445,13 @@ def test_disconnect_allows_slab_kind_even_when_wall_is_fillet(): ), patch( "bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", side_effect=lambda e: e is fillet, - ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch( + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock() + ), patch( "bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel" - ) as dispatch, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation"): + ) as dispatch, patch( + "bonsai.bim.module.model.wall._resync_walls_after_mutation" + ): DisconnectElements._perform(op, context=MagicMock()) dispatch.assert_called_once() diff --git a/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py index f9f05fb936..68a95782fd 100644 --- a/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py +++ b/src/bonsai/test/bim/module/model/test_wall_props_resync_on_dim_change.py @@ -40,18 +40,25 @@ def _execute_source(operator_cls): def test_change_extrusion_depth_resyncs_wall_props(): + """Height mutation must re-prime ``BIMWallProperties.height`` so + gizmo icons positioned from ``props.height`` track the post-mutation + wall top in the same redraw.""" from bonsai.bim.module.model.wall import ChangeExtrusionDepth assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth) def test_change_extrusion_x_angle_resyncs_wall_props(): + """Slope mutation must re-prime ``BIMWallProperties.x_angle`` so + slope-driven gizmo positions track the new angle.""" from bonsai.bim.module.model.wall import ChangeExtrusionXAngle assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle) def test_change_layer_length_resyncs_wall_props(): + """Length mutation must re-prime ``BIMWallProperties.length`` so + horizontal gizmo X positions track the new axis extent.""" from bonsai.bim.module.model.wall import ChangeLayerLength assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength) diff --git a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py index 9bbfcbb88a..499ce2509f 100644 --- a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py +++ b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py @@ -22,9 +22,9 @@ 1. Side classification reads the opening's axis-projected midpoint, not the filling's ``matrix_world.translation``. The filling origin is - flip-fragile — ``flip_object`` rotates the filler 180° + translates so - the bbox stays visually in place, which would mis-classify a flipped - door centred over the cut. + flip-fragile — flipping rotates the filler 180° + translates so the + bbox stays visually in place, which would mis-classify a flipped door + centred over the cut. 2. When the void straddles the cut and the filling moves to element2, the void copy for element1 is taken from the ORIGINAL opening (whose ``ObjectPlacement`` still references element1), not the rebound @@ -44,23 +44,24 @@ def _split_source(): def test_side_classification_uses_opening_midpoint_not_filling_origin(): + """Side classification must read the opening's axis-projected + midpoint, not the filling's world translation — the latter shifts + under flipping and would mis-classify a flipped door centred over + the cut.""" source = _split_source() assert "opening_midpoint" in source - # The pre-fix code projected the filling's world translation onto the - # axis to classify; that path must be gone. assert "filling_obj.matrix_world.translation" not in source def test_void_copy_reads_from_original_opening_before_remove(): + """When the filling moves to element2 and the void straddles the + cut, element1's pure-void copy must come from the original opening + BEFORE the cleanup that destroys it — the rebound ``new_opening`` + references element2's frame and would shift the void to element1's + origin in element2's local coords.""" source = _split_source() - # Locate the "filling moves to element2" branch via the opening - # midpoint check; the void-copy and the trailing remove_feature both - # live inside this branch, after the prior unfilled-opening loops. branch_start = source.index("if opening_midpoint > cut_percentage:") branch = source[branch_start:] add_idx = branch.index("_add_void_copy(element1, opening)") remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)") - # Read-from-original is the whole point — the rebound ``new_opening`` - # references element2's frame and would shift the void to element1's - # origin in element2's local coords. assert add_idx < remove_idx From 074021de70261aab75b8f0b341a186e72e5a44a4 Mon Sep 17 00:00:00 2001 From: carlopav Date: Mon, 15 Jun 2026 19:06:57 +0200 Subject: [PATCH 23/35] fix(ifc5d): escape quantity names when serialising Quantities to JSON serialise_cost_quantities built the "Quantities" JSON string by manual concatenation, inserting quantity.Name and the related element's Name without any escaping. A name containing a double quote, backslash or newline produced invalid JSON, breaking any downstream parser (e.g. a Typst json.decode consumer reporting "failed to parse JSON"). It also crashed with a TypeError when a name was None (str += None). Build a Python list and serialise it with json.dumps instead, keeping the exact same [[name, value], ...] output shape, the element-name prefix and the unsupported-type behaviour. None names are coalesced to "" and quantity values are defensively coerced to float. Co-Authored-By: Claude Fable 5 --- src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 25 ++++++++++++++----------- src/ifc5d/test/test_csv2ifc.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index 5aab765c26..a0f12e2535 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse import datetime +import json import logging import os import time @@ -256,26 +257,28 @@ class IfcDataGetter: return "" if cost_item.CostQuantities is None: return "" - string = "[" + result = [] for quantity in cost_item.CostQuantities: - string += '["' + prefix = "" for rel in file.get_inverse(quantity): if rel.is_a("IfcPropertySet") or rel.is_a("IfcElementQuantity"): - prop_set = rel # Find elements that have this property set - for prop_rel in file.get_inverse(prop_set): + for prop_rel in file.get_inverse(rel): if prop_rel.is_a("IfcRelDefinesByProperties"): for obj in prop_rel.RelatedObjects: if obj.is_a("IfcElement"): - string += obj.Name + " - " - string += quantity.Name + prefix += (obj.Name or "") + " - " + name = prefix + (quantity.Name or "") if quantity.is_a("IfcPhysicalSimpleQuantity"): - string += '", ' + str(quantity[3]) + "]," + value = quantity[3] + try: + value = float(value) if value is not None else 0.0 + except (TypeError, ValueError): + value = 0.0 + result.append([name, value]) else: - string += ' ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0],' - string = string.removesuffix(",") - string += "]" - return string + result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0]) + return json.dumps(result, ensure_ascii=False) class SheetData(TypedDict): diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py index c14a795da3..a630bb44ac 100644 --- a/src/ifc5d/test/test_csv2ifc.py +++ b/src/ifc5d/test/test_csv2ifc.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import csv +import json import tempfile from pathlib import Path @@ -118,3 +119,25 @@ class TestCsv2Ifc: writer.write() assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1 assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1 + + +class TestSerialiseCostQuantities: + def test_quantity_name_with_special_characters_round_trips_as_json(self): + ifc_file = ifcopenshell.file() + name = 'Prospetto est "Np=256,667-23"' + quantity = ifc_file.create_entity("IfcQuantityArea", Name=name, AreaValue=12.5) + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [[name, 12.5]] + + def test_unset_name_does_not_crash(self): + ifc_file = ifcopenshell.file() + # Name left unset so quantity.Name resolves to None at access time. + quantity = ifc_file.create_entity("IfcQuantityArea", AreaValue=3.0) + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["", 3.0]] From 5f1efeffaf010e911f2c5cf147dd5d5eeaff746d Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 10:33:56 +0200 Subject: [PATCH 24/35] Tolerate stale array child/parent GUIDs (#8177) * Tolerate stale array child/parent GUIDs A real-world IFC project (an arrayed door whose host got deleted externally) crashed Bonsai's project load with "Instance with GlobalId not found" inside setup_arrays. tool.Blender.get_object_from_guid declared Optional return but let RuntimeError propagate; callers iterating BBIM_Array child lists then crashed instead of skipping. Honour the documented contract by returning None on miss, matching the convention used by every other by_guid lookup helper in tool/array.py, tool/ifc.py, tool/geometry.py. Sweep the four user-action sites that resolve array child/parent GUIDs without a guard - they shared the same bug class but were reachable from different operators (regenerate_array, RegenerateArray clear, duplicate_ifc_objects, process_arrays). An already-missing entity is the desired terminal state for each, so the fix is try/except RuntimeError: continue/skip. setup_arrays now also collects each parent with at least one stale child GUID into IfcImporter.broken_arrays, surfaced via a new Project panel banner mirroring the existing pending_opening_recut UX. The banner reports the count and offers "Select Elements" to navigate to the affected array parents and a Dismiss button. constrain_children_to_parent was being called once per layer inside setup_arrays' for loop even though it always iterates all layers internally - lifted out of the loop (pre-existing N x perf bug that the stale-GUID print exposed). Regression tests: - test_returns_none_when_guid_not_in_file pins the get_object_from_guid Optional contract. - test_remove_array_tolerates_stale_child_guid injects a fake child GUID into BBIM_Array.Data and asserts bim.remove_array completes cleanly. Generated with the assistance of an AI coding tool. * Black: wrap long bl_description in dismiss_pending_array_repair Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/import_ifc.py | 13 ++++- .../bonsai/bim/module/geometry/operator.py | 5 +- src/bonsai/bonsai/bim/module/model/array.py | 6 ++- .../bonsai/bim/module/project/__init__.py | 3 ++ .../bonsai/bim/module/project/operator.py | 52 +++++++++++++++++++ src/bonsai/bonsai/bim/module/project/prop.py | 13 +++++ src/bonsai/bonsai/bim/module/project/ui.py | 14 +++++ src/bonsai/bonsai/tool/blender.py | 5 +- src/bonsai/bonsai/tool/geometry.py | 5 +- src/bonsai/bonsai/tool/model.py | 5 +- src/bonsai/test/tool/test_blender.py | 13 +++++ src/bonsai/test/tool/test_model.py | 24 +++++++++ 12 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index ca355cc8fa..81d577026a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -223,6 +223,7 @@ class IfcImporter: self.elements: set[ifcopenshell.entity_instance] = set() self.annotations: set[ifcopenshell.entity_instance] = set() self.gross_elements: set[ifcopenshell.entity_instance] = set() + self.broken_arrays: set[ifcopenshell.entity_instance] = set() self.element_types: set[ifcopenshell.entity_instance] = set() self.spatial_elements: set[ifcopenshell.entity_instance] = set() self.meshes: dict[str, OBJECT_DATA_TYPE] = {} @@ -1220,7 +1221,17 @@ class IfcImporter: continue for i in range(len(data)): tool.Array.set_children_lock_state(element, i, True) - tool.Array.constrain_children_to_parent(element) + tool.Array.constrain_children_to_parent(element) + for layer in data: + for child_guid in layer.get("children", ()): + try: + self.file.by_guid(child_guid) + except RuntimeError: + print( + f"setup_arrays: array parent {element.GlobalId} references missing " + f"child GUID {child_guid!r}." + ) + self.broken_arrays.add(element) def update_linked_aggregates(self): # TODO Remove this after a while. See commit 17d6b8a diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 01b8b332e2..9bc0566532 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1040,7 +1040,10 @@ class OverrideDelete(bpy.types.Operator): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") if not pset: continue - array_parents.add(ifc_file.by_guid(pset["Parent"])) + try: + array_parents.add(ifc_file.by_guid(pset["Parent"])) + except RuntimeError: + continue for array_parent in array_parents: array_parent_obj = tool.Ifc.get_object(array_parent) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index bef8f6fe11..956a08462c 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -423,7 +423,11 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): pset = tool.Ifc.get().by_id(pset["id"]) for array in arrays: for child in set(array["children"]): - if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): + try: + child_element = tool.Ifc.get().by_guid(child) + except RuntimeError: + continue + if child_obj := tool.Ifc.get_object(child_element): tool.Geometry.delete_ifc_object(child_obj) array["children"].clear() # Always operate on the parent — this operator can be invoked with diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 7243bd51b9..945cac5f66 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -30,7 +30,9 @@ classes = ( operator.BIM_FH_import_ifc, operator.BIM_OT_apply_pending_opening_cuts, operator.BIM_OT_dismiss_multi_instance_warning, + operator.BIM_OT_dismiss_pending_array_repair, operator.BIM_OT_dismiss_pending_opening_cuts, + operator.BIM_OT_select_pending_array_repair, operator.BIM_OT_select_pending_opening_cuts, operator.BIM_OT_load_clipping_planes, operator.BIM_OT_save_clipping_planes, @@ -86,6 +88,7 @@ classes = ( prop.FilterCategory, prop.Link, prop.EditedObj, + prop.PendingArrayRepair, prop.PendingOpeningRecut, prop.BIMProjectProperties, prop.MeasureToolSettings, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 8401d8c113..31b1248785 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1236,6 +1236,17 @@ class LoadProjectElements(bpy.types.Operator): f"Apply manually from the Project panel.", ) + props.pending_array_repair.clear() + if ifc_importer.broken_arrays: + for element in ifc_importer.broken_arrays: + item = props.pending_array_repair.add() + item.ifc_definition_id = element.id() + self.report( + {"WARNING"}, + f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. " + f"Inspect from the Project panel.", + ) + tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() @@ -3539,3 +3550,44 @@ class BIM_OT_select_pending_opening_cuts(bpy.types.Operator): tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects) self.report({"INFO"}, f"Selected {len(objects)} element(s).") return {"FINISHED"} + + +class BIM_OT_select_pending_array_repair(bpy.types.Operator): + bl_idname = "bim.select_pending_array_repair" + bl_label = "Select Array Parents With Missing Children" + bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file." + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + ifc_file = tool.Ifc.get() + if ifc_file is None: + self.report({"INFO"}, "No IFC file loaded.") + return {"CANCELLED"} + objects: list[bpy.types.Object] = [] + for item in tool.Project.get_project_props().pending_array_repair: + try: + element = ifc_file.by_id(item.ifc_definition_id) + except RuntimeError: + continue + obj = tool.Ifc.get_object(element) + if obj is not None: + objects.append(obj) + if not objects: + self.report({"INFO"}, "No matching Blender objects found for the pending list.") + return {"CANCELLED"} + tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects) + self.report({"INFO"}, f"Selected {len(objects)} array parent(s).") + return {"FINISHED"} + + +class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator): + bl_idname = "bim.dismiss_pending_array_repair" + bl_label = "Dismiss Pending Array Repair" + bl_description = ( + "Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged." + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + tool.Project.get_project_props().pending_array_repair.clear() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index e1214d4824..53b239ee6c 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -306,6 +306,17 @@ class PendingOpeningRecut(PropertyGroup): ifc_definition_id: int +class PendingArrayRepair(PropertyGroup): + """One array parent whose ``BBIM_Array.Data`` references at least one + child GUID that does not resolve in the current IFC file. The user can + select these parents from the Project panel banner to inspect them.""" + + ifc_definition_id: IntProperty(name="IFC Definition ID") + + if TYPE_CHECKING: + ifc_definition_id: int + + class BIMProjectProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) is_loading: BoolProperty(name="Is Loading", default=False) @@ -372,6 +383,7 @@ class BIMProjectProperties(PropertyGroup): description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings", ) pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut) + pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair) style_limit: IntProperty( name="Style Limit", default=300, @@ -538,6 +550,7 @@ class BIMProjectProperties(PropertyGroup): angular_tolerance: float void_limit: int pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut] + pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair] style_limit: int distance_limit: float false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"] diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 0759679d42..a793dbc455 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -205,6 +205,20 @@ class BIM_PT_project(Panel): row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY") row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL") + if pending := pprops.pending_array_repair: + box = self.layout.box() + box.alert = True + box.label(text="Arrays With Missing Children", icon="ERROR") + draw_multiline_text( + box.column(align=True), + f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. " + f"The arrays loaded incomplete. Select to inspect, or dismiss.", + context=context, + ) + row = box.row(align=True) + row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF") + row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL") + if props.ifc_file: self.draw_loaded_project_ui(context) else: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 78b978849d..b96878195b 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1274,7 +1274,10 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_object_from_guid(cls, guid: str) -> Union[bpy.types.Object, None]: - element = tool.Ifc.get().by_guid(guid) + try: + element = tool.Ifc.get().by_guid(guid) + except RuntimeError: + return None obj = tool.Ifc.get_object(element) if obj: return obj diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 14e4c5cfcc..cca28d8e57 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -2478,7 +2478,10 @@ class Geometry(bonsai.core.tool.Geometry): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") if not pset: continue - array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + try: + array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + except RuntimeError: + continue for array_parent in array_parents: array_parent_obj = tool.Ifc.get_object(array_parent) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index bc6f49063b..7060b2bca0 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1320,7 +1320,10 @@ class Model(bonsai.core.tool.Model): # handle elements unused in the array after regeneration removed_children = set(existing_children) - set(array["children"]) for removed_child in removed_children: - element = tool.Ifc.get().by_guid(removed_child) + try: + element = tool.Ifc.get().by_guid(removed_child) + except RuntimeError: + continue # Strip any wall/slab opening cut by this child before deletion, # so the host's HasOpenings shrinks symmetrically with count. if getattr(element, "FillsVoids", None): diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index 7a2f8017d3..e532bacc02 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -183,3 +183,16 @@ class TestNpFrombufferLegacy(NewFile): result = subject.np_frombuffer_legacy(data, n) assert result.shape == (n,) np.testing.assert_allclose(result, np.arange(n)) + + +class TestGetObjectFromGuidMissing(NewFile): + """``get_object_from_guid`` must honour its ``Optional[Object]`` return + contract: a GUID that does not resolve in the current IFC file yields + ``None``, not a ``RuntimeError``. Callers iterate stored GUID lists + (array children, library refs, …) and rely on the falsy return to + skip stale entries.""" + + def test_returns_none_when_guid_not_in_file(self): + bpy.ops.bim.create_project() + assert tool.Ifc.get() is not None + assert subject.get_object_from_guid("3iyt7r$Hf4_hQYNhBIDJI4") is None diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index fd9e3dfad8..9d21aedc1a 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -672,6 +672,30 @@ class TestUsingArrays(NewFile): pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") assert pset is None, (obj, pset) + def test_remove_array_tolerates_stale_child_guid(self): + """``bim.remove_array`` and the underlying ``regenerate_array`` must + survive a child GUID in ``BBIM_Array.Data`` that no longer resolves + in the file. Real-world IFC files can carry dangling array refs + from external edits — the remove path is meant to delete those + children, so an already-missing entity is the desired terminal + state, not a fatal error.""" + self.setup_array() + parent_obj = bpy.context.active_object + parent_element = tool.Ifc.get_entity(parent_obj) + ifc_file = tool.Ifc.get() + + pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") + data = json.loads(pset["Data"]) + data[0]["children"].append("3iyt7r$Hf4_hQYNhBIDJI4") + ifcopenshell.api.pset.edit_pset( + ifc_file, + pset=ifc_file.by_id(pset["id"]), + properties={"Data": json.dumps(data)}, + ) + + bpy.ops.bim.remove_array(item=0) + assert ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") is None + class TestApplyIfcMaterialChanges(NewFile): def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]: From a56b5660d0ab62c8b1712169da3720957099f3a5 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 13:23:46 +0200 Subject: [PATCH 25/35] Extract transform-modal gate + viewport helpers to tool.Blender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform-modal active check (Bonsai keymap macros + Blender's TRANSFORM_OT_* family) was a module-local helper in drawing/gizmos.py used by per-gizmo poll callbacks. It needs to be shared with other features that gate per-frame side effects on whether a drag is in progress (clip box plane re-arming, future modal-aware decorators). Move BONSAI_TRANSFORM_MACROS and the gate into tool.Blender as is_transform_modal_active classmethod; widen its window scan to all WM windows for callers without a window-bound context (depsgraph callbacks). Leave a thin module-local alias in drawing/gizmos.py so AST scans and existing call sites stay decoupled from the helper's home module. Also add generic Blender helpers needed by the clip-box feature (reusable by any future feature): - iter_view3d_regions: yield (area, region, region_3d) for every WINDOW region in every 3D viewport — for clip-plane / draw-handler fanout. - get_or_create_collection: idempotent named-collection lookup + link to a scene. - is_in_edit_mode: True iff the active object is in any EDIT_* mode — for features that need to suspend per-tick work during vert/edge/face manipulation. - serialize_matrix / deserialize_matrix / hash_matrix: round-trip a 4x4 matrix as a 16-float CSV string for IFC pset persistence + a matching hash for cache keys. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 39 +----- src/bonsai/bonsai/tool/blender.py | 124 +++++++++++++++++- 2 files changed, 129 insertions(+), 34 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 6028ac055d..99db87d08d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL -_BONSAI_TRANSFORM_MACROS = frozenset( - { - # Bonsai overrides Blender's default move/duplicate keymaps with - # macros that wrap TRANSFORM_OT_translate. While a macro is the outer - # modal entry, the inner TRANSFORM_OT_translate does not surface in - # window.modal_operators — the macro's own idname does. The - # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at - # runtime (the class declaration uses the dotted ``bim.`` form). - "BIM_OT_override_move_macro", # G key - "BIM_OT_override_object_duplicate_move_macro", # Shift+D - "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D - "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D - } -) - - def _is_transform_modal_active(context) -> bool: - """True iff a Blender transform modal (G/R/S and siblings, including - Bonsai's macro overrides) is currently driving per-frame ``matrix_world`` - updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of - running modal operators. Parametric gizmo groups gate poll + draw_prepare - on this so they hide for the duration of the drag instead of sliding - off-cursor as the matrix updates each frame.""" - window = getattr(context, "window", None) - if window is None: - return False - modal_ops = getattr(window, "modal_operators", None) - if not modal_ops: - return False - for op in modal_ops: - idname = op.bl_idname - if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS: - return True - return False + """Module-local alias for ``tool.Blender.is_transform_modal_active``. + + Preserved as a name so AST scans and call sites in this file stay + decoupled from the helper's home module. + """ + return tool.Blender.is_transform_modal_active(context) def _hide_all_non_modal_gizmos(group) -> None: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 78b978849d..6bdb60fa6b 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -30,7 +30,15 @@ import sys import tempfile import traceback import types -from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized +from collections.abc import ( + Callable, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, + Sized, +) from datetime import datetime from functools import cache, lru_cache from pathlib import Path @@ -575,6 +583,120 @@ class Blender(bonsai.core.tool.Blender): else: decorator_cls.uninstall() + # Bonsai overrides Blender's default move/duplicate keymaps with macros + # that wrap TRANSFORM_OT_translate. While a macro is the outer modal + # entry, the inner TRANSFORM_OT_translate does not surface in + # window.modal_operators — the macro's own idname does. The ``BIM_OT_`` + # prefix is what Blender returns from ``bl_idname`` at runtime (the + # class declaration uses the dotted ``bim.`` form). + BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset( + { + "BIM_OT_override_move_macro", # G key + "BIM_OT_override_object_duplicate_move_macro", # Shift+D + "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D + "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D + } + ) + + @classmethod + def is_transform_modal_active(cls, context: bpy.types.Context) -> bool: + """True iff a Blender transform modal (G/R/S and siblings, including + Bonsai's macro overrides) is currently driving per-frame + ``matrix_world`` updates. Reads ``window.modal_operators`` — the + Blender 4.2+ collection of running modal operators. Callers gate + per-frame side effects (gizmo positioning, IFC persistence, etc.) + on this so they don't fire during the drag. + + Falls back to scanning every window in the window manager when + ``context.window`` is ``None`` — depsgraph callbacks run with a + limited context where ``context.window`` is typically missing, + but the modal is still active on one of the WM's windows. + """ + window = getattr(context, "window", None) + if window is not None and getattr(window, "modal_operators", None): + windows = [window] + else: + wm = getattr(context, "window_manager", None) or bpy.context.window_manager + if wm is None: + return False + windows = list(wm.windows) + for w in windows: + modal_ops = getattr(w, "modal_operators", None) + if not modal_ops: + continue + for op in modal_ops: + idname = op.bl_idname + if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS: + return True + return False + + @classmethod + def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool: + """True iff the active object is in any edit-style mode. + + Catches every ``EDIT_*`` variant (mesh, curve, armature, + metaball, lattice, surface, text, grease pencil). Defaults to + ``OBJECT`` when the mode attribute is missing so background-mode + callers (no UI context) don't false-positive. + """ + ctx = context if context is not None else bpy.context + mode = getattr(ctx, "mode", "OBJECT") + return mode.startswith("EDIT_") + + @classmethod + def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]: + """Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport. + + Useful for features that need to act on every visible 3D viewport + (clip planes, draw handlers, region redraw fanout). Empty + generator when ``bpy.context.screen`` is unavailable (shutdown, + background mode without a screen). + """ + screen = getattr(getattr(bpy, "context", None), "screen", None) + if screen is None: + return + for area in screen.areas: + if area.type != "VIEW_3D": + continue + for region in area.regions: + if region.type != "WINDOW": + continue + region_3d = getattr(region, "data", None) + if region_3d is None: + continue + yield area, region, region_3d + + @classmethod + def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection: + """Return the named collection, creating + linking it to ``scene`` if absent.""" + collection = bpy.data.collections.get(name) + if collection is None: + collection = bpy.data.collections.new(name) + scene.collection.children.link(collection) + return collection + + @classmethod + def serialize_matrix(cls, matrix: Matrix) -> str: + """Serialize a 4x4 matrix as a 16-float comma-separated string. + + Round-trip pair with :meth:`deserialize_matrix`. Used for storing + a matrix in an IFC pset string property without losing precision + (``%.9g`` carries ~9 significant digits, enough for ``float32`` + round-trip). + """ + return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4)) + + @classmethod + def deserialize_matrix(cls, text: str) -> Matrix: + """Inverse of :meth:`serialize_matrix`.""" + floats = [float(v) for v in text.split(",")] + return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)]) + + @classmethod + def hash_matrix(cls, matrix: Matrix) -> int: + """Hash a 4x4 matrix by its 16 floats. Useful as a cache key.""" + return hash(tuple(matrix[r][c] for r in range(4) for c in range(4))) + @classmethod def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool: """True when the viewport camera is looking ~straight down (or up) the world Z axis. From 5cc9daa2f9ba0cf81aafdee7ed26d31ed85fefc3 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 13:24:42 +0200 Subject: [PATCH 26/35] Add OBB clip-plane and planar tessellation to tool.Cad Adds geometry primitives the viewport clip-box feature needs: - obb_world_clip_planes / obb_clip_planes_from_matrix: derive the 6 inward clip planes of an oriented bounding box (or unit cube under a matrix_world) in RegionView3D.clip_planes form. expand / expand_rel margins let callers visualising the box with overlapping geometry (an empty CUBE display sharing edges with the planes) keep the box's own wireframe inside the clip volume. - point_is_inside_clip_planes / corners_might_cross_clip_planes: cheap reject tests for the per-mesh capping pass to skip the expensive bisect when an object's AABB is fully outside the box. - newell_normal / plane_basis: robust planar-ring normal for thin near-degenerate cap rings where a two-edge cross product is unstable. - tessellate_ring_planar: triangulate [outer, *inners] 3D rings in the outer ring's best-fit plane, with a shapely constrained-Delaunay fallback for the known failure mode of mathutils.tessellate_polygon on complex concave polygons-with-holes. Tests cover unit-box, translated, rotated, and scaled cases for the OBB-from-matrix builder + the rejection helpers. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/cad.py | 231 +++++++++++++++++++++++++++++++ src/bonsai/test/tool/test_cad.py | 88 +++++++++++- 2 files changed, 318 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 957c8339fb..2a1f5f9c69 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -206,6 +206,237 @@ class Cad: """ return geometry.intersect_line_plane(v1, v2, plane_co, plane_no) + @classmethod + def obb_world_clip_planes( + cls, + center: Vector, + axes: tuple[Vector, Vector, Vector], + half_extents: Vector, + ) -> tuple[tuple[float, float, float, float], ...]: + """Return the 6 inward world clip planes of an oriented bounding box. + + Each plane is a 4-tuple ``(a, b, c, d)`` for the equation + ``a*x + b*y + c*z + d``; a point is KEPT when the value is ``>= 0`` + for every plane, matching ``RegionView3D.clip_planes`` semantics. + Return order is ``(+x, -x, +y, -y, +z, -z)`` where ``+x`` is the face + on the positive side of ``axes[0]``. ``axes`` are assumed orthonormal. + """ + cx, cy, cz = center.x, center.y, center.z + planes: list[tuple[float, float, float, float]] = [] + for i in range(3): + ux, uy, uz = axes[i].x, axes[i].y, axes[i].z + h = float(half_extents[i]) + px, py, pz = cx + h * ux, cy + h * uy, cz + h * uz + nx, ny, nz = -ux, -uy, -uz + planes.append((nx, ny, nz, -(nx * px + ny * py + nz * pz))) + px, py, pz = cx - h * ux, cy - h * uy, cz - h * uz + planes.append((ux, uy, uz, -(ux * px + uy * py + uz * pz))) + return tuple(planes) + + @classmethod + def obb_clip_planes_from_matrix( + cls, + matrix_world: Matrix, + expand: float = 0.0, + expand_rel: float = 0.0, + ) -> tuple[tuple[float, float, float, float], ...]: + """Return the 6 inward world clip planes for the unit cube under ``matrix_world``. + + The implicit box is ``[-1, +1]^3`` in object-local space, so the + host's ``matrix_world`` translation is the world centre, its + rotation orients the box axes, and each column's magnitude is the + world half-extent along that local axis. ``expand`` (absolute + world units) and ``expand_rel`` (fraction of each axis's + half-extent) both add an outward margin — callers that visualise + the box with overlapping geometry (e.g. an empty CUBE display + sharing edges with the clip planes) pass non-zero values so the + box's own wireframe sits safely INSIDE the clip volume. Use the + relative form when the box is rendered at varying scales, since + the depth-buffer precision needed to keep an edge unclipped grows + with world-coordinate magnitude. + """ + world_center = matrix_world.col[3].xyz + linear = matrix_world.to_3x3() + world_axes = [] + world_half_list = [] + for i in range(3): + v = linear.col[i].copy() + length = v.length + if length > 0.0: + world_axes.append(v / length) + else: + world_axes.append(Vector((0.0, 0.0, 0.0))) + world_half_list.append(length + expand + length * expand_rel) + return cls.obb_world_clip_planes( + world_center, + (world_axes[0], world_axes[1], world_axes[2]), + Vector(world_half_list), + ) + + @classmethod + def point_is_inside_clip_planes( + cls, + planes: tuple[tuple[float, float, float, float], ...], + point: Vector, + eps: float = 1e-6, + ) -> bool: + """True iff ``point`` is on the kept side of every plane (inclusive).""" + x, y, z = point.x, point.y, point.z + for a, b, c, d in planes: + if a * x + b * y + c * z + d < -eps: + return False + return True + + @classmethod + def newell_normal(cls, points: Sequence) -> Vector: + """Newell's-method normal for a (possibly non-planar) 3D polygon ring. + + Robust for thin / near-degenerate rings where a two-edge cross + product would be unstable. + """ + nx = ny = nz = 0.0 + n = len(points) + for i in range(n): + cur = points[i] + nxt = points[(i + 1) % n] + nx += (cur[1] - nxt[1]) * (cur[2] + nxt[2]) + ny += (cur[2] - nxt[2]) * (cur[0] + nxt[0]) + nz += (cur[0] - nxt[0]) * (cur[1] + nxt[1]) + return Vector((nx, ny, nz)) + + @classmethod + def plane_basis(cls, points: Sequence) -> tuple[Vector, Vector]: + """Return an orthonormal ``(u, v)`` basis for the ring's best-fit plane.""" + normal = cls.newell_normal(points) + if normal.length < 1e-12: + normal = Vector((0.0, 0.0, 1.0)) + normal = normal.normalized() + ref = Vector((1.0, 0.0, 0.0)) + if abs(normal.x) > 0.9: + ref = Vector((0.0, 1.0, 0.0)) + u = normal.cross(ref) + if u.length < 1e-12: + ref = Vector((0.0, 0.0, 1.0)) + u = normal.cross(ref) + u = u.normalized() + v = normal.cross(u).normalized() + return u, v + + @classmethod + def tessellate_ring_planar(cls, polyline_list: list[list]) -> list[tuple[int, int, int]]: + """Triangulate ``[outer, *inners]`` 3D coord rings in their own plane. + + Projects every ring onto the outer ring's best-fit plane and + returns ``(i, j, k)`` index triples into the flat + ``outer + inners[0] + inners[1] + ...`` vertex list. Falls + back to a shapely constrained Delaunay triangulation when + ``mathutils.geometry.tessellate_polygon`` silently leaves ring + vertices unused (its known failure mode on complex concave + polygons-with-holes). + """ + from mathutils.geometry import tessellate_polygon + + if not polyline_list or not polyline_list[0]: + return [] + outer = polyline_list[0] + u, v = cls.plane_basis(outer) + origin = Vector(outer[0]) + + def _project_xy(ring): + return [((Vector(co) - origin).dot(u), (Vector(co) - origin).dot(v)) for co in ring] + + projected_xy = [_project_xy(ring) for ring in polyline_list] + projected = [[Vector((x, y, 0.0)) for x, y in ring] for ring in projected_xy] + triangles = tessellate_polygon(projected) + + n_total = sum(len(r) for r in projected_xy) + used = {i for tri in triangles for i in tri} + if triangles and len(used) >= n_total: + return triangles + + fallback = cls._tessellate_via_shapely(projected_xy) + return fallback if fallback else triangles + + @classmethod + def _tessellate_via_shapely(cls, projected_xy: list[list[tuple[float, float]]]) -> list[tuple[int, int, int]]: + """Constrained-Delaunay fallback for :meth:`tessellate_ring_planar`. + + Honours the polygon's boundary AND holes. Returns ``[]`` when + shapely is unavailable or the polygon can't be cleaned via + ``buffer(0)``. + """ + try: + from shapely.geometry import Polygon + except Exception: + return [] + outer = projected_xy[0] + inners = projected_xy[1:] + if len(outer) < 3: + return [] + try: + poly = Polygon(outer, inners) + poly = poly if poly.is_valid else poly.buffer(0) + if poly.is_empty: + return [] + except Exception: + return [] + + flat = list(outer) + for r in inners: + flat.extend(r) + + def _key(x, y): + return (round(x, 6), round(y, 6)) + + index_of: dict[tuple[float, float], int] = {} + for idx, (x, y) in enumerate(flat): + index_of.setdefault(_key(x, y), idx) + + try: + from shapely import constrained_delaunay_triangles + + res = constrained_delaunay_triangles(poly) + tri_geoms = list(getattr(res, "geoms", []) or []) + except Exception: + try: + from shapely.ops import triangulate + + tri_geoms = [t for t in triangulate(poly) if poly.contains(t.representative_point())] + except Exception: + return [] + + out: list[tuple[int, int, int]] = [] + for t in tri_geoms: + coords = list(t.exterior.coords)[:-1] + if len(coords) != 3: + continue + idxs = [index_of.get(_key(x, y)) for x, y in coords] + if any(i is None for i in idxs): + continue + out.append(tuple(idxs)) + return out + + @classmethod + def corners_might_cross_clip_planes( + cls, + planes: tuple[tuple[float, float, float, float], ...], + corners: Sequence[Vector], + ) -> bool: + """Conservative reject test: True if ``corners`` might cross the clip volume. + + Returns False only when at least one plane has ALL corners on its + rejected side — meaning the convex hull of ``corners`` is fully + outside the clip volume and a per-mesh bisect can be skipped. + Returns True otherwise (possibly with false positives — never + false negatives), so callers always cap any object that actually + crosses the box. ``corners`` is typically the 8 world-space corners + of an object's bound box. + """ + for a, b, c, d in planes: + if all(a * v.x + b * v.y + c * v.z + d < 0.0 for v in corners): + return False + return True + def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9): """ Numpy version of intersect_edge_plane diff --git a/src/bonsai/test/tool/test_cad.py b/src/bonsai/test/tool/test_cad.py index 2e84c71718..51491e259d 100644 --- a/src/bonsai/test/tool/test_cad.py +++ b/src/bonsai/test/tool/test_cad.py @@ -16,7 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from mathutils import Vector +import math + +from mathutils import Matrix, Vector from bonsai.tool.cad import Cad as subject from test.bim.bootstrap import NewFile @@ -88,3 +90,87 @@ class TestClosestPoints(NewFile): edge1 = (V(0, 0, 0), V(0, 0, 0)) edge2 = (V(1, 0, 1), V(2, 0, 2)) assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0]) + + +class TestObbWorldClipPlanes(NewFile): + def test_unit_box_at_origin_returns_axis_aligned_planes(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert planes[0] == (-1.0, 0.0, 0.0, 1.0) + assert planes[1] == (1.0, 0.0, 0.0, 1.0) + assert planes[2] == (0.0, -1.0, 0.0, 1.0) + assert planes[3] == (0.0, 1.0, 0.0, 1.0) + assert planes[4] == (0.0, 0.0, -1.0, 1.0) + assert planes[5] == (0.0, 0.0, 1.0, 1.0) + + def test_center_is_inside_all_planes(self): + center = V(5, -3, 2) + planes = subject.obb_world_clip_planes( + center, + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(2, 1, 0.5), + ) + assert subject.point_is_inside_clip_planes(planes, center) + + def test_point_just_outside_positive_x_face_rejected(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0)) + + def test_rotated_obb_clips_along_rotated_axes(self): + s = math.sin(math.radians(45)) + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(s, s, 0), V(-s, s, 0), V(0, 0, 1)), + V(1, 1, 1), + ) + assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0)) + + def test_zero_extent_axis_does_not_raise(self): + planes = subject.obb_world_clip_planes( + V(0, 0, 0), + (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), + V(1, 1, 0), + ) + assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + + +class TestObbClipPlanesFromMatrix(NewFile): + def test_identity_matches_unit_box(self): + planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4)) + assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0)) + + def test_translated_host_shifts_clip_region(self): + translated = Matrix.Translation(V(10, 0, 0)) + planes = subject.obb_clip_planes_from_matrix(translated) + assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0)) + assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0)) + + def test_z_rotation_rotates_box(self): + rot = Matrix.Rotation(math.radians(45), 4, "Z") + planes = subject.obb_clip_planes_from_matrix(rot) + assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0)) + + def test_host_scale_scales_box_extents(self): + scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0)) + planes = subject.obb_clip_planes_from_matrix(scaled) + assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0)) + + def test_non_uniform_scale_axis_independent(self): + scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0)) + planes = subject.obb_clip_planes_from_matrix(scaled) + assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0)) + assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0)) From 51eb8aece70b7d0f610d065dd2c17a596c5fb1f8 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 13:26:08 +0200 Subject: [PATCH 27/35] Add bisect_and_cap helper to tool.Geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisects a BMesh against a set of planes (clear_outer per plane), then fills the cut edges as cap faces tagged via a BMesh int layer so the tag survives subsequent bisects. Cut edges are grouped into connected components before filling so a hollow profile's outer + inner loops produce two separate cap faces instead of a single welded outer face that hides the hole. Pre-welds T-junctions introduced by IFC Boolean meshes so the cut closes into a fillable loop. Callers are responsible for input mesh quality. Non-watertight inputs (terrain, single-shell surfaces) may produce degenerate cap faces — that's an accepted user-supplied data limitation which can be revisited if real-world feedback shows it matters. Used by the clip-box feature to compute cross-section caps per IFC product mesh. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/geometry.py | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 14e4c5cfcc..70c7b08803 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -157,6 +157,112 @@ class Geometry(bonsai.core.tool.Geometry): for modifier in obj.modifiers: obj.modifiers.remove(modifier) + @classmethod + def _group_edges_into_loops(cls, edges) -> list[list]: + """Group an edge set into connected components by shared vertices. + + Each returned group is a list of edges that share at least one + vertex chain. A hollow profile's bisect produces two disjoint + loops (outer ring + inner ring) — grouping splits them so each + can be filled independently as a separate cap face, rather than + ``contextual_create`` welding them into one solid outer face + with the inner loop demoted to interior decoration. + """ + edge_set = set(edges) + visited: set = set() + groups: list[list] = [] + for start in edges: + if start in visited: + continue + group: list = [] + stack: list = [start] + while stack: + e = stack.pop() + if e in visited: + continue + visited.add(e) + group.append(e) + for v in e.verts: + for adj in v.link_edges: + if adj in edge_set and adj not in visited: + stack.append(adj) + groups.append(group) + return groups + + @classmethod + def bisect_and_cap( + cls, + bm, + planes_local, + *, + tag_layer_name: str = "bbim_cap", + dist: float = 1e-4, + weld_dist: float = 1e-5, + ): + """Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts. + + Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards + the outside half-space and ``bmesh.ops.contextual_create`` fills + the resulting cut edges with cap faces tagged via a BMesh int + layer so the tag propagates to any split-children from subsequent + planes. After all planes, near-coincident vertices are welded + (``weld_dist``) so adjacent caps from the same cross-section + merge cleanly. + + Callers are responsible for input mesh quality. Non-watertight + inputs (terrain, single-shell surfaces) may produce degenerate + cap faces; that's an accepted user-supplied data limitation. + + Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty. + """ + import bmesh + + if not bm.faces: + return None + + # Pre-weld nearby verts: T-junctions in messy IFC meshes (a third + # vertex sitting in the middle of an edge from a Boolean + # operation) make the bisect cut terminate early, leaving open + # loops that no fill op can close. Welding the T-junction's + # near-coincident vertex into the host edge before bisecting + # turns the cut into a closed loop. + bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4)) + + cap_layer = bm.faces.layers.int.new(tag_layer_name) + for plane_co, plane_no in planes_local: + geom = bm.verts[:] + bm.edges[:] + bm.faces[:] + if not geom: + break + results = bmesh.ops.bisect_plane( + bm, + geom=geom, + dist=dist, + plane_co=plane_co, + plane_no=plane_no, + clear_outer=True, + ) + cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)] + if not cut_edges: + continue + # Group cut edges into connected components BEFORE filling. + # Feeding ``contextual_create`` all edges at once (outer + + # inner of a hollow profile) makes it create a SINGLE outer + # face and treat inner edges as decoration — collapsing the + # hole. Filling each connected loop separately produces one + # cap face per ring. + for loop_edges in cls._group_edges_into_loops(cut_edges): + try: + fill = bmesh.ops.contextual_create(bm, geom=loop_edges) + except (RuntimeError, TypeError): + continue + for f in fill.get("faces", []): + if isinstance(f, bmesh.types.BMFace) and f.is_valid: + f[cap_layer] = 1 + + if weld_dist > 0.0: + bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist) + return cap_layer + @classmethod def clear_scale(cls, obj: bpy.types.Object) -> None: """Apply and clear object scale. From 6147a58d7a1f5e061eb23a0a009f2fce34b63c44 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 16 Jun 2026 13:26:44 +0200 Subject: [PATCH 28/35] Add viewport clip-box feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clip box hides everything outside a user-controllable oriented bounding box, with cross-section caps drawn where IFC product geometry intersects the planes. The box is hosted on a Blender empty (CUBE display); its matrix_world is the single source of truth — G/R/S edits the empty and the viewport clip planes track. State persists through IFC save/load via a project-level pset (IfcProject.BBIM_ClipBoxes) so the boxes survive without binding to any IfcRoot entity (avoids the IFC scale-lock / strip). UI: BIM_PT_clip_box under the Sandbox tab. Prominent Enable Clipping + Show Caps toggles at top, then Add, then a UIList with per-row duplicate / remove icons. Scene-level enabled / show_caps so the "hide everything outside" intent applies file-wide; enabled is intentionally not persisted to the pset so reopening an IFC never silently hides geometry. Adding a clip box arms clipping so the user immediately sees the cut. Default spawn at the 3D cursor with scale 10 (a 20 m cube) so the volume covers a typical building storey or two rather than the meaningless 2 m unit cube. Modal-aware: depsgraph + draw-handler paths gate per-frame side effects on tool.Blender.is_transform_modal_active so dragging G/R/S on the box only writes the pset once on commit, not per frame. Shift+D / Alt+D / Ctrl+Shift+D on a clip box gets adopted as a first-class entry via the collection-to-list sync. Cap eligibility is gated on IfcElement (walls, slabs, doors, …) so spatial structure (IfcSpace, IfcBuildingStorey, IfcSite) and annotations / grids never sprout solid fills at clip boundaries. Cap rebuild is debounced behind a 1 s quiet window so external gizmo drags (and any other burst of non-Bonsai depsgraph updates) collapse to one rebuild on release. Bonsai's own G/R/S keeps the snappy on-release feel via a modal-end fast-path. The relevance filter compares a per-Object matrix hash against a baseline so a plain selection click — which Blender quirkily flags as a transform update — doesn't churn the cache or flash the caps off. Edit mode short-circuits both the rebuild scheduler and the draw handler entirely. Caps use the evaluated mesh (modifier stack applied) and a session/matrix/clip-box-hash cache so a typical scene only re-bisects meshes whose geometry actually changed. Performance: every per-frame poller (refresh, depsgraph handlers, draw handlers) short-circuits on the cheapest available check first — cap_cache emptiness for the post-view draw handler, scene_props.enabled for the rest — so a session with clipping disabled pays only one boolean read per tick. Known v1 limitations documented in tests / docstrings: hollow profiles cap as solid discs (single-ring tessellation only), non-watertight inputs may produce degenerate caps, quad-view untested, Cycles / EEVEE render not supported (GPU-overlay only). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/__init__.py | 1 + .../bonsai/bim/module/clip_box/__init__.py | 98 ++ .../bonsai/bim/module/clip_box/operator.py | 170 +++ src/bonsai/bonsai/bim/module/clip_box/prop.py | 107 ++ src/bonsai/bonsai/bim/module/clip_box/ui.py | 86 ++ src/bonsai/bonsai/bim/ui.py | 18 + src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/clip_box.py | 991 ++++++++++++++++++ src/bonsai/pytest.ini | 1 + .../test/bim/module/clip_box/__init__.py | 0 .../test/bim/module/clip_box/test_clip_box.py | 692 ++++++++++++ 11 files changed, 2165 insertions(+) create mode 100644 src/bonsai/bonsai/bim/module/clip_box/__init__.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/operator.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/prop.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/ui.py create mode 100644 src/bonsai/bonsai/tool/clip_box.py create mode 100644 src/bonsai/test/bim/module/clip_box/__init__.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_clip_box.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index fab7646162..4556a379d0 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -90,6 +90,7 @@ modules = { "web": None, "light": None, "alignment": None, + "clip_box": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..baebdd48f1 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -0,0 +1,98 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 bpy +from bpy.app.handlers import persistent + +import bonsai.tool as tool + +from . import operator, prop, ui + +classes = ( + operator.BIM_OT_add_clip_box, + operator.BIM_OT_duplicate_clip_box, + operator.BIM_OT_remove_clip_box, + operator.BIM_OT_set_active_clip_box, + operator.BIM_OT_toggle_clip_box_enabled, + prop.BIMClipBoxProperties, + prop.BIMSceneClipBoxProperties, + ui.BIM_UL_clip_box, + ui.BIM_PT_clip_box, +) + + +@persistent +def _on_depsgraph_update(scene, depsgraph): + tool.ClipBox.on_depsgraph_update(scene, depsgraph) + tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph) + + +@persistent +def _on_load_post(filepath): + # Restore the per-scene clip-box list from the project's BBIM_ClipBoxes + # pset. Runs after the standard load_post that creates Blender objects. + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.load_from_project_pset() + + +_draw_handler_pre = None +_draw_handler_post = None + + +def register(): + global _draw_handler_pre, _draw_handler_post + bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties) + bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties) + tool.ClipBox.reset_ownership() + if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update) + if _on_load_post not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_on_load_post) + if _draw_handler_pre is None: + _draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW") + if _draw_handler_post is None: + _draw_handler_post = bpy.types.SpaceView3D.draw_handler_add( + tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW" + ) + + +def unregister(): + global _draw_handler_pre, _draw_handler_post + if _draw_handler_post is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW") + except ValueError: + pass + _draw_handler_post = None + if _draw_handler_pre is not None: + try: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW") + except ValueError: + pass + _draw_handler_pre = None + if _on_load_post in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(_on_load_post) + if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update) + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_seen_object_matrices.clear() + tool.ClipBox.clear_clip_planes() + del bpy.types.Object.BIMClipBoxProperties + del bpy.types.Scene.BIMSceneClipBoxProperties diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py new file mode 100644 index 0000000000..b080daa3de --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -0,0 +1,170 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +import bpy + +import bonsai.tool as tool + +CLIP_BOX_NAME = "ClipBox" +CLIP_BOX_COLLECTION = "BBIM_ClipBoxes" + + +class BIM_OT_add_clip_box(bpy.types.Operator): + bl_idname = "bim.add_clip_box" + bl_label = "Add Clip Box" + bl_description = ( + "Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale " + "drive the viewport clip planes; resize with S, move with G, rotate with R" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + + obj = bpy.data.objects.new(CLIP_BOX_NAME, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.location = context.scene.cursor.location.copy() + # Default to a 20m cube (scale 10 around [-1, +1] local cube) so + # the volume covers a typical building storey or two rather than + # the meaningless 2m unit cube. The user resizes with S. + obj.scale = (10.0, 10.0, 10.0) + obj.show_in_front = True + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(obj) + + obj_props = tool.ClipBox.get_object_props(obj) + obj_props.is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + + # Adding a new clip box arms clipping so the user sees the cut + # immediately. Without this they'd have to find the panel + # toggle to discover the feature actually works. + scene_props.enabled = True + + tool.Blender.set_active_object(obj) + tool.ClipBox.refresh(context.scene) + # Persist to the project pset so the box round-trips through IFC + # save/load. A project-level pset avoids the IfcRoot scale lock / + # strip that a per-entity placement would trigger on export. + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_remove_clip_box(bpy.types.Operator): + bl_idname = "bim.remove_clip_box" + bl_label = "Remove Clip Box" + bl_description = "Remove this clip box and its host empty" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object") + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if index < 0 or index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + + entry = scene_props.clip_boxes[index] + obj = entry.obj + scene_props.clip_boxes.remove(index) + if scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1) + + if self.delete_object and obj is not None: + bpy.data.objects.remove(obj, do_unlink=True) + + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} + + +class BIM_OT_set_active_clip_box(bpy.types.Operator): + bl_idname = "bim.set_active_clip_box" + bl_label = "Set Active Clip Box" + bl_description = "Set this clip box as the active one driving the viewport clip" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + if self.index < 0 or self.index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + scene_props.active_clip_box_index = self.index + return {"FINISHED"} + + +class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator): + bl_idname = "bim.toggle_clip_box_enabled" + bl_label = "Toggle Clip Box" + bl_description = "Toggle whether the active clip box is driving the viewport clip planes" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + scene_props.enabled = not scene_props.enabled + return {"FINISHED"} + + +class BIM_OT_duplicate_clip_box(bpy.types.Operator): + bl_idname = "bim.duplicate_clip_box" + bl_label = "Duplicate Clip Box" + bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) + + def execute(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index + if source_index < 0 or source_index >= len(scene_props.clip_boxes): + return {"CANCELLED"} + source = scene_props.clip_boxes[source_index].obj + if source is None: + return {"CANCELLED"} + + copy = bpy.data.objects.new(source.name, None) + copy.empty_display_type = source.empty_display_type + copy.empty_display_size = source.empty_display_size + copy.show_in_front = source.show_in_front + copy.matrix_world = source.matrix_world.copy() + + collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) + collection.objects.link(copy) + + tool.ClipBox.get_object_props(copy).is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = copy + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + scene_props.enabled = True + + tool.Blender.set_active_object(copy) + tool.ClipBox.refresh(context.scene) + tool.ClipBox.save_to_project_pset(context.scene) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py new file mode 100644 index 0000000000..f503d8781f --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -0,0 +1,107 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bpy +from bpy.types import PropertyGroup + +import bonsai.tool as tool +from bonsai.bim.prop import ObjProperty + + +class BIMClipBoxProperties(PropertyGroup): + """Per-object marker for a clip-box host empty. + + The host empty's ``matrix_world`` is the single source of truth for + the clip box's pose and dimensions: translation = box centre, + rotation = box orientation, per-axis scale = world half-extents. The + visible cube comes from the empty's CUBE display. + + Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay + (``show_caps``) are global per-file and live on the Scene PG. + """ + + is_clip_box: bpy.props.BoolProperty( + default=False, + description="True when this empty was created as a clip-box host. Internal flag; not user-edited.", + ) + + if TYPE_CHECKING: + is_clip_box: bool + + +def update_active_clip_box_index(self, context): + tool.ClipBox.schedule_refresh() + tool.ClipBox.select_active_clip_box(context) + + +def update_show_caps(self, context): + tool.ClipBox.schedule_refresh() + + +def update_enabled(self, context): + tool.ClipBox.schedule_refresh() + + +class BIMSceneClipBoxProperties(PropertyGroup): + """Scene-level registry of clip boxes in this file. + + Multiple boxes may exist; ``active_clip_box_index`` selects which one + drives the viewport clip at any time. ``enabled`` and ``show_caps`` + are global because the user's intent ("hide everything outside the + box", "draw cap overlays") applies file-wide, not per box. + + ``enabled`` is intentionally not persisted to the project pset: + opening a fresh IFC should never silently hide geometry behind a + remembered toggle. Selecting any clip-box empty in the viewport + re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`). + """ + + clip_boxes: bpy.props.CollectionProperty(type=ObjProperty) + active_clip_box_index: bpy.props.IntProperty( + default=0, + min=0, + update=update_active_clip_box_index, + description="Index of the clip box currently driving the viewport clip planes", + ) + enabled: bpy.props.BoolProperty( + name="Enabled", + default=False, + update=update_enabled, + description="When enabled, the active clip box hides all viewport geometry outside its 6 faces", + ) + show_caps: bpy.props.BoolProperty( + name="Show Caps", + default=True, + update=update_show_caps, + description=( + "Draw filled cross-section caps where IFC product geometry " + "crosses the active clip planes. Disable for performance on " + "very heavy scenes" + ), + ) + + if TYPE_CHECKING: + active_clip_box_index: int + enabled: bool + show_caps: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py new file mode 100644 index 0000000000..b5ae95d9e2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -0,0 +1,86 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +from bpy.types import Panel, UIList + +import bonsai.tool as tool + + +class BIM_UL_clip_box(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): + obj = item.obj + if obj is None: + layout.label(text="(missing)", icon="ERROR") + return + row = layout.row(align=True) + row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE") + row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index + row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index + + +class BIM_PT_clip_box(Panel): + bl_idname = "BIM_PT_clip_box" + bl_label = "Clip Box" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_options = {"DEFAULT_CLOSED"} + bl_parent_id = "BIM_PT_tab_sandbox" + + def draw(self, context): + layout = self.layout + scene_props = tool.ClipBox.get_scene_props(context.scene) + + toggles = layout.row(align=True) + toggles.scale_y = 2.0 + toggles.prop( + scene_props, + "enabled", + text="Enable Clipping", + icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON", + toggle=True, + ) + toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True) + + layout.separator() + layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + + layout.template_list( + "BIM_UL_clip_box", + "", + scene_props, + "clip_boxes", + scene_props, + "active_clip_box_index", + rows=3, + ) + + obj = tool.ClipBox.get_active_clip_box(context.scene) + if obj is None: + layout.label(text="No active clip box", icon="INFO") + return + + col = layout.column(align=True) + col.label(text="Edit the empty with G / R / S to move / rotate / resize") + col.prop(obj, "location") + col.prop(obj, "rotation_euler") + col.prop(obj, "scale") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 84bff58927..ca1d176fda 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -517,6 +517,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): size=4, description="Color of not selected verts/edges (used in profile editing mode)", ) + clip_box_cap_color: bpy.props.FloatVectorProperty( + name="Clip Box Caps Color", + subtype="COLOR", + default=(0.0, 0.0, 0.0, 1.0), + min=0.0, + max=1.0, + size=4, + description="Fill color of clip-box cross-section caps", + ) decorator_color_special: bpy.props.FloatVectorProperty( name="Special Elements Color", subtype="COLOR", @@ -806,6 +815,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.row().prop(self, "decorator_color_special") layout.row().prop(self, "decorator_color_error") layout.row().prop(self, "decorator_color_background") + bonsai.bim.helper.draw_expandable_panel( + layout, + context, + "Clip Box", + self.draw_clip_box_colors, + ) + + def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + layout.row().prop(self, "clip_box_cap_color") def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: box = layout.box() diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index afdec36b84..f927e5e1e1 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -30,6 +30,7 @@ from bonsai.tool.bsdd import Bsdd from bonsai.tool.cad import Cad from bonsai.tool.clash import Clash from bonsai.tool.classification import Classification +from bonsai.tool.clip_box import ClipBox from bonsai.tool.collector import Collector from bonsai.tool.connection import Connection from bonsai.tool.context import Context diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py new file mode 100644 index 0000000000..1b30328a1d --- /dev/null +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -0,0 +1,991 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Optional + +import bpy + +import bonsai.tool as tool + +if TYPE_CHECKING: + from bonsai.bim.module.clip_box.prop import ( + BIMClipBoxProperties, + BIMSceneClipBoxProperties, + ) + + +PlaneTuple = tuple[float, float, float, float] +PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] + +# Outward margin (world units) so the empty's CUBE display edges sit +# safely INSIDE the clip volume. Absolute (not relative-to-scale) +# because a relative multiplier balloons with scale and produces a +# visibly-wrong gap between the wireframe and the clipped geometry. +# Sub-mesh-precision value: visually invisible at any reasonable IFC +# scale yet large enough to keep the empty's own wireframe edges off +# the clip planes when float-precision accumulation pushes a corner +# a fractional epsilon outward. +_CLIP_EXPAND_ABS = 1e-6 + + +class ClipBox: + """Driver for the viewport clip-box feature. + + Owns the bridge between ``BIMClipBoxProperties`` on a host empty and + Blender's ``RegionView3D.clip_planes`` machinery. Plane math is in + ``Cad``; this class is the bpy adapter. + + Region-ownership: ``_owned`` tracks which regions we have armed so + a subsequent arm on the same region can skip the first-arm operator + path and write planes directly. Keyed by ``region.as_pointer()``. + """ + + _owned: set[int] = set() + _region_by_key: dict[int, tuple[Any, Any]] = {} + _refresh_pending: bool = False + _last_seen_ifc_id: int = 0 + # Tracks the last matrix we persisted to the pset, keyed by Blender + # object name. Lets the depsgraph handler detect committed transform + # changes on clip boxes rehydrated from the project pset on file load + # (which have no modal poller watching them). + _persisted_matrices: dict[str, tuple] = {} + # Names of clip boxes whose matrix changed during a transform modal. + # Flushed when the gate flips back to inactive — one save per dirty + # box on commit, no writes during the drag. + _dirty_for_save: set[str] = set() + # Per-object cache of cross-section cap triangles in world space. + # Key: obj.name. Value: (cache_key_tuple, gpu_batch). Invalidated when + # the object's mesh data block, world matrix, or the clip box matrix + # changes. Rebuild is skipped while any transform modal is dragging + # matrix_world so a continuous G/R/S shows stale caps and rebuilds on + # commit instead of re-bisecting every mesh per frame. + _cap_cache: dict[str, tuple[tuple, Any]] = {} + _last_cap_clip_box_hash: int = 0 + # Debounce window for cap rebuild from external (unknown-modal) drags: + # each depsgraph tick reschedules a timer this far in the future, so + # a burst of N ticks collapses to one rebuild after the storm. + _CAP_REBUILD_DEBOUNCE_SECONDS: float = 1.0 + _last_modal_state: bool = False + _pending_cap_rebuild: Optional[Callable[[], None]] = None + # Per-object matrix hash baseline used to tell a real transform + # change from Blender's "selection touched the flag" noise: when a + # depsgraph tick reports is_updated_transform on an Object, the + # relevance filter compares the live hash against this baseline. + _last_seen_object_matrices: dict[str, int] = {} + + @classmethod + def get_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMSceneClipBoxProperties: + if scene is None: + scene = bpy.context.scene + return scene.BIMSceneClipBoxProperties + + @classmethod + def get_object_props(cls, obj: bpy.types.Object) -> BIMClipBoxProperties: + return obj.BIMClipBoxProperties + + @classmethod + def select_active_clip_box(cls, context: bpy.types.Context) -> None: + """Deselect everything, then select + activate the active clip box's empty. + + Wired into the panel UIList's ``active_clip_box_index`` update + so clicking a row in the list does the standard outliner-style + focus: the user can immediately G/R/S the box they just picked. + + Short-circuits when the active object is already the target — + keeps multi-selections intact when the index changed because the + depsgraph sync detected the user clicking the empty directly. + No-op when no active box is resolvable. + """ + obj = cls.get_active_clip_box(context.scene) + if obj is None: + return + if getattr(context, "active_object", None) is obj: + return + tool.Blender.set_objects_selection( + context, active_object=obj, selected_objects=[obj], clear_previous_selection=True + ) + + @classmethod + def get_active_clip_box(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[bpy.types.Object]: + """Return the host empty of the currently active clip box, or ``None``.""" + props = cls.get_scene_props(scene) + index = props.active_clip_box_index + if index < 0 or index >= len(props.clip_boxes): + return None + obj = props.clip_boxes[index].obj + if obj is None: + return None + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + return None + return obj + + @classmethod + def compute_planes(cls, obj: bpy.types.Object) -> PlaneSet: + """Build the 6 inward world clip planes from the host empty's matrix_world. + + The empty's CUBE display spans local ``[-1, +1]^3`` (with + ``empty_display_size = 1``); ``matrix_world`` carries translation, + rotation, and per-axis scale, so the clip planes track the cube + exactly as it looks in the viewport. A tiny outward margin + prevents the cube's own wireframe from being clipped by its own + planes. + """ + return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS) + + @classmethod + def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet: + """Same as :meth:`compute_planes` but accepts a raw matrix. + + Used by the depsgraph handler to read the *evaluated* matrix during + a live G/R/S transform — that matrix reflects the in-progress + transform offset, while ``obj.matrix_world`` stays at the + pre-transform value until the operator commits on release. + """ + return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS) + + @classmethod + def apply_clip_planes(cls, planes: PlaneSet) -> None: + """Drive every open 3D viewport's clip planes to ``planes``. + + Always calls ``view3d.clip_border`` to refresh the region's + ``clip_bb`` at the CURRENT view. Edit-mode click-select tests + against ``clip_local`` derived from that bbox; if we don't keep + ``clip_bb`` fresh, the user can orbit the view (or transform + the clip box) and find click-select rejecting verts that ARE + visible because the test is using a stale view-frustum bbox + captured the last time we armed. Re-arming on every commit + keeps the bbox aligned with the view the user is actually at. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + key = region.as_pointer() + cls._owned.add(key) + cls._region_by_key[key] = (area, region) + cls._arm_region(area, region, region_3d, planes) + + @classmethod + def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: + """Initialize the region's clip machinery and write ``planes``. + + ``view3d.clip_border`` with a FULL-REGION rect arms ``RV3D_CLIPPING`` + without leaving the C-side ``clipbb`` degenerate (which would break + edit-mode click-select). Caller must guarantee a context in which + operators are legal (not a draw handler / depsgraph callback). + """ + with bpy.context.temp_override(area=area, region=region): + bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height) + region_3d.clip_planes = planes + region_3d.use_clip_planes = True + region_3d.update() + + @classmethod + def clear_clip_planes(cls) -> None: + """Disable clip planes on every 3D viewport region. + + Unchecking ``enabled`` or removing a clip box turns clipping + off; any prior Alt+B clip is NOT restored. The ``_owned`` + ownership table is preserved across this clear so a later + re-enable can skip the ``view3d.clip_border`` re-init (which + would re-derive ``clip_bb`` at the current view and break + edit-mode click-select alignment). The full ownership reset + happens only on IFC reload or addon unregister. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + with contextlib.suppress(ReferenceError, AttributeError, TypeError): + region_3d.use_clip_planes = False + region.tag_redraw() + + @classmethod + def _active_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[BIMSceneClipBoxProperties]: + """Scene PG iff the clipping pipeline should drive this tick, else ``None``. + + Most sessions run with clipping disabled, so the cheap + ``enabled`` check fires before any active-box lookup or + per-mesh work. Callers compose with their own further checks + (e.g. ``show_caps`` for the cap pipeline) on the returned PG. + """ + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + if not scene_props.enabled: + return None + return scene_props + + @classmethod + def refresh(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Re-arm or clear the viewport clip based on the active clip box state.""" + if cls._active_scene_props(scene) is None: + cls.clear_clip_planes() + return + obj = cls.get_active_clip_box(scene) + if obj is None: + cls.clear_clip_planes() + return + cls.apply_clip_planes(cls.compute_planes(obj)) + + @classmethod + def schedule_refresh(cls) -> None: + """Schedule a refresh on the next idle tick. + + PropertyGroup ``update=`` callbacks must not call ``bpy.ops`` (which + ``apply_clip_planes`` may need for the first-time arm) — doing so + from within a property write disrupts gizmo modal accounting and + can leave the operator stack inconsistent. Deferring via a 0-delay + timer hands the refresh to Blender's main loop, where operators are + legal. Debounced: a flag suppresses repeats while one is pending. + """ + if cls._refresh_pending: + return + cls._refresh_pending = True + + def _do_refresh(): + cls._refresh_pending = False + cls.refresh() + return None + + bpy.app.timers.register(_do_refresh, first_interval=0.0) + + @classmethod + def reset_ownership(cls) -> None: + """Drop the ownership table without touching any region. Used on register/reload.""" + cls._owned.clear() + cls._region_by_key.clear() + + PSET_NAME = "BBIM_ClipBoxes" + COLLECTION_NAME = "BBIM_ClipBoxes" + + @classmethod + def _get_project_pset_entity(cls, create: bool = False): + """Return the ``IfcPropertySet`` entity holding the clip-box state. + + Stored on ``IfcProject`` because IFC's IfcRoot pipeline locks and + strips object scale on export, which a clip box (whose size IS + its scale) cannot tolerate. A project-level pset side-steps any + per-entity placement sync. + """ + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + projects = ifc_file.by_type("IfcProject") + if not projects: + return None + project = projects[0] + existing = ifcopenshell.util.element.get_psets(project).get(cls.PSET_NAME) + if existing is not None: + return ifc_file.by_id(existing["id"]) + if not create: + return None + return tool.Ifc.run("pset.add_pset", product=project, name=cls.PSET_NAME) + + @classmethod + def mark_dirty_for_save(cls, obj_name: str) -> None: + """Note that ``obj_name`` has an unpersisted matrix change. + + Accumulates dirty names during a transform drag without + touching the IFC graph; the flush gate writes exactly one + save per dirty box once no transform modal is active. + """ + cls._dirty_for_save.add(obj_name) + + @classmethod + def flush_pending_saves(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Write the pset iff there's pending dirt AND no transform modal. + + Called from the depsgraph handler every tick. Reading + ``tool.Blender.is_transform_modal_active(bpy.context)`` checks + ``window.modal_operators`` against the known transform op names + (Blender vanilla + Bonsai macro overrides — kept centrally in + :attr:`tool.Blender.BONSAI_TRANSFORM_MACROS`), so this gate + survives any Bonsai keymap override and any Python script that + wraps the same operators. + """ + if not cls._dirty_for_save: + return + if tool.Ifc.get() is None: + cls._dirty_for_save.clear() + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + cls._dirty_for_save.clear() + cls.save_to_project_pset(scene) + + @classmethod + def save_to_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Snapshot the active clip-box state to ``IfcProject.BBIM_ClipBoxes``. + + Each clip box contributes ``Box__Name`` and ``Box__Matrix`` + (a 16-float comma-separated string). ``Count`` is the canonical + size. ``enabled`` is intentionally not persisted — opening a file + should never silently hide geometry behind a remembered toggle. + No-op when no IFC file is loaded. + """ + if tool.Ifc.get() is None: + return + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + pset = cls._get_project_pset_entity(create=True) + if pset is None: + return + + properties: dict[str, str | int] = { + "Count": len(scene_props.clip_boxes), + "ShowCaps": int(scene_props.show_caps), + } + for i, entry in enumerate(scene_props.clip_boxes): + obj = entry.obj + if obj is None: + continue + properties[f"Box_{i}_Name"] = obj.name + properties[f"Box_{i}_Matrix"] = tool.Blender.serialize_matrix(obj.matrix_world) + tool.Ifc.run("pset.edit_pset", pset=pset, properties=properties) + + @classmethod + def load_from_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Rehydrate clip boxes from ``IfcProject.BBIM_ClipBoxes``. + + Idempotent: drops stale list entries (deleted hosts / un-flagged + objects), then for each saved box, creates the empty if absent + or updates its matrix if the .blend reload already restored it. + + ``scene_props.enabled`` is NOT touched: it defaults to ``False`` + (so a fresh IFC load over a fresh .blend never silently hides + geometry), and Blender's normal .blend persistence carries the + user's saved toggle through .blend reload. + """ + import ifcopenshell.util.element + + if scene is None: + scene = bpy.context.scene + scene_props = cls.get_scene_props(scene) + + for index in range(len(scene_props.clip_boxes) - 1, -1, -1): + entry = scene_props.clip_boxes[index] + obj = entry.obj + if obj is None or not cls.get_object_props(obj).is_clip_box: + scene_props.clip_boxes.remove(index) + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + projects = ifc_file.by_type("IfcProject") + if not projects: + return + pset = ifcopenshell.util.element.get_psets(projects[0]).get(cls.PSET_NAME) + if not pset: + return + + show_caps_raw = pset.get("ShowCaps") + if show_caps_raw is not None: + scene_props.show_caps = bool(int(show_caps_raw)) + # enabled is intentionally NOT read from the pset — see docstring. + + existing_by_name = {entry.obj.name: entry.obj for entry in scene_props.clip_boxes if entry.obj} + existing_objs = set(existing_by_name.values()) + count = int(pset.get("Count", 0) or 0) + for i in range(count): + name = pset.get(f"Box_{i}_Name") or f"ClipBox.{i:03d}" + matrix_str = pset.get(f"Box_{i}_Matrix") + if not matrix_str: + continue + matrix = tool.Blender.deserialize_matrix(matrix_str) + existing_obj = bpy.data.objects.get(name) + if existing_obj is None: + # Fresh IFC load: no .blend backing, no viewport clip state. + # Create the empty, place it, and force enabled=False so we + # don't silently hide geometry behind a box the user forgot. + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.show_in_front = True + collection = tool.Blender.get_or_create_collection(scene, cls.COLLECTION_NAME) + collection.objects.link(obj) + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + else: + # .blend reload: the empty (and the scene-level enabled + # toggle) survived Blender's own session save. Update the + # matrix in case the pset diverged from the .blend snapshot. + obj = existing_obj + obj.matrix_world = matrix + cls.get_object_props(obj).is_clip_box = True + if obj not in existing_objs: + entry = scene_props.clip_boxes.add() + entry.obj = obj + existing_objs.add(obj) + + if scene_props.clip_boxes and scene_props.active_clip_box_index >= len(scene_props.clip_boxes): + scene_props.active_clip_box_index = 0 + + @classmethod + def apply_clip_planes_direct(cls, planes: PlaneSet) -> None: + """Direct-write variant for contexts where ``bpy.ops`` is illegal. + + Skips the first-arm path (which needs ``view3d.clip_border``) + and writes planes directly to every armed region. + ``region_3d.update()`` pushes the new clip planes to the GPU + buffer the rasteriser samples — without it the planes sit in + the data block and the next frame still uses the previous GPU + state. ``tag_redraw`` requests that the region actually + redraws this frame. + """ + for area, region, region_3d in tool.Blender.iter_view3d_regions(): + if not region_3d.use_clip_planes: + continue + key = region.as_pointer() + cls._region_by_key[key] = (area, region) + region_3d.clip_planes = planes + region_3d.update() + region.tag_redraw() + + @classmethod + def _sync_collection_to_list(cls, scene: bpy.types.Scene) -> None: + """Add any clip-box-flagged empties not yet in ``scene_props.clip_boxes``. + + Bonsai's duplicate-move macros (Shift+D, Alt+D, Ctrl+Shift+D) + deep-copy the source's ``BIMClipBoxProperties``, so the + duplicated empty carries ``is_clip_box=True`` but no scene-list + entry exists for it. This sync turns the duplicate into a + first-class clip box matching the UIList duplicate button: a + new entry, set active, persisted to the pset. + + Scoped to the ``BBIM_ClipBoxes`` collection so the cost is O(N) + in the number of clip boxes, not O(N) in the whole scene. + """ + scene_props = cls.get_scene_props(scene) + known_objs = {entry.obj for entry in scene_props.clip_boxes if entry.obj} + collection = bpy.data.collections.get(cls.COLLECTION_NAME) + if collection is None: + return + appended = False + for obj in collection.objects: + if obj in known_objs: + continue + if obj.type != "EMPTY": + continue + obj_props = cls.get_object_props(obj) + if not obj_props.is_clip_box: + continue + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + appended = True + if appended and tool.Ifc.get() is not None: + cls.save_to_project_pset(scene) + + @classmethod + def on_depsgraph_update(cls, scene, depsgraph) -> None: + """Safety-net re-arm, IFC-load rehydrate, sync + pset persistence. + + - **Shutdown guard**: skips when ``bpy.context.screen`` is ``None`` + so the persistent handler can't fault against freed UI memory. + - **IFC reload detection**: when ``id(tool.Ifc.get())`` changes, + drop the now-stale ``_owned`` table (the regions from the old + screen were freed) and rehydrate clip boxes from the new + project's ``BBIM_ClipBoxes`` pset. + - **Collection-to-list sync**: catches clip-box empties created + outside ``bim.add_clip_box`` / ``bim.duplicate_clip_box`` — + notably Bonsai's Shift+D / Alt+D / Ctrl+Shift+D macros, which + deep-copy the source's ``BIMClipBoxProperties`` (including + ``is_clip_box=True``) but don't register the copy with us. + Detection lives here so any future entry path is handled too. + - **Live preview safety net**: re-applies the clip planes from + the active box's evaluated matrix. ``on_pre_view`` is the + primary live-preview path; this is what catches matrix changes + outside any modal (Python set, undo, constraint update). + - **Pset persistence**: when ``obj.matrix_world`` differs from + the last persisted snapshot, write it to the project pset. + Blender's G/R/S modal only commits ``matrix_world`` on release, + so this branch fires once per commit — exactly the cadence the + user expects for "save my latest transform". + """ + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + ifc_file = tool.Ifc.get() + ifc_id = id(ifc_file) if ifc_file is not None else 0 + if ifc_id != cls._last_seen_ifc_id: + cls._last_seen_ifc_id = ifc_id + cls._owned.clear() + cls._region_by_key.clear() + cls._persisted_matrices.clear() + cls._last_seen_object_matrices.clear() + if ifc_file is not None: + cls.load_from_project_pset(scene) + # Orphan-empty adoption is deferred while a transform modal is + # dragging so the active-index change on adoption can't disrupt + # the move. + if not tool.Blender.is_transform_modal_active(bpy.context): + cls._sync_collection_to_list(scene) + obj = cls.get_active_clip_box(scene) + if obj is None: + return + + current_matrix = tuple(tuple(row) for row in obj.matrix_world) + prev_matrix = cls._persisted_matrices.get(obj.name) + if prev_matrix != current_matrix: + cls._persisted_matrices[obj.name] = current_matrix + # Only persist when an IFC file is loaded; otherwise the box + # is purely Blender-side and there's nothing to write to. + # Mark dirty here, FLUSH below — the gate suppresses writes + # while a transform modal is dragging so one drag produces + # one save on release, not N saves per frame. + if ifc_file is not None and prev_matrix is not None: + cls.mark_dirty_for_save(obj.name) + cls.flush_pending_saves(scene) + + try: + eval_obj = obj.evaluated_get(depsgraph) + matrix = eval_obj.matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + cls.apply_clip_planes_direct(cls.compute_planes_from_matrix(matrix)) + + @classmethod + def on_pre_view(cls) -> None: + """Per-redraw live preview hook. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``PRE_VIEW``. + Reads the active clip box's evaluated matrix and writes the + clip planes to ``bpy.context.region_data`` — the region being + rendered THIS frame, so no ``temp_override`` is needed. + + IFC pset writes are NOT performed here; that's the depsgraph + handler's job (it fires on transform commit and writes through + the operator transaction path). + """ + if cls._active_scene_props() is None: + return + obj = cls.get_active_clip_box() + if obj is None: + return + region_3d = getattr(bpy.context, "region_data", None) + if region_3d is None or not region_3d.use_clip_planes: + return + try: + depsgraph = bpy.context.evaluated_depsgraph_get() + matrix = obj.evaluated_get(depsgraph).matrix_world + except (AttributeError, RuntimeError, ReferenceError): + return + region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) + region_3d.update() + + # ------------------------------------------------------------------ + # Cross-section caps + # + # When the clip box is enabled, each IfcProduct mesh that crosses + # the box gets a "cap" polygon drawn where its geometry intersects + # a clip plane — so cut surfaces appear filled instead of hollow. + # The pipeline (``bmesh.ops.bisect_plane(clear_outer=True)`` per + # plane, then ``bmesh.ops.contextual_create`` to fill cut edges) + # runs on a temp BMesh per object so the source mesh is untouched. + # ------------------------------------------------------------------ + + @classmethod + def _compute_caps_for_object( + cls, + obj: bpy.types.Object, + world_planes: PlaneSet, + depsgraph: Optional[Any] = None, + ) -> list[tuple[float, float, float]]: + """Return triangle vertices for ``obj``'s cap polygons. + + Flat list of ``(x, y, z)`` tuples in world space, ready for a + ``batch_for_shader("TRIS", ...)`` upload. Empty when the + object's bound box doesn't cross any clip plane. + + Uses the evaluated mesh (modifier stack applied) when a + ``depsgraph`` is passed, so caps match the rendered geometry of + objects with subsurf / boolean / mirror modifiers. Falls back to + ``obj.data`` only for callers without a depsgraph (e.g. unit + tests that fabricate a mesh outside any eval context). + """ + import bmesh + from mathutils import Vector + + bm = bmesh.new() + eval_obj = None + try: + if depsgraph is not None: + try: + eval_obj = obj.evaluated_get(depsgraph) + mesh = eval_obj.to_mesh() + bm.from_mesh(mesh) + except (RuntimeError, ReferenceError): + return [] + else: + try: + bm.from_mesh(obj.data) + except (RuntimeError, ReferenceError): + return [] + + ws_to_ls = obj.matrix_world.inverted_safe() + rot = ws_to_ls.to_quaternion() + planes_local = [] + for plane in world_planes: + inward_world = Vector(plane[:3]) + d = plane[3] + point_on_plane_world = inward_world * -d + plane_co_local = ws_to_ls @ point_on_plane_world + # bisect_plane removes the +plane_no side when clear_outer=True; + # our inward normal points INTO the box, so we negate to clear + # the box's outside. + plane_no_local = (rot @ -inward_world).normalized() + planes_local.append((plane_co_local, plane_no_local)) + + cap_layer = tool.Geometry.bisect_and_cap(bm, planes_local) + if cap_layer is None: + return [] + + cap_faces = [f for f in bm.faces if f.is_valid and f[cap_layer]] + if not cap_faces: + return [] + + mw = obj.matrix_world + return cls._triangulate_cap_faces(cap_faces, mw) + finally: + bm.free() + if eval_obj is not None: + with contextlib.suppress(RuntimeError, ReferenceError, AttributeError): + eval_obj.to_mesh_clear() + + @classmethod + def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]: + """Yield mesh objects eligible for capping: visible ``IfcElement``s. + + Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so + spatial structure (``IfcSpace``, ``IfcBuildingStorey``, + ``IfcSite``) and annotations / grids never get capped — they're + non-physical containers / overlays that shouldn't sprout solid + fill polygons at clip boundaries. + """ + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + for obj in scene.objects: + if obj.type != "MESH" or obj.data is None: + continue + if not obj.visible_get(): + continue + entity = tool.Ifc.get_entity(obj) + if entity is None or not entity.is_a("IfcElement"): + continue + yield obj + + @classmethod + def rebuild_cap_cache( + cls, + scene: Optional[bpy.types.Scene] = None, + depsgraph: Optional[Any] = None, + ) -> None: + """Recompute the per-object cap-vertex cache from the active clip box. + + No-op while a transform modal is dragging ``matrix_world`` — the + existing cache stays in place and the user sees stale caps until + the drag commits. Per-object cache entries are reused when the + object's mesh, world matrix, and the clip-box matrix all match + the prior key. Stale entries (deleted objects, disabled box, + unloaded IFC) are pruned. + + When ``depsgraph`` is supplied (the typical handler path), per-mesh + caps are computed from the evaluated mesh so modifier stacks are + honoured; without it, raw source meshes are used. + """ + scene_props = cls._active_scene_props(scene) + if scene_props is None or not scene_props.show_caps: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if scene is None: + scene = bpy.context.scene + active = cls.get_active_clip_box(scene) + if active is None: + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + return + if tool.Blender.is_transform_modal_active(bpy.context): + return + + # Cap with the SAME expanded planes the viewport clips against + # (cls.compute_planes applies the _CLIP_EXPAND_ABS margin), so the + # cap face lines up with the visible cut. Using the un-expanded + # planes would leave a visible margin-sized gap between the cut + # mesh edge and the cap. + world_planes = cls.compute_planes(active) + clip_box_hash = hash(world_planes) + cls._last_cap_clip_box_hash = clip_box_hash + + from mathutils import Vector + + live_names: set[str] = set() + for obj in cls._iter_capable_objects(scene): + live_names.add(obj.name) + mesh = obj.data + cache_key = ( + getattr(mesh, "session_uid", id(mesh)), + tool.Blender.hash_matrix(obj.matrix_world), + clip_box_hash, + ) + cached = cls._cap_cache.get(obj.name) + if cached is not None and cached[0] == cache_key: + continue + # Cheap AABB-vs-clip-box rejection before the expensive bisect. + # bound_box has 8 corners in object-local space — transform to + # world and check whether they're all on the outside of any + # clip plane. If so, the mesh can't produce a cap from this + # box and we skip the per-mesh bisect. + mw = obj.matrix_world + world_corners = [mw @ Vector(c) for c in obj.bound_box] + if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners): + cls._cap_cache[obj.name] = (cache_key, None) + continue + verts = cls._compute_caps_for_object(obj, world_planes, depsgraph=depsgraph) + batch = cls._build_cap_batch(verts) if verts else None + cls._cap_cache[obj.name] = (cache_key, batch) + + for name in list(cls._cap_cache): + if name not in live_names: + cls._cap_cache.pop(name) + for name in list(cls._last_seen_object_matrices): + if name not in live_names: + cls._last_seen_object_matrices.pop(name) + + @staticmethod + def _build_cap_batch(verts: list[tuple[float, float, float]]): + """Bake ``verts`` into a GPU ``TRIS`` batch bound to ``UNIFORM_COLOR``.""" + import gpu + from gpu_extras.batch import batch_for_shader + + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return batch_for_shader(shader, "TRIS", {"pos": verts}) + + @classmethod + def _triangulate_cap_faces(cls, cap_faces, mw) -> list[tuple[float, float, float]]: + """Triangulate cap faces and return world-space triangle vertices. + + Each cap face is tessellated as a single simple ring via + :meth:`tool.Cad.tessellate_ring_planar`. Nested cap polygons + (hollow profiles — annular columns, pipe walls) render as solid + discs in v1; proper polygon-with-holes triangulation is a known + limitation and a follow-up. + """ + verts: list[tuple[float, float, float]] = [] + for face in cap_faces: + if not face.is_valid or len(face.verts) < 3: + continue + ring = [v.co.copy() for v in face.verts] + try: + tri_indices = tool.Cad.tessellate_ring_planar([ring]) + except Exception: + continue + for i, j, k in tri_indices: + for idx in (i, j, k): + w = mw @ ring[idx] + verts.append((w.x, w.y, w.z)) + return verts + + @classmethod + def on_depsgraph_update_caps(cls, scene, depsgraph) -> None: + """Depsgraph entry-point — guard, then delegate to the + modal-aware debounce in :meth:`_handle_cap_tick`.""" + if getattr(bpy.context, "screen", None) is None: + return + if cls._active_scene_props(scene) is None: + return + # Edit mode (mesh / curve / armature / …) fires depsgraph + # constantly as the user manipulates verts/edges; the cap view + # isn't the focus of that work, and the caps would flash off on + # every nudge. Skip scheduling entirely while in any edit mode. + if tool.Blender.is_in_edit_mode(): + return + cls._handle_cap_tick(scene, depsgraph) + + @classmethod + def _handle_cap_tick(cls, scene, depsgraph) -> None: + """Schedule (or immediately fire) a cap-cache rebuild. + + Strategy: + - Default: debounce. Each depsgraph tick reschedules a + ``bpy.app.timers`` callback ``_CAP_REBUILD_DEBOUNCE_SECONDS`` + in the future, so a burst of ticks from an unknown-to-Bonsai + drag (external-addon gizmo, scripted property updates) collapses + to a single rebuild after the storm subsides. Drag is smooth, + caps catch up shortly after release. + - Fast path: when a *known* transform modal (Bonsai G/R/S) just + finished — detected as a True→False transition on + ``is_transform_modal_active`` — cancel any pending timer and + rebuild immediately, preserving the snappy on-release feel for + Bonsai-internal drags. + - Skip path: depsgraph ticks fire for selection-only changes, + UI events, undo writes, etc. — none of which can move a cap. + When no update in the tick carries ``is_updated_geometry`` or + ``is_updated_transform``, return without scheduling so the + cache and its hide-while-pending gate don't churn for free. + """ + is_modal = tool.Blender.is_transform_modal_active(bpy.context) + modal_just_ended = cls._last_modal_state and not is_modal + cls._last_modal_state = is_modal + + if modal_just_ended: + cls._cancel_pending_cap_rebuild() + cls.rebuild_cap_cache(scene, depsgraph=depsgraph) + return + + if depsgraph is not None and not cls._depsgraph_has_relevant_changes(depsgraph): + return + + cls._schedule_cap_rebuild() + + @classmethod + def _depsgraph_has_relevant_changes(cls, depsgraph) -> bool: + """True iff the tick carries an Object geometry change, or an + Object transform update whose ``matrix_world`` actually moved. + + Blender raises ``is_updated_transform`` on the selected Object + itself even for plain selection changes (no matrix delta), and + on Scene / ViewLayer IDs for the same. We'd schedule (and hide + caps for) every click without this check. Comparing a matrix + hash against a per-object baseline filters selection noise + without requiring opt-in from external addons. + + First time we see an Object the hash is recorded as baseline + (no flag), so an addon-load-time selection burst doesn't fire + a phantom rebuild; subsequent real moves are detected on the + first tick the matrix actually differs. + """ + relevant = False + for upd in depsgraph.updates: + obj = upd.id + if not isinstance(obj, bpy.types.Object): + continue + if upd.is_updated_geometry: + relevant = True + continue + if not upd.is_updated_transform: + continue + new_hash = tool.Blender.hash_matrix(obj.matrix_world) + old_hash = cls._last_seen_object_matrices.get(obj.name) + cls._last_seen_object_matrices[obj.name] = new_hash + if old_hash is not None and old_hash != new_hash: + relevant = True + return relevant + + @classmethod + def _schedule_cap_rebuild(cls) -> None: + """(Re)schedule the deferred cap rebuild. + + Each call cancels any pending timer and registers a fresh one + so a burst of updates collapses to a single rebuild once the + debounce window of quiet elapses. + """ + cls._cancel_pending_cap_rebuild() + + def _do_rebuild() -> None: + cls._pending_cap_rebuild = None + try: + cls.rebuild_cap_cache() + except Exception: + # bpy.app.timers swallows exceptions silently, leaving + # the user with stale caps + no diagnostic. Surface to + # the console so future bisect / cap edge cases are + # debuggable instead of mysteriously invisible. + import traceback + + traceback.print_exc() + # Timer fires from the main loop without an accompanying + # depsgraph tick, so the viewport won't repaint on its own; + # nudge every region so the freshly-baked cap batches show + # up without the user having to wiggle the mouse. + for _area, region, _region_3d in tool.Blender.iter_view3d_regions(): + region.tag_redraw() + return None + + bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS) + cls._pending_cap_rebuild = _do_rebuild + + @classmethod + def _cancel_pending_cap_rebuild(cls) -> None: + """Cancel any pending debounced rebuild so the next event source + gets a clean slate. Idempotent and safe to call when none is + registered (e.g. on addon unregister).""" + pending = cls._pending_cap_rebuild + if pending is not None and bpy.app.timers.is_registered(pending): + bpy.app.timers.unregister(pending) + cls._pending_cap_rebuild = None + + @classmethod + def on_post_view_caps(cls) -> None: + """Draw cached cap batches over the clipped geometry. + + Installed as a ``SpaceView3D.draw_handler_add`` at ``POST_VIEW``. + Caps render with depth-test + depth-write enabled so any + geometry in front of the cap occludes it — without this the + ``UNIFORM_COLOR`` shader defaults to no-depth and the caps + would always paint on top of the scene. One ``batch.draw`` per + object; batches are pre-baked. + """ + if not cls._cap_cache: + return + scene_props = cls._active_scene_props() + if scene_props is None or not scene_props.show_caps: + return + # Hide caps while in edit mode — the user's focus is on + # vert/edge/face manipulation, not the section view; the cache + # is also frozen by the same gate in the depsgraph path. + if tool.Blender.is_in_edit_mode(): + return + # Hide caps for the duration of any G/R/S to suppress mid-drag + # visual jitter; the cache is also frozen by the same gate so + # anything drawn here would be stale relative to the live mesh. + if tool.Blender.is_transform_modal_active(bpy.context): + return + # Hide caps while a debounced rebuild is in flight (typical + # cause: external-addon gizmo drag). The cache may reflect a + # frame from earlier in the drag; drawing it would look stale + # against the geometry the user is currently mutating. + if cls._pending_cap_rebuild is not None: + return + import gpu + + prefs = tool.Blender.get_addon_preferences() + cap_color = tuple(prefs.clip_box_cap_color) + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", cap_color) + prev_depth_test = gpu.state.depth_test_get() + prev_depth_mask = gpu.state.depth_mask_get() + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.depth_mask_set(True) + try: + for _key, batch in cls._cap_cache.values(): + if batch is None: + continue + batch.draw(shader) + finally: + gpu.state.depth_mask_set(prev_depth_mask) + gpu.state.depth_test_set(prev_depth_test) diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index e628606201..f4dbb884b6 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -8,6 +8,7 @@ markers = brick bsdd classification + clip_box context cost covering diff --git a/src/bonsai/test/bim/module/clip_box/__init__.py b/src/bonsai/test/bim/module/clip_box/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py new file mode 100644 index 0000000000..92a51e6390 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py @@ -0,0 +1,692 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 math +from unittest.mock import patch + +import bpy +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +class TestAddClipBox(NewFile): + def test_creates_empty_and_registers_entry(self): + result = bpy.ops.bim.add_clip_box() + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert host is not None + assert host.empty_display_type == "CUBE" + obj_props = tool.ClipBox.get_object_props(host) + assert obj_props.is_clip_box is True + + def test_spawns_at_3d_cursor(self): + bpy.context.scene.cursor.location = (4.0, 0.0, 2.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert host is not None + assert host.matrix_world.translation.x == pytest.approx(4.0) + assert host.matrix_world.translation.z == pytest.approx(2.0) + + def test_spawns_in_clip_boxes_collection(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + collection_names = [c.name for c in host.users_collection] + assert "BBIM_ClipBoxes" in collection_names + + +class TestActiveClipBoxResolution(NewFile): + def test_no_box_returns_none(self): + assert tool.ClipBox.get_active_clip_box() is None + + def test_active_index_out_of_range_returns_none(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.active_clip_box_index = 99 + assert tool.ClipBox.get_active_clip_box() is None + + +class TestComputePlanes(NewFile): + def test_planes_match_unit_box_at_origin(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2))) + + def test_scaled_host_grows_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0)) + + # Test points well clear of any reasonable expand margin so the + # assertion pins the OBB scaling behaviour, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0))) + + def test_rotated_host_rotates_clip_region(self): + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") + + # Test points well clear of the expand margin so the assertion + # pins rotation, not the margin value. + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0))) + assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0))) + + def test_translated_and_rotated_host_keeps_centre_inside(self): + bpy.context.scene.cursor.location = (5.0, 7.0, 0.0) + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z") + + planes = tool.ClipBox.compute_planes(host) + assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0))) + + +class TestToggleEnabled(NewFile): + def test_flips_scene_enabled_flag(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + original = scene_props.enabled + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is (not original) + + +class TestSetActiveClipBox(NewFile): + def test_switches_active_index(self): + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.active_clip_box_index == 1 + bpy.ops.bim.set_active_clip_box(index=0) + assert scene_props.active_clip_box_index == 0 + + def test_invalid_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.set_active_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestRemoveClipBox(NewFile): + def test_drops_active_entry_when_no_index(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host_name = host.name + bpy.ops.bim.remove_clip_box(delete_object=True) + assert host_name not in bpy.data.objects + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + def test_drops_specified_index(self): + # Per-row UIList button passes index explicitly; the user can + # click X on any row without first selecting it as active. + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + first_name = scene_props.clip_boxes[0].obj.name + bpy.ops.bim.remove_clip_box(index=0) + assert first_name not in bpy.data.objects + assert len(scene_props.clip_boxes) == 1 + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.remove_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.remove_clip_box(index=99) + assert result == {"CANCELLED"} + + +class TestPsetPersistence(NewFile): + def test_add_clip_box_writes_project_pset(self): + import ifcopenshell.util.element + + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + project = tool.Ifc.get().by_type("IfcProject")[0] + psets = ifcopenshell.util.element.get_psets(project) + assert tool.ClipBox.PSET_NAME in psets + assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1 + + def test_round_trip_via_pset_restores_matrix(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)) + tool.ClipBox.save_to_project_pset() + + # Simulate a fresh-load state: clear scene list AND delete the + # Blender empty so load has to recreate it. + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_boxes.clear() + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert len(scene_props.clip_boxes) == 1 + rehydrated = scene_props.clip_boxes[0].obj + assert rehydrated is not None + for r in range(4): + for c in range(4): + expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c] + assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6) + + def test_load_from_pset_is_idempotent(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + tool.ClipBox.load_from_project_pset() + tool.ClipBox.load_from_project_pset() + assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1 + + def test_load_from_pset_does_not_touch_enabled(self): + # ``enabled`` is intentionally not persisted to the pset — the + # default is False (fresh .blend) and Blender's own .blend + # session save carries the user's saved value through reload. + # ``load_from_project_pset`` must not stomp either. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + tool.ClipBox.save_to_project_pset() + + # Simulate the depsgraph IFC-reload branch: load runs without + # touching enabled; the prior True value must survive. + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is True + + # And the opposite: load when False must not flip it True. + scene_props.enabled = False + tool.ClipBox.load_from_project_pset() + assert scene_props.enabled is False + + def test_add_clip_box_creates_no_ifc_entity(self): + # The clip box is project-pset persisted; there must be no + # IfcRoot entity attached to the empty (which would lock its + # scale and strip it on export). + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tool.Ifc.get_entity(host) is None + + def test_show_caps_round_trips_via_pset(self): + # show_caps is a scene-level toggle persisted in the project pset. + # Per-mesh cap cost dominates the bisect, which doesn't scale with + # clip-box extent, so the toggle applies file-wide. + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.show_caps = False + tool.ClipBox.save_to_project_pset() + + scene_props.clip_boxes.clear() + scene_props.show_caps = True # default; load_from_project_pset must flip back to False + bpy.data.objects.remove(host, do_unlink=True) + + tool.ClipBox.load_from_project_pset() + assert scene_props.show_caps is False + + +class TestCapGeneration(NewFile): + def test_cap_for_box_straddling_clip_plane_produces_triangles(self): + # A 2x2x2 cube centred at the origin, clipped by a unit-radius clip + # box also at the origin: the four faces of the cube that pierce + # the +/- x box faces should yield cap polygons on the two faces. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) # unit box at origin + + bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + + # Every cap is at least one triangle (3 verts each), and we expect + # 6 cap polygons (one per box face) → at minimum 18 verts. + assert len(verts) >= 18 + assert len(verts) % 3 == 0 + + def test_cap_for_mesh_entirely_outside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_cap_for_mesh_entirely_inside_box_produces_nothing(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0)) + cube = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(cube, world_planes) + assert verts == [] + + def test_non_watertight_mesh_does_not_crash(self): + # The cap pipeline assumes watertight input; non-watertight + # meshes (terrain, single-shell surfaces) may produce degenerate + # caps but must not raise. The user is responsible for input + # quality. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + host.matrix_world = Matrix.Identity(4) + + bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0)) + grid = bpy.context.active_object + + world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world) + verts = tool.ClipBox._compute_caps_for_object(grid, world_planes) + assert isinstance(verts, list) + + def test_show_caps_defaults_on_and_toggles(self): + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.show_caps is True + scene_props.show_caps = False + assert scene_props.show_caps is False + + +class TestCapEligibility(NewFile): + def test_ifc_space_is_not_capped(self): + # IfcSpace is an IfcProduct (spatial structure) but should never + # cap — spaces are non-physical containers; capping them sprouts + # solid fills where the room boundary crosses the clip plane. + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + space_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcSpatialElement" + bpy.ops.bim.assign_class(ifc_class="IfcSpace") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert space_obj not in capable + + def test_ifc_wall_is_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + wall_obj = bpy.context.active_object + tool.Root.get_root_props().ifc_product = "IfcElement" + bpy.ops.bim.assign_class(ifc_class="IfcWall") + + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert wall_obj in capable + + def test_pure_blender_mesh_is_not_capped(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.active_object + # No assign_class — pure Blender mesh, no IFC entity attached. + capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + assert cube not in capable + + +class TestDuplicateClipBox(NewFile): + def test_duplicates_active_when_no_index(self): + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0)) + + bpy.ops.bim.duplicate_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + copy = tool.ClipBox.get_active_clip_box() + assert copy is not source + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c]) + assert tool.ClipBox.get_object_props(copy).is_clip_box is True + + def test_duplicates_specified_index(self): + bpy.ops.bim.add_clip_box() + first = tool.ClipBox.get_active_clip_box() + bpy.ops.bim.add_clip_box() + # Active is now index 1; duplicate index 0 explicitly. + bpy.ops.bim.duplicate_clip_box(index=0) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 3 + copy = tool.ClipBox.get_active_clip_box() + for r in range(4): + for c in range(4): + assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c]) + + def test_no_active_box_cancels(self): + result = bpy.ops.bim.duplicate_clip_box() + assert result == {"CANCELLED"} + + def test_out_of_range_index_cancels(self): + bpy.ops.bim.add_clip_box() + result = bpy.ops.bim.duplicate_clip_box(index=99) + assert result == {"CANCELLED"} + + def test_duplicate_arms_clipping(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.duplicate_clip_box() # re-arms + assert scene_props.enabled is True + + +class TestCollectionSync(NewFile): + def test_sync_adopts_orphan_clip_box_empty(self): + # Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True + # exists in the BBIM_ClipBoxes collection but no scene-list entry + # points at it. The sync must adopt it as a first-class clip box. + bpy.ops.bim.add_clip_box() + source = tool.ClipBox.get_active_clip_box() + + orphan = bpy.data.objects.new(source.name, None) + orphan.empty_display_type = "CUBE" + orphan.empty_display_size = 1.0 + orphan.matrix_world = source.matrix_world.copy() + tool.ClipBox.get_object_props(orphan).is_clip_box = True + collection = bpy.data.collections.get("BBIM_ClipBoxes") + collection.objects.link(orphan) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 2 + assert scene_props.clip_boxes[-1].obj is orphan + assert scene_props.active_clip_box_index == 1 + + def test_sync_skips_unflagged_empties(self): + bpy.ops.bim.add_clip_box() + collection = bpy.data.collections.get("BBIM_ClipBoxes") + decoy = bpy.data.objects.new("Decoy", None) + collection.objects.link(decoy) + + tool.ClipBox._sync_collection_to_list(bpy.context.scene) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + + +class TestEnabledIsSceneLevel(NewFile): + def test_default_is_false(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + + def test_add_arms_clipping(self): + # Adding any clip box flips enabled True so the user + # immediately sees the cut and discovers the panel toggle + # by association. + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is False + bpy.ops.bim.add_clip_box() + assert scene_props.enabled is True + + def test_subsequent_adds_re_arm_after_user_disables(self): + bpy.ops.bim.add_clip_box() # arms + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # user disables + bpy.ops.bim.add_clip_box() # second add re-arms + assert scene_props.enabled is True + + def test_selecting_clip_box_does_not_arm(self): + # Per design, selecting a clip box empty must NOT toggle the + # scene-level enabled — activation is panel-only. Otherwise a + # casual click in the outliner would silently hide geometry + # with no obvious unarm path for a user who hasn't found the + # panel yet. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = False # disable after first-add auto-arm + + host = tool.ClipBox.get_active_clip_box() + bpy.context.view_layer.objects.active = host + tool.ClipBox.on_depsgraph_update(bpy.context.scene, None) + assert scene_props.enabled is False + + def test_toggle_operator_flips_scene_enabled(self): + bpy.ops.bim.add_clip_box() # first-add arms it + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.enabled is True + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is False + bpy.ops.bim.toggle_clip_box_enabled() + assert scene_props.enabled is True + + +class TestSpawnScale(NewFile): + def test_default_scale_is_ten(self): + # Default spawn scale is 10 (=20m cube) to cover a typical + # storey, not the meaningless 1m unit cube. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0)) + + +class TestCapRebuildDebounce(NewFile): + """The depsgraph handler debounces cap rebuilds so a burst of + updates (e.g. an external-addon gizmo drag) collapses to one + rebuild ~250 ms after the storm subsides. Bonsai's own transform + modals get a fast path: an immediate rebuild on the True→False + transition of ``is_transform_modal_active``. + """ + + def setup_method(self): + bpy.ops.bim.add_clip_box() + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def teardown_method(self): + tool.ClipBox._cancel_pending_cap_rebuild() + tool.ClipBox._last_modal_state = False + tool.ClipBox._last_seen_object_matrices.clear() + + def test_modal_end_triggers_immediate_rebuild(self): + # Prime "previous tick had a modal active" then run a tick with + # no modal → fast path fires rebuild_cap_cache synchronously, + # bypassing the timer. Targets _handle_cap_tick directly to + # bypass the screen guard that aborts in headless test runs. + tool.ClipBox._last_modal_state = True + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild, + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + mock_rebuild.assert_called_once() + mock_schedule.assert_not_called() + + def test_burst_collapses_to_one_pending_timer(self): + # 5 ticks with no modal active → schedule called 5 times; each + # call cancels the previous pending timer and registers a fresh + # one, so exactly one timer is pending at the end. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + for _ in range(5): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None + assert bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_pending_rebuild_hides_caps(self): + # While a debounce is in flight, on_post_view_caps must not + # draw — the cached batches reflect an earlier frame and would + # look stale against the geometry being mutated. + scene_props = tool.ClipBox.get_scene_props() + scene_props.enabled = True + scene_props.show_caps = True + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "rebuild_cap_cache"), + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, None) + assert tool.ClipBox._pending_cap_rebuild is not None + + # Populate cap_cache to non-empty so the first-line gate + # "if not cls._cap_cache: return" doesn't fire — the contract + # we're pinning is the pending-rebuild gate specifically. + tool.ClipBox._cap_cache["sentinel"] = ((), None) + try: + # If pending-rebuild gate works, on_post_view_caps exits + # before importing gpu / building a shader. Patch + # gpu.shader.from_builtin to fail loudly if drawing happens. + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")), + ): + tool.ClipBox.on_post_view_caps() + finally: + tool.ClipBox._cap_cache.pop("sentinel", None) + tool.ClipBox._cancel_pending_cap_rebuild() + + def test_cancel_pending_drops_timer(self): + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + pending = tool.ClipBox._pending_cap_rebuild + assert pending is not None and bpy.app.timers.is_registered(pending) + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + assert not bpy.app.timers.is_registered(pending) + + def test_unregister_handler_cancels_pending(self): + # The module's unregister() must drop any pending rebuild so a + # timer can't fire against a freed addon. Exercise the helper + # directly — full addon unregister would tear down too much for + # a unit test. + with patch.object(tool.Blender, "is_transform_modal_active", return_value=False): + tool.ClipBox._schedule_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_selection_only_tick_does_not_schedule(self): + # Selecting an Object raises is_updated_transform=True on the + # Object itself even though no actual matrix delta occurred + # (Blender quirk). The matrix-hash baseline must filter that + # out so the cache and hide-while-pending gate don't flash on + # every click. Also covers the Scene/ViewLayer noise. + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + # Prime the baseline so cube's current matrix hash is "seen". + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + + class _SceneUpdate: + id = bpy.context.scene + is_updated_geometry = False + is_updated_transform = True + + class _CubeSelectionUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True # quirk: matrix unchanged + + class _FakeDepsgraph: + updates = (_SceneUpdate(), _CubeSelectionUpdate()) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_not_called() + assert tool.ClipBox._pending_cap_rebuild is None + + def test_real_transform_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + # Baseline hash, then mutate the matrix so the filter sees a + # true delta on the next tick. + tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world) + cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0)) + + class _TransformUpdate: + id = cube + is_updated_geometry = False + is_updated_transform = True + + class _FakeDepsgraph: + updates = (_TransformUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_geometry_update_tick_does_schedule(self): + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + + class _GeometryUpdate: + id = cube + is_updated_geometry = True + is_updated_transform = False + + class _FakeDepsgraph: + updates = (_GeometryUpdate(),) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule, + ): + tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph()) + mock_schedule.assert_called_once() + + def test_edit_mode_skips_scheduling(self): + # In any EDIT_* mode the depsgraph fires per vert/edge nudge; + # the cap view isn't the focus and would flash off on every + # tick. The entry-point gate must short-circuit before the + # debounce scheduler runs. + with ( + patch.object(tool.Blender, "is_in_edit_mode", return_value=True), + patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle, + ): + tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None) + mock_handle.assert_not_called() From 4d3bff4e3adadb0df0d00b23607c37a3d11cf392 Mon Sep 17 00:00:00 2001 From: carlopav Date: Tue, 16 Jun 2026 08:35:13 +0200 Subject: [PATCH 29/35] feat(ifc5d): include quantity Formula in serialised Quantities IfcQuantity* carries an optional Formula (IfcLabel) documenting how a quantity was derived. Export it alongside each quantity so it survives in the Quantities column. The per-quantity entry shape grows from [name, value] to [name, value, formula], which stays backward compatible for positional consumers reading index 0/1. Formula is read with a schema-safe getattr (it does not exist on IfcPhysicalComplexQuantity, nor in IFC2X3) and is coalesced to "" when absent. Co-Authored-By: Claude Fable 5 --- src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 7 +++++-- src/ifc5d/test/test_csv2ifc.py | 23 +++++++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index a0f12e2535..a57e52426c 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -269,15 +269,18 @@ class IfcDataGetter: if obj.is_a("IfcElement"): prefix += (obj.Name or "") + " - " name = prefix + (quantity.Name or "") + # Formula is an optional IfcLabel on IfcQuantity* in IFC4+; absent in + # IFC2X3, hence the schema-safe getattr. + formula = getattr(quantity, "Formula", None) or "" if quantity.is_a("IfcPhysicalSimpleQuantity"): value = quantity[3] try: value = float(value) if value is not None else 0.0 except (TypeError, ValueError): value = 0.0 - result.append([name, value]) + result.append([name, value, formula]) else: - result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0]) + result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, formula]) return json.dumps(result, ensure_ascii=False) diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py index a630bb44ac..01b4a85ee6 100644 --- a/src/ifc5d/test/test_csv2ifc.py +++ b/src/ifc5d/test/test_csv2ifc.py @@ -130,7 +130,7 @@ class TestSerialiseCostQuantities: result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) - assert json.loads(result) == [[name, 12.5]] + assert json.loads(result) == [[name, 12.5, ""]] def test_unset_name_does_not_crash(self): ifc_file = ifcopenshell.file() @@ -140,4 +140,23 @@ class TestSerialiseCostQuantities: result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) - assert json.loads(result) == [["", 3.0]] + assert json.loads(result) == [["", 3.0, ""]] + + def test_formula_is_included_when_present(self): + ifc_file = ifcopenshell.file() + quantity = ifc_file.create_entity("IfcQuantityArea", Name="Area", AreaValue=12.5, Formula="Length * Width") + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["Area", 12.5, "Length * Width"]] + + def test_quantity_without_formula_attribute_does_not_crash(self): + # IfcPhysicalComplexQuantity has no Formula attribute and is unsupported. + ifc_file = ifcopenshell.file() + quantity = ifc_file.create_entity("IfcPhysicalComplexQuantity", Name="Complex", Discrimination="layer") + cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity]) + + result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item) + + assert json.loads(result) == [["Complex ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, ""]] From d5bed316cd521d9005382af3211a3dff46ae7dd8 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Wed, 17 Jun 2026 14:28:23 +0200 Subject: [PATCH 30/35] Option for ifcwrap cmake to run standalone #8165 --- .github/workflows/ci-ifcwrap-standalone.yml | 155 ++++++++++++++++++ cmake/FindIfcOpenShell.cmake | 131 +++++++++++++++ cmake/IfcOpenShellConfig.cmake.in | 42 ++++- src/ifcwrap/CMakeLists.txt | 84 +++++++++- src/serializers/CMakeLists.txt | 2 +- .../schema_dependent/CMakeLists.txt | 1 + 6 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ci-ifcwrap-standalone.yml create mode 100644 cmake/FindIfcOpenShell.cmake diff --git a/.github/workflows/ci-ifcwrap-standalone.yml b/.github/workflows/ci-ifcwrap-standalone.yml new file mode 100644 index 0000000000..9bd901791b --- /dev/null +++ b/.github/workflows/ci-ifcwrap-standalone.yml @@ -0,0 +1,155 @@ +# This file was generated with the assistance of an AI coding tool. +name: ci-ifcwrap-standalone + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/ci-ifcwrap-standalone.yml" + - "cmake/**" + - "src/ifcwrap/**" + - "src/ifcparse/**" + - "src/ifcgeom/**" + - "src/serializers/**" + - "src/ifcconvert/**" + - "src/ifcopenshell-python/**" + - "src/svgfill/**" + push: + paths: + - ".github/workflows/ci-ifcwrap-standalone.yml" + - "cmake/**" + - "src/ifcwrap/**" + - "src/ifcparse/**" + - "src/ifcgeom/**" + - "src/serializers/**" + - "src/ifcconvert/**" + - "src/ifcopenshell-python/**" + - "src/svgfill/**" + +env: + IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install + +jobs: + build-ifcopenshell: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install C++ dependencies + run: | + sudo apt update + sudo apt-get install --no-install-recommends -y \ + cmake \ + gcc \ + g++ \ + libboost-date-time-dev \ + libboost-filesystem-dev \ + libboost-iostreams-dev \ + libboost-program-options-dev \ + libboost-regex-dev \ + libboost-system-dev \ + libboost-thread-dev \ + libeigen3-dev \ + libocct-data-exchange-dev \ + libocct-draw-dev \ + libocct-foundation-dev \ + libocct-modeling-algorithms-dev \ + libocct-modeling-data-dev \ + libocct-ocaf-dev \ + libocct-visualization-dev \ + libpcre3-dev \ + libtbb-dev \ + libxml2-dev \ + libxi-dev \ + occt-misc \ + tcl-dev \ + tk-dev \ + swig + + - name: Configure minimal IfcOpenShell + run: | + cmake -S cmake -B build-ifcopenshell \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \ + -DCMAKE_PREFIX_PATH=/usr \ + -DCMAKE_SYSTEM_PREFIX_PATH=/usr \ + -DMINIMAL_BUILD=ON \ + -DBUILD_IFCPYTHON=OFF \ + "-DSCHEMA_VERSIONS=4x3_add2" + + - name: Build and install minimal IfcOpenShell + run: | + cmake --build build-ifcopenshell --target install -j "$(nproc)" + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: 3.11 + + - name: Install Python import dependencies + run: | + python -m pip install --upgrade pip + python -m pip install numpy typing_extensions + + - name: Configure standalone IfcPython + run: | + PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" + PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')" + + cmake -S src/ifcwrap -B "build-ifcwrap-311" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \ + -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \ + -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" + + - name: Build and install standalone IfcPython + run: | + cmake --build "build-ifcwrap-311" --target install -j "$(nproc)" + + - name: Import installed IfcPython + run: | + PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY' + import ifcopenshell + + print("IfcOpenShell import ok:", ifcopenshell.version) + PY + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - name: Install Python import dependencies + run: | + python -m pip install --upgrade pip + python -m pip install numpy typing_extensions + + - name: Configure standalone IfcPython + run: | + PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" + PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')" + + cmake -S src/ifcwrap -B "build-ifcwrap-312" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \ + -DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \ + -DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \ + -DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" + + - name: Build and install standalone IfcPython + run: | + cmake --build "build-ifcwrap-312" --target install -j "$(nproc)" + + - name: Import installed IfcPython + run: | + PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY' + import ifcopenshell + + print("IfcOpenShell import ok:", ifcopenshell.version) + PY diff --git a/cmake/FindIfcOpenShell.cmake b/cmake/FindIfcOpenShell.cmake new file mode 100644 index 0000000000..22e04ef5b8 --- /dev/null +++ b/cmake/FindIfcOpenShell.cmake @@ -0,0 +1,131 @@ +# This file was generated with the assistance of an AI coding tool. +################################################################################ +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell 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 # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +################################################################################ + +include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL) + +set(_IfcOpenShell_find_args) +if(IfcOpenShell_FIND_VERSION) + list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}") + if(IfcOpenShell_FIND_VERSION_EXACT) + list(APPEND _IfcOpenShell_find_args EXACT) + endif() +endif() +list(APPEND _IfcOpenShell_find_args CONFIG QUIET) +if(IfcOpenShell_FIND_COMPONENTS) + list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS}) +endif() + +set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}") +list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") +find_package(IfcOpenShell ${_IfcOpenShell_find_args}) +set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}") + +if(NOT IfcOpenShell_FOUND) + set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.") + if(IfcOpenShell_FIND_REQUIRED) + message(FATAL_ERROR "${_IfcOpenShell_error}") + elseif(NOT IfcOpenShell_FIND_QUIETLY) + message(STATUS "${_IfcOpenShell_error}") + endif() + return() +endif() + +set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom) +set(_IfcOpenShell_missing_targets "") +foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets) + if(NOT TARGET ${_IfcOpenShell_target}) + list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target}) + endif() +endforeach() + +if(_IfcOpenShell_missing_targets) + set(IfcOpenShell_FOUND FALSE) + string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}") + set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.") + if(IfcOpenShell_FIND_REQUIRED) + message(FATAL_ERROR "${_IfcOpenShell_error}") + elseif(NOT IfcOpenShell_FIND_QUIETLY) + message(STATUS "${_IfcOpenShell_error}") + endif() + return() +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE) + set(IFCOPENSHELL_WITH_OPENCASCADE OFF) + if(TARGET IfcOpenShell::geometry_kernel_opencascade) + set(IFCOPENSHELL_WITH_OPENCASCADE ON) + endif() +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_CGAL) + set(IFCOPENSHELL_WITH_CGAL OFF) + if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL) + set(IFCOPENSHELL_WITH_CGAL ON) + endif() +endif() + +if(NOT DEFINED IFCOPENSHELL_IFCXML) + set(IFCOPENSHELL_IFCXML OFF) +endif() + +if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB) + set(IFCOPENSHELL_WITH_ROCKSDB OFF) +endif() + +set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse) +foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers) + if(TARGET ${_IfcOpenShell_target}) + list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target}) + endif() +endforeach() + +set(IFCOPENSHELL_KERNEL_LIBRARIES "") +foreach(_IfcOpenShell_target IN ITEMS + IfcOpenShell::geometry_kernel_opencascade + IfcOpenShell::geometry_kernel_cgal + IfcOpenShell::geometry_kernel_cgal_simple +) + if(TARGET ${_IfcOpenShell_target}) + list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target}) + endif() +endforeach() + +set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES}) + +if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE) + set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE) +endif() + +if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL) + set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL) +endif() + +if(TARGET IfcOpenShell::svgfill) + set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill) +endif() + +mark_as_advanced(IfcOpenShell_DIR) + +unset(_IfcOpenShell_error) +unset(_IfcOpenShell_find_args) +unset(_IfcOpenShell_missing_targets) +unset(_IfcOpenShell_missing_targets_text) +unset(_IfcOpenShell_required_targets) +unset(_IfcOpenShell_target) diff --git a/cmake/IfcOpenShellConfig.cmake.in b/cmake/IfcOpenShellConfig.cmake.in index e5e5d350b8..741e4fa233 100644 --- a/cmake/IfcOpenShellConfig.cmake.in +++ b/cmake/IfcOpenShellConfig.cmake.in @@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@) set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@) set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@) set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@) +set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@) +set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@) +set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@) +set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@) +set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@) include(CMakeFindDependencyMacro) -set(Boost_USE_STATIC_LIBS ON) -set(Boost_USE_STATIC_RUNTIME OFF) -set(Boost_USE_MULTITHREADED ON) +set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@") +set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@") +set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@") +if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "") + set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}) +endif() +if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "") + set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}) +endif() +if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "") + set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED}) +endif() set(Boost_COMPONENTS system program_options @@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB) endif() if(IFCOPENSHELL_IFCXML) - find_dependency(LibXml2 CONFIG) + find_dependency(LibXml2) endif() if(IFCOPENSHELL_WITH_CGAL) find_dependency(CGAL CONFIG) endif() +if(IFCOPENSHELL_COLLADA_SUPPORT) + find_dependency(OpenCOLLADA) +endif() + +if(IFCOPENSHELL_GLTF_SUPPORT) + find_dependency(nlohmann_json CONFIG) +endif() + +if(IFCOPENSHELL_HDF5_SUPPORT) + find_dependency(HDF5 COMPONENTS C CXX) +endif() + +if(IFCOPENSHELL_WITH_PROJ) + find_dependency(PROJ) +endif() + +if(IFCOPENSHELL_USD_SUPPORT) + find_dependency(USD) +endif() + if(IFCOPENSHELL_WITH_OPENCASCADE) find_dependency(OpenCASCADE CONFIG) if(OpenCASCADE_VERSION VERSION_LESS "7.7.0") diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt index 16e3a86565..21c11d3ae0 100644 --- a/src/ifcwrap/CMakeLists.txt +++ b/src/ifcwrap/CMakeLists.txt @@ -17,6 +17,23 @@ # # ################################################################################ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(IfcOpenShellPython LANGUAGES CXX) + set(IFCOPENSHELL_IFCWRAP_STANDALONE ON) +else() + set(IFCOPENSHELL_IFCWRAP_STANDALONE OFF) +endif() + +if(NOT DEFINED CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +if(CMAKE_CXX_STANDARD LESS 17) + message(FATAL_ERROR "C++17 or newer is required.") +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) + if(POLICY CMP0148) # 3.27 cmake_policy(SET CMP0148 OLD) endif() @@ -24,6 +41,35 @@ if(POLICY CMP0177) # 3.31 cmake_policy(SET CMP0177 OLD) endif() +set(_ifcwrap_feature_definitions "") +if(IFCOPENSHELL_IFCWRAP_STANDALONE) + get_filename_component(_ifcwrap_repo_root "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + list(PREPEND CMAKE_MODULE_PATH "${_ifcwrap_repo_root}/cmake") + + find_package(IfcOpenShell REQUIRED) + + set(WITH_OPENCASCADE "${IFCOPENSHELL_WITH_OPENCASCADE}") + set(WITH_CGAL "${IFCOPENSHELL_WITH_CGAL}") + set(SWIG_DEFINES "") + + if(IFCOPENSHELL_WITH_OPENCASCADE) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_OPENCASCADE) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_OPENCASCADE) + endif() + if(IFCOPENSHELL_WITH_CGAL) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_CGAL) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_CGAL) + endif() + if(IFCOPENSHELL_IFCXML) + list(APPEND SWIG_DEFINES -DWITH_IFCXML) + list(APPEND _ifcwrap_feature_definitions WITH_IFCXML) + endif() + if(IFCOPENSHELL_WITH_ROCKSDB) + list(APPEND SWIG_DEFINES -DIFOPSH_WITH_ROCKSDB) + list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_ROCKSDB) + endif() +endif() + find_package(SWIG) if(NOT SWIG_FOUND) message( @@ -68,7 +114,15 @@ include_directories(BEFORE ${CMAKE_CURRENT_SOURCE_DIR}) set(CMAKE_SWIG_FLAGS ${SWIG_DEFINES}) if(WITH_CGAL) - set(LIBSVGFILL svgfill) + if(IFCOPENSHELL_IFCWRAP_STANDALONE) + if(TARGET IfcOpenShell::svgfill) + set(LIBSVGFILL IfcOpenShell::svgfill) + else() + message(FATAL_ERROR "IfcOpenShell was built with CGAL, but the exported svgfill target was not found.") + endif() + else() + set(LIBSVGFILL svgfill) + endif() endif() set_source_files_properties(IfcPython.i PROPERTIES CPLUSPLUS ON) @@ -113,7 +167,21 @@ else() endif() target_link_libraries(ifcopenshell_wrapper PRIVATE Python::Module) -set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${IFCOPENSHELL_LIBRARIES}) +if(_ifcwrap_feature_definitions) + target_compile_definitions(ifcopenshell_wrapper PRIVATE ${_ifcwrap_feature_definitions}) +endif() +if(IFCOPENSHELL_IFCWRAP_STANDALONE) + set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES}) + set(_ifcwrap_geometry_libraries ${IFCOPENSHELL_GEOMETRY_LIBRARIES}) + set(_ifcwrap_cgal_libraries "") + set(_ifcwrap_swig_depends "") +else() + set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES}) + set(_ifcwrap_geometry_libraries IfcGeom ${kernel_libraries}) + set(_ifcwrap_cgal_libraries ${CGAL_LIBRARIES}) + set(_ifcwrap_swig_depends ${IFCOPENSHELL_LIBRARIES}) +endif() +set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${_ifcwrap_swig_depends}) if(WASM_BUILD) # SIDE_MODULE=1 - add to .so all symbols from linked archives (default used by pyodide). # Since currently libIfcGeom.a seems to be linked twice it results in duplicated symbols and compilation errors. @@ -134,14 +202,14 @@ if("$ENV{LDFLAGS}" MATCHES ".undefined.suppress") # On osx there is some state in the python dylib. With `-Wl,undefined,suppress` we can ignore the missing symbols at compile time. target_link_libraries( ifcopenshell_wrapper - PRIVATE ${IFCOPENSHELL_LIBRARIES} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL} + PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL} ) else() - target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL}) + target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${LIBSVGFILL}) endif() -target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES}) -target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries}) -if((NOT WIN32) AND BUILD_SHARED_LIBS) +target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_cgal_libraries}) +target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_geometry_libraries}) +if((NOT WIN32) AND BUILD_SHARED_LIBS AND COMMAND SET_INSTALL_RPATHS) SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}") endif() @@ -194,7 +262,7 @@ if(Python_Interpreter_FOUND OR PYTHON_MODULE_INSTALL_DIR) endif() endforeach() install( - FILES "${CMAKE_BINARY_DIR}/ifcwrap/ifcopenshell_wrapper.py" + FILES "${CMAKE_CURRENT_BINARY_DIR}/ifcopenshell_wrapper.py" DESTINATION "${python_package_dir}/ifcopenshell" ) install(TARGETS ifcopenshell_wrapper DESTINATION "${python_package_dir}/ifcopenshell") diff --git a/src/serializers/CMakeLists.txt b/src/serializers/CMakeLists.txt index 94b30a0e38..b18c8a59c3 100644 --- a/src/serializers/CMakeLists.txt +++ b/src/serializers/CMakeLists.txt @@ -36,7 +36,7 @@ target_link_libraries( IfcParse ) -install(TARGETS Serializers) +install(TARGETS Serializers EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) # Can't use `PUBLIC_HEADER` since we need two folders. install(FILES ${SERIALIZERS_H_FILES} DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/serializers/") diff --git a/src/serializers/schema_dependent/CMakeLists.txt b/src/serializers/schema_dependent/CMakeLists.txt index 73c1e5d3e7..a9c1107bcc 100644 --- a/src/serializers/schema_dependent/CMakeLists.txt +++ b/src/serializers/schema_dependent/CMakeLists.txt @@ -13,6 +13,7 @@ foreach(schema ${SCHEMA_VERSIONS}) if(NOT WASM_BUILD) target_link_libraries(Serializers_ifc${schema} ${OpenCASCADE_LIBRARIES}) endif() + install(TARGETS Serializers_ifc${schema} EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) endforeach() set(SERIALIZER_SCHEMA_LIBRARIES ${SERIALIZER_SCHEMA_LIBRARIES} PARENT_SCOPE) From 1e91eebf512933936a0b953f10853f49c11c1186 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:30:04 +0000 Subject: [PATCH 31/35] Bump tar from 7.5.11 to 7.5.16 in /src/ifctester/webapp Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.11 to 7.5.16. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.11...v7.5.16) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.16 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/ifctester/webapp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 90a6743443..947e26ded5 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -3177,9 +3177,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 855de34d2299eef8f94f7a5c94bfd39f0d85174a Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Wed, 17 Jun 2026 18:23:19 +0200 Subject: [PATCH 32/35] Catch and log errors during initialize_settings() --- src/ifcgeom/abstract_mapping.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ifcgeom/abstract_mapping.cpp b/src/ifcgeom/abstract_mapping.cpp index 44cbd7d0b5..350a6df02a 100644 --- a/src/ifcgeom/abstract_mapping.cpp +++ b/src/ifcgeom/abstract_mapping.cpp @@ -45,6 +45,11 @@ ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingF throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower); } auto new_mapping = it->second(file, s, logger); - new_mapping->initialize_settings(); + try { + new_mapping->initialize_settings(); + } catch (const std::exception& e) { + logger.Error("GEO", 400, e); + logger.Error("GEO", 401, "Unable to initialize conversion settings"); + } return new_mapping; } From 95fcf9e35cb290ca31ed6869002247100e432f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Tue, 16 Jun 2026 12:21:21 -0300 Subject: [PATCH 33/35] fix custom_offset scale material layers --- src/bonsai/bonsai/tool/model.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 7060b2bca0..f878c204d9 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -814,7 +814,7 @@ class Model(bonsai.core.tool.Model): unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_params = tool.Model.get_material_layer_parameters(element) layer_offset = layer_params["offset"] - thickness = layer_params["thickness"] / unit_scale + thickness = layer_params["thickness"] props = tool.Material.get_object_material_props(obj) # Try to load from pset if not already in props @@ -835,7 +835,7 @@ class Model(bonsai.core.tool.Model): return None else: # Use current props - custom_offset = props.custom_offset / unit_scale + custom_offset = props.custom_offset if tool.Model.get_usage_type(element) == "LAYER2": custom_offset_reference = props.custom_wall_reference elif tool.Model.get_usage_type(element) == "LAYER3": @@ -846,17 +846,17 @@ class Model(bonsai.core.tool.Model): direction_sense = layer_params["direction_sense"] if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: - layer_offset = custom_offset - thickness * unit_scale + layer_offset = custom_offset - thickness if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset - (thickness / 2) * unit_scale + layer_offset = custom_offset - (thickness / 2) if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} ): layer_offset = custom_offset if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset + (thickness / 2) * unit_scale + layer_offset = custom_offset + (thickness / 2) if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: - layer_offset = custom_offset + thickness * unit_scale + layer_offset = custom_offset + thickness return layer_offset / unit_scale From 156c6183ebbc6deeffc0c54469150693943168ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Tue, 16 Jun 2026 18:35:17 -0300 Subject: [PATCH 34/35] fix custom offset unit scale when loading from pset. --- src/bonsai/bonsai/tool/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index f878c204d9..137bed3371 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -822,7 +822,7 @@ class Model(bonsai.core.tool.Model): pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if pset and pset.get("UseCustomOffset", False): # Load from pset - custom_offset = pset.get("CustomOffset", 0.0) + custom_offset = pset.get("CustomOffset", 0.0) * unit_scale usage_type = tool.Model.get_usage_type(element) if usage_type == "LAYER2": From 937270fc493ebf848c2cce3c2c96415d3394e909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Wed, 17 Jun 2026 22:17:53 -0300 Subject: [PATCH 35/35] Fix thickness and offset calculation for rotated slabs. Get existing `x_angle` instead of using object `rotation_euler.x` Co-Authored-By: Ryan Schultz --- src/bonsai/bonsai/bim/module/model/slab.py | 2 +- src/bonsai/bonsai/bim/module/model/wall.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 516dd04233..9d7e88a23d 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -271,7 +271,7 @@ class DumbSlabPlaner: # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. - existing_x_angle = obj.rotation_euler.x + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 183d9d32e6..ecbe544eb9 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -873,7 +873,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): extrusion.Depth = perpendicular_depth else: if tool.Model.get_usage_type(element) == "LAYER3": - existing_x_angle = obj.rotation_euler.x + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle