diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 433d436b4c..cce861be5f 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py b/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py new file mode 100644 index 0000000000..093a24b0d3 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py @@ -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 . +# +# 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), + ), +] diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 4d34b3f555..39fbdfeda8 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -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 diff --git a/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py new file mode 100644 index 0000000000..f1f41b456a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py @@ -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 . +# +# 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)