@1agh/maude 0.25.0 → 0.26.0
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.
- package/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file draw/geometry.ts — Phase 25 geometry-engine math layer
|
|
3
|
+
* @scope plugins/design/dev-server/draw/geometry.ts
|
|
4
|
+
* @purpose Pure, deterministic geometry the LLM must NOT free-hand:
|
|
5
|
+
* • PCHIP monotone-cubic interpolation (overshoot-free curves —
|
|
6
|
+
* the whole point vs LLM-guessed Béziers, which wobble);
|
|
7
|
+
* • A* obstacle-aware connector routing with chamfered corners
|
|
8
|
+
* (diagram edges that never cross nodes, never gap);
|
|
9
|
+
* • polygon centroid + convex hull (optical centering);
|
|
10
|
+
* • optical-correction constants (circle ≈ +12.84% equal-area,
|
|
11
|
+
* overshoot ratios, centroid-centering).
|
|
12
|
+
* No `Math.random`, no `Date.now` — same input ⇒ same output, so
|
|
13
|
+
* every helper is unit-testable and canvases are reproducible.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors `canvas-arrowheads.ts#shaftPath`/`arrowHeadPoints` (pure
|
|
16
|
+
* trig returning geometry). React-free (DDR-067).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Point } from './primitives.ts';
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// PCHIP — monotone piecewise cubic Hermite (Fritsch–Carlson)
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function sign(x: number): number {
|
|
26
|
+
return x > 0 ? 1 : x < 0 ? -1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fritsch–Carlson slopes that guarantee the interpolant is monotone on every
|
|
31
|
+
* interval where the data is monotone — i.e. no overshoot. Endpoints use the
|
|
32
|
+
* standard non-centered three-point formula, clamped so they can't introduce
|
|
33
|
+
* overshoot either.
|
|
34
|
+
*
|
|
35
|
+
* @param xs strictly increasing knot positions
|
|
36
|
+
* @param ys knot values
|
|
37
|
+
*/
|
|
38
|
+
export function pchipSlopes(xs: number[], ys: number[]): number[] {
|
|
39
|
+
const n = xs.length;
|
|
40
|
+
if (n !== ys.length) throw new Error('pchipSlopes: xs and ys length mismatch');
|
|
41
|
+
if (n < 2) return new Array(n).fill(0);
|
|
42
|
+
|
|
43
|
+
const h: number[] = [];
|
|
44
|
+
const delta: number[] = [];
|
|
45
|
+
for (let k = 0; k < n - 1; k++) {
|
|
46
|
+
const dx = xs[k + 1] - xs[k];
|
|
47
|
+
if (dx <= 0) throw new Error('pchipSlopes: xs must be strictly increasing');
|
|
48
|
+
h.push(dx);
|
|
49
|
+
delta.push((ys[k + 1] - ys[k]) / dx);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const m = new Array<number>(n).fill(0);
|
|
53
|
+
|
|
54
|
+
// Interior slopes: 0 at local extrema; weighted harmonic mean otherwise.
|
|
55
|
+
for (let k = 1; k < n - 1; k++) {
|
|
56
|
+
if (sign(delta[k - 1]) * sign(delta[k]) <= 0) {
|
|
57
|
+
m[k] = 0;
|
|
58
|
+
} else {
|
|
59
|
+
const w1 = 2 * h[k] + h[k - 1];
|
|
60
|
+
const w2 = h[k] + 2 * h[k - 1];
|
|
61
|
+
m[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Endpoint slopes (shape-preserving, clamped).
|
|
66
|
+
m[0] = endpointSlope(h[0], h[1] ?? h[0], delta[0], delta[1] ?? delta[0]);
|
|
67
|
+
m[n - 1] = endpointSlope(
|
|
68
|
+
h[n - 2],
|
|
69
|
+
h[n - 3] ?? h[n - 2],
|
|
70
|
+
delta[n - 2],
|
|
71
|
+
delta[n - 3] ?? delta[n - 2]
|
|
72
|
+
);
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function endpointSlope(h0: number, h1: number, d0: number, d1: number): number {
|
|
77
|
+
let m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
|
|
78
|
+
if (sign(m) !== sign(d0)) {
|
|
79
|
+
m = 0;
|
|
80
|
+
} else if (sign(d0) !== sign(d1) && Math.abs(m) > 3 * Math.abs(d0)) {
|
|
81
|
+
m = 3 * d0;
|
|
82
|
+
}
|
|
83
|
+
return m;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Evaluate the PCHIP interpolant at `x` (clamped to the data domain). */
|
|
87
|
+
export function pchipEval(xs: number[], ys: number[], x: number): number {
|
|
88
|
+
const n = xs.length;
|
|
89
|
+
if (n === 0) throw new Error('pchipEval: empty data');
|
|
90
|
+
if (n === 1) return ys[0];
|
|
91
|
+
const m = pchipSlopes(xs, ys);
|
|
92
|
+
if (x <= xs[0]) return ys[0];
|
|
93
|
+
if (x >= xs[n - 1]) return ys[n - 1];
|
|
94
|
+
// Locate segment k with xs[k] <= x < xs[k+1].
|
|
95
|
+
let k = 0;
|
|
96
|
+
while (k < n - 2 && x >= xs[k + 1]) k++;
|
|
97
|
+
const hK = xs[k + 1] - xs[k];
|
|
98
|
+
const t = (x - xs[k]) / hK;
|
|
99
|
+
const t2 = t * t;
|
|
100
|
+
const t3 = t2 * t;
|
|
101
|
+
// Hermite basis.
|
|
102
|
+
const h00 = 2 * t3 - 3 * t2 + 1;
|
|
103
|
+
const h10 = t3 - 2 * t2 + t;
|
|
104
|
+
const h01 = -2 * t3 + 3 * t2;
|
|
105
|
+
const h11 = t3 - t2;
|
|
106
|
+
return h00 * ys[k] + h10 * hK * m[k] + h01 * ys[k + 1] + h11 * hK * m[k + 1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A smooth, overshoot-free SVG path through `points` as cubic Béziers derived
|
|
111
|
+
* from the PCHIP slopes. Points are sorted by x first (a function-graph curve).
|
|
112
|
+
* Returns an `M … C … C …` `d` string suitable for `path({ d })`.
|
|
113
|
+
*/
|
|
114
|
+
export function pchipPath(points: Point[]): string {
|
|
115
|
+
if (points.length === 0) return '';
|
|
116
|
+
const sorted = [...points].sort((a, b) => a.x - b.x);
|
|
117
|
+
if (sorted.length === 1) return `M${fmt(sorted[0].x)} ${fmt(sorted[0].y)}`;
|
|
118
|
+
const xs = sorted.map((p) => p.x);
|
|
119
|
+
const ys = sorted.map((p) => p.y);
|
|
120
|
+
const m = pchipSlopes(xs, ys);
|
|
121
|
+
let d = `M${fmt(xs[0])} ${fmt(ys[0])}`;
|
|
122
|
+
for (let k = 0; k < sorted.length - 1; k++) {
|
|
123
|
+
const h = xs[k + 1] - xs[k];
|
|
124
|
+
const c1x = xs[k] + h / 3;
|
|
125
|
+
const c1y = ys[k] + (m[k] * h) / 3;
|
|
126
|
+
const c2x = xs[k + 1] - h / 3;
|
|
127
|
+
const c2y = ys[k + 1] - (m[k + 1] * h) / 3;
|
|
128
|
+
d += ` C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(xs[k + 1])} ${fmt(ys[k + 1])}`;
|
|
129
|
+
}
|
|
130
|
+
return d;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fmt(n: number): string {
|
|
134
|
+
return Number.isInteger(n) ? String(n) : String(Math.round(n * 1e3) / 1e3);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// Polygon helpers
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Signed area (shoelace). Positive = counter-clockwise in SVG's y-down space. */
|
|
142
|
+
export function polygonArea(points: Point[]): number {
|
|
143
|
+
const n = points.length;
|
|
144
|
+
if (n < 3) return 0;
|
|
145
|
+
let a = 0;
|
|
146
|
+
for (let i = 0; i < n; i++) {
|
|
147
|
+
const p = points[i];
|
|
148
|
+
const q = points[(i + 1) % n];
|
|
149
|
+
a += p.x * q.y - q.x * p.y;
|
|
150
|
+
}
|
|
151
|
+
return a / 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Area centroid of a (simple) polygon. Falls back to the vertex average for
|
|
156
|
+
* degenerate / near-zero-area inputs so it never returns NaN.
|
|
157
|
+
*/
|
|
158
|
+
export function centroid(points: Point[]): Point {
|
|
159
|
+
const n = points.length;
|
|
160
|
+
if (n === 0) return { x: 0, y: 0 };
|
|
161
|
+
if (n < 3) return vertexMean(points);
|
|
162
|
+
const a = polygonArea(points);
|
|
163
|
+
if (Math.abs(a) < 1e-9) return vertexMean(points);
|
|
164
|
+
let cx = 0;
|
|
165
|
+
let cy = 0;
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
const p = points[i];
|
|
168
|
+
const q = points[(i + 1) % n];
|
|
169
|
+
const cross = p.x * q.y - q.x * p.y;
|
|
170
|
+
cx += (p.x + q.x) * cross;
|
|
171
|
+
cy += (p.y + q.y) * cross;
|
|
172
|
+
}
|
|
173
|
+
const f = 1 / (6 * a);
|
|
174
|
+
return { x: cx * f, y: cy * f };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function vertexMean(points: Point[]): Point {
|
|
178
|
+
const s = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
|
|
179
|
+
return { x: s.x / points.length, y: s.y / points.length };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Convex hull (Andrew's monotone chain), returned counter-clockwise, no dup endpoint. */
|
|
183
|
+
export function convexHull(points: Point[]): Point[] {
|
|
184
|
+
const pts = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
185
|
+
const n = pts.length;
|
|
186
|
+
if (n < 3) return pts;
|
|
187
|
+
const cross = (o: Point, a: Point, b: Point) =>
|
|
188
|
+
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
189
|
+
const lower: Point[] = [];
|
|
190
|
+
for (const p of pts) {
|
|
191
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
|
192
|
+
lower.pop();
|
|
193
|
+
}
|
|
194
|
+
lower.push(p);
|
|
195
|
+
}
|
|
196
|
+
const upper: Point[] = [];
|
|
197
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
198
|
+
const p = pts[i];
|
|
199
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
|
200
|
+
upper.pop();
|
|
201
|
+
}
|
|
202
|
+
upper.push(p);
|
|
203
|
+
}
|
|
204
|
+
lower.pop();
|
|
205
|
+
upper.pop();
|
|
206
|
+
return lower.concat(upper);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
// Optical corrections
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The factor by which a circle's *diameter* must exceed a square's *side* for
|
|
215
|
+
* the two to read as the same visual size — equal-area: `2/√π ≈ 1.1284`
|
|
216
|
+
* (≈ +12.84%). The canonical Bjango optical-adjustment number.
|
|
217
|
+
*/
|
|
218
|
+
export const EQUAL_AREA_CIRCLE_SCALE = 2 / Math.sqrt(Math.PI);
|
|
219
|
+
|
|
220
|
+
/** Circle diameter that visually matches a square of side `squareSide`. */
|
|
221
|
+
export function equalWeightCircleDiameter(squareSide: number): number {
|
|
222
|
+
return squareSide * EQUAL_AREA_CIRCLE_SCALE;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Grow an extent by an overshoot ratio so curved/pointed forms appear to align
|
|
227
|
+
* with flat edges. Typical ratio 0.01–0.03 (cap-line overshoot) up to the
|
|
228
|
+
* equal-area circle factor for round counters.
|
|
229
|
+
*/
|
|
230
|
+
export function overshoot(extent: number, ratio = 0.02): number {
|
|
231
|
+
return extent * (1 + ratio);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Translation needed to move a polygon's *area centroid* onto a target point.
|
|
236
|
+
* Triangles and play-glyphs must be centroid-centered, not bounding-box
|
|
237
|
+
* centered, or they read as offset toward the wide side (rubric check 22).
|
|
238
|
+
*/
|
|
239
|
+
export function centroidCenter(
|
|
240
|
+
points: Point[],
|
|
241
|
+
targetCx: number,
|
|
242
|
+
targetCy: number
|
|
243
|
+
): { dx: number; dy: number } {
|
|
244
|
+
const c = centroid(points);
|
|
245
|
+
return { dx: targetCx - c.x, dy: targetCy - c.y };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
249
|
+
// Organic blobs — smooth closed curves (the staple of organic/funky design)
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export interface BlobOpts {
|
|
253
|
+
/** Number of lobes / control points (default 7). More = busier silhouette. */
|
|
254
|
+
lobes?: number;
|
|
255
|
+
/** Radius variance 0–1 (default 0.28). 0 = perfect ellipse; higher = wobblier. */
|
|
256
|
+
irregularity?: number;
|
|
257
|
+
/** Integer seed for the deterministic radius jitter (default 1). */
|
|
258
|
+
seed?: number;
|
|
259
|
+
/** ry/rx squish (default 1 = round). */
|
|
260
|
+
squish?: number;
|
|
261
|
+
/** Start-angle rotation in radians (default 0). */
|
|
262
|
+
rotation?: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A smooth, closed organic blob path centered at (cx, cy) with mean radius
|
|
267
|
+
* `baseR`. Lobe radii are jittered by a seeded LCG (deterministic — no
|
|
268
|
+
* `Math.random`), then connected with a closed Catmull-Rom spline rendered as
|
|
269
|
+
* cubic Béziers. The bread-and-butter shape for organic / funky compositions
|
|
270
|
+
* (place the blob centers on a `composition.armature()`, don't scatter them).
|
|
271
|
+
* Returns an `M … C … Z` `d` string for `path({ d })`.
|
|
272
|
+
*/
|
|
273
|
+
export function blobPath(cx: number, cy: number, baseR: number, opts: BlobOpts = {}): string {
|
|
274
|
+
const lobes = Math.max(3, opts.lobes ?? 7);
|
|
275
|
+
const irr = opts.irregularity ?? 0.28;
|
|
276
|
+
const squish = opts.squish ?? 1;
|
|
277
|
+
const rot = opts.rotation ?? 0;
|
|
278
|
+
let s = (opts.seed ?? 1) >>> 0 || 1;
|
|
279
|
+
const rnd = () => {
|
|
280
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
281
|
+
return s / 4294967296;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const pts: Point[] = [];
|
|
285
|
+
for (let i = 0; i < lobes; i++) {
|
|
286
|
+
const a = rot + (i / lobes) * Math.PI * 2;
|
|
287
|
+
const r = baseR * (1 + (rnd() * 2 - 1) * irr);
|
|
288
|
+
pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * squish });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const n = pts.length;
|
|
292
|
+
const P = (i: number): Point => pts[((i % n) + n) % n];
|
|
293
|
+
let d = `M${fmt(P(0).x)} ${fmt(P(0).y)}`;
|
|
294
|
+
for (let i = 0; i < n; i++) {
|
|
295
|
+
const p0 = P(i - 1);
|
|
296
|
+
const p1 = P(i);
|
|
297
|
+
const p2 = P(i + 1);
|
|
298
|
+
const p3 = P(i + 2);
|
|
299
|
+
const c1x = p1.x + (p2.x - p0.x) / 6;
|
|
300
|
+
const c1y = p1.y + (p2.y - p0.y) / 6;
|
|
301
|
+
const c2x = p2.x - (p3.x - p1.x) / 6;
|
|
302
|
+
const c2y = p2.y - (p3.y - p1.y) / 6;
|
|
303
|
+
d += ` C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
|
|
304
|
+
}
|
|
305
|
+
return `${d} Z`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
// A* connector routing (obstacle-aware, chamfered corners)
|
|
310
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
export interface Rect {
|
|
313
|
+
x: number;
|
|
314
|
+
y: number;
|
|
315
|
+
width: number;
|
|
316
|
+
height: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface RouteOpts {
|
|
320
|
+
/** Grid cell size in user units (default 10). Smaller = finer, slower. */
|
|
321
|
+
grid?: number;
|
|
322
|
+
/** Inflate every obstacle by this many units before routing (default 0). */
|
|
323
|
+
padding?: number;
|
|
324
|
+
/** Search bounds; defaults to the inputs' bounding box + margin. */
|
|
325
|
+
bounds?: Rect;
|
|
326
|
+
/** Extra cost per 90° turn so routes prefer straight runs (default 2). */
|
|
327
|
+
turnPenalty?: number;
|
|
328
|
+
/** Corner chamfer radius; 0 leaves sharp orthogonal corners (default 0). */
|
|
329
|
+
chamfer?: number;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface HeapItem {
|
|
333
|
+
f: number;
|
|
334
|
+
seq: number;
|
|
335
|
+
key: string;
|
|
336
|
+
gx: number;
|
|
337
|
+
gy: number;
|
|
338
|
+
dir: number;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Route an orthogonal connector from `start` to `goal` around `obstacles` using
|
|
343
|
+
* A* on a uniform grid. Returns waypoints (world coords) including endpoints.
|
|
344
|
+
* Falls back to a straight `[start, goal]` if the grid is fully blocked — a
|
|
345
|
+
* drawing tool should degrade, not throw.
|
|
346
|
+
*/
|
|
347
|
+
export function routeConnector(
|
|
348
|
+
start: Point,
|
|
349
|
+
goal: Point,
|
|
350
|
+
obstacles: Rect[] = [],
|
|
351
|
+
opts: RouteOpts = {}
|
|
352
|
+
): Point[] {
|
|
353
|
+
const cell = opts.grid ?? 10;
|
|
354
|
+
const pad = opts.padding ?? 0;
|
|
355
|
+
const turnPenalty = opts.turnPenalty ?? 2;
|
|
356
|
+
|
|
357
|
+
const inflated = obstacles.map((o) => ({
|
|
358
|
+
x: o.x - pad,
|
|
359
|
+
y: o.y - pad,
|
|
360
|
+
width: o.width + 2 * pad,
|
|
361
|
+
height: o.height + 2 * pad,
|
|
362
|
+
}));
|
|
363
|
+
|
|
364
|
+
const bounds = opts.bounds ?? computeBounds(start, goal, inflated, cell * 2);
|
|
365
|
+
const cols = Math.max(1, Math.ceil(bounds.width / cell)) + 1;
|
|
366
|
+
const rows = Math.max(1, Math.ceil(bounds.height / cell)) + 1;
|
|
367
|
+
|
|
368
|
+
const toGrid = (p: Point) => ({
|
|
369
|
+
gx: clamp(Math.round((p.x - bounds.x) / cell), 0, cols - 1),
|
|
370
|
+
gy: clamp(Math.round((p.y - bounds.y) / cell), 0, rows - 1),
|
|
371
|
+
});
|
|
372
|
+
const toWorld = (gx: number, gy: number): Point => ({
|
|
373
|
+
x: bounds.x + gx * cell,
|
|
374
|
+
y: bounds.y + gy * cell,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const s = toGrid(start);
|
|
378
|
+
const g = toGrid(goal);
|
|
379
|
+
|
|
380
|
+
const blocked = (gx: number, gy: number): boolean => {
|
|
381
|
+
if (gx === s.gx && gy === s.gy) return false;
|
|
382
|
+
if (gx === g.gx && gy === g.gy) return false;
|
|
383
|
+
const p = toWorld(gx, gy);
|
|
384
|
+
for (const o of inflated) {
|
|
385
|
+
if (p.x >= o.x && p.x <= o.x + o.width && p.y >= o.y && p.y <= o.y + o.height) return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Directions: 1:+x 2:-x 3:+y 4:-y (0 = start, no incoming direction).
|
|
391
|
+
const DIRS = [
|
|
392
|
+
{ dx: 1, dy: 0, dir: 1 },
|
|
393
|
+
{ dx: -1, dy: 0, dir: 2 },
|
|
394
|
+
{ dx: 0, dy: 1, dir: 3 },
|
|
395
|
+
{ dx: 0, dy: -1, dir: 4 },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
const heap = new MinHeap();
|
|
399
|
+
const gScore = new Map<string, number>();
|
|
400
|
+
const cameFrom = new Map<string, string>();
|
|
401
|
+
let seq = 0;
|
|
402
|
+
|
|
403
|
+
const startKey = stateKey(s.gx, s.gy, 0);
|
|
404
|
+
gScore.set(startKey, 0);
|
|
405
|
+
heap.push({ f: heuristic(s.gx, s.gy, g), seq: seq++, key: startKey, gx: s.gx, gy: s.gy, dir: 0 });
|
|
406
|
+
|
|
407
|
+
let goalKey: string | null = null;
|
|
408
|
+
|
|
409
|
+
while (!heap.isEmpty()) {
|
|
410
|
+
const cur = heap.pop() as HeapItem;
|
|
411
|
+
if (cur.gx === g.gx && cur.gy === g.gy) {
|
|
412
|
+
goalKey = cur.key;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
const curG = gScore.get(cur.key) ?? Number.POSITIVE_INFINITY;
|
|
416
|
+
for (const d of DIRS) {
|
|
417
|
+
const nx = cur.gx + d.dx;
|
|
418
|
+
const ny = cur.gy + d.dy;
|
|
419
|
+
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
|
420
|
+
if (blocked(nx, ny)) continue;
|
|
421
|
+
const turn = cur.dir !== 0 && cur.dir !== d.dir ? turnPenalty : 0;
|
|
422
|
+
const tentative = curG + 1 + turn;
|
|
423
|
+
const nKey = stateKey(nx, ny, d.dir);
|
|
424
|
+
if (tentative < (gScore.get(nKey) ?? Number.POSITIVE_INFINITY)) {
|
|
425
|
+
gScore.set(nKey, tentative);
|
|
426
|
+
cameFrom.set(nKey, cur.key);
|
|
427
|
+
heap.push({
|
|
428
|
+
f: tentative + heuristic(nx, ny, g),
|
|
429
|
+
seq: seq++,
|
|
430
|
+
key: nKey,
|
|
431
|
+
gx: nx,
|
|
432
|
+
gy: ny,
|
|
433
|
+
dir: d.dir,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (goalKey === null) return [start, goal];
|
|
440
|
+
|
|
441
|
+
// Reconstruct grid path.
|
|
442
|
+
const gridPath: Point[] = [];
|
|
443
|
+
let k: string | undefined = goalKey;
|
|
444
|
+
while (k !== undefined) {
|
|
445
|
+
const [gx, gy] = k.split(',').map(Number);
|
|
446
|
+
gridPath.push(toWorld(gx, gy));
|
|
447
|
+
k = cameFrom.get(k);
|
|
448
|
+
}
|
|
449
|
+
gridPath.reverse();
|
|
450
|
+
|
|
451
|
+
// Snap real endpoints back in (grid-rounding may have nudged them).
|
|
452
|
+
gridPath[0] = start;
|
|
453
|
+
gridPath[gridPath.length - 1] = goal;
|
|
454
|
+
|
|
455
|
+
const simplified = simplifyCollinear(gridPath);
|
|
456
|
+
return opts.chamfer && opts.chamfer > 0 ? chamferCorners(simplified, opts.chamfer) : simplified;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function heuristic(gx: number, gy: number, goal: { gx: number; gy: number }): number {
|
|
460
|
+
return Math.abs(gx - goal.gx) + Math.abs(gy - goal.gy);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function stateKey(gx: number, gy: number, dir: number): string {
|
|
464
|
+
return `${gx},${gy},${dir}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
468
|
+
return v < lo ? lo : v > hi ? hi : v;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function computeBounds(start: Point, goal: Point, obstacles: Rect[], margin: number): Rect {
|
|
472
|
+
let minX = Math.min(start.x, goal.x);
|
|
473
|
+
let minY = Math.min(start.y, goal.y);
|
|
474
|
+
let maxX = Math.max(start.x, goal.x);
|
|
475
|
+
let maxY = Math.max(start.y, goal.y);
|
|
476
|
+
for (const o of obstacles) {
|
|
477
|
+
minX = Math.min(minX, o.x);
|
|
478
|
+
minY = Math.min(minY, o.y);
|
|
479
|
+
maxX = Math.max(maxX, o.x + o.width);
|
|
480
|
+
maxY = Math.max(maxY, o.y + o.height);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
x: minX - margin,
|
|
484
|
+
y: minY - margin,
|
|
485
|
+
width: maxX - minX + 2 * margin,
|
|
486
|
+
height: maxY - minY + 2 * margin,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Drop interior points that lie on the straight line between their neighbors. */
|
|
491
|
+
export function simplifyCollinear(points: Point[]): Point[] {
|
|
492
|
+
if (points.length <= 2) return [...points];
|
|
493
|
+
const out: Point[] = [points[0]];
|
|
494
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
495
|
+
const a = out[out.length - 1];
|
|
496
|
+
const b = points[i];
|
|
497
|
+
const c = points[i + 1];
|
|
498
|
+
const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
499
|
+
if (Math.abs(cross) > 1e-9) out.push(b);
|
|
500
|
+
}
|
|
501
|
+
out.push(points[points.length - 1]);
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Replace each interior corner with a two-point chamfer of the given radius. */
|
|
506
|
+
export function chamferCorners(points: Point[], radius: number): Point[] {
|
|
507
|
+
if (points.length <= 2 || radius <= 0) return [...points];
|
|
508
|
+
const out: Point[] = [points[0]];
|
|
509
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
510
|
+
const v = points[i];
|
|
511
|
+
const a = points[i - 1];
|
|
512
|
+
const b = points[i + 1];
|
|
513
|
+
const da = dist(v, a);
|
|
514
|
+
const db = dist(v, b);
|
|
515
|
+
const r = Math.min(radius, da / 2, db / 2);
|
|
516
|
+
if (r <= 0) {
|
|
517
|
+
out.push(v);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
out.push({ x: v.x + ((a.x - v.x) / da) * r, y: v.y + ((a.y - v.y) / da) * r });
|
|
521
|
+
out.push({ x: v.x + ((b.x - v.x) / db) * r, y: v.y + ((b.y - v.y) / db) * r });
|
|
522
|
+
}
|
|
523
|
+
out.push(points[points.length - 1]);
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function dist(a: Point, b: Point): number {
|
|
528
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Minimal binary min-heap keyed by `f` with a deterministic `seq` tiebreaker. */
|
|
532
|
+
class MinHeap {
|
|
533
|
+
private items: HeapItem[] = [];
|
|
534
|
+
|
|
535
|
+
isEmpty(): boolean {
|
|
536
|
+
return this.items.length === 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
push(item: HeapItem): void {
|
|
540
|
+
const a = this.items;
|
|
541
|
+
a.push(item);
|
|
542
|
+
let i = a.length - 1;
|
|
543
|
+
while (i > 0) {
|
|
544
|
+
const parent = (i - 1) >> 1;
|
|
545
|
+
if (this.less(a[i], a[parent])) {
|
|
546
|
+
[a[i], a[parent]] = [a[parent], a[i]];
|
|
547
|
+
i = parent;
|
|
548
|
+
} else break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
pop(): HeapItem | undefined {
|
|
553
|
+
const a = this.items;
|
|
554
|
+
if (a.length === 0) return undefined;
|
|
555
|
+
const top = a[0];
|
|
556
|
+
const last = a.pop() as HeapItem;
|
|
557
|
+
if (a.length > 0) {
|
|
558
|
+
a[0] = last;
|
|
559
|
+
let i = 0;
|
|
560
|
+
const n = a.length;
|
|
561
|
+
for (;;) {
|
|
562
|
+
const l = 2 * i + 1;
|
|
563
|
+
const r = 2 * i + 2;
|
|
564
|
+
let smallest = i;
|
|
565
|
+
if (l < n && this.less(a[l], a[smallest])) smallest = l;
|
|
566
|
+
if (r < n && this.less(a[r], a[smallest])) smallest = r;
|
|
567
|
+
if (smallest === i) break;
|
|
568
|
+
[a[i], a[smallest]] = [a[smallest], a[i]];
|
|
569
|
+
i = smallest;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return top;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private less(x: HeapItem, y: HeapItem): boolean {
|
|
576
|
+
return x.f < y.f || (x.f === y.f && x.seq < y.seq);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file draw/index.ts — Phase 25 geometry-engine public surface
|
|
3
|
+
* @scope plugins/design/dev-server/draw/index.ts
|
|
4
|
+
* @purpose Single import point for the draw engine. The draw-agent and the
|
|
5
|
+
* `draw-proof` / `svg-optimize` bin helpers import from here:
|
|
6
|
+
*
|
|
7
|
+
* import { rect, circle, place, toSvg, toJsx, optimizeSvg,
|
|
8
|
+
* diagram, contrastRatio } from './draw/index.ts';
|
|
9
|
+
*
|
|
10
|
+
* Layering (each only depends on the ones above it):
|
|
11
|
+
* primitives → the DrawPrimitive vocabulary + constructors
|
|
12
|
+
* geometry → PCHIP / A* / centroid / optical corrections
|
|
13
|
+
* palette → WCAG + OKLCH (pure color math)
|
|
14
|
+
* layout → grid snap, modular scale, label placement, diagram()
|
|
15
|
+
* serialize → primitives → SVG string + JSX string (single source)
|
|
16
|
+
* optimize → SVGO minify + validity gate (the only runtime dep)
|
|
17
|
+
*
|
|
18
|
+
* React-free (DDR-067) — nothing here imports react / a `.tsx`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export * from './primitives.ts';
|
|
22
|
+
export * from './geometry.ts';
|
|
23
|
+
export * from './palette.ts';
|
|
24
|
+
export * from './composition.ts';
|
|
25
|
+
export * from './brush.ts';
|
|
26
|
+
export * from './layout.ts';
|
|
27
|
+
export * from './serialize.ts';
|
|
28
|
+
export * from './optimize.ts';
|