feat: opstool-derived element palette and single-trace frame colours

Ports opstool's per-family element colours and its diverging response
scale; RenderStyle.response_scale_colors now also drives the PyVista
force-diagram colouring instead of a hard-coded "coolwarm".

The frame renderer drops the two-trace normal/selected workaround: my
earlier assumption that plotly cannot colour segments individually was
wrong. Scatter3d.line.color accepts an array mapped through a colorscale,
so one trace now carries per-element colours (family + selection) and is
ready to be coloured by response value later.

Attribution recorded in NOTICE per GPLv3 section 5(a)/(b).
This commit is contained in:
smillmorel 2026-09-16 19:15:55 -04:00
commit 17d2ed21d3
5 changed files with 164 additions and 45 deletions

View file

@ -9,6 +9,7 @@ import numpy as np
import pytest
from otko.services import load_project
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
SceneOptions,
@ -49,16 +50,47 @@ def test_nodes_carry_ids_as_customdata() -> None:
assert len(nodes["marker"]["color"]) == len(project.nodes)
def test_selected_element_gets_its_own_trace() -> None:
def _color_for_index(trace: dict, index: float) -> str:
"""Map a ``line.color`` scalar index to its colour via the colorscale."""
scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0])
return scale[int(index)][1]
def test_frames_are_one_trace_with_per_element_colours() -> None:
"""A single line trace carries the palette through its ``line.color`` array."""
project = _load("cantilever")
scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_elements=frozenset({2})))
normal = _traces(scene, "elements")
selected = _traces(scene, "elements-selected")
assert len(normal) == 1 and len(selected) == 1
# Plotly cannot colour one segment of a single line trace, so the
# selected element moves to the highlight trace.
assert 2 in selected[0]["customdata"]
assert 2 not in normal[0]["customdata"]
style = RenderStyle()
scene = PlotlyTraceBuilder(style).build(
project, SceneOptions(selection_elements=frozenset({2}))
)
frames = _traces(scene, "elements")
assert len(frames) == 1, "selection must not split the frame trace"
line = frames[0]["line"]
assert isinstance(line["color"], list)
assert len(line["color"]) == len(frames[0]["x"])
assert line["colorscale"]
colors = line["color"]
customdata = frames[0]["customdata"]
def index_of(element_id: int) -> set[float]:
return {colors[k] for k, value in enumerate(customdata) if value == element_id}
selected = index_of(2)
unselected = index_of(1)
assert len(selected) == 1 and len(unselected) == 1
assert selected != unselected
assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color
assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color
def test_element_palette_distinguishes_beam_from_truss() -> None:
style = RenderStyle()
builder = PlotlyTraceBuilder(style)
beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0]
truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0]
assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color
assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color
def test_selected_node_is_highlighted_by_colour() -> None: