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/pattern.py ADDED
@@ -0,0 +1,746 @@
1
+ """Geometry core: turn a Design into hole polygons.
2
+
3
+ generate(design) -> Result the whole pipeline
4
+ outline_of(design) -> Polygon plate outline (canvas rect or boundary shape)
5
+ sample_gradient(g, xy, canvas, neutral) -> ndarray evaluate one gradient field at points
6
+
7
+ Pipeline for dot modes: mode points (+angle, +size multiplier) -> spacing warp -> size/angle/shape
8
+ fields -> one template outline per dot, built vectorised with numpy + shapely.polygons ->
9
+ margin / clip filter -> stats. Voronoi and flowlines build their polygons directly.
10
+
11
+ Spacing gradient semantics (approximation): the point set is first scaled about the origin by
12
+ `edge`, then warped radially around each control in turn (points move along the direction away
13
+ from the nearest point of the control). A point at distance d moves to h(d) = integral_0^d of
14
+ value(s)/edge ds, so local spacing near a control is ~value/edge times the far spacing and density
15
+ scales by ~1/value. Beyond `radius` the offset is constant (far points are pulled/pushed by a fixed
16
+ amount). Noise and image fields are applied first as a smooth flow (see _field_warp): an image
17
+ replaces edge + controls, noise multiplies them. Applied to grid, concentric, crosslines, spiral and
18
+ fibonacci; random uses value as a local min_distance multiplier; path, custom_points, voronoi,
19
+ flowlines ignore spacing.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import shapely
28
+ from shapely.geometry import LineString, MultiPoint, Point, Polygon, box
29
+
30
+ from .model import MODES, Canvas, Design, DotStyle, Gradient, Result
31
+
32
+ CIRCLE_SEGS = 48
33
+ MIN_DOT = 0.05 # dots with diameter <= this (mm) are dropped
34
+ MIN_SPACING = 1.0 # mm; smaller lattice pitches take tens of seconds and are not printable anyway
35
+ MAX_COUNT = 20000 # cap for count parameters (also the GUI's hard max)
36
+ SLIVER = 0.4 # clipped pieces thinner than this (mm) are dropped
37
+ GOLDEN = math.pi * (3 - math.sqrt(5))
38
+ NEUTRAL = {"size": 1.0, "spacing": 1.0, "angle": 0.0, "shape": 0.0}
39
+ WARP_MODES = ("grid", "concentric", "crosslines", "spiral", "fibonacci")
40
+
41
+
42
+ # ---------------------------------------------------------------- small helpers
43
+ def _rot(xy, deg):
44
+ a = math.radians(deg)
45
+ c, s = math.cos(a), math.sin(a)
46
+ return np.c_[xy[:, 0] * c - xy[:, 1] * s, xy[:, 0] * s + xy[:, 1] * c]
47
+
48
+
49
+ def _frame(cover, deg):
50
+ """Bounds of the cover box seen from a frame rotated by deg."""
51
+ x0, y0, x1, y1 = cover
52
+ c = _rot(np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]), -deg)
53
+ return c[:, 0].min(), c[:, 1].min(), c[:, 0].max(), c[:, 1].max()
54
+
55
+
56
+ def _corner_dist(cover):
57
+ x0, y0, x1, y1 = cover
58
+ return max(math.hypot(x, y) for x in (x0, x1) for y in (y0, y1))
59
+
60
+
61
+ def _arclen(line):
62
+ return np.r_[0.0, np.cumsum(np.hypot(*np.diff(line, axis=0).T))]
63
+
64
+
65
+ def _at(line, cum, s):
66
+ return np.c_[np.interp(s, cum, line[:, 0]), np.interp(s, cum, line[:, 1])]
67
+
68
+
69
+ def _catmull(pts, closed, k=16):
70
+ """Uniform Catmull-Rom through pts; closed result repeats the first point at the end."""
71
+ P = np.asarray(pts, float)
72
+ if len(P) < 3:
73
+ return np.vstack([P, P[:1]]) if closed else P
74
+ if closed:
75
+ ext, n = np.vstack([P[-1], P, P[0], P[1]]), len(P)
76
+ else:
77
+ ext, n = np.vstack([2 * P[0] - P[1], P, 2 * P[-1] - P[-2]]), len(P) - 1
78
+ p0, p1, p2, p3 = (ext[i:i + n, None, :] for i in range(4))
79
+ t = np.linspace(0, 1, k, endpoint=False)[None, :, None]
80
+ c = 0.5 * (2 * p1 + (p2 - p0) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t ** 2
81
+ + (3 * p1 - p0 - 3 * p2 + p3) * t ** 3)
82
+ return np.vstack([c.reshape(-1, 2), P[:1] if closed else P[-1:]])
83
+
84
+
85
+ def _value_noise(xy, scale, seed):
86
+ """Smooth seeded 2D value noise in [-1, 1] with feature size `scale`."""
87
+ p = xy / max(scale, 1e-6)
88
+ i = np.floor(p)
89
+ f = p - i
90
+ f = f * f * (3 - 2 * f)
91
+ i = i.astype(np.int64)
92
+ m = np.uint64(0xFFFFFFFF)
93
+
94
+ def h(ix, iy):
95
+ n = ((ix.astype(np.uint64) * np.uint64(0x9E3779B1)) ^ (iy.astype(np.uint64) * np.uint64(0x85EBCA77))
96
+ ^ np.uint64((seed * 0xC2B2AE3D) & 0xFFFFFFFF)) & m
97
+ n = ((n ^ (n >> np.uint64(15))) * np.uint64(0x2C1B3C6D)) & m
98
+ n = ((n ^ (n >> np.uint64(12))) * np.uint64(0x297A2D39)) & m
99
+ n ^= n >> np.uint64(15)
100
+ return n.astype(np.float64) / 2147483647.5 - 1.0
101
+
102
+ x, y = i[:, 0], i[:, 1]
103
+ a, b = h(x, y), h(x + 1, y)
104
+ c, d = h(x, y + 1), h(x + 1, y + 1)
105
+ u, v = f[:, 0], f[:, 1]
106
+ return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
107
+
108
+
109
+ # ---------------------------------------------------------------- outline
110
+ def outline_of(design: Design) -> Polygon:
111
+ w, h = design.canvas.width, design.canvas.height
112
+ b = design.boundary
113
+ if b.type == "circle":
114
+ return Point(0, 0).buffer(min(w, h) / 2, quad_segs=32)
115
+ if b.type == "rounded_rectangle":
116
+ r = max(0.0, min(b.corner_radius, min(w, h) / 2 - 1e-6))
117
+ rect = box(-w / 2 + r, -h / 2 + r, w / 2 - r, h / 2 - r)
118
+ return rect.buffer(r, quad_segs=8) if r > 0 else rect
119
+ if b.type == "custom" and len(b.points) >= 3:
120
+ poly = Polygon(b.points)
121
+ if not poly.is_valid:
122
+ parts = [g for g in shapely.get_parts(shapely.make_valid(poly)) if g.geom_type == "Polygon"]
123
+ poly = max(parts, key=lambda g: g.area) if parts else box(-w / 2, -h / 2, w / 2, h / 2)
124
+ return poly
125
+ return box(-w / 2, -h / 2, w / 2, h / 2)
126
+
127
+
128
+ # ---------------------------------------------------------------- gradients
129
+ def _control_geom(c):
130
+ pts = [tuple(p) for p in c.points] or [(0.0, 0.0)]
131
+ if c.kind == "point" or len(pts) < 2:
132
+ return Point(pts[0])
133
+ if c.kind == "curve" and len(pts) >= 3:
134
+ return LineString(_catmull(pts, closed=False))
135
+ return LineString(pts)
136
+
137
+
138
+ def _falloff(t, kind):
139
+ t = np.clip(t, 0, 1)
140
+ return t if kind == "linear" else t * t * (3 - 2 * t)
141
+
142
+
143
+ def _image_field(g: Gradient, xy, canvas: Canvas):
144
+ from PIL import Image
145
+ with Image.open(Path(g.image)) as im:
146
+ a = np.asarray(im.convert("L"), dtype=np.float64) / 255.0
147
+ H, W = a.shape
148
+ col = np.clip(((xy[:, 0] / canvas.width + 0.5) * (W - 1)).round().astype(int), 0, W - 1)
149
+ row = np.clip(((0.5 - xy[:, 1] / canvas.height) * (H - 1)).round().astype(int), 0, H - 1)
150
+ v = a[row, col]
151
+ if g.invert_image:
152
+ v = 1 - v
153
+ return g.image_min + (g.image_max - g.image_min) * v
154
+
155
+
156
+ def sample_gradient(g: Gradient, xy, canvas: Canvas, neutral: float = 1.0) -> np.ndarray:
157
+ """Field value at points xy (N,2). Disabled -> `neutral` everywhere."""
158
+ xy = np.asarray(xy, float).reshape(-1, 2)
159
+ if not g.enabled:
160
+ return np.full(len(xy), float(neutral))
161
+ if g.image:
162
+ v = _image_field(g, xy, canvas)
163
+ else:
164
+ v = np.full(len(xy), float(g.edge))
165
+ best = np.zeros(len(xy))
166
+ for c in g.controls:
167
+ geom = _control_geom(c)
168
+ if geom.geom_type == "Point":
169
+ d = np.hypot(xy[:, 0] - geom.x, xy[:, 1] - geom.y)
170
+ else:
171
+ d = shapely.distance(geom, shapely.points(xy))
172
+ w = 1 - _falloff(d / max(c.radius, 1e-9), g.falloff)
173
+ upd = w > best
174
+ v[upd] = g.edge + (c.value - g.edge) * w[upd]
175
+ best = np.maximum(best, w)
176
+ if g.noise:
177
+ v = v * (1 + g.noise * _value_noise(xy, g.noise_scale, g.noise_seed))
178
+ return v
179
+
180
+
181
+ def _gmax(g: Gradient, neutral):
182
+ if not g.enabled:
183
+ return neutral
184
+ vals = [g.image_min, g.image_max] if g.image else [g.edge] + [c.value for c in g.controls]
185
+ return max(vals) * (1 + abs(g.noise))
186
+
187
+
188
+ def _warp_profile(c, g):
189
+ e = max(g.edge, 0.05)
190
+ R = max(c.radius, 1e-6)
191
+ s = np.linspace(0, R, 257)
192
+ ratio = np.maximum(g.edge + (c.value - g.edge) * (1 - _falloff(s / R, g.falloff)), 0.05) / e
193
+ h = np.r_[0.0, np.cumsum((ratio[1:] + ratio[:-1]) / 2 * np.diff(s))]
194
+ return s, h
195
+
196
+
197
+ def _warp_pad(g: Gradient):
198
+ """How far compression pulls the far field in (so the generator must cover more)."""
199
+ return sum(max(0.0, c.radius - _warp_profile(c, g)[1][-1]) for c in g.controls)
200
+
201
+
202
+ def _warp_min(g: Gradient):
203
+ """Smallest spacing multiplier the field can reach (how much the cover must grow)."""
204
+ lo = min(g.image_min, g.image_max) if g.image else g.edge
205
+ return max(lo * (1 - min(abs(g.noise), 0.9)), 0.05)
206
+
207
+
208
+ def _bilerp(Z, gx, gy, p):
209
+ fx = np.interp(p[:, 0], gx, np.arange(len(gx)))
210
+ fy = np.interp(p[:, 1], gy, np.arange(len(gy)))
211
+ i = np.clip(fx.astype(int), 0, len(gx) - 2)
212
+ j = np.clip(fy.astype(int), 0, len(gy) - 2)
213
+ u, v = fx - i, fy - j
214
+ return (Z[j, i] * (1 - u) * (1 - v) + Z[j, i + 1] * u * (1 - v)
215
+ + Z[j + 1, i] * (1 - u) * v + Z[j + 1, i + 1] * u * v)
216
+
217
+
218
+ def _field_warp(xy, g: Gradient, canvas: Canvas):
219
+ """Image / noise spacing field f: move points along the flow of grad(phi), with
220
+ laplace(phi) = 2 log(f / s) solved by FFT. A smooth flow never folds, and its
221
+ divergence sets the local area ratio, so the local pitch scales by about f/s;
222
+ s (the mean scale) is applied as a uniform zoom. Controls are handled radially by _warp."""
223
+ if not (g.image or g.noise) or not len(xy):
224
+ return xy
225
+ n = 128
226
+ lo, hi = xy.min(0), xy.max(0)
227
+ c = (lo + hi) / 2
228
+ h = 2 * ((hi - lo).max() + 2) / n # domain = 2x the points' extent (padding vs FFT wrap)
229
+ gx, gy = c[0] + (np.arange(n) - n / 2) * h, c[1] + (np.arange(n) - n / 2) * h
230
+ X, Y = np.meshgrid(gx, gy)
231
+ P = np.c_[X.ravel(), Y.ravel()]
232
+ f = _image_field(g, P, canvas) if g.image else np.ones(len(P))
233
+ if g.noise:
234
+ f = f * (1 + min(abs(g.noise), 0.9) * _value_noise(P, max(g.noise_scale, 0.5), g.noise_seed))
235
+ lf = np.log(np.maximum(f, 0.05)).reshape(n, n)
236
+ inside = (np.abs(X - c[0]) <= (hi - lo)[0] / 2 + 1) & (np.abs(Y - c[1]) <= (hi - lo)[1] / 2 + 1)
237
+ ls = lf[inside].mean()
238
+ rhs = 2 * (lf - ls)
239
+ rhs -= rhs.mean()
240
+ k = 2 * np.pi * np.fft.fftfreq(n, d=h)
241
+ KX, KY = np.meshgrid(k, k)
242
+ K2 = KX ** 2 + KY ** 2
243
+ K2[0, 0] = 1.0
244
+ ph = -np.fft.fft2(rhs) / K2
245
+ ph[0, 0] = 0
246
+ vx, vy = np.fft.ifft2(1j * KX * ph).real, np.fft.ifft2(1j * KY * ph).real
247
+ for _ in range(8):
248
+ xy = xy + np.c_[_bilerp(vx, gx, gy, xy), _bilerp(vy, gx, gy, xy)] / 8
249
+ return xy * np.exp(ls)
250
+
251
+
252
+ def _warp(xy, g: Gradient, canvas: Canvas):
253
+ if g.image: # image replaces edge + controls, as in sample_gradient
254
+ return _field_warp(xy, g, canvas)
255
+ xy = _field_warp(xy * max(g.edge, 0.05), g, canvas)
256
+ for c in g.controls:
257
+ geom = _control_geom(c)
258
+ if geom.geom_type == "Point":
259
+ foot = np.broadcast_to([geom.x, geom.y], xy.shape)
260
+ else:
261
+ pts = shapely.points(xy)
262
+ foot = shapely.get_coordinates(shapely.line_interpolate_point(geom, shapely.line_locate_point(geom, pts)))
263
+ v = xy - foot
264
+ d = np.hypot(v[:, 0], v[:, 1])
265
+ s, h = _warp_profile(c, g)
266
+ hd = np.where(d < s[-1], np.interp(d, s, h), h[-1] + d - s[-1])
267
+ k = np.divide(hd, d, out=np.ones_like(d), where=d > 1e-12)
268
+ xy = foot + v * k[:, None]
269
+ return xy
270
+
271
+
272
+ # ---------------------------------------------------------------- dot templates
273
+ def _regular(n, r, start=90.0):
274
+ a = np.radians(start + np.arange(n) * 360.0 / n)
275
+ return np.c_[r * np.cos(a), r * np.sin(a)]
276
+
277
+
278
+ def _star(n, R, inner):
279
+ k = np.arange(2 * n)
280
+ a = np.radians(90 + k * 180.0 / n)
281
+ r = np.where(k % 2 == 0, R, R * inner)
282
+ return np.c_[r * np.cos(a), r * np.sin(a)]
283
+
284
+
285
+ def _rrect(w, h, r, segs=8):
286
+ r = max(0.0, min(r, w / 2, h / 2))
287
+ if r <= 1e-9:
288
+ return np.array([[w / 2, h / 2], [-w / 2, h / 2], [-w / 2, -h / 2], [w / 2, -h / 2]])
289
+ out = []
290
+ for (sx, sy), a0 in (((1, 1), 0), ((-1, 1), 90), ((-1, -1), 180), ((1, -1), 270)):
291
+ a = np.radians(a0 + np.linspace(0, 90, segs + 1))
292
+ out.append(np.c_[sx * (w / 2 - r) + r * np.cos(a), sy * (h / 2 - r) + r * np.sin(a)])
293
+ return np.vstack(out)
294
+
295
+
296
+ def _template(shape, dot: DotStyle, d):
297
+ """Outline (M,2), CCW, centred at the origin, for diameter d."""
298
+ if shape == "rounded_rectangle":
299
+ return _rrect(d, d * dot.aspect, dot.corner_radius)
300
+ if shape == "rectangle":
301
+ return _rrect(d, d, 0)
302
+ if shape == "hexagon":
303
+ return _regular(6, d / 2)
304
+ if shape == "polygon":
305
+ return _regular(max(3, int(dot.sides)), d / 2)
306
+ if shape == "star":
307
+ return _star(max(2, int(dot.star_points)), d / 2, dot.star_inner)
308
+ if shape == "custom" and len(dot.custom_points) >= 3:
309
+ p = np.asarray(dot.custom_points, float)
310
+ lo, hi = p.min(0), p.max(0)
311
+ p = (p - (lo + hi) / 2) * (d / max((hi - lo).max(), 1e-9))
312
+ area = np.sum(p[:, 0] * np.roll(p[:, 1], -1) - np.roll(p[:, 0], -1) * p[:, 1])
313
+ return p if area >= 0 else p[::-1]
314
+ return _regular(CIRCLE_SEGS, d / 2)
315
+
316
+
317
+ def _radial(ring, ang):
318
+ """Radius of the outline along rays at angles ang (rad) from the origin (outermost hit)."""
319
+ A, E = ring, np.roll(ring, -1, 0) - ring
320
+ u = np.c_[np.cos(ang), np.sin(ang)]
321
+ with np.errstate(divide="ignore", invalid="ignore"):
322
+ den = u[:, None, 0] * E[None, :, 1] - u[:, None, 1] * E[None, :, 0]
323
+ s = (A[None, :, 0] * E[None, :, 1] - A[None, :, 1] * E[None, :, 0]) / den
324
+ t = (A[None, :, 0] * u[:, None, 1] - A[None, :, 1] * u[:, None, 0]) / den
325
+ ok = np.isfinite(s) & (s > 0) & (t >= -1e-9) & (t <= 1 + 1e-9)
326
+ return np.where(ok, s, 0).max(1)
327
+
328
+
329
+ def _morph_pair(a, b):
330
+ """Resample two outlines on a shared set of ray angles (keeps both shapes' corners)."""
331
+ ang = np.r_[np.arctan2(a[:, 1], a[:, 0]), np.arctan2(b[:, 1], b[:, 0]),
332
+ np.linspace(-math.pi, math.pi, 96, endpoint=False)]
333
+ ang = np.unique(np.round(np.mod(ang, 2 * math.pi), 6))
334
+ u = np.c_[np.cos(ang), np.sin(ang)]
335
+ return u * _radial(a, ang)[:, None], u * _radial(b, ang)[:, None]
336
+
337
+
338
+ def _build_dots(xy, scale, ang, base, target=None, t=None):
339
+ """Vectorised: one polygon per dot from a template (optionally morphed toward target by t)."""
340
+ tx, ty = base[None, :, 0], base[None, :, 1]
341
+ if target is not None:
342
+ tt = t[:, None]
343
+ tx = tx + tt * (target[None, :, 0] - tx)
344
+ ty = ty + tt * (target[None, :, 1] - ty)
345
+ a = np.radians(ang)[:, None]
346
+ c, s, k = np.cos(a), np.sin(a), scale[:, None]
347
+ X = xy[:, 0:1] + k * (tx * c - ty * s)
348
+ Y = xy[:, 1:2] + k * (tx * s + ty * c)
349
+ return shapely.polygons(np.stack([X, Y], -1))
350
+
351
+
352
+ # ---------------------------------------------------------------- dot modes
353
+ # each returns xy (N,2), angle deg (N,), size multiplier (N,)
354
+ def _empty():
355
+ return np.zeros((0, 2)), np.zeros(0), np.zeros(0)
356
+
357
+
358
+ def _grid(g, cover):
359
+ sx = max(g.spacing_x, MIN_SPACING)
360
+ sy = sx * math.sqrt(3) / 2 if g.arrangement == "hexagon" else max(g.spacing_y, MIN_SPACING)
361
+ stagger = g.arrangement in ("diamond", "hexagon")
362
+ x0, y0, x1, y1 = _frame(cover, g.rotation)
363
+ x0, x1, y0, y1 = x0 - g.offset_x, x1 - g.offset_x, y0 - g.offset_y, y1 - g.offset_y
364
+ I, J = np.meshgrid(np.arange(math.floor(x0 / sx) - 2, math.ceil(x1 / sx) + 2),
365
+ np.arange(math.floor(y0 / sy) - 1, math.ceil(y1 / sy) + 2))
366
+ I, J = I.ravel(), J.ravel()
367
+ xy = np.c_[I * sx + (J % 2) * (sx / 2 if stagger else 0) + g.offset_x, J * sy + g.offset_y]
368
+ alt = {"col": I % 2, "both": (I + J) % 2}.get(g.rotate_by, J % 2)
369
+ return _rot(xy, g.rotation), g.rotation + alt * g.row_rotation, np.ones(len(xy))
370
+
371
+
372
+ def _random(r, region, spacing, canvas, d):
373
+ rng = np.random.default_rng(r.seed)
374
+ n = min(max(int(r.count), 0), MAX_COUNT)
375
+ if n == 0 or region.is_empty:
376
+ return _empty()
377
+ x0, y0, x1, y1 = region.bounds
378
+ cand = rng.uniform((x0, y0), (x1, y1), size=(min(n * 30, 200_000), 2))
379
+ cand = cand[shapely.contains_xy(region, cand[:, 0], cand[:, 1])]
380
+ rad = max(r.min_distance, 0.2) * np.maximum(sample_gradient(spacing, cand, canvas, 1.0), 0.05)
381
+ if len(cand) == 0:
382
+ return _empty()
383
+ cell = rad.max()
384
+ cells: dict = {}
385
+ keep = []
386
+ for k, (x, y) in enumerate(cand):
387
+ cx, cy = int(x // cell), int(y // cell)
388
+ rk = rad[k]
389
+ ok = True
390
+ for i in (cx - 1, cx, cx + 1):
391
+ for j in (cy - 1, cy, cy + 1):
392
+ for q in cells.get((i, j), ()):
393
+ if (cand[q, 0] - x) ** 2 + (cand[q, 1] - y) ** 2 < ((rk + rad[q]) / 2) ** 2:
394
+ ok = False
395
+ break
396
+ if not ok:
397
+ break
398
+ if not ok:
399
+ break
400
+ if ok:
401
+ cells.setdefault((cx, cy), []).append(k)
402
+ keep.append(k)
403
+ if len(keep) >= n:
404
+ break
405
+ xy = cand[keep]
406
+ return xy, np.zeros(len(xy)), np.ones(len(xy))
407
+
408
+
409
+ def _path(p, canvas, d):
410
+ """Dots along a path plus `layers` parallel offsets, centred on the base path (half outward,
411
+ half inward). `count` is the dot count on the base path; each layer gets dots in proportion to
412
+ its length so the along-path step stays constant. Closed paths are offset with buffer (exact,
413
+ never self-intersecting), open ones with offset_curve; a layer that collapses, splits, or is
414
+ shorter than two steps (three for rings) is dropped. Alternate closed layers are shifted half a step (brick)."""
415
+ if len(p.points) >= 2:
416
+ pts, closed = np.asarray(p.points, float), p.closed
417
+ line = _catmull(pts, closed) if p.smooth else (np.vstack([pts, pts[:1]]) if closed else pts)
418
+ else:
419
+ a = np.linspace(0, 2 * math.pi, 129)
420
+ rr = 0.35 * min(canvas.width, canvas.height)
421
+ line, closed = np.c_[rr * np.cos(a), rr * np.sin(a)], True
422
+ L0, n0 = _arclen(line)[-1], min(max(int(p.count), 0), MAX_COUNT)
423
+ if n0 == 0 or L0 <= 0:
424
+ return _empty()
425
+ n0 = min(n0, int(2 * L0) + 1) # <= 2 dots per mm of path; more only stacks them
426
+ step = L0 / n0
427
+ nl = max(int(p.layers), 1)
428
+ reach = math.hypot(canvas.width, canvas.height) + float(np.abs(line).max())
429
+ if closed:
430
+ ring = shapely.LinearRing(line)
431
+ poly, ccw = shapely.make_valid(Polygon(ring)), ring.is_ccw
432
+ else:
433
+ base = LineString(line)
434
+ xs, angs = [], []
435
+ for k in range(nl):
436
+ o = (k - (nl - 1) / 2) * p.layer_spacing
437
+ if abs(o) > reach: # offset layer lies entirely off the plate
438
+ continue
439
+ if o == 0:
440
+ ln = line
441
+ elif closed:
442
+ g = poly.buffer(o, quad_segs=16)
443
+ if g.geom_type != "Polygon" or g.is_empty:
444
+ continue # collapsed or split inward offset
445
+ ext = g.exterior if g.exterior.is_ccw == ccw else g.exterior.reverse()
446
+ ln = np.asarray(ext.coords)
447
+ # start the ring near the base start so layers line up
448
+ i = int(np.argmin(np.hypot(*(ln[:-1] - line[0]).T)))
449
+ ln = np.vstack([ln[i:-1], ln[:i + 1]])
450
+ else:
451
+ g = base.offset_curve(o, quad_segs=16)
452
+ if g.geom_type != "LineString" or g.is_empty:
453
+ continue
454
+ ln = np.asarray(g.coords)
455
+ cum = _arclen(ln)
456
+ L = cum[-1]
457
+ if L < (3 if closed else 2) * step: # a ring needs >= 3 dots to read as a ring
458
+ continue
459
+ m = round(n0 * L / L0)
460
+ if closed: # no dot at the seam: s in [0, L)
461
+ s = (np.arange(m) + (0.5 if k % 2 else 0.0)) * L / m
462
+ else:
463
+ s = np.linspace(0, L, m)
464
+ eps = L * 1e-4
465
+ pa, pb = (np.mod(s - eps, L), np.mod(s + eps, L)) if closed else (s - eps, s + eps)
466
+ tan = _at(ln, cum, pb) - _at(ln, cum, pa)
467
+ xs.append(_at(ln, cum, s))
468
+ angs.append(np.degrees(np.arctan2(tan[:, 1], tan[:, 0])) if p.align else np.zeros(m))
469
+ if not xs:
470
+ return _empty()
471
+ xy = np.vstack(xs)
472
+ return xy, np.concatenate(angs), np.ones(len(xy))
473
+
474
+
475
+ def _spiral(sp):
476
+ n = min(max(int(sp.count), 0), MAX_COUNT)
477
+ if n == 0:
478
+ return _empty()
479
+ R, r0 = sp.radius, sp.inner_radius
480
+ tmax = 2 * math.pi * max(sp.turns, 1e-3)
481
+ n = min(n, int(tmax * (abs(R) + abs(r0))) + 1) # <= ~2 dots per mm of spiral; more only stacks them
482
+ if sp.even_spacing:
483
+ th = np.linspace(0, tmax, max(2000, int(400 * sp.turns)))
484
+ r = r0 + (R - r0) * th / tmax
485
+ cum = _arclen(np.c_[r * np.cos(th), r * np.sin(th)])
486
+ th = np.interp(np.linspace(0, cum[-1], n), cum, th)
487
+ else:
488
+ th = np.linspace(0, tmax, n)
489
+ r = r0 + (R - r0) * th / tmax
490
+ phi = th + math.radians(sp.rotation)
491
+ dr = (R - r0) / tmax
492
+ tang = np.degrees(np.arctan2(dr * np.sin(phi) + r * np.cos(phi), dr * np.cos(phi) - r * np.sin(phi)))
493
+ return np.c_[r * np.cos(phi), r * np.sin(phi)], (tang if sp.align else np.zeros(n)), np.ones(n)
494
+
495
+
496
+ def _concentric(c, cover):
497
+ outer = min(c.outer_radius, _corner_dist(cover)) if c.outer_radius > 0 else _corner_dist(cover)
498
+ rs = max(c.ring_spacing, MIN_SPACING)
499
+ xs, angs = [], []
500
+ k = 0
501
+ while (r := c.inner_radius + k * rs) <= outer + 1e-9:
502
+ if r < 1e-9:
503
+ th = np.array([math.radians(c.rotation)])
504
+ else:
505
+ m = max(1, round(2 * math.pi * r / max(c.dot_spacing, MIN_SPACING)))
506
+ th = np.radians(c.rotation) + (np.arange(m) + (0.5 if c.stagger and k % 2 else 0)) * 2 * math.pi / m
507
+ xs.append(np.c_[r * np.cos(th), r * np.sin(th)])
508
+ angs.append(np.degrees(th) + 90 if c.align else np.zeros(len(th)))
509
+ k += 1
510
+ if not xs:
511
+ return _empty()
512
+ xy = np.vstack(xs)
513
+ return xy, np.concatenate(angs), np.ones(len(xy))
514
+
515
+
516
+ def _fibonacci(f):
517
+ n = np.arange(min(max(int(f.count), 0), MAX_COUNT))
518
+ r = f.inner_radius + f.spread * np.sqrt(n)
519
+ th = n * GOLDEN + math.radians(f.rotation)
520
+ return np.c_[r * np.cos(th), r * np.sin(th)], (np.degrees(th) if f.align else np.zeros(len(n))), np.ones(len(n))
521
+
522
+
523
+ def _crosslines(c, cover):
524
+ a1, a2 = math.radians(c.left_angle + c.rotation), math.radians(c.right_angle + c.rotation)
525
+ M = np.array([[-math.sin(a1), math.cos(a1)], [-math.sin(a2), math.cos(a2)]]) # line normals
526
+ if abs(np.linalg.det(M)) < 1e-6:
527
+ return _empty()
528
+ s1, s2 = max(c.left_spacing, MIN_SPACING), max(c.right_spacing, MIN_SPACING)
529
+ x0, y0, x1, y1 = cover
530
+ corners = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]) @ M.T
531
+ i = np.arange(math.floor(corners[:, 0].min() / s1) - 1, math.ceil(corners[:, 0].max() / s1) + 2)
532
+ j = np.arange(math.floor(corners[:, 1].min() / s2) - 1, math.ceil(corners[:, 1].max() / s2) + 2)
533
+ I, J = (a.ravel() for a in np.meshgrid(i, j))
534
+ xy = np.c_[I * s1, J * s2] @ np.linalg.inv(M).T
535
+ inb = (xy[:, 0] >= x0) & (xy[:, 0] <= x1) & (xy[:, 1] >= y0) & (xy[:, 1] <= y1)
536
+ xy, I, J = xy[inb], I[inb], J[inb]
537
+ alt = {"left": I % 2, "right": J % 2}.get(c.rotate_by, (I + J) % 2)
538
+ return xy, c.rotation + alt * c.row_rotation, np.ones(len(xy))
539
+
540
+
541
+ def _custom(c):
542
+ xy = np.asarray(c.points, float).reshape(-1, 2)
543
+ n = len(xy)
544
+ sz = np.ones(n)
545
+ an = np.zeros(n)
546
+ sz[:min(n, len(c.sizes))] = c.sizes[:n]
547
+ an[:min(n, len(c.angles))] = c.angles[:n]
548
+ return xy, an, sz
549
+
550
+
551
+ # ---------------------------------------------------------------- polygon modes
552
+ def _scale_parts(geoms, k):
553
+ """Scale each geometry about its centroid by its own factor (vectorised)."""
554
+ cen = shapely.get_coordinates(shapely.centroid(geoms))
555
+ xy, idx = shapely.get_coordinates(geoms, return_index=True)
556
+ return shapely.set_coordinates(geoms.copy(), cen[idx] + (xy - cen[idx]) * k[idx, None])
557
+
558
+
559
+ def _voronoi(design, cover, inner):
560
+ v = design.pattern.voronoi
561
+ cs = max(v.cell_size, MIN_SPACING)
562
+ rng = np.random.default_rng(v.seed)
563
+ x0, y0, x1, y1 = _frame(cover, v.rotation)
564
+ I, J = np.meshgrid(np.arange(math.floor(x0 / cs) - 2, math.ceil(x1 / cs) + 3),
565
+ np.arange(math.floor(y0 / cs) - 2, math.ceil(y1 / cs) + 3))
566
+ seeds = np.c_[I.ravel(), J.ravel()] * cs
567
+ seeds = _rot(seeds + rng.uniform(-0.5, 0.5, seeds.shape) * cs * v.irregularity / 100.0, v.rotation)
568
+ ext = box(*cover).buffer(2 * cs)
569
+ cells = shapely.get_parts(shapely.voronoi_polygons(MultiPoint(seeds), extend_to=ext, ordered=True))
570
+ cells = cells[shapely.intersects(inner.envelope, cells)]
571
+ r = max(v.corner_radius, 0.0)
572
+ cells = shapely.buffer(cells, -(v.crack_width / 2 + r), join_style="mitre")
573
+ if r > 0:
574
+ cells = shapely.buffer(cells, r, quad_segs=4)
575
+ cells = cells[~shapely.is_empty(cells)]
576
+ g = design.gradients.size
577
+ if g.enabled and len(cells):
578
+ k = sample_gradient(g, shapely.get_coordinates(shapely.centroid(cells)), design.canvas, 1.0)
579
+ cells = _scale_parts(cells[k > 0.02], k[k > 0.02])
580
+ return cells
581
+
582
+
583
+ def _flowlines(design, cover, inner):
584
+ f = design.pattern.flowlines
585
+ w = max(f.line_width, 0.1)
586
+ sp = max(f.line_spacing, MIN_SPACING)
587
+ cap = "round" if f.edge_style == "round" else "flat"
588
+ if f.follow_boundary:
589
+ lines, k = [], 0
590
+ while True:
591
+ ring = inner.buffer(-(w / 2 + k * sp))
592
+ if ring.is_empty or k > 10_000:
593
+ break
594
+ lines += list(shapely.get_parts(shapely.get_parts(ring.boundary)))
595
+ k += 1
596
+ lines = np.array(lines, dtype=object)
597
+ else:
598
+ R = _corner_dist(cover) + w + abs(f.amplitude)
599
+ x = np.arange(-R, R + 0.5, min(0.5, max(f.wavelength, 2.0) / 32))
600
+ ys = np.arange(-math.ceil(R / sp), math.ceil(R / sp) + 1) * sp
601
+ X, Y = np.meshgrid(x, ys)
602
+ wave = f.amplitude * np.sin(2 * math.pi * X / max(f.wavelength, 2.0))
603
+ noise = 0.25 * f.amplitude * _value_noise(np.c_[X.ravel(), Y.ravel()], max(f.wavelength, 1.0), f.seed)
604
+ Y = Y + wave + noise.reshape(X.shape)
605
+ pts = _rot(np.c_[X.ravel(), Y.ravel()], f.rotation).reshape(X.shape + (2,))
606
+ lines = shapely.linestrings(pts)
607
+ if len(lines) == 0:
608
+ return lines
609
+ if f.invert:
610
+ band = shapely.union_all(shapely.buffer(lines, w / 2, cap_style="flat"))
611
+ return _bridged(shapely.get_parts(inner.difference(band)), f, inner, design.min_wall)
612
+ if not f.follow_boundary:
613
+ lines = shapely.get_parts(shapely.intersection(lines, inner.buffer(-w / 2 if cap == "round" else 0)))
614
+ lines = lines[shapely.length(lines) > 1e-6]
615
+ g = design.gradients.size
616
+ width = np.full(len(lines), w)
617
+ if g.enabled and len(lines):
618
+ width = w * sample_gradient(g, shapely.get_coordinates(shapely.point_on_surface(lines)), design.canvas, 1.0)
619
+ keep = width > MIN_DOT
620
+ lines, width = lines[keep], width[keep]
621
+ return _bridged(shapely.buffer(lines, width / 2, cap_style=cap, quad_segs=8), f, inner, design.min_wall)
622
+
623
+
624
+ def _bridged(holes, f, inner, min_wall):
625
+ """Closed ring slots (follow_boundary) would cut loose islands out of the plate; cut 4 radial
626
+ bridges through them. shortcut: bridges radiate from the outline centroid, so a ring that does
627
+ not enclose it (strongly concave outline) may still get no bridge."""
628
+ if not f.follow_boundary or len(holes) == 0:
629
+ return holes
630
+ c = inner.centroid
631
+ R = _corner_dist(inner.bounds) + abs(c.x) + abs(c.y)
632
+ bw = max(2 * min_wall, 1.0)
633
+ rays = [LineString([(c.x, c.y), (c.x + R * math.cos(a), c.y + R * math.sin(a))])
634
+ for a in np.radians(f.rotation + np.array([45, 135, 225, 315]))]
635
+ cut = shapely.union_all(shapely.buffer(rays, bw / 2, cap_style="flat"))
636
+ parts = shapely.get_parts(shapely.difference(holes, cut))
637
+ return parts[~shapely.is_empty(parts) & (shapely.area(parts) > 1e-6)]
638
+
639
+
640
+ # ---------------------------------------------------------------- fit + stats
641
+ def _fit(holes, inner, clip):
642
+ """Keep holes inside `inner`; clip=True also appends clipped pieces.
643
+ Returns (kept holes, inside mask over the input): kept = holes[inside] followed by clipped parts."""
644
+ inside = shapely.contains(inner, holes) if len(holes) else np.zeros(0, bool)
645
+ kept = holes[inside]
646
+ if not clip or len(holes) == 0:
647
+ return kept, inside
648
+ cross = holes[~inside & shapely.intersects(inner, holes)]
649
+ parts = shapely.get_parts(shapely.intersection(cross, inner))
650
+ parts = parts[shapely.get_type_id(parts) == 3]
651
+ parts = parts[~shapely.is_empty(shapely.buffer(parts, -SLIVER / 2, quad_segs=2))]
652
+ return np.r_[kept, parts], inside
653
+
654
+
655
+ def _stats(holes, outline, min_wall):
656
+ plate = outline.area
657
+ n = len(holes)
658
+ if n == 0:
659
+ return {"count": 0, "open_area_ratio": 0.0, "hole_area": 0.0, "plate_area": plate, "min_wall_violations": 0}
660
+ tree = shapely.STRtree(holes)
661
+ i, j = tree.query(holes, predicate="intersects")
662
+ area = shapely.union_all(holes).area if np.any(i != j) else float(shapely.area(holes).sum())
663
+ viol = 0
664
+ if min_wall > 0:
665
+ i, j = tree.query(holes, predicate="dwithin", distance=min_wall - 1e-6)
666
+ viol = len(np.unique(i[i != j]))
667
+ return {"count": n, "open_area_ratio": area / plate if plate else 0.0, "hole_area": area,
668
+ "plate_area": plate, "min_wall_violations": viol}
669
+
670
+
671
+ # ---------------------------------------------------------------- main
672
+ def generate(design: Design) -> Result:
673
+ outline = outline_of(design)
674
+ b, dot, gr, pat = design.boundary, design.dot, design.gradients, design.pattern
675
+ mode = pat.mode
676
+ if mode not in MODES:
677
+ raise ValueError(f"unknown pattern mode: {mode!r}")
678
+ inner = outline.buffer(-b.margin) if b.margin > 0 else outline
679
+ if inner.is_empty or dot.diameter <= 0 and mode not in ("voronoi", "flowlines"):
680
+ return Result(holes=[], outline=outline, stats=_stats([], outline, design.min_wall), circles=[])
681
+ shapely.prepare(inner)
682
+ d = dot.diameter
683
+ reach = d * max(1.0, dot.aspect) * _gmax(gr.size, 1.0)
684
+ x0, y0, x1, y1 = outline.bounds
685
+ pad = reach
686
+ warp = gr.spacing.enabled and mode in WARP_MODES
687
+ if warp:
688
+ pad += _warp_pad(gr.spacing)
689
+ cover = (x0 - pad, y0 - pad, x1 + pad, y1 + pad)
690
+ if warp:
691
+ cover = tuple(c / _warp_min(gr.spacing) for c in cover)
692
+
693
+ if mode in ("voronoi", "flowlines"):
694
+ polys = (_voronoi if mode == "voronoi" else _flowlines)(design, cover, inner)
695
+ holes, _ = _fit(np.asarray(polys, dtype=object), inner, b.clip or mode == "flowlines")
696
+ holes = list(holes)
697
+ return Result(holes=holes, outline=outline, stats=_stats(holes, outline, design.min_wall),
698
+ circles=[None] * len(holes))
699
+
700
+ if mode == "grid":
701
+ xy, ang, sz = _grid(pat.grid, cover)
702
+ elif mode == "random":
703
+ region = inner.buffer(-d / 2)
704
+ xy, ang, sz = _random(pat.random, region if not region.is_empty else inner, gr.spacing, design.canvas, d)
705
+ elif mode == "path":
706
+ xy, ang, sz = _path(pat.path, design.canvas, d)
707
+ elif mode == "spiral":
708
+ xy, ang, sz = _spiral(pat.spiral)
709
+ elif mode == "concentric":
710
+ xy, ang, sz = _concentric(pat.concentric, cover)
711
+ elif mode == "fibonacci":
712
+ xy, ang, sz = _fibonacci(pat.fibonacci)
713
+ elif mode == "crosslines":
714
+ xy, ang, sz = _crosslines(pat.crosslines, cover)
715
+ else:
716
+ xy, ang, sz = _custom(pat.custom_points)
717
+ if warp and len(xy):
718
+ xy = _warp(xy, gr.spacing, design.canvas)
719
+
720
+ # cheap bbox prefilter before evaluating fields / building polygons
721
+ ix0, iy0, ix1, iy1 = inner.bounds
722
+ m = (xy[:, 0] > ix0 - reach) & (xy[:, 0] < ix1 + reach) & (xy[:, 1] > iy0 - reach) & (xy[:, 1] < iy1 + reach)
723
+ xy, ang, sz = xy[m], ang[m], sz[m]
724
+ sz = sz * sample_gradient(gr.size, xy, design.canvas, 1.0)
725
+ m = sz * d > MIN_DOT
726
+ xy, ang, sz = xy[m], ang[m], sz[m]
727
+ ang = ang + dot.rotation + sample_gradient(gr.angle, xy, design.canvas, 0.0)
728
+
729
+ base = _template(dot.shape, dot, d)
730
+ if gr.shape.enabled:
731
+ t = np.clip(sample_gradient(gr.shape, xy, design.canvas, 0.0), 0, 1)
732
+ a, tgt = _morph_pair(base, _template(gr.target_shape, DotStyle(
733
+ sides=gr.target_sides, star_points=dot.star_points, star_inner=dot.star_inner), d))
734
+ holes = _build_dots(xy, sz, ang, a, tgt, t)
735
+ exact = (t <= 0) if dot.shape == "circle" else np.zeros(len(xy), bool)
736
+ else:
737
+ holes = _build_dots(xy, sz, ang, base)
738
+ exact = np.full(len(xy), dot.shape == "circle")
739
+ if dot.shape == "custom":
740
+ holes = shapely.make_valid(holes)
741
+
742
+ circ = [(float(x), float(y), float(s * d / 2)) if e else None for (x, y), s, e in zip(xy, sz, exact)]
743
+ kept, inside = _fit(holes, inner, b.clip)
744
+ circles = [c for c, k in zip(circ, inside) if k] + [None] * (len(kept) - int(inside.sum()))
745
+ kept = list(kept)
746
+ return Result(holes=kept, outline=outline, stats=_stats(kept, outline, design.min_wall), circles=circles)