mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-19 03:33:48 +00:00
05c9df74f9
Switches the IfcRailingType terminal-type selector from cycle-on-click to a popup menu of all terminal-type literals — 5+ values trip the §2.8 menu-pick threshold. Updates classes registration; removes EditRailingTerminalType in favour of PickRailingTerminalType which inherits PickTypeMixin. Adapts the cherry-pick from db016d881 to post-PR5 framework state: - Imports CycleTypeMixin / PickTypeMixin / PathPreservingEditMixin from bim.parametric_lifecycle (PR5 moved them off gizmos.py). - Routes is_railing through tool.Parametric (predicates moved off tool.Blender.Modifier between PR3-PR5). Skips the parametric_lifecycle.py framework refactor the source commit shipped — HEAD has the more-evolved post-PR5 framework that already covers it. Adds the _FakePropsBase + make_lifecycle_obj test helpers to test/bim/conftest.py so the new test_railing_lifecycle.py can exercise the edit triad without a real bpy.types.Object. Brings the test_railing_schematic.py marker in line with the rest of the model lane. Generated with the assistance of an AI coding tool.
271 lines
12 KiB
Python
271 lines
12 KiB
Python
# 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()
|