Merge pull request #8172 from Gorgious56/bonsai/railing-edit-gizmos

Bonsai/railing edit gizmos
This commit is contained in:
Gorgious56
2026-06-15 10:23:16 +02:00
committed by GitHub
8 changed files with 1317 additions and 23 deletions
+41
View File
@@ -1,5 +1,46 @@
import pytest
class _FakePropsBase:
"""Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
The parametric-edit lifecycle mixins read/write a common contract:
``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last
data written via ``set_props_kwargs_from_ifc_data``),
``set_props_kwargs_from_ifc_data(data)``, and
``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
(door, railing, roof) subclass this and add their own kwargs accessors
and per-type fields."""
def __init__(self, general: dict | None = None):
self.is_editing = False
self.last_kwargs: dict | None = None
self.general = dict(general) if general is not None else {}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def make_lifecycle_obj(props, *, name="obj"):
"""Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
The mixin code under test reads ``obj.props`` (the PropertyGroup
stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
catches typo'd attribute access at test time. ``bpy`` is imported inside
the function so this conftest stays importable when bpy is absent."""
from unittest import mock
import bpy
obj = mock.Mock(spec=bpy.types.Object, name=name)
obj.props = props
obj.name = name
return obj
# pytest by default doesn't print steps and where it failed. Let's fix that.
@@ -24,7 +24,11 @@ from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
BaseSchematicGizmoGroup,
DimensionGizmoConfig,
)
pytestmark = pytest.mark.drawing
@@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
def test_parametric_base_enables_dimension_snap_by_default():
"""In-place parametric gizmos align to real-world geometry, so dragging
must respect the global snap toggle (Ctrl-flip during drag) — same
contract every door / window / wall / stair / roof / mep dimension
has shipped with."""
assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True
def test_schematic_base_disables_dimension_snap():
"""Schematic dimensions float in viewport space; snapping the dragged
tip to scene vertices would produce spurious value jumps as the
mouse crosses unrelated geometry. The opt-out lives on the base so
every schematic subclass inherits it without per-class wiring."""
assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False
@@ -0,0 +1,300 @@
# 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.
"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
behaviour railing inherits from ``PathPreservingEditMixin``.
The parent short-circuit (skip the IFC commit / viewport rebuild when the
draft is identical to the stored pset) lives in
``PathPreservingEditMixin``; the tests below verify railing's subclass
honours that contract by inheritance, then pin the railing-specific
viewport-restore dispatch:
- Finish / Cancel no-op short-circuit: inherited from the parent — verified
here because railing was the original consumer that motivated the
optimisation.
- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
reloads the high-poly Body representation via ``switch_representation``;
FRAMELESS_PANEL rebuilds the bmesh preview via
``update_railing_modifier_bmesh``. This is the per-type branch that used
to live in ``_cancel_one`` and now lives in the viewport-restore hook the
parent's ``_cancel_one`` calls.
"""
from unittest import mock
import pytest
from test.bim.conftest import _FakePropsBase
from test.bim.conftest import make_lifecycle_obj as _make_obj
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeRailingProps(_FakePropsBase):
"""Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
the shared parametric-edit contract. Starts in ``is_editing=True`` because
the railing-specific overrides under test only fire on Finish / Cancel,
not on Enable."""
def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
self.railing_type = railing_type
self.is_editing = True
@pytest.fixture
def patched_railing():
"""Patch the railing module's external references for unit testing.
``_RailingEditMixin`` and the parent lifecycle reach for
``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
``ifcopenshell.util.representation.get_representation``,
``bonsai.core.geometry.switch_representation``, and the module-level
``update_railing_modifier_bmesh`` — each looked up through the railing
module's own bindings, so we patch them there.
``parametric_lifecycle.tool`` is patched separately so the parent's
``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
without falling through to the real Blender bindings.
Uses ``mock.patch.object`` with a direct module reference rather than
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
which fails at the ``bonsai.bim`` step until that subpackage has been
imported elsewhere. The direct-object form sidesteps the resolution.
Returns a dict for tests to seed return values and assert call sites.
"""
from bonsai.bim import parametric_lifecycle
from bonsai.bim.module.model import railing
with (
mock.patch.object(railing, "tool") as mock_tool,
mock.patch.object(railing, "ifcopenshell") as mock_ifc,
mock.patch.object(railing, "bonsai") as mock_bonsai,
mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
):
# _resolve will be overridden on the test subclass below so the
# parametric_lifecycle.tool patch isn't needed for that path, but the
# parent's _cancel_one / _finish_one still call
# tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
yield {
"tool": mock_tool,
"ifcopenshell": mock_ifc,
"bonsai": mock_bonsai,
"update_bmesh": mock_update_bmesh,
"pl_tool": mock_pl_tool,
}
def _railing_test_subclass(props):
"""Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
``parametric_lifecycle.tool`` (a separate import from the railing
module's ``tool``). Overriding it here keeps the test patches local
to the railing module and the hook closures local to the test."""
from bonsai.bim.module.model.railing import _RailingEditMixin
test_element = mock.Mock(name="ifc_element")
class _TestRailingMixin(_RailingEditMixin):
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
@classmethod
def _resolve(cls, obj):
return test_element, props
@classmethod
def _update_pset(cls, element, data):
cls.pset_updates(element, data)
@classmethod
def _update_modifier_ifc_data(cls, obj, context):
cls.ifc_data_updates(obj, context)
@classmethod
def _restore_viewport_after_cancel(cls, obj, context):
cls.bmesh_updates(obj, context)
# The base _post_load_data JSON-serialises path_data; bypass that
# here so the round-trip stays a plain dict and tests can compare
# by reference / equality without re-parsing.
@classmethod
def _post_load_data(cls, data):
return dict(data)
return _TestRailingMixin, test_element
# ---------------------------------------------------------------------------
# _RailingEditMixin._finish_one
# ---------------------------------------------------------------------------
def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
"""Enable → Finish without any property edit must NOT write to IFC.
Without this, every "open Edit, click Validate immediately" cycle
would create a fresh ``IfcShapeRepresentation``, pollute the file's
representation list, and burn an undo entry — the user-visible
regression that motivated the short-circuit.
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
the coverage as the original consumer of the contract.
"""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props)
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, _element = _railing_test_subclass(props)
cls._finish_one(obj, mock.Mock(name="context"))
assert props.is_editing is False, "is_editing must still flip even on no-op"
cls.pset_updates.assert_not_called()
cls.ifc_data_updates.assert_not_called()
def test_finish_one_writes_when_draft_differs(patched_railing):
"""The complement of the short-circuit: a real property change must
flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
# Draft height differs: simulating a user edit.
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
obj = _make_obj(props)
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, element = _railing_test_subclass(props)
cls._finish_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
cls.pset_updates.assert_called_once()
# The pset must receive the DRAFT data, not the stored data — that's the
# whole point of Finish committing the user's edits.
written = cls.pset_updates.call_args[0][1]
assert written["height"] == 1.5
cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
# ---------------------------------------------------------------------------
# _RailingEditMixin._cancel_one
# ---------------------------------------------------------------------------
def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
"""Cancel-without-changes is asymmetrically expensive without this guard:
``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
visibly slow on a long handrail. When nothing changed, the mesh on
screen is still the committed IFC representation (the preview only
builds on a property change) — skip the reload entirely.
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
the coverage as the original consumer of the contract.
"""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props)
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, _element = _railing_test_subclass(props)
cls._cancel_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
patched_railing["update_bmesh"].assert_not_called()
cls.bmesh_updates.assert_not_called()
# ---------------------------------------------------------------------------
# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
#
# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
# the draft differs from the stored pset. Railing's override branches on
# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
# representation rather than rebuilding the low-poly cylinder-segment preview.
# ---------------------------------------------------------------------------
def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
"""WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
the Body representation — the preview is viewport-only (low-poly cylinder)
and would persist visibly without the reload."""
from bonsai.bim.module.model.railing import _RailingEditMixin
props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
obj = _make_obj(props)
patched_railing["tool"].Model.get_railing_props.return_value = props
body_repr = mock.Mock(name="body_representation")
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
_RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
assert kwargs["obj"] is obj
assert kwargs["representation"] is body_repr
# Must NOT fall through to the FRAMELESS bmesh-rebuild path.
patched_railing["update_bmesh"].assert_not_called()
def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
swept-disk solid to reload. The restore must delegate to the module-level
``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
from bonsai.bim.module.model.railing import _RailingEditMixin
props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
obj = _make_obj(props)
patched_railing["tool"].Model.get_railing_props.return_value = props
ctx = mock.Mock(name="context")
_RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
patched_railing["update_bmesh"].assert_called_once_with(ctx)
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
# ---------------------------------------------------------------------------
# _get_railing_path_anchor: tests removed.
#
# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
# helper for the old anchor strategy and has been deleted along with the
# old gizmo group. If schematic-mode gains a similar path-derived helper,
# new tests should land here.
# ---------------------------------------------------------------------------
@@ -0,0 +1,270 @@
# 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.
import types
from types import SimpleNamespace
import bmesh
import bpy
import pytest
from bonsai import tool
from bonsai.bim.module.drawing.gizmos import (
BaseSchematicGizmoGroup,
DimensionGizmoConfig,
)
from bonsai.bim.module.model.railing import GizmoRailingSchematic
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
"""Skip the file when ``bpy`` is mocked or absent.
Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
invoked outside Blender) crash at module-collection time on the chain of
``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
"""
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ── Class shape ──────────────────────────────────────────────────────────────
def test_railing_schematic_inherits_base():
"""GizmoRailingSchematic plugs into the schematic framework, not the
in-place dimension framework. If a future refactor breaks this lineage
the schematic-specific machinery (sliders, draw handler) silently goes
dormant."""
assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
def test_railing_schematic_bl_idname_preserved():
"""``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
is referenced by keymaps and persistence. Preserve it across the class
rename — see the migration note in the class docstring."""
assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
def test_railing_schematic_props_getter_pairing():
"""``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
are the pairing test_parametric_registry depends on. If either drifts,
the addon-preferences gizmo toggle silently stops controlling this group."""
assert GizmoRailingSchematic.gizmo_pref_name == "railing"
assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
def test_railing_schematic_disables_in_place_dimension_props():
"""The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
assert GizmoRailingSchematic.dimension_gizmo_props == []
# ── Dimension configuration ─────────────────────────────────────────────────
def test_railing_schematic_has_six_dimensions():
"""One dimension per parametric property — three for each railing_type."""
assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
def test_railing_schematic_dimension_attr_names_complete():
"""The six bound attributes match the parametric properties that
``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
assert attr_names == {
"height",
"thickness",
"spacing",
"railing_diameter",
"clear_width",
"support_spacing",
}
def test_railing_schematic_dimensions_are_dimension_configs():
"""The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
arrows + label), not the abstract slider widget."""
for config in GizmoRailingSchematic.schematic_dimension_props:
assert isinstance(config, DimensionGizmoConfig)
def test_railing_schematic_dimensions_have_text_formatters():
"""Each dimension must format the label from the actual property value,
not from the visually-scaled value the gizmo's getter returns. Without a
formatter the label would show the schematic-scaled length, which is
meaningless to the user."""
for config in GizmoRailingSchematic.schematic_dimension_props:
assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
@pytest.mark.parametrize(
"attr_name,railing_type,expected",
[
("height", "FRAMELESS_PANEL", True),
("height", "WALL_MOUNTED_HANDRAIL", False),
("thickness", "FRAMELESS_PANEL", True),
("spacing", "FRAMELESS_PANEL", True),
("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
("railing_diameter", "FRAMELESS_PANEL", False),
("clear_width", "WALL_MOUNTED_HANDRAIL", True),
],
)
def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
"""The two railing types are mutually exclusive — height/thickness/spacing
belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
assert config.visibility_condition(props) is expected
def test_railing_schematic_support_spacing_hidden_for_manual_supports():
"""``support_spacing`` only drives auto-positioned supports — when the
user has switched to manual supports the dimension should disappear."""
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
assert config.visibility_condition(auto) is True
assert config.visibility_condition(manual) is False
# ── Fixed-length tag rendering ─────────────────────────────────────────────
def test_schematic_dim_visible_length_is_constant():
"""Every schematic dimension tag renders at the same width — the bar is a
UI affordance, not a proportional measurement. The constant ratio keeps
tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
real value lives in the dimension label.
Regression guard: if value-proportional scaling is reintroduced, this
contract breaks silently — small dimensions start collapsing into stacked
arrows again.
"""
cls = GizmoRailingSchematic
ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
assert ratio > 0
assert ratio <= 1.0 # bar must fit within the schematic box
def test_schematic_no_compute_schematic_scale_override():
"""The constant-length schematic must not reintroduce scale-based
proportional sizing via a ``_compute_schematic_scale`` override."""
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
# ── Path-edit guard ─────────────────────────────────────────────────────────
def test_update_editing_gizmos_override_defined_on_subclass():
"""``GizmoRailingSchematic`` must own the override that hides the pen
icon during path-edit. The parent's version shows the pen whenever
``is_editing`` is False, which includes path-edit; that would let the
user open two editing modes at once."""
assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
# ── Schematic mesh building ─────────────────────────────────────────────────
def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
"""FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
gap between the two boxes is the "spacing" semantic made literal.
The mesh proportions are fixed (independent of property values) so the
dimension gizmos can anchor to known feature positions; the property
values are shown through dimension labels, not the mesh size."""
props = SimpleNamespace(
railing_type="FRAMELESS_PANEL",
height=1.0,
thickness=0.05,
spacing=0.5,
)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
assert isinstance(bm, bmesh.types.BMesh)
assert len(bm.verts) == 16
assert len(bm.edges) == 24
finally:
bm.free()
def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
"""WALL_MOUNTED_HANDRAIL renders as three visual elements:
- **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
- **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
(6 left ring + 6 right ring + 6 axial).
- **L-brackets** at each rail end — 3 verts per bracket (rail centre,
corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
(rail→corner, corner→wall) × 2 = 4 edges.
Total: 22 verts, 26 edges.
"""
props = SimpleNamespace(
railing_type="WALL_MOUNTED_HANDRAIL",
railing_diameter=0.05,
clear_width=0.04,
support_spacing=1.0,
)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
assert isinstance(bm, bmesh.types.BMesh)
assert len(bm.verts) == 22
assert len(bm.edges) == 26
finally:
bm.free()
def test_build_schematic_mesh_proportions_independent_of_props():
"""The mesh uses fixed proportions so dimension gizmo anchor points stay
aligned with the geometry — extreme prop ratios don't change the mesh."""
small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05)
large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0)
bm_small = GizmoRailingSchematic.build_schematic_mesh(small)
bm_large = GizmoRailingSchematic.build_schematic_mesh(large)
try:
# Same vert count regardless of prop magnitude.
assert len(bm_small.verts) == len(bm_large.verts)
# Same bounding box in each axis (within floating-point noise).
for axis in range(3):
small_coords = [v.co[axis] for v in bm_small.verts]
large_coords = [v.co[axis] for v in bm_large.verts]
assert min(small_coords) == pytest.approx(min(large_coords))
assert max(small_coords) == pytest.approx(max(large_coords))
finally:
bm_small.free()
bm_large.free()
def test_build_schematic_mesh_panel_top_matches_height_frac():
"""The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``,
which is also where the ``thickness`` dimension anchors above the box.
If this drifts, the dimension labels float disconnected from the mesh."""
props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
max_y = max(v.co.y for v in bm.verts)
assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC)
finally:
bm.free()