mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
Add host-wall offset gizmos for door/window edit
When entering parametric edit on a door or window that fills a wall opening, four dimension gizmos now measure the distances from the wall edges to the filling's jambs and from the wall's base/top to the sill/header. Dragging any gizmo translates the filling along the wall's local axis; 180°-flipped fillings and slanted LAYER2 walls round-trip correctly. The has_host_wall predicate hides all four when the filling → opening → wall chain cannot be resolved. Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
d0334a72ab
commit
56097694bf
@@ -37,6 +37,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
@@ -838,6 +839,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
p.get_transom_window_center_z(),
|
||||
),
|
||||
),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
# Big quarter-arc hit shapes cover much of the door face — without a
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
|
||||
window edit gizmo groups — both fillings sit in a LAYER2 wall and the offset math is
|
||||
identical.
|
||||
|
||||
The compute side returns a *signed* value on the X axis (negative when the filling
|
||||
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
|
||||
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
|
||||
offset is always positive. Z-axis values are unsigned in both directions.
|
||||
|
||||
Fillings are assumed to align with the wall's local X axis to within ±90° — the
|
||||
parametric door/window construction path enforces this, and the X-sign math
|
||||
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
|
||||
exactly 90° in the wall plane).
|
||||
|
||||
Every public entry point falls back to a safe no-op when the host-wall chain
|
||||
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
|
||||
return a filling-relative position. This keeps the gizmos non-crashing when a
|
||||
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
|
||||
|
||||
``_GEOM_CACHE`` is module-scoped and persists across tests — tests must call
|
||||
``clear_caches()`` between cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple, Protocol
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class FillingProps(Protocol):
|
||||
"""Structural subset of door/window props this module touches."""
|
||||
|
||||
id_data: bpy.types.Object
|
||||
overall_width: float
|
||||
overall_height: float
|
||||
|
||||
|
||||
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
|
||||
_AXIS_X = 0
|
||||
_AXIS_Z = 2
|
||||
|
||||
|
||||
class _HostWallGeom(NamedTuple):
|
||||
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
|
||||
the vertical projection (already accounts for slanted extrusions)."""
|
||||
|
||||
wall_obj: bpy.types.Object
|
||||
height: float
|
||||
axis_min_x: float
|
||||
axis_max_x: float
|
||||
|
||||
|
||||
class _AxisExtent(NamedTuple):
|
||||
"""``[low, high]`` interval on one wall-local axis; low = near end.
|
||||
|
||||
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
|
||||
180° auto-flip); always 1.0 elsewhere."""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
x_sign: float = 1.0
|
||||
|
||||
|
||||
class _Edge(NamedTuple):
|
||||
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
|
||||
|
||||
axis_index: int
|
||||
is_max_end: bool
|
||||
|
||||
|
||||
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
|
||||
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
|
||||
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
|
||||
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
|
||||
|
||||
|
||||
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
|
||||
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
|
||||
|
||||
|
||||
def clear_caches() -> None:
|
||||
_GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
"""Cached host-wall geometry for a filling, or ``None`` if any link in
|
||||
filling → opening → wall → LAYER2 extrusion → scene-object resolution breaks."""
|
||||
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
|
||||
|
||||
|
||||
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
element = tool.Ifc.get_entity(filling_obj)
|
||||
if not element:
|
||||
return None
|
||||
host_wall = tool.Spatial.get_host_wall(element)
|
||||
if not host_wall:
|
||||
return None
|
||||
wall_obj = tool.Ifc.get_object(host_wall)
|
||||
length_height = tool.Wall.get_length_and_height(host_wall)
|
||||
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
|
||||
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
|
||||
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
|
||||
return None
|
||||
_, height = length_height
|
||||
axis_min_x, axis_max_x = axis_extent
|
||||
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
|
||||
|
||||
|
||||
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
|
||||
"""Filling footprint on the wall's local axis.
|
||||
|
||||
X-axis extent carries the filling's orientation sign (180° flip onto
|
||||
the opposite face) in ``x_sign``."""
|
||||
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
|
||||
origin = filling_in_wall.translation[axis_index]
|
||||
if axis_index == _AXIS_X:
|
||||
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
|
||||
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
|
||||
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
|
||||
signed_width = x_sign * props.overall_width
|
||||
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
|
||||
return _AxisExtent(origin, origin + props.overall_height)
|
||||
|
||||
|
||||
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
|
||||
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
|
||||
which drifts on trimmed walls); Z = 0 → wall height."""
|
||||
if axis_index == _AXIS_X:
|
||||
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
|
||||
return _AxisExtent(0.0, geom.height)
|
||||
|
||||
|
||||
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
|
||||
"""Distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
if is_max_end:
|
||||
return wall.high - filling.high
|
||||
return filling.low - wall.low
|
||||
|
||||
|
||||
def _translate_along_wall_axis(
|
||||
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
|
||||
) -> None:
|
||||
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
|
||||
operates in the filling's intent frame, not Blender's world frame, so a rotated
|
||||
host wall still tracks correctly."""
|
||||
if delta == 0.0:
|
||||
return
|
||||
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
|
||||
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
|
||||
|
||||
|
||||
def _get_offset(props: FillingProps, edge: _Edge) -> float:
|
||||
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
|
||||
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return
|
||||
current = _get_offset(props, edge)
|
||||
target = max(0.0, value)
|
||||
delta = (current - target) if edge.is_max_end else (target - current)
|
||||
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
|
||||
|
||||
|
||||
def has_host_wall(props: FillingProps) -> bool:
|
||||
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
|
||||
return _host_wall_geom(props.id_data) is not None
|
||||
|
||||
|
||||
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
|
||||
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
if edge.axis_index == _AXIS_X:
|
||||
return Vector((0.0, 0.0, props.overall_height / 2))
|
||||
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
edge_value = wall.high if edge.is_max_end else wall.low
|
||||
if edge.axis_index == _AXIS_X:
|
||||
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
|
||||
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
|
||||
return Vector((pos.x, 0.0, props.overall_height / 2))
|
||||
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
|
||||
# only by the filling's Z origin in the wall frame.
|
||||
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
|
||||
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
|
||||
|
||||
|
||||
def _compute_value(props: FillingProps, edge: _Edge) -> float:
|
||||
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
|
||||
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
|
||||
is a rendering hint only (user-facing offset is always positive)."""
|
||||
if edge.axis_index == _AXIS_X:
|
||||
_set_offset(props, edge, abs(value))
|
||||
else:
|
||||
_set_offset(props, edge, value)
|
||||
|
||||
|
||||
# attr_name identifies the gizmo within its group; values flow through
|
||||
# compute/apply, not via a registered property.
|
||||
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_left",
|
||||
axis=(1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _LEFT),
|
||||
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _LEFT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_right",
|
||||
axis=(-1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _RIGHT),
|
||||
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _RIGHT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_bottom",
|
||||
axis=(0, 0, 1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _BOTTOM),
|
||||
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
|
||||
matrix_position=lambda p: _edge_position(p, _BOTTOM),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_top",
|
||||
axis=(0, 0, -1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _TOP),
|
||||
apply_value=lambda p, v: _apply_value(p, _TOP, v),
|
||||
matrix_position=lambda p: _edge_position(p, _TOP),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -743,6 +744,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_window_props
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos.
|
||||
|
||||
The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the
|
||||
corresponding read path (``_get_offset``) reads back the new value — i.e.
|
||||
drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m.
|
||||
The tests below pin that round-trip, the rotated/flipped filling case (the
|
||||
add-opening flow may 180° a filling onto the wall's opposite face), and the
|
||||
visibility predicate."""
|
||||
|
||||
from math import pi
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
# Module under test, plus its cache dict so each test starts from a clean slate.
|
||||
import bonsai.bim.module.model.wall_offset_gizmos as subject
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_geom_cache():
|
||||
"""Per-test isolation — the module-level cache would otherwise carry mocks
|
||||
across tests and surface as flaky-looking failures."""
|
||||
subject._GEOM_CACHE.clear()
|
||||
yield
|
||||
subject._GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"):
|
||||
"""Filling PropertyGroup stand-in (``BIMDoorProperties`` /
|
||||
``BIMWindowProperties`` share the relevant shape). The wall-offset helpers
|
||||
only touch ``id_data`` (the filling obj), ``overall_width``, and
|
||||
``overall_height``; nothing else from the real PropertyGroup matters here."""
|
||||
filling_obj = mock.Mock(name=name)
|
||||
filling_obj.name = name
|
||||
filling_obj.matrix_world = filling_matrix
|
||||
props = mock.Mock(spec=["id_data", "overall_width", "overall_height"])
|
||||
props.id_data = filling_obj
|
||||
props.overall_width = overall_width
|
||||
props.overall_height = overall_height
|
||||
return props, filling_obj
|
||||
|
||||
|
||||
def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0):
|
||||
"""Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object``
|
||||
and the ``tool.Wall.*`` IFC reads so the helpers see a host wall
|
||||
positioned at ``wall_matrix`` with the supplied length, height, and
|
||||
extrusion angle. The IFC axis is taken to start at wall-local X=0 and
|
||||
extend to X=``length``. ``host_present=False`` makes the chain return
|
||||
None partway through."""
|
||||
wall_obj = mock.Mock(name="WallObj")
|
||||
wall_obj.matrix_world = wall_matrix
|
||||
host_wall = mock.Mock(name="IfcWall") if host_present else None
|
||||
return mock.patch.multiple(
|
||||
subject.tool,
|
||||
Ifc=mock.MagicMock(
|
||||
spec=subject.tool.Ifc,
|
||||
get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")),
|
||||
get_object=mock.Mock(return_value=wall_obj if host_present else None),
|
||||
),
|
||||
Spatial=mock.MagicMock(
|
||||
spec=subject.tool.Spatial,
|
||||
get_host_wall=mock.Mock(return_value=host_wall),
|
||||
),
|
||||
Wall=mock.MagicMock(
|
||||
spec=subject.tool.Wall,
|
||||
get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None),
|
||||
get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None),
|
||||
get_x_angle=mock.Mock(return_value=x_angle if host_present else None),
|
||||
),
|
||||
Parametric=mock.MagicMock(
|
||||
spec=subject.tool.Parametric,
|
||||
get_geom_generation=mock.Mock(return_value=geom_gen),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Visibility predicate
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_when_chain_resolves():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
def test_has_host_wall_returns_false_when_no_host():
|
||||
props, _ = _make_props(Matrix.Identity(4))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is False
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_for_slanted_wall():
|
||||
"""Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in
|
||||
the IFC extrusion direction and in the wall mesh vertices, not in the
|
||||
object transform. So wall-local Z still equals world Z, the offset math
|
||||
round-trips, and the gizmos must remain visible."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Compute helpers (un-flipped filling, wall at world origin)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_for_filling_at_known_x():
|
||||
"""Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned.
|
||||
Filling's left edge is at wall-X 1.5 → offset_left = 1.5."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_complements_left_plus_width():
|
||||
"""offset_left + overall_width + offset_right == wall length."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
left = subject._get_offset(props, subject._LEFT)
|
||||
right = subject._get_offset(props, subject._RIGHT)
|
||||
assert left + props.overall_width + right == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_filling_at_sill_height():
|
||||
"""Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_top_complements_bottom_plus_height():
|
||||
"""offset_bottom + overall_height + offset_top == wall height."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
bottom = subject._get_offset(props, subject._BOTTOM)
|
||||
top = subject._get_offset(props, subject._TOP)
|
||||
assert bottom + props.overall_height + top == pytest.approx(3.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the
|
||||
# offset math is the same as for a vertical wall. Pinning this guards
|
||||
# against the visibility gate being re-added or the math diverging.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_slanted_wall():
|
||||
"""For a LAYER2 slanted wall the wall matrix is identity rotation — sill
|
||||
height read in the wall's local Z is still the world Z above the wall
|
||||
base."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_round_trips_for_slanted_wall():
|
||||
"""Round-trip on a slanted wall: setting then reading the bottom offset
|
||||
yields the input. The apply path translates along the wall's local Z
|
||||
direction in world space (``matrix_world.to_3x3().col[2]``), which equals
|
||||
world Z for an upright wall matrix regardless of IFC slope."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.2)
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Flipped filling (180° around Z) — add-opening flow flips a filling that
|
||||
# lands on the wall's opposite face. Offset arithmetic must still report
|
||||
# the leftmost/rightmost edges in wall coordinates, not in filling coordinates.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_handles_flipped_filling():
|
||||
"""Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X
|
||||
direction, which is wall's -X. So filling's leftmost edge in wall coords is
|
||||
at wall-X 0.5, not wall-X 1.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_handles_flipped_filling():
|
||||
"""Same flipped filling — filling's rightmost edge in wall coords is at the
|
||||
filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Apply helpers — must round-trip with the compute helpers.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_wall_offset_left_translates_filling_along_wall_x():
|
||||
"""Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5
|
||||
along the wall's local X axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._LEFT, 2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_translates_filling_along_wall_z():
|
||||
"""Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5
|
||||
along the wall's local Z axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(1.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_right_round_trips_with_get():
|
||||
"""The right-edge setter is the symmetric pair of the left-edge setter —
|
||||
they must produce mutually consistent geometry, otherwise pulling the
|
||||
right edge would silently desync the left."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._RIGHT, 1.0)
|
||||
result = subject._get_offset(props, subject._RIGHT)
|
||||
assert result == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_set_wall_offset_top_round_trips_with_get():
|
||||
"""Same round-trip invariant for the top edge."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._TOP, 0.25)
|
||||
result = subject._get_offset(props, subject._TOP)
|
||||
assert result == pytest.approx(0.25)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cache invalidation
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_geom_cache_invalidates_when_generation_bumps():
|
||||
"""The cache must reset when ``tool.Parametric.get_geom_generation()``
|
||||
advances so IFC mutations don't leave stale host-wall reads in memory."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
# Patch get_geom_generation on the real class — the cache binds to the class
|
||||
# at definition time, so a mock.patch.multiple on subject.tool.Parametric
|
||||
# would be invisible to it.
|
||||
from bonsai.tool.parametric import Parametric
|
||||
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=1):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=2):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0):
|
||||
right_after_bump = subject._get_offset(props, subject._RIGHT)
|
||||
assert first == pytest.approx(1.0)
|
||||
# 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the
|
||||
# cache dropped on the generation bump.
|
||||
assert right_after_bump == pytest.approx(6.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation)
|
||||
#
|
||||
# Without the sign flip the dim arrow renders in the wrong world direction
|
||||
# for a filling whose local +X is opposite the wall's local +X. The gizmo
|
||||
# system flips its rendered dim arrow 180° around Z whenever the reported
|
||||
# value is negative, so the unflipped/flipped cases produce mirrored signs
|
||||
# and the arrow ends up pointing the right way visually in both orientations.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_left_signed_value_negative_for_flipped_filling():
|
||||
"""Filling rotated 180° around Z: its leftmost edge (in wall coords) sits
|
||||
at wall-X 0.5, so the user-facing left offset is 0.5 — but the signed
|
||||
value must be -0.5 so the gizmo flips its rendered arrow 180° around Z."""
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_right_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_right_signed_value_negative_for_flipped_filling():
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Matrix-position anchors at the wall edge (visual: arrow tail at wall,
|
||||
# head at filling). The position is in filling-local frame so that mw @ pos
|
||||
# lands at the wall edge in world.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_offset_position_lands_at_wall_start_in_world():
|
||||
"""For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the
|
||||
world origin (bound_box.min_x=0), the matrix_position transformed by the
|
||||
filling's world matrix must land at world (0, 0, mid_height)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._LEFT)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall's start at world X=0 (bound_box.min_x=0 in the patched wall).
|
||||
assert pos_world.x == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_top_offset_position_lands_at_wall_top_in_world():
|
||||
"""The top arrow anchors at wall-top — filling-local Z must equal
|
||||
``overall_height + top_offset`` so the mw-multiplied point lands on the
|
||||
wall's top edge at world height."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._TOP)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall height 3.0, wall base at world Z=0 → wall top at world Z=3.
|
||||
assert pos_world.z == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_bottom_offset_position_lands_at_wall_base_in_world():
|
||||
"""Same idea for the bottom anchor — filling-local Z = ``-bottom_offset``
|
||||
so the point lands at world Z=0 (the wall's base)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._BOTTOM)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
assert pos_world.z == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_apply_value_takes_absolute_value():
|
||||
"""Apply lambdas use ``abs(v)`` so the apply path stays correct even when
|
||||
the compute side returned a negative signed value (flipped filling)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
# Simulate the gizmo handing back a negative value (flipped-filling scenario).
|
||||
# The user-facing offset is +2.0, the filling must end up at wall-X=2.0.
|
||||
left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left")
|
||||
left_cfg.apply_value(props, -2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_clear_caches_drops_all_entries():
|
||||
"""The ``load_post`` handler calls ``clear_caches`` so a fresh file
|
||||
doesn't inherit stale entries from the previous one. Pin the contract."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._get_offset(props, subject._LEFT)
|
||||
assert subject._GEOM_CACHE._data
|
||||
subject.clear_caches()
|
||||
assert not subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Stale-cache edge cases — cache keys are Blender object names, invalidated
|
||||
# only by parametric generation bumps and ``load_post``. Anything that
|
||||
# changes scene state without bumping the generation (Blender rename,
|
||||
# external Python script deleting a wall) leaves the cache holding stale
|
||||
# entries until the next IFC mutation. These tests pin that behavior.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filling_rename_within_session_reads_correctly_under_new_name():
|
||||
"""Reading offsets after a Blender rename hits a cache miss under the
|
||||
new name and recomputes — the old-name entry is leaked but harmless,
|
||||
and the new-name read returns correct geometry."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1")
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
assert "Door1" in subject._GEOM_CACHE._data
|
||||
filling_obj.name = "Door1_renamed"
|
||||
second = subject._get_offset(props, subject._LEFT)
|
||||
assert first == pytest.approx(1.5)
|
||||
assert second == pytest.approx(1.5)
|
||||
assert "Door1_renamed" in subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
def test_host_wall_deletion_serves_stale_cache_until_invalidation():
|
||||
"""If the host wall is removed without bumping the generation counter
|
||||
(e.g. external script), the cache keeps returning the pre-deletion
|
||||
geometry — only an IFC mutation or ``clear_caches()`` drops the stale
|
||||
entry."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
# Even after the patch exits and the chain would now return None, the
|
||||
# cached entry under the filling's name is still served.
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is True # stale
|
||||
subject.clear_caches()
|
||||
assert subject.has_host_wall(props) is False # recomputed
|
||||
|
||||
|
||||
def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign():
|
||||
"""A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which
|
||||
is the ambiguous boundary for the X-sign. The implementation falls back
|
||||
to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as
|
||||
positive — same sign as an unflipped filling."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
signed = subject._compute_value(props, subject._LEFT)
|
||||
# +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords).
|
||||
assert signed == pytest.approx(1.5)
|
||||
Reference in New Issue
Block a user