diff --git a/src/otko/views/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py new file mode 100644 index 0000000..7fabb6d --- /dev/null +++ b/src/otko/views/canvas_plotly/__init__.py @@ -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}") diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py new file mode 100644 index 0000000..7c6d782 --- /dev/null +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -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}" diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py new file mode 100644 index 0000000..92104be --- /dev/null +++ b/tests/unit/test_plotly_trace_builder.py @@ -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"])