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.
This commit is contained in:
parent
1b0620a392
commit
d3878f23b3
26 changed files with 1687 additions and 153 deletions
|
|
@ -13,6 +13,7 @@ from otko.views.canvas3d.style import RenderStyle
|
|||
from otko.views.canvas_plotly.trace_builder import (
|
||||
PlotlyTraceBuilder,
|
||||
SceneOptions,
|
||||
framed_camera_distance,
|
||||
)
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
||||
|
|
@ -38,6 +39,32 @@ def test_builds_grid_nodes_and_frames() -> None:
|
|||
assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_nodes_carry_ids_as_customdata() -> None:
|
||||
project = _load("basic_truss")
|
||||
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
||||
|
|
@ -182,6 +209,9 @@ def test_payload_is_json_serialisable() -> None:
|
|||
payload = json.dumps(scene.to_payload())
|
||||
assert '"data"' in payload and '"layout"' in payload
|
||||
assert scene.layout["scene"]["aspectmode"] == "data"
|
||||
# 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"
|
||||
|
||||
|
||||
def test_empty_project_yields_no_traces() -> None:
|
||||
|
|
@ -256,3 +286,86 @@ def test_axis_outline_flag_toggles_grid_and_ticks() -> None:
|
|||
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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue