@volter/editor-blender 0.1.0

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 (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * TRACING BLENDER'S OWN ICON SOURCES — the half of `blender-icons.source.mjs`
3
+ * that turns one of Blender's per-icon SVGs into a path on this set's 16-unit
4
+ * grid (WORK.md §Blender in the tab is Blender, "Inspection parity", I2
5
+ * decision 2).
6
+ *
7
+ * ## Why a trace and not a drawing
8
+ *
9
+ * The Properties rail's sixteen tabs used to be OUR marks in Blender's idiom,
10
+ * and I1 measured what that cost: thirteen of the sixteen were a distinct NAME
11
+ * over a neighbouring silhouette this set already drew (a globe for World, a
12
+ * magnet for Constraints, a palette for Material), not a measurement of
13
+ * Blender's mark. The north star is "the reference is Blender's SOURCE as well
14
+ * as its frames" (ARCHITECTURE-CORE §Blender north star), and for an icon the
15
+ * source is literally a vector file: `release/datafiles/icons_svg/<name>.svg`
16
+ * in the Blender checkout at the engine's pin. Tracing it is a MEASUREMENT;
17
+ * drawing a lookalike is a guess with a citation stapled to it.
18
+ *
19
+ * ## Licence
20
+ *
21
+ * Blender's icon sources are GPL-2.0-or-later, like the rest of the Blender
22
+ * tree, and a trace of one is a derivative of it. `@vgai/blender` therefore
23
+ * carries BOTH licences — its SPDX expression is
24
+ * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/blender/LICENSE`): our code
25
+ * AGPL-3.0-only, Blender's traced artwork conveyed under GPL-3.0-or-later on
26
+ * Blender's own "or later", and AGPL §13 / GPL §13 permitting the
27
+ * combination. So a GPL icon PATH may live inside this package, and that is
28
+ * deliberate, stated in the package's LICENSE and recorded per glyph in
29
+ * `blender.icons.traced.json` (source file + sha256). Nothing traced here may
30
+ * be copied into an Apache-2.0 or MIT part of this repo — `packages/editor`'s
31
+ * own icon set included.
32
+ *
33
+ * ## The geometry
34
+ *
35
+ * Blender authors each icon on a 16-unit grid and Inkscape writes it out
36
+ * inside a 1600x1600 viewBox with a `matrix(100 0 0 100 tx ty)` on the path —
37
+ * so the trace is: compose the element's transforms, scale by
38
+ * `16 / viewBox width`, and the result is already this set's coordinate
39
+ * system. Nothing is re-proportioned, re-stroked or re-centred; a traced
40
+ * glyph is Blender's own outline.
41
+ *
42
+ * Not every icon is that simple, which is why this is a general affine
43
+ * transform rather than a scale-and-offset: `material.svg` carries a
44
+ * REFLECTION (`matrix(-99.99 0 0 99.99 …)`), `particles.svg` a quarter
45
+ * ROTATION (`matrix(0 100 -100 0 …)`) with a second matrix on the path inside
46
+ * it, and `pointcloud_data.svg` a NON-UNIFORM scale. Under any of those an
47
+ * elliptical arc's radii and x-axis rotation change, and a reflection flips
48
+ * its sweep — {@link transformArc} does that properly instead of assuming the
49
+ * matrix is a scale.
50
+ */
51
+
52
+ // ------------------------------------------------------------------ matrices
53
+
54
+ /** An SVG affine, `[a, b, c, d, e, f]` — x' = a·x + c·y + e, y' = b·x + d·y + f. */
55
+ const IDENTITY = [1, 0, 0, 1, 0, 0];
56
+
57
+ function multiply(m, n) {
58
+ return [
59
+ m[0] * n[0] + m[2] * n[1],
60
+ m[1] * n[0] + m[3] * n[1],
61
+ m[0] * n[2] + m[2] * n[3],
62
+ m[1] * n[2] + m[3] * n[3],
63
+ m[0] * n[4] + m[2] * n[5] + m[4],
64
+ m[1] * n[4] + m[3] * n[5] + m[5],
65
+ ];
66
+ }
67
+
68
+ /** A POINT moves by the whole matrix. There is deliberately no delta helper:
69
+ * every relative command is absolutized before it is transformed, which is
70
+ * the only way `S`/`T`'s reflected control points survive an affine. */
71
+ const applyPoint = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
72
+
73
+ /** `transform="matrix(…) translate(…) scale(…) rotate(…)"`, left to right. */
74
+ function parseTransform(text) {
75
+ if (!text) return IDENTITY;
76
+ let matrix = IDENTITY;
77
+ const each = /(matrix|translate|scale|rotate)\s*\(([^)]*)\)/g;
78
+ for (let hit = each.exec(text); hit !== null; hit = each.exec(text)) {
79
+ const n = hit[2]
80
+ .trim()
81
+ .split(/[\s,]+/)
82
+ .map(Number);
83
+ if (hit[1] === 'matrix') matrix = multiply(matrix, [n[0], n[1], n[2], n[3], n[4], n[5]]);
84
+ else if (hit[1] === 'translate') matrix = multiply(matrix, [1, 0, 0, 1, n[0], n[1] ?? 0]);
85
+ else if (hit[1] === 'scale') matrix = multiply(matrix, [n[0], 0, 0, n[1] ?? n[0], 0, 0]);
86
+ else {
87
+ const t = (n[0] * Math.PI) / 180;
88
+ const rotate = [Math.cos(t), Math.sin(t), -Math.sin(t), Math.cos(t), 0, 0];
89
+ const [cx, cy] = [n[1] ?? 0, n[2] ?? 0];
90
+ matrix = multiply(multiply(multiply(matrix, [1, 0, 0, 1, cx, cy]), rotate), [
91
+ 1,
92
+ 0,
93
+ 0,
94
+ 1,
95
+ -cx,
96
+ -cy,
97
+ ]);
98
+ }
99
+ }
100
+ return matrix;
101
+ }
102
+
103
+ /**
104
+ * AN ARC UNDER AN AFFINE. The ellipse an arc segment lies on is
105
+ * `R(φ)·diag(rx, ry)` applied to the unit circle; pushing it through `m`
106
+ * gives `m₂·R(φ)·diag(rx, ry)`, and the new `(rx', ry', φ')` are that 2x2
107
+ * matrix's singular-value decomposition. The sweep flag flips when the
108
+ * determinant is negative (a reflection reverses which way is "clockwise");
109
+ * the large-arc flag is invariant.
110
+ */
111
+ function transformArc(m, rx, ry, rotation, largeArc, sweep) {
112
+ const t = (rotation * Math.PI) / 180;
113
+ const e = [Math.cos(t) * rx, Math.sin(t) * rx, -Math.sin(t) * ry, Math.cos(t) * ry];
114
+ // m₂ · E, both column-major 2x2.
115
+ const a = m[0] * e[0] + m[2] * e[1];
116
+ const b = m[1] * e[0] + m[3] * e[1];
117
+ const c = m[0] * e[2] + m[2] * e[3];
118
+ const d = m[1] * e[2] + m[3] * e[3];
119
+ // Singular values of [[a, c], [b, d]], closed form.
120
+ const e1 = (a * a + b * b + c * c + d * d) / 2;
121
+ const e2 = Math.hypot(a * a + b * b - c * c - d * d, 2 * (a * c + b * d)) / 2;
122
+ const nextRx = Math.sqrt(Math.max(0, e1 + e2));
123
+ const nextRy = Math.sqrt(Math.max(0, e1 - e2));
124
+ // The major axis' direction is the first left-singular vector.
125
+ const phi =
126
+ Math.abs(a * c + b * d) < 1e-12 && Math.abs(a * a + b * b - (c * c + d * d)) < 1e-12
127
+ ? 0
128
+ : (Math.atan2(2 * (a * c + b * d), a * a + b * b - c * c - d * d) / 2) * (180 / Math.PI);
129
+ const determinant = m[0] * m[3] - m[1] * m[2];
130
+ return [nextRx, nextRy, phi, largeArc, determinant < 0 ? 1 - sweep : sweep];
131
+ }
132
+
133
+ // --------------------------------------------------------------- path tokens
134
+
135
+ const NUMBER = /[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g;
136
+ const ARGUMENTS = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
137
+
138
+ /** `d` as a list of `{ command, values }`, every implicit repeat expanded. */
139
+ function tokenize(d) {
140
+ const out = [];
141
+ const each = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
142
+ for (let hit = each.exec(d); hit !== null; hit = each.exec(d)) {
143
+ const command = hit[1];
144
+ const arity = ARGUMENTS[command.toUpperCase()];
145
+ const numbers = (hit[2].match(NUMBER) ?? []).map(Number);
146
+ if (arity === 0) {
147
+ out.push({ command, values: [] });
148
+ continue;
149
+ }
150
+ for (let i = 0; i < numbers.length; i += arity) {
151
+ // An implicit repeat of `M` is `L` (and of `m` is `l`) — the spec's one
152
+ // command whose repeat is a different command.
153
+ const repeated = i > 0 && command === 'M' ? 'L' : i > 0 && command === 'm' ? 'l' : command;
154
+ out.push({ command: repeated, values: numbers.slice(i, i + arity) });
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Absolutize, then transform. Smooth curves (`S`/`T`) are expanded into their
162
+ * explicit forms first: the reflected control point is defined against the
163
+ * PREVIOUS segment, and carrying that relation through an affine is only
164
+ * correct if it is written out.
165
+ */
166
+ function transformPath(d, m) {
167
+ const out = [];
168
+ let [x, y] = [0, 0];
169
+ let [startX, startY] = [0, 0];
170
+ // The last cubic's and quadratic's control points, for `S` and `T`.
171
+ let [cubicX, cubicY] = [0, 0];
172
+ let [quadX, quadY] = [0, 0];
173
+ let previous = '';
174
+ const emit = (command, ...values) => out.push({ command, values });
175
+ const point = (px, py) => applyPoint(m, px, py);
176
+
177
+ for (const { command, values } of tokenize(d)) {
178
+ const relative = command === command.toLowerCase() && command !== 'Z';
179
+ const upper = command.toUpperCase();
180
+ const ax = (i) => (relative ? x + values[i] : values[i]);
181
+ const ay = (i) => (relative ? y + values[i] : values[i]);
182
+ if (upper === 'Z') {
183
+ emit('Z');
184
+ [x, y] = [startX, startY];
185
+ previous = 'Z';
186
+ continue;
187
+ }
188
+ if (upper === 'M' || upper === 'L' || upper === 'H' || upper === 'V' || upper === 'T') {
189
+ const [nx, ny] =
190
+ upper === 'H'
191
+ ? [relative ? x + values[0] : values[0], y]
192
+ : upper === 'V'
193
+ ? [x, relative ? y + values[0] : values[0]]
194
+ : [ax(0), ay(1)];
195
+ if (upper === 'T') {
196
+ // A smooth quadratic: the control point is the previous one mirrored
197
+ // about the current point (or the current point, after anything else).
198
+ const [qx, qy] = 'QT'.includes(previous) ? [2 * x - quadX, 2 * y - quadY] : [x, y];
199
+ emit('Q', ...point(qx, qy), ...point(nx, ny));
200
+ [quadX, quadY] = [qx, qy];
201
+ } else {
202
+ emit(upper === 'M' ? 'M' : 'L', ...point(nx, ny));
203
+ }
204
+ if (upper === 'M') [startX, startY] = [nx, ny];
205
+ [x, y] = [nx, ny];
206
+ previous = upper;
207
+ continue;
208
+ }
209
+ if (upper === 'C' || upper === 'S') {
210
+ const [c1x, c1y] =
211
+ upper === 'S'
212
+ ? 'CS'.includes(previous)
213
+ ? [2 * x - cubicX, 2 * y - cubicY]
214
+ : [x, y]
215
+ : [ax(0), ay(1)];
216
+ const [c2x, c2y] = upper === 'S' ? [ax(0), ay(1)] : [ax(2), ay(3)];
217
+ const [nx, ny] = upper === 'S' ? [ax(2), ay(3)] : [ax(4), ay(5)];
218
+ emit('C', ...point(c1x, c1y), ...point(c2x, c2y), ...point(nx, ny));
219
+ [cubicX, cubicY] = [c2x, c2y];
220
+ [x, y] = [nx, ny];
221
+ previous = 'C';
222
+ continue;
223
+ }
224
+ if (upper === 'Q') {
225
+ const [qx, qy] = [ax(0), ay(1)];
226
+ const [nx, ny] = [ax(2), ay(3)];
227
+ emit('Q', ...point(qx, qy), ...point(nx, ny));
228
+ [quadX, quadY] = [qx, qy];
229
+ [x, y] = [nx, ny];
230
+ previous = 'Q';
231
+ continue;
232
+ }
233
+ // A.
234
+ const [nx, ny] = [relative ? x + values[5] : values[5], relative ? y + values[6] : values[6]];
235
+ const [rx, ry, phi, large, sweep] = transformArc(
236
+ m,
237
+ values[0],
238
+ values[1],
239
+ values[2],
240
+ values[3],
241
+ values[4],
242
+ );
243
+ emit('A', rx, ry, phi, large, sweep, ...point(nx, ny));
244
+ [x, y] = [nx, ny];
245
+ previous = 'A';
246
+ }
247
+ return out;
248
+ }
249
+
250
+ // ------------------------------------------------------------------- the door
251
+
252
+ const round = (n) => {
253
+ const r = Math.round(n * 100) / 100;
254
+ return Object.is(r, -0) ? 0 : r;
255
+ };
256
+
257
+ function serialize(segments) {
258
+ return segments
259
+ .map(({ command, values }) =>
260
+ values.length === 0 ? command : `${command}${values.map(round).join(' ')}`,
261
+ )
262
+ .join('');
263
+ }
264
+
265
+ /** The ink's bounding box, from the ON-CURVE points and control points. A
266
+ * control point can lie outside the curve, so this is an upper bound — which
267
+ * is exactly what a "does this fill its 16 box" check wants to be. */
268
+ function boundsOf(segments) {
269
+ let box = [Infinity, Infinity, -Infinity, -Infinity];
270
+ for (const { command, values } of segments) {
271
+ const points = command === 'A' ? [values.slice(5)] : chunk(values, 2);
272
+ for (const [px, py] of points) {
273
+ box = [
274
+ Math.min(box[0], px),
275
+ Math.min(box[1], py),
276
+ Math.max(box[2], px),
277
+ Math.max(box[3], py),
278
+ ];
279
+ }
280
+ }
281
+ return box.map(round);
282
+ }
283
+
284
+ function chunk(values, size) {
285
+ const out = [];
286
+ for (let i = 0; i + size <= values.length; i += size) out.push(values.slice(i, i + size));
287
+ return out;
288
+ }
289
+
290
+ /**
291
+ * THE AUTHORED UNIT, WHICH IS NOT THE ARTBOARD. Blender's icons are drawn on
292
+ * an Inkscape canvas whose grid the file itself declares —
293
+ * `<inkscape:grid spacingx="100" spacingy="100">` — and one square of that
294
+ * grid is one unit of the 16-unit icon grid. So the scale from a file's user
295
+ * units to this set's is `1 / spacing`, and the viewBox does not enter it.
296
+ *
297
+ * MEASURED at the engine's pin (v5.2.0, 790 files in
298
+ * `release/datafiles/icons_svg/`): 755 declare `spacingx="100"`, two
299
+ * (`key_shift*.svg`) declare 50, and 33 declare no grid at all — those 33 are
300
+ * machine-written with a `matrix(100 …)` on the group and take the default
301
+ * below. The viewBox is NOT square with the grid: only **536 of the 790 are
302
+ * 1600 wide**, and the rest run from 48 to 5236. A mark authored on a
303
+ * 14-unit-wide canvas is fourteen units wide, not sixteen, so scaling it by
304
+ * `16 / 1400` inflated it by a seventh and pushed its ink off the bottom of
305
+ * the cell — `light_data.svg` traced to a box 18.29 tall. That was the defect
306
+ * this constant replaces (WORK.md §Blender in the tab is Blender, I5,
307
+ * orchestrator ruling 2 of 2026-09-19: "the icon trace scales by the authored
308
+ * unit, not the artboard").
309
+ */
310
+ const DEFAULT_GRID_SPACING = 100;
311
+
312
+ /**
313
+ * AND THE ARTBOARD IS A CROP, so the mark is CENTRED in the cell rather than
314
+ * pinned to the artboard's corner.
315
+ *
316
+ * This is the other half of the same measurement, and the data states it
317
+ * plainly: rescaled by the authored unit, **every one of the 197 traced marks
318
+ * has its ink box starting at exactly (1, 1)** and ending exactly one unit
319
+ * short of the artboard — `mod_particle_instance.svg` 1600×1600 → [1, 1, 15,
320
+ * 15], `light_data.svg` 1400×1700 → [1, 1, 13, 16], `dot.svg` 600×600 →
321
+ * [1, 1, 5, 5]. That is Inkscape's "resize page to drawing" with a one-grid
322
+ * margin, run per file. So the page carries the mark's SIZE and nothing about
323
+ * where it sits in a 16-unit cell, and reading its top-left corner as the
324
+ * cell's corner is what put the 4-unit dot and the 10-unit checkbox in the
325
+ * top-left eighth of their cells.
326
+ *
327
+ * Centring the PAGE (not the ink) keeps the authored margins symmetric, and
328
+ * it is exactly zero for a 1600×1600 artboard — which is why this leaves the
329
+ * 158 glyphs authored on that page byte-identical and moves only the 39 that
330
+ * are not.
331
+ */
332
+ const centreOffset = (extent, spacing) => (16 - extent / spacing) / 2;
333
+
334
+ /** Compose one element's own `transform` onto the matrix in force. */
335
+ const withOwn = (matrix, tag) =>
336
+ multiply(matrix, parseTransform(/\btransform="([^"]+)"/.exec(tag)?.[1] ?? ''));
337
+
338
+ /**
339
+ * ONE OF BLENDER'S ICON SVGs, as a path on this set's 16-unit grid.
340
+ *
341
+ * Returns `{ path, box }` — the concatenated `d` of every `<path>` in the
342
+ * file (Blender's icons are one `<g fill="#fff">` of one to three subpaths,
343
+ * nonzero winding, which is how a knockout reads) and the ink's bounding box,
344
+ * so the caller can state how much of the box the mark fills rather than
345
+ * assume it.
346
+ *
347
+ * THE GROUPS ARE WALKED AS A TREE, not sampled. This trace used to take the
348
+ * FIRST `<g>` carrying a transform and apply it to every path in the file, on
349
+ * the stated belief that "nesting never goes deeper than that in this data
350
+ * set". Measured at the pin: **21 of the 790 files nest two or more
351
+ * transformed groups**, and `mod_particle_instance.svg` — a `matrix(0 -1 -1 0
352
+ * 558.008 761.005)` quarter turn INSIDE a `matrix(100 0 0 -100 …)` flip —
353
+ * traced to a box at [273, −60], seventeen grid units clear of the canvas. A
354
+ * single-sample read cannot see an inner transform at all, so this keeps a
355
+ * STACK: a `<g>` pushes the matrix in force times its own, `</g>` pops, and a
356
+ * `<path>` draws under whatever is on top.
357
+ */
358
+ export function traceBlenderIcon(svg) {
359
+ const viewBox = /viewBox="([^"]+)"/.exec(svg);
360
+ if (viewBox === null) throw new Error('Blender icon SVG has no viewBox');
361
+ const [, , width, height] = viewBox[1]
362
+ .trim()
363
+ .split(/[\s,]+/)
364
+ .map(Number);
365
+ const spacing = Number(/\bspacingx="([0-9.]+)"/.exec(svg)?.[1] ?? DEFAULT_GRID_SPACING);
366
+ if (!Number.isFinite(spacing) || spacing <= 0)
367
+ throw new Error(`Blender icon SVG declares an unusable grid spacing: ${spacing}`);
368
+ const toGrid = [
369
+ 1 / spacing,
370
+ 0,
371
+ 0,
372
+ 1 / spacing,
373
+ centreOffset(width, spacing),
374
+ centreOffset(height, spacing),
375
+ ];
376
+ const segments = [];
377
+ const stack = [toGrid];
378
+ // The document, tag by tag. Blender's icon SVGs are machine-written and
379
+ // well-formed, so a scanner over `<g>` / `</g>` / `<path>` is the whole
380
+ // parser this needs; every other element it meets (`<svg>`,
381
+ // `<sodipodi:namedview>`, `<inkscape:grid/>`) carries no geometry.
382
+ const each = /<(\/?)(g|path)\b([^>]*)>/g;
383
+ for (let hit = each.exec(svg); hit !== null; hit = each.exec(svg)) {
384
+ const [, closing, tag, attributes] = hit;
385
+ if (tag === 'g') {
386
+ if (closing) {
387
+ if (stack.length > 1) stack.pop();
388
+ continue;
389
+ }
390
+ // A self-closing `<g/>` opens and closes in one tag, so it never enters
391
+ // the stack; every other `<g>` does.
392
+ if (!attributes.trimEnd().endsWith('/'))
393
+ stack.push(withOwn(stack[stack.length - 1], attributes));
394
+ continue;
395
+ }
396
+ if (closing) continue;
397
+ const d = /\bd="([^"]+)"/.exec(attributes)?.[1];
398
+ if (d === undefined) continue;
399
+ segments.push(...transformPath(d, withOwn(stack[stack.length - 1], attributes)));
400
+ }
401
+ if (segments.length === 0) throw new Error('Blender icon SVG has no path data');
402
+ return { path: serialize(segments), box: boundsOf(segments) };
403
+ }