feat/plotly-canvas #5
5 changed files with 285 additions and 57 deletions
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.
commit
56f1f6c08b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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