@volter/blender-engine 0.1.148 → 0.1.149
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 +16 -0
- 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 +60 -128
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +35 -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 +2 -0
- package/browser/protocol.ts +119 -35
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +197 -82
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1338 -1427
- 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 +505 -283
- 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 +156 -151
- 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/volter_three.py
CHANGED
|
@@ -1,47 +1,4 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
THE RENDER OVERRIDE IS NOT THE EDITOR'S. `session.py` is the editor tab's
|
|
4
|
-
long-lived session and carries this same override for the session it owns;
|
|
5
|
-
this file is the other delivery of it -- a Blender STARTUP module that ships in
|
|
6
|
-
the pack's resource tree (`scripts/startup/volter_three.py`), so a bare
|
|
7
|
-
`blender -b ... -a` on ANY surface renders with three.js too. Blender imports
|
|
8
|
-
every module in `scripts/startup` and calls its `register()`.
|
|
9
|
-
|
|
10
|
-
THE ASK SHIPS WITH THE BLENDER; THE ANSWER DOES NOT. What renders is a
|
|
11
|
-
PRESENTER the host attached to this process (`browser/three/attach-presenter.ts`
|
|
12
|
-
around `browser/three/presenter.ts`): a canvas, the frame translated to
|
|
13
|
-
three.js, a camera, the display transform. This file only asks. The channel is
|
|
14
|
-
the directory protocol `session.py`'s header states, with one writer per
|
|
15
|
-
directory:
|
|
16
|
-
|
|
17
|
-
<root>/ask/ THIS PROGRAM writes and unlinks; the host only reads.
|
|
18
|
-
<root>/reply/ the HOST writes and unlinks; this program only reads.
|
|
19
|
-
<root>/arena.bin THIS PROGRAM writes (the export door does); host reads.
|
|
20
|
-
|
|
21
|
-
NO PRESENTER, NO OVERRIDE. `register()` asks `{"op": "hello"}` and waits two
|
|
22
|
-
seconds. Answered, it takes the engine ids and every render is a photograph.
|
|
23
|
-
Unanswered, it prints ONE line naming the root it waited at and registers
|
|
24
|
-
nothing at all, so Blender renders with Cycles -- correct, slow, and never
|
|
25
|
-
silent.
|
|
26
|
-
|
|
27
|
-
THE ENGINE IS TAKEN ON `load_post`, NOT IN `register()`, and that is a
|
|
28
|
-
measurement. Blender's `bpy.utils.load_scripts` registers every startup module
|
|
29
|
-
BEFORE it enables the add-ons, so at `register()` time `cycles.CyclesRender`
|
|
30
|
-
does not exist yet and taking the `CYCLES` id here would be overwritten by the
|
|
31
|
-
add-on moments later. Measured on the native 5.2.0 LTS oracle
|
|
32
|
-
(`blender -b --python-expr ...` with this module's probe in
|
|
33
|
-
`BLENDER_USER_SCRIPTS`): at `register()` `CYCLES` is unregistered, and the
|
|
34
|
-
FIRST `load_post` -- which Blender fires for the startup file itself, before
|
|
35
|
-
any `--python-expr` runs -- sees `<class 'cycles.CyclesRender'>`. So the
|
|
36
|
-
handler is where the ids are taken, and it runs again after every file load
|
|
37
|
-
because a load re-enables the add-ons and hands `CYCLES` back.
|
|
38
|
-
|
|
39
|
-
WHAT THIS FILE DOES NOT CARRY, deliberately: the editor session's incremental
|
|
40
|
-
geometry record (every frame ships in full -- `known` is always empty, so no
|
|
41
|
-
presenter can ever be asked about geometry it does not hold), its document,
|
|
42
|
-
its overlays, and its world SHADER reducer. A world here is a CONSTANT
|
|
43
|
-
background or nothing, named as a warning; see `_world`.
|
|
44
|
-
"""
|
|
1
|
+
"""Standalone Blender startup render override. Bare background Blender processes use the presenter’s ask/reply directory protocol, while session.py owns the editor session’s equivalent engine. The package carries protocol and data conversion without importing editor state."""
|
|
45
2
|
|
|
46
3
|
import base64
|
|
47
4
|
import json
|
|
@@ -65,19 +22,7 @@ REPLY = os.path.join(ROOT, "reply")
|
|
|
65
22
|
EXPORT_BUFFER_PATH = os.environ.get(
|
|
66
23
|
"VOLTER_EXPORT_BUFFER_PATH", os.path.join(ROOT, "arena.bin")
|
|
67
24
|
)
|
|
68
|
-
#
|
|
69
|
-
# refuses a revision that went backwards under a name it already holds. One
|
|
70
|
-
# process is one session; the revision only ever counts up within it.
|
|
71
|
-
#
|
|
72
|
-
# THE PID IS NOT THE NAME, and that is measured rather than cautious. A
|
|
73
|
-
# presenter outlives the Blenders it serves, so two processes' names must
|
|
74
|
-
# differ -- and in the browser the guest's pids START OVER: the second
|
|
75
|
-
# `blender -b ... -a` on one page came up as the same pid, its revision
|
|
76
|
-
# restarted at 1 under a name the view already held at 24, and every render in
|
|
77
|
-
# it died on "The runtime frame is older than the displayed model" (measured
|
|
78
|
-
# 2026-09-20 in the substrate tab; the first run on a freshly loaded page
|
|
79
|
-
# always worked, which is what hid it). The start time is what makes the name
|
|
80
|
-
# this process's own.
|
|
25
|
+
# Identify frames by process session and monotonic revision. Include process start time because guest pids can repeat while a presenter remains alive.
|
|
81
26
|
SESSION = "volter-three:%d-%d" % (os.getpid(), time.time_ns())
|
|
82
27
|
HELLO_TIMEOUT_SECONDS = 2.0
|
|
83
28
|
POLL_SECONDS = 0.002
|
|
@@ -87,25 +32,7 @@ _REVISION = [0]
|
|
|
87
32
|
_WARNED = set()
|
|
88
33
|
_WARNINGS = []
|
|
89
34
|
def _say(line):
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
WHERE THIS LINE COMES OUT, and it is not where a reader looks first.
|
|
93
|
-
MEASURED 2026-09-20 in the substrate tab with the whole command redirected
|
|
94
|
-
(`blender ... > log 2>&1`): Python's `sys.stderr` in this build is NOT the
|
|
95
|
-
process's fd 2. `print(..., file=sys.stderr)` and
|
|
96
|
-
`sys.stderr.buffer.write(...)` BOTH reached the terminal and NEITHER
|
|
97
|
-
reached the file, while `print()` to stdout reached the file and not the
|
|
98
|
-
terminal. So the one line this module ever prints -- the one that says
|
|
99
|
-
Cycles is about to render -- is read on the TERMINAL; a reader who greps
|
|
100
|
-
only a redirect will conclude the fallback is silent when it is not. That
|
|
101
|
-
reading cost an hour and a wrong diagnosis; it is written here so it costs
|
|
102
|
-
nobody else one.
|
|
103
|
-
|
|
104
|
-
The stream is taken at call time rather than captured at import because a
|
|
105
|
-
startup module is imported while Blender is still standing its streams up
|
|
106
|
-
and nothing here needs the captured one. Both spellings were measured to
|
|
107
|
-
work; this is the plainer.
|
|
108
|
-
"""
|
|
35
|
+
"""Write to Blender’s current stderr at call time, after the host has attached its streams."""
|
|
109
36
|
print(line, file=sys.stderr or sys.stdout, flush=True)
|
|
110
37
|
|
|
111
38
|
|
|
@@ -250,8 +177,6 @@ def _export(depsgraph):
|
|
|
250
177
|
|
|
251
178
|
|
|
252
179
|
def _present(depsgraph, capture):
|
|
253
|
-
if not hasattr(_blender_web, "begin_render_export"):
|
|
254
|
-
raise RuntimeError("This Blender artifact has no render-evaluated export")
|
|
255
180
|
_blender_web.begin_render_export(depsgraph)
|
|
256
181
|
try:
|
|
257
182
|
answer = ask({"frame": _export(depsgraph), "capture": {**capture, "evaluation": "render"}})
|
|
@@ -317,6 +242,8 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
317
242
|
"AgX": "agx",
|
|
318
243
|
"Filmic": "filmic",
|
|
319
244
|
"Khronos PBR Neutral": "neutral",
|
|
245
|
+
"ACES 1.3": "aces13",
|
|
246
|
+
"ACES 2.0": "aces20",
|
|
320
247
|
}.get(view.view_transform)
|
|
321
248
|
if transform is None:
|
|
322
249
|
raise RuntimeError(
|
|
@@ -349,20 +276,9 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
349
276
|
|
|
350
277
|
|
|
351
278
|
def _assert_photographed_from(reported, position, target, up):
|
|
352
|
-
"""
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
This is an instrument, not a nicety. A render's image is allowed to differ
|
|
356
|
-
from Cycles (three.js is the renderer), so a camera placed in the wrong
|
|
357
|
-
frame produces a picture nobody can call wrong: the lane shipped for its
|
|
358
|
-
whole life photographing the model's underside from below the floor because
|
|
359
|
-
Blender's Z-up numbers were read as three.js world space.
|
|
360
|
-
|
|
361
|
-
The check is exact because the conversion is exact: the model root's world
|
|
362
|
-
matrix is a signed axis permutation, so a vector through it and its inverse
|
|
363
|
-
is bit-identical. A mismatch is the conversion being wrong, never the check
|
|
364
|
-
being too strict -- do not widen it.
|
|
365
|
-
"""
|
|
279
|
+
"""Require the photograph’s camera pose transformed back into Blender’s frame to equal the sent
|
|
280
|
+
pose exactly. The model root uses a signed axis permutation, so its transform and inverse
|
|
281
|
+
preserve values without a floating-point tolerance."""
|
|
366
282
|
sent = {"position": position, "target": target, "up": up}
|
|
367
283
|
if not isinstance(reported, dict):
|
|
368
284
|
raise RuntimeError(
|
|
@@ -417,19 +333,7 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
417
333
|
|
|
418
334
|
|
|
419
335
|
def _fill_result(engine, result, answer, png, width, height):
|
|
420
|
-
"""
|
|
421
|
-
view transform over them wherever it shows or saves one (`write_still`, the
|
|
422
|
-
Image Editor), as it does over Cycles'. The photograph's PNG is the display
|
|
423
|
-
image -- the transform already ran -- so loading it put every render
|
|
424
|
-
through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
|
|
425
|
-
render view: a background 53 against the viewport's 59, a shadow's blue 2
|
|
426
|
-
against 7. The same capture's linear frame goes in instead: half floats,
|
|
427
|
-
RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
|
|
428
|
-
0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
|
|
429
|
-
path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
|
|
430
|
-
as the PNG path did.
|
|
431
|
-
|
|
432
|
-
A renderer that sends no linear frame gets the old path, and says so."""
|
|
336
|
+
"""Load scene-linear capture pixels into RenderResult so Blender applies the view transform once. A presenter without linear pixels uses the declared PNG path."""
|
|
433
337
|
linear = answer.get("linearBase64") if isinstance(answer, dict) else None
|
|
434
338
|
if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
|
|
435
339
|
import numpy
|
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>;
|
|
@@ -100,6 +62,8 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
|
|
|
100
62
|
// 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,7 +250,35 @@ 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;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* REOPEN THE DOCUMENT from the project's file, or as `backup` (its unsaved state at a restart).
|
|
260
|
+
* The project's copy is staged in as it is at start, so the stager keeps its host stamp and does
|
|
261
|
+
* not fetch the file again once Blender has read and removed it (`session.py::bind_document`); a
|
|
262
|
+
* backup is then written over it. Blender presents what it opened.
|
|
263
|
+
*/
|
|
264
|
+
async function reopenDocument(backup?: Uint8Array): Promise<void> {
|
|
265
|
+
if (!engine || !projectRoot || documentPath === null) throw new Error('The Blender session holds no document to reopen');
|
|
266
|
+
const path = `${projectRoot}/${documentPath}`;
|
|
267
|
+
staged.delete(path);
|
|
268
|
+
try {
|
|
269
|
+
await engine.files.unlink(path);
|
|
270
|
+
} catch {
|
|
271
|
+
/* the open already removed it */
|
|
272
|
+
}
|
|
273
|
+
await stageProjectFiles(engine.files, projectRoot);
|
|
274
|
+
const host = staged.get(path)?.host;
|
|
275
|
+
if (host === undefined) throw new Error(`${documentPath} could not be read from the project`);
|
|
276
|
+
if (backup) {
|
|
277
|
+
await engine.files.writeFile(path, backup);
|
|
278
|
+
staged.set(path, { host, engine: await stampOf(engine.files, path) });
|
|
279
|
+
}
|
|
280
|
+
await engine.request({ op: 'reopen-document' });
|
|
281
|
+
setDocumentDirty(backup !== undefined);
|
|
274
282
|
}
|
|
275
283
|
|
|
276
284
|
async function startBlender(project: string, document?: string): Promise<unknown> {
|
|
@@ -283,8 +291,15 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
283
291
|
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
|
|
284
292
|
if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
|
|
285
293
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
286
|
-
//
|
|
287
|
-
|
|
294
|
+
// A CHANGE IS A NEW REVISION: every present of an unsaved document carries `saveDue`, and
|
|
295
|
+
// reporting each one would tell the workbench's backup the content changed when it did not.
|
|
296
|
+
if (saveDue && documentPath !== null) {
|
|
297
|
+
const revision = (frame as { revision?: unknown } | null)?.revision;
|
|
298
|
+
if (!documentDirty || (typeof revision === 'number' && revision !== dirtyRevision)) {
|
|
299
|
+
if (typeof revision === 'number') dirtyRevision = revision;
|
|
300
|
+
setDocumentDirty(true);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
288
303
|
// THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
|
|
289
304
|
// typed arrays the tab draws from, and the record of what was sent
|
|
290
305
|
// (`describeFrame`). After the post the buffers are detached and the
|
|
@@ -357,10 +372,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
357
372
|
holder.engine = started;
|
|
358
373
|
await loadCheckpoint?.("engine-ready");
|
|
359
374
|
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.
|
|
375
|
+
// Stage the project’s files before the session starts, including the .blend opened by its first
|
|
376
|
+
// operation. Later execute calls stage their inputs on entry.
|
|
364
377
|
if (document) {
|
|
365
378
|
// The document itself not reaching the engine is a refusal, not a footnote: the open below
|
|
366
379
|
// would fail on a missing file (or open a stale copy) without saying why.
|
|
@@ -376,18 +389,6 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
376
389
|
})) as Record<string, unknown>;
|
|
377
390
|
if (document) documentPath = document;
|
|
378
391
|
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
392
|
sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
|
|
392
393
|
objectInfo: async (name: string) =>
|
|
393
394
|
JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
|
|
@@ -399,12 +400,10 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
399
400
|
return {
|
|
400
401
|
...banner,
|
|
401
402
|
engine: 'blender-wasm',
|
|
402
|
-
//
|
|
403
|
-
// which one it measured cannot call a difference a defect in either.
|
|
403
|
+
// Report the engine build that answered.
|
|
404
404
|
skew: started.skew,
|
|
405
405
|
bootMs: Math.round(started.bootMs),
|
|
406
|
-
//
|
|
407
|
-
// has no release door and is carrying the payload twice.
|
|
406
|
+
// Released packed-payload size; WALI has no packed payload and reports null.
|
|
408
407
|
releasedPayloadMB:
|
|
409
408
|
started.releasedPayloadBytes === null
|
|
410
409
|
? null
|
|
@@ -505,26 +504,7 @@ async function projectIndex(project: string): Promise<ProjectFile[] | string> {
|
|
|
505
504
|
return files;
|
|
506
505
|
}
|
|
507
506
|
|
|
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
|
-
*/
|
|
507
|
+
/** 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
508
|
const staged = new Map<string, { host: string; engine: string }>();
|
|
529
509
|
|
|
530
510
|
async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
@@ -532,16 +512,7 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
|
532
512
|
return info ? `${info.size}:${info.mtimeMs}` : '';
|
|
533
513
|
}
|
|
534
514
|
|
|
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
|
-
*/
|
|
515
|
+
/** Stage changed project files and return copy failures. execute appends them to its answer; start refuses when its document failed to stage. */
|
|
545
516
|
async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
|
|
546
517
|
const files = await projectIndex(project);
|
|
547
518
|
if (typeof files === 'string') return [files];
|
|
@@ -590,7 +561,8 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
590
561
|
/** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
|
|
591
562
|
type FollowedDocument =
|
|
592
563
|
| { moved: true; from: string; document: string }
|
|
593
|
-
| { moved: false; from: string; outside: string }
|
|
564
|
+
| { moved: false; from: string; outside: string }
|
|
565
|
+
| { moved: false; from: string; notSaving: true; note: string };
|
|
594
566
|
|
|
595
567
|
/**
|
|
596
568
|
* THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
|
|
@@ -600,9 +572,16 @@ type FollowedDocument =
|
|
|
600
572
|
* whose runtime handle holds the same path. A file outside the project is refused: the editor
|
|
601
573
|
* has no document there, so the session keeps its own and keeps saving to it, and says so
|
|
602
574
|
* naming both files, since the script believes it is now working in the other one.
|
|
575
|
+
* A notSaving answer is a different state: the engine protects the bound file and writes nothing to it.
|
|
603
576
|
*/
|
|
604
577
|
function followDocument(followed: FollowedDocument | undefined): string {
|
|
605
578
|
if (!followed) return '';
|
|
579
|
+
if ('notSaving' in followed) {
|
|
580
|
+
post({ op: 'document-saving', document: followed.from,
|
|
581
|
+
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.` });
|
|
582
|
+
log('warn', followed.note);
|
|
583
|
+
return `\n\n${followed.note}`;
|
|
584
|
+
}
|
|
606
585
|
if (!followed.moved) {
|
|
607
586
|
const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
|
|
608
587
|
`follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
|
|
@@ -614,6 +593,7 @@ function followDocument(followed: FollowedDocument | undefined): string {
|
|
|
614
593
|
// The session checks the same shape, so this is a broken promise, not a refusal to explain.
|
|
615
594
|
throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
|
|
616
595
|
}
|
|
596
|
+
post({ op: 'document-saving', document: followed.from, note: null });
|
|
617
597
|
documentPath = followed.document;
|
|
618
598
|
// Even a present that failed must not leave the new file only in the engine's filesystem.
|
|
619
599
|
setDocumentDirty(true);
|
|
@@ -639,9 +619,9 @@ async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<
|
|
|
639
619
|
const root = projectMount(hostRoot);
|
|
640
620
|
const out: FileEntry[] = [];
|
|
641
621
|
// 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
|
-
//
|
|
622
|
+
// listing. A directory that exists and cannot be read is NOT: answering "no files" would let a
|
|
623
|
+
// root the engine never had (a Windows host's `C:\…` instead of `/C:/…`) report success while
|
|
624
|
+
// nothing reached disk.
|
|
645
625
|
if ((await files.stat(root)) === null) return out;
|
|
646
626
|
const walk = async (dir: string): Promise<void> => {
|
|
647
627
|
let names: string[];
|
|
@@ -712,8 +692,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
712
692
|
// A flush lands what is pending; a document nothing changed stays the
|
|
713
693
|
// file it was opened from.
|
|
714
694
|
if (!documentDirty) return { saved: false };
|
|
715
|
-
await saveDocument();
|
|
716
|
-
|
|
695
|
+
return { saved: await saveDocument() };
|
|
696
|
+
case 'snapshot-document': {
|
|
697
|
+
if (documentPath === null) throw new Error('The Blender session holds no document');
|
|
698
|
+
const snapshot = await engineDocument(documentPath, () => {});
|
|
699
|
+
if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
|
|
700
|
+
return snapshot.bytes;
|
|
701
|
+
}
|
|
702
|
+
case 'revert-document':
|
|
703
|
+
await reopenDocument();
|
|
704
|
+
return undefined;
|
|
705
|
+
case 'restore-document':
|
|
706
|
+
await reopenDocument(request.bytes);
|
|
707
|
+
return undefined;
|
|
717
708
|
case 'history-begin':
|
|
718
709
|
case 'history-end':
|
|
719
710
|
return ask({ op: request.op });
|
|
@@ -756,12 +747,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
756
747
|
...(request.names === undefined ? {} : { names: request.names }),
|
|
757
748
|
});
|
|
758
749
|
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.
|
|
750
|
+
// Forward each request field explicitly to the Python session.
|
|
765
751
|
return ask({
|
|
766
752
|
op: 'rna-context',
|
|
767
753
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
@@ -801,10 +787,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
801
787
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
802
788
|
...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
|
|
803
789
|
});
|
|
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.
|
|
790
|
+
// Forward the clip request fields to the Python session.
|
|
808
791
|
case 'action-clip':
|
|
809
792
|
return ask({
|
|
810
793
|
op: 'action-clip',
|
|
@@ -813,6 +796,30 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
813
796
|
...(request.action === undefined ? {} : { action: request.action }),
|
|
814
797
|
...(request.summary === undefined ? {} : { summary: request.summary }),
|
|
815
798
|
});
|
|
799
|
+
case 'scene-movie':
|
|
800
|
+
return ask({ op: 'scene-movie' });
|
|
801
|
+
case 'sound-scene':
|
|
802
|
+
return ask({ op: 'sound-scene', first: request.first, last: request.last });
|
|
803
|
+
case 'gltf-export':
|
|
804
|
+
return ask({ op: 'gltf-export', kind: request.kind, force: request.force === true,
|
|
805
|
+
...(request.previous ? { previous: request.previous } : {}),
|
|
806
|
+
...(request.blendSha256 ? { blendSha256: request.blendSha256 } : {}), ...(request.luts ? { luts: request.luts } : {}) });
|
|
807
|
+
// A script reads the project's files as `execute`'s does, so they are copied in first; what could not be is
|
|
808
|
+
// answered beside its run.
|
|
809
|
+
// (the files a run wrote are read here and taken away, so its answer carries them and no later run can touch them)
|
|
810
|
+
case 'run-script': {
|
|
811
|
+
const unstaged = await stageProjectFiles(files, projectRoot);
|
|
812
|
+
const ran = await ask({ op: 'run-script', source: request.source, args: request.args, dir: request.dir }) as
|
|
813
|
+
{ output: string; error: string | null; files: string[] };
|
|
814
|
+
const written: { path: string; bytes: Uint8Array }[] = [];
|
|
815
|
+
for (const path of ran.files) {
|
|
816
|
+
written.push({ path, bytes: (await files.readFile(`${request.dir}/${path}`)).slice() });
|
|
817
|
+
await files.unlink(`${request.dir}/${path}`);
|
|
818
|
+
}
|
|
819
|
+
return { output: ran.output, error: ran.error, files: written, unstaged } satisfies ScriptRunAnswer;
|
|
820
|
+
}
|
|
821
|
+
case 'asset-library':
|
|
822
|
+
return ask({ op: 'asset-library', library: request.library });
|
|
816
823
|
case 'outliner-set':
|
|
817
824
|
return ask({
|
|
818
825
|
op: 'outliner-set',
|
|
@@ -831,33 +838,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
831
838
|
staged.delete(request.path);
|
|
832
839
|
return undefined;
|
|
833
840
|
}
|
|
834
|
-
case 'list-files':
|
|
835
|
-
return listSessionFiles(files, request.path);
|
|
836
841
|
}
|
|
837
842
|
throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
|
|
838
843
|
}
|
|
839
844
|
|
|
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
|
-
*/
|
|
845
|
+
/** 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
846
|
function reportMemory(): void {
|
|
853
847
|
if (!engine) return;
|
|
854
848
|
const bytes = engine.memoryBytes();
|
|
855
849
|
if (bytes !== null) post({ op: 'memory', bytes });
|
|
856
850
|
}
|
|
857
851
|
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
// the engine ran out of memory (`runtime.ts`) instead of "Uncaught [object Object]".
|
|
852
|
+
// Report heap size before the page handles a worker error so its diagnostic
|
|
853
|
+
// includes the engine's memory state.
|
|
861
854
|
self.addEventListener('error', reportMemory);
|
|
862
855
|
|
|
863
856
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
@@ -869,10 +862,22 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
|
869
862
|
void enqueue(() => answerRequest(request));
|
|
870
863
|
};
|
|
871
864
|
|
|
865
|
+
/** How often the engine's memory is reported while a call runs (`reportMemory`). */
|
|
866
|
+
const MEMORY_REPORT_MS = 5_000;
|
|
867
|
+
|
|
872
868
|
async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
869
|
+
// Sample heap size while a long call runs; this worker remains responsive while Blender executes on its pthread.
|
|
870
|
+
const weighing = setInterval(reportMemory, MEMORY_REPORT_MS);
|
|
871
|
+
try {
|
|
872
|
+
await answerRequestNow(request);
|
|
873
|
+
} finally {
|
|
874
|
+
clearInterval(weighing);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function answerRequestNow(request: WorkerRequest): Promise<void> {
|
|
873
879
|
try {
|
|
874
880
|
const result = await handle(request);
|
|
875
|
-
if (!loadCheckpoint && documentDirty) await saveDocument();
|
|
876
881
|
await reportHistory();
|
|
877
882
|
post({ id: request.id, result });
|
|
878
883
|
} catch (error) {
|