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
@@ -7,71 +7,38 @@
7
7
  // the normal 1px/cell `renderSpriteFrame`.
8
8
  //
9
9
  // This is a LOCAL, per-pixel kernel filter — the same shape of algorithm as
10
- // Animal Crossing's actual smoothing (xBRZ-style template matching), not the
11
- // global boundary-trace-and-round approach this file used to implement. That
12
- // approach traced each region's FULL pixel-boundary loop and could detect
13
- // long straight/staircase runs across the whole loop; it looked fine on
14
- // blocky shapes but flattened organic curves it decided were "too regular",
15
- // which isn't fixable by tuning since the run detection itself is the
16
- // problem. This version never looks past a pixel's immediate 3x3
17
- // neighborhood, so there is no run/regularity detection to mis-fire — it
18
- // physically cannot flatten a curve, because it has no notion of a curve at
19
- // all, only of each corner in isolation.
20
- //
21
- // Approach, per source pixel P:
22
- // 1. Composite the frame normally (`renderSpriteFrame` — this already
23
- // handles layer visibility/opacity/blend), then read back the native
24
- // resolution raster. Regions are exact-RGBA-equality color runs, same as
25
- // before.
26
- // 2. Supersample: P gets its own `scale` x `scale` block of the output
27
- // canvas (plain grid subdivision — every output pixel belongs to
28
- // exactly one source pixel's block, so there is no possibility of a
29
- // gap or overlap between neighboring pixels' rendering).
30
- // 3. Fill P's whole block with its own color, then independently classify
31
- // each of P's 4 corners against ONLY the 3 pixels touching that corner
32
- // (2 edge-adjacent neighbors + 1 diagonal) and, for genuine convex
33
- // corners, paint a `cornerRadius`-sized quarter-circle "cut" over that
34
- // corner revealing the relevant neighbor's color (see `cornerFill`).
35
- // Because every pixel only ever paints within its OWN block, this recoloring
36
- // can never create a gap: it's the same guarantee a supersampled nearest-
37
- // neighbor render already has, just with a softened corner instead of a hard
38
- // one.
39
- //
40
- // Kept as a sibling of pxart.js (rather than inside it) so the format
41
- // parser/serializer stays focused on the on-disk shape.
10
+ // Animal Crossing's actual smoothing (xBRZ-style template matching), not a
11
+ // global vectorization pass. Under `renderer: "vector"`,
12
+ // `renderSmoothCompositeFrame` paints each path layer's shape stack
13
+ // analytically into the supersampled canvas (even-odd fills + 1-cell-wide
14
+ // round-cap strokes) in painter's order.
42
15
  // ============================================================================
43
16
 
44
- import { MAX_CORNER_RADIUS, renderSpriteFrame } from './pxart';
17
+ import {
18
+ MAX_CORNER_RADIUS,
19
+ colorForKeyV2,
20
+ isVectorRenderer,
21
+ paletteLookup,
22
+ renderSpriteFrame,
23
+ resolveCell,
24
+ } from './pxart';
25
+ import { absoluteSegment, normalizeSubpath } from './pxartPath.js';
45
26
 
46
27
  export const DEFAULT_SMOOTH_SCALE = 8;
47
28
 
48
- // Fallback only for a caller that omits `cornerRadius` entirely; every real
49
- // caller passes the sprite's own file-level `cornerRadius` value (see
50
- // docs/pxart-format.md and Sprite.jsx/PxArtEditor.jsx), which is how the
51
- // corner radius ends up being a portable, per-sprite part of the format
52
- // rather than a fixed constant every smooth sprite is stuck with.
53
29
  const FALLBACK_CORNER_RADIUS = 0.25;
54
30
 
