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/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
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index 6bb0f96975..d3260fa278 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): pass
+ def set_z_rotation(cls, obj, z): 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
diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py
index 31e93cace7..03716236e1 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
@@ -37,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
@@ -64,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
@@ -73,4 +76,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/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
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index 9e5515d07a..0ecb767da1 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[Blender.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:
@@ -1134,6 +1328,74 @@ class Blender(bonsai.core.tool.Blender):
return True
class Modifier:
+ # ----------------------------------------------------------------------
+ # 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
+ 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
@@ -1143,13 +1405,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 +1438,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 +1512,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):
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,
+ )
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}")
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)
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 1f103c4c5e..33bc310c22 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_
@@ -130,6 +133,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 +831,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 +852,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 +941,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 +1054,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 +1105,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 +1143,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 +1190,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 +1220,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 +1522,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 +1554,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 +1671,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 +1926,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 +2068,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 +2298,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 +2517,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 +2871,10 @@ class Model(bonsai.core.tool.Model):
@classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
+ # 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,
@@ -2720,28 +2895,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/parametric.py b/src/bonsai/bonsai/tool/parametric.py
index ce6659976c..0460379273 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,57 @@ 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
+
+ # 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),
- 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"),
]
+ # 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]
+
_geom_generation: int = 0
@classmethod
@@ -288,20 +180,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 +198,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 +308,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 +323,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 +339,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 +355,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:
@@ -448,13 +389,232 @@ 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``."""
+ 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)
+ if shared is not None:
+ out.append(shared)
return out
+
+ # --- 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
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,
+ }
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 926bceca91..bc11f3d642 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:
@@ -282,29 +301,26 @@ class System(bonsai.core.tool.System):
@classmethod
def get_decoration_data(cls) -> dict[str, Any]:
+ from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
+
+ if not ObjectSystemData.is_loaded:
+ ObjectSystemData.load()
+ if not SystemDecorationData.is_loaded:
+ SystemDecorationData.load()
+ return cls._build_decoration_data()
+
+ @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 +474,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:
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
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