@volter/editor-blender 0.1.150 → 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
package/LICENSE CHANGED
@@ -29,18 +29,27 @@ ARTWORK is not.
29
29
  provenance file and emits `blender.icons.json`.
30
30
 
31
31
  The remaining 149 glyphs are our own drawings in Blender's idiom, and
32
- `contributions/blender.palette.json` is a transcription of Blender's default
33
- theme colours. Neither the traced paths nor anything derived from them may
34
- be copied into an Apache-2.0 or MIT part of this repository —
35
- `packages/editor`'s own icon set included.
32
+ `themes/blender.json` is a transcription of Blender's default theme colours.
33
+ Neither the traced paths nor anything derived from them may be copied into an
34
+ Apache-2.0 or MIT part of this repository — `packages/editor`'s own icon set
35
+ included.
36
+
37
+ THE PRODUCT ICON FONT CARRIES THE SAME TRACED GLYPHS.
38
+
39
+ `producticons/blender-icons.otf` (with `producticons/blender-product-icon-theme.json`)
40
+ is generated from `contributions/blender.icons.json` by
41
+ `scripts/workbench/generate-icons.mjs`, so it includes the traced glyphs and is
42
+ under GPL-3.0-or-later with them. `themes/blender.json`, the colour theme, and the
43
+ product icon theme are this package's Code-OSS contributions.
36
44
 
37
45
  ONE SOURCE FILE FOLLOWS BLENDER'S OWN CODE.
38
46
 
