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