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

15
NOTICE
View file

@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Ported / adapted code: `opstool`
The canvas element colour palette in `src/otko/views/canvas3d/style.py`
(per-family element colours and the diverging response colour scale) and the
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
adapted from the `opstool` project, which is distributed under the **GNU
General Public License v3.0**. opstool is Copyright © Yexiang Yan and
contributors.
In accordance with GPLv3 §5(a)/(b) this notice records that the material was
modified and adapted for OTKO. Combining the GPLv3-covered material with
OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is
conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the
opstool-derived portions.
## Runtime dependencies
OTKO depends on third-party software that is not covered by OTKO's

View file

@ -31,6 +31,7 @@ import pyvista as pv
from otko.core import Project
from otko.services.element_forces import DiagramData, ForceComponent
from otko.views.canvas3d.style import RenderStyle
_LOG = logging.getLogger("otko.diagram")
@ -62,8 +63,9 @@ class DiagramRenderer:
- ``clear()`` removes the overlay.
"""
def __init__(self, plotter: Any) -> None:
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
self._actor: Any = None
self._label_actor: Any = None
@ -158,7 +160,9 @@ class DiagramRenderer:
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
cmap="coolwarm",
# Shared response palette (opstool's default diverging scale), so
# the PyVista and Plotly backends stay visually consistent.
cmap=list(self._style.response_scale_colors),
clim=(-vmax, vmax),
show_scalar_bar=True,
scalar_bar_args={"title": comp_label, "n_labels": 5},

View file

@ -27,6 +27,31 @@ class RenderStyle:
zerolength_color: str = "#a020f0"
selected_color: str = "#00d4ff"
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
# opstool colours wireframe elements by *family* rather than painting
# every frame the same colour, which makes mixed beam/truss/link models
# readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in
# ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``.
element_beam_color: str = "#0652ff" # beam-column family
element_truss_color: str = "#FF8C00" # truss family
element_link_color: str = "#39FF14" # zero-length / link family
#: Diverging scale for scalar response overlays (force diagrams today,
#: nodal / element response plots later): blue → red, evenly spaced.
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
response_scale_colors: tuple[str, ...] = (
"#313695",
"#4575b4",
"#74add1",
"#abd9e9",
"#e0f3f8",
"#fee090",
"#fdae61",
"#f46d43",
"#d73027",
"#a50026",
)
fix_color: str = "#c0392b" # firebrick
pin_color: str = "#c0392b"
roller_color: str = "#e67e22"
@ -49,3 +74,13 @@ class RenderStyle:
load_min_length: float = 0.5
selection_thickness_factor: float = 1.6 # multiplier for selected actors
# ── helpers ──────────────────────────────────────────────────────
def response_colorscale(self) -> list[tuple[float, str]]:
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
count = len(self.response_scale_colors)
if count < 2:
return [(0.0, self.response_scale_colors[0])]
return [
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
]

View file

@ -56,6 +56,19 @@ _FRAME_CLASSES = (
ZeroLengthElement,
)
#: Element families that get their own palette entry (see ``RenderStyle``).
_TRUSS_FAMILY = (TrussElement, CorotTrussElement)
def _family_index(el: Any) -> int:
"""Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
if isinstance(el, _TRUSS_FAMILY):
return 1
if isinstance(el, ZeroLengthElement):
return 2
return 0
#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`.
_BOX_TRIS = (
(0, 1, 2),
@ -504,12 +517,30 @@ class PlotlyTraceBuilder:
points: np.ndarray,
node_row: dict[int, int],
) -> None:
"""One line trace whose per-point ``line.color`` carries the palette.
plotly accepts an *array* for ``Scatter3d.line.color`` mapped through
a ``colorscale``, so a single trace can colour every element
individually — by family and by selection — instead of one trace per
colour. That also leaves room to colour by response value later.
Each element's two endpoints (and its ``None`` separator) share one
colour index, so each drawn segment is a solid colour.
"""
if not len(points):
return
normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
normal_ids: list[Any] = []
selected_ids: list[Any] = []
scale_colors = [
self._style.element_beam_color,
self._style.element_truss_color,
self._style.element_link_color,
self._style.selected_color,
]
selected_index = len(scale_colors) - 1
x: list[float | None] = []
y: list[float | None] = []
z: list[float | None] = []
color_index: list[float] = []
customdata: list[Any] = []
for el in project.elements:
if not isinstance(el, _FRAME_CLASSES):
continue
@ -517,37 +548,39 @@ class PlotlyTraceBuilder:
j = node_row.get(el.nodes[1])
if i is None or j is None:
continue
seg = (tuple(points[i]), tuple(points[j]))
if el.id in opts.selection_elements:
selected.append(seg)
selected_ids.extend([el.id, el.id, None])
else:
normal.append(seg)
normal_ids.extend([el.id, el.id, None])
if not normal and not selected:
index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
x.extend([float(points[i][0]), float(points[j][0]), None])
y.extend([float(points[i][1]), float(points[j][1]), None])
z.extend([float(points[i][2]), float(points[j][2]), None])
color_index.extend([index, index, index])
customdata.extend([el.id, el.id, None])
if not x:
return
if normal:
data.append(
_line_trace(
normal,
color="#338cd9",
width=4,
name="elements",
meta={"kind": "element"},
customdata=normal_ids,
)
)
if selected:
data.append(
_line_trace(
selected,
color="#00ffff",
width=6,
name="elements-selected",
meta={"kind": "element"},
customdata=selected_ids,
)
)
count = len(scale_colors)
data.append(
{
"type": "scatter3d",
"mode": "lines",
"x": x,
"y": y,
"z": z,
"line": {
"color": color_index,
"colorscale": [
(index / (count - 1), color) for index, color in enumerate(scale_colors)
],
"cmin": 0,
"cmax": count - 1,
"width": 4,
},
"customdata": customdata,
"meta": {"kind": "element"},
"hoverinfo": "skip",
"name": "elements",
"showlegend": False,
}
)
def _build_supports(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions

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: