castle-web-cli 0.4.115 → 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 (55) 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 +6 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.js +27 -3
  7. package/dist/imports.d.ts +4 -0
  8. package/dist/imports.js +59 -10
  9. package/dist/init.d.ts +3 -0
  10. package/dist/init.js +99 -87
  11. package/dist/install.d.ts +1 -1
  12. package/dist/install.js +26 -24
  13. package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
  14. package/dist/shell/index.html +1 -1
  15. package/dist/vitePlugins.js +1 -1
  16. package/kits/physics-2d/CLAUDE.md +28 -16
  17. package/kits/physics-2d/behaviors/Joints.jsx +1 -1
  18. package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
  19. package/kits/physics-2d/blueprints/ball.scene +1 -1
  20. package/kits/physics-2d/blueprints/block.scene +1 -1
  21. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  22. package/kits/physics-2d/blueprints/crate.scene +1 -1
  23. package/kits/physics-2d/castle.json +11 -3
  24. package/kits/physics-2d/docs/pxart-format.md +537 -51
  25. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  26. package/kits/physics-2d/editors/brushFit.js +535 -0
  27. package/kits/physics-2d/editors/brushShapes.js +140 -0
  28. package/kits/physics-2d/editors/mediaFile.js +12 -1
  29. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  30. package/kits/physics-2d/editors/pathTools.js +1906 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  33. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  34. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  36. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  37. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  38. package/kits/physics-2d/engine/assets.js +1 -1
  39. package/kits/physics-2d/engine/blueprint.js +3 -3
  40. package/kits/physics-2d/engine/files.js +3 -1
  41. package/kits/physics-2d/engine/liveReload.js +1 -1
  42. package/kits/physics-2d/engine/physics/jointArt.js +3 -3
  43. package/kits/physics-2d/engine/pxart.js +153 -35
  44. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  45. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  46. package/kits/physics-2d/engine/ui.jsx +22 -1
  47. package/kits/physics-2d/engine/ui.module.css +36 -12
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-2d/scripts/draw.mjs +7 -7
  50. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  51. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  52. package/package.json +1 -1
  53. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  54. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  55. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
@@ -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
+ }
@@ -0,0 +1,140 @@
1
+ import { LATTICE, MIN_ELLIPSE_RADIUS, deriveShape, straightSegment } from '../engine/pxartPath.js';
2
+ import { snapPenAnchor } from './pathTools.js';
3
+ import { shapeBounds } from './pixelGeometry.js';
4
+
5
+ const MIN_DRAG = 0.25;
6
+
7
+ function snapPt(x, y, halfPixel, cellPull) {
8
+ return snapPenAnchor(x, y, halfPixel, cellPull);
9
+ }
10
+
11
+ /** Shift: square drag box from start, sized to the larger axis (sign preserved). */
12
+ function constrainDragEnd(start, end) {
13
+ const dx = end.x - start.x;
14
+ const dy = end.y - start.y;
15
+ const side = Math.max(Math.abs(dx), Math.abs(dy));
16
+ if (side < MIN_DRAG) return end;
17
+ return {
18
+ x: start.x + Math.sign(dx || 1) * side,
19
+ y: start.y + Math.sign(dy || 1) * side,
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Build a stroke-only path shape from a brush primitive drag.
25
+ * Returns null when the gesture is too small or degenerate after snap.
26
+ */
27
+ export function shapeFromBrushDrag(
28
+ mode,
29
+ start,
30
+ end,
31
+ { halfPixel = false, constrain = false, activeKey, cellPull = LATTICE / 2 } = {}
32
+ ) {
33
+ if (!start || !end || !activeKey) return null;
34
+ const [sx, sy] = snapPt(start.x, start.y, halfPixel, cellPull);
35
+ let dragEnd = end;
36
+ // Square the drag box for rect only — squaring circles makes min/max radius identical.
37
+ if (constrain && mode === 'rect') {
38
+ dragEnd = constrainDragEnd({ x: sx, y: sy }, end);
39
+ }
40
+ const [ex, ey] = snapPt(dragEnd.x, dragEnd.y, halfPixel, cellPull);
41
+ const from = { x: sx, y: sy };
42
+ const to = { x: ex, y: ey };
43
+
44
+ // One segment per edge *between* the given corners: the edge from the last
45
+ // corner back to the first is implied by `closed`, not stored.
46
+ const strokedPolygon = (pts, key) => ({
47
+ fill: null,
48
+ stroke: key,
49
+ subpaths: [{ closed: true, start: pts[0], segs: pts.slice(1).map(straightSegment) }],
50
+ });
51
+
52
+ if (mode === 'line') {
53
+ if (Math.hypot(ex - sx, ey - sy) < MIN_DRAG) return null;
54
+ return {
55
+ fill: null,
56
+ stroke: activeKey,
57
+ subpaths: [{ closed: false, start: [sx, sy], segs: [straightSegment([ex, ey])] }],
58
+ };
59
+ }
60
+
61
+ const { x0, x1, y0, y1 } = shapeBounds(from, to);
62
+ if (Math.abs(x1 - x0) < MIN_DRAG && Math.abs(y1 - y0) < MIN_DRAG) return null;
63
+
64
+ if (mode === 'rect') {
65
+ if (constrain) {
66
+ const side = Math.max(Math.abs(ex - sx), Math.abs(ey - sy));
67
+ if (side < MIN_DRAG) return null;
68
+ const x1 = sx + Math.sign(ex - sx || 1) * side;
69
+ const y1 = sy + Math.sign(ey - sy || 1) * side;
70
+ const pts = [
71
+ [sx, sy],
72
+ [x1, sy],
73
+ [x1, y1],
74
+ [sx, y1],
75
+ ];
76
+ return strokedPolygon(pts, activeKey);
77
+ }
78
+ if (Math.abs(x1 - x0) < MIN_DRAG || Math.abs(y1 - y0) < MIN_DRAG) return null;
79
+ const pts = [
80
+ [x0, y0],
81
+ [x1, y0],
82
+ [x1, y1],
83
+ [x0, y1],
84
+ ].map(([x, y]) => snapPt(x, y, halfPixel, cellPull));
85
+ return strokedPolygon(pts, activeKey);
86
+ }
87
+
88
+ if (mode === 'triangle') {
89
+ if (Math.hypot(ex - sx, ey - sy) < MIN_DRAG) return null;
90
+ // Drag defines the hypotenuse (start → end); right angle at (start.x, end.y).
91
+ const right = snapPt(sx, ey, halfPixel, cellPull);
92
+ const pts = [
93
+ [sx, sy],
94
+ right,
95
+ [ex, ey],
96
+ ];
97
+ if (
98
+ Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) < MIN_DRAG ||
99
+ Math.hypot(pts[1][0] - pts[2][0], pts[1][1] - pts[2][1]) < MIN_DRAG ||
100
+ Math.hypot(pts[2][0] - pts[0][0], pts[2][1] - pts[0][1]) < MIN_DRAG
101
+ ) {
102
+ return null;
103
+ }
104
+ return strokedPolygon(pts, activeKey);
105
+ }
106
+
107
+ if (mode === 'circle') {
108
+ const spanX = Math.abs(ex - sx);
109
+ const spanY = Math.abs(ey - sy);
110
+ // Independent radii, so the drag box is the shape. Shift squares it back
111
+ // into a circle. Previously the smaller span won and an oval was
112
+ // unreachable from this gesture at all.
113
+ const rawRx = (constrain ? Math.max(spanX, spanY) : spanX) / 2;
114
+ const rawRy = (constrain ? Math.max(spanX, spanY) : spanY) / 2;
115
+ if (Math.min(rawRx, rawRy) < MIN_DRAG) return null;
116
+ const [cx, cy] = snapPt((sx + ex) / 2, (sy + ey) / 2, halfPixel, cellPull);
117
+ return deriveShape({
118
+ prim: 'ellipse',
119
+ cx,
120
+ cy,
121
+ rx: Math.max(MIN_ELLIPSE_RADIUS, rawRx),
122
+ ry: Math.max(MIN_ELLIPSE_RADIUS, rawRy),
123
+ fill: null,
124
+ stroke: activeKey,
125
+ });
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ /** Ghost wrapper for overlay preview (stroke key applied at commit). */
132
+ export function ghostFromBrushDrag(
133
+ mode,
134
+ start,
135
+ end,
136
+ { halfPixel = false, constrain = false, activeKey, cellPull = LATTICE / 2 } = {}
137
+ ) {
138
+ const shape = shapeFromBrushDrag(mode, start, end, { halfPixel, constrain, activeKey, cellPull });
139
+ return shape;
140
+ }
@@ -1,5 +1,16 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { fileUrl, onFilesChanged } from 'castle-web-sdk';
2
+ import * as sdk from 'castle-web-sdk';
3
+
4
+ // `fileUrl` arrived in castle-web-sdk 0.4.12, and a deck takes a kit update
5
+ // (`castle-web update-import`) without reinstalling its packages -- so a deck
6
+ // still holding an older SDK would meet a kit that imports a name its SDK does
7
+ // not export, which is a hard build failure, not an undefined. Hence the
8
+ // namespace import and the fallback: taking a kit update can't break a deck over
9
+ // a version skew it never asked about. Delete both once no deck can be older
10
+ // than 0.4.12.
11
+ const fileUrl =
12
+ sdk.fileUrl ?? ((path) => `/__castle/files/raw?path=${encodeURIComponent(path)}`);
13
+ const { onFilesChanged } = sdk;
3
14
 
4
15
  // The URL a media viewer points at, re-minted whenever the file itself changes
5
16
  // on disk (a re-upload over the same name, an agent regenerating an asset). The