diff --git a/NOTICE b/NOTICE index 10a928c..24b2dd1 100644 --- a/NOTICE +++ b/NOTICE @@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Ported / adapted code: `opstool` + +The canvas element colour palette in `src/otko/views/canvas3d/style.py` +(per-family element colours and the diverging response colour scale) and the +resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were +adapted from the `opstool` project, which is distributed under the **GNU +General Public License v3.0**. opstool is Copyright © Yexiang Yan and +contributors. + +In accordance with GPLv3 §5(a)/(b) this notice records that the material was +modified and adapted for OTKO. Combining the GPLv3-covered material with +OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is +conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the +opstool-derived portions. + ## Runtime dependencies OTKO depends on third-party software that is not covered by OTKO's diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 05c5f2c..57c0a70 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -31,6 +31,7 @@ import pyvista as pv from otko.core import Project from otko.services.element_forces import DiagramData, ForceComponent +from otko.views.canvas3d.style import RenderStyle _LOG = logging.getLogger("otko.diagram") @@ -62,8 +63,9 @@ class DiagramRenderer: - ``clear()`` removes the overlay. """ - def __init__(self, plotter: Any) -> None: + def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: self._plotter = plotter + self._style = style or RenderStyle() self._actor: Any = None self._label_actor: Any = None @@ -158,7 +160,9 @@ class DiagramRenderer: self._actor = self._plotter.add_mesh( mesh, scalars="value", - cmap="coolwarm", + # Shared response palette (opstool's default diverging scale), so + # the PyVista and Plotly backends stay visually consistent. + cmap=list(self._style.response_scale_colors), clim=(-vmax, vmax), show_scalar_bar=True, scalar_bar_args={"title": comp_label, "n_labels": 5}, diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index f25d674..3e1de73 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -27,6 +27,31 @@ class RenderStyle: zerolength_color: str = "#a020f0" selected_color: str = "#00d4ff" + # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ─────── + # opstool colours wireframe elements by *family* rather than painting + # every frame the same colour, which makes mixed beam/truss/link models + # readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in + # ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``. + element_beam_color: str = "#0652ff" # beam-column family + element_truss_color: str = "#FF8C00" # truss family + element_link_color: str = "#39FF14" # zero-length / link family + + #: 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). + response_scale_colors: tuple[str, ...] = ( + "#313695", + "#4575b4", + "#74add1", + "#abd9e9", + "#e0f3f8", + "#fee090", + "#fdae61", + "#f46d43", + "#d73027", + "#a50026", + ) + fix_color: str = "#c0392b" # firebrick pin_color: str = "#c0392b" roller_color: str = "#e67e22" @@ -49,3 +74,13 @@ class RenderStyle: load_min_length: float = 0.5 selection_thickness_factor: float = 1.6 # multiplier for selected actors + + # ── helpers ────────────────────────────────────────────────────── + def response_colorscale(self) -> list[tuple[float, str]]: + """Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" + count = len(self.response_scale_colors) + if count < 2: + return [(0.0, self.response_scale_colors[0])] + return [ + (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) + ] diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 7c6d782..8c1538b 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -56,6 +56,19 @@ _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), @@ -504,12 +517,30 @@ class PlotlyTraceBuilder: points: np.ndarray, node_row: dict[int, int], ) -> None: + """One line trace whose per-point ``line.color`` carries the palette. + + plotly accepts an *array* for ``Scatter3d.line.color`` mapped through + a ``colorscale``, so a single trace can colour every element + individually — by family and by selection — instead of one trace per + colour. That also leaves room to colour by response value later. + Each element's two endpoints (and its ``None`` separator) share one + colour index, so each drawn segment is a solid colour. + """ if not len(points): return - normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - normal_ids: list[Any] = [] - selected_ids: list[Any] = [] + 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 + + x: list[float | None] = [] + y: list[float | None] = [] + z: list[float | None] = [] + color_index: list[float] = [] + customdata: list[Any] = [] for el in project.elements: if not isinstance(el, _FRAME_CLASSES): continue @@ -517,37 +548,39 @@ class PlotlyTraceBuilder: j = node_row.get(el.nodes[1]) if i is None or j is None: continue - seg = (tuple(points[i]), tuple(points[j])) - if el.id in opts.selection_elements: - selected.append(seg) - selected_ids.extend([el.id, el.id, None]) - else: - normal.append(seg) - normal_ids.extend([el.id, el.id, None]) - if not normal and not selected: + index = float(selected_index if el.id in opts.selection_elements else _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]) + color_index.extend([index, index, index]) + customdata.extend([el.id, el.id, None]) + if not x: return - if normal: - data.append( - _line_trace( - normal, - color="#338cd9", - width=4, - name="elements", - meta={"kind": "element"}, - customdata=normal_ids, - ) - ) - if selected: - data.append( - _line_trace( - selected, - color="#00ffff", - width=6, - name="elements-selected", - meta={"kind": "element"}, - customdata=selected_ids, - ) - ) + + count = len(scale_colors) + data.append( + { + "type": "scatter3d", + "mode": "lines", + "x": x, + "y": y, + "z": z, + "line": { + "color": color_index, + "colorscale": [ + (index / (count - 1), color) for index, color in enumerate(scale_colors) + ], + "cmin": 0, + "cmax": count - 1, + "width": 4, + }, + "customdata": customdata, + "meta": {"kind": "element"}, + "hoverinfo": "skip", + "name": "elements", + "showlegend": False, + } + ) def _build_supports( self, project: Project, data: list[dict[str, Any]], opts: SceneOptions diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py index 92104be..ae960a9 100644 --- a/tests/unit/test_plotly_trace_builder.py +++ b/tests/unit/test_plotly_trace_builder.py @@ -9,6 +9,7 @@ import numpy as np import pytest from otko.services import load_project +from otko.views.canvas3d.style import RenderStyle from otko.views.canvas_plotly.trace_builder import ( PlotlyTraceBuilder, SceneOptions, @@ -49,16 +50,47 @@ def test_nodes_carry_ids_as_customdata() -> None: assert len(nodes["marker"]["color"]) == len(project.nodes) -def test_selected_element_gets_its_own_trace() -> None: +def _color_for_index(trace: dict, index: float) -> str: + """Map a ``line.color`` scalar index to its colour via the colorscale.""" + scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0]) + return scale[int(index)][1] + + +def test_frames_are_one_trace_with_per_element_colours() -> None: + """A single line trace carries the palette through its ``line.color`` array.""" project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_elements=frozenset({2}))) - normal = _traces(scene, "elements") - selected = _traces(scene, "elements-selected") - assert len(normal) == 1 and len(selected) == 1 - # Plotly cannot colour one segment of a single line trace, so the - # selected element moves to the highlight trace. - assert 2 in selected[0]["customdata"] - assert 2 not in normal[0]["customdata"] + style = RenderStyle() + scene = PlotlyTraceBuilder(style).build( + project, SceneOptions(selection_elements=frozenset({2})) + ) + frames = _traces(scene, "elements") + assert len(frames) == 1, "selection must not split the frame trace" + line = frames[0]["line"] + assert isinstance(line["color"], list) + assert len(line["color"]) == len(frames[0]["x"]) + assert line["colorscale"] + + colors = line["color"] + customdata = frames[0]["customdata"] + + def index_of(element_id: int) -> set[float]: + return {colors[k] for k, value in enumerate(customdata) if value == element_id} + + selected = index_of(2) + unselected = index_of(1) + assert len(selected) == 1 and len(unselected) == 1 + assert selected != unselected + assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color + assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color + + +def test_element_palette_distinguishes_beam_from_truss() -> None: + style = RenderStyle() + builder = PlotlyTraceBuilder(style) + beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0] + truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0] + assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color + assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color def test_selected_node_is_highlighted_by_colour() -> None: