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
|
@@ -171,8 +171,19 @@
|
|
|
171
171
|
each owned by exactly one element (no two borders meet), so there are no
|
|
172
172
|
doubled/thicker seams. The grid's outer top is the toolbar's existing bottom
|
|
173
173
|
divider; outer right/bottom come from the last cells. */
|
|
174
|
-
|
|
174
|
+
/* Shared sticky left-column width: corner and layer heads must match or
|
|
175
|
+
frame columns drift out of alignment. Content may not force growth
|
|
176
|
+
(min-width:auto would otherwise expand past the flex basis). */
|
|
177
|
+
.corner,
|
|
178
|
+
.layerHead {
|
|
175
179
|
flex: 0 0 168px;
|
|
180
|
+
width: 168px;
|
|
181
|
+
min-width: 168px;
|
|
182
|
+
max-width: 168px;
|
|
183
|
+
box-sizing: border-box;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.corner {
|
|
176
187
|
border-right: 1px solid var(--castle-inspector-divider);
|
|
177
188
|
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
178
189
|
background: var(--castle-workspace-bg);
|
|
@@ -225,11 +236,10 @@
|
|
|
225
236
|
}
|
|
226
237
|
|
|
227
238
|
.layerHead {
|
|
228
|
-
flex: 0 0 168px;
|
|
229
239
|
display: flex;
|
|
230
240
|
flex-direction: column;
|
|
231
241
|
justify-content: center;
|
|
232
|
-
gap:
|
|
242
|
+
gap: 0;
|
|
233
243
|
padding: 4px 8px;
|
|
234
244
|
border-right: 1px solid var(--castle-inspector-divider);
|
|
235
245
|
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
@@ -250,6 +260,12 @@
|
|
|
250
260
|
gap: 6px;
|
|
251
261
|
}
|
|
252
262
|
|
|
263
|
+
.layerBottomRow {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: 6px;
|
|
267
|
+
}
|
|
268
|
+
|
|
253
269
|
.visBtn {
|
|
254
270
|
flex: 0 0 auto;
|
|
255
271
|
width: 22px;
|
|
@@ -305,7 +321,9 @@
|
|
|
305
321
|
/* Slim opacity slider: a thin custom track + small thumb so the layer header
|
|
306
322
|
cell stays no taller than the art (thumbnail) cells in its row. */
|
|
307
323
|
.opacity {
|
|
308
|
-
|
|
324
|
+
flex: 1 1 auto;
|
|
325
|
+
min-width: 0;
|
|
326
|
+
width: auto;
|
|
309
327
|
height: 12px;
|
|
310
328
|
margin: 0;
|
|
311
329
|
cursor: pointer;
|
|
@@ -390,6 +408,13 @@
|
|
|
390
408
|
display: block;
|
|
391
409
|
}
|
|
392
410
|
|
|
411
|
+
.thumbCanvasSmooth {
|
|
412
|
+
width: 100%;
|
|
413
|
+
height: 100%;
|
|
414
|
+
image-rendering: auto;
|
|
415
|
+
display: block;
|
|
416
|
+
}
|
|
417
|
+
|
|
393
418
|
/* Empty/absent cell: plain and empty. The cell's own grid separators (see the
|
|
394
419
|
.corner/.cell border rules) still bound it; there's no dashed interior. */
|
|
395
420
|
.empty {
|
|
@@ -427,7 +452,8 @@
|
|
|
427
452
|
/* Fade only the linked cell's thumbnail IMAGE (the canvas) to signal a shared
|
|
428
453
|
image. Scoped to the canvas inside a linked thumb, so the blue ring
|
|
429
454
|
(.linkRing box-shadow) and the link badge (.linkBadge) stay full opacity. */
|
|
430
|
-
.linkRing .thumbCanvas
|
|
455
|
+
.linkRing .thumbCanvas,
|
|
456
|
+
.linkRing .thumbCanvasSmooth {
|
|
431
457
|
opacity: 0.5;
|
|
432
458
|
}
|
|
433
459
|
|
|
@@ -14,7 +14,7 @@ export function collectAssets(files) {
|
|
|
14
14
|
if (isImageFile(path)) sprites[path] = imageArt(path, url);
|
|
15
15
|
}
|
|
16
16
|
for (const [path, text] of Object.entries(files)) {
|
|
17
|
-
if (path.endsWith('.pxart')) {
|
|
17
|
+
if (path.endsWith('.sprite') || path.endsWith('.pxart')) {
|
|
18
18
|
const sprite = parseFull(text);
|
|
19
19
|
if (sprite) sprites[path] = sprite;
|
|
20
20
|
}
|
|
@@ -372,16 +372,16 @@ export function blankPxArtText() {
|
|
|
372
372
|
return serializeCompact({ palette: {}, grid });
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
// `drawings/drawing-N.
|
|
375
|
+
// `drawings/drawing-N.sprite` -- next free numbered slug, so repeated adds don't
|
|
376
376
|
// collide and a deleted drawing's number isn't reused.
|
|
377
377
|
function mintDrawingPath(files) {
|
|
378
|
-
const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.pxart$`);
|
|
378
|
+
const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.(?:pxart|sprite)$`);
|
|
379
379
|
let max = 0;
|
|
380
380
|
for (const path of Object.keys(files ?? {})) {
|
|
381
381
|
const m = path.match(re);
|
|
382
382
|
if (m) max = Math.max(max, Number(m[1]));
|
|
383
383
|
}
|
|
384
|
-
return `${DRAWINGS_DIR}/drawing-${max + 1}.
|
|
384
|
+
return `${DRAWINGS_DIR}/drawing-${max + 1}.sprite`;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
// The z that draws on top of every current actor: max effective z + 1 (0 for
|
|
@@ -19,12 +19,13 @@ const rawModules = {
|
|
|
19
19
|
'/imports/*/scenes/**/*.scene',
|
|
20
20
|
'/imports/*/blueprints/**/*.scene',
|
|
21
21
|
'/imports/*/drawings/**/*.pxart',
|
|
22
|
+
'/imports/*/drawings/**/*.sprite',
|
|
22
23
|
'/imports/*/behaviors/**/*.jsx',
|
|
23
24
|
],
|
|
24
25
|
{ query: '?raw', import: 'default', eager: true }
|
|
25
26
|
),
|
|
26
27
|
...import.meta.glob(
|
|
27
|
-
['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/behaviors/**/*.jsx'],
|
|
28
|
+
['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/drawings/**/*.sprite', '/behaviors/**/*.jsx'],
|
|
28
29
|
{ query: '?raw', import: 'default', eager: true }
|
|
29
30
|
),
|
|
30
31
|
};
|
|
@@ -99,6 +100,7 @@ export function mediaFilesOfKind(kind) {
|
|
|
99
100
|
// `endsWith` per `return`.
|
|
100
101
|
export function getFileKind(path) {
|
|
101
102
|
if (path.endsWith('.scene')) return 'scene';
|
|
103
|
+
if (path.endsWith('.sprite')) return 'pxart';
|
|
102
104
|
if (path.endsWith('.pxart')) return 'pxart';
|
|
103
105
|
if (path.endsWith('.png')) return 'image';
|
|
104
106
|
if (path.endsWith('.jpg')) return 'image';
|
|
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
9
9
|
import { onFilesChanged, requestReload } from 'castle-web-sdk';
|
|
10
10
|
import { initialFiles } from './files';
|
|
11
11
|
|
|
12
|
-
const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
|
|
12
|
+
const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.drawing'];
|
|
13
13
|
const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
|
|
14
14
|
|
|
15
15
|
export function isDataFile(path) {
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// a small WeakMap caches the native-resolution frame canvas per sprite object.
|
|
8
8
|
|
|
9
9
|
import { resolveDeckFile } from 'castle-web-sdk';
|
|
10
|
-
import { renderSpriteFrame } from '../pxart';
|
|
10
|
+
import { displayFinish, renderSpriteFrame } from '../pxart';
|
|
11
11
|
|
|
12
|
-
export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.
|
|
12
|
+
export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.sprite';
|
|
13
13
|
|
|
14
14
|
const canvasCache = new WeakMap();
|
|
15
15
|
function frameCanvas(sprite) {
|
|
@@ -40,7 +40,7 @@ export function drawJointArt(ctx, scene, joint, a, b) {
|
|
|
40
40
|
ctx.save();
|
|
41
41
|
ctx.translate(a.x, a.y);
|
|
42
42
|
ctx.rotate(Math.atan2(dy, dx));
|
|
43
|
-
ctx.imageSmoothingEnabled = sprite.
|
|
43
|
+
ctx.imageSmoothingEnabled = displayFinish(sprite).smooth;
|
|
44
44
|
const top = -thickness / 2;
|
|
45
45
|
if ((joint.fit ?? 'tile') === 'stretch') {
|
|
46
46
|
ctx.drawImage(canvas, 0, top, len, thickness);
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isEmptyPathData,
|
|
3
|
+
parsePathData,
|
|
4
|
+
serializePathData,
|
|
5
|
+
translatePathData,
|
|
6
|
+
bakePathCell,
|
|
7
|
+
} from './pxartPath.js';
|
|
8
|
+
|
|
1
9
|
// ============================================================================
|
|
2
10
|
// Castle pixel-art format — COMPACT form ("flat" sprite)
|
|
3
11
|
// ============================================================================
|
|
@@ -41,45 +49,104 @@ export const FULL_FORM = 'full';
|
|
|
41
49
|
export const TRANSPARENT = '.';
|
|
42
50
|
|
|
43
51
|
// ---------------------------------------------------------------------------
|
|
44
|
-
//
|
|
52
|
+
// Finish: `renderer` (which renderer) + `cornerRadius` (how much rounding)
|
|
45
53
|
// ---------------------------------------------------------------------------
|
|
46
54
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
55
|
+
// Two orthogonal file-level fields:
|
|
56
|
+
//
|
|
57
|
+
// renderer "grid" (default) draws the baked `grid`; "vector" draws each
|
|
58
|
+
// path layer analytically from its shape stack and each pixel
|
|
59
|
+
// layer with the corner kernel at MAX_CORNER_RADIUS.
|
|
60
|
+
// cornerRadius ALWAYS a number, in native-pixel units, clamped to
|
|
61
|
+
// [0, MAX_CORNER_RADIUS]. 0 renders sharp/nearest-neighbor.
|
|
62
|
+
//
|
|
63
|
+
// These were briefly one field: `cornerRadius` held either a number or the
|
|
64
|
+
// string "smooth". That union is retired because a categorical mode and a
|
|
65
|
+
// continuous amount do not share a slot cleanly — `'smooth' > 0` is false (NaN
|
|
66
|
+
// comparison), so every `cornerRadius > 0` test in the codebase silently read a
|
|
67
|
+
// smooth sprite as sharp unless it special-cased the string, and
|
|
68
|
+
// clampCornerRadius had to return a string to stay lossless. Splitting them
|
|
69
|
+
// restores the invariant that cornerRadius is a number EVERYWHERE, which is what
|
|
70
|
+
// makes "a value, not a flag" actually hold: the amount of rounding stays part
|
|
71
|
+
// of the portable format instead of a fixed, code-side constant.
|
|
53
72
|
|
|
54
73
|
/** Corner cuts on the same 1-native-pixel edge must not overlap, so radii
|
|
55
74
|
* above this are clamped on parse (and by the editor's UI). */
|
|
56
75
|
export const MAX_CORNER_RADIUS = 0.5;
|
|
57
76
|
|
|
77
|
+
export const GRID_RENDERER = 'grid';
|
|
78
|
+
export const VECTOR_RENDERER = 'vector';
|
|
79
|
+
|
|
80
|
+
/** True when the sprite draws from vector geometry rather than the baked grid. */
|
|
81
|
+
export function isVectorRenderer(renderer) {
|
|
82
|
+
return renderer === VECTOR_RENDERER;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Always returns a number in [0, MAX_CORNER_RADIUS]; any non-number is 0. */
|
|
58
86
|
export function clampCornerRadius(radius) {
|
|
87
|
+
if (typeof radius !== 'number' || !Number.isFinite(radius)) return 0;
|
|
59
88
|
return Math.min(MAX_CORNER_RADIUS, Math.max(0, radius));
|
|
60
89
|
}
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
/**
|
|
92
|
+
* How a sprite wants to be shown, for callers that draw it.
|
|
93
|
+
*
|
|
94
|
+
* `smooth` is the one every display site needs: it says the supersampled
|
|
95
|
+
* render is in charge, so the native 1px/cell blit must stay out of the way.
|
|
96
|
+
* Testing `cornerRadius` alone is NOT enough — the vector renderer carries a
|
|
97
|
+
* radius of 0, so a `cornerRadius === 0` test reads a Smooth sprite as Pixel
|
|
98
|
+
* and paints the blocky bake over the analytic render.
|
|
99
|
+
*
|
|
100
|
+
* `cornerRadius` is the radius to actually draw with: pixel layers still go
|
|
101
|
+
* through the corner kernel under the vector renderer, at maximum rounding
|
|
102
|
+
* (see renderSmoothCompositeFrame), so this is not always the sprite's field.
|
|
103
|
+
*/
|
|
104
|
+
export function displayFinish(sprite) {
|
|
105
|
+
const vector = isVectorRenderer(sprite?.renderer);
|
|
106
|
+
const cornerRadius = vector ? MAX_CORNER_RADIUS : clampCornerRadius(sprite?.cornerRadius);
|
|
107
|
+
return { vector, cornerRadius, smooth: vector || cornerRadius > 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Legacy encodings, all migrating on read. None of these are structural signals,
|
|
111
|
+
// so compact-vs-full detection is unaffected.
|
|
112
|
+
//
|
|
113
|
+
// cornerRadius: "smooth" the retired union -> vector renderer, radius 0
|
|
114
|
+
// render: "smooth" the oldest enum -> grid renderer, radius 0.25
|
|
115
|
+
// render: <number> a brief numeric era -> grid renderer, that radius
|
|
116
|
+
//
|
|
117
|
+
// Old files therefore keep rendering rounded rather than silently reverting to
|
|
118
|
+
// sharp, and a file written before the split still selects the vector renderer.
|
|
119
|
+
// The enum never carried an amount of its own, hence the fixed value here.
|
|
67
120
|
const LEGACY_SMOOTH_RADIUS = 0.25;
|
|
121
|
+
const LEGACY_SMOOTH = 'smooth';
|
|
122
|
+
|
|
123
|
+
/** Read `renderer` off a parsed JSON object. Anything unrecognized — missing,
|
|
124
|
+
* null, a typo, a future value — reads as the grid renderer. */
|
|
125
|
+
function parseRenderer(data) {
|
|
126
|
+
if (data.renderer === VECTOR_RENDERER) return VECTOR_RENDERER;
|
|
127
|
+
if (data.renderer === GRID_RENDERER) return GRID_RENDERER;
|
|
128
|
+
// Pre-split files spelled the vector renderer as cornerRadius: "smooth".
|
|
129
|
+
if (data.cornerRadius === LEGACY_SMOOTH) return VECTOR_RENDERER;
|
|
130
|
+
return GRID_RENDERER;
|
|
131
|
+
}
|
|
68
132
|
|
|
69
|
-
/** Read
|
|
70
|
-
* `cornerRadius
|
|
71
|
-
* above). Defaults to 0 (sharp) for anything else (missing field, typo, a
|
|
72
|
-
* future value this parser doesn't know yet). */
|
|
133
|
+
/** Read `cornerRadius` off a parsed JSON object. Never yields a non-number, so
|
|
134
|
+
* downstream `cornerRadius > 0` tests are always meaningful. */
|
|
73
135
|
function parseCornerRadius(data) {
|
|
74
|
-
if (typeof data.cornerRadius === 'number'
|
|
75
|
-
|
|
76
|
-
|
|
136
|
+
if (typeof data.cornerRadius === 'number') return clampCornerRadius(data.cornerRadius);
|
|
137
|
+
// cornerRadius: "smooth" carried no amount — it becomes renderer: "vector".
|
|
138
|
+
if (data.cornerRadius === LEGACY_SMOOTH) return 0;
|
|
77
139
|
const legacy = data.render;
|
|
78
|
-
if (typeof legacy === 'number'
|
|
79
|
-
if (legacy ===
|
|
140
|
+
if (typeof legacy === 'number') return clampCornerRadius(legacy);
|
|
141
|
+
if (legacy === LEGACY_SMOOTH) return LEGACY_SMOOTH_RADIUS;
|
|
80
142
|
return 0;
|
|
81
143
|
}
|
|
82
144
|
|
|
145
|
+
/** The finish fields for a parsed sprite, both always well-typed. */
|
|
146
|
+
function parseFinish(data) {
|
|
147
|
+
return { renderer: parseRenderer(data), cornerRadius: parseCornerRadius(data) };
|
|
148
|
+
}
|
|
149
|
+
|
|
83
150
|
/** Parse a .pxart string into a compact PxArt record, or null if it isn't the
|
|
84
151
|
* compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
|
|
85
152
|
* `format` discriminator is optional. Tolerant of ragged rows and a `"."`
|
|
@@ -101,7 +168,7 @@ export function parseCompact(content) {
|
|
|
101
168
|
if (typeof v === 'string') palette[k] = v;
|
|
102
169
|
else if (v === null) palette[k] = null;
|
|
103
170
|
}
|
|
104
|
-
return { palette, grid: data.grid,
|
|
171
|
+
return { palette, grid: data.grid, ...parseFinish(data) };
|
|
105
172
|
}
|
|
106
173
|
} catch {
|
|
107
174
|
/* not valid pxart json */
|
|
@@ -109,15 +176,26 @@ export function parseCompact(content) {
|
|
|
109
176
|
return null;
|
|
110
177
|
}
|
|
111
178
|
|
|
179
|
+
/** Finish fields for serialization: each omitted at its default, so a plain
|
|
180
|
+
* pixel sprite carries neither and stays byte-identical to its pre-finish
|
|
181
|
+
* shape. */
|
|
182
|
+
function finishFields(art) {
|
|
183
|
+
const radius = clampCornerRadius(art?.cornerRadius);
|
|
184
|
+
return {
|
|
185
|
+
...(isVectorRenderer(art?.renderer) ? { renderer: VECTOR_RENDERER } : {}),
|
|
186
|
+
...(radius > 0 ? { cornerRadius: radius } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
112
190
|
/** Serialize a compact PxArt record to a .pxart string. Always stamps
|
|
113
|
-
* `format: "compact"`.
|
|
191
|
+
* `format: "compact"`. Finish fields are omitted at their defaults, so
|
|
114
192
|
* existing (pixel) files stay byte-identical. */
|
|
115
193
|
export function serializeCompact(art) {
|
|
116
194
|
const out = {
|
|
117
195
|
format: COMPACT_FORM,
|
|
118
196
|
palette: art.palette,
|
|
119
197
|
grid: art.grid,
|
|
120
|
-
...(art
|
|
198
|
+
...finishFields(art),
|
|
121
199
|
};
|
|
122
200
|
return JSON.stringify(out, null, 2) + '\n';
|
|
123
201
|
}
|
|
@@ -416,7 +494,20 @@ function parseCell(raw) {
|
|
|
416
494
|
if (raw == null) return null;
|
|
417
495
|
if (isPlainObject(raw)) {
|
|
418
496
|
if (Array.isArray(raw.grid) && raw.grid.every((r) => typeof r === 'string')) {
|
|
419
|
-
|
|
497
|
+
const cell = makeGridCell(raw.grid, raw.x, raw.y);
|
|
498
|
+
const path = parsePathData(raw.path);
|
|
499
|
+
if (isEmptyPathData(path)) return cell;
|
|
500
|
+
// Cel offset is a pixel-cell mechanism: a path cell's placement lives in
|
|
501
|
+
// its points, so x/y must be 0 (§6.1.1). A file that carries both is
|
|
502
|
+
// normalized by folding the offset into the source, which keeps the art
|
|
503
|
+
// where it was rather than snapping it to the origin.
|
|
504
|
+
const { x = 0, y = 0 } = cell;
|
|
505
|
+
if (!x && !y) return { ...cell, path };
|
|
506
|
+
return {
|
|
507
|
+
grid: cell.grid,
|
|
508
|
+
path: translatePathData(path, x, y),
|
|
509
|
+
_pathNeedsRebake: true,
|
|
510
|
+
};
|
|
420
511
|
}
|
|
421
512
|
if (typeof raw.link === 'number' && Number.isInteger(raw.link)) {
|
|
422
513
|
return { link: raw.link };
|
|
@@ -517,7 +608,7 @@ export function parseFull(content) {
|
|
|
517
608
|
? Math.min(1, Math.max(0, l.opacity))
|
|
518
609
|
: 1,
|
|
519
610
|
blendMode: 'normal',
|
|
520
|
-
kind: 'pixel',
|
|
611
|
+
kind: l.kind === 'path' ? 'path' : 'pixel',
|
|
521
612
|
cells: rawCells.map(parseCell),
|
|
522
613
|
};
|
|
523
614
|
});
|
|
@@ -571,6 +662,18 @@ export function parseFull(content) {
|
|
|
571
662
|
resolution = inferDimsFromLayers(layers);
|
|
572
663
|
}
|
|
573
664
|
|
|
665
|
+
for (const layer of layers) {
|
|
666
|
+
for (let fi = 0; fi < layer.cells.length; fi++) {
|
|
667
|
+
const cell = layer.cells[fi];
|
|
668
|
+
if (cell?._pathNeedsRebake && cell.path) {
|
|
669
|
+
layer.cells[fi] = {
|
|
670
|
+
grid: bakePathCell(cell.path, resolution.width, resolution.height),
|
|
671
|
+
path: cell.path,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
574
677
|
const tags = (Array.isArray(data.tags) ? data.tags : [])
|
|
575
678
|
.filter(isPlainObject)
|
|
576
679
|
.map((t) => ({
|
|
@@ -590,7 +693,7 @@ export function parseFull(content) {
|
|
|
590
693
|
defaultDurationMs,
|
|
591
694
|
tags,
|
|
592
695
|
defaultTag,
|
|
593
|
-
|
|
696
|
+
...parseFinish(data),
|
|
594
697
|
layers,
|
|
595
698
|
};
|
|
596
699
|
}
|
|
@@ -624,7 +727,8 @@ export function upgradeCompactToFull(art) {
|
|
|
624
727
|
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
625
728
|
tags: [],
|
|
626
729
|
defaultTag: undefined,
|
|
627
|
-
|
|
730
|
+
renderer: art.renderer ?? GRID_RENDERER,
|
|
731
|
+
cornerRadius: clampCornerRadius(art.cornerRadius),
|
|
628
732
|
layers: [
|
|
629
733
|
{
|
|
630
734
|
id: 'layer-0',
|
|
@@ -643,13 +747,19 @@ export function upgradeCompactToFull(art) {
|
|
|
643
747
|
// full-form serialize
|
|
644
748
|
// ---------------------------------------------------------------------------
|
|
645
749
|
|
|
646
|
-
/** Collect
|
|
750
|
+
/** Collect palette keys used by grids and editable path source data. */
|
|
647
751
|
function usedKeysOf(sprite) {
|
|
648
752
|
const used = new Set();
|
|
649
753
|
for (const layer of sprite.layers) {
|
|
650
754
|
for (const cell of layer.cells) {
|
|
651
755
|
if (cell && 'grid' in cell) {
|
|
652
756
|
for (const row of cell.grid) for (const ch of row) used.add(ch);
|
|
757
|
+
if (cell.path) {
|
|
758
|
+
for (const shape of cell.path.shapes ?? []) {
|
|
759
|
+
if (shape.fill) used.add(shape.fill);
|
|
760
|
+
if (shape.stroke) used.add(shape.stroke);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
653
763
|
}
|
|
654
764
|
}
|
|
655
765
|
}
|
|
@@ -685,16 +795,16 @@ export function serializeFull(sprite) {
|
|
|
685
795
|
repeat: t.repeat,
|
|
686
796
|
})),
|
|
687
797
|
...(sprite.defaultTag !== undefined ? { defaultTag: sprite.defaultTag } : {}),
|
|
688
|
-
//
|
|
689
|
-
// their pre-
|
|
690
|
-
...(sprite
|
|
798
|
+
// Each omitted at its default, so pixel-mode files stay byte-identical to
|
|
799
|
+
// their pre-finish shape.
|
|
800
|
+
...finishFields(sprite),
|
|
691
801
|
layers: sprite.layers.map((l) => ({
|
|
692
802
|
id: l.id,
|
|
693
803
|
name: l.name,
|
|
694
804
|
visible: l.visible,
|
|
695
805
|
opacity: l.opacity,
|
|
696
806
|
blendMode: l.blendMode,
|
|
697
|
-
kind:
|
|
807
|
+
...(l.kind === 'path' ? { kind: 'path' } : {}),
|
|
698
808
|
cells: l.cells.map((c) => {
|
|
699
809
|
if (c == null) return null;
|
|
700
810
|
if ('grid' in c) {
|
|
@@ -703,6 +813,10 @@ export function serializeFull(sprite) {
|
|
|
703
813
|
const cell = { grid: c.grid };
|
|
704
814
|
if (c.x) cell.x = c.x;
|
|
705
815
|
if (c.y) cell.y = c.y;
|
|
816
|
+
if (c.path) {
|
|
817
|
+
const path = serializePathData(c.path);
|
|
818
|
+
if (path) cell.path = path;
|
|
819
|
+
}
|
|
706
820
|
return cell;
|
|
707
821
|
}
|
|
708
822
|
return { link: c.link };
|
|
@@ -757,7 +871,7 @@ export function resolveCell(layer, frameIndex) {
|
|
|
757
871
|
visited.add(idx);
|
|
758
872
|
const cell = layer.cells[idx];
|
|
759
873
|
if (cell == null) return null;
|
|
760
|
-
if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0 };
|
|
874
|
+
if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0, path: cell.path };
|
|
761
875
|
idx = cell.link;
|
|
762
876
|
}
|
|
763
877
|
return null;
|
|
@@ -966,7 +1080,7 @@ function normalizeSvgRect(raw, ctx) {
|
|
|
966
1080
|
* Euclidean distance. Alpha is ignored for distance. Returns the chosen
|
|
967
1081
|
* "#rrggbb" (lowercase), or null when the input is invalid or fully
|
|
968
1082
|
* transparent (alpha === 00). */
|
|
969
|
-
function quantizeToPalette(hex, palette) {
|
|
1083
|
+
export function quantizeToPalette(hex, palette) {
|
|
970
1084
|
const norm = normalizeHex(hex);
|
|
971
1085
|
if (!norm) return null;
|
|
972
1086
|
const body = norm.slice(1);
|
|
@@ -1000,6 +1114,10 @@ function quantizeToPalette(hex, palette) {
|
|
|
1000
1114
|
* RGB Euclidean distance. The returned palette contains only the quantized
|
|
1001
1115
|
* colors actually used; transparent cells stay transparent ("."). Returns null
|
|
1002
1116
|
* if the svg has no usable rects.
|
|
1117
|
+
*
|
|
1118
|
+
* For vector SVG (paths, circles, strokes), use `scripts/import-svg.mjs` which
|
|
1119
|
+
* produces a path layer with baked grid, or pass `--pixel` for compact pixel-only
|
|
1120
|
+
* output. svg-rect remains the fast path for one-rect-per-pixel emissions.
|
|
1003
1121
|
*/
|
|
1004
1122
|
export function svgRectToPxArt(svg, opts) {
|
|
1005
1123
|
const resolution = opts?.resolution ?? DEFAULT_RESOLUTION;
|