From 56f1f6c08b28abc52a055d1e7518fae71c68c8bd Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:31:30 -0400 Subject: [PATCH] feat: route both canvas backends through RenderStyle Colours that were hard-coded in the renderers now come from the shared style: nodes, supports, nodal/element loads, section extrusions (+ opacity) and label font size. The PyVista frame LUT becomes a four-slot palette [beam, truss, link, selected] with the cell scalar carrying the family slot, so VTK matches the Plotly backend's per-family colouring that landed earlier. Adds style helpers shared by both backends (element_family_index, family_palette) and an immutable with_updates(). The style also gains the editable field table (STYLE_FIELDS) the Plot Properties dialog is built from. --- src/otko/views/canvas3d/model_renderer.py | 56 ++++++--- src/otko/views/canvas3d/style.py | 83 ++++++++++++- src/otko/views/canvas_plotly/trace_builder.py | 77 +++++++----- tests/unit/test_render_style.py | 117 ++++++++++++++++++ tests/unit/test_renderer.py | 9 +- 5 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 tests/unit/test_render_style.py diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 9eb9274..12c88cb 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -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", diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 3e1de73..6051fd9 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -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} diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 8c1538b..40ae710 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -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, diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py new file mode 100644 index 0000000..32b027b --- /dev/null +++ b/tests/unit/test_render_style.py @@ -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] diff --git a/tests/unit/test_renderer.py b/tests/unit/test_renderer.py index 1586ca2..04529d6 100644 --- a/tests/unit/test_renderer.py +++ b/tests/unit/test_renderer.py @@ -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]