mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 02:47:48 +00:00
Merge pull request #8109 from Gorgious56/bonsai/parametric-framework-slim
Extract parametric framework foundation into tool/ and core/
This commit is contained in:
@@ -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:
|
||||
|
||||
+346
-31
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
+313
-116
@@ -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_<type> 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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
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}")
|
||||
@@ -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)
|
||||
|
||||
+200
-24
@@ -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.<attr> != 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:
|
||||
|
||||
@@ -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<X>`` 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_<name>`` 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<Type>`` 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 ``BIM<Name>Properties`` — 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.BIM<X>Properties`` 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_<Type> pset with nested
|
||||
``lining_properties`` / ``panel_properties``; Finish via
|
||||
``update_<type>_modifier_representation`` →
|
||||
``ifcopenshell.api.feature``; Cancel via
|
||||
``switch_representation`` to the Body rep. Reference samples:
|
||||
door (multi-object) and window (single-object).
|
||||
|
||||
- ``PathPreservingEditMixin`` — BBIM_<Type> pset whose ``path_data``
|
||||
is preserved through edit; Finish via per-type
|
||||
``update_bbim_<type>_pset`` + ``update_<type>_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_<name>`` 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 ``BIM<Name>Properties`` attribute, and that the
|
||||
``is_<name>`` 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_<name>`` → 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 ``BIM<Name>Properties``
|
||||
attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` /
|
||||
``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` 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
|
||||
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
|
||||
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
|
||||
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
|
||||
``tool.Parametric.is_<name>`` 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_<name>(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_<name>
|
||||
# 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_<feature.name>`` 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_<name>``;
|
||||
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_<name>`` 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_<name> predicate: {missing}. "
|
||||
f"Add `is_<name>(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.BIM<Name>Properties`` 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<Name>`` 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<X>``
|
||||
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<X>`` 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, BIM<Name>Properties attributes, is_<name> 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
|
||||
|
||||
@@ -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_<Type> 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":
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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,
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
Reference in New Issue
Block a user