@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,2925 @@
1
+ /**
2
+ * THE SOURCE of `blender.icons.json`, which is PART OURS AND PART BLENDER'S.
3
+ * 197 of the 346 glyphs it emits are machine-TRACED from Blender's own vector
4
+ * sources and are GPL-2.0-or-later artwork (see "GPL PATHS, DELIBERATELY"
5
+ * below, `blender-icon-trace.mjs`, and the provenance file
6
+ * `blender.icons.traced.json`); the other 149 are drawn HERE in Blender's
7
+ * IDIOM. That mixture is why `@vgai/blender` is
8
+ * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/blender/LICENSE`).
9
+ * The idiom is what a screenshot conveys: a 16-unit grid, monochrome filled
10
+ * silhouettes, ~1.3-unit strokes rendered as filled capsules with round ends,
11
+ * no outline around a fill, optical size ~12 units inside the 16 box.
12
+ *
13
+ * Run `node packages/blender/contributions/blender-icons.source.mjs` to rewrite
14
+ * the JSON. The JSON is the artifact the style bundle imports; THIS is where a
15
+ * glyph is edited, because a path `d` is not a thing a person edits by hand.
16
+ *
17
+ * Keys are Font Awesome icon NAMES (`icon.iconName`) — the editor's one icon
18
+ * primitive swaps a glyph in per name and falls through to its own for any
19
+ * name absent here, so this set is partial by design.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { traceBlenderIcon } from './blender-icon-trace.mjs';
26
+
27
+ /**
28
+ * WHICH OF BLENDER'S ICONS EACH PROPERTIES TAB DRAWS, and the file each one
29
+ * is. The `ICON_*` per tab is `buttons_context_items`
30
+ * (`makesrna/intern/rna_space.cc:579-611`); the Data tab's is dynamic
31
+ * (`buttons_context_compute`, `space_buttons/buttons_context.cc:795-810`:
32
+ * `RNA_struct_ui_icon(ptr->type)`, with Light special-cased to
33
+ * `ICON_OUTLINER_DATA_LIGHT`), so it has one entry per object data type,
34
+ * each icon read from that type's own `RNA_def_struct_ui_icon` call.
35
+ * An icon's file is its name lowercased.
36
+ */
37
+ const TAB_ICON_SOURCES = {
38
+ // The scene group, `ED_buttons_tabs_list`'s first block after the tool tab.
39
+ 'properties-render': 'scene', // ICON_SCENE
40
+ 'properties-output': 'output', // ICON_OUTPUT
41
+ 'properties-view-layer': 'render_result', // ICON_RENDER_RESULT
42
+ 'properties-scene': 'scene_data', // ICON_SCENE_DATA
43
+ 'properties-world': 'world', // ICON_WORLD
44
+ // Its own block.
45
+ 'properties-collection': 'group', // ICON_GROUP
46
+ // The object group.
47
+ 'properties-object': 'object_data', // ICON_OBJECT_DATA
48
+ 'properties-modifiers': 'modifier', // ICON_MODIFIER
49
+ 'properties-particles': 'particles', // ICON_PARTICLES
50
+ 'properties-physics': 'physics', // ICON_PHYSICS
51
+ 'properties-constraints': 'constraint', // ICON_CONSTRAINT
52
+ 'properties-bone': 'bone_data', // ICON_BONE_DATA
53
+ 'properties-bone-constraints': 'constraint_bone', // ICON_CONSTRAINT_BONE
54
+ 'properties-material': 'material', // ICON_MATERIAL
55
+ // Its own block.
56
+ 'properties-texture': 'texture', // ICON_TEXTURE
57
+ // The Data tab, per object type. `properties-data` is the fallback and is
58
+ // MESH_DATA, which is what Blender shows for the starter cube.
59
+ 'properties-data': 'mesh_data',
60
+ 'properties-data-mesh': 'mesh_data', // Mesh → ICON_MESH_DATA
61
+ 'properties-data-armature': 'armature_data', // Armature → ICON_ARMATURE_DATA
62
+ 'properties-data-curve': 'curve_data', // Curve → ICON_CURVE_DATA
63
+ 'properties-data-surface': 'surface_data', // SurfaceCurve→ ICON_SURFACE_DATA
64
+ 'properties-data-meta': 'meta_data', // MetaBall → ICON_META_DATA
65
+ 'properties-data-font': 'font_data', // TextCurve → ICON_FONT_DATA
66
+ 'properties-data-lattice': 'lattice_data', // Lattice → ICON_LATTICE_DATA
67
+ 'properties-data-camera': 'camera_data', // Camera → ICON_CAMERA_DATA
68
+ 'properties-data-light': 'outliner_data_light', // Light, special-cased
69
+ 'properties-data-speaker': 'speaker', // Speaker → ICON_SPEAKER
70
+ 'properties-data-pointcloud': 'pointcloud_data', // PointCloud → ICON_POINTCLOUD_DATA
71
+ 'properties-data-volume': 'volume_data', // Volume → ICON_VOLUME_DATA
72
+ 'properties-data-empty': 'empty_data', // no data → ICON_EMPTY_DATA
73
+ };
74
+
75
+ /**
76
+ * THE OUTLINER'S MARKS — every icon `tree_element_get_icon`
77
+ * (`outliner_draw.cc:2619-2945`) and `tree_element_get_icon_from_id`
78
+ * (`:2479-2610`) can pick for a row of Blender's View Layer tree, by FAMILY
79
+ * rather than one line per mark (WORK.md §Blender in the tab is Blender,
80
+ * "Inspection parity", I3).
81
+ *
82
+ * A FAMILY AND NOT A LIST, deliberately. The door reports whatever `ICON_*`
83
+ * Blender picked, and the two places that pick are a `switch` over ID codes and
84
+ * `RNA`'s own enum items (`rna_enum_object_type_items`,
85
+ * `rna_enum_object_modifier_type_items`, `rna_enum_constraint_type_items`) —
86
+ * sixty-three modifier marks and twenty-eight constraint marks among them. A
87
+ * table of names here would be a transcription of those enums maintained in a
88
+ * second place, and the first mark it fell behind on would draw a Font Awesome
89
+ * circle in the middle of Blender's tree. The prefix is what the enums
90
+ * themselves are named by, so tracing the prefix traces the enum.
91
+ *
92
+ * Each traced glyph is named `blender-<file>` with dashes — which is exactly
93
+ * what `blender-outliner-authoring.ts`'s `blenderOutlinerKind` spells from the
94
+ * icon name the door sends, so the two meet with no table at all.
95
+ */
96
+ const OUTLINER_ICON_FAMILIES = [
97
+ // `icon_from_object_type` and `tree_element_get_icon_from_id`'s data marks.
98
+ 'outliner_',
99
+ // A light's lamp type and a probe's — the two sub-switches.
100
+ 'light_',
101
+ 'lightprobe_',
102
+ // `ModifierTypeInfo::icon` and the constraint switch.
103
+ 'mod_',
104
+ 'con_',
105
+ ];
106
+
107
+ /** The marks the Outliner draws that belong to no family above — the base
108
+ * rows' labels ("Modifiers", "Vertex Groups", "Pose"), the datablock marks a
109
+ * non-object ID answers, and the two restriction-column glyphs. */
110
+ const OUTLINER_ICON_NAMES = [
111
+ 'modifier_data',
112
+ 'constraint',
113
+ 'constraint_bone',
114
+ 'group_vertex',
115
+ 'group_bone',
116
+ 'bone_data',
117
+ 'armature_data',
118
+ 'shapekey_data',
119
+ 'anim_data',
120
+ 'particles',
121
+ 'material_data',
122
+ 'texture_data',
123
+ 'image_data',
124
+ 'world_data',
125
+ 'scene_data',
126
+ 'object_data',
127
+ 'nodetree',
128
+ 'action',
129
+ 'line_data',
130
+ 'brush_data',
131
+ 'mod_mask',
132
+ 'file_text',
133
+ 'file_font',
134
+ 'sequence',
135
+ 'color',
136
+ 'workspace',
137
+ 'library_data_direct',
138
+ 'dot',
139
+ // The Disable in Renders column (`outliner_draw.cc:1363-1384`), under names
140
+ // of OURS so the bare `camera` glyph — the viewport header's camera menu —
141
+ // is not repainted. `hierarchy-kind-icon.ts` mints the same two names.
142
+ ['outliner-render-on', 'restrict_render_off'],
143
+ ['outliner-render-off', 'restrict_render_on'],
144
+ // The EXCLUDE column (`outliner_draw.cc:1634-1653`). Blender asks for
145
+ // `ICON_NONE` there, which makes the icon toggle draw the widget's own
146
+ // checkbox — `checkbox_hlt` / `checkbox_dehlt` — so those are the two marks,
147
+ // under names of OURS for the same reason the render pair has them.
148
+ ['outliner-exclude-on', 'checkbox_hlt'],
149
+ ['outliner-exclude-off', 'checkbox_dehlt'],
150
+ ];
151
+
152
+ const TRACED = new URL('./blender.icons.traced.json', import.meta.url);
153
+
154
+ /**
155
+ * `--trace` — RE-DERIVE the traced paths from a Blender checkout. Separate
156
+ * from the ordinary run on purpose: the checkout is a developer's local clone
157
+ * of Blender at the engine's pin (the recipe is in WORK.md §Blender in the
158
+ * tab is Blender, "Inspection parity"), and a generator that silently emits
159
+ * different glyphs depending on whether a sibling directory happens to exist
160
+ * is worse than one that needs a flag. The JSON it writes is checked in and
161
+ * records each glyph's source path and sha256, so a later trace that disagrees
162
+ * is a visible diff rather than a mystery.
163
+ */
164
+ if (process.argv.includes('--trace')) {
165
+ const checkout = process.env['BLENDER_SRC'] ?? `${homedir()}/volter/blender-src`;
166
+ const out = {};
167
+ // WHICH THEME COLOUR EACH MARK TAKES, read rather than assigned by eye. An
168
+ // icon's `DEF_ICON_<GROUP>` macro in `editors/include/UI_icons.hh` is what
169
+ // `interface_icons.cc:120-134` turns into a `TH_ICON_*`, which
170
+ // `resources.cc:1059-1078` resolves against `.tui` — so the macro IS the
171
+ // tone, and this reads it per icon at trace time instead of keeping a second
172
+ // table of sixty-odd assignments in sync by hand. A plain `DEF_ICON` (and
173
+ // `DEF_ICON_COLOR`, which is an SVG drawn in its own colours) takes none.
174
+ const MACRO_TONE = {
175
+ DEF_ICON_SCENE: 'scene',
176
+ DEF_ICON_COLLECTION: 'collection',
177
+ DEF_ICON_OBJECT: 'object',
178
+ DEF_ICON_OBJECT_DATA: 'data',
179
+ DEF_ICON_MODIFIER: 'modifier',
180
+ DEF_ICON_SHADING: 'material',
181
+ };
182
+ const iconsHeader = readFileSync(
183
+ `${checkout}/source/blender/editors/include/UI_icons.hh`,
184
+ 'utf8',
185
+ );
186
+ const toneByIcon = new Map();
187
+ for (const match of iconsHeader.matchAll(/^DEF_ICON(?:_([A-Z_]+))?\(([A-Z0-9_]+)\)/gm)) {
188
+ const tone = MACRO_TONE[`DEF_ICON_${match[1] ?? ''}`];
189
+ if (tone) toneByIcon.set(match[2], tone);
190
+ }
191
+ if (toneByIcon.size === 0)
192
+ throw new Error(
193
+ 'UI_icons.hh yielded no DEF_ICON_<GROUP> lines — the tone read is the source of every ' +
194
+ "glyph's colour, so an empty read is a silent grey rail, not a missing nicety.",
195
+ );
196
+ // The Outliner's families, resolved against the checkout's own icon
197
+ // directory so a family is traced whole (see OUTLINER_ICON_FAMILIES).
198
+ const available = readdirSync(`${checkout}/release/datafiles/icons_svg`)
199
+ .filter((file) => file.endsWith('.svg'))
200
+ .map((file) => file.slice(0, -4))
201
+ .sort();
202
+ const outlinerSources = {};
203
+ for (const family of OUTLINER_ICON_FAMILIES) {
204
+ const members = available.filter((icon) => icon.startsWith(family));
205
+ if (members.length === 0)
206
+ throw new Error(
207
+ `No icon in the checkout starts with "${family}". A family that traces to nothing is ` +
208
+ 'how the Outliner silently falls back to Font Awesome circles.',
209
+ );
210
+ for (const icon of members) outlinerSources[`blender-${icon.replace(/_/g, '-')}`] = icon;
211
+ }
212
+ for (const entry of OUTLINER_ICON_NAMES) {
213
+ const [name, icon] = Array.isArray(entry)
214
+ ? entry
215
+ : [`blender-${entry.replace(/_/g, '-')}`, entry];
216
+ outlinerSources[name] = icon;
217
+ }
218
+ for (const [name, icon] of Object.entries({ ...TAB_ICON_SOURCES, ...outlinerSources })) {
219
+ const relative = `release/datafiles/icons_svg/${icon}.svg`;
220
+ let svg;
221
+ try {
222
+ svg = readFileSync(`${checkout}/${relative}`, 'utf8');
223
+ } catch {
224
+ throw new Error(
225
+ `${relative} is not in the Blender checkout at ${checkout}. Set BLENDER_SRC, or add ` +
226
+ `the directory to the sparse checkout: \`git sparse-checkout add release/datafiles/icons_svg\` ` +
227
+ 'followed by the LFS-off checkout line in WORK.md.',
228
+ );
229
+ }
230
+ if (svg.startsWith('version https://git-lfs')) {
231
+ throw new Error(
232
+ `${relative} is an LFS POINTER, not an SVG. Fetch it: ` +
233
+ `\`git lfs pull -I ${relative}\` in ${checkout}.`,
234
+ );
235
+ }
236
+ const { path, box } = traceBlenderIcon(svg);
237
+ const tone = toneByIcon.get(icon.toUpperCase());
238
+ out[name] = {
239
+ icon: `ICON_${icon.toUpperCase()}`,
240
+ source: relative,
241
+ sha256: createHash('sha256').update(svg).digest('hex'),
242
+ box,
243
+ ...(tone ? { tone } : {}),
244
+ path,
245
+ };
246
+ }
247
+ writeFileSync(TRACED, `${JSON.stringify(out, null, 2)}\n`);
248
+ console.log(`blender.icons.traced.json — ${Object.keys(out).length} glyphs traced`);
249
+ process.exit(0);
250
+ }
251
+
252
+ /** The traced marks, keyed by this set's glyph name. See the Properties-rail
253
+ * block near the bottom of this file for what they are and why. */
254
+ const traced = JSON.parse(readFileSync(TRACED, 'utf8'));
255
+
256
+ // ---------------------------------------------------------------- primitives
257
+
258
+ const f = (n) => {
259
+ const r = Math.round(n * 100) / 100;
260
+ return Object.is(r, -0) ? 0 : r;
261
+ };
262
+
263
+ /**
264
+ * TWO GLOBAL DIALS, set against the running chrome rather than guessed. The
265
+ * editor paints a glyph at the ACTIVE font size — an 11px hierarchy row, a
266
+ * 10px status bar — where a 1.3-unit stroke in a 16-unit box lands at 0.9
267
+ * device pixels and greys out. `S` grows the optical size to ~14.5 units
268
+ * inside the 16 box — Blender's glyphs FILL their box where Font Awesome's
269
+ * carry padding, and measured against the reference frames that ratio (ink
270
+ * over row height) is what makes a row read as Blender's; `W` fattens every
271
+ * stroke so it still covers a pixel in an 11px row. Geometry below is
272
+ * authored on the plain 16 grid and both dials are applied here, once.
273
+ */
274
+ const S = 1.22;
275
+ const W = 1.12;
276
+ const p = (x, y) => `${f(8 + (x - 8) * S)} ${f(8 + (y - 8) * S)}`;
277
+ /** A radius, in the same scaled space `p` emits into. */
278
+ const rr = (r) => f(r * S);
279
+
280
+ /**
281
+ * A straight stroke with round ends: the capsule its outline encloses.
282
+ *
283
+ * THE SWEEP FLAG IS 0, AND IT WAS 1 UNTIL 2026-09-19 — a one-character defect
284
+ * under every open stroke end in this set. With sweep 1 each end arc bulges
285
+ * INWARD instead of outward, so the path is the rectangle MINUS two
286
+ * half-discs: every open end came out with a crescent bitten out of it, and
287
+ * at a polyline's joint the two bites ate a round notch out of the corner.
288
+ * Nothing reported it because at the 14 px chrome size the bite is one device
289
+ * pixel; it was found at 6x in the editor-type well, whose chevron drew as
290
+ * two arrowheads with a hole where they meet (rendered side by side, sweep 1
291
+ * against sweep 0, before the flag was touched).
292
+ *
293
+ * Closed shapes change too, and toward this docblock's own words: `line(…,
294
+ * closed)` and `rframe` corners were each losing a notch to the same bites,
295
+ * half-covered by the neighbouring capsule.
296
+ *
297
+ * THE BLAST RADIUS, MEASURED rather than reasoned (every glyph rasterized at
298
+ * 64 px both ways and the masks differenced): 97 of the 146 paths move. 96 of
299
+ * them ONLY GAIN ink, at an end or a corner — a knockout that is not there
300
+ * can take nothing. The 97th, `layer-group`, gains 199 px and loses one, on an
301
+ * antialiased boundary. Nothing here was tuned against the bitten rendering:
302
+ * the two glyphs this set fits pixel-by-pixel to a reference frame
303
+ * (`properties-modifiers`, and `outliner`/`properties` below) are built from
304
+ * `poly` and `rrect` and do not move at all.
305
+ */
306
+ function cap(x1, y1, x2, y2, width = 1.3) {
307
+ const w = width * W;
308
+ const r = w / 2;
309
+ const dx = x2 - x1;
310
+ const dy = y2 - y1;
311
+ const L = Math.hypot(dx, dy);
312
+ if (L < 1e-6) return dot(x1, y1, r);
313
+ const nx = -dy / L;
314
+ const ny = dx / L;
315
+ return (
316
+ `M${p(x1 + nx * r, y1 + ny * r)}L${p(x2 + nx * r, y2 + ny * r)}` +
317
+ `A${rr(r)} ${rr(r)} 0 0 0 ${p(x2 - nx * r, y2 - ny * r)}` +
318
+ `L${p(x1 - nx * r, y1 - ny * r)}` +
319
+ `A${rr(r)} ${rr(r)} 0 0 0 ${p(x1 + nx * r, y1 + ny * r)}Z`
320
+ );
321
+ }
322
+
323
+ /** A filled disc. `ccw` winds it backwards so it punches a hole (nonzero). */
324
+ function dot(cx, cy, r, ccw = false) {
325
+ const s = ccw ? 1 : 0;
326
+ return (
327
+ `M${p(cx - r, cy)}A${rr(r)} ${rr(r)} 0 1 ${s} ${p(cx + r, cy)}` +
328
+ `A${rr(r)} ${rr(r)} 0 1 ${s} ${p(cx - r, cy)}Z`
329
+ );
330
+ }
331
+
332
+ /** A circular outline of thickness `w` — a disc with a disc punched out. */
333
+ function ring(cx, cy, r, width = 1.3) {
334
+ const w = width * W;
335
+ return dot(cx, cy, r + w / 2) + dot(cx, cy, Math.max(r - w / 2, 0.01), true);
336
+ }
337
+
338
+ /**
339
+ * A polyline stroked with round ends and round joins.
340
+ *
341
+ * THE JOINS NEED NO EXTRA DISC, and stamping one was actively destructive.
342
+ * Each `cap` ends in a full semicircle of radius w/2 centred on the join, so
343
+ * the union of two capsules already contains the whole join disc — the joins
344
+ * are round for free. The disc this used to add at every interior vertex had
345
+ * the opposite winding to the capsules it landed in, so under
346
+ * `fill-rule: nonzero` it PUNCHED A HOLE: render `line([[3,3],[3,13],[13,13]])`
347
+ * at 24x and the corner is a white ring around a black core, where the same
348
+ * two `cap` calls alone give a solid corner. At 14px that hole is a sub-pixel
349
+ * dark pip on every corner of every stroked glyph in the set — the
350
+ * "greys out at a small size" symptom, dropped here for all 130 at once.
351
+ */
352
+ function line(pts, w = 1.3, closed = false) {
353
+ let d = '';
354
+ const n = pts.length;
355
+ const last = closed ? n : n - 1;
356
+ for (let i = 0; i < last; i++) {
357
+ const a = pts[i];
358
+ const b = pts[(i + 1) % n];
359
+ d += cap(a[0], a[1], b[0], b[1], w);
360
+ }
361
+ return d;
362
+ }
363
+
364
+ /** Twice the signed area — the sign IS the winding, and under `fill-rule:
365
+ * nonzero` the winding is the whole difference between ink and a hole. */
366
+ function area2(pts) {
367
+ let a = 0;
368
+ for (let i = 0; i < pts.length; i++) {
369
+ const [x1, y1] = pts[i];
370
+ const [x2, y2] = pts[(i + 1) % pts.length];
371
+ a += x1 * y2 - x2 * y1;
372
+ }
373
+ return a;
374
+ }
375
+
376
+ /** A filled polygon, wound the way `dot` winds a disc so the two union. */
377
+ function poly(pts, hole = false) {
378
+ const wound = area2(pts) > 0 === hole ? pts : [...pts].reverse();
379
+ return `M${wound.map(([x, y]) => p(x, y)).join('L')}Z`;
380
+ }
381
+
382
+ /**
383
+ * A filled rectangle with equal corner radii.
384
+ *
385
+ * IT WINDS THE WAY `poly` AND `cap` WIND, and that is load-bearing, not a
386
+ * detail: for most of this file's life the rounded branch ran the other way
387
+ * round the corners, so under `fill-rule: nonzero` every `holeBar`/`dot(ccw)`
388
+ * aimed at a rounded rect UNIONED instead of punching, and every ordinary
389
+ * `poly`/`dot` drawn over one punched a hole instead of adding ink. Nothing
390
+ * reported it — a knockout that does nothing just looks like a solid glyph.
391
+ * It silently ate the collection box's pull slot, the wrench's fork and the
392
+ * bulb's screw thread, and it is why the pan hand carried a bite out of its
393
+ * palm where its thumb should be. Measured, not reasoned: render
394
+ * `rrect(r) + holeBar` and read the centre pixel.
395
+ */
396
+ function rrect(x, y, w, h, r = 0) {
397
+ const rad = Math.min(r, w / 2, h / 2);
398
+ if (rad <= 0.01)
399
+ return poly([
400
+ [x, y],
401
+ [x + w, y],
402
+ [x + w, y + h],
403
+ [x, y + h],
404
+ ]);
405
+ const a = (px, py) => `A${rr(rad)} ${rr(rad)} 0 0 0 ${p(px, py)}`;
406
+ return (
407
+ `M${p(x + rad, y)}${a(x, y + rad)}L${p(x, y + h - rad)}${a(x + rad, y + h)}` +
408
+ `L${p(x + w - rad, y + h)}${a(x + w, y + h - rad)}` +
409
+ `L${p(x + w, y + rad)}${a(x + w - rad, y)}Z`
410
+ );
411
+ }
412
+
413
+ /**
414
+ * A rectangular outline of thickness `w`, corners rounded by the joins.
415
+ *
416
+ * A corner radius SMALLER than the stroke's own round end is not a corner
417
+ * radius, it is a defect: the bevel becomes a segment shorter than the capsule
418
+ * is wide, its capsule overlaps both neighbours almost entirely, and the
419
+ * overlap cancels under `fill-rule: nonzero` — at 12x, `border-all` (r 0.4,
420
+ * t 1) and `object-group` (r 0.3, t 2.1) both showed a hollow pip at every
421
+ * corner. Below `t/2` the caps already round the corner by more than `r`
422
+ * asked for, so the plain four-point path is both cleaner and closer to the
423
+ * request.
424
+ */
425
+ function rframe(x, y, w, h, t = 1.3, r = 0) {
426
+ const inset = t / 2;
427
+ if (r <= inset) {
428
+ return line(
429
+ [
430
+ [x + inset, y + inset],
431
+ [x + w - inset, y + inset],
432
+ [x + w - inset, y + h - inset],
433
+ [x + inset, y + h - inset],
434
+ ],
435
+ t,
436
+ true,
437
+ );
438
+ }
439
+ const pts = [
440
+ [x + inset + r, y + inset],
441
+ [x + w - inset - r, y + inset],
442
+ [x + w - inset, y + inset + r],
443
+ [x + w - inset, y + h - inset - r],
444
+ [x + w - inset - r, y + h - inset],
445
+ [x + inset + r, y + h - inset],
446
+ [x + inset, y + h - inset - r],
447
+ [x + inset, y + inset + r],
448
+ ];
449
+ return line(r > 0 ? pts : [pts[0], pts[1], pts[3], pts[5]], t, true);
450
+ }
451
+
452
+ /**
453
+ * A stroked arc, angles in degrees, 0 = +x, growing clockwise on screen.
454
+ *
455
+ * ONE annulus band, not a chain of capsules. Built as `line()` over sampled
456
+ * points, each segment's capsule is shorter than it is wide, consecutive
457
+ * capsules overlap almost entirely, and the overlaps cancel under
458
+ * `fill-rule: nonzero` — rendered at 24x, `rotate`'s arc and `lock`'s shackle
459
+ * came out as a row of hollow circles rather than a stroke. A band has no
460
+ * overlaps to cancel: out along the outer radius, back along the inner.
461
+ */
462
+ function arc(cx, cy, r, a0, a1, w = 1.3, steps = 0) {
463
+ const t = (w * W) / 2;
464
+ const n = steps || Math.max(6, Math.ceil(Math.abs(a1 - a0) / 9));
465
+ const at = (i, rad) => {
466
+ const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
467
+ return [cx + Math.cos(a) * rad, cy + Math.sin(a) * rad];
468
+ };
469
+ const pts = [];
470
+ for (let i = 0; i <= n; i++) pts.push(at(i, r + t));
471
+ for (let i = n; i >= 0; i--) pts.push(at(i, Math.max(r - t, 0.01)));
472
+ // Round the two ends so the band still reads as a stroke, not a ribbon.
473
+ return poly(pts) + dot(...at(0, r), t) + dot(...at(n, r), t);
474
+ }
475
+
476
+ /** A stroked elliptical arc — cylinder lips, cloud shoulders, orbits. */
477
+ function ellipseArc(cx, cy, rx, ry, a0, a1, w = 1.15, rot = 0, closed = false) {
478
+ const t = (rot * Math.PI) / 180;
479
+ const n = Math.max(4, Math.ceil(Math.abs(a1 - a0) / 15));
480
+ const pts = [];
481
+ for (let i = 0; i <= (closed ? n - 1 : n); i++) {
482
+ const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
483
+ const x = Math.cos(a) * rx;
484
+ const y = Math.sin(a) * ry;
485
+ pts.push([cx + x * Math.cos(t) - y * Math.sin(t), cy + x * Math.sin(t) + y * Math.cos(t)]);
486
+ }
487
+ return line(pts, w, closed);
488
+ }
489
+
490
+ /** A filled half-disc, `a0`..`a1` swept — the lit half of a shading toggle. */
491
+ function wedge(cx, cy, r, a0, a1) {
492
+ const n = Math.max(6, Math.ceil(Math.abs(a1 - a0) / 12));
493
+ const pts = [[cx, cy]];
494
+ for (let i = 0; i <= n; i++) {
495
+ const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
496
+ pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
497
+ }
498
+ return poly(pts);
499
+ }
500
+
501
+ /**
502
+ * A HOLE — the same bar or wedge, wound backwards so `fill-rule: nonzero`
503
+ * knocks it out of a solid silhouette underneath. This is how the status
504
+ * family survives the 10px status bar: at that size a thin ring with a mark
505
+ * inside it fills in and every glyph becomes a disc, where a SOLID disc with
506
+ * its mark knocked out keeps the mark's contrast at one device pixel. It is
507
+ * also Blender's own construction — the collection box's slot and the
508
+ * camera's lens are knocked out of their silhouettes, not drawn on them.
509
+ */
510
+ function holeBar(x1, y1, x2, y2, w) {
511
+ const r = w / 2;
512
+ const dx = x2 - x1;
513
+ const dy = y2 - y1;
514
+ const L = Math.hypot(dx, dy) || 1;
515
+ const nx = (-dy / L) * r;
516
+ const ny = (dx / L) * r;
517
+ return poly(
518
+ [
519
+ [x1 + nx, y1 + ny],
520
+ [x1 - nx, y1 - ny],
521
+ [x2 - nx, y2 - ny],
522
+ [x2 + nx, y2 + ny],
523
+ ],
524
+ true,
525
+ );
526
+ }
527
+
528
+ /** A polygon wound backwards — a knocked-out shape. */
529
+ function holePoly(pts) {
530
+ return poly(pts, true);
531
+ }
532
+
533
+ /** Blender's chevron: a thin V, the outliner's disclosure and every menu arrow. */
534
+ function chevron(cx, cy, s, dir) {
535
+ const v = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[dir];
536
+ const perp = [-v[1], v[0]];
537
+ const tip = [cx + v[0] * s * 0.55, cy + v[1] * s * 0.55];
538
+ const back = -s * 0.55;
539
+ return line(
540
+ [
541
+ [cx + v[0] * back + perp[0] * s, cy + v[1] * back + perp[1] * s],
542
+ tip,
543
+ [cx + v[0] * back - perp[0] * s, cy + v[1] * back - perp[1] * s],
544
+ ],
545
+ 1.3,
546
+ );
547
+ }
548
+
549
+ /**
550
+ * Blender's isometric cube — a SOLID hexagonal silhouette with its three
551
+ * interior seams knocked out, never a wire box. `outliner.png` at matched
552
+ * scale settles it: every object mark in a row is a filled silhouette with its
553
+ * detail punched through, and the wire version of this glyph collapsed into an
554
+ * undifferentiated grey circle at the 14px row size (its three 1.15-unit
555
+ * spokes merged and the hexagon's corners rounded away).
556
+ */
557
+ function isoCube(cx = 8, cy = 8, s = 5.4, seam = 1.05) {
558
+ const top = [cx, cy - s];
559
+ const tr = [cx + s * 0.87, cy - s * 0.5];
560
+ const br = [cx + s * 0.87, cy + s * 0.5];
561
+ const bot = [cx, cy + s];
562
+ const bl = [cx - s * 0.87, cy + s * 0.5];
563
+ const tl = [cx - s * 0.87, cy - s * 0.5];
564
+ return (
565
+ poly([tl, top, tr, br, bot, bl]) +
566
+ holeBar(tl[0], tl[1], cx, cy, seam) +
567
+ holeBar(tr[0], tr[1], cx, cy, seam) +
568
+ holeBar(bot[0], bot[1], cx, cy, seam)
569
+ );
570
+ }
571
+
572
+ // ------------------------------------------------------------------- glyphs
573
+ // Keyed by Font Awesome icon name. Grouped the way the chrome reads them.
574
+
575
+ const glyphs = {};
576
+ /**
577
+ * `meta` is the glyph's COLOUR channel (`IconSetContribution`): `tone` names
578
+ * a category the palette inks, and `tonedPath` splits the drawing so only
579
+ * that second path takes it. Whole-glyph tones are assigned from one table
580
+ * further down, next to the frames they were measured in; `tonedPath` has to
581
+ * be authored HERE, because it is a division of the geometry.
582
+ */
583
+ const g = (name, d, meta) => {
584
+ glyphs[name] = { path: d, ...(meta ?? {}) };
585
+ };
586
+
587
+ // -- disclosure, carets, arrows ------------------------------------------
588
+ g('chevron-right', chevron(7.4, 8, 3.1, 'right'));
589
+ g('chevron-left', chevron(8.6, 8, 3.1, 'left'));
590
+ g('chevron-down', chevron(8, 7.4, 3.1, 'down'));
591
+ // BLENDER HAS NO SOLID CARET. Both places the editor spends one — a dropdown
592
+ // trigger's disclosure and a tree row's expander — Blender draws an open
593
+ // CHEVRON, measured in two frames: `modeling.png` at its native 2x, the
594
+ // orientation well's mark at x 1304..1316, y 76..83 (13x8 device px = 6.5x4
595
+ // CSS, peak 216); and `outliner.png` (also native 2x), the collapsed Camera
596
+ // row's mark at x 96..105, y 146..163 (10x18 = 5x9 CSS) and the expanded
597
+ // Collection row's at x 52..69, y 110..119 (18x10 = 9x5 CSS), peak 194. Both
598
+ // are a 1 CSS px stroke and both run 1.8:1, which is exactly what `chevron()`
599
+ // draws. The names stay `caret-*` — a set draws the editor's names its own
600
+ // way, and Font Awesome's filled triangle is still what every other skin gets.
601
+ // `s` is 4.4 rather than `chevron-*`'s 3.1 so the mark keeps the EXTENT the
602
+ // filled caret had here (8.8 of 16 units, ~7 CSS px against Blender's 6.5).
603
+ g('caret-right', chevron(7.4, 8, 4.4, 'right'));
604
+ g('caret-down', chevron(8, 7.4, 4.4, 'down'));
605
+ g(
606
+ 'arrow-right',
607
+ cap(2.6, 8, 12.4, 8) +
608
+ line([
609
+ [9.2, 4.8],
610
+ [12.6, 8],
611
+ [9.2, 11.2],
612
+ ]),
613
+ );
614
+ g(
615
+ 'arrow-left',
616
+ cap(3.6, 8, 13.4, 8) +
617
+ line([
618
+ [6.8, 4.8],
619
+ [3.4, 8],
620
+ [6.8, 11.2],
621
+ ]),
622
+ );
623
+ g(
624
+ 'arrow-up',
625
+ cap(8, 3.6, 8, 13.4) +
626
+ line([
627
+ [4.8, 6.8],
628
+ [8, 3.4],
629
+ [11.2, 6.8],
630
+ ]),
631
+ );
632
+ g(
633
+ 'arrow-down',
634
+ cap(8, 2.6, 8, 12.4) +
635
+ line([
636
+ [4.8, 9.2],
637
+ [8, 12.6],
638
+ [11.2, 9.2],
639
+ ]),
640
+ );
641
+
642
+ // -- the primary actions --------------------------------------------------
643
+ g('plus', cap(8, 3, 8, 13) + cap(3, 8, 13, 8));
644
+ g('minus', cap(3, 8, 13, 8));
645
+ g('xmark', cap(4.2, 4.2, 11.8, 11.8) + cap(11.8, 4.2, 4.2, 11.8));
646
+ g(
647
+ 'check',
648
+ line(
649
+ [
650
+ [3.4, 8.4],
651
+ [6.6, 11.6],
652
+ [12.8, 4.6],
653
+ ],
654
+ 1.5,
655
+ ),
656
+ );
657
+ g(
658
+ 'trash',
659
+ rrect(3.3, 4.2, 9.4, 1.5, 0.5) +
660
+ line(
661
+ [
662
+ [6.2, 4.2],
663
+ [6.2, 3.1],
664
+ [9.8, 3.1],
665
+ [9.8, 4.2],
666
+ ],
667
+ 1.2,
668
+ ) +
669
+ line(
670
+ [
671
+ [4.5, 6.1],
672
+ [5.1, 13.1],
673
+ [10.9, 13.1],
674
+ [11.5, 6.1],
675
+ ],
676
+ 1.25,
677
+ ) +
678
+ cap(6.7, 7.4, 6.9, 11.5, 1.1) +
679
+ cap(9.3, 7.4, 9.1, 11.5, 1.1),
680
+ );
681
+ g('magnifying-glass', ring(7.1, 7.1, 3.7, 1.3) + cap(9.9, 9.9, 13.2, 13.2, 1.4));
682
+ /**
683
+ * ZOOM is not SEARCH, and Blender draws the two differently — both frames
684
+ * agree: the Search field's magnifier (`outliner.png`, `properties-object.png`)
685
+ * is a thin RING with a handle, while the viewport navigation cluster's zoom
686
+ * (`modeling-object-none.png`) is a SOLID lens disc with the sign knocked
687
+ * through it and a short fat handle. So `magnifying-glass` keeps its ring and
688
+ * the two signed ones become lenses; drawn as rings, their 1.1-unit sign
689
+ * strokes were the faintest ink on the whole navigation cluster.
690
+ */
691
+ // The handle STARTS INSIDE the lens (9.3,9.3 is 3.1 from the centre of a
692
+ // 4.25 disc): begun at 10.2 it cleared the disc's edge by a tenth of a unit
693
+ // and the round cap floated off as a separate lozenge at 16px — measured in
694
+ // round 1's frame, where the navigation cluster showed a disc and a diamond.
695
+ const zoomLens = (sign) => dot(7.1, 7.1, 4.25) + sign + cap(9.3, 9.3, 13.4, 13.4, 2);
696
+ g(
697
+ 'magnifying-glass-plus',
698
+ zoomLens(holeBar(7.1, 5.2, 7.1, 9, 1.1) + holeBar(5.2, 7.1, 9, 7.1, 1.1)),
699
+ );
700
+ g('magnifying-glass-minus', zoomLens(holeBar(5.2, 7.1, 9, 7.1, 1.1)));
701
+ g('ellipsis', dot(3.6, 8, 1.15) + dot(8, 8, 1.15) + dot(12.4, 8, 1.15));
702
+ g('grip-lines', cap(3, 6.3, 13, 6.3, 1.2) + cap(3, 9.7, 13, 9.7, 1.2));
703
+ g('grip-lines-vertical', cap(6.3, 3, 6.3, 13, 1.2) + cap(9.7, 3, 9.7, 13, 1.2));
704
+ g('copy', rframe(2.4, 2.4, 8.4, 8.4, 1.2, 1) + rframe(5.9, 5.9, 8.4, 8.4, 1.2, 1));
705
+
706
+ // -- visibility, the outliner's right column ------------------------------
707
+ /**
708
+ * The eye is the KNOCKOUT construction the header describes, and it is what
709
+ * `outliner.png` shows at matched scale: one solid almond, a dark iris punched
710
+ * out of it, a bright pupil inside the iris — white / dark / white, three
711
+ * bands that survive a 14px row. Drawn instead as two thin lid strokes with a
712
+ * dot between them (what this was through round 0), every band is a
713
+ * partial-coverage stroke: measured against its own row, the glyph peaked at
714
+ * p95 145 where the label beside it peaked at 185, while Blender's peaks
715
+ * ABOVE its label (151 vs 129). A silhouette reaches full ink; a hairline
716
+ * cannot.
717
+ */
718
+ const eyeLid = [
719
+ [1.8, 8],
720
+ [3.6, 5.3],
721
+ [5.9, 4],
722
+ [8, 3.7],
723
+ [10.1, 4],
724
+ [12.4, 5.3],
725
+ [14.2, 8],
726
+ ];
727
+ const eyeAlmond = [...eyeLid, ...eyeLid.map(([x, y]) => [x, 16 - y]).reverse()];
728
+ const eye = poly(eyeAlmond) + dot(8, 8, 2.9, true) + dot(8, 8, 1.5);
729
+ g('eye', eye);
730
+ g('eye-slash', eye + cap(3.4, 12.6, 12.6, 3.4, 1.35));
731
+ g('lock', rrect(3.4, 7.2, 9.2, 6.4, 1.1) + arc(8, 7.1, 2.75, 180, 360, 1.25) + dot(8, 10.4, 1.1));
732
+ g(
733
+ 'lock-open',
734
+ rrect(3.4, 7.2, 9.2, 6.4, 1.1) + arc(11.1, 7.1, 2.75, 180, 330, 1.25) + dot(8, 10.4, 1.1),
735
+ );
736
+
737
+ // -- content kinds --------------------------------------------------------
738
+ g('cube', isoCube());
739
+ g('cubes', isoCube(5.1, 5.6, 3.5, 0.8) + isoCube(10.9, 10.4, 3.5, 0.8));
740
+ g('cubes-stacked', isoCube(8, 4.6, 3.3, 0.8) + isoCube(8, 11.4, 3.3, 0.8));
741
+ g('square', rframe(3, 3, 10, 10, 1.3));
742
+ g('circle', ring(8, 8, 4.4, 1.3));
743
+ g('circle-dot', dot(8, 8, 6) + dot(8, 8, 3.9, true) + dot(8, 8, 2));
744
+ // Blender's collection: a lidded box with a pull slot.
745
+ // Filled body, filled lid band, and the pull slot KNOCKED OUT of the body —
746
+ // exactly what `outliner.png` shows at matched scale, where Blender's
747
+ // Collection is the brightest mark in the row. Drawn as a 1.25-unit frame
748
+ // under a filled lid, it read as a hairline rectangle no heavier than the
749
+ // label beside it.
750
+ const box =
751
+ rrect(2.6, 5.4, 10.8, 8, 0.6) + rrect(2, 2.4, 12, 2.6, 0.5) + holeBar(6.2, 8.6, 9.8, 8.6, 1.6);
752
+ g('box-archive', box);
753
+ g(
754
+ 'folder',
755
+ line(
756
+ [
757
+ [2.2, 12.9],
758
+ [2.2, 4.1],
759
+ [6.4, 4.1],
760
+ [7.6, 5.7],
761
+ [13.8, 5.7],
762
+ [13.8, 12.9],
763
+ ],
764
+ 1.3,
765
+ true,
766
+ ),
767
+ );
768
+ g(
769
+ 'folder-open',
770
+ line(
771
+ [
772
+ [2.2, 12.9],
773
+ [2.2, 4.1],
774
+ [6.4, 4.1],
775
+ [7.6, 5.7],
776
+ [12.4, 5.7],
777
+ [12.4, 7.6],
778
+ ],
779
+ 1.3,
780
+ ) +
781
+ line(
782
+ [
783
+ [2.2, 12.9],
784
+ [4.4, 7.6],
785
+ [15, 7.6],
786
+ [12.8, 12.9],
787
+ ],
788
+ 1.3,
789
+ true,
790
+ ),
791
+ );
792
+ g(
793
+ 'folder-tree',
794
+ rframe(2, 2.6, 5.6, 4.2, 1.2, 0.5) +
795
+ rframe(8.4, 9.2, 5.6, 4.2, 1.2, 0.5) +
796
+ line(
797
+ [
798
+ [4.8, 6.8],
799
+ [4.8, 11.3],
800
+ [8.4, 11.3],
801
+ ],
802
+ 1.15,
803
+ ),
804
+ );
805
+ const page =
806
+ line(
807
+ [
808
+ [3.4, 1.9],
809
+ [9.2, 1.9],
810
+ [12.6, 5.3],
811
+ [12.6, 14.1],
812
+ [3.4, 14.1],
813
+ ],
814
+ 1.25,
815
+ true,
816
+ ) +
817
+ line(
818
+ [
819
+ [9, 2.1],
820
+ [9, 5.5],
821
+ [12.4, 5.5],
822
+ ],
823
+ 1.15,
824
+ );
825
+ g('file', page);
826
+ g('file-lines', page + cap(5.6, 8, 10.4, 8, 1.1) + cap(5.6, 10.6, 10.4, 10.6, 1.1));
827
+ g(
828
+ 'file-code',
829
+ page +
830
+ line(
831
+ [
832
+ [7.2, 8],
833
+ [5.6, 9.8],
834
+ [7.2, 11.6],
835
+ ],
836
+ 1.1,
837
+ ) +
838
+ line(
839
+ [
840
+ [9.4, 8],
841
+ [11, 9.8],
842
+ [9.4, 11.6],
843
+ ],
844
+ 1.1,
845
+ ),
846
+ );
847
+ g(
848
+ 'file-image',
849
+ page +
850
+ dot(6.6, 8.2, 1) +
851
+ line(
852
+ [
853
+ [5.2, 12.1],
854
+ [7.6, 9.4],
855
+ [9.2, 11.2],
856
+ [10.4, 10.1],
857
+ [12, 12.1],
858
+ ],
859
+ 1.1,
860
+ ),
861
+ );
862
+ g(
863
+ 'file-export',
864
+ page +
865
+ cap(8.4, 10.4, 14.4, 10.4, 1.2) +
866
+ line(
867
+ [
868
+ [12.2, 8.4],
869
+ [14.6, 10.4],
870
+ [12.2, 12.4],
871
+ ],
872
+ 1.2,
873
+ ),
874
+ );
875
+ g(
876
+ 'image',
877
+ rframe(2, 3, 12, 10, 1.25, 0.8) +
878
+ dot(5.8, 6.6, 1.15) +
879
+ line(
880
+ [
881
+ [3.4, 11.6],
882
+ [6.8, 8.1],
883
+ [8.8, 10.2],
884
+ [10.6, 8.4],
885
+ [12.9, 11.2],
886
+ ],
887
+ 1.15,
888
+ ),
889
+ );
890
+ g(
891
+ 'film',
892
+ rframe(1.8, 3, 12.4, 10, 1.25, 0.6) +
893
+ cap(4.6, 3.6, 4.6, 12.4, 1.15) +
894
+ cap(11.4, 3.6, 11.4, 12.4, 1.15) +
895
+ cap(4.6, 8, 11.4, 8, 1.15),
896
+ );
897
+ g(
898
+ 'code',
899
+ line(
900
+ [
901
+ [6, 3.4],
902
+ [2.2, 8],
903
+ [6, 12.6],
904
+ ],
905
+ 1.3,
906
+ ) +
907
+ line(
908
+ [
909
+ [10, 3.4],
910
+ [13.8, 8],
911
+ [10, 12.6],
912
+ ],
913
+ 1.3,
914
+ ),
915
+ );
916
+ g(
917
+ 'terminal',
918
+ line(
919
+ [
920
+ [3, 4.2],
921
+ [7, 8],
922
+ [3, 11.8],
923
+ ],
924
+ 1.3,
925
+ ) + cap(8.4, 12.2, 13.4, 12.2, 1.25),
926
+ );
927
+ g(
928
+ 'table',
929
+ rframe(2, 3, 12, 10, 1.2, 0.5) +
930
+ cap(2.6, 6.4, 13.4, 6.4, 1.15) +
931
+ cap(7.2, 6.4, 7.2, 12.4, 1.15) +
932
+ cap(2.6, 9.6, 13.4, 9.6, 1.15),
933
+ );
934
+ g(
935
+ 'table-cells',
936
+ rframe(2, 3, 12, 10, 1.2, 0.5) +
937
+ cap(2.6, 6.4, 13.4, 6.4, 1.1) +
938
+ cap(2.6, 9.6, 13.4, 9.6, 1.1) +
939
+ cap(6.2, 3.4, 6.2, 12.6, 1.1) +
940
+ cap(9.8, 3.4, 9.8, 12.6, 1.1),
941
+ );
942
+ g(
943
+ 'table-cells-large',
944
+ rframe(2, 3, 12, 10, 1.2, 0.5) + cap(2.6, 8, 13.4, 8, 1.15) + cap(8, 3.4, 8, 12.6, 1.15),
945
+ );
946
+ /**
947
+ * A FLAT 3x3 grid, and it STAYS FLAT. Four sites ask for `border-all` and
948
+ * three of them mean a flat grid — the canvas viewport's grid toggle, the
949
+ * 3D viewport overlay's, and the CSS border editor's "all borders" — so the
950
+ * trapezoid Blender draws on the navigation cluster's projection toggle is
951
+ * NOT this glyph's to become. It gets its own name below, which is the same
952
+ * ruling `editorIcons.modeling`'s docblock already records for the mesh
953
+ * operators: a glyph that IS an operation owns its own name.
954
+ */
955
+ g(
956
+ 'border-all',
957
+ rframe(3.2, 3.2, 9.6, 9.6, 1, 0.4) +
958
+ cap(6.4, 3.7, 6.4, 12.3, 0.85) +
959
+ cap(9.6, 3.7, 9.6, 12.3, 0.85) +
960
+ cap(3.7, 6.4, 12.3, 6.4, 0.85) +
961
+ cap(3.7, 9.6, 12.3, 9.6, 0.85),
962
+ );
963
+ /**
964
+ * THE PROJECTION TOGGLE at the foot of the navigation cluster — Blender's
965
+ * fourth cell, and it is a PERSPECTIVE grid, not a square one. Read off
966
+ * `modeling-edit-none.png` (device x 2774..2805, y 541..572): a symmetric
967
+ * trapezoid 32 device wide by 26 tall, four horizontal rules whose spacing
968
+ * OPENS downward (6, 8, 10 device) and four verticals converging upward, the
969
+ * outer pair running x 6.8 -> 1.5 on the left and 24.2 -> 29.5 on the right.
970
+ * Ours was a flat square 27x26 — the height was already Blender's and the
971
+ * width was 5 device short, because a square cannot be as wide at its foot as
972
+ * a trapezoid.
973
+ *
974
+ * The prior cut said so itself and stopped there, for the right reason: it
975
+ * shared `border-all` with three flat-grid sites. The name is what was in the
976
+ * way, so the name is what moved (`editorIcons.viewport.projection` is now
977
+ * `viewport-projection`, carrying Font Awesome's `border-all` drawing as its
978
+ * fallback, so a set without this key paints exactly what it painted before).
979
+ * THE PRIOR CUT ALSO MIS-COUNTED IT as "four cells across": it is three, in
980
+ * both axes — four rules bound three columns.
981
+ *
982
+ * Every constant below was seeded off that character map and then settled
983
+ * against the cell's own half-coverage mask, the same instrument the wrench
984
+ * and the hand use. Seeded 150, settled 7 of 374 reference ink px (the old
985
+ * square: 316). No constant moved as much as one device pixel from its
986
+ * reading, and the four rows moved by ONE SHARED half-pixel, which is the
987
+ * cell's own centring rather than a fit.
988
+ */
989
+ const perspectiveGrid = () => {
990
+ const cx = 8.01; // the trapezoid's axis
991
+ const yTop = 3.71; // its near and far rules
992
+ const yBot = 12.38;
993
+ const rows = [yTop, 5.78, 8.73, yBot]; // spacing opens toward the viewer
994
+ const hOuterTop = 3.17; // half-widths at each end, outer pair and inner
995
+ const hOuterBot = 5.4;
996
+ const hInnerTop = 0.89;
997
+ const hInnerBot = 1.86;
998
+ const w = 0.74;
999
+ /** A vertical's half-width at depth `y`, linear between the two ends. */
1000
+ const at = (h0, h1, y) => h0 + ((h1 - h0) * (y - yTop)) / (yBot - yTop);
1001
+ return (
1002
+ rows
1003
+ .map((y) => cap(cx - at(hOuterTop, hOuterBot, y), y, cx + at(hOuterTop, hOuterBot, y), y, w))
1004
+ .join('') +
1005
+ [
1006
+ [hOuterTop, hOuterBot],
1007
+ [hInnerTop, hInnerBot],
1008
+ ]
1009
+ .flatMap(([h0, h1]) => [-1, 1].map((s) => cap(cx + s * h0, yTop, cx + s * h1, yBot, w)))
1010
+ .join('')
1011
+ );
1012
+ };
1013
+ g('viewport-projection', perspectiveGrid());
1014
+ g(
1015
+ 'list',
1016
+ dot(3.2, 4.4, 1) +
1017
+ dot(3.2, 8, 1) +
1018
+ dot(3.2, 11.6, 1) +
1019
+ cap(6.2, 4.4, 13.2, 4.4, 1.15) +
1020
+ cap(6.2, 8, 13.2, 8, 1.15) +
1021
+ cap(6.2, 11.6, 13.2, 11.6, 1.15),
1022
+ );
1023
+
1024
+ // -- scene objects: Blender's outliner glyphs ------------------------------
1025
+ g(
1026
+ 'camera',
1027
+ rrect(1.8, 5.2, 8.2, 6.6, 0.9) +
1028
+ poly([
1029
+ [10.4, 8.5],
1030
+ [14.2, 5.4],
1031
+ [14.2, 11.6],
1032
+ ]) +
1033
+ dot(3.9, 3.2, 1.25) +
1034
+ dot(7.3, 3.2, 1.25),
1035
+ );
1036
+ g(
1037
+ 'camera-rotate',
1038
+ rrect(1.6, 4.4, 7.4, 6, 0.9) +
1039
+ poly([
1040
+ [9.4, 7.4],
1041
+ [12.8, 4.6],
1042
+ [12.8, 10.2],
1043
+ ]) +
1044
+ dot(3.5, 2.6, 1.15) +
1045
+ dot(6.6, 2.6, 1.15) +
1046
+ arc(10.4, 11.6, 2.7, 110, 400, 1.2) +
1047
+ poly([
1048
+ [8.4, 9.2],
1049
+ [10.4, 9.4],
1050
+ [8.8, 11.4],
1051
+ ]),
1052
+ );
1053
+ // The Light row in `outliner.png`: the bulb FILLS its 14px slot — a big head,
1054
+ // a wide screw base, a foot. At the old 3.5 head it rendered ~7px of ink in a
1055
+ // 14px row and read as a dim pebble beside its own label.
1056
+ g(
1057
+ 'lightbulb',
1058
+ dot(8, 5.7, 4.3) +
1059
+ rrect(5.7, 8.6, 4.6, 3.1, 0.5) +
1060
+ holeBar(5.9, 9.6, 10.1, 9.6, 0.7) +
1061
+ cap(6.2, 13.1, 9.8, 13.1, 1.5),
1062
+ );
1063
+ g(
1064
+ 'sun',
1065
+ dot(8, 8, 2.9) +
1066
+ [0, 45, 90, 135, 180, 225, 270, 315]
1067
+ .map((a) => {
1068
+ const r = (a * Math.PI) / 180;
1069
+ return cap(
1070
+ 8 + Math.cos(r) * 3,
1071
+ 8 + Math.sin(r) * 3,
1072
+ 8 + Math.cos(r) * 6,
1073
+ 8 + Math.sin(r) * 6,
1074
+ 1.5,
1075
+ );
1076
+ })
1077
+ .join(''),
1078
+ );
1079
+ g(
1080
+ 'cloud-sun',
1081
+ dot(10.6, 4.4, 2.1) +
1082
+ [215, 270, 325, 20]
1083
+ .map((a) => {
1084
+ const r = (a * Math.PI) / 180;
1085
+ return cap(
1086
+ 10.6 + Math.cos(r) * 3,
1087
+ 4.4 + Math.sin(r) * 3,
1088
+ 10.6 + Math.cos(r) * 4.4,
1089
+ 4.4 + Math.sin(r) * 4.4,
1090
+ 1.15,
1091
+ );
1092
+ })
1093
+ .join('') +
1094
+ dot(4.8, 10.2, 2.4) +
1095
+ dot(7.6, 9.4, 2.9) +
1096
+ dot(10, 10.8, 2) +
1097
+ rrect(3.8, 10.2, 7.6, 2.6, 1.2),
1098
+ );
1099
+ g(
1100
+ 'globe',
1101
+ ring(8, 8, 5.3, 1.25) +
1102
+ cap(2.7, 8, 13.3, 8, 1.1) +
1103
+ ellipseArc(8, 8, 2.6, 5.3, 0, 360, 1.1, 0, true),
1104
+ );
1105
+ g(
1106
+ 'person',
1107
+ dot(8, 3.4, 1.9) +
1108
+ cap(8, 5.6, 8, 9.6, 1.4) +
1109
+ cap(4.4, 6.8, 11.6, 6.8, 1.25) +
1110
+ cap(8, 9.4, 5.4, 13.4, 1.3) +
1111
+ cap(8, 9.4, 10.6, 13.4, 1.3),
1112
+ );
1113
+ g(
1114
+ 'person-running',
1115
+ dot(10, 3.2, 1.8) +
1116
+ cap(10.4, 5.4, 7.2, 8.4, 1.5) +
1117
+ cap(7.2, 8.4, 8.8, 10.8, 1.4) +
1118
+ cap(8.8, 10.8, 8.4, 14, 1.4) +
1119
+ cap(7.2, 8.4, 3.4, 9.6, 1.35) +
1120
+ cap(3.4, 9.6, 2.8, 12.8, 1.3) +
1121
+ cap(10.6, 5.8, 13.6, 7.8, 1.3),
1122
+ );
1123
+ g(
1124
+ 'person-falling',
1125
+ dot(11.8, 3.4, 1.8) +
1126
+ cap(10.8, 5.6, 6, 7.4, 1.5) +
1127
+ cap(6, 7.4, 2.4, 5.4, 1.35) +
1128
+ cap(6, 7.4, 7.6, 10.8, 1.4) +
1129
+ cap(7.6, 10.8, 4.6, 13.2, 1.35) +
1130
+ cap(7.6, 10.8, 12.4, 12, 1.35),
1131
+ );
1132
+ g(
1133
+ 'bone',
1134
+ poly([
1135
+ [8, 1.6],
1136
+ [10.6, 5],
1137
+ [8, 8],
1138
+ [5.4, 5],
1139
+ ]) +
1140
+ line(
1141
+ [
1142
+ [8, 8],
1143
+ [10.6, 5],
1144
+ [8, 1.6],
1145
+ [5.4, 5],
1146
+ [8, 8],
1147
+ [8, 14.4],
1148
+ ],
1149
+ 1.2,
1150
+ ) +
1151
+ line(
1152
+ [
1153
+ [10.6, 5],
1154
+ [8, 14.4],
1155
+ [5.4, 5],
1156
+ ],
1157
+ 1.2,
1158
+ ) +
1159
+ dot(8, 14.4, 1.1),
1160
+ );
1161
+
1162
+ // -- status ----------------------------------------------------------------
1163
+ g('circle-info', dot(8, 8, 6) + dot(8, 4.4, 1.05, true) + holeBar(8, 6.6, 8, 11.6, 1.9));
1164
+ g(
1165
+ 'circle-check',
1166
+ dot(8, 8, 6) + holeBar(4.8, 8.1, 7, 10.4, 1.9) + holeBar(6.6, 10.4, 11.2, 5.5, 1.9),
1167
+ );
1168
+ g('circle-exclamation', dot(8, 8, 6) + holeBar(8, 4.2, 8, 9.4, 1.9) + dot(8, 11.5, 1.05, true));
1169
+ g(
1170
+ 'circle-xmark',
1171
+ dot(8, 8, 6) + holeBar(5.4, 5.4, 10.6, 10.6, 1.9) + holeBar(10.6, 5.4, 5.4, 10.6, 1.9),
1172
+ );
1173
+ g('circle-minus', dot(8, 8, 6) + holeBar(4.8, 8, 11.2, 8, 1.9));
1174
+ g(
1175
+ 'circle-play',
1176
+ dot(8, 8, 6) +
1177
+ holePoly([
1178
+ [6.2, 4.8],
1179
+ [11.4, 8],
1180
+ [6.2, 11.2],
1181
+ ]),
1182
+ );
1183
+ g('circle-half-stroke', ring(8, 8, 5.4, 1.2) + wedge(8, 8, 4.9, 90, 270));
1184
+ g(
1185
+ 'triangle-exclamation',
1186
+ poly([
1187
+ [8, 1.9],
1188
+ [15, 13.8],
1189
+ [1, 13.8],
1190
+ ]) +
1191
+ holeBar(8, 6.2, 8, 10, 1.9) +
1192
+ dot(8, 11.9, 1.05, true),
1193
+ );
1194
+ g(
1195
+ 'spinner',
1196
+ [0, 45, 90, 135, 180, 225, 270, 315]
1197
+ .map((a, i) => {
1198
+ const r = (a * Math.PI) / 180;
1199
+ return dot(8 + Math.cos(r) * 5.1, 8 + Math.sin(r) * 5.1, 0.85 + i * 0.11);
1200
+ })
1201
+ .join(''),
1202
+ );
1203
+
1204
+ // -- transport -------------------------------------------------------------
1205
+ g(
1206
+ 'play',
1207
+ poly([
1208
+ [4.4, 2.8],
1209
+ [13.2, 8],
1210
+ [4.4, 13.2],
1211
+ ]),
1212
+ );
1213
+ g('pause', rrect(4, 3, 3, 10, 0.4) + rrect(9, 3, 3, 10, 0.4));
1214
+ g('stop', rrect(3.4, 3.4, 9.2, 9.2, 0.5));
1215
+ g(
1216
+ 'forward-step',
1217
+ poly([
1218
+ [3.6, 3.2],
1219
+ [11, 8],
1220
+ [3.6, 12.8],
1221
+ ]) + rrect(11.4, 3.2, 2.2, 9.6, 0.4),
1222
+ );
1223
+
1224
+ // -- tools -----------------------------------------------------------------
1225
+ g(
1226
+ 'arrow-pointer',
1227
+ poly([
1228
+ [4.2, 1.9],
1229
+ [4.2, 12.4],
1230
+ [6.9, 9.9],
1231
+ [8.7, 14.1],
1232
+ [10.9, 13.1],
1233
+ [9.1, 9],
1234
+ [12.6, 8.6],
1235
+ ]),
1236
+ );
1237
+ // Blender's pan hand (`modeling-object-none.png`) is COMPACT: ~13 px of ink in
1238
+ // the cluster's 16 px slot. Ours spanned 1.8..14.2, which the scale dial pushes
1239
+ // to ~15 px rendered — a hand a full size larger than the zoom beside it.
1240
+ g(
1241
+ 'hand-pointer',
1242
+ // Blender's pan hand measures 15x15 in the cluster at matched scale — SQUARE.
1243
+ // Round 2's shrink left ours 18x20: right width, three pixels too tall,
1244
+ // because the middle finger still reached the top of the box.
1245
+ rrect(6.7, 3.9, 2.8, 5.8, 1.4) + rrect(4.9, 8, 8.2, 5.1, 2) + dot(5.2, 9.3, 1.3),
1246
+ );
1247
+ /**
1248
+ * THE PAN TOOL'S OWN GLYPH. `editorIcons.viewport.pan` is `faHand`, whose
1249
+ * iconName is `hand` — a name this set did not carry, so the navigation
1250
+ * cluster drew Font Awesome's hand and nothing else on the Model workspace
1251
+ * did (checked by resolving every `fa*` identifier in `packages/editor/src`
1252
+ * and `packages/mesh/contributions` to its `iconName` and differencing
1253
+ * against these keys: `hand` was the only genuine miss; `faExclamationTriangle`
1254
+ * and `faTimesCircle` are FA-5 aliases that resolve to `triangle-exclamation`
1255
+ * and `circle-xmark`, both present).
1256
+ *
1257
+ * RE-MEASURED 2026-09-19 against the capsule's SECOND CELL on
1258
+ * `modeling-edit-none.png` (device x 2774..2805, y 421..452 — the cluster's
1259
+ * cells are 32x32 device and this one's glyph FILLS it, 32x32 of ink). Ours
1260
+ * inked 28x27: "15x15 in a 16 px slot" was a reading of an earlier frame and
1261
+ * of the wrong pixels; Blender's pan hand is 16.0 x 16.0 CSS, the same box its
1262
+ * magnifier fills, which is the whole point — the cluster's glyphs are one
1263
+ * size. Three things were wrong besides the size and each is visible at 8x:
1264
+ * the fingers were PARALLEL and touching where Blender SPLAYS them with a
1265
+ * clear gap (the index leans right going down, the ring leans left, only the
1266
+ * middle is vertical); the thumb was a horizontal block at the palm's waist
1267
+ * where Blender's points LEFT and DOWN out of the palm's lower third; and the
1268
+ * palm was a flat-bottomed box where Blender's bottom is a wide dome. Together
1269
+ * they read as a mitten.
1270
+ *
1271
+ * FITTED THE WAY `modifierWrench` WAS: the structure read off the cell as a
1272
+ * character map first (four splayed finger capsules, a domed palm, a thumb),
1273
+ * then every constant settled by rendering at the reference's own scale and
1274
+ * counting the pixels where the two HALF-COVERAGE masks disagree. Seeded from
1275
+ * the map alone it scored 106 of 534 reference ink px against the old glyph's
1276
+ * 277; settled, 39, of which seven are the dome's last row. Every finger constant stayed within a device pixel of its
1277
+ * own reading. THE PALM'S DID NOT, and that is honest rather than hidden: an
1278
+ * axis-aligned rounded rect cannot be Blender's asymmetric dome (the thumb's
1279
+ * mass makes its bottom-left tighter than its bottom-right), so the fit moved
1280
+ * the palm 2 device px left to trade that error against the thumb's. The one
1281
+ * run of reference ink we do not cover is the dome's last row.
1282
+ *
1283
+ * THE INSTRUMENT, stated because it decides every number here: our render box
1284
+ * is the `2xl` rung, 18 CSS = 36 device, while Blender's cell is 32 — so a
1285
+ * comparison crops our 36 px raster to its central 32, and a glyph that FILLS
1286
+ * Blender's cell occupies only 32/36 of our own viewBox. Reading ours at a
1287
+ * loose coverage cut instead of half makes it measure a pixel wider than it
1288
+ * is, which is the trap the mesh-data glyph's note already records.
1289
+ */
1290
+ g(
1291
+ 'hand',
1292
+ // Four fingers, each a capsule from its own cap centre down into the palm.
1293
+ cap(5.81, 3.63, 6.4, 8.76, 1.33) + // index, leaning right going down
1294
+ cap(8.73, 2.9, 8.73, 8.36, 1.33) + // middle — the tallest, and the only vertical one
1295
+ cap(11.64, 3.63, 10.81, 7.96, 1.33) + // ring, leaning the other way
1296
+ cap(13.28, 6.34, 12.34, 8.76, 1.33) + // pinky, starting well below the rest
1297
+ rrect(4.65, 7.6, 7.62, 5.83, 2.95) + // the palm
1298
+ cap(3.3, 8.99, 5.38, 10.82, 2.0), // the thumb, out of the palm's lower third
1299
+ );
1300
+ g(
1301
+ /**
1302
+ * The MOVE tool. Blender's (`modeling-edit-none.png`, tool shelf) is four
1303
+ * SOLID arrowheads standing apart on short stubs around an empty centre —
1304
+ * not a crosshair. Ours was one continuous 1.2-unit cross with 1.15-unit
1305
+ * chevrons on its ends, which at matched scale is a thin plus sign with
1306
+ * ticks: a different mark, and the lightest thing on the rail.
1307
+ *
1308
+ * RE-MEASURED 2026-09-19 off the same frame, this time as an ASCII map in
1309
+ * AUTHORED units (0.5/char) rather than by eye: the previous fit was RIGHT
1310
+ * IN FORM AND WRONG IN SIZE AND PARTS. Ours inked 35x34 device px against
1311
+ * Blender's 46x46 — 74% — because the heads stopped at radius 6.4 where
1312
+ * Blender's tips reach the box edge (radius 8, base at 5.5, half-width
1313
+ * 2.4); the STUBS were missing entirely (a 1.0-unit bar from radius 2.5 to
1314
+ * 4.6 under each head); and a 1.35 centre dot was drawn where Blender's
1315
+ * centre is empty. Size is not a detail on this rail — the same frame's
1316
+ * inset and bevel marks ink 40x40 and ours match them to a pixel, so the
1317
+ * transform group was the odd one out in its own column.
1318
+ */
1319
+ 'up-down-left-right',
1320
+ [
1321
+ [0, -1],
1322
+ [0, 1],
1323
+ [-1, 0],
1324
+ [1, 0],
1325
+ ]
1326
+ .map(
1327
+ ([dx, dy]) =>
1328
+ poly([
1329
+ [8 + dx * 5.4 - dy * 2.45, 8 + dy * 5.4 + dx * 2.45],
1330
+ [8 + dx * 5.4 + dy * 2.45, 8 + dy * 5.4 - dx * 2.45],
1331
+ [8 + dx * 8.4, 8 + dy * 8.4],
1332
+ ]) + cap(8 + dx * 2.6, 8 + dy * 2.6, 8 + dx * 5.05, 8 + dy * 5.05, 0.96),
1333
+ )
1334
+ .join(''),
1335
+ );
1336
+ g('arrows-up-down-left-right', glyphs['up-down-left-right'].path);
1337
+ g(
1338
+ /**
1339
+ * The ROTATE tool. Blender draws a heavy arc with a SOLID arrowhead at each
1340
+ * end and a solid diamond at the pivot; ours was a 1.3-unit open circle with
1341
+ * one small tick, which at matched scale is an empty ring — the pivot, the
1342
+ * direction and the weight all missing.
1343
+ *
1344
+ * RE-MEASURED 2026-09-19 (ASCII map in authored units, and the cell read at
1345
+ * 8x): it is TWO arcs, not one — the top arc ends at the LEFT in a
1346
+ * DOWN-pointing triangle and the bottom arc ends at the RIGHT in an
1347
+ * UP-pointing one, the ordinary two-headed circular arrow, where the
1348
+ * previous fit drew one dome with both heads under its ends and no bottom
1349
+ * arc at all. And it was 72% of Blender's size (33x29 device against
1350
+ * 46x38): the ring's radius is 6.5 of the 16 box, not 4.3.
1351
+ */
1352
+ 'rotate',
1353
+ arc(8, 7.8, 7.0, 200, 336, 0.8) +
1354
+ arc(8, 7.8, 7.0, 20, 156, 0.8) +
1355
+ // The head SITS ON the arc's end and points across it, so the two read as
1356
+ // one stroke that turns into a point rather than as an arc with a dart
1357
+ // beside it.
1358
+ poly([
1359
+ [0, 6.7],
1360
+ [3.3, 6.7],
1361
+ [1.65, 9.9],
1362
+ ]) +
1363
+ poly([
1364
+ [12.6, 9.3],
1365
+ [15.9, 9.3],
1366
+ [14.25, 6.1],
1367
+ ]) +
1368
+ poly([
1369
+ [7.75, 4.9],
1370
+ [10.05, 7.65],
1371
+ [7.75, 10.4],
1372
+ [5.45, 7.65],
1373
+ ]),
1374
+ );
1375
+ g(
1376
+ 'rotate-left',
1377
+ arc(8, 8, 5, 240, -60, 1.3) +
1378
+ poly([
1379
+ [5.4, 1.6],
1380
+ [2.4, 4.8],
1381
+ [6.6, 5.8],
1382
+ ]),
1383
+ );
1384
+ g(
1385
+ 'rotate-right',
1386
+ arc(8, 8, 5, -60, 240, 1.3) +
1387
+ poly([
1388
+ [10.6, 1.6],
1389
+ [13.6, 4.8],
1390
+ [9.4, 5.8],
1391
+ ]),
1392
+ );
1393
+ g(
1394
+ 'arrows-rotate',
1395
+ arc(8, 8, 5, 150, 330, 1.25) +
1396
+ arc(8, 8, 5, -30, 150, 1.25) +
1397
+ poly([
1398
+ [11.2, 1.8],
1399
+ [13.8, 5.4],
1400
+ [9.6, 5.6],
1401
+ ]) +
1402
+ poly([
1403
+ [4.8, 14.2],
1404
+ [2.2, 10.6],
1405
+ [6.4, 10.4],
1406
+ ]),
1407
+ );
1408
+ g(
1409
+ 'crosshairs',
1410
+ ring(8, 8, 4.2, 1.2) +
1411
+ cap(8, 1.6, 8, 5, 1.2) +
1412
+ cap(8, 11, 8, 14.4, 1.2) +
1413
+ cap(1.6, 8, 5, 8, 1.2) +
1414
+ cap(11, 8, 14.4, 8, 1.2),
1415
+ );
1416
+ g('bullseye', ring(8, 8, 5.4, 1.2) + ring(8, 8, 2.9, 1.2) + dot(8, 8, 1.2));
1417
+ g(
1418
+ 'arrows-to-dot',
1419
+ dot(8, 8, 1.4) +
1420
+ cap(8, 1.8, 8, 4.4, 1.2) +
1421
+ cap(8, 11.6, 8, 14.2, 1.2) +
1422
+ cap(1.8, 8, 4.4, 8, 1.2) +
1423
+ cap(11.6, 8, 14.2, 8, 1.2) +
1424
+ line(
1425
+ [
1426
+ [6.6, 3.2],
1427
+ [8, 4.6],
1428
+ [9.4, 3.2],
1429
+ ],
1430
+ 1.1,
1431
+ ) +
1432
+ line(
1433
+ [
1434
+ [6.6, 12.8],
1435
+ [8, 11.4],
1436
+ [9.4, 12.8],
1437
+ ],
1438
+ 1.1,
1439
+ ) +
1440
+ line(
1441
+ [
1442
+ [3.2, 6.6],
1443
+ [4.6, 8],
1444
+ [3.2, 9.4],
1445
+ ],
1446
+ 1.1,
1447
+ ) +
1448
+ line(
1449
+ [
1450
+ [12.8, 6.6],
1451
+ [11.4, 8],
1452
+ [12.8, 9.4],
1453
+ ],
1454
+ 1.1,
1455
+ ),
1456
+ );
1457
+
1458
+ /**
1459
+ * THE OTHER TWO TOOLS OF BLENDER'S TRANSFORM GROUP, under names of ours.
1460
+ *
1461
+ * Move and Rotate above are shared Font Awesome names (`up-down-left-right`,
1462
+ * `rotate`) and their Blender drawings say the same thing everywhere they
1463
+ * appear, so those stay keyed to the shared name. Scale and Transform do not:
1464
+ * the host's Scale tool asked for `maximize` (Font Awesome's window-expand
1465
+ * cross) and its Transform tool for `arrows-to-dot` (a crosshair), and
1466
+ * Blender's marks for the two — a small square growing into a bigger one, and
1467
+ * a square inside a broken ring of arrows — are meaningless under those
1468
+ * generic meanings. Same reasoning as `editorIcons.modeling`'s `mesh-*`:
1469
+ * a glyph that IS an operation needs its own name or it recuts every other
1470
+ * site that asked for the generic one.
1471
+ */
1472
+ g(
1473
+ /**
1474
+ * SCALE. Measured on `modeling-edit-none.png` (button 5) as an ASCII map in
1475
+ * authored units: a 1-unit square OUTLINE from (2.0, 1.5) to (14.0, 13.5),
1476
+ * a SOLID square from (1.5, 7.5) to (8.0, 14.0) overlapping its lower-left
1477
+ * corner, and a diagonal arrow inside the frame running up-and-right from
1478
+ * (8.6, 6.9) to a head whose apex is (12.4, 3.1).
1479
+ */
1480
+ 'tool-scale',
1481
+ rframe(2, 1.5, 12, 12, 0.8) +
1482
+ rrect(1.5, 7.6, 6.3, 6.3, 0.2) +
1483
+ cap(8.6, 6.9, 10.7, 4.8, 0.95) +
1484
+ poly([
1485
+ [12.4, 3.1],
1486
+ [11.5, 6.2],
1487
+ [9.3, 4],
1488
+ ]),
1489
+ );
1490
+ g(
1491
+ /**
1492
+ * TRANSFORM (all handles). Blender's (button 6): a solid square from
1493
+ * (5.0, 5.0) to (10.5, 10.5), four solid arrowheads whose tips touch the
1494
+ * box edge with bases 3.0 units in and half-width 1.6, and FOUR ARCS on a
1495
+ * radius-7.0 circle filling the 50° between one head and the next — the
1496
+ * ring is what says "all of them at once", and it is why this is not the
1497
+ * Move mark with a box in it.
1498
+ */
1499
+ 'tool-transform',
1500
+ rrect(5.15, 5.15, 5.7, 5.7, 0.25) +
1501
+ [
1502
+ [0, -1],
1503
+ [0, 1],
1504
+ [-1, 0],
1505
+ [1, 0],
1506
+ ]
1507
+ .map(([dx, dy]) =>
1508
+ poly([
1509
+ [8 + dx * 5.4 - dy * 1.7, 8 + dy * 5.4 + dx * 1.7],
1510
+ [8 + dx * 5.4 + dy * 1.7, 8 + dy * 5.4 - dx * 1.7],
1511
+ [8 + dx * 7.9, 8 + dy * 7.9],
1512
+ ]),
1513
+ )
1514
+ .join('') +
1515
+ [20, 110, 200, 290].map((a) => arc(8, 8, 7.1, a, a + 48, 0.92)).join(''),
1516
+ );
1517
+ g(
1518
+ 'magnet',
1519
+ arc(8, 7.6, 4.7, 180, 360, 1.5) +
1520
+ cap(3.3, 7.6, 3.3, 11.4, 1.5) +
1521
+ cap(12.7, 7.6, 12.7, 11.4, 1.5) +
1522
+ cap(1.9, 12.6, 4.7, 12.6, 1.7) +
1523
+ cap(11.3, 12.6, 14.1, 12.6, 1.7),
1524
+ );
1525
+ g(
1526
+ 'expand',
1527
+ line(
1528
+ [
1529
+ [6.2, 2.4],
1530
+ [2.4, 2.4],
1531
+ [2.4, 6.2],
1532
+ ],
1533
+ 1.3,
1534
+ ) +
1535
+ line(
1536
+ [
1537
+ [9.8, 2.4],
1538
+ [13.6, 2.4],
1539
+ [13.6, 6.2],
1540
+ ],
1541
+ 1.3,
1542
+ ) +
1543
+ line(
1544
+ [
1545
+ [6.2, 13.6],
1546
+ [2.4, 13.6],
1547
+ [2.4, 9.8],
1548
+ ],
1549
+ 1.3,
1550
+ ) +
1551
+ line(
1552
+ [
1553
+ [9.8, 13.6],
1554
+ [13.6, 13.6],
1555
+ [13.6, 9.8],
1556
+ ],
1557
+ 1.3,
1558
+ ),
1559
+ );
1560
+ g(
1561
+ 'compress',
1562
+ line(
1563
+ [
1564
+ [2.4, 6.2],
1565
+ [6.2, 6.2],
1566
+ [6.2, 2.4],
1567
+ ],
1568
+ 1.3,
1569
+ ) +
1570
+ line(
1571
+ [
1572
+ [13.6, 6.2],
1573
+ [9.8, 6.2],
1574
+ [9.8, 2.4],
1575
+ ],
1576
+ 1.3,
1577
+ ) +
1578
+ line(
1579
+ [
1580
+ [2.4, 9.8],
1581
+ [6.2, 9.8],
1582
+ [6.2, 13.6],
1583
+ ],
1584
+ 1.3,
1585
+ ) +
1586
+ line(
1587
+ [
1588
+ [13.6, 9.8],
1589
+ [9.8, 9.8],
1590
+ [9.8, 13.6],
1591
+ ],
1592
+ 1.3,
1593
+ ),
1594
+ );
1595
+ g('maximize', glyphs['expand'].path);
1596
+ g(
1597
+ 'up-right-and-down-left-from-center',
1598
+ cap(3.2, 12.8, 12.8, 3.2, 1.25) +
1599
+ line(
1600
+ [
1601
+ [9, 2.6],
1602
+ [13.4, 2.6],
1603
+ [13.4, 7],
1604
+ ],
1605
+ 1.25,
1606
+ ) +
1607
+ line(
1608
+ [
1609
+ [7, 13.4],
1610
+ [2.6, 13.4],
1611
+ [2.6, 9],
1612
+ ],
1613
+ 1.25,
1614
+ ),
1615
+ );
1616
+ g('window-minimize', cap(3, 11.4, 13, 11.4, 1.4));
1617
+ g(
1618
+ 'window-restore',
1619
+ rframe(2.2, 5.4, 8.4, 8.4, 1.2, 0.5) +
1620
+ line(
1621
+ [
1622
+ [5.8, 5],
1623
+ [5.8, 2.4],
1624
+ [13.8, 2.4],
1625
+ [13.8, 10.2],
1626
+ [11.2, 10.2],
1627
+ ],
1628
+ 1.2,
1629
+ ),
1630
+ );
1631
+
1632
+ // -- modeling operators ----------------------------------------------------
1633
+ g(
1634
+ 'arrow-up-from-bracket',
1635
+ cap(8, 2.4, 8, 10, 1.35) +
1636
+ line(
1637
+ [
1638
+ [4.8, 5.4],
1639
+ [8, 2.2],
1640
+ [11.2, 5.4],
1641
+ ],
1642
+ 1.3,
1643
+ ) +
1644
+ line(
1645
+ [
1646
+ [3, 9.6],
1647
+ [3, 13.6],
1648
+ [13, 13.6],
1649
+ [13, 9.6],
1650
+ ],
1651
+ 1.25,
1652
+ ),
1653
+ );
1654
+ g(
1655
+ 'border-top-left',
1656
+ line(
1657
+ [
1658
+ [2.4, 13.6],
1659
+ [2.4, 2.4],
1660
+ [13.6, 2.4],
1661
+ ],
1662
+ 1.5,
1663
+ ) +
1664
+ dot(6.6, 9.4, 0.95) +
1665
+ dot(9.4, 6.6, 0.95) +
1666
+ dot(9.4, 9.4, 0.95) +
1667
+ dot(12.9, 9.4, 0.95) +
1668
+ dot(9.4, 12.9, 0.95) +
1669
+ dot(12.9, 12.9, 0.95),
1670
+ );
1671
+ g(
1672
+ 'scissors',
1673
+ ring(4.1, 12, 1.9, 1.15) +
1674
+ ring(11.9, 12, 1.9, 1.15) +
1675
+ cap(3.4, 2.2, 10.9, 10.4, 1.25) +
1676
+ cap(12.6, 2.2, 5.1, 10.4, 1.25),
1677
+ );
1678
+ g(
1679
+ 'hammer',
1680
+ cap(3, 13.8, 9, 7.2, 1.9) +
1681
+ poly([
1682
+ [7.6, 5.8],
1683
+ [10.4, 2.2],
1684
+ [14.2, 5.2],
1685
+ [11.4, 8.8],
1686
+ ]),
1687
+ );
1688
+ g(
1689
+ /**
1690
+ * The Properties TOOL tab. Blender's (`properties-object.png`, top of the
1691
+ * tab rail, 14x14 at matched scale) is two tools standing UPRIGHT side by
1692
+ * side — a screwdriver and an open-end wrench, both solid. Ours was one
1693
+ * diagonal shaft with a ring at one end and a wedge at the other: at 14px it
1694
+ * reads as a single key, and the ring is an outline that greys out.
1695
+ */
1696
+ 'screwdriver-wrench',
1697
+ // screwdriver: blade, collar, handle
1698
+ rrect(3.5, 2.6, 1.4, 4.6, 0.3) +
1699
+ rrect(2.9, 7.2, 2.6, 1.2, 0.3) +
1700
+ rrect(3.1, 8.4, 2.2, 5, 0.8) +
1701
+ // wrench: a forked head over a shaft. The fork must be WIDE enough to
1702
+ // survive 14px — at 1.7 units it closed up and the head read as a block.
1703
+ rrect(9.6, 6.6, 2.8, 6.8, 0.9) +
1704
+ rrect(8.4, 2.6, 5.2, 4.6, 0.5) +
1705
+ holeBar(11, 2, 11, 4.9, 2.2),
1706
+ );
1707
+ g('puzzle-piece', rrect(2.6, 2.6, 10.8, 10.8, 1) + dot(13.6, 6.5, 1.9) + dot(8, 2.6, 1.9, true));
1708
+ g(
1709
+ 'sliders',
1710
+ cap(2.4, 4.4, 13.6, 4.4, 1.2) +
1711
+ cap(2.4, 8, 13.6, 8, 1.2) +
1712
+ cap(2.4, 11.6, 13.6, 11.6, 1.2) +
1713
+ dot(5.4, 4.4, 1.6) +
1714
+ dot(10.4, 8, 1.6) +
1715
+ dot(6.6, 11.6, 1.6),
1716
+ );
1717
+ g(
1718
+ 'pen-ruler',
1719
+ cap(3, 13, 10.6, 5.4, 2.1) +
1720
+ poly([
1721
+ [1.2, 14.8],
1722
+ [2.9, 10.8],
1723
+ [5.2, 13.1],
1724
+ ]) +
1725
+ poly([
1726
+ [10.2, 4.6],
1727
+ [12.2, 2.6],
1728
+ [13.4, 3.8],
1729
+ [11.4, 5.8],
1730
+ ]) +
1731
+ cap(11.6, 2, 14, 4.4, 1.6),
1732
+ );
1733
+ g(
1734
+ /**
1735
+ * The MATERIAL section's mark (`inspection/compose.ts` maps /material/ here).
1736
+ * Blender's Material tab is a SOLID disc with quadrants knocked out of it;
1737
+ * ours was a 1.3-unit outline with three dots inside, and at the 14px rail
1738
+ * the outline greyed and the dots merged — it read as a cookie. Same
1739
+ * palette silhouette, drawn Blender's way: one filled blob, wells and thumb
1740
+ * hole punched THROUGH it.
1741
+ */
1742
+ 'palette',
1743
+ // One blob with the wells and the thumb hole punched THROUGH it. Round 3
1744
+ // grew a handle off the bottom to sell the palette's notch and the glyph came
1745
+ // back a mushroom; Blender's Material tab is a compact rounded mass and this
1746
+ // matches that silhouette.
1747
+ poly(
1748
+ Array.from({ length: 28 }, (_, i) => {
1749
+ const a = (i / 28) * Math.PI * 2;
1750
+ return [8 + Math.cos(a) * 6, 8.2 + Math.sin(a) * 5.4];
1751
+ }),
1752
+ ) +
1753
+ dot(5.2, 6, 1.3, true) +
1754
+ dot(8.4, 4.9, 1.3, true) +
1755
+ dot(11.3, 6.6, 1.3, true) +
1756
+ dot(10.4, 10.7, 2, true),
1757
+ );
1758
+ g(
1759
+ // A stack of plates: the top one SOLID (the silhouette that carries the
1760
+ // glyph at a 14px row), the two beneath it strokes that read as its shadow.
1761
+ // Drawn as three outlines it was three grey lozenges with nothing solid in
1762
+ // any of them — the same failure the Collection box had.
1763
+ 'layer-group',
1764
+ poly([
1765
+ [8, 2],
1766
+ [14.2, 5.3],
1767
+ [8, 8.6],
1768
+ [1.8, 5.3],
1769
+ ]) +
1770
+ line(
1771
+ [
1772
+ [2.6, 8.1],
1773
+ [8, 10.9],
1774
+ [13.4, 8.1],
1775
+ ],
1776
+ 1.35,
1777
+ ) +
1778
+ line(
1779
+ [
1780
+ [2.6, 11],
1781
+ [8, 13.8],
1782
+ [13.4, 11],
1783
+ ],
1784
+ 1.35,
1785
+ ),
1786
+ );
1787
+ g(
1788
+ // The GROUP row's mark. Beside Blender's Collection box in `outliner.png` at
1789
+ // matched scale, a 1.1-unit frame with four nubs was indistinguishable from
1790
+ // the letterforms of its own label — same weight, same grey. Blender's is a
1791
+ // solid box: a filled band for the frame, and solid corner handles.
1792
+ 'object-group',
1793
+ rframe(2.4, 3.8, 11.2, 8.4, 2.1, 0.3) +
1794
+ rrect(0.6, 2, 3.1, 3.1, 0.3) +
1795
+ rrect(12.3, 2, 3.1, 3.1, 0.3) +
1796
+ rrect(0.6, 10.9, 3.1, 3.1, 0.3) +
1797
+ rrect(12.3, 10.9, 3.1, 3.1, 0.3),
1798
+ );
1799
+
1800
+ // -- the mesh operators: THE OPERATION, ON A CUBE ---------------------------
1801
+ /**
1802
+ * `editorIcons.modeling.*` asks for `mesh-*` (see that file's header), so these
1803
+ * six are free to be what Blender's toolbar draws: not an abstraction of the
1804
+ * verb (an up-arrow for Extrude, scissors for Knife) but THE OPERATION PERFORMED
1805
+ * ON A CUBE — a cube with one face pulled out, a cube with an inset face, a cube
1806
+ * with a chamfered edge, a cube with a cut running round it. Measured in
1807
+ * `modeling-edit-none.png` (2x, halved): each glyph inks ~20x20 CSS px in a
1808
+ * 38x34 button (Extrude 20x23 — its lifted slab is taller), the cube is a
1809
+ * ~2 px wire with the operated element FILLED, and the cube sits in three-quarter
1810
+ * view: a front square with a top and a right face receding up-and-right.
1811
+ *
1812
+ * MONOCHROME, which is this set's contract (`blender.style.ts`) and a real
1813
+ * difference from the reference: Blender tints the operated element (green for
1814
+ * these six, purple for Smooth) and reads the operation by COLOUR as much as by
1815
+ * shape. Here the same job is done by FILL — the operated face is solid against
1816
+ * the wire — and whether the set gains an accent channel is its own decision,
1817
+ * not one to smuggle in through six glyphs.
1818
+ */
1819
+
1820
+ /**
1821
+ * THE OPERATOR CUBE, once. `P(u, v, k)` is a point in the cube's own axes: `u`
1822
+ * across the front face, `v` up it, `k` back along the depth the top and right
1823
+ * faces recede along. Every one of the six is authored in those three, so the
1824
+ * cube can be re-measured in one place and all six follow.
1825
+ *
1826
+ * It fills the 16 grid corner to corner (~14 units of extent), because the
1827
+ * toolbar's glyph is the biggest in the chrome: that is the ~20 CSS px of ink
1828
+ * Blender's 38x34 button carries, once the file's `S` dial is applied.
1829
+ */
1830
+ const CUBE = { x0: 1.1, x1: 10.9, yb: 14.9, yt: 5.1, dx: 3.9, dy: -3.9 };
1831
+ const P = (u, v, k = 0) => [
1832
+ CUBE.x0 + (CUBE.x1 - CUBE.x0) * u + CUBE.dx * k,
1833
+ CUBE.yb + (CUBE.yt - CUBE.yb) * v + CUBE.dy * k,
1834
+ ];
1835
+
1836
+ /**
1837
+ * The cube as WIRE — the nine edges a cube shows from here, named so an
1838
+ * operator can drop the one it replaces (Bevel replaces the top-front edge,
1839
+ * Extrude takes the whole top away with the face it pulled out). `top` is the
1840
+ * v the box stops at, so Extrude's body can be shorter than the others' and
1841
+ * still leave the glyph the same total ink.
1842
+ */
1843
+ function cubeWire({ top = 1, w = 1.15, omit = [], u0 = 0, u1 = 1 } = {}) {
1844
+ const edges = {
1845
+ frontBottom: [P(u0, 0), P(u1, 0)],
1846
+ frontRight: [P(u1, 0), P(u1, top)],
1847
+ frontTop: [P(u0, top), P(u1, top)],
1848
+ frontLeft: [P(u0, 0), P(u0, top)],
1849
+ topLeft: [P(u0, top), P(u0, top, 1)],
1850
+ topBack: [P(u0, top, 1), P(u1, top, 1)],
1851
+ rightBack: [P(u1, top, 1), P(u1, 0, 1)],
1852
+ rightBottom: [P(u1, 0), P(u1, 0, 1)],
1853
+ seam: [P(u1, top), P(u1, top, 1)],
1854
+ };
1855
+ return Object.entries(edges)
1856
+ .filter(([name]) => !omit.includes(name))
1857
+ .map(([, [a, b]]) => cap(a[0], a[1], b[0], b[1], w))
1858
+ .join('');
1859
+ }
1860
+
1861
+ /** A SOLID slab between two v levels — the piece an operation adds: its top
1862
+ * face, the band down its front, the band down its right. */
1863
+ function slab(vLow, vHigh) {
1864
+ return (
1865
+ poly([P(0, vHigh), P(1, vHigh), P(1, vHigh, 1), P(0, vHigh, 1)]) +
1866
+ poly([P(0, vLow), P(1, vLow), P(1, vHigh), P(0, vHigh)]) +
1867
+ poly([P(1, vLow), P(1, vHigh), P(1, vHigh, 1), P(1, vLow, 1)])
1868
+ );
1869
+ }
1870
+
1871
+ /** EXTRUDE — the top face PULLED OUT: the cube left open at the top, and the
1872
+ * face floating above it as a solid slab with its own three faces. */
1873
+ g(
1874
+ 'mesh-extrude',
1875
+ // The body is OPEN at the top — it lost that face, and Blender's glyph says
1876
+ // so by letting the verticals run up and vanish behind the slab rather than
1877
+ // closing a rim under it (round 2 closed the rim and the pair read as a
1878
+ // crate with a lid; round 1 stopped the verticals short and read as a
1879
+ // bracket). So: full-height verticals to the slab's underside, no top rim.
1880
+ cubeWire({ top: 0.74, omit: ['frontTop', 'topLeft', 'topBack', 'seam'] }),
1881
+ { tone: 'operator', tonedPath: slab(0.74, 1) },
1882
+ );
1883
+
1884
+ /** INSET — the front face INSET: the rim it leaves is solid, the smaller face
1885
+ * inside it is knocked back out, and the four WALLS between them are knocked
1886
+ * apart by their own seams. Blender's inset glyph is that funnel — four
1887
+ * trapezoids converging on a small dark square — and round 4's flat frame
1888
+ * (no seams) read as a picture frame instead of a recess. */
1889
+ g('mesh-inset', cubeWire(), {
1890
+ tone: 'operator',
1891
+ tonedPath:
1892
+ poly([P(0, 0), P(1, 0), P(1, 1), P(0, 1)]) +
1893
+ holePoly([P(0.3, 0.3), P(0.7, 0.3), P(0.7, 0.7), P(0.3, 0.7)]) +
1894
+ holeBar(...P(0, 0), ...P(0.3, 0.3), 0.7) +
1895
+ holeBar(...P(1, 0), ...P(0.7, 0.3), 0.7) +
1896
+ holeBar(...P(1, 1), ...P(0.7, 0.7), 0.7) +
1897
+ holeBar(...P(0, 1), ...P(0.3, 0.7), 0.7),
1898
+ });
1899
+
1900
+ /** BEVEL — one edge CHAMFERED. RE-READ at 8x against the reference: the edge
1901
+ * Blender cuts is the TOP-RIGHT one (the seam between the top face and the
1902
+ * right face), and the glyph shows the top face solid with a second facet
1903
+ * flapping off it along that seam, a hairline of background between the two.
1904
+ * Rounds 2-4 chamfered the top-FRONT edge and the result read as a solid top,
1905
+ * which is Extrude's answer, not Bevel's. */
1906
+ g('mesh-bevel', cubeWire({ omit: ['seam'] }), {
1907
+ tone: 'operator',
1908
+ tonedPath:
1909
+ poly([P(0, 1), P(0.56, 1), P(0.56, 1, 1), P(0, 1, 1)]) +
1910
+ poly([P(0.56, 1), P(1, 0.56), P(1, 0.56, 1), P(0.56, 1, 1)]) +
1911
+ holeBar(...P(0.56, 1), ...P(0.56, 1, 1), 0.7),
1912
+ });
1913
+
1914
+ /** LOOP CUT — the cube CUT IN TWO. Blender's glyph is not a stripe on a cube:
1915
+ * it is two half-boxes standing apart with the cut itself, a solid bar, in the
1916
+ * gap between them, so the new edge loop is the subject. Round 4 drew a line
1917
+ * across an intact cube and read as decoration on the face. */
1918
+ g('mesh-loop-cut', cubeWire(), {
1919
+ tone: 'operator',
1920
+ tonedPath:
1921
+ poly([P(0.44, 0), P(0.56, 0), P(0.56, 1), P(0.44, 1)]) +
1922
+ poly([P(0.44, 1), P(0.56, 1), P(0.56, 1, 1), P(0.44, 1, 1)]),
1923
+ });
1924
+
1925
+ /** KNIFE — a cut drawn ACROSS a face rather than round the cube: a stroke that
1926
+ * turns at a vertex, its ends pinned by the points a knife sets. */
1927
+ g('mesh-knife', cubeWire(), {
1928
+ tone: 'operator',
1929
+ // A RISING cut with its turn on the front face and its far end up on the
1930
+ // top face. Round 1 fell to the right and read as a droop.
1931
+ tonedPath:
1932
+ line([P(0.16, 0.06), P(0.52, 0.72), P(0.78, 1, 0.5)], 1.5) +
1933
+ dot(...P(0.16, 0.06), 1.45) +
1934
+ dot(...P(0.52, 0.72), 1.15) +
1935
+ dot(...P(0.78, 1, 0.5), 1.45),
1936
+ });
1937
+
1938
+ /** SUBDIVIDE — the cube GRIDDED: every visible face cut once each way, in a
1939
+ * lighter stroke than the cube's own edges so the cut reads as new geometry
1940
+ * rather than as the silhouette. */
1941
+ g('mesh-subdivide', cubeWire(), {
1942
+ tone: 'operator',
1943
+ tonedPath:
1944
+ // front face
1945
+ cap(...P(0.5, 0), ...P(0.5, 1), 1.05) +
1946
+ cap(...P(0, 0.5), ...P(1, 0.5), 1.05) +
1947
+ // top face
1948
+ cap(...P(0.5, 1), ...P(0.5, 1, 1), 1.05) +
1949
+ cap(...P(0, 1, 0.5), ...P(1, 1, 0.5), 1.05) +
1950
+ // right face
1951
+ cap(...P(1, 0.5), ...P(1, 0.5, 1), 1.05) +
1952
+ cap(...P(1, 0, 0.5), ...P(1, 1, 0.5), 1.05),
1953
+ });
1954
+
1955
+ /** SMOOTH — not an operation on the operator CUBE: Blender draws this one (and
1956
+ * only this one of the shelf's marks) on a BALL, because smoothing is what
1957
+ * turns the cage into one. Measured on `modeling-edit-none.png` (button 17)
1958
+ * as an ASCII map in authored units: a faceted sphere ~6.3 units of radius
1959
+ * centred at (8.2, 6.6), its quads separated by seams KNOCKED OUT of the
1960
+ * fill — an equator across the full width, two meridians, and a latitude
1961
+ * seam near the top. The seams are holes, not strokes: drawn as strokes over
1962
+ * the ball they would be the same ink as the ball and vanish. */
1963
+ g(
1964
+ 'mesh-smooth',
1965
+ poly(
1966
+ // ELEVEN vertices, not a smooth circle: Blender's silhouette is faceted
1967
+ // and that is half of what says "quads" before the seams are read.
1968
+ Array.from({ length: 9 }, (_, i) => {
1969
+ const a = (i / 9) * Math.PI * 2 + 0.34;
1970
+ return [7.8 + Math.cos(a) * 7.1, 6.4 + Math.sin(a) * 7.1];
1971
+ }),
1972
+ ) +
1973
+ // equator, meridians, and the top latitude — each a bar-shaped hole.
1974
+ holeBar(0.8, 7.6, 15, 7.6, 0.62) +
1975
+ // The meridians and the top latitude BOW, two segments each — a sphere's
1976
+ // seams are not straight, and drawn straight the ball read as a beach
1977
+ // ball with a grid printed on it rather than as faceted geometry.
1978
+ holeBar(6.9, -0.7, 5.9, 6.4, 0.52) +
1979
+ holeBar(5.9, 6.4, 6.7, 13.5, 0.52) +
1980
+ holeBar(12.7, 1.1, 12.2, 6.4, 0.48) +
1981
+ holeBar(12.2, 6.4, 11.5, 11.7, 0.48) +
1982
+ holeBar(2.2, 3.5, 8, 1.8, 0.48) +
1983
+ holeBar(8, 1.8, 13.8, 3.3, 0.48),
1984
+ { tone: 'operator' },
1985
+ );
1986
+
1987
+ /** SHRINK/FATTEN — the selection moved ALONG ITS NORMALS, in either direction,
1988
+ * which is why Blender's mark (button 19) carries eight arrowheads: four
1989
+ * outside the cube pointing out and four inside pointing in. The cube here is
1990
+ * a pair of offset squares rather than the operator CUBE's three-quarter box
1991
+ * — measured, the front square is (3.5, 3.5)-(12, 11) and the back one is
1992
+ * offset up-and-right by 1.2 — because the arrows need the box read from the
1993
+ * front to be legible through it. */
1994
+ g(
1995
+ 'mesh-shrink-fatten',
1996
+ // The BOX, front square plus the two BACK edges the front does not hide
1997
+ // (drawing the whole back frame adds two interior lines Blender has not
1998
+ // got) and the three short connectors between them.
1999
+ rframe(3.5, 3.5, 8.5, 7.5, 0.8) +
2000
+ line(
2001
+ [
2002
+ [5, 2.5],
2003
+ [13.5, 2.5],
2004
+ [13.5, 10.5],
2005
+ ],
2006
+ 0.7,
2007
+ ) +
2008
+ cap(3.5, 3.5, 5, 2.5, 0.7) +
2009
+ cap(12, 3.5, 13.5, 2.5, 0.7) +
2010
+ cap(12, 11, 13.5, 10.5, 0.7),
2011
+ {
2012
+ tone: 'operator',
2013
+ // FOUR OUT and FOUR IN, about the mark's own centre (8, 7) — the box sits
2014
+ // a little high in the box because the outer heads need the room.
2015
+ tonedPath: [
2016
+ [0, -1],
2017
+ [0, 1],
2018
+ [-1, 0],
2019
+ [1, 0],
2020
+ ]
2021
+ .map(
2022
+ ([dx, dy]) =>
2023
+ poly([
2024
+ [8 + dx * 6.3 - dy * 1.85, 7 + dy * 6.3 + dx * 1.85],
2025
+ [8 + dx * 6.3 + dy * 1.85, 7 + dy * 6.3 - dx * 1.85],
2026
+ [8 + dx * 8.3, 7 + dy * 8.3],
2027
+ ]) +
2028
+ poly([
2029
+ [8 + dx * 3 - dy * 1.5, 7 + dy * 3 + dx * 1.5],
2030
+ [8 + dx * 3 + dy * 1.5, 7 + dy * 3 - dx * 1.5],
2031
+ [8 + dx * 0.9, 7 + dy * 0.9],
2032
+ ]),
2033
+ )
2034
+ .join(''),
2035
+ },
2036
+ );
2037
+
2038
+ // -- panels / apps ---------------------------------------------------------
2039
+ g(
2040
+ 'book-open',
2041
+ cap(8, 4.2, 8, 13.2, 1.2) +
2042
+ line(
2043
+ [
2044
+ [8, 4.2],
2045
+ [5.2, 2.6],
2046
+ [1.8, 2.6],
2047
+ [1.8, 11.6],
2048
+ [5.2, 11.6],
2049
+ [8, 13.2],
2050
+ ],
2051
+ 1.2,
2052
+ ) +
2053
+ line(
2054
+ [
2055
+ [8, 4.2],
2056
+ [10.8, 2.6],
2057
+ [14.2, 2.6],
2058
+ [14.2, 11.6],
2059
+ [10.8, 11.6],
2060
+ [8, 13.2],
2061
+ ],
2062
+ 1.2,
2063
+ ),
2064
+ );
2065
+ g(
2066
+ 'book-bookmark',
2067
+ line(
2068
+ [
2069
+ [3.6, 2.2],
2070
+ [12.6, 2.2],
2071
+ [12.6, 13.8],
2072
+ [3.6, 13.8],
2073
+ ],
2074
+ 1.2,
2075
+ true,
2076
+ ) +
2077
+ cap(5.8, 2.8, 5.8, 13.2, 1.1) +
2078
+ poly([
2079
+ [8.4, 2.8],
2080
+ [10.8, 2.8],
2081
+ [10.8, 7.8],
2082
+ [9.6, 6.6],
2083
+ [8.4, 7.8],
2084
+ ]),
2085
+ );
2086
+ g(
2087
+ 'graduation-cap',
2088
+ poly([
2089
+ [8, 2.4],
2090
+ [15, 5.8],
2091
+ [8, 9.2],
2092
+ [1, 5.8],
2093
+ ]) +
2094
+ line(
2095
+ [
2096
+ [4.2, 7.4],
2097
+ [4.2, 11.6],
2098
+ ],
2099
+ 1.2,
2100
+ ) +
2101
+ line(
2102
+ [
2103
+ [11.8, 7.4],
2104
+ [11.8, 11.6],
2105
+ ],
2106
+ 1.2,
2107
+ ) +
2108
+ arc(8, 9.6, 3.9, 10, 170, 1.2),
2109
+ );
2110
+ g(
2111
+ 'chart-simple',
2112
+ rrect(2.4, 9, 2.6, 4.8, 0.4) + rrect(6.7, 5.4, 2.6, 8.4, 0.4) + rrect(11, 2.4, 2.6, 11.4, 0.4),
2113
+ );
2114
+ g(
2115
+ 'diagram-project',
2116
+ rframe(1.8, 2.2, 5.2, 3.8, 1.15, 0.3) +
2117
+ rframe(9, 10, 5.2, 3.8, 1.15, 0.3) +
2118
+ line(
2119
+ [
2120
+ [4.4, 6.4],
2121
+ [4.4, 11.9],
2122
+ [8.6, 11.9],
2123
+ ],
2124
+ 1.15,
2125
+ ),
2126
+ );
2127
+ g(
2128
+ 'circle-nodes',
2129
+ ring(3.6, 11.6, 1.9, 1.15) +
2130
+ ring(12.4, 12, 1.9, 1.15) +
2131
+ ring(9.2, 3.8, 1.9, 1.15) +
2132
+ cap(4.9, 10.3, 8.2, 5.4, 1) +
2133
+ cap(5.5, 11.8, 10.5, 11.9, 1) +
2134
+ cap(10.6, 5.4, 11.8, 10.2, 1),
2135
+ );
2136
+ g(
2137
+ 'database',
2138
+ ellipseArc(8, 4.4, 5.2, 2.2, 0, 360, 1.15, 0, true) +
2139
+ cap(2.8, 4.4, 2.8, 11.6, 1.15) +
2140
+ cap(13.2, 4.4, 13.2, 11.6, 1.15) +
2141
+ ellipseArc(8, 11.6, 5.2, 2.2, 0, 180, 1.15) +
2142
+ ellipseArc(8, 8, 5.2, 2.2, 10, 170, 1.05),
2143
+ );
2144
+ g(
2145
+ 'desktop',
2146
+ rframe(1.8, 2.6, 12.4, 8.6, 1.2, 0.5) +
2147
+ cap(5.4, 13.6, 10.6, 13.6, 1.2) +
2148
+ cap(8, 11.4, 8, 13.4, 1.2),
2149
+ );
2150
+ g(
2151
+ 'mobile-screen-button',
2152
+ rframe(4.2, 1.6, 7.6, 12.8, 1.2, 1) + dot(8, 12.4, 0.8) + cap(6.4, 3.6, 9.6, 3.6, 1),
2153
+ );
2154
+ g(
2155
+ 'users',
2156
+ dot(5.4, 5, 2.5) +
2157
+ arc(5.4, 12.6, 4.4, 180, 360, 1.25) +
2158
+ arc(11.4, 5.4, 2.1, 250, 470, 1.15) +
2159
+ arc(11.6, 12, 3.6, 200, 340, 1.2),
2160
+ );
2161
+ g(
2162
+ 'circle-user',
2163
+ dot(8, 8, 6) +
2164
+ dot(8, 6, 2.1, true) +
2165
+ holePoly([
2166
+ [4.2, 13.3],
2167
+ [5, 11.2],
2168
+ [11, 11.2],
2169
+ [11.8, 13.3],
2170
+ ]),
2171
+ );
2172
+ g(
2173
+ 'thumbtack',
2174
+ cap(8, 9.4, 8, 14, 1.2) +
2175
+ poly([
2176
+ [5, 2.2],
2177
+ [11, 2.2],
2178
+ [10, 4],
2179
+ [10.6, 7.2],
2180
+ [12.2, 9],
2181
+ [3.8, 9],
2182
+ [5.4, 7.2],
2183
+ [6, 4],
2184
+ ]),
2185
+ );
2186
+ g(
2187
+ 'link',
2188
+ arc(5.6, 10.4, 3, 45, 315, 1.35) +
2189
+ arc(10.4, 5.6, 3, 225, 495, 1.35) +
2190
+ cap(5.8, 10.2, 10.2, 5.8, 1.35),
2191
+ );
2192
+ g(
2193
+ 'code-branch',
2194
+ ring(4.4, 3.6, 1.8, 1.2) +
2195
+ ring(4.4, 12.4, 1.8, 1.2) +
2196
+ ring(11.6, 5.6, 1.8, 1.2) +
2197
+ cap(4.4, 5.4, 4.4, 10.6, 1.15) +
2198
+ line(
2199
+ [
2200
+ [11.6, 7.4],
2201
+ [11.6, 9],
2202
+ [4.4, 9],
2203
+ ],
2204
+ 1.15,
2205
+ ),
2206
+ );
2207
+ g(
2208
+ 'hashtag',
2209
+ cap(5.8, 2.4, 4.4, 13.6, 1.2) +
2210
+ cap(11.2, 2.4, 9.8, 13.6, 1.2) +
2211
+ cap(2.6, 5.6, 13.4, 5.6, 1.2) +
2212
+ cap(2.2, 10.4, 13, 10.4, 1.2),
2213
+ );
2214
+ g(
2215
+ 'font',
2216
+ line(
2217
+ [
2218
+ [3, 13.4],
2219
+ [8, 2.4],
2220
+ [13, 13.4],
2221
+ ],
2222
+ 1.3,
2223
+ ) + cap(5.2, 9.4, 10.8, 9.4, 1.2),
2224
+ );
2225
+ g(
2226
+ 'tags',
2227
+ poly([
2228
+ [1.6, 8.2],
2229
+ [7.4, 2.4],
2230
+ [13.2, 2.4],
2231
+ [13.2, 8.2],
2232
+ [7.4, 14],
2233
+ ]),
2234
+ );
2235
+ g(
2236
+ 'align-left',
2237
+ cap(2.4, 3.6, 13.6, 3.6, 1.2) + cap(2.4, 8, 9.4, 8, 1.2) + cap(2.4, 12.4, 12, 12.4, 1.2),
2238
+ );
2239
+ g(
2240
+ 'align-center',
2241
+ cap(2.4, 3.6, 13.6, 3.6, 1.2) + cap(4.6, 8, 11.4, 8, 1.2) + cap(3.2, 12.4, 12.8, 12.4, 1.2),
2242
+ );
2243
+ g(
2244
+ 'align-right',
2245
+ cap(2.4, 3.6, 13.6, 3.6, 1.2) + cap(6.6, 8, 13.6, 8, 1.2) + cap(4, 12.4, 13.6, 12.4, 1.2),
2246
+ );
2247
+ g(
2248
+ 'volume-high',
2249
+ poly([
2250
+ [1.8, 6],
2251
+ [4.6, 6],
2252
+ [8, 2.8],
2253
+ [8, 13.2],
2254
+ [4.6, 10],
2255
+ [1.8, 10],
2256
+ ]) +
2257
+ arc(8.4, 8, 2.8, -55, 55, 1.15) +
2258
+ arc(8.4, 8, 5.2, -50, 50, 1.15),
2259
+ );
2260
+ g(
2261
+ 'volume-xmark',
2262
+ poly([
2263
+ [1.8, 6],
2264
+ [4.6, 6],
2265
+ [8, 2.8],
2266
+ [8, 13.2],
2267
+ [4.6, 10],
2268
+ [1.8, 10],
2269
+ ]) +
2270
+ cap(10, 6, 14.2, 10, 1.25) +
2271
+ cap(14.2, 6, 10, 10, 1.25),
2272
+ );
2273
+ g(
2274
+ 'music',
2275
+ cap(6.2, 4.2, 6.2, 11.6, 1.2) +
2276
+ cap(13, 2.6, 13, 10, 1.2) +
2277
+ poly([
2278
+ [5.6, 2.4],
2279
+ [13.6, 1.4],
2280
+ [13.6, 4],
2281
+ [5.6, 5],
2282
+ ]) +
2283
+ ring(4.2, 11.6, 2, 1.2) +
2284
+ ring(11, 10, 2, 1.2),
2285
+ );
2286
+ g(
2287
+ 'atom',
2288
+ dot(8, 8, 1.4) +
2289
+ ellipseArc(8, 8, 6.2, 2.1, 0, 360, 0.9, 30, true) +
2290
+ ellipseArc(8, 8, 6.2, 2.1, 0, 360, 0.9, -30, true) +
2291
+ ellipseArc(8, 8, 6.2, 2.1, 0, 360, 0.9, 90, true),
2292
+ );
2293
+ g(
2294
+ 'brain',
2295
+ line(
2296
+ [
2297
+ [8, 2.4],
2298
+ [11.6, 3],
2299
+ [13.5, 6],
2300
+ [12.6, 9.8],
2301
+ [9.8, 13.4],
2302
+ [6, 13.5],
2303
+ [3, 11.2],
2304
+ [2.5, 7.4],
2305
+ [4.4, 4],
2306
+ ],
2307
+ 1.3,
2308
+ true,
2309
+ ) +
2310
+ cap(8, 3.2, 8, 13, 1.15) +
2311
+ cap(5.2, 6, 8, 7.4, 1.05) +
2312
+ cap(10.8, 9.6, 8, 8.6, 1.05),
2313
+ );
2314
+ g(
2315
+ 'shield-halved',
2316
+ line(
2317
+ [
2318
+ [8, 1.8],
2319
+ [13.6, 4.2],
2320
+ [13.6, 8.4],
2321
+ [8, 14.2],
2322
+ [2.4, 8.4],
2323
+ [2.4, 4.2],
2324
+ ],
2325
+ 1.3,
2326
+ true,
2327
+ ) + cap(8, 2.6, 8, 13.4, 1.15),
2328
+ );
2329
+ g('boxes-placeholder', '');
2330
+ delete glyphs['boxes-placeholder'];
2331
+ g(
2332
+ 'download',
2333
+ cap(8, 2.2, 8, 9.8, 1.3) +
2334
+ line(
2335
+ [
2336
+ [4.8, 6.8],
2337
+ [8, 10.2],
2338
+ [11.2, 6.8],
2339
+ ],
2340
+ 1.25,
2341
+ ) +
2342
+ line(
2343
+ [
2344
+ [2.8, 11],
2345
+ [2.8, 13.8],
2346
+ [13.2, 13.8],
2347
+ [13.2, 11],
2348
+ ],
2349
+ 1.25,
2350
+ ),
2351
+ );
2352
+ g(
2353
+ 'cloud-arrow-down',
2354
+ dot(5.5, 6.6, 2.5) +
2355
+ dot(9, 5.6, 3.1) +
2356
+ dot(11.6, 7.3, 2.2) +
2357
+ rrect(4.4, 6.6, 8.4, 2.6, 1.3) +
2358
+ cap(8, 9, 8, 13.4, 1.25) +
2359
+ line(
2360
+ [
2361
+ [5.9, 11.2],
2362
+ [8, 13.6],
2363
+ [10.1, 11.2],
2364
+ ],
2365
+ 1.2,
2366
+ ),
2367
+ );
2368
+ g(
2369
+ 'arrow-up-right-from-square',
2370
+ line(
2371
+ [
2372
+ [7.4, 3],
2373
+ [3, 3],
2374
+ [3, 13],
2375
+ [13, 13],
2376
+ [13, 8.6],
2377
+ ],
2378
+ 1.25,
2379
+ ) +
2380
+ cap(7.6, 8.4, 13.2, 2.8, 1.25) +
2381
+ line(
2382
+ [
2383
+ [9.4, 2.6],
2384
+ [13.4, 2.6],
2385
+ [13.4, 6.6],
2386
+ ],
2387
+ 1.25,
2388
+ ),
2389
+ );
2390
+
2391
+ // ------------------------------------------------------- category tones
2392
+ /**
2393
+ * COLOUR AS A GLYPH'S OWN CHANNEL. Blender's Properties-tab rail groups by
2394
+ * HUE — in `properties-object.png` at 14 px the tab shapes are near
2395
+ * indistinguishable and the ink is what says which group a tab belongs to —
2396
+ * and the outliner tints a row's type glyph the same way. Each name below is
2397
+ * a category the palette inks (`EditorTheme.color.category`, where every
2398
+ * value's source coordinates are recorded); a glyph paints
2399
+ * `var(--vgai-category-<tone>, currentColor)`, so under a palette that names
2400
+ * no category group it paints exactly as it did monochrome.
2401
+ *
2402
+ * A NAME, NOT A SITE. A tone here tints EVERY site that asks for this icon
2403
+ * name, so a name is toned only when its meaning is the category at every
2404
+ * one of them. Measured live, that is not a hypothetical: the first cut
2405
+ * toned `cube`, `camera`, `lightbulb` and `globe`, and the running editor
2406
+ * painted the Perspective/Orthographic dropdown orange and the world-space
2407
+ * toggle red — both are viewport CONTROLS that happen to reuse the drawing.
2408
+ * The fix is `editorIcons.modeling`'s: a meaning that needs its own ink gets
2409
+ * its own name. `hierarchy-kind-icon.ts` now asks for `outliner-object`,
2410
+ * `outliner-light` and `outliner-camera`, which no other site asks for, and
2411
+ * they are aliased to the same drawings below.
2412
+ *
2413
+ * - `outliner-object`, `outliner-light`, `outliner-camera` → `object`.
2414
+ * `outliner.png`: the Camera, Cube and Light rows' type glyphs all
2415
+ * measure #bb7f4d over the row's #272727 — ONE orange for all three,
2416
+ * because Blender's outliner discriminates OBJECT from DATA, not camera
2417
+ * from light. The same ink is the Object tab's in `properties-object.png`.
2418
+ * - `atom` → `modifier`. Its only site is the inspector's physics/rigid-
2419
+ * body/collider group (`inspection/compose.ts`'s `GROUP_ICON_RULES`);
2420
+ * `properties-object.png` paints Physics — with Modifier, Particles and
2421
+ * Constraints — one blue.
2422
+ * - `palette` → `material`. Its product sites are all material sections
2423
+ * (the same `GROUP_ICON_RULES`, the model-asset and react inspectors);
2424
+ * the Material tab of the same rail.
2425
+ * - `screwdriver-wrench` → `tool`. Its only site is a project tool's own
2426
+ * glyph (`tool-loader.ts`); the Tool tab of the same rail.
2427
+ *
2428
+ * The six `mesh-*` operator marks carry their tone at the glyph instead,
2429
+ * with a `tonedPath`, because an operator mark tints only the element it
2430
+ * operates on and leaves the cube neutral.
2431
+ *
2432
+ * NOT toned, each for a measured reason rather than an oversight:
2433
+ * - `globe` — Blender's World tab is red, but no site in this editor draws
2434
+ * a globe to mean a World datablock: the two are the transform-SPACE
2435
+ * toggle and a build profile. A red globe there reads as an error state.
2436
+ * - `table` — its one product site is the mesh inspector's Vertex Groups
2437
+ * panel, which is Blender's green `DATA_PT_vertex_groups`, but it renders
2438
+ * through `SectionHeader`, which passes `tone="dim"` to every section
2439
+ * glyph — and an explicit site tone wins by contract. A tone there would
2440
+ * never paint.
2441
+ */
2442
+ /** The union `IconCategoryTone` declares (`@vgai/editor-sdk/looks`), and the
2443
+ * reason `blender.style.ts` may narrow this JSON with an assertion: a tone
2444
+ * outside it never reaches the artifact. */
2445
+ const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection'];
2446
+
2447
+ // The outliner's three names, aliased onto the drawings they share with the
2448
+ // generic glyphs. Same picture, its own name, so only the outliner is tinted.
2449
+ g('outliner-object', glyphs['cube'].path);
2450
+ // A MESH OBJECT's row mark is not a cube. Measured on the native 2x
2451
+ // `outliner.png` (the Cube row, src x 127-154, y 179-206): Blender draws an
2452
+ // APEX-DOWN triangle, heavily stroked, with a triangular knockout in its upper
2453
+ // half — the object-type sibling of the datablock's `properties-data` mark, in
2454
+ // the object orange rather than the data green. On the set's 16-unit grid the
2455
+ // outer triangle is (1.25,1.25)-(14.75,1.25)-(8,14.75) and the knockout
2456
+ // (4.75,4.25)-(11.25,4.25)-(8,9.75). `outliner-object` keeps the cube for the
2457
+ // generic three-object row, which has no mesh to name.
2458
+ g(
2459
+ 'outliner-mesh',
2460
+ poly([
2461
+ [1.25, 1.25],
2462
+ [14.75, 1.25],
2463
+ [8, 14.75],
2464
+ ]) +
2465
+ holePoly([
2466
+ [4.75, 4.25],
2467
+ [11.25, 4.25],
2468
+ [8, 9.75],
2469
+ ]),
2470
+ );
2471
+ g('outliner-light', glyphs.lightbulb.path);
2472
+ g('outliner-camera', glyphs.camera.path);
2473
+
2474
+ // The Properties editor's two package tabs, each its own name so only the
2475
+ // rail is tinted. `properties-modifiers` is the wrench Blender draws for the
2476
+ // Modifiers tab, in the modifier blue. `properties-data` is Blender's
2477
+ // MESH_DATA mark — a triangle with a vertex dot at each corner — drawn on
2478
+ // the set's own grid at its own stroke, measured from `properties-object.png`'s
2479
+ // Object Data tab (src x 10-56, y 802-830).
2480
+ /**
2481
+ * BLENDER'S MODIFIER TAB IS ONE OPEN-END WRENCH, and this glyph used to be
2482
+ * `screwdriver-wrench` — the TOOL tab's two-tool silhouette — reused under a
2483
+ * second name. At the 14px rail the two read as different marks, and the rail
2484
+ * paints them side by side, so the reuse was the loudest thing left in it.
2485
+ *
2486
+ * MEASURED off `modeling-edit-none.png` at its native 2x (the rail's OPEN
2487
+ * Modifier tab, ink x 2861..2888, y 1004..1031 — 28x28 device px, exactly the
2488
+ * box the Data tab's mark fills two cells below, which is what calibrates the
2489
+ * scale: `properties-data` spans 16.6 viewBox units, so this does too).
2490
+ * Row-by-row ink runs, thresholded at half coverage and mapped onto the set's
2491
+ * plain 16 grid, give a shape with four parts:
2492
+ *
2493
+ * - a HEAD. Not a stroke of constant width: the outer boundary is a circle
2494
+ * (5.08 left, 14.81 right at the head's own centre line) and the bore is a
2495
+ * SMALLER, concentric circle, so the ring is ~2.9 thick where the handle
2496
+ * leaves it and the jaw's flats eat into it from the other side.
2497
+ * - a JAW cut as a SLOT WITH PARALLEL FLATS, not a wedge and not a wider
2498
+ * bore. Both inner faces measure dx/dy = -1.00 over their whole length
2499
+ * (left face x+y = 12.84, right face x+y = 19.41) - parallel, ~4.3 apart,
2500
+ * on an axis at -45 degrees, opposite the handle. That parallelism is the
2501
+ * one fact that makes the mark read as a WRENCH rather than a C: a
2502
+ * radial-ended annulus puts the jaw's inner corner 3.8 device px off.
2503
+ * - the slot STOPS about a unit short of the head's centre, which is what
2504
+ * keeps the ring closed across the bottom (the frame's row y 1018 is one
2505
+ * unbroken run, 5.08..14.57).
2506
+ * - a HANDLE at exactly 45 degrees - its right boundary moves one device
2507
+ * pixel per row for nine rows - ~3.7 wide, ending in a round cap whose
2508
+ * extremes ARE the glyph's left and bottom edges.
2509
+ *
2510
+ * THE CONSTANTS BELOW ARE THE FIT, NOT THE RAW READING. Every feature above
2511
+ * is a boundary read at a 50% threshold on a 28px glyph, so each carries about
2512
+ * half a device pixel of slack, and the rim's apparent radius swings 4.5..5.0
2513
+ * depending on which row it is read from (near the top the circle is almost
2514
+ * horizontal, so half a pixel of radius moves x by two). The reading fixed the
2515
+ * STRUCTURE; the numbers were then settled by rendering the path at the
2516
+ * reference's own scale and counting the pixels where the two half-coverage
2517
+ * masks disagree. Every constant stayed inside its own reading's error bar and
2518
+ * the count came down 18 -> 5 of 298 ref ink pixels, which is the floor this
2519
+ * instrument has: the five are single pixels on antialiased diagonals.
2520
+ *
2521
+ * DRAWN AS ONE CLOSED BOUNDARY, NO KNOCKOUT AT ALL — and that is the whole
2522
+ * reason this glyph is a traced outline rather than the file's usual pile of
2523
+ * primitives. Two knockout constructions were tried and both are impossible
2524
+ * here, which is worth stating so the third attempt does not repeat them:
2525
+ * - disc + handle capsule, then bore and slot punched. The handle has to
2526
+ * reach PAST the bore to cover the head (its cap would need to sit at
2527
+ * radius >4.04 to clear the bore and <3.11 to stay inside the outer
2528
+ * circle — no such radius), so the head is DOUBLY covered exactly where
2529
+ * the punches land: winding 2 - 1 = 1, and every knockout fills back in.
2530
+ * - one outer polygon with the bore and the slot as holes. The slot has to
2531
+ * run PAST the rim to cut the jaw open, and a hole outside its fill is not
2532
+ * a hole — it is ink (winding +1), which is what it drew: a blue bar
2533
+ * across the top-right corner. The bore and the slot also overlap, and two
2534
+ * overlapping holes cancel to ink in their intersection.
2535
+ * The material is SIMPLY CONNECTED — the bore opens into the jaw, which opens
2536
+ * to the outside — so its boundary is one walk: the rim from the upper arm's
2537
+ * tip round to the handle, the handle's two sides and its cap, the rim again
2538
+ * to the lower jaw's tip, in along one flat, across the jaw's stop, round the
2539
+ * BACK of the bore, and out along the other flat.
2540
+ */
2541
+ const modifierWrench = () => {
2542
+ const cx = 10.28; // the head's centre
2543
+ const cy = 5.84;
2544
+ const rOut = 4.78; // its outer rim
2545
+ const rIn = 1.85; // its bore
2546
+ const hw = 2.15; // half the jaw's opening
2547
+ const off = -0.12; // the jaw's midline, off the head's centre
2548
+ const stop = -1.1; // where the jaw stops, along its own axis
2549
+ const hx = 3.2; // the handle's end cap
2550
+ const hy = 12.75;
2551
+ const hr = 1.87;
2552
+ const A = [Math.SQRT1_2, -Math.SQRT1_2]; // jaw/handle axis, up-right
2553
+ const N = [Math.SQRT1_2, Math.SQRT1_2]; // its perpendicular, down-right
2554
+ /** A point in the jaw's own frame: `s` along the axis, `f` across it. */
2555
+ const at = (s, f) => [cx + A[0] * s + N[0] * f, cy + A[1] * s + N[1] * f];
2556
+ const ang = ([x, y]) => (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
2557
+ const sweep = (r, a0, a1, ox = cx, oy = cy) => {
2558
+ const n = Math.max(2, Math.ceil(Math.abs(a1 - a0) / 6));
2559
+ return Array.from({ length: n + 1 }, (_, i) => {
2560
+ const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
2561
+ return [ox + Math.cos(a) * r, oy + Math.sin(a) * r];
2562
+ });
2563
+ };
2564
+ const rim = (f) => Math.sqrt(Math.max(rOut * rOut - f * f, 0));
2565
+ const fUp = off - hw; // the jaw's two flats, across its axis
2566
+ const fLo = off + hw;
2567
+ const t1 = at(rim(fUp), fUp); // the upper arm's tip
2568
+ const t2 = at(rim(fLo), fLo); // the lower jaw's tip
2569
+ // Where each side of the handle runs into the rim, in the same frame.
2570
+ const offAxis = (cx - hx) * N[0] + (cy - hy) * N[1];
2571
+ const pUp = at(-rim(-hr - offAxis), -hr - offAxis);
2572
+ const pLo = at(-rim(hr - offAxis), hr - offAxis);
2573
+ // BOTH FLATS CLEAR THE BORE (2.32 out against a 2.07 radius) and the jaw's
2574
+ // stop crosses it, so the bore meets the jaw at the STOP, never at a flat.
2575
+ const b = Math.sqrt(Math.max(rIn * rIn - stop * stop, 0));
2576
+ const v1 = at(stop, b);
2577
+ const v2 = at(stop, -b);
2578
+ return poly([
2579
+ ...sweep(rOut, ang(t1), ang(pUp) - 360), // the rim, over the top, to the handle
2580
+ ...sweep(hr, 225, 45, hx, hy), // the handle: its two sides and its cap
2581
+ ...sweep(rOut, ang(pLo) - 360, ang(t2) - 360), // the rim again, to the jaw
2582
+ at(stop, fLo), // in along the jaw's lower flat, then across its stop
2583
+ ...sweep(rIn, ang(v1), ang(v2) + 360), // round the BACK of the bore
2584
+ at(stop, fUp), // across the stop again, and out along the upper flat
2585
+ ]);
2586
+ };
2587
+ g('properties-modifiers', modifierWrench());
2588
+ /**
2589
+ * RE-MEASURED 2026-09-19 EDGE BY EDGE, and the mark is not a triangle drawn
2590
+ * THROUGH three vertex marks. The 2026-09-18 cut got the orientation and the
2591
+ * square vertices right and then drew the triangle as one closed `line`
2592
+ * running vertex-centre to vertex-centre, which fills each square's hollow
2593
+ * from the inside: at the 14 px the Outliner paints it the three squares
2594
+ * closed to blobs and the whole glyph read as a fat stem with two legs — a
2595
+ * Y — where Blender's reads as a clean triangle with three open boxes on it.
2596
+ *
2597
+ * The frames are NOT ANTIALIASED here (every ink pixel is the full
2598
+ * `#07b189`/`#05ae87` and every background pixel the full row), so these are
2599
+ * EXACT EDGES, not fits. Read down `modeling-object-none.png`'s Cube row (src
2600
+ * x 269..296, y 179..206 — the Outliner crop `[x2843..3452, y53..419]`'s
2601
+ * x 269..296, y 179..206), confirmed identical in `modeling-object-selected.png`
2602
+ * and `outliner.png`, and the same mark at the same size in
2603
+ * `properties-object.png`'s Data tab:
2604
+ *
2605
+ * three squares, OUTER 8 device px, WALL 2, HOLLOW 4 — at [0,8), [20,28)
2606
+ * and [10,18) of the mark's own 28 px box
2607
+ * a top BAR x [6,22) y [2,4) — it runs wall to wall between the two top
2608
+ * squares and stops dead at each hollow; it does NOT cross them, and it
2609
+ * sits in the UPPER half of the hollow band, not on the squares' centre
2610
+ * two DIAGONALS, 2 px wide, living only in y [8,20) — the gap between the
2611
+ * top squares' bottom edge and the bottom square's top edge. The left
2612
+ * one's centre runs x 273.04 → 279.44 down that span (a subpixel fit over
2613
+ * twelve rows, residual under 0.1 px), which is the top square's own
2614
+ * centre-x at the top and the bottom square's LEFT edge at the bottom;
2615
+ * the right one is its mirror about the box's centre.
2616
+ *
2617
+ * So the three edges are drawn BETWEEN the squares and the squares are
2618
+ * FILLED-WITH-A-KNOCKOUT rather than stroked outlines — which is also why
2619
+ * their corners are square where `rframe`'s round joins gave ours a dot.
2620
+ * `poly`/`rrect` take no `W` fattening, which is what this mark wants: it is
2621
+ * fitted to pixels, like `properties-modifiers` and the editor-type wells.
2622
+ *
2623
+ * PROVED, the way the wrench was: the path rasterized at the reference's own
2624
+ * scale (28x28, 4x4 supersampled, inked at half coverage) and differenced
2625
+ * against the frame's mask — 1 of 222 reference ink pixels disagrees, a
2626
+ * single antialiased pixel on the right diagonal's edge.
2627
+ *
2628
+ * A NOTE ON THE THRESHOLD, because it nearly cost this glyph its geometry:
2629
+ * the mark is crisp everywhere EXCEPT the diagonals' edges, and read at a
2630
+ * 0.44-coverage cut those two bars mask one pixel wider than they are. Fitted
2631
+ * to that mask the diagonals want 2.35 device px; fitted to the frame's own
2632
+ * subpixel MASS (2.26 px per row across twelve rows, times cos 28.06 deg)
2633
+ * they measure 1.99, and at the honest half-coverage cut the measured width
2634
+ * is the one that scores 1/222 where the fitted width scores 3. A shape
2635
+ * tuned to a rasterizer's threshold is tuned to the rasterizer.
2636
+ */
2637
+ /** One device px of the reference, in this file's plain 16 units: the mark
2638
+ * inks exactly 28x28 device px, which is 13.5 units once `S` has scaled it
2639
+ * (1.25..14.75) — the same calibration the wells below use. */
2640
+ const DATA_PX = 13.5 / 28;
2641
+ /** A measured edge, counted from the mark's own first ink pixel. */
2642
+ const dpx = (px) => 1.25 + px * DATA_PX;
2643
+ /** A bar with SQUARE ends. `cap`'s round ends would push ink past the square
2644
+ * each diagonal dies against, into its hollow, where Blender draws none. */
2645
+ const dataBar = (x1, y1, x2, y2, w) => {
2646
+ const dx = x2 - x1;
2647
+ const dy = y2 - y1;
2648
+ const L = Math.hypot(dx, dy);
2649
+ const nx = (-dy / L) * (w / 2);
2650
+ const ny = (dx / L) * (w / 2);
2651
+ return poly([
2652
+ [x1 + nx, y1 + ny],
2653
+ [x2 + nx, y2 + ny],
2654
+ [x2 - nx, y2 - ny],
2655
+ [x1 - nx, y1 - ny],
2656
+ ]);
2657
+ };
2658
+ /** A vertex mark: an 8-px square with its 4-px middle punched out. */
2659
+ const dataVertex = (px, py) => {
2660
+ const side = 8 * DATA_PX;
2661
+ const wall = 2 * DATA_PX;
2662
+ const hole = 4 * DATA_PX;
2663
+ const x = dpx(px);
2664
+ const y = dpx(py);
2665
+ return (
2666
+ rrect(x, y, side, side) +
2667
+ holePoly([
2668
+ [x + wall, y + wall],
2669
+ [x + wall + hole, y + wall],
2670
+ [x + wall + hole, y + wall + hole],
2671
+ [x + wall, y + wall + hole],
2672
+ ])
2673
+ );
2674
+ };
2675
+ g(
2676
+ 'properties-data',
2677
+ // the bar between the two top squares
2678
+ rrect(dpx(6), dpx(2), 16 * DATA_PX, 2 * DATA_PX) +
2679
+ // the two diagonals, square-ended, spanning only the gap between squares
2680
+ dataBar(dpx(4.04), dpx(8), dpx(10.44), dpx(20), 2 * DATA_PX) +
2681
+ dataBar(dpx(23.96), dpx(8), dpx(17.56), dpx(20), 2 * DATA_PX) +
2682
+ dataVertex(0, 0) +
2683
+ dataVertex(20, 0) +
2684
+ dataVertex(10, 20),
2685
+ );
2686
+
2687
+ // The Outliner's DATABLOCK row (`@vgai/blender`'s datablock hierarchy) —
2688
+ // the same MESH_DATA mark the Properties rail's Data tab draws, under its own
2689
+ // name because the two sites composite differently: `outliner.png`'s data
2690
+ // glyph is #07b189 over the row's #272727 and `properties-object.png`'s tab is
2691
+ // #05ae87 over the rail's #1c1c1c, one ink at α 0.791 in both — while our rail
2692
+ // paints its tab at full alpha, so the two will not stay one value.
2693
+ g('outliner-data', glyphs['properties-data'].path);
2694
+
2695
+ /**
2696
+ * BLENDER'S PROPERTIES RAIL, TRACED FROM BLENDER'S OWN ICON SOURCES.
2697
+ *
2698
+ * `ED_buttons_tabs_list` (`space_buttons.cc:201-255`) draws up to sixteen tabs
2699
+ * and each one's mark is named in `buttons_context_items`
2700
+ * (`makesrna/intern/rna_space.cc:579-611`) — `ICON_SCENE` for Render,
2701
+ * `ICON_SCENE_DATA` for Scene, `ICON_OBJECT_DATA` for Object, and so on. Each
2702
+ * of those icons IS A FILE in the Blender checkout at the engine's pin:
2703
+ * `release/datafiles/icons_svg/<lowercase name>.svg`, authored on a 16-unit
2704
+ * grid inside a 1600 viewBox. The compiled `.dat` sheets under
2705
+ * `release/datafiles/icons/` are that file's BUILD PRODUCT, not its source,
2706
+ * and Blender 5.x has no combined `blender_icons.svg` master.
2707
+ *
2708
+ * WHAT I1 LEFT AND THIS REPLACES: thirteen of the sixteen were a distinct NAME
2709
+ * over a neighbouring silhouette this set already drew — a globe for World, a
2710
+ * magnet for Constraints, a palette for Material — and the remaining two
2711
+ * (`properties-modifiers`, `properties-data`) were measured pixel-by-pixel off
2712
+ * a screenshot. All sixteen are now traced from the vector source, which is
2713
+ * both more exact than a screenshot reading and far less code: the two
2714
+ * hand-measured marks above this line took two paragraphs of prose each to
2715
+ * justify one shape.
2716
+ *
2717
+ * GPL PATHS, DELIBERATELY — AND THE PACKAGE'S SPDX SAYS SO. Blender's icon
2718
+ * sources are GPL-2.0-or-later, and `@vgai/blender` is
2719
+ * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/blender/LICENSE`, whose
2720
+ * notice block names this set, its provenance file and the §13 combination):
2721
+ * our code is AGPL, these 197 paths are Blender's artwork conveyed as GPL-3.0,
2722
+ * and both licences' §13 permit the combination. Nothing traced may be copied
2723
+ * into an Apache/MIT part of this repo; `packages/editor`'s own icon set in
2724
+ * particular stays free of it.
2725
+ *
2726
+ * NO `S` AND NO `W`. Those two dials exist to bring OUR drawings up to
2727
+ * Blender's ink ratio (see their note at the top of this file); a traced mark
2728
+ * already has it. Measured over the traced set, Blender's marks ink 14 of the
2729
+ * 16 units (x and y both 1..15 in almost every file), against the ~14.5 the
2730
+ * dialled geometry above produces — within 3.5%, so the rail reads as one set
2731
+ * while every traced glyph keeps Blender's own proportions exactly.
2732
+ *
2733
+ * Re-derive `blender.icons.traced.json` with
2734
+ * `node blender-icons.source.mjs --trace`, which reads the checkout at
2735
+ * `$BLENDER_SRC` (default `~/volter/blender-src`; the recipe is in WORK.md
2736
+ * §Blender in the tab is Blender, "Inspection parity") and records each
2737
+ * glyph's source file and sha256 beside its path. The ordinary run consumes
2738
+ * that JSON and needs no checkout.
2739
+ */
2740
+ // The TONE rides with the traced glyph, read from that icon's own
2741
+ // `DEF_ICON_<GROUP>` macro at trace time (see the `--trace` block). The
2742
+ // Properties rail's sixteen keep their assignments in `CATEGORY_TONE` below,
2743
+ // which is where they were measured; a traced entry that carries `tone` is
2744
+ // answering for itself and that table names none of them.
2745
+ for (const [name, entry] of Object.entries(traced))
2746
+ g(name, entry.path, entry.tone ? { tone: entry.tone } : undefined);
2747
+
2748
+ // -- the EDITOR-TYPE WELLS -------------------------------------------------
2749
+ /**
2750
+ * THE TWO MARKS BLENDER PUTS IN AN AREA'S EDITOR-TYPE WELL, in the two areas
2751
+ * the reference frames photograph. Every Blender area begins with that well —
2752
+ * a 32x20 CSS rounded box, 1 px `#3c3c3c` over a constant `#272727` inner —
2753
+ * and the glyph inside it is what says which editor the area is
2754
+ * (`workspace-regions.ts`'s `groupTabs`).
2755
+ *
2756
+ * BOTH ARE READ AT NATIVE 2x AND BOTH INK EXACTLY 28x28 DEVICE PX, which is
2757
+ * the same box `properties-data` and the modifier wrench fill two paragraphs
2758
+ * up — so the same calibration carries over unchanged: 28 device px of ink is
2759
+ * 13.5 units on this file's plain 16 grid (1 unit = 2.074 device px), and the
2760
+ * ink lands at 1.25..14.75 in both axes once `S` has scaled it. Every constant
2761
+ * below is a measured pixel edge divided by that one number; none is a taste
2762
+ * call.
2763
+ *
2764
+ * Neither is antialiased in the frame — the runs are the same at a 60 and a
2765
+ * 120 threshold — so every edge quoted is exact rather than a fit.
2766
+ *
2767
+ * THE OTHER FIVE PANELS OF THIS EDITOR GET NO GLYPH HERE, and the reason is
2768
+ * that there is nothing to measure: the reference set photographs the Layout,
2769
+ * Modeling, Sculpting and Texture Paint workspaces, whose areas are the 3D
2770
+ * View, the Outliner, the Properties editor, the Timeline, the Image Editor
2771
+ * and the asset shelf. No frame contains a File Browser or an Asset Browser,
2772
+ * and Blender's editor-type dropdown is closed in all of them, so the Content
2773
+ * browser's and the Asset Library's wells have NO Blender reading at all.
2774
+ * They reuse names this set already draws (`workspace-static-panels.ts` says
2775
+ * which, and says that the choice is ours).
2776
+ */
2777
+
2778
+ // `outliner.png`, the Outliner area's own well: ink x 27..54, y 11..38 device.
2779
+ // Six parts, each a pixel-exact rectangle with SQUARE corners (no corner in
2780
+ // this glyph loses a pixel at either threshold):
2781
+ // the collection box x [27,37) y [11,17)
2782
+ // the tree's stem x [31,33) y [19,37)
2783
+ // its upper arm x [31,39) y [25,27)
2784
+ // its lower arm x [31,39) y [35,37)
2785
+ // the upper child box x [41,55) y [23,29)
2786
+ // the lower child box x [41,55) y [33,39)
2787
+ // The two GAPS are the mark's own: 2 device px between the collection box and
2788
+ // the stem's top, and 2 more between each arm's end and its child box. Closing
2789
+ // either — which is what a tree icon drawn from memory does — turns three
2790
+ // separated rows into one connected bracket.
2791
+ g(
2792
+ 'outliner',
2793
+ rrect(1.25, 1.25, 4.82, 2.89) +
2794
+ rrect(3.18, 5.11, 0.96, 8.68) +
2795
+ rrect(3.18, 8.0, 3.86, 0.96) +
2796
+ rrect(3.18, 12.82, 3.86, 0.96) +
2797
+ rrect(8.0, 7.04, 6.75, 2.89) +
2798
+ rrect(8.0, 11.86, 6.75, 2.89),
2799
+ );
2800
+
2801
+ /**
2802
+ * `modeling-edit-none.png`, the Properties area's own well: ink x 2870..2897,
2803
+ * y 437..464 device. TWO SOLID STADIUMS, each 28x12 device (radius = half the
2804
+ * height), stacked with 4 device px between them — and each carries a
2805
+ * KNOCKOUT, which is what makes the pair read as two sliders at different
2806
+ * settings rather than as two blank pills:
2807
+ * stadium rows y [437,449) and y [453,465), both x [2870,2898)
2808
+ * upper knockout x [2882,2896) y [439,447)
2809
+ * lower knockout x [2888,2896) y [455,463)
2810
+ * Each knockout is FLAT ON ITS LEFT and semicircular on its right: the right
2811
+ * boundary reads 2893/2894/2895/2895 down its four half-rows, which fits a
2812
+ * radius of exactly 4 device px (= half the knockout's own height) and nothing
2813
+ * else; the left boundary is one x for all eight rows. The two differ only in
2814
+ * where that flat left edge sits, and that difference is the whole picture.
2815
+ *
2816
+ * The knockouts are strictly INSIDE their stadiums (2 device px of ink on
2817
+ * every side), so the nonzero winding is well posed — unlike the wrench's two
2818
+ * failed constructions recorded above, nothing here is punched outside its own
2819
+ * fill or punched twice.
2820
+ */
2821
+ const sliderSlot = (xL, y, xR, h) => {
2822
+ const r = h / 2;
2823
+ const cy = y + r;
2824
+ const cx = xR - r;
2825
+ const cap = Array.from({ length: 13 }, (_, i) => {
2826
+ const a = ((-90 + 15 * i) * Math.PI) / 180;
2827
+ return [cx + Math.cos(a) * r, cy + Math.sin(a) * r];
2828
+ });
2829
+ return holePoly([[xL, y], ...cap, [xL, y + h]]);
2830
+ };
2831
+ g(
2832
+ 'properties',
2833
+ rrect(1.25, 1.25, 13.5, 5.79, 2.41) +
2834
+ sliderSlot(7.04, 2.21, 13.79, 3.86) +
2835
+ rrect(1.25, 8.96, 13.5, 5.79, 2.41) +
2836
+ sliderSlot(9.93, 9.92, 13.79, 3.86),
2837
+ );
2838
+
2839
+ /**
2840
+ * BLENDER HUE-GROUPS ITS RAIL, AND THE SOURCE SAYS WHICH GROUP EACH MARK IS
2841
+ * IN. Every icon in `UI_icons.hh` is declared through a `DEF_ICON_<GROUP>`
2842
+ * macro, and `interface_icons.cc:126-132` maps those macros onto the theme
2843
+ * members `interface/resources.cc:1059-1078` reads:
2844
+ *
2845
+ * DEF_ICON_SCENE → TH_ICON_SCENE `.tui.icon_scene` #cccccc
2846
+ * DEF_ICON_COLLECTION → TH_ICON_COLLECTION `.tui.icon_collection` #ffffff
2847
+ * DEF_ICON_OBJECT → TH_ICON_OBJECT `.tui.icon_object` #e19658
2848
+ * DEF_ICON_OBJECT_DATA → TH_ICON_OBJECT_DATA `.tui.icon_object_data` #00d4a3
2849
+ * DEF_ICON_MODIFIER → TH_ICON_MODIFIER `.tui.icon_modifier` #74a2ff
2850
+ * DEF_ICON_SHADING → TH_ICON_SHADING `.tui.icon_shading` #cc6670
2851
+ *
2852
+ * (`userdef_default_theme.c:272-277`.) So the assignments below are READ, not
2853
+ * matched by eye — and reading them corrected two guesses I1 recorded as open:
2854
+ * WORLD is `DEF_ICON_SHADING(WORLD)` (`UI_icons.hh:193`), the same red as
2855
+ * Material and Texture, NOT the scene grey; and GROUP — the Collection tab —
2856
+ * is `DEF_ICON_COLLECTION(GROUP)` (`:248`), a white of its own rather than
2857
+ * that grey either. The scene group is exactly four tabs: Render
2858
+ * (`DEF_ICON_SCENE(SCENE)`, `:188`), Output (`:187`), View Layer
2859
+ * (`DEF_ICON_SCENE(RENDER_RESULT)`, `:265`) and Scene
2860
+ * (`DEF_ICON_SCENE(SCENE_DATA)`, `:267`).
2861
+ *
2862
+ * Every object-data mark — MESH_DATA, ARMATURE_DATA, BONE_DATA and the rest of
2863
+ * the per-type set the Data tab draws — is `DEF_ICON_OBJECT_DATA`
2864
+ * (`:251-263`), which is why they share one tone rather than list one each.
2865
+ */
2866
+ const CATEGORY_TONE = {
2867
+ 'properties-render': 'scene',
2868
+ 'properties-output': 'scene',
2869
+ 'properties-view-layer': 'scene',
2870
+ 'properties-scene': 'scene',
2871
+ 'properties-collection': 'collection',
2872
+ 'properties-world': 'material',
2873
+ 'properties-object': 'object',
2874
+ 'properties-physics': 'modifier',
2875
+ 'properties-particles': 'modifier',
2876
+ 'properties-constraints': 'modifier',
2877
+ 'properties-bone-constraints': 'modifier',
2878
+ 'properties-bone': 'data',
2879
+ 'properties-material': 'material',
2880
+ 'properties-texture': 'material',
2881
+ 'outliner-object': 'object',
2882
+ 'outliner-mesh': 'object',
2883
+ 'outliner-light': 'object',
2884
+ 'outliner-camera': 'object',
2885
+ 'outliner-data': 'data',
2886
+ atom: 'modifier',
2887
+ palette: 'material',
2888
+ 'screwdriver-wrench': 'tool',
2889
+ 'properties-modifiers': 'modifier',
2890
+ 'properties-data': 'data',
2891
+ // The Data tab's per-object-type marks (`buttons_context_compute` sets
2892
+ // `sbuts->dataicon` from `RNA_struct_ui_icon`) — all one group.
2893
+ ...Object.fromEntries(
2894
+ Object.keys(traced)
2895
+ .filter((name) => name.startsWith('properties-data-'))
2896
+ .map((name) => [name, 'data']),
2897
+ ),
2898
+ };
2899
+ for (const [name, tone] of Object.entries(CATEGORY_TONE)) {
2900
+ // A table that names a glyph this set no longer draws is a silent no-op,
2901
+ // and a tone assignment that silently stops applying is exactly the kind
2902
+ // of thing nobody notices until the rail goes grey.
2903
+ if (!glyphs[name]) throw new Error(`CATEGORY_TONE names a glyph this set does not draw: ${name}`);
2904
+ glyphs[name] = { ...glyphs[name], tone };
2905
+ }
2906
+ for (const [name, glyph] of Object.entries(glyphs)) {
2907
+ if (glyph.tone !== undefined && !TONES.includes(glyph.tone))
2908
+ throw new Error(
2909
+ `glyph "${name}" carries tone "${glyph.tone}", which is not an IconCategoryTone`,
2910
+ );
2911
+ if (glyph.tonedPath !== undefined && glyph.tone === undefined)
2912
+ throw new Error(
2913
+ `glyph "${name}" has a tonedPath and no tone — the second path would paint currentColor`,
2914
+ );
2915
+ }
2916
+
2917
+ // ---------------------------------------------------------------- emit
2918
+
2919
+ const ordered = {};
2920
+ for (const key of Object.keys(glyphs).sort()) ordered[key] = glyphs[key];
2921
+
2922
+ const out = { id: 'blender', title: 'Blender', glyphs: ordered };
2923
+ const target = new URL('./blender.icons.json', import.meta.url);
2924
+ writeFileSync(target, `${JSON.stringify(out, null, 2)}\n`);
2925
+ console.log(`blender.icons.json — ${Object.keys(ordered).length} glyphs`);