mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 18:16:40 +00:00
Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 local↔world
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user