feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
"""Unit tests for the pure Plotly trace builder (no Qt, no plotly import)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from otko.services import load_project
|
2026-09-16 19:15:55 -04:00
|
|
|
from otko.views.canvas3d.style import RenderStyle
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
from otko.views.canvas_plotly.trace_builder import (
|
|
|
|
|
PlotlyTraceBuilder,
|
|
|
|
|
SceneOptions,
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
framed_camera_distance,
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load(name: str): # type: ignore[no-untyped-def]
|
|
|
|
|
return load_project(EXAMPLES / f"{name}.osmodel")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def]
|
|
|
|
|
return [trace for trace in scene.data if trace.get("name") == name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_builds_grid_nodes_and_frames() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
|
|
|
|
names = {trace.get("name") for trace in scene.data}
|
|
|
|
|
assert "grid-active" in names
|
|
|
|
|
assert "nodes" in names
|
|
|
|
|
assert "elements" in names
|
|
|
|
|
assert scene.diagonal > 0
|
2026-09-16 20:52:40 -04:00
|
|
|
# The snap marker is a DOM overlay, not a trace (a gl3d restyle costs
|
|
|
|
|
# ~90 ms, which made a trace marker lag behind the cursor).
|
|
|
|
|
assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
|
|
|
|
|
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
def test_data_bounds_are_unpadded_model_extents() -> None:
|
|
|
|
|
"""Camera framing uses raw model bounds, so they must exclude the padding."""
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions())
|
|
|
|
|
assert scene.data_bounds["x"] == pytest.approx((0.0, 5.0))
|
|
|
|
|
assert scene.data_bounds["y"] == pytest.approx((0.0, 0.0))
|
|
|
|
|
assert scene.data_bounds["z"] == pytest.approx((0.0, 0.0))
|
|
|
|
|
# Padded frame bounds stay wider than the raw model bounds.
|
|
|
|
|
assert scene.axis_bounds["x"][0] < scene.data_bounds["x"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_framed_camera_distance_is_scale_invariant() -> None:
|
|
|
|
|
"""plotly's camera is in normalized scene units, so framing must not grow
|
|
|
|
|
with the model's data-unit size (the old bug rendered models as specks)."""
|
|
|
|
|
project = _load("space_frame_3d")
|
|
|
|
|
small = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
scaled = project.model_copy(deep=True)
|
|
|
|
|
for node in scaled.nodes:
|
|
|
|
|
x, y, z = node.coords
|
|
|
|
|
node.coords = (x * 1000.0, y * 1000.0, z * 1000.0)
|
|
|
|
|
large = PlotlyTraceBuilder().build(scaled, SceneOptions())
|
|
|
|
|
|
|
|
|
|
assert framed_camera_distance(large) == pytest.approx(framed_camera_distance(small), rel=1e-9)
|
|
|
|
|
# A normalized eye distance stays small (plotly's default eye is 1.25).
|
|
|
|
|
assert 1.0 < framed_camera_distance(small) < 20.0
|
|
|
|
|
|
|
|
|
|
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
def test_nodes_carry_ids_as_customdata() -> None:
|
|
|
|
|
project = _load("basic_truss")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
(nodes,) = _traces(scene, "nodes")
|
|
|
|
|
assert nodes["meta"]["kind"] == "node"
|
|
|
|
|
assert nodes["customdata"] == [node.id for node in project.nodes]
|
|
|
|
|
assert len(nodes["marker"]["color"]) == len(project.nodes)
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 19:15:55 -04:00
|
|
|
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."""
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
project = _load("cantilever")
|
2026-09-16 19:15:55 -04:00
|
|
|
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
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_selected_node_is_highlighted_by_colour() -> None:
|
|
|
|
|
project = _load("basic_truss")
|
|
|
|
|
target = project.nodes[1].id
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_nodes=frozenset({target})))
|
|
|
|
|
(nodes,) = _traces(scene, "nodes")
|
|
|
|
|
ids = nodes["customdata"]
|
|
|
|
|
assert nodes["marker"]["color"][ids.index(target)] != nodes["marker"]["color"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_working_plane_filters_grid_segments() -> None:
|
|
|
|
|
project = _load("basic_truss")
|
|
|
|
|
builder = PlotlyTraceBuilder()
|
|
|
|
|
iso = builder.build(project, SceneOptions())
|
|
|
|
|
plane = builder.build(project, SceneOptions(working_plane=("XY", 0.0)))
|
|
|
|
|
(iso_grid,) = _traces(iso, "grid-active")
|
|
|
|
|
(plane_grid,) = _traces(plane, "grid-active")
|
|
|
|
|
assert len(plane_grid["x"]) <= len(iso_grid["x"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_snap_targets_carry_world_coordinates() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
|
|
|
|
(snap,) = _traces(scene, "snap")
|
|
|
|
|
assert snap["meta"]["kind"] == "snap"
|
|
|
|
|
assert snap["customdata"], "expected at least one snappable intersection"
|
|
|
|
|
assert all(len(point) == 3 for point in snap["customdata"])
|
|
|
|
|
assert all(isinstance(coord, float) for point in snap["customdata"] for coord in point)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_extrusions_add_a_mesh3d_trace() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(show_extrusions=True))
|
|
|
|
|
(mesh,) = _traces(scene, "extrusions")
|
|
|
|
|
assert mesh["type"] == "mesh3d"
|
|
|
|
|
assert mesh["i"] and mesh["j"] and mesh["k"]
|
|
|
|
|
assert max(mesh["i"]) < len(mesh["x"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_local_axes_add_coloured_cones() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(show_local_axes=True))
|
|
|
|
|
names = {trace.get("name") for trace in scene.data}
|
|
|
|
|
assert {"local-x", "local-y", "local-z"} <= names
|
|
|
|
|
for trace in scene.data:
|
|
|
|
|
if trace.get("name", "").startswith("local-"):
|
|
|
|
|
assert trace["type"] == "cone"
|
|
|
|
|
assert len(trace["u"]) == len(trace["x"]) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_labels_traces_are_text_only() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(
|
|
|
|
|
project, SceneOptions(show_node_labels=True, show_element_labels=True)
|
|
|
|
|
)
|
|
|
|
|
(node_labels,) = _traces(scene, "node-labels")
|
|
|
|
|
(element_labels,) = _traces(scene, "element-labels")
|
|
|
|
|
assert node_labels["mode"] == "text"
|
|
|
|
|
assert len(node_labels["text"]) == len(project.nodes)
|
|
|
|
|
assert element_labels["mode"] == "text"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deformation_shifts_node_coordinates() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
|
|
|
|
|
class _Shift:
|
|
|
|
|
def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray:
|
|
|
|
|
out = points.copy()
|
|
|
|
|
out[:, 2] += 1.0
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
base = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
moved = PlotlyTraceBuilder().build(project, SceneOptions(deformation=_Shift()))
|
|
|
|
|
(base_nodes,) = _traces(base, "nodes")
|
|
|
|
|
(moved_nodes,) = _traces(moved, "nodes")
|
|
|
|
|
assert moved_nodes["z"] == pytest.approx([z + 1.0 for z in base_nodes["z"]])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_payload_is_json_serialisable() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(
|
|
|
|
|
_load("cantilever"),
|
|
|
|
|
SceneOptions(
|
|
|
|
|
selection_nodes=frozenset({1}),
|
|
|
|
|
selection_elements=frozenset({1}),
|
|
|
|
|
working_plane=("XY", 0.0),
|
|
|
|
|
show_extrusions=True,
|
|
|
|
|
show_local_axes=True,
|
|
|
|
|
show_node_labels=True,
|
|
|
|
|
show_element_labels=True,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
payload = json.dumps(scene.to_payload())
|
|
|
|
|
assert '"data"' in payload and '"layout"' in payload
|
|
|
|
|
assert scene.layout["scene"]["aspectmode"] == "data"
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
# Turntable keeps camera.up pinned to +Z (a level horizon); plotly's
|
|
|
|
|
# ``orbit`` would rotate the up vector and tip the model over.
|
|
|
|
|
assert scene.layout["scene"]["dragmode"] == "turntable"
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
|
|
|
|
|
|
2026-09-16 20:52:40 -04:00
|
|
|
def test_empty_project_yields_no_traces() -> None:
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
scene = PlotlyTraceBuilder().build(None, SceneOptions())
|
|
|
|
|
assert scene.data == []
|
|
|
|
|
|
|
|
|
|
from otko.core import Project
|
|
|
|
|
|
|
|
|
|
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
|
2026-09-16 20:52:40 -04:00
|
|
|
assert empty.data == []
|
feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_frame_trace_meta_marks_elements_pickable() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
(frames,) = _traces(scene, "elements")
|
|
|
|
|
assert frames["meta"]["kind"] == "element"
|
|
|
|
|
# None separators break the line into per-element segments.
|
|
|
|
|
assert None in frames["x"]
|
|
|
|
|
assert len(frames["customdata"]) == len(frames["x"])
|
2026-09-16 20:05:26 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── scene recipe (opstool-derived framing) ───────────────────────────────
|
|
|
|
|
def test_axis_bounds_are_padded_around_the_model() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
points = np.array([node.coords for node in project.nodes], dtype=float)
|
|
|
|
|
for index, axis in enumerate(("x", "y", "z")):
|
|
|
|
|
low, high = scene.axis_bounds[axis]
|
|
|
|
|
assert low < points[:, index].min() or low <= points[:, index].min()
|
|
|
|
|
assert high > points[:, index].max() or high >= points[:, index].max()
|
|
|
|
|
assert scene.diagonal > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_planar_model_gets_unit_slack_on_the_flat_axis() -> None:
|
|
|
|
|
project = _load("basic_truss")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
|
|
|
|
low, high = scene.axis_bounds["z"]
|
|
|
|
|
assert high - low > 0 # a degenerate axis must not collapse the view
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_axis_overrides_pin_ranges_with_autorange_off() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions())
|
|
|
|
|
overrides = scene.axis_overrides()
|
|
|
|
|
assert set(overrides) == {"xaxis", "yaxis", "zaxis"}
|
|
|
|
|
for axis, override in overrides.items():
|
|
|
|
|
assert override["autorange"] is False
|
|
|
|
|
assert len(override["range"]) == 2
|
|
|
|
|
assert override["range"] == list(scene.axis_bounds[axis[0]])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_hover_templates_identify_entities() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
|
|
|
|
(nodes,) = _traces(scene, "nodes")
|
|
|
|
|
(frames,) = _traces(scene, "elements")
|
|
|
|
|
assert nodes["hovertemplate"] == "Node #%{customdata}<extra></extra>"
|
|
|
|
|
assert frames["hovertemplate"] == "Element #%{customdata}<extra></extra>"
|
|
|
|
|
# Hover must not fall back to the raw-id "skip" mode.
|
|
|
|
|
assert "hoverinfo" not in nodes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_axis_outline_flag_toggles_grid_and_ticks() -> None:
|
|
|
|
|
plain = RenderStyle()
|
|
|
|
|
outlined = RenderStyle(show_axis_outline=True)
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene_plain = PlotlyTraceBuilder(plain).build(project, SceneOptions())
|
|
|
|
|
scene_outlined = PlotlyTraceBuilder(outlined).build(project, SceneOptions())
|
|
|
|
|
|
|
|
|
|
off_axis = scene_plain.layout["scene"]["xaxis"]
|
|
|
|
|
on_axis = scene_outlined.layout["scene"]["xaxis"]
|
|
|
|
|
assert off_axis["showgrid"] is False and off_axis["showticklabels"] is False
|
|
|
|
|
assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
|
|
|
|
|
# The coloured axis lines stay visible either way (orientation cue).
|
|
|
|
|
assert off_axis["visible"] is True and on_axis["visible"] is True
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeDeformation:
|
|
|
|
|
"""Minimal ``DeformationSource`` stand-in (keeps this test pyvista-free)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, rise: float = 0.0) -> None:
|
|
|
|
|
self._rise = rise
|
|
|
|
|
|
|
|
|
|
def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray:
|
|
|
|
|
out = points.copy()
|
|
|
|
|
out[:, 2] += self._rise
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
def magnitudes(self, node_ids: list[int]) -> np.ndarray:
|
|
|
|
|
return np.linspace(0.0, 1.0, len(node_ids)) if node_ids else np.zeros(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_loads_scale_with_magnitude_and_colour_by_pattern() -> None:
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load("space_frame_3d"), SceneOptions())
|
|
|
|
|
cones = [trace for trace in scene.data if trace.get("name") == "nodal-loads"]
|
|
|
|
|
assert len(cones) == 2, "one cone trace per load pattern"
|
|
|
|
|
lengths = sorted(
|
|
|
|
|
float(np.hypot(np.hypot(u, v), w))
|
|
|
|
|
for trace in cones
|
|
|
|
|
for u, v, w in zip(trace["u"], trace["v"], trace["w"], strict=True)
|
|
|
|
|
)
|
|
|
|
|
# 2.5e4 vs 5e4 kN → the second arrow is twice as long.
|
|
|
|
|
assert lengths[0] > 0.0
|
|
|
|
|
assert lengths[-1] == pytest.approx(2.0 * lengths[0], rel=1e-6)
|
|
|
|
|
assert cones[0]["colorscale"][0][1] != cones[1]["colorscale"][0][1]
|
|
|
|
|
assert "Pattern" in cones[0]["customdata"][0] or "|F|" in cones[0]["customdata"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_supports_use_dof_glyphs_not_markers() -> None:
|
|
|
|
|
for name in ("space_frame_3d", "basic_truss"):
|
|
|
|
|
scene = PlotlyTraceBuilder().build(_load(name), SceneOptions())
|
|
|
|
|
(supports,) = _traces(scene, "supports")
|
|
|
|
|
assert supports["type"] == "scatter3d"
|
|
|
|
|
assert supports["mode"] == "lines"
|
|
|
|
|
assert len(supports["x"]) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scalar_colouring_adds_arrays_and_colorbar() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scalars = np.linspace(0.0, 3.0, len(project.nodes))
|
|
|
|
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(scalars=scalars, scalar_label="|u|"))
|
|
|
|
|
|
|
|
|
|
(nodes,) = _traces(scene, "nodes")
|
|
|
|
|
assert isinstance(nodes["marker"]["color"], list)
|
|
|
|
|
assert nodes["marker"]["showscale"] is True
|
|
|
|
|
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
|
|
|
|
assert nodes["marker"]["cmin"] == pytest.approx(0.0)
|
|
|
|
|
assert nodes["marker"]["cmax"] == pytest.approx(3.0)
|
|
|
|
|
|
|
|
|
|
(frames,) = _traces(scene, "elements")
|
|
|
|
|
assert isinstance(frames["line"]["color"], list)
|
|
|
|
|
assert frames["line"]["cmin"] == pytest.approx(0.0)
|
|
|
|
|
# Scalar mode overrides the family palette.
|
|
|
|
|
assert "showscale" not in frames["line"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deformation_auto_colours_by_magnitude() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
scene = PlotlyTraceBuilder().build(
|
|
|
|
|
project, SceneOptions(deformation=_FakeDeformation(rise=0.5))
|
|
|
|
|
)
|
|
|
|
|
(nodes,) = _traces(scene, "nodes")
|
|
|
|
|
assert isinstance(nodes["marker"]["color"], list)
|
|
|
|
|
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_undeformed_reference_overlay_is_opt_in() -> None:
|
|
|
|
|
project = _load("cantilever")
|
|
|
|
|
deformation = _FakeDeformation(rise=0.5)
|
|
|
|
|
without = PlotlyTraceBuilder().build(project, SceneOptions(deformation=deformation))
|
|
|
|
|
assert not _traces(without, "undeformed-reference")
|
|
|
|
|
|
|
|
|
|
with_ghost = PlotlyTraceBuilder().build(
|
|
|
|
|
project, SceneOptions(deformation=deformation, show_undeformed=True)
|
|
|
|
|
)
|
|
|
|
|
(ghost,) = _traces(with_ghost, "undeformed-reference")
|
|
|
|
|
assert ghost["mode"] == "lines"
|
|
|
|
|
assert ghost["opacity"] < 1.0
|