castle-web-cli 0.4.114 → 0.4.116

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.
Files changed (95) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +7 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.d.ts +34 -0
  7. package/dist/editorConfig.js +125 -0
  8. package/dist/ide.js +245 -120
  9. package/dist/imports.d.ts +10 -0
  10. package/dist/imports.js +149 -17
  11. package/dist/init.d.ts +3 -0
  12. package/dist/init.js +113 -88
  13. package/dist/install.d.ts +1 -1
  14. package/dist/install.js +26 -24
  15. package/dist/serve.js +16 -0
  16. package/dist/shell/assets/index-DiPlPGyg.js +144 -0
  17. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  18. package/dist/shell/index.html +2 -2
  19. package/dist/unsupportedMedia.d.ts +1 -0
  20. package/dist/unsupportedMedia.js +54 -0
  21. package/dist/vitePlugins.js +13 -20
  22. package/kits/basic-2d/castle.json +1 -1
  23. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  24. package/kits/physics-2d/CLAUDE.md +65 -34
  25. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  26. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  27. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  28. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +5 -5
  29. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  30. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  31. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  32. package/kits/physics-2d/behaviors/Sprite.jsx +112 -42
  33. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  34. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  35. package/kits/physics-2d/behaviors/tint.js +24 -9
  36. package/kits/physics-2d/blueprints/ball.scene +1 -1
  37. package/kits/physics-2d/blueprints/block.scene +1 -1
  38. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  39. package/kits/physics-2d/blueprints/crate.scene +1 -1
  40. package/kits/physics-2d/castle.json +74 -4
  41. package/kits/physics-2d/docs/pxart-format.md +537 -51
  42. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  43. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  44. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  45. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  46. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  47. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  48. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  49. package/kits/physics-2d/editors/brushFit.js +535 -0
  50. package/kits/physics-2d/editors/brushShapes.js +140 -0
  51. package/kits/physics-2d/editors/mediaFile.js +31 -0
  52. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  54. package/kits/physics-2d/editors/pathTools.js +1906 -0
  55. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  56. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  57. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  58. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  59. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  60. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  61. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  62. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  63. package/kits/physics-2d/engine/art.js +105 -0
  64. package/kits/physics-2d/engine/assets.js +12 -4
  65. package/kits/physics-2d/engine/audioContext.js +34 -0
  66. package/kits/physics-2d/engine/blueprint.js +3 -3
  67. package/kits/physics-2d/engine/collider.js +28 -17
  68. package/kits/physics-2d/engine/files.js +77 -1
  69. package/kits/physics-2d/engine/liveReload.js +1 -1
  70. package/kits/physics-2d/engine/media.js +212 -0
  71. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  72. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +3 -4
  73. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  74. package/kits/physics-2d/engine/pxart.js +153 -35
  75. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  76. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  77. package/kits/physics-2d/engine/scene.js +12 -0
  78. package/kits/physics-2d/engine/tone.js +112 -0
  79. package/kits/physics-2d/engine/ui.jsx +22 -1
  80. package/kits/physics-2d/engine/ui.module.css +36 -12
  81. package/kits/physics-2d/package-lock.json +1 -1
  82. package/kits/physics-2d/scripts/draw.mjs +7 -7
  83. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  84. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  85. package/kits/physics-2d/systems/media.js +34 -0
  86. package/kits/physics-2d/systems/physics.js +12 -3
  87. package/package.json +1 -1
  88. package/dist/shell/assets/index-CqpY1xZR.js +0 -144
  89. package/dist/shell/assets/index-DCwVFL5u.css +0 -1
  90. package/kits/physics-2d/physics/index.js +0 -26
  91. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  92. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  93. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
  94. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  95. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -0,0 +1,1356 @@
1
+ // ============================================================================
2
+ // Path layers — stacked-shape vector source + the normative pixel bake.
3
+ // ============================================================================
4
+ //
5
+ // "Pixels are truth": a path LAYER's cell stores a baked `grid` (identical
6
+ // shape/semantics to a plain pixel cell's grid — every non-path-aware
7
+ // consumer reads only that) PLUS a `path` object carrying the vector source
8
+ // the editor keeps re-baking from. Schema (see docs/pxart-format.md):
9
+ //
10
+ // path: {
11
+ // shapes: [
12
+ // {
13
+ // fill: paletteKey | null,
14
+ // stroke: paletteKey | null,
15
+ // subpaths: [{ closed, start: [x,y], segs: [{ to, c1?, c2? }] }]
16
+ // },
17
+ // ...
18
+ // ]
19
+ // }
20
+ //
21
+ // Shapes paint back-to-front (array order). Per shape: fill first, then its
22
+ // own 1px stroke. Later shapes overwrite earlier pixels. Multi-subpath fills
23
+ // use even-odd (donuts). Open stroked subpaths with no fill are 1px polylines.
24
+ // Stroke is per-shape (whole outline or none) — no per-segment strokes.
25
+ //
26
+ // Points are stored as authored; the half-cell lattice is a magnetic guide at
27
+ // input time (§6.1.2). Each segment stores relative cubic offsets c1/c2 from
28
+ // its start and end anchors (absent or zero → straight). Sagitta/bow is a
29
+ // gesture vocabulary only: the bend tool reads/writes it through
30
+ // bowForEdge/snapBow/bowToSegments, but anything answering "what does this
31
+ // look like / where is it" (fill, stroke, hit-test, render) flattens the
32
+ // stored cubics.
33
+ //
34
+ // Shape objects may carry extra keys for forward-compatible variants; v1 only
35
+ // understands fill/stroke/subpaths (/prim).
36
+ //
37
+ // This module owns tolerant parse/serialize PLUS bakePathCell. Kept as a
38
+ // sibling of pxart.js so the core parser stays focused on the on-disk
39
+ // cell/layer shape; pxartPath.js does NOT import from pxart.js.
40
+ // ============================================================================
41
+
42
+ const TRANSPARENT = '.';
43
+
44
+ /**
45
+ * The half-cell lattice (§6.1.2): integers are pixel corners, half-integers are
46
+ * pixel centers. Authoring tools magnetize toward it; storage no longer enforces it.
47
+ */
48
+ export const LATTICE = 0.5;
49
+
50
+ /** Decimal places for serialized coordinates — far below a pixel, stable diffs. */
51
+ export const COORD_DECIMALS = 4;
52
+ const COORD_SCALE = 10 ** COORD_DECIMALS;
53
+
54
+ /** Normalize -0 and round for on-disk coordinates. */
55
+ export function roundCoord(n) {
56
+ if (!Number.isFinite(n)) return 0;
57
+ return Math.round(n * COORD_SCALE) / COORD_SCALE + 0;
58
+ }
59
+
60
+ export function roundPoint(x, y) {
61
+ return [roundCoord(x), roundCoord(y)];
62
+ }
63
+
64
+ /** Screen pixels within which a lattice site magnetizes (constant to the finger). */
65
+ export const MAGNETIC_SCREEN_PX = 12;
66
+
67
+ /**
68
+ * Pull one coordinate toward the nearest magnetic target when within `cellPull`.
69
+ * `corners` → half-cell lattice (integers and half-integers); otherwise pixel
70
+ * centers only (half-integers).
71
+ */
72
+ export function magneticSnapValue(n, cellPull, corners = false) {
73
+ if (!(cellPull > 0) || !Number.isFinite(n)) return n;
74
+ const targets = corners
75
+ ? [Math.round(n / LATTICE) * LATTICE]
76
+ : [Math.round(n - LATTICE) + LATTICE];
77
+ let best = n;
78
+ let bestDist = cellPull;
79
+ for (const t of targets) {
80
+ const d = Math.abs(n - t);
81
+ if (d <= cellPull && d < bestDist) {
82
+ bestDist = d;
83
+ best = t + 0;
84
+ }
85
+ }
86
+ return best;
87
+ }
88
+
89
+ export function magneticSnapPoint(x, y, cellPull, corners = false) {
90
+ return [magneticSnapValue(x, cellPull, corners), magneticSnapValue(y, cellPull, corners)];
91
+ }
92
+
93
+ /** Cell-unit pull radius from screen pixels, artboard zoom, and resolution. */
94
+ export function magneticCellPull(resolution, displayWidthPx, zoom = 1) {
95
+ if (!(resolution > 0) || !(displayWidthPx > 0) || !(zoom > 0)) return LATTICE / 2;
96
+ const cellPx = (displayWidthPx * zoom) / resolution;
97
+ return MAGNETIC_SCREEN_PX / cellPx;
98
+ }
99
+
100
+ /**
101
+ * How many spans trace a derived ellipse. Circular arcs cannot be an ellipse,
102
+ * so this is an accuracy dial: measured against a true 7×4 oval, 4 spans are
103
+ * off by 0.225 cells, 8 by 0.039, and 16 comfortably under a tenth. A circle
104
+ * is exact at any count, since then each span really is a circular arc.
105
+ */
106
+ const ELLIPSE_SPANS = 16;
107
+
108
+ /** Shapes whose geometry is generated from parameters rather than authored. */
109
+ export function isPrimitiveShape(shape) {
110
+ return shape?.prim === 'ellipse';
111
+ }
112
+
113
+ /**
114
+ * Which derived anchors double as edit handles — the four on the ellipse's own
115
+ * axes, so dragging one reads directly as a radius. The rest of the ring is
116
+ * scenery: showing all sixteen would suggest an editability that is not there.
117
+ */
118
+ export function primitiveHandleIndices(shape) {
119
+ if (!isPrimitiveShape(shape)) return [];
120
+ const quarter = ELLIPSE_SPANS / 4;
121
+ return [0, quarter, 2 * quarter, 3 * quarter];
122
+ }
123
+
124
+ /**
125
+ * Runtime-only ellipse ring as cubic segments (see deriveShape).
126
+ * Never serialized. The generated curve stays off the half-cell lattice so
127
+ * ovals remain faithful; only the parameters are magnetized during authoring
128
+ * (§6.1.2).
129
+ */
130
+ export function ellipseSubpaths(cx, cy, rx, ry) {
131
+ const at = (t) => [cx + rx * Math.cos(t), cy + ry * Math.sin(t)];
132
+ const points = [];
133
+ for (let i = 0; i < ELLIPSE_SPANS; i++) points.push(at((2 * Math.PI * i) / ELLIPSE_SPANS));
134
+
135
+ const start = [points[0][0], points[0][1]];
136
+ const segs = [];
137
+ for (let i = 0; i < ELLIPSE_SPANS; i++) {
138
+ const t0 = (2 * Math.PI * i) / ELLIPSE_SPANS;
139
+ const t1 = (2 * Math.PI * (i + 1)) / ELLIPSE_SPANS;
140
+ const samples = [];
141
+ for (let k = 0; k <= 16; k++) {
142
+ const [x, y] = at(t0 + ((t1 - t0) * k) / 16);
143
+ samples.push({ x, y });
144
+ }
145
+ const from = [points[i][0], points[i][1]];
146
+ const to = points[(i + 1) % ELLIPSE_SPANS];
147
+ const bow = sagittaAgainstChord(samples, from, to);
148
+ segs.push(...bowToSegments(from, to, bow));
149
+ }
150
+ return [{ closed: true, start, segs }];
151
+ }
152
+
153
+ /** Regenerate a primitive's geometry from its parameters. Identity otherwise. */
154
+ export function deriveShape(shape) {
155
+ if (shape?.prim !== 'ellipse') return shape;
156
+ return { ...shape, subpaths: ellipseSubpaths(shape.cx, shape.cy, shape.rx, shape.ry) };
157
+ }
158
+
159
+ export function emptyPathData() {
160
+ return { shapes: [] };
161
+ }
162
+
163
+ export function isEmptyPathData(path) {
164
+ return !path || !Array.isArray(path.shapes) || path.shapes.length === 0;
165
+ }
166
+
167
+ export function pointsEqual(a, b) {
168
+ return (
169
+ a &&
170
+ b &&
171
+ Math.abs(a[0] - b[0]) < 1e-12 &&
172
+ Math.abs(a[1] - b[1]) < 1e-12
173
+ );
174
+ }
175
+
176
+ function cloneSegment(seg) {
177
+ const out = { to: [seg.to[0], seg.to[1]] };
178
+ if (seg.c1) out.c1 = [seg.c1[0], seg.c1[1]];
179
+ if (seg.c2) out.c2 = [seg.c2[0], seg.c2[1]];
180
+ return out;
181
+ }
182
+
183
+ export function cloneSubpath(sub) {
184
+ const norm = normalizeSubpath(sub);
185
+ return {
186
+ closed: !!norm.closed,
187
+ start: [norm.start[0], norm.start[1]],
188
+ segs: (norm.segs ?? []).map(cloneSegment),
189
+ };
190
+ }
191
+
192
+ export function straightSegment(to) {
193
+ return { to: [to[0], to[1]] };
194
+ }
195
+
196
+ export function makeSubpath(closed, start, segs) {
197
+ return {
198
+ closed: !!closed,
199
+ start: [start[0], start[1]],
200
+ segs: (segs ?? []).map(cloneSegment),
201
+ };
202
+ }
203
+
204
+ export function clonePathData(path) {
205
+ return {
206
+ shapes: (path?.shapes ?? []).map((shape) => ({
207
+ ...shape,
208
+ fill: shape.fill ?? null,
209
+ stroke: shape.stroke ?? null,
210
+ subpaths: (shape.subpaths ?? []).map((sub) => cloneSubpath(sub)),
211
+ })),
212
+ };
213
+ }
214
+
215
+ /** Shift every absolute anchor by (dx, dy). Relative c1/c2 offsets are unchanged. */
216
+ export function translatePathData(path, dx, dy) {
217
+ return {
218
+ shapes: (path?.shapes ?? []).map((shape) => {
219
+ // A primitive's centre is the truth; its points are regenerated from it,
220
+ // so moving them alone would be undone by the next derive.
221
+ const moved = isPrimitiveShape(shape)
222
+ ? { ...shape, cx: shape.cx + dx, cy: shape.cy + dy }
223
+ : shape;
224
+ return {
225
+ ...moved,
226
+ subpaths: (shape.subpaths ?? []).map((sub) => ({
227
+ ...sub,
228
+ start: [sub.start[0] + dx, sub.start[1] + dy],
229
+ segs: (sub.segs ?? []).map((seg) => ({
230
+ ...cloneSegment(seg),
231
+ to: [seg.to[0] + dx, seg.to[1] + dy],
232
+ })),
233
+ })),
234
+ };
235
+ }),
236
+ };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // tolerant parse / serialize
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function finite(n, fallback = 0) {
244
+ return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
245
+ }
246
+
247
+ /** Parse one point — finite only; lattice magnetism is an authoring-time aid. */
248
+ function parsePoint(raw) {
249
+ if (!Array.isArray(raw) || raw.length < 2) return [0, 0];
250
+ return [finite(raw[0]) + 0, finite(raw[1]) + 0];
251
+ }
252
+
253
+ function parsePaletteKey(raw) {
254
+ if (raw == null) return null;
255
+ return typeof raw === 'string' && raw.length === 1 ? raw : null;
256
+ }
257
+
258
+ function parseSegment(raw) {
259
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.to)) return null;
260
+ const to = parsePoint(raw.to);
261
+ const seg = { to };
262
+ if (Array.isArray(raw.c1) && raw.c1.length >= 2) {
263
+ seg.c1 = [finite(raw.c1[0]) + 0, finite(raw.c1[1]) + 0];
264
+ }
265
+ if (Array.isArray(raw.c2) && raw.c2.length >= 2) {
266
+ seg.c2 = [finite(raw.c2[0]) + 0, finite(raw.c2[1]) + 0];
267
+ }
268
+ return stripZeroHandles(seg);
269
+ }
270
+
271
+ function parseSubpath(raw) {
272
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.start)) return null;
273
+ const closed = !!raw.closed;
274
+ const start = parsePoint(raw.start);
275
+ const segsIn = Array.isArray(raw.segs) ? raw.segs : [];
276
+ const segs = [];
277
+ for (const s of segsIn) {
278
+ const seg = parseSegment(s);
279
+ if (seg) segs.push(seg);
280
+ }
281
+ if (!start && segs.length === 0) return null;
282
+ return canonicalizeSubpath({ closed, start, segs });
283
+ }
284
+
285
+ const MIN_ELLIPSE_RADIUS = 0.01;
286
+ export { MIN_ELLIPSE_RADIUS };
287
+
288
+ /** Ellipse parameters — continuous; a zero radius would read as empty geometry. */
289
+ function parseEllipseParams(raw) {
290
+ return {
291
+ cx: finite(raw.cx) + 0,
292
+ cy: finite(raw.cy) + 0,
293
+ rx: Math.max(MIN_ELLIPSE_RADIUS, Math.abs(finite(raw.rx))),
294
+ ry: Math.max(MIN_ELLIPSE_RADIUS, Math.abs(finite(raw.ry))),
295
+ };
296
+ }
297
+
298
+ function parseShape(raw) {
299
+ if (!raw || typeof raw !== 'object') return null;
300
+
301
+ // Primitives carry parameters instead of geometry. Derive on the way in so
302
+ // every consumer downstream — bake, stroke tracing, hit-testing, the vector
303
+ // renderer — sees an ordinary shape and needs to know nothing about this.
304
+ if (raw.prim === 'ellipse') {
305
+ const { fill: _pf, stroke: _ps, subpaths: _psp, ...rest } = raw;
306
+ return deriveShape({
307
+ ...rest,
308
+ ...parseEllipseParams(raw),
309
+ fill: parsePaletteKey(raw.fill),
310
+ stroke: parsePaletteKey(raw.stroke),
311
+ });
312
+ }
313
+
314
+ const subpaths = Array.isArray(raw.subpaths)
315
+ ? raw.subpaths.map(parseSubpath).filter(Boolean)
316
+ : [];
317
+ if (subpaths.length === 0 && raw.fill == null && raw.stroke == null) return null;
318
+ // Preserve unknown keys for additive future variants.
319
+ const { fill: _f, stroke: _s, subpaths: _sp, ...rest } = raw;
320
+ return {
321
+ ...rest,
322
+ fill: parsePaletteKey(raw.fill),
323
+ stroke: parsePaletteKey(raw.stroke),
324
+ subpaths,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Tolerantly parse a cell's `path` field. Returns null when unusable at the
330
+ * TOP level (no `shapes` array) — callers then treat the cell as a plain
331
+ * pixel cell (grid is truth regardless). Malformed shapes/subpaths drop
332
+ * individually.
333
+ */
334
+ export function parsePathData(raw) {
335
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.shapes)) return null;
336
+ const shapes = raw.shapes.map(parseShape).filter(Boolean);
337
+ return { shapes };
338
+ }
339
+
340
+ /** Serialize path, or undefined when empty (omit-when-default). */
341
+ export function serializePathData(path) {
342
+ if (isEmptyPathData(path)) return undefined;
343
+ return {
344
+ shapes: path.shapes.map((shape) => {
345
+ const { fill, stroke, subpaths, ...rest } = shape;
346
+ if (isPrimitiveShape(shape)) {
347
+ return {
348
+ ...rest,
349
+ fill: fill ?? null,
350
+ stroke: stroke ?? null,
351
+ cx: roundCoord(shape.cx),
352
+ cy: roundCoord(shape.cy),
353
+ rx: roundCoord(shape.rx),
354
+ ry: roundCoord(shape.ry),
355
+ };
356
+ }
357
+ return {
358
+ ...rest,
359
+ fill: fill ?? null,
360
+ stroke: stroke ?? null,
361
+ subpaths: (subpaths ?? []).map((sub) => serializeSubpath(sub)),
362
+ };
363
+ }),
364
+ };
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Curve sampling + stroke rasterization
369
+ // ---------------------------------------------------------------------------
370
+
371
+ function dist(a, b) {
372
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
373
+ }
374
+
375
+ export function cubicPoint(p0, c1, c2, p1, t) {
376
+ const mt = 1 - t;
377
+ const a = mt * mt * mt;
378
+ const b = 3 * mt * mt * t;
379
+ const c = 3 * mt * t * t;
380
+ const d = t * t * t;
381
+ return [
382
+ a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0],
383
+ a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1],
384
+ ];
385
+ }
386
+
387
+ export function sampleEdgeCurve(edge, verts) {
388
+ const p0 = verts[edge.a] ?? [0, 0];
389
+ const p1 = verts[edge.b] ?? [0, 0];
390
+ const points = [];
391
+ // Representation layer: stored handles win. Bow-only edges (gesture previews,
392
+ // no handles yet) sample the arc chain. Wide arcs are already split into ≤90°
393
+ // cubics at every write/parse funnel, so a single stored cubic never spans one.
394
+ let segs = null;
395
+ if (edge.ha || edge.hb) {
396
+ segs = [
397
+ {
398
+ c1: edge.ha ? [p0[0] + edge.ha[0], p0[1] + edge.ha[1]] : p0,
399
+ c2: edge.hb ? [p1[0] + edge.hb[0], p1[1] + edge.hb[1]] : p1,
400
+ to: p1,
401
+ },
402
+ ];
403
+ } else if (edge.bow != null) {
404
+ segs = arcCubics(p0, p1, edge.bow);
405
+ }
406
+ if (!segs) {
407
+ const steps = Math.max(1, Math.ceil(dist(p0, p1) * 4));
408
+ for (let i = 0; i <= steps; i++) {
409
+ const t = i / steps;
410
+ points.push({
411
+ x: p0[0] + (p1[0] - p0[0]) * t,
412
+ y: p0[1] + (p1[1] - p0[1]) * t,
413
+ t,
414
+ });
415
+ }
416
+ return points;
417
+ }
418
+ let from = p0;
419
+ segs.forEach((seg, segIndex) => {
420
+ const approxLength =
421
+ dist(from, seg.c1) + dist(seg.c1, seg.c2) + dist(seg.c2, seg.to);
422
+ const steps = Math.max(12, Math.ceil(approxLength * 4));
423
+ for (let i = segIndex === 0 ? 0 : 1; i <= steps; i++) {
424
+ const local = i / steps;
425
+ const [x, y] = cubicPoint(from, seg.c1, seg.c2, seg.to, local);
426
+ points.push({ x, y, t: (segIndex + local) / segs.length });
427
+ }
428
+ from = seg.to;
429
+ });
430
+ return points;
431
+ }
432
+
433
+ const BOUNDARY_EPS = 1e-7;
434
+
435
+ function quantizeAxis(v, centroid) {
436
+ const nearest = Math.round(v);
437
+ if (Math.abs(v - nearest) >= BOUNDARY_EPS) return Math.floor(v);
438
+ if (Math.abs(v - centroid) < BOUNDARY_EPS) return Math.floor(v);
439
+ return v < centroid ? nearest - 1 : nearest;
440
+ }
441
+
442
+ function flattenEdge(edge, verts) {
443
+ const samples = sampleEdgeCurve(edge, verts);
444
+ if (samples.length === 0) return { points: [], cx: 0, cy: 0 };
445
+ let cx = 0;
446
+ let cy = 0;
447
+ for (const s of samples) {
448
+ cx += s.x;
449
+ cy += s.y;
450
+ }
451
+ cx /= samples.length;
452
+ cy /= samples.length;
453
+ return {
454
+ points: samples.map(({ x, y, t }) => ({
455
+ x: quantizeAxis(x, cx),
456
+ y: quantizeAxis(y, cy),
457
+ t,
458
+ })),
459
+ cx,
460
+ cy,
461
+ };
462
+ }
463
+
464
+ function bresenham(a, b) {
465
+ const pts = [];
466
+ let x = a.x;
467
+ let y = a.y;
468
+ const dx = Math.abs(b.x - x);
469
+ const dy = Math.abs(b.y - y);
470
+ const sx = b.x >= x ? 1 : -1;
471
+ const sy = b.y >= y ? 1 : -1;
472
+ let err = dx - dy;
473
+ for (;;) {
474
+ pts.push({ x, y });
475
+ if (x === b.x && y === b.y) break;
476
+ const e2 = err * 2;
477
+ if (e2 > -dy) {
478
+ err -= dy;
479
+ x += sx;
480
+ }
481
+ if (e2 < dx) {
482
+ err += dx;
483
+ y += sy;
484
+ }
485
+ }
486
+ const t0 = typeof a.t === 'number' ? a.t : 0;
487
+ const t1 = typeof b.t === 'number' ? b.t : t0;
488
+ if (pts.length === 1) {
489
+ pts[0].t = t0;
490
+ } else {
491
+ for (let i = 0; i < pts.length; i++) {
492
+ pts[i].t = t0 + (t1 - t0) * (i / (pts.length - 1));
493
+ }
494
+ }
495
+ return pts;
496
+ }
497
+
498
+ function repairChain(points) {
499
+ const out = [];
500
+ for (const p of points) {
501
+ const last = out[out.length - 1];
502
+ if (last && last.x === p.x && last.y === p.y) continue;
503
+ if (last && (Math.abs(p.x - last.x) > 1 || Math.abs(p.y - last.y) > 1)) {
504
+ const bridge = bresenham(last, p);
505
+ for (let i = 1; i < bridge.length - 1; i++) out.push(bridge[i]);
506
+ }
507
+ out.push(p);
508
+ }
509
+ return out;
510
+ }
511
+
512
+ function isElbow(prev, cur, next) {
513
+ const diagonal = Math.abs(next.x - prev.x) === 1 && Math.abs(next.y - prev.y) === 1;
514
+ return (
515
+ diagonal && ((cur.x === prev.x && cur.y === next.y) || (cur.y === prev.y && cur.x === next.x))
516
+ );
517
+ }
518
+
519
+ function cellDist2(p, cx, cy) {
520
+ const dx = p.x + 0.5 - cx;
521
+ const dy = p.y + 0.5 - cy;
522
+ return dx * dx + dy * dy;
523
+ }
524
+
525
+ function cleanupCorners(chain, cx, cy) {
526
+ if (chain.length < 3) return chain.slice();
527
+ const candidates = [];
528
+ for (let i = 1; i < chain.length - 1; i++) {
529
+ const prev = chain[i - 1];
530
+ const cur = chain[i];
531
+ const next = chain[i + 1];
532
+ if (!isElbow(prev, cur, next)) continue;
533
+ const opp = {
534
+ x: prev.x === cur.x ? next.x : prev.x,
535
+ y: prev.y === cur.y ? next.y : prev.y,
536
+ };
537
+ const d = cellDist2(cur, cx, cy);
538
+ if (d > cellDist2(opp, cx, cy)) candidates.push({ i, d });
539
+ }
540
+ candidates.sort(
541
+ (a, b) =>
542
+ b.d - a.d ||
543
+ Math.abs(chain[b.i].x + 0.5 - cx) - Math.abs(chain[a.i].x + 0.5 - cx) ||
544
+ Math.abs(chain[b.i].y + 0.5 - cy) - Math.abs(chain[a.i].y + 0.5 - cy),
545
+ );
546
+ const dropped = new Set();
547
+ for (const { i } of candidates) {
548
+ dropped.add(i);
549
+ let ok = true;
550
+ let prevKept = -1;
551
+ for (let j = 0; j < chain.length; j++) {
552
+ if (dropped.has(j)) continue;
553
+ if (prevKept >= 0) {
554
+ const a = chain[prevKept];
555
+ const b = chain[j];
556
+ if (Math.abs(a.x - b.x) > 1 || Math.abs(a.y - b.y) > 1) {
557
+ ok = false;
558
+ break;
559
+ }
560
+ }
561
+ prevKept = j;
562
+ }
563
+ if (!ok) dropped.delete(i);
564
+ }
565
+ return chain.filter((_, i) => !dropped.has(i));
566
+ }
567
+
568
+ /** Flatten → repair → direction-invariant corner cleanup. */
569
+ export function traceEdgePixels(edge, verts) {
570
+ const { points, cx, cy } = flattenEdge(edge, verts);
571
+ return cleanupCorners(repairChain(points), cx, cy);
572
+ }
573
+
574
+ export function closestParamOnEdge(edge, verts, x, y) {
575
+ const samples = sampleEdgeCurve(edge, verts);
576
+ let bestT = 0.5;
577
+ let bestD = Infinity;
578
+ for (const s of samples) {
579
+ const dx = s.x - x;
580
+ const dy = s.y - y;
581
+ const d = dx * dx + dy * dy;
582
+ if (d < bestD) {
583
+ bestD = d;
584
+ bestT = s.t;
585
+ }
586
+ }
587
+ return bestT;
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Detented bow vocabulary
592
+ // ---------------------------------------------------------------------------
593
+
594
+ const BOW_EPS = 1e-12;
595
+
596
+ function chordNormal(pa, pb) {
597
+ const dx = pb[0] - pa[0];
598
+ const dy = pb[1] - pa[1];
599
+ const L = Math.hypot(dx, dy);
600
+ if (L < BOW_EPS) return { L: 0, nx: 0, ny: 0, dx, dy };
601
+ return { L, nx: -dy / L, ny: dx / L, dx, dy };
602
+ }
603
+
604
+ // Detent ratios = tan(θ/4)/2 for central angles θ ∈ {0°, 45°, 90°, 135°, 180°}.
605
+ // For a circular arc of chord L spanning central angle θ, sagitta/L = tan(θ/4)/2.
606
+ // Detents therefore describe arc SHAPE (how much of a circle), not apex
607
+ // position: scale-invariant, and the quarter-circle detent (θ=90°) means four
608
+ // anchors on a circle's cardinals produce an exact circle at every radius.
609
+ // Top detent is 0.5 (semicircle), so |bow| ≤ L/2 is subsumed.
610
+ const BOW_RATIO_DETENTS = [0, 45, 90, 135, 180].map(
611
+ (deg) => Math.tan(((deg * Math.PI) / 180) / 4) / 2,
612
+ );
613
+
614
+ /** Semicircle — also the top detent. */
615
+ const MAX_BOW_RATIO = 0.5;
616
+
617
+ /**
618
+ * How near a detent a raw ratio must fall to be pulled onto it. Ratio units,
619
+ * so it is scale-invariant like the detents themselves. Detent gaps run
620
+ * 0.099–0.166, so this magnetizes roughly the outer fifth of each gap.
621
+ * Purely a feel constant — tune it here.
622
+ */
623
+ export const BOW_DETENT_PULL = 0.03;
624
+
625
+ /**
626
+ * Resolve a raw sagitta against the ratio detents, clamped to a semicircle.
627
+ * Detents are magnetic (BOW_DETENT_PULL), not mandatory — used by bend/gesture
628
+ * tooling; storage is cubics.
629
+ */
630
+ export function snapBow(bow, chordLength, pull = BOW_DETENT_PULL) {
631
+ if (!(chordLength > BOW_EPS) || !Number.isFinite(bow)) return 0;
632
+ const ratio = bow / chordLength;
633
+ const sign = ratio < 0 ? -1 : 1;
634
+ // Clamp to a semicircle so the bow matches what handlesForBow / arcCubics
635
+ // will draw (they clamp too).
636
+ const abs = Math.min(MAX_BOW_RATIO, Math.abs(ratio));
637
+ let best = BOW_RATIO_DETENTS[0];
638
+ let bestDist = Infinity;
639
+ for (const d of BOW_RATIO_DETENTS) {
640
+ const dist = Math.abs(abs - d);
641
+ if (dist < bestDist) {
642
+ bestDist = dist;
643
+ best = d;
644
+ }
645
+ }
646
+ const resolved = bestDist <= pull ? best : abs;
647
+ const out = sign * resolved * chordLength;
648
+ if (Math.abs(out) < BOW_EPS) return 0;
649
+ return out;
650
+ }
651
+
652
+ export function handlesForBow(pa, pb, bow) {
653
+ if (!pa || !pb || !Number.isFinite(bow)) return null;
654
+ const { L, nx, ny } = chordNormal(pa, pb);
655
+ if (L < BOW_EPS) return null;
656
+ const maxBow = L / 2;
657
+ let s = Math.min(maxBow, Math.max(-maxBow, bow));
658
+ if (Math.abs(s) < BOW_EPS) return null;
659
+
660
+ const R = (s * s + (L * L) / 4) / (2 * Math.abs(s));
661
+ const mx = (pa[0] + pb[0]) / 2;
662
+ const my = (pa[1] + pb[1]) / 2;
663
+ const sign = s > 0 ? 1 : -1;
664
+ const cx = mx + (s - sign * R) * nx;
665
+ const cy = my + (s - sign * R) * ny;
666
+
667
+ const sinPhi = Math.min(1, L / (2 * R));
668
+ const phi = Math.asin(sinPhi);
669
+ const k = ((4 / 3) * Math.tan(phi / 2)) * R;
670
+
671
+ let rax = pa[0] - cx;
672
+ let ray = pa[1] - cy;
673
+ let rbx = pb[0] - cx;
674
+ let rby = pb[1] - cy;
675
+ const raLen = Math.hypot(rax, ray);
676
+ const rbLen = Math.hypot(rbx, rby);
677
+ if (raLen < BOW_EPS || rbLen < BOW_EPS) return null;
678
+ rax /= raLen;
679
+ ray /= raLen;
680
+ rbx /= rbLen;
681
+ rby /= rbLen;
682
+
683
+ const toApexX = mx + s * nx - cx;
684
+ const toApexY = my + s * ny - cy;
685
+ const ccw = rax * toApexY - ray * toApexX >= 0;
686
+ const tax = ccw ? -ray : ray;
687
+ const tay = ccw ? rax : -rax;
688
+ const tbx = ccw ? -rby : rby;
689
+ const tby = ccw ? rbx : -rbx;
690
+
691
+ return {
692
+ ha: [k * tax, k * tay],
693
+ hb: [-k * tbx, -k * tby],
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Widest arc drawn as a single cubic. One cubic tracks a circular arc well only
699
+ * while the span stays small, and the falloff is steep: at radius 4 a 90° span
700
+ * is off by 0.001 cells, but a 180° span — a pill's cap, or any bow at the
701
+ * semicircle clamp — is off by 0.073, which is visible. Since a bow can never
702
+ * exceed a semicircle, this ceiling means at most two cubics per edge.
703
+ */
704
+ const MAX_CUBIC_ARC = Math.PI / 2;
705
+
706
+ /**
707
+ * The arc a bow describes, as centre, radius, start angle and signed sweep.
708
+ * Null when the bow is too slight to be an arc at all.
709
+ */
710
+ function arcFromBow(pa, pb, bow) {
711
+ if (!pa || !pb || !Number.isFinite(bow)) return null;
712
+ const { L, nx, ny } = chordNormal(pa, pb);
713
+ if (L < BOW_EPS) return null;
714
+ const maxBow = L / 2;
715
+ const s = Math.min(maxBow, Math.max(-maxBow, bow));
716
+ if (Math.abs(s) < BOW_EPS) return null;
717
+
718
+ const R = (s * s + (L * L) / 4) / (2 * Math.abs(s));
719
+ const mx = (pa[0] + pb[0]) / 2;
720
+ const my = (pa[1] + pb[1]) / 2;
721
+ const sign = s > 0 ? 1 : -1;
722
+ const cx = mx + (s - sign * R) * nx;
723
+ const cy = my + (s - sign * R) * ny;
724
+ if (!(R > BOW_EPS)) return null;
725
+
726
+ // The apex tells us which way round the centre the arc actually travels.
727
+ const rax = pa[0] - cx;
728
+ const ray = pa[1] - cy;
729
+ const ccw = rax * (my + s * ny - cy) - ray * (mx + s * nx - cx) >= 0;
730
+ const sweep = 2 * Math.asin(Math.min(1, L / (2 * R))) * (ccw ? 1 : -1);
731
+ return { cx, cy, R, a0: Math.atan2(ray, rax), sweep };
732
+ }
733
+
734
+ /**
735
+ * A bow as a chain of absolute cubic segments `{ c1, c2, to }`, subdivided so
736
+ * no segment spans more than MAX_CUBIC_ARC. Callers that draw or flatten a bow
737
+ * should use this; `handlesForBow` remains the single-cubic form, still useful
738
+ * as a "can this bow be drawn at all" predicate.
739
+ */
740
+ export function arcCubics(pa, pb, bow) {
741
+ const arc = arcFromBow(pa, pb, bow);
742
+ if (!arc) return null;
743
+ const { cx, cy, R, a0, sweep } = arc;
744
+ const count = Math.max(1, Math.ceil(Math.abs(sweep) / MAX_CUBIC_ARC));
745
+ const step = sweep / count;
746
+ const k = ((4 / 3) * Math.tan(step / 4)) * R;
747
+
748
+ const out = [];
749
+ for (let i = 0; i < count; i++) {
750
+ const t0 = a0 + step * i;
751
+ const t1 = a0 + step * (i + 1);
752
+ const p0 = [cx + R * Math.cos(t0), cy + R * Math.sin(t0)];
753
+ const p1 = [cx + R * Math.cos(t1), cy + R * Math.sin(t1)];
754
+ // Tangents point along the sweep, so k carries the sweep's sign with it.
755
+ out.push({
756
+ c1: [p0[0] - k * Math.sin(t0), p0[1] + k * Math.cos(t0)],
757
+ c2: [p1[0] + k * Math.sin(t1), p1[1] - k * Math.cos(t1)],
758
+ to: i === count - 1 ? [pb[0], pb[1]] : p1,
759
+ });
760
+ }
761
+ return out;
762
+ }
763
+
764
+ /** Max signed orthogonal distance of samples from chord pa→pb. */
765
+ export function sagittaAgainstChord(samples, pa, pb) {
766
+ const { L, dx, dy } = chordNormal(pa, pb);
767
+ if (L < BOW_EPS) return 0;
768
+ let bestDist = 0;
769
+ let bestSign = 0;
770
+ for (const s of samples) {
771
+ const cross = dx * (s.y - pa[1]) - dy * (s.x - pa[0]);
772
+ const side = cross / L;
773
+ const ad = Math.abs(side);
774
+ if (ad > bestDist) {
775
+ bestDist = ad;
776
+ bestSign = side >= 0 ? 1 : -1;
777
+ }
778
+ }
779
+ if (bestDist < BOW_EPS) return 0;
780
+ return bestSign * bestDist;
781
+ }
782
+
783
+ export function bowForEdge(edge, verts) {
784
+ if (!edge || !verts) return 0;
785
+ const pa = verts[edge.a];
786
+ const pb = verts[edge.b];
787
+ if (!pa || !pb) return 0;
788
+ return sagittaAgainstChord(sampleEdgeCurve(edge, verts), pa, pb);
789
+ }
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // Segment-list subpath model (cubic segments)
793
+ // ---------------------------------------------------------------------------
794
+
795
+ function stripZeroHandles(seg) {
796
+ const out = { to: [seg.to[0], seg.to[1]] };
797
+ if (seg.c1 && (Math.abs(seg.c1[0]) > BOW_EPS || Math.abs(seg.c1[1]) > BOW_EPS)) {
798
+ out.c1 = [seg.c1[0], seg.c1[1]];
799
+ }
800
+ if (seg.c2 && (Math.abs(seg.c2[0]) > BOW_EPS || Math.abs(seg.c2[1]) > BOW_EPS)) {
801
+ out.c2 = [seg.c2[0], seg.c2[1]];
802
+ }
803
+ return out;
804
+ }
805
+
806
+ function segmentFromAbsolute(from, to, c1Abs, c2Abs) {
807
+ const seg = { to: [to[0], to[1]] };
808
+ if (c1Abs) {
809
+ const d1 = [c1Abs[0] - from[0], c1Abs[1] - from[1]];
810
+ if (Math.hypot(d1[0], d1[1]) >= BOW_EPS) seg.c1 = d1;
811
+ }
812
+ if (c2Abs) {
813
+ const d2 = [c2Abs[0] - to[0], c2Abs[1] - to[1]];
814
+ if (Math.hypot(d2[0], d2[1]) >= BOW_EPS) seg.c2 = d2;
815
+ }
816
+ return stripZeroHandles(seg);
817
+ }
818
+
819
+ export function absoluteSegment(from, seg) {
820
+ const to = seg.to;
821
+ const c1 = seg.c1 ? [from[0] + seg.c1[0], from[1] + seg.c1[1]] : [from[0], from[1]];
822
+ const c2 = seg.c2 ? [to[0] + seg.c2[0], to[1] + seg.c2[1]] : [to[0], to[1]];
823
+ return { from, to, c1, c2 };
824
+ }
825
+
826
+ export function bowToSegments(from, to, bow) {
827
+ if (Math.abs(bow) < BOW_EPS) return [straightSegment(to)];
828
+ const cubics = arcCubics(from, to, bow);
829
+ if (!cubics?.length) return [straightSegment(to)];
830
+ const out = [];
831
+ let cur = from;
832
+ for (const c of cubics) {
833
+ out.push(segmentFromAbsolute(cur, c.to, c.c1, c.c2));
834
+ cur = c.to;
835
+ }
836
+ return out;
837
+ }
838
+
839
+ /**
840
+ * Drop a closing segment that only restates `start` in a straight line.
841
+ *
842
+ * Closure is implicit, as in SVG's `Z`: a closed subpath's segment list ends at
843
+ * its last distinct vertex, and the edge back to `start` is synthesized on read.
844
+ * A *curved* close can't be synthesized — it carries handles — so it stays an
845
+ * explicit final segment, which is why subpathClosesOnStart still has work to
846
+ * do. Canonicalizing here, on the one funnel every reader goes through, is what
847
+ * keeps the two forms from coexisting in memory and disagreeing on edge count.
848
+ */
849
+ function canonicalizeSubpath(sub) {
850
+ const segs = sub.segs ?? [];
851
+ const last = segs[segs.length - 1];
852
+ if (!sub.closed || !last || last.c1 || last.c2) return sub;
853
+ if (!pointsEqual(last.to, sub.start)) return sub;
854
+ return { ...sub, segs: segs.slice(0, -1) };
855
+ }
856
+
857
+ /** Canonical start/segs form (clone + drop redundant straight closes). */
858
+ export function normalizeSubpath(sub) {
859
+ if (!sub || typeof sub !== 'object' || !Array.isArray(sub.start)) return sub;
860
+ return canonicalizeSubpath({
861
+ closed: !!sub.closed,
862
+ start: [sub.start[0], sub.start[1]],
863
+ segs: (sub.segs ?? []).map(cloneSegment),
864
+ });
865
+ }
866
+
867
+ /**
868
+ * True when the subpath carries an explicit closing segment duplicating `start`.
869
+ *
870
+ * That duplicate is vertex 0, not a vertex of its own, so it is invisible to
871
+ * subpathPoints. Anything that rebuilds segments by index must map the closing
872
+ * segment back to vertex 0 — indexing it as `segIndex + 1` strands it at the old
873
+ * coordinate, which surfaces as a phantom anchor and a spike back to where the
874
+ * shape used to be. Expects a start-form subpath (normalizeSubpath output).
875
+ */
876
+ export function subpathClosesOnStart(sub) {
877
+ const segs = sub.segs ?? [];
878
+ return sub.closed && segs.length > 0 && pointsEqual(segs[segs.length - 1].to, sub.start);
879
+ }
880
+
881
+ /** Distinct anchor count (closing duplicate omitted). */
882
+ export function subpathVertexCount(sub) {
883
+ const s = normalizeSubpath(sub);
884
+ const segs = s.segs ?? [];
885
+ if (!s?.start) return 0;
886
+ if (!segs.length) return 1;
887
+ if (subpathClosesOnStart(s)) return segs.length;
888
+ return 1 + segs.length;
889
+ }
890
+
891
+ export function subpathPointAt(sub, index) {
892
+ const s = normalizeSubpath(sub);
893
+ const segs = s.segs ?? [];
894
+ if (!s?.start || index < 0) return null;
895
+ if (index === 0) return [s.start[0], s.start[1]];
896
+ if (subpathClosesOnStart(s)) {
897
+ if (index >= segs.length) return null;
898
+ return [segs[index - 1].to[0], segs[index - 1].to[1]];
899
+ }
900
+ const seg = segs[index - 1];
901
+ return seg ? [seg.to[0], seg.to[1]] : null;
902
+ }
903
+
904
+ export function subpathPoints(sub) {
905
+ const n = subpathVertexCount(sub);
906
+ const pts = [];
907
+ for (let i = 0; i < n; i++) {
908
+ const p = subpathPointAt(sub, i);
909
+ if (p) pts.push(p);
910
+ }
911
+ return pts;
912
+ }
913
+
914
+ /**
915
+ * True when the edge back to `start` is implied rather than stored — the
916
+ * canonical shape of a closed subpath whose closing edge is straight.
917
+ * Expects a canonicalized subpath (normalizeSubpath output).
918
+ */
919
+ function hasImpliedClose(sub) {
920
+ return !!sub.closed && (sub.segs?.length ?? 0) > 0 && !subpathClosesOnStart(sub);
921
+ }
922
+
923
+ export function subpathEdgeCount(sub) {
924
+ const s = normalizeSubpath(sub);
925
+ const n = s.segs?.length ?? 0;
926
+ return hasImpliedClose(s) ? n + 1 : n;
927
+ }
928
+
929
+ export function subpathEdgeFrom(sub, edgeIndex) {
930
+ const s = normalizeSubpath(sub);
931
+ const segs = s.segs ?? [];
932
+ if (edgeIndex === segs.length && hasImpliedClose(s)) {
933
+ return {
934
+ from: subpathPointAt(s, segs.length),
935
+ seg: straightSegment([s.start[0], s.start[1]]),
936
+ };
937
+ }
938
+ const seg = segs[edgeIndex];
939
+ if (!seg) return null;
940
+ const from =
941
+ edgeIndex === 0
942
+ ? [s.start[0], s.start[1]]
943
+ : subpathPointAt(s, edgeIndex);
944
+ return { from, seg };
945
+ }
946
+
947
+ export function segmentToEdge(from, seg, strokeKey = null) {
948
+ const edge = { a: 0, b: 1, key: strokeKey };
949
+ if (seg.c1) edge.ha = [seg.c1[0], seg.c1[1]];
950
+ if (seg.c2) edge.hb = [seg.c2[0], seg.c2[1]];
951
+ return edge;
952
+ }
953
+
954
+ export function bowForSegment(from, seg) {
955
+ return bowForEdge(segmentToEdge(from, seg), [from, seg.to]);
956
+ }
957
+
958
+ function lerpPoint(a, b, t) {
959
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
960
+ }
961
+
962
+ /** de Casteljau split of one cubic segment at parameter t ∈ [0,1]. */
963
+ export function deCasteljauSplit(from, seg, t) {
964
+ const { from: p0, to: p3, c1: p1, c2: p2 } = absoluteSegment(from, seg);
965
+ if (!seg.c1 && !seg.c2) {
966
+ const mid = lerpPoint(p0, p3, t);
967
+ return {
968
+ mid,
969
+ left: straightSegment(mid),
970
+ right: straightSegment(p3),
971
+ };
972
+ }
973
+ const p01 = lerpPoint(p0, p1, t);
974
+ const p12 = lerpPoint(p1, p2, t);
975
+ const p23 = lerpPoint(p2, p3, t);
976
+ const p012 = lerpPoint(p01, p12, t);
977
+ const p123 = lerpPoint(p12, p23, t);
978
+ const p0123 = lerpPoint(p012, p123, t);
979
+ return {
980
+ mid: [p0123[0], p0123[1]],
981
+ left: segmentFromAbsolute(p0, p0123, p01, p012),
982
+ right: segmentFromAbsolute(p0123, p3, p123, p23),
983
+ };
984
+ }
985
+
986
+ function serializeSegment(seg) {
987
+ const out = { to: roundPoint(seg.to[0], seg.to[1]) };
988
+ if (seg.c1) out.c1 = [roundCoord(seg.c1[0]), roundCoord(seg.c1[1])];
989
+ if (seg.c2) out.c2 = [roundCoord(seg.c2[0]), roundCoord(seg.c2[1])];
990
+ return out;
991
+ }
992
+
993
+ function serializeSubpath(sub) {
994
+ const norm = normalizeSubpath(sub);
995
+ return {
996
+ closed: !!norm.closed,
997
+ start: roundPoint(norm.start[0], norm.start[1]),
998
+ segs: (norm.segs ?? []).map(serializeSegment),
999
+ };
1000
+ }
1001
+
1002
+ export function transformSegmentHandles(seg, mapVector) {
1003
+ const out = { to: [seg.to[0], seg.to[1]] };
1004
+ if (seg.c1) {
1005
+ const v = mapVector(seg.c1);
1006
+ if (Math.hypot(v[0], v[1]) >= BOW_EPS) out.c1 = v;
1007
+ }
1008
+ if (seg.c2) {
1009
+ const v = mapVector(seg.c2);
1010
+ if (Math.hypot(v[0], v[1]) >= BOW_EPS) out.c2 = v;
1011
+ }
1012
+ return stripZeroHandles(out);
1013
+ }
1014
+
1015
+ // ---------------------------------------------------------------------------
1016
+ // Stacked bake
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ export const FILL_SUPERSAMPLE = 4;
1020
+
1021
+ function sampleCubic(p0, c1, c2, p1, steps) {
1022
+ const out = [];
1023
+ for (let i = 0; i <= steps; i++) {
1024
+ const t = i / steps;
1025
+ out.push(cubicPoint(p0, c1, c2, p1, t));
1026
+ }
1027
+ return out;
1028
+ }
1029
+
1030
+ /**
1031
+ * Flatten one subpath to a polyline via cubic segments.
1032
+ *
1033
+ * A closed subpath's polyline returns to `start`, walking the implied closing
1034
+ * edge. Point-in-polygon does not need that — it wraps — but anything that
1035
+ * measures or samples *along* the outline does: without it, dense sampling has
1036
+ * no points on one whole side, which silently blinded the punch-hole
1037
+ * containment test on every ring's closing edge.
1038
+ */
1039
+ export function flattenSubpath(sub, stepsPerUnit = 4) {
1040
+ const norm = normalizeSubpath(sub);
1041
+ const pts = [];
1042
+ if (!norm?.start) return pts;
1043
+ pts.push([norm.start[0], norm.start[1]]);
1044
+ let from = norm.start;
1045
+ for (const seg of norm.segs ?? []) {
1046
+ const to = seg.to;
1047
+ const { c1, c2 } = absoluteSegment(from, seg);
1048
+ const L = Math.hypot(to[0] - from[0], to[1] - from[1]);
1049
+ if (L < 1e-12) {
1050
+ from = to;
1051
+ continue;
1052
+ }
1053
+ if (!seg.c1 && !seg.c2) {
1054
+ pts.push([to[0], to[1]]);
1055
+ from = to;
1056
+ continue;
1057
+ }
1058
+ const steps = Math.max(4, Math.ceil(L * stepsPerUnit));
1059
+ const samples = sampleCubic(from, c1, c2, to, steps);
1060
+ for (let k = 1; k < samples.length; k++) pts.push(samples[k]);
1061
+ from = to;
1062
+ }
1063
+ if (hasImpliedClose(norm)) pts.push([norm.start[0], norm.start[1]]);
1064
+ return pts;
1065
+ }
1066
+
1067
+ function pointInPolyEvenOdd(px, py, poly) {
1068
+ let inside = false;
1069
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1070
+ const xi = poly[i][0];
1071
+ const yi = poly[i][1];
1072
+ const xj = poly[j][0];
1073
+ const yj = poly[j][1];
1074
+ if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi || Number.EPSILON) + xi) {
1075
+ inside = !inside;
1076
+ }
1077
+ }
1078
+ return inside;
1079
+ }
1080
+
1081
+ function shapeContains(rings, px, py) {
1082
+ let inside = false;
1083
+ for (const ring of rings) {
1084
+ if (ring.length >= 3 && pointInPolyEvenOdd(px, py, ring)) inside = !inside;
1085
+ }
1086
+ return inside;
1087
+ }
1088
+
1089
+ function closedRings(shape) {
1090
+ const rings = [];
1091
+ for (const sub of shape?.subpaths ?? []) {
1092
+ if (!sub.closed) continue;
1093
+ const poly = flattenSubpath(sub, 6);
1094
+ if (poly.length >= 3) rings.push(poly);
1095
+ }
1096
+ return rings;
1097
+ }
1098
+
1099
+ /** Even-odd fill test for a shape's closed subpaths (editor hit-testing). */
1100
+ export function shapeContainsPoint(shape, px, py) {
1101
+ if (!shape) return false;
1102
+ return shapeContains(closedRings(shape), px, py);
1103
+ }
1104
+
1105
+ /** Visit each native pixel a shape's stroke would stamp (editor + bake). */
1106
+ export function forEachStrokePixel(shape, visit) {
1107
+ if (!shape?.stroke) return;
1108
+ for (const sub of shape.subpaths ?? []) {
1109
+ const { verts, edges } = edgeObjectsForSubpath(sub, shape.stroke);
1110
+ for (const edge of edges) {
1111
+ for (const p of traceEdgePixels(edge, verts)) visit(p.x, p.y);
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ /** Build edge objects for one subpath (shared by bake + editor). */
1117
+ export function edgeObjectsForSubpath(sub, strokeKey = null) {
1118
+ const norm = normalizeSubpath(sub);
1119
+ const verts = [[norm.start[0], norm.start[1]]];
1120
+ for (const seg of norm.segs ?? []) verts.push([seg.to[0], seg.to[1]]);
1121
+ const edges = [];
1122
+ (norm.segs ?? []).forEach((seg, i) => {
1123
+ const edge = { a: i, b: i + 1, key: strokeKey };
1124
+ if (seg.c1) edge.ha = [seg.c1[0], seg.c1[1]];
1125
+ if (seg.c2) edge.hb = [seg.c2[0], seg.c2[1]];
1126
+ edges.push(edge);
1127
+ });
1128
+ if (hasImpliedClose(norm)) edges.push({ a: verts.length - 1, b: 0, key: strokeKey });
1129
+ return { verts, edges };
1130
+ }
1131
+
1132
+ /**
1133
+ * Majority-downsample a supersampled key raster.
1134
+ * Cell paints when painted subpixels cover ≥ half (ties paint).
1135
+ * Color = majority key among painted subpixels; on key-count ties prefer the
1136
+ * key with the highest last-paint order (painter's-order tiebreak).
1137
+ *
1138
+ * `orderSS` is parallel to `fillSS`: each painted subpixel carries the stack
1139
+ * paint order at which it was last written (higher = later).
1140
+ */
1141
+ function downsampleCompositeMajority(fillSS, orderSS, width, height, scale) {
1142
+ const fill = Array.from({ length: height }, () => new Array(width).fill(null));
1143
+ const minCoverage = (scale * scale) / 2;
1144
+ for (let y = 0; y < height; y++) {
1145
+ for (let x = 0; x < width; x++) {
1146
+ const counts = new Map();
1147
+ const maxOrder = new Map();
1148
+ let covered = 0;
1149
+ for (let sy = 0; sy < scale; sy++) {
1150
+ for (let sx = 0; sx < scale; sx++) {
1151
+ const key = fillSS[y * scale + sy][x * scale + sx];
1152
+ if (key == null) continue;
1153
+ covered += 1;
1154
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1155
+ const ord = orderSS[y * scale + sy][x * scale + sx];
1156
+ maxOrder.set(key, Math.max(maxOrder.get(key) ?? -1, ord));
1157
+ }
1158
+ }
1159
+ if (covered < minCoverage) continue;
1160
+ let best = null;
1161
+ let bestCount = -1;
1162
+ let bestOrd = -1;
1163
+ for (const [k, n] of counts) {
1164
+ const ord = maxOrder.get(k) ?? -1;
1165
+ if (n > bestCount || (n === bestCount && ord > bestOrd) || (n === bestCount && ord === bestOrd && k < best)) {
1166
+ bestCount = n;
1167
+ bestOrd = ord;
1168
+ best = k;
1169
+ }
1170
+ }
1171
+ fill[y][x] = best;
1172
+ }
1173
+ }
1174
+ return fill;
1175
+ }
1176
+
1177
+ /**
1178
+ * The supersample index window covering `rings`, clipped to the canvas. Returns
1179
+ * null when the rings fall entirely outside it.
1180
+ *
1181
+ * A shape can only fill samples inside its own bounding box, so this is what
1182
+ * keeps the fill scan proportional to the SHAPE rather than the canvas. Without
1183
+ * it the scan is canvas-area × supersample² × shape count, and since the
1184
+ * supersample is squared, the cost grows with the fourth power of resolution:
1185
+ * shapes that are cheap on a 16x16 sprite are not merely slower at 64x64, they
1186
+ * are unusable, and nothing in the editor hints at why.
1187
+ */
1188
+ function ringsSampleWindow(rings, width, height, scale) {
1189
+ let minX = Infinity;
1190
+ let minY = Infinity;
1191
+ let maxX = -Infinity;
1192
+ let maxY = -Infinity;
1193
+ for (const ring of rings) {
1194
+ for (const [x, y] of ring) {
1195
+ if (x < minX) minX = x;
1196
+ if (x > maxX) maxX = x;
1197
+ if (y < minY) minY = y;
1198
+ if (y > maxY) maxY = y;
1199
+ }
1200
+ }
1201
+ if (!(minX <= maxX && minY <= maxY)) return null;
1202
+ // Sample centers sit at (i + 0.5) / scale, so a sample is in range when
1203
+ // i >= minX * scale - 0.5. Floor/ceil out by one to stay conservative.
1204
+ const x0 = Math.max(0, Math.floor(minX * scale - 0.5));
1205
+ const x1 = Math.min(width * scale - 1, Math.ceil(maxX * scale + 0.5));
1206
+ const y0 = Math.max(0, Math.floor(minY * scale - 0.5));
1207
+ const y1 = Math.min(height * scale - 1, Math.ceil(maxY * scale + 0.5));
1208
+ if (x0 > x1 || y0 > y1) return null;
1209
+ return { x0, x1, y0, y1 };
1210
+ }
1211
+
1212
+ /**
1213
+ * The x where each ring edge crosses the horizontal line y = py, unsorted.
1214
+ * Reuses `out` so the fill scan doesn't allocate per row.
1215
+ */
1216
+ function crossingsAtRow(rings, py, out) {
1217
+ out.length = 0;
1218
+ for (const ring of rings) {
1219
+ if (ring.length < 3) continue;
1220
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1221
+ const yi = ring[i][1];
1222
+ const yj = ring[j][1];
1223
+ if (yi > py === yj > py) continue;
1224
+ const xi = ring[i][0];
1225
+ const xj = ring[j][0];
1226
+ out.push(((xj - xi) * (py - yi)) / (yj - yi || Number.EPSILON) + xi);
1227
+ }
1228
+ }
1229
+ return out;
1230
+ }
1231
+
1232
+ /**
1233
+ * Paint a shape's fill by scanning row by row rather than testing every sample
1234
+ * against every edge.
1235
+ *
1236
+ * Sampling each point independently costs edge-count work per sample, so the
1237
+ * fill is canvas-area × supersample² × edges × shapes. The supersample is
1238
+ * squared, so cost climbs with the FOURTH power of resolution: shapes that bake
1239
+ * instantly at 16x16 take seconds at 128x128, and the editor gives no hint why.
1240
+ * Every sample in a row crosses the same edges, so the crossings are computed
1241
+ * once per row and each sample just reads its parity — edges + width per row
1242
+ * instead of edges × width.
1243
+ *
1244
+ * The parity walk deliberately reuses the exact `px < crossing` comparison the
1245
+ * per-sample test used, on the same `px`, so this is bit-identical to the naive
1246
+ * scan rather than merely equivalent in principle. Even-odd applies across ALL
1247
+ * rings at once (that is what makes multi-subpath donuts work), which is why
1248
+ * every ring's crossings go into one sorted list.
1249
+ */
1250
+ function paintFillIntoComposite(fillSS, orderSS, shape, order, width, height, scale) {
1251
+ if (!shape.fill) return;
1252
+ const rings = closedRings(shape);
1253
+ if (!rings.length) return;
1254
+ const window = ringsSampleWindow(rings, width, height, scale);
1255
+ if (!window) return;
1256
+
1257
+ const crossings = [];
1258
+
1259
+ for (let y = window.y0; y <= window.y1; y++) {
1260
+ const py = (y + 0.5) / scale;
1261
+ crossingsAtRow(rings, py, crossings);
1262
+ if (crossings.length === 0) continue;
1263
+ crossings.sort(ascending);
1264
+
1265
+ const fillRow = fillSS[y];
1266
+ const orderRow = orderSS[y];
1267
+ // `next` is the first crossing to the right of the current sample. It only
1268
+ // moves forward as x grows, so each row costs one pass over the crossings.
1269
+ let next = 0;
1270
+ for (let x = window.x0; x <= window.x1; x++) {
1271
+ const px = (x + 0.5) / scale;
1272
+ while (next < crossings.length && !(px < crossings[next])) next++;
1273
+ if ((crossings.length - next) % 2 === 0) continue;
1274
+ fillRow[x] = shape.fill;
1275
+ orderRow[x] = order;
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ function ascending(a, b) {
1281
+ return a - b;
1282
+ }
1283
+
1284
+ function stampStrokeBlock(fillSS, orderSS, x, y, key, order, width, height, scale) {
1285
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
1286
+ const y0 = y * scale;
1287
+ const x0 = x * scale;
1288
+ for (let sy = 0; sy < scale; sy++) {
1289
+ for (let sx = 0; sx < scale; sx++) {
1290
+ fillSS[y0 + sy][x0 + sx] = key;
1291
+ orderSS[y0 + sy][x0 + sx] = order;
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ function paintStrokeIntoComposite(fillSS, orderSS, shape, order, width, height, scale) {
1297
+ forEachStrokePixel(shape, (x, y) => {
1298
+ stampStrokeBlock(fillSS, orderSS, x, y, shape.stroke, order, width, height, scale);
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Normative stacked bake.
1304
+ *
1305
+ * Compositing fills at supersample BEFORE a single downsample means two
1306
+ * abutting shapes' subpixels union across the shared boundary, so boundary
1307
+ * cells go to whoever owns the majority — no 1px transparent gutters (the
1308
+ * proto-stacked per-shape downsample left that artifact). Stroke cells stamp
1309
+ * as full scale×scale blocks so 1px stroke discipline survives while a later
1310
+ * shape's fill can still cover an earlier stroke (painter's order).
1311
+ */
1312
+ export function bakePathCell(path, width, height) {
1313
+ const outW = Math.max(0, width | 0);
1314
+ const outH = Math.max(0, height | 0);
1315
+ const grid = Array.from({ length: outH }, () => new Array(outW).fill(TRANSPARENT));
1316
+ if (!path || outW === 0 || outH === 0) {
1317
+ return grid.map((row) => row.join(''));
1318
+ }
1319
+
1320
+ const S = FILL_SUPERSAMPLE;
1321
+ const ssW = outW * S;
1322
+ const ssH = outH * S;
1323
+ const fillSS = Array.from({ length: ssH }, () => new Array(ssW).fill(null));
1324
+ const orderSS = Array.from({ length: ssH }, () => new Array(ssW).fill(-1));
1325
+
1326
+ let order = 0;
1327
+ for (const shape of path.shapes ?? []) {
1328
+ // Fill then stroke within the shape; each write bumps paint order so
1329
+ // stroke wins over that shape's own fill, and later shapes win ties.
1330
+ paintFillIntoComposite(fillSS, orderSS, shape, order++, outW, outH, S);
1331
+ paintStrokeIntoComposite(fillSS, orderSS, shape, order++, outW, outH, S);
1332
+ }
1333
+
1334
+ const native = downsampleCompositeMajority(fillSS, orderSS, outW, outH, S);
1335
+ for (let y = 0; y < outH; y++) {
1336
+ for (let x = 0; x < outW; x++) {
1337
+ if (native[y][x] != null) grid[y][x] = native[y][x];
1338
+ }
1339
+ }
1340
+ return grid.map((row) => row.join(''));
1341
+ }
1342
+
1343
+ /** Alias used by tests / validation. */
1344
+ export const bakeStacked = bakePathCell;
1345
+
1346
+ /**
1347
+ * Collect palette keys referenced by path source (for serialize pruning).
1348
+ */
1349
+ export function pathSourceKeys(path) {
1350
+ const used = new Set();
1351
+ for (const shape of path?.shapes ?? []) {
1352
+ if (shape.fill) used.add(shape.fill);
1353
+ if (shape.stroke) used.add(shape.stroke);
1354
+ }
1355
+ return used;
1356
+ }