ventforge 0.1.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.
ventforge/gui.py ADDED
@@ -0,0 +1,1667 @@
1
+ """VentForge desktop GUI (PySide6). python -m ventforge.gui [file.vent.json]"""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import json
6
+ import math
7
+ import random
8
+ import sys
9
+ import time
10
+ import traceback
11
+ from dataclasses import fields, is_dataclass
12
+ from pathlib import Path
13
+ from typing import get_type_hints
14
+
15
+ from PySide6.QtCore import QPointF, QRectF, QSettings, QSize, Qt, QThread, QTimer, QFileSystemWatcher, Signal
16
+ from PySide6.QtGui import (QAction, QBrush, QColor, QIcon, QKeySequence, QPainter, QPainterPath,
17
+ QPainterPathStroker, QPalette, QPen, QPixmap, QPolygonF, QTransform)
18
+ from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QButtonGroup, QCheckBox, QColorDialog, QComboBox,
19
+ QDialog, QDialogButtonBox, QDockWidget, QDoubleSpinBox, QFileDialog, QFormLayout,
20
+ QGraphicsEllipseItem, QGraphicsItem, QGraphicsPixmapItem, QGraphicsScene,
21
+ QGraphicsView, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
22
+ QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QScrollArea,
23
+ QSizePolicy, QSlider, QSpinBox, QStackedWidget, QStyle, QTabWidget, QToolButton,
24
+ QVBoxLayout, QWidget)
25
+
26
+ from .model import (BOUNDARIES, MODES, SHAPES, TARGET_SHAPES, Control, Design, DotStyle, Gradient, Pattern)
27
+
28
+ PRESETS = Path(__file__).resolve().parent / "presets"
29
+ GRADS = ("size", "spacing", "angle", "shape")
30
+
31
+
32
+ def pattern(): # imported lazily so the GUI module loads even while pattern.py is missing
33
+ return importlib.import_module("ventforge.pattern")
34
+
35
+
36
+ def exporter():
37
+ return importlib.import_module("ventforge.export")
38
+
39
+
40
+ # ---------------------------------------------------------------- parameter metadata
41
+ CHOICES = {
42
+ "arrangement": ("square", "diamond", "hexagon"), "edge_style": ("flat", "round"),
43
+ "falloff": ("linear", "smooth"), "shape": SHAPES, "type": BOUNDARIES, "target_shape": TARGET_SHAPES,
44
+ "mode": MODES, "pattern.grid.rotate_by": ("row", "col", "both"),
45
+ "pattern.crosslines.rotate_by": ("left", "right", "both"),
46
+ }
47
+ META = { # name: (min, max, step, suffix)
48
+ "count": (1, 20000, 1, ""), "sides": (3, 64, 1, ""), "star_points": (3, 64, 1, ""),
49
+ "target_sides": (3, 64, 1, ""), "layers": (1, 100, 1, ""), "irregularity": (0, 100, 5, " %"),
50
+ "star_inner": (0.05, 0.95, 0.05, ""), "aspect": (0.05, 20, 0.05, ""), "noise": (0, 1, 0.05, ""),
51
+ "opacity": (0, 1, 0.05, ""), "offset_x": (-500, 500, 0.5, " mm"), "offset_y": (-500, 500, 0.5, " mm"),
52
+ "turns": (0.1, 200, 0.5, ""), "spread": (0.1, 50, 0.1, ""), "width": (1, 5000, 1, " mm"),
53
+ "height": (1, 5000, 1, " mm"), "thickness": (0.1, 100, 0.1, " mm"), "diameter": (0.1, 500, 0.1, " mm"),
54
+ }
55
+ MM_WORDS = ("spacing", "radius", "diameter", "distance", "width", "margin", "size", "amplitude",
56
+ "wavelength", "scale", "min_wall")
57
+ LABELS = {"rotate_by": "Rotate by", "min_wall": "Min wall", "star_inner": "Star inner ratio",
58
+ "noise_seed": "Noise seed", "edge": "Edge value"}
59
+ DOT_VIS = {"circle": (), "hexagon": (), "custom": (), "rounded_rectangle": ("aspect", "corner_radius"),
60
+ "polygon": ("sides",), "star": ("star_points", "star_inner")}
61
+ RAND = {"spacing_x": (4, 10), "spacing_y": (4, 10), "row_rotation": (0, 90), "rotation": (0, 90),
62
+ "count": (100, 800), "min_distance": (3, 8), "turns": (2, 10), "ring_spacing": (4, 9),
63
+ "dot_spacing": (4, 9), "spread": (1.5, 4), "left_angle": (30, 80), "right_angle": (100, 150),
64
+ "left_spacing": (4, 10), "right_spacing": (4, 10), "cell_size": (5, 14), "irregularity": (0, 100),
65
+ "crack_width": (0.8, 2.5), "line_spacing": (3, 8), "line_width": (1, 3), "amplitude": (0, 8),
66
+ "wavelength": (10, 60), "seed": (1, 9999), "layer_spacing": (3, 8), "layers": (1, 4)}
67
+ CTRL_VALUE = {"size": 0.3, "spacing": 2.0, "angle": 45.0, "shape": 1.0}
68
+ # Slider ("comfortable") ranges; the number box still accepts anything inside META's hard range.
69
+ # Tuned with a sweep of pattern.generate over each slider (non-empty, <5% min-wall overlaps, <250 ms, visible
70
+ # change) on the 100 mm default canvas. Full-path entries win over the name-based SOFT fallback.
71
+ SOFT_PATH = {
72
+ "canvas.width": (10, 500), "canvas.height": (10, 500), "canvas.thickness": (0.5, 10),
73
+ "boundary.corner_radius": (0, 50), "dot.diameter": (0.3, 20), "dot.corner_radius": (0, 3),
74
+ "pattern.grid.offset_x": (-10, 10), "pattern.grid.offset_y": (-10, 10), "pattern.grid.rotation": (-90, 90),
75
+ "pattern.random.count": (5, 1000),
76
+ "pattern.path.count": (3, 80), "pattern.path.layer_spacing": (2.5, 30),
77
+ "pattern.spiral.radius": (5, 100), "pattern.spiral.turns": (1, 20), "pattern.spiral.inner_radius": (0, 40),
78
+ "pattern.spiral.count": (10, 500),
79
+ "pattern.concentric.inner_radius": (0, 60), "pattern.concentric.outer_radius": (0, 70),
80
+ "pattern.fibonacci.count": (10, 700), "pattern.fibonacci.spread": (1, 10),
81
+ "pattern.fibonacci.inner_radius": (0, 40), "pattern.crosslines.rotation": (-90, 90),
82
+ "pattern.voronoi.corner_radius": (0, 5), "pattern.flowlines.line_spacing": (2, 30),
83
+ }
84
+ SOFT = {"diameter": (0.3, 20), "thickness": (0.5, 10), "margin": (0, 20), "corner_radius": (0, 20),
85
+ "aspect": (0.2, 5), "sides": (3, 12), "star_points": (3, 12), "star_inner": (0.1, 0.9),
86
+ "target_sides": (3, 12), "count": (10, 1000), "layers": (1, 10), "turns": (1, 20), "spread": (1, 10),
87
+ "irregularity": (0, 100), "noise": (0, 1), "opacity": (0, 1), "radius": (5, 150), "inner_radius": (0, 40),
88
+ "outer_radius": (0, 80), "amplitude": (0, 10), "wavelength": (10, 200), "noise_scale": (2, 100),
89
+ "min_wall": (0.2, 3), "cell_size": (2.5, 40), "crack_width": (0.4, 6), "line_width": (0.3, 10),
90
+ "spacing_x": (2.5, 40), "spacing_y": (2.5, 40), "ring_spacing": (2.5, 40), "dot_spacing": (2.5, 40),
91
+ "left_spacing": (2.5, 40), "right_spacing": (2.5, 40), "min_distance": (2.5, 40),
92
+ "offset_x": (-20, 20), "offset_y": (-20, 20), "rotation": (-180, 180), "row_rotation": (0, 180),
93
+ "left_angle": (0, 180), "right_angle": (0, 180), "seed": (1, 9999), "noise_seed": (1, 9999)}
94
+ GRAD_SOFT = {"size": (0, 2), "spacing": (0.3, 3), "angle": (-180, 180), "shape": (0, 1)}
95
+ GRAD_HARD = {"size": (0, 20), "spacing": (0, 20), "angle": (-360, 360), "shape": (0, 1)}
96
+ # Sliders mapped logarithmically (positive ranges spanning >~1 decade): finer control at small values.
97
+ LOG = {"canvas.width", "canvas.height", "canvas.thickness", "diameter", "aspect", "count", "spread", "cell_size",
98
+ "crack_width", "line_width", "wavelength", "noise_scale", "spacing_x", "spacing_y", "ring_spacing",
99
+ "dot_spacing", "left_spacing", "right_spacing", "min_distance", "layer_spacing", "line_spacing",
100
+ "gradients.spacing.edge", "gradients.spacing.value", "gradients.spacing.image_min",
101
+ "gradients.spacing.image_max", *(f"gradients.{g}.radius" for g in GRADS)}
102
+ # Ranges that follow the canvas: soft range * (sqrt(w*h) / 100 mm) ** exponent (counts fill an area or a path).
103
+ SCALE = {"boundary.corner_radius": 1, "pattern.random.count": 2, "pattern.path.count": 1,
104
+ "pattern.spiral.radius": 1, "pattern.spiral.inner_radius": 1, "pattern.spiral.count": 2,
105
+ "pattern.concentric.inner_radius": 1, "pattern.concentric.outer_radius": 1,
106
+ "pattern.fibonacci.count": 2, "pattern.fibonacci.inner_radius": 1,
107
+ **{f"gradients.{g}.radius": 1 for g in GRADS}}
108
+ ADVANCED = {"offset_x", "offset_y", "row_rotation", "rotate_by", "seed", "noise_seed", "noise_scale", "falloff",
109
+ "image_min", "image_max", "invert_image", "closed", "smooth", "even_spacing", "layer_spacing",
110
+ "stagger", "outer_radius", "inner_radius", "clip", "edge_style", "follow_boundary", "amplitude",
111
+ "wavelength", "thickness"}
112
+ LABELS.update({"diameter": "Hole size", "spacing_x": "Spacing", "spacing_y": "Row spacing", "edge": "Far value",
113
+ "count": "Number of holes", "line_width": "Slot width", "line_spacing": "Slot spacing",
114
+ "cell_size": "Cell size", "crack_width": "Wall width", "noise": "Randomness",
115
+ "enabled": "On", "type": "Shape", "star_inner": "Star depth", "aspect": "Stretch"})
116
+
117
+
118
+ def soft_range(path, name, lo, hi, canvas=None):
119
+ g = path.split(".")[1] if path.startswith("gradients.") and path.count(".") == 2 else None
120
+ if path in SOFT_PATH:
121
+ a, b = SOFT_PATH[path]
122
+ elif g in GRAD_SOFT and name in ("edge", "image_min", "image_max", "value"):
123
+ a, b = GRAD_SOFT[g]
124
+ elif name in SOFT:
125
+ a, b = SOFT[name]
126
+ elif any(w in name for w in MM_WORDS):
127
+ a, b = (1, 30)
128
+ else:
129
+ a, b = (max(lo, -10), min(hi, 10))
130
+ if canvas is not None and path in SCALE:
131
+ k = (max(canvas.width * canvas.height, 1.0) ** 0.5 / 100) ** SCALE[path]
132
+ a, b = a * k, b * k
133
+ return max(lo, a), min(hi, b)
134
+
135
+
136
+ def is_log(path, name):
137
+ return path in LOG or name in LOG
138
+
139
+
140
+ def meta(name, t, path=""):
141
+ g = path.split(".")[1] if path.startswith("gradients.") and path.count(".") == 2 else None
142
+ if g in GRAD_HARD and name in ("edge", "image_min", "image_max", "value"):
143
+ return (*GRAD_HARD[g], 0.01 if g != "angle" else 1, " °" if g == "angle" else "")
144
+ if name in META:
145
+ return META[name]
146
+ if t is int:
147
+ return (0, 999999, 1, "")
148
+ if "rotation" in name or "angle" in name:
149
+ return (-360, 360, 1, " °")
150
+ if name in ("wavelength", "noise_scale"): # tiny values freeze the generator (fine sampling)
151
+ return (2 if name == "wavelength" else 0.5, 2000, 0.5, " mm")
152
+ if name != "layer_spacing" and ("spacing" in name or name in ("min_distance", "cell_size")):
153
+ return (1, 2000, 0.1, " mm") # below ~1 mm the hole count explodes (seconds per regenerate)
154
+ if name == "min_wall":
155
+ return (0, 20, 0.1, " mm")
156
+ if any(w in name for w in MM_WORDS):
157
+ return (0, 2000, 0.1, " mm")
158
+ return (-1000, 1000, 0.05, "")
159
+
160
+
161
+ def slider_field(path, name, t, canvas=None):
162
+ lo, hi, step, suf = meta(name, t, path)
163
+ w = SliderField(lo, hi, step, suf, soft_range(path, name, lo, hi, canvas), t is int, is_log(path, name))
164
+ w.path, w.name = path, name # lets the window rescale canvas-relative ranges
165
+ return w
166
+
167
+
168
+ def label_for(name):
169
+ return LABELS.get(name, name.replace("_", " ").capitalize())
170
+
171
+
172
+ def getp(obj, path):
173
+ for k in path.split("."):
174
+ obj = obj[int(k)] if k.isdigit() else getattr(obj, k)
175
+ return obj
176
+
177
+
178
+ def setp(obj, path, v):
179
+ head, _, k = path.rpartition(".")
180
+ o = getp(obj, head) if head else obj
181
+ if k.isdigit():
182
+ o[int(k)] = v
183
+ else:
184
+ setattr(o, k, v)
185
+
186
+
187
+ def clone(d: Design) -> Design:
188
+ return Design.from_dict(json.loads(d.to_json()))
189
+
190
+
191
+ # ---------------------------------------------------------------- geometry -> Qt
192
+ def S(x, y): # model (y up) -> scene (y down)
193
+ return QPointF(x, -y)
194
+
195
+
196
+ def _add_geom(path, g):
197
+ for p in getattr(g, "geoms", [g]):
198
+ if p.is_empty or not hasattr(p, "exterior"):
199
+ continue
200
+ for ring in (p.exterior, *p.interiors):
201
+ path.addPolygon(QPolygonF([QPointF(x, -y) for x, y in ring.coords]))
202
+ path.closeSubpath()
203
+
204
+
205
+ def result_paths(r):
206
+ holes = QPainterPath()
207
+ holes.setFillRule(Qt.OddEvenFill)
208
+ circles = r.circles if len(r.circles) == len(r.holes) else [None] * len(r.holes)
209
+ for h, c in zip(r.holes, circles):
210
+ if c:
211
+ holes.addEllipse(S(c[0], c[1]), c[2], c[2])
212
+ else:
213
+ _add_geom(holes, h)
214
+ outline = QPainterPath()
215
+ outline.setFillRule(Qt.OddEvenFill)
216
+ _add_geom(outline, r.outline)
217
+ return holes, outline
218
+
219
+
220
+ def build(d: Design):
221
+ t0 = time.perf_counter()
222
+ r = pattern().generate(d)
223
+ holes, outline = result_paths(r)
224
+ return r, holes, outline, time.perf_counter() - t0
225
+
226
+
227
+ def paint_thumb(holes, outline, color, size=88):
228
+ pm = QPixmap(size, size)
229
+ pm.fill(QColor(40, 42, 46))
230
+ p = QPainter(pm)
231
+ p.setRenderHint(QPainter.Antialiasing)
232
+ b = outline.boundingRect()
233
+ if b.width() > 0 and b.height() > 0:
234
+ k = (size - 8) / max(b.width(), b.height())
235
+ p.translate(size / 2, size / 2)
236
+ p.scale(k, k)
237
+ p.translate(-b.center())
238
+ p.fillPath(outline, QColor(70, 72, 80))
239
+ p.fillPath(holes, QColor(color))
240
+ p.end()
241
+ return QIcon(pm)
242
+
243
+
244
+ _ICONS = {}
245
+
246
+
247
+ def choice_icons(path):
248
+ """Picture tiles for the pattern-mode and hole-shape pickers, rendered with the real generator."""
249
+ if path not in ("pattern.mode", "dot.shape"):
250
+ return None
251
+ if path not in _ICONS:
252
+ icons = {}
253
+ try:
254
+ opts = MODES if path == "pattern.mode" else SHAPES
255
+ for v in opts:
256
+ d = Design()
257
+ d.canvas.width = d.canvas.height = 60
258
+ d.boundary.margin = 1
259
+ if path == "pattern.mode":
260
+ d.pattern.mode, d.dot.diameter = v, 3.2
261
+ d.pattern.random.count, d.pattern.spiral.radius, d.pattern.spiral.count = 60, 28, 90
262
+ d.pattern.fibonacci.count, d.pattern.voronoi.cell_size = 110, 9
263
+ d.pattern.custom_points.points = [[math.cos(a) * 18, math.sin(a) * 18]
264
+ for a in (i * math.pi / 4 for i in range(8))] + [[0, 0]]
265
+ d.pattern.custom_points.sizes = [1.5] * 8 + [4]
266
+ else:
267
+ d.dot.shape, d.dot.diameter, d.dot.sides = v, 44, 5
268
+ d.dot.custom_points = [[0, 1], [0.3, 0.2], [1, 0], [0.3, -0.2], [0, -1], [-0.3, -0.2],
269
+ [-1, 0], [-0.3, 0.2]]
270
+ d.pattern.mode, d.pattern.custom_points.points = "custom_points", [[0, 0]]
271
+ icons[v] = paint_thumb(*result_paths(pattern().generate(d)), "#F7BB3B", size=64)
272
+ except Exception:
273
+ traceback.print_exc()
274
+ icons = {}
275
+ _ICONS[path] = icons
276
+ return _ICONS[path]
277
+
278
+
279
+ def catmull(pts):
280
+ """Smooth path through scene points (Catmull-Rom as cubic Beziers)."""
281
+ path = QPainterPath(pts[0])
282
+ if len(pts) < 3:
283
+ for q in pts[1:]:
284
+ path.lineTo(q)
285
+ return path
286
+ ext = [pts[0], *pts, pts[-1]]
287
+ for i in range(1, len(ext) - 2):
288
+ p0, p1, p2, p3 = ext[i - 1:i + 3]
289
+ path.cubicTo(p1 + (p2 - p0) / 6, p2 - (p3 - p1) / 6, p2)
290
+ return path
291
+
292
+
293
+ # ---------------------------------------------------------------- helpers widgets
294
+ class Task(QThread):
295
+ done = Signal(object)
296
+
297
+ def __init__(self, fn, cb):
298
+ super().__init__()
299
+ self.fn, self.cb, self.value, self.error = fn, cb, None, None
300
+
301
+ def run(self):
302
+ try:
303
+ self.value = self.fn()
304
+ except Exception:
305
+ self.error = traceback.format_exc()
306
+ self.done.emit(self)
307
+
308
+
309
+ class Section(QWidget):
310
+ """Collapsible group: clickable header + body."""
311
+
312
+ def __init__(self, title, body, open_=True, small=False):
313
+ super().__init__()
314
+ btn = QToolButton(text=title, checkable=True, checked=open_)
315
+ btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
316
+ btn.setProperty("section", "small" if small else "big")
317
+ btn.setSizePolicy(btn.sizePolicy().Policy.Expanding, btn.sizePolicy().Policy.Fixed)
318
+ arrow = lambda on: btn.setArrowType(Qt.DownArrow if on else Qt.RightArrow)
319
+ arrow(open_)
320
+ body.setVisible(open_)
321
+ btn.toggled.connect(lambda on: (body.setVisible(on), arrow(on)))
322
+ lay = QVBoxLayout(self)
323
+ lay.setContentsMargins(0, 0, 0, 0)
324
+ lay.addWidget(btn)
325
+ lay.addWidget(body)
326
+
327
+
328
+ class DragLabel(QLabel):
329
+ """Form label that scrubs its spin box when dragged horizontally."""
330
+
331
+ def __init__(self, text, spin):
332
+ super().__init__(text)
333
+ self.spin, self.x0, self.v0 = spin, None, 0
334
+ if isinstance(spin, (QSpinBox, QDoubleSpinBox)):
335
+ self.setCursor(Qt.SizeHorCursor)
336
+ self.setToolTip("Drag left/right to adjust")
337
+
338
+ def mousePressEvent(self, e):
339
+ if isinstance(self.spin, (QSpinBox, QDoubleSpinBox)):
340
+ self.x0, self.v0 = e.position().x(), self.spin.value()
341
+
342
+ def mouseMoveEvent(self, e):
343
+ if self.x0 is not None:
344
+ steps = round((e.position().x() - self.x0) / 4)
345
+ self.spin.setValue(type(self.v0)(self.v0 + steps * self.spin.singleStep()))
346
+
347
+ def mouseReleaseEvent(self, e):
348
+ self.x0 = None
349
+
350
+
351
+ def row(*ws):
352
+ w = QWidget()
353
+ h = QHBoxLayout(w)
354
+ h.setContentsMargins(0, 0, 0, 0)
355
+ for x in ws:
356
+ h.addWidget(x)
357
+ return w
358
+
359
+
360
+ def button(text, slot, tip=""):
361
+ b = QPushButton(text)
362
+ b.clicked.connect(slot)
363
+ b.setToolTip(tip)
364
+ return b
365
+
366
+
367
+ class WheelSafeSlider(QSlider):
368
+ def wheelEvent(self, e): # scrolling the panel must not change values the mouse happens to pass over
369
+ if self.hasFocus():
370
+ super().wheelEvent(e)
371
+ else:
372
+ e.ignore()
373
+
374
+
375
+ class SliderField(QWidget):
376
+ """Slider over a comfortable range + a small number box for exact / out-of-range values."""
377
+ valueChanged = Signal(object)
378
+ N = 1000
379
+
380
+ def __init__(self, lo, hi, step, suffix, soft, is_int, log=False):
381
+ super().__init__()
382
+ self.step, self.is_int, (self.a, self.b) = step, is_int, soft
383
+ self.log_wanted, self.log = log, log and 0 < self.a < self.b # log needs a positive range
384
+ self.sl = WheelSafeSlider(Qt.Horizontal)
385
+ self.sl.setRange(0, self.N)
386
+ self.sl.setFocusPolicy(Qt.StrongFocus)
387
+ self.sp = QSpinBox() if is_int else QDoubleSpinBox(decimals=3 if step < 0.1 else 2)
388
+ self.sp.setRange(lo, hi)
389
+ self.sp.setSingleStep(step)
390
+ self.sp.setSuffix(suffix)
391
+ self.sp.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
392
+ self.sp.setKeyboardTracking(False)
393
+ self.sp.setFixedWidth(80)
394
+ lay = QHBoxLayout(self)
395
+ lay.setContentsMargins(0, 0, 0, 0)
396
+ lay.addWidget(self.sl, 1)
397
+ lay.addWidget(self.sp)
398
+ self.sl.valueChanged.connect(self._from_slider)
399
+ self.sp.valueChanged.connect(self._from_spin)
400
+
401
+ def _pos(self, v):
402
+ if self.log:
403
+ t = math.log(max(v, self.a) / self.a) / math.log(self.b / self.a)
404
+ else:
405
+ t = (v - self.a) / ((self.b - self.a) or 1)
406
+ return round(min(1, max(0, t)) * self.N)
407
+
408
+ def _from_slider(self, i):
409
+ t = i / self.N
410
+ v = self.a * (self.b / self.a) ** t if self.log else self.a + t * (self.b - self.a)
411
+ if self.is_int:
412
+ v = round(v)
413
+ elif self.log: # ~3 significant digits, never coarser than the spin step
414
+ v = round(v, max(0, 2 - math.floor(math.log10(v)), -math.floor(math.log10(self.step))))
415
+ else:
416
+ v = round(round(v / self.step) * self.step, 4)
417
+ self.sp.blockSignals(True)
418
+ self.sp.setValue(v)
419
+ self.sp.blockSignals(False)
420
+ self.valueChanged.emit(self.sp.value())
421
+
422
+ def _from_spin(self, v):
423
+ self.sl.blockSignals(True)
424
+ self.sl.setValue(self._pos(v))
425
+ self.sl.blockSignals(False)
426
+ self.valueChanged.emit(v)
427
+
428
+ def value(self):
429
+ return self.sp.value()
430
+
431
+ def setValue(self, v):
432
+ self.sp.setValue(v)
433
+ self._from_spin(self.sp.value()) # keeps the slider in sync even when the value did not change
434
+
435
+ def set_soft(self, soft):
436
+ self.a, self.b = soft
437
+ self.log = self.log_wanted and 0 < self.a < self.b
438
+ self.sl.blockSignals(True)
439
+ self.sl.setValue(self._pos(self.sp.value()))
440
+ self.sl.blockSignals(False)
441
+
442
+
443
+ class Picker(QWidget):
444
+ """Exclusive button choice: picture tiles (icons given) or a segmented text bar."""
445
+ valueChanged = Signal(object)
446
+
447
+ def __init__(self, options, cols=None, icon=56):
448
+ super().__init__()
449
+ self.group, self.btns = QButtonGroup(self), {}
450
+ grid = QGridLayout(self)
451
+ grid.setContentsMargins(0, 0, 0, 0)
452
+ grid.setSpacing(4)
453
+ cols = cols or len(options)
454
+ for i, (val, text, ic) in enumerate(options):
455
+ b = QToolButton(text=text, checkable=True)
456
+ b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
457
+ if ic is not None:
458
+ b.setIcon(ic)
459
+ b.setIconSize(QSize(icon, icon))
460
+ b.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
461
+ b.setProperty("tile", True)
462
+ else:
463
+ b.setProperty("seg", True)
464
+ self.group.addButton(b, i)
465
+ self.btns[val] = b
466
+ grid.addWidget(b, i // cols, i % cols)
467
+ self.vals = [o[0] for o in options]
468
+ self.group.idClicked.connect(lambda i: self.valueChanged.emit(self.vals[i]))
469
+
470
+ def value(self):
471
+ return self.vals[max(0, self.group.checkedId())]
472
+
473
+ def setValue(self, v):
474
+ if v in self.btns:
475
+ self.btns[v].setChecked(True)
476
+
477
+
478
+ class ZoomBar(QWidget):
479
+ """Floating zoom controls in the canvas corner."""
480
+
481
+ def __init__(self, canvas):
482
+ super().__init__(canvas.viewport())
483
+ self.setObjectName("zoombar")
484
+ self.setAttribute(Qt.WA_StyledBackground)
485
+ lay = QHBoxLayout(self)
486
+ lay.setContentsMargins(4, 4, 4, 4)
487
+ lay.setSpacing(2)
488
+ self.pct = QLabel("100%")
489
+ self.pct.setAlignment(Qt.AlignCenter)
490
+ self.pct.setFixedWidth(52)
491
+ for text, tip, slot in (("−", "Zoom out (-)", lambda: canvas.zoom_by(1 / 1.25)), (None, "", None),
492
+ ("+", "Zoom in (+)", lambda: canvas.zoom_by(1.25)),
493
+ ("Fit", "Fit plate to view (Z or double-click)", canvas.zoom_fit)):
494
+ if text is None:
495
+ lay.addWidget(self.pct)
496
+ continue
497
+ b = QToolButton(text=text)
498
+ b.setToolTip(tip)
499
+ b.clicked.connect(slot)
500
+ b.setFixedHeight(28)
501
+ b.setMinimumWidth(30)
502
+ lay.addWidget(b)
503
+
504
+
505
+ # ---------------------------------------------------------------- canvas
506
+ class Handle(QGraphicsEllipseItem):
507
+ def __init__(self, canvas, ci, pi):
508
+ super().__init__(-6, -6, 12, 12)
509
+ self.canvas, self.ci, self.pi = canvas, ci, pi
510
+ self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIgnoresTransformations
511
+ | QGraphicsItem.ItemSendsGeometryChanges)
512
+ self.setZValue(10)
513
+ self.setCursor(Qt.SizeAllCursor)
514
+
515
+ def itemChange(self, change, value):
516
+ if change == QGraphicsItem.ItemPositionHasChanged and not self.canvas.syncing:
517
+ self.canvas.win.move_point(self.ci, self.pi, value.x(), -value.y())
518
+ return super().itemChange(change, value)
519
+
520
+ def mousePressEvent(self, e):
521
+ self.canvas.win.select_control(self.ci, from_canvas=True)
522
+ super().mousePressEvent(e)
523
+
524
+
525
+ class Canvas(QGraphicsView):
526
+ def __init__(self, win):
527
+ super().__init__()
528
+ self.win = win
529
+ sc = QGraphicsScene(self)
530
+ sc.setSceneRect(-20000, -20000, 40000, 40000)
531
+ self.setScene(sc)
532
+ self.setRenderHint(QPainter.Antialiasing)
533
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
534
+ self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) # scale bar is drawn in viewport space
535
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
536
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
537
+ self.setFocusPolicy(Qt.StrongFocus)
538
+ self.plate = sc.addPath(QPainterPath(), QPen(QColor(170, 170, 180), 0), QBrush(QColor(62, 64, 72)))
539
+ self.bg = QGraphicsPixmapItem()
540
+ self.bg.setZValue(1)
541
+ sc.addItem(self.bg)
542
+ self.holes = sc.addPath(QPainterPath(), QPen(Qt.NoPen), QBrush(QColor("#F7BB3B")))
543
+ self.holes.setZValue(2)
544
+ dash = QPen(QColor(120, 200, 255), 0, Qt.DashLine)
545
+ self.decor = sc.addPath(QPainterPath(), dash)
546
+ self.decor.setZValue(3)
547
+ self.draft = sc.addPath(QPainterPath(), QPen(QColor(255, 120, 120), 0))
548
+ self.draft.setZValue(4)
549
+ self.ruler = sc.addLine(0, 0, 0, 0, QPen(QColor(255, 255, 255), 0))
550
+ self.ruler.setZValue(5)
551
+ self.ruler_txt = sc.addSimpleText("")
552
+ self.ruler_txt.setBrush(QColor("white"))
553
+ self.ruler_txt.setFlag(QGraphicsItem.ItemIgnoresTransformations)
554
+ self.ruler_txt.setZValue(6)
555
+ self.handles, self.syncing, self.tool = [], False, None
556
+ self.pan_pos, self.space, self.ruler_start, self.draft_pts = None, False, None, []
557
+ self.scale(4, 4)
558
+ self.fit_k, self.info = 4.0, ""
559
+ self.zoombar = ZoomBar(self)
560
+ self.viewport().setCursor(Qt.OpenHandCursor)
561
+
562
+ def resizeEvent(self, e):
563
+ super().resizeEvent(e)
564
+ self.zoombar.adjustSize()
565
+ vp = self.viewport()
566
+ self.zoombar.move(vp.width() - self.zoombar.width() - 12, vp.height() - self.zoombar.height() - 12)
567
+
568
+ def zoom_by(self, f, anchor_mouse=False):
569
+ k = self.transform().m11()
570
+ f = min(max(f, 0.05 / k), 2000 / k)
571
+ if not anchor_mouse:
572
+ self.setTransformationAnchor(QGraphicsView.AnchorViewCenter)
573
+ self.scale(f, f)
574
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
575
+ self.zoombar.pct.setText(f"{100 * self.transform().m11() / self.fit_k:.0f}%")
576
+
577
+ # --- content
578
+ def set_result(self, holes, outline, color):
579
+ self.holes.setPath(holes)
580
+ self.holes.setBrush(QColor(color))
581
+ self.plate.setPath(outline)
582
+
583
+ def set_background(self, pm, w, h, opacity):
584
+ self.bg.setVisible(pm is not None)
585
+ if pm is not None:
586
+ self.bg.setPixmap(pm)
587
+ self.bg.setTransform(QTransform.fromScale(w / pm.width(), h / pm.height()))
588
+ self.bg.setPos(-w / 2, -h / 2)
589
+ self.bg.setOpacity(opacity)
590
+
591
+ def set_handles(self, controls, sel):
592
+ for h in self.handles:
593
+ self.scene().removeItem(h)
594
+ self.handles = []
595
+ self.syncing = True
596
+ for ci, c in enumerate(controls):
597
+ for pi, (x, y) in enumerate(c.points):
598
+ h = Handle(self, ci, pi)
599
+ h.setPos(S(x, y))
600
+ self.scene().addItem(h)
601
+ self.handles.append(h)
602
+ self.syncing = False
603
+ self.update_decor(controls, sel)
604
+
605
+ def update_decor(self, controls, sel):
606
+ path = QPainterPath()
607
+ for c in controls:
608
+ pts = [S(x, y) for x, y in c.points]
609
+ if not pts:
610
+ continue
611
+ if c.kind == "point" or len(pts) == 1:
612
+ path.addEllipse(pts[0], c.radius, c.radius)
613
+ continue
614
+ spine = catmull(pts) if c.kind == "curve" else QPainterPath(pts[0])
615
+ if c.kind != "curve":
616
+ for q in pts[1:]:
617
+ spine.lineTo(q)
618
+ st = QPainterPathStroker()
619
+ st.setWidth(2 * c.radius)
620
+ st.setCapStyle(Qt.RoundCap)
621
+ path.addPath(spine)
622
+ path.addPath(st.createStroke(spine).simplified())
623
+ self.decor.setPath(path)
624
+ for h in self.handles:
625
+ on = h.ci == sel
626
+ h.setBrush(QColor(255, 210, 80) if on else QColor(120, 200, 255))
627
+ h.setPen(QPen(QColor("white" if on else "black"), 1.5))
628
+
629
+ def zoom_fit(self):
630
+ r = self.plate.path().boundingRect()
631
+ if r.width() > 0:
632
+ self.fitInView(r.adjusted(-r.width() * .06, -r.height() * .06, r.width() * .06, r.height() * .06),
633
+ Qt.KeepAspectRatio)
634
+ self.fit_k = self.transform().m11()
635
+ self.zoom_by(1)
636
+
637
+ # --- painting
638
+ def drawBackground(self, p, rect):
639
+ p.fillRect(rect, QColor(30, 31, 34))
640
+ k = self.transform().m11()
641
+ for step, col in ((1, QColor(40, 41, 45)), (10, QColor(52, 54, 60))):
642
+ if step * k < 6:
643
+ continue
644
+ p.setPen(QPen(col, 0))
645
+ x = math.floor(rect.left() / step) * step
646
+ while x < rect.right():
647
+ p.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
648
+ x += step
649
+ y = math.floor(rect.top() / step) * step
650
+ while y < rect.bottom():
651
+ p.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
652
+ y += step
653
+
654
+ def drawForeground(self, p, rect):
655
+ k = self.transform().m11() # px per mm
656
+ mm = next((v for v in (0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000) if v * k >= 60), 1000)
657
+ p.save()
658
+ p.resetTransform()
659
+ x0, y0 = 16, self.viewport().height() - 18
660
+ p.setPen(QPen(QColor(230, 230, 230), 2))
661
+ p.drawLine(x0, y0, x0 + mm * k, y0)
662
+ p.drawLine(x0, y0 - 5, x0, y0 + 3)
663
+ p.drawLine(int(x0 + mm * k), y0 - 5, int(x0 + mm * k), y0 + 3)
664
+ p.drawText(x0, y0 - 7, f"{mm:g} mm")
665
+ if self.info: # headline stats, readable at a glance
666
+ f = p.font()
667
+ f.setPointSizeF(f.pointSizeF() * 1.35)
668
+ f.setBold(True)
669
+ p.setFont(f)
670
+ p.setPen(QColor(235, 235, 235))
671
+ p.drawText(16, 30, self.info)
672
+ p.restore()
673
+
674
+ # --- interaction
675
+ def wheelEvent(self, e):
676
+ self.zoom_by(1.15 ** (e.angleDelta().y() / 120), anchor_mouse=True)
677
+
678
+ def set_tool(self, tool):
679
+ self.tool = tool
680
+ self.ruler.setVisible(tool == "ruler")
681
+ self.ruler_txt.setVisible(tool == "ruler")
682
+ self.draft_pts = []
683
+ self.draft.setPath(QPainterPath())
684
+ self.viewport().setCursor(Qt.CrossCursor if tool else Qt.OpenHandCursor)
685
+ if tool:
686
+ self.setFocus()
687
+
688
+ def mousePressEvent(self, e):
689
+ on_handle = isinstance(self.itemAt(e.position().toPoint()), Handle)
690
+ b = e.button()
691
+ # Drag empty space to pan (left or right without a tool, middle / space always)
692
+ if (b == Qt.MiddleButton or (b == Qt.LeftButton and self.space)
693
+ or (self.tool is None and b in (Qt.LeftButton, Qt.RightButton) and not on_handle)):
694
+ self.pan_pos = e.position()
695
+ self.viewport().setCursor(Qt.ClosedHandCursor)
696
+ return
697
+ pos = self.mapToScene(e.position().toPoint())
698
+ if self.tool == "ruler" and e.button() == Qt.LeftButton:
699
+ self.ruler_start = pos
700
+ self._ruler(pos)
701
+ elif self.tool == "draw":
702
+ if e.button() == Qt.RightButton:
703
+ self.finish_draw()
704
+ elif e.button() == Qt.LeftButton:
705
+ self.draft_pts.append(pos)
706
+ self._draft(pos)
707
+ else:
708
+ super().mousePressEvent(e)
709
+
710
+ def mouseMoveEvent(self, e):
711
+ if self.pan_pos is not None:
712
+ d = e.position() - self.pan_pos
713
+ self.pan_pos = e.position()
714
+ k = self.transform().m11()
715
+ self.setTransformationAnchor(QGraphicsView.NoAnchor)
716
+ self.translate(d.x() / k, d.y() / k)
717
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
718
+ return
719
+ pos = self.mapToScene(e.position().toPoint())
720
+ if self.tool == "ruler" and self.ruler_start is not None and e.buttons() & Qt.LeftButton:
721
+ self._ruler(pos)
722
+ elif self.tool == "draw" and self.draft_pts:
723
+ self._draft(pos)
724
+ else:
725
+ super().mouseMoveEvent(e)
726
+
727
+ def mouseReleaseEvent(self, e):
728
+ if self.pan_pos is not None:
729
+ self.pan_pos = None
730
+ self.viewport().setCursor(Qt.CrossCursor if self.tool else Qt.OpenHandCursor)
731
+ return
732
+ super().mouseReleaseEvent(e)
733
+ if self.tool is None:
734
+ self.win.commit() # end of a handle drag
735
+
736
+ def mouseDoubleClickEvent(self, e):
737
+ if self.tool == "draw":
738
+ self.finish_draw()
739
+ elif self.tool is None and not isinstance(self.itemAt(e.position().toPoint()), Handle):
740
+ self.zoom_fit()
741
+ else:
742
+ super().mouseDoubleClickEvent(e)
743
+
744
+ def keyPressEvent(self, e):
745
+ if e.key() == Qt.Key_Space and not e.isAutoRepeat():
746
+ self.space = True
747
+ elif e.key() in (Qt.Key_Plus, Qt.Key_Equal, Qt.Key_Minus):
748
+ self.zoom_by(1.25 if e.key() != Qt.Key_Minus else 1 / 1.25)
749
+ elif self.tool == "draw" and e.key() in (Qt.Key_Return, Qt.Key_Enter):
750
+ self.finish_draw()
751
+ elif self.tool and e.key() == Qt.Key_Escape:
752
+ self.win.set_tool(None)
753
+ else:
754
+ super().keyPressEvent(e)
755
+
756
+ def keyReleaseEvent(self, e):
757
+ if e.key() == Qt.Key_Space and not e.isAutoRepeat():
758
+ self.space = False
759
+ super().keyReleaseEvent(e)
760
+
761
+ def _ruler(self, pos):
762
+ a = self.ruler_start
763
+ self.ruler.setLine(a.x(), a.y(), pos.x(), pos.y())
764
+ d = math.hypot(pos.x() - a.x(), pos.y() - a.y())
765
+ ang = math.degrees(math.atan2(a.y() - pos.y(), pos.x() - a.x()))
766
+ self.ruler_txt.setText(f"{d:.2f} mm {ang:.1f}°")
767
+ self.ruler_txt.setPos(pos + QPointF(2 / self.transform().m11(), 0))
768
+ self.win.statusBar().showMessage(f"Distance {d:.2f} mm, dx {pos.x() - a.x():.2f}, dy {a.y() - pos.y():.2f}")
769
+
770
+ def _draft(self, cursor):
771
+ path = QPainterPath(self.draft_pts[0])
772
+ for q in self.draft_pts[1:] + [cursor]:
773
+ path.lineTo(q)
774
+ self.draft.setPath(path)
775
+
776
+ def finish_draw(self):
777
+ pts = [[round(p.x(), 3), round(-p.y(), 3)] for p in self.draft_pts]
778
+ # a double-click also delivers a press: drop the duplicate final point
779
+ while len(pts) > 1 and math.dist(pts[-1], pts[-2]) < 1e-6:
780
+ pts.pop()
781
+ self.win.set_tool(None)
782
+ if len(pts) >= 3:
783
+ self.win.set_custom_boundary(pts)
784
+
785
+
786
+ # ---------------------------------------------------------------- main window
787
+ class MainWindow(QMainWindow):
788
+ def __init__(self, path=None):
789
+ super().__init__()
790
+ self.resize(1500, 920)
791
+ self.settings = QSettings("VentForge", "VentForge")
792
+ self.design, self.path, self.dirty, self.syncing = Design(), None, False, False
793
+ self.hist, self.hpos = [self.design.to_json()], 0
794
+ self.bindings, self.tasks, self.gen_task, self.gen_pending = [], set(), None, False
795
+ self.saved_json, self.result, self.bg_key, self.need_fit = None, None, None, True
796
+ self.thumb_queue = []
797
+ self.canvas = Canvas(self)
798
+ self.setCentralWidget(self.canvas)
799
+ self.regen_timer = QTimer(self, singleShot=True, interval=40, timeout=self.regen_now)
800
+ self.commit_timer = QTimer(self, singleShot=True, interval=400, timeout=self.commit)
801
+ self.disk_timer = QTimer(self, singleShot=True, interval=120, timeout=self.check_disk)
802
+ self.watcher = QFileSystemWatcher(self)
803
+ self.watcher.fileChanged.connect(lambda _: self.disk_timer.start())
804
+ self.watcher.directoryChanged.connect(lambda _: self.disk_timer.start())
805
+ self._toolbar()
806
+ self._left_dock()
807
+ self._right_dock()
808
+ self._statusbar()
809
+ self.set_design(Design(), record=False)
810
+ if path:
811
+ self.open_file(path)
812
+ self.refresh_library()
813
+ self.update_title()
814
+
815
+ # ---------------- UI construction
816
+ def _toolbar(self):
817
+ tb = self.addToolBar("Main")
818
+ tb.setObjectName("main")
819
+ tb.setMovable(False)
820
+
821
+ tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
822
+ tb.setIconSize(QSize(18, 18))
823
+ icons = {"New": QStyle.SP_FileIcon, "Open": QStyle.SP_DialogOpenButton, "Save": QStyle.SP_DialogSaveButton,
824
+ "Undo": QStyle.SP_ArrowBack, "Redo": QStyle.SP_ArrowForward, "Reset": QStyle.SP_BrowserReload}
825
+
826
+ def act(text, slot, key=None, checkable=False):
827
+ a = QAction(text, self, checkable=checkable)
828
+ if text in icons:
829
+ a.setIcon(self.style().standardIcon(icons[text]))
830
+ a.triggered.connect(slot)
831
+ if key:
832
+ a.setShortcut(QKeySequence(key))
833
+ a.setToolTip(f"{text} ({QKeySequence(key).toString()})")
834
+ tb.addAction(a)
835
+ return a
836
+
837
+ act("New", self.new, QKeySequence.New)
838
+ act("Open", self.open_dialog, QKeySequence.Open)
839
+ act("Save", self.save, QKeySequence.Save)
840
+ act("Save As", self.save_as, "Ctrl+Shift+S")
841
+ tb.addSeparator()
842
+ self.undo_act = act("Undo", self.undo, QKeySequence.Undo)
843
+ self.redo_act = act("Redo", self.redo, "Ctrl+Y")
844
+ tb.addSeparator()
845
+ act("Reset", self.reset, None).setToolTip("Reset all parameters to defaults")
846
+ act("Randomize", self.randomize, None).setToolTip("Randomize the current mode's parameters")
847
+ tb.addSeparator()
848
+ self.ruler_act = act("Ruler", lambda on: self.set_tool("ruler" if on else None), "R", True)
849
+ self.draw_act = act("Draw outline", lambda on: self.set_tool("draw" if on else None), "B", True)
850
+ act("Zoom to fit", self.canvas.zoom_fit, "Z")
851
+ tb.addSeparator()
852
+ menu_btn = QToolButton(text=" Export ", popupMode=QToolButton.InstantPopup)
853
+ menu_btn.setObjectName("export")
854
+ m = self.menuBar().addMenu("&Export")
855
+ for ext in (".png", ".svg", ".dxf", ".step", ".stl"):
856
+ m.addAction(ext[1:].upper() + "…", lambda e=ext: self.export_as(e))
857
+ m.addSeparator()
858
+ m.addAction("Wrap onto cylinder…", self.wrap_dialog)
859
+ m.addAction("Cut into part…", self.cut_dialog)
860
+ menu_btn.setMenu(m)
861
+ tb.addWidget(menu_btn)
862
+
863
+ def _dock(self, title, sections, area):
864
+ body = QWidget()
865
+ lay = QVBoxLayout(body)
866
+ lay.setContentsMargins(6, 6, 6, 6)
867
+ for s in sections:
868
+ lay.addWidget(s)
869
+ lay.addStretch(1)
870
+ sa = QScrollArea()
871
+ sa.setWidgetResizable(True)
872
+ sa.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
873
+ sa.setWidget(body)
874
+ d = QDockWidget(title, self)
875
+ d.setObjectName(title)
876
+ d.setWidget(sa)
877
+ d.setMinimumWidth(380)
878
+ self.addDockWidget(area, d)
879
+
880
+ def bind(self, w, path):
881
+ self.bindings.append((path, w))
882
+ return w
883
+
884
+ def field_widget(self, path, name, t):
885
+ if t is bool:
886
+ w = QCheckBox()
887
+ w.toggled.connect(lambda v, p=path: self.set_param(p, bool(v)))
888
+ elif t in (int, float):
889
+ w = slider_field(path, name, t, self.design.canvas)
890
+ w.valueChanged.connect(lambda v, p=path: self.set_param(p, v))
891
+ elif t is str and (path in CHOICES or name in CHOICES):
892
+ opts = CHOICES.get(path) or CHOICES[name]
893
+ icons = choice_icons(path)
894
+ if icons or len(opts) <= 4:
895
+ w = Picker([(c, c.replace("_", " ").replace("rounded rectangle", "rounded").capitalize(),
896
+ icons.get(c) if icons else None) for c in opts],
897
+ cols=(5 if path == "pattern.mode" else 3) if icons else None)
898
+ w.valueChanged.connect(lambda v, p=path: self.set_param(p, v))
899
+ else:
900
+ w = QComboBox()
901
+ for c in opts:
902
+ w.addItem(c.replace("_", " "), c)
903
+ w.currentIndexChanged.connect(lambda i, p=path, w=w: self.set_param(p, w.itemData(i)))
904
+ elif t is str:
905
+ w = QLineEdit()
906
+ w.editingFinished.connect(lambda p=path, w=w: self.set_param(p, w.text()))
907
+ else: # point lists: edited on the canvas / via file or MCP
908
+ w = QLabel()
909
+ return self.bind(w, path)
910
+
911
+ def form(self, path, cls, skip=(), only=None):
912
+ """Basic fields in the returned form; rarely needed ones (ADVANCED) in a folded 'More options'."""
913
+ def mk():
914
+ f = QFormLayout()
915
+ f.setContentsMargins(4, 2, 4, 2)
916
+ f.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
917
+ f.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter)
918
+ return f
919
+ w = QWidget()
920
+ outer = QVBoxLayout(w)
921
+ outer.setContentsMargins(0, 0, 0, 0)
922
+ f, adv = mk(), mk()
923
+ outer.addLayout(f)
924
+ hints = get_type_hints(cls)
925
+ rows = {}
926
+ for fl in fields(cls):
927
+ if fl.name in skip or (only and fl.name not in only) or is_dataclass(hints[fl.name]):
928
+ continue
929
+ t = getattr(hints[fl.name], "__origin__", hints[fl.name])
930
+ fw = self.field_widget(f"{path}.{fl.name}" if path else fl.name, fl.name, t)
931
+ target = adv if fl.name in ADVANCED else f
932
+ if isinstance(fw, Picker) and fw.btns and next(iter(fw.btns.values())).property("tile"):
933
+ target.addRow(QLabel(label_for(fl.name)))
934
+ target.addRow(fw) # picture tiles get the full width
935
+ else:
936
+ target.addRow(QLabel(label_for(fl.name)), fw)
937
+ rows[fl.name] = fw
938
+ if adv.rowCount():
939
+ body = QWidget()
940
+ body.setLayout(adv)
941
+ outer.addWidget(Section("More options", body, False, small=True))
942
+ return w, f, rows
943
+
944
+ def _left_dock(self):
945
+ # library
946
+ lib = QWidget()
947
+ v = QVBoxLayout(lib)
948
+ self.lib_list = QListWidget(viewMode=QListWidget.IconMode, movement=QListWidget.Static,
949
+ resizeMode=QListWidget.Adjust, wordWrap=True)
950
+ self.lib_list.setIconSize(QSize(88, 88))
951
+ self.lib_list.setGridSize(QSize(104, 116))
952
+ self.lib_list.setMinimumHeight(260)
953
+ self.lib_list.itemClicked.connect(self.library_clicked)
954
+ self.lib_folder = Path(self.settings.value("library", str(Path.home() / "Documents" / "VentForge")))
955
+ self.lib_label = QLabel(wordWrap=True)
956
+ v.addWidget(self.lib_list)
957
+ v.addWidget(self.lib_label)
958
+ v.addWidget(row(button("Save to library", self.save_to_library),
959
+ button("Folder…", self.pick_library_folder), button("↻", self.refresh_library, "Refresh")))
960
+ # canvas
961
+ cw, cf, _ = self.form("canvas", type(self.design.canvas))
962
+ self.link_cb = QCheckBox("Link width/height (keep aspect)")
963
+ cf.addRow(self.link_cb)
964
+ name = self.field_widget("name", "name", str)
965
+ cf.insertRow(0, "Name", name)
966
+ mw = self.field_widget("min_wall", "min_wall", float)
967
+ cf.addRow(DragLabel("Min wall", mw), mw)
968
+ # boundary
969
+ bw, bf, _ = self.form("boundary", type(self.design.boundary))
970
+ bf.addRow(row(button("Draw outline", lambda: self.draw_act.trigger(),
971
+ "Click points on the canvas; double-click, Enter or right-click to finish"),
972
+ button("Clear outline", lambda: self.set_param("boundary.points", []))))
973
+ # background
974
+ gw = QWidget()
975
+ gf = QFormLayout(gw)
976
+ self.bg_label = QLabel(wordWrap=True)
977
+ op = self.field_widget("background.opacity", "opacity", float)
978
+ gf.addRow(self.bg_label)
979
+ gf.addRow(DragLabel("Opacity", op), op)
980
+ gf.addRow(row(button("Load image…", self.load_background),
981
+ button("Clear", lambda: self.set_param("background.image", ""))))
982
+ self._dock("Design", [Section("Library", lib), Section("Canvas", cw), Section("Boundary", bw),
983
+ Section("Background image", gw, False)], Qt.LeftDockWidgetArea)
984
+
985
+ def _right_dock(self):
986
+ self.grad_tabs = QTabWidget()
987
+ self.gw = {}
988
+ for g in GRADS:
989
+ w, f, rows = self.form(f"gradients.{g}", Gradient, skip=("controls", "image"))
990
+ gw = self.gw[g] = {}
991
+ f.insertRow(4, "", button("Reseed noise", lambda _=0, g=g: self.set_param(
992
+ f"gradients.{g}.noise_seed", random.randint(1, 99999))))
993
+ gw["img"] = QLabel(wordWrap=True)
994
+ f.addRow("Image", gw["img"])
995
+ f.addRow(row(button("Load image…", lambda _=0, g=g: self.load_grad_image(g)),
996
+ button("Clear", lambda _=0, g=g: self.set_param(f"gradients.{g}.image", ""))))
997
+ lst = gw["list"] = QListWidget()
998
+ lst.setMaximumHeight(110)
999
+ lst.currentRowChanged.connect(lambda i, g=g: self.select_control(i))
1000
+ f.addRow(QLabel("<b>Controls</b>"))
1001
+ f.addRow(lst)
1002
+ f.addRow(row(*(button(k.capitalize(), lambda _=0, g=g, k=k: self.add_control(g, k), f"Add {k}")
1003
+ for k in ("point", "line", "curve")),
1004
+ button("Delete", lambda _=0, g=g: self.delete_control(g))))
1005
+ for key, label in (("value", "Strength at control"), ("radius", "Reach")):
1006
+ sp = gw[key] = slider_field(f"gradients.{g}.{key}", key, float, self.design.canvas)
1007
+ sp.valueChanged.connect(lambda v, g=g, key=key: self.set_control_attr(g, key, v))
1008
+ f.addRow(QLabel(label), sp)
1009
+ if g == "shape":
1010
+ for n in ("target_shape", "target_sides"):
1011
+ fw = self.field_widget(f"gradients.{n}", n, str if n == "target_shape" else int)
1012
+ f.addRow(DragLabel(label_for(n), fw), fw)
1013
+ self.grad_tabs.addTab(w, g.capitalize())
1014
+ self.grad_tabs.currentChanged.connect(lambda _: self.refresh_handles())
1015
+ # dot style
1016
+ dw, self.dot_form, self.dot_rows = self.form("dot", DotStyle, skip=("custom_points", "color"))
1017
+ self.color_btn = button("", self.pick_color, "Hole display colour")
1018
+ self.dot_form.addRow("Color", self.color_btn)
1019
+ # pattern
1020
+ pw = QWidget()
1021
+ pv = QVBoxLayout(pw)
1022
+ mode = self.field_widget("pattern.mode", "mode", str)
1023
+ self.mode_stack = QStackedWidget()
1024
+ hints = get_type_hints(Pattern)
1025
+ for m in MODES:
1026
+ w, f, rows = self.form(f"pattern.{m}", hints[m])
1027
+ if m == "grid":
1028
+ self.grid_form, self.grid_rows = f, rows
1029
+ self.mode_stack.addWidget(w)
1030
+ pv.addWidget(mode)
1031
+ pv.addWidget(self.mode_stack)
1032
+ self._dock("Pattern", [Section("Pattern mode", pw), Section("Dot style", dw),
1033
+ Section("Gradients", self.grad_tabs)], Qt.RightDockWidgetArea)
1034
+
1035
+ def _statusbar(self):
1036
+ sb = self.statusBar()
1037
+ self.st = {k: QLabel() for k in ("count", "open", "viol", "time")}
1038
+ self.busy_bar = QProgressBar(maximum=0, maximumWidth=120, visible=False)
1039
+ sb.addPermanentWidget(self.busy_bar)
1040
+ for w in self.st.values():
1041
+ sb.addPermanentWidget(w)
1042
+
1043
+ # ---------------- design state
1044
+ def sync_widgets(self):
1045
+ self.syncing = True
1046
+ for path, w in self.bindings:
1047
+ v = getp(self.design, path)
1048
+ w.blockSignals(True)
1049
+ if isinstance(w, QCheckBox):
1050
+ w.setChecked(bool(v))
1051
+ elif isinstance(w, QComboBox):
1052
+ w.setCurrentIndex(max(0, w.findData(v)))
1053
+ elif isinstance(w, (QSpinBox, QDoubleSpinBox, SliderField, Picker)):
1054
+ w.setValue(v)
1055
+ elif isinstance(w, QLineEdit):
1056
+ w.setText(str(v))
1057
+ elif isinstance(w, QLabel):
1058
+ w.setText(f"{len(v)} point(s) — edit via file / MCP" if v else "none (default)")
1059
+ w.blockSignals(False)
1060
+ for w in self.findChildren(SliderField): # canvas-relative slider ranges
1061
+ if getattr(w, "path", None) in SCALE:
1062
+ w.set_soft(soft_range(w.path, w.name, w.sp.minimum(), w.sp.maximum(), self.design.canvas))
1063
+ for g in GRADS:
1064
+ self.refresh_control_list(g)
1065
+ self.syncing = False
1066
+ self.refresh_aux()
1067
+
1068
+ def refresh_aux(self):
1069
+ d = self.design
1070
+ for n, w in self.dot_rows.items():
1071
+ if n not in ("shape", "diameter", "rotation"):
1072
+ self.dot_form.setRowVisible(w, n in DOT_VIS.get(d.dot.shape, ()))
1073
+ self.color_btn.setText(d.dot.color)
1074
+ self.color_btn.setStyleSheet(f"background:{d.dot.color};color:black")
1075
+ self.mode_stack.setCurrentIndex(MODES.index(d.pattern.mode) if d.pattern.mode in MODES else 0)
1076
+ # hexagon pitch is derived from spacing_x alone
1077
+ self.grid_form.setRowVisible(self.grid_rows["spacing_y"], d.pattern.grid.arrangement != "hexagon")
1078
+ self.holes_color()
1079
+ for g in GRADS:
1080
+ img = getattr(d.gradients, g).image
1081
+ self.gw[g]["img"].setText(Path(img).name if img else "none")
1082
+ self.bg_label.setText(Path(d.background.image).name if d.background.image else "No image")
1083
+ key = (d.background.image, d.canvas.width, d.canvas.height, d.background.opacity)
1084
+ if key != self.bg_key:
1085
+ self.bg_key = key
1086
+ pm = QPixmap(self.resolve(d.background.image)) if d.background.image else None
1087
+ self.canvas.set_background(pm if pm and not pm.isNull() else None, d.canvas.width,
1088
+ d.canvas.height, d.background.opacity)
1089
+
1090
+ def holes_color(self):
1091
+ self.canvas.holes.setBrush(QColor(self.design.dot.color))
1092
+
1093
+ def resolve(self, p):
1094
+ q = Path(p)
1095
+ return str(q if q.is_absolute() or not self.path else Path(self.path).parent / q)
1096
+
1097
+ def set_param(self, path, v):
1098
+ if self.syncing:
1099
+ return
1100
+ c = self.design.canvas
1101
+ linked = self.link_cb.isChecked() and path in ("canvas.width", "canvas.height") and c.width and c.height
1102
+ if linked:
1103
+ r = c.height / c.width
1104
+ if path == "canvas.width":
1105
+ c.height = round(v * r, 3)
1106
+ else:
1107
+ c.width = round(v / r, 3)
1108
+ setp(self.design, path, v)
1109
+ self.changed(sync=linked or path.startswith("canvas.") or path.endswith(("noise_seed", ".points")))
1110
+
1111
+ def changed(self, sync=False):
1112
+ if sync:
1113
+ self.sync_widgets()
1114
+ else:
1115
+ self.refresh_aux()
1116
+ self.dirty = True
1117
+ self.update_title()
1118
+ self.regen_timer.start()
1119
+ self.commit_timer.start()
1120
+ g = self.cur_grad()
1121
+ self.canvas.update_decor(g.controls, self.gw[self.grad_name()]["list"].currentRow())
1122
+
1123
+ def set_design(self, d, record=True):
1124
+ self.design = d
1125
+ self.sync_widgets()
1126
+ self.refresh_handles()
1127
+ self.regen_now()
1128
+ if record:
1129
+ self.commit_timer.stop()
1130
+ self.commit()
1131
+
1132
+ def commit(self):
1133
+ j = self.design.to_json()
1134
+ if j != self.hist[self.hpos]:
1135
+ del self.hist[self.hpos + 1:]
1136
+ self.hist.append(j)
1137
+ self.hist = self.hist[-300:]
1138
+ self.hpos = len(self.hist) - 1
1139
+ self.autosave()
1140
+
1141
+ def autosave(self):
1142
+ """An open file is kept in sync with the editor, so the MCP server (Claude) always reads
1143
+ the user's latest edits instead of overwriting them with a stale copy."""
1144
+ if not self.path:
1145
+ self.update_title()
1146
+ return
1147
+ j = self.design.to_json()
1148
+ if j != self.saved_json:
1149
+ try:
1150
+ self.design.save(self.path)
1151
+ except Exception as e:
1152
+ self.statusBar().showMessage(f"Autosave failed: {e}")
1153
+ return
1154
+ self.saved_json = j
1155
+ self.dirty = False
1156
+ self.update_title()
1157
+
1158
+ def undo(self):
1159
+ self.commit()
1160
+ if self.hpos > 0:
1161
+ self.hpos -= 1
1162
+ self.set_design(Design.from_dict(json.loads(self.hist[self.hpos])), record=False)
1163
+ self.dirty = True
1164
+ self.autosave()
1165
+
1166
+ def redo(self):
1167
+ self.commit()
1168
+ if self.hpos < len(self.hist) - 1:
1169
+ self.hpos += 1
1170
+ self.set_design(Design.from_dict(json.loads(self.hist[self.hpos])), record=False)
1171
+ self.dirty = True
1172
+ self.autosave()
1173
+
1174
+ def reset(self):
1175
+ self.set_design(Design())
1176
+ self.dirty = True
1177
+ self.autosave()
1178
+
1179
+ def randomize(self):
1180
+ d = clone(self.design)
1181
+ m = d.pattern.mode
1182
+ p = getattr(d.pattern, m)
1183
+ hints = get_type_hints(type(p))
1184
+ for f in fields(p):
1185
+ t = hints[f.name]
1186
+ if f.name in RAND:
1187
+ lo, hi = RAND[f.name]
1188
+ setattr(p, f.name, random.randint(lo, hi) if t is int else round(random.uniform(lo, hi), 2))
1189
+ elif t is bool:
1190
+ setattr(p, f.name, random.random() < 0.5)
1191
+ elif t is str and (f"pattern.{m}.{f.name}" in CHOICES or f.name in CHOICES):
1192
+ setattr(p, f.name, random.choice(CHOICES.get(f"pattern.{m}.{f.name}") or CHOICES[f.name]))
1193
+ spacings = [getattr(p, n) for n in ("spacing_x", "spacing_y", "min_distance", "ring_spacing",
1194
+ "dot_spacing", "left_spacing", "right_spacing", "layer_spacing")
1195
+ if hasattr(p, n)]
1196
+ d.dot.shape = random.choice(("circle", "hexagon", "polygon", "star", "rounded_rectangle"))
1197
+ d.dot.diameter = round(min(spacings) * random.uniform(0.4, 0.75) if spacings else random.uniform(2, 5), 2)
1198
+ d.dot.rotation = round(random.uniform(0, 90), 1)
1199
+ d.dot.sides = random.randint(3, 8)
1200
+ d.dot.star_points = random.randint(4, 8)
1201
+ d.dot.star_inner = round(random.uniform(0.35, 0.7), 2)
1202
+ d.dot.aspect = round(random.uniform(0.4, 2.5), 2)
1203
+ self.set_design(d)
1204
+ self.dirty = True
1205
+ self.autosave()
1206
+
1207
+ # ---------------- gradients / controls
1208
+ def grad_name(self):
1209
+ return GRADS[max(0, self.grad_tabs.currentIndex())]
1210
+
1211
+ def cur_grad(self):
1212
+ return getattr(self.design.gradients, self.grad_name())
1213
+
1214
+ def refresh_handles(self):
1215
+ self.canvas.set_handles(self.cur_grad().controls, self.gw[self.grad_name()]["list"].currentRow())
1216
+
1217
+ def refresh_control_list(self, g):
1218
+ lst, ctrls = self.gw[g]["list"], getattr(self.design.gradients, g).controls
1219
+ cur = lst.currentRow()
1220
+ lst.blockSignals(True)
1221
+ lst.clear()
1222
+ for i, c in enumerate(ctrls):
1223
+ lst.addItem(f"{i + 1}. {c.kind} value {c.value:g} r {c.radius:g}")
1224
+ lst.setCurrentRow(min(cur, len(ctrls) - 1) if cur >= 0 else (0 if ctrls else -1))
1225
+ lst.blockSignals(False)
1226
+ self._control_spins(g)
1227
+
1228
+ def _control_spins(self, g):
1229
+ ctrls, i = getattr(self.design.gradients, g).controls, self.gw[g]["list"].currentRow()
1230
+ for key in ("value", "radius"):
1231
+ sp = self.gw[g][key]
1232
+ sp.setEnabled(0 <= i < len(ctrls))
1233
+ if 0 <= i < len(ctrls):
1234
+ sp.blockSignals(True)
1235
+ sp.setValue(getattr(ctrls[i], key))
1236
+ sp.blockSignals(False)
1237
+
1238
+ def select_control(self, i, from_canvas=False):
1239
+ g = self.grad_name()
1240
+ lst = self.gw[g]["list"]
1241
+ lst.blockSignals(True)
1242
+ lst.setCurrentRow(i)
1243
+ lst.blockSignals(False)
1244
+ self._control_spins(g)
1245
+ if from_canvas: # the pressed handle must survive, so only restyle
1246
+ self.canvas.update_decor(self.cur_grad().controls, i)
1247
+ else:
1248
+ self.refresh_handles()
1249
+
1250
+ def add_control(self, g, kind):
1251
+ w, h = self.design.canvas.width, self.design.canvas.height
1252
+ pts = {"point": [[0.0, 0.0]], "line": [[-w / 4, 0.0], [w / 4, 0.0]],
1253
+ "curve": [[-w * .35, -h * .15], [-w * .1, h * .15], [w * .1, -h * .15], [w * .35, h * .15]]}[kind]
1254
+ gr = getattr(self.design.gradients, g)
1255
+ gr.controls.append(Control(kind=kind, points=pts, value=CTRL_VALUE[g], radius=round(min(w, h) / 3, 1)))
1256
+ gr.enabled = True
1257
+ self.grad_tabs.setCurrentIndex(GRADS.index(g))
1258
+ self.changed(sync=True)
1259
+ self.select_control(len(gr.controls) - 1)
1260
+
1261
+ def delete_control(self, g):
1262
+ i, ctrls = self.gw[g]["list"].currentRow(), getattr(self.design.gradients, g).controls
1263
+ if 0 <= i < len(ctrls):
1264
+ del ctrls[i]
1265
+ self.changed(sync=True)
1266
+ self.refresh_handles()
1267
+
1268
+ def set_control_attr(self, g, key, v):
1269
+ i, ctrls = self.gw[g]["list"].currentRow(), getattr(self.design.gradients, g).controls
1270
+ if 0 <= i < len(ctrls):
1271
+ setattr(ctrls[i], key, v)
1272
+ self.gw[g]["list"].item(i).setText(f"{i + 1}. {ctrls[i].kind} value {ctrls[i].value:g}"
1273
+ f" r {ctrls[i].radius:g}")
1274
+ self.changed()
1275
+
1276
+ def move_point(self, ci, pi, x, y):
1277
+ self.cur_grad().controls[ci].points[pi] = [round(x, 3), round(y, 3)]
1278
+ self.changed()
1279
+
1280
+ # ---------------- canvas tools
1281
+ def set_tool(self, tool):
1282
+ self.ruler_act.setChecked(tool == "ruler")
1283
+ self.draw_act.setChecked(tool == "draw")
1284
+ self.canvas.set_tool(tool)
1285
+ if tool == "draw":
1286
+ self.statusBar().showMessage("Click to add outline points; double-click / Enter / right-click to finish,"
1287
+ " Esc to cancel")
1288
+
1289
+ def set_custom_boundary(self, pts):
1290
+ self.design.boundary.points = pts
1291
+ self.design.boundary.type = "custom"
1292
+ self.changed(sync=True)
1293
+ self.commit()
1294
+
1295
+ # ---------------- regeneration
1296
+ def run_task(self, fn, cb):
1297
+ t = Task(fn, cb)
1298
+ self.tasks.add(t)
1299
+ t.done.connect(self._task_done)
1300
+ t.start()
1301
+ return t
1302
+
1303
+ def _task_done(self, t):
1304
+ t.wait()
1305
+ self.tasks.discard(t)
1306
+ t.cb(t)
1307
+
1308
+ def regen_now(self):
1309
+ self.regen_timer.stop()
1310
+ if self.gen_task:
1311
+ self.gen_pending = True
1312
+ return
1313
+ d = clone(self.design)
1314
+ self.gen_task = self.run_task(lambda: build(d), self._gen_done)
1315
+
1316
+ def _gen_done(self, t):
1317
+ self.gen_task = None
1318
+ if self.gen_pending: # latest request wins: skip showing the stale result
1319
+ self.gen_pending = False
1320
+ self.regen_now()
1321
+ return
1322
+ if t.error:
1323
+ self.statusBar().showMessage("Generate failed: " + t.error.strip().splitlines()[-1])
1324
+ print(t.error, file=sys.stderr)
1325
+ return
1326
+ r, holes, outline, dt = t.value
1327
+ self.result = r
1328
+ self.canvas.set_result(holes, outline, self.design.dot.color)
1329
+ s = r.stats
1330
+ self.st["count"].setText(f"{s.get('count', len(r.holes))} dots")
1331
+ self.st["open"].setText(f"open {100 * s.get('open_area_ratio', 0):.1f} %")
1332
+ v = s.get("min_wall_violations", 0)
1333
+ self.st["viol"].setText(f"min-wall violations {v}")
1334
+ self.st["viol"].setStyleSheet("color:#ff7070" if v else "")
1335
+ self.st["time"].setText(f"{dt * 1000:.0f} ms")
1336
+ warn = f" ⚠ {v} walls thinner than {self.design.min_wall:g} mm" if v else ""
1337
+ self.canvas.info = (f"{s.get('count', len(r.holes))} holes · "
1338
+ f"{100 * s.get('open_area_ratio', 0):.1f} % open{warn}")
1339
+ self.canvas.viewport().update()
1340
+ if self.need_fit:
1341
+ self.need_fit = False
1342
+ self.canvas.zoom_fit()
1343
+
1344
+ def idle(self):
1345
+ return not (self.gen_task or self.gen_pending or self.regen_timer.isActive())
1346
+
1347
+ # ---------------- files
1348
+ def update_title(self):
1349
+ n = Path(self.path).name if self.path else self.design.name
1350
+ self.setWindowTitle(f"{'*' if self.dirty else ''}{n} — VentForge")
1351
+
1352
+ def watch(self, path):
1353
+ old = self.watcher.files() + self.watcher.directories()
1354
+ if old:
1355
+ self.watcher.removePaths(old)
1356
+ self.path = str(Path(path).resolve()) if path else None
1357
+ if self.path:
1358
+ self.watcher.addPaths([self.path, str(Path(self.path).parent)])
1359
+
1360
+ def maybe_save(self):
1361
+ if not self.dirty:
1362
+ return True
1363
+ r = QMessageBox.question(self, "Unsaved changes", "Save changes to the current design?",
1364
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
1365
+ return r == QMessageBox.Discard or (r == QMessageBox.Save and self.save())
1366
+
1367
+ def new(self):
1368
+ if self.maybe_save():
1369
+ self.watch(None)
1370
+ self.need_fit = True
1371
+ self.set_design(Design())
1372
+ self.dirty = False
1373
+ self.update_title()
1374
+
1375
+ def open_dialog(self):
1376
+ if self.maybe_save():
1377
+ p, _ = QFileDialog.getOpenFileName(self, "Open design", str(self.lib_folder),
1378
+ "Vent designs (*.vent.json *.json)")
1379
+ if p:
1380
+ self.open_file(p)
1381
+
1382
+ def open_file(self, p, template=False):
1383
+ try:
1384
+ d = Design.load(p)
1385
+ except Exception as e:
1386
+ QMessageBox.critical(self, "Open failed", f"{p}\n\n{e}")
1387
+ return False
1388
+ self.watch(None if template else p)
1389
+ self.saved_json = d.to_json()
1390
+ self.need_fit = True
1391
+ self.set_design(d)
1392
+ self.dirty = False
1393
+ self.update_title()
1394
+ return True
1395
+
1396
+ def save(self):
1397
+ return self.save_to(self.path) if self.path else self.save_as()
1398
+
1399
+ def save_as(self):
1400
+ p, _ = QFileDialog.getSaveFileName(self, "Save design", str(self.lib_folder / f"{self.design.name}.vent.json"),
1401
+ "Vent designs (*.vent.json)")
1402
+ if not p:
1403
+ return False
1404
+ if not p.endswith(".json"):
1405
+ p += ".vent.json"
1406
+ return self.save_to(p)
1407
+
1408
+ def save_to(self, p):
1409
+ self.commit()
1410
+ self.saved_json = self.design.to_json()
1411
+ try:
1412
+ self.design.save(p)
1413
+ except Exception as e:
1414
+ QMessageBox.critical(self, "Save failed", f"{p}\n\n{e}")
1415
+ return False
1416
+ self.watch(p)
1417
+ self.dirty = False
1418
+ self.update_title()
1419
+ self.statusBar().showMessage(f"Saved {p}", 4000)
1420
+ return True
1421
+
1422
+ def check_disk(self):
1423
+ """Live sync: reload when another process (e.g. the MCP server) rewrote the open file."""
1424
+ if not self.path or not Path(self.path).exists():
1425
+ return
1426
+ if self.path not in self.watcher.files(): # atomic replace drops the watch
1427
+ self.watcher.addPath(self.path)
1428
+ try:
1429
+ d = Design.load(self.path)
1430
+ except Exception:
1431
+ return # half-written or invalid; the next change event will retry
1432
+ j = d.to_json()
1433
+ if j in (self.saved_json, self.design.to_json()):
1434
+ return # our own write, or nothing new
1435
+ if self.commit_timer.isActive(): # mid-edit: the pending autosave keeps the user's version
1436
+ self.statusBar().showMessage("File changed on disk while you were editing; kept your version", 6000)
1437
+ return
1438
+ self.saved_json = j
1439
+ self.set_design(d)
1440
+ self.dirty = False
1441
+ self.update_title()
1442
+ self.statusBar().showMessage("Reloaded from disk", 4000)
1443
+
1444
+ # ---------------- library
1445
+ def refresh_library(self):
1446
+ self.lib_list.clear()
1447
+ self.lib_label.setText(f"User library: …/{self.lib_folder.name}")
1448
+ self.lib_label.setToolTip(str(self.lib_folder))
1449
+ files = sorted(PRESETS.glob("*.vent.json")) + (sorted(self.lib_folder.glob("*.vent.json"))
1450
+ if self.lib_folder.is_dir() else [])
1451
+ self.thumb_queue = []
1452
+ for f in files:
1453
+ it = QListWidgetItem(f.name.removesuffix(".vent.json"))
1454
+ it.setData(Qt.UserRole, str(f))
1455
+ it.setToolTip(str(f))
1456
+ self.lib_list.addItem(it)
1457
+ self.thumb_queue.append(it)
1458
+ self._next_thumb()
1459
+
1460
+ def _next_thumb(self):
1461
+ if not self.thumb_queue or any(getattr(t, "thumb", False) for t in self.tasks):
1462
+ return
1463
+ it = self.thumb_queue.pop(0)
1464
+ path = it.data(Qt.UserRole)
1465
+
1466
+ def done(t):
1467
+ if not t.error:
1468
+ d, (_, holes, outline, _) = t.value
1469
+ try:
1470
+ it.setIcon(paint_thumb(holes, outline, d.dot.color))
1471
+ except RuntimeError:
1472
+ pass # list was refreshed meanwhile
1473
+ self._next_thumb()
1474
+
1475
+ t = self.run_task(lambda: (lambda d: (d, build(d)))(Design.load(path)), done)
1476
+ t.thumb = True
1477
+
1478
+ def library_clicked(self, it):
1479
+ if self.maybe_save():
1480
+ self.open_file(it.data(Qt.UserRole), template=True)
1481
+
1482
+ def save_to_library(self):
1483
+ self.lib_folder.mkdir(parents=True, exist_ok=True)
1484
+ name = "".join(c if c.isalnum() or c in " -_" else "_" for c in self.design.name).strip() or "design"
1485
+ p = self.lib_folder / f"{name}.vent.json"
1486
+ if p.exists() and QMessageBox.question(self, "Overwrite?", f"{p.name} exists. Overwrite?") != QMessageBox.Yes:
1487
+ return
1488
+ self.save_to(str(p))
1489
+ self.refresh_library()
1490
+
1491
+ def pick_library_folder(self):
1492
+ p = QFileDialog.getExistingDirectory(self, "Library folder", str(self.lib_folder))
1493
+ if p:
1494
+ self.lib_folder = Path(p)
1495
+ self.settings.setValue("library", p)
1496
+ self.refresh_library()
1497
+
1498
+ # ---------------- images / colour
1499
+ def _pick_image(self):
1500
+ return QFileDialog.getOpenFileName(self, "Image", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tif)")[0]
1501
+
1502
+ def load_background(self):
1503
+ if p := self._pick_image():
1504
+ self.set_param("background.image", p)
1505
+
1506
+ def load_grad_image(self, g):
1507
+ if p := self._pick_image():
1508
+ self.set_param(f"gradients.{g}.image", p)
1509
+
1510
+ def pick_color(self):
1511
+ c = QColorDialog.getColor(QColor(self.design.dot.color), self, "Hole colour")
1512
+ if c.isValid():
1513
+ self.set_param("dot.color", c.name().upper())
1514
+
1515
+ # ---------------- export
1516
+ def busy(self, on):
1517
+ self.busy_bar.setVisible(on)
1518
+ (QApplication.setOverrideCursor(Qt.WaitCursor) if on else QApplication.restoreOverrideCursor())
1519
+
1520
+ def run_export(self, fn, what):
1521
+ self.busy(True)
1522
+ d = clone(self.design)
1523
+
1524
+ def done(t):
1525
+ self.busy(False)
1526
+ if t.error:
1527
+ QMessageBox.critical(self, "Export failed", t.error[-2000:])
1528
+ else:
1529
+ self.statusBar().showMessage(f"Exported {what}", 6000)
1530
+
1531
+ return self.run_task(lambda: fn(pattern().generate(d), d), done)
1532
+
1533
+ def _save_name(self, title, ext, filt):
1534
+ stem = Path(self.path).name.removesuffix(".vent.json") if self.path else self.design.name
1535
+ p, _ = QFileDialog.getSaveFileName(self, title, str(Path(self.path or self.lib_folder / "x").parent /
1536
+ (stem + ext)), filt)
1537
+ return p and (p if p.lower().endswith(ext) else p + ext)
1538
+
1539
+ def export_as(self, ext):
1540
+ if p := self._save_name(f"Export {ext[1:].upper()}", ext, f"{ext[1:].upper()} (*{ext})"):
1541
+ self.run_export(lambda r, d: exporter().export(r, d, p), p)
1542
+
1543
+ def _dialog(self, title, rows):
1544
+ dlg = QDialog(self)
1545
+ dlg.setWindowTitle(title)
1546
+ f = QFormLayout(dlg)
1547
+ for label, w in rows:
1548
+ f.addRow(label, w)
1549
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
1550
+ bb.accepted.connect(dlg.accept)
1551
+ bb.rejected.connect(dlg.reject)
1552
+ f.addRow(bb)
1553
+ return dlg.exec() == QDialog.Accepted
1554
+
1555
+ @staticmethod
1556
+ def _spin(v, lo=-10000, hi=10000, suffix=" mm", special=None):
1557
+ s = QDoubleSpinBox(decimals=3, minimum=lo, maximum=hi, value=v, suffix=suffix)
1558
+ if special:
1559
+ s.setSpecialValueText(special)
1560
+ return s
1561
+
1562
+ def _xyz(self, v):
1563
+ spins = [self._spin(x, suffix="") for x in v]
1564
+ return row(*spins), spins
1565
+
1566
+ def wrap_dialog(self):
1567
+ c = self.design.canvas
1568
+ rad = self._spin(c.width / (2 * math.pi), 0, 10000)
1569
+ wall = self._spin(c.thickness, 0.01, 1000)
1570
+ hgt = self._spin(c.height, 0, 10000)
1571
+ if not self._dialog("Wrap onto cylinder", [("Radius", rad), ("Wall", wall), ("Height", hgt)]):
1572
+ return
1573
+ if p := self._save_name("Save cylinder", ".step", "STEP (*.step);;STL (*.stl)"):
1574
+ a = dict(radius=rad.value(), wall=wall.value(), height=hgt.value())
1575
+ self.run_export(lambda r, d: exporter().wrap_cylinder(r, d, p, **a), p)
1576
+
1577
+ def cut_dialog(self):
1578
+ part = QLineEdit()
1579
+ pick = button("…", lambda: part.setText(QFileDialog.getOpenFileName(
1580
+ self, "Part", "", "STEP (*.step *.stp)")[0] or part.text()))
1581
+ (ow, o), (nw, n), (xw, x) = self._xyz((0, 0, 0)), self._xyz((0, 0, 1)), self._xyz((1, 0, 0))
1582
+ depth = self._spin(0, 0, 1000, special="auto (through)")
1583
+ if not self._dialog("Cut into part", [("Part (STEP)", row(part, pick)), ("Origin", ow), ("Normal", nw),
1584
+ ("X direction", xw), ("Depth", depth)]):
1585
+ return
1586
+ if not part.text():
1587
+ return
1588
+ if p := self._save_name("Save cut part", ".step", "STEP (*.step)"):
1589
+ a = dict(origin=tuple(s.value() for s in o), normal=tuple(s.value() for s in n),
1590
+ x_dir=tuple(s.value() for s in x), depth=depth.value() or None)
1591
+ src = part.text()
1592
+ self.run_export(lambda r, d: exporter().cut_into_part(r, d, src, p, **a), p)
1593
+
1594
+ def closeEvent(self, e):
1595
+ if not self.maybe_save():
1596
+ e.ignore()
1597
+ return
1598
+ self.regen_timer.stop()
1599
+ self.thumb_queue = []
1600
+ for t in list(self.tasks):
1601
+ t.wait()
1602
+ e.accept()
1603
+
1604
+
1605
+ def dark_theme(app):
1606
+ app.setStyle("Fusion")
1607
+ p = QPalette()
1608
+ for role, c in ((QPalette.Window, (45, 46, 50)), (QPalette.WindowText, (225, 225, 225)),
1609
+ (QPalette.Base, (32, 33, 36)), (QPalette.AlternateBase, (45, 46, 50)),
1610
+ (QPalette.Text, (225, 225, 225)), (QPalette.Button, (55, 56, 61)),
1611
+ (QPalette.ButtonText, (225, 225, 225)), (QPalette.Highlight, (247, 187, 59)),
1612
+ (QPalette.HighlightedText, (20, 20, 20)), (QPalette.ToolTipBase, (60, 60, 64)),
1613
+ (QPalette.ToolTipText, (230, 230, 230)), (QPalette.PlaceholderText, (140, 140, 140))):
1614
+ p.setColor(role, QColor(*c))
1615
+ p.setColor(QPalette.Disabled, QPalette.Text, QColor(120, 120, 120))
1616
+ p.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(120, 120, 120))
1617
+ app.setPalette(p)
1618
+ app.setStyleSheet(QSS)
1619
+
1620
+
1621
+ ACCENT = "#F7BB3B"
1622
+ QSS = f"""
1623
+ QWidget {{ font-size: 10pt; }}
1624
+ QDockWidget::title {{ background:#2a2b2f; padding:8px; font-weight:bold; }}
1625
+ QScrollArea, QScrollArea > QWidget > QWidget {{ background:#2d2e32; border:none; }}
1626
+ QToolButton[section="big"] {{ border:none; border-bottom:1px solid #3c3d43; font-weight:bold; font-size:11pt;
1627
+ padding:8px 2px; text-align:left; }}
1628
+ QToolButton[section="small"] {{ border:none; color:#a8a8b0; padding:4px 2px; }}
1629
+ QToolButton[tile="true"] {{ background:#36373c; border:1px solid #3f4046; border-radius:8px; padding:4px;
1630
+ font-size:8.5pt; }}
1631
+ QToolButton[tile="true"]:hover {{ border-color:#6a6b72; }}
1632
+ QToolButton[tile="true"]:checked {{ border:2px solid {ACCENT}; background:#43403a; }}
1633
+ QToolButton[seg="true"] {{ background:#36373c; border:1px solid #45464c; border-radius:6px; padding:5px 8px; }}
1634
+ QToolButton[seg="true"]:checked {{ background:{ACCENT}; color:#1b1b1b; font-weight:bold; border-color:{ACCENT}; }}
1635
+ QToolButton#export {{ background:{ACCENT}; color:#1b1b1b; font-weight:bold; border-radius:6px; padding:5px 10px; }}
1636
+ QPushButton {{ background:#3a3b40; border:1px solid #4a4b52; border-radius:6px; padding:5px 10px; }}
1637
+ QPushButton:hover {{ background:#45464c; }}
1638
+ QSlider::groove:horizontal {{ height:4px; background:#4a4b52; border-radius:2px; }}
1639
+ QSlider::sub-page:horizontal {{ background:{ACCENT}; border-radius:2px; }}
1640
+ QSlider::handle:horizontal {{ background:#f2f2f2; width:14px; height:14px; margin:-6px 0; border-radius:7px; }}
1641
+ QSlider::handle:horizontal:hover {{ background:white; border:2px solid {ACCENT}; }}
1642
+ QSlider:disabled::sub-page:horizontal {{ background:#5a5b62; }}
1643
+ QLineEdit, QComboBox {{ background:#232428; border:1px solid #45464c; border-radius:5px; padding:3px 6px; }}
1644
+ QAbstractSpinBox {{ background:#232428; border:1px solid #45464c; border-radius:5px; padding-right:4px; }}
1645
+ QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{ width:0; border:none; }}
1646
+ QAbstractSpinBox:focus, QLineEdit:focus {{ border-color:{ACCENT}; }}
1647
+ QTabBar::tab {{ background:#36373c; padding:6px 12px; border-top-left-radius:6px; border-top-right-radius:6px;
1648
+ margin-right:2px; }}
1649
+ QTabBar::tab:selected {{ background:{ACCENT}; color:#1b1b1b; font-weight:bold; }}
1650
+ QWidget#zoombar {{ background:rgba(40,41,45,220); border:1px solid #4a4b52; border-radius:8px; }}
1651
+ QWidget#zoombar QToolButton {{ background:transparent; border:none; font-size:12pt; padding:0 6px; }}
1652
+ QWidget#zoombar QToolButton:hover {{ background:#4a4b52; border-radius:5px; }}
1653
+ QToolBar {{ spacing:4px; padding:4px; background:#26272b; border:none; }}
1654
+ """
1655
+
1656
+
1657
+ def main(argv=None):
1658
+ argv = sys.argv if argv is None else argv
1659
+ app = QApplication.instance() or QApplication(argv)
1660
+ dark_theme(app)
1661
+ w = MainWindow(argv[1] if len(argv) > 1 else None)
1662
+ w.show()
1663
+ return app.exec()
1664
+
1665
+
1666
+ if __name__ == "__main__":
1667
+ sys.exit(main())