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/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)
|