otko/tests/unit/test_renderer.py
smillmorel 56f1f6c08b feat: route both canvas backends through RenderStyle
Colours that were hard-coded in the renderers now come from the shared
style: nodes, supports, nodal/element loads, section extrusions (+
opacity) and label font size. The PyVista frame LUT becomes a four-slot
palette [beam, truss, link, selected] with the cell scalar carrying the
family slot, so VTK matches the Plotly backend's per-family colouring
that landed earlier. Adds style helpers shared by both backends
(element_family_index, family_palette) and an immutable with_updates().

The style also gains the editable field table (STYLE_FIELDS) the Plot
Properties dialog is built from.
2026-09-16 19:31:30 -04:00

193 lines
7.7 KiB
Python

"""Unit tests for ModelRenderer (high-perf glyphed implementation)."""
from __future__ import annotations
import pytest
pv = pytest.importorskip("pyvista")
import numpy as np
from otko.core import (
ElasticBeamColumn,
ElasticSection,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
Steel01,
TrussElement,
)
from otko.services.deformation import DeformationSource
from otko.views.canvas3d.model_renderer import (
ModelRenderer,
RendererMode,
_classify_support,
)
from otko.views.canvas3d.style import SELECTED_STATE
# ──────────────────────────── support classification ────────────────────────────
def test_classify_support_full_fix() -> None:
assert _classify_support((True,) * 6, (0, 1, 2, 3, 4, 5)) == "fix"
def test_classify_support_pin_3d() -> None:
assert _classify_support((True, True, True, False, False, False), (0, 1, 2, 3, 4, 5)) == "pin"
def test_classify_support_pin_2d() -> None:
assert _classify_support((True, True, False, False, False, False), (0, 1)) == "fix"
assert _classify_support((True, True, False, False, False, False), (0, 1, 5)) == "pin"
def test_classify_support_roller() -> None:
assert _classify_support((False, True, False, False, False, False), (0, 1, 5)) == "roller"
# ──────────────────────────── renderer fixtures ────────────────────────────
@pytest.fixture
def offscreen_plotter(): # type: ignore[no-untyped-def]
pv.OFF_SCREEN = True
p = pv.Plotter(off_screen=True)
yield p
p.close()
@pytest.fixture
def small_3d_project() -> Project:
return Project(
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3.0)),
Node(id=3, coords=(4.0, 0, 3.0), mass=(100, 100, 0, 0, 0, 0)),
],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=3, forces=(0, 0, -10e3, 0, 0, 0))],
)
],
)
# ──────────────────────────── core rendering ────────────────────────────
def test_render_empty_project_does_not_raise(offscreen_plotter) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(None)
r.render(Project())
def test_render_creates_node_and_frame_polydata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
# One polydata for nodes, one for frames.
assert r._node_pd is not None
assert r._frame_pd is not None
assert len(r._node_ids_ordered) == 3
assert len(r._frame_ids_ordered) == 2
def test_render_attaches_picking_metadata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
node_ids = set(np.asarray(r._node_pd["_oss_id"]).tolist())
frame_ids = set(np.asarray(r._frame_pd.cell_data["_oss_id"]).tolist())
assert node_ids == {1, 2, 3}
assert frame_ids == {1, 2}
def test_render_twice_does_not_leak_actors(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
aux1 = len(r._aux_actors)
r.render(small_3d_project)
aux2 = len(r._aux_actors)
assert aux1 == aux2 # not doubled
# ──────────────────────────── selection ────────────────────────────
def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
r.update_selection(frozenset({1, 3}), frozenset({2}))
node_states = np.asarray(r._node_pd["_oss_state"]).tolist()
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
# Nodes 1 and 3 selected → row 0 and row 2
assert node_states == [1, 0, 1]
# Frame cells carry the palette slot: the element family normally, and
# SELECTED_STATE when selected.
selected_frame_idx = r._frame_id_to_row[2]
assert frame_states[selected_frame_idx] == SELECTED_STATE
for index, family in enumerate(r._frame_family):
expected = SELECTED_STATE if index == selected_frame_idx else family
assert frame_states[index] == expected
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
r.update_selection(frozenset({1}), frozenset())
r.update_selection(frozenset(), frozenset())
assert all(v == 0 for v in np.asarray(r._node_pd["_oss_state"]))
# ──────────────────────────── deformation modes ────────────────────────────
def test_set_deformed_mode_shifts_node_positions(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
# Node 3 gets a 0.5m horizontal disp; others zero.
disp = np.zeros((3, 3))
disp[2] = (0.5, 0.0, 0.0)
src = DeformationSource(
displacements=disp,
node_id_to_row={1: 0, 2: 1, 3: 2},
scale=1.0,
)
r.set_mode(RendererMode.DEFORMED, src)
pts = np.asarray(r._node_pd.points)
# Node 3 was at x=4.0 → now x=4.5
assert abs(pts[2, 0] - 4.5) < 1e-9
# Nodes 1 and 2 unchanged
assert tuple(pts[0]) == (0.0, 0.0, 0.0)
def test_set_mode_back_to_model_restores_original(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
disp = np.array([[0, 0, 0], [0, 0, 0], [10.0, 0, 0]])
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=1.0)
r.set_mode(RendererMode.DEFORMED, src)
r.set_mode(RendererMode.MODEL)
pts = np.asarray(r._node_pd.points)
# Node 3 back to (4, 0, 3)
assert tuple(pts[2]) == (4.0, 0.0, 3.0)
def test_deformation_scale_multiplies_displacement(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
disp = np.array([[0, 0, 0], [0, 0, 0], [1.0, 0, 0]])
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=10.0)
r.set_mode(RendererMode.DEFORMED, src)
pts = np.asarray(r._node_pd.points)
# Node 3: 4.0 + 10.0 * 1.0 = 14.0
assert abs(pts[2, 0] - 14.0) < 1e-9
# ──────────────────────────── degenerate geometry ────────────────────────────
def test_diag_handles_degenerate_geometry() -> None:
pts = np.array([[0, 0, 0], [0, 0, 0]])
assert ModelRenderer._diag_of_points(pts) == 1.0
assert ModelRenderer._diag_of_points(None) == 1.0