@es3d/es3d 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +95 -86
  3. package/docs/SPEC.md +426 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +390 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +132 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf-pack.js +1461 -0
  42. package/src/io/gltf.js +630 -630
  43. package/src/io/mesh-import.js +104 -104
  44. package/src/io/obj.js +352 -352
  45. package/src/io/step-brep.js +439 -439
  46. package/src/io/step-exact.js +1149 -1149
  47. package/src/io/step.js +1418 -1418
  48. package/src/io/stl.js +193 -193
  49. package/src/io/svg-import.js +787 -787
  50. package/src/material/materials.js +154 -154
  51. package/src/math/mat4.js +475 -475
  52. package/src/math/random.js +30 -30
  53. package/src/math/trig.js +65 -65
  54. package/src/math/vec3.js +81 -81
  55. package/src/optimize/optimizer.js +604 -579
  56. package/src/path/path-flatten.js +322 -322
  57. package/src/path/path-parser.js +332 -332
  58. package/src/path/shapes.js +129 -129
  59. package/src/path/triangulate.js +517 -517
  60. package/src/pipeline.js +74 -74
  61. package/src/render/camera.js +168 -168
  62. package/src/render/frame-builder.js +362 -362
  63. package/src/render/geometry-pool.js +149 -149
  64. package/src/render/lighting.js +149 -149
  65. package/src/render/orbit-controls.js +237 -237
  66. package/src/render/renderer.js +258 -249
  67. package/src/render/scroll-timeline.js +104 -104
  68. package/src/render/shaders/glsl.js +285 -285
  69. package/src/render/shaders/wgsl.js +252 -252
  70. package/src/render/stage.js +365 -352
  71. package/src/render/texture-cache.js +40 -40
  72. package/src/render/textures.js +223 -223
  73. package/src/render/webgl2/webgl2-backend.js +586 -532
  74. package/src/render/webgpu/webgpu-backend.js +482 -480
  75. package/src/scene/flatten.js +754 -754
  76. package/src/scene/picking.js +161 -161
  77. package/src/scene/scene-graph.js +320 -320
  78. package/src/transform/transform.js +211 -211
  79. package/src/wasm/kernels-binary.js +3 -3
  80. package/src/wasm/kernels.js +385 -385
@@ -1,787 +1,787 @@
1
- // SVG to ES3D: flat vector drawings become 3D relief models.
2
- //
3
- // SVG path data is ES3D path data, so the drawing's curves survive exactly:
4
- // every filled shape becomes an <extrude> of its outline (a flat <path> when
5
- // the depth is 0), every stroke a round tube (<polyline d="…" radius="…">)
6
- // lying on the shape's front face. Shapes are stacked in document order, so
7
- // later shapes sit on top of earlier ones, and colors become shared
8
- // <material>s. Transforms are baked into the coordinates; SVG's y axis points
9
- // down and ES3D's up, so the drawing is flipped, centred and scaled to a
10
- // chosen size.
11
- //
12
- // Supported: path, rect, circle, ellipse, line, polyline, polygon, g, svg, a,
13
- // switch, use/symbol; transform attributes; presentation attributes, style=""
14
- // and <style> sheets with simple selectors (tag, .class, #id, tag.class, *);
15
- // fill, stroke, opacity, fill-opacity, stroke-opacity, fill-rule,
16
- // stroke-width, display, visibility, color; named, hex, rgb() and hsl()
17
- // colors. Gradients and patterns become their average color; text, images,
18
- // filters, masks, clip paths, markers and dashes are skipped with a warning.
19
-
20
- import { parseXml } from '../format/xml-parser.js';
21
- import { ES3DError } from '../format/errors.js';
22
- import { parsePathData } from '../path/path-parser.js';
23
- import { arcToCenter } from '../path/path-flatten.js';
24
- import { formatPathData, formatNumber } from '../format/value-format.js';
25
- import { parseColor, colorToHex } from '../format/colors.js';
26
- import { h, Document, Comment } from '../format/ast.js';
27
- import { serialize } from '../format/serializer.js';
28
-
29
- // ---------------------------------------------------------------------------
30
- // 2D affine matrices [a, b, c, d, e, f]: x' = a·x + c·y + e, y' = b·x + d·y + f
31
-
32
- const IDENTITY = [1, 0, 0, 1, 0, 0];
33
-
34
- function mul(m, n) {
35
- return [
36
- m[0] * n[0] + m[2] * n[1],
37
- m[1] * n[0] + m[3] * n[1],
38
- m[0] * n[2] + m[2] * n[3],
39
- m[1] * n[2] + m[3] * n[3],
40
- m[0] * n[4] + m[2] * n[5] + m[4],
41
- m[1] * n[4] + m[3] * n[5] + m[5],
42
- ];
43
- }
44
-
45
- const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
46
- const det = (m) => m[0] * m[3] - m[1] * m[2];
47
-
48
- /** Uniform scale + rotation (+ mirror): arcs can then be transformed exactly. */
49
- function similarity(m) {
50
- const [a, b, c, d] = m;
51
- const dt = a * d - b * c;
52
- if (!(Math.abs(dt) > 1e-300)) return null;
53
- const s = Math.sqrt(Math.abs(dt));
54
- const tol = 1e-9 * s;
55
- const ok = dt > 0 ? Math.abs(a - d) <= tol && Math.abs(b + c) <= tol : Math.abs(a + d) <= tol && Math.abs(b - c) <= tol;
56
- return ok ? { scale: s, angle: (Math.atan2(b, a) * 180) / Math.PI, flip: dt < 0 } : null;
57
- }
58
-
59
- const NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
60
- const numbersIn = (s) => (String(s).match(NUMBER) || []).map(Number);
61
-
62
- /** SVG transform list; null when the syntax is invalid. */
63
- export function parseSvgTransform(str) {
64
- let m = IDENTITY;
65
- const re = /\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)\s*,?/gy;
66
- let match;
67
- let end = 0;
68
- while ((match = re.exec(str))) {
69
- end = re.lastIndex;
70
- const v = numbersIn(match[2]);
71
- const rad = (deg) => (deg * Math.PI) / 180;
72
- let t;
73
- switch (match[1]) {
74
- case 'matrix':
75
- if (v.length !== 6) return null;
76
- t = v;
77
- break;
78
- case 'translate':
79
- if (v.length < 1 || v.length > 2) return null;
80
- t = [1, 0, 0, 1, v[0], v[1] || 0];
81
- break;
82
- case 'scale':
83
- if (v.length < 1 || v.length > 2) return null;
84
- t = [v[0], 0, 0, v.length > 1 ? v[1] : v[0], 0, 0];
85
- break;
86
- case 'rotate': {
87
- if (v.length !== 1 && v.length !== 3) return null;
88
- const c = Math.cos(rad(v[0])), s = Math.sin(rad(v[0]));
89
- t = [c, s, -s, c, 0, 0];
90
- if (v.length === 3) t = mul(mul([1, 0, 0, 1, v[1], v[2]], t), [1, 0, 0, 1, -v[1], -v[2]]);
91
- break;
92
- }
93
- case 'skewX':
94
- if (v.length !== 1) return null;
95
- t = [1, 0, Math.tan(rad(v[0])), 1, 0, 0];
96
- break;
97
- case 'skewY':
98
- if (v.length !== 1) return null;
99
- t = [1, Math.tan(rad(v[0])), 0, 1, 0, 0];
100
- break;
101
- }
102
- m = mul(m, t);
103
- }
104
- if (str.slice(end).trim() !== '') return null;
105
- return m;
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // path segments (the absolute form produced by parsePathData)
110
-
111
- function arcToCubics(s) {
112
- const arc = arcToCenter(s.x0, s.y0, s.rx, s.ry, s.rot, s.large, s.sweep, s.x, s.y);
113
- if (!arc) return [{ c: 'L', x: s.x, y: s.y }];
114
- const phi = (s.rot * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
115
- const n = Math.max(1, Math.ceil(Math.abs(arc.dtheta) / (Math.PI / 2) - 1e-9));
116
- const dt = arc.dtheta / n;
117
- const k = (4 / 3) * Math.tan(dt / 4);
118
- const deriv = (t) => [-arc.rx * cosP * Math.sin(t) - arc.ry * sinP * Math.cos(t), -arc.rx * sinP * Math.sin(t) + arc.ry * cosP * Math.cos(t)];
119
- const out = [];
120
- let t = arc.theta1;
121
- let px = s.x0, py = s.y0;
122
- for (let i = 0; i < n; i++) {
123
- const t2 = t + dt;
124
- const [qx, qy] = i === n - 1 ? [s.x, s.y] : arc.pointAt(t2);
125
- const d1 = deriv(t), d2 = deriv(t2);
126
- out.push({ c: 'C', x1: px + k * d1[0], y1: py + k * d1[1], x2: qx - k * d2[0], y2: qy - k * d2[1], x: qx, y: qy });
127
- px = qx;
128
- py = qy;
129
- t = t2;
130
- }
131
- return out;
132
- }
133
-
134
- /** Apply an affine transform to segments; arcs stay arcs under similarity transforms, else become cubics. */
135
- function transformSegments(segs, m) {
136
- const sim = similarity(m);
137
- const out = [];
138
- for (const s of segs) {
139
- switch (s.c) {
140
- case 'M':
141
- case 'L': {
142
- const [x, y] = apply(m, s.x, s.y);
143
- out.push({ c: s.c, x, y, implicit: s.implicit });
144
- break;
145
- }
146
- case 'C': {
147
- const [x1, y1] = apply(m, s.x1, s.y1), [x2, y2] = apply(m, s.x2, s.y2), [x, y] = apply(m, s.x, s.y);
148
- out.push({ c: 'C', x1, y1, x2, y2, x, y });
149
- break;
150
- }
151
- case 'Q': {
152
- const [x1, y1] = apply(m, s.x1, s.y1), [x, y] = apply(m, s.x, s.y);
153
- out.push({ c: 'Q', x1, y1, x, y });
154
- break;
155
- }
156
- case 'A': {
157
- if (sim) {
158
- const [x0, y0] = apply(m, s.x0, s.y0), [x, y] = apply(m, s.x, s.y);
159
- let rot = sim.flip ? sim.angle - s.rot : sim.angle + s.rot;
160
- rot = ((rot % 360) + 360) % 360;
161
- out.push({ c: 'A', rx: s.rx * sim.scale, ry: s.ry * sim.scale, rot, large: s.large, sweep: sim.flip ? 1 - s.sweep : s.sweep, x0, y0, x, y });
162
- } else {
163
- for (const cub of arcToCubics(s)) out.push(...transformSegments([cub], m));
164
- }
165
- break;
166
- }
167
- case 'Z':
168
- out.push({ c: 'Z' });
169
- break;
170
- }
171
- }
172
- return out;
173
- }
174
-
175
- /** Control points of the segments (arcs as cubics): a conservative outline for bounds and area. */
176
- function controlPolygons(segs) {
177
- const polys = [];
178
- let cur = null;
179
- for (const s of segs) {
180
- if (s.c === 'M') {
181
- cur = [s.x, s.y];
182
- polys.push(cur);
183
- } else if (s.c === 'Z') {
184
- cur = null;
185
- } else {
186
- if (!cur) {
187
- cur = [];
188
- polys.push(cur);
189
- }
190
- const parts = s.c === 'A' ? arcToCubics(s) : [s];
191
- for (const p of parts) {
192
- if (p.c === 'C') cur.push(p.x1, p.y1, p.x2, p.y2, p.x, p.y);
193
- else if (p.c === 'Q') cur.push(p.x1, p.y1, p.x, p.y);
194
- else cur.push(p.x, p.y);
195
- }
196
- }
197
- }
198
- return polys;
199
- }
200
-
201
- function polygonArea(p) {
202
- let a = 0;
203
- for (let i = 0, n = p.length / 2; i < n; i++) {
204
- const j = (i + 1) % n;
205
- a += p[i * 2] * p[j * 2 + 1] - p[j * 2] * p[i * 2 + 1];
206
- }
207
- return a / 2;
208
- }
209
-
210
- // ---------------------------------------------------------------------------
211
- // styles
212
-
213
- const INHERITED = ['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray', 'visibility', 'color'];
214
- const STYLE_PROPS = new Set([...INHERITED, 'opacity', 'display', 'transform']);
215
- const DEFAULT_STYLE = { fill: 'black', 'fill-opacity': '1', 'fill-rule': 'nonzero', stroke: 'none', 'stroke-opacity': '1', 'stroke-width': '1', 'stroke-dasharray': 'none', visibility: 'visible', color: 'black' };
216
-
217
- function parseDeclarations(text) {
218
- const out = {};
219
- for (const decl of String(text).split(';')) {
220
- const i = decl.indexOf(':');
221
- if (i < 0) continue;
222
- const prop = decl.slice(0, i).trim().toLowerCase();
223
- const value = decl.slice(i + 1).replace(/!important/i, '').trim();
224
- if (prop && value) out[prop] = value;
225
- }
226
- return out;
227
- }
228
-
229
- const SIMPLE_SELECTOR = /^(\*|[A-Za-z][\w-]*)?((?:[.#][\w-]+)*)$/;
230
-
231
- /** CSS rules with simple selectors; anything more complex is counted as unsupported. */
232
- export function parseCss(text) {
233
- const rules = [];
234
- let unsupported = 0;
235
- const src = String(text).replace(/\/\*[\s\S]*?\*\//g, '').replace(/@[\w-]+[^{;]*;/g, '');
236
- let i = 0;
237
- while (i < src.length) {
238
- const open = src.indexOf('{', i);
239
- if (open < 0) break;
240
- const prelude = src.slice(i, open).trim();
241
- let depth = 1, j = open + 1;
242
- while (j < src.length && depth) {
243
- if (src[j] === '{') depth++;
244
- else if (src[j] === '}') depth--;
245
- j++;
246
- }
247
- const body = src.slice(open + 1, j - 1);
248
- i = j;
249
- if (prelude.startsWith('@')) {
250
- // @media and friends: use the inner rules, whatever the query says
251
- const inner = parseCss(body);
252
- rules.push(...inner.rules);
253
- unsupported += inner.unsupported;
254
- continue;
255
- }
256
- const decls = parseDeclarations(body);
257
- for (const raw of prelude.split(',')) {
258
- const sel = raw.trim();
259
- const m = SIMPLE_SELECTOR.exec(sel);
260
- if (!sel || !m) {
261
- if (sel) unsupported++;
262
- continue;
263
- }
264
- const tag = m[1] && m[1] !== '*' ? m[1].toLowerCase() : null;
265
- const classes = [], ids = [];
266
- for (const part of m[2].match(/[.#][\w-]+/g) || []) (part[0] === '.' ? classes : ids).push(part.slice(1));
267
- rules.push({ tag, classes, ids, specificity: ids.length * 100 + classes.length * 10 + (tag ? 1 : 0), decls });
268
- }
269
- }
270
- rules.forEach((r, k) => (r.order = k));
271
- return { rules, unsupported };
272
- }
273
-
274
- function hslToRgb(hue, s, l) {
275
- const k = (n) => (n + hue / 30) % 12;
276
- const a = s * Math.min(l, 1 - l);
277
- const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
278
- return [f(0), f(8), f(4)];
279
- }
280
-
281
- /** sRGB color [r, g, b, a] or null. */
282
- export function parseSvgColor(value) {
283
- const v = String(value).trim();
284
- const c = parseColor(v);
285
- if (c) return c;
286
- const m = /^hsla?\(\s*([^)]*)\)$/i.exec(v);
287
- if (m) {
288
- const p = m[1].split(/\s*[,/]\s*|\s+/).filter(Boolean);
289
- if (p.length < 3) return null;
290
- const hue = parseFloat(p[0]), s = parseFloat(p[1]) / 100, l = parseFloat(p[2]) / 100;
291
- let a = p[3] === undefined ? 1 : p[3].endsWith('%') ? parseFloat(p[3]) / 100 : parseFloat(p[3]);
292
- if (![hue, s, l, a].every(Number.isFinite)) return null;
293
- a = Math.max(0, Math.min(1, a));
294
- return [...hslToRgb(((hue % 360) + 360) % 360, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l))), a];
295
- }
296
- return null;
297
- }
298
-
299
- // ---------------------------------------------------------------------------
300
-
301
- const UNITS = { px: 1, pt: 4 / 3, pc: 16, mm: 96 / 25.4, cm: 96 / 2.54, in: 96, em: 16, ex: 8, q: 96 / 101.6 };
302
-
303
- /** A length in px; `ref` resolves percentages. */
304
- function parseLength(value, ref = 0) {
305
- if (value === undefined || value === null) return null;
306
- const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)\s*$/.exec(String(value));
307
- if (!m) return null;
308
- const n = Number(m[1]);
309
- const unit = m[2].toLowerCase();
310
- if (!unit) return n;
311
- if (unit === '%') return (n / 100) * ref;
312
- return UNITS[unit] !== undefined ? n * UNITS[unit] : null;
313
- }
314
-
315
- const SKIPPED_ELEMENTS = {
316
- text: 'text (convert text to paths in your drawing program first)',
317
- image: 'embedded images',
318
- foreignObject: 'foreign objects',
319
- video: 'video',
320
- audio: 'audio',
321
- canvas: 'canvas',
322
- iframe: 'iframes',
323
- };
324
- const CONTAINERS = new Set(['g', 'svg', 'a', 'switch']);
325
- const NEVER_RENDERED = new Set(['defs', 'symbol', 'clipPath', 'mask', 'pattern', 'marker', 'linearGradient', 'radialGradient', 'filter', 'style', 'script', 'title', 'desc', 'metadata', 'font', 'font-face', 'cursor', 'view']);
326
-
327
- const localName = (name) => {
328
- const i = name.indexOf(':');
329
- return i >= 0 ? name.slice(i + 1) : name;
330
- };
331
-
332
- /** Remove a DOCTYPE and expand the simple entities it declares.
333
-
334
- SVG files from Illustrator often declare a handful of short entities
335
- (`ns_svg`, a style string). Those are expanded once. Nested entity
336
- references inside a value are not expanded: that is the billion-laughs
337
- trick, and SVG spent years paying for it. A single huge value, or a
338
- pile of them, is refused. The external DTD URL is never fetched. */
339
- function preprocess(text, warn) {
340
- let src = String(text).replace(/^\uFEFF/, '');
341
- const entities = new Map();
342
- let stopped = false;
343
- src = src.replace(/<!DOCTYPE[^[>]*(\[[\s\S]*?\])?\s*>/i, (m, subset) => {
344
- if (subset) {
345
- for (const e of subset.matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
346
- if (entities.size >= 32) {
347
- warn('This SVG declares more than 32 entities. The extras were ignored.');
348
- break;
349
- }
350
- const value = e[2] ?? e[3];
351
- if (value.length > 4096) {
352
- warn(`Entity &${e[1]}; is too long to expand, so it was left out.`);
353
- continue;
354
- }
355
- entities.set(e[1], value);
356
- }
357
- }
358
- return '';
359
- });
360
- const XML = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
361
- const unknown = new Set();
362
- let expanded = 0;
363
- src = src.replace(/&([A-Za-z_][\w.-]*);/g, (m, name) => {
364
- if (XML.has(name)) return m;
365
- if (entities.has(name)) {
366
- if (stopped) return '';
367
- const value = entities.get(name);
368
- expanded += value.length;
369
- if (expanded > 200_000) {
370
- stopped = true;
371
- warn('Entity expansion was stopped because it grew past 200 kB.');
372
- return '';
373
- }
374
- // Escape "&" that is not already a predefined or numeric reference,
375
- // so "&b;" inside an entity value stays text and is not expanded again.
376
- return value.replace(/&(?!(lt|gt|amp|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
377
- }
378
- if (name === 'nbsp') return '&#160;';
379
- unknown.add(name);
380
- return '';
381
- });
382
- if (unknown.size) warn(`Unknown entities were removed: ${[...unknown].map((n) => `&${n};`).join(' ')}.`);
383
- return src;
384
- }
385
-
386
- /**
387
- * Convert an SVG document to ES3D.
388
- * @param {string} text SVG source
389
- * @param {{name?: string, size?: number, depth?: number, bevel?: number, strokes?: boolean, base?: boolean, precision?: number}} [opts]
390
- * size: the longer side of the drawing in ES3D units; depth: extrusion depth as a fraction of size
391
- * (0 = flat shapes); bevel: rounded edge radius as a fraction of size; strokes: convert strokes to
392
- * tubes; base: add a backing plate the size of the viewBox; precision: decimals of the path data
393
- * @returns {{text: string, warnings: string[], stats: {shapes: number, fills: number, strokes: number, materials: number, skipped: number}}}
394
- */
395
- export function svgToES3D(text, opts = {}) {
396
- const { name = 'drawing.svg', size = 10, depth: depthRel = 0.04, bevel: bevelRel = 0, strokes = true, base = false } = opts;
397
- const warnings = [];
398
- const warned = new Map();
399
- const warn = (msg) => warnings.push(msg);
400
- const warnOnce = (key, msg) => {
401
- warned.set(key, (warned.get(key) || 0) + 1);
402
- if (warned.get(key) === 1) warnings.push(msg);
403
- };
404
-
405
- let doc;
406
- try {
407
- doc = parseXml(preprocess(text, warn));
408
- } catch (e) {
409
- if (e instanceof ES3DError) {
410
- const d = e.diagnostics[0];
411
- throw new Error(`The SVG file is not well-formed XML (line ${d.line}, column ${d.column}): ${d.message}`);
412
- }
413
- throw e;
414
- }
415
- const svg = doc.root;
416
- if (localName(svg.name) !== 'svg') throw new Error(`Not an SVG file: the root element is <${svg.name}>, expected <svg>.`);
417
-
418
- // index ids and collect style sheets
419
- const byId = new Map();
420
- const sheets = [];
421
- const index = (el) => {
422
- const id = el.attr('id');
423
- if (id && !byId.has(id)) byId.set(id, el);
424
- if (localName(el.name) === 'style' && el.text) sheets.push(el.text);
425
- for (const c of el.children) if (c.type === 'element') index(c);
426
- };
427
- index(svg);
428
- const css = parseCss(sheets.join('\n'));
429
- if (css.unsupported) warn(`${css.unsupported} CSS selector${css.unsupported > 1 ? 's' : ''} more complex than tag, .class or #id ${css.unsupported > 1 ? 'were' : 'was'} ignored.`);
430
-
431
- // viewport: the viewBox, else width/height, else the drawing's own bounds
432
- const vb = numbersIn(svg.attr('viewBox') || '');
433
- const rootW = parseLength(svg.attr('width')), rootH = parseLength(svg.attr('height'));
434
- let frame = null;
435
- if (vb.length === 4 && vb[2] > 0 && vb[3] > 0) frame = { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
436
- else if (rootW > 0 && rootH > 0) frame = { x: 0, y: 0, w: rootW, h: rootH };
437
- const refW = frame ? frame.w : 100, refH = frame ? frame.h : 100;
438
- const refD = Math.hypot(refW, refH) / Math.SQRT2;
439
-
440
- const titleEl = svg.children.find((c) => c.type === 'element' && localName(c.name) === 'title');
441
- const title = (titleEl && titleEl.text && titleEl.text.trim()) || name.replace(/\.svgz?$/i, '');
442
-
443
- // computed style of an element given its parent's
444
- const computeStyle = (el, parent) => {
445
- const tag = localName(el.name);
446
- const st = {};
447
- for (const k of INHERITED) st[k] = parent[k];
448
- st.opacity = parent.opacity; // group opacity multiplies down the tree (approximation)
449
- st.display = 'inline';
450
- const own = {};
451
- for (const a of el.attrs) {
452
- const k = localName(a.name);
453
- if (STYLE_PROPS.has(k) && k !== 'transform') own[k] = a.value;
454
- }
455
- const classes = (el.attr('class') || '').split(/\s+/).filter(Boolean);
456
- const id = el.attr('id');
457
- const matched = css.rules.filter((r) => (!r.tag || r.tag === tag) && r.classes.every((c) => classes.includes(c)) && r.ids.every((i) => i === id));
458
- matched.sort((a, b) => a.specificity - b.specificity || a.order - b.order);
459
- for (const r of matched) Object.assign(own, r.decls);
460
- if (el.attr('style')) Object.assign(own, parseDeclarations(el.attr('style')));
461
- for (const [k, v] of Object.entries(own)) {
462
- if (v === 'inherit') continue;
463
- if (k === 'opacity') st.opacity = parent.opacity * Math.max(0, Math.min(1, parseFloat(v)));
464
- else if (k in st || INHERITED.includes(k)) st[k] = v;
465
- }
466
- if (own.display) st.display = own.display;
467
- return st;
468
- };
469
-
470
- /** A paint value → {color, opacity} or null (no paint). */
471
- const resolvePaint = (value, st) => {
472
- let v = String(value).trim();
473
- if (v === 'none' || v === 'transparent') return null;
474
- if (/^currentcolor$/i.test(v)) v = st.color;
475
- const url = /^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)\s*(.*)$/.exec(v);
476
- if (url) {
477
- const ref = byId.get(url[1]);
478
- const kind = ref && localName(ref.name);
479
- if (kind === 'linearGradient' || kind === 'radialGradient') {
480
- const stops = gradientStops(ref);
481
- if (stops.length) {
482
- warnOnce('gradient', 'Gradients were replaced by their average color.');
483
- const n = stops.length;
484
- const avg = stops.reduce((s, c) => s.map((x, k) => x + c[k] / n), [0, 0, 0, 0]);
485
- return { color: avg.slice(0, 3), opacity: avg[3] };
486
- }
487
- return null;
488
- }
489
- if (url[2]) return resolvePaint(url[2], st);
490
- warnOnce('pattern', 'Patterns and other unsupported paint servers were replaced by gray.');
491
- return { color: [0.5, 0.5, 0.5], opacity: 1 };
492
- }
493
- const c = parseSvgColor(v);
494
- if (!c) {
495
- warnOnce(`color:${v}`, `Unrecognized color "${v}" was treated as none.`);
496
- return null;
497
- }
498
- return { color: c.slice(0, 3), opacity: c[3] };
499
- };
500
-
501
- const gradientStops = (el, seen = new Set()) => {
502
- if (seen.has(el)) return [];
503
- seen.add(el);
504
- const stops = [];
505
- for (const c of el.children) {
506
- if (c.type !== 'element' || localName(c.name) !== 'stop') continue;
507
- const decl = { ...Object.fromEntries(c.attrs.map((a) => [localName(a.name), a.value])), ...parseDeclarations(c.attr('style') || '') };
508
- const col = parseSvgColor(decl['stop-color'] || 'black');
509
- if (!col) continue;
510
- const op = decl['stop-opacity'] !== undefined ? parseFloat(decl['stop-opacity']) : 1;
511
- stops.push([col[0], col[1], col[2], col[3] * (Number.isFinite(op) ? op : 1)]);
512
- }
513
- if (stops.length) return stops;
514
- const href = el.attr('href') || el.attr('xlink:href');
515
- const ref = href && href.startsWith('#') ? byId.get(href.slice(1)) : null;
516
- return ref ? gradientStops(ref, seen) : [];
517
- };
518
-
519
- /** Outline of a basic shape as absolute segments, or null. */
520
- const shapeSegments = (el, tag) => {
521
- const num = (k, ref = refD) => {
522
- const v = parseLength(el.attr(k), ref);
523
- return v === null ? 0 : v;
524
- };
525
- switch (tag) {
526
- case 'path': {
527
- const d = el.attr('d');
528
- if (!d || !d.trim()) return null;
529
- try {
530
- return parsePathData(d).segments;
531
- } catch (e) {
532
- warnOnce('pathdata', `A path with invalid path data was skipped (${e.message}).`);
533
- return null;
534
- }
535
- }
536
- case 'rect': {
537
- const x = num('x', refW), y = num('y', refH), w = num('width', refW), hh = num('height', refH);
538
- if (!(w > 0 && hh > 0)) return null;
539
- let rx = parseLength(el.attr('rx'), refW), ry = parseLength(el.attr('ry'), refH);
540
- if (rx === null || rx < 0) rx = ry !== null && ry > 0 ? ry : 0;
541
- if (ry === null || ry < 0) ry = rx;
542
- rx = Math.min(rx, w / 2);
543
- ry = Math.min(ry, hh / 2);
544
- if (!(rx > 0 && ry > 0)) return [{ c: 'M', x, y }, { c: 'L', x: x + w, y }, { c: 'L', x: x + w, y: y + hh }, { c: 'L', x, y: y + hh }, { c: 'Z' }];
545
- const arc = (x0, y0, x1, y1) => ({ c: 'A', rx, ry, rot: 0, large: 0, sweep: 1, x0, y0, x: x1, y: y1 });
546
- return [
547
- { c: 'M', x: x + rx, y },
548
- { c: 'L', x: x + w - rx, y },
549
- arc(x + w - rx, y, x + w, y + ry),
550
- { c: 'L', x: x + w, y: y + hh - ry },
551
- arc(x + w, y + hh - ry, x + w - rx, y + hh),
552
- { c: 'L', x: x + rx, y: y + hh },
553
- arc(x + rx, y + hh, x, y + hh - ry),
554
- { c: 'L', x, y: y + ry },
555
- arc(x, y + ry, x + rx, y),
556
- { c: 'Z' },
557
- ];
558
- }
559
- case 'circle':
560
- case 'ellipse': {
561
- const cx = num('cx', refW), cy = num('cy', refH);
562
- const rx = tag === 'circle' ? num('r') : num('rx', refW);
563
- const ry = tag === 'circle' ? rx : num('ry', refH);
564
- if (!(rx > 0 && ry > 0)) return null;
565
- return [
566
- { c: 'M', x: cx + rx, y: cy },
567
- { c: 'A', rx, ry, rot: 0, large: 1, sweep: 1, x0: cx + rx, y0: cy, x: cx - rx, y: cy },
568
- { c: 'A', rx, ry, rot: 0, large: 1, sweep: 1, x0: cx - rx, y0: cy, x: cx + rx, y: cy },
569
- { c: 'Z' },
570
- ];
571
- }
572
- case 'line':
573
- return [{ c: 'M', x: num('x1', refW), y: num('y1', refH) }, { c: 'L', x: num('x2', refW), y: num('y2', refH) }];
574
- case 'polyline':
575
- case 'polygon': {
576
- const v = numbersIn(el.attr('points') || '');
577
- if (v.length % 2) warnOnce('points', 'A points list with an odd number of coordinates was truncated.');
578
- if (v.length < 4) return null;
579
- const segs = [{ c: 'M', x: v[0], y: v[1] }];
580
- for (let i = 2; i + 1 < v.length; i += 2) segs.push({ c: 'L', x: v[i], y: v[i + 1] });
581
- if (tag === 'polygon') segs.push({ c: 'Z' });
582
- return segs;
583
- }
584
- default:
585
- return null;
586
- }
587
- };
588
-
589
- // ---- walk the tree
590
- const shapes = [];
591
- let skipped = 0;
592
- const rootStyle = { ...DEFAULT_STYLE, opacity: 1 };
593
- const walk = (el, ctm, parentStyle, depth, useStack) => {
594
- const tag = localName(el.name);
595
- if (NEVER_RENDERED.has(tag)) return;
596
- if (SKIPPED_ELEMENTS[tag]) {
597
- skipped++;
598
- warnOnce(`skip:${tag}`, `Skipped ${SKIPPED_ELEMENTS[tag]}.`);
599
- return;
600
- }
601
- const st = computeStyle(el, parentStyle);
602
- if (st.display === 'none') return;
603
- let m = ctm;
604
- const tAttr = el.attr('transform');
605
- if (tAttr) {
606
- const t = parseSvgTransform(tAttr);
607
- if (t) m = mul(m, t);
608
- else warnOnce('transform', `An invalid transform "${tAttr}" was ignored.`);
609
- }
610
- for (const k of ['clip-path', 'mask', 'filter']) {
611
- if (el.attr(k) || (el.attr('style') || '').includes(`${k}:`)) warnOnce(k, `${k === 'filter' ? 'Filters' : k === 'mask' ? 'Masks' : 'Clip paths'} were ignored.`);
612
- }
613
- if (tag === 'use') {
614
- const href = el.attr('href') || el.attr('xlink:href') || '';
615
- const ref = href.startsWith('#') ? byId.get(href.slice(1)) : null;
616
- if (!ref) {
617
- warnOnce('use', `A <use> pointing to "${href}" was skipped (only references within the file are supported).`);
618
- return;
619
- }
620
- if (useStack.includes(ref) || useStack.length > 16) {
621
- warnOnce('usecycle', 'A <use> that refers to itself was skipped.');
622
- return;
623
- }
624
- m = mul(m, [1, 0, 0, 1, parseLength(el.attr('x'), refW) || 0, parseLength(el.attr('y'), refH) || 0]);
625
- if (localName(ref.name) === 'symbol') {
626
- let sm = m;
627
- const svb = numbersIn(ref.attr('viewBox') || '');
628
- const uw = parseLength(el.attr('width'), refW), uh = parseLength(el.attr('height'), refH);
629
- if (svb.length === 4 && svb[2] > 0 && svb[3] > 0 && uw > 0 && uh > 0) {
630
- const k = Math.min(uw / svb[2], uh / svb[3]); // xMidYMid meet
631
- sm = mul(m, [k, 0, 0, k, (uw - svb[2] * k) / 2 - svb[0] * k, (uh - svb[3] * k) / 2 - svb[1] * k]);
632
- }
633
- const symStyle = computeStyle(ref, st);
634
- for (const c of ref.children) if (c.type === 'element') walk(c, sm, symStyle, depth + 1, [...useStack, ref]);
635
- } else {
636
- walk(ref, m, st, depth + 1, [...useStack, ref]);
637
- }
638
- return;
639
- }
640
- if (CONTAINERS.has(tag)) {
641
- if (tag === 'svg' && el !== svg) m = mul(m, [1, 0, 0, 1, parseLength(el.attr('x'), refW) || 0, parseLength(el.attr('y'), refH) || 0]);
642
- if (tag === 'switch') {
643
- const first = el.children.find((c) => c.type === 'element');
644
- if (first) walk(first, m, st, depth + 1, useStack);
645
- return;
646
- }
647
- for (const c of el.children) if (c.type === 'element') walk(c, m, st, depth + 1, useStack);
648
- return;
649
- }
650
- const segs = shapeSegments(el, tag);
651
- if (!segs) {
652
- if (!['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'].includes(tag)) warnOnce(`unknown:${tag}`, `Unknown element <${el.name}> was skipped.`);
653
- return;
654
- }
655
- if (st.visibility === 'hidden' || st.visibility === 'collapse') return;
656
- const op = st.opacity;
657
- const fill = resolvePaint(st.fill, st);
658
- const stroke = resolvePaint(st.stroke, st);
659
- const fillOpacity = parseFloat(st['fill-opacity']);
660
- const strokeOpacity = parseFloat(st['stroke-opacity']);
661
- const strokeWidth = parseLength(st['stroke-width'], refD);
662
- if (st['stroke-dasharray'] && st['stroke-dasharray'] !== 'none' && stroke) warnOnce('dash', 'Dashed strokes were drawn solid.');
663
- if (tag === 'line' || tag === 'polyline') {
664
- // never filled with an area in practice; polyline fills are rare
665
- }
666
- shapes.push({
667
- segs,
668
- ctm: m,
669
- id: el.attr('id') || null,
670
- fill: fill && tag !== 'line' ? { ...fill, opacity: fill.opacity * (Number.isFinite(fillOpacity) ? fillOpacity : 1) * op } : null,
671
- stroke: strokes && stroke && strokeWidth > 0 ? { ...stroke, opacity: stroke.opacity * (Number.isFinite(strokeOpacity) ? strokeOpacity : 1) * op, width: strokeWidth } : null,
672
- fillRule: st['fill-rule'] === 'evenodd' ? 'evenodd' : 'nonzero',
673
- });
674
- };
675
- walk(svg, IDENTITY, rootStyle, 0, []);
676
- if (!strokes && shapes.some((s) => !s.fill)) warn('Strokes were not converted (the strokes option is off).');
677
-
678
- // ---- bounds and normalization
679
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
680
- for (const s of shapes) {
681
- const pad = s.stroke ? (s.stroke.width / 2) * Math.sqrt(Math.abs(det(s.ctm))) : 0;
682
- for (const poly of controlPolygons(transformSegments(s.segs, s.ctm))) {
683
- for (let i = 0; i < poly.length; i += 2) {
684
- minX = Math.min(minX, poly[i] - pad);
685
- maxX = Math.max(maxX, poly[i] + pad);
686
- minY = Math.min(minY, poly[i + 1] - pad);
687
- maxY = Math.max(maxY, poly[i + 1] + pad);
688
- }
689
- }
690
- }
691
- const box = frame || (minX <= maxX ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : { x: 0, y: 0, w: 1, h: 1 });
692
- const longest = Math.max(box.w, box.h) || 1;
693
- const k = size / longest;
694
- const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
695
- const N = [k, 0, 0, -k, -cx * k, cy * k]; // centre, scale, flip y
696
- const decimals = opts.precision ?? Math.max(0, Math.min(8, Math.ceil(-Math.log10(size * 1e-4))));
697
- const f = (v) => formatNumber(v, decimals);
698
- const depth = Math.max(0, depthRel * size);
699
- const bevel = depth > 0 ? Math.min(Math.max(0, bevelRel * size), depth / 2) : 0;
700
-
701
- // ---- materials
702
- const materials = new Map();
703
- const materialFor = (paint) => {
704
- const hex = colorToHex([paint.color[0], paint.color[1], paint.color[2], 1]);
705
- const opacity = Math.round(Math.max(0, Math.min(1, paint.opacity)) * 100) / 100;
706
- const key = `${hex}:${opacity}`;
707
- let m = materials.get(key);
708
- if (!m) {
709
- const id = `c${hex.slice(1)}${opacity < 1 ? `-${Math.round(opacity * 100)}` : ''}`;
710
- const attrs = { id, color: hex, roughness: '0.55' };
711
- if (opacity < 1) attrs.opacity = formatNumber(opacity, 2);
712
- m = { id, el: h('material', attrs) };
713
- materials.set(key, m);
714
- }
715
- return m.id;
716
- };
717
-
718
- // ---- output
719
- const out = [];
720
- let fills = 0, tubes = 0, zeroArea = 0;
721
- const visible = shapes.filter((s) => (s.fill && s.fill.opacity > 0) || (s.stroke && s.stroke.opacity > 0));
722
- const step = depth > 0 ? Math.min(depth * 0.1, (depth * 2) / Math.max(1, visible.length)) : size * Math.min(0.002, 0.2 / Math.max(1, visible.length));
723
- visible.forEach((s, i) => {
724
- const m = mul(N, s.ctm);
725
- const segs = transformSegments(s.segs, m);
726
- const d = formatPathData(segs, { precision: decimals, minify: true });
727
- const z = i * step;
728
- const label = s.id ? { name: s.id } : {};
729
- if (s.fill && s.fill.opacity > 0) {
730
- const area = controlPolygons(segs).reduce((a, p) => a + Math.abs(polygonArea(p)), 0);
731
- if (area > (size * 1e-6) ** 2) {
732
- const attrs = { ...label, d };
733
- if (s.fillRule === 'evenodd') attrs.fillRule = 'evenodd';
734
- if (depth > 0) {
735
- attrs.depth = f(depth);
736
- if (bevel > 0) attrs.bevel = f(bevel);
737
- }
738
- if (z) attrs.position = `0,0,${f(z)}`;
739
- attrs.material = materialFor(s.fill);
740
- out.push(h(depth > 0 ? 'extrude' : 'path', attrs));
741
- fills++;
742
- } else zeroArea++;
743
- }
744
- if (s.stroke && s.stroke.opacity > 0) {
745
- const r = (s.stroke.width / 2) * Math.sqrt(Math.abs(det(s.ctm))) * k;
746
- if (r > 0) {
747
- const attrs = { ...(s.fill ? {} : label), d, radius: formatNumber(r, decimals + 1) };
748
- attrs.position = `0,0,${f(z + depth)}`;
749
- attrs.material = materialFor(s.stroke);
750
- out.push(h('polyline', attrs));
751
- tubes++;
752
- }
753
- }
754
- });
755
- if (zeroArea) warn(`${zeroArea} filled shape${zeroArea > 1 ? 's have' : ' has'} no area and ${zeroArea > 1 ? 'were' : 'was'} skipped.`);
756
- if (!out.length) warn('The drawing contains no shapes that could be converted.');
757
-
758
- // ---- document
759
- // a camera slightly above and to the right, far enough to frame the drawing
760
- // in a 3:2 view (35° vertical field of view)
761
- const tanHalf = Math.tan((17.5 * Math.PI) / 180);
762
- const dist = Math.max((box.h * k) / 2 / tanHalf, (box.w * k) / 2 / (tanHalf * 1.5)) * 1.1 + depth;
763
- const dir = [0.17, 0.13, 1];
764
- const dl = Math.hypot(...dir);
765
- const root = h(
766
- 'es3d',
767
- { version: '1' },
768
- h('title', {}, title),
769
- h('environment', { background: '#dfe3e8', sky: '#ffffff', ground: '#8a8f99', intensity: '0.65' }),
770
- h('camera', { position: `${f((dir[0] / dl) * dist)},${f((dir[1] / dl) * dist)},${f((dir[2] / dl) * dist + depth / 2)}`, target: `0,0,${f(depth / 2)}`, fov: '35' }),
771
- h('light', { type: 'directional', direction: '-0.35,-0.45,-1', intensity: '1.3' }),
772
- );
773
- const defs = h('defs');
774
- if (base) defs.appendChild(h('material', { id: 'plate', color: '#f4f4f4', roughness: '0.8' }));
775
- for (const m of materials.values()) defs.appendChild(m.el);
776
- if (defs.children.length) root.appendChild(defs);
777
- if (base) {
778
- const bd = Math.max(depth * 0.5, size * 0.01);
779
- root.appendChild(h('box', { size: `${f(box.w * k)},${f(box.h * k)},${f(bd)}`, position: `0,0,${f(-bd / 2)}`, material: 'plate' }));
780
- }
781
- const group = h('group', { name: title });
782
- for (const el of out) group.appendChild(el);
783
- root.appendChild(group);
784
- const comment = new Comment(` Converted from ${name.replace(/--/g, '- -')} by the ES3D converter: ${fills} filled shape${fills === 1 ? '' : 's'}, ${tubes} stroke${tubes === 1 ? '' : 's'}. `);
785
- const es3d = serialize(new Document({ root, syntax: 'xml', leading: [comment] }), { pretty: true, comments: true });
786
- return { text: es3d, warnings, stats: { shapes: shapes.length, fills, strokes: tubes, materials: materials.size, skipped } };
787
- }
1
+ // SVG to ES3D: flat vector drawings become 3D relief models.
2
+ //
3
+ // SVG path data is ES3D path data, so the drawing's curves survive exactly:
4
+ // every filled shape becomes an <extrude> of its outline (a flat <path> when
5
+ // the depth is 0), every stroke a round tube (<polyline d="…" radius="…">)
6
+ // lying on the shape's front face. Shapes are stacked in document order, so
7
+ // later shapes sit on top of earlier ones, and colors become shared
8
+ // <material>s. Transforms are baked into the coordinates; SVG's y axis points
9
+ // down and ES3D's up, so the drawing is flipped, centred and scaled to a
10
+ // chosen size.
11
+ //
12
+ // Supported: path, rect, circle, ellipse, line, polyline, polygon, g, svg, a,
13
+ // switch, use/symbol; transform attributes; presentation attributes, style=""
14
+ // and <style> sheets with simple selectors (tag, .class, #id, tag.class, *);
15
+ // fill, stroke, opacity, fill-opacity, stroke-opacity, fill-rule,
16
+ // stroke-width, display, visibility, color; named, hex, rgb() and hsl()
17
+ // colors. Gradients and patterns become their average color; text, images,
18
+ // filters, masks, clip paths, markers and dashes are skipped with a warning.
19
+
20
+ import { parseXml } from '../format/xml-parser.js';
21
+ import { ES3DError } from '../format/errors.js';
22
+ import { parsePathData } from '../path/path-parser.js';
23
+ import { arcToCenter } from '../path/path-flatten.js';
24
+ import { formatPathData, formatNumber } from '../format/value-format.js';
25
+ import { parseColor, colorToHex } from '../format/colors.js';
26
+ import { h, Document, Comment } from '../format/ast.js';
27
+ import { serialize } from '../format/serializer.js';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // 2D affine matrices [a, b, c, d, e, f]: x' = a·x + c·y + e, y' = b·x + d·y + f
31
+
32
+ const IDENTITY = [1, 0, 0, 1, 0, 0];
33
+
34
+ function mul(m, n) {
35
+ return [
36
+ m[0] * n[0] + m[2] * n[1],
37
+ m[1] * n[0] + m[3] * n[1],
38
+ m[0] * n[2] + m[2] * n[3],
39
+ m[1] * n[2] + m[3] * n[3],
40
+ m[0] * n[4] + m[2] * n[5] + m[4],
41
+ m[1] * n[4] + m[3] * n[5] + m[5],
42
+ ];
43
+ }
44
+
45
+ const apply = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
46
+ const det = (m) => m[0] * m[3] - m[1] * m[2];
47
+
48
+ /** Uniform scale + rotation (+ mirror): arcs can then be transformed exactly. */
49
+ function similarity(m) {
50
+ const [a, b, c, d] = m;
51
+ const dt = a * d - b * c;
52
+ if (!(Math.abs(dt) > 1e-300)) return null;
53
+ const s = Math.sqrt(Math.abs(dt));
54
+ const tol = 1e-9 * s;
55
+ const ok = dt > 0 ? Math.abs(a - d) <= tol && Math.abs(b + c) <= tol : Math.abs(a + d) <= tol && Math.abs(b - c) <= tol;
56
+ return ok ? { scale: s, angle: (Math.atan2(b, a) * 180) / Math.PI, flip: dt < 0 } : null;
57
+ }
58
+
59
+ const NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
60
+ const numbersIn = (s) => (String(s).match(NUMBER) || []).map(Number);
61
+
62
+ /** SVG transform list; null when the syntax is invalid. */
63
+ export function parseSvgTransform(str) {
64
+ let m = IDENTITY;
65
+ const re = /\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)\s*,?/gy;
66
+ let match;
67
+ let end = 0;
68
+ while ((match = re.exec(str))) {
69
+ end = re.lastIndex;
70
+ const v = numbersIn(match[2]);
71
+ const rad = (deg) => (deg * Math.PI) / 180;
72
+ let t;
73
+ switch (match[1]) {
74
+ case 'matrix':
75
+ if (v.length !== 6) return null;
76
+ t = v;
77
+ break;
78
+ case 'translate':
79
+ if (v.length < 1 || v.length > 2) return null;
80
+ t = [1, 0, 0, 1, v[0], v[1] || 0];
81
+ break;
82
+ case 'scale':
83
+ if (v.length < 1 || v.length > 2) return null;
84
+ t = [v[0], 0, 0, v.length > 1 ? v[1] : v[0], 0, 0];
85
+ break;
86
+ case 'rotate': {
87
+ if (v.length !== 1 && v.length !== 3) return null;
88
+ const c = Math.cos(rad(v[0])), s = Math.sin(rad(v[0]));
89
+ t = [c, s, -s, c, 0, 0];
90
+ if (v.length === 3) t = mul(mul([1, 0, 0, 1, v[1], v[2]], t), [1, 0, 0, 1, -v[1], -v[2]]);
91
+ break;
92
+ }
93
+ case 'skewX':
94
+ if (v.length !== 1) return null;
95
+ t = [1, 0, Math.tan(rad(v[0])), 1, 0, 0];
96
+ break;
97
+ case 'skewY':
98
+ if (v.length !== 1) return null;
99
+ t = [1, Math.tan(rad(v[0])), 0, 1, 0, 0];
100
+ break;
101
+ }
102
+ m = mul(m, t);
103
+ }
104
+ if (str.slice(end).trim() !== '') return null;
105
+ return m;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // path segments (the absolute form produced by parsePathData)
110
+
111
+ function arcToCubics(s) {
112
+ const arc = arcToCenter(s.x0, s.y0, s.rx, s.ry, s.rot, s.large, s.sweep, s.x, s.y);
113
+ if (!arc) return [{ c: 'L', x: s.x, y: s.y }];
114
+ const phi = (s.rot * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
115
+ const n = Math.max(1, Math.ceil(Math.abs(arc.dtheta) / (Math.PI / 2) - 1e-9));
116
+ const dt = arc.dtheta / n;
117
+ const k = (4 / 3) * Math.tan(dt / 4);
118
+ const deriv = (t) => [-arc.rx * cosP * Math.sin(t) - arc.ry * sinP * Math.cos(t), -arc.rx * sinP * Math.sin(t) + arc.ry * cosP * Math.cos(t)];
119
+ const out = [];
120
+ let t = arc.theta1;
121
+ let px = s.x0, py = s.y0;
122
+ for (let i = 0; i < n; i++) {
123
+ const t2 = t + dt;
124
+ const [qx, qy] = i === n - 1 ? [s.x, s.y] : arc.pointAt(t2);
125
+ const d1 = deriv(t), d2 = deriv(t2);
126
+ out.push({ c: 'C', x1: px + k * d1[0], y1: py + k * d1[1], x2: qx - k * d2[0], y2: qy - k * d2[1], x: qx, y: qy });
127
+ px = qx;
128
+ py = qy;
129
+ t = t2;
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** Apply an affine transform to segments; arcs stay arcs under similarity transforms, else become cubics. */
135
+ function transformSegments(segs, m) {
136
+ const sim = similarity(m);
137
+ const out = [];
138
+ for (const s of segs) {
139
+ switch (s.c) {
140
+ case 'M':
141
+ case 'L': {
142
+ const [x, y] = apply(m, s.x, s.y);
143
+ out.push({ c: s.c, x, y, implicit: s.implicit });
144
+ break;
145
+ }
146
+ case 'C': {
147
+ const [x1, y1] = apply(m, s.x1, s.y1), [x2, y2] = apply(m, s.x2, s.y2), [x, y] = apply(m, s.x, s.y);
148
+ out.push({ c: 'C', x1, y1, x2, y2, x, y });
149
+ break;
150
+ }
151
+ case 'Q': {
152
+ const [x1, y1] = apply(m, s.x1, s.y1), [x, y] = apply(m, s.x, s.y);
153
+ out.push({ c: 'Q', x1, y1, x, y });
154
+ break;
155
+ }
156
+ case 'A': {
157
+ if (sim) {
158
+ const [x0, y0] = apply(m, s.x0, s.y0), [x, y] = apply(m, s.x, s.y);
159
+ let rot = sim.flip ? sim.angle - s.rot : sim.angle + s.rot;
160
+ rot = ((rot % 360) + 360) % 360;
161
+ out.push({ c: 'A', rx: s.rx * sim.scale, ry: s.ry * sim.scale, rot, large: s.large, sweep: sim.flip ? 1 - s.sweep : s.sweep, x0, y0, x, y });
162
+ } else {
163
+ for (const cub of arcToCubics(s)) out.push(...transformSegments([cub], m));
164
+ }
165
+ break;
166
+ }
167
+ case 'Z':
168
+ out.push({ c: 'Z' });
169
+ break;
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /** Control points of the segments (arcs as cubics): a conservative outline for bounds and area. */
176
+ function controlPolygons(segs) {
177
+ const polys = [];
178
+ let cur = null;
179
+ for (const s of segs) {
180
+ if (s.c === 'M') {
181
+ cur = [s.x, s.y];
182
+ polys.push(cur);
183
+ } else if (s.c === 'Z') {
184
+ cur = null;
185
+ } else {
186
+ if (!cur) {
187
+ cur = [];
188
+ polys.push(cur);
189
+ }
190
+ const parts = s.c === 'A' ? arcToCubics(s) : [s];
191
+ for (const p of parts) {
192
+ if (p.c === 'C') cur.push(p.x1, p.y1, p.x2, p.y2, p.x, p.y);
193
+ else if (p.c === 'Q') cur.push(p.x1, p.y1, p.x, p.y);
194
+ else cur.push(p.x, p.y);
195
+ }
196
+ }
197
+ }
198
+ return polys;
199
+ }
200
+
201
+ function polygonArea(p) {
202
+ let a = 0;
203
+ for (let i = 0, n = p.length / 2; i < n; i++) {
204
+ const j = (i + 1) % n;
205
+ a += p[i * 2] * p[j * 2 + 1] - p[j * 2] * p[i * 2 + 1];
206
+ }
207
+ return a / 2;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // styles
212
+
213
+ const INHERITED = ['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray', 'visibility', 'color'];
214
+ const STYLE_PROPS = new Set([...INHERITED, 'opacity', 'display', 'transform']);
215
+ const DEFAULT_STYLE = { fill: 'black', 'fill-opacity': '1', 'fill-rule': 'nonzero', stroke: 'none', 'stroke-opacity': '1', 'stroke-width': '1', 'stroke-dasharray': 'none', visibility: 'visible', color: 'black' };
216
+
217
+ function parseDeclarations(text) {
218
+ const out = {};
219
+ for (const decl of String(text).split(';')) {
220
+ const i = decl.indexOf(':');
221
+ if (i < 0) continue;
222
+ const prop = decl.slice(0, i).trim().toLowerCase();
223
+ const value = decl.slice(i + 1).replace(/!important/i, '').trim();
224
+ if (prop && value) out[prop] = value;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ const SIMPLE_SELECTOR = /^(\*|[A-Za-z][\w-]*)?((?:[.#][\w-]+)*)$/;
230
+
231
+ /** CSS rules with simple selectors; anything more complex is counted as unsupported. */
232
+ export function parseCss(text) {
233
+ const rules = [];
234
+ let unsupported = 0;
235
+ const src = String(text).replace(/\/\*[\s\S]*?\*\//g, '').replace(/@[\w-]+[^{;]*;/g, '');
236
+ let i = 0;
237
+ while (i < src.length) {
238
+ const open = src.indexOf('{', i);
239
+ if (open < 0) break;
240
+ const prelude = src.slice(i, open).trim();
241
+ let depth = 1, j = open + 1;
242
+ while (j < src.length && depth) {
243
+ if (src[j] === '{') depth++;
244
+ else if (src[j] === '}') depth--;
245
+ j++;
246
+ }
247
+ const body = src.slice(open + 1, j - 1);
248
+ i = j;
249
+ if (prelude.startsWith('@')) {
250
+ // @media and friends: use the inner rules, whatever the query says
251
+ const inner = parseCss(body);
252
+ rules.push(...inner.rules);
253
+ unsupported += inner.unsupported;
254
+ continue;
255
+ }
256
+ const decls = parseDeclarations(body);
257
+ for (const raw of prelude.split(',')) {
258
+ const sel = raw.trim();
259
+ const m = SIMPLE_SELECTOR.exec(sel);
260
+ if (!sel || !m) {
261
+ if (sel) unsupported++;
262
+ continue;
263
+ }
264
+ const tag = m[1] && m[1] !== '*' ? m[1].toLowerCase() : null;
265
+ const classes = [], ids = [];
266
+ for (const part of m[2].match(/[.#][\w-]+/g) || []) (part[0] === '.' ? classes : ids).push(part.slice(1));
267
+ rules.push({ tag, classes, ids, specificity: ids.length * 100 + classes.length * 10 + (tag ? 1 : 0), decls });
268
+ }
269
+ }
270
+ rules.forEach((r, k) => (r.order = k));
271
+ return { rules, unsupported };
272
+ }
273
+
274
+ function hslToRgb(hue, s, l) {
275
+ const k = (n) => (n + hue / 30) % 12;
276
+ const a = s * Math.min(l, 1 - l);
277
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
278
+ return [f(0), f(8), f(4)];
279
+ }
280
+
281
+ /** sRGB color [r, g, b, a] or null. */
282
+ export function parseSvgColor(value) {
283
+ const v = String(value).trim();
284
+ const c = parseColor(v);
285
+ if (c) return c;
286
+ const m = /^hsla?\(\s*([^)]*)\)$/i.exec(v);
287
+ if (m) {
288
+ const p = m[1].split(/\s*[,/]\s*|\s+/).filter(Boolean);
289
+ if (p.length < 3) return null;
290
+ const hue = parseFloat(p[0]), s = parseFloat(p[1]) / 100, l = parseFloat(p[2]) / 100;
291
+ let a = p[3] === undefined ? 1 : p[3].endsWith('%') ? parseFloat(p[3]) / 100 : parseFloat(p[3]);
292
+ if (![hue, s, l, a].every(Number.isFinite)) return null;
293
+ a = Math.max(0, Math.min(1, a));
294
+ return [...hslToRgb(((hue % 360) + 360) % 360, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l))), a];
295
+ }
296
+ return null;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+
301
+ const UNITS = { px: 1, pt: 4 / 3, pc: 16, mm: 96 / 25.4, cm: 96 / 2.54, in: 96, em: 16, ex: 8, q: 96 / 101.6 };
302
+
303
+ /** A length in px; `ref` resolves percentages. */
304
+ function parseLength(value, ref = 0) {
305
+ if (value === undefined || value === null) return null;
306
+ const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)\s*$/.exec(String(value));
307
+ if (!m) return null;
308
+ const n = Number(m[1]);
309
+ const unit = m[2].toLowerCase();
310
+ if (!unit) return n;
311
+ if (unit === '%') return (n / 100) * ref;
312
+ return UNITS[unit] !== undefined ? n * UNITS[unit] : null;
313
+ }
314
+
315
+ const SKIPPED_ELEMENTS = {
316
+ text: 'text (convert text to paths in your drawing program first)',
317
+ image: 'embedded images',
318
+ foreignObject: 'foreign objects',
319
+ video: 'video',
320
+ audio: 'audio',
321
+ canvas: 'canvas',
322
+ iframe: 'iframes',
323
+ };
324
+ const CONTAINERS = new Set(['g', 'svg', 'a', 'switch']);
325
+ const NEVER_RENDERED = new Set(['defs', 'symbol', 'clipPath', 'mask', 'pattern', 'marker', 'linearGradient', 'radialGradient', 'filter', 'style', 'script', 'title', 'desc', 'metadata', 'font', 'font-face', 'cursor', 'view']);
326
+
327
+ const localName = (name) => {
328
+ const i = name.indexOf(':');
329
+ return i >= 0 ? name.slice(i + 1) : name;
330
+ };
331
+
332
+ /** Remove a DOCTYPE and expand the simple entities it declares.
333
+
334
+ SVG files from Illustrator often declare a handful of short entities
335
+ (`ns_svg`, a style string). Those are expanded once. Nested entity
336
+ references inside a value are not expanded: that is the billion-laughs
337
+ trick, and SVG spent years paying for it. A single huge value, or a
338
+ pile of them, is refused. The external DTD URL is never fetched. */
339
+ function preprocess(text, warn) {
340
+ let src = String(text).replace(/^\uFEFF/, '');
341
+ const entities = new Map();
342
+ let stopped = false;
343
+ src = src.replace(/<!DOCTYPE[^[>]*(\[[\s\S]*?\])?\s*>/i, (m, subset) => {
344
+ if (subset) {
345
+ for (const e of subset.matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
346
+ if (entities.size >= 32) {
347
+ warn('This SVG declares more than 32 entities. The extras were ignored.');
348
+ break;
349
+ }
350
+ const value = e[2] ?? e[3];
351
+ if (value.length > 4096) {
352
+ warn(`Entity &${e[1]}; is too long to expand, so it was left out.`);
353
+ continue;
354
+ }
355
+ entities.set(e[1], value);
356
+ }
357
+ }
358
+ return '';
359
+ });
360
+ const XML = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
361
+ const unknown = new Set();
362
+ let expanded = 0;
363
+ src = src.replace(/&([A-Za-z_][\w.-]*);/g, (m, name) => {
364
+ if (XML.has(name)) return m;
365
+ if (entities.has(name)) {
366
+ if (stopped) return '';
367
+ const value = entities.get(name);
368
+ expanded += value.length;
369
+ if (expanded > 200_000) {
370
+ stopped = true;
371
+ warn('Entity expansion was stopped because it grew past 200 kB.');
372
+ return '';
373
+ }
374
+ // Escape "&" that is not already a predefined or numeric reference,
375
+ // so "&b;" inside an entity value stays text and is not expanded again.
376
+ return value.replace(/&(?!(lt|gt|amp|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
377
+ }
378
+ if (name === 'nbsp') return '&#160;';
379
+ unknown.add(name);
380
+ return '';
381
+ });
382
+ if (unknown.size) warn(`Unknown entities were removed: ${[...unknown].map((n) => `&${n};`).join(' ')}.`);
383
+ return src;
384
+ }
385
+
386
+ /**
387
+ * Convert an SVG document to ES3D.
388
+ * @param {string} text SVG source
389
+ * @param {{name?: string, size?: number, depth?: number, bevel?: number, strokes?: boolean, base?: boolean, precision?: number}} [opts]
390
+ * size: the longer side of the drawing in ES3D units; depth: extrusion depth as a fraction of size
391
+ * (0 = flat shapes); bevel: rounded edge radius as a fraction of size; strokes: convert strokes to
392
+ * tubes; base: add a backing plate the size of the viewBox; precision: decimals of the path data
393
+ * @returns {{text: string, warnings: string[], stats: {shapes: number, fills: number, strokes: number, materials: number, skipped: number}}}
394
+ */
395
+ export function svgToES3D(text, opts = {}) {
396
+ const { name = 'drawing.svg', size = 10, depth: depthRel = 0.04, bevel: bevelRel = 0, strokes = true, base = false } = opts;
397
+ const warnings = [];
398
+ const warned = new Map();
399
+ const warn = (msg) => warnings.push(msg);
400
+ const warnOnce = (key, msg) => {
401
+ warned.set(key, (warned.get(key) || 0) + 1);
402
+ if (warned.get(key) === 1) warnings.push(msg);
403
+ };
404
+
405
+ let doc;
406
+ try {
407
+ doc = parseXml(preprocess(text, warn));
408
+ } catch (e) {
409
+ if (e instanceof ES3DError) {
410
+ const d = e.diagnostics[0];
411
+ throw new Error(`The SVG file is not well-formed XML (line ${d.line}, column ${d.column}): ${d.message}`);
412
+ }
413
+ throw e;
414
+ }
415
+ const svg = doc.root;
416
+ if (localName(svg.name) !== 'svg') throw new Error(`Not an SVG file: the root element is <${svg.name}>, expected <svg>.`);
417
+
418
+ // index ids and collect style sheets
419
+ const byId = new Map();
420
+ const sheets = [];
421
+ const index = (el) => {
422
+ const id = el.attr('id');
423
+ if (id && !byId.has(id)) byId.set(id, el);
424
+ if (localName(el.name) === 'style' && el.text) sheets.push(el.text);
425
+ for (const c of el.children) if (c.type === 'element') index(c);
426
+ };
427
+ index(svg);
428
+ const css = parseCss(sheets.join('\n'));
429
+ if (css.unsupported) warn(`${css.unsupported} CSS selector${css.unsupported > 1 ? 's' : ''} more complex than tag, .class or #id ${css.unsupported > 1 ? 'were' : 'was'} ignored.`);
430
+
431
+ // viewport: the viewBox, else width/height, else the drawing's own bounds
432
+ const vb = numbersIn(svg.attr('viewBox') || '');
433
+ const rootW = parseLength(svg.attr('width')), rootH = parseLength(svg.attr('height'));
434
+ let frame = null;
435
+ if (vb.length === 4 && vb[2] > 0 && vb[3] > 0) frame = { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
436
+ else if (rootW > 0 && rootH > 0) frame = { x: 0, y: 0, w: rootW, h: rootH };
437
+ const refW = frame ? frame.w : 100, refH = frame ? frame.h : 100;
438
+ const refD = Math.hypot(refW, refH) / Math.SQRT2;
439
+
440
+ const titleEl = svg.children.find((c) => c.type === 'element' && localName(c.name) === 'title');
441
+ const title = (titleEl && titleEl.text && titleEl.text.trim()) || name.replace(/\.svgz?$/i, '');
442
+
443
+ // computed style of an element given its parent's
444
+ const computeStyle = (el, parent) => {
445
+ const tag = localName(el.name);
446
+ const st = {};
447
+ for (const k of INHERITED) st[k] = parent[k];
448
+ st.opacity = parent.opacity; // group opacity multiplies down the tree (approximation)
449
+ st.display = 'inline';
450
+ const own = {};
451
+ for (const a of el.attrs) {
452
+ const k = localName(a.name);
453
+ if (STYLE_PROPS.has(k) && k !== 'transform') own[k] = a.value;
454
+ }
455
+ const classes = (el.attr('class') || '').split(/\s+/).filter(Boolean);
456
+ const id = el.attr('id');
457
+ const matched = css.rules.filter((r) => (!r.tag || r.tag === tag) && r.classes.every((c) => classes.includes(c)) && r.ids.every((i) => i === id));
458
+ matched.sort((a, b) => a.specificity - b.specificity || a.order - b.order);
459
+ for (const r of matched) Object.assign(own, r.decls);
460
+ if (el.attr('style')) Object.assign(own, parseDeclarations(el.attr('style')));
461
+ for (const [k, v] of Object.entries(own)) {
462
+ if (v === 'inherit') continue;
463
+ if (k === 'opacity') st.opacity = parent.opacity * Math.max(0, Math.min(1, parseFloat(v)));
464
+ else if (k in st || INHERITED.includes(k)) st[k] = v;
465
+ }
466
+ if (own.display) st.display = own.display;
467
+ return st;
468
+ };
469
+
470
+ /** A paint value → {color, opacity} or null (no paint). */
471
+ const resolvePaint = (value, st) => {
472
+ let v = String(value).trim();
473
+ if (v === 'none' || v === 'transparent') return null;
474
+ if (/^currentcolor$/i.test(v)) v = st.color;
475
+ const url = /^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)\s*(.*)$/.exec(v);
476
+ if (url) {
477
+ const ref = byId.get(url[1]);
478
+ const kind = ref && localName(ref.name);
479
+ if (kind === 'linearGradient' || kind === 'radialGradient') {
480
+ const stops = gradientStops(ref);
481
+ if (stops.length) {
482
+ warnOnce('gradient', 'Gradients were replaced by their average color.');
483
+ const n = stops.length;
484
+ const avg = stops.reduce((s, c) => s.map((x, k) => x + c[k] / n), [0, 0, 0, 0]);
485
+ return { color: avg.slice(0, 3), opacity: avg[3] };
486
+ }
487
+ return null;
488
+ }
489
+ if (url[2]) return resolvePaint(url[2], st);
490
+ warnOnce('pattern', 'Patterns and other unsupported paint servers were replaced by gray.');
491
+ return { color: [0.5, 0.5, 0.5], opacity: 1 };
492
+ }
493
+ const c = parseSvgColor(v);
494
+ if (!c) {
495
+ warnOnce(`color:${v}`, `Unrecognized color "${v}" was treated as none.`);
496
+ return null;
497
+ }
498
+ return { color: c.slice(0, 3), opacity: c[3] };
499
+ };
500
+
501
+ const gradientStops = (el, seen = new Set()) => {
502
+ if (seen.has(el)) return [];
503
+ seen.add(el);
504
+ const stops = [];
505
+ for (const c of el.children) {
506
+ if (c.type !== 'element' || localName(c.name) !== 'stop') continue;
507
+ const decl = { ...Object.fromEntries(c.attrs.map((a) => [localName(a.name), a.value])), ...parseDeclarations(c.attr('style') || '') };
508
+ const col = parseSvgColor(decl['stop-color'] || 'black');
509
+ if (!col) continue;
510
+ const op = decl['stop-opacity'] !== undefined ? parseFloat(decl['stop-opacity']) : 1;
511
+ stops.push([col[0], col[1], col[2], col[3] * (Number.isFinite(op) ? op : 1)]);
512
+ }
513
+ if (stops.length) return stops;
514
+ const href = el.attr('href') || el.attr('xlink:href');
515
+ const ref = href && href.startsWith('#') ? byId.get(href.slice(1)) : null;
516
+ return ref ? gradientStops(ref, seen) : [];
517
+ };
518
+
519
+ /** Outline of a basic shape as absolute segments, or null. */
520
+ const shapeSegments = (el, tag) => {
521
+ const num = (k, ref = refD) => {
522
+ const v = parseLength(el.attr(k), ref);
523
+ return v === null ? 0 : v;
524
+ };
525
+ switch (tag) {
526
+ case 'path': {
527
+ const d = el.attr('d');
528
+ if (!d || !d.trim()) return null;
529
+ try {
530
+ return parsePathData(d).segments;
531
+ } catch (e) {
532
+ warnOnce('pathdata', `A path with invalid path data was skipped (${e.message}).`);
533
+ return null;
534
+ }
535
+ }
536
+ case 'rect': {
537
+ const x = num('x', refW), y = num('y', refH), w = num('width', refW), hh = num('height', refH);
538
+ if (!(w > 0 && hh > 0)) return null;
539
+ let rx = parseLength(el.attr('rx'), refW), ry = parseLength(el.attr('ry'), refH);
540
+ if (rx === null || rx < 0) rx = ry !== null && ry > 0 ? ry : 0;
541
+ if (ry === null || ry < 0) ry = rx;
542
+ rx = Math.min(rx, w / 2);
543
+ ry = Math.min(ry, hh / 2);
544
+ if (!(rx > 0 && ry > 0)) return [{ c: 'M', x, y }, { c: 'L', x: x + w, y }, { c: 'L', x: x + w, y: y + hh }, { c: 'L', x, y: y + hh }, { c: 'Z' }];
545
+ const arc = (x0, y0, x1, y1) => ({ c: 'A', rx, ry, rot: 0, large: 0, sweep: 1, x0, y0, x: x1, y: y1 });
546
+ return [
547
+ { c: 'M', x: x + rx, y },
548
+ { c: 'L', x: x + w - rx, y },
549
+ arc(x + w - rx, y, x + w, y + ry),
550
+ { c: 'L', x: x + w, y: y + hh - ry },
551
+ arc(x + w, y + hh - ry, x + w - rx, y + hh),
552
+ { c: 'L', x: x + rx, y: y + hh },
553
+ arc(x + rx, y + hh, x, y + hh - ry),
554
+ { c: 'L', x, y: y + ry },
555
+ arc(x, y + ry, x + rx, y),
556
+ { c: 'Z' },
557
+ ];
558
+ }
559
+ case 'circle':
560
+ case 'ellipse': {
561
+ const cx = num('cx', refW), cy = num('cy', refH);
562
+ const rx = tag === 'circle' ? num('r') : num('rx', refW);
563
+ const ry = tag === 'circle' ? rx : num('ry', refH);
564
+ if (!(rx > 0 && ry > 0)) return null;
565
+ return [
566
+ { c: 'M', x: cx + rx, y: cy },
567
+ { c: 'A', rx, ry, rot: 0, large: 1, sweep: 1, x0: cx + rx, y0: cy, x: cx - rx, y: cy },
568
+ { c: 'A', rx, ry, rot: 0, large: 1, sweep: 1, x0: cx - rx, y0: cy, x: cx + rx, y: cy },
569
+ { c: 'Z' },
570
+ ];
571
+ }
572
+ case 'line':
573
+ return [{ c: 'M', x: num('x1', refW), y: num('y1', refH) }, { c: 'L', x: num('x2', refW), y: num('y2', refH) }];
574
+ case 'polyline':
575
+ case 'polygon': {
576
+ const v = numbersIn(el.attr('points') || '');
577
+ if (v.length % 2) warnOnce('points', 'A points list with an odd number of coordinates was truncated.');
578
+ if (v.length < 4) return null;
579
+ const segs = [{ c: 'M', x: v[0], y: v[1] }];
580
+ for (let i = 2; i + 1 < v.length; i += 2) segs.push({ c: 'L', x: v[i], y: v[i + 1] });
581
+ if (tag === 'polygon') segs.push({ c: 'Z' });
582
+ return segs;
583
+ }
584
+ default:
585
+ return null;
586
+ }
587
+ };
588
+
589
+ // ---- walk the tree
590
+ const shapes = [];
591
+ let skipped = 0;
592
+ const rootStyle = { ...DEFAULT_STYLE, opacity: 1 };
593
+ const walk = (el, ctm, parentStyle, depth, useStack) => {
594
+ const tag = localName(el.name);
595
+ if (NEVER_RENDERED.has(tag)) return;
596
+ if (SKIPPED_ELEMENTS[tag]) {
597
+ skipped++;
598
+ warnOnce(`skip:${tag}`, `Skipped ${SKIPPED_ELEMENTS[tag]}.`);
599
+ return;
600
+ }
601
+ const st = computeStyle(el, parentStyle);
602
+ if (st.display === 'none') return;
603
+ let m = ctm;
604
+ const tAttr = el.attr('transform');
605
+ if (tAttr) {
606
+ const t = parseSvgTransform(tAttr);
607
+ if (t) m = mul(m, t);
608
+ else warnOnce('transform', `An invalid transform "${tAttr}" was ignored.`);
609
+ }
610
+ for (const k of ['clip-path', 'mask', 'filter']) {
611
+ if (el.attr(k) || (el.attr('style') || '').includes(`${k}:`)) warnOnce(k, `${k === 'filter' ? 'Filters' : k === 'mask' ? 'Masks' : 'Clip paths'} were ignored.`);
612
+ }
613
+ if (tag === 'use') {
614
+ const href = el.attr('href') || el.attr('xlink:href') || '';
615
+ const ref = href.startsWith('#') ? byId.get(href.slice(1)) : null;
616
+ if (!ref) {
617
+ warnOnce('use', `A <use> pointing to "${href}" was skipped (only references within the file are supported).`);
618
+ return;
619
+ }
620
+ if (useStack.includes(ref) || useStack.length > 16) {
621
+ warnOnce('usecycle', 'A <use> that refers to itself was skipped.');
622
+ return;
623
+ }
624
+ m = mul(m, [1, 0, 0, 1, parseLength(el.attr('x'), refW) || 0, parseLength(el.attr('y'), refH) || 0]);
625
+ if (localName(ref.name) === 'symbol') {
626
+ let sm = m;
627
+ const svb = numbersIn(ref.attr('viewBox') || '');
628
+ const uw = parseLength(el.attr('width'), refW), uh = parseLength(el.attr('height'), refH);
629
+ if (svb.length === 4 && svb[2] > 0 && svb[3] > 0 && uw > 0 && uh > 0) {
630
+ const k = Math.min(uw / svb[2], uh / svb[3]); // xMidYMid meet
631
+ sm = mul(m, [k, 0, 0, k, (uw - svb[2] * k) / 2 - svb[0] * k, (uh - svb[3] * k) / 2 - svb[1] * k]);
632
+ }
633
+ const symStyle = computeStyle(ref, st);
634
+ for (const c of ref.children) if (c.type === 'element') walk(c, sm, symStyle, depth + 1, [...useStack, ref]);
635
+ } else {
636
+ walk(ref, m, st, depth + 1, [...useStack, ref]);
637
+ }
638
+ return;
639
+ }
640
+ if (CONTAINERS.has(tag)) {
641
+ if (tag === 'svg' && el !== svg) m = mul(m, [1, 0, 0, 1, parseLength(el.attr('x'), refW) || 0, parseLength(el.attr('y'), refH) || 0]);
642
+ if (tag === 'switch') {
643
+ const first = el.children.find((c) => c.type === 'element');
644
+ if (first) walk(first, m, st, depth + 1, useStack);
645
+ return;
646
+ }
647
+ for (const c of el.children) if (c.type === 'element') walk(c, m, st, depth + 1, useStack);
648
+ return;
649
+ }
650
+ const segs = shapeSegments(el, tag);
651
+ if (!segs) {
652
+ if (!['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'].includes(tag)) warnOnce(`unknown:${tag}`, `Unknown element <${el.name}> was skipped.`);
653
+ return;
654
+ }
655
+ if (st.visibility === 'hidden' || st.visibility === 'collapse') return;
656
+ const op = st.opacity;
657
+ const fill = resolvePaint(st.fill, st);
658
+ const stroke = resolvePaint(st.stroke, st);
659
+ const fillOpacity = parseFloat(st['fill-opacity']);
660
+ const strokeOpacity = parseFloat(st['stroke-opacity']);
661
+ const strokeWidth = parseLength(st['stroke-width'], refD);
662
+ if (st['stroke-dasharray'] && st['stroke-dasharray'] !== 'none' && stroke) warnOnce('dash', 'Dashed strokes were drawn solid.');
663
+ if (tag === 'line' || tag === 'polyline') {
664
+ // never filled with an area in practice; polyline fills are rare
665
+ }
666
+ shapes.push({
667
+ segs,
668
+ ctm: m,
669
+ id: el.attr('id') || null,
670
+ fill: fill && tag !== 'line' ? { ...fill, opacity: fill.opacity * (Number.isFinite(fillOpacity) ? fillOpacity : 1) * op } : null,
671
+ stroke: strokes && stroke && strokeWidth > 0 ? { ...stroke, opacity: stroke.opacity * (Number.isFinite(strokeOpacity) ? strokeOpacity : 1) * op, width: strokeWidth } : null,
672
+ fillRule: st['fill-rule'] === 'evenodd' ? 'evenodd' : 'nonzero',
673
+ });
674
+ };
675
+ walk(svg, IDENTITY, rootStyle, 0, []);
676
+ if (!strokes && shapes.some((s) => !s.fill)) warn('Strokes were not converted (the strokes option is off).');
677
+
678
+ // ---- bounds and normalization
679
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
680
+ for (const s of shapes) {
681
+ const pad = s.stroke ? (s.stroke.width / 2) * Math.sqrt(Math.abs(det(s.ctm))) : 0;
682
+ for (const poly of controlPolygons(transformSegments(s.segs, s.ctm))) {
683
+ for (let i = 0; i < poly.length; i += 2) {
684
+ minX = Math.min(minX, poly[i] - pad);
685
+ maxX = Math.max(maxX, poly[i] + pad);
686
+ minY = Math.min(minY, poly[i + 1] - pad);
687
+ maxY = Math.max(maxY, poly[i + 1] + pad);
688
+ }
689
+ }
690
+ }
691
+ const box = frame || (minX <= maxX ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : { x: 0, y: 0, w: 1, h: 1 });
692
+ const longest = Math.max(box.w, box.h) || 1;
693
+ const k = size / longest;
694
+ const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
695
+ const N = [k, 0, 0, -k, -cx * k, cy * k]; // centre, scale, flip y
696
+ const decimals = opts.precision ?? Math.max(0, Math.min(8, Math.ceil(-Math.log10(size * 1e-4))));
697
+ const f = (v) => formatNumber(v, decimals);
698
+ const depth = Math.max(0, depthRel * size);
699
+ const bevel = depth > 0 ? Math.min(Math.max(0, bevelRel * size), depth / 2) : 0;
700
+
701
+ // ---- materials
702
+ const materials = new Map();
703
+ const materialFor = (paint) => {
704
+ const hex = colorToHex([paint.color[0], paint.color[1], paint.color[2], 1]);
705
+ const opacity = Math.round(Math.max(0, Math.min(1, paint.opacity)) * 100) / 100;
706
+ const key = `${hex}:${opacity}`;
707
+ let m = materials.get(key);
708
+ if (!m) {
709
+ const id = `c${hex.slice(1)}${opacity < 1 ? `-${Math.round(opacity * 100)}` : ''}`;
710
+ const attrs = { id, color: hex, roughness: '0.55' };
711
+ if (opacity < 1) attrs.opacity = formatNumber(opacity, 2);
712
+ m = { id, el: h('material', attrs) };
713
+ materials.set(key, m);
714
+ }
715
+ return m.id;
716
+ };
717
+
718
+ // ---- output
719
+ const out = [];
720
+ let fills = 0, tubes = 0, zeroArea = 0;
721
+ const visible = shapes.filter((s) => (s.fill && s.fill.opacity > 0) || (s.stroke && s.stroke.opacity > 0));
722
+ const step = depth > 0 ? Math.min(depth * 0.1, (depth * 2) / Math.max(1, visible.length)) : size * Math.min(0.002, 0.2 / Math.max(1, visible.length));
723
+ visible.forEach((s, i) => {
724
+ const m = mul(N, s.ctm);
725
+ const segs = transformSegments(s.segs, m);
726
+ const d = formatPathData(segs, { precision: decimals, minify: true });
727
+ const z = i * step;
728
+ const label = s.id ? { name: s.id } : {};
729
+ if (s.fill && s.fill.opacity > 0) {
730
+ const area = controlPolygons(segs).reduce((a, p) => a + Math.abs(polygonArea(p)), 0);
731
+ if (area > (size * 1e-6) ** 2) {
732
+ const attrs = { ...label, d };
733
+ if (s.fillRule === 'evenodd') attrs.fillRule = 'evenodd';
734
+ if (depth > 0) {
735
+ attrs.depth = f(depth);
736
+ if (bevel > 0) attrs.bevel = f(bevel);
737
+ }
738
+ if (z) attrs.position = `0,0,${f(z)}`;
739
+ attrs.material = materialFor(s.fill);
740
+ out.push(h(depth > 0 ? 'extrude' : 'path', attrs));
741
+ fills++;
742
+ } else zeroArea++;
743
+ }
744
+ if (s.stroke && s.stroke.opacity > 0) {
745
+ const r = (s.stroke.width / 2) * Math.sqrt(Math.abs(det(s.ctm))) * k;
746
+ if (r > 0) {
747
+ const attrs = { ...(s.fill ? {} : label), d, radius: formatNumber(r, decimals + 1) };
748
+ attrs.position = `0,0,${f(z + depth)}`;
749
+ attrs.material = materialFor(s.stroke);
750
+ out.push(h('polyline', attrs));
751
+ tubes++;
752
+ }
753
+ }
754
+ });
755
+ if (zeroArea) warn(`${zeroArea} filled shape${zeroArea > 1 ? 's have' : ' has'} no area and ${zeroArea > 1 ? 'were' : 'was'} skipped.`);
756
+ if (!out.length) warn('The drawing contains no shapes that could be converted.');
757
+
758
+ // ---- document
759
+ // a camera slightly above and to the right, far enough to frame the drawing
760
+ // in a 3:2 view (35° vertical field of view)
761
+ const tanHalf = Math.tan((17.5 * Math.PI) / 180);
762
+ const dist = Math.max((box.h * k) / 2 / tanHalf, (box.w * k) / 2 / (tanHalf * 1.5)) * 1.1 + depth;
763
+ const dir = [0.17, 0.13, 1];
764
+ const dl = Math.hypot(...dir);
765
+ const root = h(
766
+ 'es3d',
767
+ { version: '1' },
768
+ h('title', {}, title),
769
+ h('environment', { background: '#dfe3e8', sky: '#ffffff', ground: '#8a8f99', intensity: '0.65' }),
770
+ h('camera', { position: `${f((dir[0] / dl) * dist)},${f((dir[1] / dl) * dist)},${f((dir[2] / dl) * dist + depth / 2)}`, target: `0,0,${f(depth / 2)}`, fov: '35' }),
771
+ h('light', { type: 'directional', direction: '-0.35,-0.45,-1', intensity: '1.3' }),
772
+ );
773
+ const defs = h('defs');
774
+ if (base) defs.appendChild(h('material', { id: 'plate', color: '#f4f4f4', roughness: '0.8' }));
775
+ for (const m of materials.values()) defs.appendChild(m.el);
776
+ if (defs.children.length) root.appendChild(defs);
777
+ if (base) {
778
+ const bd = Math.max(depth * 0.5, size * 0.01);
779
+ root.appendChild(h('box', { size: `${f(box.w * k)},${f(box.h * k)},${f(bd)}`, position: `0,0,${f(-bd / 2)}`, material: 'plate' }));
780
+ }
781
+ const group = h('group', { name: title });
782
+ for (const el of out) group.appendChild(el);
783
+ root.appendChild(group);
784
+ const comment = new Comment(` Converted from ${name.replace(/--/g, '- -')} by the ES3D converter: ${fills} filled shape${fills === 1 ? '' : 's'}, ${tubes} stroke${tubes === 1 ? '' : 's'}. `);
785
+ const es3d = serialize(new Document({ root, syntax: 'xml', leading: [comment] }), { pretty: true, comments: true });
786
+ return { text: es3d, warnings, stats: { shapes: shapes.length, fills, strokes: tubes, materials: materials.size, skipped } };
787
+ }