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.
This commit is contained in:
parent
f99b3a3efd
commit
fc5971ad3d
3 changed files with 1132 additions and 0 deletions
176
tests/unit/test_plotly_trace_builder.py
Normal file
176
tests/unit/test_plotly_trace_builder.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""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
|
||||
from otko.views.canvas_plotly.trace_builder import (
|
||||
PlotlyTraceBuilder,
|
||||
SceneOptions,
|
||||
)
|
||||
|
||||
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 _kinds(scene) -> list[str]: # type: ignore[no-untyped-def]
|
||||
return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data]
|
||||
|
||||
|
||||
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
|
||||
# The hover-snap marker is always present so JS can restyle it.
|
||||
assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_selected_element_gets_its_own_trace() -> None:
|
||||
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"]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_empty_project_yields_only_the_hover_marker() -> None:
|
||||
scene = PlotlyTraceBuilder().build(None, SceneOptions())
|
||||
assert scene.data == []
|
||||
assert scene.hover_trace == -1
|
||||
|
||||
from otko.core import Project
|
||||
|
||||
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
|
||||
assert _kinds(empty) == ["hover"]
|
||||
|
||||
|
||||
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"])
|
||||
Loading…
Reference in a new issue