@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
Binary file
@@ -0,0 +1,129 @@
1
+ {
2
+ "_generated": "scripts/workbench/generate-icons.mjs — do not edit. Source: packages/blender/contributions/blender.icons.json sha256 d515ee16432ecbc75a05f9854d7055e4e33d4f05aead3db89709b9d8239df670; 37 ids over 35 traced glyphs.",
3
+ "fonts": [
4
+ {
5
+ "id": "blender",
6
+ "src": [
7
+ {
8
+ "path": "./blender-icons.otf",
9
+ "format": "opentype"
10
+ }
11
+ ],
12
+ "weight": "normal",
13
+ "style": "normal"
14
+ }
15
+ ],
16
+ "iconDefinitions": {
17
+ "chevron-down": {
18
+ "fontCharacter": "\\e000"
19
+ },
20
+ "chevron-left": {
21
+ "fontCharacter": "\\e001"
22
+ },
23
+ "chevron-right": {
24
+ "fontCharacter": "\\e002"
25
+ },
26
+ "chevron-up": {
27
+ "fontCharacter": "\\e000"
28
+ },
29
+ "arrow-down": {
30
+ "fontCharacter": "\\e003"
31
+ },
32
+ "arrow-left": {
33
+ "fontCharacter": "\\e004"
34
+ },
35
+ "arrow-right": {
36
+ "fontCharacter": "\\e005"
37
+ },
38
+ "arrow-up": {
39
+ "fontCharacter": "\\e006"
40
+ },
41
+ "triangle-down": {
42
+ "fontCharacter": "\\e007"
43
+ },
44
+ "triangle-right": {
45
+ "fontCharacter": "\\e008"
46
+ },
47
+ "close": {
48
+ "fontCharacter": "\\e009"
49
+ },
50
+ "chrome-close": {
51
+ "fontCharacter": "\\e009"
52
+ },
53
+ "add": {
54
+ "fontCharacter": "\\e00a"
55
+ },
56
+ "remove": {
57
+ "fontCharacter": "\\e00b"
58
+ },
59
+ "trash": {
60
+ "fontCharacter": "\\e00c"
61
+ },
62
+ "search": {
63
+ "fontCharacter": "\\e00d"
64
+ },
65
+ "zoom-in": {
66
+ "fontCharacter": "\\e00e"
67
+ },
68
+ "zoom-out": {
69
+ "fontCharacter": "\\e00f"
70
+ },
71
+ "eye": {
72
+ "fontCharacter": "\\e010"
73
+ },
74
+ "eye-closed": {
75
+ "fontCharacter": "\\e011"
76
+ },
77
+ "check": {
78
+ "fontCharacter": "\\e012"
79
+ },
80
+ "info": {
81
+ "fontCharacter": "\\e013"
82
+ },
83
+ "warning": {
84
+ "fontCharacter": "\\e014"
85
+ },
86
+ "error": {
87
+ "fontCharacter": "\\e015"
88
+ },
89
+ "folder": {
90
+ "fontCharacter": "\\e016"
91
+ },
92
+ "folder-opened": {
93
+ "fontCharacter": "\\e017"
94
+ },
95
+ "file": {
96
+ "fontCharacter": "\\e018"
97
+ },
98
+ "file-code": {
99
+ "fontCharacter": "\\e019"
100
+ },
101
+ "copy": {
102
+ "fontCharacter": "\\e01a"
103
+ },
104
+ "link": {
105
+ "fontCharacter": "\\e01b"
106
+ },
107
+ "lock": {
108
+ "fontCharacter": "\\e01c"
109
+ },
110
+ "unlock": {
111
+ "fontCharacter": "\\e01d"
112
+ },
113
+ "play": {
114
+ "fontCharacter": "\\e01e"
115
+ },
116
+ "debug-pause": {
117
+ "fontCharacter": "\\e01f"
118
+ },
119
+ "debug-stop": {
120
+ "fontCharacter": "\\e020"
121
+ },
122
+ "list-flat": {
123
+ "fontCharacter": "\\e021"
124
+ },
125
+ "ellipsis": {
126
+ "fontCharacter": "\\e022"
127
+ }
128
+ }
129
+ }
@@ -1,13 +1,4 @@
1
- /**
2
- * `@volter/editor-blender`'s server half (`package.json#volter.serving`): the routes the Blender
3
- * in the tab needs from the session — the engine's WebAssembly build, the file spool its
4
- * transports move bytes through, the project's files as Blender reads them, and the two ways
5
- * bytes it produced reach the project (a shipped output, the session's `.blend`). They were
6
- * the kit's own routes; the kit now names no Blender, and reaches these only because the
7
- * product that composes this package declares its serving module. Writes go through the kit's
8
- * services (`@volter/sdk/session/project-serving`), so they are attributed and recorded
9
- * like every other project write.
10
- */
1
+ /** The Blender serving half supplies engine artifacts, spool transport, project-file reads, output writes and document saves. SDK serving services attribute and commit project writes. */
11
2
 
12
3
  import { createHash } from 'node:crypto';
13
4
  import { createReadStream } from 'node:fs';
@@ -17,7 +8,7 @@ import { tmpdir } from 'node:os';
17
8
  import { dirname, join } from 'node:path';
18
9
  import { createBrotliDecompress } from 'node:zlib';
19
10
  import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
20
- import { isContainedRelativePath } from '@volter/sdk/session/relative-path-guard';
11
+ import { isContainedRelativePath } from '@volter/sdk/relative-path-guard';
21
12
  import type { ProjectServingServices } from '@volter/sdk/session/project-serving';
22
13
  import type { Plugin } from 'vite';
23
14
  import {
@@ -59,10 +50,8 @@ async function readBody(req: IncomingMessage): Promise<Buffer> {
59
50
  export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
60
51
  // ---- File bodies, which are NOT commands.
61
52
  //
62
- // A FILE IS A BODY, NOT A JSON FIELD. `blender-read-file` used to answer with the whole file
63
- // base64'd inside a control-plane message, so a mesh the twin had written came back as a
64
- // single JSON frame on the command socket (measured 2026-09-15: a 31 MB file became ~41.5 MB
65
- // of base64 in one message and killed the socket). So bytes move as bytes: the page POSTs an
53
+ // A FILE IS A BODY, NOT A JSON FIELD: a whole file base64'd inside one control-plane message
54
+ // can kill the command socket. So bytes move as bytes: the page POSTs an
66
55
  // octet-stream; the requester GETs it once and it is gone. The spool keeps PATH AUTHORITY
67
56
  // WITH THE REQUESTER: the page never names a destination on disk, only an id the caller
68
57
  // minted.
@@ -105,9 +94,7 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
105
94
  json(res, await blenderWasmStatus());
106
95
  },
107
96
  },
108
- // The substrate skew's own doors beside the standalone bundle's: Blender's runtime tree as
109
- // one index and one blob (a request per file would be ~2,300 round trips), and the
110
- // substrate's browser ESM with its bare specifiers rewritten.
97
+ // Serve the substrate runtime tree as one index and blob, plus browser ESM with rewritten bare specifiers.
111
98
  {
112
99
  method: 'GET',
113
100
  match: /^\/__editor\/blender-wasm\/runtime\.idx$/,
@@ -212,11 +199,7 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
212
199
  }
213
200
  // The substrate skew's module is served as brotli too, made once (`brotliArtifact`).
214
201
  const found = file === BLENDER_WALI_ARTIFACT ? await brotliArtifact(onDisk) : onDisk;
215
- // A VALIDATOR, SO THE COMPILED ENGINE IS CACHED. Chrome keeps the machine code it
216
- // compiled from an `instantiateStreaming` response only while that response sits in its
217
- // HTTP cache, and a response with no validator is never stored (measured: every boot
218
- // compiled all 86 MB again, 12-36 s at load ~35). `no-cache` still asks every time, and
219
- // this answers 304 while the file on disk is the same one.
202
+ // Supply a validator so Chrome can cache the compiled engine. no-cache revalidates each boot; return 304 while the artifact identity matches.
220
203
  const info = await stat(found.path);
221
204
  // A pre-compressed file goes out as stored, declared as brotli, to a client that accepts
222
205
  // brotli; one that does not (a service worker in the tab asks with no Accept-Encoding)
@@ -318,10 +301,7 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
318
301
  json(res, { error: 'That path leaves the project root.' }, 403);
319
302
  return;
320
303
  }
321
- // STREAMED, never read whole: on a tab-served project this server runs in the page, and a
322
- // 307 MB document read into one buffer and ended as one body was copied along the page's
323
- // own response path. MEASURED 2026-09-29 on the hosted Stoneguard open: the page's
324
- // isolate held 1,009 MB of ArrayBuffers while the document crossed, against 19 MB before.
304
+ // Stream document bytes; the tab-hosted response path must not buffer the entire model.
325
305
  let size: number;
326
306
  try {
327
307
  const info = await stat(absolute);
@@ -430,20 +410,7 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
430
410
  }
431
411
  },
432
412
  },
433
- // ---- The session's DOCUMENT, written back OUT into the project. A Blender session holds one
434
- // `.blend`; its `save_as_mainfile` writes it into the engine's filesystem, and this is how
435
- // those bytes reach the disk the project lives on. Unlike the spool, the destination shape is
436
- // pinned BY THIS SERVER, and the write is the project's one attributed, conflict-checked
437
- // transaction: a `.blend` is the project's file like any other.
438
- //
439
- // IT ARRIVES AS A DELTA. The page cuts each save into content-defined chunks
440
- // (`blender-engine/browser/document-chunks.mts`) and posts their manifest; this answers with
441
- // the chunks it does not already hold, the page posts those, and the manifest again commits.
442
- // MEASURED 2026-09-29, native Blender 5.2 on the Stoneguard bridge: a 497 MB save, one object
443
- // moved, changed 1 of 475 one-MiB chunks, so an edit carries about a megabyte instead of the
444
- // whole file. Chunks this server reuses come from the document as it lies on disk, and each
445
- // is hashed again against the manifest before the commit: a file changed underneath (another
446
- // writer, a restore) is not drift but a missing chunk, re-sent.
413
+ // Save the session .blend through the attributed, conflict-checked project transaction. A chunk manifest requests only missing bytes. Rehash reused disk chunks at commit so an external file change requests replacement chunks.
447
414
  {
448
415
  method: 'POST',
449
416
  match: /^\/__editor\/blender-document-chunk$/,
@@ -491,8 +458,7 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
491
458
  json(res, { error: 'No project open, so there is nowhere to save the document.' }, 404);
492
459
  return;
493
460
  }
494
- // The editor server's `express.json` has already read a JSON body when it runs first
495
- // (MEASURED: the stream arrived empty); read the stream only when it has not.
461
+ // Use the parsed JSON body when express.json has consumed the stream; otherwise read it here.
496
462
  const parsed = (req as IncomingMessage & { body?: unknown }).body;
497
463
  let manifest: unknown = parsed !== null && typeof parsed === 'object' && !Buffer.isBuffer(parsed) ? parsed : null;
498
464
  if (manifest === null) {
@@ -1,57 +1,4 @@
1
- /**
2
- * The headless Blender build, served to the editor tab — and the door that
3
- * says WHICH BUILD it is.
4
- *
5
- * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
6
- * 2026-09-17), and since the 2026-09-18 amendment there are two builds of it:
7
- *
8
- * emscripten — the STANDALONE skew, and the one `@volter/blender-engine` SHIPS under
9
- * `wasm/`: the glue as text plus the two large files pre-compressed
10
- * (`blender_browser.wasm.br`, `blender_browser.data.br`, ~38 MB together
11
- * against ~156 MB raw). They are served as-is with `Content-Encoding: br`;
12
- * the browser decompresses before the runtime sees a byte, so the glue's
13
- * own `instantiateStreaming` and `.data` preload run unchanged.
14
- *
15
- * wali — the SUBSTRATE skew: one `blender.wasm` linked for
16
- * `wasm32-wali-linux-musl` beside the `runtime/` tree it reads through
17
- * `BLENDER_SYSTEM_*` (browser-substrate `programs/blender/5.2.0`, whose
18
- * `gate.sh` stages exactly that pair).
19
- *
20
- * THE SKEW IS NOT CONFIGURED, IT IS READ. One editor serves one Blender, and
21
- * the served directory holds either `blender_browser.js` or `blender.wasm`;
22
- * that IS the answer, and `status` carries it. There is no manifest field, no
23
- * setting and no flag, because nothing a user does may depend on the skew —
24
- * the ruling's "a skew may refuse a capability by name, never answer the same
25
- * question differently" leaves nothing for a chooser to choose.
26
- * `VOLTER_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
27
- * different build, and pointing it at a WALI pack is how the substrate skew is
28
- * reached in development.
29
- *
30
- * ONE PATCH IS APPLIED TO THE EMSCRIPTEN GLUE, and it is applied HERE rather
31
- * than in the worker so that the pthread workers — which the runtime spawns by
32
- * loading this same URL as a classic worker (`Module.mainScriptUrlOrBlob`) —
33
- * get the identical text. The build is `-sPROXY_TO_PTHREAD`, and its
34
- * `pthread_create` glue transfers a `#canvas` to the spawned thread by default.
35
- * There is no canvas in a worker, and the transfer aborts the spawn, so the
36
- * transfer list is emptied. The upstream release already does exactly this for
37
- * node (`if(ENVIRONMENT_IS_NODE){transferredCanvasNames=0}`); a worker needs
38
- * the same, and for the same reason.
39
- *
40
- * THE SAME PATCHING IS WHAT CARRIES THE SUBSTRATE, and it is the precedent
41
- * being transcribed rather than a new mechanism: `@volter/browser-wali` and
42
- * `@volter/browser-runtime` are served out of this door too
43
- * (`/__editor/blender-wasm/wali/<package>/<file>.js`), with their bare
44
- * `@volter/...` specifiers rewritten to those URLs on the way out. That is
45
- * what keeps `@volter/blender-engine` free of any dependency on browser-substrate: the
46
- * standalone skew installs nothing, bundles nothing and fetches nothing, and
47
- * the WALI engine imports the substrate from URLs instead of specifiers. The
48
- * nested workers `BrowserWaliWorkerProgram` spawns load from these same URLs,
49
- * so they get the identical rewritten text — exactly the reason the glue patch
50
- * lives here.
51
- *
52
- * A missing directory is a LOUD 404 naming the variable, never a silent
53
- * fall-through: the engine either is Blender or says why it is not.
54
- */
1
+
55
2
  import { createHash } from 'node:crypto';
56
3
  import { createReadStream, existsSync } from 'node:fs';
57
4
  import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
@@ -60,7 +7,7 @@ import { dirname, isAbsolute, join, posix, relative } from 'node:path';
60
7
  import { fileURLToPath } from 'node:url';
61
8
  import { brotliCompress, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
62
9
 
63
- export type BlenderSkew = 'emscripten' | 'wali';
10
+ type BlenderSkew = 'emscripten' | 'wali';
64
11
 
65
12
  /** Engine artifacts plus the separately packaged Essentials asset payload. */
66
13
  export const BLENDER_WASM_FILES = [
@@ -71,76 +18,33 @@ export const BLENDER_WASM_FILES = [
71
18
  'essentials.bin',
72
19
  ] as const;
73
20
 
74
- export type BlenderWasmFile = (typeof BLENDER_WASM_FILES)[number];
75
-
76
21
  /** What the WALI skew's directory must hold: the artifact, and the runtime
77
22
  * tree Blender reads through `BLENDER_SYSTEM_*`. */
78
23
  export const BLENDER_WALI_ARTIFACT = 'blender.wasm';
79
- export const BLENDER_WALI_RUNTIME = 'runtime';
24
+ const BLENDER_WALI_RUNTIME = 'runtime';
80
25
 
81
- /**
82
- * THE THREAD BUDGET, stated once, here, because it is a property of this RUN
83
- * SURFACE and not of the program.
84
- *
85
- * Blender is SERIAL unless it is told `-t N` and says so on stderr
86
- * (browser-substrate `programs/blender/5.2.0/README.md` §(a): `-t N` sets
87
- * oneTBB's global control and blenlib's explicitly sized arena). The POOL is a
88
- * different number and must be comfortably above it: measured 2026-09-18 in
89
- * the pack's own emulator, `blender -t 4` TRAPS with a host pool of 4, because
90
- * the stated count buys four oneTBB workers and Blender starts threads of its
91
- * own besides. 4 in a pool of 16 is the pair this lane measured end to end
92
- * (`blender-n-gate`, 2026-09-19: boot 12.0 s, first request 0.9 s, later calls
93
- * ~1.5 s), and a pool of 8 under `-t 4` trapped during boot on this box.
94
- */
95
- /**
96
- * FOUR, MEASURED, NOT THE MACHINE'S CORES. The Emscripten skew runs Blender at 6
97
- * on this 12-core box; measured 2026-09-20 on the same operations, WALI at 12
98
- * threads was SLOWER than at 4 everywhere (subsurf level 6: 40 ms against 17;
99
- * an ico sphere: 30 against 15; the courtyard 31.1 s against 28.6) -- the
100
- * pool's scheduling costs more than the cores return, so more `-t` is not more
101
- * speed here. The pool stays four times the count, the ratio that measured
102
- * clean (16 under 4; 8 under 4 trapped at boot), because Blender starts threads
103
- * of its own beyond the oneTBB workers `-t` buys.
104
- */
105
- /*
106
- * ONE, MEASURED 2026-09-27, as the standalone skew runs Blender. Through the
107
- * editor's own worker, on the same work as the standalone skew: at `-t 4` the
108
- * session held 870 MB after it against the standalone skew's 632, and a
109
- * boolean of two 8k-polygon spheres took 2.2 s; at `-t 1`, 629 MB and 1.3 s
110
- * (the standalone skew 3.7 s). On this runtime more `-t` has cost more than
111
- * it returned, as the courtyard's 12 against 4 already showed above. The
112
- * pool stays 16: its workers start on a thread's first use, and Blender's
113
- * libraries start threads of their own.
114
- */
115
- export const BLENDER_WALI_WORKERS = { pool: 16, blender: 1 } as const;
26
+ /** The run surface owns the thread budget: Blender’s explicit worker count and the host pool are separate. The pool also serves threads started by Blender libraries. */
27
+
28
+ /* Use one Blender worker with a 16-worker host pool; pool threads start lazily on first use. */
29
+ const BLENDER_WALI_WORKERS = { pool: 16, blender: 1 } as const;
116
30
 
117
31
  /** The substrate packages the WALI engine imports, by the URL segment each is
118
32
  * served under and the specifier prefix rewritten to it. */
119
- export const BLENDER_WALI_SUBSTRATE = [
33
+ const BLENDER_WALI_SUBSTRATE = [
120
34
  { segment: 'browser-wali', specifier: '@volter/browser-wali' },
121
35
  { segment: 'browser-runtime', specifier: '@volter/browser-runtime' },
122
36
  ] as const;
123
37
 
124
- export const BLENDER_WASM_DIR_VARIABLE = 'VOLTER_BLENDER_WASM_DIR';
38
+ const BLENDER_WASM_DIR_VARIABLE = 'VOLTER_BLENDER_WASM_DIR';
125
39
 
126
- /**
127
- * The bundle `@volter/blender-engine` ships — Blender itself, so its own GPL
128
- * package (ARCHITECTURE-CORE §Licensing, "Blender's licence stops at the
129
- * wire"). This resolution is a PATH, not an import: the editor serves those
130
- * bytes and never imports the engine. `import.meta.url` is THIS FILE's esbuild bundle
131
- * (`<pkg>/dist-node/serving.mjs`, the `volter.serving` module); from it, two
132
- * directories up and across is the sibling package -- `packages/blender-engine/wasm`
133
- * in a checkout, `node_modules/@volter/blender-engine/wasm` installed. Existence is
134
- * checked once so a realm that lacks it gets a named absence, not a path.
135
- */
136
- export const SHIPPED_BLENDER_WASM_DIR: string | null = (() => {
40
+ const SHIPPED_BLENDER_WASM_DIR: string | null = (() => {
137
41
  const candidate = fileURLToPath(new URL('../../blender-engine/wasm/', import.meta.url));
138
42
  return existsSync(candidate) ? candidate.replace(/\/$/, '') : null;
139
43
  })();
140
44
 
141
45
  /** Where the build is: the named directory, else the shipped one, else null. */
142
- export function blenderWasmDir(env: NodeJS.ProcessEnv = process.env): string | null {
143
- const named = env[BLENDER_WASM_DIR_VARIABLE];
46
+ function blenderWasmDir(): string | null {
47
+ const named = process.env[BLENDER_WASM_DIR_VARIABLE];
144
48
  if (typeof named === 'string' && named.trim() !== '') {
145
49
  const dir = named.trim();
146
50
  return isAbsolute(dir) ? dir : null;
@@ -178,7 +82,7 @@ export interface BlenderWasmStatus {
178
82
  available: boolean;
179
83
  /** Which build the served directory holds; null when nothing is served. */
180
84
  skew: BlenderSkew | null;
181
- /** The directory that was looked in, or null when the variable is unset. */
85
+ /** The explicit or shipped artifact directory, or null when neither resolves. */
182
86
  dir: string | null;
183
87
  /** On-disk byte sizes of the files, for the ones that are there
184
88
  * (the `.br` size when a file is stored pre-compressed). */
@@ -210,10 +114,8 @@ async function skewOf(dir: string): Promise<BlenderSkew | null> {
210
114
  return null;
211
115
  }
212
116
 
213
- export async function blenderWasmStatus(
214
- env: NodeJS.ProcessEnv = process.env,
215
- ): Promise<BlenderWasmStatus> {
216
- const dir = blenderWasmDir(env);
117
+ export async function blenderWasmStatus(): Promise<BlenderWasmStatus> {
118
+ const dir = blenderWasmDir();
217
119
  if (dir === null)
218
120
  return {
219
121
  available: false,
@@ -240,7 +142,7 @@ export async function blenderWasmStatus(
240
142
  `${BLENDER_WALI_ARTIFACT} (the substrate skew)`,
241
143
  ],
242
144
  };
243
- return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir, env);
145
+ return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir);
244
146
  }
245
147
 
246
148
  /**
@@ -249,13 +151,13 @@ export async function blenderWasmStatus(
249
151
  * `edit` as that process, and `edit` hands its environment to the session it spawns
250
152
  * (`launcher/launch.ts`). A session started on this machine has no such variable.
251
153
  */
252
- export const BROWSER_SUBSTRATE_VARIABLE = 'BROWSER_SUBSTRATE_URL';
154
+ const BROWSER_SUBSTRATE_VARIABLE = 'BROWSER_SUBSTRATE_URL';
253
155
 
254
- export function servedFromTab(env: NodeJS.ProcessEnv = process.env): boolean {
255
- return (env[BROWSER_SUBSTRATE_VARIABLE] ?? '').trim() !== '';
156
+ function servedFromTab(): boolean {
157
+ return (process.env[BROWSER_SUBSTRATE_VARIABLE] ?? '').trim() !== '';
256
158
  }
257
159
 
258
- async function emscriptenStatus(dir: string, env: NodeJS.ProcessEnv): Promise<BlenderWasmStatus> {
160
+ async function emscriptenStatus(dir: string): Promise<BlenderWasmStatus> {
259
161
  const sizes: Record<string, number> = {};
260
162
  const encoded: Record<string, 'br'> = {};
261
163
  const missing: string[] = [];
@@ -268,7 +170,7 @@ async function emscriptenStatus(dir: string, env: NodeJS.ProcessEnv): Promise<Bl
268
170
  sizes[file] = found.size;
269
171
  if (found.encoding) encoded[file] = found.encoding;
270
172
  }
271
- return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab(env) };
173
+ return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab() };
272
174
  }
273
175
 
274
176
  /** The decoded bytes' digests the build records, for the files it records them for. */
@@ -329,16 +231,13 @@ async function waliStatus(dir: string): Promise<BlenderWasmStatus> {
329
231
  };
330
232
  }
331
233
 
332
- /**
333
- * The glue, patched. Read once and held: it is 700 KB of text and every
334
- * pthread the runtime spawns asks for it again.
335
- */
234
+ /** Cache the patched glue for reuse by each runtime pthread. */
336
235
  let patchedGlue: { dir: string; text: string } | null = null;
337
236
 
338
237
  const CANVAS_TRANSFER =
339
238
  'var transferredCanvasNames=attr?(growMemViews(),HEAPU32)[attr+40>>2]:0;if(';
340
239
 
341
- export function patchBlenderGlue(source: string): string {
240
+ function patchBlenderGlue(source: string): string {
342
241
  // A headless build spawns no window and its glue carries no canvas transfer:
343
242
  // then there is nothing to patch and the glue is served as-is. Only a build
344
243
  // that DOES transfer a `#canvas` (the port's UI-derived release) needs the
@@ -375,26 +274,15 @@ export interface RuntimeIndex {
375
274
  * still a new file, and everything that names the tree by its index (the tab's OPFS store,
376
275
  * `runtimeTag`, the compressed blob's cache) must see it. */
377
276
  files: { path: string; size: number; mode: number; sha256: string }[];
378
- newestMtimeMs?: number;
379
277
  }
380
278
 
381
279
  let cachedRuntimeIndex: { dir: string; index: RuntimeIndex } | null = null;
382
280
 
383
- /**
384
- * Blender's runtime tree, as ONE index plus ONE blob.
385
- *
386
- * ~2,300 files and 78 MB, every one of which has to exist before Blender's
387
- * first act. The tab fetches this index and then `runtime.bin`, whose bytes
388
- * are the same files concatenated IN THIS ORDER, and slices
389
- * (`blender-wali-engine.mts`'s `stageRuntime`). A request per file would be
390
- * 2,300 round trips; a tar would be a dependency and a decoder. The order is
391
- * the index's and nothing else may assume it.
392
- */
281
+ /** Publish the runtime tree as one index and one concatenated blob. The index defines file order; stageRuntime slices each file from runtime.bin. */
393
282
  export async function runtimeIndex(dir: string): Promise<RuntimeIndex> {
394
283
  if (cachedRuntimeIndex?.dir === dir) return cachedRuntimeIndex.index;
395
284
  const root = join(dir, BLENDER_WALI_RUNTIME);
396
285
  const files: RuntimeIndex['files'] = [];
397
- let newest = 0;
398
286
  const walk = async (where: string): Promise<void> => {
399
287
  for (const entry of (await readdir(where, { withFileTypes: true })).sort((left, right) =>
400
288
  left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
@@ -412,22 +300,16 @@ export async function runtimeIndex(dir: string): Promise<RuntimeIndex> {
412
300
  mode: info.mode & 0o7777,
413
301
  sha256: createHash('sha256').update(await readFile(path)).digest('hex'),
414
302
  });
415
- newest = Math.max(newest, info.mtimeMs);
416
303
  }
417
304
  };
418
305
  await walk(root);
419
306
  if (files.length === 0) throw new Error(`${root} is empty`);
420
- const index = { files, newestMtimeMs: newest };
307
+ const index = { files };
421
308
  cachedRuntimeIndex = { dir, index };
422
309
  return index;
423
310
  }
424
311
 
425
- /**
426
- * THE RUNTIME BLOB'S VALIDATOR, so a warm boot takes it from the HTTP cache.
427
- * Without one it was read from 1,317 files and sent again on every boot: the
428
- * standalone skew's `.data` payload is one cached file, and this was most of
429
- * what the substrate skew's boot spent before Blender ran (2026-09-27).
430
- */
312
+ /** The runtime blob’s validator permits warm boots to reuse the HTTP cache. */
431
313
  export function runtimeTag(index: RuntimeIndex): string {
432
314
  const contents = createHash('sha256');
433
315
  for (const file of index.files) contents.update(`${file.path}\0${file.mode}\0${file.sha256}\n`);
@@ -440,19 +322,8 @@ export async function writeRuntimeBlob(dir: string, write: (chunk: Buffer) => vo
440
322
  for (const file of (await runtimeIndex(dir)).files) write(await readFile(join(root, file.path)));
441
323
  }
442
324
 
443
- /**
444
- * Where a substrate package's browser ESM is, or null when it is not
445
- * installed. Resolved through Node's own resolution from THIS file, so it
446
- * finds whatever the project that opted into this skew declared.
447
- *
448
- * `import.meta.resolve` and not `createRequire().resolve`: these packages
449
- * export `"."` and `"./*.js"` under the `import` condition ONLY, so a CJS
450
- * resolve raises ERR_PACKAGE_PATH_NOT_EXPORTED on a package that is plainly
451
- * installed. The package's OWN ENTRY is resolved rather than its
452
- * `package.json`, which is not exported at all; the entry's directory IS the
453
- * published `dist`, which is where every served module comes from.
454
- */
455
- export function substrateDir(specifier: string): string | null {
325
+ /** Resolve substrate browser ESM through import.meta.resolve from this file. These packages export their entry and .js modules under the import condition; the entry’s directory is their published dist. Return null when the package is not installed. */
326
+ function substrateDir(specifier: string): string | null {
456
327
  try {
457
328
  return dirname(fileURLToPath(import.meta.resolve(specifier)));
458
329
  } catch {
@@ -496,15 +367,7 @@ export async function substrateModule(
496
367
 
497
368
  const compressing = new Map<string, Promise<void>>();
498
369
 
499
- /**
500
- * A file as brotli, kept under the system's temporary directory by `key`
501
- * once made: its path, or null while it is being made (the first caller
502
- * starts it and is answered uncompressed). The substrate skew's artifacts are
503
- * past what Chrome keeps in its HTTP cache uncompressed -- `blender.wasm`
504
- * 93 MB, the runtime blob 66 -- so every boot fetched them again and compiled
505
- * the module again; the standalone skew's go out as brotli (18 MB and 7.7 MB)
506
- * and are kept (2026-09-27: brotli, 20 MB and 25 MB).
507
- */
370
+ /** Cache each Brotli artifact by content key under the system temporary directory. While compression runs, serve the original file; return the compressed path once ready. */
508
371
  async function brotliCopy(key: string, produce: () => Promise<Buffer>): Promise<string | null> {
509
372
  const path = join(tmpdir(), 'volter-blender-brotli', `${key.replace(/[^\w.-]/g, '')}.br`);
510
373
  try {
package/serving/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@volter/editor-blender`'s server half (`package.json#volter.serving`, the project-serving door in
2
+ * `@volter/editor-blender`'s server half (`package.json#contributes.volter.serving`, the project-serving door in
3
3
  * `@volter/sdk/session/project-serving`): the routes the Blender in the tab reads and
4
4
  * writes through.
5
5
  */
package/src/api.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * EDITOR-BLENDER'S API, AS TYPES: what its page half's `activate` returns
3
+ * (`../contributions/blender.page.ts`), the model Play seam's types and a landed save's. A package that reads the
4
+ * API imports these type-only; the values come from `getExtension(...).exports`.
5
+ */
6
+ export type BlenderPageApi = ReturnType<typeof import('../contributions/blender.page').activate>;
7
+ export type * from './model-play-extension';
8
+ export type { ModelPlayMode } from './play-mode';
9
+ export type { ModelDocumentSaved } from '../host/blender-runtime-host';
package/src/layouts.tsx CHANGED
@@ -1,20 +1,4 @@
1
- /**
2
- * The BLENDER layout — the composition a models project's adapter declares
3
- * (`editor/volter.adapter.ts`: `editor: { Layout: ModelLayout }`, the door
4
- * `@volter/sdk/layouts` documents). `ModelArrangement` names the Model
5
- * workspace and the chrome regions it shows; Sculpt and Texture open on it too
6
- * — their reserved panels (the brush rail, the layer stack) ship WITH their
7
- * programs, never as empty chrome, and until then nothing about a sculpt
8
- * arrangement differs from a modeling one.
9
- *
10
- * WHERE THE PANELS SIT IS THE WORKBENCH'S. A workspace declares what it SHOWS
11
- * (`regions`) and which documents stand beside the centre one (`areas`); the
12
- * sizes, the splits and the tab order are the frame's editor-group state,
13
- * which VS Code persists itself. A captured grid used to ship here beside the
14
- * contribution, measured off Blender 5.2's own MODELING workspace; the
15
- * measurement that outlived it is `contributions/model.layout.ts`'s Timeline
16
- * RATIO, which is a proportion rather than a geometry and is stated there.
17
- */
1
+ /** ModelArrangement supplies Blender's chrome regions. ModelLayout composes them with the SDK frame; the workbench persists pane sizes, splits and tab order. */
18
2
  import {
19
3
  EditorFooter,
20
4
  EditorFrame,
package/src/looks.ts CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * The Blender skew's LOOK and BINDINGS as importable values — `@volter/editor-blender/looks`,
3
- * the public surface a project's `editor/volter.adapter.ts` declares them from
4
- * (ARCHITECTURE-CORE §Adapters and contributions are code, not configs):
5
- *
6
- * editor: { Layout: ModelLayout, style: blenderStyle, keymap: blenderKeymap }
7
- *
8
- * The same two objects the package's `workspace.style` / `workspace.keymap`
9
- * contributions register (`package.json#volter.contributions`). Registration
10
- * publishes them to every project that declares this package; the adapter's
11
- * declaration is what CHOOSES them for one.
12
- */
1
+
13
2
  export { keymap as blenderKeymap } from '../contributions/blender.keymap';
14
3
  export { style as blenderStyle } from '../contributions/blender.style';
@@ -0,0 +1,25 @@
1
+ /** The engine's current save refusal, scoped to the document it protects. */
2
+ const notices = new Map<string, string>();
3
+ const listeners = new Set<() => void>();
4
+
5
+ export function setModelDocumentSaveNotice(document: string, note: string | null): void {
6
+ if ((notices.get(document) ?? null) === note) return;
7
+ if (note === null) notices.delete(document);
8
+ else notices.set(document, note);
9
+ for (const listener of [...listeners]) listener();
10
+ }
11
+
12
+ export function modelDocumentSaveNotice(document: string | undefined): string | null {
13
+ return document === undefined ? null : notices.get(document) ?? null;
14
+ }
15
+
16
+ export function subscribeModelDocumentSaving(listener: () => void): () => void {
17
+ listeners.add(listener);
18
+ return () => { listeners.delete(listener); };
19
+ }
20
+
21
+ export function clearModelDocumentSaving(): void {
22
+ if (!notices.size) return;
23
+ notices.clear();
24
+ for (const listener of [...listeners]) listener();
25
+ }