feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
5 changed files with 285 additions and 57 deletions
Showing only changes of commit 56f1f6c08b - Show all commits

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.
smillmorel 2026-09-16 19:31:30 -04:00

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib
import enum
from dataclasses import dataclass
from typing import Any, ClassVar
from typing import Any
import numpy as np
import pyvista as pv
@ -33,7 +33,12 @@ from otko.core import (
ZeroLengthElement,
ZeroLengthSectionElement,
)
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas3d.style import (
SELECTED_STATE,
RenderStyle,
element_family_index,
family_palette,
)
class RendererMode(enum.Enum):
@ -185,9 +190,6 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}"
_NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
_FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
@ -206,6 +208,8 @@ class ModelRenderer:
self._frame_actor: Any = None
self._frame_ids_ordered: list[int] = []
self._frame_id_to_row: dict[int, int] = {}
#: Per-frame palette index (family), parallel to ``_frame_ids_ordered``.
self._frame_family: list[int] = []
self._node_label_actor: Any = None
self._element_label_actor: Any = None
self._show_node_labels: bool = False
@ -278,6 +282,16 @@ class ModelRenderer:
if self._project is not None:
self.render(self._project)
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and rebuild the scene it colours.
Colours are baked into actor LUTs at build time, so a style change
needs a full re-render rather than a scalar-array tweak.
"""
self._style = style
if self._project is not None:
self.render(self._project)
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
"""Update the working-plane filter and rebuild the grid overlay.
@ -306,11 +320,12 @@ class ModelRenderer:
self._reglyph_nodes()
if self._frame_pd is not None and self._frame_ids_ordered:
states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
# Restore each cell to its family slot, then flag the selected ones.
states = np.array(self._frame_family, dtype=np.int8)
for eid in element_ids:
row = self._frame_id_to_row.get(eid)
if row is not None:
states[row] = 1
states[row] = SELECTED_STATE
self._frame_pd.cell_data["_oss_state"] = states
self._frame_pd.Modified()
@ -379,7 +394,7 @@ class ModelRenderer:
self._node_actor = self._plotter.add_mesh(
glyph,
scalars="_oss_state",
cmap=self._NODE_LUT,
cmap=[self._style.node_color, self._style.node_selected_color],
clim=[0, 1],
show_scalar_bar=False,
pickable=True,
@ -392,6 +407,7 @@ class ModelRenderer:
return
cells: list[int] = []
ids: list[int] = []
families: list[int] = []
for el in frames:
try:
i = self._node_id_to_row[el.nodes[0]]
@ -400,6 +416,7 @@ class ModelRenderer:
continue
cells.extend([2, i, j])
ids.append(el.id)
families.append(element_family_index(el))
if not ids:
return
pd = pv.PolyData()
@ -407,16 +424,18 @@ class ModelRenderer:
pd.lines = np.array(cells, dtype=np.int64)
pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64)
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
# Cell scalar carries the palette slot (family, or SELECTED_STATE).
pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8)
self._frame_pd = pd
self._frame_ids_ordered = ids
self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)}
self._frame_family = families
self._frame_actor = self._plotter.add_mesh(
pd,
scalars="_oss_state",
cmap=self._FRAME_LUT,
clim=[0, 1],
cmap=family_palette(self._style),
clim=[0, SELECTED_STATE],
show_scalar_bar=False,
line_width=3.0,
pickable=True,
@ -761,8 +780,8 @@ class ModelRenderer:
"""Add one semi-transparent steel-blue non-pickable aux actor."""
actor = self._plotter.add_mesh(
mesh,
color=(0.35, 0.60, 0.85), # cool steel-blue
opacity=0.22,
color=self._style.extrusion_color,
opacity=self._style.extrusion_opacity,
show_edges=True,
edge_color=(0.15, 0.25, 0.45),
line_width=1.0,
@ -982,7 +1001,7 @@ class ModelRenderer:
ndf = project.ndf
dof_idx = _dof_indices(ndf)
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
support_color = (1.0, 0.5, 0.1)
support_color = self._style.support_color
for node in project.nodes:
if not any(node.restraint[i] for i in dof_idx):
continue
@ -996,7 +1015,7 @@ class ModelRenderer:
if not project.load_patterns or self._node_original_points is None:
return
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
load_color = (0.2, 0.85, 0.2)
load_color = self._style.nodal_load_color
node_by_id = {n.id: n for n in project.nodes}
elem_by_id = {e.id: e for e in project.elements}
for pattern in project.load_patterns:
@ -1027,7 +1046,7 @@ class ModelRenderer:
# Draw N arrows along the element span, each perpendicular
# to the axis in the direction of the load. Uses the same
# orange-green palette as nodal loads but with shorter arrows.
elem_load_color = (1.0, 0.55, 0.2) # orange
elem_load_color = self._style.element_load_color # distributed loads
n_arrows_per_elem = 5
for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad):
@ -1116,6 +1135,7 @@ class ModelRenderer:
self._node_ids_ordered = []
self._node_id_to_row = {}
self._frame_ids_ordered = []
self._frame_family = []
self._frame_id_to_row = {}
self._node_original_points = None
self._deformation = None
@ -1148,7 +1168,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
pts,
labels,
font_size=12,
font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",
@ -1178,7 +1198,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
np.asarray(centers),
labels,
font_size=12,
font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",

View file

@ -4,11 +4,63 @@ All colors, sizes, and glyph parameters live here. Changes apply
globally through the renderer; downstream code never hard-codes a
color or radius. A future "theme" feature can simply swap a different
RenderStyle instance.
The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through
Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``).
Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0);
see ``NOTICE``.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any, ClassVar
from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement
#: Selected elements / nodes occupy the last slot of the palette LUT.
SELECTED_STATE: int = 3
def element_family_index(element: object) -> int:
"""Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
if isinstance(element, TrussElement | CorotTrussElement):
return 1
if isinstance(element, ZeroLengthElement):
return 2
return 0
def family_palette(style: RenderStyle) -> list[str]:
"""LUT shared by both backends: [beam, truss, link, selected]."""
return [
style.element_beam_color,
style.element_truss_color,
style.element_link_color,
style.selected_color,
]
#: Colours / sizes the Plot Properties dialog exposes, in display order.
STYLE_FIELDS: tuple[tuple[str, str, str], ...] = (
("element_beam_color", "Beam elements", "color"),
("element_truss_color", "Truss elements", "color"),
("element_link_color", "Link / zero-length", "color"),
("selected_color", "Selection highlight", "color"),
("node_color", "Nodes", "color"),
("node_selected_color", "Selected nodes", "color"),
("support_color", "Supports", "color"),
("nodal_load_color", "Nodal loads", "color"),
("element_load_color", "Element loads", "color"),
("extrusion_color", "Section extrusions", "color"),
("extrusion_opacity", "Extrusion opacity", "float"),
("background_top", "Background (top)", "color"),
("background_bottom", "Background (bottom)", "color"),
("label_font_size", "Label font size", "int"),
)
#: Field names the dialog edits and that are persisted between sessions.
EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS)
@dataclass(frozen=True, slots=True)
@ -19,13 +71,13 @@ class RenderStyle:
background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb"
node_color: str = "#f0a500" # warm gold
node_selected_color: str = "#00d4ff" # bright cyan
node_color: str = "#d9d9d9" # normal node sphere / marker
node_selected_color: str = "#00ffff" # selected node
frame_color: str = "#1f1f1f"
truss_color: str = "#2e5cb8"
truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad
zerolength_color: str = "#a020f0"
selected_color: str = "#00d4ff"
selected_color: str = "#00ffff" # selected element highlight
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
# opstool colours wireframe elements by *family* rather than painting
@ -36,6 +88,14 @@ class RenderStyle:
element_truss_color: str = "#FF8C00" # truss family
element_link_color: str = "#39FF14" # zero-length / link family
# ── overlay colours ─────────────────────────────────────────────
support_color: str = "#ff8019"
nodal_load_color: str = "#33d933"
element_load_color: str = "#ff8c33"
extrusion_color: str = "#598cff"
extrusion_opacity: float = 0.22
label_font_size: int = 12
#: 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).
@ -57,7 +117,7 @@ class RenderStyle:
roller_color: str = "#e67e22"
custom_support_color: str = "#7f8c8d"
load_color: str = "#27ae60"
load_color: str = "#27ae60" # Y-axis colour of the view triad
mass_color: str = "#9b59b6"
# ── sizes (relative to bbox diagonal unless absolute) ────────────
@ -75,6 +135,9 @@ class RenderStyle:
selection_thickness_factor: float = 1.6 # multiplier for selected actors
#: Names of the fields the Plot Properties dialog edits.
EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS
# ── helpers ──────────────────────────────────────────────────────
def response_colorscale(self) -> list[tuple[float, str]]:
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
@ -84,3 +147,11 @@ class RenderStyle:
return [
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
]
def with_updates(self, **changes: Any) -> RenderStyle:
"""Return a copy with ``changes`` applied (frozen-dataclass safe)."""
return replace(self, **changes)
def editable_values(self) -> dict[str, Any]:
"""The dialog-managed subset of this style, as a plain dict."""
return {name: getattr(self, name) for name in self.EDITABLE}

View file

@ -44,7 +44,11 @@ from otko.core import (
ZeroLengthElement,
ZeroLengthSectionElement,
)
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas3d.style import (
RenderStyle,
element_family_index,
family_palette,
)
_FRAME_CLASSES = (
ElasticBeamColumn,
@ -56,19 +60,6 @@ _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),
@ -96,7 +87,6 @@ _SUPPORT_SYMBOLS = {
_NODE_MARKER_SIZE = 7.0
_SUPPORT_MARKER_SIZE = 11.0
_SNAP_MARKER_SIZE = 8.0
_LABEL_FONT_SIZE = 11
@dataclass(frozen=True)
@ -275,6 +265,10 @@ class PlotlyTraceBuilder:
def __init__(self, style: RenderStyle | None = None) -> None:
self._style = style or RenderStyle()
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style; the next :meth:`build` picks it up."""
self._style = style
# ── public ───────────────────────────────────────────────────────
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
opts = options or SceneOptions()
@ -488,7 +482,12 @@ class PlotlyTraceBuilder:
) -> None:
if not len(points):
return
colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids]
colors = [
self._style.node_selected_color
if nid in opts.selection_nodes
else self._style.node_color
for nid in node_ids
]
data.append(
{
"type": "scatter3d",
@ -528,12 +527,7 @@ class PlotlyTraceBuilder:
"""
if not len(points):
return
scale_colors = [
self._style.element_beam_color,
self._style.element_truss_color,
self._style.element_link_color,
self._style.selected_color,
]
scale_colors = family_palette(self._style)
selected_index = len(scale_colors) - 1
x: list[float | None] = []
@ -548,7 +542,9 @@ class PlotlyTraceBuilder:
j = node_row.get(el.nodes[1])
if i is None or j is None:
continue
index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
index = float(
selected_index if el.id in opts.selection_elements else element_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])
@ -603,7 +599,7 @@ class PlotlyTraceBuilder:
"y": [float(n.coords[1]) for n in nodes],
"z": [float(n.coords[2]) for n in nodes],
"marker": {
"color": "#ff8019",
"color": self._style.support_color,
"size": _SUPPORT_MARKER_SIZE,
"symbol": _SUPPORT_SYMBOLS[kind],
"line": {"color": "#7f3f00", "width": 1},
@ -708,7 +704,7 @@ class PlotlyTraceBuilder:
nodal_u,
nodal_v,
nodal_w,
color="#33d933",
color=self._style.nodal_load_color,
name="nodal-loads",
size=scale,
)
@ -722,7 +718,7 @@ class PlotlyTraceBuilder:
dist_u,
dist_v,
dist_w,
color="#ff8c33",
color=self._style.element_load_color,
name="element-loads",
size=0.6 * scale,
)
@ -833,7 +829,13 @@ class PlotlyTraceBuilder:
y_local, z_local = _frame_basis(el, x_local)
mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z))
if not mesh.is_empty:
data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions"))
data.append(
mesh.as_trace(
color=self._style.extrusion_color,
opacity=self._style.extrusion_opacity,
name="extrusions",
)
)
@staticmethod
def _box_corners(
@ -872,7 +874,11 @@ class PlotlyTraceBuilder:
) -> None:
if opts.show_node_labels and len(points):
labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes]
data.append(_text_trace(points, labels, name="node-labels"))
data.append(
_text_trace(
points, labels, name="node-labels", font_size=self._style.label_font_size
)
)
if opts.show_element_labels and len(points):
centers: list[np.ndarray] = []
labels: list[str] = []
@ -886,7 +892,14 @@ class PlotlyTraceBuilder:
centers.append((points[i] + points[j]) / 2.0)
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
if centers:
data.append(_text_trace(np.asarray(centers), labels, name="element-labels"))
data.append(
_text_trace(
np.asarray(centers),
labels,
name="element-labels",
font_size=self._style.label_font_size,
)
)
@staticmethod
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
@ -942,7 +955,9 @@ def _cone_trace(
}
def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]:
def _text_trace(
points: np.ndarray, labels: list[str], *, name: str, font_size: int
) -> dict[str, Any]:
return {
"type": "scatter3d",
"mode": "text",
@ -951,7 +966,7 @@ def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str
"z": [float(p[2]) for p in points],
"text": labels,
"textposition": "top center",
"textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"},
"textfont": {"size": font_size, "color": "#111111"},
"hoverinfo": "skip",
"name": name,
"showlegend": False,

View 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]

View file

@ -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]