@volter/editor-blender 0.1.8 → 0.1.10

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 (39) hide show
  1. package/contributions/blender-header-menus.tsx +4 -9
  2. package/contributions/blender-icons.source.mjs +74 -6
  3. package/contributions/blender-outliner-authoring.ts +10 -21
  4. package/contributions/blender-palette.source.mjs +16 -5
  5. package/contributions/blender-properties-view.tsx +28 -9
  6. package/contributions/blender-runtime-skin.ts +6 -0
  7. package/contributions/blender-runtime.document.tsx +219 -23
  8. package/contributions/blender.command.ts +5 -1
  9. package/contributions/blender.environment.ts +32 -0
  10. package/contributions/blender.icons.json +22 -2
  11. package/contributions/blender.icons.traced.json +28 -0
  12. package/contributions/blender.keymap.ts +33 -0
  13. package/contributions/blender.palette.json +6 -0
  14. package/contributions/blender.status.tsx +2 -6
  15. package/contributions/blender.style.ts +30 -89
  16. package/contributions/model.layout.ts +7 -7
  17. package/contributions/models.finder.ts +16 -0
  18. package/contributions/sculpt.layout.ts +3 -0
  19. package/contributions/shading.layout.ts +3 -0
  20. package/contributions/studiolights/world/city.exr +0 -0
  21. package/contributions/studiolights/world/courtyard.exr +0 -0
  22. package/contributions/studiolights/world/forest.exr +0 -0
  23. package/contributions/studiolights/world/interior.exr +0 -0
  24. package/contributions/studiolights/world/license.txt +15 -0
  25. package/contributions/studiolights/world/night.exr +0 -0
  26. package/contributions/studiolights/world/studio.exr +0 -0
  27. package/contributions/studiolights/world/sunrise.exr +0 -0
  28. package/contributions/studiolights/world/sunset.exr +0 -0
  29. package/contributions/uv-editing.layout.ts +3 -0
  30. package/dist-node/serving.mjs +624 -0
  31. package/host/blender-runtime-host.ts +11 -1
  32. package/node/blender-mcp.ts +2 -2
  33. package/package.json +20 -10
  34. package/serving/blender-routes.ts +427 -0
  35. package/serving/blender-wasm-artifact.ts +449 -0
  36. package/serving/index.ts +10 -0
  37. package/src/layouts.tsx +4 -5
  38. package/src/presentation.ts +111 -0
  39. package/src/regions.ts +59 -0
@@ -26,8 +26,7 @@
26
26
  * WHAT IS DRAWN IS WHAT ACTS. A row Blender draws that this unit does not act
27
27
  * on is simply not here — never a row that opens a dialog we do not have, and
28
28
  * never one that no-ops. Where a row exists in Blender and is REFUSED here it
29
- * is drawn disabled with the refusal as its reason: Set Origin ▸ Origin to 3D
30
- * Cursor (there is no 3D cursor yet) and the four Add rows that want a FILE
29
+ * is drawn disabled with the refusal as its reason: the four Add rows that want a FILE
31
30
  * (Image ▸ Reference…/Background…/Mesh Plane…, Volume ▸ Import OpenVDB… —
32
31
  * `BlenderCreatableKind.refusal` carries each sentence). Add is the whole of
33
32
  * `VIEW3D_MT_add` now, so "not here" means Blender's menu-search row and
@@ -95,13 +94,9 @@ const ORIGIN_ROWS: readonly {
95
94
  // `type` enum's own UI names.
96
95
  { label: 'Geometry to Origin', type: 'GEOMETRY_ORIGIN' },
97
96
  { label: 'Origin to Geometry', type: 'ORIGIN_GEOMETRY' },
98
- {
99
- label: 'Origin to 3D Cursor',
100
- type: 'ORIGIN_CURSOR',
101
- refusal:
102
- 'There is no 3D cursor in this editor yet, so there is no point to move the origin to. ' +
103
- 'Blender places it with Shift+Right-click; that gesture is not built here.',
104
- },
97
+ // The cursor is `Scene.cursor`, placed with Shift+Right-click on the model
98
+ // (`blender-runtime.document.tsx`).
99
+ { label: 'Origin to 3D Cursor', type: 'ORIGIN_CURSOR' },
105
100
  ];
106
101
 
107
102
  /**
@@ -147,6 +147,13 @@ const OUTLINER_ICON_NAMES = [
147
147
  // under names of OURS for the same reason the render pair has them.
148
148
  ['outliner-exclude-on', 'checkbox_hlt'],
149
149
  ['outliner-exclude-off', 'checkbox_dehlt'],
150
+ // The navigation cluster's camera button (`view3d_gizmo_navigate.cc`): `VIEW_CAMERA_UNSELECTED`
151
+ // outside the camera view and `VIEW_CAMERA` in it, under names of OURS for the same reason.
152
+ ['viewport-camera', 'view_camera_unselected'],
153
+ ['viewport-camera-view', 'view_camera'],
154
+ // And, in a camera view, the lock to it: `VIEW_LOCKED` / `VIEW_UNLOCKED`.
155
+ ['viewport-camera-locked', 'view_locked'],
156
+ ['viewport-camera-unlocked', 'view_unlocked'],
150
157
  ];
151
158
 
152
159
  const TRACED = new URL('./blender.icons.traced.json', import.meta.url);
@@ -693,11 +700,30 @@ g('magnifying-glass', ring(7.1, 7.1, 3.7, 1.3) + cap(9.9, 9.9, 13.2, 13.2, 1.4))
693
700
  // and the round cap floated off as a separate lozenge at 16px — measured in
694
701
  // round 1's frame, where the navigation cluster showed a disc and a diamond.
695
702
  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)));
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.
707
+ const plusHole = (cx, cy, a, t) =>
708
+ poly(
709
+ [
710
+ [cx - t, cy - a],
711
+ [cx + t, cy - a],
712
+ [cx + t, cy - t],
713
+ [cx + a, cy - t],
714
+ [cx + a, cy + t],
715
+ [cx + t, cy + t],
716
+ [cx + t, cy + a],
717
+ [cx - t, cy + a],
718
+ [cx - t, cy + t],
719
+ [cx - a, cy + t],
720
+ [cx - a, cy - t],
721
+ [cx - t, cy - t],
722
+ ],
723
+ true,
724
+ );
725
+ g('magnifying-glass-plus', zoomLens(plusHole(7.1, 7.1, 2.3, 0.36)));
726
+ g('magnifying-glass-minus', zoomLens(holeBar(4.8, 7.1, 9.4, 7.1, 0.72)));
701
727
  g('ellipsis', dot(3.6, 8, 1.15) + dot(8, 8, 1.15) + dot(12.4, 8, 1.15));
702
728
  g('grip-lines', cap(3, 6.3, 13, 6.3, 1.2) + cap(3, 9.7, 13, 9.7, 1.2));
703
729
  g('grip-lines-vertical', cap(6.3, 3, 6.3, 13, 1.2) + cap(9.7, 3, 9.7, 13, 1.2));
@@ -1011,6 +1037,19 @@ const perspectiveGrid = () => {
1011
1037
  );
1012
1038
  };
1013
1039
  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
+ */
1045
+ const orthographicGrid = () => {
1046
+ const lo = 2.61;
1047
+ const hi = 13.41;
1048
+ const w = 0.74;
1049
+ const at = [0, 1, 2, 3].map((i) => lo + ((hi - lo) * i) / 3);
1050
+ return at.map((v) => cap(lo, v, hi, v, w) + cap(v, lo, v, hi, w)).join('');
1051
+ };
1052
+ g('viewport-orthographic', orthographicGrid());
1014
1053
  g(
1015
1054
  'list',
1016
1055
  dot(3.2, 4.4, 1) +
@@ -1487,6 +1526,35 @@ g(
1487
1526
  [9.3, 4],
1488
1527
  ]),
1489
1528
  );
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
+ */
1536
+ const MARQUEE_T = 0.7;
1537
+ const marquee =
1538
+ // corners: an L each
1539
+ rrect(1, 1, 2.7, MARQUEE_T) + rrect(1, 1, MARQUEE_T, 2.5) +
1540
+ rrect(12.3, 1, 2.7, MARQUEE_T) + rrect(14.3, 1, MARQUEE_T, 2.4) +
1541
+ rrect(1, 14.3, 2.5, MARQUEE_T) + rrect(1, 12.4, MARQUEE_T, 2.6) +
1542
+ rrect(12.3, 14.3, 2.7, MARQUEE_T) + rrect(14.3, 12.4, MARQUEE_T, 2.6) +
1543
+ // the dashes between
1544
+ rrect(5, 1, 2.2, MARQUEE_T) + rrect(8.4, 1, 2.3, MARQUEE_T) +
1545
+ rrect(5.5, 14.3, 2.4, MARQUEE_T) + rrect(9, 14.3, 2.4, MARQUEE_T) +
1546
+ rrect(1, 4.6, MARQUEE_T, 2.1) + rrect(1, 8, MARQUEE_T, 2.1) +
1547
+ rrect(14.3, 4.6, MARQUEE_T, 2.1) + rrect(14.3, 8, MARQUEE_T, 2.1);
1548
+ g(
1549
+ 'tool-select-box',
1550
+ poly([
1551
+ [6.1, 4.5],
1552
+ [11.2, 9.4],
1553
+ [8.2, 9.4],
1554
+ [6.2, 11.5],
1555
+ ]),
1556
+ { tone: 'select', tonedPath: marquee },
1557
+ );
1490
1558
  g(
1491
1559
  /**
1492
1560
  * TRANSFORM (all handles). Blender's (button 6): a solid square from
@@ -2442,7 +2510,7 @@ g(
2442
2510
  /** The union `IconCategoryTone` declares (`@vgai/editor-sdk/looks`), and the
2443
2511
  * reason `blender.style.ts` may narrow this JSON with an assertion: a tone
2444
2512
  * outside it never reaches the artifact. */
2445
- const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection'];
2513
+ const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection', 'select'];
2446
2514
 
2447
2515
  // The outliner's three names, aliased onto the drawings they share with the
2448
2516
  // generic glyphs. Same picture, its own name, so only the outliner is tinted.
@@ -57,7 +57,7 @@
57
57
  */
58
58
 
59
59
  import type { BlenderOutlinerRow } from '@volter/blender-engine/browser/rna';
60
- import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-sdk/contributions';
60
+ import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-threejs/object3d-contributions';
61
61
  import { editorHost } from '@volter/editor-sdk/host';
62
62
  import type {
63
63
  AuthoringAdapter,
@@ -75,6 +75,7 @@ import type {
75
75
  WriteAck,
76
76
  } from '@volter/editor-project/adapter';
77
77
  import type * as THREE from 'three';
78
+ import { threeObject } from '@volter/editor-threejs/adapter/three-contract';
78
79
  import { blenderExecute, blenderRnaSet, beginBlenderGesture, endBlenderGesture } from '../host/blender-runtime-host';
79
80
  import {
80
81
  blenderEngineSelection,
@@ -302,13 +303,10 @@ function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): n
302
303
  * implementation of a primitive's defaults, its vertex count or its shading
303
304
  * here; the kind names the operator and the engine does the rest.
304
305
  *
305
- * `location=(0, 0, 0)` IS STATED RATHER THAN DEFAULTED, because Blender's own
306
- * answer is different: `VIEW3D_MT_add` runs every one of these with
307
- * `operator_context = 'EXEC_REGION_WIN'` and each `*_add` operator's `invoke`
308
- * places the new object at the 3D CURSOR. There is no 3D cursor in this editor
309
- * yet — it is not this unit's (WORK.md §B6) — so the origin is what the rows
310
- * below ask for explicitly instead of inheriting whatever `scene.cursor`
311
- * happens to hold. When a cursor arrives, these lose the argument.
306
+ * A NEW OBJECT LANDS AT THE 3D CURSOR, as in Blender: no row states a
307
+ * `location`, and an unset one is `scene->cursor.location`
308
+ * (`editors/object/object_add.cc`, `location_from_view`). The cursor is drawn
309
+ * and placed on the Model document (`blender-runtime-cursor.ts`).
312
310
  *
313
311
  * A ROW THAT NEEDS A FILE IS DRAWN DISABLED WITH THE REASON, never as a
314
312
  * gesture that cannot complete (the shape B6 set for Set Origin ▸ Origin to 3D
@@ -343,12 +341,6 @@ export interface BlenderCreatableKind {
343
341
  readonly group: string | null;
344
342
  /** The call, complete. */
345
343
  readonly call: string;
346
- /** `false` where Blender's operator has NO `location` property to place with:
347
- * the generic add options come from `add_generic_props`
348
- * (`editors/object/object_add.cc:431`), and the two rows here whose operator
349
- * never calls it are `object.quick_fur` and `image.import_as_mesh_planes`.
350
- * Passing one anyway is a `TypeError` from Blender, not a placement. */
351
- readonly placesAtOrigin?: boolean;
352
344
  /** This editor cannot run the row, and this is the sentence saying why — the
353
345
  * row is drawn DISABLED with it as its reason and the operator is never
354
346
  * called. */
@@ -444,7 +436,6 @@ export const CREATABLE_KINDS: readonly BlenderCreatableKind[] = [
444
436
  label: 'Fur',
445
437
  group: 'Curve',
446
438
  call: 'object.quick_fur',
447
- placesAtOrigin: false,
448
439
  },
449
440
  // `VIEW3D_MT_surface_add`, `space_view3d.py:2484-2492`.
450
441
  {
@@ -656,7 +647,6 @@ export const CREATABLE_KINDS: readonly BlenderCreatableKind[] = [
656
647
  label: 'Mesh Plane...',
657
648
  group: 'Image',
658
649
  call: 'image.import_as_mesh_planes',
659
- placesAtOrigin: false,
660
650
  refusal: needsAFilePicker('An image as a mesh plane', 'CANCELLED, with nothing created'),
661
651
  },
662
652
  {
@@ -825,11 +815,10 @@ export function blenderCreatableKinds(): readonly BlenderCreatableKind[] {
825
815
  return [...CREATABLE_KINDS, ...liveCollectionKinds()];
826
816
  }
827
817
 
828
- /** The complete `bpy.ops` expression a kind runs, origin included. */
818
+ /** The complete `bpy.ops` expression a kind runs. */
829
819
  function addCall(kind: BlenderCreatableKind): string {
830
820
  const [call, ...args] = kind.call.split(', ');
831
- const all = kind.placesAtOrigin === false ? args : [...args, 'location=(0, 0, 0)'];
832
- return `bpy.ops.${call}(${all.join(', ')})`;
821
+ return `bpy.ops.${call}(${args.join(', ')})`;
833
822
  }
834
823
 
835
824
  /** A Python string literal for one Blender name. JSON's own escaping is a
@@ -1059,7 +1048,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1059
1048
  */
1060
1049
  const rowIdFor = (id: string): string | null => {
1061
1050
  if (rows().has(id)) return id;
1062
- const object = defaultAdapter.hierarchy.object3D?.(id) ?? null;
1051
+ const object = threeObject(defaultAdapter.hierarchy, id);
1063
1052
  return object === null ? null : rowIdForObject(object);
1064
1053
  };
1065
1054
 
@@ -1096,7 +1085,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1096
1085
  return row === undefined ? null : nodeFor(row, state.parentOf.get(row.id) ?? null);
1097
1086
  },
1098
1087
  object3D: (id) => objectFor(id),
1099
- idForObject3D: (object) => rowIdForObject(object),
1088
+ idForObject3D: (object: THREE.Object3D) => rowIdForObject(object),
1100
1089
  };
1101
1090
 
1102
1091
  /** The Blender object NAMES our selection covers — what the door wants. */
@@ -187,14 +187,20 @@ const MAPPING = {
187
187
  '#69aa15',
188
188
  'the same blend over `.tui.yaxis` #8bdc00 — measured, see axisX.',
189
189
  ),
190
- // There is no `viewport.axisZ`: `EditorTheme.color.viewport` declares X and
191
- // Y only (`packages/editor/src/theme.ts`), because the editor's grid plane
192
- // draws two axis lines. `.tui.zaxis` #2890ff is the member a third would
193
- // cite when one exists — a palette key no reader reads is a name with no
194
- // caller.
190
+ // There is no `viewport.axisZ`: the grid plane draws two axis lines. The
191
+ // gizmos carry all three (`gizmo.*`).
195
192
  'viewport.selection': rgb('space_view3d.select'),
196
193
  'viewport.active': rgb('space_view3d.active'),
197
194
 
195
+ // The transform gizmo draws the theme's axis colours as they are
196
+ // (`transform_gizmo_3d.cc`, `gizmo_get_axis_color`), and so does the navigation gizmo, mixed
197
+ // toward the viewport by each ball's depth as it draws (`view3d_gizmo_navigate_type.cc`; the
198
+ // stage computes the mix, so the measured (245,54,81) of `modeling-object-none.png` is `.tui.xaxis`
199
+ // at that ball's depth, not a colour of its own).
200
+ 'gizmo.x': rgb('tui.xaxis'),
201
+ 'gizmo.y': rgb('tui.yaxis'),
202
+ 'gizmo.z': rgb('tui.zaxis'),
203
+
198
204
  'region.outliner': rgb('space_outliner.back'),
199
205
  'region.properties': rgb('space_properties.back'),
200
206
 
@@ -214,6 +220,11 @@ const MAPPING = {
214
220
  '`DEF_ICON`, drawn in `.text`. Measured on the reference frames, and a level below ' +
215
221
  '`.tui.icon_scene` #cccccc, which is what it sits beside.',
216
222
  ),
223
+ 'category.select': held(
224
+ '#ffaf2a',
225
+ "Blender's Select Box marquee, baked into the tool icon's geometry (`ops.generic.select_box`), " +
226
+ 'not a theme member. Measured on `gizmo-select-box.png`: (255,175,42).',
227
+ ),
217
228
  'category.operator': held(
218
229
  '#95dab2',
219
230
  "Blender's edit-mode tool column tints its creating operators green; no theme member carries " +
@@ -383,9 +383,27 @@ function Numeric({
383
383
  );
384
384
  }
385
385
 
386
- /** X/Y/Z/W — `RNA_property_array_item_char`'s own letters for a vector or
387
- * colour; anything longer is numbered, as Blender numbers it. */
388
- const ARRAY_LETTERS = ['X', 'Y', 'Z', 'W'];
386
+ /** An array component's letter, as `RNA_property_array_item_char` gives it:
387
+ * W/X/Y/Z for a quaternion or axis-angle (the angle first), X/Y/Z/W for the
388
+ * vector subtypes, R/G/B/A for a colour, and none otherwise, where the
389
+ * component is numbered. */
390
+ const VECTOR_SUBTYPES = new Set([
391
+ 'TRANSLATION',
392
+ 'DIRECTION',
393
+ 'XYZ',
394
+ 'XYZ_LENGTH',
395
+ 'EULER',
396
+ 'VELOCITY',
397
+ 'ACCELERATION',
398
+ 'COORDS',
399
+ ]);
400
+ function arrayItemChar(subtype: string, index: number): string | undefined {
401
+ if (subtype === 'QUATERNION' || subtype === 'AXISANGLE') return 'WXYZ'[index];
402
+ if (index >= 4) return undefined;
403
+ if (VECTOR_SUBTYPES.has(subtype)) return 'XYZW'[index];
404
+ if (subtype === 'COLOR' || subtype === 'COLOR_GAMMA') return 'RGBA'[index];
405
+ return undefined;
406
+ }
389
407
 
390
408
  /** A field's WHOLE name, for the control's `aria-label` — the property's UI
391
409
  * name (its RNA identifier when it has none), plus the array component's
@@ -393,7 +411,7 @@ const ARRAY_LETTERS = ['X', 'Y', 'Z', 'W'];
393
411
  * component after the first. */
394
412
  function fieldName(row: BlenderRnaRow, index?: number): string {
395
413
  const name = row.name || row.identifier;
396
- return index === undefined ? name : `${name} ${ARRAY_LETTERS[index] ?? index}`;
414
+ return index === undefined ? name : `${name} ${arrayItemChar(row.subtype, index) ?? index}`;
397
415
  }
398
416
 
399
417
  function colourOf(values: readonly number[]): string {
@@ -499,8 +517,8 @@ function ArrayRows({ row, label, title, write }: WidgetProps & { label: string;
499
517
  <>
500
518
  {values.map((value, index) => (
501
519
  <Row
502
- key={`${row.identifier}-${ARRAY_LETTERS[index] ?? index}`}
503
- label={componentLabel(label, index, values.length)}
520
+ key={`${row.identifier}-${index}`}
521
+ label={componentLabel(label, index, row.subtype)}
504
522
  title={title}
505
523
  readonly={row.readonly}
506
524
  identifier={`${row.identifier}[${index}]`}
@@ -526,9 +544,10 @@ function ArrayRows({ row, label, title, write }: WidgetProps & { label: string;
526
544
  );
527
545
  }
528
546
 
529
- function componentLabel(label: string, index: number, length: number): string {
530
- if (length > ARRAY_LETTERS.length) return index === 0 ? label : `${label} ${index}`;
531
- return `${index === 0 ? `${label} ` : ''}${ARRAY_LETTERS[index]}`;
547
+ function componentLabel(label: string, index: number, subtype: string): string {
548
+ const letter = arrayItemChar(subtype, index);
549
+ if (letter === undefined) return index === 0 ? label : `${label} ${index}`;
550
+ return `${index === 0 ? `${label} ` : ''}${letter}`;
532
551
  }
533
552
 
534
553
  function widgetFor(props: WidgetProps): React.ReactNode {
@@ -207,6 +207,12 @@ export class BlenderSkinDirector {
207
207
  };
208
208
  }
209
209
 
210
+ /** The frame on screen, or null before the clip door has been read (when `frame()` can only
211
+ * say 1). */
212
+ playhead(): number | null {
213
+ return this.#clip ? this.frame() : null;
214
+ }
215
+
210
216
  /** The mixer's time as a Blender frame. With no clip the scene's own current
211
217
  * frame stands, which is Blender's answer for a file with no animation. */
212
218
  frame(): number {