@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.
- package/LICENSE +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- 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
|
+
}
|