feat: opstool-derived element palette and single-trace frame colours
Ports opstool's per-family element colours and its diverging response scale; RenderStyle.response_scale_colors now also drives the PyVista force-diagram colouring instead of a hard-coded "coolwarm". The frame renderer drops the two-trace normal/selected workaround: my earlier assumption that plotly cannot colour segments individually was wrong. Scatter3d.line.color accepts an array mapped through a colorscale, so one trace now carries per-element colours (family + selection) and is ready to be coloured by response value later. Attribution recorded in NOTICE per GPLv3 section 5(a)/(b).
This commit is contained in:
parent
40a673c562
commit
17d2ed21d3
5 changed files with 164 additions and 45 deletions
15
NOTICE
15
NOTICE
|
|
@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
|
## Runtime dependencies
|
||||||
|
|
||||||
OTKO depends on third-party software that is not covered by OTKO's
|
OTKO depends on third-party software that is not covered by OTKO's
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import pyvista as pv
|
||||||
|
|
||||||
from otko.core import Project
|
from otko.core import Project
|
||||||
from otko.services.element_forces import DiagramData, ForceComponent
|
from otko.services.element_forces import DiagramData, ForceComponent
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
|
||||||
_LOG = logging.getLogger("otko.diagram")
|
_LOG = logging.getLogger("otko.diagram")
|
||||||
|
|
||||||
|
|
@ -62,8 +63,9 @@ class DiagramRenderer:
|
||||||
- ``clear()`` removes the overlay.
|
- ``clear()`` removes the overlay.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plotter: Any) -> None:
|
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
||||||
self._plotter = plotter
|
self._plotter = plotter
|
||||||
|
self._style = style or RenderStyle()
|
||||||
self._actor: Any = None
|
self._actor: Any = None
|
||||||
self._label_actor: Any = None
|
self._label_actor: Any = None
|
||||||
|
|
||||||
|
|
@ -158,7 +160,9 @@ class DiagramRenderer:
|
||||||
self._actor = self._plotter.add_mesh(
|
self._actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
scalars="value",
|
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),
|
clim=(-vmax, vmax),
|
||||||
show_scalar_bar=True,
|
show_scalar_bar=True,
|
||||||
scalar_bar_args={"title": comp_label, "n_labels": 5},
|
scalar_bar_args={"title": comp_label, "n_labels": 5},
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,31 @@ class RenderStyle:
|
||||||
zerolength_color: str = "#a020f0"
|
zerolength_color: str = "#a020f0"
|
||||||
selected_color: str = "#00d4ff"
|
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
|
fix_color: str = "#c0392b" # firebrick
|
||||||
pin_color: str = "#c0392b"
|
pin_color: str = "#c0392b"
|
||||||
roller_color: str = "#e67e22"
|
roller_color: str = "#e67e22"
|
||||||
|
|
@ -49,3 +74,13 @@ class RenderStyle:
|
||||||
load_min_length: float = 0.5
|
load_min_length: float = 0.5
|
||||||
|
|
||||||
selection_thickness_factor: float = 1.6 # multiplier for selected actors
|
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)
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -56,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),
|
||||||
|
|
@ -504,12 +517,30 @@ class PlotlyTraceBuilder:
|
||||||
points: np.ndarray,
|
points: np.ndarray,
|
||||||
node_row: dict[int, int],
|
node_row: dict[int, int],
|
||||||
) -> None:
|
) -> 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):
|
if not len(points):
|
||||||
return
|
return
|
||||||
normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
scale_colors = [
|
||||||
selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
self._style.element_beam_color,
|
||||||
normal_ids: list[Any] = []
|
self._style.element_truss_color,
|
||||||
selected_ids: list[Any] = []
|
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:
|
for el in project.elements:
|
||||||
if not isinstance(el, _FRAME_CLASSES):
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
continue
|
continue
|
||||||
|
|
@ -517,36 +548,38 @@ 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
|
||||||
seg = (tuple(points[i]), tuple(points[j]))
|
index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
|
||||||
if el.id in opts.selection_elements:
|
x.extend([float(points[i][0]), float(points[j][0]), None])
|
||||||
selected.append(seg)
|
y.extend([float(points[i][1]), float(points[j][1]), None])
|
||||||
selected_ids.extend([el.id, el.id, None])
|
z.extend([float(points[i][2]), float(points[j][2]), None])
|
||||||
else:
|
color_index.extend([index, index, index])
|
||||||
normal.append(seg)
|
customdata.extend([el.id, el.id, None])
|
||||||
normal_ids.extend([el.id, el.id, None])
|
if not x:
|
||||||
if not normal and not selected:
|
|
||||||
return
|
return
|
||||||
if normal:
|
|
||||||
|
count = len(scale_colors)
|
||||||
data.append(
|
data.append(
|
||||||
_line_trace(
|
{
|
||||||
normal,
|
"type": "scatter3d",
|
||||||
color="#338cd9",
|
"mode": "lines",
|
||||||
width=4,
|
"x": x,
|
||||||
name="elements",
|
"y": y,
|
||||||
meta={"kind": "element"},
|
"z": z,
|
||||||
customdata=normal_ids,
|
"line": {
|
||||||
)
|
"color": color_index,
|
||||||
)
|
"colorscale": [
|
||||||
if selected:
|
(index / (count - 1), color) for index, color in enumerate(scale_colors)
|
||||||
data.append(
|
],
|
||||||
_line_trace(
|
"cmin": 0,
|
||||||
selected,
|
"cmax": count - 1,
|
||||||
color="#00ffff",
|
"width": 4,
|
||||||
width=6,
|
},
|
||||||
name="elements-selected",
|
"customdata": customdata,
|
||||||
meta={"kind": "element"},
|
"meta": {"kind": "element"},
|
||||||
customdata=selected_ids,
|
"hoverinfo": "skip",
|
||||||
)
|
"name": "elements",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_supports(
|
def _build_supports(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from otko.services import load_project
|
from otko.services import load_project
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
from otko.views.canvas_plotly.trace_builder import (
|
from otko.views.canvas_plotly.trace_builder import (
|
||||||
PlotlyTraceBuilder,
|
PlotlyTraceBuilder,
|
||||||
SceneOptions,
|
SceneOptions,
|
||||||
|
|
@ -49,16 +50,47 @@ def test_nodes_carry_ids_as_customdata() -> None:
|
||||||
assert len(nodes["marker"]["color"]) == len(project.nodes)
|
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")
|
project = _load("cantilever")
|
||||||
scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_elements=frozenset({2})))
|
style = RenderStyle()
|
||||||
normal = _traces(scene, "elements")
|
scene = PlotlyTraceBuilder(style).build(
|
||||||
selected = _traces(scene, "elements-selected")
|
project, SceneOptions(selection_elements=frozenset({2}))
|
||||||
assert len(normal) == 1 and len(selected) == 1
|
)
|
||||||
# Plotly cannot colour one segment of a single line trace, so the
|
frames = _traces(scene, "elements")
|
||||||
# selected element moves to the highlight trace.
|
assert len(frames) == 1, "selection must not split the frame trace"
|
||||||
assert 2 in selected[0]["customdata"]
|
line = frames[0]["line"]
|
||||||
assert 2 not in normal[0]["customdata"]
|
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:
|
def test_selected_node_is_highlighted_by_colour() -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue