Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd365be77d | |||
| 56f1f6c08b |
14 changed files with 633 additions and 59 deletions
|
|
@ -20,6 +20,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
|||
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
|
||||
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
|
||||
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
|
||||
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
|
||||
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
||||
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
|
||||
|
||||
|
|
|
|||
|
|
@ -342,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
self._renderer.set_show_local_axes(enabled)
|
||||
self.render()
|
||||
|
||||
def set_style(self, style: RenderStyle) -> None:
|
||||
"""Swap the visual style and repaint the scene.
|
||||
|
||||
Applies the background immediately (it lives outside the renderer)
|
||||
and rebuilds the actors whose colours come from the style LUTs.
|
||||
"""
|
||||
self._style = style
|
||||
self.set_background(style.background_bottom, top=style.background_top)
|
||||
self._renderer.set_style(style)
|
||||
self.render()
|
||||
|
||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||
"""Toggle viewport labels such as node and element names."""
|
||||
self._renderer.set_display_options(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,12 @@ class PlotlyCanvas(QWidget):
|
|||
self._options = replace(self._options, show_local_axes=bool(enabled))
|
||||
self.render()
|
||||
|
||||
def set_style(self, style: RenderStyle) -> None:
|
||||
"""Swap the visual style and repaint the figure."""
|
||||
self._style = style
|
||||
self._builder.set_style(style)
|
||||
self.render()
|
||||
|
||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||
self._options = replace(
|
||||
self._options,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
|
|||
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
||||
from otko.views.dialogs.plot_properties import PlotPropertiesDialog
|
||||
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
||||
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
||||
from otko.views.dialogs.replicate import ReplicateDialog
|
||||
|
|
@ -69,6 +70,7 @@ __all__ = [
|
|||
"PathTimeSeriesDialog",
|
||||
"PatternLoadsDialog",
|
||||
"PlainPatternDialog",
|
||||
"PlotPropertiesDialog",
|
||||
"QuickGridLinesDialog",
|
||||
"QuickGuideDialog",
|
||||
"ReplicateDialog",
|
||||
|
|
|
|||
139
src/otko/views/dialogs/plot_properties.py
Normal file
139
src/otko/views/dialogs/plot_properties.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
|
||||
|
||||
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
|
||||
so adding an editable property is a one-line change there plus a default on
|
||||
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
|
||||
so the canvas can preview them live; ``style()`` returns the composed result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
|
||||
|
||||
|
||||
class _ColorButton(QPushButton):
|
||||
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
|
||||
|
||||
colorChanged = Signal(str)
|
||||
|
||||
def __init__(self, color: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._color = color
|
||||
self.setFixedWidth(96)
|
||||
self._refresh()
|
||||
self.clicked.connect(self._pick)
|
||||
|
||||
def color(self) -> str:
|
||||
"""Current colour as a ``#rrggbb`` string."""
|
||||
return self._color
|
||||
|
||||
def set_color(self, color: str) -> None:
|
||||
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
|
||||
self._color = color
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
self.setText(self._color.upper())
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
|
||||
|
||||
def _text_color(self) -> str:
|
||||
color = QColor(self._color)
|
||||
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
|
||||
return "#000000" if luminance > 140 else "#ffffff"
|
||||
|
||||
def _pick(self) -> None:
|
||||
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
|
||||
if chosen.isValid():
|
||||
self.set_color(chosen.name())
|
||||
self.colorChanged.emit(self._color)
|
||||
|
||||
|
||||
class PlotPropertiesDialog(QDialog):
|
||||
"""Modeless-free editor for the persisted plot style."""
|
||||
|
||||
#: Emitted with the composed :class:`RenderStyle` on every edit.
|
||||
changed = Signal(object)
|
||||
|
||||
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Plot Properties")
|
||||
self._base = style
|
||||
self._colors: dict[str, _ColorButton] = {}
|
||||
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
for name, label, kind in STYLE_FIELDS:
|
||||
widget = self._build_widget(name, getattr(style, name), kind)
|
||||
form.addRow(QLabel(f"{label}:"), widget)
|
||||
layout.addLayout(form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
|
||||
assert reset is not None
|
||||
reset.clicked.connect(self._reset)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# ── public ──────────────────────────────────────────────────────
|
||||
def plot_style(self) -> RenderStyle:
|
||||
"""The composed style for the current widget values.
|
||||
|
||||
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
|
||||
already exists and must keep its Qt meaning.
|
||||
"""
|
||||
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
|
||||
updates.update({name: w.value() for name, w in self._numbers.items()})
|
||||
return self._base.with_updates(**updates)
|
||||
|
||||
# ── internals ───────────────────────────────────────────────────
|
||||
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
|
||||
if kind == "color":
|
||||
button = _ColorButton(str(value))
|
||||
button.colorChanged.connect(self._emit_changed)
|
||||
self._colors[name] = button
|
||||
return button
|
||||
if kind == "float":
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.0, 1.0)
|
||||
spin.setSingleStep(0.05)
|
||||
spin.setDecimals(2)
|
||||
spin.setValue(float(value)) # type: ignore[arg-type]
|
||||
spin.valueChanged.connect(self._emit_changed)
|
||||
self._numbers[name] = spin
|
||||
return spin
|
||||
int_spin = QSpinBox()
|
||||
int_spin.setRange(6, 32)
|
||||
int_spin.setValue(int(value)) # type: ignore[call-overload]
|
||||
int_spin.valueChanged.connect(self._emit_changed)
|
||||
self._numbers[name] = int_spin
|
||||
return int_spin
|
||||
|
||||
def _emit_changed(self, *_args: object) -> None:
|
||||
self.changed.emit(self.plot_style())
|
||||
|
||||
def _reset(self) -> None:
|
||||
defaults = RenderStyle()
|
||||
for name, button in self._colors.items():
|
||||
button.set_color(getattr(defaults, name))
|
||||
for name, spin in self._numbers.items():
|
||||
spin.setValue(getattr(defaults, name))
|
||||
self._emit_changed()
|
||||
|
|
@ -13,6 +13,7 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ from otko.views.action_handlers import ActionHandlers
|
|||
from otko.views.canvas3d import ModelCanvas
|
||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
||||
from otko.views.canvas3d.selection import SelectionState
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
from otko.views.canvas_base import plotly_available
|
||||
from otko.views.dock_manager import DockManager
|
||||
from otko.views.menu_builder import MenuBuilder
|
||||
|
|
@ -72,6 +74,8 @@ class MainWindow(
|
|||
|
||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||
self._canvas_backend = self._stored_canvas_backend()
|
||||
#: Shared, user-editable visual style (both backends read it).
|
||||
self._style = self._load_plot_style()
|
||||
self._canvases: dict[str, Any] = {}
|
||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||
# A stack holds every backend's canvas: swapping is just
|
||||
|
|
@ -115,9 +119,9 @@ class MainWindow(
|
|||
# Imported lazily so a plotly-less install still boots PyVista.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
canvas = PlotlyCanvas(self, selection=self._selection)
|
||||
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
|
||||
else:
|
||||
canvas = ModelCanvas(self, selection=self._selection)
|
||||
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
||||
self._canvases[backend] = canvas
|
||||
return canvas
|
||||
|
||||
|
|
@ -145,6 +149,35 @@ class MainWindow(
|
|||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||
return self._canvas_backend
|
||||
|
||||
# ── plot style ───────────────────────────────────────────────────
|
||||
def _load_plot_style(self) -> RenderStyle:
|
||||
"""Style from the last session, ignoring anything unrecognised."""
|
||||
raw = self._settings.value("plot/props")
|
||||
if not raw:
|
||||
return RenderStyle()
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except ValueError:
|
||||
return RenderStyle()
|
||||
if not isinstance(data, dict):
|
||||
return RenderStyle()
|
||||
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
|
||||
try:
|
||||
return RenderStyle(**allowed)
|
||||
except (TypeError, ValueError):
|
||||
return RenderStyle()
|
||||
|
||||
def _save_plot_style(self) -> None:
|
||||
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
|
||||
|
||||
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
|
||||
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
|
||||
self._style = style
|
||||
for canvas in self._canvases.values():
|
||||
canvas.set_style(style)
|
||||
if persist:
|
||||
self._save_plot_style()
|
||||
|
||||
def _build_tooling(self) -> None:
|
||||
"""(Re)build the tool controller and tools against the current canvas."""
|
||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||
|
|
@ -261,6 +294,7 @@ class MainWindow(
|
|||
self._act_about.triggered.connect(self._on_about)
|
||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||
self._act_set_units.triggered.connect(self._on_set_units)
|
||||
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
||||
|
||||
# Edit
|
||||
self._act_delete.triggered.connect(self._on_delete)
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ class MenuBuilder:
|
|||
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
||||
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||
self._act_set_units = QAction("Set Display &Units…", self)
|
||||
self._act_plot_properties = QAction("&Plot Properties…", self)
|
||||
self._act_plot_properties.setToolTip(
|
||||
"Colours and sizes used by both canvas backends (persisted)."
|
||||
)
|
||||
|
||||
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
||||
from otko.views.canvas_base import plotly_available
|
||||
|
|
@ -512,6 +516,7 @@ class MenuBuilder:
|
|||
|
||||
m_options = mb.addMenu("&Options")
|
||||
m_options.addAction(self._act_set_units)
|
||||
m_options.addAction(self._act_plot_properties)
|
||||
m_backend = m_options.addMenu("Canvas &Backend")
|
||||
m_backend.addAction(self._act_canvas_pyvista)
|
||||
m_backend.addAction(self._act_canvas_plotly)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,23 @@ class RenderControls:
|
|||
)
|
||||
|
||||
# ── SAP2000-style view switching + working level ───────────────
|
||||
def _on_plot_properties(self) -> None:
|
||||
"""Edit the shared visual style.
|
||||
|
||||
Edits preview live on both canvases; Cancel restores the style the
|
||||
dialog opened with, and only OK persists to ``QSettings``.
|
||||
"""
|
||||
from otko.views.dialogs import PlotPropertiesDialog
|
||||
|
||||
original = self._style
|
||||
dialog = PlotPropertiesDialog(original, self)
|
||||
dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.set_plot_style(dialog.plot_style())
|
||||
self._log("Plot properties updated.")
|
||||
else:
|
||||
self.set_plot_style(original, persist=False)
|
||||
|
||||
def _on_toggle_parallel(self, on: bool) -> None:
|
||||
cam = self._canvas.camera
|
||||
cam.parallel_projection = on
|
||||
|
|
|
|||
131
tests/gui/test_plot_properties.py
Normal file
131
tests/gui/test_plot_properties.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""GUI tests for Options → Plot Properties.
|
||||
|
||||
Covers the dialog itself plus MainWindow's live-apply / persist / revert
|
||||
wiring. QSettings are injected so nothing touches real user state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("pyvistaqt")
|
||||
|
||||
from PySide6.QtCore import QObject, QSettings, Signal
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
|
||||
def _settings(tmp_path): # type: ignore[no-untyped-def]
|
||||
return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||
|
||||
|
||||
def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
window = MainWindow(settings=_settings(tmp_path))
|
||||
qtbot.addWidget(window)
|
||||
return window
|
||||
|
||||
|
||||
def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
|
||||
window = _window(tmp_path, qtbot)
|
||||
assert window._style == RenderStyle()
|
||||
|
||||
|
||||
def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _window(tmp_path, qtbot)
|
||||
window.swap_canvas_backend("plotly") # build the second canvas too
|
||||
|
||||
window.set_plot_style(
|
||||
window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
|
||||
)
|
||||
|
||||
assert set(window._canvases) == {"pyvista", "plotly"}
|
||||
for canvas in window._canvases.values():
|
||||
assert canvas._style.element_beam_color == "#ff0000"
|
||||
assert canvas._style.label_font_size == 20
|
||||
|
||||
# A fresh window sharing the settings file reloads the customisation.
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
fresh = MainWindow(settings=_settings(tmp_path))
|
||||
qtbot.addWidget(fresh)
|
||||
assert fresh._style.element_beam_color == "#ff0000"
|
||||
assert fresh._style.label_font_size == 20
|
||||
|
||||
|
||||
def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _window(tmp_path, qtbot)
|
||||
window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
|
||||
|
||||
assert window._canvas._style.node_color == "#010203"
|
||||
assert window._settings.value("plot/props") is None
|
||||
|
||||
|
||||
def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
window = _window(tmp_path, qtbot)
|
||||
original = window._style
|
||||
preview = original.with_updates(node_color="#010203")
|
||||
|
||||
class _RejectingDialog(QObject):
|
||||
changed = Signal(object)
|
||||
|
||||
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._style = preview
|
||||
|
||||
def exec(self) -> QDialog.DialogCode:
|
||||
self.changed.emit(preview) # live preview happens...
|
||||
return QDialog.DialogCode.Rejected # ...but the user cancels
|
||||
|
||||
def plot_style(self): # type: ignore[no-untyped-def]
|
||||
return self._style
|
||||
|
||||
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
|
||||
window._on_plot_properties()
|
||||
|
||||
assert window._style == original
|
||||
assert window._canvas._style.node_color == original.node_color
|
||||
assert window._settings.value("plot/props") is None
|
||||
|
||||
|
||||
def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
window = _window(tmp_path, qtbot)
|
||||
preview = window._style.with_updates(node_color="#010203")
|
||||
|
||||
class _AcceptingDialog(QObject):
|
||||
changed = Signal(object)
|
||||
|
||||
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._style = preview
|
||||
|
||||
def exec(self) -> QDialog.DialogCode:
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def plot_style(self): # type: ignore[no-untyped-def]
|
||||
return self._style
|
||||
|
||||
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
|
||||
window._on_plot_properties()
|
||||
|
||||
assert window._style.node_color == "#010203"
|
||||
assert window._canvas._style.node_color == "#010203"
|
||||
assert "#010203" in str(window._settings.value("plot/props"))
|
||||
|
||||
|
||||
def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
from otko.views.dialogs import PlotPropertiesDialog
|
||||
|
||||
dialog = PlotPropertiesDialog(RenderStyle())
|
||||
qtbot.addWidget(dialog)
|
||||
assert dialog.plot_style() == RenderStyle()
|
||||
|
||||
dialog._colors["node_color"].set_color("#abcdef")
|
||||
assert dialog.plot_style().node_color == "#abcdef"
|
||||
|
||||
dialog._reset()
|
||||
assert dialog.plot_style() == RenderStyle()
|
||||
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