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.
- package/dist/agent-prompts.js +2 -2
- package/dist/agent.d.ts +9 -9
- package/dist/agent.js +590 -589
- package/dist/castleJson.d.ts +6 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.js +27 -3
- package/dist/imports.d.ts +4 -0
- package/dist/imports.js +59 -10
- package/dist/init.d.ts +3 -0
- package/dist/init.js +99 -87
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
- package/dist/shell/index.html +1 -1
- package/dist/vitePlugins.js +1 -1
- package/kits/physics-2d/CLAUDE.md +28 -16
- package/kits/physics-2d/behaviors/Joints.jsx +1 -1
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
- package/kits/physics-2d/blueprints/ball.scene +1 -1
- package/kits/physics-2d/blueprints/block.scene +1 -1
- package/kits/physics-2d/blueprints/cauldron.scene +1 -1
- package/kits/physics-2d/blueprints/crate.scene +1 -1
- package/kits/physics-2d/castle.json +11 -3
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
- package/kits/physics-2d/editors/brushFit.js +535 -0
- package/kits/physics-2d/editors/brushShapes.js +140 -0
- package/kits/physics-2d/editors/mediaFile.js +12 -1
- package/kits/physics-2d/editors/pathOverlay.js +340 -0
- package/kits/physics-2d/editors/pathTools.js +1906 -0
- package/kits/physics-2d/editors/pixelCanvas.js +13 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
- package/kits/physics-2d/editors/pixelGeometry.js +4 -2
- package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
- package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
- package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
- package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
- package/kits/physics-2d/engine/assets.js +1 -1
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/files.js +3 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- package/kits/physics-2d/engine/physics/jointArt.js +3 -3
- package/kits/physics-2d/engine/pxart.js +153 -35
- package/kits/physics-2d/engine/pxartPath.js +1356 -0
- package/kits/physics-2d/engine/pxartSmooth.js +276 -125
- package/kits/physics-2d/engine/ui.jsx +22 -1
- package/kits/physics-2d/engine/ui.module.css +36 -12
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/scripts/draw.mjs +7 -7
- package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
- package/package.json +1 -1
- /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
- /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
- /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
|
|
11
|
-
// global
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
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++)
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
206
|
-
ctx.lineTo(
|
|
207
|
-
ctx.arcTo(
|
|
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(
|
|
210
|
-
ctx.lineTo(
|
|
211
|
-
ctx.arcTo(
|
|
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(
|
|
214
|
-
ctx.lineTo(
|
|
215
|
-
ctx.arcTo(
|
|
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(
|
|
218
|
-
ctx.lineTo(
|
|
219
|
-
ctx.arcTo(
|
|
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={{
|
|
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
|
-
.
|
|
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
|
-
/*
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
.
|
|
1612
|
+
.finishSegments > * + * {
|
|
1614
1613
|
border-left: 1px solid var(--castle-inspector-border);
|
|
1615
1614
|
}
|
|
1616
1615
|
|
|
1617
|
-
.
|
|
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
|
-
.
|
|
1631
|
+
.finishSegment:hover {
|
|
1630
1632
|
background: var(--castle-inspector-input-bg);
|
|
1631
1633
|
}
|
|
1632
1634
|
|
|
1633
|
-
/* Compound selector (not a bare `.
|
|
1635
|
+
/* Compound selector (not a bare `.finishSegmentOn`) for the same reason
|
|
1634
1636
|
documented above `.drawingToolButton.drawingToolSelected`: it must beat
|
|
1635
|
-
`.
|
|
1636
|
-
.
|
|
1637
|
-
.
|
|
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 {
|