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.
150 lines
5.7 KiB
Python
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()
|