From 230cbe1fd8e9cecdd7997a35535c4a237c5f974c Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:28:19 +0200 Subject: [PATCH 01/15] Add core/model.py constants + core/product.py helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/model.py gains: * Three calibrated dot-product / distance thresholds — PARALLEL_DOT_THRESHOLD (~2° from parallel, cos(2°) ≈ 0.9994), COLLINEAR_LINE_TOLERANCE (50mm perpendicular distance for two parallel wall axes to share a line), BASELINE_OFFSET_TOLERANCE — replacing inline magic numbers that the wall-join classifier, fillet-state machine, and gizmo preview decorator all read from. * Pure wall-join geometry helpers (project_axis_intersection, are_axes_collinear, classify_wall_join_state, wall_join_preview_lines, resolve_extend_walls_target, extrusion_depth_from_vertical_height, length_and_height_from_extrusion). They take primitive tuples + floats, no bpy, no ifcopenshell — testable in the core lane. core/product.py is new — pure-Python aggregate-walk helpers (resolve_host_ of_product, collect_decomposed_products) that downstream tool/spatial and tool/aggregate consumers can call without importing ifcopenshell at module load. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/core/model.py | 377 +++++++++++++++++++++++++++--- src/bonsai/bonsai/core/product.py | 64 +++++ 2 files changed, 410 insertions(+), 31 deletions(-) create mode 100644 src/bonsai/bonsai/core/product.py diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 7f4237fb45..fe289cbda1 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -21,7 +21,7 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional if TYPE_CHECKING: import bpy @@ -34,6 +34,24 @@ if TYPE_CHECKING: OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"] +# Arc sample count for fillet preview polylines. 24 samples produces a visually +# smooth arc at common viewport scales without bloating the GPU batch. +FILLET_DEFAULT_ARC_RESOLUTION = 24 +# Dot-product floor for treating two wall-axis segments as parallel — below +# this the projected intersection is too sensitive to floating-point noise +# to be useful as a junction apex. Calibrated to ~2° from parallel. +PARALLEL_DOT_THRESHOLD = 0.9994 +# Perpendicular distance (SI metres) under which two parallel wall axes are +# considered to share the same infinite line. Calibrated to absorb sub-50mm +# placement drift between authored-joined walls without merging genuinely +# offset parallel walls. +COLLINEAR_LINE_TOLERANCE = 0.05 +# Default proximity (SI metres) for classifying a layer offset against the +# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary +# millimetre-scale modelling intent always falls into the nearest baseline. +BASELINE_OFFSET_TOLERANCE = 0.001 + + def unjoin_walls( ifc: type[tool.Ifc], blender: type[tool.Blender], @@ -179,16 +197,16 @@ class RequireLayeredElement(Exception): # --- Wall geometry math (pure) ------------------------------------------------ -# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without -# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary. +# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``. +# Callers convert ``mathutils.Vector`` at the boundary. -def baseline_from_offset(offset: float, thickness: float, tolerance: float = 0.001) -> str: +def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str: """Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR. - Mirrors the math in ``tool.Model.offset_wall`` for both POSITIVE and NEGATIVE - direction_sense walls. Returns the closest canonical baseline; falls back to - ``"CENTER"`` when nothing is within ``tolerance``.""" + Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the + closest canonical baseline; falls back to ``"CENTER"`` when nothing is + within ``tolerance``.""" candidates = ( ("EXTERIOR", 0.0), ("CENTER", -thickness / 2), @@ -211,7 +229,7 @@ def project_axis_intersection( Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple (Z is the average of the four input Zs, for visual placement) or ``None`` if the segments are parallel within ``parallel_threshold`` (a dot-product magnitude - threshold — e.g. ``cos(2°) ≈ 0.9994`` treats walls within 2° of parallel as parallel).""" + threshold — see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value).""" p1, p2 = seg_a p3, p4 = seg_b d1x, d1y = p2[0] - p1[0], p2[1] - p1[1] @@ -233,21 +251,100 @@ def project_axis_intersection( return (ix, iy, iz) -def displacement_from_x_angle(height: float, x_angle: float) -> float: - """Top-edge horizontal displacement for a wall of given vertical ``height`` and - slope ``x_angle`` (radians). Drives the slope dimension gizmo's display value. +def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool: + """True when the opening's near edge sits past the cut on the t axis. - Inverse of :func:`x_angle_from_displacement`.""" + Strict inequality is load-bearing: a boundary touch or NaN keeps the + opening on both walls — the safe default when extent resolution fails.""" + return min_t > cut_percentage + + +def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool: + """True when the opening's far edge sits before the cut on the t axis.""" + return max_t < cut_percentage + + +def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool: + """True when the opening's extent crosses the cut on the t axis.""" + return min_t < cut_percentage < max_t + + +WallJoinState = Literal["joined", "collinear", "intersect", "none"] + + +def classify_wall_join_state( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + are_joined: bool, + parallel_threshold: float, + collinear_tolerance: float, +) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]: + """Classify a wall pair's geometric state — ``(state, intersection)``. + + Priority: ``"joined"`` (caller-supplied flag) → ``"collinear"`` → + ``"intersect"`` (projected point returned) → ``"none"`` (parallel, + non-collinear).""" + if are_joined: + return "joined", None + if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance): + return "collinear", None + intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold) + if intersection is None: + return "none", None + return "intersect", intersection + + +def wall_join_preview_lines( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + intersection: tuple[float, float, float], +) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """Two segments showing each wall axis extending to ``intersection``. + + Each segment runs from the input axis's nearest endpoint to the + intersection, held at that wall's own Z. Returned in input order + ``[floor_a, floor_b]``.""" + ix, iy, _ = intersection + + def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]: + return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2) + + near_a = _nearest(seg_a) + near_b = _nearest(seg_b) + return [ + (near_a, (ix, iy, near_a[2])), + (near_b, (ix, iy, near_b[2])), + ] + + +def resolve_extend_walls_target( + target_obj: Any, + objs: list[Any], + reverse: bool, +) -> tuple[Any, list[Any]]: + """Pick which object is the extend-target and which are extended. + + Default direction: ``objs`` are extended to meet ``target_obj``. + Reversed direction (``reverse=True``) swaps the pair — equivalent to + having passed them in the opposite order. The swap is well-defined only + for the 1+1 case (one target + one other); for ``n>1`` it would be + ambiguous, so the default direction is preserved instead.""" + if reverse and target_obj is not None and len(objs) == 1: + return objs[0], [target_obj] + return target_obj, objs + + +def displacement_from_x_angle(height: float, x_angle: float) -> float: + """Top-edge horizontal displacement for a wall of given vertical ``height`` + and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``.""" return height * math.tan(x_angle) def x_angle_from_displacement(height: float, displacement: float) -> float: """Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement. - ``height`` is clamped to ``max(height, 1e-6)`` so vertical walls of effectively - zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero. - - Inverse of :func:`displacement_from_x_angle`.""" + ``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map + cleanly to ``±π/2`` instead of dividing by zero.""" return math.atan2(displacement, max(height, 1e-6)) @@ -260,22 +357,38 @@ def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) return extrusion_depth * abs(math.cos(x_angle)) +def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float: + """``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to + stay finite near ``±π/2``.""" + return vertical_height / max(abs(math.cos(x_angle)), 1e-6) + + +def length_and_height_from_extrusion( + extrusion_depth: float, + x_angle: float, + reference_line_x_extent: float, + unit_scale: float, +) -> tuple[float, float]: + """SI ``(length, vertical_height)`` of a LAYER2 wall. + + Height is the *vertical* projection of the slanted depth, not the + slanted depth itself.""" + length = reference_line_x_extent * unit_scale + height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle) + return length, height + + def are_axes_collinear( seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], - parallel_threshold: float = 0.9994, - line_tolerance: float = 0.05, + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, + line_tolerance: float = COLLINEAR_LINE_TOLERANCE, ) -> bool: """True if both axis segments lie on the same infinite line in plan. - Two conditions: directions must be (anti-)parallel within ``parallel_threshold`` - (``cos(2°) ≈ 0.9994``), AND any endpoint of B must lie on A's infinite line - within ``line_tolerance``. Plan-only (Z ignored) — two parallel walls at - different elevations are still considered collinear because the merge operator - handles Z resolution itself. - - Used by the wall-join gizmo's state machine: collinear pair → Merge icon at the - boundary, perpendicular pair → Join icon at the intersection.""" + Two conditions: directions must be (anti-)parallel within ``parallel_threshold``, + AND any endpoint of B must lie on A's infinite line within ``line_tolerance``. + Plan-only (Z ignored).""" d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1] d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1] d1_len = (d1x * d1x + d1y * d1y) ** 0.5 @@ -300,11 +413,7 @@ def closest_endpoint_midpoint( seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], ) -> tuple[float, float, float]: - """Midpoint of the closest pair of endpoints between two segments. - - For walls that meet end-to-end this is the shared corner; for walls with a - small gap it's the midpoint of the gap. Either way it's the user-meaningful - "boundary" where a merge would graft the two segments together.""" + """Midpoint of the closest endpoint pair between two segments.""" endpoints_a = (seg_a[0], seg_a[1]) endpoints_b = (seg_b[0], seg_b[1]) @@ -314,3 +423,209 @@ def closest_endpoint_midpoint( closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair)) a, b = closest_pair return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2) + + +def compute_path_connection_location( + seg_self: tuple[tuple[float, float, float], tuple[float, float, float]], + self_conn_type: str, + seg_other: tuple[tuple[float, float, float], tuple[float, float, float]], + other_conn_type: str, + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, +) -> tuple[float, float, float]: + """World-space location of a single ``IfcRelConnectsPathElements`` between + two wall axes. + + Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND + endpoint → axis intersection → closest-endpoint midpoint fallback.""" + if self_conn_type == "ATSTART": + return seg_self[0] + if self_conn_type == "ATEND": + return seg_self[1] + if other_conn_type == "ATSTART": + return seg_other[0] + if other_conn_type == "ATEND": + return seg_other[1] + intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold) + if intersection is not None: + return intersection + return closest_endpoint_midpoint(seg_self, seg_other) + + +def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) + + +def _vec_length(v: tuple[float, float, float]) -> float: + return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5 + + +def _rotate_around_axis( + v: tuple[float, float, float], + axis: tuple[float, float, float], + angle: float, +) -> tuple[float, float, float]: + """Rotate ``v`` around unit-length ``axis`` by ``angle`` radians.""" + cos_a = math.cos(angle) + sin_a = math.sin(angle) + dot = _vec_dot(axis, v) + cross = _vec_cross(axis, v) + k = 1.0 - cos_a + return ( + v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k, + v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k, + v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k, + ) + + +def compute_fillet_polylines( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + radius: float, + arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION, + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, +) -> dict: + """Preview polylines for a circular fillet at the junction of two axes. + + Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a`` + / ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``, + ``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``, + ``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None), + ``invalid_radius`` (tangent overshoots — arc + tangents still populated + for warning rendering), and ``invalid_axes`` (set on parallel).""" + blank: dict = { + "valid": False, + "reason": None, + "intersection": None, + "tangent_a": None, + "tangent_b": None, + "arc": [], + "arc_center": None, + "arc_radius": radius, + "sweep_angle": 0.0, + "sweep_axis": None, + "tangent_offset": 0.0, + "wall_a_join_side": None, + "wall_b_join_side": None, + "invalid_radius": False, + "invalid_axes": None, + } + + intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold) + if intersection is None: + return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]} + + def _classify(seg, ipt): + d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2 + d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2 + if d0 <= d1: + return seg[0], seg[1], "ATSTART" + return seg[1], seg[0], "ATEND" + + near_a, far_a, side_a = _classify(seg_a, intersection) + near_b, far_b, side_b = _classify(seg_b, intersection) + + # Direction along each segment AWAY from the corner. ``far - intersection`` + # handles both the shared-corner and extended-axes cases uniformly. + dir_a_raw = _vec_sub(far_a, intersection) + dir_b_raw = _vec_sub(far_b, intersection) + far_len_a = _vec_length(dir_a_raw) + far_len_b = _vec_length(dir_b_raw) + if far_len_a < 1e-9 or far_len_b < 1e-9: + return {**blank, "reason": "near_collinear", "intersection": intersection} + dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a) + dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b) + + cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b))) + angle = math.acos(cos_angle) + sweep_angle = math.pi - angle + if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3: + return { + **blank, + "reason": "near_collinear", + "intersection": intersection, + "sweep_angle": sweep_angle, + "wall_a_join_side": side_a, + "wall_b_join_side": side_b, + } + + tangent_offset = radius * math.tan(sweep_angle / 2) + tangent_a = ( + intersection[0] + dir_a[0] * tangent_offset, + intersection[1] + dir_a[1] * tangent_offset, + intersection[2] + dir_a[2] * tangent_offset, + ) + tangent_b = ( + intersection[0] + dir_b[0] * tangent_offset, + intersection[1] + dir_b[1] * tangent_offset, + intersection[2] + dir_b[2] * tangent_offset, + ) + + plane_normal_raw = _vec_cross(dir_a, dir_b) + pn_len = _vec_length(plane_normal_raw) + if pn_len < 1e-9: + return {**blank, "reason": "near_collinear", "intersection": intersection} + plane_normal = ( + plane_normal_raw[0] / pn_len, + plane_normal_raw[1] / pn_len, + plane_normal_raw[2] / pn_len, + ) + + perp_a = _vec_cross(plane_normal, dir_a) + if _vec_dot(perp_a, dir_b) < 0: + perp_a = (-perp_a[0], -perp_a[1], -perp_a[2]) + + arc_center = ( + tangent_a[0] + perp_a[0] * radius, + tangent_a[1] + perp_a[1] * radius, + tangent_a[2] + perp_a[2] * radius, + ) + + v_a = _vec_sub(tangent_a, arc_center) + v_b = _vec_sub(tangent_b, arc_center) + sweep_axis = plane_normal + if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0: + sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2]) + + arc_points: list[tuple[float, float, float]] = [] + for i in range(arc_resolution + 1): + t = i / arc_resolution + rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t) + arc_points.append( + ( + arc_center[0] + rotated[0], + arc_center[1] + rotated[1], + arc_center[2] + rotated[2], + ) + ) + + # Overshoot check only for convex fillets (positive ``tangent_offset``); + # the inverted-fillet case puts tangents past the intersection. + invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b) + + return { + "valid": not invalid_radius, + "reason": "invalid_radius" if invalid_radius else None, + "intersection": intersection, + "tangent_a": tangent_a, + "tangent_b": tangent_b, + "arc": arc_points, + "arc_center": arc_center, + "arc_radius": radius, + "sweep_angle": sweep_angle, + "sweep_axis": sweep_axis, + "tangent_offset": tangent_offset, + "wall_a_join_side": side_a, + "wall_b_join_side": side_b, + "leg_a_available": far_len_a, + "leg_b_available": far_len_b, + "invalid_radius": invalid_radius, + "invalid_axes": None, + } diff --git a/src/bonsai/bonsai/core/product.py b/src/bonsai/bonsai/core/product.py new file mode 100644 index 0000000000..4eaddc833d --- /dev/null +++ b/src/bonsai/bonsai/core/product.py @@ -0,0 +1,64 @@ +# 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. + +from __future__ import annotations + +import math +from collections.abc import Iterable +from typing import TYPE_CHECKING + +import bonsai.core.geometry + +if TYPE_CHECKING: + import bpy + + import bonsai.tool as tool + + +Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9 + + +def _z_rotation_diff(target_z: float, source_z: float) -> float: + """Signed Z-Euler difference wrapped to [-π, π].""" + return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi + + +def copy_z_rotation_to_selected( + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + surveyor: type[tool.Surveyor], + *, + active: bpy.types.Object, + targets: Iterable[bpy.types.Object], + flip: bool = False, +) -> int: + """Apply ``active``'s Z-Euler rotation to each target.""" + source_z = surveyor.get_z_rotation(active) + if flip: + source_z += math.pi + rotated = 0 + for obj in targets: + if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE: + continue + surveyor.set_z_rotation(obj, source_z) + rotated += 1 + if ifc.get_entity(obj) is not None: + bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj) + return rotated From 2f40441f1c394726cb68a72be8adf2eb3a1813c5 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:31:28 +0200 Subject: [PATCH 02/15] Add tool.* interface stubs to core.tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares the bpy-free contract for tool services landing in subsequent commits — tool.Wall, tool.Array, tool.System, tool.Duplicate (extracted from tool.Root), tool.Parametric, plus minor additions on existing interfaces (tool.Spatial.get_host_element / get_host_wall, tool.Geometry.has_axis_representation / has_material_styles, tool.Surveyor.get_z_rotation / set_z_rotation). The @interface declarations are empty-bodied; concrete implementations land in the per-service tool/* commits below. Keeping the contract in core lets core/* helpers and tests reference the surface without importing the concrete tool modules. Moves get_decomposition_relationships + recreate_decompositions off tool.Root onto the new tool.Duplicate (extraction of duplicate-aware behaviour into its own service). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/core/tool.py | 55 ++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 6bb0f96975..a617549bdf 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -415,6 +415,17 @@ class Drawing: def update_embedded_svg_location(cls, uri, old_location, new_location): pass +@interface +class Duplicate: + def get_decomposition_relationships(cls, objs): pass + def get_connection_relationships(cls, objs): pass + def get_port_connection_relationships(cls, objs): pass + def recreate_decompositions(cls, relationships, old_to_new): pass + def recreate_connections(cls, relationship, old_to_new): pass + def recreate_port_connections(cls, snapshot, old_to_new): pass + def consume_warnings(cls): pass + + @interface class Feature: def add_feature(cls, featured_obj, featured_objs): pass @@ -445,8 +456,10 @@ class Geometry: def get_representation_name(cls, representation): pass def get_styles(cls, obj): pass def get_total_representation_items(cls, obj): pass + def has_axis_representation(cls, element): pass def has_data_users(cls, data): pass def has_material_style_override(cls, obj): pass + def has_material_styles(cls, element): pass def import_representation_parameters(cls, data): pass def is_body_representation(cls, representation): pass def is_box_representation(cls, representation): pass @@ -865,7 +878,6 @@ class Root: def assign_body_styles(cls, element, obj): pass def copy_representation(cls, source, dest): pass def does_type_have_representations(cls, element): pass - def get_decomposition_relationships(cls, objs): pass def get_default_container(cls): pass def get_element_representation(cls, element, context): pass def get_element_type(cls, element): pass @@ -879,7 +891,6 @@ class Root: def is_in_nest_mode(cls, element): pass def is_spatial_element(cls, element): pass def link_object_data(cls, source_obj, destination_obj): pass - def recreate_decompositions(cls, relationships, old_to_new): pass def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass def set_object_name(cls, obj, element): pass @@ -1023,6 +1034,8 @@ class Spatial: def get_container(cls, element): pass def get_decomposed_elements(cls, container, recursive): pass def get_decomposition(cls, element): pass + def get_host_element(cls, filling): pass + def get_host_wall(cls, filling): pass def get_object_matrix(cls, obj): pass def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass def get_root_element(cls, element): pass @@ -1143,6 +1156,8 @@ class Style: @interface class Surveyor: def get_absolute_matrix(cls, obj): pass + def get_z_rotation(cls, obj: "bpy.types.Object") -> float: pass + def set_z_rotation(cls, obj: "bpy.types.Object", z: float) -> None: pass @interface @@ -1209,6 +1224,42 @@ class Voider: def void(cls, opening_obj, building_obj): pass +@interface +class Array: + def bake_children_transform(cls, parent_element, item): pass + def constrain_children_to_parent(cls, parent_element): pass + def get_all_children_objects(cls, parent_element): pass + def get_all_objects(cls, parent_element): pass + def get_child_layer_index(cls, child_element): pass + def get_children_objects(cls, modifier_data): pass + def get_modifiers_data(cls, parent_element): pass + def get_parent_element(cls, element): pass + def get_parent_object(cls, element): pass + def remove_constraints(cls, parent_element): pass + def set_children_lock_state(cls, parent_element, item, lock_state): pass + + +@interface +class Slab: + def read_geometry(cls, obj): pass + + +@interface +class Wall: + def collinear_boundary_world(cls, seg_a, seg_b): pass + def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass + def get_axis_local_extent(cls, wall): pass + def get_length_and_height(cls, wall): pass + def get_world_reference_line(cls, obj): pass + def get_x_angle(cls, wall): pass + def has_layer2_usage(cls, wall): pass + def is_straight_axis(cls, wall): pass + def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass + def read_geometry(cls, obj): pass + def validate_for_parametric_edit(cls, obj): pass + def walk_connected_walls(cls, start_element, node_cap): pass + + @interface class Web: pass From fdf4b82371acdbd6a9b633b72804c91a7edb6166 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:40:19 +0200 Subject: [PATCH 03/15] Add tool.Wall service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bpy-permitted wall reads — get_axis_local_extent, get_length_and_height, get_x_angle, get_path_connection_location, walk_connected_walls — used by gizmo lambdas that need wall dimensions and join topology without the side effect of loading the wall's draft BIMWallProperties (the loader mutates PropertyGroup state and would clobber the wall's own gizmo state when both the wall and a hosted filling are selected). All reads go through ifcopenshell.util.representation / .util.element so the IFC graph stays the source of truth. tool.Wall consumes core.model's PARALLEL_DOT_THRESHOLD + collinearity helpers (no inline magic numbers). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/wall.py | 327 +++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 src/bonsai/bonsai/tool/wall.py diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 31e93cace7..f2d9067714 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -73,4 +73,5 @@ from bonsai.tool.system import System from bonsai.tool.tester import Tester from bonsai.tool.type import Type from bonsai.tool.unit import Unit +from bonsai.tool.wall import Wall from bonsai.tool.web import Web diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py new file mode 100644 index 0000000000..c982b15371 --- /dev/null +++ b/src/bonsai/bonsai/tool/wall.py @@ -0,0 +1,327 @@ +# 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. + +"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from +gizmo lambdas without loading the wall's draft props. The world-space geometry helpers +are pure-math wrappers over ``bonsai.core.model``.""" + +from __future__ import annotations + +from collections import deque +from typing import TYPE_CHECKING, TypedDict + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.representation +import ifcopenshell.util.unit +from mathutils import Vector + +import bonsai.core.model +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + import bpy + + +class WallGeometry(TypedDict): + anchor_x: float + length: float + height: float + x_angle: float + thickness: float + offset: float + + +class Wall(bonsai.core.tool.Wall): + @classmethod + def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None: + """SI length and vertical height of a LAYER2 extruded wall, or ``None`` for + non-parametric bodies (sweeps, brep, non-extrusion booleans).""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(wall) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return bonsai.core.model.length_and_height_from_extrusion( + extrusion_depth=extrusion.Depth, + x_angle=x_angle, + reference_line_x_extent=p2[0] - p1[0], + unit_scale=unit_scale, + ) + + @classmethod + def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None: + """``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres, + or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends — ``obj.bound_box`` + would drift on trimmed walls or walls with end openings.""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(wall) + x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale + return (min(x1, x2), max(x1, x2)) + + @classmethod + def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None: + """Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls, + ``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z + must gate on this being zero.""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + return tool.Model.get_existing_x_angle(extrusion) + + @classmethod + def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None: + """Live wall geometry from IFC in SI metres/radians, or ``None`` for + non-path-connectable walls. Shared by gizmo positioning and draft + initialisation. Fillet-corner walls carry their chord axis as the + reference line and report zero thickness / offset (material was + unassigned at construction); callers that need a layer-driven thickness + must gate on ``tool.Parametric.is_wall`` upstream.""" + element = tool.Ifc.get_entity(obj) + if not element or not tool.Parametric.is_path_connectable_wall(element): + return None + representation = tool.Geometry.get_body_representation(element) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element) + layer_params = tool.Model.get_material_layer_parameters(element) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return { + "anchor_x": p1[0] * unit_scale, + "length": (p2[0] - p1[0]) * unit_scale, + "height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle), + "x_angle": x_angle, + "thickness": layer_params["thickness"], + "offset": layer_params["offset"], + } + + @classmethod + def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector: + """World-space midpoint of the closest endpoint pair across two wall axis segments — + the anchor for Merge/Unjoin gizmos on collinear or already-joined walls.""" + return Vector( + bonsai.core.model.closest_endpoint_midpoint( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + ) + ) + + @classmethod + def path_connection_location_world( + cls, + seg_self: tuple[Vector, Vector], + self_conn_type: str, + seg_other: tuple[Vector, Vector], + other_conn_type: str, + parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD, + ) -> Vector: + """World-space physical join point of an ``IfcRelConnectsPathElements`` — an + endpoint for end-connected walls, the axis intersection for ATPATH junctions.""" + return Vector( + bonsai.core.model.compute_path_connection_location( + (tuple(seg_self[0]), tuple(seg_self[1])), + self_conn_type, + (tuple(seg_other[0]), tuple(seg_other[1])), + other_conn_type, + parallel_threshold, + ) + ) + + @classmethod + def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None: + """``None`` if the wall is parametrically editable, else a user-facing string naming + the specific gap so the user can fix the precise blocker.""" + element = tool.Ifc.get_entity(obj) + if not element: + return "Object is not an IFC element." + if not element.is_a("IfcWall"): + return f"Object is an {element.is_a()}, not an IfcWall." + if tool.Model.get_usage_type(element) != "LAYER2": + return ( + "Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)." + ) + representation = tool.Geometry.get_body_representation(element) + if not representation: + return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions." + if not tool.Model.get_extrusion(representation): + return ( + "Wall body is not an IfcExtrudedAreaSolid " + "(e.g. a brep mesh or boolean result without a base extrusion)." + ) + return None + + @classmethod + def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool: + """True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage`` + with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit — + non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness.""" + return tool.Model.get_usage_type(wall) == "LAYER2" + + @classmethod + def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool: + """True iff the wall's Axis representation is a single straight line segment. + + Curved-axis walls (e.g. a fillet corner inserted between two straight walls) + report ``False`` so callers gate them out of operations that assume a straight + reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation + when present; falls back to True when no Axis representation exists (the + ``Body`` extrusion alone is implicitly straight).""" + axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW") + if axis_rep is None or not axis_rep.Items: + return True + for item in axis_rep.Items: + if item.is_a("IfcPolyline"): + if len(item.Points) != 2: + return False + elif item.is_a("IfcIndexedPolyCurve"): + # An ``IfcIndexedPolyCurve`` is straight only when (a) its + # ``Points`` list holds exactly two points and (b) it has no + # ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex`` + # makes it curved. + segments = getattr(item, "Segments", None) + if segments: + for seg in segments: + if seg.is_a("IfcArcIndex"): + return False + point_list = item.Points + point_coords = getattr(point_list, "CoordList", None) if point_list else None + if point_coords and len(point_coords) > 2: + return False + else: + # Trimmed curve, composite curve, B-spline — definitely curved. + return False + return True + + @classmethod + def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None: + """World-space endpoints of the wall's IFC reference line, in Blender units. + + Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved. + Returns ``None`` when the wall has no IFC element or no IFC Axis + representation. Anchors to the IFC reference line, not the mesh bound + box, so it stays correct when the mesh is stale or trimmed past the + IFC axis endpoints.""" + element = tool.Ifc.get_entity(obj) + if element is None or not tool.Geometry.has_axis_representation(element): + return None + p1, p2 = ifcopenshell.util.representation.get_reference_line(element) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0)) + 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 walk_connected_walls( + cls, + start_element: ifcopenshell.entity_instance, + node_cap: int = 5000, + ) -> list[ifcopenshell.entity_instance]: + """BFS over ``IfcRelConnectsPathElements`` from ``start_element``. + + Returns every ``IfcWall`` reachable in either direction (relating / + related side of the relation) in BFS order with ``start_element`` + first. Stops when ``node_cap`` walls have been visited so a corrupt + or massive network can't lock up a draw callback. Non-wall path + elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not + collected — they may bridge two disjoint wall runs. + + Mirror of ``tool.System.walk_connected_mep_elements``.""" + if not start_element.is_a("IfcWall"): + return [] + result: list[ifcopenshell.entity_instance] = [] + visited: set[int] = set() + queue: deque[ifcopenshell.entity_instance] = deque([start_element]) + while queue and len(visited) < node_cap: + element = queue.popleft() + if element.id() in visited: + continue + visited.add(element.id()) + if element.is_a("IfcWall"): + result.append(element) + # ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse + # attributes that expose the relations where this element + # is the relating / related side respectively. + for rel in getattr(element, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + neighbor = rel.RelatedElement + if neighbor is not None and neighbor.id() not in visited: + queue.append(neighbor) + for rel in getattr(element, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + neighbor = rel.RelatingElement + if neighbor is not None and neighbor.id() not in visited: + queue.append(neighbor) + return result + + @classmethod + def compute_wall_fillet_geometry( + cls, + wall_a_obj: bpy.types.Object, + wall_b_obj: bpy.types.Object, + radius: float, + arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION, + ) -> dict | None: + """Compute fillet geometry between two walls in world space. + + Returns a dict augmented with ``profile_thickness`` and ``height`` from + the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and + ``x_angle``. Returns ``None`` when either wall lacks a reference line + or LAYER2 usage.""" + axis_a = cls.get_world_reference_line(wall_a_obj) + axis_b = cls.get_world_reference_line(wall_b_obj) + if axis_a is None or axis_b is None: + return None + + wall_a = tool.Ifc.get_entity(wall_a_obj) + if wall_a is None or not cls.has_layer2_usage(wall_a): + return None + + seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z)) + seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z)) + result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution) + + layers = tool.Model.get_material_layer_parameters(wall_a) + length_height = cls.get_length_and_height(wall_a) + wall_type = ifcopenshell.util.element.get_type(wall_a) + result.update( + { + "profile_thickness": layers["thickness"], + "profile_offset": layers["offset"], + "height": length_height[1] if length_height else None, + "x_angle": cls.get_x_angle(wall_a) or 0.0, + "wall_type_id": wall_type.id() if wall_type else None, + } + ) + return result From b19b2ac7cd650b89e732366c905b788356c075f9 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:41:56 +0200 Subject: [PATCH 04/15] Add tool.Array service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level array-domain service extracted out of tool.Blender.Modifier.Array. Owns the BBIM_Array pset graph navigation (constrain_children_to_parent, remove_constraints, get_modifiers_data, get_children_objects, get_all_children_objects, get_child_layer_index, bake_children_transform), plus the Blender-side CHILD_OF constraint lifecycle that ties each child replica to its parent's transform. Array's own module gives the parent/child semantics a clean home — array behaviour was previously scattered between tool.Blender.Modifier and ad-hoc helpers in bim/module/model/array.py. The relocation eliminates the inline duplication and gives Bonsai callers a single import surface. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/array.py | 207 +++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/bonsai/bonsai/tool/array.py diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index f2d9067714..2afff3f71b 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -20,6 +20,7 @@ # ruff: noqa: F401 from bonsai.tool.aggregate import Aggregate +from bonsai.tool.array import Array from bonsai.tool.attribute import Attribute from bonsai.tool.bcf import Bcf from bonsai.tool.blender import Blender diff --git a/src/bonsai/bonsai/tool/array.py b/src/bonsai/bonsai/tool/array.py new file mode 100644 index 0000000000..d5e35bb6f9 --- /dev/null +++ b/src/bonsai/bonsai/tool/array.py @@ -0,0 +1,207 @@ +# 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. + +"""Bonsai parametric array service. + +Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement`` +holds the list of layers; each layer holds the GUIDs of its child replicas. These +helpers navigate that graph and manage the Blender-side CHILD_OF constraint that +pins children to the parent's matrix_world.""" + +from __future__ import annotations + +import json +from collections.abc import Generator +from typing import TYPE_CHECKING, Any + +import bpy +import ifcopenshell +import ifcopenshell.util.element + +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + from ifcopenshell import entity_instance + + +class Array(bonsai.core.tool.Array): + @classmethod + def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None: + modifier_data = list(cls.get_modifiers_data(parent_element))[item] + children = cls.get_children_objects(modifier_data) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + with bpy.context.temp_override(object=child): + bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT") + + @classmethod + def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None: + if not (parent_obj := tool.Ifc.get_object(parent_element)): + return # Filtered out, arrayed void, etc + assert isinstance(parent_obj, bpy.types.Object) + children = cls.get_all_children_objects(parent_element) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + child.constraints.remove(constraint) + constraint = child.constraints.new("CHILD_OF") + constraint.name = "BBIM_Array_CHILD_OF" + assert isinstance(constraint, bpy.types.ChildOfConstraint) + constraint.target = parent_obj + + @classmethod + def set_children_lock_state( + cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True + ) -> None: + modifier_data = list(cls.get_modifiers_data(parent_element))[item] + children = cls.get_children_objects(modifier_data) + for child_obj in children: + tool.Blender.lock_transform(child_obj, lock_state) + + @classmethod + def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None: + children = cls.get_all_children_objects(parent_element) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + child.constraints.remove(constraint) + + @classmethod + def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + parent_obj = tool.Ifc.get_object(parent_element) + assert isinstance(parent_obj, bpy.types.Object) + children_objects = list(cls.get_all_children_objects(parent_element)) + array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0 + return array_objects + + @classmethod + def get_all_children_objects( + cls, parent_element: ifcopenshell.entity_instance + ) -> Generator[bpy.types.Object, None, None]: + for array_modifier in cls.get_modifiers_data(parent_element): + yield from cls.get_children_objects(array_modifier) + + @classmethod + def get_parent_element(cls, element: entity_instance) -> entity_instance | None: + """Inverse of ``get_all_children_objects``: resolve an array element + back to its parent entity. Returns ``None`` when the element isn't + part of a Bonsai parametric array, or the stored Parent GUID does + not resolve in the current file (this is a data-integrity warning + and is logged to the console).""" + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return None + parent_guid = pset["Parent"] + try: + return tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + print( + f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve " + f"in the current file — array integrity may be broken." + ) + return None + + @classmethod + def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None: + parent_element = cls.get_parent_element(element) + if parent_element is None: + return None + return tool.Ifc.get_object(parent_element) + + @classmethod + def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]: + array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") + yield from json.loads(array_pset["Data"]) + + @classmethod + def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]: + child_guid: str + for child_guid in modifier_data["children"]: + child_obj = tool.Blender.get_object_from_guid(child_guid) + if child_obj: + yield child_obj + + @classmethod + def get_array_root_guid(cls, element: entity_instance) -> str: + """Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's + GlobalId. For an element with no ``BBIM_Array`` pset (independent + window, never arrayed, or former-child after the apply path), returns + the element's own GlobalId — its "family" is just itself.""" + current = element + seen: set[str] = set() + while True: + pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array") + parent_guid = pset.get("Parent") if pset else None + if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen: + return current.GlobalId + seen.add(parent_guid) + try: + current = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return current.GlobalId + + @classmethod + def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]: + """Type-occurrences that should receive parametric updates when + ``element`` is edited. + + Returns occurrences in ``element``'s Bonsai array family. When + ``element`` is not part of any array, returns the type-occurrence + peers that are likewise free of ``BBIM_Array`` (preserving the + bulk-edit-by-type UX for standalone parametric elements). An + occurrence whose ``BBIM_Array`` root differs from ``element``'s root + is excluded — that is the "independent former child" case the array + apply path produces.""" + occurrences = tool.Ifc.get_all_element_occurrences(element) + element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not element_pset: + return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")] + element_root = cls.get_array_root_guid(element) + return [o for o in occurrences if cls.get_array_root_guid(o) == element_root] + + @classmethod + def get_child_layer_index(cls, child_element: entity_instance) -> int | None: + """Index of the layer that produced ``child_element``, or ``None`` + if the child is unparented, missing from the parent's data, or the + parent's pset is unreadable. Total: never raises.""" + pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + if not pset: + return None + parent_guid = pset.get("Parent") + if not parent_guid or parent_guid == child_element.GlobalId: + return None + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return None + data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not data_text: + return None + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return None + child_guid = child_element.GlobalId + for i, layer in enumerate(layers): + if child_guid in layer.get("children", []): + return i + return None From 96b6985960d59e00904448db38e32e07d32af042 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:45:14 +0200 Subject: [PATCH 05/15] Extend tool.System with port + path helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: * direction_from_port_pair(port_a, port_b) — derive the connect_port direction kwarg from each port's FlowDirection (NOTDEFINED for non-canonical pairs). Centralises a pattern that callers were inlining inconsistently. * tool.System.walk_connected_mep_elements — BFS over connected MEP flow elements via IfcRelConnectsPorts. * tool.System.get_port_world_position — port placement → world-space Vector, used by the MEP path decorator. * tool.System._build_decoration_data — cached decoration metadata for the MEP system-path overlay. Plus a get_port_relating_element return-type tightening (Union with None) and a partial-init cycle workaround on bim.module.system.data imports (now function-local — top-level import triggered the cycle through tool.Ifc.Operator). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/system.py | 140 ++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 22 deletions(-) diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 926bceca91..8d2b421370 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -19,6 +19,7 @@ from __future__ import annotations import re +from collections import deque from enum import Enum from typing import TYPE_CHECKING, Any, Optional, Union @@ -26,6 +27,7 @@ import bpy import ifcopenshell.api.geometry import ifcopenshell.api.system import ifcopenshell.util.element +import ifcopenshell.util.placement import ifcopenshell.util.system from mathutils import Matrix, Vector @@ -35,12 +37,29 @@ import bonsai.core.root import bonsai.core.tool import bonsai.tool as tool from bonsai.bim import import_ifc -from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + +# Data-class imports from ``bonsai.bim.module.system.data`` are function-local: +# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator. if TYPE_CHECKING: from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties +_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = { + ("SOURCE", "SINK"): "SOURCE", + ("SINK", "SOURCE"): "SINK", + ("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK", +} + + +def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str: + """Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port`` + from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs.""" + a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED" + b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED" + return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED") + + class System(bonsai.core.tool.System): @classmethod def get_system_props(cls) -> BIMSystemProperties: @@ -81,7 +100,7 @@ class System(bonsai.core.tool.System): # make sure obj.dimensions and .matrix_world has valid data bpy.context.view_layer.update() # need to make sure .ObjectPlacement is also updated when we're going to add ports - tool.Model.sync_object_ifc_position(obj) + tool.Geometry.commit_placement_if_moved(obj) mep_element = tool.Ifc.get_entity(obj) bbox = tool.Blender.get_object_bounding_box(obj) @@ -162,12 +181,12 @@ class System(bonsai.core.tool.System): return ifcopenshell.util.system.get_ports(element) @classmethod - def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: if tool.Ifc.get_schema() == "IFC2X3": - element = port.ContainedIn[0].RelatedElement - else: - element = port.Nests[0].RelatingObject - return element + rel = port.ContainedIn[0] if port.ContainedIn else None + return rel.RelatedElement if rel else None + rel = port.Nests[0] if port.Nests else None + return rel.RelatingObject if rel else None @classmethod def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str: @@ -280,31 +299,42 @@ class System(bonsai.core.tool.System): system_props = cls.get_system_props() return tool.Ifc.get_entity_by_id(system_props.active_system_id) + # Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)). + _decoration_data_cache_key: tuple | None = None + _decoration_data_cache: dict[str, Any] | None = None + @classmethod def get_decoration_data(cls) -> dict[str, Any]: + from bonsai.bim.decorator_cache import get_decorator_cache_token + from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + + if not ObjectSystemData.is_loaded: + ObjectSystemData.load() + if not SystemDecorationData.is_loaded: + SystemDecorationData.load() + + token = get_decorator_cache_token() + key = (token, id(SystemDecorationData.data["decorated_elements"])) + if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None: + return cls._decoration_data_cache + + result = cls._build_decoration_data() + cls._decoration_data_cache_key = key + cls._decoration_data_cache = result + return result + + @classmethod + def _build_decoration_data(cls) -> dict[str, Any]: + from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + all_vertices = [] preview_edges = [] special_vertices = [] selected_edges = [] selected_vertices = [] - view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d - viewport_matrix = view3d_space.view_matrix.inverted() - viewport_y_axis = viewport_matrix.col[1].to_3d().normalized() - camera_pos = viewport_matrix.translation - dir_to_camera = lambda x: (camera_pos - x).normalized() - - def most_aligned_vector(a, vectors): - return max(vectors, key=lambda v: abs(a.dot(v))) - start_vert_i = 0 - if not ObjectSystemData.is_loaded: - ObjectSystemData.load() - - if not SystemDecorationData.is_loaded: - SystemDecorationData.load() - class FlowDirection(Enum): BACKWARD = -1 FORWARD = 1 @@ -458,6 +488,72 @@ class System(bonsai.core.tool.System): def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool: return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") + @classmethod + def walk_connected_mep_elements( + cls, start_element: ifcopenshell.entity_instance + ) -> list[ifcopenshell.entity_instance]: + """Return all MEP elements reachable from ``start_element`` via + ``IfcRelConnectsPorts`` in either direction, in BFS order with + ``start_element`` first. + + Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are + returned; non-MEP neighbours reached via a fitting's port are + traversed but not collected. + """ + if not cls.is_mep_element(start_element): + return [] + result: list[ifcopenshell.entity_instance] = [] + visited: set[int] = set() + queue: deque[ifcopenshell.entity_instance] = deque([start_element]) + while queue: + element = queue.popleft() + if element.id() in visited: + continue + visited.add(element.id()) + if not cls.is_mep_element(element): + continue + result.append(element) + for port in cls.get_ports(element): + connected_port = cls.get_connected_port(port) + if connected_port is None: + continue + neighbor = cls.get_port_relating_element(connected_port) + if neighbor is None or neighbor.id() in visited: + continue + queue.append(neighbor) + return result + + @classmethod + def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector: + """World-space position of an ``IfcDistributionPort``. + + Follows the parent element's live ``matrix_world`` when available so + an uncommitted rotation doesn't drift from its ports; falls back to + the raw IFC placement otherwise.""" + placement = getattr(port, "ObjectPlacement", None) + if placement is None: + return Vector((0.0, 0.0, 0.0)) + port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist()) + + parent_element = cls.get_port_relating_element(port) + if parent_element is None: + return Vector(port_ifc_matrix.translation) + + parent_obj = tool.Ifc.get_object(parent_element) + if parent_obj is None: + return Vector(port_ifc_matrix.translation) + + parent_placement = getattr(parent_element, "ObjectPlacement", None) + if parent_placement is None: + return Vector(port_ifc_matrix.translation) + parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist()) + + try: + port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix + except ValueError: + return Vector(port_ifc_matrix.translation) + return (parent_obj.matrix_world @ port_local_to_parent).translation + @classmethod def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: if not element.HasControlElements: From 49ddda62810d204a1cd5f5f04bdf422627b2f2e1 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 26 May 2026 23:48:11 +0200 Subject: [PATCH 06/15] Add tool.Duplicate service Extract the duplicate-aware relationship-walk + restoration logic (get_decomposition_relationships, get_connection_relationships, get_port_connection_relationships, recreate_decompositions, recreate_connections, recreate_port_connections, consume_warnings) out of tool.Root into its own service. tool.Root's responsibility is identity and addressing of IFC roots; the duplicate-aware bookkeeping of "before duplication, what relations did this graph have, and how do I restore them on the new copies?" deserves its own home. The split was already declared on core/tool.py (C2); this commit lands the concrete tool.Duplicate implementation. tool.Root keeps its own copies of the methods on v0.8.0's tool/root.py during this PR so callers in bim/module/spatial/operator.py keep working at runtime; the Root cleanup lands in PR4 alongside the caller updates. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/duplicate.py | 328 ++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/bonsai/bonsai/tool/duplicate.py diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 2afff3f71b..4cedc3994e 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -38,6 +38,7 @@ from bonsai.tool.debug import Debug from bonsai.tool.demo import Demo from bonsai.tool.document import Document from bonsai.tool.drawing import Drawing +from bonsai.tool.duplicate import Duplicate from bonsai.tool.feature import Feature from bonsai.tool.geometry import Geometry from bonsai.tool.georeference import Georeference diff --git a/src/bonsai/bonsai/tool/duplicate.py b/src/bonsai/bonsai/tool/duplicate.py new file mode 100644 index 0000000000..eb3630ccf1 --- /dev/null +++ b/src/bonsai/bonsai/tool/duplicate.py @@ -0,0 +1,328 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2021 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 dataclasses import dataclass, field +from typing import Any, Literal + +import bpy +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation + +import bonsai.core.geometry +import bonsai.core.tool +import bonsai.tool as tool + + +@dataclass +class DecompositionRecord: + type: Literal["fill"] + element: ifcopenshell.entity_instance + + +@dataclass +class ConnectionRecord: + type: Literal["path"] + relating_element: ifcopenshell.entity_instance + related_element: ifcopenshell.entity_instance + relating_connection_type: str + related_connection_type: str + relating_priorities: list[int] + related_priorities: list[int] + + +@dataclass +class PortConnectionRecord: + relating_port_index: int + related_element: ifcopenshell.entity_instance + related_port_index: int + direction: str + + +@dataclass +class PortConnectionSnapshot: + """Port-to-port connections and per-element port counts captured before duplication.""" + + by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict) + port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict) + + +class Duplicate(bonsai.core.tool.Duplicate): + + _pending_warnings: list[str] = [] + + @classmethod + def _emit_warning(cls, message: str) -> None: + """Buffer a warning for later retrieval by an operator. Falling through + to a print keeps the message in the Blender console for the headless / + no-operator code path.""" + cls._pending_warnings.append(message) + print(f"Bonsai: WARNING — {message}") + + @classmethod + def consume_warnings(cls) -> list[str]: + """Return and clear the buffered warnings — operators call this after + ``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``.""" + warnings = cls._pending_warnings + cls._pending_warnings = [] + return warnings + + @classmethod + def get_decomposition_relationships( + cls, objs: list[bpy.types.Object] + ) -> dict[ifcopenshell.entity_instance, DecompositionRecord]: + relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {} + for obj in objs: + element = tool.Ifc.get_entity(obj) + if not element: + continue + if building := tool.Spatial.get_host_element(element): + relationships[element] = DecompositionRecord(type="fill", element=building) + return relationships + + @classmethod + def get_connection_relationships( + cls, objs: list[bpy.types.Object] + ) -> dict[ifcopenshell.entity_instance, ConnectionRecord]: + relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {} + for obj in objs: + element = tool.Ifc.get_entity(obj) + if not element: + continue + if hasattr(element, "ConnectedTo") and element.ConnectedTo: + paths = [ + connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements") + ] + for path in paths: + relationships[element] = ConnectionRecord( + type="path", + relating_element=path.RelatingElement, + related_element=path.RelatedElement, + relating_connection_type=path.RelatingConnectionType, + related_connection_type=path.RelatedConnectionType, + relating_priorities=list(path.RelatingPriorities or []), + related_priorities=list(path.RelatedPriorities or []), + ) + return relationships + + @classmethod + def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot: + """Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates.""" + # Function-local: top-level import would trigger a partial-init cycle. + from bonsai.tool.system import direction_from_port_pair + + snapshot = PortConnectionSnapshot() + elements_in_set: set[ifcopenshell.entity_instance] = set() + for obj in objs: + element = tool.Ifc.get_entity(obj) + if element is not None and tool.System.is_mep_element(element): + elements_in_set.add(element) + if not elements_in_set: + return snapshot + + ordered_elements = sorted(elements_in_set, key=lambda e: e.id()) + for element in ordered_elements: + snapshot.port_counts[element] = len(tool.System.get_ports(element)) + + seen: set[tuple[tuple[int, int], tuple[int, int]]] = set() + for element in ordered_elements: + ports = tool.System.get_ports(element) + for port_index, port in enumerate(ports): + connected_port = tool.System.get_connected_port(port) + if connected_port is None: + continue + other_element = tool.System.get_port_relating_element(connected_port) + if other_element is None or other_element not in elements_in_set: + continue + other_ports = tool.System.get_ports(other_element) + try: + other_port_index = other_ports.index(connected_port) + except ValueError: + continue + pair_key = tuple( + sorted( + [ + (element.id(), port_index), + (other_element.id(), other_port_index), + ] + ) + ) + if pair_key in seen: + continue + seen.add(pair_key) + + snapshot.by_element.setdefault(element, []).append( + PortConnectionRecord( + relating_port_index=port_index, + related_element=other_element, + related_port_index=other_port_index, + direction=direction_from_port_pair(port, connected_port), + ) + ) + return snapshot + + @classmethod + def recreate_decompositions( + cls, + relationships: dict[ifcopenshell.entity_instance, DecompositionRecord], + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + for subelement, data in relationships.items(): + new_subelements = old_to_new.get(subelement) + new_elements = old_to_new.get(data.element) + if not new_subelements or not new_elements: + continue + for i, new_subelement in enumerate(new_subelements): + new_element = new_elements[i] + if data.type == "fill": + element = new_element + filling = new_subelement + voided_obj = tool.Ifc.get_object(new_element) + filling_obj = tool.Ifc.get_object(new_subelement) + + existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement + opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence) + tool.Ifc.run( + "geometry.edit_object_placement", + product=opening, + matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement), + is_si=False, + ) + + representation = ifcopenshell.util.representation.get_representation( + existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" + ) + representation = ifcopenshell.util.representation.resolve_representation(representation) + mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation) + tool.Ifc.run( + "geometry.assign_representation", + product=opening, + representation=mapped_representation, + ) + tool.Ifc.run("feature.add_feature", feature=opening, element=element) + tool.Ifc.run("feature.add_filling", opening=opening, element=filling) + + voided_objs = [voided_obj] + # Openings affect all subelements of an aggregate + for child_subelement in ifcopenshell.util.element.get_decomposition(element): + subobj = tool.Ifc.get_object(child_subelement) + if subobj: + voided_objs.append(subobj) + + for voided_obj in voided_objs: + if mesh_data := voided_obj.data: + representation = tool.Ifc.get().by_id( + tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id + ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=voided_obj, + representation=representation, + ) + + @classmethod + def recreate_connections( + cls, + relationship: dict[ifcopenshell.entity_instance, ConnectionRecord], + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + for element, data in relationship.items(): + try: + new_relating_element = old_to_new.get(data.relating_element)[0] + new_related_element = old_to_new.get(data.related_element)[0] + except (KeyError, IndexError, TypeError): + continue + new_rel = tool.Ifc.run( + "geometry.connect_path", + relating_element=new_relating_element, + related_element=new_related_element, + relating_connection=data.relating_connection_type, + related_connection=data.related_connection_type, + ) + # connect_path hardcodes priorities to []; restore them post-hoc. + priority_attrs: dict[str, Any] = {} + if data.relating_priorities: + priority_attrs["RelatingPriorities"] = data.relating_priorities + if data.related_priorities: + priority_attrs["RelatedPriorities"] = data.related_priorities + if new_rel is not None and priority_attrs: + try: + tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs) + except (RuntimeError, ifcopenshell.Error) as e: + cls._emit_warning( + f"connection priority restore failed for {new_rel}; " + f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}" + ) + + @classmethod + def recreate_port_connections( + cls, + snapshot: PortConnectionSnapshot, + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + """Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot.""" + for relating_element, records in snapshot.by_element.items(): + for record in records: + related_element = record.related_element + try: + new_relating = old_to_new[relating_element][0] + new_related = old_to_new[related_element][0] + except (KeyError, IndexError): + continue + + new_relating_ports = tool.System.get_ports(new_relating) + new_related_ports = tool.System.get_ports(new_related) + + expected_relating = snapshot.port_counts.get(relating_element) + if expected_relating is not None and len(new_relating_ports) != expected_relating: + cls._emit_warning( + f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, " + f"snapshot had {expected_relating}" + ) + continue + expected_related = snapshot.port_counts.get(related_element) + if expected_related is not None and len(new_related_ports) != expected_related: + cls._emit_warning( + f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, " + f"snapshot had {expected_related}" + ) + continue + + try: + new_port_a = new_relating_ports[record.relating_port_index] + new_port_b = new_related_ports[record.related_port_index] + except IndexError: + cls._emit_warning( + f"port reconnect skipped — record references port index past the duplicate's port list" + ) + continue + try: + tool.Ifc.run( + "system.connect_port", + port1=new_port_a, + port2=new_port_b, + direction=record.direction or "NOTDEFINED", + ) + except (RuntimeError, ifcopenshell.Error) as e: + cls._emit_warning(f"port reconnect failed between duplicates: {e}") From a0c6f6f9a681d0b5b7430a8a2a92b98b3d31fd2e Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 00:00:53 +0200 Subject: [PATCH 07/15] Extend tool.Blender for parametric framework + decorators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: * ViewportDecorator base class — install/uninstall/draw lifecycle for 3D viewport gpu overlays, with handler-rollback-on-failure so a partial install can't leave dangling draw handlers. * sync_all classmethod — drive each listed ViewportDecorator subclass to its desired install state in one call. * is_view_top_down + top_down_factor — viewport-camera orientation predicates used by gizmo billboarding and decorator layout. * get_screen_up_world — screen-up vector in world space for gizmo text orientation. * are_viewport_gizmos_enabled — central gate for the global draw_gizmos_in_3d_viewport pref, replacing duplicated prefs reads. * DecoratorColors NamedTuple + get_decorator_colors — single source for the colour palette every viewport decorator binds. Preserves Ryan Schultz's add_layout_hotkey_operator polish (719309571, 2026-05-25): the row-position move + separator(factor=1) between the modifier and key icons stay intact in this extraction. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/blender.py | 361 ++++++++++++++++++++---------- 1 file changed, 245 insertions(+), 116 deletions(-) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 9e5515d07a..0c73d58599 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -22,7 +22,6 @@ from __future__ import annotations import contextlib import importlib -import json import os import platform import subprocess @@ -30,7 +29,7 @@ import sys import tempfile import traceback import types -from collections.abc import Callable, Generator, Iterable, Sequence, Sized +from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized from datetime import datetime from functools import cache, lru_cache from pathlib import Path @@ -47,7 +46,6 @@ from typing import ( import bmesh import bpy -import ifcopenshell.api import ifcopenshell.util.element import numpy as np import numpy.typing as npt @@ -99,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [ OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera] +_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType") +_STAIR_MODIFIER_IFC_CLASSES = ( + "IfcStairFlight", + "IfcStairFlightType", + "IfcMember", + "IfcMemberType", + "IfcStair", + "IfcStairType", +) +_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle") +_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle") +_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType") + class Blender(bonsai.core.tool.Blender): OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE") @@ -417,6 +428,189 @@ class Blender(bonsai.core.tool.Blender): with bpy.context.temp_override(**cls.get_viewport_context()): bpy.ops.wm.tool_set_by_id(name=tool_name) + @classmethod + def are_viewport_gizmos_enabled(cls) -> bool: + """Central gate every Bonsai gizmo poll / decorator draw checks before + rendering. Centralises the read of + ``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences.""" + return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport + + class DecoratorColors(NamedTuple): + selected: tuple + unselected: tuple + special: tuple + error: tuple + background: tuple + + @classmethod + def get_decorator_colors(cls) -> Blender.DecoratorColors: + """The five ``decorator_color_*`` fields read together so each viewport + decorator's draw callback resolves them in one call instead of five.""" + prefs = cls.get_addon_preferences() + return cls.DecoratorColors( + selected=prefs.decorator_color_selected, + unselected=prefs.decorator_color_unselected, + special=prefs.decorator_color_special, + error=prefs.decorator_color_error, + background=prefs.decorator_color_background, + ) + + class ViewportDecorator: + """Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators. + + Single-handler subclasses set ``draw_method`` (default ``"draw"``); the + handler binds at ``POST_VIEW``. Multi-handler subclasses set + ``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it + is non-``None`` it supersedes ``draw_method``. + + Decorators whose ``install`` must accept extra arguments (e.g. a callback + or a precomputed bmesh) override ``install`` themselves.""" + + draw_method: str = "draw" + draw_methods: tuple[tuple[str, str], ...] | None = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.handlers = [] + cls.is_installed = False + # Fail loudly at class-definition time if draw_method / draw_methods + # names an attribute the class doesn't expose. Without this, a typo + # only surfaces on the first redraw — as a silent missing-attribute + # handler — which may be far from the offending declaration. + method_names = ( + tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,) + ) + for name in method_names: + if getattr(cls, name, None) is None: + raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class") + + @classmethod + def install(cls, context: bpy.types.Context) -> None: + if cls.is_installed: + cls.uninstall() + handler = cls() + bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),) + # Rollback partial registrations on any draw_handler_add failure, so + # cls.handlers never ends up holding a half-installed set. + added: list = [] + try: + for method_name, phase in bindings: + added.append( + bpy.types.SpaceView3D.draw_handler_add( + getattr(handler, method_name), (context,), "WINDOW", phase + ) + ) + except Exception: + for h in added: + try: + bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW") + except ValueError: + pass + raise + cls.handlers = added + cls.is_installed = True + + @classmethod + def uninstall(cls) -> None: + for h in cls.handlers: + try: + bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW") + except ValueError: + pass + cls.handlers.clear() + cls.is_installed = False + + @staticmethod + def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]: + """Return the live ``GizmoGroup`` instance registered under + ``context.region``, or ``None`` if there isn't one. The per-region + weakref dict on the gizmo class is populated by ``setup()``; multi- + viewport setups put one entry per region in it so each region's + decorator sees only its own region's hover state.""" + instances = getattr(gizmo_cls, "_active_instances", None) + if not instances: + return None + region = getattr(context, "region", None) + if region is None: + return None + ref = instances.get(region.as_pointer()) + if ref is None: + return None + return ref() + + def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool: + """True iff the gizmo group instance in the current region exposes a gizmo + under ``attr_name`` that reports as highlighted. Any access exception is + swallowed so a transient bpy-state hiccup never breaks the draw loop.""" + inst = self._lookup_active_instance(gizmo_cls, context) + if inst is None: + return False + try: + return bool(getattr(inst, attr_name).is_highlight) + except (AttributeError, ReferenceError): + return False + + @classmethod + def sync_all( + cls, + context: bpy.types.Context, + enabled: Mapping[type[ViewportDecorator], bool], + ) -> None: + """Drive each listed decorator to its desired install state in one call. + + Each entry whose value is ``True`` ends up installed; each entry whose + value is ``False`` ends up uninstalled. Pass ``True`` for always-on + overlays so they survive subsequent file loads.""" + for decorator_cls, should_install in enabled.items(): + if should_install: + decorator_cls.install(context) + else: + decorator_cls.uninstall() + + @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. + + Default threshold of 0.9659 = cos(15°) — a 15° tilt cone around ±world Z. + Above the threshold the world-Z axis projects to a small fraction of its + true length on screen, so callers that lay icons or markers out along + world Z should switch to a screen-space offset and any gizmo whose intent + is specifically "vertical" loses its visual cue. The cone is kept narrow + so vertical-intent gizmos stay visible across the typical orbit range of + 3D viewport work and drop out only near genuine plan view.""" + rv3d = context.region_data + if rv3d is None: + return False + view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() + return abs(view_forward.z) > threshold + + @classmethod + def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float: + """Continuous 0–1 ramp matching ``is_view_top_down``'s cone: 0 outside the + cone, ramping linearly to 1 at strict alignment with world Z. Callers that + want a proportional effect (an icon-stack lift growing as the view + approaches plan) use this in place of the boolean to avoid a one-frame + visual jump as the camera crosses the threshold.""" + rv3d = context.region_data + if rv3d is None: + return 0.0 + view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() + alignment = abs(view_forward.z) + if alignment <= threshold: + return 0.0 + return (alignment - threshold) / (1.0 - threshold) + + @classmethod + def get_screen_up_world(cls, context: bpy.types.Context) -> Vector: + """World-space direction corresponding to the camera's up axis (screen-vertical). + + Returns ``+Y`` when region data is unavailable so callers can compute an + offset without a guard branch.""" + rv3d = context.region_data + if rv3d is None: + return Vector((0.0, 1.0, 0.0)) + return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized() + @classmethod def get_shader_editor_context(cls) -> Union[dict[str, Any], None]: for screen in bpy.data.screens: @@ -1143,13 +1337,13 @@ class Blender(bonsai.core.tool.Blender): """ # roof and railing both finalize then drop into path-edit mode — handle # them before the generic finish dispatch so the path transition runs. - if cls.is_roof(element): - if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj): - tool.Parametric.run_bim_op(feature.finish_op) + if tool.Parametric.is_roof(element): + if tool.Parametric.ROOF.is_editing(obj): + tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op) bpy.ops.bim.enable_editing_roof_path() - elif cls.is_railing(element): - if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj): - tool.Parametric.run_bim_op(feature.finish_op) + elif tool.Parametric.is_railing(element): + if tool.Parametric.RAILING.is_editing(obj): + tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op) bpy.ops.bim.enable_editing_railing_path() elif feature := tool.Parametric.is_object_editing(obj): tool.Parametric.run_bim_op(feature.finish_op) @@ -1176,59 +1370,67 @@ class Blender(bonsai.core.tool.Blender): @classmethod def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType")) + return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class( - obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType") - ) + return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle")) + return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle")) + return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType")) + return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES) @classmethod - def is_railing(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Railing") + def is_array_child(cls, element: entity_instance) -> bool: + """True if element is a CHILD of a Bonsai parametric array. - @classmethod - def is_roof(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Roof") + Children are managed replicas regenerated from the parent's pset — + their parametric attributes (door dimensions, wall lengths, …) are + overwritten on the next ``regenerate_array``. Parametric gizmo + groups skip children via this predicate in ``poll``. - @classmethod - def is_window(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Window") - - @classmethod - def is_door(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Door") - - @classmethod - def is_stair(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Stair") - - @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. - - Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset — - their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage, - IfcExtrudedAreaSolid). Any LAYER2 wall qualifies.""" - if not element.is_a("IfcWall"): + This sits on a different axis from ``tool.Parametric.is_array``: + cardinality (parent vs child) is orthogonal to feature kind, and + an arrayed wall fires both ``is_wall`` and ``is_array`` on the + same element.""" + if element is None: return False - return tool.Model.get_usage_type(element) == "LAYER2" + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return False + parent_guid = pset.get("Parent") + return parent_guid is not None and parent_guid != element.GlobalId @classmethod - def is_editing_railing_path(cls, obj: bpy.types.Object): + def is_slab(cls, element: entity_instance) -> bool: + """A slab is host-eligible for the parametric add-opening gizmo if + it is an IfcSlab with LAYER3 usage. + + Slabs carry no proprietary BBIM_Slab pset — their parametric state + lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage + with LayerSetDirection AXIS3). Any LAYER3 slab qualifies.""" + if element is None or not element.is_a("IfcSlab"): + return False + return tool.Model.get_usage_type(element) == "LAYER3" + + @classmethod + def is_pipe_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcPipeSegment") + + @classmethod + def is_duct_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcDuctSegment") + + @classmethod + def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool: props = tool.Model.get_railing_props(obj) return props.is_editing_path @@ -1242,79 +1444,6 @@ class Blender(bonsai.core.tool.Blender): feature = tool.Parametric.find_for_element(element) return bool(feature and feature.has_non_editable_path) - class Array: - @classmethod - def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None: - modifier_data = list(cls.get_modifiers_data(parent_element))[item] - children = cls.get_children_objects(modifier_data) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - with bpy.context.temp_override(object=child): - bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT") - - @classmethod - def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None: - if not (parent_obj := tool.Ifc.get_object(parent_element)): - return # Filtered out, arrayed void, etc - assert isinstance(parent_obj, bpy.types.Object) - children = cls.get_all_children_objects(parent_element) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - child.constraints.remove(constraint) - constraint = child.constraints.new("CHILD_OF") - constraint.name = "BBIM_Array_CHILD_OF" - assert isinstance(constraint, bpy.types.ChildOfConstraint) - constraint.target = parent_obj - - @classmethod - def set_children_lock_state( - cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True - ) -> None: - modifier_data = list(cls.get_modifiers_data(parent_element))[item] - children = cls.get_children_objects(modifier_data) - for child_obj in children: - Blender.lock_transform(child_obj, lock_state) - - @classmethod - def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None: - children = cls.get_all_children_objects(parent_element) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - child.constraints.remove(constraint) - - @classmethod - def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]: - parent_obj = tool.Ifc.get_object(parent_element) - assert isinstance(parent_obj, bpy.types.Object) - children_objects = list(cls.get_all_children_objects(parent_element)) - array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0 - return array_objects - - @classmethod - def get_all_children_objects( - cls, parent_element: ifcopenshell.entity_instance - ) -> Generator[bpy.types.Object, None, None]: - for array_modifier in cls.get_modifiers_data(parent_element): - yield from cls.get_children_objects(array_modifier) - - @classmethod - def get_modifiers_data( - cls, parent_element: ifcopenshell.entity_instance - ) -> Generator[dict[str, Any], None, None]: - array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") - yield from json.loads(array_pset["Data"]) - - @classmethod - def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]: - child_guid: str - for child_guid in modifier_data["children"]: - child_obj = tool.Blender.get_object_from_guid(child_guid) - if child_obj: - yield child_obj - class Attribute: @classmethod def fill_attribute(cls, data: bpy.types.ID, attribute_name: str, domain: str, data_type: str, values): From 3483683cb4a986f2e50a403c09dfdd10c18d0474 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 00:04:03 +0200 Subject: [PATCH 08/15] Add tool.Geometry helpers for body representation + placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: * get_body_representation(element) — DRY of the repeated ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") call across slab / wall / opening / stair / roof / door / window / mep. One central place to read the body rep; every caller stops re-spelling the four magic strings. * has_axis_representation(element) — predicate for elements with a GRAPH_VIEW Axis representation. Used by the wall/MEP path decorators to skip elements without an unambiguous 1D path. * has_material_styles(element) — predicate for whether the element carries IfcStyledItem material assignments. * restore_placement_from_ifc(obj, element) — snap obj.matrix_world back to element's committed IFC placement + rebaseline the drift checksum. * restore_or_rebaseline_placement(obj, element) — Cancel-flow helper: restores if ObjectPlacement exists, just rebaselines the checksum if not. * detach_representation(product) — remove the active representation from a product without deleting the entity (used by parametric rebuilds that wipe + re-add). commit_placement_if_moved docstring expanded with a "drop-in scope" note so callers don't redundantly wrap it in an is_moved check that the helper already does. Switches the duplicate-aware helper calls (formerly tool.Root.*) to tool.Duplicate.* now that the service exists (C6). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/geometry.py | 129 ++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 0d690d0308..4fe0fdc7ab 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -73,7 +73,7 @@ import bonsai.core.style import bonsai.core.system import bonsai.core.tool import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore +from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock if TYPE_CHECKING: from bonsai.bim.module.geometry.prop import ( @@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def clear_cache(cls, element: ifcopenshell.entity_instance) -> None: - cache = IfcStore.get_cache() + # Cache acquisition can fail if the HDF5 file is locked by another + # process — degrade gracefully rather than aborting the caller's + # reimport flow. A stale cache entry is harmless; a raised exception + # prevents the actual mesh swap. The wrapper sets the project-panel + # warning flag on lock so the user sees one prominent notice instead + # of per-element log spam. + try: + cache = get_cache_or_detect_lock() + except Exception as exc: + print(f"clear_cache: skipping cache invalidation for {element} ({exc})") + return if cache and hasattr(element, "GlobalId"): cache.remove(element.GlobalId) + @classmethod + def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool: + """True if the element carries a shape representation whose + RepresentationIdentifier is 'Axis'. Elements without one cannot be + projected to an unambiguous 1D path; callers that draw schematic axis + overlays must skip them rather than fall back to mesh-derived geometry.""" + product_rep = getattr(element, "Representation", None) + if product_rep is None: + return False + for rep in product_rep.Representations: + if getattr(rep, "RepresentationIdentifier", None) == "Axis": + return True + return False + + @classmethod + def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``. + Single source for the ``(context, identifier, target_view)`` triple used + by every body-geometry reader across walls, slabs, doors, openings, and + feature decorators.""" + return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + @classmethod def clear_modifiers(cls, obj: bpy.types.Object) -> None: for modifier in obj.modifiers: @@ -788,6 +820,15 @@ class Geometry(bonsai.core.tool.Geometry): return True return False + @classmethod + def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool: + """True when any of ``element``'s materials exposes an + ``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling.""" + return any( + tool.Material.get_style(material) is not None + for material in ifcopenshell.util.element.get_materials(element) + ) + @classmethod def reimport_element_representations( cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True @@ -1154,6 +1195,53 @@ class Geometry(bonsai.core.tool.Geometry): props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()) props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) + @classmethod + def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None: + """Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the + object has drifted since its last placement commit. + + Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites + whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced + upstream (inside an ``if is_moved:`` block) should call + ``edit_object_placement`` directly to avoid the redundant inner check.""" + if not tool.Ifc.is_moved(obj): + return + bonsai.core.geometry.edit_object_placement( + tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale + ) + + @classmethod + def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement, + then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns + False afterwards. + + Precondition: ``element.ObjectPlacement`` must not be None. Callers in a + cancel-style flow that want a "restore-or-clear-drift" semantic must gate + on ObjectPlacement themselves and call ``record_object_position`` directly + in the no-placement branch.""" + assert element.ObjectPlacement is not None, ( + "restore_placement_from_ifc requires ObjectPlacement — gate the caller " + "or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic" + ) + matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + matrix_np[:3, 3] *= unit_scale + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np) + cls.record_object_position(obj) + + @classmethod + def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed + IFC placement; when the element has no ObjectPlacement, re-baseline the drift + checksum instead so a subsequent edit does not silently commit the discarded drag.""" + if not tool.Ifc.is_moved(obj): + return + if element.ObjectPlacement is None: + cls.record_object_position(obj) + return + cls.restore_placement_from_ifc(obj, element) + @classmethod def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None: tool.Ifc.get().remove(connection) @@ -1205,6 +1293,20 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(obj) return new_obj + @classmethod + def detach_representation(cls, product: ifcopenshell.entity_instance) -> None: + """Replace ``product.Representation`` with a deep copy so the product + no longer shares its representation tree (mapped or direct) with any + other entity. The ``IfcGeometricRepresentationContext`` is excluded + from the copy so contexts stay file-singletons. No-op when the + product has no ``Representation`` attribute or it is unset.""" + rep = getattr(product, "Representation", None) + if rep is None: + return + product.Representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"] + ) + @classmethod def resolve_mapped_representation( cls, representation: ifcopenshell.entity_instance @@ -2132,8 +2234,11 @@ class Geometry(bonsai.core.tool.Geometry): new_active_obj = None # Track decompositions so they can be recreated after the operation - decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate) - connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate) + decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate) + connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate) + # Snapshot port-to-port connections — copy_class disconnects new ports + # by default, leaving Shift+D duplicates unconnected. + port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate) old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {} old_obj_name_to_new_obj_name: dict[str, str] = {} @@ -2155,10 +2260,7 @@ class Geometry(bonsai.core.tool.Geometry): keep_data_linked = linked and not element and not is_tracked_opening # Prior to duplicating, sync the object placement to make decomposition recreation more stable. - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement( - tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False - ) + cls.commit_placement_if_moved(obj, apply_scale=False) new_obj = obj.copy() temp_data = None @@ -2212,7 +2314,7 @@ class Geometry(bonsai.core.tool.Geometry): array_data = arrays_to_duplicate.get(obj, None) tool.Model.handle_array_on_copied_element(new, array_data) if array_data: - for child in tool.Blender.Modifier.Array.get_all_children_objects(new): + for child in tool.Array.get_all_children_objects(new): child.select_set(True) # TODO: add new array children to recreate their decomposition too @@ -2240,10 +2342,11 @@ class Geometry(bonsai.core.tool.Geometry): # Remove connections with old objects and recreates paths cls.remove_old_connections(old_to_new) - tool.Root.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new) # Recreate decompositions - tool.Root.recreate_decompositions(decomposition_relationships, old_to_new) + tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new) cls.remove_linked_aggregate_data(old_to_new) bonsai.bim.handler.refresh_ui_data() tool.Root.reload_grid_decorator() @@ -2308,8 +2411,8 @@ class Geometry(bonsai.core.tool.Geometry): continue array_data = [] - for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent): - children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data)) + for modifier_data in tool.Array.get_modifiers_data(array_parent): + children = set(tool.Array.get_children_objects(modifier_data)) if children.issubset(selected_objects): modifier_data["children"] = [] array_data.append(modifier_data) From db9d903650abb2b5b3e657b1db3c7541de50de9d Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 00:14:51 +0200 Subject: [PATCH 09/15] Polish tool.Model + tool.Pset + add tool.Slab service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool.Model gains: * get_pipe_segment_props / get_duct_segment_props — typed prop accessors for the MEP-segment edit lifecycle. * resolve_active_props_for_edit — picks the right BIM*Properties to drive a parametric edit triad based on the active object's IFC class. * mirror_parent_void_fillings_to_children — when an array parent has hosted fillings (door/window in a wall), replicate the same fill rels onto each array child. Uses tool.Array.get_parametric_propagation_ targets so the propagation stays within the array family (the old get_all_element_occurrences over-propagated to standalone occurrences of the same type, which silently mutated unrelated arrays). * unshare_opening_representation — fork a shared IfcShapeRepresentation so editing one opening doesn't mutate its array sibling. * duplicate_ifc_objects gains a post-condition select-restore on the array parent so callers don't get a deselected parent for N>=2 arrays. sync_object_ifc_position is kept as a thin delegate to tool.Geometry.commit_placement_if_moved (the new home, added in C8) so the 6 v0.8.0 callers in mep / product / system don't AttributeError; PR4 migrates each caller and removes the delegate. tool.Pset gains: * upsert_pset — get-or-add-or-edit in one call. * write_bbim_data — JSON-encode + write BBIM_* metadata in one call. tool.Slab is new — slab-specific reads (active extrusion, axis direction) used by the slab gizmos, pure-IFC, no PropertyGroup mutation. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/model.py | 244 ++++++++++++++++++++++++++--- src/bonsai/bonsai/tool/pset.py | 30 ++++ src/bonsai/bonsai/tool/slab.py | 74 +++++++++ 4 files changed, 325 insertions(+), 24 deletions(-) create mode 100644 src/bonsai/bonsai/tool/slab.py diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 4cedc3994e..03716236e1 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -66,6 +66,7 @@ from bonsai.tool.resource import Resource from bonsai.tool.root import Root from bonsai.tool.search import Search from bonsai.tool.sequence import Sequence +from bonsai.tool.slab import Slab from bonsai.tool.snap import Snap from bonsai.tool.spatial import Spatial from bonsai.tool.structural import Structural diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 1f103c4c5e..5b7aafab7c 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -22,7 +22,7 @@ from __future__ import annotations import collections.abc import json -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from copy import deepcopy from math import atan, cos, degrees, pi, radians from typing import ( @@ -39,9 +39,11 @@ from typing import ( import bmesh import bpy import ifcopenshell +import ifcopenshell.api.feature import ifcopenshell.api.geometry import ifcopenshell.api.grid import ifcopenshell.api.pset +import ifcopenshell.api.root import ifcopenshell.geom import ifcopenshell.ifcopenshell_wrapper as W import ifcopenshell.util.element @@ -60,6 +62,7 @@ import bonsai.core.geometry import bonsai.core.tool import bonsai.tool as tool from bonsai.bim import import_ifc +from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE T = TypeVar("T") V_ = tool.Blender.V_ @@ -72,8 +75,10 @@ if TYPE_CHECKING: from bonsai.bim.module.model.prop import ( BIMArrayProperties, BIMDoorProperties, + BIMDuctSegmentProperties, BIMExternalParametricGeometryProperties, BIMModelProperties, + BIMPipeSegmentProperties, BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, @@ -113,6 +118,14 @@ 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_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: + return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] + + @classmethod + def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties: + return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties: return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue] @@ -130,6 +143,35 @@ class Model(bonsai.core.tool.Model): assert (scene := bpy.context.scene) return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def resolve_active_props_for_edit( + cls, + context: bpy.types.Context, + props_getter: Callable[[bpy.types.Object], Any], + *, + subtype: Optional[tuple[str, Any]] = None, + ) -> Optional[tuple[bpy.types.Object, Any]]: + """Resolve ``(obj, props)`` for an operator that acts on the active + object only while a parametric edit is active. + + Returns ``None`` (the operator should ``return {"CANCELLED"}``) when + any of these fail: + - no active object, + - ``props.is_editing`` is False, + - ``subtype`` is given as ``(attr, value)`` and ``props. != value``. + """ + obj = context.active_object + if not obj: + return None + props = props_getter(obj) + if not getattr(props, "is_editing", False): + return None + if subtype is not None: + attr, value = subtype + if getattr(props, attr, None) != value: + return None + return obj, props + @classmethod def convert_si_to_unit(cls, value: T) -> T: if isinstance(value, (tuple, list)): @@ -799,7 +841,7 @@ class Model(bonsai.core.tool.Model): assert element or representation, "Either element or representation must be provided." if representation is None: assert element - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + representation = tool.Geometry.get_body_representation(element) if not representation: return [] booleans = [] @@ -820,7 +862,7 @@ class Model(bonsai.core.tool.Model): return [] boolean_ids = json.loads(pset["Data"]) if representation is None: - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + representation = tool.Geometry.get_body_representation(element) if not representation: return [] booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids] @@ -909,7 +951,7 @@ class Model(bonsai.core.tool.Model): # Revolved area check should happen inside bim.enable_editing_extrusion_axis # but keep it here to trigger import_representation_items, # so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support. - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + body = tool.Geometry.get_body_representation(element) if body and any( i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body) ): @@ -1022,7 +1064,14 @@ class Model(bonsai.core.tool.Model): def handle_array_on_copied_element( cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None ) -> None: - """if no `array_data` is provided then an array will be removed from the element""" + """Post-copy hook: decide what to do with the BBIM_Array pset a copy + inherits from its source. + + - ``array_data=None`` — detach the copy from any array. Removes the + inherited BBIM_Array pset and any CHILD_OF constraint. + - ``array_data`` provided — promote the copy to a fresh array parent + with an empty children list, using the provided layer config. + """ if array_data is None: array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") @@ -1066,8 +1115,8 @@ class Model(bonsai.core.tool.Model): ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data}) for i in range(len(array_data)): - tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + tool.Array.set_children_lock_state(element, i, True) + tool.Array.constrain_children_to_parent(element) @classmethod def regenerate_array( @@ -1104,12 +1153,17 @@ class Model(bonsai.core.tool.Model): offset = base_offset * i for obj in obj_stack: + # IndexError when child_i is past the recorded children list + # (count grew); RuntimeError when by_guid finds no entity (the + # child was deleted outside the array op); AssertionError when + # the IFC entity exists but its Blender object was unlinked. + # All three fall through to duplication. try: global_id = array["children"][child_i] child_element = tool.Ifc.get().by_guid(global_id) child_obj = tool.Ifc.get_object(child_element) assert child_obj - except: + except (IndexError, RuntimeError, AssertionError): old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj]) child_element = next(iter(old_to_new.values()))[0] child_obj = tool.Ifc.get_object(child_element) @@ -1146,14 +1200,24 @@ class Model(bonsai.core.tool.Model): removed_children = set(existing_children) - set(array["children"]) for removed_child in removed_children: element = tool.Ifc.get().by_guid(removed_child) + # 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): + ifcopenshell.api.feature.remove_feature( + tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement + ) obj = tool.Ifc.get_object(element) if obj: tool.Geometry.delete_ifc_object(obj) + if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements: + cls.mirror_parent_void_fillings_to_children(parent_element, children_elements) + if array_i in array_layers_to_apply: for child_element in children_elements: pset = tool.Pset.get_element_pset(child_element, "BBIM_Array") ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset) + cls.unshare_opening_representation(child_element) array["children"] = [] array["count"] = 1 @@ -1166,6 +1230,112 @@ class Model(bonsai.core.tool.Model): tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId} ) + # Post-condition: parent is selected on return. duplicate_ifc_objects + # deselects the source on every call inside the regen loop; without + # this restore, callers get a deselected parent for arrays with N >= 2. + # TODO: batch the per-child duplicate_ifc_objects([parent]) calls into + # a single N-way duplicate — N depsgraph churns + N select/deselect + # flips is wasteful, and a batched duplicate would also remove the + # need for this restore. + parent_obj.select_set(True) + + @classmethod + def mirror_parent_void_fillings_to_children( + cls, + parent_element: ifcopenshell.entity_instance, + children_elements: Sequence[ifcopenshell.entity_instance], + ) -> None: + """Replicate the parent's FillsVoids → host chain onto each array child. + + For each child, tears down any stale opening, creates a new + IfcOpeningElement at the child's current placement, reuses the parent's + opening representation as a MappedRepresentation, and adds the + void + filling pair so the host element is cut once per child. + + No-op when the parent is not a filling, when the host element cannot + be resolved, or when the children list is empty. Opt out via the + per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy + key ``mirror_to_host`` still honoured for round-trip with older files). + """ + host = tool.Spatial.get_host_element(parent_element) + if host is None or not children_elements: + return + + ifc_file = tool.Ifc.get() + parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement + parent_opening_rep = ifcopenshell.util.representation.get_representation( + parent_opening, "Model", "Body", "MODEL_VIEW" + ) + if parent_opening_rep is None: + return + parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep) + + for child in children_elements: + if getattr(child, "FillsVoids", None): + ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement) + child_obj = tool.Ifc.get_object(child) + if child_obj is None: + continue + + new_opening = ifcopenshell.api.root.create_entity( + ifc_file, + ifc_class="IfcOpeningElement", + predefined_type="OPENING", + name="Opening", + ) + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=new_opening, + matrix=np.array(child_obj.matrix_world), + is_si=True, + ) + mapped_representation = ifcopenshell.api.geometry.map_representation( + ifc_file, representation=parent_opening_rep + ) + ifcopenshell.api.geometry.assign_representation( + ifc_file, product=new_opening, representation=mapped_representation + ) + ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host) + ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child) + + # Openings affect every sub-element of an aggregate, not just the named host. + voided_objs: list[bpy.types.Object] = [] + host_obj = tool.Ifc.get_object(host) + if host_obj is not None: + voided_objs.append(host_obj) + for subelement in tool.Aggregate.get_parts_recursively(host): + subobj = tool.Ifc.get_object(subelement) + if subobj is not None: + voided_objs.append(subobj) + + for voided_obj in voided_objs: + if not voided_obj.data: + continue + voided_element = tool.Ifc.get_entity(voided_obj) + if voided_element is None: + continue + context = tool.Geometry.get_active_representation_context(voided_obj) + representation = tool.Geometry.get_representation_by_context(voided_element, context) + if representation is None: + continue + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation + ) + + @classmethod + def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None: + """Detach a filling's opening representation from any shared mapped body. + + Required when a Bonsai array child is promoted to an independent + object: the array's per-child opening mirror builds each child's + opening representation as an ``IfcMappedRepresentation`` over the + parent opening's body. Without this detach, a later edit replacing + the parent body rewrites the shared ``IfcRepresentationMap`` and + reshapes the former-child's opening too.""" + if not getattr(filling, "FillsVoids", None): + return + tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement) + @classmethod def replace_object_ifc_representation( cls, @@ -1362,8 +1532,7 @@ class Model(bonsai.core.tool.Model): @classmethod def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None: """make sure IFC position will be in sync with the Blender object position, if object was moved in Blender""" - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) @classmethod def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix: @@ -1395,7 +1564,7 @@ class Model(bonsai.core.tool.Model): if not obj.data: continue element = tool.Ifc.get_entity(obj) - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + body = tool.Geometry.get_body_representation(element) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -1512,6 +1681,10 @@ class Model(bonsai.core.tool.Model): "TRIPLE_PANEL_VERTICAL", ] + RoofGenerationMethod = Literal["HEIGHT", "ANGLE"] + + RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"] + @classmethod def generate_stair_2d_profile( cls, @@ -1763,7 +1936,7 @@ class Model(bonsai.core.tool.Model): from bonsai.bim.module.model.opening import FilledOpeningGenerator ifc_file = tool.Ifc.get() - fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)} + fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)} voided_objs = set() has_replaced_opening_representation = False @@ -1905,7 +2078,9 @@ class Model(bonsai.core.tool.Model): bm = bmesh.new() bm.from_mesh(mesh) - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) + # Looser than auto_detect_curves' VTX_PRECISION: profiles must close into + # a single loop, so nearly-coincident endpoints should snap together. + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE) bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess @@ -2133,7 +2308,7 @@ class Model(bonsai.core.tool.Model): bm = bmesh.new() bm.from_mesh(mesh) - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION) bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess @@ -2352,6 +2527,12 @@ class Model(bonsai.core.tool.Model): @classmethod def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float: + """Signed slope of the extrusion's direction in the y-z plane (radians). + + Assumes extrusion directions lie in the y-z plane (LAYER2 wall and + LAYER3 slab convention). For inverted extrusions (z ≤ 0), adds π to + preserve angular continuity for callers consuming the angle via + cos/sin.""" x, y, z = extrusion.ExtrudedDirection.DirectionRatios vector = Vector((0, 1)) x_angle = vector.angle_signed(Vector((y, z))) @@ -2700,6 +2881,20 @@ class Model(bonsai.core.tool.Model): @classmethod def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + # Curved fillet-corner walls own a hand-built banana body that + # ``regenerate_wall_representation`` would flatten — it reads the + # axis as a 2-point reference line and builds a straight extrusion. + # Instead rebuild the curve in place: ``regenerate_fillet_corner_wall`` + # keeps radius + placement from the pset / current ``ObjectPlacement`` + # while picking up new thickness / height from the wall type, which + # is what we want when a type-property edit triggered this call. + if ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"): + # Lazy import: ``tool.Model`` loads before ``bim/module/model`` + # at addon enable; a module-level import would cycle. + from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall + + regenerate_fillet_corner_wall(element, obj) + return rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) bonsai.core.geometry.switch_representation( tool.Ifc, @@ -2720,28 +2915,29 @@ class Model(bonsai.core.tool.Model): queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() for wall in walls: element = tool.Ifc.get_entity(wall) - if tool.Ifc.is_moved(wall): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall) + tool.Geometry.commit_placement_if_moved(wall) queue.add((element, wall)) for rel in getattr(element, "ConnectedTo", []): obj = tool.Ifc.get_object(rel.RelatedElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) queue.add((rel.RelatedElement, obj)) for rel in getattr(element, "ConnectedFrom", []): obj = tool.Ifc.get_object(rel.RelatingElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) queue.add((rel.RelatingElement, obj)) for element, wall in queue: - if tool.Model.get_usage_type(element) == "LAYER2" and wall: - # Use layer custom offset + if not wall: + continue + is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2" + is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) + if not (is_layer2_usage or is_fillet_corner): + continue + if is_layer2_usage: custom_offset = tool.Model.get_material_layer_custom_offset(element, wall) material = ifcopenshell.util.element.get_material(element) if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None: material.OffsetFromReferenceLine = custom_offset - - cls.recreate_wall(element, wall) + cls.recreate_wall(element, wall) @classmethod def regenerate_slab(cls, obj: bpy.types.Object) -> None: diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 2e2fc4383c..aa72a85e6f 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -18,10 +18,12 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any, Literal, Union, assert_never import bpy import ifcopenshell +import ifcopenshell.api.pset import ifcopenshell.util.attribute import ifcopenshell.util.element @@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset): if pset: return tool.Ifc.get().by_id(pset["id"]) + @classmethod + def upsert_pset( + cls, + element: ifcopenshell.entity_instance, + pset_name: str, + properties: dict[str, Any], + ) -> ifcopenshell.entity_instance: + """Get or create ``pset_name`` on ``element``, write ``properties``, return the pset. + Centralises the get-element-pset → add-pset-if-missing → edit-pset idiom.""" + ifc_file = tool.Ifc.get() + pset = cls.get_element_pset(element, pset_name) + if not pset: + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name) + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties) + return pset + + @classmethod + def write_bbim_data( + cls, + element: ifcopenshell.entity_instance, + pset_name: str, + data: dict[str, Any], + ) -> ifcopenshell.entity_instance: + """Get or create the BBIM_ pset and write ``data`` as the IfcText-serialised + JSON ``Data`` property. Canonical writer for parametric-modifier pset state.""" + data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list)) + return cls.upsert_pset(element, pset_name, {"Data": data_text}) + @classmethod def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties: if obj_type == "Object": diff --git a/src/bonsai/bonsai/tool/slab.py b/src/bonsai/bonsai/tool/slab.py new file mode 100644 index 0000000000..5ad85b2222 --- /dev/null +++ b/src/bonsai/bonsai/tool/slab.py @@ -0,0 +1,74 @@ +# 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. + +"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions. + +Exposes ``read_geometry``: a single live read of the parametric attributes +(extrusion depth and slope) that drive icon placement and dimension display +on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay +declarative — they get a dict, not an IFC walk.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +import ifcopenshell.util.unit + +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + import bpy + + +class SlabGeometry(TypedDict): + depth: float + x_angle: float + + +class Slab(bonsai.core.tool.Slab): + @classmethod + def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None: + """Live-read slab parametric geometry as a dict, or ``None`` if the + object is not a LAYER3 extruded slab. + + Returned keys (all SI units): ``depth`` (extrusion thickness along the + slab's local Z), ``x_angle`` (slope in radians; zero for level slabs). + + The slope is encoded in ``obj.matrix_world`` as a post-rotation, so + callers projecting world points into slab-local space via + ``mw.inverted()`` will see a level frame whose Z runs along the slab + thickness — ``x_angle`` is reported for callers that need the slope + as a scalar but is already applied by the placement.""" + element = tool.Ifc.get_entity(obj) + if not element or not tool.Blender.Modifier.is_slab(element): + return None + representation = tool.Geometry.get_body_representation(element) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return { + "depth": extrusion.Depth * unit_scale, + "x_angle": x_angle, + } From f37c77e80c150b99cc386de3283c139151984cf0 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 09:21:39 +0200 Subject: [PATCH 10/15] =?UTF-8?q?Refactor=20tool.Parametric=20=E2=80=94=20?= =?UTF-8?q?feature=20registry=20+=20lifecycle=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool.Parametric becomes the central registry for Bonsai's parametric features (wall, slab, door, window, railing, roof, stair, plus mep-segment variants). Each feature registers a ParametricObject spec declaring its enable/finish/cancel op names, props accessor, regen callback, and is_element_type predicate. Public surface: * tool.Parametric.WALL / SLAB / DOOR / WINDOW / RAILING / ROOF / STAIR / PIPE_SEGMENT / DUCT_SEGMENT — typed accessors per feature. * tool.Parametric.is_wall / is_door / is_window / is_railing / is_roof / is_stair — element-type predicates that move off tool.Blender.Modifier into the parametric registry. The next commit adds backward-compat shims on tool.Blender.Modifier so v0.8.0 callers keep working. * tool.Parametric.is_object_editing(obj) — returns the registered feature an object is currently editing, or None. * tool.Parametric.run_bim_op(op_name) — invoke a parametric op by bl_idname. * tool.Parametric.heal_stale_edit_flags — clear is_editing flags on file load so a saved-mid-edit project doesn't leave gizmos poll-locked. * supports_build_edit_lifecycle field on ParametricObject — declares whether the feature implements the build/edit/cancel triad. The previous bare `print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}")` exception-handler is replaced with logger.warning(..., exc_info=True). Same channel (Bonsai configures logging to the Blender console at WARNING level), strictly more information (full traceback), correct idiom for an error-path message. A second logger.warning is added for parametric predicate failures, also exception-handler scope. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/parametric.py | 687 ++++++++++++++++----------- 1 file changed, 416 insertions(+), 271 deletions(-) diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index ce6659976c..594352c449 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -18,239 +18,89 @@ # # This file was generated with the assistance of an AI coding tool. -"""Registry + save-time auto-commit for parametric draft edits. +"""Registry and save-time auto-commit for parametric draft edits. -Single source of truth: adding a new parametric element type is one entry in -`Parametric.EDIT_TYPES`. Every consumer — save-time auto-commit, the -finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty`` -attachment in ``bim/module/model/__init__.py``, and the per-type -``GizmoPreferences`` registration in ``bim/__init__.py`` — derives the -class names, operator ``bl_idname``s, and predicates from the registry entry's -short ``name`` token. +The registry is consumed along two orthogonal axes: -Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/`` -modules can consume it without crossing the layer boundary. The orchestration -helpers (``commit_object_draft``, ``commit_pending_edits``) call -``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender -rather than a Python import of ``bim/``. +- **Predicate axis**: every entry carries an ``is_`` total predicate. Used + by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls. +- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True`` + share the ``Enable/Finish/CancelEditing`` operator shape and are wired + through ``build_edit_lifecycle``. The remainder declare their edit operators + directly because their lifecycle (per-attribute diff dispatch, layer-stack + editing, mid-spline gizmo drag, …) does not fit the shared mixin contract. ----------------------------------------------------------------------- -How to add a new parametric object ----------------------------------------------------------------------- - -End-to-end walkthrough for wiring a new IFC element type (e.g. ``IfcSlab``) -into the gizmo-driven parametric edit framework. Numbered steps are -**required** unless flagged OPTIONAL. Keep this section in sync with the -implementation files it references — if a step's example code stops matching -the real registration site, the step is out of date. - -STEP 1 — Add the registry entry (this file) - Append to `Parametric.EDIT_TYPES`:: - - ParametricObject("slab", has_non_editable_path=False), - - The ``name`` token drives every derived identifier: - ``BIMSlabProperties``, ``bim.enable_editing_slab`` / - ``bim.finish_editing_slab`` / ``bim.cancel_editing_slab``, and the - ``slab`` field on ``GizmoPreferences``. Set ``has_non_editable_path=True`` - if the modifier exposes no user-editable path (cf. door, window, stair). - -STEP 2 — Define the ``PropertyGroup`` (``bim/module/model/prop.py``) - Class name **must** be ``BIMProperties`` — capitalisation matches - `ParametricObject.props_attr`:: - - class BIMSlabProperties(bpy.types.PropertyGroup): - is_editing: BoolProperty(...) - # ... per-type draft fields, snapshots, mesh_dirty, etc. ... - - The ``is_editing`` flag is the single field every consumer of the registry - expects. - -STEP 3 — Register the PropertyGroup class - Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near - the existing ``prop.BIMProperties`` entries). The - ``bpy.types.Object.BIMSlabProperties`` attachment is automatic — - `Parametric.register_object_properties` loops the registry. - -STEP 4 — Implement the Enable / Finish / Cancel triad - In ``bim/module/model/slab.py``, define three ``bpy.types.Operator`` - subclasses with the canonical ``bl_idname``\\s: - - - ``EnableEditingSlab`` → ``bl_idname = "bim.enable_editing_slab"`` - - ``FinishEditingSlab`` → ``bl_idname = "bim.finish_editing_slab"`` - - ``CancelEditingSlab`` → ``bl_idname = "bim.cancel_editing_slab"`` - - **First, check if your new type fits one of the existing lifecycle - shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit - the matching mixin and the triad collapses to ~25 lines total: - - - ``FeatureModifierEditMixin`` — BBIM_ pset with nested - ``lining_properties`` / ``panel_properties``; Finish via - ``update__modifier_representation`` → - ``ifcopenshell.api.feature``; Cancel via - ``switch_representation`` to the Body rep. Reference samples: - door (multi-object) and window (single-object). - - - ``PathPreservingEditMixin`` — BBIM_ pset whose ``path_data`` - is preserved through edit; Finish via per-type - ``update_bbim__pset`` + ``update__modifier_ifc_data``; - Cancel rebuilds the bmesh preview. Reference samples: railing, roof. - - If neither shape fits (the type needs validation-first lifecycle, an - explicit snapshot, delegate-to-sub-operators Finish, or a unique - post-Finish step) implement the triad standalone — see ``wall.py`` - (validation/snapshot/delegate) or ``stair.py`` (raw pset JSON + - ``update_ifc_stair_props``) as references. Register all three in the - module's ``classes`` tuple. - -STEP 5 — Implement the gizmo group (same file) - Subclass ``BaseParametricGizmoGroup`` from - ``bim/module/drawing/gizmos.py``:: - - class GizmoSlabEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): - bl_idname = "OBJECT_GGT_bim_slab_edition" - - @classmethod - def is_element_type(cls, element): - return tool.Blender.Modifier.is_slab(element) - - dimension_gizmo_props = [DimensionGizmoConfig(...)] - - Register it in the ``classes`` tuple. The classmethod makes - ``tool.Blender.Modifier.is_slab(element)`` testable via the gizmo's - ``poll()``. - -STEP 6 — Add the element-type predicate (``tool/blender.py``) - Inside the ``Blender.Modifier`` class, alongside ``is_door`` / ``is_wall``:: - - @classmethod - def is_slab(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Slab") - - The method name **must** be ``is_`` to match - `ParametricObject.name` — `Parametric.find_for_element` - looks it up by string. - -STEP 7 — OPTIONAL: typed property accessor (``tool/model.py``) - Convenience helper for call sites that statically know the IFC type:: - - @classmethod - def get_slab_props(cls, obj) -> BIMSlabProperties: - return obj.BIMSlabProperties - - Call sites that work generically (registry-driven) can use - ``getattr(obj, feature.props_attr)`` directly and skip this step. - -STEP 8 — OPTIONAL: gizmo visibility preferences (``bim/ui.py``) - For per-gizmo show/hide toggles, define:: - - class GizmoPreferencesSlab(bpy.types.PropertyGroup): - length: BoolProperty(name="Length", default=True, ...) - # ... one BoolProperty per gizmo ... - - Then add a matching field on ``GizmoPreferences``:: - - slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab) - - Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in - ``bim/__init__.py`` — the registry-driven discovery in this module finds - it by name (``GizmoPreferences`` + capitalised registry token) and - registers it automatically. - -STEP 9 — OPTIONAL: pure geometry helpers (``core/model.py``) - Per-type math (collinearity checks, slope/displacement conversions, - intersection helpers) lives here. The hard rule: no ``bpy`` / - ``ifcopenshell`` imports at module load — wrap them in - ``if TYPE_CHECKING:`` blocks only. Lets the helpers be unit-tested - headless via ``pytest test/core/``. - -STEP 10 — Verify - From ``src/bonsai/``:: - - ruff check . - black --check . - pytest test/core/ -x -q - blender -b -P runpytest.py -- test/bim/ -x -q -m model - - The Blender-backed lane runs a registry smoke test that iterates the - EDIT_TYPES list and asserts each entry's enable/finish/cancel operator - resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object`` - carries the matching ``BIMProperties`` attribute, and that the - ``is_`` predicate exists on ``tool.Blender.Modifier``. Forget any - of the steps above and that test fails with a precise pointer at - what's missing. - - Then manually in Blender: - - 1. Enable Bonsai → create an instance of the new IFC type. - 2. Run ``bim.enable_editing_`` → confirm the gizmo group polls in - and the dimension handles appear. - 3. Modify a draft field, save the file → confirm auto-commit fires - (watch the console for the ``parametric_commit`` log line). - 4. Disable + re-enable the addon → no ``bpy_struct: unknown property - type`` errors in the console (validates the register/unregister - symmetry driven by the registry).""" +Adding a new parametric element type is a single entry in ``EDIT_TYPES``; +flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches +one of the shared mixins in ``bim/parametric_lifecycle.py``.""" from __future__ import annotations +import logging import re -import traceback +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional import bpy import bonsai.core.tool import bonsai.tool as tool +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from ifcopenshell import entity_instance -# ``name`` must be a single ASCII lowercase token starting with a letter: -# ``str.capitalize()`` only handles single-word names cleanly, so a compound -# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` — -# off the Bonsai naming convention and silently broken. -_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$") +# Lowercase ASCII snake_case token; each segment a non-empty letter/digit +# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``. +_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") + + +def _camel_case(name: str) -> str: + return "".join(part.capitalize() for part in name.split("_")) @dataclass(frozen=True) class ParametricObject: - """One parametric element type's draft + enable + finish + cancel triad. + """One parametric element type's draft + enable + finish + cancel edit lifecycle. - The short ``name`` token ("door", "window", "stair", "railing", "roof", - "wall", …) drives every derived identifier: the ``BIMProperties`` - attribute on ``bpy.types.Object`` and the ``bim.enable_editing_`` / - ``bim.finish_editing_`` / ``bim.cancel_editing_`` operator - ``bl_idname``s. The ``name`` is validated at construction time — a - multi-word IFC type would silently mis-derive through - ``str.capitalize()`` and breaks the single-token assumption. + The ``name`` token drives every derived identifier: the + ``BIMProperties`` attribute on ``bpy.types.Object``, the + ``bim.enable_editing_`` / ``bim.finish_editing_`` / + ``bim.cancel_editing_`` operator ``bl_idname``s, and the + ``tool.Parametric.is_`` runtime predicate. - ``has_non_editable_path`` flags element types whose modifier exposes no - user-editable path (door, window, stair). + The predicate is part of the contract and MUST be total — accept any IFC + entity, return a bool, never raise. A raising predicate breaks the save + path for every parametric type, not just its own. - The paired runtime predicate ``tool.Blender.Modifier.is_(element)`` - is part of the registry contract: it MUST be **total** — accept any - IFC entity and return a boolean, never raise. The registry iterates - every predicate against the active element on save; a raising predicate - propagates upward and breaks the save path for *all* parametric types, - not just its own.""" + ``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the + shared mixin contract (``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets``) and that therefore wire their operators through + ``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute + diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this + False and declare their operator classes directly.""" name: str has_non_editable_path: bool = False + supports_build_edit_lifecycle: bool = False def __post_init__(self) -> None: if not _VALID_NAME_RE.match(self.name): raise ValueError( - f"ParametricObject name {self.name!r} must be a single ASCII lowercase " - f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only " - f"handles single-word names — compound IFC types need an explicit " - f"naming override (not yet supported)." + f"ParametricObject name {self.name!r} must match " + f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, " + f"optionally split by single underscores (e.g. ``door`` or " + f"``pipe_segment``). Leading / trailing underscores and " + f"consecutive underscores are rejected because they produce " + f"empty CamelCase segments in derived class names." ) @property def props_attr(self) -> str: - return f"BIM{self.name.capitalize()}Properties" + return f"BIM{_camel_case(self.name)}Properties" @property def enable_op(self) -> str: @@ -270,15 +120,58 @@ class ParametricObject: class Parametric(bonsai.core.tool.Parametric): + class GenerationKeyedCache: + """A dict-keyed cache stamped with the parametric generation counter + at fill time. Reads at a later generation drop the whole dict and + re-run the loader. Any IFC commit bumps the generation, invalidating + all entries en bloc. + + ``None`` values are stored verbatim; only "key not in dict" counts as + a miss.""" + + def __init__(self) -> None: + self._gen: int | None = None + self._data: dict = {} + + def get_or_compute(self, key, loader): + current = Parametric.get_geom_generation() + if self._gen != current: + self._data.clear() + self._gen = current + if key not in self._data: + self._data[key] = loader() + return self._data[key] + + def clear(self) -> None: + """Explicit drop. Use from ``load_post`` so a fresh file starts clean.""" + self._data.clear() + self._gen = None + EDIT_TYPES: list[ParametricObject] = [ - ParametricObject("door", has_non_editable_path=True), - ParametricObject("window", has_non_editable_path=True), - ParametricObject("stair", has_non_editable_path=True), - ParametricObject("railing"), - ParametricObject("roof"), + ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("railing", supports_build_edit_lifecycle=True), + ParametricObject("roof", supports_build_edit_lifecycle=True), ParametricObject("wall"), + ParametricObject("array", supports_build_edit_lifecycle=True), + ParametricObject("pipe_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("duct_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True), ] + # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by + # the binding loop at module bottom. Declared here so IDEs and type + # checkers see the attributes without running the loop. + DOOR: ClassVar[ParametricObject] + WINDOW: ClassVar[ParametricObject] + STAIR: ClassVar[ParametricObject] + RAILING: ClassVar[ParametricObject] + ROOF: ClassVar[ParametricObject] + WALL: ClassVar[ParametricObject] + ARRAY: ClassVar[ParametricObject] + PIPE_SEGMENT: ClassVar[ParametricObject] + DUCT_SEGMENT: ClassVar[ParametricObject] + _geom_generation: int = 0 @classmethod @@ -288,20 +181,9 @@ class Parametric(bonsai.core.tool.Parametric): @classmethod def refresh_post_commit(cls) -> None: """Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level - ``BIMModelProperties`` (workspace tool header H/L/A fields) from current - IFC state and bumps the geometry generation counter so per-gizmo-group - caches keyed off it drop their stale entries on the next draw. - - Why this exists: ``update_bim_tool_props`` was historically only wired - to the active-object msgbus, so in-place IFC mutations on the current - selection (S_E, C_E, change_extrusion_*, …) left the header showing - stale values until the user changed selection. Same shape of bug for - the wall gizmo cache: ``GizmoGroup.refresh()`` only fires on Blender's - own state-change events, not on every ``bpy.ops.bim.*`` mutation. - - Cheap when nothing parametric is active — ``update_bim_tool_props`` - early-returns when no Bonsai workspace tool is selected or the active - object isn't an IFC element.""" + workspace-tool header fields from current IFC state and bumps the + geometry generation counter so caches keyed off it drop stale + entries on the next draw.""" import bonsai.bim.handler # late import: bim.handler imports tool.* cls._geom_generation += 1 @@ -317,51 +199,104 @@ class Parametric(bonsai.core.tool.Parametric): return next((f for f in cls.EDIT_TYPES if f.name == name), None) @classmethod - def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]: - """Return the registry entry whose IFC type predicate matches ``element``. + def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool: + """Resolve and invoke ``is_`` defensively. The contract is + that predicates are total (see ``ParametricObject`` docstring); a + regression that turns one predicate raising would otherwise break the + save path for every parametric type, not just its own.""" + predicate = getattr(cls, f"is_{feature.name}", None) + if predicate is None: + return False + try: + return bool(predicate(element)) + except Exception: + logger.warning( + "parametric predicate is_%s raised on %r", + feature.name, + element, + exc_info=True, + ) + return False - The per-type predicate lives at ``tool.Blender.Modifier.is_``; - resolved here by attribute lookup at call time, which avoids a - ``tool.parametric`` ↔ ``tool.blender`` import cycle.""" + @classmethod + def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]: + """Return the registry entry whose IFC type predicate matches ``element``.""" for feature in cls.EDIT_TYPES: - predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None) - if predicate is not None and predicate(element): + if cls._safe_predicate(feature, element): return feature return None @classmethod - def is_object_editing(cls, obj: bpy.types.Object) -> Optional[ParametricObject]: + def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]: + """Return the registry entry whose edit lifecycle is active on ``obj``, or None. + + ``skip_name`` excludes one entry from the scan, for callers that want + to know if a *different* type is editing.""" for feature in cls.EDIT_TYPES: + if feature.name == skip_name: + continue if feature.is_editing(obj): return feature return None + @classmethod + def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]: + """Return the active registry entry on ``obj``, validated against the + per-type predicate. Returns None when no ``is_editing`` flag is set + or when the flag is stale. + + Self-heals: a predicate mismatch clears the flag in place so the + finish dispatch never re-picks up a phantom edit.""" + feature = cls.is_object_editing(obj) + if feature is None: + return None + element = tool.Ifc.get_entity(obj) + if element is None or not cls._safe_predicate(feature, element): + getattr(obj, feature.props_attr).is_editing = False + return None + return feature + + @classmethod + def heal_stale_edit_flags(cls) -> None: + """Validate every scene object's ``is_editing`` flag against the + per-type predicate, clearing stale flags in place. + + Run from ``load_post`` so a ``.blend`` saved with phantom flags + (e.g. a save that bypassed the auto-commit flush) is consistent the + moment it opens.""" + for obj in bpy.data.objects: + cls._validated_editing_feature(obj) + @classmethod def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]: - """``(object, finish_operator_bl_idname)`` pairs for every object with - an in-progress parametric draft. The first registry match per object wins.""" - return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))] + """``(object, finish_operator_bl_idname)`` pairs for every object + with an in-progress parametric draft. Stale flags are cleared in + place and excluded.""" + pending: list[tuple[bpy.types.Object, str]] = [] + for obj in bpy.data.objects: + feature = cls._validated_editing_feature(obj) + if feature is not None: + pending.append((obj, feature.finish_op)) + return pending @classmethod def run_bim_op(cls, bl_idname: str) -> None: - """Invoke a ``bim.*`` operator by its ``bl_idname``. + """Invoke a ``bim.*`` operator by ``bl_idname``. - Constraint enforced via ``assert``: the operator MUST be a - ``tool.Ifc.Operator`` subclass — its transaction wrap is what - makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation - of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's - transaction system.""" + Asserts the operator is a ``tool.Ifc.Operator`` subclass — bypassing + that wrap would mutate IFC outside Bonsai's transaction system.""" verb = bl_idname.removeprefix("bim.") op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None) - assert op_cls is not None and issubclass( - op_cls, tool.Ifc.Operator - ), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation" + if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator): + raise RuntimeError( + f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation" + ) getattr(bpy.ops.bim, verb)() @classmethod def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool: - """Run ``finish_op`` scoped to ``obj`` alone. Returns True on success, False if - the operator raised (with traceback printed to the console). + """Run ``finish_op`` scoped to ``obj`` alone. Returns False (with + traceback printed) if the operator raised. Both ``temp_override`` and ``view_layer.objects.active`` are set: ``temp_override`` does not rebind ``objects.active``, and some finish @@ -374,9 +309,13 @@ class Parametric(bonsai.core.tool.Parametric): try: cls.run_bim_op(finish_op) return True - except Exception as e: - print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}") - traceback.print_exc() + except Exception: + logger.warning( + "commit of %r via %s failed", + obj.name, + finish_op, + exc_info=True, + ) return False finally: view_layer.objects.active = original_active @@ -385,14 +324,9 @@ class Parametric(bonsai.core.tool.Parametric): def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]: """Run each pending draft's finish operator scoped to its object. - A per-object failure does not abort the loop — remaining drafts still - flush, otherwise the auto-commit would ship the exact silent-desync - it exists to prevent. - - Each finish op wraps its own IFC transaction, so N pending drafts - produce N+1 undo entries (one per commit, plus the save). Ctrl+Z - walks back through commits individually — intentional, each commit - is reversible on its own.""" + A per-object failure does not abort the loop — remaining drafts + still flush, otherwise the auto-commit would ship the exact silent + desync it exists to prevent.""" committed = 0 failed: list[bpy.types.Object] = [] for obj, finish_op in cls.get_pending_edits(): @@ -406,18 +340,12 @@ class Parametric(bonsai.core.tool.Parametric): def commit_pending_edits_for_selection( cls, names: Optional[tuple[str, ...]] = None ) -> tuple[int, list[bpy.types.Object]]: - """Selection-scoped variant of `commit_pending_edits`. ``names`` - filters which registry entries to consider — e.g. ``("wall",)`` to commit - only wall drafts among selected objects; ``None`` considers every type. - - Used by multi-object operators (``bim.unjoin_walls``, ``bim.merge_wall``, - ``bim.extend_walls_to_wall`` etc.) that must run against committed IFC - state — running them with a wall whose draft hasn't been flushed leaves - stale gizmos pointing at obsolete IFC numbers.""" + """Selection-scoped variant. ``names`` filters which registry entries + to consider; ``None`` considers every type.""" committed = 0 failed: list[bpy.types.Object] = [] for obj in tool.Blender.get_selected_objects(): - feature = cls.is_object_editing(obj) + feature = cls._validated_editing_feature(obj) if feature is None: continue if names is not None and feature.name not in names: @@ -428,11 +356,25 @@ class Parametric(bonsai.core.tool.Parametric): failed.append(obj) return committed, failed + @classmethod + def _assert_predicates_registered(cls) -> None: + """Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching + ``is_`` classmethod. Without this, a typo in the registry entry + produces a silent-False predicate that never matches — every + parametric draft of that type bypasses save-flow auto-commit.""" + missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))] + if missing: + raise RuntimeError( + f"tool.Parametric.EDIT_TYPES has entries with no is_ predicate: {missing}. " + f"Add `is_(cls, element) -> bool` classmethods on tool.Parametric, " + f"or remove the entries from EDIT_TYPES." + ) + @classmethod def register_object_properties(cls, prop_module) -> None: """Attach ``bpy.types.Object.BIMProperties`` for every registered - parametric type, looking up the matching ``PropertyGroup`` class on - ``prop_module``. Skips entries whose ``PropertyGroup`` class is absent.""" + parametric type. Skips entries whose ``PropertyGroup`` is absent.""" + cls._assert_predicates_registered() for feature in cls.EDIT_TYPES: prop_cls = getattr(prop_module, feature.props_attr, None) if prop_cls is None: @@ -447,14 +389,217 @@ class Parametric(bonsai.core.tool.Parametric): @classmethod def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: - """``GizmoPreferences`` classes that exist on ``ui_module`` for - every registry entry. Order matches `EDIT_TYPES`. Used by - ``bim/__init__.py`` to inject the per-type ``GizmoPreferences`` - classes at the correct point — before ``ui.GizmoPreferences``, which - references them via ``PointerProperty``.""" - out: list[type] = [] - for feature in cls.EDIT_TYPES: - gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None) - if gpref is not None: - out.append(gpref) - return out + """Shared ``GizmoPreferencesFeature`` class as a one-element list, or + empty if absent. Must register before ``GizmoPreferences``.""" + shared = getattr(ui_module, "GizmoPreferencesFeature", None) + return [shared] if shared is not None else [] + + # --- Feature-kind predicates ------------------------------------------------ + # One predicate per registered parametric type. Each is total: accepts any + # IFC entity (or None), returns a bool, never raises. Predicates live with + # the registry rather than ``tool.Blender.Modifier`` because they ARE the + # registry contract — ``find_for_element`` and ``_validated_editing_feature`` + # resolve them by name. Coupling them on the same class makes a typo at + # registration time an immediate AttributeError instead of a silent None + # predicate that never matches. + + @classmethod + def is_array(cls, element: entity_instance) -> bool: + """True if element is the PARENT of a Bonsai parametric array. + + Array children also carry a ``BBIM_Array`` pset (their ``Parent`` + field points back to the original), so checking pset presence alone + would falsely match them. The parent is distinguished by + ``pset.Parent == element.GlobalId``.""" + import ifcopenshell.util.element + + if element is None: + return False + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return False + return pset.get("Parent") == element.GlobalId + + @classmethod + def is_railing(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None + + @classmethod + def is_roof(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None + + @classmethod + def is_window(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Window") is not None + + @classmethod + def is_door(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Door") is not None + + @classmethod + def is_stair(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None + + @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. + + Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset — + their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage, + IfcExtrudedAreaSolid). Any LAYER2 wall qualifies.""" + if element is None or not element.is_a("IfcWall"): + return False + return tool.Model.get_usage_type(element) == "LAYER2" + + @classmethod + def is_path_connectable_wall(cls, element: entity_instance) -> bool: + """An IfcWall that may participate in IfcRelConnectsPathElements joins — + either a LAYER2 parametric wall, or a fillet-corner wall whose body is + hand-built but whose axis still drives path connections. + + Distinct from ``is_wall``: that predicate gates parametric edits that + would regenerate the body and flatten a curved fillet. Unjoin / join + gizmo polls and path-connection partner enumeration use this looser + predicate so fillet corners (which have no LAYER2 usage by spec) still + surface their join icons.""" + if element is None or not element.is_a("IfcWall"): + return False + if tool.Model.get_usage_type(element) == "LAYER2": + return True + import ifcopenshell.util.element + + return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) + + @classmethod + def is_pipe_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcPipeSegment") + + @classmethod + def is_duct_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcDuctSegment") + + @classmethod + def build_edit_lifecycle( + cls, + feature_name: str, + mixin: type, + labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]], + bl_options: Optional[set[str]] = None, + enable_extra_props: Optional[dict[str, Any]] = None, + enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None, + module_name: Optional[str] = None, + ) -> tuple[type, type, type]: + """Generate (Enable, Finish, Cancel) operator classes for a parametric type. + + ``mixin`` provides ``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or + a sibling). ``labels`` is ``((enable_label, enable_desc), …)`` in + Enable / Finish / Cancel order. + + ``bl_idname`` and the Python class name come from the registry entry — + ``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo + produces an unregistered operator. Anchoring bl_idnames to the registry + eliminates the silent-mismatch failure mode where a hand-typed + ``bl_idname = "bim.enable_editing_dor"`` produces a class that + ``find_for_element`` never resolves to. + + ``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to + attach to the Enable class only (e.g. array's ``item: IntProperty`` + carrying the target layer index across redo). When set, + ``enable_extra_kwargs`` must also be supplied: it receives the Enable + operator instance and returns a kwargs dict forwarded to + ``_enable_targets`` so the mixin's enable phase sees the extras. + + ``module_name`` sets ``__module__`` on the generated classes — pass + ``__name__`` from the calling feature module so Blender's right-click + → Edit Source resolves to the feature module rather than the factory + site. Defaults to the factory's module, which is sub-optimal for + debugging but harmless.""" + import bonsai.tool as _tool # late import: tool/__init__.py wires this module last + + feature = cls.find_by_name(feature_name) + if feature is None: + raise RuntimeError( + f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a " + f"ParametricObject entry before declaring its operators" + ) + if not feature.supports_build_edit_lifecycle: + raise RuntimeError( + f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — " + f"its edit lifecycle is bespoke. Either declare " + f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator " + f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit " + f"the shared mixin contract." + ) + if (enable_extra_props is None) != (enable_extra_kwargs is None): + raise RuntimeError( + f"build_edit_lifecycle({feature_name!r}): enable_extra_props and " + f"enable_extra_kwargs must be supplied together — extras with no " + f"kwargs builder are unreachable, kwargs with no extras have nothing to forward" + ) + options = bl_options if bl_options is not None else {"REGISTER", "UNDO"} + base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator) + capitalised = _camel_case(feature_name) + + def _build( + action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict] + ) -> type: + if extras and target_method == "_enable_targets": + assert enable_extra_kwargs is not None + kwargs_builder = enable_extra_kwargs + + def _execute(self, context: bpy.types.Context) -> set[str]: + return getattr(self, target_method)(context, **kwargs_builder(self)) + + else: + + def _execute(self, context: bpy.types.Context) -> set[str]: + return getattr(self, target_method)(context) + + attrs: dict[str, Any] = { + "bl_idname": bl_idname, + "bl_label": label, + "bl_description": desc, + "bl_options": options, + "_execute": _execute, + } + if module_name is not None: + attrs["__module__"] = module_name + if extras: + # Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors. + attrs["__annotations__"] = dict(extras) + return type(f"{action}Editing{capitalised}", base_classes, attrs) + + return ( + _build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props), + _build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None), + _build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None), + ) + + +_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES] +if len(set(_edit_type_names)) != len(_edit_type_names): + raise RuntimeError( + f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key " + f"for derived bl_idnames, BIMProperties attributes, is_ predicates, " + f"and the uppercase constant — a duplicate silently shadows the first entry." + ) +del _edit_type_names + +# Bind every registered ParametricObject as an uppercase class attribute so +# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a +# registry entry renames the constant; a typo at the call site surfaces as +# AttributeError at module load. +for _entry in Parametric.EDIT_TYPES: + setattr(Parametric, _entry.name.upper(), _entry) +del _entry From 5dc7513de086247294f7275d3b99ee7ca50b954f Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 09:23:29 +0200 Subject: [PATCH 11/15] Add tool.Blender.Modifier backward-compat shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit moved is_ predicates off tool.Blender.Modifier onto tool.Parametric, and earlier C4 moved the Array helper bag off tool.Blender.Modifier.Array onto tool.Array. PR4 will migrate every caller; this commit keeps the OLD entry points alive as thin delegates so PR2 ships without breaking ~30 caller sites that still spell the old API in v0.8.0: * tool.Blender.Modifier.is_door / is_railing / is_roof / is_stair / is_wall / is_window — delegate to tool.Parametric.is_. * tool.Blender.Modifier.Array.bake_children_transform / constrain_ children_to_parent / get_all_children_objects / get_all_objects / get_children_objects / get_modifiers_data / remove_constraints / set_children_lock_state — delegate to tool.Array.. These shims are removed in PR5's cleanup commit once PR4 has rewritten the call sites in bim/import_ifc.py, bim/module/geometry/operator.py, bim/module/geometry/data.py, bim/module/model/array.py + the per-feature operators (door, wall, window, railing, roof, stair, ui). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/blender.py | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 0c73d58599..40c5059563 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1328,6 +1328,74 @@ class Blender(bonsai.core.tool.Blender): return True class Modifier: + # ---------------------------------------------------------------------- + # Backward-compat shims for callers still using the pre-refactor API. + # The is_ predicates now live on tool.Parametric; the Array helper + # bag now lives on tool.Array. PR4 migrates each caller; these shims + # are removed in PR5's cleanup. + # ---------------------------------------------------------------------- + + @classmethod + def is_door(cls, element: entity_instance) -> bool: + return tool.Parametric.is_door(element) + + @classmethod + def is_railing(cls, element: entity_instance) -> bool: + return tool.Parametric.is_railing(element) + + @classmethod + def is_roof(cls, element: entity_instance) -> bool: + return tool.Parametric.is_roof(element) + + @classmethod + def is_stair(cls, element: entity_instance) -> bool: + return tool.Parametric.is_stair(element) + + @classmethod + def is_wall(cls, element: entity_instance) -> bool: + return tool.Parametric.is_wall(element) + + @classmethod + def is_window(cls, element: entity_instance) -> bool: + return tool.Parametric.is_window(element) + + class Array: + @classmethod + def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None: + tool.Array.bake_children_transform(parent_element, item) + + @classmethod + def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None: + tool.Array.constrain_children_to_parent(parent_element) + + @classmethod + def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list: + return tool.Array.get_all_children_objects(parent_element) + + @classmethod + def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list: + return tool.Array.get_all_objects(parent_element) + + @classmethod + def get_children_objects(cls, modifier_data: dict) -> list: + return tool.Array.get_children_objects(modifier_data) + + @classmethod + def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance): + return tool.Array.get_modifiers_data(parent_element) + + @classmethod + def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None: + tool.Array.remove_constraints(parent_element) + + @classmethod + def set_children_lock_state( + cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool + ) -> None: + tool.Array.set_children_lock_state(parent_element, item, lock) + + # ---------------------------------------------------------------------- + @classmethod def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool: """Tries to validate the current BIM modifier parameters for the active object From 6ec83723782212c129cc66ba825502daa9941ffe Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 10:33:35 +0200 Subject: [PATCH 12/15] Extract bim/ifc + tool/cad helpers referenced by PR2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes addon-load ImportError that surfaces when tool/geometry.py and tool/model.py (extracted in C8 / C9) reference symbols that don't exist on v0.8.0: * bim/ifc.py: get_cache_or_detect_lock — IfcStore.get_cache variant that tracks the multi-instance-cache-locked-by-other- process flag, sets it on PermissionError, clears it (along with the dismiss flag) on subsequent success. Used by tool.Geometry.* to gate IFC cache reads without crashing when another Blender instance holds the cache lock. * tool/cad.py: WELD_TOLERANCE constant + paired CAD helpers (auto-detect-curves vertex precision, polyline normal helpers, etc.) used by tool.Model.* + by the parametric model operators that land in PR4. Both modules had zero upstream commits since the gizmos-8088 fork point — safe bulk extraction. PR4 has no caller-line work for either file (the additions are pure additions, no existing API removed); the v0.8.0 callers of get_cache_or_detect_lock and WELD_TOLERANCE are the PR2-scope files that needed them. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/ifc.py | 40 +++++++++++- src/bonsai/bonsai/tool/cad.py | 111 ++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index 453a69058f..bba06b783e 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -64,6 +64,44 @@ class TransactionStep(TypedDict): operations: list[Operation] +# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache — +# signal that another Blender process has the same IFC file open. Project panel +# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed +# flag is sticky per-session so the warning doesn't re-nag once the user has +# acknowledged it. +_cache_locked_by_other_process: bool = False +_multi_instance_warning_dismissed: bool = False + + +def is_cache_locked_by_other_process() -> bool: + return _cache_locked_by_other_process and not _multi_instance_warning_dismissed + + +def dismiss_multi_instance_warning() -> None: + global _multi_instance_warning_dismissed + _multi_instance_warning_dismissed = True + + +def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None: + """Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets + it on ``PermissionError``, clears it (along with the dismiss flag) when a + subsequent call succeeds. Returns ``None`` on lock; other exceptions + propagate. Callers that don't need the warning side effect can use + ``IfcStore.get_cache`` directly.""" + global _cache_locked_by_other_process, _multi_instance_warning_dismissed + try: + cache = IfcStore.get_cache() + except PermissionError: + _cache_locked_by_other_process = True + return None + if _cache_locked_by_other_process: + # Lock released — clear both flags so a future re-locking re-surfaces + # the warning rather than staying suppressed by the previous dismiss. + _cache_locked_by_other_process = False + _multi_instance_warning_dismissed = False + return cache + + class IfcStore: path: str = "" """Should be set only using ``tool.Ifc.set_path``.""" @@ -196,7 +234,7 @@ class IfcStore: shutil.copy2(IfcStore.cache_path, new_cache_path) except PermissionError: pass # Well we tried. No cache for you! - IfcStore.get_cache() + get_cache_or_detect_lock() @staticmethod def load_file(path: str) -> None: diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index c91b5df0d8..4c61bf1b15 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -32,6 +32,7 @@ from __future__ import annotations import math import sys +from collections.abc import Sequence from typing import TYPE_CHECKING, Union import bmesh @@ -45,6 +46,13 @@ if TYPE_CHECKING: VTX_PRECISION = 1.0e-5 +# Tolerances below are in Blender units (SI metres). +# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision. +WELD_TOLERANCE = 1.0e-4 +# How close a vertex must be to the cut plane to count as on it. +BISECT_TOLERANCE = 1.0e-4 +# Strict weld for cleaning up exactly-coincident vertices. +WELD_EPSILON = 1.0e-6 class Cad: @@ -996,3 +1004,106 @@ class Cad: y = height_half + height_half * (prj[1] / w) return Vector((float(x), float(y))) return default + + @classmethod + def sweep_disk_along_polyline( + cls, + bm: bmesh.types.BMesh, + points: Sequence[Vector], + radius: float, + arc_indices: Sequence[int] = (), + profile_segments: int = 8, + ) -> None: + """Append a tube of ``radius`` along the polyline ``points`` to ``bm``. + + Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each + consecutive pair of points becomes a capped cylinder. The cylinders + overlap at joints rather than being mitered — the visual artifact is + negligible at typical handrail radii (~25mm) and acceptable for + live parametric-edit preview. + + ``arc_indices`` is accepted for API symmetry with the IFC builder + (which receives the same data structure), but is currently unused — + arcs are visualised as polyline kinks. Tessellating each arc with a + Lagrange or circular interpolation would smooth the joints; deferred + until profile fidelity becomes a concern. + + :param bm: target bmesh, mutated in place. + :param points: polyline vertices. + :param radius: tube radius (project units). + :param arc_indices: indices of arc midpoints (currently ignored). + :param profile_segments: sides on each cylinder cross-section. + """ + del arc_indices # accepted for forward compatibility; see docstring + if len(points) < 2: + return + for p0, p1 in zip(points, points[1:]): + cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments) + + @classmethod + def add_disk_extrusion( + cls, + bm: bmesh.types.BMesh, + position: Vector, + radius: float, + depth: float, + axis_rotation_z: float, + profile_segments: int = 12, + ) -> None: + """Append a flat cylinder (disk extrusion) to ``bm``. + + A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated + by ``axis_rotation_z`` radians around Z. ``position`` is the disk's + base, not its centre. + + :param bm: target bmesh, mutated in place. + :param position: base of the extrusion in object-local coordinates. + :param radius: disk radius. + :param depth: extrusion depth along the (rotated) Y axis. + :param axis_rotation_z: rotation around Z applied to the +Y axis to + obtain the extrusion direction. + :param profile_segments: sides on the disk's edge. + """ + # The +Y axis rotated by axis_rotation_z around Z gives the extrusion + # direction: (-sin(θ), cos(θ), 0). The disk axis points along it. + axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0)) + end = position + axis * depth + cls._add_capped_cylinder(bm, position, end, radius, profile_segments) + + @classmethod + def _add_capped_cylinder( + cls, + bm: bmesh.types.BMesh, + p0: Vector, + p1: Vector, + radius: float, + segments: int, + ) -> None: + """Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``.""" + direction = p1 - p0 + length = direction.length + if length < 1e-9: + return + direction = direction / length + + z_axis = Vector((0.0, 0.0, 1.0)) + dot = direction.dot(z_axis) + if dot > 1.0 - 1e-6: + rotation = Matrix.Identity(4) + elif dot < -1.0 + 1e-6: + # Anti-parallel: rotate 180° around X so the cone flips bottom-to-top. + rotation = Matrix.Rotation(math.pi, 4, "X") + else: + rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4() + + matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation + bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=length, + matrix=matrix, + ) From 1c8fad3c1364f44fecc301c1bba4bef44a911da3 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 13:26:21 +0200 Subject: [PATCH 13/15] Fix tool.Parametric to ship safely on v0.8.0 bim layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrective fixes folded into one commit. All surface as addon-load / save-time exceptions on v0.8.0's bim layer because PR2's tool.Parametric refactor over-committed to the PR4 contract. 1. iter_gizmo_preference_classes — the previous implementation returned only the shared GizmoPreferencesFeature class. v0.8.0's bim/ui.py declares PointerProperty fields ('door', 'window', ...) on GizmoPreferences that point at per-feature GizmoPreferences classes; those must be registered BEFORE GizmoPreferences itself. The shared-class-only return broke addon registration with: 'door' PointerProperty could not register (see previous error) Restore the v0.8.0 per-feature lookup (iterate EDIT_TYPES, look up each GizmoPreferences on ui_module) and keep the shared-class lookup as forward-compat. Tag FIXME(PR5). 2. EDIT_TYPES — drop the array / pipe_segment / duct_segment entries from the registry. Their bim.finish_editing_ operators land with PR4. Registering them in PR2's EDIT_TYPES without the operators makes auto-commit-on-save dispatch a non-existent finish_op for any object whose BIMProperties.is_editing flag is True, raising: RuntimeError: 'bim.finish_editing_array' must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation PR4 re-adds the three entries together with their operators. Tag FIXME(PR4). 3. tool.Blender.Modifier shim block — upgrade the prose comment to a formal FIXME(PR5) marker so the PR5 cleanup sweep finds it via grep alongside every other tagged shim site. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/blender.py | 8 +++---- src/bonsai/bonsai/tool/parametric.py | 33 ++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 40c5059563..b0c9423906 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1329,10 +1329,10 @@ class Blender(bonsai.core.tool.Blender): class Modifier: # ---------------------------------------------------------------------- - # Backward-compat shims for callers still using the pre-refactor API. - # The is_ predicates now live on tool.Parametric; the Array helper - # bag now lives on tool.Array. PR4 migrates each caller; these shims - # are removed in PR5's cleanup. + # FIXME(PR5): backward-compat shims for callers still using the + # pre-refactor API. The is_ predicates now live on tool.Parametric; + # the Array helper bag now lives on tool.Array. PR4 migrates each caller; + # this whole shim block is removed in PR5's cleanup. # ---------------------------------------------------------------------- @classmethod diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 594352c449..0460379273 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -147,6 +147,11 @@ class Parametric(bonsai.core.tool.Parametric): self._data.clear() self._gen = None + # FIXME(PR4): array / pipe_segment / duct_segment land with their + # finish/cancel operators in PR4. Adding them to EDIT_TYPES without those + # operators makes auto-commit-on-save dispatch bim.finish_editing_ + # for objects flagged as in-edit, which then raises because the operator + # doesn't exist. PR4 re-adds the three entries together with the operators. EDIT_TYPES: list[ParametricObject] = [ ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True), ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True), @@ -154,9 +159,6 @@ class Parametric(bonsai.core.tool.Parametric): ParametricObject("railing", supports_build_edit_lifecycle=True), ParametricObject("roof", supports_build_edit_lifecycle=True), ParametricObject("wall"), - ParametricObject("array", supports_build_edit_lifecycle=True), - ParametricObject("pipe_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True), - ParametricObject("duct_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True), ] # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by @@ -168,9 +170,6 @@ class Parametric(bonsai.core.tool.Parametric): RAILING: ClassVar[ParametricObject] ROOF: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject] - ARRAY: ClassVar[ParametricObject] - PIPE_SEGMENT: ClassVar[ParametricObject] - DUCT_SEGMENT: ClassVar[ParametricObject] _geom_generation: int = 0 @@ -389,10 +388,26 @@ class Parametric(bonsai.core.tool.Parametric): @classmethod def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: - """Shared ``GizmoPreferencesFeature`` class as a one-element list, or - empty if absent. Must register before ``GizmoPreferences``.""" + """``GizmoPreferences`` classes that exist on ``ui_module`` for + every registry entry, plus the shared ``GizmoPreferencesFeature`` if + present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to + inject the per-type ``GizmoPreferences`` classes at the correct + point — before ``ui.GizmoPreferences``, which references them via + ``PointerProperty``.""" + # FIXME(PR5): drop the per-feature loop once PR4 consolidates + # bim/ui.py to use a single shared GizmoPreferencesFeature class + # and rewrites GizmoPreferences accordingly. The shared-class + # branch is the forward-compat path; the per-feature loop keeps + # v0.8.0's bim/ui.py working until then. + out: list[type] = [] + for feature in cls.EDIT_TYPES: + gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None) + if gpref is not None: + out.append(gpref) shared = getattr(ui_module, "GizmoPreferencesFeature", None) - return [shared] if shared is not None else [] + if shared is not None: + out.append(shared) + return out # --- Feature-kind predicates ------------------------------------------------ # One predicate per registered parametric type. Each is total: accepts any From 89b7eff03e3e59ab1590d062377f70d4c4fc5214 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 13:26:38 +0200 Subject: [PATCH 14/15] Add addon-load smoke test pinning register/unregister cycle Surfaces any regression in: * the modules dict in bim/__init__.py (added a folder, forgot the entry) * PointerProperty wiring on bpy.types.{Scene,Object,...} * registry-driven GizmoPreferences auto-registration in tool.Parametric.iter_gizmo_preference_classes * bpy.app.handlers append/remove balance * every register()/unregister() across the 45+ feature modules as a single PASSED/FAILED test instead of the silent "addon failed to enable" users encounter in a fresh Blender. Paired with the existing test_parametric_registry.py contract tests, this catches both the registry-shape regressions (operators/PropertyGroups/predicates) and the registration-mechanics regressions (PointerProperty types not registered before their owners). Generated with the assistance of an AI coding tool. --- src/bonsai/test/bim/test_addon_lifecycle.py | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/bonsai/test/bim/test_addon_lifecycle.py diff --git a/src/bonsai/test/bim/test_addon_lifecycle.py b/src/bonsai/test/bim/test_addon_lifecycle.py new file mode 100644 index 0000000000..16ea0b9f0c --- /dev/null +++ b/src/bonsai/test/bim/test_addon_lifecycle.py @@ -0,0 +1,60 @@ +# 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. + +"""Addon-load smoke for ``bonsai``. + +Pins the registration/unregistration cycle as a runnable contract. The cycle +exercises every ``register()`` site across ``bim/__init__.py``'s modules dict, +every ``PointerProperty`` attachment, every gizmo-prefs auto-registration, and +every ``bpy.app.handlers`` install. A regression in any of those surfaces here +as an exception with a traceback that points at the failing site, instead of +the silent ``addon failed to enable`` users see in a fresh Blender.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_addon_unregister_then_register_does_not_raise(): + """Running the suite has already enabled the addon. Cycle through one + unregister + register to exercise both halves, then leave the addon + enabled so downstream tests in the same Blender session keep working.""" + import bonsai + + bonsai.unregister() + try: + bonsai.register() + except Exception: + # Re-raise after attempting to leave the session in a usable state for + # any tests that run after this one. + try: + bonsai.register() + except Exception: + pass + raise From 786d3c8a894e57b340d725a701977456d285e05a Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 14:38:37 +0200 Subject: [PATCH 15/15] Fix latent runtime bugs + ty annotations surfaced by CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five code paths in slim PR2 referenced symbols that don't exist in v0.8.0's bim layer, raising at first call. Plus three type annotations that ty flagged as unresolved. 1. tool/system.py:get_decoration_data — drop the cache layer that keyed on a token from a bim/decorator_cache.py module. The cache is dead-or-broken in slim: the depsgraph bump handler that would invalidate the token lives in PR3's bim/handler.py decompose, so the token stays at 0 forever. Either the cache never hits (decorated_elements rebuilt → new id() per call) or returns stale data (list reused). Revert to direct `_build_decoration_data()` calls. PR3 reintroduces the cache atomically: decorator_cache module + handler install + cache wrap + tests. Keeps `_build_decoration_data` extraction (cleaner than v0.8.0's monolithic version regardless of cache). 2. tool/spatial.py — add `get_host_element` + `get_host_wall`. The interface stubs in `core/tool.py:1037-1038` were declared but never implemented. `tool/duplicate.py:99` (object duplication with fills) and `tool/model.py:1260` (array per-child opening mirror) call these and would raise AttributeError. 3. tool/model.py:recreate_wall — drop the fillet-corner branch that function-locally imports `regenerate_fillet_corner_wall` from `bim/module/model/wall`. The function lands with PR4; fall through to the straight-extrusion path preserves v0.8.0 behaviour for fillet walls until then. Tag FIXME(PR4). 4. tool/model.py — drop `get_pipe_segment_props` / `get_duct_segment_props` accessors. Their return types reference `BIMPipeSegmentProperties` / `BIMDuctSegmentProperties` which land with PR4's prop.py; calling either accessor on v0.8.0 would AttributeError on `obj.BIMSegmentProperties`. Zero callers in slim — PR4 reintroduces both accessors together with the PropertyGroups they wrap. Also drops the matching TYPE_CHECKING imports. 5. tool/blender.py:557 — `Mapping[type[ViewportDecorator], bool]` needs the qualified `Blender.ViewportDecorator` because the annotation is on a method INSIDE the same nested class; the bare name doesn't resolve at type-check time. 6. core/tool.py Surveyor — drop the `obj: "bpy.types.Object"` / `z: float` / `-> float` / `-> None` annotations on `get_z_rotation` / `set_z_rotation`. The `@interface` decorator wraps each method as `classmethod(abstractmethod(...))` at import time, but ty doesn't track the wrap and flags every call site as `missing-argument` plus the `pass` body as `empty-body` against the declared return type, plus the `bpy.types.Object` forward-ref as `unresolved-reference`. Reverting to v0.8.0's untyped style (matching the sibling `get_absolute_matrix(cls, obj)` stub) clears six ty errors at the cost of zero runtime semantics — the abstract stubs only serve as registry markers, concrete `tool.Surveyor.*` carries the real signatures. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/core/tool.py | 4 ++-- src/bonsai/bonsai/tool/blender.py | 2 +- src/bonsai/bonsai/tool/model.py | 28 ++++------------------------ src/bonsai/bonsai/tool/spatial.py | 26 ++++++++++++++++++++++++++ src/bonsai/bonsai/tool/system.py | 16 +--------------- 5 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index a617549bdf..d3260fa278 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1156,8 +1156,8 @@ class Style: @interface class Surveyor: def get_absolute_matrix(cls, obj): pass - def get_z_rotation(cls, obj: "bpy.types.Object") -> float: pass - def set_z_rotation(cls, obj: "bpy.types.Object", z: float) -> None: pass + def get_z_rotation(cls, obj): pass + def set_z_rotation(cls, obj, z): pass @interface diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b0c9423906..0ecb767da1 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -554,7 +554,7 @@ class Blender(bonsai.core.tool.Blender): def sync_all( cls, context: bpy.types.Context, - enabled: Mapping[type[ViewportDecorator], bool], + enabled: Mapping[type[Blender.ViewportDecorator], bool], ) -> None: """Drive each listed decorator to its desired install state in one call. diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 5b7aafab7c..33bc310c22 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -75,10 +75,8 @@ if TYPE_CHECKING: from bonsai.bim.module.model.prop import ( BIMArrayProperties, BIMDoorProperties, - BIMDuctSegmentProperties, BIMExternalParametricGeometryProperties, BIMModelProperties, - BIMPipeSegmentProperties, BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, @@ -118,14 +116,6 @@ 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_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: - return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] - - @classmethod - def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties: - return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue] - @classmethod def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties: return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue] @@ -2881,20 +2871,10 @@ class Model(bonsai.core.tool.Model): @classmethod def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: - # Curved fillet-corner walls own a hand-built banana body that - # ``regenerate_wall_representation`` would flatten — it reads the - # axis as a 2-point reference line and builds a straight extrusion. - # Instead rebuild the curve in place: ``regenerate_fillet_corner_wall`` - # keeps radius + placement from the pset / current ``ObjectPlacement`` - # while picking up new thickness / height from the wall type, which - # is what we want when a type-property edit triggered this call. - if ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"): - # Lazy import: ``tool.Model`` loads before ``bim/module/model`` - # at addon enable; a module-level import would cycle. - from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall - - regenerate_fillet_corner_wall(element, obj) - return + # FIXME(PR4): the fillet-corner branch lands with PR4's + # `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0 + # the function doesn't exist; falling through to the straight-extrusion + # path preserves v0.8.0 behaviour for fillet walls until PR4 ships. rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) bonsai.core.geometry.switch_representation( tool.Ifc, diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 11a41672bc..163a3ea3c2 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -90,6 +90,32 @@ class Spatial(bonsai.core.tool.Spatial): break return element + @classmethod + def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The building element that hosts a filling (door/window) via the + standard ``FillsVoids → RelatingOpeningElement → VoidsElements → + RelatingBuildingElement`` chain, with safety guards at each hop. + Returns ``None`` if any link is missing, or if the given entity is + not a fillable type (no ``FillsVoids`` inverse). + + For the wall-only case (gizmos that only make sense on walls), use + `get_host_wall` which adds an ``IfcWall`` type filter on top of this.""" + if not getattr(filling, "FillsVoids", None): + return None + opening = filling.FillsVoids[0].RelatingOpeningElement + if not opening.VoidsElements: + return None + return opening.VoidsElements[0].RelatingBuildingElement + + @classmethod + def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The ``IfcWall`` that hosts a filling (door/window), or ``None``. + + Walls only — fillings hosted in slabs / roofs / arbitrary elements + produce ``None`` so wall-offset callers stay opted out cleanly.""" + host = cls.get_host_element(filling) + return host if host and host.is_a("IfcWall") else None + @classmethod def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool: if tool.Ifc.get_schema() == "IFC2X3": diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 8d2b421370..bc11f3d642 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -299,29 +299,15 @@ class System(bonsai.core.tool.System): system_props = cls.get_system_props() return tool.Ifc.get_entity_by_id(system_props.active_system_id) - # Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)). - _decoration_data_cache_key: tuple | None = None - _decoration_data_cache: dict[str, Any] | None = None - @classmethod def get_decoration_data(cls) -> dict[str, Any]: - from bonsai.bim.decorator_cache import get_decorator_cache_token from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData if not ObjectSystemData.is_loaded: ObjectSystemData.load() if not SystemDecorationData.is_loaded: SystemDecorationData.load() - - token = get_decorator_cache_token() - key = (token, id(SystemDecorationData.data["decorated_elements"])) - if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None: - return cls._decoration_data_cache - - result = cls._build_decoration_data() - cls._decoration_data_cache_key = key - cls._decoration_data_cache = result - return result + return cls._build_decoration_data() @classmethod def _build_decoration_data(cls) -> dict[str, Any]: