@volter/editor-blender 0.1.150 → 0.1.152

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 +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  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 +99 -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 +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  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 +145 -333
  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 +45 -31
  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 +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  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 +3 -30
  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 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -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 -48
  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/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  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
@@ -0,0 +1,312 @@
1
+ /**
2
+ * RFC 0007'S EXPORTS (`blender-export-component`, `blender-export-level`, session
3
+ * verbs): the model on screen, written by Blender's own glTF exporter (`bpy.ops.export_scene.gltf`
4
+ * in this tab's Blender, `@volter/blender-engine`'s `gltf_export.py`) into the project.
5
+ *
6
+ * - A COMPONENT (`.volter/export/components/<name>.glb`): one collection named after it, every
7
+ * action as a glTF animation, Y-up, the authoring collections `Notes` and `References` left out.
8
+ * - A LEVEL (`.volter/export/levels/<name>.glb`): the level's own objects, a placement marker in
9
+ * place of every library-override placement and every instance of a linked collection
10
+ * (`extras.component`, `extras.action`, `extras.tint`, `KHR_node_visibility`), and the timeline
11
+ * as one scene animation plus the scene's `extras.timeline`. Its answer names the components the
12
+ * markers reference (`cyclotron export level` exports or confirms each).
13
+ *
14
+ * A MANIFEST beside each file (`<name>.json`) records the `.blend`'s SHA-256, the exporter and its
15
+ * settings, and the file's SHA-256: the project's earlier manifest is handed to Blender, and when
16
+ * nothing it records has changed, nothing is exported (`current`).
17
+ *
18
+ * BOUNDED STEPS. An export of a large model holds Blender for longer than one call should last,
19
+ * so a call waits at most {@link STEP_MS} for it: an export still running answers
20
+ * `{ pending: true, token }`, and the same verb with that `token` waits again for the same export.
21
+ * Blender is never asked twice; the file is written once, when the export is done.
22
+ *
23
+ * `blend` names the `.blend` the caller means (project-relative); a model not yet presented is
24
+ * refused with "has not been presented yet", which the CLI waits on. `force` exports even when
25
+ * the manifest says the earlier export stands.
26
+ */
27
+ import type { GltfExportAnswer, GltfExportKind, GltfExportManifest } from '@volter/blender-engine/browser/protocol';
28
+ import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
29
+ import type { EditorCommandResult, VerbHandler } from '@volter/sdk/commands';
30
+ import { editorHost } from '@volter/sdk/host';
31
+ import { blenderEngineFile, blenderGltfExport, flushModelDocument, presentedModelBlend } from '../host/blender-runtime-host';
32
+
33
+ /** The longest one call waits for an export before answering `pending` (well inside a 95 s call). */
34
+ const STEP_MS = 60_000;
35
+ /** How long a finished export's answer is kept for a caller that has not asked for it yet. */
36
+ const KEEP_MS = 5 * 60_000;
37
+
38
+ /** Where each kind is written in the project. */
39
+ export const GLTF_EXPORT_DIRS: Readonly<Record<GltfExportKind, string>> = {
40
+ component: '.volter/export/components',
41
+ level: '.volter/export/levels',
42
+ };
43
+ /** One display LUT per project and per view settings, named by their key (`gltf_export.py`). */
44
+ export const GLTF_DISPLAY_DIR = '.volter/export/display';
45
+
46
+ /** The record of the LUTs carried in: each key and the SHA-256 its file had (read as one file: the standalone
47
+ * editor's file door lists only `public/**`, so the folder is never listed). */
48
+ const LUT_RECORD = `${GLTF_DISPLAY_DIR}/luts.json`;
49
+
50
+ async function lutRecord(): Promise<Record<string, string>> {
51
+ try {
52
+ const record = JSON.parse(await editorHost().files.read(LUT_RECORD)) as unknown;
53
+ return record && typeof record === 'object' ? record as Record<string, string> : {};
54
+ } catch { return {}; }
55
+ }
56
+
57
+ /** THE DISPLAY LUTS THE PROJECT HOLDS INTACT, by key, each with its SHA-256 as recorded when it was carried
58
+ * in (`luts.json` beside them). Checked on every export: a LUT whose bytes are not the ones recorded
59
+ * (damaged, replaced, gone) or that has no record is left out, so Blender bakes it again when the view
60
+ * settings name it. Blender bakes one only for view settings that have none. */
61
+ async function heldLuts(): Promise<Record<string, string>> {
62
+ const held: Record<string, string> = {};
63
+ await Promise.all(Object.entries(await lutRecord()).map(async ([key, recorded]) => {
64
+ // (a LUT that is gone has no hash: left out)
65
+ if (typeof recorded === 'string' && await fileSha256(`${GLTF_DISPLAY_DIR}/${key}.cube`).catch(() => null) === recorded) held[key] = recorded;
66
+ }));
67
+ return held;
68
+ }
69
+
70
+ interface Run {
71
+ readonly kind: GltfExportKind;
72
+ readonly blend: string;
73
+ readonly started: number;
74
+ readonly done: Promise<EditorCommandResult>;
75
+ settled: boolean;
76
+ expiry?: ReturnType<typeof setTimeout>;
77
+ }
78
+ const runs = new Map<string, Run>();
79
+
80
+ const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
81
+
82
+ async function sha256(bytes: Uint8Array): Promise<string> {
83
+ const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
84
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
85
+ }
86
+
87
+ /** Cache each project file’s SHA-256 by size and modification time, persisting hashes in HASH_CACHE across reloads. A null file stat requires hashing on every read. */
88
+ const knownHashes = new Map<string, { readonly size: number; readonly mtime: number; readonly sha256: string }>();
89
+ const HASH_CACHE = '.volter/cache/file-hashes.json';
90
+ let hashesRead: Promise<void> | null = null;
91
+ let hashesDue: ReturnType<typeof setTimeout> | null = null;
92
+ function readHashes(): Promise<void> {
93
+ return hashesRead ??= (async () => {
94
+ try {
95
+ const kept = JSON.parse(await editorHost().files.read(HASH_CACHE)) as Record<string, { size?: unknown; mtime?: unknown; sha256?: unknown }>;
96
+ for (const [path, entry] of Object.entries(kept))
97
+ if (typeof entry?.size === 'number' && typeof entry.mtime === 'number' && typeof entry.sha256 === 'string' && !knownHashes.has(path))
98
+ knownHashes.set(path, { size: entry.size, mtime: entry.mtime, sha256: entry.sha256 });
99
+ } catch { /* none kept yet, or unreadable: each file is hashed when asked */ }
100
+ })();
101
+ }
102
+ /** The hashes written back a second after the last new one (one write for an export's several). */
103
+ function keepHashes(): void {
104
+ if (hashesDue !== null) return;
105
+ hashesDue = setTimeout(() => {
106
+ hashesDue = null;
107
+ void editorHost().files.write(HASH_CACHE, `${JSON.stringify(Object.fromEntries(knownHashes))}\n`).catch(() => {});
108
+ }, 1000);
109
+ }
110
+ async function fileSha256(path: string): Promise<string> {
111
+ const files = editorHost().files;
112
+ await readHashes();
113
+ const stat = await files.stat(path).catch(() => null);
114
+ const known = knownHashes.get(path);
115
+ if (stat && known && known.size === stat.size && known.mtime === stat.mtime) return known.sha256;
116
+ const hash = await sha256(await files.readBytes(path));
117
+ // (stat again after the read: a write landing between the two leaves no entry rather than a stale one)
118
+ const after = stat ? await files.stat(path).catch(() => null) : null;
119
+ if (stat && after && after.size === stat.size && after.mtime === stat.mtime) knownHashes.set(path, { size: stat.size, mtime: stat.mtime, sha256: hash });
120
+ else knownHashes.delete(path);
121
+ keepHashes();
122
+ return hash;
123
+ }
124
+
125
+ /** Cache a project manifest by its size and modification time; return undefined when absent. */
126
+ const knownManifests = new Map<string, { readonly size: number; readonly mtime: number; readonly manifest: GltfExportManifest }>();
127
+ async function readManifest(path: string): Promise<GltfExportManifest | undefined> {
128
+ const files = editorHost().files;
129
+ const stat = await files.stat(path).catch(() => null);
130
+ if (!stat) return undefined;
131
+ const known = knownManifests.get(path);
132
+ if (known && known.size === stat.size && known.mtime === stat.mtime) return known.manifest;
133
+ try {
134
+ const manifest = JSON.parse(await files.read(path)) as GltfExportManifest;
135
+ knownManifests.set(path, { size: stat.size, mtime: stat.mtime, manifest });
136
+ return manifest;
137
+ } catch { return undefined; }
138
+ }
139
+
140
+ /** Cache an imported export’s SHA-256 and parsed manifest while its size matches the written bytes. Re-read any differing file. */
141
+ async function heldAsWritten(path: string, bytes: Uint8Array, manifest?: GltfExportManifest): Promise<void> {
142
+ const stat = await editorHost().files.stat(path).catch(() => null);
143
+ if (!stat || stat.size !== bytes.length) return;
144
+ if (manifest) {
145
+ if (typeof manifest.sha256 === 'string' && manifest.bytes === bytes.length) {
146
+ knownHashes.set(path, { size: stat.size, mtime: stat.mtime, sha256: manifest.sha256 });
147
+ keepHashes();
148
+ }
149
+ } else if (path.endsWith('.json')) {
150
+ try { knownManifests.set(path, { size: stat.size, mtime: stat.mtime, manifest: JSON.parse(new TextDecoder().decode(bytes)) as GltfExportManifest }); }
151
+ catch { knownManifests.delete(path); }
152
+ }
153
+ }
154
+
155
+ /** What Blender's currency check reads of an earlier manifest (`gltf_export.py`'s `_current` and
156
+ * `_lut_missing`), so a request carries that and not the whole manifest; a current answer echoes it back. */
157
+ function currencyOf(manifest: GltfExportManifest): GltfExportManifest {
158
+ const whole = manifest as unknown as Record<string, unknown>;
159
+ const kept: Record<string, unknown> = {};
160
+ for (const key of ['format', 'blendSha256', 'exporter', 'settings', 'sha256', 'display']) if (key in whole) kept[key] = whole[key];
161
+ return kept as unknown as GltfExportManifest;
162
+ }
163
+
164
+ /** The project's earlier export of this file, as Blender compares it: its manifest and its file's hash. */
165
+ async function previousExport(dir: string, name: string): Promise<{ manifest: GltfExportManifest; sha256: string | null } | undefined> {
166
+ const files = editorHost().files;
167
+ // (the manifest and the file's hash at once: neither waits on the other)
168
+ const [manifest, sha256] = await Promise.all([readManifest(`${dir}/${name}.json`), fileSha256(`${dir}/${name}.glb`).catch(() => null)]);
169
+ if (!manifest) return undefined;
170
+ // A level's placements' own actions live beside it: one gone, the level is exported again.
171
+ const own = Object.values((manifest as { placementActions?: Record<string, { file?: unknown }> }).placementActions ?? {});
172
+ const present = await Promise.all(own.map(async (entry) =>
173
+ typeof entry.file !== 'string' || (await files.stat(`${dir}/${entry.file}`).catch(() => null)) !== null));
174
+ return present.every(Boolean) ? { manifest, sha256 } : undefined;
175
+ }
176
+
177
+ /** What a caller is told about an export: its paths and the manifest's counts, not the whole manifest. */
178
+ function summary(blend: string, dir: string, answer: GltfExportAnswer): Record<string, unknown> {
179
+ const manifest = answer.manifest;
180
+ return {
181
+ kind: answer.kind,
182
+ blend,
183
+ out: `${dir}/${answer.name}.glb`,
184
+ manifest: `${dir}/${answer.name}.json`,
185
+ current: answer.current,
186
+ ms: answer.ms,
187
+ bytes: manifest.bytes,
188
+ sha256: manifest.sha256,
189
+ exporter: manifest.exporter,
190
+ nodes: manifest.nodes,
191
+ animations: manifest.animations.length,
192
+ hiddenNodes: manifest.hiddenNodes,
193
+ warnings: manifest.warnings,
194
+ ...(manifest.markers ? { markers: manifest.markers.length } : {}),
195
+ ...(manifest.components ? { components: manifest.components } : {}),
196
+ ...(manifest.unsupported ? { unsupported: manifest.unsupported } : {}),
197
+ ...(manifest.collections ? { collections: manifest.collections } : {}),
198
+ // (what a component of this Blender is exported with: `cyclotron export level` checks the
199
+ // components' manifests against it without opening them)
200
+ componentSettings: answer.settings.component,
201
+ };
202
+ }
203
+
204
+ async function runExport(kind: GltfExportKind, blend: string, force: boolean): Promise<EditorCommandResult> {
205
+ const dir = GLTF_EXPORT_DIRS[kind];
206
+ const name = (blend.split('/').pop() ?? blend).replace(/\.blend$/i, '');
207
+ // (where the round trip's time goes, in ms: the earlier export read and hashed, the `.blend` hashed, the LUTs
208
+ // checked, Blender's answer, and its files carried into the project)
209
+ // (the first three run at once, each timed on its own)
210
+ const steps: Record<string, number> = {};
211
+ const began = performance.now();
212
+ const timed = async <T>(step: string, work: Promise<T>): Promise<T> => {
213
+ try { return await work; } finally { steps[step] = Math.round(performance.now() - began); }
214
+ };
215
+ await flushModelDocument(blend);
216
+ const [previous, blendSha256, luts] = await Promise.all([
217
+ timed('previous', force ? Promise.resolve(undefined) : previousExport(dir, name)),
218
+ // The `.blend` as the project holds it: Blender streamed it in and keeps no copy to hash.
219
+ timed('blendHash', fileSha256(blend)),
220
+ // The project's LUTs that are intact, with their recorded SHA-256s (which every manifest records): a damaged one is baked again.
221
+ timed('luts', heldLuts()),
222
+ ]);
223
+ let at = performance.now();
224
+ const lap = (step: string): void => { const now = performance.now(); steps[step] = Math.round(now - at); at = now; };
225
+ // (Blender is sent what its currency check reads of the earlier manifest; a current answer echoes that back,
226
+ // and stands for the whole one held here)
227
+ const sent = await blenderGltfExport({ kind, blendSha256, luts, ...(force ? { force: true } : {}),
228
+ ...(previous ? { previous: { manifest: currencyOf(previous.manifest), sha256: previous.sha256 } } : {}) });
229
+ lap('blenderAnswer');
230
+ const answer = sent !== null && sent.current && previous ? { ...sent, manifest: previous.manifest } : sent;
231
+ if (answer === null) return { ok: false, error: "Blender's session is not started: open the model, then export again." };
232
+ if (answer.document !== blend)
233
+ return { ok: false, error: `Blender exported ${answer.document}, not ${blend}: the view moved to another file during the export.` };
234
+ // The export is in Blender's own filesystem: carry it into the project, the file before its manifest.
235
+ const files = editorHost().files;
236
+ // (the carry's time split in its reads from Blender's filesystem and its writes into the project)
237
+ let reads = 0, writes = 0;
238
+ for (const path of [...answer.files].sort((a, b) => Number(a.endsWith('.json')) - Number(b.endsWith('.json')))) {
239
+ const reading = performance.now();
240
+ const bytes = await blenderEngineFile(path);
241
+ reads += performance.now() - reading;
242
+ const writing = performance.now();
243
+ const name = path.split('/').pop()!;
244
+ if (name.endsWith('.cube')) {
245
+ // A LUT carried in is recorded as it arrived: the next exports check it against this.
246
+ await files.write(`${GLTF_DISPLAY_DIR}/${name}`, bytes);
247
+ const record = await lutRecord();
248
+ record[name.slice(0, -'.cube'.length)] = await sha256(bytes);
249
+ await files.write(LUT_RECORD, `${JSON.stringify(record, null, 2)}\n`);
250
+ } else {
251
+ await files.write(`${dir}/${name}`, bytes);
252
+ const own = name === `${answer.name}.glb`;
253
+ await heldAsWritten(`${dir}/${name}`, bytes, own ? answer.manifest : undefined);
254
+ }
255
+ writes += performance.now() - writing;
256
+ }
257
+ lap('carry');
258
+ if (answer.files.length > 0) { steps['carryRead'] = Math.round(reads); steps['carryWrite'] = Math.round(writes); }
259
+ return { ok: true, data: { ...summary(blend, dir, answer), steps } };
260
+ }
261
+
262
+ async function step(token: string, run: Run): Promise<EditorCommandResult> {
263
+ let timer: ReturnType<typeof setTimeout> | undefined;
264
+ const waiting = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), STEP_MS); });
265
+ const result = await Promise.race([run.done, waiting]).finally(() => clearTimeout(timer));
266
+ if (result === null)
267
+ return { ok: true, data: { pending: true, token, kind: run.kind, blend: run.blend, elapsedMs: Math.round(performance.now() - run.started) } };
268
+ if (run.expiry !== undefined) clearTimeout(run.expiry);
269
+ runs.delete(token);
270
+ return result;
271
+ }
272
+
273
+ async function exportModel(kind: GltfExportKind, command: Record<string, unknown>): Promise<EditorCommandResult> {
274
+ const verb = `blender-export-${kind}`;
275
+ const token = command['token'];
276
+ if (token !== undefined) {
277
+ if (typeof token !== 'string') return { ok: false, error: `${verb}'s token is the one its pending answer gave, a string.` };
278
+ const run = runs.get(token);
279
+ if (!run || run.kind !== kind) return { ok: false, error: `${verb} has no export with token ${token}: start the export again.` };
280
+ return step(token, run);
281
+ }
282
+ const wanted = command['blend'];
283
+ if (wanted !== undefined && (typeof wanted !== 'string' || !/\.blend$/i.test(wanted)))
284
+ return { ok: false, error: `${verb}'s blend is a project-relative .blend path; it was given ${JSON.stringify(wanted)}.` };
285
+ const force = command['force'];
286
+ if (force !== undefined && typeof force !== 'boolean') return { ok: false, error: `${verb}'s force is true or false.` };
287
+ // WHAT THE VIEW SHOWS NOW, not what was asked to open: just after a switch the view still holds the last file.
288
+ const blend = presentedModelBlend(blenderModelView);
289
+ if (blend === null || (wanted !== undefined && blend !== wanted))
290
+ return { ok: false, error: `${wanted ?? 'The model'} has not been presented yet${blend ? ` (the view still shows ${blend})` : ''}: wait for it to show, then export it.` };
291
+ // A caller that lost its token (it was stopped while it waited) joins the export it started.
292
+ const running = [...runs.entries()].find(([, one]) => !one.settled);
293
+ if (running && running[1].kind === kind && running[1].blend === blend && force !== true) return step(running[0], running[1]);
294
+ if (running) return { ok: false, error: `An export of ${running[1].blend} is still running; ask ${`blender-export-${running[1].kind}`} with its token, then export again.` };
295
+ const id = crypto.randomUUID();
296
+ const run: Run = {
297
+ kind, blend, started: performance.now(), settled: false,
298
+ done: runExport(kind, blend, force === true).catch((error: unknown) => ({ ok: false, error: `${verb} ${blend}: ${message(error)}` })),
299
+ };
300
+ void run.done.then(() => {
301
+ run.settled = true;
302
+ run.expiry = setTimeout(() => { if (runs.get(id) === run) runs.delete(id); }, KEEP_MS);
303
+ });
304
+ runs.set(id, run);
305
+ return step(id, run);
306
+ }
307
+
308
+ export const gltfExportVerbs: Readonly<Record<string, VerbHandler>> = {
309
+ // Each call waits at most STEP_MS for its export, then answers pending with a token.
310
+ 'blender-export-component': (command) => exportModel('component', command as Record<string, unknown>),
311
+ 'blender-export-level': (command) => exportModel('level', command as Record<string, unknown>),
312
+ };
@@ -1,50 +1,4 @@
1
- /**
2
- * THE MODEL DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER, in Object Mode
3
- * (WORK.md §THE BLENDER RELEASE, B6; ARCHITECTURE-CORE §Blender north star
4
- * goal 3 as amended — the person "can make MACRO adjustments at the screen …
5
- * each a bpy call through the doors that exist").
6
- *
7
- * `VIEW3D_MT_editor_menus` (`scripts/startup/bl_ui/space_view3d.py:1143-1210`)
8
- * draws four menu words in Object Mode, in this order: **View**
9
- * (`:1153`), **Select** (`:1166`, `VIEW3D_MT_select_object`), **Add**
10
- * (`:1169`, `VIEW3D_MT_add`) and **Object** (`:1210`, `VIEW3D_MT_object`).
11
- * All four are here.
12
- *
13
- * VIEW CARRIES ITS AREA ROWS AND NOTHING ELSE. Its camera-view rows — Front / Right / Top /
14
- * Perspective 3/4, and the Perspective–Orthographic pair — are already a menu in THIS SAME HEADER
15
- * STRIP: the host draws `Object3DDocumentToolbar` at the strip's trailing edge for every document
16
- * with a 3D session, which is exactly where Blender's viewport header carries them too
17
- * (`DocumentHeaderStrip`'s own note), so repeating them here would be the same control twice in
18
- * one row. What View owns that no other control does is **Area** (`VIEW3D_MT_view`'s
19
- * `layout.menu("INFO_MT_area")`, `space_view3d.py:1520`; `space_info.py`'s `INFO_MT_area`): the
20
- * split that puts a second area beside this one. Blender's standard way to watch the render while
21
- * modeling is exactly that — split the viewport and set the new area to Rendered shading through
22
- * the camera — so Vertical Split opens a second 3D viewport beside the model, set up that way
23
- * (`blender-runtime.document.tsx`), and Close Area closes it. The other rows of `INFO_MT_area` (Horizontal Split, the new-window, maximize and fullscreen toggles) and
24
- * the rest of View — the sidebar and tool-shelf toggles, local view, the view-point and view-axis
25
- * submenus, Frame Selected, Viewport Render — are not drawn.
26
- *
27
- * WHAT IS DRAWN IS WHAT ACTS. A row Blender draws that this unit does not act
28
- * on is simply not here — never a row that opens a dialog we do not have, and
29
- * never one that no-ops. Where a row exists in Blender and is REFUSED here it
30
- * is drawn disabled with the refusal as its reason: the four Add rows that want a FILE
31
- * (Image ▸ Reference…/Background…/Mesh Plane…, Volume ▸ Import OpenVDB… —
32
- * `BlenderCreatableKind.refusal` carries each sentence). Add is the whole of
33
- * `VIEW3D_MT_add` now, so "not here" means Blender's menu-search row and
34
- * nothing else.
35
- *
36
- * EVERY ROW IS BLENDER RUNNING ITS OWN OPERATOR, through the doors B6 built:
37
- * `StructureProvider` for Add / Duplicate / Delete, and
38
- * `runBlenderObjectOperator` for Apply / Clear / Set Origin. Nothing here
39
- * reimplements a transform apply or an origin move.
40
- *
41
- * NO GAME CONTROLS (owner, 2026-10-06: "it's very confusing that the timeline at the bottom has
42
- * a play button AND there's a play button at the top"). The header carries no Play of its own,
43
- * nor View's Play row: in Game mode the one Play is the Game panel's (`blender-game-panel.tsx`),
44
- * in Movie mode the one transport is the Timeline's. The Game / Movie switch that chooses
45
- * between them is the bottom area's, at the leading edge of its header row (`PlayModeSwitch`),
46
- * because switching modes swaps that area and nothing here.
47
- */
1
+
48
2
 
49
3
  import type { ToolNotice } from '@volter/sdk/contributions';
50
4
  import {
@@ -70,14 +24,7 @@ import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
70
24
 
71
25
  type MenuId = 'view' | 'select' | 'add' | 'object';
72
26
 
73
- /**
74
- * BLENDER'S OWN ROWS for the three transform submenus of `VIEW3D_MT_object`,
75
- * each citing the line it is transcribed from. The rows Blender draws in those
76
- * submenus that this unit does not act on — Apply's five delta rows, its
77
- * Visual Transform / Visual Geometry to Mesh / Parent Inverse block, Clear's
78
- * Origin row, Set Origin's two centre-of-mass rows — are not here, for the
79
- * reason the header's own note gives.
80
- */
27
+ /** Supported transform rows from Blender's VIEW3D_MT_object submenus. */
81
28
  const APPLY_ROWS: readonly { readonly label: string; readonly call: string }[] = [
82
29
  // `VIEW3D_MT_object_apply`, `space_view3d.py:3206-3219`: one
83
30
  // `object.transform_apply` per row with the three booleans set.
@@ -98,7 +45,6 @@ const CLEAR_ROWS: readonly { readonly label: string; readonly call: string }[] =
98
45
  const ORIGIN_ROWS: readonly {
99
46
  readonly label: string;
100
47
  readonly type: string;
101
- readonly refusal?: string;
102
48
  }[] = [
103
49
  // `VIEW3D_MT_object`, `space_view3d.py:2817` — one
104
50
  // `operator_menu_enum("object.origin_set", "type")`, so the labels are the
@@ -110,67 +56,47 @@ const ORIGIN_ROWS: readonly {
110
56
  { label: 'Origin to 3D Cursor', type: 'ORIGIN_CURSOR' },
111
57
  ];
112
58
 
113
- /**
114
- * `VIEW3D_MT_add`'S OWN ROWS, in its own order, with its own separators
115
- * (`space_view3d.py:2699-2764`, read at the engine's pin) — EIGHTEEN
116
- * top-level rows, each carrying the line it is transcribed from.
117
- *
118
- * Eighteen and not seventeen: `:2707` is Point Cloud
119
- * (`object.pointcloud_random_add`), a flat row between Text and Volume that a
120
- * count taken from Blender's manual misses. The pinned source is the
121
- * reference, so it is drawn.
122
- *
123
- * A SUBMENU NAMES A GROUP OF {@link CREATABLE_KINDS}, which is where each
124
- * row's label, call and refusal live; a flat row names one kind. The one
125
- * dynamic submenu is Collection Instance, whose rows are the file's own
126
- * collections (`liveCollectionKinds`).
127
- *
128
- * WHAT BLENDER DRAWS THAT IS NOT HERE, and why — the same rule the header's
129
- * note states: `Search…` (`:2689`) draws only under `EXEC_REGION_WIN` and
130
- * opens the menu-search dialog this editor has no door for. Everything else
131
- * `VIEW3D_MT_add` draws is here, including the rows that refuse (they are
132
- * drawn disabled with the engine's own reason rather than omitted, because a
133
- * person looking for Reference… should find it and be told why it cannot run).
134
- */
59
+ /** Blender's VIEW3D_MT_add row order. Groups resolve current blenderCreatableKinds;
60
+ * Collection Instance resolves the file's live collections. Unsupported operations expose their refusal. */
135
61
  type AddRow =
136
62
  /** `layout.separator()`, at this line — which is also the row's key. */
137
63
  | { readonly separatorAt: string }
138
64
  /** A row `VIEW3D_MT_add` draws directly, by its kind id. */
139
- | { readonly kind: string; readonly at: string }
140
- /** A submenu: the label Blender gives it, and the `CREATABLE_KINDS` group
65
+ | { readonly kind: string }
66
+ /** A submenu: the label Blender gives it, and the creatable-kind group
141
67
  * whose rows fill it (the same string). */
142
- | { readonly submenu: string; readonly at: string };
68
+ | { readonly submenu: string };
143
69
 
144
70
  const ADD_MENU: readonly AddRow[] = [
145
- { submenu: 'Mesh', at: '2699' },
146
- { submenu: 'Curve', at: '2702' },
147
- { submenu: 'Surface', at: '2704' },
148
- { submenu: 'Metaball', at: '2705' },
149
- { kind: 'text', at: '2706' },
150
- { kind: 'point-cloud', at: '2707' },
151
- { submenu: 'Volume', at: '2708' },
152
- { submenu: 'Grease Pencil', at: '2709' },
71
+ { submenu: 'Mesh' },
72
+ { submenu: 'Curve' },
73
+ { submenu: 'Surface' },
74
+ { submenu: 'Metaball' },
75
+ { kind: 'text' },
76
+ { kind: 'point-cloud' },
77
+ { submenu: 'Volume' },
78
+ { submenu: 'Grease Pencil' },
153
79
  { separatorAt: '2711' },
154
80
  // `:2713` asks `VIEW3D_MT_armature_add.is_extended()`; no add-on extends it
155
81
  // in this engine, so Blender's own `else` branch — the flat row — is drawn.
156
- { kind: 'armature', at: '2716' },
157
- { submenu: 'Lattice', at: '2718' },
82
+ { kind: 'armature' },
83
+ { submenu: 'Lattice' },
158
84
  { separatorAt: '2720' },
159
- { submenu: 'Empty', at: '2722' },
160
- { submenu: 'Image', at: '2723' },
85
+ { submenu: 'Empty' },
86
+ { submenu: 'Image' },
161
87
  { separatorAt: '2725' },
162
- { submenu: 'Light', at: '2727' },
163
- { submenu: 'Light Probe', at: '2728' },
88
+ { submenu: 'Light' },
89
+ { submenu: 'Light Probe' },
164
90
  { separatorAt: '2730' },
165
91
  // `:2732`'s `is_extended()` is false the same way, so `VIEW3D_MT_camera_add`
166
92
  // draws its single row INTO this menu (`:2735`).
167
- { kind: 'camera', at: '2735' },
93
+ { kind: 'camera' },
168
94
  { separatorAt: '2737' },
169
- { kind: 'speaker', at: '2739' },
95
+ { kind: 'speaker' },
170
96
  { separatorAt: '2741' },
171
- { submenu: 'Force Field', at: '2743' },
97
+ { submenu: 'Force Field' },
172
98
  { separatorAt: '2745' },
173
- { submenu: 'Collection Instance', at: '2759' },
99
+ { submenu: 'Collection Instance' },
174
100
  ];
175
101
 
176
102
  /** BLENDER'S OWN TEXT for a Collection Instance row with no collection to
@@ -199,7 +125,7 @@ function MenuWord({
199
125
  aria-expanded={open === id}
200
126
  onClick={() => setOpen(open === id ? null : id)}
201
127
  // BLENDER'S OWN BAR BEHAVIOUR: with one menu open, moving across the
202
- // bar opens the next. The same rule `ApplicationMenus` transcribes for
128
+ // bar opens the next. The same rule the native menu follows for
203
129
  // the title bar's words.
204
130
  onPointerEnter={() => {
205
131
  if (open) setOpen(id);
@@ -210,7 +136,7 @@ function MenuWord({
210
136
  </MenuTrigger>
211
137
  {open === id && (
212
138
  // PORTALED, not `position: absolute` — the document header strip is
213
- // 26 px tall and clips, the same fact `ApplicationMenus` records about
139
+ // 26 px tall and clips, the same fact the menu documents about
214
140
  // the workbench's own title bar.
215
141
  <AnchoredMenu
216
142
  anchorRef={anchorRef}
@@ -228,20 +154,7 @@ function MenuWord({
228
154
  );
229
155
  }
230
156
 
231
- /**
232
- * WHAT A ROW DID, SAID OUT LOUD. Every row here is a bpy call whose success and
233
- * whose REFUSAL are both invisible on screen — an added object appears, but a
234
- * refused `transform_apply` on a linked mesh, a Blender traceback, or an
235
- * operator the headless build has no context for all look exactly like a menu
236
- * that closed. So each row's `WriteAck` is read and reported through the host's
237
- * own notice door (`ToolContributionProps.notify`).
238
- *
239
- * MEASURED 2026-09-21, which is why this exists rather than being tidy: Add ▸
240
- * Mesh ▸ UV Sphere created the sphere and never selected it, and the ONLY
241
- * evidence was `bpy.data.objects` read from a second door — the menu itself
242
- * reported nothing either way. An ack that says `persisted: false` now carries
243
- * Blender's own sentence to the screen.
244
- */
157
+ /** Report each menu row’s WriteAck through the host notice door. A refused Blender operator or unpersisted write must remain visible after the menu closes. */
245
158
  function reportAck(
246
159
  notify: ((notice: ToolNotice) => () => void) | undefined,
247
160
  label: string,
@@ -270,7 +183,6 @@ export function BlenderObjectModeHeader({
270
183
  // And on the set of live Outliners: the handle this bar acts through may arrive after its first render.
271
184
  useSyncExternalStore(subscribeBlenderOutlinerHandles, blenderOutlinerHandlesVersion, blenderOutlinerHandlesVersion);
272
185
  const [open, setOpen] = useState<MenuId | null>(null);
273
- const rootRef = useRef<HTMLDivElement>(null);
274
186
  const viewRef = useRef<HTMLDivElement>(null);
275
187
  const selectRef = useRef<HTMLDivElement>(null);
276
188
  const addRef = useRef<HTMLDivElement>(null);
@@ -281,9 +193,7 @@ export function BlenderObjectModeHeader({
281
193
  () => false,
282
194
  );
283
195
  const handle = blenderOutlinerHandle(documentId);
284
- // NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
285
- // document there is no subject for any of these rows, and a menu of rows
286
- // that cannot act is the inert control this file's header rules out.
196
+ // Without a published Outliner this document offers no menu subject.
287
197
  if (handle === null) return null;
288
198
 
289
199
  const run = (
@@ -299,14 +209,9 @@ export function BlenderObjectModeHeader({
299
209
  const all = handle.allObjectNames();
300
210
  const nothingSelected = selected.length === 0 ? 'Nothing is selected.' : undefined;
301
211
 
302
- // THE ADD MENU'S ROWS. The kinds are read fresh on every render because one
303
- // submenu's rows are the file's own collections, which change as the person
304
- // works (`liveCollectionKinds`) — and this bar already re-renders on the
305
- // Outliner's version, which is where that tree comes from.
212
+ // Resolve current creatable kinds, including the file's live collections, on each Outliner render.
306
213
  const kinds = blenderCreatableKinds();
307
- /** One row: Blender's label, its kind's call, and — where the kind refuses —
308
- * drawn DISABLED with that sentence as its reason, the shape Set Origin ▸
309
- * Origin to 3D Cursor set. */
214
+ /** Draw each kind's refusal as a disabled row with its reason. */
310
215
  const addItem = (kind: BlenderCreatableKind): ReactNode => (
311
216
  <MenuItem
312
217
  key={kind.kind}
@@ -358,10 +263,9 @@ export function BlenderObjectModeHeader({
358
263
  compact
359
264
  label="Blender Object Mode menus"
360
265
  data-testid="blender-object-mode-menus"
361
- ref={rootRef}
362
266
  >
363
267
  {/* VIEW ▸ AREA — `INFO_MT_area`'s split and close, for the second 3D viewport beside the
364
- model (this file's header). */}
268
+ model. */}
365
269
  <MenuWord id="view" open={open} setOpen={setOpen} anchorRef={viewRef}>
366
270
  <MenuSubmenu label="Area" data-testid="blender-view-area">
367
271
  <MenuItem
@@ -423,7 +327,7 @@ export function BlenderObjectModeHeader({
423
327
 
424
328
  {/* ADD — `VIEW3D_MT_add`, `space_view3d.py:2679-2764`, EVERY ROW IT
425
329
  DRAWS, in its order, with its separators and its submenus. The shape
426
- is `ADD_MENU` above; what each row DOES is `CREATABLE_KINDS`. */}
330
+ is ADD_MENU; blenderCreatableKinds supplies each operation. */}
427
331
  <MenuWord id="add" open={open} setOpen={setOpen} anchorRef={addRef}>
428
332
  {ADD_MENU.map((row) => addRowNode(row))}
429
333
  </MenuWord>
@@ -443,8 +347,8 @@ export function BlenderObjectModeHeader({
443
347
  <MenuItem
444
348
  key={row.type}
445
349
  data-testid={`blender-origin-${row.type}`}
446
- disabled={row.refusal !== undefined || selected.length === 0}
447
- title={row.refusal ?? nothingSelected}
350
+ disabled={selected.length === 0}
351
+ title={nothingSelected}
448
352
  onSelect={run(`Set Origin ▸ ${row.label}`, (outliner) =>
449
353
  runBlenderObjectOperator(
450
354
  outliner.selectedObjectNames(),
@@ -498,7 +402,7 @@ export function BlenderObjectModeHeader({
498
402
  const ids = outliner.selectedObjectNames()
499
403
  .map(name => outliner.rowIdForObject(name))
500
404
  .filter((id): id is string => id !== null);
501
- return outliner.structure.duplicateMany?.(ids);
405
+ return outliner.structure.duplicateMany(ids);
502
406
  })}
503
407
  >
504
408
  Duplicate Objects
@@ -512,7 +416,7 @@ export function BlenderObjectModeHeader({
512
416
  .selectedObjectNames()
513
417
  .map((name) => outliner.rowIdForObject(name))
514
418
  .filter((id): id is string => id !== null);
515
- return outliner.structure.removeMany?.(ids);
419
+ return outliner.structure.removeMany(ids);
516
420
  })}
517
421
  >
518
422
  Delete