otko/src/otko/views/dialogs/plot_properties.py
smillmorel 5f977e9df0 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.
2026-09-16 20:05:26 -04:00

150 lines
5.7 KiB
Python

"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
so adding an editable property is a one-line change there plus a default on
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
so the canvas can preview them live; ``style()`` returns the composed result.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
class _ColorButton(QPushButton):
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
colorChanged = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._color = color
self.setFixedWidth(96)
self._refresh()
self.clicked.connect(self._pick)
def color(self) -> str:
"""Current colour as a ``#rrggbb`` string."""
return self._color
def set_color(self, color: str) -> None:
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
self._color = color
self._refresh()
def _refresh(self) -> None:
self.setText(self._color.upper())
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
def _text_color(self) -> str:
color = QColor(self._color)
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
return "#000000" if luminance > 140 else "#ffffff"
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
if chosen.isValid():
self.set_color(chosen.name())
self.colorChanged.emit(self._color)
class PlotPropertiesDialog(QDialog):
"""Modeless-free editor for the persisted plot style."""
#: Emitted with the composed :class:`RenderStyle` on every edit.
changed = Signal(object)
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Plot Properties")
self._base = style
self._colors: dict[str, _ColorButton] = {}
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
self._checks: dict[str, QCheckBox] = {}
layout = QVBoxLayout(self)
form = QFormLayout()
for name, label, kind in STYLE_FIELDS:
widget = self._build_widget(name, getattr(style, name), kind)
form.addRow(QLabel(f"{label}:"), widget)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
assert reset is not None
reset.clicked.connect(self._reset)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# ── public ──────────────────────────────────────────────────────
def plot_style(self) -> RenderStyle:
"""The composed style for the current widget values.
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
already exists and must keep its Qt meaning.
"""
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 ───────────────────────────────────────────────────
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
if kind == "color":
button = _ColorButton(str(value))
button.colorChanged.connect(self._emit_changed)
self._colors[name] = button
return button
if kind == "float":
spin = QDoubleSpinBox()
spin.setRange(0.0, 1.0)
spin.setSingleStep(0.05)
spin.setDecimals(2)
spin.setValue(float(value)) # type: ignore[arg-type]
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]
int_spin.valueChanged.connect(self._emit_changed)
self._numbers[name] = int_spin
return int_spin
def _emit_changed(self, *_args: object) -> None:
self.changed.emit(self.plot_style())
def _reset(self) -> None:
defaults = RenderStyle()
for name, button in self._colors.items():
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()