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.
193 lines
7.7 KiB
Python
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
|