@volter/blender-engine 0.1.148 → 0.1.150
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.
- package/LICENSE +22 -5
- package/README.md +4 -11
- package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
- package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
- package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
- package/browser/addons/vrm/README.txt +3 -0
- package/browser/addons/vrm/__init__.py +41 -0
- package/browser/addons/vrm/blender_manifest.toml +16 -0
- package/browser/addons/vrm/common/__init__.py +1 -0
- package/browser/addons/vrm/common/animation.py +58 -0
- package/browser/addons/vrm/common/blender_manifest.py +82 -0
- package/browser/addons/vrm/common/char.py +16 -0
- package/browser/addons/vrm/common/convert.py +287 -0
- package/browser/addons/vrm/common/convert_any.py +48 -0
- package/browser/addons/vrm/common/debug.py +40 -0
- package/browser/addons/vrm/common/deep.py +162 -0
- package/browser/addons/vrm/common/error_dialog.py +274 -0
- package/browser/addons/vrm/common/fs.py +71 -0
- package/browser/addons/vrm/common/gl.py +20 -0
- package/browser/addons/vrm/common/gltf.py +1149 -0
- package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
- package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
- package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
- package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
- package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
- package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
- package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
- package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
- package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
- package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
- package/browser/addons/vrm/common/legacy_gltf.py +16 -0
- package/browser/addons/vrm/common/logger.py +84 -0
- package/browser/addons/vrm/common/mtoon0.blend +0 -0
- package/browser/addons/vrm/common/mtoon1.blend +0 -0
- package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
- package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
- package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
- package/browser/addons/vrm/common/ops/__init__.py +10 -0
- package/browser/addons/vrm/common/ops/export_scene.py +67 -0
- package/browser/addons/vrm/common/ops/icyp.py +53 -0
- package/browser/addons/vrm/common/ops/import_scene.py +57 -0
- package/browser/addons/vrm/common/ops/vrm.py +2380 -0
- package/browser/addons/vrm/common/ops/wm.py +167 -0
- package/browser/addons/vrm/common/preferences.py +362 -0
- package/browser/addons/vrm/common/progress.py +87 -0
- package/browser/addons/vrm/common/rotation.py +89 -0
- package/browser/addons/vrm/common/safe_removal.py +46 -0
- package/browser/addons/vrm/common/scene_watcher.py +230 -0
- package/browser/addons/vrm/common/shader.py +2243 -0
- package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
- package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
- package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
- package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
- package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
- package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
- package/browser/addons/vrm/common/version.py +161 -0
- package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
- package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
- package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
- package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
- package/browser/addons/vrm/common/workspace.py +227 -0
- package/browser/addons/vrm/common/writable_context.py +77 -0
- package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
- package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
- package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
- package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
- package/browser/addons/vrm/documentation/en-us/index.md +41 -0
- package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
- package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
- package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
- package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
- package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
- package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
- package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
- package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
- package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
- package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
- package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
- package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
- package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
- package/browser/addons/vrm/editor/__init__.py +1 -0
- package/browser/addons/vrm/editor/extension.py +425 -0
- package/browser/addons/vrm/editor/extension_accessor.py +75 -0
- package/browser/addons/vrm/editor/handler.py +84 -0
- package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
- package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
- package/browser/addons/vrm/editor/make_armature.py +709 -0
- package/browser/addons/vrm/editor/menu.py +147 -0
- package/browser/addons/vrm/editor/migration.py +297 -0
- package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
- package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
- package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
- package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
- package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
- package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
- package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
- package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
- package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
- package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
- package/browser/addons/vrm/editor/ops.py +471 -0
- package/browser/addons/vrm/editor/panel.py +315 -0
- package/browser/addons/vrm/editor/property_group.py +888 -0
- package/browser/addons/vrm/editor/search.py +682 -0
- package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
- package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
- package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
- package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
- package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
- package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
- package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
- package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
- package/browser/addons/vrm/editor/subscription.py +123 -0
- package/browser/addons/vrm/editor/t_pose.py +713 -0
- package/browser/addons/vrm/editor/validation.py +1271 -0
- package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
- package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
- package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
- package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
- package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
- package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
- package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
- package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
- package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
- package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
- package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
- package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
- package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
- package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
- package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
- package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
- package/browser/addons/vrm/exporter/__init__.py +1 -0
- package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
- package/browser/addons/vrm/exporter/export_scene.py +930 -0
- package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
- package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
- package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
- package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
- package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
- package/browser/addons/vrm/external/__init__.py +1 -0
- package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
- package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
- package/browser/addons/vrm/external/rigify_support.py +1 -0
- package/browser/addons/vrm/importer/__init__.py +1 -0
- package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
- package/browser/addons/vrm/importer/file_handler.py +34 -0
- package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
- package/browser/addons/vrm/importer/import_scene.py +646 -0
- package/browser/addons/vrm/importer/license_validation.py +198 -0
- package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
- package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
- package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
- package/browser/addons/vrm/importer/vrm_diff.py +316 -0
- package/browser/addons/vrm/locale/__init__.py +1 -0
- package/browser/addons/vrm/locale/ja_jp.py +826 -0
- package/browser/addons/vrm/locale/ruff.toml +8 -0
- package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
- package/browser/addons/vrm/locale/zh_hans.py +928 -0
- package/browser/addons/vrm/registration.py +670 -0
- package/browser/addons/vrm.json +18 -0
- package/browser/blender-emscripten-engine.mts +66 -129
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +37 -81
- package/browser/describe-thrown.mts +3 -6
- package/browser/document-chunks.mts +4 -16
- package/browser/gltf_export.py +3161 -0
- package/browser/heap-growth.mts +23 -0
- package/browser/index.ts +3 -0
- package/browser/protocol.ts +130 -43
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +271 -106
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1389 -1446
- package/browser/three/attach-presenter.ts +11 -39
- package/browser/three/blender-base64.ts +1 -36
- package/browser/three/blender-corner-normals.ts +1 -23
- package/browser/three/blender-display-shaders.json +1 -1
- package/browser/three/blender-display-shaders.lut +0 -0
- package/browser/three/blender-display-transform.ts +2 -1
- package/browser/three/blender-frame-columns.ts +5 -13
- package/browser/three/blender-graph-material.ts +18 -3
- package/browser/three/blender-node-graph.ts +10 -34
- package/browser/three/blender-physical-material.ts +2 -31
- package/browser/three/blender-runtime-armature.ts +7 -60
- package/browser/three/blender-runtime-frame.ts +9 -23
- package/browser/three/blender-runtime-geometry.ts +22 -26
- package/browser/three/blender-runtime-lighting.ts +48 -178
- package/browser/three/blender-runtime-shadows.ts +187 -1
- package/browser/three/blender-runtime-skeleton.ts +53 -1
- package/browser/three/blender-runtime-view.ts +492 -306
- package/browser/three/blender-runtime-weights.ts +9 -72
- package/browser/three/blender-sky.ts +13 -33
- package/browser/three/blender-triangulate.ts +7 -35
- package/browser/three/blender-viewport-sampling.ts +235 -0
- package/browser/three/blender-workbench-material.ts +25 -29
- package/browser/three/blender-workbench-outline.ts +199 -0
- package/browser/three/blender-world-volume.ts +62 -10
- package/browser/three/presenter.ts +10 -26
- package/browser/three/release.ts +5 -21
- package/browser/three/sky-precompute-worker.ts +1 -18
- package/browser/three/sky-worker.ts +5 -23
- package/browser/three/world-field-sampler.ts +12 -26
- package/browser/volter_three.py +9 -105
- package/browser/worker.ts +195 -180
- package/package.json +5 -2
- package/wasm/BUNDLE.json +17 -17
- package/wasm/DEPENDENCY-LICENSES.txt +14 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
- package/browser/frame-transfer.mts +0 -19
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/blender-agx.ts +0 -235
- package/browser/three/blender-display-lut.ts +0 -145
- package/browser/three/blender-filmic.ts +0 -49
- package/browser/three/blender-standard.ts +0 -68
- package/browser/three/blender-world-visibility.ts +0 -278
- package/browser/three/filmic-srgb.lut +0 -0
package/browser/worker.ts
CHANGED
|
@@ -2,47 +2,11 @@ import { PullJob, checkpointStream } from './pull-job.mts';
|
|
|
2
2
|
import { sendFrameValue } from './frame-stream.mts';
|
|
3
3
|
import { describeThrown } from './describe-thrown.mts';
|
|
4
4
|
export { describeThrown } from './describe-thrown.mts';
|
|
5
|
-
|
|
6
|
-
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
7
|
-
* 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
|
|
8
|
-
* WebAssembly, running headless in this worker (`blender-engine.mts`), with
|
|
9
|
-
* `session.py` as its one door and three.js as its renderer. The artifact is
|
|
10
|
-
* served by the editor (`/__editor/blender-wasm/*`, `blender-wasm-artifact.ts`);
|
|
11
|
-
* when it is not served, `start` refuses BY NAME. There is no other engine.
|
|
12
|
-
*
|
|
13
|
-
* NOTHING HERE KNOWS WHICH SKEW ANSWERED. Since the 2026-09-18 amendment there
|
|
14
|
-
* are two builds -- the standalone Emscripten module and Blender as a WALI
|
|
15
|
-
* program on browser-substrate -- and `blender-engine.mts` is where they meet.
|
|
16
|
-
* Above that seam this file uses `engine.files`, `engine.readArena()` and
|
|
17
|
-
* `engine.memoryBytes()` and could not tell them apart; ONE GATE, BOTH SKEWS
|
|
18
|
-
* is what that buys.
|
|
19
|
-
*
|
|
20
|
-
* Nothing here touches a server beyond the editor's own routes. A frame
|
|
21
|
-
* leaves through `present` to the Model document; a photograph comes back the
|
|
22
|
-
* same way. The project's own files are staged into the engine's filesystem
|
|
23
|
-
* before every call, and what the session writes is mirrored back out by the
|
|
24
|
-
* transport (`list-files`/`read-file`).
|
|
25
|
-
*
|
|
26
|
-
* THE DOCUMENT'S SAVE BARRIER LIVES HERE. Python's loop cannot ask the tab for anything
|
|
27
|
-
* while it is idle — `serveAsks` only runs inside a request's poll loop, so an
|
|
28
|
-
* `ask` raised between calls is never answered and wedges the Blender pthread.
|
|
29
|
-
* So the session marks a present `saveDue`, this file finishes the command,
|
|
30
|
-
* calls `save-document` as an ordinary request (which Python's loop
|
|
31
|
-
* picks up BETWEEN calls, never mid-call), and carries the bytes to the
|
|
32
|
-
* project through `/__editor/blender-document` BEFORE acknowledging the edit —
|
|
33
|
-
* as a delta: the chunks the server does not hold (`document-chunks.mts`).
|
|
34
|
-
*
|
|
35
|
-
* WHY THE CARRY IS NOT THE MIRROR'S JOB (`volter blender-mcp`, class Mirror):
|
|
36
|
-
* the Mirror is pull-based and runs only after an `execute_blender_code`, so
|
|
37
|
-
* a document saved after the LAST call of a modeling session
|
|
38
|
-
* would never leave the worker — which is exactly the state this closes
|
|
39
|
-
* ("closing the tab loses the model"). The Mirror still lists and mirrors the
|
|
40
|
-
* same file in the MCP lane; it just is not what persistence depends on.
|
|
41
|
-
*/
|
|
5
|
+
|
|
42
6
|
/// <reference types="vite/client" />
|
|
43
7
|
|
|
44
8
|
import { startupOperation, type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
45
|
-
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
9
|
+
import type { CaptureRequest, FileEntry, ScriptRunAnswer, WorkerReply, WorkerRequest } from './protocol';
|
|
46
10
|
import { documentChunks } from './document-chunks.mts';
|
|
47
11
|
import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
|
|
48
12
|
|
|
@@ -50,8 +14,6 @@ const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(rep
|
|
|
50
14
|
const log = (level: 'log' | 'warn' | 'error', text: string) => post({ op: 'log', level, text });
|
|
51
15
|
|
|
52
16
|
interface Session {
|
|
53
|
-
start(project: string): Promise<unknown>;
|
|
54
|
-
execute(code: string): Promise<unknown>;
|
|
55
17
|
sceneInfo(): Promise<unknown>;
|
|
56
18
|
objectInfo(name: string): Promise<unknown>;
|
|
57
19
|
screenshotView(maxSize: number): Promise<unknown>;
|
|
@@ -95,11 +57,13 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
|
|
|
95
57
|
|
|
96
58
|
// ---- The session's document.
|
|
97
59
|
//
|
|
98
|
-
// One
|
|
99
|
-
//
|
|
100
|
-
// no host path is ever on the wire.
|
|
60
|
+
// One `.blend` at a time, the one `open-document` last named (or a script's Save As moved
|
|
61
|
+
// to). `documentPath` is the PROJECT-RELATIVE spelling — the only one that crosses to the
|
|
62
|
+
// server, which joins it to its own root so no host path is ever on the wire.
|
|
101
63
|
let documentPath: string | null = null;
|
|
102
64
|
let documentDirty = false;
|
|
65
|
+
/** The revision the last reported change was. */
|
|
66
|
+
let dirtyRevision: number | null = null;
|
|
103
67
|
// Commands and saves share one lane. In particular a flush cannot overtake
|
|
104
68
|
// an accepted edit, and a later edit cannot race an upload of older bytes.
|
|
105
69
|
let workTail: Promise<unknown> = Promise.resolve();
|
|
@@ -180,26 +144,14 @@ function setDocumentDirty(dirty: boolean): void {
|
|
|
180
144
|
}
|
|
181
145
|
|
|
182
146
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* Failure is a rejection: explicit shutdown must retain the live model.
|
|
147
|
+
* The document saved into the engine's own filesystem, and its bytes read back out: what a save
|
|
148
|
+
* carries to the project and a backup keeps. `null` when Blender holds another file's model, so
|
|
149
|
+
* the session wrote nothing over the document (said, where the person looks).
|
|
187
150
|
*/
|
|
188
|
-
async function
|
|
189
|
-
if (!engine || documentPath === null) return;
|
|
190
|
-
const relative = documentPath;
|
|
191
|
-
// Each phase's milliseconds, reported with the save: on a large document the save is the
|
|
192
|
-
// edit's cost, and where it goes differs by host (a tab-served project runs the server here).
|
|
193
|
-
const ms: Record<string, number> = {};
|
|
194
|
-
let mark = performance.now();
|
|
195
|
-
const lap = (phase: string) => {
|
|
196
|
-
const now = performance.now();
|
|
197
|
-
ms[phase] = Math.round(now - mark);
|
|
198
|
-
mark = now;
|
|
199
|
-
};
|
|
151
|
+
async function engineDocument(relative: string, lap: (phase: string) => void): Promise<{ readonly path: string; readonly bytes: Uint8Array } | null> {
|
|
200
152
|
let answer: { saved?: boolean; path?: string; size?: number; reason?: string; holds?: string };
|
|
201
153
|
try {
|
|
202
|
-
answer = (await engine
|
|
154
|
+
answer = (await engine!.request({ op: 'save-document' })) as typeof answer;
|
|
203
155
|
lap('save');
|
|
204
156
|
} catch (error) {
|
|
205
157
|
// A document that cannot be written is the session's work at risk, so it
|
|
@@ -210,25 +162,53 @@ async function saveDocument(): Promise<void> {
|
|
|
210
162
|
// the document. Said each time an edit goes unsaved, where the person looks: the document on
|
|
211
163
|
// disk is the last one saved, and what they see now is not being kept.
|
|
212
164
|
if (answer?.saved === false && answer.reason === 'not-the-document') {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
165
|
+
const note = `${relative} is not being saved: Blender holds ${answer.holds ?? 'another file'}, ` +
|
|
166
|
+
`not its model. Save As a .blend under src/models to keep this as a document of its own, or reopen ${relative} to return to it.`;
|
|
167
|
+
post({ op: 'document-saving', document: relative, note });
|
|
168
|
+
log('warn', `@@VOLTER-WARN ${note}`);
|
|
169
|
+
return null;
|
|
217
170
|
}
|
|
218
171
|
if (!answer?.saved || typeof answer.path !== 'string')
|
|
219
172
|
throw new Error(`Blender did not save the document ${relative}`);
|
|
220
|
-
let bytes: Uint8Array;
|
|
221
173
|
try {
|
|
222
|
-
bytes = await engine
|
|
174
|
+
const bytes = await engine!.files.readFile(answer.path);
|
|
223
175
|
lap('read');
|
|
176
|
+
return { path: answer.path, bytes };
|
|
224
177
|
} catch (error) {
|
|
225
178
|
throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
|
|
226
179
|
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Save the document and land it in the project: true once the project holds it, false when there was nothing to
|
|
184
|
+
* save to (no engine or document, or Blender holds another file's model).
|
|
185
|
+
*
|
|
186
|
+
* Only called inside the command lane, after previous calls have finished.
|
|
187
|
+
* Failure is a rejection: explicit shutdown must retain the live model.
|
|
188
|
+
*/
|
|
189
|
+
async function saveDocument(): Promise<boolean> {
|
|
190
|
+
if (!engine || documentPath === null) return false;
|
|
191
|
+
const relative = documentPath;
|
|
192
|
+
// Each phase's milliseconds, reported with the save: on a large document the save is the
|
|
193
|
+
// edit's cost, and where it goes differs by host (a tab-served project runs the server here).
|
|
194
|
+
const ms: Record<string, number> = {};
|
|
195
|
+
let mark = performance.now();
|
|
196
|
+
const lap = (phase: string) => {
|
|
197
|
+
const now = performance.now();
|
|
198
|
+
ms[phase] = Math.round(now - mark);
|
|
199
|
+
mark = now;
|
|
200
|
+
};
|
|
201
|
+
const saved = await engineDocument(relative, lap);
|
|
202
|
+
if (saved === null) {
|
|
203
|
+
setDocumentDirty(false);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const { bytes } = saved;
|
|
227
207
|
// The engine's copy is now the newer one, so the stager must stop treating
|
|
228
208
|
// this path as the host's: an entry left in `staged` would make the next
|
|
229
209
|
// call re-fetch the whole document over the session's own save, and would
|
|
230
|
-
// hide it from `list-files` (which lists only what the SESSION owns).
|
|
231
|
-
staged.delete(
|
|
210
|
+
// hide it from `list-files-snapshot` (which lists only what the SESSION owns).
|
|
211
|
+
staged.delete(saved.path);
|
|
232
212
|
let sent = 0;
|
|
233
213
|
try {
|
|
234
214
|
const chunks = await documentChunks(bytes);
|
|
@@ -270,10 +250,70 @@ async function saveDocument(): Promise<void> {
|
|
|
270
250
|
throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
|
|
271
251
|
}
|
|
272
252
|
setDocumentDirty(false);
|
|
253
|
+
post({ op: 'document-saving', document: relative, note: null });
|
|
273
254
|
log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent, ms })}`);
|
|
255
|
+
return true;
|
|
274
256
|
}
|
|
275
257
|
|
|
276
|
-
|
|
258
|
+
/**
|
|
259
|
+
* OPEN `document` IN THE RUNNING BLENDER from the project's file, or as `backup` (its unsaved state at a
|
|
260
|
+
* restart): another `.blend`, or the one it holds again (a revert, a restore). The project's copy is staged
|
|
261
|
+
* in fresh, so the stager keeps its host stamp and does not fetch the file again once Blender has read and
|
|
262
|
+
* removed it (`session.py::bind_document`); a backup is then written over it. Blender presents what it opened.
|
|
263
|
+
*
|
|
264
|
+
* A failure before Blender's own open (the path, the file's staging, a backup with no project file) leaves
|
|
265
|
+
* Blender as it was, holding the document it held, and answers `notOpened` with why; one inside Blender's
|
|
266
|
+
* open throws.
|
|
267
|
+
*/
|
|
268
|
+
async function openDocument(document: string, backup?: Uint8Array): Promise<unknown> {
|
|
269
|
+
if (!engine || !projectRoot) throw new Error('The Blender session has not started');
|
|
270
|
+
try {
|
|
271
|
+
await stageDocument(engine, projectRoot, document, backup);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { notOpened: describeThrown(error) };
|
|
274
|
+
}
|
|
275
|
+
documentPath = document;
|
|
276
|
+
const opened = await engine.request({ op: 'open-document', document });
|
|
277
|
+
setDocumentDirty(backup !== undefined);
|
|
278
|
+
return opened;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `document`'s file staged into the engine for its open, from the project or as `backup`. */
|
|
282
|
+
async function stageDocument(engine: BlenderEngine, projectRoot: string, document: string, backup?: Uint8Array): Promise<void> {
|
|
283
|
+
// The document is the project's own file and is named the project's own way. An absolute path here
|
|
284
|
+
// would be a host path on the wire and a destination the page chose, which this transport never carries.
|
|
285
|
+
if (!isDocumentPath(document))
|
|
286
|
+
throw new Error(
|
|
287
|
+
`The Blender document must be a project-relative .blend path with no traversal ` +
|
|
288
|
+
`(models/model.blend); got ${JSON.stringify(document)}`,
|
|
289
|
+
);
|
|
290
|
+
// The file given up and the one opened leave the engine and are staged again from the project: the
|
|
291
|
+
// one given up was saved there, and its copy here is the session's no longer (a script reads it, and a
|
|
292
|
+
// listing of the session's own files leaves it out, as it does every project file staged in).
|
|
293
|
+
for (const path of new Set([documentPath, document].flatMap((held) => held === null ? [] : [`${projectRoot}/${held}`]))) {
|
|
294
|
+
staged.delete(path);
|
|
295
|
+
try {
|
|
296
|
+
await engine.files.unlink(path);
|
|
297
|
+
} catch {
|
|
298
|
+
/* not in the engine: never staged, or the open already removed it */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const path = `${projectRoot}/${document}`;
|
|
302
|
+
// The document itself not reaching the engine is a refusal, not a footnote: the open would
|
|
303
|
+
// fail on a missing file (or open a stale copy) without saying why.
|
|
304
|
+
const failed = await stageProjectFiles(engine.files, projectRoot);
|
|
305
|
+
const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
|
|
306
|
+
if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
|
|
307
|
+
await loadCheckpoint?.('project-imported');
|
|
308
|
+
if (backup) {
|
|
309
|
+
const host = staged.get(path)?.host;
|
|
310
|
+
if (host === undefined) throw new Error(`${document} could not be read from the project`);
|
|
311
|
+
await engine.files.writeFile(path, backup);
|
|
312
|
+
staged.set(path, { host, engine: await stampOf(engine.files, path) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function startBlender(project: string): Promise<unknown> {
|
|
277
317
|
// The engine is named through a holder rather than the module-level
|
|
278
318
|
// `engine`, because `ask` is handed to the engine before the engine exists.
|
|
279
319
|
const holder: { engine: BlenderEngine | null } = { engine: null };
|
|
@@ -283,8 +323,15 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
283
323
|
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
|
|
284
324
|
if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
|
|
285
325
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
286
|
-
//
|
|
287
|
-
|
|
326
|
+
// A CHANGE IS A NEW REVISION: every present of an unsaved document carries `saveDue`, and
|
|
327
|
+
// reporting each one would tell the workbench's backup the content changed when it did not.
|
|
328
|
+
if (saveDue && documentPath !== null) {
|
|
329
|
+
const revision = (frame as { revision?: unknown } | null)?.revision;
|
|
330
|
+
if (!documentDirty || (typeof revision === 'number' && revision !== dirtyRevision)) {
|
|
331
|
+
if (typeof revision === 'number') dirtyRevision = revision;
|
|
332
|
+
setDocumentDirty(true);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
288
335
|
// THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
|
|
289
336
|
// typed arrays the tab draws from, and the record of what was sent
|
|
290
337
|
// (`describeFrame`). After the post the buffers are detached and the
|
|
@@ -357,37 +404,9 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
357
404
|
holder.engine = started;
|
|
358
405
|
await loadCheckpoint?.("engine-ready");
|
|
359
406
|
engine = started;
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
// not in the engine's filesystem until it is staged. Every other call stages
|
|
363
|
-
// on its way in (`execute`); start had nothing to open before it did.
|
|
364
|
-
if (document) {
|
|
365
|
-
// The document itself not reaching the engine is a refusal, not a footnote: the open below
|
|
366
|
-
// would fail on a missing file (or open a stale copy) without saying why.
|
|
367
|
-
const failed = await stageProjectFiles(started.files, project);
|
|
368
|
-
const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
|
|
369
|
-
if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
|
|
370
|
-
}
|
|
371
|
-
await loadCheckpoint?.("project-imported");
|
|
372
|
-
const banner = (await started.request({
|
|
373
|
-
op: 'start',
|
|
374
|
-
project,
|
|
375
|
-
...(document ? { document } : {}),
|
|
376
|
-
})) as Record<string, unknown>;
|
|
377
|
-
if (document) documentPath = document;
|
|
407
|
+
// The project's files are staged by each call that reads them: a document's open, a script's run.
|
|
408
|
+
const banner = (await started.request({ op: 'start', project })) as Record<string, unknown>;
|
|
378
409
|
session = {
|
|
379
|
-
start: async () => banner,
|
|
380
|
-
execute: async (code: string) => {
|
|
381
|
-
const answer = (await started.request({ op: 'execute', code })) as {
|
|
382
|
-
executed: boolean;
|
|
383
|
-
result: string;
|
|
384
|
-
error?: string;
|
|
385
|
-
};
|
|
386
|
-
// The MCP door's own shape: a script's failure is TEXT, not a rejection.
|
|
387
|
-
return answer.error
|
|
388
|
-
? `Error executing code: ${answer.error}`
|
|
389
|
-
: `Code executed successfully: ${answer.result}`;
|
|
390
|
-
},
|
|
391
410
|
sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
|
|
392
411
|
objectInfo: async (name: string) =>
|
|
393
412
|
JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
|
|
@@ -399,12 +418,10 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
399
418
|
return {
|
|
400
419
|
...banner,
|
|
401
420
|
engine: 'blender-wasm',
|
|
402
|
-
//
|
|
403
|
-
// which one it measured cannot call a difference a defect in either.
|
|
421
|
+
// Report the engine build that answered.
|
|
404
422
|
skew: started.skew,
|
|
405
423
|
bootMs: Math.round(started.bootMs),
|
|
406
|
-
//
|
|
407
|
-
// has no release door and is carrying the payload twice.
|
|
424
|
+
// Released packed-payload size; WALI has no packed payload and reports null.
|
|
408
425
|
releasedPayloadMB:
|
|
409
426
|
started.releasedPayloadBytes === null
|
|
410
427
|
? null
|
|
@@ -429,19 +446,11 @@ export function projectMount(project: string): string {
|
|
|
429
446
|
return /^[A-Za-z]:[\\/]/.test(project) ? `/${project.replaceAll('\\', '/')}` : project;
|
|
430
447
|
}
|
|
431
448
|
|
|
432
|
-
async function start(hostProject: string
|
|
449
|
+
async function start(hostProject: string): Promise<unknown> {
|
|
433
450
|
if (session) throw new Error('The Blender session is already started');
|
|
434
451
|
const project = projectMount(hostProject);
|
|
435
452
|
if (!project.startsWith('/'))
|
|
436
453
|
throw new Error("The Blender session needs the project's absolute path");
|
|
437
|
-
// The document is the project's own file and is named the project's own way.
|
|
438
|
-
// An absolute path here would be a host path on the wire and a destination
|
|
439
|
-
// the page chose, which is the one thing this transport does not carry.
|
|
440
|
-
if (document !== undefined && !isDocumentPath(document))
|
|
441
|
-
throw new Error(
|
|
442
|
-
`The Blender document must be a project-relative .blend path with no traversal ` +
|
|
443
|
-
`(models/model.blend); got ${JSON.stringify(document)}`,
|
|
444
|
-
);
|
|
445
454
|
const served = await blenderIsServed();
|
|
446
455
|
if (!served.available)
|
|
447
456
|
throw new Error(
|
|
@@ -450,7 +459,7 @@ async function start(hostProject: string, document?: string): Promise<unknown> {
|
|
|
450
459
|
'(packages/blender-engine/wasm, or the directory VOLTER_BLENDER_WASM_DIR names); nothing stands in for it.',
|
|
451
460
|
);
|
|
452
461
|
projectRoot = project;
|
|
453
|
-
return startBlender(project
|
|
462
|
+
return startBlender(project);
|
|
454
463
|
}
|
|
455
464
|
|
|
456
465
|
/** A project-relative `.blend`, with no traversal and no absolute root. The
|
|
@@ -505,26 +514,7 @@ async function projectIndex(project: string): Promise<ProjectFile[] | string> {
|
|
|
505
514
|
return files;
|
|
506
515
|
}
|
|
507
516
|
|
|
508
|
-
/**
|
|
509
|
-
* The project files this session has staged IN, and the two stamps that say
|
|
510
|
-
* so: `host`, what the host's index reported (so a re-stage can skip a file
|
|
511
|
-
* that has not moved on disk), and `engine`, what the file looked like in the
|
|
512
|
-
* engine's own filesystem the instant after it was written there.
|
|
513
|
-
*
|
|
514
|
-
* THE SECOND ONE IS WHY THIS IS A PAIR. Ownership is not a property of the
|
|
515
|
-
* PATH, it is a property of the BYTES: a staged file Python never touched is
|
|
516
|
-
* the host's, and the same path after `export_scene.gltf` has written over it
|
|
517
|
-
* is the session's output and has to reach the project. Keyed on the path
|
|
518
|
-
* alone, a re-bake of an artifact that already exists — which is what every
|
|
519
|
-
* bake after the first one is — was listed by nobody and silently went
|
|
520
|
-
* nowhere, while a first bake of a NEW path worked, so nothing about the door
|
|
521
|
-
* looked broken (measured 2026-09-19 re-baking cinematic-story's sky city).
|
|
522
|
-
*
|
|
523
|
-
* `engine` is `size:mtimeMs`, which is what both skews' `stat` answers. A
|
|
524
|
-
* rewrite that lands in the same millisecond at exactly the same length would
|
|
525
|
-
* read as untouched; a bake is seconds long and this is the strongest signal
|
|
526
|
-
* the filesystem offers.
|
|
527
|
-
*/
|
|
517
|
+
/** For each staged project file, retain the host stamp and its size:mtimeMs stamp immediately after writing into the engine. A changed engine stamp identifies output that must return to the project even when its path was staged before. */
|
|
528
518
|
const staged = new Map<string, { host: string; engine: string }>();
|
|
529
519
|
|
|
530
520
|
async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
@@ -532,16 +522,7 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
|
532
522
|
return info ? `${info.size}:${info.mtimeMs}` : '';
|
|
533
523
|
}
|
|
534
524
|
|
|
535
|
-
/**
|
|
536
|
-
* Copy the project's changed files into the engine, and answer what could NOT be copied.
|
|
537
|
-
*
|
|
538
|
-
* THE ANSWER IS THE POINT (2026-10-06 audit: files that failed to copy were "reported to the
|
|
539
|
-
* console only"). `say` writes the page console once per line, which nobody running a script
|
|
540
|
-
* reads; a script that then opens or loads the missing file fails, or worse succeeds on a
|
|
541
|
-
* stale copy, with no word of why. So every call returns its failures, and the callers put
|
|
542
|
-
* them where the caller of THAT is looking: `execute` appends them to the script's answer, and
|
|
543
|
-
* `start` refuses when the document itself is among them.
|
|
544
|
-
*/
|
|
525
|
+
/** Stage changed project files and return copy failures. execute appends them to its answer; start refuses when its document failed to stage. */
|
|
545
526
|
async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
|
|
546
527
|
const files = await projectIndex(project);
|
|
547
528
|
if (typeof files === 'string') return [files];
|
|
@@ -590,7 +571,8 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
590
571
|
/** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
|
|
591
572
|
type FollowedDocument =
|
|
592
573
|
| { moved: true; from: string; document: string }
|
|
593
|
-
| { moved: false; from: string; outside: string }
|
|
574
|
+
| { moved: false; from: string; outside: string }
|
|
575
|
+
| { moved: false; from: string; notSaving: true; note: string };
|
|
594
576
|
|
|
595
577
|
/**
|
|
596
578
|
* THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
|
|
@@ -600,9 +582,16 @@ type FollowedDocument =
|
|
|
600
582
|
* whose runtime handle holds the same path. A file outside the project is refused: the editor
|
|
601
583
|
* has no document there, so the session keeps its own and keeps saving to it, and says so
|
|
602
584
|
* naming both files, since the script believes it is now working in the other one.
|
|
585
|
+
* A notSaving answer is a different state: the engine protects the bound file and writes nothing to it.
|
|
603
586
|
*/
|
|
604
587
|
function followDocument(followed: FollowedDocument | undefined): string {
|
|
605
588
|
if (!followed) return '';
|
|
589
|
+
if ('notSaving' in followed) {
|
|
590
|
+
post({ op: 'document-saving', document: followed.from,
|
|
591
|
+
note: `${followed.from} is not being saved because Blender holds another scene. Save this scene as a new .blend inside the project to keep it, or reopen the saved document to return to it.` });
|
|
592
|
+
log('warn', followed.note);
|
|
593
|
+
return `\n\n${followed.note}`;
|
|
594
|
+
}
|
|
606
595
|
if (!followed.moved) {
|
|
607
596
|
const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
|
|
608
597
|
`follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
|
|
@@ -614,6 +603,7 @@ function followDocument(followed: FollowedDocument | undefined): string {
|
|
|
614
603
|
// The session checks the same shape, so this is a broken promise, not a refusal to explain.
|
|
615
604
|
throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
|
|
616
605
|
}
|
|
606
|
+
post({ op: 'document-saving', document: followed.from, note: null });
|
|
617
607
|
documentPath = followed.document;
|
|
618
608
|
// Even a present that failed must not leave the new file only in the engine's filesystem.
|
|
619
609
|
setDocumentDirty(true);
|
|
@@ -639,9 +629,9 @@ async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<
|
|
|
639
629
|
const root = projectMount(hostRoot);
|
|
640
630
|
const out: FileEntry[] = [];
|
|
641
631
|
// A root the engine has no directory at holds no output yet, and that is an honest empty
|
|
642
|
-
// listing. A directory that exists and cannot be read is NOT:
|
|
643
|
-
//
|
|
644
|
-
//
|
|
632
|
+
// listing. A directory that exists and cannot be read is NOT: answering "no files" would let a
|
|
633
|
+
// root the engine never had (a Windows host's `C:\…` instead of `/C:/…`) report success while
|
|
634
|
+
// nothing reached disk.
|
|
645
635
|
if ((await files.stat(root)) === null) return out;
|
|
646
636
|
const walk = async (dir: string): Promise<void> => {
|
|
647
637
|
let names: string[];
|
|
@@ -683,7 +673,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
683
673
|
switch (request.op) {
|
|
684
674
|
case 'start': {
|
|
685
675
|
if (loadJob) throw new Error('The Blender load has already started');
|
|
686
|
-
return pullWork('start', () => start(request.project
|
|
676
|
+
return pullWork('start', () => start(request.project));
|
|
687
677
|
}
|
|
688
678
|
case 'load-next':
|
|
689
679
|
if (!loadJob) throw new Error('No Blender load to continue');
|
|
@@ -711,9 +701,20 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
711
701
|
case 'flush-document':
|
|
712
702
|
// A flush lands what is pending; a document nothing changed stays the
|
|
713
703
|
// file it was opened from.
|
|
704
|
+
// (it names what it saved: the tab's handle may already name the next document)
|
|
714
705
|
if (!documentDirty) return { saved: false };
|
|
715
|
-
|
|
716
|
-
|
|
706
|
+
{
|
|
707
|
+
const document = documentPath;
|
|
708
|
+
return { saved: await saveDocument(), ...(document === null ? {} : { document }) };
|
|
709
|
+
}
|
|
710
|
+
case 'snapshot-document': {
|
|
711
|
+
if (documentPath === null) throw new Error('The Blender session holds no document');
|
|
712
|
+
const snapshot = await engineDocument(documentPath, () => {});
|
|
713
|
+
if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
|
|
714
|
+
return snapshot.bytes;
|
|
715
|
+
}
|
|
716
|
+
case 'open-document':
|
|
717
|
+
return pullWork('open-document', () => openDocument(request.document, request.bytes));
|
|
717
718
|
case 'history-begin':
|
|
718
719
|
case 'history-end':
|
|
719
720
|
return ask({ op: request.op });
|
|
@@ -756,12 +757,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
756
757
|
...(request.names === undefined ? {} : { names: request.names }),
|
|
757
758
|
});
|
|
758
759
|
case 'rna-context':
|
|
759
|
-
//
|
|
760
|
-
// REBUILDS the request rather than forwarding it, so a field added at
|
|
761
|
-
// `runtime.ts` and read in `session.py` still arrives as `None` and the
|
|
762
|
-
// door answers about something else entirely. Measured 2026-09-19 (I4
|
|
763
|
-
// follow-up (a)): `collection` crossed both ends and never the middle,
|
|
764
|
-
// and the Collection tab simply did not stand.
|
|
760
|
+
// Forward each request field explicitly to the Python session.
|
|
765
761
|
return ask({
|
|
766
762
|
op: 'rna-context',
|
|
767
763
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
@@ -801,10 +797,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
801
797
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
802
798
|
...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
|
|
803
799
|
});
|
|
804
|
-
//
|
|
805
|
-
// reason is a measured defect: I4's Collection tab simply did not stand
|
|
806
|
-
// because `collection` crossed `runtime.ts` and `session.py` and never
|
|
807
|
-
// this switch, with no error anywhere.
|
|
800
|
+
// Forward the clip request fields to the Python session.
|
|
808
801
|
case 'action-clip':
|
|
809
802
|
return ask({
|
|
810
803
|
op: 'action-clip',
|
|
@@ -813,6 +806,30 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
813
806
|
...(request.action === undefined ? {} : { action: request.action }),
|
|
814
807
|
...(request.summary === undefined ? {} : { summary: request.summary }),
|
|
815
808
|
});
|
|
809
|
+
case 'scene-movie':
|
|
810
|
+
return ask({ op: 'scene-movie' });
|
|
811
|
+
case 'sound-scene':
|
|
812
|
+
return ask({ op: 'sound-scene', first: request.first, last: request.last });
|
|
813
|
+
case 'gltf-export':
|
|
814
|
+
return ask({ op: 'gltf-export', kind: request.kind, force: request.force === true,
|
|
815
|
+
...(request.previous ? { previous: request.previous } : {}),
|
|
816
|
+
...(request.blendSha256 ? { blendSha256: request.blendSha256 } : {}), ...(request.luts ? { luts: request.luts } : {}) });
|
|
817
|
+
// A script reads the project's files as `execute`'s does, so they are copied in first; what could not be is
|
|
818
|
+
// answered beside its run.
|
|
819
|
+
// (the files a run wrote are read here and taken away, so its answer carries them and no later run can touch them)
|
|
820
|
+
case 'run-script': {
|
|
821
|
+
const unstaged = await stageProjectFiles(files, projectRoot);
|
|
822
|
+
const ran = await ask({ op: 'run-script', source: request.source, args: request.args, dir: request.dir }) as
|
|
823
|
+
{ output: string; error: string | null; files: string[] };
|
|
824
|
+
const written: { path: string; bytes: Uint8Array }[] = [];
|
|
825
|
+
for (const path of ran.files) {
|
|
826
|
+
written.push({ path, bytes: (await files.readFile(`${request.dir}/${path}`)).slice() });
|
|
827
|
+
await files.unlink(`${request.dir}/${path}`);
|
|
828
|
+
}
|
|
829
|
+
return { output: ran.output, error: ran.error, files: written, unstaged } satisfies ScriptRunAnswer;
|
|
830
|
+
}
|
|
831
|
+
case 'asset-library':
|
|
832
|
+
return ask({ op: 'asset-library', library: request.library });
|
|
816
833
|
case 'outliner-set':
|
|
817
834
|
return ask({
|
|
818
835
|
op: 'outliner-set',
|
|
@@ -831,33 +848,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
831
848
|
staged.delete(request.path);
|
|
832
849
|
return undefined;
|
|
833
850
|
}
|
|
834
|
-
case 'list-files':
|
|
835
|
-
return listSessionFiles(files, request.path);
|
|
836
851
|
}
|
|
837
852
|
throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
|
|
838
853
|
}
|
|
839
854
|
|
|
840
|
-
/**
|
|
841
|
-
* Tell the tab how big the engine's memory is now.
|
|
842
|
-
*
|
|
843
|
-
* O(1) on the standalone skew — a read of the `HEAPU8` view's length — and
|
|
844
|
-
* posted after every call because that is when it can have changed. It is the
|
|
845
|
-
* engine's memory as opposed to the page's: the census already carries
|
|
846
|
-
* `heapUsedMB` (the PAGE's JS heap) and had no field at all for the wasm,
|
|
847
|
-
* which is the larger half. Before the session exists there is nothing to read
|
|
848
|
-
* and nothing is posted, and the substrate skew answers null — its module's
|
|
849
|
-
* memory lives in a worker with no memory door, and a zero there would be a
|
|
850
|
-
* measurement nobody took.
|
|
851
|
-
*/
|
|
855
|
+
/** Post the current engine memory size after calls. Before initialization there is no reading; WALI reports null because its memory lives in a separate worker. */
|
|
852
856
|
function reportMemory(): void {
|
|
853
857
|
if (!engine) return;
|
|
854
858
|
const bytes = engine.memoryBytes();
|
|
855
859
|
if (bytes !== null) post({ op: 'memory', bytes });
|
|
856
860
|
}
|
|
857
861
|
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
// the engine ran out of memory (`runtime.ts`) instead of "Uncaught [object Object]".
|
|
862
|
+
// Report heap size before the page handles a worker error so its diagnostic
|
|
863
|
+
// includes the engine's memory state.
|
|
861
864
|
self.addEventListener('error', reportMemory);
|
|
862
865
|
|
|
863
866
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
@@ -869,10 +872,22 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
|
869
872
|
void enqueue(() => answerRequest(request));
|
|
870
873
|
};
|
|
871
874
|
|
|
875
|
+
/** How often the engine's memory is reported while a call runs (`reportMemory`). */
|
|
876
|
+
const MEMORY_REPORT_MS = 5_000;
|
|
877
|
+
|
|
872
878
|
async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
879
|
+
// Sample heap size while a long call runs; this worker remains responsive while Blender executes on its pthread.
|
|
880
|
+
const weighing = setInterval(reportMemory, MEMORY_REPORT_MS);
|
|
881
|
+
try {
|
|
882
|
+
await answerRequestNow(request);
|
|
883
|
+
} finally {
|
|
884
|
+
clearInterval(weighing);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function answerRequestNow(request: WorkerRequest): Promise<void> {
|
|
873
889
|
try {
|
|
874
890
|
const result = await handle(request);
|
|
875
|
-
if (!loadCheckpoint && documentDirty) await saveDocument();
|
|
876
891
|
await reportHistory();
|
|
877
892
|
post({ id: request.id, result });
|
|
878
893
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/blender-engine",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.150",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"three": "^0.
|
|
33
|
+
"three": "^0.186.1",
|
|
34
34
|
"zod": "^3.0.0 || ^4.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"meshoptimizer": "1.2.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"esbuild": "0.25.12"
|
|
50
53
|
}
|
|
51
54
|
}
|