mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-12 06:32:09 +00:00
Add railing parametric edit + schematic preview
Port gizmos-8088's railing gizmo block to v0.8.0:
- _RailingEditMixin (PathPreservingEditMixin specialisation) +
EnableEditingRailing / CancelEditingRailing / FinishEditingRailing
edit triad
- CycleRailingType (2-value type cycler) + ToggleRailingUseManualSupports
one-shot + EditRailingTerminalType
- FlipRailingPathOrder + EnableEditingRailingPath /
CancelEditingRailingPath / FinishEditingRailingPath path-edit
operators (mutually exclusive with the schematic frame)
- GizmoRailingSchematic (BaseSchematicGizmoGroup specialisation) —
axonometric schematic frame with per-attribute dimension gizmos
for FRAMELESS_PANEL + WALL_MOUNTED_HANDRAIL railing types;
hover-on-attr highlights the schematic edges tagged with the
matching feature
Tests: test_railing_lifecycle.py (280 LOC) +
test_railing_schematic.py (272 LOC).
Drops the per-feature GizmoPreferences{Door,Window,Stair,Wall,Roof,
Railing} PropertyGroups that the source commit added to bim/ui.py
— that finer-grained per-attribute toggle model was deliberately
collapsed to flat per-feature bools in the PR5b prefs sweep, and
GizmoRailingSchematic gates on the flat ``prefs.gizmos.railing``
bool via ``gizmo_pref_name`` so no functionality is lost.
Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
# 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`` lifecycle overrides.
|
||||
|
||||
The generic ``PathPreservingEditMixin`` lifecycle is tested in
|
||||
``test_parametric_lifecycle.py``. This file pins the **railing-specific
|
||||
overrides** that subclass it:
|
||||
|
||||
- ``_RailingEditMixin._finish_one`` short-circuit: when the draft equals
|
||||
the stored pset, ``_update_pset`` and ``_update_modifier_ifc_data`` are
|
||||
skipped so an Enable → Finish-without-changes cycle creates no new
|
||||
``IfcShapeRepresentation``.
|
||||
- ``_RailingEditMixin._cancel_one`` short-circuit: same logic guards the
|
||||
expensive ``bonsai.core.geometry.switch_representation`` call (which
|
||||
re-tessellates the swept-disk solid) when nothing actually changed.
|
||||
- ``_RailingEditMixin._cancel_one`` WALL_MOUNTED_HANDRAIL branch: when
|
||||
changes WERE made, the cancel reloads the IFC body via
|
||||
``switch_representation`` instead of running ``update_modifier_bmesh``
|
||||
(which would leave the low-poly cylinder-segment preview on screen).
|
||||
"""
|
||||
|
||||
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`` calls ``tool.Model.get_modeling_bbim_pset_data``,
|
||||
``tool.Ifc.get_entity``, ``ifcopenshell.util.representation.get_representation``,
|
||||
and ``bonsai.core.geometry.switch_representation`` — each looked up
|
||||
through the railing module's own bindings, so we patch them there.
|
||||
|
||||
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.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,
|
||||
):
|
||||
# _resolve will be overridden on the test subclass below so the
|
||||
# parametric_lifecycle.tool patch isn't needed.
|
||||
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
|
||||
yield {"tool": mock_tool, "ifcopenshell": mock_ifc, "bonsai": mock_bonsai}
|
||||
|
||||
|
||||
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="_update_modifier_bmesh")
|
||||
|
||||
@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 _update_modifier_bmesh(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.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["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["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.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["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()
|
||||
cls.bmesh_updates.assert_not_called()
|
||||
|
||||
|
||||
def test_cancel_one_wall_mounted_handrail_switches_representation(patched_railing):
|
||||
"""Cancel after a real edit on a WALL_MOUNTED_HANDRAIL must reload the
|
||||
committed Body representation (high-poly, IFC-derived) rather than
|
||||
re-running the low-poly bmesh preview — that preview is a viewport-only
|
||||
approximation and would persist visibly after Cancel without this.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
# Differs → not a no-op → cancel must take the real branch.
|
||||
props = _FakeRailingProps(
|
||||
railing_type="WALL_MOUNTED_HANDRAIL",
|
||||
general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5},
|
||||
)
|
||||
obj = _make_obj(props)
|
||||
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
body_repr = mock.Mock(name="body_representation")
|
||||
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
|
||||
|
||||
cls, _element = _railing_test_subclass(props)
|
||||
cls._cancel_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False
|
||||
# Must call switch_representation with the Body representation; must NOT
|
||||
# call _update_modifier_bmesh (that's the FRAMELESS branch).
|
||||
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
|
||||
cls.bmesh_updates.assert_not_called()
|
||||
|
||||
|
||||
def test_cancel_one_frameless_panel_runs_bmesh_preview(patched_railing):
|
||||
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
|
||||
swept-disk solid to reload. Cancel must run the bmesh rebuild instead
|
||||
of switch_representation, which would no-op or worse."""
|
||||
stored = {"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.05}
|
||||
props = _FakeRailingProps(
|
||||
railing_type="FRAMELESS_PANEL",
|
||||
general={"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.08},
|
||||
)
|
||||
obj = _make_obj(props)
|
||||
patched_railing["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
|
||||
cls.bmesh_updates.assert_called_once_with(obj, mock.ANY)
|
||||
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,272 @@
|
||||
# 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.railing
|
||||
|
||||
|
||||
@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 design has no need for a scale factor. If a
|
||||
subclass redefines ``_compute_schematic_scale``, it indicates the
|
||||
scale-based proportional sizing was reintroduced — which is the design
|
||||
we deliberately stepped away from."""
|
||||
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()
|
||||
Reference in New Issue
Block a user