@es3d/es3d 1.0.0 → 1.0.1
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/LICENSE +201 -201
- package/README.md +86 -86
- package/docs/SPEC.md +417 -417
- package/package.json +1 -1
- package/scripts/es3d-cli.mjs +377 -377
- package/src/cache/geometry-store.js +392 -392
- package/src/cache/mesh-bundle.js +123 -123
- package/src/cache/sha256.js +73 -73
- package/src/format/ast.js +238 -238
- package/src/format/colors.js +93 -93
- package/src/format/compact-parser.js +171 -171
- package/src/format/compact-tokenizer.js +165 -165
- package/src/format/errors.js +247 -247
- package/src/format/json.js +55 -55
- package/src/format/limits.js +14 -14
- package/src/format/parse.js +92 -92
- package/src/format/schema.js +617 -617
- package/src/format/serializer.js +333 -333
- package/src/format/validator.js +330 -330
- package/src/format/value-error.js +12 -12
- package/src/format/value-format.js +314 -314
- package/src/format/values.js +459 -459
- package/src/format/xml-parser.js +136 -136
- package/src/format/xml-tokenizer.js +237 -237
- package/src/geometry/contour.js +86 -86
- package/src/geometry/extrude.js +175 -175
- package/src/geometry/gear.js +85 -85
- package/src/geometry/geometry-cache.js +159 -159
- package/src/geometry/lod-error.js +178 -178
- package/src/geometry/mesh.js +458 -458
- package/src/geometry/primitives.js +299 -299
- package/src/geometry/registry.js +645 -645
- package/src/geometry/revolve.js +136 -136
- package/src/geometry/sweep.js +441 -441
- package/src/index.js +47 -47
- package/src/io/compression.js +67 -67
- package/src/io/convert.js +236 -236
- package/src/io/download.js +13 -13
- package/src/io/external-scene.js +127 -127
- package/src/io/file-loader.js +28 -28
- package/src/io/gltf.js +630 -630
- package/src/io/mesh-import.js +104 -104
- package/src/io/obj.js +352 -352
- package/src/io/step-brep.js +439 -439
- package/src/io/step-exact.js +1149 -1149
- package/src/io/step.js +1418 -1418
- package/src/io/stl.js +193 -193
- package/src/io/svg-import.js +787 -787
- package/src/material/materials.js +154 -154
- package/src/math/mat4.js +475 -475
- package/src/math/random.js +30 -30
- package/src/math/trig.js +65 -65
- package/src/math/vec3.js +81 -81
- package/src/optimize/optimizer.js +579 -579
- package/src/path/path-flatten.js +322 -322
- package/src/path/path-parser.js +332 -332
- package/src/path/shapes.js +129 -129
- package/src/path/triangulate.js +517 -517
- package/src/pipeline.js +74 -74
- package/src/render/camera.js +168 -168
- package/src/render/frame-builder.js +362 -362
- package/src/render/geometry-pool.js +149 -149
- package/src/render/lighting.js +149 -149
- package/src/render/orbit-controls.js +237 -237
- package/src/render/renderer.js +258 -249
- package/src/render/scroll-timeline.js +104 -104
- package/src/render/shaders/glsl.js +285 -285
- package/src/render/shaders/wgsl.js +252 -252
- package/src/render/stage.js +364 -352
- package/src/render/texture-cache.js +40 -40
- package/src/render/textures.js +223 -223
- package/src/render/webgl2/webgl2-backend.js +586 -532
- package/src/render/webgpu/webgpu-backend.js +482 -480
- package/src/scene/flatten.js +754 -754
- package/src/scene/picking.js +161 -161
- package/src/scene/scene-graph.js +320 -320
- package/src/transform/transform.js +211 -211
- package/src/wasm/kernels-binary.js +3 -3
- package/src/wasm/kernels.js +385 -385
package/src/io/svg-import.js
CHANGED
|
@@ -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, '&');
|
|
377
|
-
}
|
|
378
|
-
if (name === 'nbsp') return ' ';
|
|
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, '&');
|
|
377
|
+
}
|
|
378
|
+
if (name === 'nbsp') return ' ';
|
|
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
|
+
}
|