55
- /** Render one frame of a Sprite with locally corner-rounded fills,
56
- * supersampled into `canvas` (sized to width*scale x height*scale). Reuses
57
- * `renderSpriteFrame` for compositing (layers, opacity, visibility, blend),
58
- * so those keep working unchanged. `cornerRadius` is in native-pixel units,
59
- * clamped to `MAX_CORNER_RADIUS` (two cuts on the same edge must not
60
- * overlap). */
31
+ /** Render one frame with locally corner-rounded fills. */
61
32
  export function renderSmoothSpriteFrame(
62
33
  sprite,
63
34
  frameIndex,
64
35
  canvas,
65
- { scale = DEFAULT_SMOOTH_SCALE, cornerRadius = FALLBACK_CORNER_RADIUS } = {}
36
+ { scale = DEFAULT_SMOOTH_SCALE, cornerRadius = FALLBACK_CORNER_RADIUS } = {},
66
37
  ) {
67
38
  const clampedRadius = Math.min(MAX_CORNER_RADIUS, Math.max(0, cornerRadius));
68
- const { width, height } = sprite.resolution;
69
- canvas.width = Math.max(1, Math.round(width * scale));
70
- canvas.height = Math.max(1, Math.round(height * scale));
71
- const ctx = canvas.getContext('2d');
72
- if (!ctx) return;
73
- ctx.clearRect(0, 0, canvas.width, canvas.height);
74
- if (width <= 0 || height <= 0) return;
39
+ const target = setupScaledCanvas(canvas, sprite.resolution, scale);
40
+ if (!target) return;
41
+ const { ctx, width, height } = target;
75
42
 
76
43
  const native = document.createElement('canvas');
77
44
  renderSpriteFrame(sprite, frameIndex, native);
@@ -79,9 +46,6 @@ export function renderSmoothSpriteFrame(
79
46
  const data = nctx?.getImageData(0, 0, width, height).data;
80
47
  if (!data) return;
81
48
 
82
- // null = out of canvas OR fully transparent; both read as "not this pixel's
83
- // color" to every classification below, same as the old mask's treatment
84
- // of out-of-canvas/transparent as background.
85
49
  const colorAt = (x, y) => {
86
50
  if (x < 0 || x >= width || y < 0 || y >= height) return null;
87
51
  const i = (y * width + x) * 4;
@@ -90,31 +54,215 @@ export function renderSmoothSpriteFrame(
90
54
  };
91
55
 
92
56
  ctx.save();
93
- ctx.scale(scale, scale);
94
- // Every pixel is processed, including transparent ones: a fully-enclosed
95
- // transparent "hole" is, from its own corners' point of view, exactly the
96
- // same kind of convex corner as an opaque pixel poking out of a
97
- // background — it needs to cut (and round) its OWN corners the same way,
98
- // or an enclosed 1px hole would stay a hard square forever (its opaque
99
- // neighbors never round toward it, by the same-neighbor rule below).
57
+ ctx.imageSmoothingEnabled = false;
100
58
  for (let y = 0; y < height; y++) {
101
- for (let x = 0; x < width; x++) drawPixelBlock(ctx, colorAt, x, y, colorAt(x, y), clampedRadius);
59
+ for (let x = 0; x < width; x++) {
60
+ drawPixelBlock(ctx, colorAt, x, y, scale, colorAt(x, y), clampedRadius);
61
+ }
102
62
  }
103
63
  ctx.restore();
104
64
  }
105
65
 
66
+ // Render the "smooth" finish one layer at a time. Path layers paint their
67
+ // shape stack analytically at supersample; pixel layers use maximum corner
68
+ // smoothing. Layer visibility and opacity remain authoritative.
69
+ export function renderSmoothCompositeFrame(
70
+ sprite,
71
+ frameIndex,
72
+ canvas,
73
+ { scale = DEFAULT_SMOOTH_SCALE } = {},
74
+ ) {
75
+ const target = setupScaledCanvas(canvas, sprite.resolution, scale);
76
+ if (!target) return;
77
+ const { ctx, width, height } = target;
78
+
79
+ const lookup = paletteLookup(sprite.palette);
80
+ const prevAlpha = ctx.globalAlpha;
81
+ for (const layer of sprite.layers) {
82
+ if (!layer.visible || layer.opacity <= 0) continue;
83
+ const resolved = resolveCell(layer, frameIndex);
84
+ if (!resolved) continue;
85
+ ctx.globalAlpha = Math.min(1, Math.max(0, layer.opacity));
86
+ drawScaledLayer(ctx, sprite, layer, resolved, frameIndex, {
87
+ scale,
88
+ width,
89
+ height,
90
+ lookup,
91
+ analyticPath: true,
92
+ cornerRadius: MAX_CORNER_RADIUS,
93
+ });
94
+ }
95
+ ctx.globalAlpha = prevAlpha;
96
+ }
97
+
98
+ /**
99
+ * Draw one resolved layer into `ctx` at `scale`. A path layer renders
100
+ * analytically from its shape stack when `analyticPath`; everything else goes
101
+ * through the corner kernel at `cornerRadius`.
102
+ */
103
+ function drawScaledLayer(
104
+ ctx,
105
+ sprite,
106
+ layer,
107
+ resolved,
108
+ frameIndex,
109
+ { scale, width, height, lookup, analyticPath, cornerRadius },
110
+ ) {
111
+ if (analyticPath && layer.kind === 'path' && resolved.path) {
112
+ const layerCanvas = renderScaledPathCell(resolved.path, width, height, scale, lookup);
113
+ ctx.drawImage(layerCanvas, Math.round(resolved.x * scale), Math.round(resolved.y * scale));
114
+ return;
115
+ }
116
+ const layerCanvas = document.createElement('canvas');
117
+ const single = { ...sprite, layers: [{ ...layer, visible: true, opacity: 1 }] };
118
+ renderSmoothSpriteFrame(single, frameIndex, layerCanvas, { scale, cornerRadius });
119
+ ctx.drawImage(layerCanvas, 0, 0);
120
+ }
121
+
122
+ /** Finish render of one (layer, frame) cell — path layers analytic under the
123
+ * vector renderer, else baked grid + corner kernel. */
124
+ export function renderSmoothLayerCell(
125
+ sprite,
126
+ layerIndex,
127
+ frameIndex,
128
+ canvas,
129
+ { scale = DEFAULT_SMOOTH_SCALE } = {},
130
+ ) {
131
+ const layer = sprite.layers[layerIndex];
132
+ if (!layer) return;
133
+ const resolved = resolveCell(layer, frameIndex);
134
+ if (!resolved) return;
135
+ const vector = isVectorRenderer(sprite.renderer);
136
+ const radius = sprite.cornerRadius ?? 0;
137
+ // Nothing to add over the plain nearest-neighbor blit.
138
+ if (!vector && radius === 0) return;
139
+
140
+ const { width, height } = sprite.resolution;
141
+ const lookup = paletteLookup(sprite.palette);
142
+ const target = setupScaledCanvas(canvas, sprite.resolution, scale);
143
+ if (!target) return;
144
+ const { ctx } = target;
145
+
146
+ const prevAlpha = ctx.globalAlpha;
147
+ ctx.globalAlpha = Math.min(1, Math.max(0, layer.opacity ?? 1));
148
+ drawScaledLayer(ctx, sprite, layer, resolved, frameIndex, {
149
+ scale,
150
+ width,
151
+ height,
152
+ lookup,
153
+ analyticPath: vector,
154
+ cornerRadius: vector ? MAX_CORNER_RADIUS : radius,
155
+ });
156
+ ctx.globalAlpha = prevAlpha;
157
+ }
158
+
159
+ function setupScaledCanvas(canvas, resolution, scale) {
160
+ const { width, height } = resolution;
161
+ canvas.width = Math.max(1, Math.round(width * scale));
162
+ canvas.height = Math.max(1, Math.round(height * scale));
163
+ const ctx = canvas.getContext('2d');
164
+ if (!ctx) return null;
165
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
166
+ return width > 0 && height > 0 ? { ctx, width, height } : null;
167
+ }
168
+
169
+ /**
170
+ * Analytic smooth render of a stacked path: fills as even-odd canvas paths,
171
+ * strokes as 1-cell-wide analytic strokes with round caps/joins along the
172
+ * true curves, shapes in stack order.
173
+ */
174
+ function renderScaledPathCell(path, width, height, scale, lookup) {
175
+ const w = Math.max(1, Math.round(width * scale));
176
+ const h = Math.max(1, Math.round(height * scale));
177
+ const off = document.createElement('canvas');
178
+ off.width = w;
179
+ off.height = h;
180
+ const octx = off.getContext('2d');
181
+ if (!octx) return off;
182
+
183
+ for (const shape of path?.shapes ?? []) {
184
+ if (shape.fill) {
185
+ const color = colorForKeyV2(lookup, shape.fill);
186
+ if (color) {
187
+ const geometry = buildFillPath2D(shape, scale);
188
+ if (geometry) {
189
+ octx.fillStyle = color;
190
+ octx.fill(geometry, 'evenodd');
191
+ }
192
+ }
193
+ }
194
+ if (shape.stroke) {
195
+ const color = colorForKeyV2(lookup, shape.stroke);
196
+ if (color) {
197
+ octx.save();
198
+ octx.strokeStyle = color;
199
+ octx.lineWidth = scale; // 1 native cell
200
+ octx.lineCap = 'round';
201
+ octx.lineJoin = 'round';
202
+ for (const sub of shape.subpaths ?? []) {
203
+ const strokePath = buildSubpathPath2D(sub, scale);
204
+ if (strokePath) octx.stroke(strokePath);
205
+ }
206
+ octx.restore();
207
+ }
208
+ }
209
+ }
210
+ return off;
211
+ }
212
+
213
+ function buildFillPath2D(shape, scale) {
214
+ const geometry = new Path2D();
215
+ let any = false;
216
+ for (const sub of shape.subpaths ?? []) {
217
+ if (!sub.closed) continue;
218
+ if (appendSubpath(geometry, sub, scale)) any = true;
219
+ }
220
+ return any ? geometry : null;
221
+ }
222
+
223
+ function buildSubpathPath2D(sub, scale) {
224
+ const geometry = new Path2D();
225
+ if (!appendSubpath(geometry, sub, scale)) return null;
226
+ return geometry;
227
+ }
228
+
229
+ function appendSubpath(geometry, raw, scale) {
230
+ // Read through the same funnel as the bake does. A subpath still stored in
231
+ // the pre-segment form has no `start`, so reading it raw drew nothing at all
232
+ // — art that baked correctly at Pixel vanished at the smooth finishes.
233
+ const sub = normalizeSubpath(raw);
234
+ if (!sub?.start) return false;
235
+ geometry.moveTo(sub.start[0] * scale, sub.start[1] * scale);
236
+ let from = sub.start;
237
+ for (const seg of sub.segs ?? []) {
238
+ if (!seg.c1 && !seg.c2) {
239
+ geometry.lineTo(seg.to[0] * scale, seg.to[1] * scale);
240
+ } else {
241
+ const { c1, c2, to } = absoluteSegment(from, seg);
242
+ geometry.bezierCurveTo(
243
+ c1[0] * scale,
244
+ c1[1] * scale,
245
+ c2[0] * scale,
246
+ c2[1] * scale,
247
+ to[0] * scale,
248
+ to[1] * scale
249
+ );
250
+ }
251
+ from = seg.to;
252
+ }
253
+ if (sub.closed) geometry.closePath();
254
+ return true;
255
+ }
256
+
106
257
  // ---------------------------------------------------------------------------
107
258
  // per-pixel local corner kernel
108
259
  // ---------------------------------------------------------------------------
109
260
 
110
261
  const NO_CUT = undefined;
111
262
 
112
- // Fill pixel (x, y)'s own unit square [x, x+1] x [y, y+1] with `color` (a
113
- // no-op when `color` is null — transparent, nothing to fill), then paint a
114
- // small rounded cut over each of its 4 corners that qualifies (see
115
- // `cornerFill`). Uses ONLY the 3x3 neighborhood of (x, y) — genuinely local,
116
- // unlike the old global loop trace.
117
- function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
263
+ function drawPixelBlock(ctx, colorAt, x, y, scale, color, cornerRadius) {
264
+ const px = x * scale;
265
+ const py = y * scale;
118
266
  const north = colorAt(x, y - 1);
119
267
  const south = colorAt(x, y + 1);
120
268
  const west = colorAt(x - 1, y);
@@ -129,20 +277,19 @@ function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
129
277
 
130
278
  if (color) {
131
279
  ctx.fillStyle = rgbaFillStyle(color);
132
- ctx.fillRect(x, y, 1, 1);
280
+ ctx.fillRect(px, py, scale, scale);
133
281
  }
134
282
 
135
283
  if (cornerRadius <= 0) return;
284
+ const r = cornerRadius * scale;
136
285
  for (const [corner, reveal] of cuts) {
137
286
  if (reveal === NO_CUT) continue;
138
287
  ctx.beginPath();
139
- tracePixelWedge(ctx, corner, x, y, cornerRadius);
288
+ tracePixelWedge(ctx, corner, px, py, scale, r);
140
289
  if (reveal === null) {
141
- // Revealing transparency: clip to the wedge and clear it, rather than
142
- // fill it, since there's no color to paint.
143
290
  ctx.save();
144
291
  ctx.clip();
145
- ctx.clearRect(x, y, 1, 1);
292
+ ctx.clearRect(px, py, scale, scale);
146
293
  ctx.restore();
147
294
  } else {
148
295
  ctx.fillStyle = rgbaFillStyle(reveal);
@@ -151,72 +298,76 @@ function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
151
298
  }
152
299
  }
153
300
 
154
- // Classify one corner of pixel `color`, given its two edge-adjacent
155
- // neighbors (`a`, `b`) and its diagonal neighbor (`g`). Returns the neighbor
156
- // color to reveal at that corner's rounded cut, or `NO_CUT` to leave the
157
- // corner sharp:
158
- // - `a` or `b` matches `color`: either a flat/interior corner, or (when
159
- // they don't BOTH match) a straight edge passing by rather than a real
160
- // corner. Either way, nothing to round from P's side — a matching
161
- // neighbor's OWN corner classification (evaluated independently, when
162
- // IT is P) is what rounds the opposite case; the physical wedge that
163
- // gets cut always lives entirely inside whichever pixel's corner is
164
- // convex, so there's no double-handling.
165
- // - neither `a` nor `b` matches, but `g` DOES: a diagonal touch between
166
- // two same-colored pixels (the "checkerboard" case). Left uncut, so a
167
- // smoothed diagonal stroke doesn't get visually pinched off at every
168
- // step.
169
- // - neither `a`, `b`, nor `g` matches, and `a` and `b` are THE SAME color:
170
- // a genuine, unambiguous convex corner of P's own region touching one
171
- // other region. Cut it, revealing that color.
172
- // - neither `a`, `b`, nor `g` matches, and `a` and `b` DIFFER: three (or
173
- // four, counting `g`) distinct colors meet at this exact point — e.g. a
174
- // "T" where one region's straight edge is crossed by the boundary
175
- // between two others. Left uncut. Rounding here would have to guess
176
- // which of `a`/`b` "wins", and since the pixel on the OTHER side of
177
- // that guess is classifying this same point independently — and would
178
- // guess differently — a pair of pixels each revealing the OTHER's
179
- // color produces a little criss-crossed notch instead of one clean
180
- // curve. Leaving every pixel at a 3+-way point sharp keeps it a single
181
- // consistent (if unrounded) vertex.
182
301
  function cornerFill(color, a, b, g) {
183
302
  if (a === color || b === color) return NO_CUT;
184
303
  if (g === color) return NO_CUT;
185
304
  return a === b ? a : NO_CUT;
186
305
  }
187
306
 
188
- // `color` is always a `"r,g,b,a"` string (0-255 channels, alpha 0-255) —
189
- // see `colorAt` — never the transparent sentinel (only opaque colors ever
190
- // reach this function as `color`, only ever as `reveal`).
191
307
  function rgbaFillStyle(color) {
192
308
  const [r, g, b, a] = color.split(',').map(Number);
193
309
  return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
194
310
  }
195
311
 
196
- // Trace the small corner wedge that gets cut from pixel (x, y)'s unit square
197
- // at `corner` (one of 'TL' | 'TR' | 'BL' | 'BR'): the sliver between the
198
- // exact corner point and the quarter-circle of radius `r` tangent to both
199
- // adjacent edges at distance `r` from the corner — i.e. exactly the piece a
200
- // standard rounded-rect corner removes from a sharp one. `ctx.arcTo`'s
201
- // corner-point-as-control-point form draws that same tangent arc without
202
- // hand-computed sweep angles.
203
- function tracePixelWedge(ctx, corner, x, y, r) {
312
+ /** Display-resolution ghost of native cells using the same corner kernel as placed pixels. */
313
+ export function renderSmoothCellsPreview(
314
+ ctx,
315
+ cells,
316
+ color,
317
+ { scale, cornerRadius, alpha = 1 } = {},
318
+ ) {
319
+ if (!cells?.length || !scale || cornerRadius <= 0) return;
320
+ const clampedRadius = Math.min(MAX_CORNER_RADIUS, Math.max(0, cornerRadius));
321
+ const cellSet = new Set(cells.map((c) => `${c.x},${c.y}`));
322
+ const rgba = cssColorToRgbaString(color, alpha);
323
+ const colorAt = (x, y) => (cellSet.has(`${x},${y}`) ? rgba : null);
324
+
325
+ ctx.save();
326
+ ctx.imageSmoothingEnabled = false;
327
+ ctx.globalAlpha = 1;
328
+ for (const { x, y } of cells) {
329
+ drawPixelBlock(ctx, colorAt, x, y, scale, rgba, clampedRadius);
330
+ }
331
+ ctx.restore();
332
+ }
333
+
334
+ function cssColorToRgbaString(color, alpha = 1) {
335
+ let r = 255;
336
+ let g = 255;
337
+ let b = 255;
338
+ if (typeof color === 'string' && color.startsWith('#')) {
339
+ const h = color.slice(1);
340
+ if (h.length === 3) {
341
+ r = parseInt(h[0] + h[0], 16);
342
+ g = parseInt(h[1] + h[1], 16);
343
+ b = parseInt(h[2] + h[2], 16);
344
+ } else if (h.length >= 6) {
345
+ r = parseInt(h.slice(0, 2), 16);
346
+ g = parseInt(h.slice(2, 4), 16);
347
+ b = parseInt(h.slice(4, 6), 16);
348
+ }
349
+ }
350
+ const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255);
351
+ return `${r},${g},${b},${a}`;
352
+ }
353
+
354
+ function tracePixelWedge(ctx, corner, px, py, cell, r) {
204
355
  if (corner === 'TL') {
205
- ctx.moveTo(x, y);
206
- ctx.lineTo(x + r, y);
207
- ctx.arcTo(x, y, x, y + r, r);
356
+ ctx.moveTo(px, py);
357
+ ctx.lineTo(px + r, py);
358
+ ctx.arcTo(px, py, px, py + r, r);
208
359
  } else if (corner === 'TR') {
209
- ctx.moveTo(x + 1, y);
210
- ctx.lineTo(x + 1, y + r);
211
- ctx.arcTo(x + 1, y, x + 1 - r, y, r);
360
+ ctx.moveTo(px + cell, py);
361
+ ctx.lineTo(px + cell, py + r);
362
+ ctx.arcTo(px + cell, py, px + cell - r, py, r);
212
363
  } else if (corner === 'BL') {
213
- ctx.moveTo(x, y + 1);
214
- ctx.lineTo(x, y + 1 - r);
215
- ctx.arcTo(x, y + 1, x + r, y + 1, r);
364
+ ctx.moveTo(px, py + cell);
365
+ ctx.lineTo(px, py + cell - r);
366
+ ctx.arcTo(px, py + cell, px + r, py + cell, r);
216
367
  } else {
217
- ctx.moveTo(x + 1, y + 1);
218
- ctx.lineTo(x + 1 - r, y + 1);
219
- ctx.arcTo(x + 1, y + 1, x + 1, y + 1 - r, r);
368
+ ctx.moveTo(px + cell, py + cell);
369
+ ctx.lineTo(px + cell - r, py + cell);
370
+ ctx.arcTo(px + cell, py + cell, px + cell, py + cell - r, r);
220
371
  }
221
372
  ctx.closePath();
222
373
  }
@@ -5,6 +5,7 @@ import {
5
5
  faArrowsAltH,
6
6
  faArrowsAltV,
7
7
  faBars,
8
+ faBezierCurve,
8
9
  faChevronDown,
9
10
  faChevronRight,
10
11
  faChevronUp,
@@ -12,6 +13,8 @@ import {
12
13
  faClone,
13
14
  faCode,
14
15
  faCodeBranch,
16
+ faDotCircle,
17
+ faDrawPolygon,
15
18
  faEraser,
16
19
  faExpand,
17
20
  faExternalLinkAlt,
@@ -22,9 +25,12 @@ import {
22
25
  faGlobe,
23
26
  faImage,
24
27
  faLayerGroup,
28
+ faLocationArrow,
25
29
  faObjectGroup,
30
+ faPaintBrush,
26
31
  faPalette,
27
32
  faPencilAlt,
33
+ faPenNib,
28
34
  faPlay,
29
35
  faPlus,
30
36
  faRedo,
@@ -37,6 +43,7 @@ import {
37
43
  faStamp,
38
44
  faStop,
39
45
  faSyncAlt,
46
+ faTh,
40
47
  faTimes,
41
48
  faTrash,
42
49
  faUndo,
@@ -190,6 +197,7 @@ export function IconButton({ icon, label, active = false, variant = '', ...props
190
197
  }
191
198
  const icons = {
192
199
  bars: faBars,
200
+ 'bezier-curve': faBezierCurve,
193
201
  camera: faVideo,
194
202
  'chevron-down': faChevronDown,
195
203
  'chevron-right': faChevronRight,
@@ -199,6 +207,8 @@ const icons = {
199
207
  code: faCode,
200
208
  // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
201
209
  'code-fork': faCodeBranch,
210
+ 'dot-circle': faDotCircle,
211
+ 'draw-polygon': faDrawPolygon,
202
212
  eraser: faEraser,
203
213
  expand: faExpand,
204
214
  external: faExternalLinkAlt,
@@ -209,13 +219,18 @@ const icons = {
209
219
  file: faFile,
210
220
  film: faFilm,
211
221
  globe: faGlobe,
222
+ grid: faTh,
212
223
  image: faImage,
213
224
  'layer-group': faLayerGroup,
225
+ // FA's location-arrow points up-right; flip it for a path-select cursor feel.
226
+ 'location-arrow': { ...faLocationArrow, flipX: true },
214
227
  marquee: faVectorSquare,
215
228
  move: faArrowsAlt,
216
229
  'object-group': faObjectGroup,
230
+ paintbrush: faPaintBrush,
217
231
  palette: faPalette,
218
232
  pencil: faPencilAlt,
233
+ 'pen-nib': faPenNib,
219
234
  play: faPlay,
220
235
  plus: faPlus,
221
236
  redo: faRedo,
@@ -244,7 +259,13 @@ export function Icon({ name }) {
244
259
  <svg
245
260
  viewBox={`0 0 ${width} ${height}`}
246
261
  aria-hidden="true"
247
- style={{ width: '1em', height: '1em', display: 'inline-block', fill: 'currentColor' }}>
262
+ style={{
263
+ width: '1em',
264
+ height: '1em',
265
+ display: 'inline-block',
266
+ fill: 'currentColor',
267
+ ...(def.flipX ? { transform: 'scaleX(-1)' } : {}),
268
+ }}>
248
269
  <path d={d} />
249
270
  </svg>
250
271
  );
@@ -1591,7 +1591,7 @@
1591
1591
  color: var(--castle-inspector-muted);
1592
1592
  }
1593
1593
 
1594
- .cornerRadiusBar {
1594
+ .finishBar {
1595
1595
  display: flex;
1596
1596
  align-items: center;
1597
1597
  gap: 6px;
@@ -1600,21 +1600,23 @@
1600
1600
  color: var(--castle-inspector-muted);
1601
1601
  }
1602
1602
 
1603
- /* Corner-radius segmented control (0 / ¼ / ½) — same bordered-row-of-
1604
- buttons pattern as .drawingShapeModeRow, sized for short text labels
1605
- instead of square glyph buttons. */
1606
- .cornerRadiusSegments {
1603
+ /* Finish segmented control (square / ¼ / ½ / circle) — bordered row of
1604
+ short text + icon segments. */
1605
+ .finishSegments {
1607
1606
  display: flex;
1608
1607
  border: 1px solid var(--castle-inspector-border);
1609
1608
  border-radius: var(--castle-radius);
1610
1609
  overflow: hidden;
1611
1610
  }
1612
1611
 
1613
- .cornerRadiusSegments > * + * {
1612
+ .finishSegments > * + * {
1614
1613
  border-left: 1px solid var(--castle-inspector-border);
1615
1614
  }
1616
1615
 
1617
- .cornerRadiusSegment {
1616
+ .finishSegment {
1617
+ display: inline-flex;
1618
+ align-items: center;
1619
+ justify-content: center;
1618
1620
  min-width: 28px;
1619
1621
  padding: 3px 8px;
1620
1622
  border: 0;
@@ -1626,15 +1628,15 @@
1626
1628
  line-height: 1.35;
1627
1629
  }
1628
1630
 
1629
- .cornerRadiusSegment:hover {
1631
+ .finishSegment:hover {
1630
1632
  background: var(--castle-inspector-input-bg);
1631
1633
  }
1632
1634
 
1633
- /* Compound selector (not a bare `.cornerRadiusSegmentOn`) for the same reason
1635
+ /* Compound selector (not a bare `.finishSegmentOn`) for the same reason
1634
1636
  documented above `.drawingToolButton.drawingToolSelected`: it must beat
1635
- `.cornerRadiusSegment:hover` on specificity no matter source order. */
1636
- .cornerRadiusSegment.cornerRadiusSegmentOn,
1637
- .cornerRadiusSegment.cornerRadiusSegmentOn:hover {
1637
+ `.finishSegment:hover` on specificity no matter source order. */
1638
+ .finishSegment.finishSegmentOn,
1639
+ .finishSegment.finishSegmentOn:hover {
1638
1640
  background: var(--castle-selected);
1639
1641
  color: var(--castle-selected-ink);
1640
1642
  }
@@ -1873,6 +1875,7 @@
1873
1875
  height: 100%;
1874
1876
  pointer-events: none;
1875
1877
  z-index: -1;
1878
+ image-rendering: auto;
1876
1879
  }
1877
1880
 
1878
1881
  .drawingCanvas {
@@ -1888,6 +1891,19 @@
1888
1891
  outline: none;
1889
1892
  }
1890
1893
 
1894
+ /* Smooth/rounded finish: the main canvas is transparent (smooth layer below).
1895
+ Pixelated upscaling of the native grid would paint a visible cell lattice
1896
+ over the whole artboard — use auto scaling instead. */
1897
+ .drawingCanvasSmooth {
1898
+ image-rendering: auto;
1899
+ position: absolute;
1900
+ inset: 0;
1901
+ width: 100%;
1902
+ height: 100%;
1903
+ flex: 0 0 auto;
1904
+ outline: none;
1905
+ }
1906
+
1891
1907
  .palette {
1892
1908
  display: grid;
1893
1909
  grid-template-columns: repeat(12, minmax(0, 1fr));
@@ -2010,6 +2026,14 @@
2010
2026
  color: var(--castle-selected-ink);
2011
2027
  }
2012
2028
 
2029
+ /* Groups of shape-action rows (fill/stroke · transform · punch/delete). */
2030
+ .drawingShapeModeGroups {
2031
+ display: flex;
2032
+ flex-wrap: wrap;
2033
+ gap: 8px;
2034
+ align-items: flex-start;
2035
+ }
2036
+
2013
2037
  /* Horizontal segmented row of shape sub-mode buttons in the wide-layout Tool
2014
2038
  Settings panel. Reuses .drawingToolButton glyph buttons with shared borders. */
2015
2039
  .drawingShapeModeRow {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "../../sdk": {
30
30
  "name": "castle-web-sdk",
31
- "version": "0.4.11",
31
+ "version": "0.4.12",
32
32
  "devDependencies": {
33
33
  "eslint": "^9.0.0",
34
34
  "jscpd": "^4.0.5",