diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 6051fd9..a55d43b 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -56,6 +56,7 @@ STYLE_FIELDS: tuple[tuple[str, str, str], ...] = ( ("extrusion_opacity", "Extrusion opacity", "float"), ("background_top", "Background (top)", "color"), ("background_bottom", "Background (bottom)", "color"), + ("show_axis_outline", "Axis outline (grid + ticks)", "bool"), ("label_font_size", "Label font size", "int"), ) @@ -96,6 +97,11 @@ class RenderStyle: extrusion_opacity: float = 0.22 label_font_size: int = 12 + #: Draw the plotly scene grid + tick marks around the model (the "outline" + #: of opstool's scene recipe). Off by default to keep the SAP2000-like + #: clean viewport; the coloured X/Y/Z axis lines always stay visible. + show_axis_outline: bool = False + #: Diverging scale for scalar response overlays (force diagrams today, #: nodal / element response plots later): blue → red, evenly spaced. #: This is opstool's ``default_cmap`` (RdYlBu reversed). diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 40ae710..fa5fc94 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -114,11 +114,25 @@ class Scene: diagonal: float = 1.0 #: Trace index (in ``data``) of the empty hover-snap marker, or -1. hover_trace: int = -1 + #: Padded ``(min, max)`` per axis, used to frame the camera deterministically. + axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict) 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} + def axis_overrides(self) -> dict[str, Any]: + """Nested ``scene`` range pins for the framed data window. + + Only pushed together with the camera (view presets / new project), so + the user's own zoom and pan survive ordinary data updates. The padding + comes from opstool's scene recipe (``pad_ratio`` 0.15). + """ + return { + f"{name}axis": {"range": [low, high], "autorange": False} + for name, (low, high) in self.axis_bounds.items() + } + @dataclass class _Mesh: @@ -173,6 +187,34 @@ def _diag_of_points(pts: np.ndarray | None) -> float: return d if d > 0 else 1.0 +#: Padding fraction added around the model when framing the view. +#: From opstool's scene recipe (``pad_ratio=0.15`` for model views). +_PAD_RATIO = 0.15 + + +def _padded_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]: + """Padded ``(min, max)`` per axis; degenerate axes get unit slack.""" + if pts is None or len(pts) == 0: + return {name: (-1.0, 1.0) for name in ("x", "y", "z")} + lower = np.asarray(pts, dtype=float).min(axis=0) + upper = np.asarray(pts, dtype=float).max(axis=0) + pad = (upper - lower) * _PAD_RATIO + bounds: dict[str, tuple[float, float]] = {} + for index, name in enumerate(("x", "y", "z")): + low = float(lower[index] - pad[index]) + high = float(upper[index] + pad[index]) + if high - low < 1e-9: # planar / single-point model + low, high = low - 1.0, high + 1.0 + bounds[name] = (low, high) + return bounds + + +def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float: + """Padded bounding-box diagonal — the framing distance yardstick.""" + diagonal = float(np.linalg.norm([high - low for low, high in bounds.values()])) + return diagonal if diagonal > 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)) @@ -298,25 +340,31 @@ class PlotlyTraceBuilder: 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) + bounds = _padded_axis_bounds(all_pts) return Scene( data=data, layout=self._layout(), center=(float(center[0]), float(center[1]), float(center[2])), - diagonal=_diag_of_points(all_pts), + diagonal=_diag_of_bounds(bounds), hover_trace=hover_trace, + axis_bounds=bounds, ) # ── layout ─────────────────────────────────────────────────────── def _layout(self) -> dict[str, Any]: style = self._style + outline = style.show_axis_outline def axis(color: str, title: str) -> dict[str, Any]: + # Coloured axis lines + titles stay visible as the orientation cue + # (plotly has no corner triad); the grid/ticks are the optional + # "outline" from opstool's scene recipe. return { "title": {"text": title, "font": {"color": color, "size": 12}}, - "showgrid": False, + "showgrid": outline, "showbackground": False, - "zeroline": False, - "showticklabels": False, + "zeroline": outline, + "showticklabels": outline, "showspikes": False, "visible": True, "linecolor": color, @@ -502,7 +550,9 @@ class PlotlyTraceBuilder: }, "customdata": list(node_ids), "meta": {"kind": "node"}, - "hoverinfo": "skip", + # Engineering-notation hover (opstool's trace recipe) so a + # hover identifies the entity instead of showing the raw id. + "hovertemplate": "Node #%{customdata}", "name": "nodes", "showlegend": False, } @@ -572,7 +622,7 @@ class PlotlyTraceBuilder: }, "customdata": customdata, "meta": {"kind": "element"}, - "hoverinfo": "skip", + "hovertemplate": "Element #%{customdata}", "name": "elements", "showlegend": False, } diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py index 5240546..ae249ef 100644 --- a/src/otko/views/dialogs/plot_properties.py +++ b/src/otko/views/dialogs/plot_properties.py @@ -11,6 +11,7 @@ from __future__ import annotations from PySide6.QtCore import Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( + QCheckBox, QColorDialog, QDialog, QDialogButtonBox, @@ -75,6 +76,7 @@ class PlotPropertiesDialog(QDialog): self._base = style self._colors: dict[str, _ColorButton] = {} self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {} + self._checks: dict[str, QCheckBox] = {} layout = QVBoxLayout(self) form = QFormLayout() @@ -102,6 +104,7 @@ class PlotPropertiesDialog(QDialog): """ updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()} updates.update({name: w.value() for name, w in self._numbers.items()}) + updates.update({name: w.isChecked() for name, w in self._checks.items()}) return self._base.with_updates(**updates) # ── internals ─────────────────────────────────────────────────── @@ -120,6 +123,12 @@ class PlotPropertiesDialog(QDialog): spin.valueChanged.connect(self._emit_changed) self._numbers[name] = spin return spin + if kind == "bool": + check = QCheckBox() + check.setChecked(bool(value)) + check.toggled.connect(self._emit_changed) + self._checks[name] = check + return check int_spin = QSpinBox() int_spin.setRange(6, 32) int_spin.setValue(int(value)) # type: ignore[call-overload] @@ -136,4 +145,6 @@ class PlotPropertiesDialog(QDialog): button.set_color(getattr(defaults, name)) for name, spin in self._numbers.items(): spin.setValue(getattr(defaults, name)) + for name, check in self._checks.items(): + check.setChecked(getattr(defaults, name)) self._emit_changed() diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py index ae960a9..45b2b21 100644 --- a/tests/unit/test_plotly_trace_builder.py +++ b/tests/unit/test_plotly_trace_builder.py @@ -206,3 +206,57 @@ def test_frame_trace_meta_marks_elements_pickable() -> None: # 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}" + assert frames["hovertemplate"] == "Element #%{customdata}" + # 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 diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py index 32b027b..c179eb7 100644 --- a/tests/unit/test_render_style.py +++ b/tests/unit/test_render_style.py @@ -49,7 +49,7 @@ def test_editable_subset_matches_the_field_table() -> None: kinds = {name: kind for name, _label, kind in STYLE_FIELDS} for name in EDITABLE_FIELDS: assert hasattr(style, name), name - assert kinds[name] in {"color", "float", "int"} + assert kinds[name] in {"color", "float", "int", "bool"} def test_response_colorscale_is_an_evenly_spaced_mapping() -> None: