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.
258 lines
10 KiB
Python
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
|