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:
smillmorel 2026-09-16 19:31:30 -04:00
commit 56f1f6c08b
5 changed files with 285 additions and 57 deletions

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib import contextlib
import enum import enum
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, ClassVar from typing import Any
import numpy as np import numpy as np
import pyvista as pv import pyvista as pv
@ -33,7 +33,12 @@ from otko.core import (
ZeroLengthElement, ZeroLengthElement,
ZeroLengthSectionElement, 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): class RendererMode(enum.Enum):
@ -185,9 +190,6 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb) r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}" 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: def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter self._plotter = plotter
self._style = style or RenderStyle() self._style = style or RenderStyle()
@ -206,6 +208,8 @@ class ModelRenderer:
self._frame_actor: Any = None self._frame_actor: Any = None
self._frame_ids_ordered: list[int] = [] self._frame_ids_ordered: list[int] = []
self._frame_id_to_row: dict[int, 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._node_label_actor: Any = None
self._element_label_actor: Any = None self._element_label_actor: Any = None
self._show_node_labels: bool = False self._show_node_labels: bool = False
@ -278,6 +282,16 @@ class ModelRenderer:
if self._project is not None: if self._project is not None:
self.render(self._project) 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: def set_working_plane(self, plane: tuple[str, float] | None) -> None:
"""Update the working-plane filter and rebuild the grid overlay. """Update the working-plane filter and rebuild the grid overlay.
@ -306,11 +320,12 @@ class ModelRenderer:
self._reglyph_nodes() self._reglyph_nodes()
if self._frame_pd is not None and self._frame_ids_ordered: 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: for eid in element_ids:
row = self._frame_id_to_row.get(eid) row = self._frame_id_to_row.get(eid)
if row is not None: if row is not None:
states[row] = 1 states[row] = SELECTED_STATE
self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.cell_data["_oss_state"] = states
self._frame_pd.Modified() self._frame_pd.Modified()
@ -379,7 +394,7 @@ class ModelRenderer:
self._node_actor = self._plotter.add_mesh( self._node_actor = self._plotter.add_mesh(
glyph, glyph,
scalars="_oss_state", scalars="_oss_state",
cmap=self._NODE_LUT, cmap=[self._style.node_color, self._style.node_selected_color],
clim=[0, 1], clim=[0, 1],
show_scalar_bar=False, show_scalar_bar=False,
pickable=True, pickable=True,
@ -392,6 +407,7 @@ class ModelRenderer:
return return
cells: list[int] = [] cells: list[int] = []
ids: list[int] = [] ids: list[int] = []
families: list[int] = []
for el in frames: for el in frames:
try: try:
i = self._node_id_to_row[el.nodes[0]] i = self._node_id_to_row[el.nodes[0]]
@ -400,6 +416,7 @@ class ModelRenderer:
continue continue
cells.extend([2, i, j]) cells.extend([2, i, j])
ids.append(el.id) ids.append(el.id)
families.append(element_family_index(el))
if not ids: if not ids:
return return
pd = pv.PolyData() pd = pv.PolyData()
@ -407,16 +424,18 @@ class ModelRenderer:
pd.lines = np.array(cells, dtype=np.int64) pd.lines = np.array(cells, dtype=np.int64)
pd.cell_data["_oss_id"] = np.array(ids, 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_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_pd = pd
self._frame_ids_ordered = ids self._frame_ids_ordered = ids
self._frame_id_to_row = {eid: i for i, eid in enumerate(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( self._frame_actor = self._plotter.add_mesh(
pd, pd,
scalars="_oss_state", scalars="_oss_state",
cmap=self._FRAME_LUT, cmap=family_palette(self._style),
clim=[0, 1], clim=[0, SELECTED_STATE],
show_scalar_bar=False, show_scalar_bar=False,
line_width=3.0, line_width=3.0,
pickable=True, pickable=True,
@ -761,8 +780,8 @@ class ModelRenderer:
"""Add one semi-transparent steel-blue non-pickable aux actor.""" """Add one semi-transparent steel-blue non-pickable aux actor."""
actor = self._plotter.add_mesh( actor = self._plotter.add_mesh(
mesh, mesh,
color=(0.35, 0.60, 0.85), # cool steel-blue color=self._style.extrusion_color,
opacity=0.22, opacity=self._style.extrusion_opacity,
show_edges=True, show_edges=True,
edge_color=(0.15, 0.25, 0.45), edge_color=(0.15, 0.25, 0.45),
line_width=1.0, line_width=1.0,
@ -982,7 +1001,7 @@ class ModelRenderer:
ndf = project.ndf ndf = project.ndf
dof_idx = _dof_indices(ndf) dof_idx = _dof_indices(ndf)
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6) 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: for node in project.nodes:
if not any(node.restraint[i] for i in dof_idx): if not any(node.restraint[i] for i in dof_idx):
continue continue
@ -996,7 +1015,7 @@ class ModelRenderer:
if not project.load_patterns or self._node_original_points is None: if not project.load_patterns or self._node_original_points is None:
return return
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6) 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} node_by_id = {n.id: n for n in project.nodes}
elem_by_id = {e.id: e for e in project.elements} elem_by_id = {e.id: e for e in project.elements}
for pattern in project.load_patterns: for pattern in project.load_patterns:
@ -1027,7 +1046,7 @@ class ModelRenderer:
# Draw N arrows along the element span, each perpendicular # Draw N arrows along the element span, each perpendicular
# to the axis in the direction of the load. Uses the same # to the axis in the direction of the load. Uses the same
# orange-green palette as nodal loads but with shorter arrows. # 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 n_arrows_per_elem = 5
for eload in pattern.element_loads: for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad): if not isinstance(eload, UniformElementLoad):
@ -1116,6 +1135,7 @@ class ModelRenderer:
self._node_ids_ordered = [] self._node_ids_ordered = []
self._node_id_to_row = {} self._node_id_to_row = {}
self._frame_ids_ordered = [] self._frame_ids_ordered = []
self._frame_family = []
self._frame_id_to_row = {} self._frame_id_to_row = {}
self._node_original_points = None self._node_original_points = None
self._deformation = None self._deformation = None
@ -1148,7 +1168,7 @@ class ModelRenderer:
return self._plotter.add_point_labels( return self._plotter.add_point_labels(
pts, pts,
labels, labels,
font_size=12, font_size=self._style.label_font_size,
shape_opacity=0.15, shape_opacity=0.15,
text_color="black", text_color="black",
point_color="white", point_color="white",
@ -1178,7 +1198,7 @@ class ModelRenderer:
return self._plotter.add_point_labels( return self._plotter.add_point_labels(
np.asarray(centers), np.asarray(centers),
labels, labels,
font_size=12, font_size=self._style.label_font_size,
shape_opacity=0.15, shape_opacity=0.15,
text_color="black", text_color="black",
point_color="white", 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 globally through the renderer; downstream code never hard-codes a
color or radius. A future "theme" feature can simply swap a different color or radius. A future "theme" feature can simply swap a different
RenderStyle instance. 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 __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) @dataclass(frozen=True, slots=True)
@ -19,13 +71,13 @@ class RenderStyle:
background_top: str = "#dbe2ef" background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb" background_bottom: str = "#f5f7fb"
node_color: str = "#f0a500" # warm gold node_color: str = "#d9d9d9" # normal node sphere / marker
node_selected_color: str = "#00d4ff" # bright cyan node_selected_color: str = "#00ffff" # selected node
frame_color: str = "#1f1f1f" 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" 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) ─────── # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
# opstool colours wireframe elements by *family* rather than painting # opstool colours wireframe elements by *family* rather than painting
@ -36,6 +88,14 @@ class RenderStyle:
element_truss_color: str = "#FF8C00" # truss family element_truss_color: str = "#FF8C00" # truss family
element_link_color: str = "#39FF14" # zero-length / link 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, #: Diverging scale for scalar response overlays (force diagrams today,
#: nodal / element response plots later): blue → red, evenly spaced. #: nodal / element response plots later): blue → red, evenly spaced.
#: This is opstool's ``default_cmap`` (RdYlBu reversed). #: This is opstool's ``default_cmap`` (RdYlBu reversed).
@ -57,7 +117,7 @@ class RenderStyle:
roller_color: str = "#e67e22" roller_color: str = "#e67e22"
custom_support_color: str = "#7f8c8d" custom_support_color: str = "#7f8c8d"
load_color: str = "#27ae60" load_color: str = "#27ae60" # Y-axis colour of the view triad
mass_color: str = "#9b59b6" mass_color: str = "#9b59b6"
# ── sizes (relative to bbox diagonal unless absolute) ──────────── # ── sizes (relative to bbox diagonal unless absolute) ────────────
@ -75,6 +135,9 @@ class RenderStyle:
selection_thickness_factor: float = 1.6 # multiplier for selected actors 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 ────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────
def response_colorscale(self) -> list[tuple[float, str]]: def response_colorscale(self) -> list[tuple[float, str]]:
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" """Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
@ -84,3 +147,11 @@ class RenderStyle:
return [ return [
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) (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, ZeroLengthElement,
ZeroLengthSectionElement, ZeroLengthSectionElement,
) )
from otko.views.canvas3d.style import RenderStyle from otko.views.canvas3d.style import (
RenderStyle,
element_family_index,
family_palette,
)
_FRAME_CLASSES = ( _FRAME_CLASSES = (
ElasticBeamColumn, ElasticBeamColumn,
@ -56,19 +60,6 @@ _FRAME_CLASSES = (
ZeroLengthElement, 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`. #: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`.
_BOX_TRIS = ( _BOX_TRIS = (
(0, 1, 2), (0, 1, 2),
@ -96,7 +87,6 @@ _SUPPORT_SYMBOLS = {
_NODE_MARKER_SIZE = 7.0 _NODE_MARKER_SIZE = 7.0
_SUPPORT_MARKER_SIZE = 11.0 _SUPPORT_MARKER_SIZE = 11.0
_SNAP_MARKER_SIZE = 8.0 _SNAP_MARKER_SIZE = 8.0
_LABEL_FONT_SIZE = 11
@dataclass(frozen=True) @dataclass(frozen=True)
@ -275,6 +265,10 @@ class PlotlyTraceBuilder:
def __init__(self, style: RenderStyle | None = None) -> None: def __init__(self, style: RenderStyle | None = None) -> None:
self._style = style or RenderStyle() 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 ─────────────────────────────────────────────────────── # ── public ───────────────────────────────────────────────────────
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
opts = options or SceneOptions() opts = options or SceneOptions()
@ -488,7 +482,12 @@ class PlotlyTraceBuilder:
) -> None: ) -> None:
if not len(points): if not len(points):
return 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( data.append(
{ {
"type": "scatter3d", "type": "scatter3d",
@ -528,12 +527,7 @@ class PlotlyTraceBuilder:
""" """
if not len(points): if not len(points):
return return
scale_colors = [ scale_colors = family_palette(self._style)
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 selected_index = len(scale_colors) - 1
x: list[float | None] = [] x: list[float | None] = []
@ -548,7 +542,9 @@ class PlotlyTraceBuilder:
j = node_row.get(el.nodes[1]) j = node_row.get(el.nodes[1])
if i is None or j is None: if i is None or j is None:
continue 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]) x.extend([float(points[i][0]), float(points[j][0]), None])
y.extend([float(points[i][1]), float(points[j][1]), None]) y.extend([float(points[i][1]), float(points[j][1]), None])
z.extend([float(points[i][2]), float(points[j][2]), 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], "y": [float(n.coords[1]) for n in nodes],
"z": [float(n.coords[2]) for n in nodes], "z": [float(n.coords[2]) for n in nodes],
"marker": { "marker": {
"color": "#ff8019", "color": self._style.support_color,
"size": _SUPPORT_MARKER_SIZE, "size": _SUPPORT_MARKER_SIZE,
"symbol": _SUPPORT_SYMBOLS[kind], "symbol": _SUPPORT_SYMBOLS[kind],
"line": {"color": "#7f3f00", "width": 1}, "line": {"color": "#7f3f00", "width": 1},
@ -708,7 +704,7 @@ class PlotlyTraceBuilder:
nodal_u, nodal_u,
nodal_v, nodal_v,
nodal_w, nodal_w,
color="#33d933", color=self._style.nodal_load_color,
name="nodal-loads", name="nodal-loads",
size=scale, size=scale,
) )
@ -722,7 +718,7 @@ class PlotlyTraceBuilder:
dist_u, dist_u,
dist_v, dist_v,
dist_w, dist_w,
color="#ff8c33", color=self._style.element_load_color,
name="element-loads", name="element-loads",
size=0.6 * scale, size=0.6 * scale,
) )
@ -833,7 +829,13 @@ class PlotlyTraceBuilder:
y_local, z_local = _frame_basis(el, x_local) 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)) mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z))
if not mesh.is_empty: 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 @staticmethod
def _box_corners( def _box_corners(
@ -872,7 +874,11 @@ class PlotlyTraceBuilder:
) -> None: ) -> None:
if opts.show_node_labels and len(points): 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] 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): if opts.show_element_labels and len(points):
centers: list[np.ndarray] = [] centers: list[np.ndarray] = []
labels: list[str] = [] labels: list[str] = []
@ -886,7 +892,14 @@ class PlotlyTraceBuilder:
centers.append((points[i] + points[j]) / 2.0) centers.append((points[i] + points[j]) / 2.0)
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}") labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
if centers: 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 @staticmethod
def _build_hover_marker(data: list[dict[str, Any]]) -> int: 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 { return {
"type": "scatter3d", "type": "scatter3d",
"mode": "text", "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], "z": [float(p[2]) for p in points],
"text": labels, "text": labels,
"textposition": "top center", "textposition": "top center",
"textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"}, "textfont": {"size": font_size, "color": "#111111"},
"hoverinfo": "skip", "hoverinfo": "skip",
"name": name, "name": name,
"showlegend": False, "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, RendererMode,
_classify_support, _classify_support,
) )
from otko.views.canvas3d.style import SELECTED_STATE
# ──────────────────────────── support classification ──────────────────────────── # ──────────────────────────── 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() frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
# Nodes 1 and 3 selected → row 0 and row 2 # Nodes 1 and 3 selected → row 0 and row 2
assert node_states == [1, 0, 1] 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] 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] def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]