39
- `contributions/blender-pose.ts` evaluates a pose as Blender does, and is
40
- written from Blender's source: its damped-track constraint follows
41
- `damptrack_do_transform` statement by statement, and its NLA stack, channel
42
- blending, bone matrices and F-Curve evaluation follow the Blender functions
43
- its header names. It is a derivative work of GPL-2.0-or-later code and,
47
+ `contributions/blender-pose.ts` places the NLA's strips as Blender does, and
48
+ is written from Blender's source: which strip a frame falls on, the action
49
+ frame it shows, its influence (one keyed influence F-Curve evaluated as
50
+ `fcurve_eval_keyframes` does) and where the active action sits follow the
51
+ Blender functions its header names. The pose itself is three.js's
52
+ (`blender-mixer-pose.ts`, the package's own licence). It is a derivative work of GPL-2.0-or-later code and,
44
53
  exercising Blender's "or later", is conveyed under the GNU General Public
45
54
  License, version 3 or later, not the AGPL. The file says so in its header.
46
55
  It may not be copied into an Apache-2.0 or MIT part of this repository.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Blender integration
2
2
 
3
3
  Blender modeling documents, inspectors, commands, layouts and presentation for
4
- Volter Editor. The integration talks to the independent Blender engine over its
4
+ Cyclotron. The integration talks to the independent Blender engine over its
5
5
  worker wire and contributes its editing surfaces through the SDK.
6
6
 
7
7
  The package uses the project contracts and shared Three rendering/capture
8
8
  utilities. It imports no editor-core internals and no game runtime. Its
9
- contributions are enumerated in package.json; the eventual product composes
9
+ contributions are enumerated in package.json; Cyclotron composes
10
10
  that list through the host's contribution loader.
11
11
 
12
12
  Run `npm run typecheck -w @volter/editor-blender` through the active World.
@@ -24,6 +24,6 @@ While opening, the document shows the last available model photograph as a
24
24
  labeled read-only preview and a loading panel. Without a preview it shows a
25
25
  full status surface. A failed open offers Retry and Return to previous model.
26
26
 
27
- This is a private migration staging package. Full editor startup, live editing,
28
- wire/branding migration and existing-consumer cutover remain unfinished. Passing
29
- a package build is not proof that the complete editor is ready to release.
27
+ A package build does not establish product readiness. Verify file persistence,
28
+ native history, visible presentation and resource cleanup through Cyclotron's
29
+ own document and command doors.
@@ -7,15 +7,9 @@
7
7
  * character plays it; the channel list is one row per bone with the frames it keys.
8
8
  * - **NLA** (`SPACE_NLA`): each armature's tracks, bottom track last as Blender lists them, their
9
9
  * strips over scene frames, and the active action's own line on top.
10
- *
11
- * WHILE A GAME RUNS both show what the game is animating, read-only, the way a game engine's
12
- * animator view highlights the live state: each character's clips as its last update posed them
13
- * (`blender-play-skin.ts`'s `live`), with the frame and influence of each. Nothing is written
14
- * then: the game owns its copy.
15
10
  */
16
11
  import { type ReactNode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
17
12
  import { blenderRnaSet, blenderRnaVersion, subscribeBlenderRna } from '../host/blender-runtime-host';
18
- import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
19
13
  import {
20
14
  type AnimationEditor,
21
15
  setTimelineViewState,
@@ -23,7 +17,7 @@ import {
23
17
  timelineViewState,
24
18
  timelineViewVersion,
25
19
  } from '../src/timeline-view-state';
26
- import { PlayModeSwitch } from './blender-game-panel';
20
+ import { PlayModeSwitch } from './blender-play-area';
27
21
  import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
28
22
  import {
29
23
  CHANNEL_HEIGHT,
@@ -69,21 +63,6 @@ export function AnimationEditorMenu() {
69
63
  );
70
64
  }
71
65
 
72
- /** Re-render on every frame while a game runs (its animation changes each update), else never. */
73
- function useLive() {
74
- const live = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null);
75
- const [, setTick] = useState(0);
76
- useEffect(() => {
77
- if (!live) return;
78
- let frame = requestAnimationFrame(function tick() {
79
- setTick((n) => (n + 1) % 1_000_000);
80
- frame = requestAnimationFrame(tick);
81
- });
82
- return () => cancelAnimationFrame(frame);
83
- }, [live]);
84
- return live;
85
- }
86
-
87
66
  function useMeasured() {
88
67
  const ref = useRef<HTMLDivElement | null>(null);
89
68
  const [size, setSize] = useState({ w: 0, h: 0 });
@@ -190,36 +169,27 @@ const address = (armature: string) => `bpy.data.objects[${JSON.stringify(armatur
190
169
  export function BlenderActionEditor() {
191
170
  useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
192
171
  useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
193
- const live = useLive();
194
172
  const { ref, size } = useMeasured();
195
173
  const [refusal, setRefusal] = useState<string | null>(null);
196
174
  const play = blenderSkin.state();
197
175
  const armature = blenderSkin.subject ?? play.armature;
198
- const running = live?.live().find((one) => one.armature === armature) ?? null;
199
- // READ-ONLY WHILE ANY GAME RUNS: the game owns its copy, and the document is not edited under it.
200
- const gameRunning = live !== null;
201
- const top = running?.layers.at(-1) ?? null;
202
- const action = running ? top?.action ?? null : play.action;
203
- const channels = useMemo(
204
- () => (running && action ? live!.channels(running.armature, action) : blenderSkin.channels()),
205
- // The live clip's keys do not change while it plays; the edited clip's change with its revision.
206
- [running ? action : play.action, play.engineCalls, live],
207
- );
176
+ const action = play.action;
177
+ // The edited clip's keys change with its revision.
178
+ const channels = useMemo(() => blenderSkin.channels(), [play.action, play.engineCalls]);
208
179
  const frames = channels.flatMap((channel) => channel.keys);
209
180
  const start = frames.length ? Math.min(...frames) : play.start;
210
181
  const end = frames.length ? Math.max(...frames) : play.end;
211
182
  const width = Math.max(0, size.w - CHANNELS_WIDTH);
212
183
  const { toX, majors, toFrame } = ruler(start, end, width);
213
- const frame = running ? top?.frame ?? start : play.frame;
184
+ const frame = play.frame;
214
185
  const rowTop = (i: number) => SCRUB_HEIGHT + CHANNEL_HEIGHT * (i + 1);
215
186
  const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (channels.length + 2);
216
187
  const summary = [...new Set(frames)].sort((a, b) => a - b);
217
188
 
218
189
  const pick = async (name: string) => {
219
- if (!armature || gameRunning) return;
190
+ if (!armature) return;
220
191
  setRefusal(null);
221
- // ONE WRITE, as Blender's own selector makes it: assigning the action lets Blender choose the
222
- // slot that fits this armature (measured: `OBArmature` for a UAL1 clip on a renamed rig).
192
+ // Assign the action once and let Blender choose the slot for this armature.
223
193
  try {
224
194
  await blenderRnaSet(address(armature), 'action', `bpy.data.actions[${JSON.stringify(name)}]`);
225
195
  } catch (error) {
@@ -234,8 +204,8 @@ export function BlenderActionEditor() {
234
204
  aria-label="Action"
235
205
  data-testid="action-editor-action"
236
206
  value={action ?? ''}
237
- disabled={!armature || gameRunning}
238
- title={gameRunning ? 'A game is running; it chooses the clips while it runs' : 'The action this armature plays (animation_data.action)'}
207
+ disabled={!armature}
208
+ title="The action this armature plays (animation_data.action)"
239
209
  onChange={(event) => void pick(event.target.value)}
240
210
  style={{
241
211
  height: TIMELINE_CHROME.unit,
@@ -248,13 +218,8 @@ export function BlenderActionEditor() {
248
218
  }}
249
219
  >
250
220
  {action === null ? <option value="">No action</option> : null}
251
- {(running && action ? [action] : blenderSkin.actionNames()).map((name) => <option key={name} value={name}>{name}</option>)}
221
+ {blenderSkin.actionNames().map((name) => <option key={name} value={name}>{name}</option>)}
252
222
  </select>
253
- {running ? (
254
- <span data-testid="action-editor-live" style={{ color: TIMELINE_THEME.playhead }}>
255
- ● game running: {running.layers.map((layer) => `${layer.action} @${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}`).join(' + ')}
256
- </span>
257
- ) : null}
258
223
  <div style={{ flex: 1 }} />
259
224
  {refusal ? <span style={{ color: TIMELINE_CHROME.refusal }}>{refusal}</span> : null}
260
225
  </Header>
@@ -274,10 +239,9 @@ export function BlenderActionEditor() {
274
239
  <svg
275
240
  width={width}
276
241
  height={height}
277
- style={{ display: 'block', cursor: gameRunning ? 'default' : 'ew-resize' }}
242
+ style={{ display: 'block', cursor: 'ew-resize' }}
278
243
  aria-label="Action Editor"
279
244
  onPointerDown={(event) => {
280
- if (gameRunning) return;
281
245
  const box = event.currentTarget.getBoundingClientRect();
282
246
  blenderSkin.transport?.seekFrame(Math.round(toFrame(event.clientX - box.left)));
283
247
  }}
@@ -298,42 +262,27 @@ export function BlenderActionEditor() {
298
262
  export function BlenderNlaEditor() {
299
263
  useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
300
264
  useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
301
- const live = useLive();
302
265
  const { ref, size } = useMeasured();
303
266
  const play = blenderSkin.state();
304
267
  const stacks = blenderSkin.stacks();
305
- const running = live?.live() ?? null;
306
268
  const width = Math.max(0, size.w - CHANNELS_WIDTH);
307
269
  const { toX, majors } = ruler(play.start, play.end, width);
308
- type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[]; live?: string; mute?: boolean };
270
+ type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[]; mute?: boolean };
309
271
  const rows: Row[] = [];
310
272
  for (const stack of stacks) {
311
- const layers = running?.find((one) => one.armature === stack.armature)?.layers ?? null;
312
- const influence = (track: string | null) => {
313
- const layer = layers?.find((one) => one.track === track);
314
- return layer ? `@${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}` : undefined;
315
- };
316
273
  rows.push({ label: stack.armature, kind: 'armature', strips: [] });
317
- const activeAction = layers ? layers.find((one) => one.track === null)?.action ?? null : stack.action;
318
- const activeLive = influence(null);
319
- rows.push({ label: activeAction ? `[Action] ${activeAction}` : '[Action] (none)', kind: 'action', strips: [], ...(activeLive ? { live: activeLive } : {}) });
274
+ rows.push({ label: stack.action ? `[Action] ${stack.action}` : '[Action] (none)', kind: 'action', strips: [] });
320
275
  for (const track of [...(stack.animation?.tracks ?? [])].reverse()) {
321
- const playingHere = influence(track.name);
322
276
  rows.push({
323
277
  label: track.name,
324
278
  kind: 'track',
325
279
  mute: track.mute,
326
280
  strips: track.strips.map((strip) => ({ name: strip.name, start: strip.start, end: strip.end, mute: strip.mute })),
327
- ...(playingHere ? { live: playingHere } : {}),
328
281
  });
329
282
  }
330
283
  }
331
284
  const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (rows.length + 1);
332
- const header = (
333
- <Header>
334
- {running ? <span data-testid="nla-live" style={{ color: TIMELINE_THEME.playhead }}>● game running: each playing track shows the game's frame and influence; the strips are the file's layout</span> : null}
335
- </Header>
336
- );
285
+ const header = <Header />;
337
286
  return (
338
287
  <Frame header={header}>
339
288
  <div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
@@ -353,7 +302,7 @@ export function BlenderNlaEditor() {
353
302
  color: row.kind === 'armature' ? TIMELINE_CHROME.textHi : row.mute ? '#6a6a6a' : TIMELINE_CHROME.text,
354
303
  }}
355
304
  >
356
- {row.label}{row.live ? <span style={{ color: TIMELINE_THEME.playhead }}> {row.live}</span> : null}
305
+ {row.label}
357
306
  </div>
358
307
  ))}
359
308
  </div>
@@ -370,15 +319,14 @@ export function BlenderNlaEditor() {
370
319
  width={Math.max(2, toX(strip.end) - toX(strip.start))}
371
320
  height={CHANNEL_HEIGHT - 4}
372
321
  rx={2}
373
- fill={strip.mute || row.mute ? '#4a4a4a' : running ? (row.live ? '#5a7fb8' : '#555555') : '#7b7b7b'}
374
- opacity={running ? 0.6 : 1}
322
+ fill={strip.mute || row.mute ? '#4a4a4a' : '#7b7b7b'}
375
323
  stroke="#1a1a1a"
376
324
  />
377
325
  <text x={toX(strip.start) + 4} y={y + CHANNEL_HEIGHT - 7} fill="#ffffff" fontSize={10}>{strip.name}</text>
378
326
  </g>
379
327
  ));
380
328
  })}
381
- {running ? null : <Playhead x={toX(play.frame)} frame={play.frame} height={height} />}
329
+ <Playhead x={toX(play.frame)} frame={play.frame} height={height} />
382
330
  </svg>
383
331
  </div>
384
332
  </div>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * THE SCENE'S DISPLAY TRANSFORM AS BLENDER HOLDS IT: `scene.view_settings` (view transform, look,
3
+ * exposure in stops, gamma) read through the RNA door, as `BlenderDisplaySettings`. The Rendered
4
+ * areas and Play draw through it (`blender-runtime.document.tsx`), and a web export carries it so
5
+ * the page draws through the same OCIO processor (`blender-export.verbs.ts`).
6
+ */
7
+ import type { BlenderDisplaySettings } from '@volter/blender-engine/browser/three/blender-display-transform';
8
+ import { blenderRna, blenderRnaContext } from '../host/blender-runtime-host';
9
+
10
+ /** Null while Blender's session has no scene to read; throws for a display device the browser
11
+ * has no processor for. */
12
+ export async function readBlenderDisplaySettings(): Promise<BlenderDisplaySettings | null> {
13
+ const context = await blenderRnaContext();
14
+ if (!context) return null;
15
+ const [settings, displaySettings] = await Promise.all([
16
+ blenderRna(`${context.scene}.view_settings`),
17
+ blenderRna(`${context.scene}.display_settings`),
18
+ ]);
19
+ if (settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return null;
20
+ if (displaySettings?.kind === 'struct') {
21
+ const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
22
+ if (typeof device === 'string' && device !== 'sRGB')
23
+ throw new Error(`Blender display device ${device} has no browser display processor yet`);
24
+ }
25
+ const rows = settings.groups.flatMap(group => group.rows);
26
+ const transform = rows.find(row => row.identifier === 'view_transform')?.value;
27
+ const stops = rows.find(row => row.identifier === 'exposure')?.value;
28
+ const look = rows.find(row => row.identifier === 'look')?.value;
29
+ const gamma = rows.find(row => row.identifier === 'gamma')?.value;
30
+ return {
31
+ transform: typeof transform === 'string' ? transform : 'AgX',
32
+ look: typeof look === 'string' ? look : 'None',
33
+ exposure: typeof stops === 'number' ? 2 ** stops : 1,
34
+ gamma: typeof gamma === 'number' ? gamma : 1,
35
+ };
36
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * THE SCENE AS GLTF, ANIMATION INCLUDED (`blender-export-gltf`, `docs/SCENE-ANIMATION.md` step 6): the
3
+ * clips the editor plays, written so another engine (Godot, Unity, a viewer) plays the same.
4
+ *
5
+ * - THE MODEL is the presented scene (`BlenderRuntimeView.root`: meshes with their skins, materials,
6
+ * cameras, lights), through three.js's `GLTFExporter`.
7
+ * - EVERY ARMATURE'S ACTIONS (its assigned action and its NLA strips' actions)
8
+ * become one glTF animation each, `<armature>|<action>`: its bones' transforms as Blender sampled
9
+ * them (`blenderActionClip`), on node channels.
10
+ * - THE SCENE'S MOVIE (`blender-playback-movie.ts`) becomes the animation `Scene`: the moving objects and
11
+ * cameras on node channels, the shape keys as morph target weights.
12
+ * - A MATERIAL'S KEYED INPUTS (base colour, alpha, emission) become `KHR_animation_pointer` channels on
13
+ * the material in the animation `Materials`; an emission over 1 is carried by
14
+ * `KHR_materials_emissive_strength`.
15
+ *
16
+ * Constraints are held at the presented frame: a reader receives TRS, never constraints.
17
+ */
18
+ import * as THREE from 'three';
19
+ import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
20
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
21
+ import { mixerClip } from './blender-mixer-pose';
22
+ import { playMovie } from './blender-playback-movie';
23
+ import type { BlenderActionClip, BlenderSceneMovie } from '@volter/blender-engine/browser/rna';
24
+
25
+ /** A glTF document as JSON, as far as this file writes into it. */
26
+ interface GltfJson {
27
+ materials?: { name?: string; alphaMode?: string; pbrMetallicRoughness?: { baseColorFactor?: number[] }; emissiveFactor?: number[]; extensions?: Record<string, unknown> }[];
28
+ animations?: { name?: string; samplers: { input: number; output: number; interpolation: string }[]; channels: unknown[] }[];
29
+ accessors?: unknown[];
30
+ bufferViews?: unknown[];
31
+ buffers?: { byteLength: number; uri?: string }[];
32
+ extensionsUsed?: string[];
33
+ }
34
+
35
+ /** What a model's geometry carries into glTF: the standard attributes. Everything else on a drawn
36
+ * geometry is the presenter's (the drawn vertex's Blender vertex, its orco, a shading's inputs). */
37
+ const MODEL_ATTRIBUTES = new Set(['position', 'normal', 'tangent', 'uv', 'uv1', 'uv2', 'uv3', 'color', 'skinIndex', 'skinWeight']);
38
+
39
+ /** One keyed material input, as the movie plays it on a presented mesh. */
40
+ interface Pointer { readonly material: THREE.Material; readonly prop: 'color' | 'emissive' | 'opacity'; readonly track: THREE.KeyframeTrack }
41
+
42
+ export interface GltfExport {
43
+ readonly json: GltfJson;
44
+ readonly animations: readonly string[];
45
+ readonly pointers: number;
46
+ readonly skipped: readonly string[];
47
+ readonly failed: readonly string[];
48
+ }
49
+
50
+ /** Write the scene and its animation as inline JSON glTF. */
51
+ export async function exportSceneGltf(view: BlenderRuntimeView,
52
+ read: {
53
+ clip(armature: string, action: string): Promise<BlenderActionClip | null>;
54
+ movie(): Promise<BlenderSceneMovie | null>;
55
+ }): Promise<GltfExport> {
56
+ const clips: THREE.AnimationClip[] = [];
57
+ const failed: string[] = [];
58
+ const skipped = new Set<string>();
59
+ // EVERY ARMATURE'S ACTIONS: one animation each
60
+ const facts = view.animationFacts();
61
+ for (const rig of view.skeletons.rigs()) {
62
+ const entry = facts.armatures[rig.armature];
63
+ if (!entry) continue;
64
+ const names = new Set<string>();
65
+ if (entry.action) names.add(entry.action);
66
+ for (const track of entry.animation?.tracks ?? []) for (const strip of track.strips) if (strip.action) names.add(strip.action);
67
+ for (const action of [...names].sort()) {
68
+ try {
69
+ const baked = await read.clip(rig.armature, action);
70
+ const clip = baked ? mixerClip(baked) : null;
71
+ if (!clip) continue;
72
+ const frames = Math.max(1, Math.round(clip.end - clip.start) + 1);
73
+ const times = Float32Array.from({ length: frames }, (_, i) => i / clip.fps);
74
+ const column = (values: Float32Array, stride: number): [Float32Array, Float32Array] =>
75
+ values.length === stride ? [new Float32Array([0]), values] : [times.slice(0, values.length / stride), values];
76
+ const tracks: THREE.KeyframeTrack[] = [];
77
+ for (const [name, sampled] of clip.bones) {
78
+ const bone = rig.bones.get(name);
79
+ if (!bone) continue;
80
+ tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.position`, ...column(sampled.position, 3)));
81
+ tracks.push(new THREE.QuaternionKeyframeTrack(`${bone.uuid}.quaternion`, ...column(sampled.quaternion, 4)));
82
+ tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.scale`, ...column(sampled.scale, 3)));
83
+ }
84
+ if (tracks.length) clips.push(new THREE.AnimationClip(`${rig.armature}|${action}`, (frames - 1) / clip.fps, tracks));
85
+ } catch (error) {
86
+ failed.push(`${rig.armature} / ${action}: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ }
89
+ }
90
+ // THE SCENE'S MOVIE: objects and cameras, shape keys (morph weights), and materials (pointers below),
91
+ // with the meshes in their own materials whatever the viewport shows
92
+ const data = await read.movie();
93
+ return view.withAuthoredMaterials(async () => {
94
+ const movie = data ? playMovie(view, data, new THREE.AnimationMixer(view.root)) : null;
95
+ for (const warning of movie?.warnings ?? []) skipped.add(warning);
96
+ const pointers: Pointer[] = [];
97
+ const sceneTracks: THREE.KeyframeTrack[] = [];
98
+ for (const clip of movie?.clips() ?? []) {
99
+ for (const track of clip.tracks) {
100
+ const material = /^([^.]+)\.material(?:\[(\d+)\])?\.(color|emissive|opacity)$/.exec(track.name);
101
+ if (material) {
102
+ const mesh = view.root.getObjectByProperty('uuid', material[1]!) as THREE.Mesh | undefined;
103
+ const worn = mesh ? (Array.isArray(mesh.material) ? mesh.material[Number(material[2] ?? 0)] : mesh.material) : undefined;
104
+ if (worn) pointers.push({ material: worn, prop: material[3] as Pointer['prop'], track });
105
+ continue;
106
+ }
107
+ if (track.name.endsWith('.visible')) { skipped.add('keyed visibility is not written to the glTF export'); continue; }
108
+ sceneTracks.push(track);
109
+ }
110
+ }
111
+ if (sceneTracks.length) clips.push(new THREE.AnimationClip('Scene', -1, sceneTracks));
112
+ view.root.updateMatrixWorld(true);
113
+ // THE PRESENTER'S OWN ATTRIBUTES (the drawn vertex's Blender vertex, its orco, the Solid shading's
114
+ // hints) are the editor's bookkeeping, not the model's: set aside while it writes
115
+ const aside: [THREE.BufferGeometry, string, THREE.BufferAttribute | THREE.InterleavedBufferAttribute][] = [];
116
+ let json!: GltfJson;
117
+ try {
118
+ view.root.traverse((object) => {
119
+ const geometry = (object as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
120
+ if (!geometry?.attributes) return;
121
+ for (const name of Object.keys(geometry.attributes)) {
122
+ if (MODEL_ATTRIBUTES.has(name)) continue;
123
+ aside.push([geometry, name, geometry.getAttribute(name)]);
124
+ geometry.deleteAttribute(name);
125
+ }
126
+ });
127
+ // The presenter carries no mesh tangents. GLTFExporter inverts no-tangent OpenGL normal maps, matching GLTFLoader’s negative normalScale.y and preserving Blender’s orientation; DirectX maps use the opposite sign.
128
+ const exporter = new GLTFExporter();
129
+ json = await exporter.parseAsync(view.root, { binary: false, animations: clips, onlyVisible: false }) as GltfJson;
130
+ writePointers(json, pointers);
131
+ } finally {
132
+ for (const [geometry, name, attribute] of aside) geometry.setAttribute(name, attribute);
133
+ movie?.dispose();
134
+ }
135
+ return { json, animations: (json.animations ?? []).map((one) => one.name ?? ''), pointers: pointers.length, skipped: [...skipped], failed };
136
+ });
137
+ }
138
+
139
+ /** THE MATERIALS' ANIMATION as `KHR_animation_pointer` channels, with their keys in a buffer of their own. */
140
+ function writePointers(json: GltfJson, pointers: readonly Pointer[]): void {
141
+ // AN EMISSION OVER 1 (a glow the movie set, an emission strength) is a factor of at most 1 times a strength
142
+ for (const material of json.materials ?? []) {
143
+ const factor = material.emissiveFactor;
144
+ const top = factor ? Math.max(...factor) : 0;
145
+ if (!factor || top <= 1) continue;
146
+ material.emissiveFactor = factor.map((one) => one / top);
147
+ const had = (material.extensions?.['KHR_materials_emissive_strength'] as { emissiveStrength?: number } | undefined)?.emissiveStrength ?? 1;
148
+ material.extensions = { ...material.extensions, KHR_materials_emissive_strength: { emissiveStrength: had * top } };
149
+ json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), 'KHR_materials_emissive_strength'])];
150
+ }
151
+ if (!pointers.length) return;
152
+ const accessors = (json.accessors ??= []);
153
+ const views = (json.bufferViews ??= []);
154
+ const buffers = (json.buffers ??= []);
155
+ const chunks: Float32Array[] = [];
156
+ let offset = 0;
157
+ const buffer = buffers.length;
158
+ const accessor = (values: Float32Array, type: 'SCALAR' | 'VEC3' | 'VEC4', bounds = false): number => {
159
+ views.push({ buffer, byteOffset: offset, byteLength: values.byteLength });
160
+ const size = type === 'SCALAR' ? 1 : type === 'VEC3' ? 3 : 4;
161
+ accessors.push({
162
+ bufferView: views.length - 1, componentType: 5126, count: values.length / size, type,
163
+ ...(bounds ? { min: [Math.min(...values)], max: [Math.max(...values)] } : {}),
164
+ });
165
+ chunks.push(values);
166
+ offset += values.byteLength;
167
+ return accessors.length - 1;
168
+ };
169
+ const samplers: { input: number; output: number; interpolation: string }[] = [];
170
+ const channels: unknown[] = [];
171
+ const used = new Set<string>(['KHR_animation_pointer']);
172
+ const channel = (times: Float32Array, values: Float32Array, type: 'SCALAR' | 'VEC3' | 'VEC4', pointer: string): void => {
173
+ samplers.push({ input: accessor(times, 'SCALAR', true), output: accessor(values, type), interpolation: 'LINEAR' });
174
+ channels.push({ sampler: samplers.length - 1, target: { path: 'pointer', extensions: { KHR_animation_pointer: { pointer } } } });
175
+ };
176
+ const byMaterial = new Map<THREE.Material, Partial<Record<Pointer['prop'], THREE.KeyframeTrack>>>();
177
+ for (const one of pointers) byMaterial.set(one.material, { ...byMaterial.get(one.material), [one.prop]: one.track });
178
+ for (const [material, props] of byMaterial) {
179
+ const index = (json.materials ?? []).findIndex((one) => one.name === material.name);
180
+ if (index < 0) continue;
181
+ const own = material as THREE.MeshStandardMaterial;
182
+ // BASE COLOUR AND ALPHA are one glTF factor
183
+ if (props.color || props.opacity) {
184
+ if (props.opacity?.values.some(value => Number(value) < 1)) json.materials![index]!.alphaMode = 'BLEND';
185
+ const times = (props.color ?? props.opacity)!.times as Float32Array;
186
+ const color = props.color?.values as Float32Array | undefined;
187
+ const alpha = props.opacity?.values as Float32Array | undefined;
188
+ const values = new Float32Array(times.length * 4);
189
+ for (let i = 0; i < times.length; i++) {
190
+ values[i * 4] = color ? color[Math.min(i, color.length / 3 - 1) * 3]! : own.color?.r ?? 1;
191
+ values[i * 4 + 1] = color ? color[Math.min(i, color.length / 3 - 1) * 3 + 1]! : own.color?.g ?? 1;
192
+ values[i * 4 + 2] = color ? color[Math.min(i, color.length / 3 - 1) * 3 + 2]! : own.color?.b ?? 1;
193
+ values[i * 4 + 3] = alpha ? alpha[Math.min(i, alpha.length - 1)]! : own.opacity ?? 1;
194
+ }
195
+ channel(times, values, 'VEC4', `/materials/${index}/pbrMetallicRoughness/baseColorFactor`);
196
+ }
197
+ // EMISSION: glTF's factor is at most 1; the strength over it goes in KHR_materials_emissive_strength
198
+ if (props.emissive) {
199
+ const times = props.emissive.times as Float32Array;
200
+ const glow = props.emissive.values as Float32Array;
201
+ const count = Math.min(times.length, glow.length / 3);
202
+ const factor = new Float32Array(count * 3);
203
+ const strength = new Float32Array(count);
204
+ for (let i = 0; i < count; i++) {
205
+ const top = Math.max(1, glow[i * 3]!, glow[i * 3 + 1]!, glow[i * 3 + 2]!);
206
+ strength[i] = top;
207
+ for (let c = 0; c < 3; c++) factor[i * 3 + c] = glow[i * 3 + c]! / top;
208
+ }
209
+ channel(times.slice(0, count), factor, 'VEC3', `/materials/${index}/emissiveFactor`);
210
+ if (strength.some((one) => one > 1)) {
211
+ const entry = json.materials![index]!;
212
+ entry.extensions = { ...entry.extensions, KHR_materials_emissive_strength: { emissiveStrength: Math.max(...strength) } };
213
+ used.add('KHR_materials_emissive_strength');
214
+ channel(times.slice(0, count), strength, 'SCALAR', `/materials/${index}/extensions/KHR_materials_emissive_strength/emissiveStrength`);
215
+ }
216
+ }
217
+ }
218
+ if (!channels.length) return;
219
+ // THE KEYS, in a buffer of their own, inline as the rest of the JSON export is
220
+ const bytes = new Uint8Array(offset);
221
+ let at = 0;
222
+ for (const chunk of chunks) { bytes.set(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), at); at += chunk.byteLength; }
223
+ let binary = '';
224
+ for (const byte of bytes) binary += String.fromCharCode(byte);
225
+ buffers.push({ byteLength: bytes.byteLength, uri: `data:application/octet-stream;base64,${btoa(binary)}` });
226
+ (json.animations ??= []).push({ name: 'Materials', samplers, channels });
227
+ json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), ...used])];
228
+ }
@@ -0,0 +1,39 @@
1
+ import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
2
+ import type { EditorCommandResult, VerbHandler } from '@volter/sdk/commands';
3
+ import { editorHost } from '@volter/sdk/host';
4
+ import { blenderActionClip, blenderSceneMovie } from '../host/blender-runtime-host';
5
+ import { modelDocumentSource, servedModelDocument } from '../src/play-mode';
6
+ import { exportSceneGltf } from './blender-export-gltf';
7
+
8
+ /**
9
+ * THE SCENE AS GLTF (`blender-export-gltf`, docs/SCENE-ANIMATION.md step 6): the presented model with
10
+ * every armature's actions, the scene's movie and its keyed materials (`blender-export-gltf.ts`),
11
+ * written to `out` (project-relative, `.gltf`; default `exports/<name>.gltf`).
12
+ */
13
+ async function exportGltf(command: Record<string, unknown>): Promise<EditorCommandResult> {
14
+ const documentId = servedModelDocument();
15
+ const blend = documentId ? modelDocumentSource(documentId) ?? modelDocumentSource(documentId.replace(/#.*$/, '')) : null;
16
+ if (!blend) return { ok: false, error: 'No model document is open: open the .blend to export, then export again.' };
17
+ if (blenderModelView.snapshot() === null)
18
+ return { ok: false, error: `${blend} has not been presented yet: wait for the model to show, then export again.` };
19
+ const named = command['out'];
20
+ if (named !== undefined && (typeof named !== 'string' || !/\.gltf$/i.test(named)))
21
+ return { ok: false, error: `blender-export-gltf's out is a project-relative .gltf path; it was given ${JSON.stringify(named)}.` };
22
+ const out = (named as string | undefined) ?? `exports/${blend.replace(/^.*\//, '').replace(/\.blend$/i, '')}.gltf`;
23
+ const started = performance.now();
24
+ const written = await exportSceneGltf(blenderModelView, {
25
+ clip: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
26
+ movie: () => blenderSceneMovie(),
27
+ });
28
+ const text = JSON.stringify(written.json);
29
+ await editorHost().files.write(out, text);
30
+ return {
31
+ ok: true,
32
+ data: { out, blend, bytes: text.length, animations: written.animations, pointers: written.pointers, skipped: written.skipped, failed: written.failed, ms: Math.round(performance.now() - started) },
33
+ };
34
+ }
35
+
36
+ export const exportVerbs: Readonly<Record<string, VerbHandler>> = {
37
+ // The model and its animation as glTF: every armature's clips read from Blender.
38
+ 'blender-export-gltf': (command) => exportGltf(command as Record<string, unknown>),
39
+ };