mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-26 18:21:59 +00:00
Extend clip box with face handles and presets
Add source-based clip box presets — a dropdown menu next to the Add Clip Box button lets the user pre-size a clip box to the bounding box of a chosen IFC source: a spatial element, IFC class, type, material, profile, drawing camera frustum, status, system, group, or zone. The picker dialog uses prop_with_search so files with hundreds of materials or types remain browsable. Add interactive face resize handles — six near-invisible click-target quads render on the active clip box when its empty is the active object. Dragging a face grows or shrinks the box one-sided on that axis; the opposite face stays fixed. Ctrl+Click on a face aligns the viewport to look at that face, following Blender's numpad-view convention applied to the box's local axes so rotated boxes align orthogonally to the screen. The gizmos honour negative-scale empties so the visible cube and the clickable handles stay aligned. Add settings and info menus — a gear-icon menu next to the Enable Clipping / Show Caps toggles exposes per-file preferences (cap only IFC products, show face handles); an info-icon menu adjacent documents the gizmo gestures. A quick-access toggle row also appears in the viewport Overlay popover, greyed out when no clip box exists, and orphaned clip-box list entries now expose an X button so users can recover from external host-empty deletions. Plumbing: cap rebuild fires synchronously on gizmo release and clip-box selection change, so the cross-section overlay re-forms without waiting for the depsgraph debounce; cap eligibility honours the "Only IFC Products" toggle. Includes 121 tests covering source resolution, drag math, face visibility, gizmo registration, and the view-alignment up-axis convention. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -23,16 +23,24 @@ from bpy.app.handlers import persistent
|
|||||||
|
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
|
|
||||||
from . import operator, prop, ui
|
from . import face_quad, gizmos, operator, prop, ui
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
operator.BIM_OT_add_clip_box,
|
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_duplicate_clip_box,
|
||||||
operator.BIM_OT_remove_clip_box,
|
operator.BIM_OT_remove_clip_box,
|
||||||
operator.BIM_OT_set_active_clip_box,
|
operator.BIM_OT_set_active_clip_box,
|
||||||
operator.BIM_OT_toggle_clip_box_enabled,
|
operator.BIM_OT_toggle_clip_box_enabled,
|
||||||
prop.BIMClipBoxProperties,
|
prop.BIMClipBoxProperties,
|
||||||
prop.BIMSceneClipBoxProperties,
|
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_UL_clip_box,
|
||||||
ui.BIM_PT_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,921 @@
|
|||||||
|
# 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",
|
||||||
|
"BIM_GT_box_face_outline",
|
||||||
|
"BIM_GT_box_face_quad",
|
||||||
|
"FACE_QUAD_ALPHA",
|
||||||
|
"FACE_QUAD_ALPHA_HIGHLIGHT",
|
||||||
|
"FACE_QUAD_SELECT_BIAS",
|
||||||
|
"FACE_ROUTES",
|
||||||
|
"apply_face_quad_layout",
|
||||||
|
"compute_face_resize",
|
||||||
|
"face_outward_axis_local",
|
||||||
|
"front_facing_face_mask",
|
||||||
|
"view_axis_parallel_face_mask",
|
||||||
|
]
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 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.
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
import bonsai.tool as tool
|
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_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):
|
class BIM_OT_add_clip_box(bpy.types.Operator):
|
||||||
@@ -38,39 +156,61 @@ class BIM_OT_add_clip_box(bpy.types.Operator):
|
|||||||
bl_options = {"REGISTER", "UNDO"}
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
def execute(self, context):
|
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
|
# 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 volume covers a typical building storey or two rather than
|
||||||
# the meaningless 2m unit cube. The user resizes with S.
|
# the meaningless 2m unit cube. The user resizes with S.
|
||||||
obj.scale = (10.0, 10.0, 10.0)
|
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal(
|
||||||
obj.show_in_front = True
|
(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)
|
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
|
||||||
obj_props.is_clip_box = True
|
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()
|
source_kind: bpy.props.EnumProperty(
|
||||||
entry.obj = obj
|
name="Source Kind",
|
||||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
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
|
def invoke(self, context, event):
|
||||||
# immediately. Without this they'd have to find the panel
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
# toggle to discover the feature actually works.
|
|
||||||
scene_props.enabled = True
|
|
||||||
|
|
||||||
tool.Blender.set_active_object(obj)
|
def draw(self, context):
|
||||||
tool.ClipBox.refresh(context.scene)
|
layout = self.layout
|
||||||
# Persist to the project pset so the box round-trips through IFC
|
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
|
||||||
# save/load. A project-level pset avoids the IfcRoot scale lock /
|
# Search button appears once the enum exceeds the helper's threshold,
|
||||||
# strip that a per-entity placement would trigger on export.
|
# giving the user a popup picker instead of a plain dropdown.
|
||||||
tool.ClipBox.save_to_project_pset(context.scene)
|
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"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,23 +288,9 @@ class BIM_OT_duplicate_clip_box(bpy.types.Operator):
|
|||||||
if source is None:
|
if source is None:
|
||||||
return {"CANCELLED"}
|
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_type = source.empty_display_type
|
||||||
copy.empty_display_size = source.empty_display_size
|
copy.empty_display_size = source.empty_display_size
|
||||||
copy.show_in_front = source.show_in_front
|
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"}
|
return {"FINISHED"}
|
||||||
|
|||||||
@@ -53,16 +53,29 @@ class BIMClipBoxProperties(PropertyGroup):
|
|||||||
def update_active_clip_box_index(self, context):
|
def update_active_clip_box_index(self, context):
|
||||||
tool.ClipBox.schedule_refresh()
|
tool.ClipBox.schedule_refresh()
|
||||||
tool.ClipBox.select_active_clip_box(context)
|
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):
|
def update_show_caps(self, context):
|
||||||
tool.ClipBox.schedule_refresh()
|
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):
|
def update_enabled(self, context):
|
||||||
tool.ClipBox.schedule_refresh()
|
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()
|
||||||
|
|
||||||
|
|
||||||
class BIMSceneClipBoxProperties(PropertyGroup):
|
class BIMSceneClipBoxProperties(PropertyGroup):
|
||||||
"""Scene-level registry of clip boxes in this file.
|
"""Scene-level registry of clip boxes in this file.
|
||||||
|
|
||||||
@@ -100,8 +113,33 @@ class BIMSceneClipBoxProperties(PropertyGroup):
|
|||||||
"very heavy scenes"
|
"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, …)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 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:
|
if TYPE_CHECKING:
|
||||||
active_clip_box_index: int
|
active_clip_box_index: int
|
||||||
enabled: bool
|
enabled: bool
|
||||||
show_caps: bool
|
show_caps: bool
|
||||||
|
clip_only_ifc_products: bool
|
||||||
|
enable_gizmos: bool
|
||||||
|
|||||||
@@ -20,18 +20,73 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from bpy.types import Panel, UIList
|
from bpy.types import Menu, Panel, UIList
|
||||||
|
|
||||||
import bonsai.tool as tool
|
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, "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):
|
class BIM_UL_clip_box(UIList):
|
||||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
|
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
|
||||||
obj = item.obj
|
obj = item.obj
|
||||||
if obj is None:
|
|
||||||
layout.label(text="(missing)", icon="ERROR")
|
|
||||||
return
|
|
||||||
row = layout.row(align=True)
|
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.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.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
|
||||||
row.operator("bim.remove_clip_box", text="", icon="X", 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,
|
toggle=True,
|
||||||
)
|
)
|
||||||
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", 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.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(
|
layout.template_list(
|
||||||
"BIM_UL_clip_box",
|
"BIM_UL_clip_box",
|
||||||
|
|||||||
@@ -1950,6 +1950,13 @@ class BIM_PT_decorators_overlay(Panel):
|
|||||||
row = col.row(align=True)
|
row = col.row(align=True)
|
||||||
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
|
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
|
||||||
row.prop(model_props, "show_cut_decorator_fill", text="Fill 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):
|
class BIM_PT_snappping(Panel):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterable, Iterator
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -38,6 +38,21 @@ if TYPE_CHECKING:
|
|||||||
PlaneTuple = tuple[float, float, float, float]
|
PlaneTuple = tuple[float, float, float, float]
|
||||||
PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple]
|
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
|
# Outward margins so the empty's CUBE display edges sit safely INSIDE
|
||||||
# the clip volume. A fixed absolute margin fails under rotation: the
|
# the clip volume. A fixed absolute margin fails under rotation: the
|
||||||
# float error in computing each column's length and in per-vertex dot
|
# float error in computing each column's length and in per-vertex dot
|
||||||
@@ -187,12 +202,38 @@ class ClipBox:
|
|||||||
keeps the bbox aligned with the view the user is actually at.
|
keeps the bbox aligned with the view the user is actually at.
|
||||||
"""
|
"""
|
||||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
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()
|
key = region.as_pointer()
|
||||||
cls._owned.add(key)
|
cls._owned.add(key)
|
||||||
cls._region_by_key[key] = (area, region)
|
cls._region_by_key[key] = (area, region)
|
||||||
cls._arm_region(area, region, region_3d, planes)
|
cls._arm_region(area, region, region_3d, planes)
|
||||||
cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix)
|
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
|
@classmethod
|
||||||
def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
|
def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
|
||||||
"""Initialize the region's clip machinery and write ``planes``.
|
"""Initialize the region's clip machinery and write ``planes``.
|
||||||
@@ -201,12 +242,25 @@ class ClipBox:
|
|||||||
without leaving the C-side ``clipbb`` degenerate (which would break
|
without leaving the C-side ``clipbb`` degenerate (which would break
|
||||||
edit-mode click-select). Caller must guarantee a context in which
|
edit-mode click-select). Caller must guarantee a context in which
|
||||||
operators are legal (not a draw handler / depsgraph callback).
|
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):
|
with bpy.context.temp_override(area=area, region=region):
|
||||||
bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
|
bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
|
||||||
region_3d.clip_planes = planes
|
region_3d.clip_planes = planes
|
||||||
region_3d.use_clip_planes = True
|
region_3d.use_clip_planes = True
|
||||||
region_3d.update()
|
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||||
|
region_3d.update()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_clip_planes(cls) -> None:
|
def clear_clip_planes(cls) -> None:
|
||||||
@@ -466,10 +520,15 @@ class ClipBox:
|
|||||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||||
if not region_3d.use_clip_planes:
|
if not region_3d.use_clip_planes:
|
||||||
continue
|
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()
|
key = region.as_pointer()
|
||||||
cls._region_by_key[key] = (area, region)
|
cls._region_by_key[key] = (area, region)
|
||||||
region_3d.clip_planes = planes
|
region_3d.clip_planes = planes
|
||||||
region_3d.update()
|
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||||
|
region_3d.update()
|
||||||
region.tag_redraw()
|
region.tag_redraw()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -615,7 +674,12 @@ class ClipBox:
|
|||||||
except (AttributeError, RuntimeError, ReferenceError):
|
except (AttributeError, RuntimeError, ReferenceError):
|
||||||
return
|
return
|
||||||
region_3d.clip_planes = cls.compute_planes_from_matrix(matrix)
|
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
|
# clip_bb captured by view3d.clip_border is view-aligned, so an
|
||||||
# orbit/pan/zoom leaves the picker testing against the old
|
# orbit/pan/zoom leaves the picker testing against the old
|
||||||
# frustum even after clip_planes refresh. Re-arm so the picker
|
# frustum even after clip_planes refresh. Re-arm so the picker
|
||||||
@@ -628,6 +692,219 @@ class ClipBox:
|
|||||||
if prev_view is not None and prev_view != current_view:
|
if prev_view is not None and prev_view != current_view:
|
||||||
cls.schedule_refresh()
|
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
|
# Cross-section caps
|
||||||
#
|
#
|
||||||
@@ -709,27 +986,64 @@ class ClipBox:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]:
|
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
|
When ``clip_only_ifc_products`` is set (default), limits to
|
||||||
spatial structure (``IfcSpace``, ``IfcBuildingStorey``,
|
``IfcElement`` (walls, slabs, doors, windows, …) so spatial
|
||||||
``IfcSite``) and annotations / grids never get capped — they're
|
structure (``IfcSpace``, ``IfcBuildingStorey``, ``IfcSite``) and
|
||||||
non-physical containers / overlays that shouldn't sprout solid
|
annotations / grids never get capped — they're non-physical
|
||||||
fill polygons at clip boundaries.
|
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()
|
scene_props = cls.get_scene_props(scene)
|
||||||
if ifc_file is None:
|
only_ifc = scene_props.clip_only_ifc_products
|
||||||
|
if only_ifc and tool.Ifc.get() is None:
|
||||||
return
|
return
|
||||||
for obj in scene.objects:
|
for obj in scene.objects:
|
||||||
if obj.type != "MESH" or obj.data is None:
|
if obj.type != "MESH" or obj.data is None:
|
||||||
continue
|
continue
|
||||||
if not obj.visible_get():
|
if not obj.visible_get():
|
||||||
continue
|
continue
|
||||||
entity = tool.Ifc.get_entity(obj)
|
if only_ifc:
|
||||||
if entity is None or not entity.is_a("IfcElement"):
|
entity = tool.Ifc.get_entity(obj)
|
||||||
continue
|
if entity is None or not entity.is_a("IfcElement"):
|
||||||
|
continue
|
||||||
yield obj
|
yield obj
|
||||||
|
|
||||||
|
@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
|
@classmethod
|
||||||
def rebuild_cap_cache(
|
def rebuild_cap_cache(
|
||||||
cls,
|
cls,
|
||||||
@@ -929,14 +1243,17 @@ class ClipBox:
|
|||||||
return relevant
|
return relevant
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _schedule_cap_rebuild(cls) -> None:
|
def _schedule_cap_rebuild(cls, *, interval: Optional[float] = None) -> None:
|
||||||
"""(Re)schedule the deferred cap rebuild.
|
"""(Re)schedule the deferred cap rebuild.
|
||||||
|
|
||||||
Each call cancels any pending timer and registers a fresh one
|
Each call cancels any pending timer and registers a fresh one
|
||||||
so a burst of updates collapses to a single rebuild once the
|
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()
|
cls._cancel_pending_cap_rebuild()
|
||||||
|
delay = interval if interval is not None else cls._CAP_REBUILD_DEBOUNCE_SECONDS
|
||||||
|
|
||||||
def _do_rebuild() -> None:
|
def _do_rebuild() -> None:
|
||||||
cls._pending_cap_rebuild = None
|
cls._pending_cap_rebuild = None
|
||||||
@@ -958,7 +1275,7 @@ class ClipBox:
|
|||||||
region.tag_redraw()
|
region.tag_redraw()
|
||||||
return None
|
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
|
cls._pending_cap_rebuild = _do_rebuild
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 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,298 @@
|
|||||||
|
# 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,76 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# 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
|
||||||
Reference in New Issue
Block a user