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:
parent
17d2ed21d3
commit
56f1f6c08b
5 changed files with 285 additions and 57 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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,
|
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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue