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
@@ -1464,10 +1464,14 @@ function useScenePlayLoop({
1464
1464
  useEffect(() => {
1465
1465
  if (!isPlaying) {
1466
1466
  runtimeRef.current = null;
1467
- return;
1467
+ return undefined;
1468
1468
  }
1469
- if (!sceneData) return;
1470
- runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1469
+ if (!sceneData) return undefined;
1470
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1471
+ runtimeRef.current = runtime;
1472
+ // Pressing Stop has to stop what the runtime started -- a sound keeps
1473
+ // playing on its own otherwise, with no scene left to stop it.
1474
+ return () => runtime.dispose();
1471
1475
  // eslint-disable-next-line react-hooks/exhaustive-deps
1472
1476
  }, [sprites, isPlaying, text, runtimeRef]);
1473
1477
  // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
@@ -10,6 +10,8 @@ import { getFileKind } from '../engine/files';
10
10
  import { useLiveDeckFiles } from '../engine/liveReload';
11
11
  import { collectAssets } from '../engine/assets';
12
12
  import { MainEditor, styles } from '../engine/ui';
13
+ import { ImageViewer } from './ImageViewer';
14
+ import { MediaPlayer } from './MediaPlayer';
13
15
  import { PxArtEditor } from './PxArtEditor';
14
16
  import { SceneEditor } from './SceneEditor';
15
17
 
@@ -111,6 +113,12 @@ export function SingleEditor({ path, editor }) {
111
113
  );
112
114
  } else if (kind === 'pxart') {
113
115
  body = <PxArtEditor path={path} text={text} onChange={onChange} />;
116
+ } else if (kind === 'image') {
117
+ // Viewers read their file's BYTES over the serve, not the text file map --
118
+ // `files` holds source, and an image decoded as utf-8 is nothing.
119
+ body = <ImageViewer path={path} />;
120
+ } else if (kind === 'audio' || kind === 'video') {
121
+ body = <MediaPlayer path={path} kind={kind} />;
114
122
  }
115
123
  // No `else`: code/text files are handled by the builtin shell code editor, so
116
124
  // the shell never routes them here. `body` stays null for any other kind.
@@ -1,17 +1,15 @@
1
- // Discover every behavior class from `behaviors/*.jsx` and the physics module's
2
- // `physics/behaviors/**/*.jsx`. Vite HMR is off, so a newly-added behavior file is
3
- // picked up on the next reload/restart. The physics glob is what lets the
4
- // self-contained physics module ship its behaviors without polluting the core
5
- // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
1
+ // Discover every behavior class from `behaviors/**/*.jsx`. Vite HMR is off, so a
2
+ // newly-added behavior file is picked up on the next reload/restart.
6
3
  // Root-anchored so the kit finds the DECK's behaviors even when the kit is
7
4
  // itself an import (see engine/files.js). Imports are swept first and the deck's
8
5
  // own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
9
6
  // deck defines wins over one of the same name from a dependency.
7
+ // `behaviors/` is the ONE place behaviors live -- the rest of the system (the
8
+ // deck file map, the editor's visible paths, lint, the duplicate gate) keys off
9
+ // that directory, so a behavior kept anywhere else is invisible to all of it.
10
10
  const modules = {
11
11
  ...import.meta.glob('/imports/*/behaviors/**/*.jsx', { eager: true }),
12
- ...import.meta.glob('/imports/*/physics/behaviors/**/*.jsx', { eager: true }),
13
12
  ...import.meta.glob('/behaviors/**/*.jsx', { eager: true }),
14
- ...import.meta.glob('/physics/behaviors/**/*.jsx', { eager: true }),
15
13
  };
16
14
  function isBehaviorClass(value) {
17
15
  return typeof value === 'function' && typeof value.behaviorName === 'string';
@@ -0,0 +1,535 @@
1
+ // Freehand brush → detented path vocabulary.
2
+ // Pure functions (Node-testable). Constants are fixed / opinionated — no knobs.
3
+ // Sibling spirit of scripts/import-svg.mjs bow fitting, but for sampled ink.
4
+
5
+ import { bowToSegments, handlesForBow, magneticSnapPoint, snapBow, straightSegment, subpathVertexCount, LATTICE } from '../engine/pxartPath.js';
6
+
7
+ // --- Tuned constants (each comment: what it does) ---
8
+
9
+ /** Capture: skip a raw pointer sample closer than this (cells) to the previous. */
10
+ export const DECIMATE_MIN_DIST = 0.05;
11
+
12
+ /** Resample ink to this even arc-length spacing (cells) before corner/RDP/bow work. */
13
+ export const RESAMPLE_SPACING = 0.1;
14
+
15
+ /** Corner detection: look at turn over this arc-length window (cells). */
16
+ export const CORNER_WINDOW = 1.0;
17
+
18
+ /** Corner detection: mark a sample a corner when the local turn exceeds this (degrees). */
19
+ export const CORNER_ANGLE_DEG = 60;
20
+
21
+ /** Auto-close: endpoint must land within this distance (cells) of the start. */
22
+ export const AUTO_CLOSE_DIST = 1.5;
23
+
24
+ /** Auto-close: require at least this total ink length (cells) before closing. */
25
+ export const AUTO_CLOSE_MIN_LENGTH = 4;
26
+
27
+ /** Ramer–Douglas–Peucker: max deviation (cells) allowed from a chord when dropping samples. */
28
+ export const RDP_TOLERANCE = 0.75;
29
+
30
+ /** After lattice snap: drop anchors that leave a segment shorter than this (cells). */
31
+ export const MIN_SEGMENT = 0.75;
32
+
33
+ /** Reject the whole gesture when total ink length is under this (cells). */
34
+ export const MIN_INK_LENGTH = 1.5;
35
+
36
+ /** Bow fit: treat a span as straight when |raw sagitta| is below this (cells) before snap. */
37
+ export const STRAIGHT_SAGITTA = 0.2;
38
+
39
+ const CORNER_ANGLE_RAD = (CORNER_ANGLE_DEG * Math.PI) / 180;
40
+
41
+ function dist(a, b) {
42
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
43
+ }
44
+
45
+ /** Append a raw pointer sample, skipping near-duplicates. Mutates and returns ink. */
46
+ export function appendInkPoint(ink, x, y) {
47
+ if (!ink.length) {
48
+ ink.push([x, y]);
49
+ return ink;
50
+ }
51
+ const prev = ink[ink.length - 1];
52
+ if (Math.hypot(x - prev[0], y - prev[1]) < DECIMATE_MIN_DIST) return ink;
53
+ ink.push([x, y]);
54
+ return ink;
55
+ }
56
+
57
+ /** Polyline arc length. */
58
+ export function inkLength(points) {
59
+ let len = 0;
60
+ for (let i = 1; i < points.length; i++) len += dist(points[i - 1], points[i]);
61
+ return len;
62
+ }
63
+
64
+ /** Resample to roughly even arc-length spacing. */
65
+ export function resampleInk(points, spacing = RESAMPLE_SPACING) {
66
+ if (!points?.length) return [];
67
+ if (points.length === 1) return [[points[0][0], points[0][1]]];
68
+ const total = inkLength(points);
69
+ if (total < 1e-12) return [[points[0][0], points[0][1]]];
70
+ const out = [[points[0][0], points[0][1]]];
71
+ let acc = 0;
72
+ let nextAt = spacing;
73
+ for (let i = 1; i < points.length; i++) {
74
+ const a = points[i - 1];
75
+ const b = points[i];
76
+ const seg = dist(a, b);
77
+ if (seg < 1e-12) continue;
78
+ let t0 = 0;
79
+ while (acc + seg * (1 - t0) >= nextAt - 1e-12) {
80
+ const need = nextAt - acc;
81
+ const t = t0 + need / seg;
82
+ out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
83
+ nextAt += spacing;
84
+ t0 = t;
85
+ }
86
+ acc += seg * (1 - t0);
87
+ }
88
+ const last = points[points.length - 1];
89
+ const tip = out[out.length - 1];
90
+ if (dist(tip, last) > spacing * 0.25) out.push([last[0], last[1]]);
91
+ else {
92
+ tip[0] = last[0];
93
+ tip[1] = last[1];
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Indices into `samples` that are intentional corners.
100
+ * Endpoints of an open gesture are always corners. Closed: wrap-aware turns only
101
+ * (the join is not double-counted as an endpoint pair).
102
+ */
103
+ export function detectCorners(samples, closed) {
104
+ const n = samples.length;
105
+ if (n === 0) return [];
106
+ if (n === 1) return [0];
107
+ if (n === 2) return closed ? [0] : [0, 1];
108
+
109
+ const half = CORNER_WINDOW / 2;
110
+ const flags = new Array(n).fill(false);
111
+ if (!closed) {
112
+ flags[0] = true;
113
+ flags[n - 1] = true;
114
+ }
115
+
116
+ // Prefix arc lengths for window lookups.
117
+ const arc = new Array(n).fill(0);
118
+ for (let i = 1; i < n; i++) arc[i] = arc[i - 1] + dist(samples[i - 1], samples[i]);
119
+ const total = closed ? arc[n - 1] + dist(samples[n - 1], samples[0]) : arc[n - 1];
120
+
121
+ function lerpAlong(a, b, u) {
122
+ return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
123
+ }
124
+
125
+ function sampleOnOpenArc(t) {
126
+ for (let i = 1; i < n; i++) {
127
+ if (arc[i] >= t) {
128
+ const a0 = arc[i - 1];
129
+ return lerpAlong(samples[i - 1], samples[i], (t - a0) / (arc[i] - a0 || 1e-12));
130
+ }
131
+ }
132
+ return [samples[n - 1][0], samples[n - 1][1]];
133
+ }
134
+
135
+ function sampleAtArc(target) {
136
+ if (!closed) return sampleOnOpenArc(Math.max(0, Math.min(total, target)));
137
+ let t = ((target % total) + total) % total;
138
+ if (t <= arc[n - 1]) return sampleOnOpenArc(t);
139
+ const a0 = arc[n - 1];
140
+ return lerpAlong(samples[n - 1], samples[0], (t - a0) / (total - a0 || 1e-12));
141
+ }
142
+
143
+ const start = closed ? 0 : 1;
144
+ const end = closed ? n : n - 1;
145
+ for (let i = start; i < end; i++) {
146
+ const s = arc[i];
147
+ const prev = sampleAtArc(s - half);
148
+ const next = sampleAtArc(s + half);
149
+ const vx0 = samples[i][0] - prev[0];
150
+ const vy0 = samples[i][1] - prev[1];
151
+ const vx1 = next[0] - samples[i][0];
152
+ const vy1 = next[1] - samples[i][1];
153
+ const L0 = Math.hypot(vx0, vy0);
154
+ const L1 = Math.hypot(vx1, vy1);
155
+ if (L0 < 1e-9 || L1 < 1e-9) continue;
156
+ const dot = (vx0 * vx1 + vy0 * vy1) / (L0 * L1);
157
+ const ang = Math.acos(Math.max(-1, Math.min(1, dot)));
158
+ if (ang >= CORNER_ANGLE_RAD) flags[i] = true;
159
+ }
160
+
161
+ const idxs = [];
162
+ for (let i = 0; i < n; i++) if (flags[i]) idxs.push(i);
163
+ if (!idxs.length) idxs.push(0);
164
+ return idxs;
165
+ }
166
+
167
+ function pointLineDist(p, a, b) {
168
+ const dx = b[0] - a[0];
169
+ const dy = b[1] - a[1];
170
+ const L2 = dx * dx + dy * dy;
171
+ if (L2 < 1e-18) return dist(p, a);
172
+ const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2;
173
+ const qx = a[0] + t * dx;
174
+ const qy = a[1] + t * dy;
175
+ return Math.hypot(p[0] - qx, p[1] - qy);
176
+ }
177
+
178
+ /** Open-polyline RDP; returns kept indices into `pts` (inclusive ends). */
179
+ export function rdpIndices(pts, tol = RDP_TOLERANCE) {
180
+ const n = pts.length;
181
+ if (n <= 2) return n === 0 ? [] : n === 1 ? [0] : [0, 1];
182
+ const keep = new Array(n).fill(false);
183
+ keep[0] = true;
184
+ keep[n - 1] = true;
185
+ const stack = [[0, n - 1]];
186
+ while (stack.length) {
187
+ const [i0, i1] = stack.pop();
188
+ let maxD = 0;
189
+ let maxI = -1;
190
+ const a = pts[i0];
191
+ const b = pts[i1];
192
+ for (let i = i0 + 1; i < i1; i++) {
193
+ const d = pointLineDist(pts[i], a, b);
194
+ if (d > maxD) {
195
+ maxD = d;
196
+ maxI = i;
197
+ }
198
+ }
199
+ if (maxI >= 0 && maxD > tol) {
200
+ keep[maxI] = true;
201
+ stack.push([i0, maxI], [maxI, i1]);
202
+ }
203
+ }
204
+ const out = [];
205
+ for (let i = 0; i < n; i++) if (keep[i]) out.push(i);
206
+ return out;
207
+ }
208
+
209
+ /** Closed-loop RDP via a diameter split into two open halves. */
210
+ function rdpClosedIndices(pts, tol = RDP_TOLERANCE) {
211
+ const n = pts.length;
212
+ if (n <= 2) return [...Array(n).keys()];
213
+ let maxD = 0;
214
+ let maxI = 1;
215
+ for (let i = 1; i < n; i++) {
216
+ const d = dist(pts[i], pts[0]);
217
+ if (d > maxD) {
218
+ maxD = d;
219
+ maxI = i;
220
+ }
221
+ }
222
+ const halfA = pts.slice(0, maxI + 1);
223
+ const halfB = pts.slice(maxI).concat([pts[0]]);
224
+ const idxA = rdpIndices(halfA, tol);
225
+ const idxB = rdpIndices(halfB, tol);
226
+ const set = new Set();
227
+ for (const i of idxA) set.add(i);
228
+ for (const i of idxB) {
229
+ const global = i < halfB.length - 1 ? maxI + i : 0;
230
+ set.add(global);
231
+ }
232
+ return [...set].sort((a, b) => a - b);
233
+ }
234
+
235
+ /**
236
+ * Signed sagitta of ink between two anchors: mean perpendicular deviation of
237
+ * mid-span samples from the chord (sign-correct, mirror-symmetric).
238
+ */
239
+ export function fitBowForSpan(spanPts, a, b) {
240
+ const dx = b[0] - a[0];
241
+ const dy = b[1] - a[1];
242
+ const L = Math.hypot(dx, dy);
243
+ if (L < 1e-12) return 0;
244
+ const nx = -dy / L;
245
+ const ny = dx / L;
246
+ const mx = (a[0] + b[0]) / 2;
247
+ const my = (a[1] + b[1]) / 2;
248
+ if (!spanPts?.length) return 0;
249
+
250
+ // Prefer samples in the middle third of the span (by index).
251
+ const n = spanPts.length;
252
+ const i0 = Math.floor(n * 0.33);
253
+ const i1 = Math.max(i0 + 1, Math.ceil(n * 0.67));
254
+ let sum = 0;
255
+ let count = 0;
256
+ for (let i = i0; i < i1; i++) {
257
+ const p = spanPts[i];
258
+ sum += (p[0] - mx) * nx + (p[1] - my) * ny;
259
+ count++;
260
+ }
261
+ if (!count) {
262
+ const mid = spanPts[Math.floor(n / 2)];
263
+ sum = (mid[0] - mx) * nx + (mid[1] - my) * ny;
264
+ count = 1;
265
+ }
266
+ const raw = sum / count;
267
+ if (Math.abs(raw) < STRAIGHT_SAGITTA) return 0;
268
+ const bow = snapBow(raw, L);
269
+ if (bow !== 0 && !handlesForBow(a, b, bow)) return 0;
270
+ return bow;
271
+ }
272
+
273
+ function spanBetween(samples, i0, i1, closed) {
274
+ const n = samples.length;
275
+ if (!closed) return samples.slice(i0, i1 + 1);
276
+ if (i0 <= i1) return samples.slice(i0, i1 + 1);
277
+ return samples.slice(i0).concat(samples.slice(0, i1 + 1));
278
+ }
279
+
280
+ /**
281
+ * Snap anchors (pixel centers by default; half-cell lattice when halfPixel),
282
+ * merge coincidences, drop short edges.
283
+ * `cornerFlags[i]` marks anchors that must not lose to a non-corner neighbor.
284
+ */
285
+ export function snapMergeCull(anchors, cornerFlags, halfPixel = false, cellPull = LATTICE / 2) {
286
+ if (!anchors.length) return { points: [], corners: [] };
287
+ let pts = anchors.map((p) => magneticSnapPoint(p[0], p[1], cellPull, halfPixel));
288
+ let corners = cornerFlags.map(Boolean);
289
+
290
+ // Merge consecutive duplicates (and wrap for later closed handling at caller).
291
+ const merged = [];
292
+ const mergedCorners = [];
293
+ for (let i = 0; i < pts.length; i++) {
294
+ const p = pts[i];
295
+ if (merged.length && dist(merged[merged.length - 1], p) < 1e-9) {
296
+ mergedCorners[mergedCorners.length - 1] =
297
+ mergedCorners[mergedCorners.length - 1] || corners[i];
298
+ continue;
299
+ }
300
+ merged.push(p);
301
+ mergedCorners.push(corners[i]);
302
+ }
303
+ pts = merged;
304
+ corners = mergedCorners;
305
+
306
+ // Drop short segments: never drop a corner in favor of a non-corner.
307
+ let changed = true;
308
+ while (changed && pts.length > 2) {
309
+ changed = false;
310
+ for (let i = 0; i < pts.length - 1; i++) {
311
+ if (dist(pts[i], pts[i + 1]) + 1e-12 >= MIN_SEGMENT) continue;
312
+ const drop = corners[i] && !corners[i + 1] ? i + 1 : !corners[i] && corners[i + 1] ? i : i + 1;
313
+ // Prefer dropping non-corners; if both corners, drop the later one.
314
+ const choice =
315
+ corners[i] === corners[i + 1] ? (corners[i] ? i + 1 : drop) : drop;
316
+ if (corners[choice] && !corners[choice === i ? i + 1 : i]) {
317
+ // Would drop corner for non-corner — skip this pair.
318
+ continue;
319
+ }
320
+ pts.splice(choice, 1);
321
+ corners.splice(choice, 1);
322
+ changed = true;
323
+ break;
324
+ }
325
+ }
326
+ return { points: pts, corners };
327
+ }
328
+
329
+ function autoClose(samples) {
330
+ if (samples.length < 2) return false;
331
+ const len = inkLength(samples);
332
+ if (len < AUTO_CLOSE_MIN_LENGTH) return false;
333
+ return dist(samples[0], samples[samples.length - 1]) <= AUTO_CLOSE_DIST;
334
+ }
335
+
336
+ function isValidResult(closed, start, segs) {
337
+ const n = subpathVertexCount({ closed, start, segs });
338
+ if (closed) {
339
+ if (n >= 3) return true;
340
+ if (n === 2) return (segs ?? []).some((s) => s.c1 || s.c2);
341
+ return false;
342
+ }
343
+ return n >= 2;
344
+ }
345
+
346
+ /**
347
+ * Fit raw freehand ink to a single subpath in the detented vocabulary.
348
+ * Returns null when the gesture is rejected (too short / invalid after fit).
349
+ * Does NOT assign fill/stroke — caller applies active palette key.
350
+ */
351
+ export function fitBrushInk(rawInk, { halfPixel = false, cellPull = LATTICE / 2 } = {}) {
352
+ if (!rawInk?.length) return null;
353
+ const length = inkLength(rawInk);
354
+ if (length < MIN_INK_LENGTH) return null;
355
+
356
+ const samples = resampleInk(rawInk, RESAMPLE_SPACING);
357
+ if (samples.length < 2) return null;
358
+
359
+ let closed = autoClose(samples);
360
+ // When auto-closing, drop a trailing tip that sits on the start so the loop
361
+ // doesn't double-count the join; keep samples as an open ring for indexing.
362
+ let ring = samples;
363
+ if (closed && dist(samples[0], samples[samples.length - 1]) < RESAMPLE_SPACING * 0.5) {
364
+ ring = samples.slice(0, -1);
365
+ if (ring.length < 2) return null;
366
+ }
367
+
368
+ const cornerIdxs = detectCorners(ring, closed);
369
+ const cornerSet = new Set(cornerIdxs);
370
+
371
+ // Build ordered corner list around the ring.
372
+ let corners = [...cornerIdxs].sort((a, b) => a - b);
373
+ if (closed && corners.length === 0) corners = [0];
374
+ if (!closed) {
375
+ if (corners[0] !== 0) corners = [0, ...corners];
376
+ if (corners[corners.length - 1] !== ring.length - 1) corners = [...corners, ring.length - 1];
377
+ // dedupe
378
+ corners = [...new Set(corners)].sort((a, b) => a - b);
379
+ }
380
+
381
+ // Runs between consecutive corners (wrap if closed).
382
+ const runEnds = [];
383
+ if (closed) {
384
+ if (corners.length === 1) {
385
+ // Smooth closed blob: one circular run.
386
+ runEnds.push({ from: corners[0], to: corners[0], fullLoop: true });
387
+ } else {
388
+ for (let i = 0; i < corners.length; i++) {
389
+ const from = corners[i];
390
+ const to = corners[(i + 1) % corners.length];
391
+ runEnds.push({ from, to, fullLoop: false });
392
+ }
393
+ }
394
+ } else {
395
+ for (let i = 0; i < corners.length - 1; i++) {
396
+ runEnds.push({ from: corners[i], to: corners[i + 1], fullLoop: false });
397
+ }
398
+ }
399
+
400
+ // Simplify each run; collect global sample indices for anchors.
401
+ const anchorSampleIdx = new Set(corners);
402
+ for (const run of runEnds) {
403
+ let runPts;
404
+ let mapBack;
405
+ if (run.fullLoop) {
406
+ // Rotate so the sole corner is index 0, then closed RDP.
407
+ const rot = ring.slice(run.from).concat(ring.slice(0, run.from));
408
+ const kept = rdpClosedIndices(rot, RDP_TOLERANCE);
409
+ for (const k of kept) {
410
+ anchorSampleIdx.add((run.from + k) % ring.length);
411
+ }
412
+ continue;
413
+ }
414
+ if (run.from <= run.to) {
415
+ runPts = ring.slice(run.from, run.to + 1);
416
+ mapBack = (j) => run.from + j;
417
+ } else {
418
+ runPts = ring.slice(run.from).concat(ring.slice(0, run.to + 1));
419
+ mapBack = (j) => {
420
+ const after = ring.length - run.from;
421
+ return j < after ? run.from + j : j - after;
422
+ };
423
+ }
424
+ if (runPts.length < 2) continue;
425
+ const kept = rdpIndices(runPts, RDP_TOLERANCE);
426
+ for (const j of kept) anchorSampleIdx.add(mapBack(j));
427
+ }
428
+
429
+ let orderedIdx = [...anchorSampleIdx].sort((a, b) => a - b);
430
+ if (closed && orderedIdx.length >= 2) {
431
+ // Closed: do not keep a duplicate of start at the end.
432
+ // (ring is not closed with a repeated first point.)
433
+ }
434
+
435
+ let anchors = orderedIdx.map((i) => [ring[i][0], ring[i][1]]);
436
+ let isCorner = orderedIdx.map((i) => cornerSet.has(i));
437
+ // Endpoints of open paths are corners.
438
+ if (!closed && isCorner.length) {
439
+ isCorner[0] = true;
440
+ isCorner[isCorner.length - 1] = true;
441
+ }
442
+
443
+ const culled = snapMergeCull(anchors, isCorner, halfPixel, cellPull);
444
+ anchors = culled.points;
445
+ isCorner = culled.corners;
446
+
447
+ // Closed: merge first/last if they snapped together.
448
+ if (closed && anchors.length >= 2 && dist(anchors[0], anchors[anchors.length - 1]) < 1e-9) {
449
+ anchors.pop();
450
+ isCorner[0] = isCorner[0] || isCorner.pop();
451
+ }
452
+
453
+ // Short wrap edge on closed paths.
454
+ if (closed && anchors.length >= 2) {
455
+ while (anchors.length > 2 && dist(anchors[0], anchors[anchors.length - 1]) < MIN_SEGMENT) {
456
+ const last = anchors.length - 1;
457
+ if (isCorner[last] && !isCorner[0]) {
458
+ // drop first non-corner — rare; drop last if both corners
459
+ anchors.splice(last, 1);
460
+ isCorner.splice(last, 1);
461
+ } else if (!isCorner[last] && isCorner[0]) {
462
+ anchors.splice(last, 1);
463
+ isCorner.splice(last, 1);
464
+ } else if (!isCorner[last]) {
465
+ anchors.splice(last, 1);
466
+ isCorner.splice(last, 1);
467
+ } else {
468
+ break;
469
+ }
470
+ }
471
+ }
472
+
473
+ if (!anchors.length) return null;
474
+
475
+ // Map snapped anchors back onto ring indices for bow spans (nearest sample).
476
+ function nearestSampleIndex(p) {
477
+ let best = 0;
478
+ let bestD = Infinity;
479
+ for (let i = 0; i < ring.length; i++) {
480
+ const d = dist(ring[i], p);
481
+ if (d < bestD) {
482
+ bestD = d;
483
+ best = i;
484
+ }
485
+ }
486
+ return best;
487
+ }
488
+ const sampleForAnchor = anchors.map(nearestSampleIndex);
489
+
490
+ const edgeCount = closed ? anchors.length : Math.max(0, anchors.length - 1);
491
+ const segs = [];
492
+ for (let e = 0; e < edgeCount; e++) {
493
+ const a = anchors[e];
494
+ const b = anchors[(e + 1) % anchors.length];
495
+ const i0 = sampleForAnchor[e];
496
+ const i1 = sampleForAnchor[(e + 1) % sampleForAnchor.length];
497
+ const span = spanBetween(ring, i0, i1, closed);
498
+ const bow = fitBowForSpan(span, a, b);
499
+ segs.push(...bowToSegments(a, b, bow));
500
+ }
501
+
502
+ const start = [anchors[0][0], anchors[0][1]];
503
+ if (!isValidResult(closed, start, segs)) {
504
+ if (closed && anchors.length >= 2) {
505
+ closed = false;
506
+ const openSegs = segs.slice(0, Math.max(0, anchors.length - 1));
507
+ if (!isValidResult(false, start, openSegs)) return null;
508
+ return { closed: false, start, segs: openSegs };
509
+ }
510
+ return null;
511
+ }
512
+
513
+ return { closed, start, segs };
514
+ }
515
+
516
+ /** Build a stacked-shape object from a fit result + active palette key. */
517
+ export function shapeFromBrushFit(fit, activeKey) {
518
+ if (!fit) return null;
519
+ // Always stroke + transparent fill (closed or open); fill is a separate toggle.
520
+ return {
521
+ fill: null,
522
+ stroke: activeKey,
523
+ subpaths: [
524
+ {
525
+ closed: !!fit.closed,
526
+ start: [fit.start[0], fit.start[1]],
527
+ segs: (fit.segs ?? []).map((s) => ({
528
+ to: [s.to[0], s.to[1]],
529
+ c1: s.c1 ? [s.c1[0], s.c1[1]] : undefined,
530
+ c2: s.c2 ? [s.c2[0], s.c2[1]] : undefined,
531
+ })),
532
+ },
533
+ ],
534
+ };
535
+ }