castle-web-cli 0.4.114 → 0.4.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +7 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.d.ts +34 -0
- package/dist/editorConfig.js +125 -0
- package/dist/ide.js +245 -120
- package/dist/imports.d.ts +10 -0
- package/dist/imports.js +149 -17
- package/dist/init.d.ts +3 -0
- package/dist/init.js +113 -88
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/serve.js +16 -0
- package/dist/shell/assets/index-DiPlPGyg.js +144 -0
- package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/unsupportedMedia.d.ts +1 -0
- package/dist/unsupportedMedia.js +54 -0
- package/dist/vitePlugins.js +13 -20
- package/kits/basic-2d/castle.json +1 -1
- package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/CLAUDE.md +65 -34
- package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
- package/kits/physics-2d/behaviors/Collider.jsx +1 -1
- package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
- package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +5 -5
- package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
- package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
- package/kits/physics-2d/behaviors/Sound.jsx +152 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +112 -42
- package/kits/physics-2d/behaviors/Tone.jsx +83 -0
- package/kits/physics-2d/behaviors/Video.jsx +111 -0
- package/kits/physics-2d/behaviors/tint.js +24 -9
- 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 +74 -4
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
- package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
- package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
- package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
- package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
- 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 +31 -0
- package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
- 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/ScenePlayer.jsx +1 -0
- package/kits/physics-2d/engine/art.js +105 -0
- package/kits/physics-2d/engine/assets.js +12 -4
- package/kits/physics-2d/engine/audioContext.js +34 -0
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/collider.js +28 -17
- package/kits/physics-2d/engine/files.js +77 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- package/kits/physics-2d/engine/media.js +212 -0
- package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
- package/kits/physics-2d/{physics → engine/physics}/jointArt.js +3 -4
- package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
- 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/scene.js +12 -0
- package/kits/physics-2d/engine/tone.js +112 -0
- 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/kits/physics-2d/systems/media.js +34 -0
- package/kits/physics-2d/systems/physics.js +12 -3
- package/package.json +1 -1
- package/dist/shell/assets/index-CqpY1xZR.js +0 -144
- package/dist/shell/assets/index-DCwVFL5u.css +0 -1
- package/kits/physics-2d/physics/index.js +0 -26
- /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
- /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
- /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Path layers — stacked-shape vector source + the normative pixel bake.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// "Pixels are truth": a path LAYER's cell stores a baked `grid` (identical
|
|
6
|
+
// shape/semantics to a plain pixel cell's grid — every non-path-aware
|
|
7
|
+
// consumer reads only that) PLUS a `path` object carrying the vector source
|
|
8
|
+
// the editor keeps re-baking from. Schema (see docs/pxart-format.md):
|
|
9
|
+
//
|
|
10
|
+
// path: {
|
|
11
|
+
// shapes: [
|
|
12
|
+
// {
|
|
13
|
+
// fill: paletteKey | null,
|
|
14
|
+
// stroke: paletteKey | null,
|
|
15
|
+
// subpaths: [{ closed, start: [x,y], segs: [{ to, c1?, c2? }] }]
|
|
16
|
+
// },
|
|
17
|
+
// ...
|
|
18
|
+
// ]
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// Shapes paint back-to-front (array order). Per shape: fill first, then its
|
|
22
|
+
// own 1px stroke. Later shapes overwrite earlier pixels. Multi-subpath fills
|
|
23
|
+
// use even-odd (donuts). Open stroked subpaths with no fill are 1px polylines.
|
|
24
|
+
// Stroke is per-shape (whole outline or none) — no per-segment strokes.
|
|
25
|
+
//
|
|
26
|
+
// Points are stored as authored; the half-cell lattice is a magnetic guide at
|
|
27
|
+
// input time (§6.1.2). Each segment stores relative cubic offsets c1/c2 from
|
|
28
|
+
// its start and end anchors (absent or zero → straight). Sagitta/bow is a
|
|
29
|
+
// gesture vocabulary only: the bend tool reads/writes it through
|
|
30
|
+
// bowForEdge/snapBow/bowToSegments, but anything answering "what does this
|
|
31
|
+
// look like / where is it" (fill, stroke, hit-test, render) flattens the
|
|
32
|
+
// stored cubics.
|
|
33
|
+
//
|
|
34
|
+
// Shape objects may carry extra keys for forward-compatible variants; v1 only
|
|
35
|
+
// understands fill/stroke/subpaths (/prim).
|
|
36
|
+
//
|
|
37
|
+
// This module owns tolerant parse/serialize PLUS bakePathCell. Kept as a
|
|
38
|
+
// sibling of pxart.js so the core parser stays focused on the on-disk
|
|
39
|
+
// cell/layer shape; pxartPath.js does NOT import from pxart.js.
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
const TRANSPARENT = '.';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The half-cell lattice (§6.1.2): integers are pixel corners, half-integers are
|
|
46
|
+
* pixel centers. Authoring tools magnetize toward it; storage no longer enforces it.
|
|
47
|
+
*/
|
|
48
|
+
export const LATTICE = 0.5;
|
|
49
|
+
|
|
50
|
+
/** Decimal places for serialized coordinates — far below a pixel, stable diffs. */
|
|
51
|
+
export const COORD_DECIMALS = 4;
|
|
52
|
+
const COORD_SCALE = 10 ** COORD_DECIMALS;
|
|
53
|
+
|
|
54
|
+
/** Normalize -0 and round for on-disk coordinates. */
|
|
55
|
+
export function roundCoord(n) {
|
|
56
|
+
if (!Number.isFinite(n)) return 0;
|
|
57
|
+
return Math.round(n * COORD_SCALE) / COORD_SCALE + 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function roundPoint(x, y) {
|
|
61
|
+
return [roundCoord(x), roundCoord(y)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Screen pixels within which a lattice site magnetizes (constant to the finger). */
|
|
65
|
+
export const MAGNETIC_SCREEN_PX = 12;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pull one coordinate toward the nearest magnetic target when within `cellPull`.
|
|
69
|
+
* `corners` → half-cell lattice (integers and half-integers); otherwise pixel
|
|
70
|
+
* centers only (half-integers).
|
|
71
|
+
*/
|
|
72
|
+
export function magneticSnapValue(n, cellPull, corners = false) {
|
|
73
|
+
if (!(cellPull > 0) || !Number.isFinite(n)) return n;
|
|
74
|
+
const targets = corners
|
|
75
|
+
? [Math.round(n / LATTICE) * LATTICE]
|
|
76
|
+
: [Math.round(n - LATTICE) + LATTICE];
|
|
77
|
+
let best = n;
|
|
78
|
+
let bestDist = cellPull;
|
|
79
|
+
for (const t of targets) {
|
|
80
|
+
const d = Math.abs(n - t);
|
|
81
|
+
if (d <= cellPull && d < bestDist) {
|
|
82
|
+
bestDist = d;
|
|
83
|
+
best = t + 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function magneticSnapPoint(x, y, cellPull, corners = false) {
|
|
90
|
+
return [magneticSnapValue(x, cellPull, corners), magneticSnapValue(y, cellPull, corners)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Cell-unit pull radius from screen pixels, artboard zoom, and resolution. */
|
|
94
|
+
export function magneticCellPull(resolution, displayWidthPx, zoom = 1) {
|
|
95
|
+
if (!(resolution > 0) || !(displayWidthPx > 0) || !(zoom > 0)) return LATTICE / 2;
|
|
96
|
+
const cellPx = (displayWidthPx * zoom) / resolution;
|
|
97
|
+
return MAGNETIC_SCREEN_PX / cellPx;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How many spans trace a derived ellipse. Circular arcs cannot be an ellipse,
|
|
102
|
+
* so this is an accuracy dial: measured against a true 7×4 oval, 4 spans are
|
|
103
|
+
* off by 0.225 cells, 8 by 0.039, and 16 comfortably under a tenth. A circle
|
|
104
|
+
* is exact at any count, since then each span really is a circular arc.
|
|
105
|
+
*/
|
|
106
|
+
const ELLIPSE_SPANS = 16;
|
|
107
|
+
|
|
108
|
+
/** Shapes whose geometry is generated from parameters rather than authored. */
|
|
109
|
+
export function isPrimitiveShape(shape) {
|
|
110
|
+
return shape?.prim === 'ellipse';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Which derived anchors double as edit handles — the four on the ellipse's own
|
|
115
|
+
* axes, so dragging one reads directly as a radius. The rest of the ring is
|
|
116
|
+
* scenery: showing all sixteen would suggest an editability that is not there.
|
|
117
|
+
*/
|
|
118
|
+
export function primitiveHandleIndices(shape) {
|
|
119
|
+
if (!isPrimitiveShape(shape)) return [];
|
|
120
|
+
const quarter = ELLIPSE_SPANS / 4;
|
|
121
|
+
return [0, quarter, 2 * quarter, 3 * quarter];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Runtime-only ellipse ring as cubic segments (see deriveShape).
|
|
126
|
+
* Never serialized. The generated curve stays off the half-cell lattice so
|
|
127
|
+
* ovals remain faithful; only the parameters are magnetized during authoring
|
|
128
|
+
* (§6.1.2).
|
|
129
|
+
*/
|
|
130
|
+
export function ellipseSubpaths(cx, cy, rx, ry) {
|
|
131
|
+
const at = (t) => [cx + rx * Math.cos(t), cy + ry * Math.sin(t)];
|
|
132
|
+
const points = [];
|
|
133
|
+
for (let i = 0; i < ELLIPSE_SPANS; i++) points.push(at((2 * Math.PI * i) / ELLIPSE_SPANS));
|
|
134
|
+
|
|
135
|
+
const start = [points[0][0], points[0][1]];
|
|
136
|
+
const segs = [];
|
|
137
|
+
for (let i = 0; i < ELLIPSE_SPANS; i++) {
|
|
138
|
+
const t0 = (2 * Math.PI * i) / ELLIPSE_SPANS;
|
|
139
|
+
const t1 = (2 * Math.PI * (i + 1)) / ELLIPSE_SPANS;
|
|
140
|
+
const samples = [];
|
|
141
|
+
for (let k = 0; k <= 16; k++) {
|
|
142
|
+
const [x, y] = at(t0 + ((t1 - t0) * k) / 16);
|
|
143
|
+
samples.push({ x, y });
|
|
144
|
+
}
|
|
145
|
+
const from = [points[i][0], points[i][1]];
|
|
146
|
+
const to = points[(i + 1) % ELLIPSE_SPANS];
|
|
147
|
+
const bow = sagittaAgainstChord(samples, from, to);
|
|
148
|
+
segs.push(...bowToSegments(from, to, bow));
|
|
149
|
+
}
|
|
150
|
+
return [{ closed: true, start, segs }];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Regenerate a primitive's geometry from its parameters. Identity otherwise. */
|
|
154
|
+
export function deriveShape(shape) {
|
|
155
|
+
if (shape?.prim !== 'ellipse') return shape;
|
|
156
|
+
return { ...shape, subpaths: ellipseSubpaths(shape.cx, shape.cy, shape.rx, shape.ry) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function emptyPathData() {
|
|
160
|
+
return { shapes: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isEmptyPathData(path) {
|
|
164
|
+
return !path || !Array.isArray(path.shapes) || path.shapes.length === 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function pointsEqual(a, b) {
|
|
168
|
+
return (
|
|
169
|
+
a &&
|
|
170
|
+
b &&
|
|
171
|
+
Math.abs(a[0] - b[0]) < 1e-12 &&
|
|
172
|
+
Math.abs(a[1] - b[1]) < 1e-12
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cloneSegment(seg) {
|
|
177
|
+
const out = { to: [seg.to[0], seg.to[1]] };
|
|
178
|
+
if (seg.c1) out.c1 = [seg.c1[0], seg.c1[1]];
|
|
179
|
+
if (seg.c2) out.c2 = [seg.c2[0], seg.c2[1]];
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function cloneSubpath(sub) {
|
|
184
|
+
const norm = normalizeSubpath(sub);
|
|
185
|
+
return {
|
|
186
|
+
closed: !!norm.closed,
|
|
187
|
+
start: [norm.start[0], norm.start[1]],
|
|
188
|
+
segs: (norm.segs ?? []).map(cloneSegment),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function straightSegment(to) {
|
|
193
|
+
return { to: [to[0], to[1]] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function makeSubpath(closed, start, segs) {
|
|
197
|
+
return {
|
|
198
|
+
closed: !!closed,
|
|
199
|
+
start: [start[0], start[1]],
|
|
200
|
+
segs: (segs ?? []).map(cloneSegment),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function clonePathData(path) {
|
|
205
|
+
return {
|
|
206
|
+
shapes: (path?.shapes ?? []).map((shape) => ({
|
|
207
|
+
...shape,
|
|
208
|
+
fill: shape.fill ?? null,
|
|
209
|
+
stroke: shape.stroke ?? null,
|
|
210
|
+
subpaths: (shape.subpaths ?? []).map((sub) => cloneSubpath(sub)),
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Shift every absolute anchor by (dx, dy). Relative c1/c2 offsets are unchanged. */
|
|
216
|
+
export function translatePathData(path, dx, dy) {
|
|
217
|
+
return {
|
|
218
|
+
shapes: (path?.shapes ?? []).map((shape) => {
|
|
219
|
+
// A primitive's centre is the truth; its points are regenerated from it,
|
|
220
|
+
// so moving them alone would be undone by the next derive.
|
|
221
|
+
const moved = isPrimitiveShape(shape)
|
|
222
|
+
? { ...shape, cx: shape.cx + dx, cy: shape.cy + dy }
|
|
223
|
+
: shape;
|
|
224
|
+
return {
|
|
225
|
+
...moved,
|
|
226
|
+
subpaths: (shape.subpaths ?? []).map((sub) => ({
|
|
227
|
+
...sub,
|
|
228
|
+
start: [sub.start[0] + dx, sub.start[1] + dy],
|
|
229
|
+
segs: (sub.segs ?? []).map((seg) => ({
|
|
230
|
+
...cloneSegment(seg),
|
|
231
|
+
to: [seg.to[0] + dx, seg.to[1] + dy],
|
|
232
|
+
})),
|
|
233
|
+
})),
|
|
234
|
+
};
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// tolerant parse / serialize
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
function finite(n, fallback = 0) {
|
|
244
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Parse one point — finite only; lattice magnetism is an authoring-time aid. */
|
|
248
|
+
function parsePoint(raw) {
|
|
249
|
+
if (!Array.isArray(raw) || raw.length < 2) return [0, 0];
|
|
250
|
+
return [finite(raw[0]) + 0, finite(raw[1]) + 0];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parsePaletteKey(raw) {
|
|
254
|
+
if (raw == null) return null;
|
|
255
|
+
return typeof raw === 'string' && raw.length === 1 ? raw : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseSegment(raw) {
|
|
259
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.to)) return null;
|
|
260
|
+
const to = parsePoint(raw.to);
|
|
261
|
+
const seg = { to };
|
|
262
|
+
if (Array.isArray(raw.c1) && raw.c1.length >= 2) {
|
|
263
|
+
seg.c1 = [finite(raw.c1[0]) + 0, finite(raw.c1[1]) + 0];
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(raw.c2) && raw.c2.length >= 2) {
|
|
266
|
+
seg.c2 = [finite(raw.c2[0]) + 0, finite(raw.c2[1]) + 0];
|
|
267
|
+
}
|
|
268
|
+
return stripZeroHandles(seg);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseSubpath(raw) {
|
|
272
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.start)) return null;
|
|
273
|
+
const closed = !!raw.closed;
|
|
274
|
+
const start = parsePoint(raw.start);
|
|
275
|
+
const segsIn = Array.isArray(raw.segs) ? raw.segs : [];
|
|
276
|
+
const segs = [];
|
|
277
|
+
for (const s of segsIn) {
|
|
278
|
+
const seg = parseSegment(s);
|
|
279
|
+
if (seg) segs.push(seg);
|
|
280
|
+
}
|
|
281
|
+
if (!start && segs.length === 0) return null;
|
|
282
|
+
return canonicalizeSubpath({ closed, start, segs });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const MIN_ELLIPSE_RADIUS = 0.01;
|
|
286
|
+
export { MIN_ELLIPSE_RADIUS };
|
|
287
|
+
|
|
288
|
+
/** Ellipse parameters — continuous; a zero radius would read as empty geometry. */
|
|
289
|
+
function parseEllipseParams(raw) {
|
|
290
|
+
return {
|
|
291
|
+
cx: finite(raw.cx) + 0,
|
|
292
|
+
cy: finite(raw.cy) + 0,
|
|
293
|
+
rx: Math.max(MIN_ELLIPSE_RADIUS, Math.abs(finite(raw.rx))),
|
|
294
|
+
ry: Math.max(MIN_ELLIPSE_RADIUS, Math.abs(finite(raw.ry))),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseShape(raw) {
|
|
299
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
300
|
+
|
|
301
|
+
// Primitives carry parameters instead of geometry. Derive on the way in so
|
|
302
|
+
// every consumer downstream — bake, stroke tracing, hit-testing, the vector
|
|
303
|
+
// renderer — sees an ordinary shape and needs to know nothing about this.
|
|
304
|
+
if (raw.prim === 'ellipse') {
|
|
305
|
+
const { fill: _pf, stroke: _ps, subpaths: _psp, ...rest } = raw;
|
|
306
|
+
return deriveShape({
|
|
307
|
+
...rest,
|
|
308
|
+
...parseEllipseParams(raw),
|
|
309
|
+
fill: parsePaletteKey(raw.fill),
|
|
310
|
+
stroke: parsePaletteKey(raw.stroke),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const subpaths = Array.isArray(raw.subpaths)
|
|
315
|
+
? raw.subpaths.map(parseSubpath).filter(Boolean)
|
|
316
|
+
: [];
|
|
317
|
+
if (subpaths.length === 0 && raw.fill == null && raw.stroke == null) return null;
|
|
318
|
+
// Preserve unknown keys for additive future variants.
|
|
319
|
+
const { fill: _f, stroke: _s, subpaths: _sp, ...rest } = raw;
|
|
320
|
+
return {
|
|
321
|
+
...rest,
|
|
322
|
+
fill: parsePaletteKey(raw.fill),
|
|
323
|
+
stroke: parsePaletteKey(raw.stroke),
|
|
324
|
+
subpaths,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Tolerantly parse a cell's `path` field. Returns null when unusable at the
|
|
330
|
+
* TOP level (no `shapes` array) — callers then treat the cell as a plain
|
|
331
|
+
* pixel cell (grid is truth regardless). Malformed shapes/subpaths drop
|
|
332
|
+
* individually.
|
|
333
|
+
*/
|
|
334
|
+
export function parsePathData(raw) {
|
|
335
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.shapes)) return null;
|
|
336
|
+
const shapes = raw.shapes.map(parseShape).filter(Boolean);
|
|
337
|
+
return { shapes };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Serialize path, or undefined when empty (omit-when-default). */
|
|
341
|
+
export function serializePathData(path) {
|
|
342
|
+
if (isEmptyPathData(path)) return undefined;
|
|
343
|
+
return {
|
|
344
|
+
shapes: path.shapes.map((shape) => {
|
|
345
|
+
const { fill, stroke, subpaths, ...rest } = shape;
|
|
346
|
+
if (isPrimitiveShape(shape)) {
|
|
347
|
+
return {
|
|
348
|
+
...rest,
|
|
349
|
+
fill: fill ?? null,
|
|
350
|
+
stroke: stroke ?? null,
|
|
351
|
+
cx: roundCoord(shape.cx),
|
|
352
|
+
cy: roundCoord(shape.cy),
|
|
353
|
+
rx: roundCoord(shape.rx),
|
|
354
|
+
ry: roundCoord(shape.ry),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
...rest,
|
|
359
|
+
fill: fill ?? null,
|
|
360
|
+
stroke: stroke ?? null,
|
|
361
|
+
subpaths: (subpaths ?? []).map((sub) => serializeSubpath(sub)),
|
|
362
|
+
};
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Curve sampling + stroke rasterization
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function dist(a, b) {
|
|
372
|
+
return Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function cubicPoint(p0, c1, c2, p1, t) {
|
|
376
|
+
const mt = 1 - t;
|
|
377
|
+
const a = mt * mt * mt;
|
|
378
|
+
const b = 3 * mt * mt * t;
|
|
379
|
+
const c = 3 * mt * t * t;
|
|
380
|
+
const d = t * t * t;
|
|
381
|
+
return [
|
|
382
|
+
a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0],
|
|
383
|
+
a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1],
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function sampleEdgeCurve(edge, verts) {
|
|
388
|
+
const p0 = verts[edge.a] ?? [0, 0];
|
|
389
|
+
const p1 = verts[edge.b] ?? [0, 0];
|
|
390
|
+
const points = [];
|
|
391
|
+
// Representation layer: stored handles win. Bow-only edges (gesture previews,
|
|
392
|
+
// no handles yet) sample the arc chain. Wide arcs are already split into ≤90°
|
|
393
|
+
// cubics at every write/parse funnel, so a single stored cubic never spans one.
|
|
394
|
+
let segs = null;
|
|
395
|
+
if (edge.ha || edge.hb) {
|
|
396
|
+
segs = [
|
|
397
|
+
{
|
|
398
|
+
c1: edge.ha ? [p0[0] + edge.ha[0], p0[1] + edge.ha[1]] : p0,
|
|
399
|
+
c2: edge.hb ? [p1[0] + edge.hb[0], p1[1] + edge.hb[1]] : p1,
|
|
400
|
+
to: p1,
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
} else if (edge.bow != null) {
|
|
404
|
+
segs = arcCubics(p0, p1, edge.bow);
|
|
405
|
+
}
|
|
406
|
+
if (!segs) {
|
|
407
|
+
const steps = Math.max(1, Math.ceil(dist(p0, p1) * 4));
|
|
408
|
+
for (let i = 0; i <= steps; i++) {
|
|
409
|
+
const t = i / steps;
|
|
410
|
+
points.push({
|
|
411
|
+
x: p0[0] + (p1[0] - p0[0]) * t,
|
|
412
|
+
y: p0[1] + (p1[1] - p0[1]) * t,
|
|
413
|
+
t,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return points;
|
|
417
|
+
}
|
|
418
|
+
let from = p0;
|
|
419
|
+
segs.forEach((seg, segIndex) => {
|
|
420
|
+
const approxLength =
|
|
421
|
+
dist(from, seg.c1) + dist(seg.c1, seg.c2) + dist(seg.c2, seg.to);
|
|
422
|
+
const steps = Math.max(12, Math.ceil(approxLength * 4));
|
|
423
|
+
for (let i = segIndex === 0 ? 0 : 1; i <= steps; i++) {
|
|
424
|
+
const local = i / steps;
|
|
425
|
+
const [x, y] = cubicPoint(from, seg.c1, seg.c2, seg.to, local);
|
|
426
|
+
points.push({ x, y, t: (segIndex + local) / segs.length });
|
|
427
|
+
}
|
|
428
|
+
from = seg.to;
|
|
429
|
+
});
|
|
430
|
+
return points;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const BOUNDARY_EPS = 1e-7;
|
|
434
|
+
|
|
435
|
+
function quantizeAxis(v, centroid) {
|
|
436
|
+
const nearest = Math.round(v);
|
|
437
|
+
if (Math.abs(v - nearest) >= BOUNDARY_EPS) return Math.floor(v);
|
|
438
|
+
if (Math.abs(v - centroid) < BOUNDARY_EPS) return Math.floor(v);
|
|
439
|
+
return v < centroid ? nearest - 1 : nearest;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function flattenEdge(edge, verts) {
|
|
443
|
+
const samples = sampleEdgeCurve(edge, verts);
|
|
444
|
+
if (samples.length === 0) return { points: [], cx: 0, cy: 0 };
|
|
445
|
+
let cx = 0;
|
|
446
|
+
let cy = 0;
|
|
447
|
+
for (const s of samples) {
|
|
448
|
+
cx += s.x;
|
|
449
|
+
cy += s.y;
|
|
450
|
+
}
|
|
451
|
+
cx /= samples.length;
|
|
452
|
+
cy /= samples.length;
|
|
453
|
+
return {
|
|
454
|
+
points: samples.map(({ x, y, t }) => ({
|
|
455
|
+
x: quantizeAxis(x, cx),
|
|
456
|
+
y: quantizeAxis(y, cy),
|
|
457
|
+
t,
|
|
458
|
+
})),
|
|
459
|
+
cx,
|
|
460
|
+
cy,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function bresenham(a, b) {
|
|
465
|
+
const pts = [];
|
|
466
|
+
let x = a.x;
|
|
467
|
+
let y = a.y;
|
|
468
|
+
const dx = Math.abs(b.x - x);
|
|
469
|
+
const dy = Math.abs(b.y - y);
|
|
470
|
+
const sx = b.x >= x ? 1 : -1;
|
|
471
|
+
const sy = b.y >= y ? 1 : -1;
|
|
472
|
+
let err = dx - dy;
|
|
473
|
+
for (;;) {
|
|
474
|
+
pts.push({ x, y });
|
|
475
|
+
if (x === b.x && y === b.y) break;
|
|
476
|
+
const e2 = err * 2;
|
|
477
|
+
if (e2 > -dy) {
|
|
478
|
+
err -= dy;
|
|
479
|
+
x += sx;
|
|
480
|
+
}
|
|
481
|
+
if (e2 < dx) {
|
|
482
|
+
err += dx;
|
|
483
|
+
y += sy;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const t0 = typeof a.t === 'number' ? a.t : 0;
|
|
487
|
+
const t1 = typeof b.t === 'number' ? b.t : t0;
|
|
488
|
+
if (pts.length === 1) {
|
|
489
|
+
pts[0].t = t0;
|
|
490
|
+
} else {
|
|
491
|
+
for (let i = 0; i < pts.length; i++) {
|
|
492
|
+
pts[i].t = t0 + (t1 - t0) * (i / (pts.length - 1));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return pts;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function repairChain(points) {
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const p of points) {
|
|
501
|
+
const last = out[out.length - 1];
|
|
502
|
+
if (last && last.x === p.x && last.y === p.y) continue;
|
|
503
|
+
if (last && (Math.abs(p.x - last.x) > 1 || Math.abs(p.y - last.y) > 1)) {
|
|
504
|
+
const bridge = bresenham(last, p);
|
|
505
|
+
for (let i = 1; i < bridge.length - 1; i++) out.push(bridge[i]);
|
|
506
|
+
}
|
|
507
|
+
out.push(p);
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isElbow(prev, cur, next) {
|
|
513
|
+
const diagonal = Math.abs(next.x - prev.x) === 1 && Math.abs(next.y - prev.y) === 1;
|
|
514
|
+
return (
|
|
515
|
+
diagonal && ((cur.x === prev.x && cur.y === next.y) || (cur.y === prev.y && cur.x === next.x))
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function cellDist2(p, cx, cy) {
|
|
520
|
+
const dx = p.x + 0.5 - cx;
|
|
521
|
+
const dy = p.y + 0.5 - cy;
|
|
522
|
+
return dx * dx + dy * dy;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function cleanupCorners(chain, cx, cy) {
|
|
526
|
+
if (chain.length < 3) return chain.slice();
|
|
527
|
+
const candidates = [];
|
|
528
|
+
for (let i = 1; i < chain.length - 1; i++) {
|
|
529
|
+
const prev = chain[i - 1];
|
|
530
|
+
const cur = chain[i];
|
|
531
|
+
const next = chain[i + 1];
|
|
532
|
+
if (!isElbow(prev, cur, next)) continue;
|
|
533
|
+
const opp = {
|
|
534
|
+
x: prev.x === cur.x ? next.x : prev.x,
|
|
535
|
+
y: prev.y === cur.y ? next.y : prev.y,
|
|
536
|
+
};
|
|
537
|
+
const d = cellDist2(cur, cx, cy);
|
|
538
|
+
if (d > cellDist2(opp, cx, cy)) candidates.push({ i, d });
|
|
539
|
+
}
|
|
540
|
+
candidates.sort(
|
|
541
|
+
(a, b) =>
|
|
542
|
+
b.d - a.d ||
|
|
543
|
+
Math.abs(chain[b.i].x + 0.5 - cx) - Math.abs(chain[a.i].x + 0.5 - cx) ||
|
|
544
|
+
Math.abs(chain[b.i].y + 0.5 - cy) - Math.abs(chain[a.i].y + 0.5 - cy),
|
|
545
|
+
);
|
|
546
|
+
const dropped = new Set();
|
|
547
|
+
for (const { i } of candidates) {
|
|
548
|
+
dropped.add(i);
|
|
549
|
+
let ok = true;
|
|
550
|
+
let prevKept = -1;
|
|
551
|
+
for (let j = 0; j < chain.length; j++) {
|
|
552
|
+
if (dropped.has(j)) continue;
|
|
553
|
+
if (prevKept >= 0) {
|
|
554
|
+
const a = chain[prevKept];
|
|
555
|
+
const b = chain[j];
|
|
556
|
+
if (Math.abs(a.x - b.x) > 1 || Math.abs(a.y - b.y) > 1) {
|
|
557
|
+
ok = false;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
prevKept = j;
|
|
562
|
+
}
|
|
563
|
+
if (!ok) dropped.delete(i);
|
|
564
|
+
}
|
|
565
|
+
return chain.filter((_, i) => !dropped.has(i));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Flatten → repair → direction-invariant corner cleanup. */
|
|
569
|
+
export function traceEdgePixels(edge, verts) {
|
|
570
|
+
const { points, cx, cy } = flattenEdge(edge, verts);
|
|
571
|
+
return cleanupCorners(repairChain(points), cx, cy);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function closestParamOnEdge(edge, verts, x, y) {
|
|
575
|
+
const samples = sampleEdgeCurve(edge, verts);
|
|
576
|
+
let bestT = 0.5;
|
|
577
|
+
let bestD = Infinity;
|
|
578
|
+
for (const s of samples) {
|
|
579
|
+
const dx = s.x - x;
|
|
580
|
+
const dy = s.y - y;
|
|
581
|
+
const d = dx * dx + dy * dy;
|
|
582
|
+
if (d < bestD) {
|
|
583
|
+
bestD = d;
|
|
584
|
+
bestT = s.t;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return bestT;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// Detented bow vocabulary
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
const BOW_EPS = 1e-12;
|
|
595
|
+
|
|
596
|
+
function chordNormal(pa, pb) {
|
|
597
|
+
const dx = pb[0] - pa[0];
|
|
598
|
+
const dy = pb[1] - pa[1];
|
|
599
|
+
const L = Math.hypot(dx, dy);
|
|
600
|
+
if (L < BOW_EPS) return { L: 0, nx: 0, ny: 0, dx, dy };
|
|
601
|
+
return { L, nx: -dy / L, ny: dx / L, dx, dy };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Detent ratios = tan(θ/4)/2 for central angles θ ∈ {0°, 45°, 90°, 135°, 180°}.
|
|
605
|
+
// For a circular arc of chord L spanning central angle θ, sagitta/L = tan(θ/4)/2.
|
|
606
|
+
// Detents therefore describe arc SHAPE (how much of a circle), not apex
|
|
607
|
+
// position: scale-invariant, and the quarter-circle detent (θ=90°) means four
|
|
608
|
+
// anchors on a circle's cardinals produce an exact circle at every radius.
|
|
609
|
+
// Top detent is 0.5 (semicircle), so |bow| ≤ L/2 is subsumed.
|
|
610
|
+
const BOW_RATIO_DETENTS = [0, 45, 90, 135, 180].map(
|
|
611
|
+
(deg) => Math.tan(((deg * Math.PI) / 180) / 4) / 2,
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
/** Semicircle — also the top detent. */
|
|
615
|
+
const MAX_BOW_RATIO = 0.5;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* How near a detent a raw ratio must fall to be pulled onto it. Ratio units,
|
|
619
|
+
* so it is scale-invariant like the detents themselves. Detent gaps run
|
|
620
|
+
* 0.099–0.166, so this magnetizes roughly the outer fifth of each gap.
|
|
621
|
+
* Purely a feel constant — tune it here.
|
|
622
|
+
*/
|
|
623
|
+
export const BOW_DETENT_PULL = 0.03;
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Resolve a raw sagitta against the ratio detents, clamped to a semicircle.
|
|
627
|
+
* Detents are magnetic (BOW_DETENT_PULL), not mandatory — used by bend/gesture
|
|
628
|
+
* tooling; storage is cubics.
|
|
629
|
+
*/
|
|
630
|
+
export function snapBow(bow, chordLength, pull = BOW_DETENT_PULL) {
|
|
631
|
+
if (!(chordLength > BOW_EPS) || !Number.isFinite(bow)) return 0;
|
|
632
|
+
const ratio = bow / chordLength;
|
|
633
|
+
const sign = ratio < 0 ? -1 : 1;
|
|
634
|
+
// Clamp to a semicircle so the bow matches what handlesForBow / arcCubics
|
|
635
|
+
// will draw (they clamp too).
|
|
636
|
+
const abs = Math.min(MAX_BOW_RATIO, Math.abs(ratio));
|
|
637
|
+
let best = BOW_RATIO_DETENTS[0];
|
|
638
|
+
let bestDist = Infinity;
|
|
639
|
+
for (const d of BOW_RATIO_DETENTS) {
|
|
640
|
+
const dist = Math.abs(abs - d);
|
|
641
|
+
if (dist < bestDist) {
|
|
642
|
+
bestDist = dist;
|
|
643
|
+
best = d;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const resolved = bestDist <= pull ? best : abs;
|
|
647
|
+
const out = sign * resolved * chordLength;
|
|
648
|
+
if (Math.abs(out) < BOW_EPS) return 0;
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function handlesForBow(pa, pb, bow) {
|
|
653
|
+
if (!pa || !pb || !Number.isFinite(bow)) return null;
|
|
654
|
+
const { L, nx, ny } = chordNormal(pa, pb);
|
|
655
|
+
if (L < BOW_EPS) return null;
|
|
656
|
+
const maxBow = L / 2;
|
|
657
|
+
let s = Math.min(maxBow, Math.max(-maxBow, bow));
|
|
658
|
+
if (Math.abs(s) < BOW_EPS) return null;
|
|
659
|
+
|
|
660
|
+
const R = (s * s + (L * L) / 4) / (2 * Math.abs(s));
|
|
661
|
+
const mx = (pa[0] + pb[0]) / 2;
|
|
662
|
+
const my = (pa[1] + pb[1]) / 2;
|
|
663
|
+
const sign = s > 0 ? 1 : -1;
|
|
664
|
+
const cx = mx + (s - sign * R) * nx;
|
|
665
|
+
const cy = my + (s - sign * R) * ny;
|
|
666
|
+
|
|
667
|
+
const sinPhi = Math.min(1, L / (2 * R));
|
|
668
|
+
const phi = Math.asin(sinPhi);
|
|
669
|
+
const k = ((4 / 3) * Math.tan(phi / 2)) * R;
|
|
670
|
+
|
|
671
|
+
let rax = pa[0] - cx;
|
|
672
|
+
let ray = pa[1] - cy;
|
|
673
|
+
let rbx = pb[0] - cx;
|
|
674
|
+
let rby = pb[1] - cy;
|
|
675
|
+
const raLen = Math.hypot(rax, ray);
|
|
676
|
+
const rbLen = Math.hypot(rbx, rby);
|
|
677
|
+
if (raLen < BOW_EPS || rbLen < BOW_EPS) return null;
|
|
678
|
+
rax /= raLen;
|
|
679
|
+
ray /= raLen;
|
|
680
|
+
rbx /= rbLen;
|
|
681
|
+
rby /= rbLen;
|
|
682
|
+
|
|
683
|
+
const toApexX = mx + s * nx - cx;
|
|
684
|
+
const toApexY = my + s * ny - cy;
|
|
685
|
+
const ccw = rax * toApexY - ray * toApexX >= 0;
|
|
686
|
+
const tax = ccw ? -ray : ray;
|
|
687
|
+
const tay = ccw ? rax : -rax;
|
|
688
|
+
const tbx = ccw ? -rby : rby;
|
|
689
|
+
const tby = ccw ? rbx : -rbx;
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
ha: [k * tax, k * tay],
|
|
693
|
+
hb: [-k * tbx, -k * tby],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Widest arc drawn as a single cubic. One cubic tracks a circular arc well only
|
|
699
|
+
* while the span stays small, and the falloff is steep: at radius 4 a 90° span
|
|
700
|
+
* is off by 0.001 cells, but a 180° span — a pill's cap, or any bow at the
|
|
701
|
+
* semicircle clamp — is off by 0.073, which is visible. Since a bow can never
|
|
702
|
+
* exceed a semicircle, this ceiling means at most two cubics per edge.
|
|
703
|
+
*/
|
|
704
|
+
const MAX_CUBIC_ARC = Math.PI / 2;
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The arc a bow describes, as centre, radius, start angle and signed sweep.
|
|
708
|
+
* Null when the bow is too slight to be an arc at all.
|
|
709
|
+
*/
|
|
710
|
+
function arcFromBow(pa, pb, bow) {
|
|
711
|
+
if (!pa || !pb || !Number.isFinite(bow)) return null;
|
|
712
|
+
const { L, nx, ny } = chordNormal(pa, pb);
|
|
713
|
+
if (L < BOW_EPS) return null;
|
|
714
|
+
const maxBow = L / 2;
|
|
715
|
+
const s = Math.min(maxBow, Math.max(-maxBow, bow));
|
|
716
|
+
if (Math.abs(s) < BOW_EPS) return null;
|
|
717
|
+
|
|
718
|
+
const R = (s * s + (L * L) / 4) / (2 * Math.abs(s));
|
|
719
|
+
const mx = (pa[0] + pb[0]) / 2;
|
|
720
|
+
const my = (pa[1] + pb[1]) / 2;
|
|
721
|
+
const sign = s > 0 ? 1 : -1;
|
|
722
|
+
const cx = mx + (s - sign * R) * nx;
|
|
723
|
+
const cy = my + (s - sign * R) * ny;
|
|
724
|
+
if (!(R > BOW_EPS)) return null;
|
|
725
|
+
|
|
726
|
+
// The apex tells us which way round the centre the arc actually travels.
|
|
727
|
+
const rax = pa[0] - cx;
|
|
728
|
+
const ray = pa[1] - cy;
|
|
729
|
+
const ccw = rax * (my + s * ny - cy) - ray * (mx + s * nx - cx) >= 0;
|
|
730
|
+
const sweep = 2 * Math.asin(Math.min(1, L / (2 * R))) * (ccw ? 1 : -1);
|
|
731
|
+
return { cx, cy, R, a0: Math.atan2(ray, rax), sweep };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* A bow as a chain of absolute cubic segments `{ c1, c2, to }`, subdivided so
|
|
736
|
+
* no segment spans more than MAX_CUBIC_ARC. Callers that draw or flatten a bow
|
|
737
|
+
* should use this; `handlesForBow` remains the single-cubic form, still useful
|
|
738
|
+
* as a "can this bow be drawn at all" predicate.
|
|
739
|
+
*/
|
|
740
|
+
export function arcCubics(pa, pb, bow) {
|
|
741
|
+
const arc = arcFromBow(pa, pb, bow);
|
|
742
|
+
if (!arc) return null;
|
|
743
|
+
const { cx, cy, R, a0, sweep } = arc;
|
|
744
|
+
const count = Math.max(1, Math.ceil(Math.abs(sweep) / MAX_CUBIC_ARC));
|
|
745
|
+
const step = sweep / count;
|
|
746
|
+
const k = ((4 / 3) * Math.tan(step / 4)) * R;
|
|
747
|
+
|
|
748
|
+
const out = [];
|
|
749
|
+
for (let i = 0; i < count; i++) {
|
|
750
|
+
const t0 = a0 + step * i;
|
|
751
|
+
const t1 = a0 + step * (i + 1);
|
|
752
|
+
const p0 = [cx + R * Math.cos(t0), cy + R * Math.sin(t0)];
|
|
753
|
+
const p1 = [cx + R * Math.cos(t1), cy + R * Math.sin(t1)];
|
|
754
|
+
// Tangents point along the sweep, so k carries the sweep's sign with it.
|
|
755
|
+
out.push({
|
|
756
|
+
c1: [p0[0] - k * Math.sin(t0), p0[1] + k * Math.cos(t0)],
|
|
757
|
+
c2: [p1[0] + k * Math.sin(t1), p1[1] - k * Math.cos(t1)],
|
|
758
|
+
to: i === count - 1 ? [pb[0], pb[1]] : p1,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
return out;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Max signed orthogonal distance of samples from chord pa→pb. */
|
|
765
|
+
export function sagittaAgainstChord(samples, pa, pb) {
|
|
766
|
+
const { L, dx, dy } = chordNormal(pa, pb);
|
|
767
|
+
if (L < BOW_EPS) return 0;
|
|
768
|
+
let bestDist = 0;
|
|
769
|
+
let bestSign = 0;
|
|
770
|
+
for (const s of samples) {
|
|
771
|
+
const cross = dx * (s.y - pa[1]) - dy * (s.x - pa[0]);
|
|
772
|
+
const side = cross / L;
|
|
773
|
+
const ad = Math.abs(side);
|
|
774
|
+
if (ad > bestDist) {
|
|
775
|
+
bestDist = ad;
|
|
776
|
+
bestSign = side >= 0 ? 1 : -1;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (bestDist < BOW_EPS) return 0;
|
|
780
|
+
return bestSign * bestDist;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function bowForEdge(edge, verts) {
|
|
784
|
+
if (!edge || !verts) return 0;
|
|
785
|
+
const pa = verts[edge.a];
|
|
786
|
+
const pb = verts[edge.b];
|
|
787
|
+
if (!pa || !pb) return 0;
|
|
788
|
+
return sagittaAgainstChord(sampleEdgeCurve(edge, verts), pa, pb);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// Segment-list subpath model (cubic segments)
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
function stripZeroHandles(seg) {
|
|
796
|
+
const out = { to: [seg.to[0], seg.to[1]] };
|
|
797
|
+
if (seg.c1 && (Math.abs(seg.c1[0]) > BOW_EPS || Math.abs(seg.c1[1]) > BOW_EPS)) {
|
|
798
|
+
out.c1 = [seg.c1[0], seg.c1[1]];
|
|
799
|
+
}
|
|
800
|
+
if (seg.c2 && (Math.abs(seg.c2[0]) > BOW_EPS || Math.abs(seg.c2[1]) > BOW_EPS)) {
|
|
801
|
+
out.c2 = [seg.c2[0], seg.c2[1]];
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function segmentFromAbsolute(from, to, c1Abs, c2Abs) {
|
|
807
|
+
const seg = { to: [to[0], to[1]] };
|
|
808
|
+
if (c1Abs) {
|
|
809
|
+
const d1 = [c1Abs[0] - from[0], c1Abs[1] - from[1]];
|
|
810
|
+
if (Math.hypot(d1[0], d1[1]) >= BOW_EPS) seg.c1 = d1;
|
|
811
|
+
}
|
|
812
|
+
if (c2Abs) {
|
|
813
|
+
const d2 = [c2Abs[0] - to[0], c2Abs[1] - to[1]];
|
|
814
|
+
if (Math.hypot(d2[0], d2[1]) >= BOW_EPS) seg.c2 = d2;
|
|
815
|
+
}
|
|
816
|
+
return stripZeroHandles(seg);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function absoluteSegment(from, seg) {
|
|
820
|
+
const to = seg.to;
|
|
821
|
+
const c1 = seg.c1 ? [from[0] + seg.c1[0], from[1] + seg.c1[1]] : [from[0], from[1]];
|
|
822
|
+
const c2 = seg.c2 ? [to[0] + seg.c2[0], to[1] + seg.c2[1]] : [to[0], to[1]];
|
|
823
|
+
return { from, to, c1, c2 };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function bowToSegments(from, to, bow) {
|
|
827
|
+
if (Math.abs(bow) < BOW_EPS) return [straightSegment(to)];
|
|
828
|
+
const cubics = arcCubics(from, to, bow);
|
|
829
|
+
if (!cubics?.length) return [straightSegment(to)];
|
|
830
|
+
const out = [];
|
|
831
|
+
let cur = from;
|
|
832
|
+
for (const c of cubics) {
|
|
833
|
+
out.push(segmentFromAbsolute(cur, c.to, c.c1, c.c2));
|
|
834
|
+
cur = c.to;
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Drop a closing segment that only restates `start` in a straight line.
|
|
841
|
+
*
|
|
842
|
+
* Closure is implicit, as in SVG's `Z`: a closed subpath's segment list ends at
|
|
843
|
+
* its last distinct vertex, and the edge back to `start` is synthesized on read.
|
|
844
|
+
* A *curved* close can't be synthesized — it carries handles — so it stays an
|
|
845
|
+
* explicit final segment, which is why subpathClosesOnStart still has work to
|
|
846
|
+
* do. Canonicalizing here, on the one funnel every reader goes through, is what
|
|
847
|
+
* keeps the two forms from coexisting in memory and disagreeing on edge count.
|
|
848
|
+
*/
|
|
849
|
+
function canonicalizeSubpath(sub) {
|
|
850
|
+
const segs = sub.segs ?? [];
|
|
851
|
+
const last = segs[segs.length - 1];
|
|
852
|
+
if (!sub.closed || !last || last.c1 || last.c2) return sub;
|
|
853
|
+
if (!pointsEqual(last.to, sub.start)) return sub;
|
|
854
|
+
return { ...sub, segs: segs.slice(0, -1) };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Canonical start/segs form (clone + drop redundant straight closes). */
|
|
858
|
+
export function normalizeSubpath(sub) {
|
|
859
|
+
if (!sub || typeof sub !== 'object' || !Array.isArray(sub.start)) return sub;
|
|
860
|
+
return canonicalizeSubpath({
|
|
861
|
+
closed: !!sub.closed,
|
|
862
|
+
start: [sub.start[0], sub.start[1]],
|
|
863
|
+
segs: (sub.segs ?? []).map(cloneSegment),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* True when the subpath carries an explicit closing segment duplicating `start`.
|
|
869
|
+
*
|
|
870
|
+
* That duplicate is vertex 0, not a vertex of its own, so it is invisible to
|
|
871
|
+
* subpathPoints. Anything that rebuilds segments by index must map the closing
|
|
872
|
+
* segment back to vertex 0 — indexing it as `segIndex + 1` strands it at the old
|
|
873
|
+
* coordinate, which surfaces as a phantom anchor and a spike back to where the
|
|
874
|
+
* shape used to be. Expects a start-form subpath (normalizeSubpath output).
|
|
875
|
+
*/
|
|
876
|
+
export function subpathClosesOnStart(sub) {
|
|
877
|
+
const segs = sub.segs ?? [];
|
|
878
|
+
return sub.closed && segs.length > 0 && pointsEqual(segs[segs.length - 1].to, sub.start);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Distinct anchor count (closing duplicate omitted). */
|
|
882
|
+
export function subpathVertexCount(sub) {
|
|
883
|
+
const s = normalizeSubpath(sub);
|
|
884
|
+
const segs = s.segs ?? [];
|
|
885
|
+
if (!s?.start) return 0;
|
|
886
|
+
if (!segs.length) return 1;
|
|
887
|
+
if (subpathClosesOnStart(s)) return segs.length;
|
|
888
|
+
return 1 + segs.length;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function subpathPointAt(sub, index) {
|
|
892
|
+
const s = normalizeSubpath(sub);
|
|
893
|
+
const segs = s.segs ?? [];
|
|
894
|
+
if (!s?.start || index < 0) return null;
|
|
895
|
+
if (index === 0) return [s.start[0], s.start[1]];
|
|
896
|
+
if (subpathClosesOnStart(s)) {
|
|
897
|
+
if (index >= segs.length) return null;
|
|
898
|
+
return [segs[index - 1].to[0], segs[index - 1].to[1]];
|
|
899
|
+
}
|
|
900
|
+
const seg = segs[index - 1];
|
|
901
|
+
return seg ? [seg.to[0], seg.to[1]] : null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function subpathPoints(sub) {
|
|
905
|
+
const n = subpathVertexCount(sub);
|
|
906
|
+
const pts = [];
|
|
907
|
+
for (let i = 0; i < n; i++) {
|
|
908
|
+
const p = subpathPointAt(sub, i);
|
|
909
|
+
if (p) pts.push(p);
|
|
910
|
+
}
|
|
911
|
+
return pts;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* True when the edge back to `start` is implied rather than stored — the
|
|
916
|
+
* canonical shape of a closed subpath whose closing edge is straight.
|
|
917
|
+
* Expects a canonicalized subpath (normalizeSubpath output).
|
|
918
|
+
*/
|
|
919
|
+
function hasImpliedClose(sub) {
|
|
920
|
+
return !!sub.closed && (sub.segs?.length ?? 0) > 0 && !subpathClosesOnStart(sub);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export function subpathEdgeCount(sub) {
|
|
924
|
+
const s = normalizeSubpath(sub);
|
|
925
|
+
const n = s.segs?.length ?? 0;
|
|
926
|
+
return hasImpliedClose(s) ? n + 1 : n;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export function subpathEdgeFrom(sub, edgeIndex) {
|
|
930
|
+
const s = normalizeSubpath(sub);
|
|
931
|
+
const segs = s.segs ?? [];
|
|
932
|
+
if (edgeIndex === segs.length && hasImpliedClose(s)) {
|
|
933
|
+
return {
|
|
934
|
+
from: subpathPointAt(s, segs.length),
|
|
935
|
+
seg: straightSegment([s.start[0], s.start[1]]),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const seg = segs[edgeIndex];
|
|
939
|
+
if (!seg) return null;
|
|
940
|
+
const from =
|
|
941
|
+
edgeIndex === 0
|
|
942
|
+
? [s.start[0], s.start[1]]
|
|
943
|
+
: subpathPointAt(s, edgeIndex);
|
|
944
|
+
return { from, seg };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function segmentToEdge(from, seg, strokeKey = null) {
|
|
948
|
+
const edge = { a: 0, b: 1, key: strokeKey };
|
|
949
|
+
if (seg.c1) edge.ha = [seg.c1[0], seg.c1[1]];
|
|
950
|
+
if (seg.c2) edge.hb = [seg.c2[0], seg.c2[1]];
|
|
951
|
+
return edge;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export function bowForSegment(from, seg) {
|
|
955
|
+
return bowForEdge(segmentToEdge(from, seg), [from, seg.to]);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function lerpPoint(a, b, t) {
|
|
959
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** de Casteljau split of one cubic segment at parameter t ∈ [0,1]. */
|
|
963
|
+
export function deCasteljauSplit(from, seg, t) {
|
|
964
|
+
const { from: p0, to: p3, c1: p1, c2: p2 } = absoluteSegment(from, seg);
|
|
965
|
+
if (!seg.c1 && !seg.c2) {
|
|
966
|
+
const mid = lerpPoint(p0, p3, t);
|
|
967
|
+
return {
|
|
968
|
+
mid,
|
|
969
|
+
left: straightSegment(mid),
|
|
970
|
+
right: straightSegment(p3),
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const p01 = lerpPoint(p0, p1, t);
|
|
974
|
+
const p12 = lerpPoint(p1, p2, t);
|
|
975
|
+
const p23 = lerpPoint(p2, p3, t);
|
|
976
|
+
const p012 = lerpPoint(p01, p12, t);
|
|
977
|
+
const p123 = lerpPoint(p12, p23, t);
|
|
978
|
+
const p0123 = lerpPoint(p012, p123, t);
|
|
979
|
+
return {
|
|
980
|
+
mid: [p0123[0], p0123[1]],
|
|
981
|
+
left: segmentFromAbsolute(p0, p0123, p01, p012),
|
|
982
|
+
right: segmentFromAbsolute(p0123, p3, p123, p23),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function serializeSegment(seg) {
|
|
987
|
+
const out = { to: roundPoint(seg.to[0], seg.to[1]) };
|
|
988
|
+
if (seg.c1) out.c1 = [roundCoord(seg.c1[0]), roundCoord(seg.c1[1])];
|
|
989
|
+
if (seg.c2) out.c2 = [roundCoord(seg.c2[0]), roundCoord(seg.c2[1])];
|
|
990
|
+
return out;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function serializeSubpath(sub) {
|
|
994
|
+
const norm = normalizeSubpath(sub);
|
|
995
|
+
return {
|
|
996
|
+
closed: !!norm.closed,
|
|
997
|
+
start: roundPoint(norm.start[0], norm.start[1]),
|
|
998
|
+
segs: (norm.segs ?? []).map(serializeSegment),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function transformSegmentHandles(seg, mapVector) {
|
|
1003
|
+
const out = { to: [seg.to[0], seg.to[1]] };
|
|
1004
|
+
if (seg.c1) {
|
|
1005
|
+
const v = mapVector(seg.c1);
|
|
1006
|
+
if (Math.hypot(v[0], v[1]) >= BOW_EPS) out.c1 = v;
|
|
1007
|
+
}
|
|
1008
|
+
if (seg.c2) {
|
|
1009
|
+
const v = mapVector(seg.c2);
|
|
1010
|
+
if (Math.hypot(v[0], v[1]) >= BOW_EPS) out.c2 = v;
|
|
1011
|
+
}
|
|
1012
|
+
return stripZeroHandles(out);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Stacked bake
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
export const FILL_SUPERSAMPLE = 4;
|
|
1020
|
+
|
|
1021
|
+
function sampleCubic(p0, c1, c2, p1, steps) {
|
|
1022
|
+
const out = [];
|
|
1023
|
+
for (let i = 0; i <= steps; i++) {
|
|
1024
|
+
const t = i / steps;
|
|
1025
|
+
out.push(cubicPoint(p0, c1, c2, p1, t));
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Flatten one subpath to a polyline via cubic segments.
|
|
1032
|
+
*
|
|
1033
|
+
* A closed subpath's polyline returns to `start`, walking the implied closing
|
|
1034
|
+
* edge. Point-in-polygon does not need that — it wraps — but anything that
|
|
1035
|
+
* measures or samples *along* the outline does: without it, dense sampling has
|
|
1036
|
+
* no points on one whole side, which silently blinded the punch-hole
|
|
1037
|
+
* containment test on every ring's closing edge.
|
|
1038
|
+
*/
|
|
1039
|
+
export function flattenSubpath(sub, stepsPerUnit = 4) {
|
|
1040
|
+
const norm = normalizeSubpath(sub);
|
|
1041
|
+
const pts = [];
|
|
1042
|
+
if (!norm?.start) return pts;
|
|
1043
|
+
pts.push([norm.start[0], norm.start[1]]);
|
|
1044
|
+
let from = norm.start;
|
|
1045
|
+
for (const seg of norm.segs ?? []) {
|
|
1046
|
+
const to = seg.to;
|
|
1047
|
+
const { c1, c2 } = absoluteSegment(from, seg);
|
|
1048
|
+
const L = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
1049
|
+
if (L < 1e-12) {
|
|
1050
|
+
from = to;
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (!seg.c1 && !seg.c2) {
|
|
1054
|
+
pts.push([to[0], to[1]]);
|
|
1055
|
+
from = to;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const steps = Math.max(4, Math.ceil(L * stepsPerUnit));
|
|
1059
|
+
const samples = sampleCubic(from, c1, c2, to, steps);
|
|
1060
|
+
for (let k = 1; k < samples.length; k++) pts.push(samples[k]);
|
|
1061
|
+
from = to;
|
|
1062
|
+
}
|
|
1063
|
+
if (hasImpliedClose(norm)) pts.push([norm.start[0], norm.start[1]]);
|
|
1064
|
+
return pts;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function pointInPolyEvenOdd(px, py, poly) {
|
|
1068
|
+
let inside = false;
|
|
1069
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1070
|
+
const xi = poly[i][0];
|
|
1071
|
+
const yi = poly[i][1];
|
|
1072
|
+
const xj = poly[j][0];
|
|
1073
|
+
const yj = poly[j][1];
|
|
1074
|
+
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi || Number.EPSILON) + xi) {
|
|
1075
|
+
inside = !inside;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return inside;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function shapeContains(rings, px, py) {
|
|
1082
|
+
let inside = false;
|
|
1083
|
+
for (const ring of rings) {
|
|
1084
|
+
if (ring.length >= 3 && pointInPolyEvenOdd(px, py, ring)) inside = !inside;
|
|
1085
|
+
}
|
|
1086
|
+
return inside;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function closedRings(shape) {
|
|
1090
|
+
const rings = [];
|
|
1091
|
+
for (const sub of shape?.subpaths ?? []) {
|
|
1092
|
+
if (!sub.closed) continue;
|
|
1093
|
+
const poly = flattenSubpath(sub, 6);
|
|
1094
|
+
if (poly.length >= 3) rings.push(poly);
|
|
1095
|
+
}
|
|
1096
|
+
return rings;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Even-odd fill test for a shape's closed subpaths (editor hit-testing). */
|
|
1100
|
+
export function shapeContainsPoint(shape, px, py) {
|
|
1101
|
+
if (!shape) return false;
|
|
1102
|
+
return shapeContains(closedRings(shape), px, py);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Visit each native pixel a shape's stroke would stamp (editor + bake). */
|
|
1106
|
+
export function forEachStrokePixel(shape, visit) {
|
|
1107
|
+
if (!shape?.stroke) return;
|
|
1108
|
+
for (const sub of shape.subpaths ?? []) {
|
|
1109
|
+
const { verts, edges } = edgeObjectsForSubpath(sub, shape.stroke);
|
|
1110
|
+
for (const edge of edges) {
|
|
1111
|
+
for (const p of traceEdgePixels(edge, verts)) visit(p.x, p.y);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Build edge objects for one subpath (shared by bake + editor). */
|
|
1117
|
+
export function edgeObjectsForSubpath(sub, strokeKey = null) {
|
|
1118
|
+
const norm = normalizeSubpath(sub);
|
|
1119
|
+
const verts = [[norm.start[0], norm.start[1]]];
|
|
1120
|
+
for (const seg of norm.segs ?? []) verts.push([seg.to[0], seg.to[1]]);
|
|
1121
|
+
const edges = [];
|
|
1122
|
+
(norm.segs ?? []).forEach((seg, i) => {
|
|
1123
|
+
const edge = { a: i, b: i + 1, key: strokeKey };
|
|
1124
|
+
if (seg.c1) edge.ha = [seg.c1[0], seg.c1[1]];
|
|
1125
|
+
if (seg.c2) edge.hb = [seg.c2[0], seg.c2[1]];
|
|
1126
|
+
edges.push(edge);
|
|
1127
|
+
});
|
|
1128
|
+
if (hasImpliedClose(norm)) edges.push({ a: verts.length - 1, b: 0, key: strokeKey });
|
|
1129
|
+
return { verts, edges };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Majority-downsample a supersampled key raster.
|
|
1134
|
+
* Cell paints when painted subpixels cover ≥ half (ties paint).
|
|
1135
|
+
* Color = majority key among painted subpixels; on key-count ties prefer the
|
|
1136
|
+
* key with the highest last-paint order (painter's-order tiebreak).
|
|
1137
|
+
*
|
|
1138
|
+
* `orderSS` is parallel to `fillSS`: each painted subpixel carries the stack
|
|
1139
|
+
* paint order at which it was last written (higher = later).
|
|
1140
|
+
*/
|
|
1141
|
+
function downsampleCompositeMajority(fillSS, orderSS, width, height, scale) {
|
|
1142
|
+
const fill = Array.from({ length: height }, () => new Array(width).fill(null));
|
|
1143
|
+
const minCoverage = (scale * scale) / 2;
|
|
1144
|
+
for (let y = 0; y < height; y++) {
|
|
1145
|
+
for (let x = 0; x < width; x++) {
|
|
1146
|
+
const counts = new Map();
|
|
1147
|
+
const maxOrder = new Map();
|
|
1148
|
+
let covered = 0;
|
|
1149
|
+
for (let sy = 0; sy < scale; sy++) {
|
|
1150
|
+
for (let sx = 0; sx < scale; sx++) {
|
|
1151
|
+
const key = fillSS[y * scale + sy][x * scale + sx];
|
|
1152
|
+
if (key == null) continue;
|
|
1153
|
+
covered += 1;
|
|
1154
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1155
|
+
const ord = orderSS[y * scale + sy][x * scale + sx];
|
|
1156
|
+
maxOrder.set(key, Math.max(maxOrder.get(key) ?? -1, ord));
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (covered < minCoverage) continue;
|
|
1160
|
+
let best = null;
|
|
1161
|
+
let bestCount = -1;
|
|
1162
|
+
let bestOrd = -1;
|
|
1163
|
+
for (const [k, n] of counts) {
|
|
1164
|
+
const ord = maxOrder.get(k) ?? -1;
|
|
1165
|
+
if (n > bestCount || (n === bestCount && ord > bestOrd) || (n === bestCount && ord === bestOrd && k < best)) {
|
|
1166
|
+
bestCount = n;
|
|
1167
|
+
bestOrd = ord;
|
|
1168
|
+
best = k;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
fill[y][x] = best;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return fill;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* The supersample index window covering `rings`, clipped to the canvas. Returns
|
|
1179
|
+
* null when the rings fall entirely outside it.
|
|
1180
|
+
*
|
|
1181
|
+
* A shape can only fill samples inside its own bounding box, so this is what
|
|
1182
|
+
* keeps the fill scan proportional to the SHAPE rather than the canvas. Without
|
|
1183
|
+
* it the scan is canvas-area × supersample² × shape count, and since the
|
|
1184
|
+
* supersample is squared, the cost grows with the fourth power of resolution:
|
|
1185
|
+
* shapes that are cheap on a 16x16 sprite are not merely slower at 64x64, they
|
|
1186
|
+
* are unusable, and nothing in the editor hints at why.
|
|
1187
|
+
*/
|
|
1188
|
+
function ringsSampleWindow(rings, width, height, scale) {
|
|
1189
|
+
let minX = Infinity;
|
|
1190
|
+
let minY = Infinity;
|
|
1191
|
+
let maxX = -Infinity;
|
|
1192
|
+
let maxY = -Infinity;
|
|
1193
|
+
for (const ring of rings) {
|
|
1194
|
+
for (const [x, y] of ring) {
|
|
1195
|
+
if (x < minX) minX = x;
|
|
1196
|
+
if (x > maxX) maxX = x;
|
|
1197
|
+
if (y < minY) minY = y;
|
|
1198
|
+
if (y > maxY) maxY = y;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (!(minX <= maxX && minY <= maxY)) return null;
|
|
1202
|
+
// Sample centers sit at (i + 0.5) / scale, so a sample is in range when
|
|
1203
|
+
// i >= minX * scale - 0.5. Floor/ceil out by one to stay conservative.
|
|
1204
|
+
const x0 = Math.max(0, Math.floor(minX * scale - 0.5));
|
|
1205
|
+
const x1 = Math.min(width * scale - 1, Math.ceil(maxX * scale + 0.5));
|
|
1206
|
+
const y0 = Math.max(0, Math.floor(minY * scale - 0.5));
|
|
1207
|
+
const y1 = Math.min(height * scale - 1, Math.ceil(maxY * scale + 0.5));
|
|
1208
|
+
if (x0 > x1 || y0 > y1) return null;
|
|
1209
|
+
return { x0, x1, y0, y1 };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* The x where each ring edge crosses the horizontal line y = py, unsorted.
|
|
1214
|
+
* Reuses `out` so the fill scan doesn't allocate per row.
|
|
1215
|
+
*/
|
|
1216
|
+
function crossingsAtRow(rings, py, out) {
|
|
1217
|
+
out.length = 0;
|
|
1218
|
+
for (const ring of rings) {
|
|
1219
|
+
if (ring.length < 3) continue;
|
|
1220
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
1221
|
+
const yi = ring[i][1];
|
|
1222
|
+
const yj = ring[j][1];
|
|
1223
|
+
if (yi > py === yj > py) continue;
|
|
1224
|
+
const xi = ring[i][0];
|
|
1225
|
+
const xj = ring[j][0];
|
|
1226
|
+
out.push(((xj - xi) * (py - yi)) / (yj - yi || Number.EPSILON) + xi);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return out;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Paint a shape's fill by scanning row by row rather than testing every sample
|
|
1234
|
+
* against every edge.
|
|
1235
|
+
*
|
|
1236
|
+
* Sampling each point independently costs edge-count work per sample, so the
|
|
1237
|
+
* fill is canvas-area × supersample² × edges × shapes. The supersample is
|
|
1238
|
+
* squared, so cost climbs with the FOURTH power of resolution: shapes that bake
|
|
1239
|
+
* instantly at 16x16 take seconds at 128x128, and the editor gives no hint why.
|
|
1240
|
+
* Every sample in a row crosses the same edges, so the crossings are computed
|
|
1241
|
+
* once per row and each sample just reads its parity — edges + width per row
|
|
1242
|
+
* instead of edges × width.
|
|
1243
|
+
*
|
|
1244
|
+
* The parity walk deliberately reuses the exact `px < crossing` comparison the
|
|
1245
|
+
* per-sample test used, on the same `px`, so this is bit-identical to the naive
|
|
1246
|
+
* scan rather than merely equivalent in principle. Even-odd applies across ALL
|
|
1247
|
+
* rings at once (that is what makes multi-subpath donuts work), which is why
|
|
1248
|
+
* every ring's crossings go into one sorted list.
|
|
1249
|
+
*/
|
|
1250
|
+
function paintFillIntoComposite(fillSS, orderSS, shape, order, width, height, scale) {
|
|
1251
|
+
if (!shape.fill) return;
|
|
1252
|
+
const rings = closedRings(shape);
|
|
1253
|
+
if (!rings.length) return;
|
|
1254
|
+
const window = ringsSampleWindow(rings, width, height, scale);
|
|
1255
|
+
if (!window) return;
|
|
1256
|
+
|
|
1257
|
+
const crossings = [];
|
|
1258
|
+
|
|
1259
|
+
for (let y = window.y0; y <= window.y1; y++) {
|
|
1260
|
+
const py = (y + 0.5) / scale;
|
|
1261
|
+
crossingsAtRow(rings, py, crossings);
|
|
1262
|
+
if (crossings.length === 0) continue;
|
|
1263
|
+
crossings.sort(ascending);
|
|
1264
|
+
|
|
1265
|
+
const fillRow = fillSS[y];
|
|
1266
|
+
const orderRow = orderSS[y];
|
|
1267
|
+
// `next` is the first crossing to the right of the current sample. It only
|
|
1268
|
+
// moves forward as x grows, so each row costs one pass over the crossings.
|
|
1269
|
+
let next = 0;
|
|
1270
|
+
for (let x = window.x0; x <= window.x1; x++) {
|
|
1271
|
+
const px = (x + 0.5) / scale;
|
|
1272
|
+
while (next < crossings.length && !(px < crossings[next])) next++;
|
|
1273
|
+
if ((crossings.length - next) % 2 === 0) continue;
|
|
1274
|
+
fillRow[x] = shape.fill;
|
|
1275
|
+
orderRow[x] = order;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function ascending(a, b) {
|
|
1281
|
+
return a - b;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function stampStrokeBlock(fillSS, orderSS, x, y, key, order, width, height, scale) {
|
|
1285
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return;
|
|
1286
|
+
const y0 = y * scale;
|
|
1287
|
+
const x0 = x * scale;
|
|
1288
|
+
for (let sy = 0; sy < scale; sy++) {
|
|
1289
|
+
for (let sx = 0; sx < scale; sx++) {
|
|
1290
|
+
fillSS[y0 + sy][x0 + sx] = key;
|
|
1291
|
+
orderSS[y0 + sy][x0 + sx] = order;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function paintStrokeIntoComposite(fillSS, orderSS, shape, order, width, height, scale) {
|
|
1297
|
+
forEachStrokePixel(shape, (x, y) => {
|
|
1298
|
+
stampStrokeBlock(fillSS, orderSS, x, y, shape.stroke, order, width, height, scale);
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Normative stacked bake.
|
|
1304
|
+
*
|
|
1305
|
+
* Compositing fills at supersample BEFORE a single downsample means two
|
|
1306
|
+
* abutting shapes' subpixels union across the shared boundary, so boundary
|
|
1307
|
+
* cells go to whoever owns the majority — no 1px transparent gutters (the
|
|
1308
|
+
* proto-stacked per-shape downsample left that artifact). Stroke cells stamp
|
|
1309
|
+
* as full scale×scale blocks so 1px stroke discipline survives while a later
|
|
1310
|
+
* shape's fill can still cover an earlier stroke (painter's order).
|
|
1311
|
+
*/
|
|
1312
|
+
export function bakePathCell(path, width, height) {
|
|
1313
|
+
const outW = Math.max(0, width | 0);
|
|
1314
|
+
const outH = Math.max(0, height | 0);
|
|
1315
|
+
const grid = Array.from({ length: outH }, () => new Array(outW).fill(TRANSPARENT));
|
|
1316
|
+
if (!path || outW === 0 || outH === 0) {
|
|
1317
|
+
return grid.map((row) => row.join(''));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const S = FILL_SUPERSAMPLE;
|
|
1321
|
+
const ssW = outW * S;
|
|
1322
|
+
const ssH = outH * S;
|
|
1323
|
+
const fillSS = Array.from({ length: ssH }, () => new Array(ssW).fill(null));
|
|
1324
|
+
const orderSS = Array.from({ length: ssH }, () => new Array(ssW).fill(-1));
|
|
1325
|
+
|
|
1326
|
+
let order = 0;
|
|
1327
|
+
for (const shape of path.shapes ?? []) {
|
|
1328
|
+
// Fill then stroke within the shape; each write bumps paint order so
|
|
1329
|
+
// stroke wins over that shape's own fill, and later shapes win ties.
|
|
1330
|
+
paintFillIntoComposite(fillSS, orderSS, shape, order++, outW, outH, S);
|
|
1331
|
+
paintStrokeIntoComposite(fillSS, orderSS, shape, order++, outW, outH, S);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const native = downsampleCompositeMajority(fillSS, orderSS, outW, outH, S);
|
|
1335
|
+
for (let y = 0; y < outH; y++) {
|
|
1336
|
+
for (let x = 0; x < outW; x++) {
|
|
1337
|
+
if (native[y][x] != null) grid[y][x] = native[y][x];
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return grid.map((row) => row.join(''));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Alias used by tests / validation. */
|
|
1344
|
+
export const bakeStacked = bakePathCell;
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Collect palette keys referenced by path source (for serialize pruning).
|
|
1348
|
+
*/
|
|
1349
|
+
export function pathSourceKeys(path) {
|
|
1350
|
+
const used = new Set();
|
|
1351
|
+
for (const shape of path?.shapes ?? []) {
|
|
1352
|
+
if (shape.fill) used.add(shape.fill);
|
|
1353
|
+
if (shape.stroke) used.add(shape.stroke);
|
|
1354
|
+
}
|
|
1355
|
+
return used;
|
|
1356
|
+
}
|