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/__init__.py +0 -0
- ventforge/__main__.py +22 -0
- ventforge/export.py +629 -0
- ventforge/gui.py +1667 -0
- ventforge/mcp_server.py +206 -0
- ventforge/model.py +301 -0
- ventforge/pattern.py +746 -0
- ventforge/presets/halftone_fade.vent.json +186 -0
- ventforge/presets/hex_honeycomb.vent.json +174 -0
- ventforge/presets/spiral_dashes.vent.json +174 -0
- ventforge/presets/sunflower.vent.json +186 -0
- ventforge/presets/voronoi_stone.vent.json +174 -0
- ventforge/presets/wavy_flowlines.vent.json +174 -0
- ventforge-0.1.0.dist-info/METADATA +84 -0
- ventforge-0.1.0.dist-info/RECORD +19 -0
- ventforge-0.1.0.dist-info/WHEEL +5 -0
- ventforge-0.1.0.dist-info/entry_points.txt +3 -0
- ventforge-0.1.0.dist-info/licenses/LICENSE +674 -0
- ventforge-0.1.0.dist-info/top_level.txt +1 -0
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())
|