mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-12 14:33:28 +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]
|
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:
|
class IfcStore:
|
||||||
path: str = ""
|
path: str = ""
|
||||||
"""Should be set only using ``tool.Ifc.set_path``."""
|
"""Should be set only using ``tool.Ifc.set_path``."""
|
||||||
@@ -196,7 +234,7 @@ class IfcStore:
|
|||||||
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass # Well we tried. No cache for you!
|
pass # Well we tried. No cache for you!
|
||||||
IfcStore.get_cache()
|
get_cache_or_detect_lock()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_file(path: str) -> None:
|
def load_file(path: str) -> None:
|
||||||
|
|||||||
+346
-31
@@ -21,7 +21,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import TYPE_CHECKING, Literal, Optional
|
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import bpy
|
import bpy
|
||||||
@@ -34,6 +34,24 @@ if TYPE_CHECKING:
|
|||||||
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
|
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(
|
def unjoin_walls(
|
||||||
ifc: type[tool.Ifc],
|
ifc: type[tool.Ifc],
|
||||||
blender: type[tool.Blender],
|
blender: type[tool.Blender],
|
||||||
@@ -179,16 +197,16 @@ class RequireLayeredElement(Exception):
|
|||||||
|
|
||||||
|
|
||||||
# --- Wall geometry math (pure) ------------------------------------------------
|
# --- Wall geometry math (pure) ------------------------------------------------
|
||||||
# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without
|
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
|
||||||
# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary.
|
# 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.
|
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
|
||||||
|
|
||||||
Mirrors the math in ``tool.Model.offset_wall`` for both POSITIVE and NEGATIVE
|
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
|
||||||
direction_sense walls. Returns the closest canonical baseline; falls back to
|
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
|
||||||
``"CENTER"`` when nothing is within ``tolerance``."""
|
within ``tolerance``."""
|
||||||
candidates = (
|
candidates = (
|
||||||
("EXTERIOR", 0.0),
|
("EXTERIOR", 0.0),
|
||||||
("CENTER", -thickness / 2),
|
("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
|
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
|
(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
|
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
|
p1, p2 = seg_a
|
||||||
p3, p4 = seg_b
|
p3, p4 = seg_b
|
||||||
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
|
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
|
||||||
@@ -233,21 +251,100 @@ def project_axis_intersection(
|
|||||||
return (ix, iy, iz)
|
return (ix, iy, iz)
|
||||||
|
|
||||||
|
|
||||||
def displacement_from_x_angle(height: float, x_angle: float) -> float:
|
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
|
||||||
"""Top-edge horizontal displacement for a wall of given vertical ``height`` and
|
"""True when the opening's near edge sits past the cut on the t axis.
|
||||||
slope ``x_angle`` (radians). Drives the slope dimension gizmo's display value.
|
|
||||||
|
|
||||||
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)
|
return height * math.tan(x_angle)
|
||||||
|
|
||||||
|
|
||||||
def x_angle_from_displacement(height: float, displacement: float) -> float:
|
def x_angle_from_displacement(height: float, displacement: float) -> float:
|
||||||
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
|
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
|
||||||
|
|
||||||
``height`` is clamped to ``max(height, 1e-6)`` so vertical walls of effectively
|
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
|
||||||
zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero.
|
cleanly to ``±π/2`` instead of dividing by zero."""
|
||||||
|
|
||||||
Inverse of :func:`displacement_from_x_angle`."""
|
|
||||||
return math.atan2(displacement, max(height, 1e-6))
|
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))
|
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(
|
def are_axes_collinear(
|
||||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||||
seg_b: 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,
|
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||||
line_tolerance: float = 0.05,
|
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True if both axis segments lie on the same infinite line in plan.
|
"""True if both axis segments lie on the same infinite line in plan.
|
||||||
|
|
||||||
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``
|
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
|
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
|
||||||
within ``line_tolerance``. Plan-only (Z ignored) — two parallel walls at
|
Plan-only (Z ignored)."""
|
||||||
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."""
|
|
||||||
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
|
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]
|
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
|
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_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||||
) -> tuple[float, float, float]:
|
) -> tuple[float, float, float]:
|
||||||
"""Midpoint of the closest pair of endpoints between two segments.
|
"""Midpoint of the closest endpoint pair 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."""
|
|
||||||
endpoints_a = (seg_a[0], seg_a[1])
|
endpoints_a = (seg_a[0], seg_a[1])
|
||||||
endpoints_b = (seg_b[0], seg_b[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))
|
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
|
a, b = closest_pair
|
||||||
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
|
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
|
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
|
@interface
|
||||||
class Feature:
|
class Feature:
|
||||||
def add_feature(cls, featured_obj, featured_objs): pass
|
def add_feature(cls, featured_obj, featured_objs): pass
|
||||||
@@ -445,8 +456,10 @@ class Geometry:
|
|||||||
def get_representation_name(cls, representation): pass
|
def get_representation_name(cls, representation): pass
|
||||||
def get_styles(cls, obj): pass
|
def get_styles(cls, obj): pass
|
||||||
def get_total_representation_items(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_data_users(cls, data): pass
|
||||||
def has_material_style_override(cls, obj): pass
|
def has_material_style_override(cls, obj): pass
|
||||||
|
def has_material_styles(cls, element): pass
|
||||||
def import_representation_parameters(cls, data): pass
|
def import_representation_parameters(cls, data): pass
|
||||||
def is_body_representation(cls, representation): pass
|
def is_body_representation(cls, representation): pass
|
||||||
def is_box_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 assign_body_styles(cls, element, obj): pass
|
||||||
def copy_representation(cls, source, dest): pass
|
def copy_representation(cls, source, dest): pass
|
||||||
def does_type_have_representations(cls, element): pass
|
def does_type_have_representations(cls, element): pass
|
||||||
def get_decomposition_relationships(cls, objs): pass
|
|
||||||
def get_default_container(cls): pass
|
def get_default_container(cls): pass
|
||||||
def get_element_representation(cls, element, context): pass
|
def get_element_representation(cls, element, context): pass
|
||||||
def get_element_type(cls, element): pass
|
def get_element_type(cls, element): pass
|
||||||
@@ -879,7 +891,6 @@ class Root:
|
|||||||
def is_in_nest_mode(cls, element): pass
|
def is_in_nest_mode(cls, element): pass
|
||||||
def is_spatial_element(cls, element): pass
|
def is_spatial_element(cls, element): pass
|
||||||
def link_object_data(cls, source_obj, destination_obj): 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 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
|
def set_object_name(cls, obj, element): pass
|
||||||
|
|
||||||
@@ -1023,6 +1034,8 @@ class Spatial:
|
|||||||
def get_container(cls, element): pass
|
def get_container(cls, element): pass
|
||||||
def get_decomposed_elements(cls, container, recursive): pass
|
def get_decomposed_elements(cls, container, recursive): pass
|
||||||
def get_decomposition(cls, element): 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_object_matrix(cls, obj): pass
|
||||||
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
|
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
|
||||||
def get_root_element(cls, element): pass
|
def get_root_element(cls, element): pass
|
||||||
@@ -1143,6 +1156,8 @@ class Style:
|
|||||||
@interface
|
@interface
|
||||||
class Surveyor:
|
class Surveyor:
|
||||||
def get_absolute_matrix(cls, obj): pass
|
def get_absolute_matrix(cls, obj): pass
|
||||||
|
def get_z_rotation(cls, obj): pass
|
||||||
|
def set_z_rotation(cls, obj, z): pass
|
||||||
|
|
||||||
|
|
||||||
@interface
|
@interface
|
||||||
@@ -1209,6 +1224,42 @@ class Voider:
|
|||||||
def void(cls, opening_obj, building_obj): pass
|
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
|
@interface
|
||||||
class Web:
|
class Web:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# ruff: noqa: F401
|
# ruff: noqa: F401
|
||||||
|
|
||||||
from bonsai.tool.aggregate import Aggregate
|
from bonsai.tool.aggregate import Aggregate
|
||||||
|
from bonsai.tool.array import Array
|
||||||
from bonsai.tool.attribute import Attribute
|
from bonsai.tool.attribute import Attribute
|
||||||
from bonsai.tool.bcf import Bcf
|
from bonsai.tool.bcf import Bcf
|
||||||
from bonsai.tool.blender import Blender
|
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.demo import Demo
|
||||||
from bonsai.tool.document import Document
|
from bonsai.tool.document import Document
|
||||||
from bonsai.tool.drawing import Drawing
|
from bonsai.tool.drawing import Drawing
|
||||||
|
from bonsai.tool.duplicate import Duplicate
|
||||||
from bonsai.tool.feature import Feature
|
from bonsai.tool.feature import Feature
|
||||||
from bonsai.tool.geometry import Geometry
|
from bonsai.tool.geometry import Geometry
|
||||||
from bonsai.tool.georeference import Georeference
|
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.root import Root
|
||||||
from bonsai.tool.search import Search
|
from bonsai.tool.search import Search
|
||||||
from bonsai.tool.sequence import Sequence
|
from bonsai.tool.sequence import Sequence
|
||||||
|
from bonsai.tool.slab import Slab
|
||||||
from bonsai.tool.snap import Snap
|
from bonsai.tool.snap import Snap
|
||||||
from bonsai.tool.spatial import Spatial
|
from bonsai.tool.spatial import Spatial
|
||||||
from bonsai.tool.structural import Structural
|
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.tester import Tester
|
||||||
from bonsai.tool.type import Type
|
from bonsai.tool.type import Type
|
||||||
from bonsai.tool.unit import Unit
|
from bonsai.tool.unit import Unit
|
||||||
|
from bonsai.tool.wall import Wall
|
||||||
from bonsai.tool.web import Web
|
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 contextlib
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -30,7 +29,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import types
|
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 datetime import datetime
|
||||||
from functools import cache, lru_cache
|
from functools import cache, lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -47,7 +46,6 @@ from typing import (
|
|||||||
|
|
||||||
import bmesh
|
import bmesh
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell.api
|
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import numpy.typing as npt
|
import numpy.typing as npt
|
||||||
@@ -99,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [
|
|||||||
|
|
||||||
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
|
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):
|
class Blender(bonsai.core.tool.Blender):
|
||||||
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
|
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()):
|
with bpy.context.temp_override(**cls.get_viewport_context()):
|
||||||
bpy.ops.wm.tool_set_by_id(name=tool_name)
|
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
|
@classmethod
|
||||||
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
|
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
|
||||||
for screen in bpy.data.screens:
|
for screen in bpy.data.screens:
|
||||||
@@ -1134,6 +1328,74 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
class Modifier:
|
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
|
@classmethod
|
||||||
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
|
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
|
"""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
|
# roof and railing both finalize then drop into path-edit mode — handle
|
||||||
# them before the generic finish dispatch so the path transition runs.
|
# them before the generic finish dispatch so the path transition runs.
|
||||||
if cls.is_roof(element):
|
if tool.Parametric.is_roof(element):
|
||||||
if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj):
|
if tool.Parametric.ROOF.is_editing(obj):
|
||||||
tool.Parametric.run_bim_op(feature.finish_op)
|
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
|
||||||
bpy.ops.bim.enable_editing_roof_path()
|
bpy.ops.bim.enable_editing_roof_path()
|
||||||
elif cls.is_railing(element):
|
elif tool.Parametric.is_railing(element):
|
||||||
if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj):
|
if tool.Parametric.RAILING.is_editing(obj):
|
||||||
tool.Parametric.run_bim_op(feature.finish_op)
|
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
|
||||||
bpy.ops.bim.enable_editing_railing_path()
|
bpy.ops.bim.enable_editing_railing_path()
|
||||||
elif feature := tool.Parametric.is_object_editing(obj):
|
elif feature := tool.Parametric.is_object_editing(obj):
|
||||||
tool.Parametric.run_bim_op(feature.finish_op)
|
tool.Parametric.run_bim_op(feature.finish_op)
|
||||||
@@ -1176,59 +1438,67 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
|
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
|
@classmethod
|
||||||
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
|
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||||
return tool.Blender.is_object_an_ifc_class(
|
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
|
||||||
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
|
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
|
@classmethod
|
||||||
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
|
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
|
@classmethod
|
||||||
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
|
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
|
@classmethod
|
||||||
def is_railing(cls, element: entity_instance) -> bool:
|
def is_array_child(cls, element: entity_instance) -> bool:
|
||||||
return tool.Pset.get_element_pset(element, "BBIM_Railing")
|
"""True if element is a CHILD of a Bonsai parametric array.
|
||||||
|
|
||||||
@classmethod
|
Children are managed replicas regenerated from the parent's pset —
|
||||||
def is_roof(cls, element: entity_instance) -> bool:
|
their parametric attributes (door dimensions, wall lengths, …) are
|
||||||
return tool.Pset.get_element_pset(element, "BBIM_Roof")
|
overwritten on the next ``regenerate_array``. Parametric gizmo
|
||||||
|
groups skip children via this predicate in ``poll``.
|
||||||
|
|
||||||
@classmethod
|
This sits on a different axis from ``tool.Parametric.is_array``:
|
||||||
def is_window(cls, element: entity_instance) -> bool:
|
cardinality (parent vs child) is orthogonal to feature kind, and
|
||||||
return tool.Pset.get_element_pset(element, "BBIM_Window")
|
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
|
||||||
|
same element."""
|
||||||
@classmethod
|
if element is None:
|
||||||
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"):
|
|
||||||
return False
|
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
|
@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)
|
props = tool.Model.get_railing_props(obj)
|
||||||
return props.is_editing_path
|
return props.is_editing_path
|
||||||
|
|
||||||
@@ -1242,79 +1512,6 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
feature = tool.Parametric.find_for_element(element)
|
feature = tool.Parametric.find_for_element(element)
|
||||||
return bool(feature and feature.has_non_editable_path)
|
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:
|
class Attribute:
|
||||||
@classmethod
|
@classmethod
|
||||||
def fill_attribute(cls, data: bpy.types.ID, attribute_name: str, domain: str, data_type: str, values):
|
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 math
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
from typing import TYPE_CHECKING, Union
|
from typing import TYPE_CHECKING, Union
|
||||||
|
|
||||||
import bmesh
|
import bmesh
|
||||||
@@ -45,6 +46,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
VTX_PRECISION = 1.0e-5
|
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:
|
class Cad:
|
||||||
@@ -996,3 +1004,106 @@ class Cad:
|
|||||||
y = height_half + height_half * (prj[1] / w)
|
y = height_half + height_half * (prj[1] / w)
|
||||||
return Vector((float(x), float(y)))
|
return Vector((float(x), float(y)))
|
||||||
return default
|
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.system
|
||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
import bonsai.tool as 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:
|
if TYPE_CHECKING:
|
||||||
from bonsai.bim.module.geometry.prop import (
|
from bonsai.bim.module.geometry.prop import (
|
||||||
@@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
|
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"):
|
if cache and hasattr(element, "GlobalId"):
|
||||||
cache.remove(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
|
@classmethod
|
||||||
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
|
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
|
||||||
for modifier in obj.modifiers:
|
for modifier in obj.modifiers:
|
||||||
@@ -788,6 +820,15 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
return True
|
return True
|
||||||
return False
|
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
|
@classmethod
|
||||||
def reimport_element_representations(
|
def reimport_element_representations(
|
||||||
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
|
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.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())
|
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
|
@classmethod
|
||||||
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
|
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
|
||||||
tool.Ifc.get().remove(connection)
|
tool.Ifc.get().remove(connection)
|
||||||
@@ -1205,6 +1293,20 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
bpy.data.objects.remove(obj)
|
bpy.data.objects.remove(obj)
|
||||||
return new_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
|
@classmethod
|
||||||
def resolve_mapped_representation(
|
def resolve_mapped_representation(
|
||||||
cls, representation: ifcopenshell.entity_instance
|
cls, representation: ifcopenshell.entity_instance
|
||||||
@@ -2132,8 +2234,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
|
|
||||||
new_active_obj = None
|
new_active_obj = None
|
||||||
# Track decompositions so they can be recreated after the operation
|
# Track decompositions so they can be recreated after the operation
|
||||||
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
|
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate)
|
||||||
connection_relationships = tool.Root.get_connection_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_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
|
||||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
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
|
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.
|
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
||||||
if tool.Ifc.is_moved(obj):
|
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||||
bonsai.core.geometry.edit_object_placement(
|
|
||||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
|
|
||||||
)
|
|
||||||
|
|
||||||
new_obj = obj.copy()
|
new_obj = obj.copy()
|
||||||
temp_data = None
|
temp_data = None
|
||||||
@@ -2212,7 +2314,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
array_data = arrays_to_duplicate.get(obj, None)
|
array_data = arrays_to_duplicate.get(obj, None)
|
||||||
tool.Model.handle_array_on_copied_element(new, array_data)
|
tool.Model.handle_array_on_copied_element(new, array_data)
|
||||||
if 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)
|
child.select_set(True)
|
||||||
|
|
||||||
# TODO: add new array children to recreate their decomposition too
|
# 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
|
# Remove connections with old objects and recreates paths
|
||||||
cls.remove_old_connections(old_to_new)
|
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
|
# 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)
|
cls.remove_linked_aggregate_data(old_to_new)
|
||||||
bonsai.bim.handler.refresh_ui_data()
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
tool.Root.reload_grid_decorator()
|
tool.Root.reload_grid_decorator()
|
||||||
@@ -2308,8 +2411,8 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
array_data = []
|
array_data = []
|
||||||
for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent):
|
for modifier_data in tool.Array.get_modifiers_data(array_parent):
|
||||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
children = set(tool.Array.get_children_objects(modifier_data))
|
||||||
if children.issubset(selected_objects):
|
if children.issubset(selected_objects):
|
||||||
modifier_data["children"] = []
|
modifier_data["children"] = []
|
||||||
array_data.append(modifier_data)
|
array_data.append(modifier_data)
|
||||||
|
|||||||
+200
-24
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import collections.abc
|
import collections.abc
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from math import atan, cos, degrees, pi, radians
|
from math import atan, cos, degrees, pi, radians
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -39,9 +39,11 @@ from typing import (
|
|||||||
import bmesh
|
import bmesh
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.feature
|
||||||
import ifcopenshell.api.geometry
|
import ifcopenshell.api.geometry
|
||||||
import ifcopenshell.api.grid
|
import ifcopenshell.api.grid
|
||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
|
import ifcopenshell.api.root
|
||||||
import ifcopenshell.geom
|
import ifcopenshell.geom
|
||||||
import ifcopenshell.ifcopenshell_wrapper as W
|
import ifcopenshell.ifcopenshell_wrapper as W
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
@@ -60,6 +62,7 @@ import bonsai.core.geometry
|
|||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim import import_ifc
|
from bonsai.bim import import_ifc
|
||||||
|
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
V_ = tool.Blender.V_
|
V_ = tool.Blender.V_
|
||||||
@@ -130,6 +133,35 @@ class Model(bonsai.core.tool.Model):
|
|||||||
assert (scene := bpy.context.scene)
|
assert (scene := bpy.context.scene)
|
||||||
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
|
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
|
@classmethod
|
||||||
def convert_si_to_unit(cls, value: T) -> T:
|
def convert_si_to_unit(cls, value: T) -> T:
|
||||||
if isinstance(value, (tuple, list)):
|
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."
|
assert element or representation, "Either element or representation must be provided."
|
||||||
if representation is None:
|
if representation is None:
|
||||||
assert element
|
assert element
|
||||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
representation = tool.Geometry.get_body_representation(element)
|
||||||
if not representation:
|
if not representation:
|
||||||
return []
|
return []
|
||||||
booleans = []
|
booleans = []
|
||||||
@@ -820,7 +852,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
return []
|
return []
|
||||||
boolean_ids = json.loads(pset["Data"])
|
boolean_ids = json.loads(pset["Data"])
|
||||||
if representation is None:
|
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:
|
if not representation:
|
||||||
return []
|
return []
|
||||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
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
|
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
|
||||||
# but keep it here to trigger import_representation_items,
|
# 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.
|
# 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(
|
if body and any(
|
||||||
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
|
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(
|
def handle_array_on_copied_element(
|
||||||
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
|
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
|
||||||
) -> 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:
|
if array_data is None:
|
||||||
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
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})
|
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
|
||||||
|
|
||||||
for i in range(len(array_data)):
|
for i in range(len(array_data)):
|
||||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
tool.Array.set_children_lock_state(element, i, True)
|
||||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
tool.Array.constrain_children_to_parent(element)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def regenerate_array(
|
def regenerate_array(
|
||||||
@@ -1104,12 +1143,17 @@ class Model(bonsai.core.tool.Model):
|
|||||||
offset = base_offset * i
|
offset = base_offset * i
|
||||||
|
|
||||||
for obj in obj_stack:
|
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:
|
try:
|
||||||
global_id = array["children"][child_i]
|
global_id = array["children"][child_i]
|
||||||
child_element = tool.Ifc.get().by_guid(global_id)
|
child_element = tool.Ifc.get().by_guid(global_id)
|
||||||
child_obj = tool.Ifc.get_object(child_element)
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
assert child_obj
|
assert child_obj
|
||||||
except:
|
except (IndexError, RuntimeError, AssertionError):
|
||||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||||
child_element = next(iter(old_to_new.values()))[0]
|
child_element = next(iter(old_to_new.values()))[0]
|
||||||
child_obj = tool.Ifc.get_object(child_element)
|
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"])
|
removed_children = set(existing_children) - set(array["children"])
|
||||||
for removed_child in removed_children:
|
for removed_child in removed_children:
|
||||||
element = tool.Ifc.get().by_guid(removed_child)
|
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)
|
obj = tool.Ifc.get_object(element)
|
||||||
if obj:
|
if obj:
|
||||||
tool.Geometry.delete_ifc_object(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:
|
if array_i in array_layers_to_apply:
|
||||||
for child_element in children_elements:
|
for child_element in children_elements:
|
||||||
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
||||||
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
|
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
|
||||||
|
cls.unshare_opening_representation(child_element)
|
||||||
|
|
||||||
array["children"] = []
|
array["children"] = []
|
||||||
array["count"] = 1
|
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}
|
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
|
@classmethod
|
||||||
def replace_object_ifc_representation(
|
def replace_object_ifc_representation(
|
||||||
cls,
|
cls,
|
||||||
@@ -1362,8 +1522,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
|
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"""
|
"""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):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
|
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:
|
if not obj.data:
|
||||||
continue
|
continue
|
||||||
element = tool.Ifc.get_entity(obj)
|
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(
|
bonsai.core.geometry.switch_representation(
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
tool.Geometry,
|
tool.Geometry,
|
||||||
@@ -1512,6 +1671,10 @@ class Model(bonsai.core.tool.Model):
|
|||||||
"TRIPLE_PANEL_VERTICAL",
|
"TRIPLE_PANEL_VERTICAL",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
|
||||||
|
|
||||||
|
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_stair_2d_profile(
|
def generate_stair_2d_profile(
|
||||||
cls,
|
cls,
|
||||||
@@ -1763,7 +1926,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||||
|
|
||||||
ifc_file = tool.Ifc.get()
|
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()
|
voided_objs = set()
|
||||||
has_replaced_opening_representation = False
|
has_replaced_opening_representation = False
|
||||||
@@ -1905,7 +2068,9 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
bm.from_mesh(mesh)
|
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")
|
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||||
|
|
||||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
# 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 = bmesh.new()
|
||||||
bm.from_mesh(mesh)
|
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")
|
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||||
|
|
||||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
# 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
|
@classmethod
|
||||||
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
|
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
|
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
||||||
vector = Vector((0, 1))
|
vector = Vector((0, 1))
|
||||||
x_angle = vector.angle_signed(Vector((y, z)))
|
x_angle = vector.angle_signed(Vector((y, z)))
|
||||||
@@ -2700,6 +2871,10 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
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)
|
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||||
bonsai.core.geometry.switch_representation(
|
bonsai.core.geometry.switch_representation(
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
@@ -2720,28 +2895,29 @@ class Model(bonsai.core.tool.Model):
|
|||||||
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
||||||
for wall in walls:
|
for wall in walls:
|
||||||
element = tool.Ifc.get_entity(wall)
|
element = tool.Ifc.get_entity(wall)
|
||||||
if tool.Ifc.is_moved(wall):
|
tool.Geometry.commit_placement_if_moved(wall)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
|
|
||||||
queue.add((element, wall))
|
queue.add((element, wall))
|
||||||
for rel in getattr(element, "ConnectedTo", []):
|
for rel in getattr(element, "ConnectedTo", []):
|
||||||
obj = tool.Ifc.get_object(rel.RelatedElement)
|
obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||||
if tool.Ifc.is_moved(obj):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
queue.add((rel.RelatedElement, obj))
|
queue.add((rel.RelatedElement, obj))
|
||||||
for rel in getattr(element, "ConnectedFrom", []):
|
for rel in getattr(element, "ConnectedFrom", []):
|
||||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||||
if tool.Ifc.is_moved(obj):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
queue.add((rel.RelatingElement, obj))
|
queue.add((rel.RelatingElement, obj))
|
||||||
for element, wall in queue:
|
for element, wall in queue:
|
||||||
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
|
if not wall:
|
||||||
# Use layer custom offset
|
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)
|
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
|
||||||
material = ifcopenshell.util.element.get_material(element)
|
material = ifcopenshell.util.element.get_material(element)
|
||||||
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
|
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
|
||||||
material.OffsetFromReferenceLine = custom_offset
|
material.OffsetFromReferenceLine = custom_offset
|
||||||
|
cls.recreate_wall(element, wall)
|
||||||
cls.recreate_wall(element, wall)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
|
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.
|
# 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
|
The registry is consumed along two orthogonal axes:
|
||||||
`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.
|
|
||||||
|
|
||||||
Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/``
|
- **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
|
||||||
modules can consume it without crossing the layer boundary. The orchestration
|
by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
|
||||||
helpers (``commit_object_draft``, ``commit_pending_edits``) call
|
- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
|
||||||
``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender
|
share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
|
||||||
rather than a Python import of ``bim/``.
|
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.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
|
||||||
How to add a new parametric object
|
flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
|
||||||
----------------------------------------------------------------------
|
one of the shared mixins in ``bim/parametric_lifecycle.py``."""
|
||||||
|
|
||||||
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)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import traceback
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Any, ClassVar, Optional
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ifcopenshell import entity_instance
|
from ifcopenshell import entity_instance
|
||||||
|
|
||||||
|
|
||||||
# ``name`` must be a single ASCII lowercase token starting with a letter:
|
# Lowercase ASCII snake_case token; each segment a non-empty letter/digit
|
||||||
# ``str.capitalize()`` only handles single-word names cleanly, so a compound
|
# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
|
||||||
# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` —
|
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
|
||||||
# off the Bonsai naming convention and silently broken.
|
|
||||||
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$")
|
|
||||||
|
def _camel_case(name: str) -> str:
|
||||||
|
return "".join(part.capitalize() for part in name.split("_"))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ParametricObject:
|
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",
|
The ``name`` token drives every derived identifier: the
|
||||||
"wall", …) drives every derived identifier: the ``BIM<Name>Properties``
|
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
|
||||||
attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` /
|
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
|
||||||
``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` operator
|
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
|
||||||
``bl_idname``s. The ``name`` is validated at construction time — a
|
``tool.Parametric.is_<name>`` runtime predicate.
|
||||||
multi-word IFC type would silently mis-derive through
|
|
||||||
``str.capitalize()`` and breaks the single-token assumption.
|
|
||||||
|
|
||||||
``has_non_editable_path`` flags element types whose modifier exposes no
|
The predicate is part of the contract and MUST be total — accept any IFC
|
||||||
user-editable path (door, window, stair).
|
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)``
|
``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
|
||||||
is part of the registry contract: it MUST be **total** — accept any
|
shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
|
||||||
IFC entity and return a boolean, never raise. The registry iterates
|
``_cancel_targets``) and that therefore wire their operators through
|
||||||
every predicate against the active element on save; a raising predicate
|
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
|
||||||
propagates upward and breaks the save path for *all* parametric types,
|
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
|
||||||
not just its own."""
|
False and declare their operator classes directly."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
has_non_editable_path: bool = False
|
has_non_editable_path: bool = False
|
||||||
|
supports_build_edit_lifecycle: bool = False
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not _VALID_NAME_RE.match(self.name):
|
if not _VALID_NAME_RE.match(self.name):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"ParametricObject name {self.name!r} must be a single ASCII lowercase "
|
f"ParametricObject name {self.name!r} must match "
|
||||||
f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only "
|
f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
|
||||||
f"handles single-word names — compound IFC types need an explicit "
|
f"optionally split by single underscores (e.g. ``door`` or "
|
||||||
f"naming override (not yet supported)."
|
f"``pipe_segment``). Leading / trailing underscores and "
|
||||||
|
f"consecutive underscores are rejected because they produce "
|
||||||
|
f"empty CamelCase segments in derived class names."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def props_attr(self) -> str:
|
def props_attr(self) -> str:
|
||||||
return f"BIM{self.name.capitalize()}Properties"
|
return f"BIM{_camel_case(self.name)}Properties"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enable_op(self) -> str:
|
def enable_op(self) -> str:
|
||||||
@@ -270,15 +120,57 @@ class ParametricObject:
|
|||||||
|
|
||||||
|
|
||||||
class Parametric(bonsai.core.tool.Parametric):
|
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] = [
|
EDIT_TYPES: list[ParametricObject] = [
|
||||||
ParametricObject("door", has_non_editable_path=True),
|
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||||
ParametricObject("window", has_non_editable_path=True),
|
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||||
ParametricObject("stair", has_non_editable_path=True),
|
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||||
ParametricObject("railing"),
|
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||||
ParametricObject("roof"),
|
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||||
ParametricObject("wall"),
|
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
|
_geom_generation: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -288,20 +180,9 @@ class Parametric(bonsai.core.tool.Parametric):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def refresh_post_commit(cls) -> None:
|
def refresh_post_commit(cls) -> None:
|
||||||
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
|
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
|
||||||
``BIMModelProperties`` (workspace tool header H/L/A fields) from current
|
workspace-tool header fields from current IFC state and bumps the
|
||||||
IFC state and bumps the geometry generation counter so per-gizmo-group
|
geometry generation counter so caches keyed off it drop stale
|
||||||
caches keyed off it drop their stale entries on the next draw.
|
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."""
|
|
||||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||||
|
|
||||||
cls._geom_generation += 1
|
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)
|
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
|
def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
|
||||||
"""Return the registry entry whose IFC type predicate matches ``element``.
|
"""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>``;
|
@classmethod
|
||||||
resolved here by attribute lookup at call time, which avoids a
|
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
|
||||||
``tool.parametric`` ↔ ``tool.blender`` import cycle."""
|
"""Return the registry entry whose IFC type predicate matches ``element``."""
|
||||||
for feature in cls.EDIT_TYPES:
|
for feature in cls.EDIT_TYPES:
|
||||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
if cls._safe_predicate(feature, element):
|
||||||
if predicate is not None and predicate(element):
|
|
||||||
return feature
|
return feature
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@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:
|
for feature in cls.EDIT_TYPES:
|
||||||
|
if feature.name == skip_name:
|
||||||
|
continue
|
||||||
if feature.is_editing(obj):
|
if feature.is_editing(obj):
|
||||||
return feature
|
return feature
|
||||||
return None
|
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
|
@classmethod
|
||||||
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
||||||
"""``(object, finish_operator_bl_idname)`` pairs for every object with
|
"""``(object, finish_operator_bl_idname)`` pairs for every object
|
||||||
an in-progress parametric draft. The first registry match per object wins."""
|
with an in-progress parametric draft. Stale flags are cleared in
|
||||||
return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))]
|
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
|
@classmethod
|
||||||
def run_bim_op(cls, bl_idname: str) -> None:
|
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
|
Asserts the operator is a ``tool.Ifc.Operator`` subclass — bypassing
|
||||||
``tool.Ifc.Operator`` subclass — its transaction wrap is what
|
that wrap would mutate IFC outside Bonsai's transaction system."""
|
||||||
makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation
|
|
||||||
of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's
|
|
||||||
transaction system."""
|
|
||||||
verb = bl_idname.removeprefix("bim.")
|
verb = bl_idname.removeprefix("bim.")
|
||||||
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
|
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
|
||||||
assert op_cls is not None and issubclass(
|
if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
|
||||||
op_cls, tool.Ifc.Operator
|
raise RuntimeError(
|
||||||
), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
|
f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
|
||||||
|
)
|
||||||
getattr(bpy.ops.bim, verb)()
|
getattr(bpy.ops.bim, verb)()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
|
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
|
"""Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
|
||||||
the operator raised (with traceback printed to the console).
|
traceback printed) if the operator raised.
|
||||||
|
|
||||||
Both ``temp_override`` and ``view_layer.objects.active`` are set:
|
Both ``temp_override`` and ``view_layer.objects.active`` are set:
|
||||||
``temp_override`` does not rebind ``objects.active``, and some finish
|
``temp_override`` does not rebind ``objects.active``, and some finish
|
||||||
@@ -374,9 +308,13 @@ class Parametric(bonsai.core.tool.Parametric):
|
|||||||
try:
|
try:
|
||||||
cls.run_bim_op(finish_op)
|
cls.run_bim_op(finish_op)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}")
|
logger.warning(
|
||||||
traceback.print_exc()
|
"commit of %r via %s failed",
|
||||||
|
obj.name,
|
||||||
|
finish_op,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
view_layer.objects.active = original_active
|
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]]:
|
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
|
||||||
"""Run each pending draft's finish operator scoped to its object.
|
"""Run each pending draft's finish operator scoped to its object.
|
||||||
|
|
||||||
A per-object failure does not abort the loop — remaining drafts still
|
A per-object failure does not abort the loop — remaining drafts
|
||||||
flush, otherwise the auto-commit would ship the exact silent-desync
|
still flush, otherwise the auto-commit would ship the exact silent
|
||||||
it exists to prevent.
|
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."""
|
|
||||||
committed = 0
|
committed = 0
|
||||||
failed: list[bpy.types.Object] = []
|
failed: list[bpy.types.Object] = []
|
||||||
for obj, finish_op in cls.get_pending_edits():
|
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(
|
def commit_pending_edits_for_selection(
|
||||||
cls, names: Optional[tuple[str, ...]] = None
|
cls, names: Optional[tuple[str, ...]] = None
|
||||||
) -> tuple[int, list[bpy.types.Object]]:
|
) -> tuple[int, list[bpy.types.Object]]:
|
||||||
"""Selection-scoped variant of `commit_pending_edits`. ``names``
|
"""Selection-scoped variant. ``names`` filters which registry entries
|
||||||
filters which registry entries to consider — e.g. ``("wall",)`` to commit
|
to consider; ``None`` considers every type."""
|
||||||
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."""
|
|
||||||
committed = 0
|
committed = 0
|
||||||
failed: list[bpy.types.Object] = []
|
failed: list[bpy.types.Object] = []
|
||||||
for obj in tool.Blender.get_selected_objects():
|
for obj in tool.Blender.get_selected_objects():
|
||||||
feature = cls.is_object_editing(obj)
|
feature = cls._validated_editing_feature(obj)
|
||||||
if feature is None:
|
if feature is None:
|
||||||
continue
|
continue
|
||||||
if names is not None and feature.name not in names:
|
if names is not None and feature.name not in names:
|
||||||
@@ -428,11 +355,25 @@ class Parametric(bonsai.core.tool.Parametric):
|
|||||||
failed.append(obj)
|
failed.append(obj)
|
||||||
return committed, failed
|
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
|
@classmethod
|
||||||
def register_object_properties(cls, prop_module) -> None:
|
def register_object_properties(cls, prop_module) -> None:
|
||||||
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
|
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
|
||||||
parametric type, looking up the matching ``PropertyGroup`` class on
|
parametric type. Skips entries whose ``PropertyGroup`` is absent."""
|
||||||
``prop_module``. Skips entries whose ``PropertyGroup`` class is absent."""
|
cls._assert_predicates_registered()
|
||||||
for feature in cls.EDIT_TYPES:
|
for feature in cls.EDIT_TYPES:
|
||||||
prop_cls = getattr(prop_module, feature.props_attr, None)
|
prop_cls = getattr(prop_module, feature.props_attr, None)
|
||||||
if prop_cls is None:
|
if prop_cls is None:
|
||||||
@@ -448,13 +389,232 @@ class Parametric(bonsai.core.tool.Parametric):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
|
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
|
||||||
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
|
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
|
||||||
every registry entry. Order matches `EDIT_TYPES`. Used by
|
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
|
||||||
``bim/__init__.py`` to inject the per-type ``GizmoPreferences<X>``
|
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
|
||||||
classes at the correct point — before ``ui.GizmoPreferences``, which
|
inject the per-type ``GizmoPreferences<X>`` classes at the correct
|
||||||
references them via ``PointerProperty``."""
|
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] = []
|
out: list[type] = []
|
||||||
for feature in cls.EDIT_TYPES:
|
for feature in cls.EDIT_TYPES:
|
||||||
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
|
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
|
||||||
if gpref is not None:
|
if gpref is not None:
|
||||||
out.append(gpref)
|
out.append(gpref)
|
||||||
|
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
|
||||||
|
if shared is not None:
|
||||||
|
out.append(shared)
|
||||||
return out
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
|
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.pset
|
||||||
import ifcopenshell.util.attribute
|
import ifcopenshell.util.attribute
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
|
|
||||||
@@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset):
|
|||||||
if pset:
|
if pset:
|
||||||
return tool.Ifc.get().by_id(pset["id"])
|
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
|
@classmethod
|
||||||
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
|
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
|
||||||
if obj_type == "Object":
|
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
|
break
|
||||||
return element
|
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
|
@classmethod
|
||||||
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
|
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
|
||||||
if tool.Ifc.get_schema() == "IFC2X3":
|
if tool.Ifc.get_schema() == "IFC2X3":
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import bpy
|
|||||||
import ifcopenshell.api.geometry
|
import ifcopenshell.api.geometry
|
||||||
import ifcopenshell.api.system
|
import ifcopenshell.api.system
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
|
import ifcopenshell.util.placement
|
||||||
import ifcopenshell.util.system
|
import ifcopenshell.util.system
|
||||||
from mathutils import Matrix, Vector
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
@@ -35,12 +37,29 @@ import bonsai.core.root
|
|||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim import import_ifc
|
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:
|
if TYPE_CHECKING:
|
||||||
from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties
|
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):
|
class System(bonsai.core.tool.System):
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_system_props(cls) -> BIMSystemProperties:
|
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
|
# make sure obj.dimensions and .matrix_world has valid data
|
||||||
bpy.context.view_layer.update()
|
bpy.context.view_layer.update()
|
||||||
# need to make sure .ObjectPlacement is also updated when we're going to add ports
|
# 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)
|
mep_element = tool.Ifc.get_entity(obj)
|
||||||
bbox = tool.Blender.get_object_bounding_box(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)
|
return ifcopenshell.util.system.get_ports(element)
|
||||||
|
|
||||||
@classmethod
|
@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":
|
if tool.Ifc.get_schema() == "IFC2X3":
|
||||||
element = port.ContainedIn[0].RelatedElement
|
rel = port.ContainedIn[0] if port.ContainedIn else None
|
||||||
else:
|
return rel.RelatedElement if rel else None
|
||||||
element = port.Nests[0].RelatingObject
|
rel = port.Nests[0] if port.Nests else None
|
||||||
return element
|
return rel.RelatingObject if rel else None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
|
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
|
||||||
@@ -282,29 +301,26 @@ class System(bonsai.core.tool.System):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_decoration_data(cls) -> dict[str, Any]:
|
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 = []
|
all_vertices = []
|
||||||
preview_edges = []
|
preview_edges = []
|
||||||
special_vertices = []
|
special_vertices = []
|
||||||
selected_edges = []
|
selected_edges = []
|
||||||
selected_vertices = []
|
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
|
start_vert_i = 0
|
||||||
|
|
||||||
if not ObjectSystemData.is_loaded:
|
|
||||||
ObjectSystemData.load()
|
|
||||||
|
|
||||||
if not SystemDecorationData.is_loaded:
|
|
||||||
SystemDecorationData.load()
|
|
||||||
|
|
||||||
class FlowDirection(Enum):
|
class FlowDirection(Enum):
|
||||||
BACKWARD = -1
|
BACKWARD = -1
|
||||||
FORWARD = 1
|
FORWARD = 1
|
||||||
@@ -458,6 +474,72 @@ class System(bonsai.core.tool.System):
|
|||||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
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
|
@classmethod
|
||||||
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||||
if not element.HasControlElements:
|
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