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
26
src/otko/views/canvas_plotly/__init__.py
Normal file
26
src/otko/views/canvas_plotly/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Plotly-backed canvas package (optional GUI backend).
|
||||||
|
|
||||||
|
``PlotlyCanvas`` is exported lazily (PEP 562): the pure
|
||||||
|
:mod:`~otko.views.canvas_plotly.trace_builder` must stay importable in the
|
||||||
|
headless test job, which has no Qt at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from otko.views.canvas_plotly.trace_builder import (
|
||||||
|
PlotlyTraceBuilder,
|
||||||
|
Scene,
|
||||||
|
SceneOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["PlotlyCanvas", "PlotlyTraceBuilder", "Scene", "SceneOptions"]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
if name == "PlotlyCanvas":
|
||||||
|
from otko.views.canvas_plotly.plotly_canvas import PlotlyCanvas
|
||||||
|
|
||||||
|
return PlotlyCanvas
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
930
src/otko/views/canvas_plotly/trace_builder.py
Normal file
930
src/otko/views/canvas_plotly/trace_builder.py
Normal file
|
|
@ -0,0 +1,930 @@
|
||||||
|
"""Project → Plotly figure dictionaries.
|
||||||
|
|
||||||
|
Pure data transformation: no Qt, no pyvista, no plotly import. The
|
||||||
|
:class:`PlotlyCanvas` serialises the returned :class:`Scene` to JSON and
|
||||||
|
hands it to plotly.js; the unit tests exercise it headless.
|
||||||
|
|
||||||
|
Geometry conventions mirror :mod:`otko.views.canvas3d.model_renderer` so the
|
||||||
|
two backends draw the same model, but the output medium differs:
|
||||||
|
|
||||||
|
- Nodes / supports / labels are ``scatter3d`` markers, whose size is in
|
||||||
|
*pixels* (plotly.js does not scale markers with world units).
|
||||||
|
- Frames are ``scatter3d`` line traces. Plotly cannot colour individual
|
||||||
|
segments of one line trace, so selection is expressed as two traces
|
||||||
|
(normal + selected) whose segment lists are regrouped on every update.
|
||||||
|
- Loads and local axes are ``cone`` traces (plotly.js has no 3D arrow glyph).
|
||||||
|
- Section extrusions are ``mesh3d`` box sweeps from
|
||||||
|
:func:`otko.services.section_bbox.bbox_for_section` — the same bbox
|
||||||
|
fallback the PyVista backend uses when a shape hint is not an exact fit.
|
||||||
|
|
||||||
|
Picking rides on ``customdata`` + trace ``meta``: every pickable trace is
|
||||||
|
tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads
|
||||||
|
``point.data.meta.kind`` to decide what the click meant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from otko.core import (
|
||||||
|
BeamWithHingesElement,
|
||||||
|
CorotTrussElement,
|
||||||
|
DispBeamColumn,
|
||||||
|
ElasticBeamColumn,
|
||||||
|
ForceBeamColumn,
|
||||||
|
NodalLoad,
|
||||||
|
PlainLoadPattern,
|
||||||
|
Project,
|
||||||
|
QuadElement,
|
||||||
|
TrussElement,
|
||||||
|
UniformElementLoad,
|
||||||
|
ZeroLengthElement,
|
||||||
|
ZeroLengthSectionElement,
|
||||||
|
)
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
|
||||||
|
_FRAME_CLASSES = (
|
||||||
|
ElasticBeamColumn,
|
||||||
|
DispBeamColumn,
|
||||||
|
ForceBeamColumn,
|
||||||
|
BeamWithHingesElement,
|
||||||
|
TrussElement,
|
||||||
|
CorotTrussElement,
|
||||||
|
ZeroLengthElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`.
|
||||||
|
_BOX_TRIS = (
|
||||||
|
(0, 1, 2),
|
||||||
|
(0, 2, 3),
|
||||||
|
(4, 5, 6),
|
||||||
|
(4, 6, 7),
|
||||||
|
(0, 1, 5),
|
||||||
|
(0, 5, 4),
|
||||||
|
(3, 2, 6),
|
||||||
|
(3, 6, 7),
|
||||||
|
(0, 3, 7),
|
||||||
|
(0, 7, 4),
|
||||||
|
(1, 2, 6),
|
||||||
|
(1, 6, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Support kind → plotly 3D marker symbol.
|
||||||
|
_SUPPORT_SYMBOLS = {
|
||||||
|
"fix": "square",
|
||||||
|
"pin": "triangle-up",
|
||||||
|
"roller": "circle",
|
||||||
|
"custom": "diamond",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NODE_MARKER_SIZE = 7.0
|
||||||
|
_SUPPORT_MARKER_SIZE = 11.0
|
||||||
|
_SNAP_MARKER_SIZE = 8.0
|
||||||
|
_LABEL_FONT_SIZE = 11
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SceneOptions:
|
||||||
|
"""Everything the builder needs beyond the project itself."""
|
||||||
|
|
||||||
|
selection_nodes: frozenset[int] = frozenset()
|
||||||
|
selection_elements: frozenset[int] = frozenset()
|
||||||
|
#: Anything exposing ``shifted(points, node_ids)`` (e.g. ``DeformationSource``).
|
||||||
|
deformation: Any = None
|
||||||
|
working_plane: tuple[str, float] | None = None
|
||||||
|
show_node_labels: bool = False
|
||||||
|
show_element_labels: bool = False
|
||||||
|
show_extrusions: bool = False
|
||||||
|
show_local_axes: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scene:
|
||||||
|
"""A transport-ready plotly figure plus framing metadata."""
|
||||||
|
|
||||||
|
data: list[dict[str, Any]]
|
||||||
|
layout: dict[str, Any]
|
||||||
|
center: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
diagonal: float = 1.0
|
||||||
|
#: Trace index (in ``data``) of the empty hover-snap marker, or -1.
|
||||||
|
hover_trace: int = -1
|
||||||
|
|
||||||
|
def to_payload(self) -> dict[str, Any]:
|
||||||
|
"""Figure dict without the camera — camera is owned by the widget."""
|
||||||
|
return {"data": self.data, "layout": self.layout}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Mesh:
|
||||||
|
"""Accumulates a triangle soup for a single ``mesh3d`` trace."""
|
||||||
|
|
||||||
|
x: list[float] = field(default_factory=list)
|
||||||
|
y: list[float] = field(default_factory=list)
|
||||||
|
z: list[float] = field(default_factory=list)
|
||||||
|
i: list[int] = field(default_factory=list)
|
||||||
|
j: list[int] = field(default_factory=list)
|
||||||
|
k: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add_box(self, corners: np.ndarray) -> None:
|
||||||
|
base = len(self.x)
|
||||||
|
for cx, cy, cz in corners:
|
||||||
|
self.x.append(float(cx))
|
||||||
|
self.y.append(float(cy))
|
||||||
|
self.z.append(float(cz))
|
||||||
|
for a, b, c in _BOX_TRIS:
|
||||||
|
self.i.append(base + a)
|
||||||
|
self.j.append(base + b)
|
||||||
|
self.k.append(base + c)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return not self.x
|
||||||
|
|
||||||
|
def as_trace(self, *, color: str, opacity: float, name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "mesh3d",
|
||||||
|
"x": self.x,
|
||||||
|
"y": self.y,
|
||||||
|
"z": self.z,
|
||||||
|
"i": self.i,
|
||||||
|
"j": self.j,
|
||||||
|
"k": self.k,
|
||||||
|
"color": color,
|
||||||
|
"opacity": opacity,
|
||||||
|
"flatshading": True,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showscale": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── small geometry helpers ────────────────────────────────────────────────
|
||||||
|
def _diag_of_points(pts: np.ndarray | None) -> float:
|
||||||
|
if pts is None or len(pts) == 0:
|
||||||
|
return 1.0
|
||||||
|
mn, mx = pts.min(axis=0), pts.max(axis=0)
|
||||||
|
d = float(np.linalg.norm(mx - mn))
|
||||||
|
return d if d > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
||||||
|
x = x_local / float(np.linalg.norm(x_local))
|
||||||
|
vecxz = getattr(el, "vecxz", None)
|
||||||
|
if vecxz is not None:
|
||||||
|
try:
|
||||||
|
v = np.asarray(vecxz, dtype=float)
|
||||||
|
z_local = v - float(np.dot(v, x)) * x
|
||||||
|
n = float(np.linalg.norm(z_local))
|
||||||
|
if n > 1e-9:
|
||||||
|
z_local /= n
|
||||||
|
y_local = np.cross(z_local, x)
|
||||||
|
m = float(np.linalg.norm(y_local))
|
||||||
|
if m > 1e-9:
|
||||||
|
return y_local / m, z_local
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
z_global = np.array([0.0, 0.0, 1.0])
|
||||||
|
y_local = np.cross(z_global, x)
|
||||||
|
if float(np.linalg.norm(y_local)) < 1e-6:
|
||||||
|
y_local = np.cross(np.array([1.0, 0.0, 0.0]), x)
|
||||||
|
y_local /= float(np.linalg.norm(y_local))
|
||||||
|
z_local = np.cross(x, y_local)
|
||||||
|
return y_local, z_local
|
||||||
|
|
||||||
|
|
||||||
|
def _dof_indices(ndf: int) -> tuple[int, ...]:
|
||||||
|
if ndf == 6:
|
||||||
|
return (0, 1, 2, 3, 4, 5)
|
||||||
|
if ndf == 3:
|
||||||
|
return (0, 1, 5)
|
||||||
|
if ndf == 2:
|
||||||
|
return (0, 1)
|
||||||
|
return tuple(range(ndf))
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> str:
|
||||||
|
flags = [restraint[i] for i in dof_idx]
|
||||||
|
if all(flags):
|
||||||
|
return "fix"
|
||||||
|
trans_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx < 3]
|
||||||
|
rot_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx >= 3]
|
||||||
|
if trans_flags and all(trans_flags) and not any(rot_flags):
|
||||||
|
return "pin"
|
||||||
|
if sum(flags) == 1:
|
||||||
|
return "roller"
|
||||||
|
return "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def _line_trace(
|
||||||
|
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
width: float,
|
||||||
|
name: str,
|
||||||
|
opacity: float = 1.0,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
customdata: list[Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One ``scatter3d`` line trace from None-separated segment endpoints."""
|
||||||
|
x: list[float | None] = []
|
||||||
|
y: list[float | None] = []
|
||||||
|
z: list[float | None] = []
|
||||||
|
for a, b in segments:
|
||||||
|
x.extend([float(a[0]), float(b[0]), None])
|
||||||
|
y.extend([float(a[1]), float(b[1]), None])
|
||||||
|
z.extend([float(a[2]), float(b[2]), None])
|
||||||
|
trace: dict[str, Any] = {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "lines",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"line": {"color": color, "width": width},
|
||||||
|
"opacity": opacity,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
if meta is not None:
|
||||||
|
trace["meta"] = meta
|
||||||
|
if customdata is not None:
|
||||||
|
trace["customdata"] = customdata
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
class PlotlyTraceBuilder:
|
||||||
|
"""Builds the full figure for a project."""
|
||||||
|
|
||||||
|
def __init__(self, style: RenderStyle | None = None) -> None:
|
||||||
|
self._style = style or RenderStyle()
|
||||||
|
|
||||||
|
# ── public ───────────────────────────────────────────────────────
|
||||||
|
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
|
||||||
|
opts = options or SceneOptions()
|
||||||
|
if project is None:
|
||||||
|
return Scene(data=[], layout=self._layout())
|
||||||
|
|
||||||
|
nodes = list(project.nodes)
|
||||||
|
node_ids = [n.id for n in nodes]
|
||||||
|
points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3))
|
||||||
|
if opts.deformation is not None and len(points):
|
||||||
|
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
|
||||||
|
node_row = {nid: i for i, nid in enumerate(node_ids)}
|
||||||
|
|
||||||
|
data: list[dict[str, Any]] = []
|
||||||
|
grid_pts = self._build_grid(project, data, opts)
|
||||||
|
self._build_extrusions(project, data, opts)
|
||||||
|
self._build_local_axes(project, data, opts)
|
||||||
|
self._build_loads(project, data, opts)
|
||||||
|
self._build_supports(project, data, opts)
|
||||||
|
self._build_frames(project, data, opts, points, node_row)
|
||||||
|
self._build_nodes(data, opts, points, node_ids)
|
||||||
|
self._build_labels(project, data, opts, points, node_row)
|
||||||
|
hover_trace = self._build_hover_marker(data)
|
||||||
|
|
||||||
|
candidates = [points] if len(points) else []
|
||||||
|
if grid_pts is not None:
|
||||||
|
candidates.append(grid_pts)
|
||||||
|
all_pts = np.vstack(candidates) if candidates else np.empty((0, 3))
|
||||||
|
center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0)
|
||||||
|
return Scene(
|
||||||
|
data=data,
|
||||||
|
layout=self._layout(),
|
||||||
|
center=(float(center[0]), float(center[1]), float(center[2])),
|
||||||
|
diagonal=_diag_of_points(all_pts),
|
||||||
|
hover_trace=hover_trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── layout ───────────────────────────────────────────────────────
|
||||||
|
def _layout(self) -> dict[str, Any]:
|
||||||
|
style = self._style
|
||||||
|
|
||||||
|
def axis(color: str, title: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": {"text": title, "font": {"color": color, "size": 12}},
|
||||||
|
"showgrid": False,
|
||||||
|
"showbackground": False,
|
||||||
|
"zeroline": False,
|
||||||
|
"showticklabels": False,
|
||||||
|
"showspikes": False,
|
||||||
|
"visible": True,
|
||||||
|
"linecolor": color,
|
||||||
|
"linewidth": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"paper_bgcolor": style.background_bottom,
|
||||||
|
"plot_bgcolor": style.background_bottom,
|
||||||
|
"showlegend": False,
|
||||||
|
"margin": {"l": 0, "r": 0, "t": 0, "b": 0},
|
||||||
|
"uirevision": "otko",
|
||||||
|
"scene": {
|
||||||
|
"bgcolor": style.background_bottom,
|
||||||
|
"aspectmode": "data",
|
||||||
|
"dragmode": "orbit",
|
||||||
|
"xaxis": axis(style.fix_color, "X"),
|
||||||
|
"yaxis": axis(style.load_color, "Y"),
|
||||||
|
"zaxis": axis(style.truss_color, "Z"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── grid ─────────────────────────────────────────────────────────
|
||||||
|
def _build_grid(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> np.ndarray | None:
|
||||||
|
coord_systems = getattr(project, "coord_systems", None) or []
|
||||||
|
palette = [
|
||||||
|
((0.08, 0.08, 0.08), (0.85, 0.55, 0.00)),
|
||||||
|
((0.20, 0.35, 0.55), (0.85, 0.55, 0.00)),
|
||||||
|
((0.20, 0.55, 0.30), (0.85, 0.55, 0.00)),
|
||||||
|
((0.55, 0.20, 0.40), (0.85, 0.55, 0.00)),
|
||||||
|
((0.35, 0.20, 0.55), (0.85, 0.55, 0.00)),
|
||||||
|
]
|
||||||
|
all_dots: list[np.ndarray] = []
|
||||||
|
|
||||||
|
for idx, cs in enumerate(coord_systems):
|
||||||
|
grid = cs.grid
|
||||||
|
if not grid.visible or getattr(grid, "hide_all", False):
|
||||||
|
continue
|
||||||
|
xs = list(grid.x_lines)
|
||||||
|
ys = list(grid.y_lines)
|
||||||
|
zs = list(grid.z_lines)
|
||||||
|
if not (xs or ys or zs):
|
||||||
|
continue
|
||||||
|
palette_idx = 0 if cs.is_global() else (idx % (len(palette) - 1)) + 1
|
||||||
|
grid_color, dot_color = palette[palette_idx]
|
||||||
|
grid_hex = _rgb_to_hex(grid_color)
|
||||||
|
dot_hex = _rgb_to_hex(dot_color)
|
||||||
|
|
||||||
|
xmin, xmax = (min(xs), max(xs)) if xs else (-1.0, 1.0)
|
||||||
|
ymin, ymax = (min(ys), max(ys)) if ys else (-1.0, 1.0)
|
||||||
|
if xmin == xmax:
|
||||||
|
xmin, xmax = xmin - 1.0, xmax + 1.0
|
||||||
|
if ymin == ymax:
|
||||||
|
ymin, ymax = ymin - 1.0, ymax + 1.0
|
||||||
|
|
||||||
|
plane_axis: int | None = None
|
||||||
|
plane_offset: float | None = None
|
||||||
|
if opts.working_plane is not None:
|
||||||
|
name, off = opts.working_plane
|
||||||
|
axis_idx = {"XY": 2, "XZ": 1, "YZ": 0}[name]
|
||||||
|
plane_axis = axis_idx
|
||||||
|
plane_offset = off - cs.coord.origin[axis_idx]
|
||||||
|
|
||||||
|
def on_plane(
|
||||||
|
local: tuple[float, float, float],
|
||||||
|
_axis: int | None = plane_axis,
|
||||||
|
_offset: float | None = plane_offset,
|
||||||
|
) -> bool:
|
||||||
|
if _axis is None or _offset is None:
|
||||||
|
return True
|
||||||
|
return abs(local[_axis] - _offset) < 1e-6
|
||||||
|
|
||||||
|
active: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
dim: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
|
||||||
|
def add_seg(
|
||||||
|
p1: tuple[float, float, float],
|
||||||
|
p2: tuple[float, float, float],
|
||||||
|
_active: list = active,
|
||||||
|
_dim: list = dim,
|
||||||
|
_on_plane: Any = on_plane,
|
||||||
|
_cs: Any = cs,
|
||||||
|
) -> None:
|
||||||
|
on = _on_plane(p1) and _on_plane(p2)
|
||||||
|
bucket = _active if on else _dim
|
||||||
|
bucket.append((_cs.coord.local_to_world(p1), _cs.coord.local_to_world(p2)))
|
||||||
|
|
||||||
|
z_planes = zs if zs else [0.0]
|
||||||
|
for z in z_planes:
|
||||||
|
for x in xs:
|
||||||
|
add_seg((x, ymin, z), (x, ymax, z))
|
||||||
|
for y in ys:
|
||||||
|
add_seg((xmin, y, z), (xmax, y, z))
|
||||||
|
if opts.working_plane is None and zs and xs and ys:
|
||||||
|
for x in xs:
|
||||||
|
for y in ys:
|
||||||
|
add_seg((x, y, zs[0]), (x, y, zs[-1]))
|
||||||
|
|
||||||
|
if dim:
|
||||||
|
data.append(_line_trace(dim, color=grid_hex, width=1, name="grid", opacity=0.18))
|
||||||
|
if active:
|
||||||
|
data.append(_line_trace(active, color=grid_hex, width=2, name="grid-active"))
|
||||||
|
|
||||||
|
active_dots: list[tuple[float, float, float]] = []
|
||||||
|
dim_dots: list[tuple[float, float, float]] = []
|
||||||
|
for z in z_planes:
|
||||||
|
for x in xs or [0.0]:
|
||||||
|
for y in ys or [0.0]:
|
||||||
|
pt = cs.coord.local_to_world((x, y, z))
|
||||||
|
(active_dots if on_plane((x, y, z)) else dim_dots).append(pt)
|
||||||
|
if dim_dots:
|
||||||
|
data.append(self._dot_trace(dim_dots, color="#999999", opacity=0.35))
|
||||||
|
if active_dots:
|
||||||
|
data.append(
|
||||||
|
self._dot_trace(
|
||||||
|
active_dots,
|
||||||
|
color=dot_hex,
|
||||||
|
opacity=1.0,
|
||||||
|
meta={"kind": "snap"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
all_dots.extend(np.asarray(active_dots, dtype=float))
|
||||||
|
|
||||||
|
return np.vstack(all_dots) if all_dots else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dot_trace(
|
||||||
|
points: list[tuple[float, float, float]],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
opacity: float,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
customdata = (
|
||||||
|
[[float(p[0]), float(p[1]), float(p[2])] for p in points] if meta is not None else None
|
||||||
|
)
|
||||||
|
trace: dict[str, Any] = {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"marker": {"color": color, "size": _SNAP_MARKER_SIZE, "line": {"width": 0}},
|
||||||
|
"opacity": opacity,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "snap",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
if meta is not None:
|
||||||
|
trace["meta"] = meta
|
||||||
|
trace["customdata"] = customdata
|
||||||
|
return trace
|
||||||
|
|
||||||
|
# ── model entities ───────────────────────────────────────────────
|
||||||
|
def _build_nodes(
|
||||||
|
self,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_ids: list[int],
|
||||||
|
) -> None:
|
||||||
|
if not len(points):
|
||||||
|
return
|
||||||
|
colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids]
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"marker": {
|
||||||
|
"color": colors,
|
||||||
|
"size": _NODE_MARKER_SIZE,
|
||||||
|
"line": {"color": "#4d4d4d", "width": 1},
|
||||||
|
},
|
||||||
|
"customdata": list(node_ids),
|
||||||
|
"meta": {"kind": "node"},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "nodes",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_frames(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_row: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
if not len(points):
|
||||||
|
return
|
||||||
|
normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
normal_ids: list[Any] = []
|
||||||
|
selected_ids: list[Any] = []
|
||||||
|
for el in project.elements:
|
||||||
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
|
continue
|
||||||
|
i = node_row.get(el.nodes[0])
|
||||||
|
j = node_row.get(el.nodes[1])
|
||||||
|
if i is None or j is None:
|
||||||
|
continue
|
||||||
|
seg = (tuple(points[i]), tuple(points[j]))
|
||||||
|
if el.id in opts.selection_elements:
|
||||||
|
selected.append(seg)
|
||||||
|
selected_ids.extend([el.id, el.id, None])
|
||||||
|
else:
|
||||||
|
normal.append(seg)
|
||||||
|
normal_ids.extend([el.id, el.id, None])
|
||||||
|
if not normal and not selected:
|
||||||
|
return
|
||||||
|
if normal:
|
||||||
|
data.append(
|
||||||
|
_line_trace(
|
||||||
|
normal,
|
||||||
|
color="#338cd9",
|
||||||
|
width=4,
|
||||||
|
name="elements",
|
||||||
|
meta={"kind": "element"},
|
||||||
|
customdata=normal_ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selected:
|
||||||
|
data.append(
|
||||||
|
_line_trace(
|
||||||
|
selected,
|
||||||
|
color="#00ffff",
|
||||||
|
width=6,
|
||||||
|
name="elements-selected",
|
||||||
|
meta={"kind": "element"},
|
||||||
|
customdata=selected_ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_supports(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not project.nodes:
|
||||||
|
return
|
||||||
|
dof_idx = _dof_indices(project.ndf)
|
||||||
|
groups: dict[str, list[Any]] = {}
|
||||||
|
for node in project.nodes:
|
||||||
|
if not any(node.restraint[i] for i in dof_idx):
|
||||||
|
continue
|
||||||
|
kind = _classify_support(node.restraint, dof_idx)
|
||||||
|
groups.setdefault(kind, []).append(node)
|
||||||
|
for kind, nodes in groups.items():
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(n.coords[0]) for n in nodes],
|
||||||
|
"y": [float(n.coords[1]) for n in nodes],
|
||||||
|
"z": [float(n.coords[2]) for n in nodes],
|
||||||
|
"marker": {
|
||||||
|
"color": "#ff8019",
|
||||||
|
"size": _SUPPORT_MARKER_SIZE,
|
||||||
|
"symbol": _SUPPORT_SYMBOLS[kind],
|
||||||
|
"line": {"color": "#7f3f00", "width": 1},
|
||||||
|
},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": f"support-{kind}",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── loads / axes / extrusions ────────────────────────────────────
|
||||||
|
def _build_loads(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not project.load_patterns or not project.nodes:
|
||||||
|
return
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
elem_by_id = {e.id: e for e in project.elements}
|
||||||
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
|
scale = max(_diag_of_points(pts) * 0.05, 1e-6)
|
||||||
|
|
||||||
|
nodal_x: list[float] = []
|
||||||
|
nodal_y: list[float] = []
|
||||||
|
nodal_z: list[float] = []
|
||||||
|
nodal_u: list[float] = []
|
||||||
|
nodal_v: list[float] = []
|
||||||
|
nodal_w: list[float] = []
|
||||||
|
dist_x: list[float] = []
|
||||||
|
dist_y: list[float] = []
|
||||||
|
dist_z: list[float] = []
|
||||||
|
dist_u: list[float] = []
|
||||||
|
dist_v: list[float] = []
|
||||||
|
dist_w: list[float] = []
|
||||||
|
|
||||||
|
for pattern in project.load_patterns:
|
||||||
|
if not isinstance(pattern, PlainLoadPattern):
|
||||||
|
continue
|
||||||
|
for nload in pattern.nodal_loads:
|
||||||
|
if not isinstance(nload, NodalLoad):
|
||||||
|
continue
|
||||||
|
node = node_by_id.get(nload.node_id)
|
||||||
|
if node is None:
|
||||||
|
continue
|
||||||
|
f = np.asarray(nload.forces[:3], dtype=float)
|
||||||
|
mag = float(np.linalg.norm(f))
|
||||||
|
if mag < 1e-12:
|
||||||
|
continue
|
||||||
|
direction = f / mag
|
||||||
|
tail = np.asarray(node.coords, dtype=float) - direction * scale
|
||||||
|
nodal_x.append(float(tail[0]))
|
||||||
|
nodal_y.append(float(tail[1]))
|
||||||
|
nodal_z.append(float(tail[2]))
|
||||||
|
nodal_u.append(float(direction[0]))
|
||||||
|
nodal_v.append(float(direction[1]))
|
||||||
|
nodal_w.append(float(direction[2]))
|
||||||
|
|
||||||
|
for eload in pattern.element_loads:
|
||||||
|
if not isinstance(eload, UniformElementLoad):
|
||||||
|
continue
|
||||||
|
elem = elem_by_id.get(eload.element_id)
|
||||||
|
if elem is None:
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(elem.nodes[0])
|
||||||
|
node_j = node_by_id.get(elem.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
z_global = np.array([0.0, 0.0, 1.0])
|
||||||
|
y_local = np.cross(z_global, x_local)
|
||||||
|
if float(np.linalg.norm(y_local)) < 1e-6:
|
||||||
|
y_local = np.cross(np.array([0.0, 1.0, 0.0]), x_local)
|
||||||
|
y_local /= float(np.linalg.norm(y_local))
|
||||||
|
z_local = np.cross(x_local, y_local)
|
||||||
|
load_vec = eload.wx * x_local + eload.wy * y_local + eload.wz * z_local
|
||||||
|
mag = float(np.linalg.norm(load_vec))
|
||||||
|
if mag < 1e-12:
|
||||||
|
continue
|
||||||
|
direction = load_vec / mag
|
||||||
|
n_arrows = 5
|
||||||
|
for k in range(n_arrows):
|
||||||
|
t = (k + 0.5) / n_arrows
|
||||||
|
tail = pi + t * axis - direction * (0.4 * scale)
|
||||||
|
dist_x.append(float(tail[0]))
|
||||||
|
dist_y.append(float(tail[1]))
|
||||||
|
dist_z.append(float(tail[2]))
|
||||||
|
dist_u.append(float(direction[0]))
|
||||||
|
dist_v.append(float(direction[1]))
|
||||||
|
dist_w.append(float(direction[2]))
|
||||||
|
|
||||||
|
if nodal_x:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
nodal_x,
|
||||||
|
nodal_y,
|
||||||
|
nodal_z,
|
||||||
|
nodal_u,
|
||||||
|
nodal_v,
|
||||||
|
nodal_w,
|
||||||
|
color="#33d933",
|
||||||
|
name="nodal-loads",
|
||||||
|
size=scale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dist_x:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
dist_x,
|
||||||
|
dist_y,
|
||||||
|
dist_z,
|
||||||
|
dist_u,
|
||||||
|
dist_v,
|
||||||
|
dist_w,
|
||||||
|
color="#ff8c33",
|
||||||
|
name="element-loads",
|
||||||
|
size=0.6 * scale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_local_axes(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not opts.show_local_axes or not project.nodes:
|
||||||
|
return
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
|
cap = max(_diag_of_points(pts) * 0.08, 1e-6)
|
||||||
|
axes: dict[str, dict[str, list[float]]] = {
|
||||||
|
"x": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
"y": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
"z": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
}
|
||||||
|
for el in project.elements:
|
||||||
|
if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement):
|
||||||
|
continue
|
||||||
|
if not isinstance(
|
||||||
|
el,
|
||||||
|
ElasticBeamColumn
|
||||||
|
| DispBeamColumn
|
||||||
|
| ForceBeamColumn
|
||||||
|
| BeamWithHingesElement
|
||||||
|
| TrussElement
|
||||||
|
| CorotTrussElement,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
|
node_j = node_by_id.get(el.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
y_local, z_local = _frame_basis(el, x_local)
|
||||||
|
mid = (pi + pj) / 2.0
|
||||||
|
for key, direction in (("x", x_local), ("y", y_local), ("z", z_local)):
|
||||||
|
bucket = axes[key]
|
||||||
|
bucket["x"].append(float(mid[0]))
|
||||||
|
bucket["y"].append(float(mid[1]))
|
||||||
|
bucket["z"].append(float(mid[2]))
|
||||||
|
bucket["u"].append(float(direction[0]))
|
||||||
|
bucket["v"].append(float(direction[1]))
|
||||||
|
bucket["w"].append(float(direction[2]))
|
||||||
|
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
|
||||||
|
bucket = axes[key]
|
||||||
|
if bucket["x"]:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
bucket["x"],
|
||||||
|
bucket["y"],
|
||||||
|
bucket["z"],
|
||||||
|
bucket["u"],
|
||||||
|
bucket["v"],
|
||||||
|
bucket["w"],
|
||||||
|
color=color,
|
||||||
|
name=f"local-{key}",
|
||||||
|
size=cap,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_extrusions(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not opts.show_extrusions:
|
||||||
|
return
|
||||||
|
from otko.services.section_bbox import bbox_for_section
|
||||||
|
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
section_by_id = {s.id: s for s in project.sections}
|
||||||
|
mesh = _Mesh()
|
||||||
|
for el in project.elements:
|
||||||
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
|
continue
|
||||||
|
if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement):
|
||||||
|
continue
|
||||||
|
section_id = getattr(el, "section_id", None)
|
||||||
|
if section_id is None:
|
||||||
|
continue
|
||||||
|
section = section_by_id.get(section_id)
|
||||||
|
if section is None:
|
||||||
|
continue
|
||||||
|
dims = bbox_for_section(section, project)
|
||||||
|
if dims is None:
|
||||||
|
continue
|
||||||
|
w_y, h_z = dims
|
||||||
|
if w_y <= 0 or h_z <= 0:
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
|
node_j = node_by_id.get(el.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
y_local, z_local = _frame_basis(el, x_local)
|
||||||
|
mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z))
|
||||||
|
if not mesh.is_empty:
|
||||||
|
data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _box_corners(
|
||||||
|
pi: np.ndarray,
|
||||||
|
x_local: np.ndarray,
|
||||||
|
y_local: np.ndarray,
|
||||||
|
z_local: np.ndarray,
|
||||||
|
length: float,
|
||||||
|
w_y: float,
|
||||||
|
h_z: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
hy, hz = w_y / 2.0, h_z / 2.0
|
||||||
|
offsets = np.array(
|
||||||
|
[
|
||||||
|
[0.0, -hy, -hz],
|
||||||
|
[length, -hy, -hz],
|
||||||
|
[length, +hy, -hz],
|
||||||
|
[0.0, +hy, -hz],
|
||||||
|
[0.0, -hy, +hz],
|
||||||
|
[length, -hy, +hz],
|
||||||
|
[length, +hy, +hz],
|
||||||
|
[0.0, +hy, +hz],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
basis = np.column_stack([x_local, y_local, z_local])
|
||||||
|
return pi + offsets @ basis.T
|
||||||
|
|
||||||
|
# ── labels / hover marker ────────────────────────────────────────
|
||||||
|
def _build_labels(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_row: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
if opts.show_node_labels and len(points):
|
||||||
|
labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes]
|
||||||
|
data.append(_text_trace(points, labels, name="node-labels"))
|
||||||
|
if opts.show_element_labels and len(points):
|
||||||
|
centers: list[np.ndarray] = []
|
||||||
|
labels: list[str] = []
|
||||||
|
for el in project.elements:
|
||||||
|
if len(el.nodes) != 2:
|
||||||
|
continue
|
||||||
|
i = node_row.get(el.nodes[0])
|
||||||
|
j = node_row.get(el.nodes[1])
|
||||||
|
if i is None or j is None:
|
||||||
|
continue
|
||||||
|
centers.append((points[i] + points[j]) / 2.0)
|
||||||
|
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
|
||||||
|
if centers:
|
||||||
|
data.append(_text_trace(np.asarray(centers), labels, name="element-labels"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [],
|
||||||
|
"y": [],
|
||||||
|
"z": [],
|
||||||
|
"marker": {
|
||||||
|
"color": "#ffd900",
|
||||||
|
"size": 13,
|
||||||
|
"line": {"color": "#8a6d00", "width": 1},
|
||||||
|
},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "snap-hover",
|
||||||
|
"showlegend": False,
|
||||||
|
"meta": {"kind": "hover"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return len(data) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cone_trace(
|
||||||
|
x: list[float],
|
||||||
|
y: list[float],
|
||||||
|
z: list[float],
|
||||||
|
u: list[float],
|
||||||
|
v: list[float],
|
||||||
|
w: list[float],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
name: str,
|
||||||
|
size: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "cone",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"u": u,
|
||||||
|
"v": v,
|
||||||
|
"w": w,
|
||||||
|
"anchor": "tail",
|
||||||
|
"sizemode": "absolute",
|
||||||
|
"sizeref": float(size),
|
||||||
|
"colorscale": [[0, color], [1, color]],
|
||||||
|
"showscale": False,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "text",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"text": labels,
|
||||||
|
"textposition": "top center",
|
||||||
|
"textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(rgb: tuple[float, float, float]) -> str:
|
||||||
|
r, g, b = (int(round(v * 255)) for v in rgb)
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
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