feat: opstool-style scene recipe for the Plotly canvas
Frames the view deterministically: Scene now carries padded per-axis bounds (opstool's pad_ratio 0.15, with unit slack on degenerate axes) plus axis_overrides() pinning range + autorange=False, so the model is never flush against the viewport edge. Adds entity hover templates (Node #n / Element #n with an empty extra tag), and an optional axis outline (grid + ticks) - off by default so the SAP2000-like clean viewport is unchanged; the coloured X/Y/Z axis lines always stay as the orientation cue. The outline is exposed as a bool in the Plot Properties table, so the table-driven dialog now builds a checkbox for kind=bool.
This commit is contained in:
parent
bd365be77d
commit
5f977e9df0
5 changed files with 128 additions and 7 deletions
|
|
@ -56,6 +56,7 @@ STYLE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
||||||
("extrusion_opacity", "Extrusion opacity", "float"),
|
("extrusion_opacity", "Extrusion opacity", "float"),
|
||||||
("background_top", "Background (top)", "color"),
|
("background_top", "Background (top)", "color"),
|
||||||
("background_bottom", "Background (bottom)", "color"),
|
("background_bottom", "Background (bottom)", "color"),
|
||||||
|
("show_axis_outline", "Axis outline (grid + ticks)", "bool"),
|
||||||
("label_font_size", "Label font size", "int"),
|
("label_font_size", "Label font size", "int"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,6 +97,11 @@ class RenderStyle:
|
||||||
extrusion_opacity: float = 0.22
|
extrusion_opacity: float = 0.22
|
||||||
label_font_size: int = 12
|
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,
|
#: Diverging scale for scalar response overlays (force diagrams today,
|
||||||
#: nodal / element response plots later): blue → red, evenly spaced.
|
#: nodal / element response plots later): blue → red, evenly spaced.
|
||||||
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,25 @@ class Scene:
|
||||||
diagonal: float = 1.0
|
diagonal: float = 1.0
|
||||||
#: Trace index (in ``data``) of the empty hover-snap marker, or -1.
|
#: Trace index (in ``data``) of the empty hover-snap marker, or -1.
|
||||||
hover_trace: int = -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]:
|
def to_payload(self) -> dict[str, Any]:
|
||||||
"""Figure dict without the camera — camera is owned by the widget."""
|
"""Figure dict without the camera — camera is owned by the widget."""
|
||||||
return {"data": self.data, "layout": self.layout}
|
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
|
@dataclass
|
||||||
class _Mesh:
|
class _Mesh:
|
||||||
|
|
@ -173,6 +187,34 @@ def _diag_of_points(pts: np.ndarray | None) -> float:
|
||||||
return d if d > 0 else 1.0
|
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]:
|
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
||||||
x = x_local / float(np.linalg.norm(x_local))
|
x = x_local / float(np.linalg.norm(x_local))
|
||||||
|
|
@ -298,25 +340,31 @@ class PlotlyTraceBuilder:
|
||||||
candidates.append(grid_pts)
|
candidates.append(grid_pts)
|
||||||
all_pts = np.vstack(candidates) if candidates else np.empty((0, 3))
|
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)
|
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(
|
return Scene(
|
||||||
data=data,
|
data=data,
|
||||||
layout=self._layout(),
|
layout=self._layout(),
|
||||||
center=(float(center[0]), float(center[1]), float(center[2])),
|
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,
|
hover_trace=hover_trace,
|
||||||
|
axis_bounds=bounds,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── layout ───────────────────────────────────────────────────────
|
# ── layout ───────────────────────────────────────────────────────
|
||||||
def _layout(self) -> dict[str, Any]:
|
def _layout(self) -> dict[str, Any]:
|
||||||
style = self._style
|
style = self._style
|
||||||
|
outline = style.show_axis_outline
|
||||||
|
|
||||||
def axis(color: str, title: str) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"title": {"text": title, "font": {"color": color, "size": 12}},
|
"title": {"text": title, "font": {"color": color, "size": 12}},
|
||||||
"showgrid": False,
|
"showgrid": outline,
|
||||||
"showbackground": False,
|
"showbackground": False,
|
||||||
"zeroline": False,
|
"zeroline": outline,
|
||||||
"showticklabels": False,
|
"showticklabels": outline,
|
||||||
"showspikes": False,
|
"showspikes": False,
|
||||||
"visible": True,
|
"visible": True,
|
||||||
"linecolor": color,
|
"linecolor": color,
|
||||||
|
|
@ -502,7 +550,9 @@ class PlotlyTraceBuilder:
|
||||||
},
|
},
|
||||||
"customdata": list(node_ids),
|
"customdata": list(node_ids),
|
||||||
"meta": {"kind": "node"},
|
"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}<extra></extra>",
|
||||||
"name": "nodes",
|
"name": "nodes",
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
|
|
@ -572,7 +622,7 @@ class PlotlyTraceBuilder:
|
||||||
},
|
},
|
||||||
"customdata": customdata,
|
"customdata": customdata,
|
||||||
"meta": {"kind": "element"},
|
"meta": {"kind": "element"},
|
||||||
"hoverinfo": "skip",
|
"hovertemplate": "Element #%{customdata}<extra></extra>",
|
||||||
"name": "elements",
|
"name": "elements",
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
QColorDialog,
|
QColorDialog,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
|
|
@ -75,6 +76,7 @@ class PlotPropertiesDialog(QDialog):
|
||||||
self._base = style
|
self._base = style
|
||||||
self._colors: dict[str, _ColorButton] = {}
|
self._colors: dict[str, _ColorButton] = {}
|
||||||
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
|
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
|
||||||
|
self._checks: dict[str, QCheckBox] = {}
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
|
|
@ -102,6 +104,7 @@ class PlotPropertiesDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
|
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.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)
|
return self._base.with_updates(**updates)
|
||||||
|
|
||||||
# ── internals ───────────────────────────────────────────────────
|
# ── internals ───────────────────────────────────────────────────
|
||||||
|
|
@ -120,6 +123,12 @@ class PlotPropertiesDialog(QDialog):
|
||||||
spin.valueChanged.connect(self._emit_changed)
|
spin.valueChanged.connect(self._emit_changed)
|
||||||
self._numbers[name] = spin
|
self._numbers[name] = spin
|
||||||
return 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 = QSpinBox()
|
||||||
int_spin.setRange(6, 32)
|
int_spin.setRange(6, 32)
|
||||||
int_spin.setValue(int(value)) # type: ignore[call-overload]
|
int_spin.setValue(int(value)) # type: ignore[call-overload]
|
||||||
|
|
@ -136,4 +145,6 @@ class PlotPropertiesDialog(QDialog):
|
||||||
button.set_color(getattr(defaults, name))
|
button.set_color(getattr(defaults, name))
|
||||||
for name, spin in self._numbers.items():
|
for name, spin in self._numbers.items():
|
||||||
spin.setValue(getattr(defaults, name))
|
spin.setValue(getattr(defaults, name))
|
||||||
|
for name, check in self._checks.items():
|
||||||
|
check.setChecked(getattr(defaults, name))
|
||||||
self._emit_changed()
|
self._emit_changed()
|
||||||
|
|
|
||||||
|
|
@ -206,3 +206,57 @@ def test_frame_trace_meta_marks_elements_pickable() -> None:
|
||||||
# None separators break the line into per-element segments.
|
# None separators break the line into per-element segments.
|
||||||
assert None in frames["x"]
|
assert None in frames["x"]
|
||||||
assert len(frames["customdata"]) == len(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
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def test_editable_subset_matches_the_field_table() -> None:
|
||||||
kinds = {name: kind for name, _label, kind in STYLE_FIELDS}
|
kinds = {name: kind for name, _label, kind in STYLE_FIELDS}
|
||||||
for name in EDITABLE_FIELDS:
|
for name in EDITABLE_FIELDS:
|
||||||
assert hasattr(style, name), name
|
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:
|
def test_response_colorscale_is_an_evenly_spaced_mapping() -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue