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.
This commit is contained in:
parent
17d2ed21d3
commit
56f1f6c08b
5 changed files with 285 additions and 57 deletions
117
tests/unit/test_render_style.py
Normal file
117
tests/unit/test_render_style.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Unit tests for the shared render style and its editable subset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement
|
||||
from otko.views.canvas3d.style import (
|
||||
EDITABLE_FIELDS,
|
||||
SELECTED_STATE,
|
||||
STYLE_FIELDS,
|
||||
RenderStyle,
|
||||
element_family_index,
|
||||
family_palette,
|
||||
)
|
||||
|
||||
|
||||
def test_defaults_are_the_documented_palette() -> None:
|
||||
style = RenderStyle()
|
||||
assert style.node_color == "#d9d9d9"
|
||||
assert style.selected_color == "#00ffff"
|
||||
# opstool-derived element palette.
|
||||
assert style.element_beam_color == "#0652ff"
|
||||
assert style.element_truss_color == "#FF8C00"
|
||||
assert style.element_link_color == "#39FF14"
|
||||
assert len(style.response_scale_colors) == 10
|
||||
assert style.response_scale_colors[0] == "#313695" # cold end
|
||||
assert style.response_scale_colors[-1] == "#a50026" # warm end
|
||||
|
||||
|
||||
def test_with_updates_is_immutable() -> None:
|
||||
original = RenderStyle()
|
||||
updated = original.with_updates(node_color="#123456", label_font_size=20)
|
||||
assert updated is not original
|
||||
assert updated.node_color == "#123456"
|
||||
assert updated.label_font_size == 20
|
||||
# The source instance is untouched (frozen dataclass contract).
|
||||
assert original.node_color == "#d9d9d9"
|
||||
assert original.label_font_size == 12
|
||||
|
||||
|
||||
def test_editable_subset_matches_the_field_table() -> None:
|
||||
style = RenderStyle()
|
||||
assert RenderStyle.EDITABLE == EDITABLE_FIELDS
|
||||
assert set(style.editable_values()) == set(EDITABLE_FIELDS)
|
||||
# Every advertised field must exist on the dataclass with the right kind.
|
||||
kinds = {name: kind for name, _label, kind in STYLE_FIELDS}
|
||||
for name in EDITABLE_FIELDS:
|
||||
assert hasattr(style, name), name
|
||||
assert kinds[name] in {"color", "float", "int"}
|
||||
|
||||
|
||||
def test_response_colorscale_is_an_evenly_spaced_mapping() -> None:
|
||||
style = RenderStyle()
|
||||
scale = style.response_colorscale()
|
||||
assert len(scale) == len(style.response_scale_colors)
|
||||
assert scale[0] == (0.0, style.response_scale_colors[0])
|
||||
assert scale[-1] == (1.0, style.response_scale_colors[-1])
|
||||
positions = [position for position, _color in scale]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("element", "expected"),
|
||||
[
|
||||
(ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0),
|
||||
(TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1),
|
||||
(ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2),
|
||||
],
|
||||
)
|
||||
def test_element_family_index(element: object, expected: int) -> None:
|
||||
assert element_family_index(element) == expected
|
||||
|
||||
|
||||
def test_family_palette_order_and_selected_slot() -> None:
|
||||
style = RenderStyle(node_color="#010101")
|
||||
palette = family_palette(style)
|
||||
assert palette == [
|
||||
style.element_beam_color,
|
||||
style.element_truss_color,
|
||||
style.element_link_color,
|
||||
style.selected_color,
|
||||
]
|
||||
assert len(palette) - 1 == SELECTED_STATE
|
||||
|
||||
|
||||
def test_editable_values_round_trip_through_replace() -> None:
|
||||
style = RenderStyle()
|
||||
values = style.editable_values()
|
||||
assert RenderStyle(**values) == style
|
||||
# And the persisted shape is JSON-friendly (str/int/float only).
|
||||
import json
|
||||
|
||||
assert json.loads(json.dumps(values)) == values
|
||||
|
||||
|
||||
def test_style_is_a_frozen_slots_dataclass() -> None:
|
||||
assert dataclasses.is_dataclass(RenderStyle)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
RenderStyle().node_color = "#000000" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_project_builder_helper_is_unused_but_valid() -> None:
|
||||
"""A style constructed from a project's elements classifies every family."""
|
||||
project = Project(
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1),
|
||||
],
|
||||
)
|
||||
indices = [element_family_index(el) for el in project.elements]
|
||||
assert indices == [0, 1]
|
||||
|
|
@ -24,6 +24,7 @@ from otko.views.canvas3d.model_renderer import (
|
|||
RendererMode,
|
||||
_classify_support,
|
||||
)
|
||||
from otko.views.canvas3d.style import SELECTED_STATE
|
||||
|
||||
|
||||
# ──────────────────────────── support classification ────────────────────────────
|
||||
|
|
@ -125,9 +126,13 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project
|
|||
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]
|
||||
# Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
|
||||
# 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] == 1
|
||||
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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue