Compare commits

..
14 changed files with 59 additions and 633 deletions

View file

@ -20,7 +20,6 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.** - `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/`: 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/` (**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. - `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). - 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).

View file

@ -342,17 +342,6 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled) self._renderer.set_show_local_axes(enabled)
self.render() 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: def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
"""Toggle viewport labels such as node and element names.""" """Toggle viewport labels such as node and element names."""
self._renderer.set_display_options( self._renderer.set_display_options(

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib import contextlib
import enum import enum
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, ClassVar
import numpy as np import numpy as np
import pyvista as pv import pyvista as pv
@ -33,12 +33,7 @@ from otko.core import (
ZeroLengthElement, ZeroLengthElement,
ZeroLengthSectionElement, ZeroLengthSectionElement,
) )
from otko.views.canvas3d.style import ( from otko.views.canvas3d.style import RenderStyle
SELECTED_STATE,
RenderStyle,
element_family_index,
family_palette,
)
class RendererMode(enum.Enum): class RendererMode(enum.Enum):
@ -190,6 +185,9 @@ 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()
@ -208,8 +206,6 @@ 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
@ -282,16 +278,6 @@ 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.
@ -320,12 +306,11 @@ 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:
# Restore each cell to its family slot, then flag the selected ones. states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
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] = SELECTED_STATE states[row] = 1
self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.cell_data["_oss_state"] = states
self._frame_pd.Modified() self._frame_pd.Modified()
@ -394,7 +379,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._style.node_color, self._style.node_selected_color], cmap=self._NODE_LUT,
clim=[0, 1], clim=[0, 1],
show_scalar_bar=False, show_scalar_bar=False,
pickable=True, pickable=True,
@ -407,7 +392,6 @@ 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]]
@ -416,7 +400,6 @@ 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()
@ -424,18 +407,16 @@ 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)
# Cell scalar carries the palette slot (family, or SELECTED_STATE). pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
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=family_palette(self._style), cmap=self._FRAME_LUT,
clim=[0, SELECTED_STATE], clim=[0, 1],
show_scalar_bar=False, show_scalar_bar=False,
line_width=3.0, line_width=3.0,
pickable=True, pickable=True,
@ -780,8 +761,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=self._style.extrusion_color, color=(0.35, 0.60, 0.85), # cool steel-blue
opacity=self._style.extrusion_opacity, opacity=0.22,
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,
@ -1001,7 +982,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 = self._style.support_color support_color = (1.0, 0.5, 0.1)
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
@ -1015,7 +996,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 = self._style.nodal_load_color load_color = (0.2, 0.85, 0.2)
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:
@ -1046,7 +1027,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 = self._style.element_load_color # distributed loads elem_load_color = (1.0, 0.55, 0.2) # orange
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):
@ -1135,7 +1116,6 @@ 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
@ -1168,7 +1148,7 @@ class ModelRenderer:
return self._plotter.add_point_labels( return self._plotter.add_point_labels(
pts, pts,
labels, labels,
font_size=self._style.label_font_size, font_size=12,
shape_opacity=0.15, shape_opacity=0.15,
text_color="black", text_color="black",
point_color="white", point_color="white",
@ -1198,7 +1178,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=self._style.label_font_size, font_size=12,
shape_opacity=0.15, shape_opacity=0.15,
text_color="black", text_color="black",
point_color="white", point_color="white",

View file

@ -4,63 +4,11 @@ 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, replace from dataclasses import dataclass
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)
@ -71,13 +19,13 @@ class RenderStyle:
background_top: str = "#dbe2ef" background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb" background_bottom: str = "#f5f7fb"
node_color: str = "#d9d9d9" # normal node sphere / marker node_color: str = "#f0a500" # warm gold
node_selected_color: str = "#00ffff" # selected node node_selected_color: str = "#00d4ff" # bright cyan
frame_color: str = "#1f1f1f" frame_color: str = "#1f1f1f"
truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad truss_color: str = "#2e5cb8"
zerolength_color: str = "#a020f0" zerolength_color: str = "#a020f0"
selected_color: str = "#00ffff" # selected element highlight selected_color: str = "#00d4ff"
# ── 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
@ -88,14 +36,6 @@ 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).
@ -117,7 +57,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" # Y-axis colour of the view triad load_color: str = "#27ae60"
mass_color: str = "#9b59b6" mass_color: str = "#9b59b6"
# ── sizes (relative to bbox diagonal unless absolute) ──────────── # ── sizes (relative to bbox diagonal unless absolute) ────────────
@ -135,9 +75,6 @@ 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`."""
@ -147,11 +84,3 @@ class RenderStyle:
return [ return [
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
] ]
def with_updates(self, **changes: Any) -> RenderStyle:
"""Return a copy with ``changes`` applied (frozen-dataclass safe)."""
return replace(self, **changes)
def editable_values(self) -> dict[str, Any]:
"""The dialog-managed subset of this style, as a plain dict."""
return {name: getattr(self, name) for name in self.EDITABLE}

View file

@ -271,12 +271,6 @@ class PlotlyCanvas(QWidget):
self._options = replace(self._options, show_local_axes=bool(enabled)) self._options = replace(self._options, show_local_axes=bool(enabled))
self.render() 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: def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._options = replace( self._options = replace(
self._options, self._options,

View file

@ -44,11 +44,7 @@ from otko.core import (
ZeroLengthElement, ZeroLengthElement,
ZeroLengthSectionElement, ZeroLengthSectionElement,
) )
from otko.views.canvas3d.style import ( from otko.views.canvas3d.style import RenderStyle
RenderStyle,
element_family_index,
family_palette,
)
_FRAME_CLASSES = ( _FRAME_CLASSES = (
ElasticBeamColumn, ElasticBeamColumn,
@ -60,6 +56,19 @@ _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),
@ -87,6 +96,7 @@ _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)
@ -265,10 +275,6 @@ 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()
@ -482,12 +488,7 @@ class PlotlyTraceBuilder:
) -> None: ) -> None:
if not len(points): if not len(points):
return return
colors = [ colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids]
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",
@ -527,7 +528,12 @@ class PlotlyTraceBuilder:
""" """
if not len(points): if not len(points):
return return
scale_colors = family_palette(self._style) scale_colors = [
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] = []
@ -542,9 +548,7 @@ 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( index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
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])
@ -599,7 +603,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": self._style.support_color, "color": "#ff8019",
"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},
@ -704,7 +708,7 @@ class PlotlyTraceBuilder:
nodal_u, nodal_u,
nodal_v, nodal_v,
nodal_w, nodal_w,
color=self._style.nodal_load_color, color="#33d933",
name="nodal-loads", name="nodal-loads",
size=scale, size=scale,
) )
@ -718,7 +722,7 @@ class PlotlyTraceBuilder:
dist_u, dist_u,
dist_v, dist_v,
dist_w, dist_w,
color=self._style.element_load_color, color="#ff8c33",
name="element-loads", name="element-loads",
size=0.6 * scale, size=0.6 * scale,
) )
@ -829,13 +833,7 @@ 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( data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions"))
mesh.as_trace(
color=self._style.extrusion_color,
opacity=self._style.extrusion_opacity,
name="extrusions",
)
)
@staticmethod @staticmethod
def _box_corners( def _box_corners(
@ -874,11 +872,7 @@ 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( data.append(_text_trace(points, labels, name="node-labels"))
_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] = []
@ -892,14 +886,7 @@ 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( data.append(_text_trace(np.asarray(centers), labels, name="element-labels"))
_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:
@ -955,9 +942,7 @@ def _cone_trace(
} }
def _text_trace( def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]:
points: np.ndarray, labels: list[str], *, name: str, font_size: int
) -> dict[str, Any]:
return { return {
"type": "scatter3d", "type": "scatter3d",
"mode": "text", "mode": "text",
@ -966,7 +951,7 @@ def _text_trace(
"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": font_size, "color": "#111111"}, "textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"},
"hoverinfo": "skip", "hoverinfo": "skip",
"name": name, "name": name,
"showlegend": False, "showlegend": False,

View file

@ -34,7 +34,6 @@ from otko.views.dialogs.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog
from otko.views.dialogs.plain_pattern import PlainPatternDialog 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_grid_lines import QuickGridLinesDialog
from otko.views.dialogs.quick_guide import QuickGuideDialog from otko.views.dialogs.quick_guide import QuickGuideDialog
from otko.views.dialogs.replicate import ReplicateDialog from otko.views.dialogs.replicate import ReplicateDialog
@ -70,7 +69,6 @@ __all__ = [
"PathTimeSeriesDialog", "PathTimeSeriesDialog",
"PatternLoadsDialog", "PatternLoadsDialog",
"PlainPatternDialog", "PlainPatternDialog",
"PlotPropertiesDialog",
"QuickGridLinesDialog", "QuickGridLinesDialog",
"QuickGuideDialog", "QuickGuideDialog",
"ReplicateDialog", "ReplicateDialog",

View file

@ -1,139 +0,0 @@
"""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()

View file

@ -13,7 +13,6 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -26,7 +25,6 @@ from otko.views.action_handlers import ActionHandlers
from otko.views.canvas3d import ModelCanvas from otko.views.canvas3d import ModelCanvas
from otko.views.canvas3d.diagram_renderer import DiagramRenderer from otko.views.canvas3d.diagram_renderer import DiagramRenderer
from otko.views.canvas3d.selection import SelectionState 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.canvas_base import plotly_available
from otko.views.dock_manager import DockManager from otko.views.dock_manager import DockManager
from otko.views.menu_builder import MenuBuilder from otko.views.menu_builder import MenuBuilder
@ -74,8 +72,6 @@ class MainWindow(
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO") self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
self._canvas_backend = self._stored_canvas_backend() 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._canvases: dict[str, Any] = {}
self._diagram_renderers: dict[str, DiagramRenderer] = {} self._diagram_renderers: dict[str, DiagramRenderer] = {}
# A stack holds every backend's canvas: swapping is just # A stack holds every backend's canvas: swapping is just
@ -119,9 +115,9 @@ class MainWindow(
# Imported lazily so a plotly-less install still boots PyVista. # Imported lazily so a plotly-less install still boots PyVista.
from otko.views.canvas_plotly import PlotlyCanvas from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection) canvas = PlotlyCanvas(self, selection=self._selection)
else: else:
canvas = ModelCanvas(self, style=self._style, selection=self._selection) canvas = ModelCanvas(self, selection=self._selection)
self._canvases[backend] = canvas self._canvases[backend] = canvas
return canvas return canvas
@ -149,35 +145,6 @@ class MainWindow(
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
return self._canvas_backend 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: def _build_tooling(self) -> None:
"""(Re)build the tool controller and tools against the current canvas.""" """(Re)build the tool controller and tools against the current canvas."""
self._tool_controller = ToolController(self._canvas, self._vm, self) self._tool_controller = ToolController(self._canvas, self._vm, self)
@ -294,7 +261,6 @@ class MainWindow(
self._act_about.triggered.connect(self._on_about) self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide) self._act_quick_guide.triggered.connect(self._on_quick_guide)
self._act_set_units.triggered.connect(self._on_set_units) self._act_set_units.triggered.connect(self._on_set_units)
self._act_plot_properties.triggered.connect(self._on_plot_properties)
# Edit # Edit
self._act_delete.triggered.connect(self._on_delete) self._act_delete.triggered.connect(self._on_delete)

View file

@ -192,10 +192,6 @@ class MenuBuilder:
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run. # No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self) self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", 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. # Canvas backend — exclusive radio pair live-switching the 3D view.
from otko.views.canvas_base import plotly_available from otko.views.canvas_base import plotly_available
@ -516,7 +512,6 @@ class MenuBuilder:
m_options = mb.addMenu("&Options") m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units) m_options.addAction(self._act_set_units)
m_options.addAction(self._act_plot_properties)
m_backend = m_options.addMenu("Canvas &Backend") m_backend = m_options.addMenu("Canvas &Backend")
m_backend.addAction(self._act_canvas_pyvista) m_backend.addAction(self._act_canvas_pyvista)
m_backend.addAction(self._act_canvas_plotly) m_backend.addAction(self._act_canvas_plotly)

View file

@ -50,23 +50,6 @@ class RenderControls:
) )
# ── SAP2000-style view switching + working level ─────────────── # ── 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: def _on_toggle_parallel(self, on: bool) -> None:
cam = self._canvas.camera cam = self._canvas.camera
cam.parallel_projection = on cam.parallel_projection = on

View file

@ -1,131 +0,0 @@
"""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()

View file

@ -1,117 +0,0 @@
"""Unit tests for the shared render style and its editable subset."""
from __future__ import annotations
import dataclasses
import pytest
from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement
from otko.views.canvas3d.style import (
EDITABLE_FIELDS,
SELECTED_STATE,
STYLE_FIELDS,
RenderStyle,
element_family_index,
family_palette,
)
def test_defaults_are_the_documented_palette() -> None:
style = RenderStyle()
assert style.node_color == "#d9d9d9"
assert style.selected_color == "#00ffff"
# opstool-derived element palette.
assert style.element_beam_color == "#0652ff"
assert style.element_truss_color == "#FF8C00"
assert style.element_link_color == "#39FF14"
assert len(style.response_scale_colors) == 10
assert style.response_scale_colors[0] == "#313695" # cold end
assert style.response_scale_colors[-1] == "#a50026" # warm end
def test_with_updates_is_immutable() -> None:
original = RenderStyle()
updated = original.with_updates(node_color="#123456", label_font_size=20)
assert updated is not original
assert updated.node_color == "#123456"
assert updated.label_font_size == 20
# The source instance is untouched (frozen dataclass contract).
assert original.node_color == "#d9d9d9"
assert original.label_font_size == 12
def test_editable_subset_matches_the_field_table() -> None:
style = RenderStyle()
assert RenderStyle.EDITABLE == EDITABLE_FIELDS
assert set(style.editable_values()) == set(EDITABLE_FIELDS)
# Every advertised field must exist on the dataclass with the right kind.
kinds = {name: kind for name, _label, kind in STYLE_FIELDS}
for name in EDITABLE_FIELDS:
assert hasattr(style, name), name
assert kinds[name] in {"color", "float", "int"}
def test_response_colorscale_is_an_evenly_spaced_mapping() -> None:
style = RenderStyle()
scale = style.response_colorscale()
assert len(scale) == len(style.response_scale_colors)
assert scale[0] == (0.0, style.response_scale_colors[0])
assert scale[-1] == (1.0, style.response_scale_colors[-1])
positions = [position for position, _color in scale]
assert positions == sorted(positions)
@pytest.mark.parametrize(
("element", "expected"),
[
(ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0),
(TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1),
(ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2),
],
)
def test_element_family_index(element: object, expected: int) -> None:
assert element_family_index(element) == expected
def test_family_palette_order_and_selected_slot() -> None:
style = RenderStyle(node_color="#010101")
palette = family_palette(style)
assert palette == [
style.element_beam_color,
style.element_truss_color,
style.element_link_color,
style.selected_color,
]
assert len(palette) - 1 == SELECTED_STATE
def test_editable_values_round_trip_through_replace() -> None:
style = RenderStyle()
values = style.editable_values()
assert RenderStyle(**values) == style
# And the persisted shape is JSON-friendly (str/int/float only).
import json
assert json.loads(json.dumps(values)) == values
def test_style_is_a_frozen_slots_dataclass() -> None:
assert dataclasses.is_dataclass(RenderStyle)
with pytest.raises(dataclasses.FrozenInstanceError):
RenderStyle().node_color = "#000000" # type: ignore[misc]
def test_project_builder_helper_is_unused_but_valid() -> None:
"""A style constructed from a project's elements classifies every family."""
project = Project(
ndm=2,
ndf=3,
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1),
],
)
indices = [element_family_index(el) for el in project.elements]
assert indices == [0, 1]

View file

@ -24,7 +24,6 @@ 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 ────────────────────────────
@ -126,13 +125,9 @@ 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]
# Frame cells carry the palette slot: the element family normally, and # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
# 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] == SELECTED_STATE assert frame_states[selected_frame_idx] == 1
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]