otko/tests/unit/test_plotly_trace_builder.py
smillmorel 1b0620a392 perf: replace the Plotly snap marker with a projected DOM overlay
Orbiting and the snap preview felt far behind the cursor. Measured in the
page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a
one-trace figure, so every marker update stalled the scene and the marker
trailed the mouse; each one also queued another redraw while the user was
dragging.

The marker is now a pointer-events-none div positioned by projecting the
snapped world point through glplot.cameraParams (validated: the camera
centre lands at the canvas centre), updated with one style write and
re-projected on plotly_relayout so it stays glued to the target during
orbit. No plotly calls at all on the hover path - 200 mouse-move events
now cost 0 restyles - and the empty hover trace is gone from the figure.

Also logs the WebGL renderer once (via the console bridge) since hardware
acceleration decides how smooth orbit feels and is otherwise invisible.
2026-09-16 20:52:40 -04:00

258 lines
10 KiB
Python

"""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.canvas3d.style import RenderStyle
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 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 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)
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 _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")
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:
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_no_traces() -> None:
scene = PlotlyTraceBuilder().build(None, SceneOptions())
assert scene.data == []
from otko.core import Project
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
assert empty.data == []
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"])
# ── 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