@volter/editor-blender 0.1.149 → 0.1.151

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 (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +100 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +142 -332
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +43 -23
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +12 -32
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -43
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -1,11 +1,10 @@
1
1
  /**
2
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
3
+ * Traced glyphs come from Blender's own vector sources and are GPL-2.0-or-later
4
+ * artwork (see `blender-icon-trace.mjs` and the provenance file
5
+ * `blender.icons.traced.json`); the remaining glyphs are drawn HERE in Blender's
7
6
  * IDIOM. That mixture is why `@volter/editor-blender` is
8
- * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/blender/LICENSE`).
7
+ * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/editor-blender/LICENSE`).
9
8
  * The idiom is what a screenshot conveys: a 16-unit grid, monochrome filled
10
9
  * silhouettes, ~1.3-unit strokes rendered as filled capsules with round ends,
11
10
  * no outline around a fill, optical size ~12 units inside the 16 box.
@@ -14,7 +13,7 @@
14
13
  * the JSON. The JSON is the artifact the style bundle imports; THIS is where a
15
14
  * glyph is edited, because a path `d` is not a thing a person edits by hand.
16
15
  *
17
- * Keys are Font Awesome icon NAMES (`icon.iconName`) — the editor's one icon
16
+ * Keys are icon NAMES (`icon.iconName`) — the editor's one icon
18
17
  * primitive swaps a glyph in per name and falls through to its own for any
19
18
  * name absent here, so this set is partial by design.
20
19
  */
@@ -72,27 +71,7 @@ const TAB_ICON_SOURCES = {
72
71
  'properties-data-empty': 'empty_data', // no data → ICON_EMPTY_DATA
73
72
  };
74
73
 
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
- */
74
+ /** Trace Outliner icon families from tree_element_get_icon, tree_element_get_icon_from_id and RNA enum prefixes. blenderOutlinerKind derives the same blender-<file> names without a second per-icon lookup table. */
96
75
  const OUTLINER_ICON_FAMILIES = [
97
76
  // `icon_from_object_type` and `tree_element_get_icon_from_id`'s data marks.
98
77
  'outliner_',
@@ -159,14 +138,9 @@ const OUTLINER_ICON_NAMES = [
159
138
  const TRACED = new URL('./blender.icons.traced.json', import.meta.url);
160
139
 
161
140
  /**
162
- * `--trace` — RE-DERIVE the traced paths from a Blender checkout. Separate
163
- * from the ordinary run on purpose: the checkout is a developer's local clone
164
- * of Blender at the engine's pin (the recipe is in WORK.md §Blender in the
165
- * tab is Blender, "Inspection parity"), and a generator that silently emits
166
- * different glyphs depending on whether a sibling directory happens to exist
167
- * is worse than one that needs a flag. The JSON it writes is checked in and
168
- * records each glyph's source path and sha256, so a later trace that disagrees
169
- * is a visible diff rather than a mystery.
141
+ * `--trace` rebuilds the traced paths from a Blender checkout at the engine's
142
+ * pin, selected by BLENDER_SRC or the default checkout path below. Ordinary
143
+ * generation consumes the saved JSON, which records each source path and sha256.
170
144
  */
171
145
  if (process.argv.includes('--trace')) {
172
146
  const checkout = process.env['BLENDER_SRC'] ?? `${homedir()}/volter/blender-src`;
@@ -230,8 +204,7 @@ if (process.argv.includes('--trace')) {
230
204
  } catch {
231
205
  throw new Error(
232
206
  `${relative} is not in the Blender checkout at ${checkout}. Set BLENDER_SRC, or add ` +
233
- `the directory to the sparse checkout: \`git sparse-checkout add release/datafiles/icons_svg\` ` +
234
- 'followed by the LFS-off checkout line in WORK.md.',
207
+ `the directory to the sparse checkout: \`git sparse-checkout add release/datafiles/icons_svg\`.`,
235
208
  );
236
209
  }
237
210
  if (svg.startsWith('version https://git-lfs')) {
@@ -267,17 +240,7 @@ const f = (n) => {
267
240
  return Object.is(r, -0) ? 0 : r;
268
241
  };
269
242
 
270
- /**
271
- * TWO GLOBAL DIALS, set against the running chrome rather than guessed. The
272
- * editor paints a glyph at the ACTIVE font size — an 11px hierarchy row, a
273
- * 10px status bar — where a 1.3-unit stroke in a 16-unit box lands at 0.9
274
- * device pixels and greys out. `S` grows the optical size to ~14.5 units
275
- * inside the 16 box — Blender's glyphs FILL their box where Font Awesome's
276
- * carry padding, and measured against the reference frames that ratio (ink
277
- * over row height) is what makes a row read as Blender's; `W` fattens every
278
- * stroke so it still covers a pixel in an 11px row. Geometry below is
279
- * authored on the plain 16 grid and both dials are applied here, once.
280
- */
243
+ /** S sets optical extent within the 16-unit grid; W gives strokes enough coverage at small chrome sizes. Apply both once to authored geometry. */
281
244
  const S = 1.22;
282
245
  const W = 1.12;
283
246
  const p = (x, y) => `${f(8 + (x - 8) * S)} ${f(8 + (y - 8) * S)}`;
@@ -287,28 +250,10 @@ const rr = (r) => f(r * S);
287
250
  /**
288
251
  * A straight stroke with round ends: the capsule its outline encloses.
289
252
  *
290
- * THE SWEEP FLAG IS 0, AND IT WAS 1 UNTIL 2026-09-19 — a one-character defect
291
- * under every open stroke end in this set. With sweep 1 each end arc bulges
292
- * INWARD instead of outward, so the path is the rectangle MINUS two
293
- * half-discs: every open end came out with a crescent bitten out of it, and
294
- * at a polyline's joint the two bites ate a round notch out of the corner.
295
- * Nothing reported it because at the 14 px chrome size the bite is one device
296
- * pixel; it was found at 6x in the editor-type well, whose chevron drew as
297
- * two arrowheads with a hole where they meet (rendered side by side, sweep 1
298
- * against sweep 0, before the flag was touched).
299
- *
300
- * Closed shapes change too, and toward this docblock's own words: `line(…,
301
- * closed)` and `rframe` corners were each losing a notch to the same bites,
302
- * half-covered by the neighbouring capsule.
303
- *
304
- * THE BLAST RADIUS, MEASURED rather than reasoned (every glyph rasterized at
305
- * 64 px both ways and the masks differenced): 97 of the 146 paths move. 96 of
306
- * them ONLY GAIN ink, at an end or a corner — a knockout that is not there
307
- * can take nothing. The 97th, `layer-group`, gains 199 px and loses one, on an
308
- * antialiased boundary. Nothing here was tuned against the bitten rendering:
309
- * the two glyphs this set fits pixel-by-pixel to a reference frame
310
- * (`properties-modifiers`, and `outliner`/`properties` below) are built from
311
- * `poly` and `rrect` and do not move at all.
253
+ * THE SWEEP FLAG IS 0. With sweep 1 each end arc would bulge INWARD instead of
254
+ * outward, so the path would be the rectangle MINUS two half-discs: every open
255
+ * end with a crescent bitten out of it, and at a polyline's joint a round notch
256
+ * out of the corner.
312
257
  */
313
258
  function cap(x1, y1, x2, y2, width = 1.3) {
314
259
  const w = width * W;
@@ -342,20 +287,7 @@ function ring(cx, cy, r, width = 1.3) {
342
287
  return dot(cx, cy, r + w / 2) + dot(cx, cy, Math.max(r - w / 2, 0.01), true);
343
288
  }
344
289
 
345
- /**
346
- * A polyline stroked with round ends and round joins.
347
- *
348
- * THE JOINS NEED NO EXTRA DISC, and stamping one was actively destructive.
349
- * Each `cap` ends in a full semicircle of radius w/2 centred on the join, so
350
- * the union of two capsules already contains the whole join disc — the joins
351
- * are round for free. The disc this used to add at every interior vertex had
352
- * the opposite winding to the capsules it landed in, so under
353
- * `fill-rule: nonzero` it PUNCHED A HOLE: render `line([[3,3],[3,13],[13,13]])`
354
- * at 24x and the corner is a white ring around a black core, where the same
355
- * two `cap` calls alone give a solid corner. At 14px that hole is a sub-pixel
356
- * dark pip on every corner of every stroked glyph in the set — the
357
- * "greys out at a small size" symptom, dropped here for all 130 at once.
358
- */
290
+ /** Round-ended capsules already cover the join disc. An extra disc reverses winding under nonzero fill and cuts a hole in the join. */
359
291
  function line(pts, w = 1.3, closed = false) {
360
292
  let d = '';
361
293
  const n = pts.length;
@@ -386,20 +318,7 @@ function poly(pts, hole = false) {
386
318
  return `M${wound.map(([x, y]) => p(x, y)).join('L')}Z`;
387
319
  }
388
320
 
389
- /**
390
- * A filled rectangle with equal corner radii.
391
- *
392
- * IT WINDS THE WAY `poly` AND `cap` WIND, and that is load-bearing, not a
393
- * detail: for most of this file's life the rounded branch ran the other way
394
- * round the corners, so under `fill-rule: nonzero` every `holeBar`/`dot(ccw)`
395
- * aimed at a rounded rect UNIONED instead of punching, and every ordinary
396
- * `poly`/`dot` drawn over one punched a hole instead of adding ink. Nothing
397
- * reported it — a knockout that does nothing just looks like a solid glyph.
398
- * It silently ate the collection box's pull slot, the wrench's fork and the
399
- * bulb's screw thread, and it is why the pan hand carried a bite out of its
400
- * palm where its thumb should be. Measured, not reasoned: render
401
- * `rrect(r) + holeBar` and read the centre pixel.
402
- */
321
+ /** A rounded rectangle winds in the same direction as poly and cap so ordinary geometry unions and reversed geometry cuts holes under nonzero fill. */
403
322
  function rrect(x, y, w, h, r = 0) {
404
323
  const rad = Math.min(r, w / 2, h / 2);
405
324
  if (rad <= 0.01)
@@ -417,19 +336,8 @@ function rrect(x, y, w, h, r = 0) {
417
336
  );
418
337
  }
419
338
 
420
- /**
421
- * A rectangular outline of thickness `w`, corners rounded by the joins.
422
- *
423
- * A corner radius SMALLER than the stroke's own round end is not a corner
424
- * radius, it is a defect: the bevel becomes a segment shorter than the capsule
425
- * is wide, its capsule overlaps both neighbours almost entirely, and the
426
- * overlap cancels under `fill-rule: nonzero` — at 12x, `border-all` (r 0.4,
427
- * t 1) and `object-group` (r 0.3, t 2.1) both showed a hollow pip at every
428
- * corner. Below `t/2` the caps already round the corner by more than `r`
429
- * asked for, so the plain four-point path is both cleaner and closer to the
430
- * request.
431
- */
432
- function rframe(x, y, w, h, t = 1.3, r = 0) {
339
+ /** A rectangular outline uses rounded joins. For r at or below half the stroke width, use a plain four-point path; the caps already provide the requested corner rounding. */
340
+ function rframe(x, y, w, h, t, r = 0) {
433
341
  const inset = t / 2;
434
342
  if (r <= inset) {
435
343
  return line(
@@ -453,22 +361,18 @@ function rframe(x, y, w, h, t = 1.3, r = 0) {
453
361
  [x + inset, y + h - inset - r],
454
362
  [x + inset, y + inset + r],
455
363
  ];
456
- return line(r > 0 ? pts : [pts[0], pts[1], pts[3], pts[5]], t, true);
364
+ return line(pts, t, true);
457
365
  }
458
366
 
459
367
  /**
460
368
  * A stroked arc, angles in degrees, 0 = +x, growing clockwise on screen.
461
369
  *
462
- * ONE annulus band, not a chain of capsules. Built as `line()` over sampled
463
- * points, each segment's capsule is shorter than it is wide, consecutive
464
- * capsules overlap almost entirely, and the overlaps cancel under
465
- * `fill-rule: nonzero` — rendered at 24x, `rotate`'s arc and `lock`'s shackle
466
- * came out as a row of hollow circles rather than a stroke. A band has no
467
- * overlaps to cancel: out along the outer radius, back along the inner.
370
+ * One annulus band follows the outer radius and returns along the inner
371
+ * radius, preserving a continuous fill without overlapping capsule windings.
468
372
  */
469
- function arc(cx, cy, r, a0, a1, w = 1.3, steps = 0) {
373
+ function arc(cx, cy, r, a0, a1, w) {
470
374
  const t = (w * W) / 2;
471
- const n = steps || Math.max(6, Math.ceil(Math.abs(a1 - a0) / 9));
375
+ const n = Math.max(6, Math.ceil(Math.abs(a1 - a0) / 9));
472
376
  const at = (i, rad) => {
473
377
  const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
474
378
  return [cx + Math.cos(a) * rad, cy + Math.sin(a) * rad];
@@ -481,7 +385,7 @@ function arc(cx, cy, r, a0, a1, w = 1.3, steps = 0) {
481
385
  }
482
386
 
483
387
  /** A stroked elliptical arc — cylinder lips, cloud shoulders, orbits. */
484
- function ellipseArc(cx, cy, rx, ry, a0, a1, w = 1.15, rot = 0, closed = false) {
388
+ function ellipseArc(cx, cy, rx, ry, a0, a1, w, rot = 0, closed = false) {
485
389
  const t = (rot * Math.PI) / 180;
486
390
  const n = Math.max(4, Math.ceil(Math.abs(a1 - a0) / 15));
487
391
  const pts = [];
@@ -539,7 +443,7 @@ function holePoly(pts) {
539
443
 
540
444
  /** Blender's chevron: a thin V, the outliner's disclosure and every menu arrow. */
541
445
  function chevron(cx, cy, s, dir) {
542
- const v = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[dir];
446
+ const v = { right: [1, 0], left: [-1, 0], down: [0, 1] }[dir];
543
447
  const perp = [-v[1], v[0]];
544
448
  const tip = [cx + v[0] * s * 0.55, cy + v[1] * s * 0.55];
545
449
  const back = -s * 0.55;
@@ -553,14 +457,7 @@ function chevron(cx, cy, s, dir) {
553
457
  );
554
458
  }
555
459
 
556
- /**
557
- * Blender's isometric cube — a SOLID hexagonal silhouette with its three
558
- * interior seams knocked out, never a wire box. `outliner.png` at matched
559
- * scale settles it: every object mark in a row is a filled silhouette with its
560
- * detail punched through, and the wire version of this glyph collapsed into an
561
- * undifferentiated grey circle at the 14px row size (its three 1.15-unit
562
- * spokes merged and the hexagon's corners rounded away).
563
- */
460
+ /** Blender’s isometric cube is a filled hexagonal silhouette with three interior seams knocked out. */
564
461
  function isoCube(cx = 8, cy = 8, s = 5.4, seam = 1.05) {
565
462
  const top = [cx, cy - s];
566
463
  const tr = [cx + s * 0.87, cy - s * 0.5];
@@ -577,16 +474,10 @@ function isoCube(cx = 8, cy = 8, s = 5.4, seam = 1.05) {
577
474
  }
578
475
 
579
476
  // ------------------------------------------------------------------- glyphs
580
- // Keyed by Font Awesome icon name. Grouped the way the chrome reads them.
477
+ // Keyed by icon name. Grouped the way the chrome reads them.
581
478
 
582
479
  const glyphs = {};
583
- /**
584
- * `meta` is the glyph's COLOUR channel (`IconSetContribution`): `tone` names
585
- * a category the palette inks, and `tonedPath` splits the drawing so only
586
- * that second path takes it. Whole-glyph tones are assigned from one table
587
- * further down, next to the frames they were measured in; `tonedPath` has to
588
- * be authored HERE, because it is a division of the geometry.
589
- */
480
+ /** meta carries category tone and optional tonedPath. A whole-glyph tone belongs to the table below; a tonedPath is part of the geometry. */
590
481
  const g = (name, d, meta) => {
591
482
  glyphs[name] = { path: d, ...(meta ?? {}) };
592
483
  };
@@ -595,18 +486,7 @@ const g = (name, d, meta) => {
595
486
  g('chevron-right', chevron(7.4, 8, 3.1, 'right'));
596
487
  g('chevron-left', chevron(8.6, 8, 3.1, 'left'));
597
488
  g('chevron-down', chevron(8, 7.4, 3.1, 'down'));
598
- // BLENDER HAS NO SOLID CARET. Both places the editor spends one — a dropdown
599
- // trigger's disclosure and a tree row's expander — Blender draws an open
600
- // CHEVRON, measured in two frames: `modeling.png` at its native 2x, the
601
- // orientation well's mark at x 1304..1316, y 76..83 (13x8 device px = 6.5x4
602
- // CSS, peak 216); and `outliner.png` (also native 2x), the collapsed Camera
603
- // row's mark at x 96..105, y 146..163 (10x18 = 5x9 CSS) and the expanded
604
- // Collection row's at x 52..69, y 110..119 (18x10 = 9x5 CSS), peak 194. Both
605
- // are a 1 CSS px stroke and both run 1.8:1, which is exactly what `chevron()`
606
- // draws. The names stay `caret-*` — a set draws the editor's names its own
607
- // way, and Font Awesome's filled triangle is still what every other skin gets.
608
- // `s` is 4.4 rather than `chevron-*`'s 3.1 so the mark keeps the EXTENT the
609
- // filled caret had here (8.8 of 16 units, ~7 CSS px against Blender's 6.5).
489
+ // Caret names draw open chevrons. Their 4.4-unit extent preserves the dropdown caret’s size; chevron-* uses 3.1.
610
490
  g('caret-right', chevron(7.4, 8, 4.4, 'right'));
611
491
  g('caret-down', chevron(8, 7.4, 4.4, 'down'));
612
492
  g(
@@ -686,24 +566,11 @@ g(
686
566
  cap(9.3, 7.4, 9.1, 11.5, 1.1),
687
567
  );
688
568
  g('magnifying-glass', ring(7.1, 7.1, 3.7, 1.3) + cap(9.9, 9.9, 13.2, 13.2, 1.4));
689
- /**
690
- * ZOOM is not SEARCH, and Blender draws the two differently — both frames
691
- * agree: the Search field's magnifier (`outliner.png`, `properties-object.png`)
692
- * is a thin RING with a handle, while the viewport navigation cluster's zoom
693
- * (`modeling-object-none.png`) is a SOLID lens disc with the sign knocked
694
- * through it and a short fat handle. So `magnifying-glass` keeps its ring and
695
- * the two signed ones become lenses; drawn as rings, their 1.1-unit sign
696
- * strokes were the faintest ink on the whole navigation cluster.
697
- */
698
- // The handle STARTS INSIDE the lens (9.3,9.3 is 3.1 from the centre of a
699
- // 4.25 disc): begun at 10.2 it cleared the disc's edge by a tenth of a unit
700
- // and the round cap floated off as a separate lozenge at 16px — measured in
701
- // round 1's frame, where the navigation cluster showed a disc and a diamond.
569
+ /** Search uses a ring magnifier; viewport zoom uses a solid lens with a cut-out sign. */
570
+ // Start the handle inside the lens so its rounded cap joins the disc.
702
571
  const zoomLens = (sign) => dot(7.1, 7.1, 4.25) + sign + cap(9.3, 9.3, 13.4, 13.4, 2);
703
- // THE PLUS IS ONE HOLE, a cross outline: two crossing hole bars wind the crossing twice, and
704
- // under nonzero fill that square paints lens-coloured again (the frame showed a pale centre).
705
- // Its arms are Blender's on `gizmo-select-box.png`'s cluster: 14 device px across a 26 px
706
- // lens, 2.2 px thick.
572
+ // Draw the plus as one cross-shaped hole; crossing two hole bars fills their shared center under nonzero winding.
573
+ // Its arms span 14 of the 26-device-pixel lens and are 2.2 pixels thick.
707
574
  const plusHole = (cx, cy, a, t) =>
708
575
  poly(
709
576
  [
@@ -730,17 +597,7 @@ g('grip-lines-vertical', cap(6.3, 3, 6.3, 13, 1.2) + cap(9.7, 3, 9.7, 13, 1.2));
730
597
  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));
731
598
 
732
599
  // -- visibility, the outliner's right column ------------------------------
733
- /**
734
- * The eye is the KNOCKOUT construction the header describes, and it is what
735
- * `outliner.png` shows at matched scale: one solid almond, a dark iris punched
736
- * out of it, a bright pupil inside the iris — white / dark / white, three
737
- * bands that survive a 14px row. Drawn instead as two thin lid strokes with a
738
- * dot between them (what this was through round 0), every band is a
739
- * partial-coverage stroke: measured against its own row, the glyph peaked at
740
- * p95 145 where the label beside it peaked at 185, while Blender's peaks
741
- * ABOVE its label (151 vs 129). A silhouette reaches full ink; a hairline
742
- * cannot.
743
- */
600
+ /** The eye is a solid almond with a dark iris cut out and a bright pupil inside, preserving three visible bands in small rows. */
744
601
  const eyeLid = [
745
602
  [1.8, 8],
746
603
  [3.6, 5.3],
@@ -767,12 +624,7 @@ g('cubes-stacked', isoCube(8, 4.6, 3.3, 0.8) + isoCube(8, 11.4, 3.3, 0.8));
767
624
  g('square', rframe(3, 3, 10, 10, 1.3));
768
625
  g('circle', ring(8, 8, 4.4, 1.3));
769
626
  g('circle-dot', dot(8, 8, 6) + dot(8, 8, 3.9, true) + dot(8, 8, 2));
770
- // Blender's collection: a lidded box with a pull slot.
771
- // Filled body, filled lid band, and the pull slot KNOCKED OUT of the body —
772
- // exactly what `outliner.png` shows at matched scale, where Blender's
773
- // Collection is the brightest mark in the row. Drawn as a 1.25-unit frame
774
- // under a filled lid, it read as a hairline rectangle no heavier than the
775
- // label beside it.
627
+ // Blender’s collection mark is a filled, lidded box with a pull slot knocked out of its body.
776
628
  const box =
777
629
  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);
778
630
  g('box-archive', box);
@@ -969,15 +821,7 @@ g(
969
821
  'table-cells-large',
970
822
  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),
971
823
  );
972
- /**
973
- * A FLAT 3x3 grid, and it STAYS FLAT. Four sites ask for `border-all` and
974
- * three of them mean a flat grid — the canvas viewport's grid toggle, the
975
- * 3D viewport overlay's, and the CSS border editor's "all borders" — so the
976
- * trapezoid Blender draws on the navigation cluster's projection toggle is
977
- * NOT this glyph's to become. It gets its own name below, which is the same
978
- * ruling `editorIcons.modeling`'s docblock already records for the mesh
979
- * operators: a glyph that IS an operation owns its own name.
980
- */
824
+ /** border-all draws a flat 3×3 grid for grid and border controls. The viewport’s perspective projection toggle has its own glyph name. */
981
825
  g(
982
826
  'border-all',
983
827
  rframe(3.2, 3.2, 9.6, 9.6, 1, 0.4) +
@@ -986,32 +830,7 @@ g(
986
830
  cap(3.7, 6.4, 12.3, 6.4, 0.85) +
987
831
  cap(3.7, 9.6, 12.3, 9.6, 0.85),
988
832
  );
989
- /**
990
- * THE PROJECTION TOGGLE at the foot of the navigation cluster — Blender's
991
- * fourth cell, and it is a PERSPECTIVE grid, not a square one. Read off
992
- * `modeling-edit-none.png` (device x 2774..2805, y 541..572): a symmetric
993
- * trapezoid 32 device wide by 26 tall, four horizontal rules whose spacing
994
- * OPENS downward (6, 8, 10 device) and four verticals converging upward, the
995
- * outer pair running x 6.8 -> 1.5 on the left and 24.2 -> 29.5 on the right.
996
- * Ours was a flat square 27x26 — the height was already Blender's and the
997
- * width was 5 device short, because a square cannot be as wide at its foot as
998
- * a trapezoid.
999
- *
1000
- * The prior cut said so itself and stopped there, for the right reason: it
1001
- * shared `border-all` with three flat-grid sites. The name is what was in the
1002
- * way, so the name is what moved (`editorIcons.viewport.projection` is now
1003
- * `viewport-projection`, carrying Font Awesome's `border-all` drawing as its
1004
- * fallback, so a set without this key paints exactly what it painted before).
1005
- * THE PRIOR CUT ALSO MIS-COUNTED IT as "four cells across": it is three, in
1006
- * both axes — four rules bound three columns.
1007
- *
1008
- * Every constant below was seeded off that character map and then settled
1009
- * against the cell's own half-coverage mask, the same instrument the wrench
1010
- * and the hand use. Seeded 150, settled 7 of 374 reference ink px (the old
1011
- * square: 316). No constant moved as much as one device pixel from its
1012
- * reading, and the four rows moved by ONE SHARED half-pixel, which is the
1013
- * cell's own centring rather than a fit.
1014
- */
833
+ /** The viewport projection glyph is a perspective grid with four rules bounding three columns. Horizontal spacing increases downward and vertical lines converge upward. */
1015
834
  const perspectiveGrid = () => {
1016
835
  const cx = 8.01; // the trapezoid's axis
1017
836
  const yTop = 3.71; // its near and far rules
@@ -1037,11 +856,7 @@ const perspectiveGrid = () => {
1037
856
  );
1038
857
  };
1039
858
  g('viewport-projection', perspectiveGrid());
1040
- /*
1041
- * VIEW_ORTHO, the same toggle in an orthographic view: a flat square grid of three by three
1042
- * cells, as wide as the perspective trapezoid (32 device px on `gizmo-select-box.png`'s
1043
- * navigation capsule at 2x) and centred with it, its rules the trapezoid's weight.
1044
- */
859
+ /* VIEW_ORTHO draws a centered 3×3 square grid at the perspective trapezoid’s width and rule weight. */
1045
860
  const orthographicGrid = () => {
1046
861
  const lo = 2.61;
1047
862
  const hi = 13.41;
@@ -1089,9 +904,7 @@ g(
1089
904
  [8.8, 11.4],
1090
905
  ]),
1091
906
  );
1092
- // The Light row in `outliner.png`: the bulb FILLS its 14px slot — a big head,
1093
- // a wide screw base, a foot. At the old 3.5 head it rendered ~7px of ink in a
1094
- // 14px row and read as a dim pebble beside its own label.
907
+ // The Light glyph fills its slot with a bulb head, screw base and foot.
1095
908
  g(
1096
909
  'lightbulb',
1097
910
  dot(8, 5.7, 4.3) +
@@ -1273,59 +1086,13 @@ g(
1273
1086
  [12.6, 8.6],
1274
1087
  ]),
1275
1088
  );
1276
- // Blender's pan hand (`modeling-object-none.png`) is COMPACT: ~13 px of ink in
1277
- // the cluster's 16 px slot. Ours spanned 1.8..14.2, which the scale dial pushes
1278
- // to ~15 px rendered — a hand a full size larger than the zoom beside it.
1089
+ // Keep the pan hand compact at about 13 pixels of ink within its 16-pixel slot.
1279
1090
  g(
1280
1091
  'hand-pointer',
1281
- // Blender's pan hand measures 15x15 in the cluster at matched scale — SQUARE.
1282
- // Round 2's shrink left ours 18x20: right width, three pixels too tall,
1283
- // because the middle finger still reached the top of the box.
1092
+ // Keep the pan hand square within the navigation glyph box.
1284
1093
  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),
1285
1094
  );
1286
- /**
1287
- * THE PAN TOOL'S OWN GLYPH. `editorIcons.viewport.pan` is `faHand`, whose
1288
- * iconName is `hand` — a name this set did not carry, so the navigation
1289
- * cluster drew Font Awesome's hand and nothing else on the Model workspace
1290
- * did (checked by resolving every `fa*` identifier in `packages/editor/src`
1291
- * and `packages/mesh/contributions` to its `iconName` and differencing
1292
- * against these keys: `hand` was the only genuine miss; `faExclamationTriangle`
1293
- * and `faTimesCircle` are FA-5 aliases that resolve to `triangle-exclamation`
1294
- * and `circle-xmark`, both present).
1295
- *
1296
- * RE-MEASURED 2026-09-19 against the capsule's SECOND CELL on
1297
- * `modeling-edit-none.png` (device x 2774..2805, y 421..452 — the cluster's
1298
- * cells are 32x32 device and this one's glyph FILLS it, 32x32 of ink). Ours
1299
- * inked 28x27: "15x15 in a 16 px slot" was a reading of an earlier frame and
1300
- * of the wrong pixels; Blender's pan hand is 16.0 x 16.0 CSS, the same box its
1301
- * magnifier fills, which is the whole point — the cluster's glyphs are one
1302
- * size. Three things were wrong besides the size and each is visible at 8x:
1303
- * the fingers were PARALLEL and touching where Blender SPLAYS them with a
1304
- * clear gap (the index leans right going down, the ring leans left, only the
1305
- * middle is vertical); the thumb was a horizontal block at the palm's waist
1306
- * where Blender's points LEFT and DOWN out of the palm's lower third; and the
1307
- * palm was a flat-bottomed box where Blender's bottom is a wide dome. Together
1308
- * they read as a mitten.
1309
- *
1310
- * FITTED THE WAY `modifierWrench` WAS: the structure read off the cell as a
1311
- * character map first (four splayed finger capsules, a domed palm, a thumb),
1312
- * then every constant settled by rendering at the reference's own scale and
1313
- * counting the pixels where the two HALF-COVERAGE masks disagree. Seeded from
1314
- * the map alone it scored 106 of 534 reference ink px against the old glyph's
1315
- * 277; settled, 39, of which seven are the dome's last row. Every finger constant stayed within a device pixel of its
1316
- * own reading. THE PALM'S DID NOT, and that is honest rather than hidden: an
1317
- * axis-aligned rounded rect cannot be Blender's asymmetric dome (the thumb's
1318
- * mass makes its bottom-left tighter than its bottom-right), so the fit moved
1319
- * the palm 2 device px left to trade that error against the thumb's. The one
1320
- * run of reference ink we do not cover is the dome's last row.
1321
- *
1322
- * THE INSTRUMENT, stated because it decides every number here: our render box
1323
- * is the `2xl` rung, 18 CSS = 36 device, while Blender's cell is 32 — so a
1324
- * comparison crops our 36 px raster to its central 32, and a glyph that FILLS
1325
- * Blender's cell occupies only 32/36 of our own viewBox. Reading ours at a
1326
- * loose coverage cut instead of half makes it measure a pixel wider than it
1327
- * is, which is the trap the mesh-data glyph's note already records.
1328
- */
1095
+
1329
1096
  g(
1330
1097
  'hand',
1331
1098
  // Four fingers, each a capsule from its own cap centre down into the palm.
@@ -1337,24 +1104,7 @@ g(
1337
1104
  cap(3.3, 8.99, 5.38, 10.82, 2.0), // the thumb, out of the palm's lower third
1338
1105
  );
1339
1106
  g(
1340
- /**
1341
- * The MOVE tool. Blender's (`modeling-edit-none.png`, tool shelf) is four
1342
- * SOLID arrowheads standing apart on short stubs around an empty centre —
1343
- * not a crosshair. Ours was one continuous 1.2-unit cross with 1.15-unit
1344
- * chevrons on its ends, which at matched scale is a thin plus sign with
1345
- * ticks: a different mark, and the lightest thing on the rail.
1346
- *
1347
- * RE-MEASURED 2026-09-19 off the same frame, this time as an ASCII map in
1348
- * AUTHORED units (0.5/char) rather than by eye: the previous fit was RIGHT
1349
- * IN FORM AND WRONG IN SIZE AND PARTS. Ours inked 35x34 device px against
1350
- * Blender's 46x46 — 74% — because the heads stopped at radius 6.4 where
1351
- * Blender's tips reach the box edge (radius 8, base at 5.5, half-width
1352
- * 2.4); the STUBS were missing entirely (a 1.0-unit bar from radius 2.5 to
1353
- * 4.6 under each head); and a 1.35 centre dot was drawn where Blender's
1354
- * centre is empty. Size is not a detail on this rail — the same frame's
1355
- * inset and bevel marks ink 40x40 and ours match them to a pixel, so the
1356
- * transform group was the odd one out in its own column.
1357
- */
1107
+ /** Move: four solid arrowheads on separate stubs around an empty centre. */
1358
1108
  'up-down-left-right',
1359
1109
  [
1360
1110
  [0, -1],
@@ -1374,20 +1124,7 @@ g(
1374
1124
  );
1375
1125
  g('arrows-up-down-left-right', glyphs['up-down-left-right'].path);
1376
1126
  g(
1377
- /**
1378
- * The ROTATE tool. Blender draws a heavy arc with a SOLID arrowhead at each
1379
- * end and a solid diamond at the pivot; ours was a 1.3-unit open circle with
1380
- * one small tick, which at matched scale is an empty ring — the pivot, the
1381
- * direction and the weight all missing.
1382
- *
1383
- * RE-MEASURED 2026-09-19 (ASCII map in authored units, and the cell read at
1384
- * 8x): it is TWO arcs, not one — the top arc ends at the LEFT in a
1385
- * DOWN-pointing triangle and the bottom arc ends at the RIGHT in an
1386
- * UP-pointing one, the ordinary two-headed circular arrow, where the
1387
- * previous fit drew one dome with both heads under its ends and no bottom
1388
- * arc at all. And it was 72% of Blender's size (33x29 device against
1389
- * 46x38): the ring's radius is 6.5 of the 16 box, not 4.3.
1390
- */
1127
+ /** Rotate: two heavy arcs with opposing solid arrowheads and a diamond pivot. */
1391
1128
  'rotate',
1392
1129
  arc(8, 7.8, 7.0, 200, 336, 0.8) +
1393
1130
  arc(8, 7.8, 7.0, 20, 156, 0.8) +
@@ -1497,25 +1234,12 @@ g(
1497
1234
  /**
1498
1235
  * THE OTHER TWO TOOLS OF BLENDER'S TRANSFORM GROUP, under names of ours.
1499
1236
  *
1500
- * Move and Rotate above are shared Font Awesome names (`up-down-left-right`,
1501
- * `rotate`) and their Blender drawings say the same thing everywhere they
1502
- * appear, so those stay keyed to the shared name. Scale and Transform do not:
1503
- * the host's Scale tool asked for `maximize` (Font Awesome's window-expand
1504
- * cross) and its Transform tool for `arrows-to-dot` (a crosshair), and
1505
- * Blender's marks for the two — a small square growing into a bigger one, and
1506
- * a square inside a broken ring of arrows — are meaningless under those
1507
- * generic meanings. Same reasoning as `editorIcons.modeling`'s `mesh-*`:
1508
- * a glyph that IS an operation needs its own name or it recuts every other
1509
- * site that asked for the generic one.
1237
+ * Move and Rotate share Font Awesome names (`up-down-left-right`, `rotate`).
1238
+ * Scale and Transform use operation-specific names so their Blender glyphs
1239
+ * do not replace generic window-expand or crosshair icons elsewhere.
1510
1240
  */
1511
1241
  g(
1512
- /**
1513
- * SCALE. Measured on `modeling-edit-none.png` (button 5) as an ASCII map in
1514
- * authored units: a 1-unit square OUTLINE from (2.0, 1.5) to (14.0, 13.5),
1515
- * a SOLID square from (1.5, 7.5) to (8.0, 14.0) overlapping its lower-left
1516
- * corner, and a diagonal arrow inside the frame running up-and-right from
1517
- * (8.6, 6.9) to a head whose apex is (12.4, 3.1).
1518
- */
1242
+ /** Scale: an outlined square overlapped at lower left by a solid square, with a diagonal arrow inside. */
1519
1243
  'tool-scale',
1520
1244
  rframe(2, 1.5, 12, 12, 0.8) +
1521
1245
  rrect(1.5, 7.6, 6.3, 6.3, 0.2) +
@@ -1526,13 +1250,7 @@ g(
1526
1250
  [9.3, 4],
1527
1251
  ]),
1528
1252
  );
1529
- /*
1530
- * SELECT BOX. Measured on `gizmo-select-box.png` (the lit first button, native 2x) and mapped onto
1531
- * the 16-unit grid with the marquee spanning 1..15: a dashed square, 0.7 units thick, dashes about
1532
- * 2.3 units with 1.1-unit gaps and an L at each corner, in the selection tools' orange (the
1533
- * `select` tone, `tonedPath`); inside it a pointer from its tip (6.1, 4.5) down to (6.2, 11.5),
1534
- * notched at (8.2, 9.4), to (11.2, 9.4).
1535
- */
1253
+ /* Select Box: an orange dashed marquee with corner marks and a pointer, authored on the 16-unit grid. */
1536
1254
  const MARQUEE_T = 0.7;
1537
1255
  const marquee =
1538
1256
  // corners: an L each
@@ -1754,20 +1472,13 @@ g(
1754
1472
  ]),
1755
1473
  );
1756
1474
  g(
1757
- /**
1758
- * The Properties TOOL tab. Blender's (`properties-object.png`, top of the
1759
- * tab rail, 14x14 at matched scale) is two tools standing UPRIGHT side by
1760
- * side — a screwdriver and an open-end wrench, both solid. Ours was one
1761
- * diagonal shaft with a ring at one end and a wedge at the other: at 14px it
1762
- * reads as a single key, and the ring is an outline that greys out.
1763
- */
1475
+ /** The Tool tab draws an upright screwdriver and open-end wrench, both solid. */
1764
1476
  'screwdriver-wrench',
1765
1477
  // screwdriver: blade, collar, handle
1766
1478
  rrect(3.5, 2.6, 1.4, 4.6, 0.3) +
1767
1479
  rrect(2.9, 7.2, 2.6, 1.2, 0.3) +
1768
1480
  rrect(3.1, 8.4, 2.2, 5, 0.8) +
1769
- // wrench: a forked head over a shaft. The fork must be WIDE enough to
1770
- // survive 14px — at 1.7 units it closed up and the head read as a block.
1481
+ // Wrench: a forked head over a shaft, with a 2.2-unit jaw opening for 14px use.
1771
1482
  rrect(9.6, 6.6, 2.8, 6.8, 0.9) +
1772
1483
  rrect(8.4, 2.6, 5.2, 4.6, 0.5) +
1773
1484
  holeBar(11, 2, 11, 4.9, 2.2),
@@ -1799,19 +1510,9 @@ g(
1799
1510
  cap(11.6, 2, 14, 4.4, 1.6),
1800
1511
  );
1801
1512
  g(
1802
- /**
1803
- * The MATERIAL section's mark (`inspection/compose.ts` maps /material/ here).
1804
- * Blender's Material tab is a SOLID disc with quadrants knocked out of it;
1805
- * ours was a 1.3-unit outline with three dots inside, and at the 14px rail
1806
- * the outline greyed and the dots merged — it read as a cookie. Same
1807
- * palette silhouette, drawn Blender's way: one filled blob, wells and thumb
1808
- * hole punched THROUGH it.
1809
- */
1513
+ /** The Material section draws a solid palette with wells and a thumb hole cut through it. */
1810
1514
  'palette',
1811
- // One blob with the wells and the thumb hole punched THROUGH it. Round 3
1812
- // grew a handle off the bottom to sell the palette's notch and the glyph came
1813
- // back a mushroom; Blender's Material tab is a compact rounded mass and this
1814
- // matches that silhouette.
1515
+ // A compact palette silhouette with wells and thumb hole cut through it.
1815
1516
  poly(
1816
1517
  Array.from({ length: 28 }, (_, i) => {
1817
1518
  const a = (i / 28) * Math.PI * 2;
@@ -1824,10 +1525,7 @@ g(
1824
1525
  dot(10.4, 10.7, 2, true),
1825
1526
  );
1826
1527
  g(
1827
- // A stack of plates: the top one SOLID (the silhouette that carries the
1828
- // glyph at a 14px row), the two beneath it strokes that read as its shadow.
1829
- // Drawn as three outlines it was three grey lozenges with nothing solid in
1830
- // any of them — the same failure the Collection box had.
1528
+ // A stack of plates: a solid top silhouette with two stroked plates beneath.
1831
1529
  'layer-group',
1832
1530
  poly([
1833
1531
  [8, 2],
@@ -1853,10 +1551,7 @@ g(
1853
1551
  ),
1854
1552
  );
1855
1553
  g(
1856
- // The GROUP row's mark. Beside Blender's Collection box in `outliner.png` at
1857
- // matched scale, a 1.1-unit frame with four nubs was indistinguishable from
1858
- // the letterforms of its own label — same weight, same grey. Blender's is a
1859
- // solid box: a filled band for the frame, and solid corner handles.
1554
+ // The group mark uses a solid box frame and solid corner handles.
1860
1555
  'object-group',
1861
1556
  rframe(2.4, 3.8, 11.2, 8.4, 2.1, 0.3) +
1862
1557
  rrect(0.6, 2, 3.1, 3.1, 0.3) +
@@ -1866,63 +1561,35 @@ g(
1866
1561
  );
1867
1562
 
1868
1563
  // -- the mesh operators: THE OPERATION, ON A CUBE ---------------------------
1869
- /**
1870
- * `editorIcons.modeling.*` asks for `mesh-*` (see that file's header), so these
1871
- * six are free to be what Blender's toolbar draws: not an abstraction of the
1872
- * verb (an up-arrow for Extrude, scissors for Knife) but THE OPERATION PERFORMED
1873
- * ON A CUBE — a cube with one face pulled out, a cube with an inset face, a cube
1874
- * with a chamfered edge, a cube with a cut running round it. Measured in
1875
- * `modeling-edit-none.png` (2x, halved): each glyph inks ~20x20 CSS px in a
1876
- * 38x34 button (Extrude 20x23 — its lifted slab is taller), the cube is a
1877
- * ~2 px wire with the operated element FILLED, and the cube sits in three-quarter
1878
- * view: a front square with a top and a right face receding up-and-right.
1879
- *
1880
- * MONOCHROME, which is this set's contract (`blender.style.ts`) and a real
1881
- * difference from the reference: Blender tints the operated element (green for
1882
- * these six, purple for Smooth) and reads the operation by COLOUR as much as by
1883
- * shape. Here the same job is done by FILL — the operated face is solid against
1884
- * the wire — and whether the set gains an accent channel is its own decision,
1885
- * not one to smuggle in through six glyphs.
1886
- */
1887
-
1888
- /**
1889
- * THE OPERATOR CUBE, once. `P(u, v, k)` is a point in the cube's own axes: `u`
1890
- * across the front face, `v` up it, `k` back along the depth the top and right
1891
- * faces recede along. Every one of the six is authored in those three, so the
1892
- * cube can be re-measured in one place and all six follow.
1893
- *
1894
- * It fills the 16 grid corner to corner (~14 units of extent), because the
1895
- * toolbar's glyph is the biggest in the chrome: that is the ~20 CSS px of ink
1896
- * Blender's 38x34 button carries, once the file's `S` dial is applied.
1897
- */
1564
+ /** Shared three-quarter cube geometry for mesh operators. Fill marks the operated element;
1565
+ * the rest stays wire geometry. tonedPath tints only the operated element. */
1898
1566
  const CUBE = { x0: 1.1, x1: 10.9, yb: 14.9, yt: 5.1, dx: 3.9, dy: -3.9 };
1567
+ /** P(u, v, k) maps the operator cube’s front axes and receding depth. All six operators share this geometry and its optical scale. */
1899
1568
  const P = (u, v, k = 0) => [
1900
1569
  CUBE.x0 + (CUBE.x1 - CUBE.x0) * u + CUBE.dx * k,
1901
1570
  CUBE.yb + (CUBE.yt - CUBE.yb) * v + CUBE.dy * k,
1902
1571
  ];
1903
1572
 
1904
1573
  /**
1905
- * The cube as WIRE — the nine edges a cube shows from here, named so an
1906
- * operator can drop the one it replaces (Bevel replaces the top-front edge,
1907
- * Extrude takes the whole top away with the face it pulled out). `top` is the
1908
- * v the box stops at, so Extrude's body can be shorter than the others' and
1909
- * still leave the glyph the same total ink.
1574
+ * The cube's nine visible edges are named so each operator can omit the edges
1575
+ * it replaces. `top` sets the body's height while the operator's added geometry
1576
+ * completes the glyph.
1910
1577
  */
1911
- function cubeWire({ top = 1, w = 1.15, omit = [], u0 = 0, u1 = 1 } = {}) {
1578
+ function cubeWire({ top = 1, omit = [] } = {}) {
1912
1579
  const edges = {
1913
- frontBottom: [P(u0, 0), P(u1, 0)],
1914
- frontRight: [P(u1, 0), P(u1, top)],
1915
- frontTop: [P(u0, top), P(u1, top)],
1916
- frontLeft: [P(u0, 0), P(u0, top)],
1917
- topLeft: [P(u0, top), P(u0, top, 1)],
1918
- topBack: [P(u0, top, 1), P(u1, top, 1)],
1919
- rightBack: [P(u1, top, 1), P(u1, 0, 1)],
1920
- rightBottom: [P(u1, 0), P(u1, 0, 1)],
1921
- seam: [P(u1, top), P(u1, top, 1)],
1580
+ frontBottom: [P(0, 0), P(1, 0)],
1581
+ frontRight: [P(1, 0), P(1, top)],
1582
+ frontTop: [P(0, top), P(1, top)],
1583
+ frontLeft: [P(0, 0), P(0, top)],
1584
+ topLeft: [P(0, top), P(0, top, 1)],
1585
+ topBack: [P(0, top, 1), P(1, top, 1)],
1586
+ rightBack: [P(1, top, 1), P(1, 0, 1)],
1587
+ rightBottom: [P(1, 0), P(1, 0, 1)],
1588
+ seam: [P(1, top), P(1, top, 1)],
1922
1589
  };
1923
1590
  return Object.entries(edges)
1924
1591
  .filter(([name]) => !omit.includes(name))
1925
- .map(([, [a, b]]) => cap(a[0], a[1], b[0], b[1], w))
1592
+ .map(([, [a, b]]) => cap(a[0], a[1], b[0], b[1], 1.15))
1926
1593
  .join('');
1927
1594
  }
1928
1595
 
@@ -1940,20 +1607,12 @@ function slab(vLow, vHigh) {
1940
1607
  * face floating above it as a solid slab with its own three faces. */
1941
1608
  g(
1942
1609
  'mesh-extrude',
1943
- // The body is OPEN at the top — it lost that face, and Blender's glyph says
1944
- // so by letting the verticals run up and vanish behind the slab rather than
1945
- // closing a rim under it (round 2 closed the rim and the pair read as a
1946
- // crate with a lid; round 1 stopped the verticals short and read as a
1947
- // bracket). So: full-height verticals to the slab's underside, no top rim.
1610
+ // Full-height cube verticals end behind the lifted slab; omit the top rim of the extruded body.
1948
1611
  cubeWire({ top: 0.74, omit: ['frontTop', 'topLeft', 'topBack', 'seam'] }),
1949
1612
  { tone: 'operator', tonedPath: slab(0.74, 1) },
1950
1613
  );
1951
1614
 
1952
- /** INSET — the front face INSET: the rim it leaves is solid, the smaller face
1953
- * inside it is knocked back out, and the four WALLS between them are knocked
1954
- * apart by their own seams. Blender's inset glyph is that funnel — four
1955
- * trapezoids converging on a small dark square — and round 4's flat frame
1956
- * (no seams) read as a picture frame instead of a recess. */
1615
+ /** Inset: four solid rim trapezoids separated by seams around the recessed face. */
1957
1616
  g('mesh-inset', cubeWire(), {
1958
1617
  tone: 'operator',
1959
1618
  tonedPath:
@@ -1965,12 +1624,8 @@ g('mesh-inset', cubeWire(), {
1965
1624
  holeBar(...P(0, 1), ...P(0.3, 0.7), 0.7),
1966
1625
  });
1967
1626
 
1968
- /** BEVEL — one edge CHAMFERED. RE-READ at 8x against the reference: the edge
1969
- * Blender cuts is the TOP-RIGHT one (the seam between the top face and the
1970
- * right face), and the glyph shows the top face solid with a second facet
1971
- * flapping off it along that seam, a hairline of background between the two.
1972
- * Rounds 2-4 chamfered the top-FRONT edge and the result read as a solid top,
1973
- * which is Extrude's answer, not Bevel's. */
1627
+ /** Bevel chamfers the top-right seam between the top and right faces. A solid
1628
+ * top face and chamfer facet meet along a narrow background separation. */
1974
1629
  g('mesh-bevel', cubeWire({ omit: ['seam'] }), {
1975
1630
  tone: 'operator',
1976
1631
  tonedPath:
@@ -1979,10 +1634,7 @@ g('mesh-bevel', cubeWire({ omit: ['seam'] }), {
1979
1634
  holeBar(...P(0.56, 1), ...P(0.56, 1, 1), 0.7),
1980
1635
  });
1981
1636
 
1982
- /** LOOP CUT — the cube CUT IN TWO. Blender's glyph is not a stripe on a cube:
1983
- * it is two half-boxes standing apart with the cut itself, a solid bar, in the
1984
- * gap between them, so the new edge loop is the subject. Round 4 drew a line
1985
- * across an intact cube and read as decoration on the face. */
1637
+ /** Loop Cut: two separated half-boxes with a solid cut bar in the gap. */
1986
1638
  g('mesh-loop-cut', cubeWire(), {
1987
1639
  tone: 'operator',
1988
1640
  tonedPath:
@@ -1994,8 +1646,7 @@ g('mesh-loop-cut', cubeWire(), {
1994
1646
  * turns at a vertex, its ends pinned by the points a knife sets. */
1995
1647
  g('mesh-knife', cubeWire(), {
1996
1648
  tone: 'operator',
1997
- // A RISING cut with its turn on the front face and its far end up on the
1998
- // top face. Round 1 fell to the right and read as a droop.
1649
+ // The cut rises across the front and ends on the top face.
1999
1650
  tonedPath:
2000
1651
  line([P(0.16, 0.06), P(0.52, 0.72), P(0.78, 1, 0.5)], 1.5) +
2001
1652
  dot(...P(0.16, 0.06), 1.45) +
@@ -2020,19 +1671,11 @@ g('mesh-subdivide', cubeWire(), {
2020
1671
  cap(...P(1, 0, 0.5), ...P(1, 1, 0.5), 1.05),
2021
1672
  });
2022
1673
 
2023
- /** SMOOTH — not an operation on the operator CUBE: Blender draws this one (and
2024
- * only this one of the shelf's marks) on a BALL, because smoothing is what
2025
- * turns the cage into one. Measured on `modeling-edit-none.png` (button 17)
2026
- * as an ASCII map in authored units: a faceted sphere ~6.3 units of radius
2027
- * centred at (8.2, 6.6), its quads separated by seams KNOCKED OUT of the
2028
- * fill — an equator across the full width, two meridians, and a latitude
2029
- * seam near the top. The seams are holes, not strokes: drawn as strokes over
2030
- * the ball they would be the same ink as the ball and vanish. */
1674
+ /** Smooth draws a faceted ball with seam knockouts for the equator, meridians and upper latitude. */
2031
1675
  g(
2032
1676
  'mesh-smooth',
2033
1677
  poly(
2034
- // ELEVEN vertices, not a smooth circle: Blender's silhouette is faceted
2035
- // and that is half of what says "quads" before the seams are read.
1678
+ // Nine vertices give the ball a faceted silhouette.
2036
1679
  Array.from({ length: 9 }, (_, i) => {
2037
1680
  const a = (i / 9) * Math.PI * 2 + 0.34;
2038
1681
  return [7.8 + Math.cos(a) * 7.1, 6.4 + Math.sin(a) * 7.1];
@@ -2040,9 +1683,7 @@ g(
2040
1683
  ) +
2041
1684
  // equator, meridians, and the top latitude — each a bar-shaped hole.
2042
1685
  holeBar(0.8, 7.6, 15, 7.6, 0.62) +
2043
- // The meridians and the top latitude BOW, two segments each — a sphere's
2044
- // seams are not straight, and drawn straight the ball read as a beach
2045
- // ball with a grid printed on it rather than as faceted geometry.
1686
+ // The meridians and top latitude bow through two segments each.
2046
1687
  holeBar(6.9, -0.7, 5.9, 6.4, 0.52) +
2047
1688
  holeBar(5.9, 6.4, 6.7, 13.5, 0.52) +
2048
1689
  holeBar(12.7, 1.1, 12.2, 6.4, 0.48) +
@@ -2052,13 +1693,7 @@ g(
2052
1693
  { tone: 'operator' },
2053
1694
  );
2054
1695
 
2055
- /** SHRINK/FATTEN — the selection moved ALONG ITS NORMALS, in either direction,
2056
- * which is why Blender's mark (button 19) carries eight arrowheads: four
2057
- * outside the cube pointing out and four inside pointing in. The cube here is
2058
- * a pair of offset squares rather than the operator CUBE's three-quarter box
2059
- * — measured, the front square is (3.5, 3.5)-(12, 11) and the back one is
2060
- * offset up-and-right by 1.2 — because the arrows need the box read from the
2061
- * front to be legible through it. */
1696
+ /** Shrink/Fatten draws offset front-facing squares with four inward and four outward arrowheads for motion along normals. */
2062
1697
  g(
2063
1698
  'mesh-shrink-fatten',
2064
1699
  // The BOX, front square plus the two BACK edges the front does not hide
@@ -2219,13 +1854,6 @@ g(
2219
1854
  'mobile-screen-button',
2220
1855
  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),
2221
1856
  );
2222
- g(
2223
- 'users',
2224
- dot(5.4, 5, 2.5) +
2225
- arc(5.4, 12.6, 4.4, 180, 360, 1.25) +
2226
- arc(11.4, 5.4, 2.1, 250, 470, 1.15) +
2227
- arc(11.6, 12, 3.6, 200, 340, 1.2),
2228
- );
2229
1857
  g(
2230
1858
  'circle-user',
2231
1859
  dot(8, 8, 6) +
@@ -2394,8 +2022,6 @@ g(
2394
2022
  true,
2395
2023
  ) + cap(8, 2.6, 8, 13.4, 1.15),
2396
2024
  );
2397
- g('boxes-placeholder', '');
2398
- delete glyphs['boxes-placeholder'];
2399
2025
  g(
2400
2026
  'download',
2401
2027
  cap(8, 2.2, 8, 9.8, 1.3) +
@@ -2457,72 +2083,17 @@ g(
2457
2083
  );
2458
2084
 
2459
2085
  // ------------------------------------------------------- category tones
2460
- /**
2461
- * COLOUR AS A GLYPH'S OWN CHANNEL. Blender's Properties-tab rail groups by
2462
- * HUE — in `properties-object.png` at 14 px the tab shapes are near
2463
- * indistinguishable and the ink is what says which group a tab belongs to —
2464
- * and the outliner tints a row's type glyph the same way. Each name below is
2465
- * a category the palette inks (`EditorTheme.color.category`, where every
2466
- * value's source coordinates are recorded); a glyph paints
2467
- * `var(--volter-category-<tone>, currentColor)`, so under a palette that names
2468
- * no category group it paints exactly as it did monochrome.
2469
- *
2470
- * A NAME, NOT A SITE. A tone here tints EVERY site that asks for this icon
2471
- * name, so a name is toned only when its meaning is the category at every
2472
- * one of them. Measured live, that is not a hypothetical: the first cut
2473
- * toned `cube`, `camera`, `lightbulb` and `globe`, and the running editor
2474
- * painted the Perspective/Orthographic dropdown orange and the world-space
2475
- * toggle red — both are viewport CONTROLS that happen to reuse the drawing.
2476
- * The fix is `editorIcons.modeling`'s: a meaning that needs its own ink gets
2477
- * its own name. `hierarchy-kind-icon.ts` now asks for `outliner-object`,
2478
- * `outliner-light` and `outliner-camera`, which no other site asks for, and
2479
- * they are aliased to the same drawings below.
2480
- *
2481
- * - `outliner-object`, `outliner-light`, `outliner-camera` → `object`.
2482
- * `outliner.png`: the Camera, Cube and Light rows' type glyphs all
2483
- * measure #bb7f4d over the row's #272727 — ONE orange for all three,
2484
- * because Blender's outliner discriminates OBJECT from DATA, not camera
2485
- * from light. The same ink is the Object tab's in `properties-object.png`.
2486
- * - `atom` → `modifier`. Its only site is the inspector's physics/rigid-
2487
- * body/collider group (`inspection/compose.ts`'s `GROUP_ICON_RULES`);
2488
- * `properties-object.png` paints Physics — with Modifier, Particles and
2489
- * Constraints — one blue.
2490
- * - `palette` → `material`. Its product sites are all material sections
2491
- * (the same `GROUP_ICON_RULES`, the model-asset and react inspectors);
2492
- * the Material tab of the same rail.
2493
- * - `screwdriver-wrench` → `tool`. Its only site is a project tool's own
2494
- * glyph (`tool-loader.ts`); the Tool tab of the same rail.
2495
- *
2496
- * The six `mesh-*` operator marks carry their tone at the glyph instead,
2497
- * with a `tonedPath`, because an operator mark tints only the element it
2498
- * operates on and leaves the cube neutral.
2499
- *
2500
- * NOT toned, each for a measured reason rather than an oversight:
2501
- * - `globe` — Blender's World tab is red, but no site in this editor draws
2502
- * a globe to mean a World datablock: the two are the transform-SPACE
2503
- * toggle and a build profile. A red globe there reads as an error state.
2504
- * - `table` — its one product site is the mesh inspector's Vertex Groups
2505
- * panel, which is Blender's green `DATA_PT_vertex_groups`, but it renders
2506
- * through `SectionHeader`, which passes `tone="dim"` to every section
2507
- * glyph — and an explicit site tone wins by contract. A tone there would
2508
- * never paint.
2509
- */
2510
2086
  /** The union `IconCategoryTone` declares (`@volter/sdk/looks`), and the
2511
2087
  * reason `blender.style.ts` may narrow this JSON with an assertion: a tone
2512
- * outside it never reaches the artifact. */
2088
+ * outside it never reaches the artifact. Assign tones only to icon names whose
2089
+ * meaning has that category at every site. Outliner aliases own their tint;
2090
+ * generic viewport controls stay neutral. Mesh operators tint only tonedPath. */
2513
2091
  const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection', 'select'];
2514
2092
 
2515
2093
  // The outliner's three names, aliased onto the drawings they share with the
2516
2094
  // generic glyphs. Same picture, its own name, so only the outliner is tinted.
2517
2095
  g('outliner-object', glyphs['cube'].path);
2518
- // A MESH OBJECT's row mark is not a cube. Measured on the native 2x
2519
- // `outliner.png` (the Cube row, src x 127-154, y 179-206): Blender draws an
2520
- // APEX-DOWN triangle, heavily stroked, with a triangular knockout in its upper
2521
- // half — the object-type sibling of the datablock's `properties-data` mark, in
2522
- // the object orange rather than the data green. On the set's 16-unit grid the
2523
- // outer triangle is (1.25,1.25)-(14.75,1.25)-(8,14.75) and the knockout
2524
- // (4.75,4.25)-(11.25,4.25)-(8,9.75). `outliner-object` keeps the cube for the
2525
- // generic three-object row, which has no mesh to name.
2096
+ // The mesh-object mark is an apex-down triangle with an upper triangular knockout. outliner-object remains the generic cube mark.
2526
2097
  g(
2527
2098
  'outliner-mesh',
2528
2099
  poly([
@@ -2539,174 +2110,10 @@ g(
2539
2110
  g('outliner-light', glyphs.lightbulb.path);
2540
2111
  g('outliner-camera', glyphs.camera.path);
2541
2112
 
2542
- // The Properties editor's two package tabs, each its own name so only the
2543
- // rail is tinted. `properties-modifiers` is the wrench Blender draws for the
2544
- // Modifiers tab, in the modifier blue. `properties-data` is Blender's
2545
- // MESH_DATA mark — a triangle with a vertex dot at each corner — drawn on
2546
- // the set's own grid at its own stroke, measured from `properties-object.png`'s
2547
- // Object Data tab (src x 10-56, y 802-830).
2548
- /**
2549
- * BLENDER'S MODIFIER TAB IS ONE OPEN-END WRENCH, and this glyph used to be
2550
- * `screwdriver-wrench` — the TOOL tab's two-tool silhouette — reused under a
2551
- * second name. At the 14px rail the two read as different marks, and the rail
2552
- * paints them side by side, so the reuse was the loudest thing left in it.
2553
- *
2554
- * MEASURED off `modeling-edit-none.png` at its native 2x (the rail's OPEN
2555
- * Modifier tab, ink x 2861..2888, y 1004..1031 — 28x28 device px, exactly the
2556
- * box the Data tab's mark fills two cells below, which is what calibrates the
2557
- * scale: `properties-data` spans 16.6 viewBox units, so this does too).
2558
- * Row-by-row ink runs, thresholded at half coverage and mapped onto the set's
2559
- * plain 16 grid, give a shape with four parts:
2560
- *
2561
- * - a HEAD. Not a stroke of constant width: the outer boundary is a circle
2562
- * (5.08 left, 14.81 right at the head's own centre line) and the bore is a
2563
- * SMALLER, concentric circle, so the ring is ~2.9 thick where the handle
2564
- * leaves it and the jaw's flats eat into it from the other side.
2565
- * - a JAW cut as a SLOT WITH PARALLEL FLATS, not a wedge and not a wider
2566
- * bore. Both inner faces measure dx/dy = -1.00 over their whole length
2567
- * (left face x+y = 12.84, right face x+y = 19.41) - parallel, ~4.3 apart,
2568
- * on an axis at -45 degrees, opposite the handle. That parallelism is the
2569
- * one fact that makes the mark read as a WRENCH rather than a C: a
2570
- * radial-ended annulus puts the jaw's inner corner 3.8 device px off.
2571
- * - the slot STOPS about a unit short of the head's centre, which is what
2572
- * keeps the ring closed across the bottom (the frame's row y 1018 is one
2573
- * unbroken run, 5.08..14.57).
2574
- * - a HANDLE at exactly 45 degrees - its right boundary moves one device
2575
- * pixel per row for nine rows - ~3.7 wide, ending in a round cap whose
2576
- * extremes ARE the glyph's left and bottom edges.
2577
- *
2578
- * THE CONSTANTS BELOW ARE THE FIT, NOT THE RAW READING. Every feature above
2579
- * is a boundary read at a 50% threshold on a 28px glyph, so each carries about
2580
- * half a device pixel of slack, and the rim's apparent radius swings 4.5..5.0
2581
- * depending on which row it is read from (near the top the circle is almost
2582
- * horizontal, so half a pixel of radius moves x by two). The reading fixed the
2583
- * STRUCTURE; the numbers were then settled by rendering the path at the
2584
- * reference's own scale and counting the pixels where the two half-coverage
2585
- * masks disagree. Every constant stayed inside its own reading's error bar and
2586
- * the count came down 18 -> 5 of 298 ref ink pixels, which is the floor this
2587
- * instrument has: the five are single pixels on antialiased diagonals.
2588
- *
2589
- * DRAWN AS ONE CLOSED BOUNDARY, NO KNOCKOUT AT ALL — and that is the whole
2590
- * reason this glyph is a traced outline rather than the file's usual pile of
2591
- * primitives. Two knockout constructions were tried and both are impossible
2592
- * here, which is worth stating so the third attempt does not repeat them:
2593
- * - disc + handle capsule, then bore and slot punched. The handle has to
2594
- * reach PAST the bore to cover the head (its cap would need to sit at
2595
- * radius >4.04 to clear the bore and <3.11 to stay inside the outer
2596
- * circle — no such radius), so the head is DOUBLY covered exactly where
2597
- * the punches land: winding 2 - 1 = 1, and every knockout fills back in.
2598
- * - one outer polygon with the bore and the slot as holes. The slot has to
2599
- * run PAST the rim to cut the jaw open, and a hole outside its fill is not
2600
- * a hole — it is ink (winding +1), which is what it drew: a blue bar
2601
- * across the top-right corner. The bore and the slot also overlap, and two
2602
- * overlapping holes cancel to ink in their intersection.
2603
- * The material is SIMPLY CONNECTED — the bore opens into the jaw, which opens
2604
- * to the outside — so its boundary is one walk: the rim from the upper arm's
2605
- * tip round to the handle, the handle's two sides and its cap, the rim again
2606
- * to the lower jaw's tip, in along one flat, across the jaw's stop, round the
2607
- * BACK of the bore, and out along the other flat.
2608
- */
2609
- const modifierWrench = () => {
2610
- const cx = 10.28; // the head's centre
2611
- const cy = 5.84;
2612
- const rOut = 4.78; // its outer rim
2613
- const rIn = 1.85; // its bore
2614
- const hw = 2.15; // half the jaw's opening
2615
- const off = -0.12; // the jaw's midline, off the head's centre
2616
- const stop = -1.1; // where the jaw stops, along its own axis
2617
- const hx = 3.2; // the handle's end cap
2618
- const hy = 12.75;
2619
- const hr = 1.87;
2620
- const A = [Math.SQRT1_2, -Math.SQRT1_2]; // jaw/handle axis, up-right
2621
- const N = [Math.SQRT1_2, Math.SQRT1_2]; // its perpendicular, down-right
2622
- /** A point in the jaw's own frame: `s` along the axis, `f` across it. */
2623
- const at = (s, f) => [cx + A[0] * s + N[0] * f, cy + A[1] * s + N[1] * f];
2624
- const ang = ([x, y]) => (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
2625
- const sweep = (r, a0, a1, ox = cx, oy = cy) => {
2626
- const n = Math.max(2, Math.ceil(Math.abs(a1 - a0) / 6));
2627
- return Array.from({ length: n + 1 }, (_, i) => {
2628
- const a = ((a0 + ((a1 - a0) * i) / n) * Math.PI) / 180;
2629
- return [ox + Math.cos(a) * r, oy + Math.sin(a) * r];
2630
- });
2631
- };
2632
- const rim = (f) => Math.sqrt(Math.max(rOut * rOut - f * f, 0));
2633
- const fUp = off - hw; // the jaw's two flats, across its axis
2634
- const fLo = off + hw;
2635
- const t1 = at(rim(fUp), fUp); // the upper arm's tip
2636
- const t2 = at(rim(fLo), fLo); // the lower jaw's tip
2637
- // Where each side of the handle runs into the rim, in the same frame.
2638
- const offAxis = (cx - hx) * N[0] + (cy - hy) * N[1];
2639
- const pUp = at(-rim(-hr - offAxis), -hr - offAxis);
2640
- const pLo = at(-rim(hr - offAxis), hr - offAxis);
2641
- // BOTH FLATS CLEAR THE BORE (2.32 out against a 2.07 radius) and the jaw's
2642
- // stop crosses it, so the bore meets the jaw at the STOP, never at a flat.
2643
- const b = Math.sqrt(Math.max(rIn * rIn - stop * stop, 0));
2644
- const v1 = at(stop, b);
2645
- const v2 = at(stop, -b);
2646
- return poly([
2647
- ...sweep(rOut, ang(t1), ang(pUp) - 360), // the rim, over the top, to the handle
2648
- ...sweep(hr, 225, 45, hx, hy), // the handle: its two sides and its cap
2649
- ...sweep(rOut, ang(pLo) - 360, ang(t2) - 360), // the rim again, to the jaw
2650
- at(stop, fLo), // in along the jaw's lower flat, then across its stop
2651
- ...sweep(rIn, ang(v1), ang(v2) + 360), // round the BACK of the bore
2652
- at(stop, fUp), // across the stop again, and out along the upper flat
2653
- ]);
2654
- };
2655
- g('properties-modifiers', modifierWrench());
2656
- /**
2657
- * RE-MEASURED 2026-09-19 EDGE BY EDGE, and the mark is not a triangle drawn
2658
- * THROUGH three vertex marks. The 2026-09-18 cut got the orientation and the
2659
- * square vertices right and then drew the triangle as one closed `line`
2660
- * running vertex-centre to vertex-centre, which fills each square's hollow
2661
- * from the inside: at the 14 px the Outliner paints it the three squares
2662
- * closed to blobs and the whole glyph read as a fat stem with two legs — a
2663
- * Y — where Blender's reads as a clean triangle with three open boxes on it.
2664
- *
2665
- * The frames are NOT ANTIALIASED here (every ink pixel is the full
2666
- * `#07b189`/`#05ae87` and every background pixel the full row), so these are
2667
- * EXACT EDGES, not fits. Read down `modeling-object-none.png`'s Cube row (src
2668
- * x 269..296, y 179..206 — the Outliner crop `[x2843..3452, y53..419]`'s
2669
- * x 269..296, y 179..206), confirmed identical in `modeling-object-selected.png`
2670
- * and `outliner.png`, and the same mark at the same size in
2671
- * `properties-object.png`'s Data tab:
2672
- *
2673
- * three squares, OUTER 8 device px, WALL 2, HOLLOW 4 — at [0,8), [20,28)
2674
- * and [10,18) of the mark's own 28 px box
2675
- * a top BAR x [6,22) y [2,4) — it runs wall to wall between the two top
2676
- * squares and stops dead at each hollow; it does NOT cross them, and it
2677
- * sits in the UPPER half of the hollow band, not on the squares' centre
2678
- * two DIAGONALS, 2 px wide, living only in y [8,20) — the gap between the
2679
- * top squares' bottom edge and the bottom square's top edge. The left
2680
- * one's centre runs x 273.04 → 279.44 down that span (a subpixel fit over
2681
- * twelve rows, residual under 0.1 px), which is the top square's own
2682
- * centre-x at the top and the bottom square's LEFT edge at the bottom;
2683
- * the right one is its mirror about the box's centre.
2684
- *
2685
- * So the three edges are drawn BETWEEN the squares and the squares are
2686
- * FILLED-WITH-A-KNOCKOUT rather than stroked outlines — which is also why
2687
- * their corners are square where `rframe`'s round joins gave ours a dot.
2688
- * `poly`/`rrect` take no `W` fattening, which is what this mark wants: it is
2689
- * fitted to pixels, like `properties-modifiers` and the editor-type wells.
2690
- *
2691
- * PROVED, the way the wrench was: the path rasterized at the reference's own
2692
- * scale (28x28, 4x4 supersampled, inked at half coverage) and differenced
2693
- * against the frame's mask — 1 of 222 reference ink pixels disagrees, a
2694
- * single antialiased pixel on the right diagonal's edge.
2695
- *
2696
- * A NOTE ON THE THRESHOLD, because it nearly cost this glyph its geometry:
2697
- * the mark is crisp everywhere EXCEPT the diagonals' edges, and read at a
2698
- * 0.44-coverage cut those two bars mask one pixel wider than they are. Fitted
2699
- * to that mask the diagonals want 2.35 device px; fitted to the frame's own
2700
- * subpixel MASS (2.26 px per row across twelve rows, times cos 28.06 deg)
2701
- * they measure 1.99, and at the honest half-coverage cut the measured width
2702
- * is the one that scores 1/222 where the fitted width scores 3. A shape
2703
- * tuned to a rasterizer's threshold is tuned to the rasterizer.
2704
- */
2705
- /** One device px of the reference, in this file's plain 16 units: the mark
2706
- * inks exactly 28x28 device px, which is 13.5 units once `S` has scaled it
2707
- * (1.25..14.75) — the same calibration the wells below use. */
2113
+ // Properties tabs use distinct names so their category tones affect only the rail.
2114
+ /** The reference's 28-device-pixel mark spans 13.5 glyph units (1.25..14.75). */
2708
2115
  const DATA_PX = 13.5 / 28;
2709
- /** A measured edge, counted from the mark's own first ink pixel. */
2116
+ /** An edge offset from the glyph’s first ink pixel. */
2710
2117
  const dpx = (px) => 1.25 + px * DATA_PX;
2711
2118
  /** A bar with SQUARE ends. `cap`'s round ends would push ink past the square
2712
2119
  * each diagonal dies against, into its hollow, where Blender draws none. */
@@ -2740,6 +2147,7 @@ const dataVertex = (px, py) => {
2740
2147
  ])
2741
2148
  );
2742
2149
  };
2150
+ /** The mesh-data mark has three hollow square vertices and three edges drawn only between their walls. Filled shapes with knockouts preserve square corners; diagonal widths use half-coverage edges without optical W expansion. */
2743
2151
  g(
2744
2152
  'properties-data',
2745
2153
  // the bar between the two top squares
@@ -2752,185 +2160,15 @@ g(
2752
2160
  dataVertex(10, 20),
2753
2161
  );
2754
2162
 
2755
- // The Outliner's DATABLOCK row (`@volter/editor-blender`'s datablock hierarchy) —
2756
- // the same MESH_DATA mark the Properties rail's Data tab draws, under its own
2757
- // name because the two sites composite differently: `outliner.png`'s data
2758
- // glyph is #07b189 over the row's #272727 and `properties-object.png`'s tab is
2759
- // #05ae87 over the rail's #1c1c1c, one ink at α 0.791 in both — while our rail
2760
- // paints its tab at full alpha, so the two will not stay one value.
2163
+ // The Outliner datablock uses the mesh-data shape under its own name because its row and the Properties rail apply different alpha and category colours.
2761
2164
  g('outliner-data', glyphs['properties-data'].path);
2762
2165
 
2763
- /**
2764
- * BLENDER'S PROPERTIES RAIL, TRACED FROM BLENDER'S OWN ICON SOURCES.
2765
- *
2766
- * `ED_buttons_tabs_list` (`space_buttons.cc:201-255`) draws up to sixteen tabs
2767
- * and each one's mark is named in `buttons_context_items`
2768
- * (`makesrna/intern/rna_space.cc:579-611`) — `ICON_SCENE` for Render,
2769
- * `ICON_SCENE_DATA` for Scene, `ICON_OBJECT_DATA` for Object, and so on. Each
2770
- * of those icons IS A FILE in the Blender checkout at the engine's pin:
2771
- * `release/datafiles/icons_svg/<lowercase name>.svg`, authored on a 16-unit
2772
- * grid inside a 1600 viewBox. The compiled `.dat` sheets under
2773
- * `release/datafiles/icons/` are that file's BUILD PRODUCT, not its source,
2774
- * and Blender 5.x has no combined `blender_icons.svg` master.
2775
- *
2776
- * WHAT I1 LEFT AND THIS REPLACES: thirteen of the sixteen were a distinct NAME
2777
- * over a neighbouring silhouette this set already drew — a globe for World, a
2778
- * magnet for Constraints, a palette for Material — and the remaining two
2779
- * (`properties-modifiers`, `properties-data`) were measured pixel-by-pixel off
2780
- * a screenshot. All sixteen are now traced from the vector source, which is
2781
- * both more exact than a screenshot reading and far less code: the two
2782
- * hand-measured marks above this line took two paragraphs of prose each to
2783
- * justify one shape.
2784
- *
2785
- * GPL PATHS, DELIBERATELY — AND THE PACKAGE'S SPDX SAYS SO. Blender's icon
2786
- * sources are GPL-2.0-or-later, and `@volter/editor-blender` is
2787
- * `AGPL-3.0-only AND GPL-3.0-or-later` (`packages/blender/LICENSE`, whose
2788
- * notice block names this set, its provenance file and the §13 combination):
2789
- * our code is AGPL, these 197 paths are Blender's artwork conveyed as GPL-3.0,
2790
- * and both licences' §13 permit the combination. Nothing traced may be copied
2791
- * into an Apache/MIT part of this repo; `packages/editor`'s own icon set in
2792
- * particular stays free of it.
2793
- *
2794
- * NO `S` AND NO `W`. Those two dials exist to bring OUR drawings up to
2795
- * Blender's ink ratio (see their note at the top of this file); a traced mark
2796
- * already has it. Measured over the traced set, Blender's marks ink 14 of the
2797
- * 16 units (x and y both 1..15 in almost every file), against the ~14.5 the
2798
- * dialled geometry above produces — within 3.5%, so the rail reads as one set
2799
- * while every traced glyph keeps Blender's own proportions exactly.
2800
- *
2801
- * Re-derive `blender.icons.traced.json` with
2802
- * `node blender-icons.source.mjs --trace`, which reads the checkout at
2803
- * `$BLENDER_SRC` (default `~/volter/blender-src`; the recipe is in WORK.md
2804
- * §Blender in the tab is Blender, "Inspection parity") and records each
2805
- * glyph's source file and sha256 beside its path. The ordinary run consumes
2806
- * that JSON and needs no checkout.
2807
- */
2808
- // The TONE rides with the traced glyph, read from that icon's own
2809
- // `DEF_ICON_<GROUP>` macro at trace time (see the `--trace` block). The
2810
- // Properties rail's sixteen keep their assignments in `CATEGORY_TONE` below,
2811
- // which is where they were measured; a traced entry that carries `tone` is
2812
- // answering for itself and that table names none of them.
2166
+ /** Trace Properties-rail icons from release/datafiles/icons_svg at the engine pin. Blender artwork is conveyed under GPL-3.0-or-later with the package’s AGPL-3.0-only code; preserve LICENSE and per-glyph source hashes in blender.icons.traced.json. Apply neither S nor W to traced paths. Rebuild the trace with node blender-icons.source.mjs --trace using BLENDER_SRC; ordinary generation consumes the saved trace JSON. */
2167
+ // Traced entries carry their source DEF_ICON_<GROUP> tone. CATEGORY_TONE supplies the Properties-rail assignments.
2813
2168
  for (const [name, entry] of Object.entries(traced))
2814
2169
  g(name, entry.path, entry.tone ? { tone: entry.tone } : undefined);
2815
2170
 
2816
- // -- the EDITOR-TYPE WELLS -------------------------------------------------
2817
- /**
2818
- * THE TWO MARKS BLENDER PUTS IN AN AREA'S EDITOR-TYPE WELL, in the two areas
2819
- * the reference frames photograph. Every Blender area begins with that well —
2820
- * a 32x20 CSS rounded box, 1 px `#3c3c3c` over a constant `#272727` inner —
2821
- * and the glyph inside it is what says which editor the area is
2822
- * (`workspace-regions.ts`'s `groupTabs`).
2823
- *
2824
- * BOTH ARE READ AT NATIVE 2x AND BOTH INK EXACTLY 28x28 DEVICE PX, which is
2825
- * the same box `properties-data` and the modifier wrench fill two paragraphs
2826
- * up — so the same calibration carries over unchanged: 28 device px of ink is
2827
- * 13.5 units on this file's plain 16 grid (1 unit = 2.074 device px), and the
2828
- * ink lands at 1.25..14.75 in both axes once `S` has scaled it. Every constant
2829
- * below is a measured pixel edge divided by that one number; none is a taste
2830
- * call.
2831
- *
2832
- * Neither is antialiased in the frame — the runs are the same at a 60 and a
2833
- * 120 threshold — so every edge quoted is exact rather than a fit.
2834
- *
2835
- * THE OTHER FIVE PANELS OF THIS EDITOR GET NO GLYPH HERE, and the reason is
2836
- * that there is nothing to measure: the reference set photographs the Layout,
2837
- * Modeling, Sculpting and Texture Paint workspaces, whose areas are the 3D
2838
- * View, the Outliner, the Properties editor, the Timeline, the Image Editor
2839
- * and the asset shelf. No frame contains a File Browser or an Asset Browser,
2840
- * and Blender's editor-type dropdown is closed in all of them, so the Content
2841
- * browser's and the Asset Library's wells have NO Blender reading at all.
2842
- * They reuse names this set already draws (`workspace-static-panels.ts` says
2843
- * which, and says that the choice is ours).
2844
- */
2845
-
2846
- // `outliner.png`, the Outliner area's own well: ink x 27..54, y 11..38 device.
2847
- // Six parts, each a pixel-exact rectangle with SQUARE corners (no corner in
2848
- // this glyph loses a pixel at either threshold):
2849
- // the collection box x [27,37) y [11,17)
2850
- // the tree's stem x [31,33) y [19,37)
2851
- // its upper arm x [31,39) y [25,27)
2852
- // its lower arm x [31,39) y [35,37)
2853
- // the upper child box x [41,55) y [23,29)
2854
- // the lower child box x [41,55) y [33,39)
2855
- // The two GAPS are the mark's own: 2 device px between the collection box and
2856
- // the stem's top, and 2 more between each arm's end and its child box. Closing
2857
- // either — which is what a tree icon drawn from memory does — turns three
2858
- // separated rows into one connected bracket.
2859
- g(
2860
- 'outliner',
2861
- rrect(1.25, 1.25, 4.82, 2.89) +
2862
- rrect(3.18, 5.11, 0.96, 8.68) +
2863
- rrect(3.18, 8.0, 3.86, 0.96) +
2864
- rrect(3.18, 12.82, 3.86, 0.96) +
2865
- rrect(8.0, 7.04, 6.75, 2.89) +
2866
- rrect(8.0, 11.86, 6.75, 2.89),
2867
- );
2868
-
2869
- /**
2870
- * `modeling-edit-none.png`, the Properties area's own well: ink x 2870..2897,
2871
- * y 437..464 device. TWO SOLID STADIUMS, each 28x12 device (radius = half the
2872
- * height), stacked with 4 device px between them — and each carries a
2873
- * KNOCKOUT, which is what makes the pair read as two sliders at different
2874
- * settings rather than as two blank pills:
2875
- * stadium rows y [437,449) and y [453,465), both x [2870,2898)
2876
- * upper knockout x [2882,2896) y [439,447)
2877
- * lower knockout x [2888,2896) y [455,463)
2878
- * Each knockout is FLAT ON ITS LEFT and semicircular on its right: the right
2879
- * boundary reads 2893/2894/2895/2895 down its four half-rows, which fits a
2880
- * radius of exactly 4 device px (= half the knockout's own height) and nothing
2881
- * else; the left boundary is one x for all eight rows. The two differ only in
2882
- * where that flat left edge sits, and that difference is the whole picture.
2883
- *
2884
- * The knockouts are strictly INSIDE their stadiums (2 device px of ink on
2885
- * every side), so the nonzero winding is well posed — unlike the wrench's two
2886
- * failed constructions recorded above, nothing here is punched outside its own
2887
- * fill or punched twice.
2888
- */
2889
- const sliderSlot = (xL, y, xR, h) => {
2890
- const r = h / 2;
2891
- const cy = y + r;
2892
- const cx = xR - r;
2893
- const cap = Array.from({ length: 13 }, (_, i) => {
2894
- const a = ((-90 + 15 * i) * Math.PI) / 180;
2895
- return [cx + Math.cos(a) * r, cy + Math.sin(a) * r];
2896
- });
2897
- return holePoly([[xL, y], ...cap, [xL, y + h]]);
2898
- };
2899
- g(
2900
- 'properties',
2901
- rrect(1.25, 1.25, 13.5, 5.79, 2.41) +
2902
- sliderSlot(7.04, 2.21, 13.79, 3.86) +
2903
- rrect(1.25, 8.96, 13.5, 5.79, 2.41) +
2904
- sliderSlot(9.93, 9.92, 13.79, 3.86),
2905
- );
2906
-
2907
- /**
2908
- * BLENDER HUE-GROUPS ITS RAIL, AND THE SOURCE SAYS WHICH GROUP EACH MARK IS
2909
- * IN. Every icon in `UI_icons.hh` is declared through a `DEF_ICON_<GROUP>`
2910
- * macro, and `interface_icons.cc:126-132` maps those macros onto the theme
2911
- * members `interface/resources.cc:1059-1078` reads:
2912
- *
2913
- * DEF_ICON_SCENE → TH_ICON_SCENE `.tui.icon_scene` #cccccc
2914
- * DEF_ICON_COLLECTION → TH_ICON_COLLECTION `.tui.icon_collection` #ffffff
2915
- * DEF_ICON_OBJECT → TH_ICON_OBJECT `.tui.icon_object` #e19658
2916
- * DEF_ICON_OBJECT_DATA → TH_ICON_OBJECT_DATA `.tui.icon_object_data` #00d4a3
2917
- * DEF_ICON_MODIFIER → TH_ICON_MODIFIER `.tui.icon_modifier` #74a2ff
2918
- * DEF_ICON_SHADING → TH_ICON_SHADING `.tui.icon_shading` #cc6670
2919
- *
2920
- * (`userdef_default_theme.c:272-277`.) So the assignments below are READ, not
2921
- * matched by eye — and reading them corrected two guesses I1 recorded as open:
2922
- * WORLD is `DEF_ICON_SHADING(WORLD)` (`UI_icons.hh:193`), the same red as
2923
- * Material and Texture, NOT the scene grey; and GROUP — the Collection tab —
2924
- * is `DEF_ICON_COLLECTION(GROUP)` (`:248`), a white of its own rather than
2925
- * that grey either. The scene group is exactly four tabs: Render
2926
- * (`DEF_ICON_SCENE(SCENE)`, `:188`), Output (`:187`), View Layer
2927
- * (`DEF_ICON_SCENE(RENDER_RESULT)`, `:265`) and Scene
2928
- * (`DEF_ICON_SCENE(SCENE_DATA)`, `:267`).
2929
- *
2930
- * Every object-data mark — MESH_DATA, ARMATURE_DATA, BONE_DATA and the rest of
2931
- * the per-type set the Data tab draws — is `DEF_ICON_OBJECT_DATA`
2932
- * (`:251-263`), which is why they share one tone rather than list one each.
2933
- */
2171
+ /** Map UI_icons.hh DEF_ICON groups to theme categories: scene, collection, object, object-data, modifier and shading. World shares shading with Material; Render, Output, View Layer and Scene share scene; per-type Data icons share object-data. */
2934
2172
  const CATEGORY_TONE = {
2935
2173
  'properties-render': 'scene',
2936
2174
  'properties-output': 'scene',
@@ -2965,9 +2203,7 @@ const CATEGORY_TONE = {
2965
2203
  ),
2966
2204
  };
2967
2205
  for (const [name, tone] of Object.entries(CATEGORY_TONE)) {
2968
- // A table that names a glyph this set no longer draws is a silent no-op,
2969
- // and a tone assignment that silently stops applying is exactly the kind
2970
- // of thing nobody notices until the rail goes grey.
2206
+ // Every tone assignment must name a glyph in this set.
2971
2207
  if (!glyphs[name]) throw new Error(`CATEGORY_TONE names a glyph this set does not draw: ${name}`);
2972
2208
  glyphs[name] = { ...glyphs[name], tone };
2973
2209
  }