mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +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
|
||||
|
||||
from . import operator, prop, ui
|
||||
from . import face_quad, gizmos, operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.BIM_OT_add_clip_box,
|
||||
operator.BIM_OT_add_clip_box_for_source,
|
||||
operator.BIM_OT_align_view_to_clip_face,
|
||||
operator.BIM_OT_duplicate_clip_box,
|
||||
operator.BIM_OT_remove_clip_box,
|
||||
operator.BIM_OT_set_active_clip_box,
|
||||
operator.BIM_OT_toggle_clip_box_enabled,
|
||||
prop.BIMClipBoxProperties,
|
||||
prop.BIMSceneClipBoxProperties,
|
||||
face_quad.BIM_GT_box_face_quad,
|
||||
face_quad.BIM_GT_box_face_outline,
|
||||
gizmos.OBJECT_GGT_bim_clip_box,
|
||||
ui.BIM_MT_clip_box_add_for_source,
|
||||
ui.BIM_MT_clip_box_info,
|
||||
ui.BIM_MT_clip_box_settings,
|
||||
ui.BIM_UL_clip_box,
|
||||
ui.BIM_PT_clip_box,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
|
||||
|
||||
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
|
||||
an IFC entity id stringified for entity-driven kinds, an IFC class name for
|
||||
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
|
||||
turns the picked id into a ``matrix_world`` via the source-preset helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
EnumItems = list[tuple[str, str, str]]
|
||||
|
||||
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
|
||||
# tuples a callback returns, so the Python strings must outlive the draw call.
|
||||
# Stashing the latest result per kind keeps them alive across callback firings.
|
||||
_items_cache: dict[str, EnumItems] = {}
|
||||
|
||||
# Sentinel id used for the "no options available" placeholder. The operator
|
||||
# treats this as an invalid pick and surfaces an ERROR.
|
||||
NO_OPTIONS_ID = "__none__"
|
||||
|
||||
|
||||
def _cache(kind: str, items: EnumItems) -> EnumItems:
|
||||
_items_cache[kind] = items
|
||||
return items
|
||||
|
||||
|
||||
def _no_options(label: str) -> EnumItems:
|
||||
# Blender refuses to draw an EnumProperty with zero entries — show a
|
||||
# placeholder so the dialog renders and the user sees the empty state.
|
||||
return [(NO_OPTIONS_ID, label, "")]
|
||||
|
||||
|
||||
def _label(entity, ifc_class: str | None = None) -> str:
|
||||
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
return f"{ifc_class}: {name}" if ifc_class else name
|
||||
|
||||
|
||||
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
|
||||
"""Shared shape for the IFC-driven enum callbacks.
|
||||
|
||||
Returns the no-IFC placeholder if no file is loaded, then runs
|
||||
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
|
||||
returns the empty-result placeholder if nothing matched. The output is
|
||||
always routed through the module cache.
|
||||
"""
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache(kind, _no_options("No IFC loaded"))
|
||||
items = build_fn(ifc)
|
||||
items.sort(key=lambda t: t[1].lower())
|
||||
if not items:
|
||||
return _cache(kind, _no_options(empty_label))
|
||||
return _cache(kind, items)
|
||||
|
||||
|
||||
# Top-down spatial hierarchy so the picker reads in the order an architect
|
||||
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
|
||||
# — spaces are typically empty volumes used for room metadata, so clipping to
|
||||
# one rarely matches the user intent of "show me what's in this container".
|
||||
SPATIAL_CLASSES: tuple[str, ...] = (
|
||||
"IfcProject",
|
||||
"IfcSite",
|
||||
"IfcBuilding",
|
||||
"IfcBuildingStorey",
|
||||
)
|
||||
|
||||
|
||||
def spatial_items(self, context) -> EnumItems:
|
||||
# Special-case: per-class sort within the hierarchy order rather than a
|
||||
# flat alphabetical sort, so the dropdown reads project → site → building.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("SPATIAL", _no_options("No IFC loaded"))
|
||||
items: EnumItems = []
|
||||
for ifc_class in SPATIAL_CLASSES:
|
||||
try:
|
||||
entities = ifc.by_type(ifc_class, include_subtypes=False)
|
||||
except RuntimeError:
|
||||
continue
|
||||
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
|
||||
items.append((str(entity.id()), _label(entity, ifc_class), ""))
|
||||
if not items:
|
||||
return _cache("SPATIAL", _no_options("No spatial containers"))
|
||||
return _cache("SPATIAL", items)
|
||||
|
||||
|
||||
def class_items(self, context) -> EnumItems:
|
||||
# Special-case: the picker value IS the IFC class name, not an entity id,
|
||||
# so the build shape differs from the other entity-driven callbacks.
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return _cache("CLASS", _no_options("No IFC loaded"))
|
||||
# List only IFC classes ACTUALLY present in the file (not the whole
|
||||
# schema), so the user picks from classes that can produce a non-empty
|
||||
# clip volume. ``e.is_a()`` returns the most specific class per element.
|
||||
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
|
||||
if not present:
|
||||
return _cache("CLASS", _no_options("No products"))
|
||||
return _cache("CLASS", [(cls, cls, "") for cls in present])
|
||||
|
||||
|
||||
def type_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"TYPE", "No types defined",
|
||||
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
|
||||
)
|
||||
|
||||
|
||||
def material_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"MATERIAL", "No materials defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
|
||||
)
|
||||
|
||||
|
||||
def profile_items(self, context) -> EnumItems:
|
||||
# ProfileName is optional. Skip unnamed profiles — they can't be
|
||||
# meaningfully picked from a flat list.
|
||||
return _build_items(
|
||||
"PROFILE", "No named profiles",
|
||||
lambda ifc: [
|
||||
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
|
||||
for e in ifc.by_type("IfcProfileDef")
|
||||
if getattr(e, "ProfileName", None)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def drawing_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"DRAWING", "No drawings defined",
|
||||
lambda ifc: [
|
||||
(str(e.id()), _label(e), "")
|
||||
for e in ifc.by_type("IfcAnnotation")
|
||||
if e.ObjectType == "DRAWING"
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Display labels for each status value. The id strings on the left are the
|
||||
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
|
||||
STATUS_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("No Status", "No Status"),
|
||||
("NEW", "New"),
|
||||
("EXISTING", "Existing"),
|
||||
("DEMOLISH", "Demolish"),
|
||||
("TEMPORARY", "Temporary"),
|
||||
("OTHER", "Other"),
|
||||
("NOTKNOWN", "Not Known"),
|
||||
("UNSET", "Unset"),
|
||||
)
|
||||
|
||||
|
||||
def status_items(self, context) -> EnumItems:
|
||||
# Fixed enum; no IFC needed. Still routed through the cache to share the
|
||||
# same string-lifetime guarantee as the other callbacks.
|
||||
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
|
||||
|
||||
|
||||
def system_items(self, context) -> EnumItems:
|
||||
# IfcStructuralAnalysisModel is a structural-grouping container, not a
|
||||
# distribution system — excluded to match Bonsai's other system pickers.
|
||||
return _build_items(
|
||||
"SYSTEM", "No systems defined",
|
||||
lambda ifc: [
|
||||
(str(e.id()), _label(e, e.is_a()), "")
|
||||
for e in ifc.by_type("IfcSystem")
|
||||
if not e.is_a("IfcStructuralAnalysisModel")
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def group_items(self, context) -> EnumItems:
|
||||
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
|
||||
# under Group as well — those get their own picker entries.
|
||||
return _build_items(
|
||||
"GROUP", "No groups defined",
|
||||
lambda ifc: [
|
||||
(str(e.id()), _label(e), "")
|
||||
for e in ifc.by_type("IfcGroup", include_subtypes=False)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def zone_items(self, context) -> EnumItems:
|
||||
return _build_items(
|
||||
"ZONE", "No zones defined",
|
||||
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
|
||||
)
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
|
||||
from . import data
|
||||
|
||||
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
|
||||
# PEP 563 stringifies the operator's EnumProperty class annotations, which
|
||||
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
|
||||
# — including the enum-search helper that draws the search-button icon.
|
||||
|
||||
CLIP_BOX_NAME = "ClipBox"
|
||||
CLIP_BOX_COLLECTION = "BBIM_ClipBoxes"
|
||||
|
||||
# Display labels for the source-based picker, used for the menu entries and
|
||||
# the dialog title. The dict keys are the canonical source-kind identifiers.
|
||||
SOURCE_KIND_LABELS: dict[str, str] = {
|
||||
"SPATIAL": "Spatial Element",
|
||||
"CLASS": "Class",
|
||||
"TYPE": "Type",
|
||||
"MATERIAL": "Material",
|
||||
"PROFILE": "Profile",
|
||||
"DRAWING": "Drawing",
|
||||
"STATUS": "Status",
|
||||
"SYSTEM": "System",
|
||||
"GROUP": "Group",
|
||||
"ZONE": "Zone",
|
||||
}
|
||||
|
||||
|
||||
_SOURCE_ID_DISPATCH = {
|
||||
"SPATIAL": data.spatial_items,
|
||||
"CLASS": data.class_items,
|
||||
"TYPE": data.type_items,
|
||||
"MATERIAL": data.material_items,
|
||||
"PROFILE": data.profile_items,
|
||||
"DRAWING": data.drawing_items,
|
||||
"STATUS": data.status_items,
|
||||
"SYSTEM": data.system_items,
|
||||
"GROUP": data.group_items,
|
||||
"ZONE": data.zone_items,
|
||||
}
|
||||
|
||||
|
||||
def _source_id_items(self, context):
|
||||
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
|
||||
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
|
||||
if fn is None:
|
||||
return [(data.NO_OPTIONS_ID, "No options", "")]
|
||||
return fn(self, context)
|
||||
|
||||
|
||||
def _source_display_name(kind, source_id):
|
||||
"""Human-readable name of the picked source, used in the clip-box name."""
|
||||
if kind == "STATUS":
|
||||
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
|
||||
if kind == "CLASS":
|
||||
# source_id IS the human-readable IFC class name.
|
||||
return source_id
|
||||
ifc = tool.Ifc.get()
|
||||
if ifc is None:
|
||||
return source_id
|
||||
try:
|
||||
entity = ifc.by_id(int(source_id))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
return source_id
|
||||
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
|
||||
|
||||
|
||||
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
|
||||
bl_idname = "bim.align_view_to_clip_face"
|
||||
bl_label = "Align View to Clip Box Face"
|
||||
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
|
||||
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
rv3d = getattr(context, "region_data", None)
|
||||
if rv3d is None:
|
||||
return {"CANCELLED"}
|
||||
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
|
||||
if clip_box is None:
|
||||
return {"CANCELLED"}
|
||||
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
|
||||
outward_local = Vector((0.0, 0.0, 0.0))
|
||||
outward_local[self.axis] = 1.0 if self.is_max else -1.0
|
||||
outward = (rot_mat @ outward_local).normalized()
|
||||
if outward.length == 0.0:
|
||||
return {"CANCELLED"}
|
||||
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
|
||||
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
|
||||
"""Box-local up direction for a face, following Blender numpad conventions.
|
||||
|
||||
Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z
|
||||
normal) → local +Y is up; bottom face (local -Z normal) → local -Y is
|
||||
up. The caller rotates this through the empty's matrix so the
|
||||
resulting world up axis tracks the box's orientation.
|
||||
"""
|
||||
if axis == 2:
|
||||
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
|
||||
return Vector((0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
|
||||
"""Build a camera ``view_rotation`` that looks along ``forward`` with
|
||||
``up_hint`` projected to the camera's local +Y."""
|
||||
back = -forward.normalized()
|
||||
right = up_hint.cross(back)
|
||||
if right.length < 1e-6:
|
||||
right = Vector((1.0, 0.0, 0.0))
|
||||
right.normalize()
|
||||
up = back.cross(right).normalized()
|
||||
return Matrix(
|
||||
(
|
||||
(right.x, up.x, back.x),
|
||||
(right.y, up.y, back.y),
|
||||
(right.z, up.z, back.z),
|
||||
)
|
||||
).to_quaternion()
|
||||
|
||||
|
||||
class BIM_OT_add_clip_box(bpy.types.Operator):
|
||||
@@ -38,39 +156,61 @@ class BIM_OT_add_clip_box(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
|
||||
obj = bpy.data.objects.new(CLIP_BOX_NAME, None)
|
||||
obj.empty_display_type = "CUBE"
|
||||
obj.empty_display_size = 1.0
|
||||
obj.location = context.scene.cursor.location.copy()
|
||||
# Default to a 20m cube (scale 10 around [-1, +1] local cube) so
|
||||
# the volume covers a typical building storey or two rather than
|
||||
# the meaningless 2m unit cube. The user resizes with S.
|
||||
obj.scale = (10.0, 10.0, 10.0)
|
||||
obj.show_in_front = True
|
||||
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal(
|
||||
(10.0, 10.0, 10.0, 1.0)
|
||||
)
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
|
||||
return {"FINISHED"}
|
||||
|
||||
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
|
||||
collection.objects.link(obj)
|
||||
|
||||
obj_props = tool.ClipBox.get_object_props(obj)
|
||||
obj_props.is_clip_box = True
|
||||
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
|
||||
bl_idname = "bim.add_clip_box_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
bl_description = (
|
||||
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
|
||||
"profile, drawing camera frustum, element status, system, group, or zone"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = obj
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
source_kind: bpy.props.EnumProperty(
|
||||
name="Source Kind",
|
||||
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
|
||||
default="SPATIAL",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
source_id: bpy.props.EnumProperty(
|
||||
name="Source",
|
||||
items=_source_id_items,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
# Adding a new clip box arms clipping so the user sees the cut
|
||||
# immediately. Without this they'd have to find the panel
|
||||
# toggle to discover the feature actually works.
|
||||
scene_props.enabled = True
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
tool.Blender.set_active_object(obj)
|
||||
tool.ClipBox.refresh(context.scene)
|
||||
# Persist to the project pset so the box round-trips through IFC
|
||||
# save/load. A project-level pset avoids the IfcRoot scale lock /
|
||||
# strip that a per-entity placement would trigger on export.
|
||||
tool.ClipBox.save_to_project_pset(context.scene)
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
|
||||
# Search button appears once the enum exceeds the helper's threshold,
|
||||
# giving the user a popup picker instead of a plain dropdown.
|
||||
prop_with_search(layout, self, "source_id", text=label)
|
||||
|
||||
def execute(self, context):
|
||||
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
|
||||
self.report({"ERROR"}, "No source selected.")
|
||||
return {"CANCELLED"}
|
||||
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
|
||||
if matrix is None:
|
||||
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
|
||||
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -148,23 +288,9 @@ class BIM_OT_duplicate_clip_box(bpy.types.Operator):
|
||||
if source is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
copy = bpy.data.objects.new(source.name, None)
|
||||
copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
|
||||
# Preserve the source's display attrs so the duplicate matches.
|
||||
copy.empty_display_type = source.empty_display_type
|
||||
copy.empty_display_size = source.empty_display_size
|
||||
copy.show_in_front = source.show_in_front
|
||||
copy.matrix_world = source.matrix_world.copy()
|
||||
|
||||
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
|
||||
collection.objects.link(copy)
|
||||
|
||||
tool.ClipBox.get_object_props(copy).is_clip_box = True
|
||||
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = copy
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
scene_props.enabled = True
|
||||
|
||||
tool.Blender.set_active_object(copy)
|
||||
tool.ClipBox.refresh(context.scene)
|
||||
tool.ClipBox.save_to_project_pset(context.scene)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -53,16 +53,29 @@ class BIMClipBoxProperties(PropertyGroup):
|
||||
def update_active_clip_box_index(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
tool.ClipBox.select_active_clip_box(context)
|
||||
# Rebuild caps for the new active box's clip volume.
|
||||
tool.ClipBox.invalidate_cap_cache(immediate=True)
|
||||
|
||||
|
||||
def update_show_caps(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
# Off → on must trigger a rebuild so caps reappear immediately rather
|
||||
# than wait for the next depsgraph tick. The rebuild is a no-op when
|
||||
# show_caps is now False (it clears and returns), so this is safe in
|
||||
# both directions.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
def update_enabled(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
|
||||
|
||||
def update_clip_only_ifc_products(self, context):
|
||||
# The eligibility set for capping changed — drop the cache and let the
|
||||
# debounced rebuild pick up the new objects on the next idle tick.
|
||||
tool.ClipBox.invalidate_cap_cache()
|
||||
|
||||
|
||||
class BIMSceneClipBoxProperties(PropertyGroup):
|
||||
"""Scene-level registry of clip boxes in this file.
|
||||
|
||||
@@ -100,8 +113,33 @@ class BIMSceneClipBoxProperties(PropertyGroup):
|
||||
"very heavy scenes"
|
||||
),
|
||||
)
|
||||
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
|
||||
# NOT written to the project pset so the IFC stays portable across users
|
||||
# who may have different Blender-side reference geometry to clip.
|
||||
clip_only_ifc_products: bpy.props.BoolProperty(
|
||||
name="Only IFC Products",
|
||||
default=True,
|
||||
update=update_clip_only_ifc_products,
|
||||
description=(
|
||||
"When enabled, only IFC element geometry gets cross-section caps. "
|
||||
"Disable to also cap Blender-side reference meshes (sketches, "
|
||||
"imported obj, primitive cubes, …)"
|
||||
),
|
||||
)
|
||||
# Also Scene-only — gizmo visibility is a per-user editing preference,
|
||||
# not a portable IFC property.
|
||||
enable_gizmos: bpy.props.BoolProperty(
|
||||
name="Show Face Handles",
|
||||
default=True,
|
||||
description=(
|
||||
"Show interactive face-resize handles on the active clip box. "
|
||||
"Disable to fall back to plain G/R/S transforms on the empty"
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_clip_box_index: int
|
||||
enabled: bool
|
||||
show_caps: bool
|
||||
clip_only_ifc_products: bool
|
||||
enable_gizmos: bool
|
||||
|
||||
@@ -20,18 +20,73 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bpy.types import Panel, UIList
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
|
||||
# icon set; semantically close to the kind so users can scan the menu visually.
|
||||
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
|
||||
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
|
||||
("CLASS", "Clip by Class", "BLANK1"),
|
||||
("TYPE", "Clip Type", "FILE_3D"),
|
||||
("MATERIAL", "Clip Material", "MATERIAL"),
|
||||
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
|
||||
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
|
||||
("STATUS", "Clip by Status", "INFO"),
|
||||
("SYSTEM", "Clip by System", "MOD_FLUID"),
|
||||
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
|
||||
("ZONE", "Clip by Zone", "MOD_LATTICE"),
|
||||
)
|
||||
|
||||
|
||||
class BIM_MT_clip_box_add_for_source(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_add_for_source"
|
||||
bl_label = "Add Clip Box From Source"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
for kind, label, icon in _SOURCE_MENU_ENTRIES:
|
||||
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
|
||||
op.source_kind = kind
|
||||
|
||||
|
||||
class BIM_MT_clip_box_settings(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_settings"
|
||||
bl_label = "Clip Box Settings"
|
||||
|
||||
def draw(self, context):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
self.layout.prop(scene_props, "clip_only_ifc_products")
|
||||
self.layout.prop(scene_props, "enable_gizmos")
|
||||
|
||||
|
||||
class BIM_MT_clip_box_info(Menu):
|
||||
bl_idname = "BIM_MT_clip_box_info"
|
||||
bl_label = "Clip Box Face Handles"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Face Handles", icon="INFO")
|
||||
layout.separator()
|
||||
layout.label(text="Drag a face to resize the clip box on that axis.")
|
||||
layout.label(text="The opposite face stays fixed (one-sided resize).")
|
||||
layout.label(text="Ctrl+Click a face to align the viewport to it.")
|
||||
layout.separator()
|
||||
layout.label(text="Toggle handles from the Settings (gear) menu.")
|
||||
|
||||
|
||||
class BIM_UL_clip_box(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
|
||||
obj = item.obj
|
||||
if obj is None:
|
||||
layout.label(text="(missing)", icon="ERROR")
|
||||
return
|
||||
row = layout.row(align=True)
|
||||
if obj is None:
|
||||
# Host empty was deleted from outliner; still expose the
|
||||
# remove button so the orphan entry isn't permanent.
|
||||
row.label(text="(missing)", icon="ERROR")
|
||||
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
|
||||
return
|
||||
row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
|
||||
row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
|
||||
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
|
||||
@@ -60,9 +115,13 @@ class BIM_PT_clip_box(Panel):
|
||||
toggle=True,
|
||||
)
|
||||
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
|
||||
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
|
||||
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
|
||||
|
||||
layout.separator()
|
||||
layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
|
||||
row = layout.row(align=True)
|
||||
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
|
||||
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
|
||||
|
||||
layout.template_list(
|
||||
"BIM_UL_clip_box",
|
||||
|
||||
@@ -1950,6 +1950,13 @@ class BIM_PT_decorators_overlay(Panel):
|
||||
row = col.row(align=True)
|
||||
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
|
||||
row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator")
|
||||
clip_box_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
row = col.row(align=True)
|
||||
# Grey out the toggles when there is no clip box to act on, so the
|
||||
# user can see the controls but can't flip a switch that does nothing.
|
||||
row.enabled = bool(clip_box_props.clip_boxes)
|
||||
row.prop(clip_box_props, "enabled", text="Enable Clipping")
|
||||
row.prop(clip_box_props, "show_caps", text="Show Caps")
|
||||
|
||||
|
||||
class BIM_PT_snappping(Panel):
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import bpy
|
||||
@@ -38,6 +38,21 @@ if TYPE_CHECKING:
|
||||
PlaneTuple = tuple[float, float, float, float]
|
||||
PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple]
|
||||
|
||||
# Stable contract of Pset_*Common.Status values plus the "absent" entry.
|
||||
# Duplicated locally rather than imported so this module has no load-order
|
||||
# dependency on the sequence layer that hosts the matching query helper.
|
||||
SOURCE_STATUS_VALUES: tuple[str, ...] = (
|
||||
"No Status",
|
||||
"NEW",
|
||||
"EXISTING",
|
||||
"DEMOLISH",
|
||||
"TEMPORARY",
|
||||
"OTHER",
|
||||
"NOTKNOWN",
|
||||
"UNSET",
|
||||
)
|
||||
|
||||
|
||||
# Outward margins so the empty's CUBE display edges sit safely INSIDE
|
||||
# the clip volume. A fixed absolute margin fails under rotation: the
|
||||
# float error in computing each column's length and in per-vertex dot
|
||||
@@ -187,12 +202,38 @@ class ClipBox:
|
||||
keeps the bbox aligned with the view the user is actually at.
|
||||
"""
|
||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||
# Skip collapsed / initializing regions wholesale: arming one
|
||||
# CTDs Blender (see _region_is_renderable), and recording an
|
||||
# arm signature for a region we didn't actually arm would make
|
||||
# the next view-change comparison spurious.
|
||||
if not cls._region_is_renderable(region, region_3d):
|
||||
continue
|
||||
key = region.as_pointer()
|
||||
cls._owned.add(key)
|
||||
cls._region_by_key[key] = (area, region)
|
||||
cls._arm_region(area, region, region_3d, planes)
|
||||
cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix)
|
||||
|
||||
@classmethod
|
||||
def _region_is_renderable(cls, region: Any, region_3d: Any) -> bool:
|
||||
"""True iff ``region`` is safe to arm clip planes against.
|
||||
|
||||
A collapsed / still-initializing region (``width`` or ``height``
|
||||
== 0, or no readable ``view_matrix``) has no live view-matrix
|
||||
state. Calling ``region_3d.update()`` against it drives
|
||||
``ED_view3d_update_viewmat -> GPU_matrix_ortho_set`` into a null
|
||||
deref and HARD-CRASHES Blender (CTD, not a catchable exception) —
|
||||
observed when a 3D viewport is split/collapsed while the clip box
|
||||
re-arms from a timer. Skipping such regions is the load-bearing
|
||||
guard; they get armed on the next refresh once they have a size.
|
||||
"""
|
||||
try:
|
||||
if int(getattr(region, "width", 0)) <= 0 or int(getattr(region, "height", 0)) <= 0:
|
||||
return False
|
||||
return getattr(region_3d, "view_matrix", None) is not None
|
||||
except (ReferenceError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
|
||||
"""Initialize the region's clip machinery and write ``planes``.
|
||||
@@ -201,12 +242,25 @@ class ClipBox:
|
||||
without leaving the C-side ``clipbb`` degenerate (which would break
|
||||
edit-mode click-select). Caller must guarantee a context in which
|
||||
operators are legal (not a draw handler / depsgraph callback).
|
||||
|
||||
The ``_region_is_renderable`` guard is the real crash fix — arming a
|
||||
collapsed / initializing region drives ``region_3d.update()`` into a
|
||||
native null deref inside ``GPU_matrix_ortho_set`` that NO Python
|
||||
``try``/``except`` can catch (it's a CTD, not an exception). The
|
||||
``suppress`` around ``update()`` is unrelated to that: it only
|
||||
swallows the *catchable* ``RuntimeError`` ("context is incorrect")
|
||||
/ ``ReferenceError`` (region freed mid-call) that the override path
|
||||
can still surface — it does NOT and CANNOT make ``update()``
|
||||
crash-safe.
|
||||
"""
|
||||
if not cls._region_is_renderable(region, region_3d):
|
||||
return
|
||||
with bpy.context.temp_override(area=area, region=region):
|
||||
bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
|
||||
region_3d.clip_planes = planes
|
||||
region_3d.use_clip_planes = True
|
||||
region_3d.update()
|
||||
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||
region_3d.update()
|
||||
|
||||
@classmethod
|
||||
def clear_clip_planes(cls) -> None:
|
||||
@@ -466,10 +520,15 @@ class ClipBox:
|
||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||
if not region_3d.use_clip_planes:
|
||||
continue
|
||||
# A collapsed / initializing region CTDs inside update() — same
|
||||
# null deref as the operator arm path (see _region_is_renderable).
|
||||
if not cls._region_is_renderable(region, region_3d):
|
||||
continue
|
||||
key = region.as_pointer()
|
||||
cls._region_by_key[key] = (area, region)
|
||||
region_3d.clip_planes = planes
|
||||
region_3d.update()
|
||||
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||
region_3d.update()
|
||||
region.tag_redraw()
|
||||
|
||||
@classmethod
|
||||
@@ -615,7 +674,12 @@ class ClipBox:
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
return
|
||||
region_3d.clip_planes = cls.compute_planes_from_matrix(matrix)
|
||||
region_3d.update()
|
||||
# PRE_VIEW runs for the region being drawn this frame (always sized
|
||||
# and renderable), so the collapsed-region CTD can't occur here.
|
||||
# The suppress only mops up a catchable RuntimeError / ReferenceError
|
||||
# from a region freed mid-draw — same as the arm paths.
|
||||
with contextlib.suppress(RuntimeError, ReferenceError):
|
||||
region_3d.update()
|
||||
# clip_bb captured by view3d.clip_border is view-aligned, so an
|
||||
# orbit/pan/zoom leaves the picker testing against the old
|
||||
# frustum even after clip_planes refresh. Re-arm so the picker
|
||||
@@ -628,6 +692,219 @@ class ClipBox:
|
||||
if prev_view is not None and prev_view != current_view:
|
||||
cls.schedule_refresh()
|
||||
|
||||
@classmethod
|
||||
def create_clip_box_empty(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
matrix: Any,
|
||||
name: str = "ClipBox",
|
||||
) -> bpy.types.Object:
|
||||
"""Create + register a clip-box empty whose ``matrix_world`` is ``matrix``.
|
||||
|
||||
Single entry point for any operator that needs to materialise a
|
||||
clip box: handles the host collection, the per-object
|
||||
``is_clip_box`` flag, the scene-list entry, auto-enable, viewport
|
||||
re-arm, and project-pset persistence. Returns the new empty.
|
||||
"""
|
||||
scene_props = cls.get_scene_props(context.scene)
|
||||
|
||||
obj = bpy.data.objects.new(name, None)
|
||||
obj.empty_display_type = "CUBE"
|
||||
obj.empty_display_size = 1.0
|
||||
obj.show_in_front = True
|
||||
obj.matrix_world = matrix
|
||||
|
||||
collection = tool.Blender.get_or_create_collection(context.scene, cls.COLLECTION_NAME)
|
||||
collection.objects.link(obj)
|
||||
|
||||
cls.get_object_props(obj).is_clip_box = True
|
||||
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = obj
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
# Auto-enable so the user sees the cut immediately rather than
|
||||
# having to find the panel toggle after the add.
|
||||
scene_props.enabled = True
|
||||
|
||||
tool.Blender.set_active_object(obj)
|
||||
cls.refresh(context.scene)
|
||||
# Project-level pset rather than a per-entity placement so IfcRoot's
|
||||
# scale-strip-on-export can't lose the box's dimensions.
|
||||
cls.save_to_project_pset(context.scene)
|
||||
return obj
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Source-based presets
|
||||
#
|
||||
# Build the host empty's ``matrix_world`` from a chosen IFC source
|
||||
# (a spatial container, a type, a material, a drawing, …) so the
|
||||
# user gets a clip box pre-sized to the AABB of the matched
|
||||
# elements instead of having to drag a default cube into position.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def iter_elements_for_source(cls, kind: str, source_id: str) -> list[Any]:
|
||||
"""Resolve the IFC products matching ``(kind, source_id)``.
|
||||
|
||||
``kind`` selects the IFC-graph walk; ``source_id`` is the picker
|
||||
value: an IFC entity id (stringified) for the entity-driven
|
||||
kinds, or one of :data:`SOURCE_STATUS_VALUES` for ``"STATUS"``.
|
||||
|
||||
Empty list when the IFC file is absent, ``source_id`` does not
|
||||
resolve, or the walk has no matches. ``"DRAWING"`` returns the
|
||||
single drawing entity so callers can introspect it; the actual
|
||||
clip volume for that kind is built from the camera frustum, not
|
||||
an AABB of decomposed elements.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return []
|
||||
|
||||
if kind == "STATUS":
|
||||
if source_id not in SOURCE_STATUS_VALUES:
|
||||
return []
|
||||
return list(tool.Sequence.get_elements_by_status(source_id))
|
||||
|
||||
if kind == "CLASS":
|
||||
# source_id is an IFC class name (e.g. "IfcWall"). by_type with
|
||||
# include_subtypes=True (default) so "IfcWall" matches
|
||||
# IfcWallStandardCase etc., matching "all walls" in user terms.
|
||||
try:
|
||||
return list(ifc_file.by_type(source_id))
|
||||
except RuntimeError:
|
||||
return []
|
||||
|
||||
try:
|
||||
entity_id = int(source_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
try:
|
||||
entity = ifc_file.by_id(entity_id)
|
||||
except RuntimeError:
|
||||
return []
|
||||
if entity is None:
|
||||
return []
|
||||
|
||||
if kind == "SPATIAL":
|
||||
return list(ifcopenshell.util.element.get_decomposition(entity, is_recursive=True))
|
||||
if kind == "TYPE":
|
||||
return list(ifcopenshell.util.element.get_types(entity))
|
||||
if kind == "MATERIAL":
|
||||
return list(ifcopenshell.util.element.get_elements_by_material(ifc_file, entity))
|
||||
if kind == "PROFILE":
|
||||
return list(ifcopenshell.util.element.get_elements_by_profile(entity))
|
||||
if kind in ("SYSTEM", "GROUP", "ZONE"):
|
||||
return list(ifcopenshell.util.element.get_grouped_by(entity, is_recursive=True))
|
||||
if kind == "DRAWING":
|
||||
return [entity]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def compute_matrix_for_source(cls, kind: str, source_id: str) -> Optional[Any]:
|
||||
"""Build the empty's ``matrix_world`` for ``(kind, source_id)``.
|
||||
|
||||
Returns ``None`` when nothing matches — the operator turns that
|
||||
into an ERROR report + ``CANCELLED``.
|
||||
|
||||
``"DRAWING"`` returns a rotated matrix aligned to the camera and
|
||||
sized to ``clip_start..clip_end`` × the drawing's in-plane
|
||||
extents. All other kinds return an axis-aligned matrix sized to
|
||||
the world AABB of the matched elements' Blender objects.
|
||||
"""
|
||||
if kind == "DRAWING":
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return None
|
||||
try:
|
||||
entity = ifc_file.by_id(int(source_id))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
return None
|
||||
camera_obj = tool.Ifc.get_object(entity)
|
||||
if camera_obj is None or camera_obj.type != "CAMERA":
|
||||
return None
|
||||
return cls._camera_frustum_matrix(camera_obj)
|
||||
|
||||
elements = cls.iter_elements_for_source(kind, source_id)
|
||||
return cls._world_bbox_matrix_for_elements(elements)
|
||||
|
||||
@classmethod
|
||||
def _world_bbox_matrix_for_elements(cls, elements: Iterable[Any]) -> Optional[Any]:
|
||||
"""World-AABB matrix of the Blender objects backing ``elements``.
|
||||
|
||||
``matrix_world = Translation(center) @ Diagonal(half_extents)``
|
||||
so the CUBE empty's local ``[-1, +1]^3`` lands on the AABB
|
||||
corners. Filters out elements without a Blender object and
|
||||
elements whose object has a zero-volume bound box (typical for
|
||||
empties used as containers). Returns ``None`` when nothing
|
||||
survives the filter so the caller can ERROR instead of creating
|
||||
a degenerate clip box.
|
||||
|
||||
Half-extents are floored at :data:`_CLIP_EXPAND_ABS` so a single
|
||||
point or flat slab still produces an invertible matrix.
|
||||
"""
|
||||
from mathutils import Matrix
|
||||
|
||||
min_x = min_y = min_z = float("inf")
|
||||
max_x = max_y = max_z = float("-inf")
|
||||
found = False
|
||||
for element in elements:
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
continue
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
if bbox["dimensions"] == (0.0, 0.0, 0.0):
|
||||
continue
|
||||
min_x = min(min_x, bbox["min_x"])
|
||||
min_y = min(min_y, bbox["min_y"])
|
||||
min_z = min(min_z, bbox["min_z"])
|
||||
max_x = max(max_x, bbox["max_x"])
|
||||
max_y = max(max_y, bbox["max_y"])
|
||||
max_z = max(max_z, bbox["max_z"])
|
||||
found = True
|
||||
if not found:
|
||||
return None
|
||||
cx, cy, cz = (min_x + max_x) / 2, (min_y + max_y) / 2, (min_z + max_z) / 2
|
||||
hx = max((max_x - min_x) / 2, _CLIP_EXPAND_ABS)
|
||||
hy = max((max_y - min_y) / 2, _CLIP_EXPAND_ABS)
|
||||
hz = max((max_z - min_z) / 2, _CLIP_EXPAND_ABS)
|
||||
return Matrix.Translation((cx, cy, cz)) @ Matrix.Diagonal((hx, hy, hz, 1.0))
|
||||
|
||||
@classmethod
|
||||
def _camera_frustum_matrix(cls, camera_obj: bpy.types.Object) -> Optional[Any]:
|
||||
"""Rotated matrix matching the camera frustum ``clip_start..clip_end``.
|
||||
|
||||
Bonsai's drawing module parameterises a drawing camera's frustum
|
||||
via ``BIMCameraProperties.width`` and ``.height`` (the printed
|
||||
extents in world units). The CUBE empty inherits the camera's
|
||||
rotation; depth spans ``[clip_start, clip_end]`` along the
|
||||
camera's local −Z (Blender cameras look down −Z).
|
||||
|
||||
Returns ``None`` when the camera has no usable drawing extents —
|
||||
the operator surfaces that as an ERROR + ``CANCELLED``.
|
||||
"""
|
||||
from mathutils import Matrix
|
||||
|
||||
cam_data = camera_obj.data
|
||||
cam_props = getattr(cam_data, "BIMCameraProperties", None)
|
||||
if cam_props is None:
|
||||
return None
|
||||
width = float(getattr(cam_props, "width", 0.0) or 0.0)
|
||||
height = float(getattr(cam_props, "height", 0.0) or 0.0)
|
||||
if width <= 0.0 or height <= 0.0:
|
||||
return None
|
||||
|
||||
clip_start = float(getattr(cam_data, "clip_start", 0.0))
|
||||
clip_end = float(getattr(cam_data, "clip_end", 1.0))
|
||||
|
||||
x_half = max(width / 2.0, _CLIP_EXPAND_ABS)
|
||||
y_half = max(height / 2.0, _CLIP_EXPAND_ABS)
|
||||
z_half = max((clip_end - clip_start) / 2.0, _CLIP_EXPAND_ABS)
|
||||
z_center = -(clip_start + clip_end) / 2.0
|
||||
offset = Matrix.Translation((0.0, 0.0, z_center)) @ Matrix.Diagonal((x_half, y_half, z_half, 1.0))
|
||||
return camera_obj.matrix_world @ offset
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cross-section caps
|
||||
#
|
||||
@@ -709,27 +986,64 @@ class ClipBox:
|
||||
|
||||
@classmethod
|
||||
def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]:
|
||||
"""Yield mesh objects eligible for capping: visible ``IfcElement``s.
|
||||
"""Yield mesh objects eligible for capping.
|
||||
|
||||
Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so
|
||||
spatial structure (``IfcSpace``, ``IfcBuildingStorey``,
|
||||
``IfcSite``) and annotations / grids never get capped — they're
|
||||
non-physical containers / overlays that shouldn't sprout solid
|
||||
fill polygons at clip boundaries.
|
||||
When ``clip_only_ifc_products`` is set (default), limits to
|
||||
``IfcElement`` (walls, slabs, doors, windows, …) so spatial
|
||||
structure (``IfcSpace``, ``IfcBuildingStorey``, ``IfcSite``) and
|
||||
annotations / grids never get capped — they're non-physical
|
||||
containers / overlays that shouldn't sprout solid fill polygons
|
||||
at clip boundaries.
|
||||
|
||||
When unset, any visible mesh in the scene is eligible regardless
|
||||
of IFC association — useful for clipping Blender-side reference
|
||||
geometry alongside a loaded IFC.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
scene_props = cls.get_scene_props(scene)
|
||||
only_ifc = scene_props.clip_only_ifc_products
|
||||
if only_ifc and tool.Ifc.get() is None:
|
||||
return
|
||||
for obj in scene.objects:
|
||||
if obj.type != "MESH" or obj.data is None:
|
||||
continue
|
||||
if not obj.visible_get():
|
||||
continue
|
||||
entity = tool.Ifc.get_entity(obj)
|
||||
if entity is None or not entity.is_a("IfcElement"):
|
||||
continue
|
||||
if only_ifc:
|
||||
entity = tool.Ifc.get_entity(obj)
|
||||
if entity is None or not entity.is_a("IfcElement"):
|
||||
continue
|
||||
yield obj
|
||||
|
||||
@classmethod
|
||||
def invalidate_cap_cache(cls, *, immediate: bool = False) -> None:
|
||||
"""Drop the cap cache and schedule a fresh rebuild.
|
||||
|
||||
Public entry point for property-update callbacks (or any external
|
||||
change to eligibility / clip-box selection) so callers don't reach
|
||||
into the private cache state directly. Pass ``immediate=True`` for
|
||||
UI-driven changes that should rebuild on the next idle tick
|
||||
without waiting for the depsgraph-debounce window.
|
||||
"""
|
||||
cls._cap_cache.clear()
|
||||
cls._last_cap_clip_box_hash = 0
|
||||
cls._schedule_cap_rebuild(interval=0.0 if immediate else None)
|
||||
|
||||
@classmethod
|
||||
def rebuild_caps_now(cls, scene: Optional[bpy.types.Scene] = None) -> None:
|
||||
"""Drop the cap cache and rebuild SYNCHRONOUSLY, then redraw.
|
||||
|
||||
Public entry point for interactive end-of-drag handlers (e.g. the
|
||||
face-resize gizmo group) where the user expects the caps to
|
||||
re-form the instant they release the handle — without the
|
||||
debounce window the depsgraph path inserts.
|
||||
"""
|
||||
cls._cancel_pending_cap_rebuild()
|
||||
cls._cap_cache.clear()
|
||||
cls._last_cap_clip_box_hash = 0
|
||||
cls.rebuild_cap_cache(scene)
|
||||
for _area, region, _region_3d in tool.Blender.iter_view3d_regions():
|
||||
region.tag_redraw()
|
||||
|
||||
@classmethod
|
||||
def rebuild_cap_cache(
|
||||
cls,
|
||||
@@ -929,14 +1243,17 @@ class ClipBox:
|
||||
return relevant
|
||||
|
||||
@classmethod
|
||||
def _schedule_cap_rebuild(cls) -> None:
|
||||
def _schedule_cap_rebuild(cls, *, interval: Optional[float] = None) -> None:
|
||||
"""(Re)schedule the deferred cap rebuild.
|
||||
|
||||
Each call cancels any pending timer and registers a fresh one
|
||||
so a burst of updates collapses to a single rebuild once the
|
||||
debounce window of quiet elapses.
|
||||
debounce window of quiet elapses. Pass ``interval=0.0`` for
|
||||
next-tick rebuild without debounce (UI-driven changes that
|
||||
only fire on explicit user action, not depsgraph bursts).
|
||||
"""
|
||||
cls._cancel_pending_cap_rebuild()
|
||||
delay = interval if interval is not None else cls._CAP_REBUILD_DEBOUNCE_SECONDS
|
||||
|
||||
def _do_rebuild() -> None:
|
||||
cls._pending_cap_rebuild = None
|
||||
@@ -958,7 +1275,7 @@ class ClipBox:
|
||||
region.tag_redraw()
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS)
|
||||
bpy.app.timers.register(_do_rebuild, first_interval=delay)
|
||||
cls._pending_cap_rebuild = _do_rebuild
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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