castle-web-cli 0.4.123 → 0.4.124
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/castleJson.d.ts +2 -0
- package/dist/castleJson.js +39 -0
- package/dist/headlessCover.d.ts +13 -0
- package/dist/headlessCover.js +99 -0
- package/dist/ide.js +12 -42
- package/dist/imports.js +42 -2
- package/dist/index.js +1 -9
- package/dist/init.d.ts +1 -0
- package/dist/init.js +15 -1
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +0 -58
- package/dist/save-deck.js +52 -1
- package/dist/serve.js +16 -0
- package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
- package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
- package/dist/shell/assets/index-BfOPkSej.css +1 -0
- package/dist/shell/assets/index-u0nYFqbF.js +434 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +2 -2
- package/kits/physics-2d/castle.json +10 -2
- package/kits/physics-2d/docs/pxart-format.md +33 -26
- package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
- package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
- package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
- package/kits/physics-2d/editors/pathOverlay.js +1 -1
- package/kits/physics-2d/editors/pathTools.js +9 -1
- package/kits/physics-2d/editors/pixelGeometry.js +14 -13
- package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
- package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
- package/kits/physics-2d/editors/pxArtTools.js +3 -43
- package/kits/physics-2d/editors/styleEditor.module.css +105 -0
- package/kits/physics-2d/editors/styleTheme.js +16 -0
- package/kits/physics-2d/engine/files.js +2 -1
- package/kits/physics-2d/engine/liveReload.js +4 -3
- package/kits/physics-2d/engine/palettes.js +636 -0
- package/kits/physics-2d/engine/pxart.js +6 -6
- package/kits/physics-2d/engine/svgImport.js +1056 -0
- package/kits/physics-2d/engine/ui.jsx +2 -0
- package/kits/physics-2d/engine/ui.module.css +54 -9
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/package.json +1 -0
- package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
- package/kits/physics-2d/scripts/draw.mjs +5 -3
- package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
- package/kits/physics-2d/scripts/palette.mjs +10 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
- package/kits/physics-2d/theme.style +3 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-CARLHafh.css +0 -1
- package/dist/shell/assets/index-DWgt4KzC.js +0 -434
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
// Browser-safe SVG → stacked path-layer import.
|
|
2
|
+
// Convert an LLM-emitted SVG into shapes + baked grid. See scripts/svg-emission-guide.md.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AGENT_PALETTE_16,
|
|
6
|
+
allocatePaletteKeys,
|
|
7
|
+
fromCells,
|
|
8
|
+
normalizeHex,
|
|
9
|
+
quantizeToPalette,
|
|
10
|
+
KEY_ALPHABET,
|
|
11
|
+
} from './pxart.js';
|
|
12
|
+
import {
|
|
13
|
+
bakePathCell,
|
|
14
|
+
pointsEqual,
|
|
15
|
+
roundCoord,
|
|
16
|
+
subpathPoints,
|
|
17
|
+
subpathVertexCount,
|
|
18
|
+
translatePathData,
|
|
19
|
+
} from './pxartPath.js';
|
|
20
|
+
|
|
21
|
+
const KAPPA = 0.5522847498;
|
|
22
|
+
const STROKE_PHASE_NUDGE = 0.05;
|
|
23
|
+
|
|
24
|
+
const NAMED_COLORS = {
|
|
25
|
+
black: '#000000',
|
|
26
|
+
white: '#ffffff',
|
|
27
|
+
red: '#ff0000',
|
|
28
|
+
green: '#008000',
|
|
29
|
+
blue: '#0000ff',
|
|
30
|
+
yellow: '#ffff00',
|
|
31
|
+
cyan: '#00ffff',
|
|
32
|
+
magenta: '#ff00ff',
|
|
33
|
+
orange: '#ffa500',
|
|
34
|
+
gray: '#808080',
|
|
35
|
+
grey: '#808080',
|
|
36
|
+
silver: '#c0c0c0',
|
|
37
|
+
maroon: '#800000',
|
|
38
|
+
purple: '#800080',
|
|
39
|
+
teal: '#008080',
|
|
40
|
+
navy: '#000080',
|
|
41
|
+
lime: '#00ff00',
|
|
42
|
+
aqua: '#00ffff',
|
|
43
|
+
fuchsia: '#ff00ff',
|
|
44
|
+
none: null,
|
|
45
|
+
transparent: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const VOIDISH = new Set([
|
|
49
|
+
'path',
|
|
50
|
+
'polygon',
|
|
51
|
+
'polyline',
|
|
52
|
+
'rect',
|
|
53
|
+
'circle',
|
|
54
|
+
'ellipse',
|
|
55
|
+
'line',
|
|
56
|
+
'image',
|
|
57
|
+
'stop',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function parseAttrs(raw) {
|
|
61
|
+
const attrs = {};
|
|
62
|
+
const re = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = re.exec(raw))) {
|
|
65
|
+
attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
|
|
66
|
+
}
|
|
67
|
+
return attrs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseStyle(style) {
|
|
71
|
+
const out = {};
|
|
72
|
+
if (!style) return out;
|
|
73
|
+
for (const part of String(style).split(';')) {
|
|
74
|
+
const i = part.indexOf(':');
|
|
75
|
+
if (i < 0) continue;
|
|
76
|
+
const k = part.slice(0, i).trim().toLowerCase();
|
|
77
|
+
const v = part.slice(i + 1).trim();
|
|
78
|
+
if (k) out[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkElements(svgText) {
|
|
84
|
+
const re = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\/?([A-Za-z][\w:-]*)\b([^>]*)>/g;
|
|
85
|
+
const stack = [];
|
|
86
|
+
const roots = [];
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(svgText))) {
|
|
89
|
+
const full = m[0];
|
|
90
|
+
if (full.startsWith('<!--') || full.startsWith('<![CDATA')) continue;
|
|
91
|
+
const name = m[1].toLowerCase();
|
|
92
|
+
const attrRaw = m[2] ?? '';
|
|
93
|
+
if (full.startsWith('</')) {
|
|
94
|
+
while (stack.length) {
|
|
95
|
+
const top = stack.pop();
|
|
96
|
+
if (top.name === name) break;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const selfClose = /\/>$/.test(full);
|
|
101
|
+
const el = { name, attrs: parseAttrs(attrRaw), children: [] };
|
|
102
|
+
if (stack.length) stack[stack.length - 1].children.push(el);
|
|
103
|
+
else roots.push(el);
|
|
104
|
+
if (!selfClose && !VOIDISH.has(name)) stack.push(el);
|
|
105
|
+
}
|
|
106
|
+
return roots;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findSvgRoot(nodes) {
|
|
110
|
+
for (const n of nodes) {
|
|
111
|
+
if (n.name === 'svg') return n;
|
|
112
|
+
const inner = findSvgRoot(n.children);
|
|
113
|
+
if (inner) return inner;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseViewBox(attrs) {
|
|
119
|
+
if (attrs.viewbox) {
|
|
120
|
+
const parts = attrs.viewbox
|
|
121
|
+
.trim()
|
|
122
|
+
.split(/[\s,]+/)
|
|
123
|
+
.map(Number);
|
|
124
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
125
|
+
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const w = parseFloat(attrs.width);
|
|
129
|
+
const h = parseFloat(attrs.height);
|
|
130
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
131
|
+
return { minX: 0, minY: 0, width: w, height: h };
|
|
132
|
+
}
|
|
133
|
+
return { minX: 0, minY: 0, width: 16, height: 16 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeTransform(vb, size) {
|
|
137
|
+
const s = Math.min(size / vb.width, size / vb.height);
|
|
138
|
+
const ox = (size - vb.width * s) / 2 - vb.minX * s;
|
|
139
|
+
const oy = (size - vb.height * s) / 2 - vb.minY * s;
|
|
140
|
+
return {
|
|
141
|
+
scale: s,
|
|
142
|
+
xf: (x, y) => [x * s + ox, y * s + oy],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveColor(raw, diagnostics, where) {
|
|
147
|
+
if (raw == null) return undefined;
|
|
148
|
+
const s = String(raw).trim().toLowerCase();
|
|
149
|
+
if (!s) return undefined;
|
|
150
|
+
if (s === 'none' || s === 'transparent') return null;
|
|
151
|
+
if (s.startsWith('url(')) {
|
|
152
|
+
diagnostics.rejected.push(`${where}: gradient/url paint (${raw})`);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const hex = normalizeHex(s);
|
|
156
|
+
if (hex) return hex.length === 9 ? hex.slice(0, 7) : hex;
|
|
157
|
+
if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, s)) return NAMED_COLORS[s];
|
|
158
|
+
diagnostics.rejected.push(`${where}: unresolved color "${raw}"`);
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isEvenOddFillRule(attrs) {
|
|
163
|
+
const raw = attrs['fill-rule'] ?? attrs.fillRule ?? '';
|
|
164
|
+
return String(raw).toLowerCase() === 'evenodd';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function warnFillRuleIfNeeded(subpaths, attrs, tag, diagnostics) {
|
|
168
|
+
const closed = (subpaths ?? []).filter((s) => s.closed).length;
|
|
169
|
+
if (closed >= 2 && !isEvenOddFillRule(attrs)) {
|
|
170
|
+
diagnostics.notes.push(
|
|
171
|
+
`<${tag}>: ${closed} closed subpaths with default nonzero fill-rule; Castle renders even-odd`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function presentation(attrs) {
|
|
177
|
+
const style = parseStyle(attrs.style);
|
|
178
|
+
return {
|
|
179
|
+
fill: style.fill ?? attrs.fill,
|
|
180
|
+
stroke: style.stroke ?? attrs.stroke,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function tokenizePathD(d) {
|
|
185
|
+
const tokens = [];
|
|
186
|
+
const re = /([A-Za-z])|([+-]?(?:\d*\.\d+|\d+)(?:[eE][+-]?\d+)?)/g;
|
|
187
|
+
let m;
|
|
188
|
+
while ((m = re.exec(d))) {
|
|
189
|
+
if (m[1]) tokens.push({ type: 'cmd', v: m[1] });
|
|
190
|
+
else tokens.push({ type: 'num', v: Number(m[2]) });
|
|
191
|
+
}
|
|
192
|
+
return tokens;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function lerp2(a, b, t) {
|
|
196
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function splitCubicAt(p0, c1, c2, p1, t) {
|
|
200
|
+
const p01 = lerp2(p0, c1, t);
|
|
201
|
+
const p12 = lerp2(c1, c2, t);
|
|
202
|
+
const p23 = lerp2(c2, p1, t);
|
|
203
|
+
const p012 = lerp2(p01, p12, t);
|
|
204
|
+
const p123 = lerp2(p12, p23, t);
|
|
205
|
+
const p0123 = lerp2(p012, p123, t);
|
|
206
|
+
return [
|
|
207
|
+
{ p0, c1: p01, c2: p012, p1: p0123 },
|
|
208
|
+
{ p0: p0123, c1: p123, c2: p23, p1 },
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function cubicInflectionTs(p0, c1, c2, p1) {
|
|
213
|
+
const steps = 64;
|
|
214
|
+
let prevSign = 0;
|
|
215
|
+
let prevT = 0;
|
|
216
|
+
let prevCross = 0;
|
|
217
|
+
const ts = [];
|
|
218
|
+
for (let i = 0; i <= steps; i++) {
|
|
219
|
+
const t = i / steps;
|
|
220
|
+
const mt = 1 - t;
|
|
221
|
+
const d1x = mt * mt * (c1[0] - p0[0]) + 2 * mt * t * (c2[0] - c1[0]) + t * t * (p1[0] - c2[0]);
|
|
222
|
+
const d1y = mt * mt * (c1[1] - p0[1]) + 2 * mt * t * (c2[1] - c1[1]) + t * t * (p1[1] - c2[1]);
|
|
223
|
+
const d2x = mt * (c2[0] - 2 * c1[0] + p0[0]) + t * (p1[0] - 2 * c2[0] + c1[0]);
|
|
224
|
+
const d2y = mt * (c2[1] - 2 * c1[1] + p0[1]) + t * (p1[1] - 2 * c2[1] + c1[1]);
|
|
225
|
+
const cross = d1x * d2y - d1y * d2x;
|
|
226
|
+
const sign = Math.sign(cross);
|
|
227
|
+
if (sign === 0 && t > 1e-3 && t < 1 - 1e-3) {
|
|
228
|
+
ts.push(t);
|
|
229
|
+
prevSign = 0;
|
|
230
|
+
} else if (prevSign !== 0 && sign !== 0 && sign !== prevSign) {
|
|
231
|
+
const frac = Math.abs(prevCross) / (Math.abs(prevCross) + Math.abs(cross));
|
|
232
|
+
const ti = prevT + frac * (t - prevT);
|
|
233
|
+
if (ti > 1e-3 && ti < 1 - 1e-3) ts.push(ti);
|
|
234
|
+
prevSign = sign;
|
|
235
|
+
} else if (sign !== 0) {
|
|
236
|
+
prevSign = sign;
|
|
237
|
+
}
|
|
238
|
+
prevT = t;
|
|
239
|
+
prevCross = cross;
|
|
240
|
+
}
|
|
241
|
+
return ts;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function splitCubicAtInflections(p0, c1, c2, p1) {
|
|
245
|
+
const ts = cubicInflectionTs(p0, c1, c2, p1);
|
|
246
|
+
if (!ts.length) return [{ p0, c1, c2, p1 }];
|
|
247
|
+
const pieces = [];
|
|
248
|
+
let cur = { p0, c1, c2, p1 };
|
|
249
|
+
let offset = 0;
|
|
250
|
+
for (const absT of ts) {
|
|
251
|
+
const localT = (absT - offset) / (1 - offset);
|
|
252
|
+
if (!(localT > 1e-3 && localT < 1 - 1e-3)) continue;
|
|
253
|
+
const [left, right] = splitCubicAt(cur.p0, cur.c1, cur.c2, cur.p1, localT);
|
|
254
|
+
pieces.push(left);
|
|
255
|
+
cur = right;
|
|
256
|
+
offset = absT;
|
|
257
|
+
}
|
|
258
|
+
pieces.push(cur);
|
|
259
|
+
return pieces;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Emit absolute cubic pieces for one SVG elliptical arc. */
|
|
263
|
+
function arcToCubicSegments(x1, y1, rx, ry, phiDeg, largeArc, sweep, x2, y2) {
|
|
264
|
+
const out = [];
|
|
265
|
+
if (!Number.isFinite(rx) || !Number.isFinite(ry) || (rx === 0 && ry === 0)) {
|
|
266
|
+
out.push({ kind: 'L', x: x2, y: y2 });
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
if (x1 === x2 && y1 === y2) return out;
|
|
270
|
+
|
|
271
|
+
const phi = (phiDeg * Math.PI) / 180;
|
|
272
|
+
const cosPhi = Math.cos(phi);
|
|
273
|
+
const sinPhi = Math.sin(phi);
|
|
274
|
+
const dx = (x1 - x2) / 2;
|
|
275
|
+
const dy = (y1 - y2) / 2;
|
|
276
|
+
const x1p = cosPhi * dx + sinPhi * dy;
|
|
277
|
+
const y1p = -sinPhi * dx + cosPhi * dy;
|
|
278
|
+
let RX = Math.abs(rx);
|
|
279
|
+
let RY = Math.abs(ry);
|
|
280
|
+
const lam = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
|
|
281
|
+
if (lam > 1) {
|
|
282
|
+
const s = Math.sqrt(lam);
|
|
283
|
+
RX *= s;
|
|
284
|
+
RY *= s;
|
|
285
|
+
}
|
|
286
|
+
const RX2 = RX * RX;
|
|
287
|
+
const RY2 = RY * RY;
|
|
288
|
+
const x1p2 = x1p * x1p;
|
|
289
|
+
const y1p2 = y1p * y1p;
|
|
290
|
+
const num = RX2 * RY2 - RX2 * y1p2 - RY2 * x1p2;
|
|
291
|
+
const den = RX2 * y1p2 + RY2 * x1p2;
|
|
292
|
+
let cq = den === 0 ? 0 : Math.sqrt(Math.max(0, num / den));
|
|
293
|
+
if (largeArc === sweep) cq = -cq;
|
|
294
|
+
const cxp = (cq * (RX * y1p)) / RY;
|
|
295
|
+
const cyp = (cq * (-RY * x1p)) / RX;
|
|
296
|
+
const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2;
|
|
297
|
+
const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2;
|
|
298
|
+
|
|
299
|
+
function unitAngle(ux, uy, vx, vy) {
|
|
300
|
+
const sign = ux * vy - uy * vx < 0 ? -1 : 1;
|
|
301
|
+
const dot = ux * vx + uy * vy;
|
|
302
|
+
const ul = Math.hypot(ux, uy);
|
|
303
|
+
const vl = Math.hypot(vx, vy);
|
|
304
|
+
const c = Math.min(1, Math.max(-1, dot / (ul * vl || 1)));
|
|
305
|
+
return sign * Math.acos(c);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const v1x = (x1p - cxp) / RX;
|
|
309
|
+
const v1y = (y1p - cyp) / RY;
|
|
310
|
+
const v2x = (-x1p - cxp) / RX;
|
|
311
|
+
const v2y = (-y1p - cyp) / RY;
|
|
312
|
+
let theta1 = unitAngle(1, 0, v1x, v1y);
|
|
313
|
+
let dTheta = unitAngle(v1x, v1y, v2x, v2y);
|
|
314
|
+
if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
|
|
315
|
+
if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
|
|
316
|
+
|
|
317
|
+
function pointAt(theta) {
|
|
318
|
+
const cosT = Math.cos(theta);
|
|
319
|
+
const sinT = Math.sin(theta);
|
|
320
|
+
return [
|
|
321
|
+
cosPhi * RX * cosT - sinPhi * RY * sinT + cx,
|
|
322
|
+
sinPhi * RX * cosT + cosPhi * RY * sinT + cy,
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function tangentAt(theta) {
|
|
327
|
+
const tx = -RX * Math.sin(theta);
|
|
328
|
+
const ty = RY * Math.cos(theta);
|
|
329
|
+
return [cosPhi * tx - sinPhi * ty, sinPhi * tx + cosPhi * ty];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const maxStep = Math.PI / 2;
|
|
333
|
+
const n = Math.max(1, Math.ceil(Math.abs(dTheta) / maxStep - 1e-12));
|
|
334
|
+
const step = dTheta / n;
|
|
335
|
+
let t0 = theta1;
|
|
336
|
+
for (let i = 1; i <= n; i++) {
|
|
337
|
+
const t1 = theta1 + step * i;
|
|
338
|
+
const p0 = pointAt(t0);
|
|
339
|
+
const p1 = pointAt(t1);
|
|
340
|
+
const T0 = tangentAt(t0);
|
|
341
|
+
const T1 = tangentAt(t1);
|
|
342
|
+
const k = (4 / 3) * Math.tan(Math.abs(step) / 4);
|
|
343
|
+
const c1 = [p0[0] + k * T0[0], p0[1] + k * T0[1]];
|
|
344
|
+
const c2 = [p1[0] - k * T1[0], p1[1] - k * T1[1]];
|
|
345
|
+
out.push({ kind: 'C', x1: c1[0], y1: c1[1], x2: c2[0], y2: c2[1], x: p1[0], y: p1[1] });
|
|
346
|
+
t0 = t1;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const PATH_CMDS = new Set([
|
|
352
|
+
'M', 'm', 'L', 'l', 'H', 'h', 'V', 'v', 'C', 'c', 'S', 's', 'Q', 'q', 'T', 't', 'A', 'a', 'Z', 'z',
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
function parsePathD(d, diagnostics) {
|
|
356
|
+
const tokens = tokenizePathD(d);
|
|
357
|
+
let i = 0;
|
|
358
|
+
const subpaths = [];
|
|
359
|
+
let cx = 0;
|
|
360
|
+
let cy = 0;
|
|
361
|
+
let startX = 0;
|
|
362
|
+
let startY = 0;
|
|
363
|
+
let lastCtrl = null;
|
|
364
|
+
let lastCmd = null;
|
|
365
|
+
let reflectCmd = null;
|
|
366
|
+
let current = null;
|
|
367
|
+
|
|
368
|
+
function num() {
|
|
369
|
+
if (i >= tokens.length || tokens[i].type !== 'num') return null;
|
|
370
|
+
return tokens[i++].v;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function ensureSub() {
|
|
374
|
+
if (!current) {
|
|
375
|
+
current = { closed: false, segs: [], start: [cx, cy] };
|
|
376
|
+
subpaths.push(current);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
while (i < tokens.length) {
|
|
381
|
+
let cmd;
|
|
382
|
+
if (tokens[i].type === 'cmd') {
|
|
383
|
+
cmd = tokens[i++].v;
|
|
384
|
+
if (!PATH_CMDS.has(cmd)) {
|
|
385
|
+
if (diagnostics) diagnostics.rejected.push(`path d: unknown command '${cmd}'`);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
lastCmd = cmd;
|
|
389
|
+
} else if (!lastCmd) break;
|
|
390
|
+
else cmd = lastCmd;
|
|
391
|
+
const rel = cmd === cmd.toLowerCase();
|
|
392
|
+
const c = cmd.toUpperCase();
|
|
393
|
+
|
|
394
|
+
if (c === 'A') {
|
|
395
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
396
|
+
const rx = num();
|
|
397
|
+
const ry = num();
|
|
398
|
+
const phi = num();
|
|
399
|
+
const large = num();
|
|
400
|
+
const sweep = num();
|
|
401
|
+
const x = num();
|
|
402
|
+
const y = num();
|
|
403
|
+
if ([rx, ry, phi, large, sweep, x, y].some((v) => v == null)) break;
|
|
404
|
+
const nx = rel ? cx + x : x;
|
|
405
|
+
const ny = rel ? cy + y : y;
|
|
406
|
+
ensureSub();
|
|
407
|
+
for (const piece of arcToCubicSegments(cx, cy, rx, ry, phi, !!large, !!sweep, nx, ny)) {
|
|
408
|
+
current.segs.push(piece);
|
|
409
|
+
}
|
|
410
|
+
cx = nx;
|
|
411
|
+
cy = ny;
|
|
412
|
+
lastCtrl = null;
|
|
413
|
+
reflectCmd = null;
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (c === 'M') {
|
|
419
|
+
const x0 = num();
|
|
420
|
+
const y0 = num();
|
|
421
|
+
if (x0 == null || y0 == null) break;
|
|
422
|
+
cx = rel ? cx + x0 : x0;
|
|
423
|
+
cy = rel ? cy + y0 : y0;
|
|
424
|
+
startX = cx;
|
|
425
|
+
startY = cy;
|
|
426
|
+
current = { closed: false, segs: [], start: [cx, cy] };
|
|
427
|
+
subpaths.push(current);
|
|
428
|
+
lastCtrl = null;
|
|
429
|
+
reflectCmd = null;
|
|
430
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
431
|
+
const x = num();
|
|
432
|
+
const y = num();
|
|
433
|
+
if (x == null || y == null) break;
|
|
434
|
+
const nx = rel ? cx + x : x;
|
|
435
|
+
const ny = rel ? cy + y : y;
|
|
436
|
+
current.segs.push({ kind: 'L', x: nx, y: ny });
|
|
437
|
+
cx = nx;
|
|
438
|
+
cy = ny;
|
|
439
|
+
lastCtrl = null;
|
|
440
|
+
reflectCmd = null;
|
|
441
|
+
}
|
|
442
|
+
lastCmd = rel ? 'l' : 'L';
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (c === 'Z') {
|
|
447
|
+
if (current) {
|
|
448
|
+
current.closed = true;
|
|
449
|
+
if (cx !== startX || cy !== startY) {
|
|
450
|
+
current.segs.push({ kind: 'L', x: startX, y: startY });
|
|
451
|
+
}
|
|
452
|
+
cx = startX;
|
|
453
|
+
cy = startY;
|
|
454
|
+
}
|
|
455
|
+
current = null;
|
|
456
|
+
lastCtrl = null;
|
|
457
|
+
reflectCmd = null;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
ensureSub();
|
|
462
|
+
|
|
463
|
+
if (c === 'L') {
|
|
464
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
465
|
+
const x = num();
|
|
466
|
+
const y = num();
|
|
467
|
+
if (x == null || y == null) break;
|
|
468
|
+
const nx = rel ? cx + x : x;
|
|
469
|
+
const ny = rel ? cy + y : y;
|
|
470
|
+
current.segs.push({ kind: 'L', x: nx, y: ny });
|
|
471
|
+
cx = nx;
|
|
472
|
+
cy = ny;
|
|
473
|
+
lastCtrl = null;
|
|
474
|
+
reflectCmd = null;
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (c === 'H') {
|
|
480
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
481
|
+
const x = num();
|
|
482
|
+
if (x == null) break;
|
|
483
|
+
const nx = rel ? cx + x : x;
|
|
484
|
+
current.segs.push({ kind: 'L', x: nx, y: cy });
|
|
485
|
+
cx = nx;
|
|
486
|
+
lastCtrl = null;
|
|
487
|
+
reflectCmd = null;
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (c === 'V') {
|
|
493
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
494
|
+
const y = num();
|
|
495
|
+
if (y == null) break;
|
|
496
|
+
const ny = rel ? cy + y : y;
|
|
497
|
+
current.segs.push({ kind: 'L', x: cx, y: ny });
|
|
498
|
+
cy = ny;
|
|
499
|
+
lastCtrl = null;
|
|
500
|
+
reflectCmd = null;
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (c === 'C') {
|
|
506
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
507
|
+
const x1 = num();
|
|
508
|
+
const y1 = num();
|
|
509
|
+
const x2 = num();
|
|
510
|
+
const y2 = num();
|
|
511
|
+
const x = num();
|
|
512
|
+
const y = num();
|
|
513
|
+
if ([x1, y1, x2, y2, x, y].some((v) => v == null)) break;
|
|
514
|
+
const ax1 = rel ? cx + x1 : x1;
|
|
515
|
+
const ay1 = rel ? cy + y1 : y1;
|
|
516
|
+
const ax2 = rel ? cx + x2 : x2;
|
|
517
|
+
const ay2 = rel ? cy + y2 : y2;
|
|
518
|
+
const nx = rel ? cx + x : x;
|
|
519
|
+
const ny = rel ? cy + y : y;
|
|
520
|
+
current.segs.push({ kind: 'C', x1: ax1, y1: ay1, x2: ax2, y2: ay2, x: nx, y: ny });
|
|
521
|
+
lastCtrl = [ax2, ay2];
|
|
522
|
+
cx = nx;
|
|
523
|
+
cy = ny;
|
|
524
|
+
reflectCmd = rel ? 'c' : 'C';
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (c === 'S') {
|
|
530
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
531
|
+
const x2 = num();
|
|
532
|
+
const y2 = num();
|
|
533
|
+
const x = num();
|
|
534
|
+
const y = num();
|
|
535
|
+
if ([x2, y2, x, y].some((v) => v == null)) break;
|
|
536
|
+
const smooth =
|
|
537
|
+
lastCtrl &&
|
|
538
|
+
reflectCmd &&
|
|
539
|
+
(reflectCmd === 'C' || reflectCmd === 'c' || reflectCmd === 'S' || reflectCmd === 's');
|
|
540
|
+
const ax1 = smooth ? 2 * cx - lastCtrl[0] : cx;
|
|
541
|
+
const ay1 = smooth ? 2 * cy - lastCtrl[1] : cy;
|
|
542
|
+
const ax2 = rel ? cx + x2 : x2;
|
|
543
|
+
const ay2 = rel ? cy + y2 : y2;
|
|
544
|
+
const nx = rel ? cx + x : x;
|
|
545
|
+
const ny = rel ? cy + y : y;
|
|
546
|
+
current.segs.push({ kind: 'C', x1: ax1, y1: ay1, x2: ax2, y2: ay2, x: nx, y: ny });
|
|
547
|
+
lastCtrl = [ax2, ay2];
|
|
548
|
+
cx = nx;
|
|
549
|
+
cy = ny;
|
|
550
|
+
reflectCmd = rel ? 's' : 'S';
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (c === 'Q') {
|
|
556
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
557
|
+
const x1 = num();
|
|
558
|
+
const y1 = num();
|
|
559
|
+
const x = num();
|
|
560
|
+
const y = num();
|
|
561
|
+
if ([x1, y1, x, y].some((v) => v == null)) break;
|
|
562
|
+
const ax1 = rel ? cx + x1 : x1;
|
|
563
|
+
const ay1 = rel ? cy + y1 : y1;
|
|
564
|
+
const nx = rel ? cx + x : x;
|
|
565
|
+
const ny = rel ? cy + y : y;
|
|
566
|
+
current.segs.push({ kind: 'Q', x1: ax1, y1: ay1, x: nx, y: ny });
|
|
567
|
+
lastCtrl = [ax1, ay1];
|
|
568
|
+
cx = nx;
|
|
569
|
+
cy = ny;
|
|
570
|
+
reflectCmd = rel ? 'q' : 'Q';
|
|
571
|
+
}
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (c === 'T') {
|
|
576
|
+
while (i < tokens.length && tokens[i].type === 'num') {
|
|
577
|
+
const x = num();
|
|
578
|
+
const y = num();
|
|
579
|
+
if (x == null || y == null) break;
|
|
580
|
+
const smooth =
|
|
581
|
+
lastCtrl &&
|
|
582
|
+
reflectCmd &&
|
|
583
|
+
(reflectCmd === 'Q' || reflectCmd === 'q' || reflectCmd === 'T' || reflectCmd === 't');
|
|
584
|
+
const ax1 = smooth ? 2 * cx - lastCtrl[0] : cx;
|
|
585
|
+
const ay1 = smooth ? 2 * cy - lastCtrl[1] : cy;
|
|
586
|
+
const nx = rel ? cx + x : x;
|
|
587
|
+
const ny = rel ? cy + y : y;
|
|
588
|
+
current.segs.push({ kind: 'Q', x1: ax1, y1: ay1, x: nx, y: ny });
|
|
589
|
+
lastCtrl = [ax1, ay1];
|
|
590
|
+
cx = nx;
|
|
591
|
+
cy = ny;
|
|
592
|
+
reflectCmd = rel ? 't' : 'T';
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return { subpaths };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function parsePoints(pointsAttr) {
|
|
604
|
+
const nums = String(pointsAttr)
|
|
605
|
+
.trim()
|
|
606
|
+
.split(/[\s,]+/)
|
|
607
|
+
.map(Number)
|
|
608
|
+
.filter((n) => Number.isFinite(n));
|
|
609
|
+
const pts = [];
|
|
610
|
+
for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
|
|
611
|
+
return pts;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Absolute cubic control points → relative segment `{ to, c1?, c2? }`. */
|
|
615
|
+
function toRelativeSegment(from, to, c1Abs, c2Abs) {
|
|
616
|
+
const seg = { to: [to[0], to[1]] };
|
|
617
|
+
if (c1Abs) {
|
|
618
|
+
const d1 = [c1Abs[0] - from[0], c1Abs[1] - from[1]];
|
|
619
|
+
if (Math.hypot(d1[0], d1[1]) >= 1e-12) seg.c1 = d1;
|
|
620
|
+
}
|
|
621
|
+
if (c2Abs) {
|
|
622
|
+
const d2 = [c2Abs[0] - to[0], c2Abs[1] - to[1]];
|
|
623
|
+
if (Math.hypot(d2[0], d2[1]) >= 1e-12) seg.c2 = d2;
|
|
624
|
+
}
|
|
625
|
+
return seg;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Four kappa cubics tracing an ellipse/circle in design space. */
|
|
629
|
+
function ellipseToSubpath(cx, cy, rx, ry) {
|
|
630
|
+
const kx = KAPPA * rx;
|
|
631
|
+
const ky = KAPPA * ry;
|
|
632
|
+
const start = [cx + rx, cy];
|
|
633
|
+
const right = [cx, cy + ry];
|
|
634
|
+
const left = [cx - rx, cy];
|
|
635
|
+
const top = [cx, cy - ry];
|
|
636
|
+
const segs = [
|
|
637
|
+
toRelativeSegment(start, right, [cx + rx, cy + ky], [cx + kx, cy + ry]),
|
|
638
|
+
toRelativeSegment(right, left, [cx - kx, cy + ry], [cx - rx, cy + ky]),
|
|
639
|
+
toRelativeSegment(left, top, [cx - rx, cy - ky], [cx - kx, cy - ry]),
|
|
640
|
+
toRelativeSegment(top, start, [cx + kx, cy - ry], [cx + rx, cy - ky]),
|
|
641
|
+
];
|
|
642
|
+
return { closed: true, start, segs };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function circleToSubpath(cx, cy, rx, ry) {
|
|
646
|
+
return { closed: true, ellipse: { cx, cy, rx, ry }, start: [cx + rx, cy], segs: [] };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function materializeEllipseSubpath(e, xf) {
|
|
650
|
+
const [cx, cy] = xf(e.cx, e.cy);
|
|
651
|
+
const [ex] = xf(e.cx + e.rx, e.cy);
|
|
652
|
+
const [, fy] = xf(e.cx, e.cy + e.ry);
|
|
653
|
+
const rx = Math.abs(ex - cx);
|
|
654
|
+
const ry = Math.abs(fy - cy);
|
|
655
|
+
return ellipseToSubpath(cx, cy, rx, ry);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function convertElement(el, diagnostics, inherited = {}) {
|
|
659
|
+
const tag = el.name;
|
|
660
|
+
if (tag === 'defs' || tag === 'clippath' || tag === 'mask') {
|
|
661
|
+
diagnostics.rejected.push(`<${tag}>: skipped container`);
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
if (tag === 'lineargradient' || tag === 'radialgradient') {
|
|
665
|
+
diagnostics.rejected.push(`<${tag}>: gradients not supported`);
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
if (tag === 'text' || tag === 'tspan' || tag === 'image' || tag === 'use' || tag === 'foreignobject') {
|
|
669
|
+
diagnostics.rejected.push(`<${tag}>: not supported`);
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
if (tag === 'line') {
|
|
673
|
+
diagnostics.rejected.push('<line>: not in v1 subset (use path/polyline)');
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
if (tag === 'title' || tag === 'desc' || tag === 'metadata' || tag === 'style') return [];
|
|
677
|
+
if (el.attrs.transform) {
|
|
678
|
+
diagnostics.rejected.push(`<${tag}>: transform attribute rejected`);
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const pres = presentation(el.attrs);
|
|
683
|
+
const nextInherited = {
|
|
684
|
+
fill: pres.fill !== undefined ? pres.fill : inherited.fill,
|
|
685
|
+
stroke: pres.stroke !== undefined ? pres.stroke : inherited.stroke,
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
if (tag === 'g' || tag === 'svg') {
|
|
689
|
+
const out = [];
|
|
690
|
+
for (const child of el.children) out.push(...convertElement(child, diagnostics, nextInherited));
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const filledTags = new Set(['path', 'polygon', 'rect', 'circle', 'ellipse']);
|
|
695
|
+
const defaultFill = filledTags.has(tag) ? '#000000' : 'none';
|
|
696
|
+
const fillRaw = nextInherited.fill !== undefined ? nextInherited.fill : defaultFill;
|
|
697
|
+
const strokeRaw = nextInherited.stroke !== undefined ? nextInherited.stroke : 'none';
|
|
698
|
+
const fill = resolveColor(fillRaw, diagnostics, `<${tag}> fill`);
|
|
699
|
+
const stroke = resolveColor(strokeRaw, diagnostics, `<${tag}> stroke`);
|
|
700
|
+
const fillHex = fill === undefined ? null : fill;
|
|
701
|
+
const strokeHex = stroke === undefined ? null : stroke;
|
|
702
|
+
|
|
703
|
+
if (tag === 'path') {
|
|
704
|
+
if (!el.attrs.d) {
|
|
705
|
+
diagnostics.rejected.push('<path>: missing d');
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
const parsed = parsePathD(el.attrs.d, diagnostics);
|
|
709
|
+
if (!parsed.subpaths.length) {
|
|
710
|
+
diagnostics.rejected.push('<path>: empty/unparsed d');
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
diagnostics.parsed.push('path');
|
|
714
|
+
if (fillHex) warnFillRuleIfNeeded(parsed.subpaths, el.attrs, 'path', diagnostics);
|
|
715
|
+
return [{ fill: fillHex, stroke: strokeHex, subpaths: parsed.subpaths }];
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (tag === 'polygon' || tag === 'polyline') {
|
|
719
|
+
const pts = parsePoints(el.attrs.points ?? '');
|
|
720
|
+
if (pts.length < 2) {
|
|
721
|
+
diagnostics.rejected.push(`<${tag}>: need ≥2 points`);
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
const segs = [];
|
|
725
|
+
for (let i = 1; i < pts.length; i++) segs.push({ kind: 'L', x: pts[i][0], y: pts[i][1] });
|
|
726
|
+
diagnostics.parsed.push(tag);
|
|
727
|
+
return [{ fill: fillHex, stroke: strokeHex, subpaths: [{ closed: tag === 'polygon', segs, start: pts[0] }] }];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (tag === 'rect') {
|
|
731
|
+
const x = parseFloat(el.attrs.x ?? '0');
|
|
732
|
+
const y = parseFloat(el.attrs.y ?? '0');
|
|
733
|
+
const w = parseFloat(el.attrs.width ?? '0');
|
|
734
|
+
const h = parseFloat(el.attrs.height ?? '0');
|
|
735
|
+
if (!(w > 0 && h > 0)) {
|
|
736
|
+
diagnostics.rejected.push('<rect>: non-positive size');
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
diagnostics.parsed.push('rect');
|
|
740
|
+
return [
|
|
741
|
+
{
|
|
742
|
+
fill: fillHex,
|
|
743
|
+
stroke: strokeHex,
|
|
744
|
+
subpaths: [
|
|
745
|
+
{
|
|
746
|
+
closed: true,
|
|
747
|
+
segs: [
|
|
748
|
+
{ kind: 'L', x: x + w, y },
|
|
749
|
+
{ kind: 'L', x: x + w, y: y + h },
|
|
750
|
+
{ kind: 'L', x, y: y + h },
|
|
751
|
+
],
|
|
752
|
+
start: [x, y],
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
},
|
|
756
|
+
];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (tag === 'circle') {
|
|
760
|
+
const cx = parseFloat(el.attrs.cx ?? '0');
|
|
761
|
+
const cy = parseFloat(el.attrs.cy ?? '0');
|
|
762
|
+
const r = parseFloat(el.attrs.r ?? '0');
|
|
763
|
+
if (!(r > 0)) {
|
|
764
|
+
diagnostics.rejected.push('<circle>: non-positive r');
|
|
765
|
+
return [];
|
|
766
|
+
}
|
|
767
|
+
diagnostics.parsed.push('circle');
|
|
768
|
+
return [{ fill: fillHex, stroke: strokeHex, subpaths: [circleToSubpath(cx, cy, r, r)] }];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (tag === 'ellipse') {
|
|
772
|
+
const cx = parseFloat(el.attrs.cx ?? '0');
|
|
773
|
+
const cy = parseFloat(el.attrs.cy ?? '0');
|
|
774
|
+
const rx = parseFloat(el.attrs.rx ?? '0');
|
|
775
|
+
const ry = parseFloat(el.attrs.ry ?? '0');
|
|
776
|
+
if (!(rx > 0 && ry > 0)) {
|
|
777
|
+
diagnostics.rejected.push('<ellipse>: non-positive rx/ry');
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
diagnostics.parsed.push('ellipse');
|
|
781
|
+
return [{ fill: fillHex, stroke: strokeHex, subpaths: [circleToSubpath(cx, cy, rx, ry)] }];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
diagnostics.rejected.push(`<${tag}>: not supported`);
|
|
785
|
+
return [];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Transform a raw parsed subpath into design-space cubic segments with relative
|
|
790
|
+
* handles. Closed Z closing segments that duplicate start are dropped later.
|
|
791
|
+
*/
|
|
792
|
+
function materializeSubpath(sub, xf) {
|
|
793
|
+
const start = xf(sub.start[0], sub.start[1]);
|
|
794
|
+
const segs = [];
|
|
795
|
+
let from = start;
|
|
796
|
+
|
|
797
|
+
for (const seg of sub.segs) {
|
|
798
|
+
if (seg.kind === 'L') {
|
|
799
|
+
const to = xf(seg.x, seg.y);
|
|
800
|
+
if (Math.hypot(to[0] - from[0], to[1] - from[1]) < 1e-12) continue;
|
|
801
|
+
segs.push({ to });
|
|
802
|
+
from = to;
|
|
803
|
+
} else if (seg.kind === 'C') {
|
|
804
|
+
const c1 = xf(seg.x1, seg.y1);
|
|
805
|
+
const c2 = xf(seg.x2, seg.y2);
|
|
806
|
+
const to = xf(seg.x, seg.y);
|
|
807
|
+
const pieces = splitCubicAtInflections(from, c1, c2, to);
|
|
808
|
+
for (const piece of pieces) {
|
|
809
|
+
if (Math.hypot(piece.p1[0] - from[0], piece.p1[1] - from[1]) < 1e-12) continue;
|
|
810
|
+
segs.push(toRelativeSegment(from, piece.p1, piece.c1, piece.c2));
|
|
811
|
+
from = piece.p1;
|
|
812
|
+
}
|
|
813
|
+
} else if (seg.kind === 'Q') {
|
|
814
|
+
const q = xf(seg.x1, seg.y1);
|
|
815
|
+
const to = xf(seg.x, seg.y);
|
|
816
|
+
const c1 = [from[0] + (2 / 3) * (q[0] - from[0]), from[1] + (2 / 3) * (q[1] - from[1])];
|
|
817
|
+
const c2 = [to[0] + (2 / 3) * (q[0] - to[0]), to[1] + (2 / 3) * (q[1] - to[1])];
|
|
818
|
+
const pieces = splitCubicAtInflections(from, c1, c2, to);
|
|
819
|
+
for (const piece of pieces) {
|
|
820
|
+
if (Math.hypot(piece.p1[0] - from[0], piece.p1[1] - from[1]) < 1e-12) continue;
|
|
821
|
+
segs.push(toRelativeSegment(from, piece.p1, piece.c1, piece.c2));
|
|
822
|
+
from = piece.p1;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return { closed: !!sub.closed, start, segs };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function roundSegment(seg) {
|
|
831
|
+
const out = { to: [roundCoord(seg.to[0]), roundCoord(seg.to[1])] };
|
|
832
|
+
if (seg.c1) out.c1 = [roundCoord(seg.c1[0]), roundCoord(seg.c1[1])];
|
|
833
|
+
if (seg.c2) out.c2 = [roundCoord(seg.c2[0]), roundCoord(seg.c2[1])];
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function subpathHasCurvature(sub) {
|
|
838
|
+
return (sub.segs ?? []).some((seg) => seg.c1 || seg.c2);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Clean up a materialized subpath: round coords, drop degenerate segments,
|
|
843
|
+
* omit duplicate closing vertex, demote degenerate closed loops.
|
|
844
|
+
*/
|
|
845
|
+
function finalizeSubpath(sub) {
|
|
846
|
+
let closed = !!sub.closed;
|
|
847
|
+
const start = [roundCoord(sub.start[0]), roundCoord(sub.start[1])];
|
|
848
|
+
const segs = [];
|
|
849
|
+
let from = start;
|
|
850
|
+
|
|
851
|
+
for (const raw of sub.segs ?? []) {
|
|
852
|
+
const seg = roundSegment(raw);
|
|
853
|
+
const to = seg.to;
|
|
854
|
+
if (Math.hypot(to[0] - from[0], to[1] - from[1]) < 1e-12) continue;
|
|
855
|
+
if (!seg.c1 && !seg.c2) {
|
|
856
|
+
segs.push({ to });
|
|
857
|
+
} else {
|
|
858
|
+
const out = { to };
|
|
859
|
+
if (seg.c1 && Math.hypot(seg.c1[0], seg.c1[1]) >= 1e-12) out.c1 = seg.c1;
|
|
860
|
+
if (seg.c2 && Math.hypot(seg.c2[0], seg.c2[1]) >= 1e-12) out.c2 = seg.c2;
|
|
861
|
+
segs.push(out);
|
|
862
|
+
}
|
|
863
|
+
from = to;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Canonical closure: the edge back to `start` is implied, so a straight
|
|
867
|
+
// closing segment is dropped. A curved one is kept — it carries geometry that
|
|
868
|
+
// an implied close can't express, e.g. the second arc of a lens.
|
|
869
|
+
const closer = segs[segs.length - 1];
|
|
870
|
+
if (closed && closer && !closer.c1 && !closer.c2 && pointsEqual(closer.to, start)) {
|
|
871
|
+
segs.pop();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const vertCount =
|
|
875
|
+
closed && segs.length > 0 && pointsEqual(segs[segs.length - 1].to, start)
|
|
876
|
+
? segs.length
|
|
877
|
+
: 1 + segs.length;
|
|
878
|
+
|
|
879
|
+
if (closed && (vertCount < 2 || (vertCount === 2 && !subpathHasCurvature({ segs })))) {
|
|
880
|
+
closed = false;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return { closed, start, segs };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function isIntegerPhase(n) {
|
|
887
|
+
return Math.abs(n - Math.round(n)) < 1e-9;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function shapeHasIntegerPhase(subpaths) {
|
|
891
|
+
for (const sub of subpaths) {
|
|
892
|
+
for (const [x, y] of subpathPoints(sub)) {
|
|
893
|
+
if (isIntegerPhase(x) || isIntegerPhase(y)) return true;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function applyStrokePhaseNudge(shape) {
|
|
900
|
+
const nudged = translatePathData({ shapes: [shape] }, STROKE_PHASE_NUDGE, STROKE_PHASE_NUDGE).shapes[0];
|
|
901
|
+
return {
|
|
902
|
+
...nudged,
|
|
903
|
+
subpaths: nudged.subpaths.map((sub) => finalizeSubpath(sub)),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Import SVG → stacked layer model.
|
|
909
|
+
* Returns { size, shapes, palette, keyOf, hexOf, diagnostics, refShapes }
|
|
910
|
+
* where refShapes are pre-nudge design-space shapes (palette keys) for scoring.
|
|
911
|
+
*/
|
|
912
|
+
export function importStackedSvg(svgText, size = 16, quantPalette = AGENT_PALETTE_16) {
|
|
913
|
+
const diagnostics = { parsed: [], rejected: [], notes: [] };
|
|
914
|
+
const roots = walkElements(svgText);
|
|
915
|
+
const svg = findSvgRoot(roots);
|
|
916
|
+
if (!svg) throw new Error('No <svg> root found');
|
|
917
|
+
if (svg.attrs.transform) diagnostics.rejected.push('<svg>: transform attribute rejected');
|
|
918
|
+
|
|
919
|
+
const vb = parseViewBox(svg.attrs);
|
|
920
|
+
const { xf } = makeTransform(vb, size);
|
|
921
|
+
|
|
922
|
+
const rawShapes = [];
|
|
923
|
+
for (const child of svg.children) {
|
|
924
|
+
if (child.name === 'g' && child.attrs.transform) {
|
|
925
|
+
diagnostics.rejected.push('<g>: transform attribute rejected (children skipped)');
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
rawShapes.push(...convertElement(child, diagnostics));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const paletteDrift = [];
|
|
932
|
+
const preNudge = [];
|
|
933
|
+
for (const shape of rawShapes) {
|
|
934
|
+
const strokeQ = shape.stroke ? quantizeToPalette(shape.stroke, quantPalette) : null;
|
|
935
|
+
const fillQ = shape.fill ? quantizeToPalette(shape.fill, quantPalette) : null;
|
|
936
|
+
if (shape.stroke && strokeQ) {
|
|
937
|
+
const from = normalizeHex(shape.stroke)?.slice(0, 7) ?? shape.stroke;
|
|
938
|
+
if (from !== strokeQ) paletteDrift.push({ role: 'stroke', from, to: strokeQ });
|
|
939
|
+
}
|
|
940
|
+
if (shape.fill && fillQ) {
|
|
941
|
+
const from = normalizeHex(shape.fill)?.slice(0, 7) ?? shape.fill;
|
|
942
|
+
if (from !== fillQ) paletteDrift.push({ role: 'fill', from, to: fillQ });
|
|
943
|
+
}
|
|
944
|
+
const subs = shape.subpaths.map((sub) =>
|
|
945
|
+
finalizeSubpath(
|
|
946
|
+
sub.ellipse ? materializeEllipseSubpath(sub.ellipse, xf) : materializeSubpath(sub, xf),
|
|
947
|
+
),
|
|
948
|
+
);
|
|
949
|
+
preNudge.push({ fillHex: fillQ, strokeHex: strokeQ, subpaths: subs });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const usedHexes = [];
|
|
953
|
+
for (const s of preNudge) {
|
|
954
|
+
if (s.fillHex) usedHexes.push(s.fillHex);
|
|
955
|
+
if (s.strokeHex) usedHexes.push(s.strokeHex);
|
|
956
|
+
}
|
|
957
|
+
const { palette: paletteMap, keyOf } = allocatePaletteKeys(usedHexes);
|
|
958
|
+
|
|
959
|
+
const shapes = [];
|
|
960
|
+
let anchorsBefore = 0;
|
|
961
|
+
let anchorsAfter = 0;
|
|
962
|
+
for (const s of preNudge) {
|
|
963
|
+
let shapeObj = {
|
|
964
|
+
fill: s.fillHex ? keyOf.get(s.fillHex) : null,
|
|
965
|
+
stroke: s.strokeHex ? keyOf.get(s.strokeHex) : null,
|
|
966
|
+
subpaths: s.subpaths,
|
|
967
|
+
};
|
|
968
|
+
for (const sub of s.subpaths) anchorsBefore += subpathVertexCount(sub);
|
|
969
|
+
|
|
970
|
+
if (s.strokeHex && shapeHasIntegerPhase(s.subpaths)) {
|
|
971
|
+
shapeObj = applyStrokePhaseNudge(shapeObj);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const sub of shapeObj.subpaths) anchorsAfter += subpathVertexCount(sub);
|
|
975
|
+
shapes.push(shapeObj);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const refShapes = preNudge.map((s) => ({
|
|
979
|
+
fill: s.fillHex ? keyOf.get(s.fillHex) : null,
|
|
980
|
+
stroke: s.strokeHex ? keyOf.get(s.strokeHex) : null,
|
|
981
|
+
subpaths: s.subpaths,
|
|
982
|
+
}));
|
|
983
|
+
|
|
984
|
+
diagnostics.notes.push(
|
|
985
|
+
'stacked import: one shape per SVG element, painter order, cubic segments, uniform stroke nudge when integer-phase',
|
|
986
|
+
);
|
|
987
|
+
diagnostics.paletteDrift = paletteDrift;
|
|
988
|
+
diagnostics.anchorsBefore = anchorsBefore;
|
|
989
|
+
diagnostics.anchorsAfter = anchorsAfter;
|
|
990
|
+
diagnostics.shapeCount = shapes.length;
|
|
991
|
+
|
|
992
|
+
return {
|
|
993
|
+
size,
|
|
994
|
+
shapes,
|
|
995
|
+
refShapes,
|
|
996
|
+
palette: paletteMap,
|
|
997
|
+
keyOf,
|
|
998
|
+
diagnostics,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Public importer API used by tests / validation. */
|
|
1003
|
+
export function importSvg(svgText, size = 16, quantPalette = AGENT_PALETTE_16) {
|
|
1004
|
+
const imported = importStackedSvg(svgText, size, quantPalette);
|
|
1005
|
+
const { shapes, refShapes, palette, diagnostics } = imported;
|
|
1006
|
+
const pathData = { shapes };
|
|
1007
|
+
const grid = bakePathCell(pathData, size, size);
|
|
1008
|
+
|
|
1009
|
+
const paletteArr = [];
|
|
1010
|
+
for (const ch of KEY_ALPHABET) {
|
|
1011
|
+
if (palette[ch]) paletteArr.push({ key: ch, hex: palette[ch] });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const sprite = {
|
|
1015
|
+
resolution: { width: size, height: size },
|
|
1016
|
+
palette: paletteArr,
|
|
1017
|
+
frames: [{}],
|
|
1018
|
+
defaultDurationMs: 100,
|
|
1019
|
+
tags: [],
|
|
1020
|
+
layers: [
|
|
1021
|
+
{
|
|
1022
|
+
id: 'layer-0',
|
|
1023
|
+
name: 'Path',
|
|
1024
|
+
visible: true,
|
|
1025
|
+
opacity: 1,
|
|
1026
|
+
blendMode: 'normal',
|
|
1027
|
+
kind: 'path',
|
|
1028
|
+
cells: [{ grid, path: pathData }],
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
sprite,
|
|
1035
|
+
pathData,
|
|
1036
|
+
shapes,
|
|
1037
|
+
refShapes,
|
|
1038
|
+
grid,
|
|
1039
|
+
diagnostics: {
|
|
1040
|
+
...diagnostics,
|
|
1041
|
+
shapeCount: shapes.length,
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Bake imported SVG to a compact pixel-only sprite (no path source). */
|
|
1047
|
+
export function importSvgToPixel(svgText, size = 16, quantPalette = AGENT_PALETTE_16) {
|
|
1048
|
+
const { shapes, palette, diagnostics } = importStackedSvg(svgText, size, quantPalette);
|
|
1049
|
+
const grid = bakePathCell({ shapes }, size, size);
|
|
1050
|
+
const cells = grid.map((row) => row.split(''));
|
|
1051
|
+
const paletteObj = {};
|
|
1052
|
+
for (const [key, hex] of Object.entries(palette)) paletteObj[key] = hex;
|
|
1053
|
+
const art = fromCells(paletteObj, cells);
|
|
1054
|
+
return { art, grid, diagnostics };
|
|
1055
|
+
}
|
|
1056
|
+
|