plotdeck 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotdeck/ui/theme.py ADDED
@@ -0,0 +1,329 @@
1
+ """Theme tokens and stylesheet construction.
2
+
3
+ All colours live here; widgets read them from :func:`tokens` instead of
4
+ hard-coding hex values, so light and dark themes stay consistent.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ DARK = {
10
+ "window": "#0F1215",
11
+ "surface": "#1A1E23",
12
+ "editor": "#14171A",
13
+ "border": "#2A3038",
14
+ "text": "#D7DDE4",
15
+ "muted": "#8B95A1",
16
+ "accent": "#4C9BF0",
17
+ "accent_text": "#FFFFFF",
18
+ "idle": "#3FB67A",
19
+ "run": "#4C9BF0",
20
+ "hold": "#E0A030",
21
+ "alarm": "#E5584F",
22
+ "offline": "#7D8896",
23
+ "selection": "#233A52",
24
+ "gutter": "#101316",
25
+ "g_word": "#7CB7FF",
26
+ "m_word": "#C792EA",
27
+ "axis_word": "#E6C07B",
28
+ "feed_word": "#7FD1AE",
29
+ "comment": "#7D8896",
30
+ "error": "#E5584F",
31
+ "danger": "#C0453D",
32
+ "danger_hover": "#D2544B",
33
+ "primary": "#2F6FB5",
34
+ "primary_hover": "#3A83D2",
35
+ "tooltip": "#22282F",
36
+ }
37
+
38
+ LIGHT = {
39
+ "window": "#F2F4F7",
40
+ "surface": "#FFFFFF",
41
+ "editor": "#FBFCFD",
42
+ "border": "#D5DBE3",
43
+ "text": "#1D242C",
44
+ "muted": "#5C6773",
45
+ "accent": "#1F6FD0",
46
+ "accent_text": "#FFFFFF",
47
+ "idle": "#1E8F58",
48
+ "run": "#1F6FD0",
49
+ "hold": "#A86A00",
50
+ "alarm": "#C0392B",
51
+ "offline": "#6B7682",
52
+ "selection": "#D6E6FA",
53
+ "gutter": "#EEF1F5",
54
+ "g_word": "#1B5FA8",
55
+ "m_word": "#7A3FA0",
56
+ "axis_word": "#8A6200",
57
+ "feed_word": "#117A5B",
58
+ "comment": "#6B7682",
59
+ "error": "#C0392B",
60
+ "danger": "#C0392B",
61
+ "danger_hover": "#D64B3D",
62
+ "primary": "#1F6FD0",
63
+ "primary_hover": "#2C82E8",
64
+ "tooltip": "#FFFFFF",
65
+ }
66
+
67
+ UI_FONT = '"IBM Plex Sans", "SF Pro Text", "Helvetica Neue", sans-serif'
68
+ MONO_FONT = '"IBM Plex Mono", "SF Mono", Menlo, monospace'
69
+
70
+
71
+ def tokens(dark: bool = True) -> dict[str, str]:
72
+ return dict(DARK if dark else LIGHT)
73
+
74
+
75
+ def is_dark_system() -> bool:
76
+ """Best-effort detection of the macOS appearance without importing Qt."""
77
+ try:
78
+ from PySide6.QtCore import Qt
79
+ from PySide6.QtGui import QGuiApplication
80
+
81
+ hints = QGuiApplication.styleHints()
82
+ if hasattr(hints, "colorScheme"):
83
+ return hints.colorScheme() == Qt.ColorScheme.Dark
84
+ except Exception: # noqa: BLE001
85
+ pass
86
+ return True
87
+
88
+
89
+ def state_color(token: dict[str, str], family: str) -> str:
90
+ return {
91
+ "idle": token["idle"],
92
+ "run": token["run"],
93
+ "hold": token["hold"],
94
+ "alarm": token["alarm"],
95
+ }.get(family, token["offline"])
96
+
97
+
98
+ def build_stylesheet(token: dict[str, str]) -> str:
99
+ return f"""
100
+ * {{
101
+ font-family: {UI_FONT};
102
+ font-size: 13px;
103
+ }}
104
+ QMainWindow, QDialog {{
105
+ background: {token["window"]};
106
+ color: {token["text"]};
107
+ }}
108
+ QWidget#Panel, QDockWidget > QWidget {{
109
+ background: {token["surface"]};
110
+ color: {token["text"]};
111
+ }}
112
+ QDockWidget {{
113
+ color: {token["muted"]};
114
+ titlebar-close-icon: none;
115
+ titlebar-normal-icon: none;
116
+ }}
117
+ QDockWidget::title {{
118
+ background: {token["window"]};
119
+ border-bottom: 1px solid {token["border"]};
120
+ padding: 6px 8px;
121
+ font-size: 11px;
122
+ font-weight: 600;
123
+ letter-spacing: 1px;
124
+ text-transform: uppercase;
125
+ }}
126
+ QToolBar {{
127
+ background: {token["surface"]};
128
+ border-bottom: 1px solid {token["border"]};
129
+ spacing: 6px;
130
+ padding: 5px 8px;
131
+ }}
132
+ QToolBar::separator {{
133
+ background: {token["border"]};
134
+ width: 1px;
135
+ margin: 4px 6px;
136
+ }}
137
+ QStatusBar {{
138
+ background: {token["surface"]};
139
+ border-top: 1px solid {token["border"]};
140
+ color: {token["muted"]};
141
+ }}
142
+ QLabel {{
143
+ background: transparent;
144
+ color: {token["text"]};
145
+ }}
146
+ QLabel#Muted, QLabel#Legend {{
147
+ color: {token["muted"]};
148
+ }}
149
+ QLabel#SectionHeader {{
150
+ color: {token["muted"]};
151
+ font-size: 11px;
152
+ font-weight: 600;
153
+ letter-spacing: 1px;
154
+ }}
155
+ QLabel#DroValue {{
156
+ font-family: {MONO_FONT};
157
+ font-size: 26px;
158
+ font-weight: 600;
159
+ }}
160
+ QLabel#DroAxis {{
161
+ font-family: {MONO_FONT};
162
+ font-size: 14px;
163
+ font-weight: 700;
164
+ color: {token["muted"]};
165
+ }}
166
+ QLabel#DroSecondary {{
167
+ font-family: {MONO_FONT};
168
+ font-size: 11px;
169
+ color: {token["muted"]};
170
+ }}
171
+ QPushButton, QToolButton {{
172
+ background: {token["surface"]};
173
+ color: {token["text"]};
174
+ border: 1px solid {token["border"]};
175
+ border-radius: 6px;
176
+ padding: 6px 12px;
177
+ min-height: 20px;
178
+ }}
179
+ QPushButton:hover, QToolButton:hover {{
180
+ background: {token["selection"]};
181
+ }}
182
+ QPushButton:pressed, QToolButton:pressed {{
183
+ background: {token["border"]};
184
+ }}
185
+ QPushButton:disabled, QToolButton:disabled {{
186
+ color: {token["muted"]};
187
+ background: {token["window"]};
188
+ }}
189
+ QToolButton:checked {{
190
+ background: {token["selection"]};
191
+ border-color: {token["accent"]};
192
+ }}
193
+ QPushButton#Primary {{
194
+ background: {token["primary"]};
195
+ border-color: {token["primary"]};
196
+ color: {token["accent_text"]};
197
+ font-weight: 600;
198
+ }}
199
+ QPushButton#Primary:hover {{
200
+ background: {token["primary_hover"]};
201
+ }}
202
+ QPushButton#Danger, QToolButton#Danger {{
203
+ background: {token["danger"]};
204
+ border-color: {token["danger"]};
205
+ color: {token["accent_text"]};
206
+ font-weight: 700;
207
+ min-width: 72px;
208
+ }}
209
+ QPushButton#Danger:hover, QToolButton#Danger:hover {{
210
+ background: {token["danger_hover"]};
211
+ }}
212
+ QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
213
+ background: {token["editor"]};
214
+ color: {token["text"]};
215
+ border: 1px solid {token["border"]};
216
+ border-radius: 6px;
217
+ selection-background-color: {token["selection"]};
218
+ selection-color: {token["text"]};
219
+ }}
220
+ QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
221
+ padding: 5px 8px;
222
+ min-height: 20px;
223
+ }}
224
+ QPlainTextEdit, QTextEdit {{
225
+ padding: 4px;
226
+ }}
227
+ QComboBox::drop-down {{
228
+ border: none;
229
+ width: 20px;
230
+ }}
231
+ QComboBox QAbstractItemView {{
232
+ background: {token["surface"]};
233
+ color: {token["text"]};
234
+ border: 1px solid {token["border"]};
235
+ selection-background-color: {token["selection"]};
236
+ }}
237
+ QCheckBox {{
238
+ color: {token["text"]};
239
+ spacing: 6px;
240
+ }}
241
+ QSlider::groove:horizontal {{
242
+ height: 4px;
243
+ background: {token["border"]};
244
+ border-radius: 2px;
245
+ }}
246
+ QSlider::sub-page:horizontal {{
247
+ background: {token["accent"]};
248
+ border-radius: 2px;
249
+ }}
250
+ QSlider::handle:horizontal {{
251
+ background: {token["text"]};
252
+ width: 14px;
253
+ margin: -6px 0;
254
+ border-radius: 7px;
255
+ }}
256
+ QProgressBar {{
257
+ background: {token["editor"]};
258
+ border: 1px solid {token["border"]};
259
+ border-radius: 4px;
260
+ text-align: center;
261
+ color: {token["text"]};
262
+ min-height: 16px;
263
+ }}
264
+ QProgressBar::chunk {{
265
+ background: {token["accent"]};
266
+ border-radius: 3px;
267
+ }}
268
+ QSplitter::handle {{
269
+ background: {token["border"]};
270
+ }}
271
+ QMenuBar, QMenu {{
272
+ background: {token["surface"]};
273
+ color: {token["text"]};
274
+ }}
275
+ QMenuBar::item:selected, QMenu::item:selected {{
276
+ background: {token["selection"]};
277
+ }}
278
+ QMenu::separator {{
279
+ height: 1px;
280
+ background: {token["border"]};
281
+ margin: 4px 8px;
282
+ }}
283
+ QScrollBar:vertical {{
284
+ background: {token["editor"]};
285
+ width: 12px;
286
+ margin: 0;
287
+ }}
288
+ QScrollBar::handle:vertical {{
289
+ background: {token["border"]};
290
+ min-height: 24px;
291
+ border-radius: 6px;
292
+ margin: 2px;
293
+ }}
294
+ QScrollBar:horizontal {{
295
+ background: {token["editor"]};
296
+ height: 12px;
297
+ margin: 0;
298
+ }}
299
+ QScrollBar::handle:horizontal {{
300
+ background: {token["border"]};
301
+ min-width: 24px;
302
+ border-radius: 6px;
303
+ margin: 2px;
304
+ }}
305
+ QScrollBar::add-line, QScrollBar::sub-line {{
306
+ width: 0;
307
+ height: 0;
308
+ }}
309
+ QToolTip {{
310
+ background: {token["tooltip"]};
311
+ color: {token["text"]};
312
+ border: 1px solid {token["border"]};
313
+ padding: 4px;
314
+ }}
315
+ QHeaderView::section {{
316
+ background: {token["window"]};
317
+ color: {token["muted"]};
318
+ border: none;
319
+ border-bottom: 1px solid {token["border"]};
320
+ padding: 5px;
321
+ }}
322
+ QTableWidget {{
323
+ background: {token["editor"]};
324
+ color: {token["text"]};
325
+ gridline-color: {token["border"]};
326
+ border: 1px solid {token["border"]};
327
+ border-radius: 6px;
328
+ }}
329
+ """
@@ -0,0 +1,374 @@
1
+ """Toolpath preview: 2D plot by default, optional 3D view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import time
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pyqtgraph as pg
11
+ from PySide6.QtCore import Qt, QTimer
12
+ from PySide6.QtWidgets import (
13
+ QHBoxLayout,
14
+ QLabel,
15
+ QPushButton,
16
+ QStackedWidget,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+ from ..core.gcode_parser import ProgramLine, Segment, Toolpath, build_toolpath
22
+ from .job_panel import format_duration
23
+ from .theme import tokens
24
+
25
+
26
+ def _segment_points(segment: Segment) -> list[tuple[float, float, float]]:
27
+ return segment.points if len(segment.points) > 1 else [segment.start, segment.end]
28
+
29
+
30
+ class Visualizer(QWidget):
31
+ def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
32
+ super().__init__(parent)
33
+ self._token = token or tokens()
34
+ self.setObjectName("Panel")
35
+ self._segments: list[Segment] = []
36
+ self._segment_lines: list[int] = []
37
+ self._offsets: list[int] = []
38
+ self._points = np.empty((0, 2))
39
+ self._points_3d = np.empty((0, 3))
40
+ self._last_render = 0.0
41
+ self._pending_line: int | None = None
42
+ self._executed_offset = 0
43
+ self._toolpath: Toolpath | None = None
44
+ self._marker_3d: Any = None
45
+ self._remaining_3d: Any = None
46
+ self._executed_3d: Any = None
47
+ self._gl: Any = None
48
+
49
+ self._plot = pg.PlotWidget(background=self._token["editor"])
50
+ self._plot.showGrid(x=True, y=True, alpha=0.12)
51
+ self._plot.setAspectLocked(True)
52
+ # Mouse wheel zooms, left-drag pans (pyqtgraph defaults, made explicit).
53
+ self._plot.setMouseEnabled(x=True, y=True)
54
+ self._plot.setLabel("bottom", "X", units="mm")
55
+ self._plot.setLabel("left", "Y", units="mm")
56
+ self._plot.getAxis("bottom").setPen(pg.mkPen(self._token["border"]))
57
+ self._plot.getAxis("left").setPen(pg.mkPen(self._token["border"]))
58
+ for axis in ("bottom", "left"):
59
+ self._plot.getAxis(axis).setTextPen(pg.mkPen(self._token["muted"]))
60
+
61
+ self._remaining = pg.PlotDataItem(
62
+ pen=pg.mkPen(self._token["muted"], width=1, style=Qt.PenStyle.DashLine)
63
+ )
64
+ self._executed = pg.PlotDataItem(pen=pg.mkPen(self._token["idle"], width=1.6))
65
+ self._marker = pg.ScatterPlotItem(
66
+ size=11,
67
+ brush=pg.mkBrush(self._token["accent"]),
68
+ pen=pg.mkPen(self._token["window"]),
69
+ )
70
+ self._plot.addItem(self._remaining)
71
+ self._plot.addItem(self._executed)
72
+ self._plot.addItem(self._marker)
73
+ self._marker.hide()
74
+
75
+ self._stack = QStackedWidget()
76
+ self._stack.addWidget(self._plot)
77
+ self._placeholder = QLabel("3D view is unavailable (PyOpenGL is not installed).")
78
+ self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
79
+ self._stack.addWidget(self._placeholder)
80
+ self._gl = None
81
+
82
+ header = QLabel("TOOLPATH")
83
+ header.setObjectName("SectionHeader")
84
+ self._zoom_out_button = QPushButton("−")
85
+ self._zoom_out_button.setFixedWidth(30)
86
+ self._zoom_out_button.setToolTip("Zoom out (⌘− or scroll)")
87
+ self._zoom_out_button.setAccessibleName("Zoom out")
88
+ self._zoom_out_button.clicked.connect(self.zoom_out)
89
+ self._zoom_in_button = QPushButton("+")
90
+ self._zoom_in_button.setFixedWidth(30)
91
+ self._zoom_in_button.setToolTip("Zoom in (⌘+ or scroll)")
92
+ self._zoom_in_button.setAccessibleName("Zoom in")
93
+ self._zoom_in_button.clicked.connect(self.zoom_in)
94
+ self._fit = QPushButton("Fit")
95
+ self._fit.setToolTip("Fit the toolpath in view (F or ⌘0)")
96
+ self._fit.clicked.connect(self.fit)
97
+ self._two_d = QPushButton("2D")
98
+ self._three_d = QPushButton("3D")
99
+ for button in (self._two_d, self._three_d):
100
+ button.setCheckable(True)
101
+ button.setFixedWidth(40)
102
+ self._two_d.setChecked(True)
103
+ self._two_d.clicked.connect(lambda: self._set_view(2))
104
+ self._three_d.clicked.connect(lambda: self._set_view(3))
105
+
106
+ top = QHBoxLayout()
107
+ top.addWidget(header)
108
+ top.addStretch(1)
109
+ top.addWidget(self._two_d)
110
+ top.addWidget(self._three_d)
111
+ top.addWidget(self._zoom_out_button)
112
+ top.addWidget(self._zoom_in_button)
113
+ top.addWidget(self._fit)
114
+
115
+ self._info = QLabel("No file loaded")
116
+ self._info.setObjectName("Muted")
117
+ self._legend = QLabel()
118
+ self._legend.setObjectName("Legend")
119
+ self._update_legend()
120
+
121
+ layout = QVBoxLayout(self)
122
+ layout.setContentsMargins(10, 8, 10, 8)
123
+ layout.setSpacing(4)
124
+ layout.addLayout(top)
125
+ layout.addWidget(self._stack, 1)
126
+ layout.addWidget(self._legend)
127
+ layout.addWidget(self._info)
128
+
129
+ self._throttle = QTimer(self)
130
+ self._throttle.setSingleShot(True)
131
+ self._throttle.setInterval(140)
132
+ self._throttle.timeout.connect(self._flush)
133
+
134
+ # ------------------------------------------------------------------ #
135
+ def set_token(self, token: dict[str, str]) -> None:
136
+ self._token = token
137
+ self._plot.setBackground(token["editor"])
138
+ self._plot.getAxis("bottom").setPen(pg.mkPen(token["border"]))
139
+ self._plot.getAxis("left").setPen(pg.mkPen(token["border"]))
140
+ for axis in ("bottom", "left"):
141
+ self._plot.getAxis(axis).setTextPen(pg.mkPen(token["muted"]))
142
+ self._remaining.setPen(pg.mkPen(token["muted"], width=1, style=Qt.PenStyle.DashLine))
143
+ self._executed.setPen(pg.mkPen(token["idle"], width=1.6))
144
+ self._marker.setBrush(pg.mkBrush(token["accent"]))
145
+ self._marker.setPen(pg.mkPen(token["window"]))
146
+ if self._gl is not None:
147
+ self._gl.setBackgroundColor(token["editor"])
148
+ if self._executed_3d is not None:
149
+ self._executed_3d.setColor(pg.glColor(token["idle"]))
150
+ if self._marker_3d is not None:
151
+ self._marker_3d.setColor(pg.glColor(token["accent"]))
152
+
153
+ @property
154
+ def toolpath(self): # noqa: ANN201
155
+ return self._toolpath
156
+
157
+ def set_program(self, program: list[ProgramLine], limits=None) -> None: # noqa: ANN001
158
+ self._toolpath = build_toolpath(program, limits=limits)
159
+ self._segments = self._toolpath.segments
160
+ self._segment_lines = [segment.source_line for segment in self._segments]
161
+ self._points, self._offsets = self._build_arrays(self._segments, 2)
162
+ self._points_3d, _ = self._build_arrays(self._segments, 3)
163
+ self._executed.setData(x=self._points[:0, 0], y=self._points[:0, 1], connect="finite")
164
+ self._remaining.setData(x=self._points[:, 0], y=self._points[:, 1], connect="finite")
165
+ self._marker.hide()
166
+ self._last_render = 0.0
167
+ self._pending_line = None
168
+ self._update_info()
169
+ if self._gl is not None:
170
+ self._refresh_3d()
171
+ self.fit()
172
+
173
+ def clear(self) -> None:
174
+ self._segments = []
175
+ self._segment_lines = []
176
+ self._offsets = []
177
+ self._points = np.empty((0, 2))
178
+ self._points_3d = np.empty((0, 3))
179
+ self._executed.setData(x=[], y=[])
180
+ self._remaining.setData(x=[], y=[])
181
+ self._marker.hide()
182
+ self._info.setText("No file loaded")
183
+ self._placeholder.setText("3D view is unavailable")
184
+ if self._gl is not None:
185
+ self._refresh_3d()
186
+
187
+ def set_progress_line(self, line: int, force: bool = False) -> None:
188
+ if not self._segments:
189
+ return
190
+ now = time.monotonic()
191
+ if not force and now - self._last_render < 0.15:
192
+ self._pending_line = line
193
+ if not self._throttle.isActive():
194
+ self._throttle.start()
195
+ return
196
+ self._apply_line(line)
197
+
198
+ def _flush(self) -> None:
199
+ if self._pending_line is not None:
200
+ self._apply_line(self._pending_line)
201
+ self._pending_line = None
202
+
203
+ def _apply_line(self, line: int) -> None:
204
+ count = bisect.bisect_right(self._segment_lines, line)
205
+ offset = self._offsets[count - 1] if count else 0
206
+ executed = self._points[:offset]
207
+ remaining = self._points[offset:]
208
+ self._executed.setData(x=executed[:, 0], y=executed[:, 1], connect="finite")
209
+ self._remaining.setData(x=remaining[:, 0], y=remaining[:, 1], connect="finite")
210
+ self._last_render = time.monotonic()
211
+ if self._gl is not None:
212
+ self._refresh_3d(offset)
213
+
214
+ def set_position(self, x: float | None, y: float | None, z: float | None) -> None:
215
+ if x is None or y is None:
216
+ self._marker.hide()
217
+ return
218
+ self._marker.setData([x], [y])
219
+ self._marker.show()
220
+ if self._gl is not None and self._marker_3d is not None:
221
+ self._marker_3d.setData(
222
+ pos=np.array([[x, y, z or 0.0]]), color=pg.glColor(self._token["accent"])
223
+ )
224
+ self._marker_3d.setVisible(True)
225
+
226
+ def zoom_in(self) -> None:
227
+ self.zoom(0.8)
228
+
229
+ def zoom_out(self) -> None:
230
+ self.zoom(1.25)
231
+
232
+ def zoom(self, factor: float) -> None:
233
+ """Zoom by *factor* (<1 zooms in, >1 zooms out) in 2D or 3D."""
234
+ if factor <= 0:
235
+ return
236
+ if self._stack.currentIndex() == 1:
237
+ if self._gl is None:
238
+ return
239
+ distance = float(self._gl.opts.get("distance", 10.0))
240
+ self._gl.setCameraPosition(distance=max(0.05, distance * factor))
241
+ self._gl.update()
242
+ return
243
+ self._plot.getViewBox().scaleBy((factor, factor))
244
+ self._plot.update()
245
+
246
+ def _update_legend(self) -> None:
247
+ if self._stack.currentIndex() == 1:
248
+ self._legend.setText("drag to rotate · scroll or +/− to zoom")
249
+ else:
250
+ self._legend.setText(
251
+ "green = executed · dashed = remaining · scroll or +/− to zoom · drag to pan"
252
+ )
253
+
254
+ def fit(self) -> None:
255
+ if self._toolpath is None or self._toolpath.bounds is None:
256
+ self._plot.setRange(xRange=(-10, 10), yRange=(-10, 10), padding=0)
257
+ return
258
+ low, high = self._toolpath.bounds
259
+ width = max(high[0] - low[0], 1.0)
260
+ height = max(high[1] - low[1], 1.0)
261
+ margin = max(width, height) * 0.08
262
+ self._plot.setRange(
263
+ xRange=(low[0] - margin, high[0] + margin),
264
+ yRange=(low[1] - margin, high[1] + margin),
265
+ padding=0,
266
+ )
267
+ if self._gl is not None:
268
+ span = max(width, height, high[2] - low[2], 1.0)
269
+ self._gl.opts["center"] = pg.Vector(
270
+ (low[0] + high[0]) / 2, (low[1] + high[1]) / 2, (low[2] + high[2]) / 2
271
+ )
272
+ self._gl.setCameraPosition(distance=span * 2.2, elevation=32, azimuth=45)
273
+ self._gl.update()
274
+
275
+ # ------------------------------------------------------------------ #
276
+ def _set_view(self, dimensions: int) -> None:
277
+ if dimensions == 2:
278
+ self._two_d.setChecked(True)
279
+ self._three_d.setChecked(False)
280
+ self._stack.setCurrentIndex(0)
281
+ self._update_legend()
282
+ return
283
+ self._ensure_3d()
284
+ if self._gl is None:
285
+ self._three_d.setChecked(False)
286
+ self._two_d.setChecked(True)
287
+ return
288
+ self._two_d.setChecked(False)
289
+ self._three_d.setChecked(True)
290
+ self._stack.setCurrentIndex(1)
291
+ self._update_legend()
292
+ self._refresh_3d()
293
+ self.fit()
294
+
295
+ def _ensure_3d(self) -> None:
296
+ if self._gl is not None:
297
+ return
298
+ try:
299
+ import pyqtgraph.opengl as gl
300
+
301
+ view = gl.GLViewWidget()
302
+ view.setBackgroundColor(self._token["editor"])
303
+ grid = gl.GLGridItem()
304
+ grid.setSize(200, 200, 10)
305
+ grid.setSpacing(10, 10)
306
+ view.addItem(grid)
307
+ axes = gl.GLAxisItem()
308
+ axes.setSize(20, 20, 20)
309
+ view.addItem(axes)
310
+ self._remaining_3d = gl.GLLinePlotItem(color=(0.55, 0.58, 0.63, 1.0), width=1)
311
+ self._executed_3d = gl.GLLinePlotItem(color=pg.glColor(self._token["idle"]), width=2)
312
+ self._marker_3d = gl.GLScatterPlotItem(
313
+ pos=np.empty((0, 3)), color=pg.glColor(self._token["accent"]), size=10
314
+ )
315
+ for item in (self._remaining_3d, self._executed_3d, self._marker_3d):
316
+ view.addItem(item)
317
+ self._gl = view
318
+ self._marker_3d.setVisible(False)
319
+ self._stack.removeWidget(self._placeholder)
320
+ self._stack.addWidget(view)
321
+ self._three_d.setEnabled(True)
322
+ self._three_d.setToolTip("3D view")
323
+ except Exception: # noqa: BLE001
324
+ self._gl = None
325
+ self._placeholder.setText("3D view could not be initialised on this system.")
326
+ self._three_d.setToolTip("3D view unavailable")
327
+ self._three_d.setEnabled(False)
328
+
329
+ def _refresh_3d(self, offset: int | None = None) -> None:
330
+ if self._gl is None or self._remaining_3d is None or self._executed_3d is None:
331
+ return
332
+ if offset is None:
333
+ offset = self._executed_offset
334
+ self._executed_offset = offset
335
+ points = self._points_3d
336
+ self._remaining_3d.setData(pos=points[offset:], mode="line_strip")
337
+ self._executed_3d.setData(pos=points[:offset], mode="line_strip")
338
+
339
+ def _build_arrays(
340
+ self, segments: list[Segment], dimensions: int
341
+ ) -> tuple[np.ndarray, list[int]]:
342
+ total = sum(len(_segment_points(segment)) for segment in segments)
343
+ stride = max(1, total // 40_000)
344
+ rows: list[tuple[float, ...]] = []
345
+ offsets: list[int] = []
346
+ for segment in segments:
347
+ points = _segment_points(segment)
348
+ picked = points[::stride]
349
+ if picked[-1] != points[-1]:
350
+ picked.append(points[-1])
351
+ for point in picked:
352
+ rows.append(point[:dimensions])
353
+ rows.append(tuple(float("nan") for _ in range(dimensions)))
354
+ offsets.append(len(rows))
355
+ if not rows:
356
+ return np.empty((0, dimensions)), []
357
+ return np.asarray(rows, dtype=float), offsets
358
+
359
+ def _update_info(self) -> None:
360
+ path = self._toolpath
361
+ if path is None or not path.segments:
362
+ self._info.setText("No drawable moves in this file")
363
+ return
364
+ low, high = path.bounds if path.bounds else ((0, 0, 0), (0, 0, 0))
365
+ size = tuple(high[i] - low[i] for i in range(3))
366
+ self._info.setText(
367
+ f"{len(path.segments)} moves · {path.total_length:.0f} mm · "
368
+ f"{format_duration(path.estimated_seconds)} · "
369
+ f"{size[0]:.1f} × {size[1]:.1f} × {size[2]:.1f} mm"
370
+ )
371
+ if path.tools:
372
+ self._info.setToolTip("Tools: " + ", ".join(f"T{tool}" for tool in path.tools))
373
+ else:
374
+ self._info.setToolTip("No tool changes in this file")
@@ -0,0 +1 @@
1
+ """Small shared helpers: settings and logging."""