This commit is contained in:
Thomas Krijnen
2026-06-23 20:23:13 +02:00
52 changed files with 6139 additions and 584 deletions
@@ -23,16 +23,24 @@ from bpy.app.handlers import persistent
import bonsai.tool as tool
from . import operator, prop, ui
from . import face_quad, gizmos, operator, prop, ui
classes = (
operator.BIM_OT_add_clip_box,
operator.BIM_OT_add_clip_box_for_source,
operator.BIM_OT_align_view_to_clip_face,
operator.BIM_OT_duplicate_clip_box,
operator.BIM_OT_remove_clip_box,
operator.BIM_OT_set_active_clip_box,
operator.BIM_OT_toggle_clip_box_enabled,
prop.BIMClipBoxProperties,
prop.BIMSceneClipBoxProperties,
face_quad.BIM_GT_box_face_quad,
face_quad.BIM_GT_box_face_outline,
gizmos.OBJECT_GGT_bim_clip_box,
ui.BIM_MT_clip_box_add_for_source,
ui.BIM_MT_clip_box_info,
ui.BIM_MT_clip_box_settings,
ui.BIM_UL_clip_box,
ui.BIM_PT_clip_box,
)
@@ -0,0 +1,212 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
an IFC entity id stringified for entity-driven kinds, an IFC class name for
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
turns the picked id into a ``matrix_world`` via the source-preset helper.
"""
from __future__ import annotations
import bonsai.tool as tool
EnumItems = list[tuple[str, str, str]]
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
# tuples a callback returns, so the Python strings must outlive the draw call.
# Stashing the latest result per kind keeps them alive across callback firings.
_items_cache: dict[str, EnumItems] = {}
# Sentinel id used for the "no options available" placeholder. The operator
# treats this as an invalid pick and surfaces an ERROR.
NO_OPTIONS_ID = "__none__"
def _cache(kind: str, items: EnumItems) -> EnumItems:
_items_cache[kind] = items
return items
def _no_options(label: str) -> EnumItems:
# Blender refuses to draw an EnumProperty with zero entries — show a
# placeholder so the dialog renders and the user sees the empty state.
return [(NO_OPTIONS_ID, label, "")]
def _label(entity, ifc_class: str | None = None) -> str:
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
return f"{ifc_class}: {name}" if ifc_class else name
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
"""Shared shape for the IFC-driven enum callbacks.
Returns the no-IFC placeholder if no file is loaded, then runs
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
returns the empty-result placeholder if nothing matched. The output is
always routed through the module cache.
"""
ifc = tool.Ifc.get()
if ifc is None:
return _cache(kind, _no_options("No IFC loaded"))
items = build_fn(ifc)
items.sort(key=lambda t: t[1].lower())
if not items:
return _cache(kind, _no_options(empty_label))
return _cache(kind, items)
# Top-down spatial hierarchy so the picker reads in the order an architect
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
# — spaces are typically empty volumes used for room metadata, so clipping to
# one rarely matches the user intent of "show me what's in this container".
SPATIAL_CLASSES: tuple[str, ...] = (
"IfcProject",
"IfcSite",
"IfcBuilding",
"IfcBuildingStorey",
)
def spatial_items(self, context) -> EnumItems:
# Special-case: per-class sort within the hierarchy order rather than a
# flat alphabetical sort, so the dropdown reads project → site → building.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("SPATIAL", _no_options("No IFC loaded"))
items: EnumItems = []
for ifc_class in SPATIAL_CLASSES:
try:
entities = ifc.by_type(ifc_class, include_subtypes=False)
except RuntimeError:
continue
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
items.append((str(entity.id()), _label(entity, ifc_class), ""))
if not items:
return _cache("SPATIAL", _no_options("No spatial containers"))
return _cache("SPATIAL", items)
def class_items(self, context) -> EnumItems:
# Special-case: the picker value IS the IFC class name, not an entity id,
# so the build shape differs from the other entity-driven callbacks.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("CLASS", _no_options("No IFC loaded"))
# List only IFC classes ACTUALLY present in the file (not the whole
# schema), so the user picks from classes that can produce a non-empty
# clip volume. ``e.is_a()`` returns the most specific class per element.
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
if not present:
return _cache("CLASS", _no_options("No products"))
return _cache("CLASS", [(cls, cls, "") for cls in present])
def type_items(self, context) -> EnumItems:
return _build_items(
"TYPE",
"No types defined",
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
)
def material_items(self, context) -> EnumItems:
return _build_items(
"MATERIAL",
"No materials defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
)
def profile_items(self, context) -> EnumItems:
# ProfileName is optional. Skip unnamed profiles — they can't be
# meaningfully picked from a flat list.
return _build_items(
"PROFILE",
"No named profiles",
lambda ifc: [
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
for e in ifc.by_type("IfcProfileDef")
if getattr(e, "ProfileName", None)
],
)
def drawing_items(self, context) -> EnumItems:
return _build_items(
"DRAWING",
"No drawings defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"],
)
# Display labels for each status value. The id strings on the left are the
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
STATUS_LABELS: tuple[tuple[str, str], ...] = (
("No Status", "No Status"),
("NEW", "New"),
("EXISTING", "Existing"),
("DEMOLISH", "Demolish"),
("TEMPORARY", "Temporary"),
("OTHER", "Other"),
("NOTKNOWN", "Not Known"),
("UNSET", "Unset"),
)
def status_items(self, context) -> EnumItems:
# Fixed enum; no IFC needed. Still routed through the cache to share the
# same string-lifetime guarantee as the other callbacks.
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
def system_items(self, context) -> EnumItems:
# IfcStructuralAnalysisModel is a structural-grouping container, not a
# distribution system — excluded to match Bonsai's other system pickers.
return _build_items(
"SYSTEM",
"No systems defined",
lambda ifc: [
(str(e.id()), _label(e, e.is_a()), "")
for e in ifc.by_type("IfcSystem")
if not e.is_a("IfcStructuralAnalysisModel")
],
)
def group_items(self, context) -> EnumItems:
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
# under Group as well — those get their own picker entries.
return _build_items(
"GROUP",
"No groups defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)],
)
def zone_items(self, context) -> EnumItems:
return _build_items(
"ZONE",
"No zones defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
)
@@ -0,0 +1,879 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Generic face-quad resize gizmos for any axis-aligned local box.
This module contains the box-agnostic core of the interactive
face-resize gizmos: two Gizmo classes (a near-invisible click target
welded to each face, and a thin colored edge outline), a per-redraw
orchestrator that places six of each on a box, and the pure one-sided
resize arithmetic. None of it knows about IFC, clip boxes, or
``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can
reuse the same classes and helpers.
Consumer contract — the adapter group must:
1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline``
instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind
each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read
and mutate the box's host (e.g. an Empty's ``location`` / ``scale``).
2. Call :func:`apply_face_quad_layout` from ``refresh()`` /
``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``,
the host's ``matrix_world``, the OBB rotation as a 4x4
(``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``),
and the current ``region`` / ``rv3d``.
3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group
for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them.
The resize arithmetic in :func:`compute_face_resize` is pure: feed it
the modal scalar plus drag-start snapshots and it returns the host's
new scale-on-axis and new origin location.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from typing import Any
import bpy
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
from mathutils import Matrix, Vector
# ---------------------------------------------------------------------------
# Public iteration order
# ---------------------------------------------------------------------------
# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its
# six face-quad gizmos in this order so positional indexing into the
# layout helper stays correct.
FACE_ROUTES: tuple[tuple[int, bool], ...] = (
(0, False),
(0, True),
(1, False),
(1, True),
(2, False),
(2, True),
)
# ---------------------------------------------------------------------------
# Public visual constants (adapter reads these in setup())
# ---------------------------------------------------------------------------
# Standard XYZ axis colors (Blender convention).
AXIS_COLOR: dict[int, tuple[float, float, float]] = {
0: (1.0, 0.2, 0.2),
1: (0.2, 1.0, 0.2),
2: (0.2, 0.4, 1.0),
}
# Documented "selectable but unpainted" trick: the GPU still writes the
# selection buffer at this alpha so clicks register, but no visible
# pixels are produced.
FACE_QUAD_ALPHA: float = 0.001
# Very faint hover tint — just enough to confirm "you're aiming at this
# face" without painting visibly over geometry behind it.
FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04
# Setup-time default for ``select_bias``; the layout helper overwrites
# it per frame to the front-facing or halo value below. Kept below the
# canonical arrow bias so a bailed frame can't let a front quad steal
# clicks meant for a hidden control.
FACE_QUAD_SELECT_BIAS: float = 0.5
# ---------------------------------------------------------------------------
# Internal constants
# ---------------------------------------------------------------------------
# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two
# CCW triangles viewed from +Z. matrix_basis stretches it onto the
# face's perpendicular extents.
_QUAD_TRIS: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
]
# Unit-quad outline as 4 line segments in the local XY plane at z=0.
_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
]
# Degenerate zero-area triangle for hidden back-facing quads with no
# visible-adjacent neighbours (rare orientation). Blender tolerates
# this; the gizmo is hidden anyway so nothing renders.
_EMPTY_TRIS: list[tuple[float, float, float]] = [
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
]
# Rotates the gizmo's local +Z onto the outward face normal in the
# box's local frame. Right-hand rotation around the named axis.
_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = {
(0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"),
(0, True): Matrix.Rotation(math.pi / 2, 4, "Y"),
(1, False): Matrix.Rotation(math.pi / 2, 4, "X"),
(1, True): Matrix.Rotation(-math.pi / 2, 4, "X"),
(2, False): Matrix.Rotation(math.pi, 4, "X"),
(2, True): Matrix.Identity(4),
}
# Per-face mapping from face-quad local axes to local box axes for the
# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis
# indices the quad's local X and Y span after the orientation rotation.
_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = {
(0, False): (2, 1),
(0, True): (2, 1),
(1, False): (0, 2),
(1, True): (0, 2),
(2, False): (0, 1),
(2, True): (0, 1),
}
# Front-facing quad sits ABOVE the halo strips so the cursor on the
# visible face area always grabs the visible face, never accidentally
# routes to a back-face halo strip in an adjacent screen region.
_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5
_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0
# Target halo-strip thickness in screen pixels. The world-space margin
# is recomputed per frame so the rim stays a roughly constant on-screen
# size regardless of viewport zoom.
_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0
# Minimum world half-extent a face resize may shrink to. Stops a drag
# from collapsing the host to zero or negative scale.
_MIN_HALF_EXTENT: float = 1e-4
# ---------------------------------------------------------------------------
# Pure predicates (testable without Blender)
# ---------------------------------------------------------------------------
Vec3 = tuple[float, float, float]
def face_outward_axis_local(axis: int, is_max: bool) -> Vec3:
"""Un-rotated outward face normal in the box's local AABB coords.
For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the X
face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by
applying the host's rotation and the OBB rotation:
``mw_rot @ cage_rotation @ this``.
"""
sign = 1.0 if is_max else -1.0
out = [0.0, 0.0, 0.0]
out[axis] = sign
return (out[0], out[1], out[2])
def front_facing_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
eps: float = 1e-6,
) -> tuple[bool, ...]:
"""Which of the 6 box faces point toward the camera.
A face is front-facing iff its outward normal points AGAINST the
view direction (``dot(normal, view_dir) < -eps``). The ``-eps``
margin prevents flicker at grazing angles.
``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns
a 6-tuple of bool parallel to that order.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world)
def view_axis_parallel_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
threshold: float = 0.95,
) -> tuple[bool, ...]:
"""Which faces have normals (anti-)parallel to the view direction.
True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the
face is nearly perpendicular to the screen plane. Provided as a
pure predicate for callers that want to detect degenerate-drag
conditions; the layout helper itself no longer gates on it.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world)
# ---------------------------------------------------------------------------
# Pure resize arithmetic
# ---------------------------------------------------------------------------
def compute_face_resize(
*,
value: float,
init_world_half: float,
init_location: tuple[float, float, float],
world_axis: tuple[float, float, float],
display_size: float,
) -> tuple[float, tuple[float, float, float]]:
"""Pure one-sided face-resize arithmetic.
Returns ``(new_scale_axis, new_location)`` — the host's new scale
on the dragged axis and its new world origin — such that the
dragged face moves by the modal's outward delta while the OPPOSITE
face stays put.
``value`` is ``init + delta``, where ``init`` is the unsigned
drag-start world half-extent and ``delta`` is the cursor projection
onto the face's OUTWARD world normal. Realized half-extent is
clamped to a small floor; the location shift uses the realized
(post-clamp) delta so the opposite face stays fixed even at the
clamp.
"""
face_delta = value - init_world_half
new_world_half = init_world_half + 0.5 * face_delta
if new_world_half < _MIN_HALF_EXTENT:
new_world_half = _MIN_HALF_EXTENT
realized_delta = 2.0 * (new_world_half - init_world_half)
ds = display_size if display_size != 0.0 else 1.0
new_scale_axis = new_world_half / ds
shift = 0.5 * realized_delta
new_location = (
init_location[0] + shift * world_axis[0],
init_location[1] + shift * world_axis[1],
init_location[2] + shift * world_axis[2],
)
return new_scale_axis, new_location
# ---------------------------------------------------------------------------
# Internal geometry helpers
# ---------------------------------------------------------------------------
def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]:
"""Return ``(w, h)`` for the face quad's scale matrix."""
w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)]
w = float(bmax[w_axis] - bmin[w_axis])
h = float(bmax[h_axis] - bmin[h_axis])
return w, h
def _shared_edge_corner_keys(
axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool
) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
"""Return the 2 corner-bit triples shared by two adjacent faces.
Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two
returned corners are ordered with the free-axis bit ascending.
"""
if axis_a == axis_b:
return None
free_axis = 3 - axis_a - axis_b
bit_a = 1 if is_max_a else 0
bit_b = 1 if is_max_b else 0
corner_lo = [0, 0, 0]
corner_hi = [0, 0, 0]
corner_lo[axis_a] = bit_a
corner_hi[axis_a] = bit_a
corner_lo[axis_b] = bit_b
corner_hi[axis_b] = bit_b
corner_lo[free_axis] = 0
corner_hi[free_axis] = 1
return (
(corner_lo[0], corner_lo[1], corner_lo[2]),
(corner_hi[0], corner_hi[1], corner_hi[2]),
)
def _face_corner_keys(axis: int, is_max: bool) -> tuple[
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
]:
"""Return the 4 corner-bit triples of a face in CCW order.
Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with
two non-overlapping triangles.
"""
fixed_bit = 1 if is_max else 0
free_axes = [a for a in (0, 1, 2) if a != axis]
fa0, fa1 = free_axes
corners = []
for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)):
key = [0, 0, 0]
key[axis] = fixed_bit
key[fa0] = ka
key[fa1] = kb
corners.append((key[0], key[1], key[2]))
return (corners[0], corners[1], corners[2], corners[3])
def _build_strip_tris_relative(
edge_p0_local: tuple[float, float, float],
edge_p1_local: tuple[float, float, float],
extrusion_local: tuple[float, float, float],
) -> list[tuple[float, float, float]]:
"""Build two CCW triangles (6 vertices) for a thin halo strip.
All inputs are in coords relative to the gizmo's ``matrix_basis``
anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and
extrudes by ``extrusion_local`` perpendicular to the edge.
"""
p0x, p0y, p0z = edge_p0_local
p1x, p1y, p1z = edge_p1_local
ex, ey, ez = extrusion_local
p0e = (p0x + ex, p0y + ey, p0z + ez)
p1e = (p1x + ex, p1y + ey, p1z + ez)
return [
(p0x, p0y, p0z),
p0e,
p1e,
(p0x, p0y, p0z),
p1e,
(p1x, p1y, p1z),
]
def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool:
"""True if the back-face quad's geometry differs from the cached upload.
Pure orbit/pan doesn't change either the box pose or the cage
rotation, so the computed strip vertices are byte-identical to the
previous frame's. Hitting the cache lets the back-facing branch
skip ``new_custom_shape`` and the GPU upload.
"""
cached = getattr(quad_gz, "_strips_cache_key", None)
last_state = getattr(quad_gz, "_last_geometry_state", None)
key = (face_quad_local, all_tris)
if cached is None or last_state != "strips" or cached != key:
quad_gz._strips_cache_key = key
quad_gz._last_geometry_state = "strips"
return True
return False
def _compute_face_basis(
mw: Any,
mw_rot: Any,
cage_rotation: Any,
pivot_local: Any,
face_local: Any,
orient: Any,
) -> tuple[Any, Any]:
"""World-space (translation, outward-normal-direction) for one face."""
rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local
face_world = mw @ rotated_face_local
world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized()
return face_world, world_axis
def _compose_face_matrix_basis(
face_world: Any,
mw_rot_scale: Any,
cage_rotation: Any,
orient: Any,
w: float,
h: float,
) -> Any:
"""Compose the 5-term ``matrix_basis`` for a face-plane gizmo.
Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @
Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the
world-space face rectangle, including the host's scale.
"""
quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0))
return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale
def _compute_box_corners_world(
bmin: Any,
bmax: Any,
pivot_local: Any,
cage_rotation_3x3: Any,
mw: Any,
) -> dict[tuple[int, int, int], Any]:
"""Return the 8 OBB corners in world space, keyed by bit-triple."""
corners: dict[tuple[int, int, int], Any] = {}
for ix in (0, 1):
for iy in (0, 1):
for iz in (0, 1):
local = Vector(
(
float(bmax.x if ix else bmin.x),
float(bmax.y if iy else bmin.y),
float(bmax.z if iz else bmin.z),
)
)
rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local
corners[(ix, iy, iz)] = mw @ rotated
return corners
def _abs_scale_matrix(mw: Any) -> Any:
"""Return a copy of ``mw`` with all scale components ``abs()``-ed.
Without this, a negative-scale host produces a visible/clickable
face inversion: ``mw @ local_vec`` flips the +axis face onto the
-axis world side, while the rotation-only normal stays pointing
in the +axis direction — so the gizmo for "the +X face" sits at
world -X but reports its outward normal as +X.
"""
loc, rot, scale = mw.decompose()
abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z)))
return Matrix.LocRotScale(loc, rot, abs_scale)
def _world_radius_to_screen_pixels(
region: Any,
rv3d: Any,
center_world: Vector,
world_radius: float,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel radius of a world-space circle.
Projects ``center_world`` and a sample point offset by
``world_radius`` along the camera's view-aligned right axis to
region pixels, and returns the screen-pixel distance between them.
Falls back to ``min_pixels`` if either projection fails.
"""
try:
view_inv = rv3d.view_matrix.inverted()
right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized()
except (AttributeError, ValueError):
right = Vector((1.0, 0.0, 0.0))
sample_world = center_world + right * world_radius
return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels)
def _world_segment_to_screen_pixels(
region: Any,
rv3d: Any,
p0_world: Vector,
p1_world: Vector,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel length of an arbitrary world segment.
Unlike :func:`_world_radius_to_screen_pixels`, this measures the
ACTUAL projected length of the segment — foreshortening included.
Use this when the segment direction is known to be oblique to the
screen plane (e.g. a back face's outward normal): a perpendicular
radius measurement overestimates the on-screen length, leaving
halo strips visually narrower than the requested pixel target.
"""
p0 = location_3d_to_region_2d(region, rv3d, p0_world)
p1 = location_3d_to_region_2d(region, rv3d, p1_world)
if not p0 or not p1:
return min_pixels
dx = float(p1[0]) - float(p0[0])
dy = float(p1[1]) - float(p0[1])
return max(min_pixels, (dx * dx + dy * dy) ** 0.5)
# ---------------------------------------------------------------------------
# Gizmo classes
# ---------------------------------------------------------------------------
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Near-invisible face-quad click target with drag-to-resize modal.
Geometry: a unit quad in the local XY plane at z=0. The adapter
group's layout helper rotates and scales it onto the face plane;
the quad is welded to the world face (``use_draw_scale = False``).
"""
bl_idname = "BIM_GT_box_face_quad"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
__slots__ = (
"custom_shape",
"custom_shape_select",
"init_value",
"move_get_cb",
"move_set_cb",
"axis",
"start_location",
"depth_point",
"callback",
"ctrl_click_cb",
"_group",
"_face_axis",
"is_max",
"_drag_snapshot",
"_last_geometry_state",
"_strips_cache_key",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
# Back-facing quads bind ``custom_shape_select`` to the halo-strip
# TRIS so clicks OUTSIDE the box silhouette catch the back face.
# Front-facing quads leave it None and reuse ``custom_shape``.
shape = getattr(self, "custom_shape_select", None) or self.custom_shape
self.draw_custom_shape(shape, select_id=select_id)
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS)
self.custom_shape_select = None
# Quad welded to world geometry — clicks must align with the
# visible face, not a screen-size widget. Disables Blender's
# per-frame pixel-constant autoscale.
self.use_draw_scale = False
# ---- modal -------------------------------------------------------------
def invoke(self, context: Any, event: Any) -> set[str]:
# CTRL+click handoff: dispatch a host-defined callback (e.g.
# align-view) instead of starting a drag.
if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None:
self.ctrl_click_cb(context, event)
return {"FINISHED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
self.init_value = self.move_get_cb()
# Freeze the projection plane at invoke — projection-plane
# drift on tilted axes causes exponential delta runaway.
self.depth_point = self.matrix_basis.translation.copy()
self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
if getattr(self, "_group", None) is not None:
self._group._lock_for(self)
return {"RUNNING_MODAL"}
def exit(self, context: Any, cancel: bool) -> None:
try:
if context.area:
context.area.header_text_set(None)
if cancel:
self.move_set_cb(self.init_value)
if hasattr(self, "callback"):
self.callback(self.move_get_cb())
finally:
self._drag_snapshot = None
if getattr(self, "_group", None) is not None:
self._group._unlock_all()
def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]:
if event.type == "ESC":
return {"CANCELLED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
delta = (end_location - self.start_location).dot(self.axis)
if "SNAP" in tweak:
delta = round(delta, 1)
if "PRECISE" in tweak:
delta /= 10.0
self.move_set_cb(self.init_value + delta)
if context.area:
context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})")
return {"RUNNING_MODAL"}
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Thin non-interactive colored edge outline for one face.
Drawn as 4 line segments in the face plane. The layout helper
toggles its ``alpha`` between near-zero and ``1.0`` based on the
sibling face-quad's ``is_highlight`` state — so hovering the quad
lights up the matching outline. ``hide_select = True`` keeps the
outline out of the GPU selection buffer.
"""
bl_idname = "BIM_GT_box_face_outline"
bl_target_properties = ()
__slots__ = (
"custom_shape",
"_face_axis",
"is_max",
"_last_outline_state",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
return None
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES)
self.use_draw_scale = False
self.hide_select = True
self._last_outline_state = "unit"
# ---------------------------------------------------------------------------
# Per-redraw orchestrator
# ---------------------------------------------------------------------------
def apply_face_quad_layout(
*,
quad_gizmos,
outline_gizmos,
bmin: Any,
bmax: Any,
matrix_world: Any,
cage_rotation: Any,
region: Any,
rv3d: Any,
locked: bool,
) -> None:
"""Lay out 6 face quads + 6 outlines on the box for this redraw.
``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in
:data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners
in the host's local frame; ``matrix_world`` is the host's world
matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use
``Matrix.Identity(4)`` when rotation rides in ``matrix_world``).
``region`` / ``rv3d`` drive the view-dependent front/back split and
the screen-constant halo margin; passing ``rv3d = None`` bails.
Negative scale on the host is normalized to positive internally so
the visible cube and the clickable face gizmos stay aligned —
callers don't need to pre-process ``matrix_world``.
When ``locked`` (a drag is active), ``hide`` / ``select_bias``
writes are skipped — the active quad's geometry is still refreshed
so it tracks the moving box.
"""
if rv3d is None or getattr(rv3d, "view_rotation", None) is None:
return
if len(quad_gizmos) != 6 or len(outline_gizmos) != 6:
return
mw = _abs_scale_matrix(matrix_world)
mw_rot = mw.to_quaternion().to_matrix()
mw_rot_scale = mw.to_3x3()
cage_rotation_3x3 = cage_rotation.to_3x3()
pivot_local = (bmin + bmax) * 0.5
box_center_local = pivot_local
face_midpoints_local = {
(0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)),
(0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)),
(1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)),
(1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)),
(2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))),
(2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))),
}
view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized()
view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z))
face_normals_world = []
for route_axis, route_is_max in FACE_ROUTES:
axis_local = Vector(face_outward_axis_local(route_axis, route_is_max))
n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized()
face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z)))
front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple)
box_center_world = mw @ pivot_local
corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw)
route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)}
for i, route in enumerate(FACE_ROUTES):
quad_gz = quad_gizmos[i]
is_front = front[i]
axis_b, is_max_b = route
# Place the colored OUTLINE on every face using the same composed
# face matrix the front-facing solid quad uses. Hidden/shown via
# alpha at the end of the pass.
outline_orient = _AXIS_ORIENT[route]
outline_face_world, _outline_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
outline_orient,
)
ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
outline_gizmos[i].matrix_basis = _compose_face_matrix_basis(
outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh
)
if is_front:
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS
orient = _AXIS_ORIENT[route]
face_world, world_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
orient,
)
w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h)
quad_gz.axis = world_axis
if getattr(quad_gz, "_last_geometry_state", None) != "solid":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "solid"
continue
# Back-facing: anchor at the back face centre; build halo strips
# in the planes of the adjacent FRONT faces, extruded outside
# the silhouette toward this face's outward normal.
face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local)
quad_gz.matrix_basis = Matrix.Translation(face_world)
quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized()
adjacent_front_routes = [
(axis_a, is_max_a)
for axis_a in range(3)
if axis_a != axis_b
for is_max_a in (False, True)
if front[route_to_index[(axis_a, is_max_a)]]
]
# Per-face world margin: measure the screen-projected length of
# ONE world unit along THIS face's outward normal. The world
# margin that yields ~N pixels on screen is then ``N / length``.
# Foreshortening on oblique faces shortens the projected step,
# so the world step must grow to keep the strip the same width
# on screen.
face_world_margin = 0.0
if region is not None:
sample_end = box_center_world + quad_gz.axis * 1.0
screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0)
if screen_step > 0.0:
face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step
if face_world_margin <= 0.0 or not adjacent_front_routes:
if not locked:
quad_gz.hide = True
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
if getattr(quad_gz, "_last_geometry_state", None) != "empty":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "empty"
continue
extrusion_world = quad_gz.axis * face_world_margin
extrusion_local = (
float(extrusion_world.x),
float(extrusion_world.y),
float(extrusion_world.z),
)
all_tris: list[tuple[float, float, float]] = []
for axis_a, is_max_a in adjacent_front_routes:
edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b)
if edge_keys is None:
continue
key0, key1 = edge_keys
wp0 = corners_world[key0]
wp1 = corners_world[key1]
local_p0 = (
float(wp0.x - face_world.x),
float(wp0.y - face_world.y),
float(wp0.z - face_world.z),
)
local_p1 = (
float(wp1.x - face_world.x),
float(wp1.y - face_world.y),
float(wp1.z - face_world.z),
)
all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local))
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
corner_keys = _face_corner_keys(axis_b, is_max_b)
wc_local = [
(
float(corners_world[k].x - face_world.x),
float(corners_world[k].y - face_world.y),
float(corners_world[k].z - face_world.z),
)
for k in corner_keys
]
face_quad_local = [
wc_local[0],
wc_local[1],
wc_local[2],
wc_local[0],
wc_local[2],
wc_local[3],
]
if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)):
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local)
quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris)
quad_gz._last_geometry_state = "strips"
# Outline alpha follows ONLY the hovered quad's own state — light
# the outline of the face under the cursor, nothing else.
if not locked:
for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True):
lit = bool(getattr(quad_gz, "is_highlight", False))
outline_gz.alpha = 1.0 if lit else 0.0
outline_gz.alpha_highlight = 1.0 if lit else 0.0
__all__ = [
"AXIS_COLOR",
"FACE_QUAD_ALPHA",
"FACE_QUAD_ALPHA_HIGHLIGHT",
"FACE_QUAD_SELECT_BIAS",
"FACE_ROUTES",
"BIM_GT_box_face_outline",
"BIM_GT_box_face_quad",
"apply_face_quad_layout",
"compute_face_resize",
"face_outward_axis_local",
"front_facing_face_mask",
"view_axis_parallel_face_mask",
]
@@ -0,0 +1,312 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Interactive face-quad resize gizmos for the active clip box.
Adapter group that binds the generic :mod:`face_quad` core to a Bonsai
clip-box Empty: six near-invisible click quads + six edge outlines on
the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged
face moves along its outward world normal while the opposite face stays
put — by writing the empty's ``location`` and ``scale``. Bonsai's
depsgraph handler then re-arms the clip planes from the new matrix.
"""
from __future__ import annotations
import contextlib
from typing import Any
import bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from . import face_quad
# Local-frame bounds of the empty's CUBE display. The display spans
# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets
# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is
# the unit cube. The empty's per-axis scale + rotation + translation
# ride in ``matrix_world``, which the layout helper applies.
_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0))
_LOCAL_BMAX = Vector((1.0, 1.0, 1.0))
def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector:
"""Outward world-space unit normal of the ``(axis, is_max)`` face.
Uses the rotation-only matrix so a negative-scale empty doesn't
flip the resulting direction — the visible "+X face" then stays
associated with world +X (transformed through rotation).
"""
rot_mat = empty.matrix_world.to_quaternion().to_matrix()
n = Vector(rot_mat.col[axis])
if n.length <= 0.0:
return Vector((0.0, 0.0, 0.0))
n.normalize()
return n if is_max else -n
def _world_half_extent(empty: bpy.types.Object, axis: int) -> float:
"""The empty's box half-extent along local ``axis`` in WORLD units.
A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world``
stretches it by the column length on ``axis``. So the world
half-extent is ``|column[axis]| * empty_display_size``.
"""
col_len = empty.matrix_world.to_3x3().col[axis].length
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
return float(col_len) * display_size
def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure returning the world half-extent at drag start and
snapshotting the empty's full transform on the gizmo instance.
The snapshot lives on the gizmo (not the group) so a PERSISTENT
group servicing multiple clip boxes can't bleed one drag's state
onto another. Cleared on ``exit`` by the shared face-quad hook.
"""
def getter() -> float:
empty = group._empty
if empty is None:
return 0.0
existing = getattr(gz, "_drag_snapshot", None)
if existing is not None and existing.get("empty_name") == getattr(empty, "name", None):
return float(existing["world_half"])
world_half = _world_half_extent(empty, axis)
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
gz._drag_snapshot = {
"empty_name": getattr(empty, "name", None),
"world_half": world_half,
"location": tuple(float(v) for v in empty.location),
"scale": tuple(float(v) for v in empty.scale),
"display_size": display_size if display_size != 0.0 else 1.0,
"world_axis": tuple(_world_axis(empty, axis, is_max)),
}
return float(world_half)
return getter
def _make_ctrl_click_cb(axis: int, is_max: bool):
"""Closure that dispatches CTRL+click on a face to the align-view operator.
Routing through an operator (rather than mutating ``rv3d`` here)
keeps the action F3-searchable and undoable.
"""
def _callback(_context: Any, _event: Any) -> None:
bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max)
return _callback
def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure that applies a one-sided face resize by writing the
empty's ``location`` + ``scale``.
The modal calls this with ``value = init + delta`` where ``delta``
is the cursor's projection onto the face's OUTWARD world normal.
Both reads come from ``gz._drag_snapshot`` so every frame is
relative to drag start, never compounding.
"""
del is_max # snapshot's world_axis carries the direction
def setter(value: float) -> None:
empty = group._empty
if empty is None:
return
snap = getattr(gz, "_drag_snapshot", None)
if snap is None or snap.get("empty_name") != getattr(empty, "name", None):
return
new_scale_axis, new_location = face_quad.compute_face_resize(
value=value,
init_world_half=snap["world_half"],
init_location=snap["location"],
world_axis=snap["world_axis"],
display_size=snap["display_size"],
)
new_scale = list(snap["scale"])
# Preserve the sign of the original scale so a user-flipped empty
# stays flipped after the resize — compute_face_resize returns a
# positive magnitude, the sign is the user's intent to keep.
sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0
new_scale[axis] = sign * new_scale_axis
empty.scale = new_scale
empty.location = Vector(new_location)
return setter
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
"""Face-quad resize handles on the active clip box.
Renders six near-invisible click-target quads and six colored edge
outlines on the active clip-box empty whenever clipping is enabled.
Click-and-drag a face to resize one-sided; the opposite face stays
put. CTRL+click and plain click fall through to selection.
"""
bl_idname = "OBJECT_GGT_bim_clip_box"
bl_label = "Bonsai Clip Box Faces"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
@classmethod
def poll(cls, context: Any) -> bool:
scene = getattr(context, "scene", None)
if scene is None:
return False
scene_props = tool.ClipBox.get_scene_props(scene)
if not scene_props.enabled or not scene_props.enable_gizmos:
return False
active_clip_box = tool.ClipBox.get_active_clip_box(scene)
if active_clip_box is None:
return False
# Only render when the user has the active clip box itself
# selected — otherwise the face handles would intercept clicks
# meant for the geometry behind them.
return getattr(context, "active_object", None) is active_clip_box
@classmethod
def setup_keymap(cls, keyconfig):
# Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to
# selection — the user can still click through a near-invisible
# face quad to pick a mesh behind it.
km = keyconfig.keymaps.new(
name=cls.bl_idname,
space_type=cls.bl_space_type,
region_type=cls.bl_region_type,
)
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG")
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True)
return km
def setup(self, context: Any) -> None:
# ``_empty`` is resolved each refresh so the PERSISTENT group
# follows whichever clip box is active in the scene PG.
self._empty: bpy.types.Object | None = None
self._locked = False
self._face_routes: list[tuple[int, bool]] = []
for axis, is_max in face_quad.FACE_ROUTES:
gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname)
gz._group = self
gz._face_axis = axis
gz.is_max = is_max
gz._drag_snapshot = None
gz._last_geometry_state = "solid"
gz._strips_cache_key = None
gz.color = face_quad.AXIS_COLOR[axis]
gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis])
gz.alpha = face_quad.FACE_QUAD_ALPHA
gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT
gz.use_draw_modal = True
gz.scale_basis = 1.0
gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS
gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max)
gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max)
# CTRL+click on a face aligns the viewport to look at it.
gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max)
self._face_routes.append((axis, is_max))
# Outlines added last so they composite on top of the quad
# fills (Blender draws gizmos in creation order).
for axis, is_max in face_quad.FACE_ROUTES:
ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname)
ol._face_axis = axis
ol.is_max = is_max
ol.color = face_quad.AXIS_COLOR[axis]
ol.color_highlight = face_quad.AXIS_COLOR[axis]
ol.alpha = 0.0
ol.alpha_highlight = 0.0
ol.line_width = 2.5
def _quad_gizmos(self):
return self.gizmos[: len(self._face_routes)]
def _outline_gizmos(self):
n = len(self._face_routes)
return self.gizmos[n : 2 * n]
def refresh(self, context: Any) -> None:
"""State-change path: resolve the active empty, then run the
shared face-quad layout so the quads aren't stale for a frame
after a selection or active-index change."""
empty = tool.ClipBox.get_active_clip_box(context.scene)
self._empty = empty
if empty is None:
for gz in self.gizmos:
gz.hide = True
return
self._layout(context, empty)
def draw_prepare(self, context: Any) -> None:
"""Per-redraw — fires on orbit — re-run the layout so the
front/back split, halo strips, and outline highlights track
the camera and any live G/R/S on the empty."""
empty = self._empty
if empty is None:
return
self._layout(context, empty)
def _layout(self, context: Any, empty: bpy.types.Object) -> None:
face_quad.apply_face_quad_layout(
quad_gizmos=self._quad_gizmos(),
outline_gizmos=self._outline_gizmos(),
bmin=_LOCAL_BMIN,
bmax=_LOCAL_BMAX,
matrix_world=empty.matrix_world,
# The empty's rotation rides in matrix_world, so the
# box-local OBB rotation is identity.
cage_rotation=Matrix.Identity(4),
region=getattr(context, "region", None),
rv3d=getattr(context, "region_data", None),
locked=self._locked,
)
# ---- mutual exclusion (lock siblings during a drag) ------------------
def _lock_for(self, active_gizmo) -> None:
self._locked = True
for gz in self.gizmos:
if gz is not active_gizmo:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = True
def _unlock_all(self) -> None:
self._locked = False
for gz in self.gizmos:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = False
# Rebuild caps synchronously so the cross-section overlay
# re-forms the instant the user releases the handle, rather
# than waiting for the depsgraph's debounced rebuild path.
with contextlib.suppress(RuntimeError, ReferenceError):
tool.ClipBox.rebuild_caps_now()
# Push an undo step so the user can revert a face drag with Ctrl+Z.
with contextlib.suppress(RuntimeError):
bpy.ops.ed.undo_push(message="Resize Clip Box")
+168 -44
View File
@@ -18,14 +18,132 @@
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from . import data
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
# PEP 563 stringifies the operator's EnumProperty class annotations, which
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
# — including the enum-search helper that draws the search-button icon.
CLIP_BOX_NAME = "ClipBox"
CLIP_BOX_COLLECTION = "BBIM_ClipBoxes"
# Display labels for the source-based picker, used for the menu entries and
# the dialog title. The dict keys are the canonical source-kind identifiers.
SOURCE_KIND_LABELS: dict[str, str] = {
"SPATIAL": "Spatial Element",
"CLASS": "Class",
"TYPE": "Type",
"MATERIAL": "Material",
"PROFILE": "Profile",
"DRAWING": "Drawing",
"STATUS": "Status",
"SYSTEM": "System",
"GROUP": "Group",
"ZONE": "Zone",
}
_SOURCE_ID_DISPATCH = {
"SPATIAL": data.spatial_items,
"CLASS": data.class_items,
"TYPE": data.type_items,
"MATERIAL": data.material_items,
"PROFILE": data.profile_items,
"DRAWING": data.drawing_items,
"STATUS": data.status_items,
"SYSTEM": data.system_items,
"GROUP": data.group_items,
"ZONE": data.zone_items,
}
def _source_id_items(self, context):
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
if fn is None:
return [(data.NO_OPTIONS_ID, "No options", "")]
return fn(self, context)
def _source_display_name(kind, source_id):
"""Human-readable name of the picked source, used in the clip-box name."""
if kind == "STATUS":
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
if kind == "CLASS":
# source_id IS the human-readable IFC class name.
return source_id
ifc = tool.Ifc.get()
if ifc is None:
return source_id
try:
entity = ifc.by_id(int(source_id))
except (TypeError, ValueError, RuntimeError):
return source_id
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
bl_idname = "bim.align_view_to_clip_face"
bl_label = "Align View to Clip Box Face"
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
bl_options = {"REGISTER"}
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
def execute(self, context):
rv3d = getattr(context, "region_data", None)
if rv3d is None:
return {"CANCELLED"}
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
if clip_box is None:
return {"CANCELLED"}
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
outward_local = Vector((0.0, 0.0, 0.0))
outward_local[self.axis] = 1.0 if self.is_max else -1.0
outward = (rot_mat @ outward_local).normalized()
if outward.length == 0.0:
return {"CANCELLED"}
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
return {"FINISHED"}
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
"""Box-local up direction for a face, following Blender numpad conventions.
Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z
normal) → local +Y is up; bottom face (local -Z normal) → local -Y is
up. The caller rotates this through the empty's matrix so the
resulting world up axis tracks the box's orientation.
"""
if axis == 2:
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
return Vector((0.0, 0.0, 1.0))
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
"""Build a camera ``view_rotation`` that looks along ``forward`` with
``up_hint`` projected to the camera's local +Y."""
back = -forward.normalized()
right = up_hint.cross(back)
if right.length < 1e-6:
right = Vector((1.0, 0.0, 0.0))
right.normalize()
up = back.cross(right).normalized()
return Matrix(
(
(right.x, up.x, back.x),
(right.y, up.y, back.y),
(right.z, up.z, back.z),
)
).to_quaternion()
class BIM_OT_add_clip_box(bpy.types.Operator):
@@ -38,39 +156,59 @@ class BIM_OT_add_clip_box(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
obj = bpy.data.objects.new(CLIP_BOX_NAME, None)
obj.empty_display_type = "CUBE"
obj.empty_display_size = 1.0
obj.location = context.scene.cursor.location.copy()
# Default to a 20m cube (scale 10 around [-1, +1] local cube) so
# the volume covers a typical building storey or two rather than
# the meaningless 2m unit cube. The user resizes with S.
obj.scale = (10.0, 10.0, 10.0)
obj.show_in_front = True
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0))
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
return {"FINISHED"}
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
collection.objects.link(obj)
obj_props = tool.ClipBox.get_object_props(obj)
obj_props.is_clip_box = True
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
bl_idname = "bim.add_clip_box_for_source"
bl_label = "Add Clip Box From Source"
bl_description = (
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
"profile, drawing camera frustum, element status, system, group, or zone"
)
bl_options = {"REGISTER", "UNDO"}
entry = scene_props.clip_boxes.add()
entry.obj = obj
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
source_kind: bpy.props.EnumProperty(
name="Source Kind",
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
default="SPATIAL",
options={"SKIP_SAVE"},
)
source_id: bpy.props.EnumProperty(
name="Source",
items=_source_id_items,
options={"SKIP_SAVE"},
)
# Adding a new clip box arms clipping so the user sees the cut
# immediately. Without this they'd have to find the panel
# toggle to discover the feature actually works.
scene_props.enabled = True
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
tool.Blender.set_active_object(obj)
tool.ClipBox.refresh(context.scene)
# Persist to the project pset so the box round-trips through IFC
# save/load. A project-level pset avoids the IfcRoot scale lock /
# strip that a per-entity placement would trigger on export.
tool.ClipBox.save_to_project_pset(context.scene)
def draw(self, context):
layout = self.layout
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
# Search button appears once the enum exceeds the helper's threshold,
# giving the user a popup picker instead of a plain dropdown.
prop_with_search(layout, self, "source_id", text=label)
def execute(self, context):
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
self.report({"ERROR"}, "No source selected.")
return {"CANCELLED"}
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
if matrix is None:
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
self.report(
{"ERROR"},
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
)
return {"CANCELLED"}
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
return {"FINISHED"}
@@ -148,23 +286,9 @@ class BIM_OT_duplicate_clip_box(bpy.types.Operator):
if source is None:
return {"CANCELLED"}
copy = bpy.data.objects.new(source.name, None)
copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
# Preserve the source's display attrs so the duplicate matches.
copy.empty_display_type = source.empty_display_type
copy.empty_display_size = source.empty_display_size
copy.show_in_front = source.show_in_front
copy.matrix_world = source.matrix_world.copy()
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
collection.objects.link(copy)
tool.ClipBox.get_object_props(copy).is_clip_box = True
entry = scene_props.clip_boxes.add()
entry.obj = copy
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
scene_props.enabled = True
tool.Blender.set_active_object(copy)
tool.ClipBox.refresh(context.scene)
tool.ClipBox.save_to_project_pset(context.scene)
return {"FINISHED"}
@@ -53,16 +53,33 @@ class BIMClipBoxProperties(PropertyGroup):
def update_active_clip_box_index(self, context):
tool.ClipBox.schedule_refresh()
tool.ClipBox.select_active_clip_box(context)
# Rebuild caps for the new active box's clip volume.
tool.ClipBox.invalidate_cap_cache(immediate=True)
def update_show_caps(self, context):
tool.ClipBox.schedule_refresh()
# Off → on must trigger a rebuild so caps reappear immediately rather
# than wait for the next depsgraph tick. The rebuild is a no-op when
# show_caps is now False (it clears and returns), so this is safe in
# both directions.
tool.ClipBox.invalidate_cap_cache()
def update_enabled(self, context):
tool.ClipBox.schedule_refresh()
def update_clip_only_ifc_products(self, context):
# The eligibility set for capping changed — drop the cache and let the
# debounced rebuild pick up the new objects on the next idle tick.
tool.ClipBox.invalidate_cap_cache()
def update_include_linked_ifc(self, context):
tool.ClipBox.invalidate_cap_cache()
class BIMSceneClipBoxProperties(PropertyGroup):
"""Scene-level registry of clip boxes in this file.
@@ -100,8 +117,49 @@ class BIMSceneClipBoxProperties(PropertyGroup):
"very heavy scenes"
),
)
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
# NOT written to the project pset so the IFC stays portable across users
# who may have different Blender-side reference geometry to clip.
clip_only_ifc_products: bpy.props.BoolProperty(
name="Only IFC Products",
default=True,
update=update_clip_only_ifc_products,
description=(
"When enabled, only IFC element geometry gets cross-section caps. "
"Disable to also cap Blender-side reference meshes (sketches, "
"imported obj, primitive cubes, …)"
),
)
# Opt-in inclusion of geometry sitting inside loaded Project Links
# collection-instance empties. Off by default — linked IFCs commonly
# carry the entire site / structural / MEP context, and bisecting
# them on every clip-box edit can be expensive.
include_linked_ifc: bpy.props.BoolProperty(
name="Include Linked IFC",
default=False,
update=update_include_linked_ifc,
description=(
"Also generate cross-section caps for geometry inside linked "
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
"carry the entire site / structural backbone, and capping them "
"adds per-mesh bisect cost on every clip-box edit"
),
)
# Also Scene-only — gizmo visibility is a per-user editing preference,
# not a portable IFC property.
enable_gizmos: bpy.props.BoolProperty(
name="Show Face Handles",
default=True,
description=(
"Show interactive face-resize handles on the active clip box. "
"Disable to fall back to plain G/R/S transforms on the empty"
),
)
if TYPE_CHECKING:
active_clip_box_index: int
enabled: bool
show_caps: bool
clip_only_ifc_products: bool
include_linked_ifc: bool
enable_gizmos: bool
+64 -5
View File
@@ -20,18 +20,73 @@
from __future__ import annotations
from bpy.types import Panel, UIList
from bpy.types import Menu, Panel, UIList
import bonsai.tool as tool
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
# icon set; semantically close to the kind so users can scan the menu visually.
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
("CLASS", "Clip by Class", "BLANK1"),
("TYPE", "Clip Type", "FILE_3D"),
("MATERIAL", "Clip Material", "MATERIAL"),
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
("STATUS", "Clip by Status", "INFO"),
("SYSTEM", "Clip by System", "MOD_FLUID"),
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
("ZONE", "Clip by Zone", "MOD_LATTICE"),
)
class BIM_MT_clip_box_add_for_source(Menu):
bl_idname = "BIM_MT_clip_box_add_for_source"
bl_label = "Add Clip Box From Source"
def draw(self, context):
layout = self.layout
for kind, label, icon in _SOURCE_MENU_ENTRIES:
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
op.source_kind = kind
class BIM_MT_clip_box_settings(Menu):
bl_idname = "BIM_MT_clip_box_settings"
bl_label = "Clip Box Settings"
def draw(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
self.layout.prop(scene_props, "clip_only_ifc_products")
self.layout.prop(scene_props, "include_linked_ifc")
self.layout.prop(scene_props, "enable_gizmos")
class BIM_MT_clip_box_info(Menu):
bl_idname = "BIM_MT_clip_box_info"
bl_label = "Clip Box Face Handles"
def draw(self, context):
layout = self.layout
layout.label(text="Face Handles", icon="INFO")
layout.separator()
layout.label(text="Drag a face to resize the clip box on that axis.")
layout.label(text="The opposite face stays fixed (one-sided resize).")
layout.label(text="Ctrl+Click a face to align the viewport to it.")
layout.separator()
layout.label(text="Toggle handles from the Settings (gear) menu.")
class BIM_UL_clip_box(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
obj = item.obj
if obj is None:
layout.label(text="(missing)", icon="ERROR")
return
row = layout.row(align=True)
if obj is None:
# Host empty was deleted from outliner; still expose the
# remove button so the orphan entry isn't permanent.
row.label(text="(missing)", icon="ERROR")
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
return
row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
@@ -60,9 +115,13 @@ class BIM_PT_clip_box(Panel):
toggle=True,
)
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
layout.separator()
layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
row = layout.row(align=True)
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
layout.template_list(
"BIM_UL_clip_box",
@@ -110,6 +110,7 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoPairDisconnect,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
@@ -279,9 +280,7 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
+31 -51
View File
@@ -2069,46 +2069,6 @@ class BoundingBoxDecorator:
co2.y -= y_overlap / 2 + min_spacing
def _fill_quads_alpha(
context: bpy.types.Context,
quads: list[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
color_rgb: tuple[float, float, float],
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
indices.append((base, base + 1, base + 2))
indices.append((base, base + 2, base + 3))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments the world location where a connecting fitting (bend /
@@ -2609,14 +2569,20 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
CONNECTION_EPS_SQ = 1e-4 * 1e-4
def __init__(self) -> None:
# Two-tier cache. Walk cache keyed on (start_guid, ifc_file): re-walk
# only on selection change or file reload. Compare ``ifc_file`` with
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
# integer ids rather than ``entity_instance`` references — re-resolved
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
# the dangling-SWIG-handle class of bug: an entity removed between
# frames either bumps geom_gen (cache miss → re-walk) or fails to
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
self._cached_start_guid: str | None = None
self._cached_ifc_file: Any = None
self._cached_walk: list[Any] = []
# Geometry cache: shared TokenCache so resolved world-space lines +
# dots re-build on every depsgraph / undo / redo / load.
self._cached_geom_gen: int = -1
self._cached_walk_ids: list[int] = []
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
# mutations that don't surface via the depsgraph still flush the
# resolved world-space lines and dots.
self._geom_cache: TokenCache[
tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
@@ -2779,9 +2745,22 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
start_guid = start_element.GlobalId
if start_guid == self._failed_seed_guid:
return
if start_guid == self._cached_start_guid and ifc_file is self._cached_ifc_file and self._cached_walk:
connected = self._cached_walk
else:
current_geom_gen = tool.Parametric.get_geom_generation()
connected: list[Any] | None = None
if (
start_guid == self._cached_start_guid
and ifc_file is self._cached_ifc_file
and current_geom_gen == self._cached_geom_gen
and self._cached_walk_ids
):
try:
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
except RuntimeError:
# An entity was removed without bumping geom_gen — rare but
# possible from non-operator code paths. Force a re-walk
# rather than feeding a stale handle to _build_geometry.
connected = None
if connected is None:
try:
connected = self._walk(start_element)
except Exception:
@@ -2790,12 +2769,13 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
traceback.print_exc()
self._walk_failure_logged = True
self._cached_walk = []
self._cached_walk_ids = []
self._failed_seed_guid = start_guid
return
self._cached_start_guid = start_guid
self._cached_ifc_file = ifc_file
self._cached_walk = connected
self._cached_geom_gen = current_geom_gen
self._cached_walk_ids = [e.id() for e in connected]
if not connected:
return
@@ -2810,7 +2790,7 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
try:
lines, free_points, connection_points = self._geom_cache.get_or_compute(
(start_guid, id(ifc_file)),
(start_guid, id(ifc_file), current_geom_gen),
lambda: self._build_geometry(connected),
)
except Exception:
+81 -128
View File
@@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
return tool.System.get_port_relating_element(connected_port)
def find_fitting_between_segments(segment_a, segment_b):
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
``None`` if no fitting (or multiple fittings only direct one-fitting
joins handled)."""
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
return None
b_ports_set = set(tool.System.get_ports(segment_b))
for a_port in tool.System.get_ports(segment_a):
connected_port = tool.System.get_connected_port(a_port)
if connected_port is None:
continue
fitting = tool.System.get_port_relating_element(connected_port)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
continue
for fitting_port in tool.System.get_ports(fitting):
other_port = tool.System.get_connected_port(fitting_port)
if other_port is not None and other_port in b_ports_set:
return fitting
return None
def _resolve_active_mep_segment(operator, context):
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
@@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
Used when the connection at the port is in the JOINED state (the fitting
has at least one other port connecting to a different element). The
segment isn't resized — only the bridging fitting is removed. Refuses
to act on an OBSTRUCTION fitting (those are routed through
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
to absorb the freed length)."""
bl_idname = "bim.mep_unjoin_at_port"
bl_label = "Unjoin MEP Segment at Port"
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
bl_options = {"REGISTER", "UNDO"}
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
position: bpy.props.EnumProperty(
name="Port",
items=[
("START", "At Start", "Operate on the segment's start port"),
("END", "At End", "Operate on the segment's end port"),
],
default="END",
)
def _execute(self, context):
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
"""Remove the terminal fitting at a segment's named port.
@@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting joining two selected MEP segments.
Removes the fitting; segments are left in place for the user to reposition."""
bl_idname = "bim.mep_unjoin_pair"
bl_label = "Unjoin MEP Segments"
bl_description = "Delete the fitting joining the two selected MEP segments"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
return False
return True
def _execute(self, context):
selected_objs = tool.Blender.get_selected_objects()
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
return {"CANCELLED"}
fitting = find_fitting_between_segments(elements[0], elements[1])
if fitting is None:
self.report({"ERROR"}, "No single fitting joins the selected segments.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class SelectMEPPathMembers(bpy.types.Operator):
"""Replace the selection with every MEP element reachable from the active
one via IfcRelConnectsPorts the entire connected distribution network."""
@@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
"""True iff the active object is a parametric BEND fitting eligible for
the bend-preview re-edit path. Re-edit reads parameters from the type's
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
re-editability not the body representation class. The bend creation
path tessellates the swept-disk body as an upstream-geometry-kernel
workaround, so a freshly-committed bend's body contains only an
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
returns False for it; the pset gate is what keeps the pen icon
eligible."""
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
return tool.System.has_parametric_body(element)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
),
IconActionConfig(
name="unjoin_start",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_end",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_pair",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_pair",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda _active: _n_mep_selected(2),
),
]
@@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return False
return tool.System.has_parametric_body(element)
if tool.System.has_parametric_body(element):
return True
# Bend fittings carry their parametric definition in the type's
# ``BBIM_Fitting`` pset because the bend creation path tessellates
# the swept-disk body (upstream geometry-kernel workaround), so
# ``has_parametric_body`` returns False for them. Fall back to the
# pset gate so the pen icon (re_edit_bend) stays reachable.
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
@@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@classmethod
def _wire_anchored_icon_targets(cls, group) -> None:
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
icon so a click dispatches to the right port without a per-frame
property write; apply the warning-red hover colour to destructive
icons. Takes any object with ``action_<name>_gizmo`` attributes so
tests can exercise the wiring without instantiating the GizmoGroup."""
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
icons so a click dispatches to the right port without a per-frame
property write, and pre-bind the unified ``bim.disconnect_elements``
operator on each unjoin icon so :py:meth:`position_gizmos` only has
to update the two GUIDs per frame. Takes any object with
``action_<name>_gizmo`` attributes so tests can exercise the wiring
without instantiating the GizmoGroup."""
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
@@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
op_props.position = position_arg
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
op_props.position = position_arg
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
group.unjoin_op_props = {}
for config_name in cls.UNJOIN_CONFIGS:
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
gz.color_highlight = warning_color
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
"""Lay out icons across three regions: row above bbox top, segment
@@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if not visible:
gz.hide = True
continue
if config.name.startswith("unjoin_"):
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
gz.hide = True
continue
if segment_endpoints is None:
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
start_world, end_world = segment_endpoints
@@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if len(selected) == 2:
elements = [tool.Ifc.get_entity(o) for o in selected]
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
else:
pair_fitting = False
else:
@@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.hide = True
continue
if config.name == "unjoin_pair":
selected = tool.Blender.get_selected_objects()
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
if not self._bind_unjoin_pair(pair_elements):
gz.hide = True
continue
if not bend_anchor_attempted:
bend_anchor = compute_mep_join_location()
bend_anchor_attempted = True
@@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if name in self.ENDPOINT_CONFIGS:
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
return self.ICON_SCALE
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
"""Resolve the fitting at the named port and bind both GUIDs on the
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
the partner is unresolvable (port not joined to a disconnectable
fitting), and the caller hides the icon."""
element = tool.Ifc.get_entity(segment_obj)
if element is None:
return False
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
return False
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
return False
op_props = self.unjoin_op_props[config_name]
op_props.element_a_guid = element.GlobalId
op_props.element_b_guid = fitting.GlobalId
return True
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
``bim.disconnect_elements`` op_props. Returns False when either side
is missing a GlobalId (e.g. selection lost an active object), and
the caller hides the icon."""
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
return False
op_props = self.unjoin_op_props["unjoin_pair"]
op_props.element_a_guid = pair_elements[0].GlobalId
op_props.element_b_guid = pair_elements[1].GlobalId
return True
+3 -4
View File
@@ -63,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
_BBOX_HIGHLIGHT_LINE_WIDTH,
PolylineDecorator,
ProductDecorator,
_fill_quads_alpha,
bbox_world_edges,
draw_polyline_segments,
)
@@ -401,13 +400,13 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I
)
return
path_objs: list[bpy.types.Object] = []
for rel, kind in rels:
for subject, kind in rels:
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
rel=rel,
subject=subject,
kind=kind,
elem=elem_a,
partner=elem_b,
@@ -4859,7 +4858,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
],
color_rgb: tuple[float, float, float],
) -> None:
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA))
@staticmethod
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
@@ -21,6 +21,7 @@ import bpy
from . import operator, prop, ui
classes = (
operator.AddIfcPatchPreset,
operator.ExecuteIfcPatch,
operator.ExtractSelectedElements,
operator.RunMigratePatch,
@@ -28,6 +29,7 @@ classes = (
operator.SelectIfcPatchOutput,
operator.UpdateIfcPatchArguments,
prop.BIMPatchProperties,
ui.BIM_MT_ifc_patch_presets,
ui.BIM_PT_patch,
)
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
import bpy
import ifcopenshell
import ifcpatch
from bl_operators.presets import AddPresetBase
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
return False
return True
def invoke(self, context, event):
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
# IfcFacetedBrep). Confirm before running so the user knows.
if tool.Patch.migration_is_lossy_downgrade():
return context.window_manager.invoke_props_dialog(self, width=480)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
column = layout.column(align=True)
column.label(text="Geometry will be preserved as faithfully as possible:")
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
column.separator()
column.label(text="The following information is lost:")
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
def execute(self, context):
props = tool.Patch.get_patch_props()
recipe_name = props.ifc_patch_recipes
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
query = tool.Search.get_query_for_selected_elements()
props.ifc_patch_args_attr[0].string_value = query
return {"FINISHED"}
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
"""Save / remove ifc-patch argument presets, scoped per recipe.
Presets live in the standard Blender preset directory under
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
does not pollute the preset list for ``Migrate``. Persistence across files
and sessions is inherited from Blender's preset system."""
bl_idname = "bim.add_ifc_patch_preset"
bl_label = "Add IFC Patch Preset"
preset_menu = "BIM_MT_ifc_patch_presets"
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
@property
def preset_subdir(self) -> str:
return tool.Patch.get_preset_subdir()
@property
def preset_values(self) -> list[str]:
# `Attribute.get_value_name()` returns the storage field for the
# argument's data_type (string_value, bool_value, …). For file
# arguments it returns the wrapping PointerProperty (`filepath_value`)
# — the scalar path the preset needs is `.single_file` on that.
props = tool.Patch.get_patch_props()
values = []
for i, arg in enumerate(props.ifc_patch_args_attr):
field = arg.get_value_name()
if not field:
continue
if arg.data_type == "file":
field = f"{field}.single_file"
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
return values
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
# Blender's script.execute_preset mutates the menu class's bl_label to
# the loaded preset's display name (used as a "currently selected"
# indicator). The label persists across recipe changes — making the new
# recipe's menu falsely show the previous recipe's preset name. Reset
# the label to the menu's canonical title so it always matches the
# active recipe's preset list.
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
if menu_cls is not None:
menu_cls.bl_label = "IFC Patch Presets"
class BIMPatchProperties(PropertyGroup):
+19
View File
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
"""Lists ifc-patch presets for the currently selected recipe.
``preset_subdir`` is resolved per draw so switching recipes swaps the
preset list without re-registering the menu."""
bl_label = "IFC Patch Presets"
preset_operator = "script.execute_preset"
def draw(self, context: bpy.types.Context) -> None:
self.preset_subdir = tool.Patch.get_preset_subdir()
bpy.types.Menu.draw_preset(self, context)
class BIM_PT_patch(bpy.types.Panel):
bl_label = "Patch"
bl_idname = "BIM_PT_patch"
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
if props.ifc_patch_args_attr:
preset_row = layout.row(heading="Preset", align=True)
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
+7
View File
@@ -1950,6 +1950,13 @@ class BIM_PT_decorators_overlay(Panel):
row = col.row(align=True)
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator")
clip_box_props = tool.ClipBox.get_scene_props(context.scene)
row = col.row(align=True)
# Grey out the toggles when there is no clip box to act on, so the
# user can see the controls but can't flip a switch that does nothing.
row.enabled = bool(clip_box_props.clip_boxes)
row.prop(clip_box_props, "enabled", text="Enable Clipping")
row.prop(clip_box_props, "show_caps", text="Show Caps")
class BIM_PT_snappping(Panel):
+32 -18
View File
@@ -22,11 +22,18 @@
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
disconnect-on-delete). Each rel kind returned by
disconnect-on-delete). Each kind returned by
:py:meth:`bonsai.tool.connection.Connection.find_rels` /
:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
rel kind means extending one dispatch table both call sites benefit
kind means extending one dispatch table both call sites benefit
automatically and the AST forward-compat guard enforces coverage.
The ``subject`` parameter is the entity whose teardown effects the
disconnect: for ``"path"`` / ``"element"`` / ``"element-top"`` kinds it
carries an ``IfcRel*`` relationship entity (the rel that gets removed);
for ``"mep-pair-fitting"`` it carries an ``IfcFlowFitting`` (the fitting
that gets deleted). The slot is uniform on intent the dispatch decides
the teardown mechanism by kind.
"""
from __future__ import annotations
@@ -37,25 +44,24 @@ import bonsai.core.geometry
from bonsai.core.model import regenerate_wall_to_underside
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
def disconnect_rel(
ifc: "type[tool.Ifc]",
geometry: "type[tool.Geometry]",
model: "type[tool.Model]",
connection: "type[tool.Connection]",
rel: "ifcopenshell.entity_instance",
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
connection: type[tool.Connection],
subject: ifcopenshell.entity_instance,
kind: str,
elem: "ifcopenshell.entity_instance",
partner: "ifcopenshell.entity_instance",
elem: ifcopenshell.entity_instance,
partner: ifcopenshell.entity_instance,
skip_elem_recreate: bool = False,
skip_partner_recreate: bool = False,
) -> None:
"""Run the post-disconnect cleanup for one rel.
"""Run the post-disconnect cleanup for one connection.
``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate``
flags suppress per-side regenerate / recreate work used by the
@@ -65,7 +71,7 @@ def disconnect_rel(
runs on both sides.
"""
if kind == "path":
bonsai.core.geometry.remove_connection(geometry, connection=rel)
bonsai.core.geometry.remove_connection(geometry, connection=subject)
if not skip_elem_recreate:
elem_obj = ifc.get_object(elem)
if elem_obj is not None:
@@ -75,11 +81,11 @@ def disconnect_rel(
if partner_obj is not None:
model.recreate_wall(partner, partner_obj)
elif kind == "element-top":
wall, _slab = connection.orient_element_top(rel, elem, partner)
wall, _slab = connection.orient_element_top(subject, elem, partner)
ifc.run(
"geometry.disconnect_element",
relating_element=rel.RelatingElement,
related_element=rel.RelatedElement,
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
# Skip the wall-side regenerate when the wall is itself being deleted —
# either it's the elem of this cascade pass, or it's the partner that
@@ -92,8 +98,16 @@ def disconnect_rel(
elif kind == "element":
ifc.run(
"geometry.disconnect_element",
relating_element=rel.RelatingElement,
related_element=rel.RelatedElement,
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
elif kind == "mep-pair-fitting":
if skip_elem_recreate and subject is elem:
return
if skip_partner_recreate and subject is partner:
return
fitting_obj = ifc.get_object(subject)
if fitting_obj is not None:
geometry.delete_ifc_object(fitting_obj)
else:
raise ValueError(f"Unknown rel kind: {kind!r}")
raise ValueError(f"Unknown kind: {kind!r}")
+79
View File
@@ -55,9 +55,11 @@ from typing import (
import bmesh
import bpy
import gpu
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
from gpu_extras.batch import batch_for_shader
from ifcopenshell import entity_instance
from mathutils import Matrix, Vector
@@ -2403,6 +2405,83 @@ class Blender(bonsai.core.tool.Blender):
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
draw_batch("TRIS", world_vert_coords, color, tris)
@classmethod
def draw_quads(
cls,
context: bpy.types.Context,
quads: Sequence[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
*,
fill_color: Optional[tuple[float, float, float, float]] = None,
outline_color: Optional[tuple[float, float, float, float]] = None,
outline_width: float = 1.0,
) -> None:
"""Render ``quads`` (each a 4-tuple of CCW world-space corners) as
a filled TRIS batch, an outline LINES batch, or both.
Both colors are RGBA 4-tuples. Pass ``fill_color=None`` to skip
the fill pass and ``outline_color=None`` to skip the outline.
Skipping both is a no-op.
Replaces the per-decorator quad-fill helpers that used to live
inline in each feature module.
"""
if not quads or (fill_color is None and outline_color is None):
return
region = getattr(context, "region", None)
if region is None:
return
verts: list[tuple[float, float, float]] = []
tri_indices: list[tuple[int, int, int]] = []
line_indices: list[tuple[int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
if fill_color is not None:
tri_indices.append((base, base + 1, base + 2))
tri_indices.append((base, base + 2, base + 3))
if outline_color is not None:
line_indices.append((base, base + 1))
line_indices.append((base + 1, base + 2))
line_indices.append((base + 2, base + 3))
line_indices.append((base + 3, base))
if not cls.validate_shader_batch_data(verts, None):
return
gpu.state.blend_set("ALPHA")
try:
if fill_color is not None and tri_indices:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", fill_color)
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=tri_indices)
batch.draw(shader)
if outline_color is not None and line_indices:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", outline_color)
# Outline width: the UNIFORM_COLOR shader respects the
# GPU's current line-width state; restore on exit.
prev_width = gpu.state.line_width_get()
gpu.state.line_width_set(outline_width)
try:
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=line_indices)
batch.draw(shader)
finally:
gpu.state.line_width_set(prev_width)
finally:
gpu.state.blend_set("NONE")
@classmethod
def build_dashed_line_segments(
cls,
+407 -21
View File
@@ -21,7 +21,7 @@
from __future__ import annotations
import contextlib
from collections.abc import Callable, Iterator
from collections.abc import Callable, Iterable, Iterator
from typing import TYPE_CHECKING, Any, Optional
import bpy
@@ -29,6 +29,8 @@ import bpy
import bonsai.tool as tool
if TYPE_CHECKING:
from mathutils import Matrix
from bonsai.bim.module.clip_box.prop import (
BIMClipBoxProperties,
BIMSceneClipBoxProperties,
@@ -38,6 +40,21 @@ if TYPE_CHECKING:
PlaneTuple = tuple[float, float, float, float]
PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple]
# Stable contract of Pset_*Common.Status values plus the "absent" entry.
# Duplicated locally rather than imported so this module has no load-order
# dependency on the sequence layer that hosts the matching query helper.
SOURCE_STATUS_VALUES: tuple[str, ...] = (
"No Status",
"NEW",
"EXISTING",
"DEMOLISH",
"TEMPORARY",
"OTHER",
"NOTKNOWN",
"UNSET",
)
# Outward margins so the empty's CUBE display edges sit safely INSIDE
# the clip volume. A fixed absolute margin fails under rotation: the
# float error in computing each column's length and in per-vertex dot
@@ -187,12 +204,38 @@ class ClipBox:
keeps the bbox aligned with the view the user is actually at.
"""
for area, region, region_3d in tool.Blender.iter_view3d_regions():
# Skip collapsed / initializing regions wholesale: arming one
# CTDs Blender (see _region_is_renderable), and recording an
# arm signature for a region we didn't actually arm would make
# the next view-change comparison spurious.
if not cls._region_is_renderable(region, region_3d):
continue
key = region.as_pointer()
cls._owned.add(key)
cls._region_by_key[key] = (area, region)
cls._arm_region(area, region, region_3d, planes)
cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix)
@classmethod
def _region_is_renderable(cls, region: Any, region_3d: Any) -> bool:
"""True iff ``region`` is safe to arm clip planes against.
A collapsed / still-initializing region (``width`` or ``height``
== 0, or no readable ``view_matrix``) has no live view-matrix
state. Calling ``region_3d.update()`` against it drives
``ED_view3d_update_viewmat -> GPU_matrix_ortho_set`` into a null
deref and HARD-CRASHES Blender (CTD, not a catchable exception)
observed when a 3D viewport is split/collapsed while the clip box
re-arms from a timer. Skipping such regions is the load-bearing
guard; they get armed on the next refresh once they have a size.
"""
try:
if int(getattr(region, "width", 0)) <= 0 or int(getattr(region, "height", 0)) <= 0:
return False
return getattr(region_3d, "view_matrix", None) is not None
except (ReferenceError, AttributeError, TypeError):
return False
@classmethod
def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
"""Initialize the region's clip machinery and write ``planes``.
@@ -201,12 +244,25 @@ class ClipBox:
without leaving the C-side ``clipbb`` degenerate (which would break
edit-mode click-select). Caller must guarantee a context in which
operators are legal (not a draw handler / depsgraph callback).
The ``_region_is_renderable`` guard is the real crash fix arming a
collapsed / initializing region drives ``region_3d.update()`` into a
native null deref inside ``GPU_matrix_ortho_set`` that NO Python
``try``/``except`` can catch (it's a CTD, not an exception). The
``suppress`` around ``update()`` is unrelated to that: it only
swallows the *catchable* ``RuntimeError`` ("context is incorrect")
/ ``ReferenceError`` (region freed mid-call) that the override path
can still surface it does NOT and CANNOT make ``update()``
crash-safe.
"""
if not cls._region_is_renderable(region, region_3d):
return
with bpy.context.temp_override(area=area, region=region):
bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
region_3d.clip_planes = planes
region_3d.use_clip_planes = True
region_3d.update()
with contextlib.suppress(RuntimeError, ReferenceError):
region_3d.update()
@classmethod
def clear_clip_planes(cls) -> None:
@@ -466,10 +522,15 @@ class ClipBox:
for area, region, region_3d in tool.Blender.iter_view3d_regions():
if not region_3d.use_clip_planes:
continue
# A collapsed / initializing region CTDs inside update() — same
# null deref as the operator arm path (see _region_is_renderable).
if not cls._region_is_renderable(region, region_3d):
continue
key = region.as_pointer()
cls._region_by_key[key] = (area, region)
region_3d.clip_planes = planes
region_3d.update()
with contextlib.suppress(RuntimeError, ReferenceError):
region_3d.update()
region.tag_redraw()
@classmethod
@@ -615,7 +676,12 @@ class ClipBox:
except (AttributeError, RuntimeError, ReferenceError):
return
region_3d.clip_planes = cls.compute_planes_from_matrix(matrix)
region_3d.update()
# PRE_VIEW runs for the region being drawn this frame (always sized
# and renderable), so the collapsed-region CTD can't occur here.
# The suppress only mops up a catchable RuntimeError / ReferenceError
# from a region freed mid-draw — same as the arm paths.
with contextlib.suppress(RuntimeError, ReferenceError):
region_3d.update()
# clip_bb captured by view3d.clip_border is view-aligned, so an
# orbit/pan/zoom leaves the picker testing against the old
# frustum even after clip_planes refresh. Re-arm so the picker
@@ -628,6 +694,219 @@ class ClipBox:
if prev_view is not None and prev_view != current_view:
cls.schedule_refresh()
@classmethod
def create_clip_box_empty(
cls,
context: bpy.types.Context,
matrix: Any,
name: str = "ClipBox",
) -> bpy.types.Object:
"""Create + register a clip-box empty whose ``matrix_world`` is ``matrix``.
Single entry point for any operator that needs to materialise a
clip box: handles the host collection, the per-object
``is_clip_box`` flag, the scene-list entry, auto-enable, viewport
re-arm, and project-pset persistence. Returns the new empty.
"""
scene_props = cls.get_scene_props(context.scene)
obj = bpy.data.objects.new(name, None)
obj.empty_display_type = "CUBE"
obj.empty_display_size = 1.0
obj.show_in_front = True
obj.matrix_world = matrix
collection = tool.Blender.get_or_create_collection(context.scene, cls.COLLECTION_NAME)
collection.objects.link(obj)
cls.get_object_props(obj).is_clip_box = True
entry = scene_props.clip_boxes.add()
entry.obj = obj
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
# Auto-enable so the user sees the cut immediately rather than
# having to find the panel toggle after the add.
scene_props.enabled = True
tool.Blender.set_active_object(obj)
cls.refresh(context.scene)
# Project-level pset rather than a per-entity placement so IfcRoot's
# scale-strip-on-export can't lose the box's dimensions.
cls.save_to_project_pset(context.scene)
return obj
# ------------------------------------------------------------------
# Source-based presets
#
# Build the host empty's ``matrix_world`` from a chosen IFC source
# (a spatial container, a type, a material, a drawing, …) so the
# user gets a clip box pre-sized to the AABB of the matched
# elements instead of having to drag a default cube into position.
# ------------------------------------------------------------------
@classmethod
def iter_elements_for_source(cls, kind: str, source_id: str) -> list[Any]:
"""Resolve the IFC products matching ``(kind, source_id)``.
``kind`` selects the IFC-graph walk; ``source_id`` is the picker
value: an IFC entity id (stringified) for the entity-driven
kinds, or one of :data:`SOURCE_STATUS_VALUES` for ``"STATUS"``.
Empty list when the IFC file is absent, ``source_id`` does not
resolve, or the walk has no matches. ``"DRAWING"`` returns the
single drawing entity so callers can introspect it; the actual
clip volume for that kind is built from the camera frustum, not
an AABB of decomposed elements.
"""
import ifcopenshell.util.element
ifc_file = tool.Ifc.get()
if ifc_file is None:
return []
if kind == "STATUS":
if source_id not in SOURCE_STATUS_VALUES:
return []
return list(tool.Sequence.get_elements_by_status(source_id))
if kind == "CLASS":
# source_id is an IFC class name (e.g. "IfcWall"). by_type with
# include_subtypes=True (default) so "IfcWall" matches
# IfcWallStandardCase etc., matching "all walls" in user terms.
try:
return list(ifc_file.by_type(source_id))
except RuntimeError:
return []
try:
entity_id = int(source_id)
except (TypeError, ValueError):
return []
try:
entity = ifc_file.by_id(entity_id)
except RuntimeError:
return []
if entity is None:
return []
if kind == "SPATIAL":
return list(ifcopenshell.util.element.get_decomposition(entity, is_recursive=True))
if kind == "TYPE":
return list(ifcopenshell.util.element.get_types(entity))
if kind == "MATERIAL":
return list(ifcopenshell.util.element.get_elements_by_material(ifc_file, entity))
if kind == "PROFILE":
return list(ifcopenshell.util.element.get_elements_by_profile(entity))
if kind in ("SYSTEM", "GROUP", "ZONE"):
return list(ifcopenshell.util.element.get_grouped_by(entity, is_recursive=True))
if kind == "DRAWING":
return [entity]
return []
@classmethod
def compute_matrix_for_source(cls, kind: str, source_id: str) -> Optional[Any]:
"""Build the empty's ``matrix_world`` for ``(kind, source_id)``.
Returns ``None`` when nothing matches the operator turns that
into an ERROR report + ``CANCELLED``.
``"DRAWING"`` returns a rotated matrix aligned to the camera and
sized to ``clip_start..clip_end`` × the drawing's in-plane
extents. All other kinds return an axis-aligned matrix sized to
the world AABB of the matched elements' Blender objects.
"""
if kind == "DRAWING":
ifc_file = tool.Ifc.get()
if ifc_file is None:
return None
try:
entity = ifc_file.by_id(int(source_id))
except (TypeError, ValueError, RuntimeError):
return None
camera_obj = tool.Ifc.get_object(entity)
if camera_obj is None or camera_obj.type != "CAMERA":
return None
return cls._camera_frustum_matrix(camera_obj)
elements = cls.iter_elements_for_source(kind, source_id)
return cls._world_bbox_matrix_for_elements(elements)
@classmethod
def _world_bbox_matrix_for_elements(cls, elements: Iterable[Any]) -> Optional[Any]:
"""World-AABB matrix of the Blender objects backing ``elements``.
``matrix_world = Translation(center) @ Diagonal(half_extents)``
so the CUBE empty's local ``[-1, +1]^3`` lands on the AABB
corners. Filters out elements without a Blender object and
elements whose object has a zero-volume bound box (typical for
empties used as containers). Returns ``None`` when nothing
survives the filter so the caller can ERROR instead of creating
a degenerate clip box.
Half-extents are floored at :data:`_CLIP_EXPAND_ABS` so a single
point or flat slab still produces an invertible matrix.
"""
from mathutils import Matrix
min_x = min_y = min_z = float("inf")
max_x = max_y = max_z = float("-inf")
found = False
for element in elements:
obj = tool.Ifc.get_object(element)
if obj is None:
continue
bbox = tool.Blender.get_object_world_bounding_box(obj)
if bbox["dimensions"] == (0.0, 0.0, 0.0):
continue
min_x = min(min_x, bbox["min_x"])
min_y = min(min_y, bbox["min_y"])
min_z = min(min_z, bbox["min_z"])
max_x = max(max_x, bbox["max_x"])
max_y = max(max_y, bbox["max_y"])
max_z = max(max_z, bbox["max_z"])
found = True
if not found:
return None
cx, cy, cz = (min_x + max_x) / 2, (min_y + max_y) / 2, (min_z + max_z) / 2
hx = max((max_x - min_x) / 2, _CLIP_EXPAND_ABS)
hy = max((max_y - min_y) / 2, _CLIP_EXPAND_ABS)
hz = max((max_z - min_z) / 2, _CLIP_EXPAND_ABS)
return Matrix.Translation((cx, cy, cz)) @ Matrix.Diagonal((hx, hy, hz, 1.0))
@classmethod
def _camera_frustum_matrix(cls, camera_obj: bpy.types.Object) -> Optional[Any]:
"""Rotated matrix matching the camera frustum ``clip_start..clip_end``.
Bonsai's drawing module parameterises a drawing camera's frustum
via ``BIMCameraProperties.width`` and ``.height`` (the printed
extents in world units). The CUBE empty inherits the camera's
rotation; depth spans ``[clip_start, clip_end]`` along the
camera's local Z (Blender cameras look down Z).
Returns ``None`` when the camera has no usable drawing extents
the operator surfaces that as an ERROR + ``CANCELLED``.
"""
from mathutils import Matrix
cam_data = camera_obj.data
cam_props = getattr(cam_data, "BIMCameraProperties", None)
if cam_props is None:
return None
width = float(getattr(cam_props, "width", 0.0) or 0.0)
height = float(getattr(cam_props, "height", 0.0) or 0.0)
if width <= 0.0 or height <= 0.0:
return None
clip_start = float(getattr(cam_data, "clip_start", 0.0))
clip_end = float(getattr(cam_data, "clip_end", 1.0))
x_half = max(width / 2.0, _CLIP_EXPAND_ABS)
y_half = max(height / 2.0, _CLIP_EXPAND_ABS)
z_half = max((clip_end - clip_start) / 2.0, _CLIP_EXPAND_ABS)
z_center = -(clip_start + clip_end) / 2.0
offset = Matrix.Translation((0.0, 0.0, z_center)) @ Matrix.Diagonal((x_half, y_half, z_half, 1.0))
return camera_obj.matrix_world @ offset
# ------------------------------------------------------------------
# Cross-section caps
#
@@ -645,6 +924,8 @@ class ClipBox:
obj: bpy.types.Object,
world_planes: PlaneSet,
depsgraph: Optional[Any] = None,
*,
world_matrix: Optional[Matrix] = None,
) -> list[tuple[float, float, float]]:
"""Return triangle vertices for ``obj``'s cap polygons.
@@ -657,14 +938,25 @@ class ClipBox:
objects with subsurf / boolean / mirror modifiers. Falls back to
``obj.data`` only for callers without a depsgraph (e.g. unit
tests that fabricate a mesh outside any eval context).
``world_matrix`` overrides ``obj.matrix_world`` for the localworld
transform. Used by the linked-IFC path where the effective world
placement of a library-linked mesh is the instance empty's
``matrix_world`` composed with the inner mesh's own matrix, not
the linked object's own ``matrix_world`` (which is library-local).
When supplied, the depsgraph path is skipped library-linked
objects aren't part of the active scene's depsgraph and their
Bonsai-baked meshes don't carry modifier stacks anyway.
"""
import bmesh
from mathutils import Vector
mw = world_matrix if world_matrix is not None else obj.matrix_world
bm = bmesh.new()
eval_obj = None
try:
if depsgraph is not None:
if depsgraph is not None and world_matrix is None:
try:
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
@@ -677,7 +969,7 @@ class ClipBox:
except (RuntimeError, ReferenceError):
return []
ws_to_ls = obj.matrix_world.inverted_safe()
ws_to_ls = mw.inverted_safe()
rot = ws_to_ls.to_quaternion()
planes_local = []
for plane in world_planes:
@@ -699,7 +991,6 @@ class ClipBox:
if not cap_faces:
return []
mw = obj.matrix_world
return cls._triangulate_cap_faces(cap_faces, mw)
finally:
bm.free()
@@ -709,27 +1000,96 @@ class ClipBox:
@classmethod
def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]:
"""Yield mesh objects eligible for capping: visible ``IfcElement``s.
"""Yield mesh objects eligible for capping.
Limits to ``IfcElement`` (walls, slabs, doors, windows, ) so
spatial structure (``IfcSpace``, ``IfcBuildingStorey``,
``IfcSite``) and annotations / grids never get capped they're
non-physical containers / overlays that shouldn't sprout solid
fill polygons at clip boundaries.
When ``clip_only_ifc_products`` is set (default), limits to
``IfcElement`` (walls, slabs, doors, windows, ) so spatial
structure (``IfcSpace``, ``IfcBuildingStorey``, ``IfcSite``) and
annotations / grids never get capped they're non-physical
containers / overlays that shouldn't sprout solid fill polygons
at clip boundaries.
When unset, any visible mesh in the scene is eligible regardless
of IFC association useful for clipping Blender-side reference
geometry alongside a loaded IFC.
"""
ifc_file = tool.Ifc.get()
if ifc_file is None:
scene_props = cls.get_scene_props(scene)
only_ifc = scene_props.clip_only_ifc_products
if only_ifc and tool.Ifc.get() is None:
return
for obj in scene.objects:
if obj.type != "MESH" or obj.data is None:
continue
if not obj.visible_get():
continue
entity = tool.Ifc.get_entity(obj)
if entity is None or not entity.is_a("IfcElement"):
continue
if only_ifc:
entity = tool.Ifc.get_entity(obj)
if entity is None or not entity.is_a("IfcElement"):
continue
yield obj
@classmethod
def _iter_linked_ifc_capable_meshes(
cls, scene: bpy.types.Scene
) -> Iterator[tuple[bpy.types.Object, bpy.types.Object, Matrix]]:
"""Yield ``(instance_empty, inner_mesh, effective_world_matrix)``
for meshes inside loaded Project Links collection-instance empties.
Gated by ``BIMSceneClipBoxProperties.include_linked_ifc``: returns
nothing when the toggle is off so the main cap path stays untouched.
The effective world matrix is ``instance.matrix_world @
inner.matrix_world`` the inner object's own ``matrix_world`` is
library-local (positioned relative to the linked collection's
origin), so the instance empty's placement has to be prepended to
land the cap at the right place in the active scene.
"""
scene_props = cls.get_scene_props(scene)
if not scene_props.include_linked_ifc:
return
project_props = tool.Project.get_project_props()
for link in project_props.get_loaded_links():
instance = tool.Project.get_link_empty_handle(link)
if instance is None or instance.instance_collection is None:
continue
if not instance.visible_get():
continue
instance_mw = instance.matrix_world
for inner in instance.instance_collection.all_objects:
if inner.type != "MESH" or inner.data is None:
continue
yield instance, inner, instance_mw @ inner.matrix_world
@classmethod
def invalidate_cap_cache(cls, *, immediate: bool = False) -> None:
"""Drop the cap cache and schedule a fresh rebuild.
Public entry point for property-update callbacks (or any external
change to eligibility / clip-box selection) so callers don't reach
into the private cache state directly. Pass ``immediate=True`` for
UI-driven changes that should rebuild on the next idle tick
without waiting for the depsgraph-debounce window.
"""
cls._cap_cache.clear()
cls._last_cap_clip_box_hash = 0
cls._schedule_cap_rebuild(interval=0.0 if immediate else None)
@classmethod
def rebuild_caps_now(cls, scene: Optional[bpy.types.Scene] = None) -> None:
"""Drop the cap cache and rebuild SYNCHRONOUSLY, then redraw.
Public entry point for interactive end-of-drag handlers (e.g. the
face-resize gizmo group) where the user expects the caps to
re-form the instant they release the handle without the
debounce window the depsgraph path inserts.
"""
cls._cancel_pending_cap_rebuild()
cls._cap_cache.clear()
cls._last_cap_clip_box_hash = 0
cls.rebuild_cap_cache(scene)
for _area, region, _region_3d in tool.Blender.iter_view3d_regions():
region.tag_redraw()
@classmethod
def rebuild_cap_cache(
cls,
@@ -801,6 +1161,29 @@ class ClipBox:
batch = cls._build_cap_batch(verts) if verts else None
cls._cap_cache[obj.name] = (cache_key, batch)
# Linked-IFC inner meshes (gated by include_linked_ifc). The
# ``link:`` prefix on the cache name namespaces them so they
# cannot collide with a scene-object named identically.
for instance, inner, world_matrix in cls._iter_linked_ifc_capable_meshes(scene):
cache_name = f"link:{instance.name}:{inner.name}"
live_names.add(cache_name)
mesh = inner.data
cache_key = (
getattr(mesh, "session_uid", id(mesh)),
tool.Blender.hash_matrix(world_matrix),
clip_box_hash,
)
cached = cls._cap_cache.get(cache_name)
if cached is not None and cached[0] == cache_key:
continue
world_corners = [world_matrix @ Vector(c) for c in inner.bound_box]
if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners):
cls._cap_cache[cache_name] = (cache_key, None)
continue
verts = cls._compute_caps_for_object(inner, world_planes, depsgraph=depsgraph, world_matrix=world_matrix)
batch = cls._build_cap_batch(verts) if verts else None
cls._cap_cache[cache_name] = (cache_key, batch)
for name in list(cls._cap_cache):
if name not in live_names:
cls._cap_cache.pop(name)
@@ -929,14 +1312,17 @@ class ClipBox:
return relevant
@classmethod
def _schedule_cap_rebuild(cls) -> None:
def _schedule_cap_rebuild(cls, *, interval: Optional[float] = None) -> None:
"""(Re)schedule the deferred cap rebuild.
Each call cancels any pending timer and registers a fresh one
so a burst of updates collapses to a single rebuild once the
debounce window of quiet elapses.
debounce window of quiet elapses. Pass ``interval=0.0`` for
next-tick rebuild without debounce (UI-driven changes that
only fire on explicit user action, not depsgraph bursts).
"""
cls._cancel_pending_cap_rebuild()
delay = interval if interval is not None else cls._CAP_REBUILD_DEBOUNCE_SECONDS
def _do_rebuild() -> None:
cls._pending_cap_rebuild = None
@@ -958,7 +1344,7 @@ class ClipBox:
region.tag_redraw()
return None
bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS)
bpy.app.timers.register(_do_rebuild, first_interval=delay)
cls._pending_cap_rebuild = _do_rebuild
@classmethod
+55 -37
View File
@@ -18,25 +18,33 @@
#
# This file was generated with the assistance of an AI coding tool.
"""Generic discovery of the relation linking two IFC elements.
"""Generic discovery of the connection linking two IFC elements.
Used by ``bim.disconnect_elements`` so the operator surface is one operator
per disconnect intent (active vs. partner, identified by GlobalId) rather
than one per rel class. The kind label returned alongside the rel lets the
operator dispatch the right post-disconnect cleanup:
than one per rel class. Each lookup returns ``(subject, kind)`` tuples where
``subject`` is the entity whose teardown effects the disconnect:
- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.)
- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
(the rel kind ``extend_walls_to_underside`` creates)
- ``"element"`` for any other ``IfcRelConnectsElements``
- ``"path"`` ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.).
``subject`` is the rel; removing it disconnects.
- ``"element-top"`` ``IfcRelConnectsElements`` with ``Description=="TOP"``
(created by ``extend_walls_to_underside``). ``subject`` is the rel.
- ``"element"`` any other ``IfcRelConnectsElements``. ``subject`` is the rel.
- ``"mep-pair-fitting"`` two MEP elements joined via ``IfcRelConnectsPorts``
through a single bridging ``IfcFlowFitting``. ``subject`` is the fitting
itself; removing it disconnects. ``OBSTRUCTION`` fittings are excluded
here; those go through ``bim.mep_add_obstruction(mode=REMOVE)``.
Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
operator's cleanup switch maps each kind to the right post-mutation calls."""
Add new kinds by extending :py:meth:`Connection.find_rels`. The dispatch in
``bonsai.core.connection.disconnect_rel`` maps each kind to the right
post-mutation cleanup; the AST forward-compat guard enforces coverage."""
from __future__ import annotations
from typing import TYPE_CHECKING
import bonsai.tool as tool
if TYPE_CHECKING:
import ifcopenshell
@@ -45,16 +53,16 @@ class Connection:
@classmethod
def find_rels(
cls,
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "list[tuple[ifcopenshell.entity_instance, str]]":
"""Return every supported rel linking ``elem_a`` to ``elem_b`` as a
list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
``ConnectedFrom`` because either side of the rel can be the relating
element, and the same pair may carry rels authored with opposite
orientations (``disconnect_path``'s ``(relating, related)`` mode only
inspects ``relating.ConnectedTo``, so a single call would miss the
opposite-orientation rel)."""
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str]]:
"""Return every supported connection linking ``elem_a`` to ``elem_b``
as a list of ``(subject, kind)`` tuples ``subject`` is the entity
whose teardown effects the disconnect (the rel itself for
relationship-kinds, the bridging fitting for ``"mep-pair-fitting"``).
Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side
of a rel can be the relating element, and the same pair may carry
rels authored with opposite orientations."""
rels: list[tuple[ifcopenshell.entity_instance, str]] = []
seen: set[int] = set()
@@ -79,16 +87,20 @@ class Connection:
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind)
fitting = tool.System.find_bridging_fitting(elem_a, elem_b)
if fitting is not None:
_record(fitting, "mep-pair-fitting")
return rels
@classmethod
def find_rel(
cls,
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "tuple[ifcopenshell.entity_instance | None, str | None]":
"""Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than
``find_rels`` when callers only need to know whether a connection
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance | None, str | None]:
"""Return the first ``(subject, kind)`` or ``(None, None)``. Cheaper
than ``find_rels`` when callers only need to know whether a connection
exists or what kind it is."""
rels = cls.find_rels(elem_a, elem_b)
return rels[0] if rels else (None, None)
@@ -96,17 +108,23 @@ class Connection:
@classmethod
def find_rels_for_element(
cls,
elem: "ifcopenshell.entity_instance",
) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]":
"""Return every supported rel touching ``elem`` as ``(rel, kind, partner)``
triples. ``partner`` is the *other* element on the rel the side cascade
cleanup must operate on when ``elem`` is being deleted.
elem: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]:
"""Return every supported connection touching ``elem`` as
``(subject, kind, partner)`` triples. ``partner`` is the *other*
element on the connection the side cascade cleanup must operate on
when ``elem`` is being deleted.
Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
enumerate everything the disconnect operator would handle pairwise.
Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably
does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts``
cleanup is owned by ``tool.Geometry.delete_ifc_object``'s
``remove_port`` loop, which runs unconditionally on any IFC root
deletion. Including MEP here would cause the cascade to also remove
the bridging fitting when one of its connected segments is deleted
a policy choice (fitting may still join other live segments) that's
better left to the user via the explicit disconnect operator.
"""
result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = []
result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = []
seen: set[int] = set()
def _record(rel, kind, partner):
@@ -133,10 +151,10 @@ class Connection:
@classmethod
def orient_element_top(
cls,
rel: "ifcopenshell.entity_instance",
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]":
rel: ifcopenshell.entity_instance,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]:
"""Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
The ``extend_walls_to_underside`` flow stores slab as the relating
+2 -2
View File
@@ -396,13 +396,13 @@ class Geometry(bonsai.core.tool.Geometry):
# same OverrideDelete batch.
if element.is_a("IfcRoot"):
skip_ids = batch_being_deleted_ids or set()
for rel, kind, partner in tool.Connection.find_rels_for_element(element):
for subject, kind, partner in tool.Connection.find_rels_for_element(element):
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
rel=rel,
subject=subject,
kind=kind,
elem=element,
partner=partner,
+72
View File
@@ -18,18 +18,33 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.schema
import ifcpatch
import bonsai.core.tool
import bonsai.tool
if TYPE_CHECKING:
from bonsai.bim.module.patch.prop import BIMPatchProperties
# Lower index = older schema. Used to detect downgrades vs upgrades.
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
class Patch(bonsai.core.tool.Patch):
@classmethod
def get_patch_props(cls) -> BIMPatchProperties:
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
"SplitByBuildingStorey",
)
@classmethod
def get_preset_subdir(cls) -> str:
"""Resolve the preset subdirectory for the currently selected recipe.
Returns a stable string for the ``-`` placeholder so the menu and save
operator remain usable when no real recipe has been picked yet."""
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
return f"bonsai/ifc_patch/{recipe}"
@classmethod
def migration_is_lossy_downgrade(cls) -> bool:
"""``True`` when the currently configured patch is the ``Migrate``
recipe targeting an older schema than the input file. Used to gate
the destructive-migration confirmation dialog."""
props = cls.get_patch_props()
if props.ifc_patch_recipes != "Migrate":
return False
target_schema = next(
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
None,
)
if not target_schema:
return False
source_schema = cls._patch_source_schema()
if not source_schema:
return False
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
@classmethod
def _patch_source_schema(cls) -> str:
"""Resolve the IFC schema of the configured input without parsing the
full file. For loaded-from-memory the schema is in the entity_instance
wrapper; for disk paths we read only the STEP file header (first ~2KB)
rather than ``ifcopenshell.open`` which parses the whole file."""
props = cls.get_patch_props()
if props.should_load_from_memory:
ifc_file = bonsai.tool.Ifc.get()
return ifc_file.schema if ifc_file else ""
if not props.ifc_patch_input:
return ""
try:
with open(props.ifc_patch_input, "rb") as f:
header = f.read(2048).decode("utf-8", errors="ignore")
except OSError:
return ""
match = _IFC_FILE_SCHEMA_RE.search(header)
if not match:
return ""
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
# base via the canonical normaliser — handles longest-prefix-first
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
# IFC4X3 files as IFC4.
try:
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
except AssertionError:
return ""
@classmethod
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
if recipe == "ExtractElements":
+63
View File
@@ -488,6 +488,69 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool:
"""A fitting whose deletion is the supported teardown for one of
its port connections. ``OBSTRUCTION`` fittings are excluded they
have a dedicated grow/shrink flow (``bim.mep_add_obstruction``
with ``mode=REMOVE``) that absorbs the freed segment length."""
if not element.is_a("IfcFlowFitting"):
return False
return getattr(element, "PredefinedType", None) != "OBSTRUCTION"
@classmethod
def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Entities reachable from ``element``'s ports via a single
``IfcRelConnectsPorts`` hop, deduped by IFC id."""
neighbours: list[ifcopenshell.entity_instance] = []
seen: set[int] = set()
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbour = ifcopenshell.util.system.get_port_element(connected_port)
if neighbour is None or neighbour.id() in seen:
continue
seen.add(neighbour.id())
neighbours.append(neighbour)
return neighbours
@classmethod
def find_bridging_fitting(
cls,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the disconnectable ``IfcFlowFitting`` whose removal
disconnects ``elem_a`` from ``elem_b``, or ``None``.
Two topologies are handled. (1) Direct port-to-port between a
segment/fitting and a disconnectable fitting: the fitting endpoint
is returned. (2) Two segments joined by a single bridging
disconnectable fitting: the bridging fitting is returned.
``OBSTRUCTION`` fittings short-circuit to ``None``."""
if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)):
return None
a_neighbours = cls.neighbours_at_ports(elem_a)
b_neighbours = cls.neighbours_at_ports(elem_b)
elem_a_id = elem_a.id()
elem_b_id = elem_b.id()
if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours):
return elem_a
if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours):
return elem_b
a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)]
if not a_fittings:
return None
b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)}
for fitting in a_fittings:
if fitting.id() in b_fitting_ids:
return fitting
return None
@classmethod
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when the MEP element's body representation is a profile sweep
@@ -0,0 +1,124 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
import bpy
import ifcopenshell
import ifcopenshell.api.spatial
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
obj = bpy.context.active_object
entity = ifc.create_entity(ifc_class)
tool.Ifc.link(entity, obj)
return entity, obj
class TestAddClipBoxForSourceSpatial(NewFile):
def test_spatial_creates_clip_box_sized_to_contained_walls(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
storey = ifc.create_entity("IfcBuildingStorey")
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
result = bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=str(storey.id()))
assert result == {"FINISHED"}
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
host = scene_props.clip_boxes[0].obj
assert tool.ClipBox.get_object_props(host).is_clip_box is True
translation, _, scale = host.matrix_world.decompose()
assert translation.x == pytest.approx(2.0)
assert scale.x == pytest.approx(3.0)
class TestAddClipBoxForSourceClass(NewFile):
def test_class_creates_clip_box_for_all_walls(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
# Two walls + one window; the IfcWall pick should cover only the walls.
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
_make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
_make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0)
result = bpy.ops.bim.add_clip_box_for_source(source_kind="CLASS", source_id="IfcWall")
assert result == {"FINISHED"}
scene_props = tool.ClipBox.get_scene_props()
host = scene_props.clip_boxes[0].obj
translation, _, scale = host.matrix_world.decompose()
# AABB of the two walls only (x in [-1, 5]); window at x=20 must not contribute.
assert translation.x == pytest.approx(2.0)
assert scale.x == pytest.approx(3.0)
class TestAddClipBoxForSourceEmpty(NewFile):
def test_no_matching_elements_reports_error(self):
# bpy.ops.* raises RuntimeError when an operator reports {"ERROR"},
# so the assertion is on the raised message rather than the return code.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
walltype = ifc.create_entity("IfcWallType")
# No occurrences linked — TYPE source resolves to 0 elements.
with pytest.raises(RuntimeError, match="No elements found"):
bpy.ops.bim.add_clip_box_for_source(source_kind="TYPE", source_id=str(walltype.id()))
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 0
def test_placeholder_source_id_reports_error(self):
# With no IFC file loaded, data.py callbacks return the NO_OPTIONS_ID
# sentinel. Submitting that sentinel as the picked source must ERROR.
from bonsai.bim.module.clip_box import data as clip_data
with pytest.raises(RuntimeError, match="No source selected"):
bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID)
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 0
class TestRemoveClipBoxOrphan(NewFile):
def test_remove_orphan_entry_when_host_object_deleted(self):
# The remove operator must work on an orphan entry — i.e. one whose
# host empty was deleted out from under it via the outliner.
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
host = scene_props.clip_boxes[0].obj
assert host is not None
bpy.data.objects.remove(host, do_unlink=True)
# Entry survives but its `obj` pointer is now None.
assert len(scene_props.clip_boxes) == 1
assert scene_props.clip_boxes[0].obj is None
result = bpy.ops.bim.remove_clip_box(index=0)
assert result == {"FINISHED"}
assert len(scene_props.clip_boxes) == 0
@@ -0,0 +1,273 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Pins the ``clip_only_ifc_products`` toggle contract.
The toggle gates the cap-eligibility filter (IFC-only vs. all visible meshes)
and lives only on the Blender Scene PG the project pset must never carry it.
"""
import math
import bpy
import ifcopenshell
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)):
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
obj = bpy.context.active_object
entity = ifc.create_entity("IfcWall")
tool.Ifc.link(entity, obj)
return entity, obj
def _make_blender_cube(location=(0.0, 0.0, 0.0)):
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
return bpy.context.active_object
class TestDefaultIsTrue(NewFile):
def test_clip_only_ifc_products_defaults_to_true(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.clip_only_ifc_products is True
class TestCapEligibilityHonorsToggle(NewFile):
def test_only_ifc_true_excludes_non_ifc_mesh(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = True
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert wall in eligible
assert cube not in eligible
def test_only_ifc_false_includes_non_ifc_mesh(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert wall in eligible
assert cube in eligible
def test_only_ifc_false_works_without_ifc_file_loaded(self):
# No IFC at all; eligibility should still yield Blender meshes when
# the IFC-only filter is off, since there's nothing to filter against.
cube = _make_blender_cube(location=(0.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert cube in eligible
class TestShowCapsTriggersRebuild(NewFile):
def test_show_caps_off_then_on_schedules_cap_rebuild(self):
# Off → On must schedule a rebuild — without this, caps stay empty
# until the user nudges geometry to fire the next depsgraph tick.
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.show_caps = False
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
scene_props.show_caps = True
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox._cancel_pending_cap_rebuild()
class TestRebuildCapsNow(NewFile):
def test_rebuild_caps_now_cancels_any_pending_debounce(self):
# Synchronous path must wipe the debounced timer — otherwise the
# rebuild fires twice when the gizmo unlock interleaves with a
# depsgraph tick.
bpy.ops.bim.add_clip_box()
tool.ClipBox._schedule_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox.rebuild_caps_now()
assert tool.ClipBox._pending_cap_rebuild is None
class TestActiveClipBoxIndexRebuildsCaps(NewFile):
def test_index_change_schedules_cap_rebuild(self):
# UI-list click changes active_clip_box_index — the cap cache
# belongs to the previous box's clip volume, so a rebuild must
# be scheduled so the overlay matches the newly-active box.
bpy.ops.bim.add_clip_box()
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
scene_props.active_clip_box_index = 0
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox._cancel_pending_cap_rebuild()
def _exec_align_view(axis: int, is_max: bool):
"""Run ``bim.align_view_to_clip_face`` against the first VIEW_3D area
and return its ``rv3d``. Skips if no viewport is available in the
test session."""
for area in bpy.context.window.screen.areas:
if area.type != "VIEW_3D":
continue
region = next((r for r in area.regions if r.type == "WINDOW"), None)
if region is None:
continue
with bpy.context.temp_override(area=area, region=region):
result = bpy.ops.bim.align_view_to_clip_face("EXEC_DEFAULT", axis=axis, is_max=is_max)
assert result == {"FINISHED"}
return bpy.context.space_data.region_3d
pytest.skip("No VIEW_3D area available")
class TestAlignViewToClipFace(NewFile):
def test_align_view_sets_rv3d_rotation_to_face_normal(self):
# The operator must reorient the viewport so its forward axis
# points AGAINST the picked face's outward normal (so the user
# sees the face from outside).
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
# Rotate the empty so the +X face's outward world normal isn't
# axis-aligned — proves the operator handles arbitrary rotation.
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "Z") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
outward = clip_box.matrix_world.to_3x3().col[0].normalized()
forward = rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))
assert (forward - (-outward)).length < 1e-4
def test_align_view_uses_box_local_z_up_for_side_face(self):
# Side faces (±X, ±Y local normals) follow Blender's numpad 1 / 3
# convention but in the BOX'S local frame: local +Z is the
# screen-up axis, transformed through the empty's rotation.
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
clip_box.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert (
up_world - expected_up
).length < 1e-3, (
f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}"
)
def test_align_view_keeps_box_local_z_up_for_negative_y_face(self):
# Clicking the -Y face used to put world +Z at the BOTTOM of the
# screen. With box-local convention it stays at the top.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=1, is_max=False)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.z > 0.99, f"-Y face view must keep box-local +Z as up, got {tuple(up_world)}"
def test_align_view_respects_box_local_axes_when_box_x_rotated(self):
# Rotating around X moves box-local +Z away from world +Z; the
# up axis must follow the BOX, otherwise the box edges no longer
# appear horizontal/vertical when aligned to a face — the bug
# users hit on rotated boxes.
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "X") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert (
up_world - expected_up
).length < 1e-3, f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}"
def test_align_view_uses_box_local_y_up_for_top_face(self):
# Top face (local +Z outward) follows Blender's numpad-7
# convention applied in the box's local frame: local +Y is up.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=2, is_max=True)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.y > 0.99, f"Top-face view must have box-local +Y as up, got {tuple(up_world)}"
def test_align_view_uses_box_local_negative_y_up_for_bottom_face(self):
# Bottom face (local -Z outward) follows ctrl-numpad-7: box-local
# -Y is up.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=2, is_max=False)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.y < -0.99, f"Bottom-face view must have box-local -Y as up, got {tuple(up_world)}"
class TestNotPersistedToProjectPset(NewFile):
def test_pset_does_not_carry_clip_only_ifc_products(self):
bpy.ops.bim.create_project()
scene_props = tool.ClipBox.get_scene_props()
# Flip to a non-default value, then trigger a pset write.
scene_props.clip_only_ifc_products = False
bpy.ops.bim.add_clip_box() # writes the pset
import ifcopenshell.util.element
project = tool.Ifc.get().by_type("IfcProject")[0]
pset = ifcopenshell.util.element.get_psets(project).get(tool.ClipBox.PSET_NAME, {})
# Whatever the pset stores, it must not carry this scene-only toggle.
for key in pset:
assert (
"clip_only_ifc" not in key.lower()
), f"Project pset unexpectedly carries the scene-only toggle (key {key!r})"
def test_load_from_pset_does_not_touch_clip_only_ifc_products(self):
# Round-trip: set the toggle on the Scene, simulate a pset load, and
# confirm the loader didn't overwrite the user's Scene-level choice.
bpy.ops.bim.create_project()
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
tool.ClipBox.load_from_project_pset()
assert scene_props.clip_only_ifc_products is False
@@ -0,0 +1,294 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Tests for the generic face-quad gizmo core.
Pins the three contracts the layout helper depends on:
* ``compute_face_resize`` pure one-sided resize arithmetic.
* ``front_facing_face_mask`` view-aware face visibility predicate.
* ``apply_face_quad_layout`` front-facing faces upload the solid
unit quad ("solid" state); back-facing faces upload the halo strips
("strips" state).
"""
import pytest
from mathutils import Matrix, Vector
from bonsai.bim.module.clip_box import face_quad
pytestmark = pytest.mark.clip_box
# ---------------------------------------------------------------- compute_face_resize ---
class TestComputeFaceResize:
def test_outward_drag_on_max_face_grows_half_extent_and_shifts_origin(self):
# Pulling the +X face outward by 2.0 world units must:
# - grow the world half by half the cursor delta (one-sided);
# - shift the empty's origin so the opposite (-X) face stays put.
new_scale, new_loc = face_quad.compute_face_resize(
value=10.0 + 2.0, # init + delta
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
# half-extent: 10 + 2/2 = 11
assert new_scale == pytest.approx(11.0)
# origin shifts by half the realized delta (= 1.0) along +X
assert new_loc[0] == pytest.approx(1.0)
assert new_loc[1] == pytest.approx(0.0)
assert new_loc[2] == pytest.approx(0.0)
def test_inward_drag_clamps_at_minimum_half_extent(self):
# Pulling the face inward by more than the current half collapses
# to a tiny floor instead of going negative. The realized delta
# (post-clamp) drives the location shift so the opposite face
# stays fixed even at the clamp.
new_scale, new_loc = face_quad.compute_face_resize(
value=0.0, # delta = -1.0
init_world_half=1.0,
init_location=(5.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
assert new_scale > 0.0
assert new_scale < 1.0
# New origin sits between init (5.0) and -X face (which is at 4.0
# = init.x - init_world_half). Since the clamp limited shrinkage,
# the new origin is just slightly less than init.x.
assert 4.0 < new_loc[0] < 5.0
def test_drag_on_min_face_via_negative_world_axis_grows_outward(self):
# On the -X face, ``world_axis`` is (-1, 0, 0). A positive
# ``delta`` (outward on this face) must still grow the half
# extent and shift the origin in the -X direction.
new_scale, new_loc = face_quad.compute_face_resize(
value=10.0 + 2.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(-1.0, 0.0, 0.0),
display_size=1.0,
)
assert new_scale == pytest.approx(11.0)
# Origin shifts toward -X.
assert new_loc[0] == pytest.approx(-1.0)
def test_display_size_scales_the_resulting_scale_axis(self):
# The returned scale is half_extent / display_size — so a
# display_size of 2.0 halves the scale relative to display_size
# of 1.0 for the same world half-extent.
new_scale_1, _ = face_quad.compute_face_resize(
value=10.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
new_scale_2, _ = face_quad.compute_face_resize(
value=10.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=2.0,
)
assert new_scale_1 == pytest.approx(10.0)
assert new_scale_2 == pytest.approx(5.0)
# ----------------------------------------------------------- front_facing_face_mask ---
class TestFrontFacingFaceMask:
def test_view_along_neg_z_lights_up_only_pos_z_face(self):
# Camera looking down -Z (typical default front view): only the
# +Z face (last entry) faces the camera.
normals = (
(-1.0, 0.0, 0.0), # -X face
(1.0, 0.0, 0.0), # +X face
(0.0, -1.0, 0.0), # -Y face
(0.0, 1.0, 0.0), # +Y face
(0.0, 0.0, -1.0), # -Z face
(0.0, 0.0, 1.0), # +Z face
)
view_dir = (0.0, 0.0, -1.0)
mask = face_quad.front_facing_face_mask(normals, view_dir)
assert mask == (False, False, False, False, False, True)
def test_view_along_pos_x_lights_up_neg_x_face(self):
# Camera looking along +X (front of the -X face).
normals = (
(-1.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, -1.0),
(0.0, 0.0, 1.0),
)
view_dir = (1.0, 0.0, 0.0)
mask = face_quad.front_facing_face_mask(normals, view_dir)
assert mask == (True, False, False, False, False, False)
def test_wrong_length_raises(self):
with pytest.raises(ValueError, match="expected 6 face normals"):
face_quad.front_facing_face_mask([(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0))
# ----------------------------------------------------- apply_face_quad_layout (front/back) ---
class _FakeQuad:
"""Stand-in for ``BIM_GT_box_face_quad`` — only the slots the layout helper writes."""
def __init__(self):
self.matrix_basis = Matrix.Identity(4)
self.axis = Vector((0.0, 0.0, 0.0))
self.hide = False
self.select_bias = 0.0
self.is_highlight = False
self.custom_shape = None
self.custom_shape_select = None
self._last_geometry_state = None
self._strips_cache_key = None
def new_custom_shape(self, kind, verts):
# Layout helper only stores the result; nothing further is asked of it.
return (kind, tuple(tuple(v) for v in verts))
class _FakeOutline:
def __init__(self):
self.matrix_basis = Matrix.Identity(4)
self.alpha = 0.0
self.alpha_highlight = 0.0
class _FakeRV3D:
def __init__(self, view_rotation, view_matrix):
self.view_rotation = view_rotation
self.view_matrix = view_matrix
# Blender's location_3d_to_region_2d reads perspective_matrix to
# project world points; a simple ortho-projection matrix is enough
# for the layout helper's halo-strip pixel measurement.
self.perspective_matrix = view_matrix
self.is_perspective = False
class _FakeRegion:
width = 800
height = 600
def _run_layout(view_dir: Vector) -> tuple[str, ...]:
"""Apply the layout helper for a unit cube at the origin with a
given world-space view direction; return each route's
``_last_geometry_state`` in :data:`FACE_ROUTES` order."""
quads = [_FakeQuad() for _ in range(6)]
outlines = [_FakeOutline() for _ in range(6)]
# view_rotation is the quaternion that rotates the camera's local
# forward (-Z) onto the desired world view direction.
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(view_dir.normalized())
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
face_quad.apply_face_quad_layout(
quad_gizmos=quads,
outline_gizmos=outlines,
bmin=Vector((-1.0, -1.0, -1.0)),
bmax=Vector((1.0, 1.0, 1.0)),
matrix_world=Matrix.Identity(4),
cage_rotation=Matrix.Identity(4),
region=_FakeRegion(),
rv3d=rv3d,
locked=False,
)
return tuple(getattr(q, "_last_geometry_state", None) for q in quads)
class TestApplyFaceQuadLayout:
def test_oblique_view_yields_solid_fronts_and_strips_or_empty_backs(self):
# Oblique view direction (1, 1, -1) hits the box from the +X, +Y,
# +Z octant. Faces facing toward the camera (-X, -Y, +Z) must
# render as "solid"; faces facing away (+X, +Y, -Z) must render
# as back-facing — either "strips" (when adjacent front faces
# give halo edges) or "empty" (when no front-facing neighbour).
states = _run_layout(view_dir=Vector((1.0, 1.0, -1.0)))
# FACE_ROUTES order: (-X, +X, -Y, +Y, -Z, +Z)
# Front-facing routes (against the view direction): -X, -Y, +Z
assert states[0] == "solid" # -X
assert states[2] == "solid" # -Y
assert states[5] == "solid" # +Z
# Back-facing routes (with the view direction): +X, +Y, -Z
for back_idx in (1, 3, 4):
assert states[back_idx] in ("strips", "empty")
def test_negative_scale_host_does_not_invert_front_back_split(self):
# User-reported bug: when the host empty has scale=-1 on an axis,
# the visible +X side of the cube sits on world +X (negative-scale
# flips the local +X vertex onto world -X but the local -X vertex
# onto world +X — same set of points). The OLD layout used the
# signed matrix for positions while rotation-only for normals,
# which placed the "+X face" gizmo on world -X. After the
# ``_abs_scale_matrix`` fix the gizmo for the +X face must sit
# at world +X for an outward-X-facing view to register it as
# front-facing.
quads = [_FakeQuad() for _ in range(6)]
outlines = [_FakeOutline() for _ in range(6)]
# View toward +X: the +X face is at world +X for a standard box.
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(Vector((-1.0, 0.0, 0.0)))
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
# Negative X scale (mirroring the cube along world X).
mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0))
face_quad.apply_face_quad_layout(
quad_gizmos=quads,
outline_gizmos=outlines,
bmin=Vector((-1.0, -1.0, -1.0)),
bmax=Vector((1.0, 1.0, 1.0)),
matrix_world=mw,
cage_rotation=Matrix.Identity(4),
region=_FakeRegion(),
rv3d=rv3d,
locked=False,
)
# Route 1 = (axis=0, is_max=True) = the +X face. Must be solid
# (front-facing) for a +X-facing view, regardless of sign-of-scale.
assert quads[1]._last_geometry_state == "solid"
def test_view_parallel_front_face_remains_interactive(self):
# Looking dead-on at +Z (view_dir = -Z): the +Z face sits
# antiparallel to the view direction, so it's still the
# front-facing face. It must render solid (clickable for both
# the resize drag and the CTRL+click align-view dispatch),
# never hidden — the older "lockout" treatment removed
# CTRL+click access on the very face users most want to click.
states = _run_layout(view_dir=Vector((0.0, 0.0, -1.0)))
assert states[5] == "solid" # +Z face (front-facing) stays interactive.
# -Z face has no adjacent front-facing neighbours in this view,
# so its halo strip degenerates to empty — but that's the
# back-face path, not a deliberate lockout.
assert states[4] == "empty"
@@ -0,0 +1,202 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Pins the ``include_linked_ifc`` toggle contract.
The toggle extends the cap pipeline to also bisect meshes living inside
Project Links collection-instance empties without it those meshes
are clipped by Blender's native viewport clip but never get
cross-section caps drawn at the cut.
"""
import bpy
import pytest
from mathutils import Matrix
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_synthetic_linked_collection(
inner_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
instance_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[bpy.types.Object, bpy.types.Object, bpy.types.Collection]:
"""Build a synthetic link: a collection with one mesh + an instance empty.
Mirrors the structural shape of a real loaded link without driving
the multi-process .ifc.cache.blend pipeline. Returns
``(instance_empty, inner_mesh, collection)`` so tests can assert
against the exact objects they created.
"""
collection = bpy.data.collections.new("LinkedIFC")
bpy.ops.mesh.primitive_cube_add(size=2.0, location=inner_location)
inner = bpy.context.active_object
for c in list(inner.users_collection):
c.objects.unlink(inner)
collection.objects.link(inner)
empty = bpy.data.objects.new("LinkedIFC.001", None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
bpy.context.scene.collection.objects.link(empty)
# matrix_world (not .location) so the test reads a fresh value without
# needing a depsgraph tick to propagate matrix_local → matrix_world.
empty.matrix_world = Matrix.Translation(instance_location)
return empty, inner, collection
def _register_synthetic_link(empty: bpy.types.Object) -> None:
"""Add a Project ▸ Links entry pointing at ``empty``.
No IFC is set in the bootstrap fixture, so
``tool.Project.get_link_empty_handle`` resolves via the link's
``empty_handle`` PointerProperty rather than the IfcStore.
"""
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "synthetic"
link.is_loaded = True
link.empty_handle = empty
class TestDefaultIsOff(NewFile):
def test_include_linked_ifc_defaults_to_false(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.include_linked_ifc is False
class TestIteratorGating(NewFile):
def test_iterator_returns_nothing_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = False
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_yields_inner_mesh_when_toggle_on(self):
empty, inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert len(yielded) == 1
instance, mesh_obj, _world_matrix = yielded[0]
assert instance is empty
assert mesh_obj is inner
def test_iterator_composes_instance_and_inner_matrix(self):
# The inner mesh's matrix_world is library-local (cube at origin
# inside the collection). The instance empty is offset by 5m on X.
# The effective world matrix must combine the two so the cap lands
# in the active scene, not at the inner mesh's library origin.
empty, inner, _col = _make_synthetic_linked_collection(
inner_location=(0.0, 0.0, 0.0),
instance_location=(5.0, 0.0, 0.0),
)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
_instance, _mesh_obj, world_matrix = next(iter(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)))
expected = empty.matrix_world @ inner.matrix_world
assert (world_matrix.translation - expected.translation).length < 1e-6
# And the composition picks up the empty's offset.
assert world_matrix.translation.x == pytest.approx(5.0)
def test_iterator_skips_links_with_no_instance_collection(self):
# A link whose empty_handle was created but never linked to a
# collection (e.g. half-initialised link) must not yield anything.
empty = bpy.data.objects.new("LinkedIFC.broken", None)
empty.instance_type = "COLLECTION"
bpy.context.scene.collection.objects.link(empty)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_skips_unloaded_links(self):
empty, _inner, _col = _make_synthetic_linked_collection()
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "unloaded"
link.is_loaded = False
link.empty_handle = empty
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
class TestUpdateCallbackInvalidatesCache(NewFile):
def test_toggling_include_linked_ifc_clears_cap_cache(self):
# Seed the cache with a sentinel so we can detect invalidation.
tool.ClipBox._cap_cache["sentinel"] = (object(), None)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
assert "sentinel" not in tool.ClipBox._cap_cache
tool.ClipBox._cancel_pending_cap_rebuild()
class TestRebuildCachesLinkedMesh(NewFile):
def test_rebuild_adds_link_prefixed_entry_when_toggle_on(self):
# The default clip box spawns a 20m cube around the cursor, so a
# 2m cube at the origin sits fully inside both the box and the
# instance's translation — guaranteeing the AABB-vs-planes check
# passes and a (cache_key, batch) entry lands in _cap_cache.
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
link_keys = [name for name in tool.ClipBox._cap_cache if name.startswith("link:")]
assert link_keys, f"expected a link: cache entry, got {list(tool.ClipBox._cap_cache)}"
def test_rebuild_drops_link_entry_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
assert any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
scene_props.include_linked_ifc = False
tool.ClipBox.rebuild_caps_now()
assert not any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
@@ -0,0 +1,74 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
"""Forward-compat guards for the source-based clip-box wiring.
Adding a new source kind requires matching entries across four sites the
label dict, the dispatch table, a callback in ``data.py``, and the menu entry.
Missing one path silently degrades the dialog to "No options" with no error.
These tests pin the four-way integrity.
"""
import pytest
from bonsai.bim.module.clip_box import data, operator, ui
pytestmark = pytest.mark.clip_box
def test_every_label_has_a_dispatch_entry():
missing = set(operator.SOURCE_KIND_LABELS) - set(operator._SOURCE_ID_DISPATCH)
assert not missing, f"Kinds missing from dispatch: {sorted(missing)}"
def test_every_dispatch_value_is_callable():
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
assert callable(fn), f"Dispatch entry for {kind} is not callable"
def test_every_dispatch_target_lives_in_data_module():
# Each callback must be a real attribute of the data module; protects
# against typos in the dispatch table that would otherwise only surface
# at the first dialog open.
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
assert (
getattr(data, fn.__name__, None) is fn
), f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py"
def test_every_menu_entry_is_a_known_kind():
for kind, label, icon in ui._SOURCE_MENU_ENTRIES:
assert kind in operator.SOURCE_KIND_LABELS, f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS"
def test_every_label_has_a_menu_entry():
menu_kinds = {kind for kind, _label, _icon in ui._SOURCE_MENU_ENTRIES}
missing = set(operator.SOURCE_KIND_LABELS) - menu_kinds
assert not missing, f"Kinds missing from menu: {sorted(missing)}"
def test_status_values_match_between_tool_and_data():
# The status picker labels in data.STATUS_LABELS and the tool-layer
# validation list must agree — the dispatcher rejects any status value
# missing from the latter.
from bonsai.tool.clip_box import SOURCE_STATUS_VALUES
data_values = tuple(value for value, _label in data.STATUS_LABELS)
assert data_values == SOURCE_STATUS_VALUES
@@ -63,6 +63,148 @@ def test_fully_overridden_subclass_is_accepted():
assert cls.__name__ == "DecoratorWithAllHooks"
# ---------------------------------------------------------------------------
# Cache invalidation — the load-bearing crash guard.
#
# Without geom-generation gating, the walk cache holds entity_instance
# references that outlive their backing IFC entities after an
# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a
# on a freed SWIG handle and segfaults Blender. The gate must fire
# whenever tool.Parametric.get_geom_generation bumps — which is on
# every tool.Ifc.Operator commit (via refresh_post_commit), covering
# every disconnect path.
from types import SimpleNamespace
from unittest.mock import Mock, patch
def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids):
decorator._cached_start_guid = start_guid
decorator._cached_ifc_file = ifc_file
decorator._cached_geom_gen = geom_gen
decorator._cached_walk_ids = list(walk_ids)
def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged():
"""Cache hit: same seed, same ifc_file, same geom_gen → reuse the
stored walk. Steady-state path while the IFC is idle."""
cls = _build_subclass("DecoratorCacheReuse")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102])
current_geom_gen = 5
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert hit, "Cache must hit when seed, file, and geom_gen are unchanged"
def test_walk_cache_invalidates_on_geom_generation_bump():
"""Cache must miss when geom_gen bumps so entities removed by an
``ifcopenshell.api`` mutation never survive in the cached walk
list into the next draw pass."""
cls = _build_subclass("DecoratorCacheGenInvalidates")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
current_geom_gen = 6 # IFC mutation has bumped the counter
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities"
def test_walk_cache_invalidates_on_seed_change():
"""Selecting a different network seed forces a re-walk even if
geom_gen is unchanged."""
cls = _build_subclass("DecoratorCacheSeedChange")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
hit = (
"NEW-GUID" == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_invalidates_on_ifc_file_swap():
"""Loading a different IFC file must invalidate even if the new
seed happens to share the GUID (different IfcOpenShell file
objects different identity)."""
cls = _build_subclass("DecoratorCacheFileSwap")
dec = cls()
old_file = SimpleNamespace()
new_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101])
hit = (
"GUID" == dec._cached_start_guid
and new_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_stores_ids_not_entity_references():
"""Structural safety: the cache stores STEP integer ids, not raw
``entity_instance`` references re-resolved via ``ifc_file.by_id``
on each cache hit. Eliminates the dangling-SWIG-handle class entirely:
even if geom_gen mistakenly fails to bump, a deleted entity's id won't
resolve, the cache-hit branch returns ``None``, and the next draw
re-walks against live entities."""
cls = _build_subclass("DecoratorCacheStoresIds")
dec = cls()
_seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42])
assert dec._cached_walk_ids == [42]
assert all(isinstance(eid, int) for eid in dec._cached_walk_ids)
def test_geom_cache_key_includes_geom_generation():
"""``TokenCache.get_or_compute`` keys that include geom_gen flush
the cached world-space geometry on IFC mutations the depsgraph
token doesn't observe — without that key component, a re-walk
would feed a fresh list to the lambda while the cache still
returned the prior result."""
import bonsai.bim.decorator_cache as decorator_cache
from bonsai.bim.module.model.decorator import MEPSystemPathDecorator
decorator_cache.reset_for_test()
dec = MEPSystemPathDecorator()
builds: list[int] = []
def _build():
builds.append(1)
return ([], [], [])
ifc_file = SimpleNamespace()
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
assert len(builds) == 1, "Same key (same gen) should reuse the cached value"
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build)
assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value"
# ---------------------------------------------------------------------------
# Pure-geometry classifier contract.
#
@@ -47,6 +47,11 @@ def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: i
def _elem(*, connected_to=(), connected_from=()):
e = Mock()
# Default is_a to False so the MEP-pair-fitting branch of find_rels
# (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the
# generic _elem stubs used by the wall-side dispatch tests. Test
# cases that want is_a("IfcWall")-True explicitly override e.is_a.
e.is_a = lambda _c: False
e.ConnectedTo = list(connected_to)
e.ConnectedFrom = list(connected_from)
e.GlobalId = "GUID"
@@ -167,6 +172,140 @@ def test_find_rels_for_element_skips_rels_without_partner():
assert tool.Connection.find_rels_for_element(elem) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rels — MEP pair-fitting detection
# ---------------------------------------------------------------------------
def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()):
"""Stand-in IFC element with port mocks and ``is_a`` short-circuits."""
e = Mock()
e.id = lambda: elem_id
e.is_a = lambda c: c in klasses
e.PredefinedType = predefined_type
# Empty path / element rels so the find_rels prologue iterates cleanly
# before reaching the MEP port-walk branch.
e.ConnectedTo = []
e.ConnectedFrom = []
e._ports = list(ports)
return e
def _port(port_id, owner, connected_to=None):
p = Mock()
p.id = lambda: port_id
p._owner = owner
p._connected_to = connected_to
return p
def _patch_port_walk():
"""Patch the port helpers ``tool.System.find_bridging_fitting`` consumes
so the mep-pair-fitting detection in ``find_rels`` can be exercised
without a real IFC fixture. Three patches: ``get_ports`` and
``get_connected_port`` are ``tool.System`` classmethods that delegate
to ``ifcopenshell.util.system``; ``get_port_element`` is called
directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``."""
return (
patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports),
patch(
"bonsai.tool.system.System.get_connected_port",
side_effect=lambda p: p._connected_to,
),
patch(
"bonsai.tool.system.ifcopenshell.util.system.get_port_element",
side_effect=lambda p: p._owner,
),
)
def test_find_rels_detects_segment_segment_bridging_fitting():
"""Two flow segments joined by a single bridging fitting must surface
as ``(fitting, 'mep-pair-fitting')`` the fitting whose deletion
effects the disconnect."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
f_port_a = _port(201, fitting, connected_to=a_port)
f_port_b = _port(202, fitting, connected_to=b_port)
a_port._connected_to = f_port_a
b_port._connected_to = f_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
fitting._ports = [f_port_a, f_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg_a, seg_b)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_detects_segment_fitting_direct():
"""A segment + its directly-connected fitting also surface as the
same kind, with the fitting itself as the deletion target."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg = _mep(1)
seg_port = _port(101, seg)
f_port = _port(201, fitting, connected_to=seg_port)
seg_port._connected_to = f_port
seg._ports = [seg_port]
fitting._ports = [f_port]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg, fitting)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_skips_obstruction_fitting():
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
they must not surface as a disconnect target."""
obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
o_port_a = _port(201, obstruction, connected_to=a_port)
o_port_b = _port(202, obstruction, connected_to=b_port)
a_port._connected_to = o_port_a
b_port._connected_to = o_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
obstruction._ports = [o_port_a, o_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_returns_empty_for_two_unrelated_mep_segments():
"""No bridging fitting, no detection."""
seg_a = _mep(1)
seg_b = _mep(2)
seg_a._ports = []
seg_b._ports = []
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_skips_non_mep_pair():
"""Walls don't have ports — find_rels must early-out before walking
them as if they were MEP."""
wall_a = Mock()
wall_a.is_a = lambda c: c == "IfcWall"
wall_a.ConnectedTo = []
wall_a.ConnectedFrom = []
wall_b = Mock()
wall_b.is_a = lambda c: c == "IfcWall"
wall_b.ConnectedTo = []
wall_b.ConnectedFrom = []
assert tool.Connection.find_rels(wall_a, wall_b) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rel — first-match convenience
# ---------------------------------------------------------------------------
@@ -241,9 +380,9 @@ def test_disconnect_dispatches_one_call_per_rel():
assert dispatch.call_count == 2
# Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
# — orient_element_top inside disconnect_rel recovers the wall/slab roles.
for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
for call, expected_subject, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
kw = call.kwargs
assert kw["rel"] is expected_rel
assert kw["subject"] is expected_subject
assert kw["kind"] == expected_kind
assert kw["elem"] is elem_a
assert kw["partner"] is elem_b
@@ -346,7 +485,7 @@ def test_disconnect_gizmo_direction_symmetry():
# disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
# by argument order but orient_element_top inside disconnect_rel resolves
# the wall/slab roles symmetrically.
assert wall_first["rel"] is rel and slab_first["rel"] is rel
assert wall_first["subject"] is rel and slab_first["subject"] is rel
assert wall_first["kind"] == slab_first["kind"] == "element-top"
assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
@@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0}
def counting_port_state(elem, at_start):
call_counts["port_connection_state"] += 1
return "FREE"
def counting_find_fitting(a, b):
call_counts["find_fitting_between_segments"] += 1
call_counts["find_bridging_fitting"] += 1
return None
def counting_join_location():
@@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
),
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
@@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
# Second frame must reuse the cached values — no second IFC walk.
assert call_counts["port_connection_state"] == first["port_connection_state"]
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"]
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
@@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
), patch(
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
@@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
inst.position_gizmos(context)
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
assert (
fitting_call_count["n"] > first_fitting
), "find_fitting_between_segments must recompute after generation advance"
assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance"
def test_selection_change_invalidates_cache(_patched_visibility):
@@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility):
), patch(
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
@@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility):
selection_state["selected"] = [active, other_b]
inst.position_gizmos(context)
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change"
@@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
)
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
``position`` pinned. Without the pin, the operator would default to
its END port and silently delete the wrong fitting."""
def test_unjoin_icons_bind_unified_disconnect_operator():
"""Every unjoin icon (pair, start, end) routes to the unified
``bim.disconnect_elements`` operator and the group holds an
``op_props`` slot for each so the per-frame GUID writes have a
target."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
op_props = gz.target_set_operator.return_value
assert op_props.position == expected_position or op_props.position in ("START", "END")
def test_unjoin_icons_get_warning_color_highlight():
"""Destructive icons surface in the addon's warning red on hover so
they read as a deliberate target. ``color_highlight`` is overridden
after ``super().setup()`` wires the default highlight."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
warning_color = (1.0, 0.1, 0.1)
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
GizmoMEPActions._wire_anchored_icon_targets(inst)
assert isinstance(inst.unjoin_op_props, dict)
for name in GizmoMEPActions.UNJOIN_CONFIGS:
gz = getattr(inst, f"action_{name}_gizmo")
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
gz.target_set_operator.assert_any_call("bim.disconnect_elements")
assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}"
def test_bind_unjoin_pair_writes_both_guids():
"""``_bind_unjoin_pair`` is the per-frame hand-off from gizmo
position-gizmos to the unified disconnect operator: both segment
GlobalIds get written onto the pre-wired op_props so a click
dispatches with the right pair."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
pair_op_props = inst.unjoin_op_props["unjoin_pair"]
seg_a = Mock(GlobalId="GUID-A")
seg_b = Mock(GlobalId="GUID-B")
assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True
assert pair_op_props.element_a_guid == "GUID-A"
assert pair_op_props.element_b_guid == "GUID-B"
def test_bind_unjoin_pair_rejects_incomplete_pair():
"""Defensive: a selection mid-change can hand the gizmo a one-element
or None-containing pair. The bind must refuse rather than write a
half-resolved op_props that would later CANCEL with a confusing
error message."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False
def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids():
"""The per-port unjoin gizmo resolves the partner fitting at the
named port and writes (segment_guid, fitting_guid) onto the
pre-wired op_props so the unified disconnect operator gets both
endpoints."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
port_op_props = inst.unjoin_op_props["unjoin_end"]
segment_obj = Mock()
segment = Mock(GlobalId="SEG-GUID")
fitting = Mock(GlobalId="FIT-GUID")
fitting.is_a = lambda c: c == "IfcFlowFitting"
fitting.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting
):
ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False)
assert ok is True
assert port_op_props.element_a_guid == "SEG-GUID"
assert port_op_props.element_b_guid == "FIT-GUID"
def test_bind_unjoin_at_port_refuses_obstruction_partner():
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
routing them through the unified disconnect would just delete the
fitting and leave a visible gap. Mirror the find_rels exclusion
here so the icon hides when the partner is an obstruction."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
segment = Mock(GlobalId="SEG-GUID")
obstruction = Mock(GlobalId="OBS-GUID")
obstruction.is_a = lambda c: c == "IfcFlowFitting"
obstruction.PredefinedType = "OBSTRUCTION"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction
):
assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False
# ---------------------------------------------------------------------------
@@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight():
# ---------------------------------------------------------------------------
def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset():
"""A bend whose body has been tessellated as the upstream geometry-kernel
workaround still has its parametric definition on the type's
``BBIM_Fitting`` pset the re-edit operator reads from there, so the
pen icon must surface on it. ``has_parametric_body`` would return False
for the tessellated body; the pset gate is what makes the icon
reachable."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
bend_type = Mock()
bend_type.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset",
return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1},
):
assert _active_is_bend_fitting(bend_obj) is True
def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset():
"""A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType
== BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited
the re-edit operator reads parameters from the pset. Reject so the pen
icon hides rather than dispatching an operator that would CANCEL."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
bend_type = Mock()
bend_type.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None
):
assert _active_is_bend_fitting(bend_obj) is False
def test_active_is_bend_fitting_rejects_non_bend():
"""Non-bend objects (segments, fittings with PredefinedType != BEND)
fail the first gate regardless of pset state."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=False
):
assert _active_is_bend_fitting(bend_obj) is False
def test_active_is_flow_segment_handles_unbound_object():
"""A Blender object with no IFC binding must not raise from a
visibility predicate. The lambda runs on every selection event."""
@@ -0,0 +1,139 @@
# 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.
"""End-to-end integration tests for the unified MEP disconnect path.
Builds a real IFC scene (two pipe segments joined via ports to a bridging
fitting) and exercises the full chain:
tool.Connection.find_rels(seg_a, seg_b)
returns (fitting, "mep-pair-fitting")
bonsai.core.connection.disconnect_rel(subject=fitting, kind=...)
tool.Geometry.delete_ifc_object(fitting_obj)
cascade-on-delete removes the IfcRelConnectsPorts via remove_port
The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each
piece in isolation. This module pins that they compose the surface the
gizmo click hits in production."""
import bpy
import ifcopenshell.api.system
import pytest
import bonsai.core.connection
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.model
class TestMEPPairDisconnectEndToEnd(NewFile):
def _make_segment(self, name: str):
"""Create one IfcPipeSegment occurrence with ports at both ends.
Returns (blender_object, ifc_element)."""
bpy.ops.mesh.primitive_cube_add(size=1)
obj = bpy.data.objects["Cube"]
obj.name = name
bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="")
element = tool.Ifc.get_entity(obj)
tool.System.add_ports(obj)
return obj, element
def _make_bend_fitting(self, name: str):
"""Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two
ports. Manual setup bpy.ops.bim.assign_class doesn't add ports."""
bpy.ops.mesh.primitive_cube_add(size=0.3)
obj = bpy.data.objects["Cube"]
obj.name = name
bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="")
element = tool.Ifc.get_entity(obj)
tool.System.add_ports(obj)
return obj, element
def _setup_joined_pair(self):
bpy.ops.bim.create_project()
seg_a_obj, seg_a = self._make_segment("SegA")
seg_b_obj, seg_b = self._make_segment("SegB")
bend_obj, bend = self._make_bend_fitting("Bend")
ifc_file = tool.Ifc.get()
seg_a_ports = tool.System.get_ports(seg_a)
seg_b_ports = tool.System.get_ports(seg_b)
bend_ports = tool.System.get_ports(bend)
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0])
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1])
return seg_a, seg_b, bend, bend_obj
def test_find_rels_returns_mep_pair_fitting_subject(self):
seg_a, seg_b, bend, _ = self._setup_joined_pair()
rels = tool.Connection.find_rels(seg_a, seg_b)
assert rels == [(bend, "mep-pair-fitting")]
def test_disconnect_rel_removes_the_bridging_fitting(self):
"""The end-to-end contract: dispatch removes the fitting from the
IFC file, the Blender object is deleted, and a follow-up find_rels
on the same pair returns empty there's nothing left to disconnect."""
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
bend_id = bend.id()
bend_obj_name = bend_obj.name
ifc_file = tool.Ifc.get()
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=bend,
kind="mep-pair-fitting",
elem=seg_a,
partner=seg_b,
)
# The fitting is gone from the IFC file.
with pytest.raises(RuntimeError):
ifc_file.by_id(bend_id)
# The pair is no longer joined.
assert tool.Connection.find_rels(seg_a, seg_b) == []
# The Blender object was removed by delete_ifc_object.
assert bend_obj_name not in bpy.data.objects
def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self):
"""Cascade-side guard: if the fitting is itself the element being
deleted (subject is elem), skip the deletion is already in flight
and re-deleting would crash."""
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
bend_id = bend.id()
bend_obj_name = bend_obj.name
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=bend,
kind="mep-pair-fitting",
elem=bend,
partner=seg_a,
skip_elem_recreate=True,
)
# Fitting still present — the dispatch correctly skipped.
assert tool.Ifc.get().by_id(bend_id).id() == bend_id
assert bend_obj_name in bpy.data.objects
@@ -61,76 +61,6 @@ def _make_op(_cls, **fields):
return op
# ---------------------------------------------------------------------------
# MEPUnjoinAtPort
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"port_state, fitting_predefined_type, expected_result, expects_delete",
[
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
],
)
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
by ``(port_state, fitting type)``.
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
the bridging fitting is deleted via the standard delete entry point.
- ``JOINED + OBSTRUCTION``: deliberately refused obstructions go
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
extends to absorb the freed length; using delete here would leave
a visible gap.
- ``FREE``: nothing to do no bridging fitting exists. The operator
reports a user-facing error and CANCELS rather than no-op silently."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == expected_result
if expects_delete:
delete.assert_called_once_with(fitting_obj)
else:
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_active_is_not_segment():
"""The operator only operates on flow segments; non-segment active
objects must fail loud rather than mutate something unexpected."""
from bonsai.bim.module.model import mep
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = fitting
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPRemoveTerminalFitting
# ---------------------------------------------------------------------------
@@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port():
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPUnjoinPair
# ---------------------------------------------------------------------------
def test_unjoin_pair_deletes_bridging_fitting():
"""Happy path: two selected segments share a single non-OBSTRUCTION
bridging fitting delete it."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
fitting = _fitting(predefined_type="JUNCTION")
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_unjoin_pair_refuses_obstruction_bridging():
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
through the dedicated REMOVE path; this operator surfaces the
redirect rather than silently doing the wrong thing."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
"""The pair is selected but no single fitting bridges them — the
user is told instead of getting a silent no-op."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
"""The poll filters the gizmo, but a programmatic invocation could
still hand the operator an invalid selection. The execute path
independently verifies both inputs are IfcFlowSegment."""
from bonsai.bim.module.model import mep
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
):
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# SelectMEPPathMembers
# ---------------------------------------------------------------------------
@@ -0,0 +1,70 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestExecuteIfcPatchDowngradeEndToEnd(NewFile):
"""Drives the full panel flow the user sees: load IFC4 in memory, pick
Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the
expected IfcBuildingElementProxy fallback + ObjectType encoding.
A regression here means a real user clicking Execute either crashes
Blender, produces a broken file, or silently drops type information
that the recipe is supposed to preserve via ObjectType."""
def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self):
ifc = ifcopenshell.file(schema="IFC4")
ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
props.ifc_patch_recipes = "Migrate"
next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3"
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "downgraded.ifc"
props.ifc_patch_output = str(output_path)
result = bpy.ops.bim.execute_ifc_patch()
assert result == {"FINISHED"}
assert output_path.exists(), "Recipe ran but no output file was written"
written = ifcopenshell.open(str(output_path))
assert written.schema == "IFC2X3"
proxies = written.by_type("IfcBuildingElementProxy")
assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy"
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", (
"Original class + PredefinedType must be encoded into ObjectType "
"so the downgrade isn't a total information loss"
)
assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW"
@@ -0,0 +1,131 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None:
"""Drive the BIMPatchProperties into the configuration that a user produces
by picking Recipe + Schema in the panel + checking "Load from memory".
Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic
args collection only then can we assign the schema arg's enum_value."""
props = tool.Patch.get_patch_props()
if source_ifc is not None:
tool.Ifc.set(source_ifc)
props.should_load_from_memory = True
props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr
if target_schema is not None:
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = target_schema
class TestMigrationIsLossyDowngrade(NewFile):
"""Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s
confirmation popup. Every row of the truth table corresponds to a real
user-facing flow wrong answers either nag the user on safe migrations
or silently let lossy ones through with no warning."""
def test_ifc4_to_ifc2x3_in_memory_is_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self):
# Regression for the gate that originally only fired for self.file.schema == "IFC4",
# silently leaving IFC4X3 sources crashing on IFC4-only geometry.
ifc = ifcopenshell.file(schema="IFC4X3")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC2X3")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_ifc4_to_ifc4_same_schema_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_non_migrate_recipe_is_not_lossy(self):
# The popup only ever applies to the Migrate recipe — other recipes
# (ExtractElements, TessellateElements, …) handle their own warnings.
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_no_source_set_is_not_lossy(self):
# Without an input file or in-memory IFC, the predicate cannot tell
# what the source schema is — defaults to False so the popup doesn't
# block harmless cases where the user is still configuring the panel.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = "IFC2X3"
assert tool.Patch.migration_is_lossy_downgrade() is False
class TestPatchSourceSchemaSniff(NewFile):
"""End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection
bug originally lived in this code path a raw startswith(\"IFC4\") loop
matching IFC4X3_ADD2 before the IFC4X3 base check was reached."""
def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self):
ifc = ifcopenshell.file(schema="IFC4X3")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self):
# Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header
# and confirms the regex + get_fallback_schema normaliser correctly
# collapse it to IFC4X3, not IFC4.
with tempfile.TemporaryDirectory() as tmpdir:
ifc_path = Path(tmpdir) / "sample.ifc"
ifc_path.write_text(
"ISO-10303-21;\n"
"HEADER;\n"
"FILE_DESCRIPTION((''),'2;1');\n"
"FILE_NAME('','2026',(''),(''),'','','');\n"
"FILE_SCHEMA(('IFC4X3_ADD2'));\n"
"ENDSEC;\n"
"DATA;\nENDSEC;\nEND-ISO-10303-21;\n"
)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = str(ifc_path)
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_missing_input_returns_empty_string(self):
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = ""
assert tool.Patch._patch_source_schema() == ""
@@ -0,0 +1,55 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
import bpy
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestPresetMenuLabelResetsOnRecipeChange(NewFile):
"""Blender's ``script.execute_preset`` mutates the menu class's bl_label
to the loaded preset's display name as a "currently-selected" indicator.
Without a recipe-change callback, that label persists into the next
recipe's menu — falsely advertising a preset that belongs to a
different recipe's subdir and isn't selectable from the new menu."""
def test_changing_recipe_restores_canonical_label(self):
# Simulate the state Blender leaves after the user picked a preset
# for the previous recipe.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
menu_cls.bl_label = "Structural"
# Switching the recipe must fire update_ifc_patch_recipe, which
# resets the menu label.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
assert menu_cls.bl_label == "IFC Patch Presets"
def test_canonical_label_is_used_when_no_preset_was_loaded(self):
# Fresh state — label is the bl_label-default from the class declaration.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "ExtractElements"
assert menu_cls.bl_label == "IFC Patch Presets"
+168 -26
View File
@@ -60,8 +60,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem_a", partner="elem_b",
ifc,
geometry,
model,
connection,
subject="rel",
kind="path",
elem="elem_a",
partner="elem_b",
)
remove.assert_called_once_with(geometry, connection="rel")
@@ -78,8 +84,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
subject="rel",
kind="path",
elem="elem",
partner="partner",
skip_elem_recreate=True,
)
@@ -93,8 +105,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
subject="rel",
kind="path",
elem="elem",
partner="partner",
skip_partner_recreate=True,
)
@@ -108,8 +126,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
subject="rel",
kind="path",
elem="elem",
partner="partner",
skip_elem_recreate=True,
skip_partner_recreate=True,
)
@@ -131,13 +155,17 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel=rel, kind="element-top", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
subject=rel,
kind="element-top",
elem="elem",
partner="partner",
)
ifc.run.assert_called_once_with(
"geometry.disconnect_element", relating_element="slab", related_element="wall"
)
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="slab", related_element="wall")
regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
def test_slab_delete_cascade_still_regenerates_wall(self):
@@ -150,8 +178,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="slab", partner="wall",
ifc,
Mock(),
Mock(),
connection,
subject=rel,
kind="element-top",
elem="slab",
partner="wall",
skip_elem_recreate=True, # slab is being deleted
)
@@ -167,8 +201,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="wall", partner="slab",
ifc,
Mock(),
Mock(),
connection,
subject=rel,
kind="element-top",
elem="wall",
partner="slab",
skip_elem_recreate=True, # wall is being deleted
)
@@ -185,8 +225,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="slab", partner="wall",
ifc,
Mock(),
Mock(),
connection,
subject=rel,
kind="element-top",
elem="slab",
partner="wall",
skip_elem_recreate=True,
skip_partner_recreate=True, # wall also in batch
)
@@ -200,19 +246,115 @@ class TestDisconnectRelElement:
ifc = Mock()
subject.disconnect_rel(
ifc, Mock(), Mock(), Mock(),
rel=rel, kind="element", elem="elem_a", partner="elem_b",
ifc,
Mock(),
Mock(),
Mock(),
subject=rel,
kind="element",
elem="elem_a",
partner="elem_b",
)
ifc.run.assert_called_once_with(
"geometry.disconnect_element", relating_element="A", related_element="B"
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="A", related_element="B")
class TestDisconnectRelMEPPairFitting:
"""The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose
removal disconnects the pair deletion routes through
``geometry.delete_ifc_object`` so the cascade-on-delete contract still
owns port-rel cleanup."""
def test_deletes_fitting_via_delete_ifc_object(self):
fitting = Mock(name="fitting")
fitting_obj = Mock(name="fitting_obj")
ifc = _ifc_with_objects({fitting: fitting_obj})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
subject=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner="seg_b",
)
geometry.delete_ifc_object.assert_called_once_with(fitting_obj)
def test_noops_when_fitting_has_no_blender_object(self):
"""Defensive: a fitting with no bound Blender object can't be
deleted via ``delete_ifc_object``; the dispatch must not crash."""
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
subject=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner="seg_b",
)
geometry.delete_ifc_object.assert_not_called()
def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self):
"""Cascade case: the fitting is itself the element being deleted
don't try to delete it twice."""
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({fitting: Mock()})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
subject=fitting,
kind="mep-pair-fitting",
elem=fitting,
partner="other",
skip_elem_recreate=True,
)
geometry.delete_ifc_object.assert_not_called()
def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self):
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({fitting: Mock()})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
subject=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner=fitting,
skip_partner_recreate=True,
)
geometry.delete_ifc_object.assert_not_called()
class TestDisconnectRelUnknownKind:
def test_raises_value_error(self):
with pytest.raises(ValueError, match="Unknown rel kind"):
with pytest.raises(ValueError, match="Unknown kind"):
subject.disconnect_rel(
Mock(), Mock(), Mock(), Mock(),
rel="rel", kind="bogus", elem="a", partner="b",
Mock(),
Mock(),
Mock(),
Mock(),
subject="rel",
kind="bogus",
elem="a",
partner="b",
)
@@ -0,0 +1,369 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 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.
import math
import bpy
import ifcopenshell
import ifcopenshell.api.spatial
import ifcopenshell.api.type
import pytest
from mathutils import Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
"""Real bpy cube + ifc entity, linked. ``size`` is the cube edge length."""
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
obj = bpy.context.active_object
entity = ifc.create_entity(ifc_class)
tool.Ifc.link(entity, obj)
return entity, obj
class TestWorldBboxMatrix(NewFile):
def test_two_cubes_returns_centred_aabb_matrix(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
assert matrix is not None
translation, _, scale = matrix.decompose()
# World AABB: x in [-1, 5], y/z in [-1, 1] -> center (2, 0, 0), half (3, 1, 1).
assert translation.x == pytest.approx(2.0)
assert translation.y == pytest.approx(0.0)
assert translation.z == pytest.approx(0.0)
assert scale.x == pytest.approx(3.0)
assert scale.y == pytest.approx(1.0)
assert scale.z == pytest.approx(1.0)
def test_empty_iterable_returns_none(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assert tool.ClipBox._world_bbox_matrix_for_elements([]) is None
def test_element_without_blender_object_is_skipped(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
unbound = ifc.create_entity("IfcWall")
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, unbound])
assert matrix is not None
translation, _, scale = matrix.decompose()
assert translation.x == pytest.approx(0.0)
assert translation.y == pytest.approx(0.0)
assert translation.z == pytest.approx(0.0)
assert scale.x == pytest.approx(1.0)
def test_all_filtered_returns_none(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
unbound_a = ifc.create_entity("IfcWall")
unbound_b = ifc.create_entity("IfcWall")
assert tool.ClipBox._world_bbox_matrix_for_elements([unbound_a, unbound_b]) is None
def test_coincident_cubes_return_invertible_matrix(self):
# Two cubes at the same location collapse to a zero-volume AABB.
# The half-extent floor must keep the matrix invertible so downstream
# clip-plane math doesn't divide through a singular transform.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
assert matrix is not None
# A zero determinant means the matrix would map every point onto a
# subspace — the floor must prevent that.
assert matrix.determinant() != 0.0
class TestCameraFrustumMatrix(NewFile):
def _make_camera(self, location=(0.0, 0.0, 0.0), rotation=None):
cam_data = bpy.data.cameras.new("DrawingCam")
cam_data.type = "ORTHO"
obj = bpy.data.objects.new("DrawingCam", cam_data)
bpy.context.scene.collection.objects.link(obj)
obj.location = location
if rotation is not None:
obj.rotation_euler = rotation
bpy.context.view_layer.update()
return obj
def test_identity_camera_width_height_drive_in_plane_extents(self):
obj = self._make_camera()
cam = obj.data
cam.clip_start = 0.0
cam.clip_end = 10.0
cam.BIMCameraProperties.width = 8.0
cam.BIMCameraProperties.height = 6.0
matrix = tool.ClipBox._camera_frustum_matrix(obj)
translation, _, scale = matrix.decompose()
# Identity rotation: box centre at (0, 0, -5) in world (cameras look down -Z).
assert translation.x == pytest.approx(0.0)
assert translation.y == pytest.approx(0.0)
assert translation.z == pytest.approx(-5.0)
# Half-extents: width/2, height/2, (clip_end - clip_start) / 2.
assert scale.x == pytest.approx(4.0)
assert scale.y == pytest.approx(3.0)
assert scale.z == pytest.approx(5.0)
def test_rotated_camera_preserves_rotation_in_matrix(self):
obj = self._make_camera(rotation=(0.0, math.radians(90), 0.0))
cam = obj.data
cam.clip_start = 0.0
cam.clip_end = 4.0
cam.BIMCameraProperties.width = 2.0
cam.BIMCameraProperties.height = 2.0
matrix = tool.ClipBox._camera_frustum_matrix(obj)
_, rotation, scale = matrix.decompose()
# Scale is rotation-invariant.
assert scale.x == pytest.approx(1.0)
assert scale.y == pytest.approx(1.0)
assert scale.z == pytest.approx(2.0)
# The rotation component matches the camera's own rotation; quaternion
# dot product near unit magnitude means the orientations agree.
cam_rot = obj.matrix_world.decompose()[1]
assert abs(cam_rot.dot(rotation)) > 0.999
def test_returns_none_when_width_height_zero(self):
# A camera without usable drawing extents (width or height ≤ 0)
# cannot define a clip volume — caller surfaces ERROR + CANCELLED.
obj = self._make_camera()
cam = obj.data
cam.clip_start = 0.0
cam.clip_end = 10.0
cam.BIMCameraProperties.width = 8.0
# height stays at the BIMCameraProperties default (50). We can't set
# height=0 here because the update callback divides width/height.
# Set width=0 directly via the underlying ID property instead, which
# bypasses the registered FloatProperty update path.
cam.BIMCameraProperties["width"] = 0.0
assert tool.ClipBox._camera_frustum_matrix(obj) is None
class TestIterElementsForSource(NewFile):
def test_no_ifc_file_returns_empty(self):
# NewFile leaves IfcStore purged; tool.Ifc.get() is None here.
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "1") == []
def test_unknown_kind_returns_empty(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifc.create_entity("IfcWall")
assert tool.ClipBox.iter_elements_for_source("UNKNOWN_KIND", str(wall.id())) == []
def test_non_integer_source_id_returns_empty(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "not_an_int") == []
def test_unresolved_source_id_returns_empty(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "999999") == []
def test_spatial_returns_decomposition(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
storey = ifc.create_entity("IfcBuildingStorey")
wall_a = ifc.create_entity("IfcWall")
wall_b = ifc.create_entity("IfcWall")
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id()))
assert set(result) == {wall_a, wall_b}
def test_type_returns_occurrences(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_type = ifc.create_entity("IfcWallType")
wall_a = ifc.create_entity("IfcWall")
wall_b = ifc.create_entity("IfcWall")
ifcopenshell.api.type.assign_type(ifc, related_objects=[wall_a, wall_b], relating_type=wall_type)
result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id()))
assert set(result) == {wall_a, wall_b}
def test_drawing_returns_drawing_entity(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
result = tool.ClipBox.iter_elements_for_source("DRAWING", str(drawing.id()))
assert result == [drawing]
def test_status_invalid_value_returns_empty(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assert tool.ClipBox.iter_elements_for_source("STATUS", "MADE_UP_STATUS") == []
def test_class_returns_all_instances_of_ifc_class(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a = ifc.create_entity("IfcWall")
wall_b = ifc.create_entity("IfcWall")
window = ifc.create_entity("IfcWindow")
result = tool.ClipBox.iter_elements_for_source("CLASS", "IfcWall")
assert set(result) == {wall_a, wall_b}
assert window not in result
def test_class_unknown_ifc_class_returns_empty(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifc.create_entity("IfcWall")
assert tool.ClipBox.iter_elements_for_source("CLASS", "IfcNotARealClass") == []
class TestComputeMatrixForSource(NewFile):
def test_spatial_aggregates_contained_elements(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
storey = ifc.create_entity("IfcBuildingStorey")
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id()))
assert matrix is not None
translation, _, scale = matrix.decompose()
assert translation.x == pytest.approx(2.0)
assert scale.x == pytest.approx(3.0)
def test_no_match_returns_none(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_type = ifc.create_entity("IfcWallType")
# No occurrences linked.
assert tool.ClipBox.compute_matrix_for_source("TYPE", str(wall_type.id())) is None
def test_drawing_uses_camera_frustum(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
cam_data = bpy.data.cameras.new("Cam")
cam_data.type = "ORTHO"
cam_data.clip_start = 0.0
cam_data.clip_end = 10.0
cam_data.BIMCameraProperties.width = 4.0
cam_data.BIMCameraProperties.height = 4.0
obj = bpy.data.objects.new("Cam", cam_data)
bpy.context.scene.collection.objects.link(obj)
tool.Ifc.link(drawing, obj)
matrix = tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id()))
assert matrix is not None
_, _, scale = matrix.decompose()
assert scale.x == pytest.approx(2.0)
assert scale.y == pytest.approx(2.0)
assert scale.z == pytest.approx(5.0)
def test_drawing_with_non_camera_returns_none(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
obj = bpy.data.objects.new("NotACamera", None)
bpy.context.scene.collection.objects.link(obj)
tool.Ifc.link(drawing, obj)
assert tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) is None
def test_status_with_no_matching_elements_returns_none(self):
# STATUS pick with a valid status value but no element carrying that
# status — the dispatcher must surface "nothing matched" the same way
# an empty TYPE / MATERIAL pick does.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
# Create a wall but never assign its Pset_WallCommon.Status — so a
# STATUS=NEW query finds 0 elements.
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
assert tool.ClipBox.compute_matrix_for_source("STATUS", "NEW") is None
class _FakeRegion:
def __init__(self, width, height):
self.width = width
self.height = height
class _FakeRV3D:
def __init__(self, view_matrix=()): # () is a truthy-enough non-None stand-in
self.view_matrix = view_matrix
self.updated = False
self.use_clip_planes = False
self.clip_planes = None
def update(self):
self.updated = True
class TestRegionIsRenderable:
"""``_region_is_renderable`` gates the clip-plane arm against collapsed /
initializing regions whose ``region_3d.update()`` would CTD Blender inside
``GPU_matrix_ortho_set`` (the timer-arm crash this guard fixes)."""
def test_sized_region_with_view_matrix_is_renderable(self):
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), _FakeRV3D()) is True
def test_zero_width_is_not_renderable(self):
assert tool.ClipBox._region_is_renderable(_FakeRegion(0, 600), _FakeRV3D()) is False
def test_zero_height_is_not_renderable(self):
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 0), _FakeRV3D()) is False
def test_missing_view_matrix_is_not_renderable(self):
rv3d = _FakeRV3D()
rv3d.view_matrix = None
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), rv3d) is False
def test_arm_region_early_returns_on_zero_size(self):
# A collapsed region must never reach temp_override / clip_border /
# update() — _arm_region short-circuits at the size guard. Positively
# assert update() was NOT called and no clip state was written, so a
# regression that drops the guard fails here rather than passing on
# "didn't crash".
rv3d = _FakeRV3D()
tool.ClipBox._arm_region(object(), _FakeRegion(0, 0), rv3d, ())
assert rv3d.updated is False
assert rv3d.use_clip_planes is False
assert rv3d.clip_planes is None
@@ -19,12 +19,12 @@
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups.
branch for every ``kind`` emitted by ``tool.connection.Connection`` lookups.
Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
Adding a new kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
would silently regress the disconnect operator and the cascade-on-delete: a new
kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")``
kind would reach the dispatch, hit the ``raise ValueError("Unknown kind")``
fallback, and either crash the operator or leave the cascade half-done. This
guard makes the symmetry mandatory at test time."""
+127
View File
@@ -23,6 +23,7 @@ import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.root
import ifcopenshell.api.system
import ifcopenshell.util.representation
import ifcopenshell.util.system
import ifcopenshell.util.unit
import numpy as np
@@ -39,6 +40,132 @@ class TestImplementsTool(NewFile):
assert isinstance(subject(), bonsai.core.tool.System)
class TestHasParametricBody(NewFile):
"""The MEP-action gizmo predicates gate on ``has_parametric_body``;
fittings whose swept body lives on the type via ``IfcMappedItem`` must
return True so the pen-icon and lock-icon rows show on the occurrence."""
def _build_bend_occurrence_with_mapped_body(self):
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
placement = ifc_file.create_entity(
"IfcAxis2Placement3D",
Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
)
line = ifc_file.create_entity(
"IfcLine",
Pnt=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Dir=ifc_file.create_entity(
"IfcVector",
Orientation=ifc_file.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Magnitude=1.0,
),
)
trimmed = ifc_file.create_entity(
"IfcTrimmedCurve",
BasisCurve=line,
Trim1=(ifc_file.create_entity("IfcParameterValue", wrappedValue=0.0),),
Trim2=(ifc_file.create_entity("IfcParameterValue", wrappedValue=1.0),),
SenseAgreement=True,
MasterRepresentation="PARAMETER",
)
swept = ifc_file.create_entity("IfcSweptDiskSolid", Directrix=trimmed, Radius=0.05)
type_body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="AdvancedSweptSolid",
Items=(swept,),
)
rep_map = ifc_file.create_entity(
"IfcRepresentationMap", MappingOrigin=placement, MappedRepresentation=type_body
)
fitting_type = ifc_file.create_entity(
"IfcPipeFittingType",
GlobalId=ifcopenshell.guid.new(),
Name="BendType",
PredefinedType="BEND",
RepresentationMaps=(rep_map,),
)
mapped_item = ifc_file.create_entity(
"IfcMappedItem",
MappingSource=rep_map,
MappingTarget=ifc_file.create_entity(
"IfcCartesianTransformationOperator3D",
LocalOrigin=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
),
)
occurrence_body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=(mapped_item,),
)
fitting = ifc_file.create_entity(
"IfcPipeFitting",
GlobalId=ifcopenshell.guid.new(),
Name="Bend",
PredefinedType="BEND",
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(occurrence_body,)),
)
ifc_file.create_entity(
"IfcRelDefinesByType",
GlobalId=ifcopenshell.guid.new(),
RelatedObjects=(fitting,),
RelatingType=fitting_type,
)
return fitting
def test_returns_true_for_swept_disk_via_mapped_item(self):
"""``traverse()`` follows the
``IfcMappedItem.MappingSource.MappedRepresentation`` chain so the
``IfcSweptDiskSolid`` on the type's body is reachable from the
occurrence's body representation. Bend fittings produced by the
bend-preview commit path use this exact representation shape."""
fitting = self._build_bend_occurrence_with_mapped_body()
assert subject.has_parametric_body(fitting) is True
def test_returns_false_for_tessellated_body(self):
"""The bend creation path replaces the swept-disk body with an
``IfcTriangulatedFaceSet`` as an upstream geometry-kernel
workaround. The traverse finds no extruded / swept solid, so the
predicate returns False pinning the constraint that drives the
``BBIM_Fitting`` pset fallback in the bend-icon visibility
predicate."""
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
coords = ifc_file.create_entity(
"IfcCartesianPointList3D",
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
)
tessellation = ifc_file.create_entity(
"IfcTriangulatedFaceSet",
Coordinates=coords,
CoordIndex=((1, 2, 3),),
)
body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=(tessellation,),
)
fitting = ifc_file.create_entity(
"IfcPipeFitting",
GlobalId=ifcopenshell.guid.new(),
Name="TessellatedBend",
PredefinedType="BEND",
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(body,)),
)
assert subject.has_parametric_body(fitting) is False
class TestAddPorts(NewFile):
def setup_mep_segment(self):
bpy.ops.bim.create_project()
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import functools
import json
import os
import time
@@ -148,6 +149,57 @@ def get_subtypes(
return get_classes(declaration)
def _enum_value_outside_target(attribute: ifcopenshell_wrapper.attribute, value: Any) -> bool:
"""``True`` when ``attribute`` is an enumeration and the string ``value``
is not in its declared items. Used by the Migrator to silently skip enum
values that exist in the source schema but not the target without
parsing C++ wrapper error strings."""
if not isinstance(value, str):
return False
try:
enum_items = ifcopenshell.util.attribute.get_enum_items(attribute)
except (AssertionError, AttributeError):
return False
return value not in enum_items
@functools.cache
def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: IFC_SCHEMA = "IFC4") -> frozenset[str]:
"""``IfcRepresentationItem`` subclasses present in ``source_schema`` but
missing in ``target_schema``.
Derived from the loaded schema declarations once per (source, target) pair
and cached. The result is the canonical set of geometry classes a
downgrade from ``source_schema`` to ``target_schema`` must convert
(``IfcPolygonalFaceSet``, ``IfcTriangulatedFaceSet``, ``IfcAdvancedBrep``,
B-splines, advanced surfaces, alignment curves on IFC4X3 2X3, ) or
purge. Defaults match the IFC4 IFC2X3 case for backwards compatibility
with the original caller."""
source = ifcopenshell_wrapper.schema_by_name(source_schema)
target = ifcopenshell_wrapper.schema_by_name(target_schema)
target_names = {decl.name() for decl in target.entities()}
result: set[str] = set()
for decl in source.entities():
if decl.name() in target_names:
continue
cursor: Any = decl
while cursor is not None:
if cursor.name() == "IfcRepresentationItem":
result.add(decl.name())
break
cursor = cursor.supertype()
return frozenset(result)
def ifc4_only_geometry_classes() -> frozenset[str]:
"""Backwards-compatible alias for the IFC4 → IFC2X3 geometry-gap set.
New code should call :func:`geometry_classes_introduced_after` with the
explicit (target, source) pair so IFC4X3 IFC2X3 downgrades pick up the
additional IFC4X3-only geometry classes."""
return geometry_classes_introduced_after("IFC2X3", "IFC4")
def reassign_class(
ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str
) -> ifcopenshell.entity_instance:
@@ -263,7 +315,20 @@ class Migrator:
migrated_ids: dict[int, int]
attribute_overrides: dict[int, dict[int, str]]
def __init__(self):
def __init__(self, *, fallback_element_to_proxy: bool = False) -> None:
"""Construct a schema migrator.
:param fallback_element_to_proxy: When ``True`` and the target schema is
IFC2X3, IFC4 entity classes that have no direct IFC2X3 equivalent
but inherit from ``IfcElement`` / ``IfcElementType`` are migrated as
``IfcBuildingElementProxy`` / ``IfcBuildingElementProxyType``
respectively, instead of raising. Caller code is then responsible
for preserving the lost original class information out-of-band (the
``Migrate`` ifcpatch recipe encodes it into ``ObjectType``).
Defaults to ``False`` so non-recipe callers keep the strict
failure-on-unmappable contract.
"""
self.fallback_element_to_proxy = fallback_element_to_proxy
self.migrated_ids = {}
self.attribute_overrides = {}
self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r"))
@@ -379,6 +444,17 @@ class Migrator:
self.migrated_ids[element.id()] = new_element.id()
return new_element
@staticmethod
def _is_subclass_of(ifc_class: str, ancestor: str, source_file: ifcopenshell.file) -> bool:
schema = ifcopenshell_wrapper.schema_by_name(source_file.schema_identifier)
try:
return is_a(schema.declaration_by_name(ifc_class), ancestor)
except RuntimeError:
# Class doesn't exist in the source schema — happens for cross-schema
# introspection of an entity created with a name the wrapper doesn't
# recognise. Treat as "not a subclass".
return False
def migrate_class(
self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file
) -> ifcopenshell.entity_instance:
@@ -389,15 +465,44 @@ class Migrator:
if isinstance(value, float):
ifc_class = "IfcQuantityNumber"
try:
new_element = new_file.create_entity(ifc_class)
return new_file.create_entity(ifc_class)
except:
# The element does not exist in this schema
# Complex migration is not yet supported (e.g. polygonal face set to faceted brep)
if new_file.schema == "IFC2X3":
new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class])
elif new_file.schema == "IFC4":
new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class])
return new_element
pass
# The class does not exist in the target schema — look up an equivalent.
# The lookup tables use empty-string as a sentinel meaning "no direct
# equivalent, needs geometric translation" (e.g. polygonal face set →
# faceted brep). Callers that want a clean downgrade are expected to
# preprocess such carriers before calling the Migrator; see the
# `Migrate` ifcpatch recipe.
if new_file.schema == "IFC2X3":
equivalent = self.class_4_to_2x3.get(ifc_class, None)
elif new_file.schema == "IFC4":
equivalent = self.class_2x3_to_4.get(ifc_class, None)
else:
equivalent = None
# IfcBuildingElementProxy fallback is opt-in (see constructor) — only
# the IfcElement / IfcElementType subtrees have a meaningful generic
# IFC2X3 stand-in; non-element IFC4-only classes (rels, geometry items,
# materials, times) still raise below.
if not equivalent and new_file.schema == "IFC2X3" and self.fallback_element_to_proxy:
if self._is_subclass_of(ifc_class, "IfcElement", element.wrapped_data.file):
equivalent = "IfcBuildingElementProxy"
elif self._is_subclass_of(ifc_class, "IfcElementType", element.wrapped_data.file):
equivalent = "IfcBuildingElementProxyType"
if not equivalent:
inverses = element.wrapped_data.file.get_inverse(element)
inverse_hint = ", ".join(f"#{i.id()}={i.is_a()}" for i in list(inverses)[:3])
if len(inverses) > 3:
inverse_hint += f", … (+{len(inverses) - 3} more)"
raise NotImplementedError(
f"Cannot migrate #{element.id()}={ifc_class} to schema "
f"{new_file.schema}: no direct equivalent exists. "
f"Referenced by: {inverse_hint or '(no inverses)'}."
)
return new_file.create_entity(equivalent)
def migrate_attributes(
self,
@@ -526,11 +631,40 @@ class Migrator:
new_value.append(self.migrate(item, new_file))
value = new_value
if value is not None:
if _enum_value_outside_target(attribute, value):
# Enum value present in source schema but missing in target
# (typically a downgrade after a cross-class fallback, e.g.
# IfcLamp.PredefinedType=COMPACTFLUORESCENT copied onto
# IfcBuildingElementProxy.CompositionType whose enum is
# IfcElementCompositionEnum). Leave the attribute unset rather
# than abort the whole entity's migration. Detected
# structurally so other RuntimeError causes (type mismatches,
# invalid values) still propagate.
return
setattr(new_element, attribute.name(), value)
def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any:
if attribute.name() in self.default_values:
return self.default_values[attribute.name()]
elif attribute.name() == "Position":
# IFC4 relaxed Position to OPTIONAL for many profile defs; IFC2X3
# still requires it. Synthesize a unit placement at origin so
# IfcIShapeProfileDef and friends downgrade without crashing
# downstream validators.
try:
type_name = attribute.type_of_attribute().as_named_type().declared_type().name()
except Exception:
type_name = None
if type_name == "IfcAxis2Placement2D":
return new_file.create_entity(
"IfcAxis2Placement2D",
Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0)),
)
if type_name == "IfcAxis2Placement3D":
return new_file.create_entity(
"IfcAxis2Placement3D",
Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0)),
)
elif attribute.name() == "OwnerHistory":
self.default_entities[attribute.name()] = new_file.create_entity(
"IfcOwnerHistory",
@@ -21,7 +21,7 @@ from __future__ import annotations
import collections.abc
from collections.abc import Sequence
from itertools import chain
from math import atan, cos, degrees, pi, radians, sin, sqrt, tan
from math import atan, atan2, cos, degrees, hypot, isclose, pi, radians, sin, sqrt, tan
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import numpy as np
@@ -301,6 +301,130 @@ def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]:
return x1 + t * (x2 - x1)
def arc_to_polyline_points(
start: VectorType, mid: VectorType, end: VectorType, subdivisions: int = 16
) -> list[tuple[float, ...]]:
"""Approximate a circular arc through (start, mid, end) with chord points.
The arc is determined uniquely by three points a circle is fit in the
XY plane and the angle is walked from start through mid to end, sampling
``subdivisions + 1`` points inclusive of the endpoints. Falls back to a
straight chord ``[start, end]`` for collinear / degenerate inputs.
Only planar arcs in the XY plane are supported. For 3D inputs (length 3
tuples), the Z coordinate of each output point is held constant at
``start[2]``. Inputs where start/mid/end have differing Z values raise
``ValueError`` rather than silently project caller should rotate the
arc into the XY plane first if it lives in a non-axis-aligned plane.
:raises ValueError: if subdivisions < 1, or if 3D inputs have mismatched
Z coordinates (non-planar arc).
"""
if subdivisions < 1:
raise ValueError(f"subdivisions must be >= 1, got {subdivisions}")
if len(start) >= 3:
# Tolerance accommodates floating-point noise from kernel transforms
# — IFC point coordinates that the author wrote as the same Z value
# may diverge by ~1e-15 after placement-matrix round-trips.
z_tol = 1e-9
if not (isclose(start[2], mid[2], abs_tol=z_tol) and isclose(start[2], end[2], abs_tol=z_tol)):
raise ValueError(
f"arc_to_polyline_points only handles arcs in the XY plane; "
f"got mismatched Z coordinates ({start[2]}, {mid[2]}, {end[2]})."
)
sx, sy = start[0], start[1]
mx, my = mid[0], mid[1]
ex, ey = end[0], end[1]
d = 2 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
if abs(d) < 1e-12:
return [tuple(start), tuple(end)]
cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (ex**2 + ey**2) * (sy - my)) / d
cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex) + (ex**2 + ey**2) * (mx - sx)) / d
a_start = atan2(sy - cy, sx - cx)
a_mid = atan2(my - cy, mx - cx)
a_end = atan2(ey - cy, ex - cx)
sweep = _signed_sweep_through_mid(a_start, a_mid, a_end)
radius = hypot(sx - cx, sy - cy)
pts: list[tuple[float, ...]] = []
for i in range(subdivisions + 1):
t = i / subdivisions
angle = a_start + sweep * t
x = cx + radius * cos(angle)
y = cy + radius * sin(angle)
if len(start) == 2:
pts.append((x, y))
else:
pts.append((x, y, start[2]))
return pts
def _signed_sweep_through_mid(a_start: float, a_mid: float, a_end: float) -> float:
"""Total angle (radians) from a_start to a_end going through a_mid."""
two_pi = 2 * pi
ccw_total = (a_end - a_start) % two_pi
ccw_to_mid = (a_mid - a_start) % two_pi
if ccw_to_mid <= ccw_total:
return ccw_total
return -((a_start - a_end) % two_pi)
def polygonal_face_set_to_faceted_brep(face_set: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
"""Convert an ``IfcPolygonalFaceSet`` or ``IfcTriangulatedFaceSet`` into an
``IfcFacetedBrep`` in the same file, preserving vertex coordinates and face
topology (including inner voids on ``IfcIndexedPolygonalFaceWithVoids``).
The returned brep is the canonical IFC2X3-compatible form of these IFC4
tessellated representations. The caller is responsible for rewiring inverse
references and removing the source face set when downgrading.
:raises TypeError: if ``face_set`` is not an ``IfcPolygonalFaceSet`` or
``IfcTriangulatedFaceSet``.
:raises ValueError: if ``face_set.Coordinates`` is missing or any face's
coordinate index references a vertex outside the coordinate list.
"""
if not (face_set.is_a("IfcPolygonalFaceSet") or face_set.is_a("IfcTriangulatedFaceSet")):
raise TypeError(
f"polygonal_face_set_to_faceted_brep expected IfcPolygonalFaceSet or "
f"IfcTriangulatedFaceSet, got {face_set.is_a()}."
)
if face_set.Coordinates is None:
raise ValueError(f"{face_set.is_a()} #{face_set.id()} has no Coordinates point list.")
ifc_file = face_set.file
coords = face_set.Coordinates.CoordList
vertex_count = len(coords)
ifc_points = [ifc_file.createIfcCartesianPoint(tuple(c)) for c in coords]
def _resolve(indices: Sequence[int]) -> list[ifcopenshell.entity_instance]:
# IfcIndexedPolygonalFace.CoordIndex / IfcTriangulatedFaceSet.CoordIndex
# are 1-based. Out-of-range hits early with a clear message rather
# than the cryptic IndexError from list[i-1].
out = []
for index in indices:
if not 1 <= index <= vertex_count:
raise ValueError(
f"{face_set.is_a()} #{face_set.id()} face references vertex {index}, "
f"outside CoordList range 1..{vertex_count}."
)
out.append(ifc_points[index - 1])
return out
ifc_faces: list[ifcopenshell.entity_instance] = []
if face_set.is_a("IfcTriangulatedFaceSet"):
for triangle in face_set.CoordIndex:
loop = ifc_file.createIfcPolyLoop(_resolve(triangle))
ifc_faces.append(ifc_file.createIfcFace([ifc_file.createIfcFaceOuterBound(loop, True)]))
else: # IfcPolygonalFaceSet
for indexed_face in face_set.Faces:
outer_loop = ifc_file.createIfcPolyLoop(_resolve(indexed_face.CoordIndex))
bounds = [ifc_file.createIfcFaceOuterBound(outer_loop, True)]
if indexed_face.is_a("IfcIndexedPolygonalFaceWithVoids"):
for inner in indexed_face.InnerCoordIndices or ():
bounds.append(ifc_file.createIfcFaceBound(ifc_file.createIfcPolyLoop(_resolve(inner)), True))
ifc_faces.append(ifc_file.createIfcFace(bounds))
return ifc_file.createIfcFacetedBrep(ifc_file.createIfcClosedShell(ifc_faces))
# Note: using ShapeBuilder try not to reuse IFC elements in the process
# otherwise you might run into situation where builder.mirror or other operation
# is applied twice during one run to the same element
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.util.schema as subject
import test.bootstrap
@@ -119,6 +122,157 @@ END-ISO-10303-21;
assert isinstance(qt_float_count_measure_ifc4x3[3], float)
assert qt_float_count_measure_ifc4x3[3] == 723.0
def test_migrate_class_raises_clear_error_for_ifc4_only_non_element_class_to_ifc2x3(self):
"""IFC4-only non-element classes (geometry items, etc.) have no
IfcBuildingElementProxy fallback and must surface a clear error naming
the failing class not the cryptic 'Entity name not found in schema'."""
ifc4_file = ifcopenshell.api.project.create_file()
point_list = ifc4_file.create_entity("IfcCartesianPointList2D", CoordList=((0.0, 0.0), (1.0, 0.0)))
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
migrator = subject.Migrator()
with pytest.raises(NotImplementedError) as exc_info:
migrator.migrate(point_list, ifc2x3_file)
message = str(exc_info.value)
assert "IfcCartesianPointList2D" in message
assert "IFC2X3" in message
def test_migrate_class_falls_back_to_ifcbuildingelementproxy_when_opt_in(self):
"""With ``fallback_element_to_proxy=True``, IFC4-only IfcElement
subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ) migrate
as IfcBuildingElementProxy instead of raising. Default behavior
(no opt-in) raises so non-recipe callers keep the strict contract."""
ifc4_file = ifcopenshell.api.project.create_file()
lamp = ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
# Default migrator raises (strict contract preserved).
with pytest.raises(NotImplementedError, match="IfcLamp"):
subject.Migrator().migrate(lamp, ifc2x3_file)
# Opt-in migrator substitutes IfcBuildingElementProxy.
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
new_lamp = subject.Migrator(fallback_element_to_proxy=True).migrate(lamp, ifc2x3_file)
assert new_lamp.is_a("IfcBuildingElementProxy")
class TestGetFallbackSchema:
"""Pins the schema-identifier normalisation contract relied on by callers
that need to map upstream variants (IFC4X3_ADD2, IFC2X3_TC1, IFC4_ADD2, )
to a base schema name for compatibility tables / downgrade detection."""
def test_ifc4x3_variants_collapse_to_ifc4x3(self):
# Longest-prefix-first: IFC4X3_ADD2 must NOT be misclassified as IFC4
# — the function checks IFC4X3 before IFC4.
assert subject.get_fallback_schema("IFC4X3") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD1") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD2") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_RC1") == "IFC4X3"
def test_ifc4_variants_collapse_to_ifc4(self):
assert subject.get_fallback_schema("IFC4") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD1") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD2") == "IFC4"
# IFC4X1 / IFC4X2 are draft schemas — collapse to IFC4 by design.
assert subject.get_fallback_schema("IFC4X1") == "IFC4"
assert subject.get_fallback_schema("IFC4X2") == "IFC4"
def test_ifc2x3_variants_collapse_to_ifc2x3(self):
assert subject.get_fallback_schema("IFC2X3") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_TC1") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_FINAL") == "IFC2X3"
def test_unknown_version_asserts(self):
# Asserts under non-optimised Python; in -O mode would return the
# unmodified input. Caller should guard accordingly.
with pytest.raises(AssertionError):
subject.get_fallback_schema("IFC10")
class TestIfc4OnlyGeometryClasses:
def test_known_ifc4_only_classes_present(self):
result = subject.ifc4_only_geometry_classes()
# Classes that genuinely don't exist in IFC2X3 and inherit
# IfcRepresentationItem in IFC4.
for name in (
"IfcPolygonalFaceSet",
"IfcTriangulatedFaceSet",
"IfcIndexedPolyCurve",
"IfcCartesianPointList3D",
"IfcAdvancedBrep",
):
assert name in result, f"{name} should be classified as IFC4-only geometry"
def test_ifc2x3_compatible_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# Classes that exist in both schemas — must NOT be flagged.
for name in ("IfcPolyline", "IfcFacetedBrep", "IfcCartesianPoint", "IfcExtrudedAreaSolid"):
assert name not in result, f"{name} exists in IFC2X3, should not be IFC4-only"
def test_non_geometry_ifc4_only_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# IFC4-only but not IfcRepresentationItem subclasses — out of scope.
for name in ("IfcEvent", "IfcWorkCalendar", "IfcLamp"):
assert name not in result, f"{name} is not an IfcRepresentationItem subclass"
def test_result_is_cached_frozenset(self):
first = subject.ifc4_only_geometry_classes()
second = subject.ifc4_only_geometry_classes()
assert first is second # @functools.cache returns the same object
class TestGeometryClassesIntroducedAfter:
"""Generalised version of ``ifc4_only_geometry_classes`` — pins the
schema-aware contract that supports IFC4X3 IFC2X3 downgrades, not just
IFC4 IFC2X3."""
def test_ifc4_to_ifc2x3_matches_legacy_helper(self):
# The legacy ``ifc4_only_geometry_classes`` is now a thin alias.
assert subject.geometry_classes_introduced_after("IFC2X3", "IFC4") == subject.ifc4_only_geometry_classes()
def test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3(self):
# IFC4X3 is a superset of IFC4 — every IFC4-only geometry class is
# also missing from IFC2X3 when the source is IFC4X3, plus any new
# IFC4X3-only geometry (alignment curves, distance expressions, …).
ifc4_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4")
ifc4x3_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4X3")
assert ifc4_gap <= ifc4x3_gap
def test_ifc4_to_ifc4x3_is_empty(self):
# IFC4X3 contains every IFC4 IfcRepresentationItem subclass — no
# IFC4 class is missing from IFC4X3.
assert subject.geometry_classes_introduced_after("IFC4X3", "IFC4") == frozenset()
class TestEnumValueOutsideTarget:
@staticmethod
def _attr(class_name: str, attr_name: str):
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC2X3")
decl = schema.declaration_by_name(class_name)
return next(a for a in decl.all_attributes() if a.name() == attr_name)
def test_enum_value_present_in_target_returns_false(self):
# IfcCovering.PredefinedType is IfcCoveringTypeEnum — CEILING is valid.
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "CEILING") is False
def test_enum_value_missing_in_target_returns_true(self):
# IfcCoveringTypeEnum has no COMPACTFLUORESCENT (an IfcLampTypeEnum value).
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "COMPACTFLUORESCENT") is True
def test_non_enum_attribute_returns_false(self):
# IfcCovering.Name is IfcLabel — not an enum, so the helper must return False.
attr = self._attr("IfcCovering", "Name")
assert subject._enum_value_outside_target(attr, "anything") is False
def test_non_string_value_returns_false(self):
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, 42) is False
class TestExtendedMaterialProperties(test.bootstrap.IFC4):
def test_migrate_extended_material_properties_ifc2x3_ifc4(self):
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
material = ifc2x3_file.createIfcMaterial(Name="Material")
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from math import degrees, radians
from math import degrees, radians, sqrt
from typing import Any, Union
import numpy as np
@@ -28,6 +28,7 @@ import test.bootstrap
from ifcopenshell.util.shape_builder import (
ShapeBuilder,
V,
arc_to_polyline_points,
is_x,
np_angle,
np_angle_signed,
@@ -36,9 +37,116 @@ from ifcopenshell.util.shape_builder import (
np_normal,
np_rotation_matrix,
np_to_3d,
polygonal_face_set_to_faceted_brep,
)
class TestArcToPolylinePoints:
def test_quarter_arc_2d_samples_n_plus_one_points(self):
# Quarter arc from (1,0) through (cos45°, sin45°) to (0,1) — unit circle.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points((1.0, 0.0), (sqrt_half, sqrt_half), (0.0, 1.0), 8)
assert len(points) == 9
assert points[0] == pytest.approx((1.0, 0.0), abs=1e-9)
assert points[-1] == pytest.approx((0.0, 1.0), abs=1e-9)
for x, y in points:
assert x * x + y * y == pytest.approx(1.0, abs=1e-9)
def test_collinear_inputs_fall_back_to_straight_chord(self):
points = arc_to_polyline_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 16)
assert points == [(0.0, 0.0), (2.0, 0.0)]
def test_3d_inputs_with_constant_z_preserved(self):
points = arc_to_polyline_points((1.0, 0.0, 5.0), (0.7071, 0.7071, 5.0), (0.0, 1.0, 5.0), 4)
assert len(points) == 5
assert all(p[2] == 5.0 for p in points)
def test_3d_inputs_with_mismatched_z_raises(self):
with pytest.raises(ValueError, match="XY plane"):
arc_to_polyline_points((1.0, 0.0, 0.0), (0.0, 1.0, 1.0), (-1.0, 0.0, 0.0))
def test_3d_inputs_with_near_equal_z_pass_within_tolerance(self):
# Real IFC files often have float noise of ~1e-15 in Z values that the
# author meant to be identical — kernel transforms introduce it. The
# planar check tolerates this rather than rejecting valid input.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points(
(1.0, 0.0, 5.0), (sqrt_half, sqrt_half, 5.0 + 1e-15), (0.0, 1.0, 5.0 - 2e-16), 4
)
assert len(points) == 5
def test_subdivisions_zero_raises(self):
with pytest.raises(ValueError, match="subdivisions"):
arc_to_polyline_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0)
class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4):
def test_triangulated_face_set_preserves_coordinates(self):
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)),
)
face_set = self.file.create_entity(
"IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 2, 4), (2, 3, 4), (3, 1, 4), (1, 3, 2)]
)
brep = polygonal_face_set_to_faceted_brep(face_set)
assert brep.is_a("IfcFacetedBrep")
assert len(brep.Outer.CfsFaces) == 4
# Every CoordList vertex appears in the brep at the same coordinate.
brep_points = {tuple(p.Coordinates) for f in brep.Outer.CfsFaces for p in f.Bounds[0].Bound.Polygon}
assert (0.0, 0.0, 0.0) in brep_points
assert (1.0, 0.0, 0.0) in brep_points
assert (0.0, 1.0, 0.0) in brep_points
assert (0.5, 0.5, 1.0) in brep_points
def test_polygonal_face_set_with_voids_preserves_inner_bounds(self):
# Quad with a triangular hole through it.
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=(
(0.0, 0.0, 0.0),
(4.0, 0.0, 0.0),
(4.0, 4.0, 0.0),
(0.0, 4.0, 0.0),
(1.0, 1.0, 0.0),
(3.0, 1.0, 0.0),
(2.0, 3.0, 0.0),
),
)
face = self.file.create_entity(
"IfcIndexedPolygonalFaceWithVoids",
CoordIndex=(1, 2, 3, 4),
InnerCoordIndices=[(5, 6, 7)],
)
face_set = self.file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=[face])
brep = polygonal_face_set_to_faceted_brep(face_set)
assert len(brep.Outer.CfsFaces) == 1
bounds = brep.Outer.CfsFaces[0].Bounds
# Outer + 1 inner bound.
assert len(bounds) == 2
outer = next(b for b in bounds if b.is_a("IfcFaceOuterBound"))
inner = next(b for b in bounds if not b.is_a("IfcFaceOuterBound"))
assert len(outer.Bound.Polygon) == 4
assert len(inner.Bound.Polygon) == 3
def test_wrong_class_raises_typeerror(self):
# An IfcCartesianPointList3D is not a face set.
not_a_face_set = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
with pytest.raises(TypeError, match="IfcPolygonalFaceSet"):
polygonal_face_set_to_faceted_brep(not_a_face_set)
def test_out_of_range_index_raises_valueerror(self):
coords = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
# CoordIndex 5 doesn't exist in a 1-vertex coord list.
face_set = self.file.create_entity("IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 1, 5)])
with pytest.raises(ValueError, match="outside CoordList range"):
polygonal_face_set_to_faceted_brep(face_set)
class TestMathutilsCompatibleMethods(test.bootstrap.IFC4):
def test_np_rotation_matrix(self):
from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
@@ -17,6 +17,12 @@
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.util.element
import ifcopenshell.util.shape_builder
# Number of straight chords used to approximate one IfcArcIndex when flattening
# an IfcIndexedPolyCurve to an IfcPolyline. Higher values track the true arc
# more closely at the cost of file weight.
ARC_SUBDIVISION = 16
class Patcher:
@@ -34,6 +40,9 @@ class Patcher:
an IFC4 model (IFC2X3 does not have this geometry type) to help
compatibility in viewers like Navisworks.
Arc segments (``IfcArcIndex``) are approximated by a chord polyline
through ``ARC_SUBDIVISION`` evenly-spaced points along the arc.
Example:
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "DowngradeIndexedPolyCurve", "arguments": []})
@@ -47,19 +56,46 @@ class Patcher:
curve_map = {}
for curve in self.file.by_type("IfcIndexedPolyCurve"):
if "IfcArcIndex" in [s.is_a() for s in curve.Segments]:
print("Could not convert curve due to arcs", curve)
continue
coordinates = curve.Points.CoordList
points = []
for i, segment in enumerate(curve.Segments):
segment = segment.wrappedValue
if i == 0:
points.append(self.file.createIfcCartesianPoint(coordinates[segment[0] - 1]))
points.append(self.file.createIfcCartesianPoint(coordinates[segment[1] - 1]))
polyline = self.file.create_entity("IfcPolyline", points)
segments = curve.Segments
if segments is None:
# IFC4: an absent Segments list means the curve is a polyline
# through every CoordList point in declared order.
points = [tuple(c) for c in coordinates]
else:
points = self._segments_to_points(segments, coordinates)
if points is None:
continue
ifc_points = [self.file.createIfcCartesianPoint(p) for p in points]
polyline = self.file.create_entity("IfcPolyline", ifc_points)
curve_map[curve] = polyline
for curve, polyline in curve_map.items():
for inverse in self.file.get_inverse(curve):
ifcopenshell.util.element.replace_attribute(inverse, curve, polyline)
ifcopenshell.util.element.replace_element(curve, polyline)
def _segments_to_points(self, segments, coordinates):
points: list[tuple[float, ...]] = []
for i, segment in enumerate(segments):
indices = segment.wrappedValue
if segment.is_a("IfcArcIndex"):
if len(indices) != 3:
return None
arc_points = ifcopenshell.util.shape_builder.arc_to_polyline_points(
coordinates[indices[0] - 1],
coordinates[indices[1] - 1],
coordinates[indices[2] - 1],
ARC_SUBDIVISION,
)
if i == 0:
points.append(tuple(arc_points[0]))
points.extend(tuple(p) for p in arc_points[1:])
else:
# IfcLineIndex is LIST [2:?] OF IfcPositiveInteger — a polyline
# through every listed index. Skip the first index on non-leading
# segments since it duplicates the previous segment's endpoint.
seg_points = [tuple(coordinates[idx - 1]) for idx in indices]
if i == 0:
points.extend(seg_points)
else:
points.extend(seg_points[1:])
return points
@@ -41,7 +41,14 @@ class Patcher(ifcpatch.BasePatcher):
to a new IFC file. For example, you might want to extract only the walls
in a model and save it as a new model.
:param query: A query to select the subset of IFC elements.
:param query: A query to select the subset of IFC elements, using the
ifcopenshell.util.selector.filter_elements grammar. Supports
exclusion (blacklist) via '!' on entity classes and '!=' on
attribute / pset / material / classification / location / group
facets. Entity-class exclusion does not auto-seed from "all
elements", so a bare '! IfcSlab' query returns nothing — start
with a broad include (e.g. 'IfcProduct', 'IfcElement') and
subtract from it.
:param assume_asset_uniqueness_by_name: Avoid adding assets (profiles, materials, styles)
with the same name multiple times. Which helps in avoiding duplicated assets.
-----
@@ -63,6 +70,12 @@ class Patcher(ifcpatch.BasePatcher):
# Extract all walls and slabs
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]})
# Extract everything except slabs (seed with a broad include, then subtract)
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcProduct, ! IfcSlab"]})
# Extract walls whose Name is not "Foo"
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, attribute.Name != \"Foo\""]})
"""
super().__init__(file, logger)
self.query = query
@@ -190,6 +190,8 @@ class Patcher(ifcpatch.BasePatcher):
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
for curve in self.file.by_type("IfcIndexedPolyCurve"):
if curve.Segments is None:
continue
if True in [s.is_a("IfcArcIndex") for s in curve.Segments]:
shape = ifcopenshell.geom.create_shape(settings, curve)
e = shape.edges
+133 -7
View File
@@ -20,7 +20,9 @@ from logging import Logger
from typing import Union
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.schema
import ifcopenshell.util.shape_builder
import ifcpatch
@@ -32,10 +34,39 @@ class Patcher(ifcpatch.BasePatcher):
logger: Union[Logger, None] = None,
schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4",
):
"""Migrate from one IFC version to another
"""Migrate from one IFC version to another.
Note that this is experimental and will try to preserve as much data as
possible. Upgrading to IFC4 is more stable than downgrading to IFC2X3.
The recipe iterates every entity in the source file and rewrites it
into a new file with the target schema, delegating per-entity class /
attribute translation to :class:`ifcopenshell.util.schema.Migrator`.
Upgrades (IFC2X3 IFC4, IFC4 IFC4X3) are best supported because the
target schema is a superset; downgrades are lossy by definition (see
below). Entities that fail to migrate are collected; on completion a
summary ``RuntimeError`` is raised listing up to 20 failures.
IFC4 IFC2X3 downgrade additionally runs a preprocessing pipeline so
IFC4-only geometry and element classes survive the schema gap:
- ``IfcIndexedPolyCurve`` (including arc segments, approximated by a
chord polyline) is flattened to ``IfcPolyline``.
- ``IfcPolygonalFaceSet`` and ``IfcTriangulatedFaceSet`` are converted
directly to ``IfcFacetedBrep`` at the entity level, preserving the
original mesh topology.
- Orphan IFC4-only geometry instances left over after the rewires are
purged so the migration loop does not trip on them.
- IFC4-only ``IfcElement`` subclasses (``IfcLamp``, ``IfcPipeSegment``,
``IfcGeographicElement``, ) fall back to ``IfcBuildingElementProxy``
via the Migrator's ``fallback_element_to_proxy`` opt-in. The
original class and ``PredefinedType`` are encoded into
``ObjectType`` (e.g. ``"IfcLamp/COMPACTFLUORESCENT"``) when
``ObjectType`` is empty, so the type information survives the
downgrade.
Non-element IFC4-only entities (relationships, geometry items outside
any product, ) that have no direct equivalent still raise
``NotImplementedError`` from the Migrator with the failing class and
inverse references named, instead of the cryptic
``Entity with name '' not found in schema 'IFC2X3'``.
:param schema: The schema identifier of the IFC version to migrate to.
@@ -50,10 +81,105 @@ class Patcher(ifcpatch.BasePatcher):
self.schema = schema
def patch(self):
# IFC4 and IFC4X3 both have geometry / element classes absent in
# IFC2X3, so both source schemas need the downgrade preprocessing +
# IfcBuildingElementProxy fallback when targeting IFC2X3.
is_downgrade_to_ifc2x3 = self.schema == "IFC2X3" and self.file.schema in ("IFC4", "IFC4X3")
if is_downgrade_to_ifc2x3:
self._prepare_for_downgrade()
self.file_patched = ifcopenshell.file(schema=self.schema)
migrator = ifcopenshell.util.schema.Migrator()
migrator = ifcopenshell.util.schema.Migrator(fallback_element_to_proxy=is_downgrade_to_ifc2x3)
migrator.preprocess(self.file, self.file_patched)
migrated = 0
failures: list[tuple[ifcopenshell.entity_instance, Exception]] = []
for element in self.file:
new_element = migrator.migrate(element, self.file_patched)
print("Migrating", element)
print("Successfully converted to", new_element)
try:
migrator.migrate(element, self.file_patched)
migrated += 1
except Exception as exc:
failures.append((element, exc))
if is_downgrade_to_ifc2x3:
self._encode_fallback_class_into_object_type(migrator)
# BasePatcher.__init__ guarantees self.logger is non-None
# (ensure_logger falls back to logging.getLogger("IFCPatch")).
self.logger.info(f"Migrated {migrated} entities to {self.schema}.")
if failures:
summary = [f"{len(failures)} entities could not be migrated to {self.schema}:"]
for element, exc in failures[:20]:
summary.append(f" #{element.id()}={element.is_a()}: {exc}")
if len(failures) > 20:
summary.append(f" … (+{len(failures) - 20} more)")
raise RuntimeError("\n".join(summary))
def _prepare_for_downgrade(self) -> None:
from ifcpatch.recipes.DowngradeIndexedPolyCurve import Patcher as DowngradePolyCurve
DowngradePolyCurve(self.file, self.logger).patch()
self._convert_face_sets_to_faceted_brep()
self._purge_orphaned_ifc4_only_entities()
def _convert_face_sets_to_faceted_brep(self) -> None:
face_sets = list(self.file.by_type("IfcPolygonalFaceSet")) + list(self.file.by_type("IfcTriangulatedFaceSet"))
if not face_sets:
return
# IfcShapeRepresentations carrying these face sets need their type tag
# updated from "Tessellation" (IFC4) to "Brep" (IFC2X3-compatible).
# Snapshot the relevant inverses before rewiring — the inverse set is
# invalidated once replace_element runs.
touched_reps: set[int] = set()
for face_set in face_sets:
faceted_brep = ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep(face_set)
touched_reps.update(
inv.id() for inv in self.file.get_inverse(face_set) if inv.is_a("IfcShapeRepresentation")
)
ifcopenshell.util.element.replace_element(face_set, faceted_brep)
for rep_id in touched_reps:
self.file.by_id(rep_id).RepresentationType = "Brep"
def _purge_orphaned_ifc4_only_entities(self) -> None:
# Preprocessing rewires references away from source-schema-only
# carriers but does not delete the now-unreferenced instances
# themselves. Sweep iteratively so cascades collapse leaf-first
# (curves → point lists, face sets → indexed faces → point lists).
# Scoped to the actual source schema so IFC4X3 → IFC2X3 downgrades
# also catch IFC4X3-only geometry (IfcAlignmentCurve etc.), not just
# the IFC4 gap.
targets = ifcopenshell.util.schema.geometry_classes_introduced_after(
self.schema, source_schema=self.file.schema
)
while True:
removed = False
for ifc_class in targets:
for entity in list(self.file.by_type(ifc_class)):
if not self.file.get_inverse(entity):
self.file.remove(entity)
removed = True
if not removed:
break
def _encode_fallback_class_into_object_type(self, migrator: ifcopenshell.util.schema.Migrator) -> None:
# IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, …) migrate
# as IfcBuildingElementProxy. The subclass identity + its PredefinedType
# would otherwise be silently lost — IFC2X3 IfcBuildingElementProxy has
# no slot for them. Encode "<OriginalClass>/<PredefinedType>" into
# ObjectType when empty (don't trample author-supplied values).
for source_id, new_id in migrator.migrated_ids.items():
try:
source = self.file.by_id(source_id)
new = self.file_patched.by_id(new_id)
except RuntimeError:
continue
if not new.is_a("IfcBuildingElementProxy"):
continue
if source.is_a("IfcBuildingElementProxy"):
continue
if getattr(new, "ObjectType", None):
continue
predef = getattr(source, "PredefinedType", None)
new.ObjectType = f"{source.is_a()}/{predef}" if predef else source.is_a()
@@ -0,0 +1,137 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Bonsai Contributors
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import ifcpatch
import test.bootstrap
class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
def _make_curve(self, segments=None):
point_list = self.file.create_entity(
"IfcCartesianPointList2D",
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)],
)
curve = self.file.create_entity(
"IfcIndexedPolyCurve",
Points=point_list,
Segments=segments,
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
return curve
def test_run_without_segments(self):
"""An IfcIndexedPolyCurve with no Segments must downgrade to an
IfcPolyline through every CoordList point in order IFC4 defines
the implicit-polyline meaning of an absent Segments list, and the
ifcopenshell shape builder emits this form for simple open curves."""
self._make_curve(segments=None)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
polylines = self.file.by_type("IfcPolyline")
assert len(polylines) == 1
assert len(polylines[0].Points) == 3
def test_run_with_line_segments(self):
"""Line-segmented IfcIndexedPolyCurves downgrade to an equivalent IfcPolyline."""
segments = [
self.file.createIfcLineIndex((1, 2)),
self.file.createIfcLineIndex((2, 3)),
]
self._make_curve(segments=segments)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
polylines = self.file.by_type("IfcPolyline")
assert len(polylines) == 1
assert len(polylines[0].Points) == 3
def test_run_with_multi_index_line_segment(self):
"""An IfcLineIndex with >2 indices encodes a polyline through every
index the downgraded IfcPolyline must include every one of them.
This is the canonical form Bonsai's shape builder emits for closed
rectangle profiles (e.g. parametric wall body outlines), serialised
as ``IfcIndexedPolyCurve(Points, (IfcLineIndex((1,2,3,4,1))))``."""
point_list = self.file.create_entity(
"IfcCartesianPointList2D",
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
)
curve = self.file.create_entity(
"IfcIndexedPolyCurve",
Points=point_list,
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
polylines = self.file.by_type("IfcPolyline")
assert len(polylines) == 1
assert len(polylines[0].Points) == 5
coords = [p.Coordinates for p in polylines[0].Points]
assert coords[0] == coords[-1] == (0.0, 0.0)
assert coords[1] == (1.0, 0.0)
assert coords[2] == (1.0, 1.0)
assert coords[3] == (0.0, 1.0)
def test_run_with_chained_multi_index_segments(self):
"""When two IfcLineIndex segments are chained, the shared endpoint
between them must appear once, not twice."""
point_list = self.file.create_entity(
"IfcCartesianPointList2D",
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
)
curve = self.file.create_entity(
"IfcIndexedPolyCurve",
Points=point_list,
Segments=[
self.file.createIfcLineIndex((1, 2, 3)),
self.file.createIfcLineIndex((3, 4)),
],
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
polylines = self.file.by_type("IfcPolyline")
assert len(polylines) == 1
coords = [p.Coordinates for p in polylines[0].Points]
assert coords == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
def test_run_facets_arc_segments(self):
"""Arc-segmented IfcIndexedPolyCurves are downgraded by sampling the
circular arc into a chord polyline. The chord count is fixed by
the recipe's subdivision parameter."""
from ifcpatch.recipes.DowngradeIndexedPolyCurve import ARC_SUBDIVISION
segments = [self.file.createIfcArcIndex((1, 2, 3))]
self._make_curve(segments=segments)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
polylines = self.file.by_type("IfcPolyline")
assert len(polylines) == 1
assert len(polylines[0].Points) == ARC_SUBDIVISION + 1
+181
View File
@@ -17,6 +17,9 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.project
import ifcpatch
import test.bootstrap
@@ -27,3 +30,181 @@ class TestMigrate(test.bootstrap.IFC4):
old_file.header.file_name.name = "test"
new_file = ifcpatch.execute({"file": old_file, "recipe": "Migrate", "arguments": ["IFC4"]})
assert new_file.header.file_name.name == "test"
def test_migrate_ifc4_to_ifc2x3_flattens_indexed_polycurve(self):
"""Downgrade IFC4 → IFC2X3 should auto-run DowngradeIndexedPolyCurve on
IfcIndexedPolyCurve carriers, so the migrated file uses IfcPolyline (which
exists in IFC2X3) instead of crashing on the IFC4-only curve class."""
ifc4_file = self.file
point_list = ifc4_file.create_entity(
"IfcCartesianPointList2D",
CoordList=((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
)
segments = [
ifc4_file.create_entity("IfcLineIndex", (1, 2)),
ifc4_file.create_entity("IfcLineIndex", (2, 3)),
ifc4_file.create_entity("IfcLineIndex", (3, 4)),
ifc4_file.create_entity("IfcLineIndex", (4, 1)),
]
curve = ifc4_file.create_entity(
"IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False
)
ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
assert new_file.schema == "IFC2X3"
new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0]
assert new_profile.OuterCurve.is_a("IfcPolyline")
# The preprocessing step should have purged orphaned IFC4-only entities
# from the source before the migration loop reached them.
assert not ifc4_file.by_type("IfcIndexedPolyCurve")
assert not ifc4_file.by_type("IfcCartesianPointList2D")
def test_migrate_ifc4_to_ifc2x3_encodes_fallback_class_in_object_type(self):
"""IfcLamp / IfcPipeSegment / IfcGeographicElement fall back to
IfcBuildingElementProxy on downgrade. The original class and
PredefinedType are encoded into ObjectType so the type info survives
but only when ObjectType is empty (author-supplied values stay)."""
ifc4_file = self.file
ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
ifc4_file.create_entity("IfcPipeSegment", GlobalId="0_bkftCTnBCOOZeUxtJngE")
ifc4_file.create_entity(
"IfcGeographicElement",
GlobalId="3_b4gD1aP3ARmIm2ePijXi",
ObjectType="Terrain Mesh", # author-supplied, must not be overwritten
PredefinedType="TERRAIN",
)
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
proxies = {p.GlobalId: p for p in new_file.by_type("IfcBuildingElementProxy")}
# IfcLamp with no author ObjectType: encoded as IfcLamp/COMPACTFLUORESCENT.
assert proxies["2K6Z3DR8X37AS9XFvX8GcW"].ObjectType == "IfcLamp/COMPACTFLUORESCENT"
# IfcPipeSegment with no PredefinedType set: just the class name.
assert proxies["0_bkftCTnBCOOZeUxtJngE"].ObjectType == "IfcPipeSegment"
# IfcGeographicElement with author ObjectType: preserved as-is.
assert proxies["3_b4gD1aP3ARmIm2ePijXi"].ObjectType == "Terrain Mesh"
def test_migrate_ifc4_to_ifc2x3_converts_polygonal_face_set_to_faceted_brep(self):
"""IfcPolygonalFaceSet has no IFC2X3 equivalent. Direct entity-level
conversion produces an IfcFacetedBrep with the same topology, regardless
of which representation context the source lived in."""
ifc4_file = self.file
coords = ifc4_file.create_entity(
"IfcCartesianPointList3D",
CoordList=(
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
(0.5, 0.5, 1.0),
),
)
# Square base + 4 triangle sides — a simple pyramid.
faces = [
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 3, 4)),
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 5)),
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(2, 3, 5)),
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(3, 4, 5)),
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(4, 1, 5)),
]
face_set = ifc4_file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=faces)
context = ifc4_file.create_entity(
"IfcGeometricRepresentationContext",
ContextType="Model",
CoordinateSpaceDimension=3,
Precision=0.01,
WorldCoordinateSystem=ifc4_file.createIfcAxis2Placement3D(
Location=ifc4_file.createIfcCartesianPoint((0.0, 0.0, 0.0))
),
)
ifc4_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=[face_set],
)
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
assert new_file.schema == "IFC2X3"
breps = new_file.by_type("IfcFacetedBrep")
assert len(breps) == 1
brep = breps[0]
assert len(brep.Outer.CfsFaces) == 5
# Coordinates from the source CartesianPointList3D must appear in the
# resulting brep's loop points — otherwise the conversion silently
# corrupted geometry.
brep_coords = {tuple(p.Coordinates) for face in brep.Outer.CfsFaces for p in face.Bounds[0].Bound.Polygon}
for expected in ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)):
assert expected in brep_coords, f"vertex {expected} missing from converted brep"
rep = new_file.by_type("IfcShapeRepresentation")[0]
assert rep.RepresentationType == "Brep"
assert rep.Items[0].is_a("IfcFacetedBrep")
def test_migrate_ifc4_to_ifc2x3_summarises_unmappable_entities(self):
"""When an IFC4-only entity that cannot be auto-substituted survives
preprocessing, the recipe must surface a summary RuntimeError naming
the failing class not the cryptic ``RuntimeError: Entity with name
'' not found``.
Uses ``IfcWorkCalendar`` as the fixture an IFC4 entity that
(a) is not an IfcRepresentationItem (skips the geometry purge),
(b) is not an IfcElement (skips the proxy fallback),
(c) has no IFC2X3 equivalent in ``class_4_to_2x3.json`` (mapped to ``""``).
These three conditions together guarantee it always reaches the
unmappable error path, independent of future schema additions."""
ifc4_file = self.file
ifc4_file.create_entity("IfcWorkCalendar", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
with pytest.raises(RuntimeError) as exc_info:
ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
message = str(exc_info.value)
assert "IfcWorkCalendar" in message
def test_migrate_ifc4x3_to_ifc2x3_runs_downgrade_preprocessing(self):
"""IFC4X3 → IFC2X3 must trigger the same downgrade preprocessing as
IFC4 IFC2X3: curve flatten, face-set brep, IfcBuildingElementProxy
fallback, ObjectType encoding. Pins the gate at
``self.file.schema in ('IFC4', 'IFC4X3')`` a narrower check would
silently leave IFC4X3 sources crashing on IFC4-only geometry."""
ifc4x3_file = ifcopenshell.api.project.create_file(version="IFC4X3")
ifc4x3_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
new_file = ifcpatch.execute({"file": ifc4x3_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
assert new_file.schema == "IFC2X3"
proxies = new_file.by_type("IfcBuildingElementProxy")
assert len(proxies) == 1
# ObjectType encoding ran — same as the IFC4 → IFC2X3 case.
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT"
def test_migrate_ifc4_to_ifc2x3_flattens_arc_bearing_indexed_polycurve(self):
"""An IfcIndexedPolyCurve with IfcArcIndex segments is approximated
with a chord polyline rather than skipped, so the parent profile def
and its representations stay parametric (no fallback to tessellation)."""
ifc4_file = self.file
point_list = ifc4_file.create_entity(
"IfcCartesianPointList2D",
CoordList=((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)),
)
# Two half-arcs forming a circle: (1,0)→(0,1)→(-1,0)→(0,-1)→(1,0).
segments = [
ifc4_file.create_entity("IfcArcIndex", (1, 2, 3)),
ifc4_file.create_entity("IfcArcIndex", (3, 4, 1)),
]
curve = ifc4_file.create_entity(
"IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False
)
ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
assert new_file.schema == "IFC2X3"
new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0]
assert new_profile.OuterCurve.is_a("IfcPolyline")
# Arc subdivision should produce many more points than the 4 input coords.
assert len(new_profile.OuterCurve.Points) > 4