@volter/blender-engine 0.1.8 → 0.1.10
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/browser/blender-emscripten-engine.mts +4 -4
- package/browser/blender-engine.mts +5 -5
- package/browser/blender-wali-engine.mts +7 -7
- package/browser/rna.ts +2 -2
- package/browser/runtime.ts +2 -2
- package/browser/session.py +209 -34
- package/browser/three/attach-presenter.ts +1 -1
- package/browser/three/blender-node-graph.ts +15 -6
- package/browser/three/blender-runtime-armature.ts +1 -1
- package/browser/three/blender-runtime-camera-view.ts +137 -0
- package/browser/three/blender-runtime-cursor.ts +243 -0
- package/browser/three/blender-runtime-extras.ts +772 -0
- package/browser/three/blender-runtime-lighting.ts +20 -5
- package/browser/three/blender-runtime-view.ts +454 -7
- package/browser/three/blender-workbench-material.ts +115 -0
- package/browser/three/release.ts +1 -1
- package/browser/{vgai_three.py → volter_three.py} +19 -19
- package/browser/worker.ts +3 -3
- package/package.json +1 -1
|
@@ -193,9 +193,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
193
193
|
// editor's console door is for conditions somebody must resolve, so only the
|
|
194
194
|
// session's OWN named conditions go there.
|
|
195
195
|
const say = (_level: 'log' | 'error', text: string) => {
|
|
196
|
-
if (text.startsWith('@@
|
|
196
|
+
if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
|
|
197
197
|
options.log(
|
|
198
|
-
text.startsWith('@@
|
|
198
|
+
text.startsWith('@@VOLTER-WARN') || text.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
|
|
199
199
|
text,
|
|
200
200
|
);
|
|
201
201
|
};
|
|
@@ -224,11 +224,11 @@ export async function startEmscriptenBlenderEngine(
|
|
|
224
224
|
BLENDER_SYSTEM_PYTHON: '/bw/python',
|
|
225
225
|
BLENDER_SYSTEM_SCRIPTS: '/bw/scripts',
|
|
226
226
|
BLENDER_SYSTEM_DATAFILES: '/bw/datafiles',
|
|
227
|
-
|
|
227
|
+
VOLTER_SESSION_ROOT: SESSION_ROOT,
|
|
228
228
|
HOME: '/root',
|
|
229
229
|
TMPDIR: '/tmp',
|
|
230
230
|
});
|
|
231
|
-
//
|
|
231
|
+
// VOLTER_EXPORT_BUFFER_PATH IS DELIBERATELY UNSET. This engine reads the
|
|
232
232
|
// arena off `HEAPU8`; asking the door to also write it to a file would
|
|
233
233
|
// cost this skew a megabyte-scale write per present for bytes it
|
|
234
234
|
// already has (`session.py`, EXPORT_BUFFER_PATH).
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* and that IS the answer. There is no manifest field, no setting and no flag,
|
|
30
30
|
* because nothing a user does should depend on the skew -- if it did, the
|
|
31
31
|
* skews would be answering differently, which the ruling forbids.
|
|
32
|
-
* `
|
|
32
|
+
* `VOLTER_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
|
|
33
33
|
* different build and is how the substrate skew is reached in development.
|
|
34
34
|
*
|
|
35
35
|
* WHAT DIFFERS, and it is four things, all below this line:
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* implementation over {@link BlenderFiles}.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
export const SESSION_ROOT = '/work/.
|
|
46
|
+
export const SESSION_ROOT = '/work/.volter-session';
|
|
47
47
|
export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
|
|
48
48
|
|
|
49
49
|
/**
|
|
@@ -137,7 +137,7 @@ export interface BlenderEngine {
|
|
|
137
137
|
readonly bootMs: number;
|
|
138
138
|
/** The engine's memory in bytes, now. Linear memory on the standalone skew;
|
|
139
139
|
* null where the skew cannot see the module's memory from the host, which
|
|
140
|
-
* is the WALI case and is why `
|
|
140
|
+
* is the WALI case and is why `volter-model-editor status` prints "unreported" rather than
|
|
141
141
|
* a zero. */
|
|
142
142
|
memoryBytes(): number | null;
|
|
143
143
|
/** Bytes of packed `.data` payload handed back after boot, or null when the
|
|
@@ -303,7 +303,7 @@ export function openSessionChannel(
|
|
|
303
303
|
*
|
|
304
304
|
* The loop below waits for `out/<id>.done` and nothing else. When the
|
|
305
305
|
* program behind the directory DIES, that file is never written, so the
|
|
306
|
-
* call waits forever: `
|
|
306
|
+
* call waits forever: `volter-model-editor status` reports it IN FLIGHT for as long as
|
|
307
307
|
* anyone looks, `blender-start` keeps answering from the host's cached
|
|
308
308
|
* banner, and the harness has no answer to time out against. MEASURED
|
|
309
309
|
* 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
|
|
@@ -404,7 +404,7 @@ export function openSessionChannel(
|
|
|
404
404
|
`Blender is gone, so this call will never be answered: ${ended}. ` +
|
|
405
405
|
'The session keeps its directory and its cached banner, so every later call would ' +
|
|
406
406
|
'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
|
|
407
|
-
'(`
|
|
407
|
+
'(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
|
|
408
408
|
);
|
|
409
409
|
await sleep(pollDelay(performance.now() - began));
|
|
410
410
|
}
|
|
@@ -245,9 +245,9 @@ export async function startWaliBlenderEngine(
|
|
|
245
245
|
// `--python-use-system-env` below is what makes Blender read the
|
|
246
246
|
// variable at all.
|
|
247
247
|
PYTHONPATH: `${RESOURCES}/python/lib/numpy313.zip:${RESOURCES}/python/lib/wheels313.zip`,
|
|
248
|
-
|
|
248
|
+
VOLTER_SESSION_ROOT: SESSION_ROOT,
|
|
249
249
|
// THE ARENA'S DOOR ON THIS SKEW. See `readArena`.
|
|
250
|
-
|
|
250
|
+
VOLTER_EXPORT_BUFFER_PATH: ARENA_PATH,
|
|
251
251
|
},
|
|
252
252
|
threadPoolSize: workers.pool,
|
|
253
253
|
// Blender talks to nothing. The editor's own routes are this worker's, not
|
|
@@ -260,13 +260,13 @@ export async function startWaliBlenderEngine(
|
|
|
260
260
|
const say = (text: string) => {
|
|
261
261
|
for (const line of text.split('\n')) {
|
|
262
262
|
if (line === '') continue;
|
|
263
|
-
if (line.startsWith('@@
|
|
263
|
+
if (line.startsWith('@@VOLTER-READY ')) readyLine = line.slice('@@VOLTER-READY '.length);
|
|
264
264
|
// BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS
|
|
265
265
|
// -- the same rule as the standalone engine, and for the same measured
|
|
266
266
|
// reason: only the session's own named conditions belong in the set an
|
|
267
267
|
// agent must drive to zero.
|
|
268
268
|
options.log(
|
|
269
|
-
line.startsWith('@@
|
|
269
|
+
line.startsWith('@@VOLTER-WARN') || line.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
|
|
270
270
|
line,
|
|
271
271
|
);
|
|
272
272
|
}
|
|
@@ -304,7 +304,7 @@ export async function startWaliBlenderEngine(
|
|
|
304
304
|
// only defensible because the CALLER can degrade VISIBLY; without this
|
|
305
305
|
// the degrade is silent, and before it existed the notice reached a
|
|
306
306
|
// nested worker's devtools console and nothing else.
|
|
307
|
-
onDiagnostic: (message: string) => options.log('error', `@@
|
|
307
|
+
onDiagnostic: (message: string) => options.log('error', `@@VOLTER-ERROR ${message}`),
|
|
308
308
|
},
|
|
309
309
|
)
|
|
310
310
|
.then(
|
|
@@ -316,7 +316,7 @@ export async function startWaliBlenderEngine(
|
|
|
316
316
|
},
|
|
317
317
|
)
|
|
318
318
|
.finally(() => {
|
|
319
|
-
if (ended !== null) options.log('error', `@@
|
|
319
|
+
if (ended !== null) options.log('error', `@@VOLTER-ERROR the Blender program ended: ${ended}`);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
322
|
while (readyLine === null) {
|
|
@@ -352,7 +352,7 @@ export async function startWaliBlenderEngine(
|
|
|
352
352
|
readArena: () => filesystem.readFile(ARENA_PATH),
|
|
353
353
|
bootMs,
|
|
354
354
|
// The module's memory is the program worker's and no door reports it.
|
|
355
|
-
// Null rather than zero: `
|
|
355
|
+
// Null rather than zero: `volter-model-editor status` prints "unreported", which is true,
|
|
356
356
|
// where a zero would be a measurement that was never taken.
|
|
357
357
|
memoryBytes: () => null,
|
|
358
358
|
// There is no packed payload on this skew: Blender's runtime tree is
|
package/browser/rna.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (`bl_rna.properties`) — the ruling's "everything Blender SHOWS about a
|
|
9
9
|
* datablock, we show, in OUR panels, read from the engine through bpy and RNA"
|
|
10
10
|
* (ARCHITECTURE-CORE §Blender north star, "Inspection parity, not editing
|
|
11
|
-
* parity"). Nothing in this file is a
|
|
11
|
+
* parity"). Nothing in this file is a volter invention over RNA: every field is
|
|
12
12
|
* a `PropertyRNA` member, a `bpy.types` name, or an address `path_from_id()`
|
|
13
13
|
* produced.
|
|
14
14
|
*/
|
|
@@ -241,7 +241,7 @@ export interface BlenderRnaWrite {
|
|
|
241
241
|
// session's scene, as rows (WORK.md §Blender in the tab is Blender,
|
|
242
242
|
// "Inspection parity", I3). Every field below is Blender's own — a `TSE_*`
|
|
243
243
|
// element type, an `ICON_*` name `tree_element_get_icon` picks, a restriction
|
|
244
|
-
// column `outliner_draw_restrictbuts` draws — and nothing here is a
|
|
244
|
+
// column `outliner_draw_restrictbuts` draws — and nothing here is a volter
|
|
245
245
|
// invention over the Outliner.
|
|
246
246
|
|
|
247
247
|
/**
|
package/browser/runtime.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface PresentAnswer {
|
|
|
69
69
|
* Read by anything that has to say WHERE a byte came from rather than merely
|
|
70
70
|
* display it — `blender-list-files` answers with it, so the CLI's write-back
|
|
71
71
|
* door can record which session's model a mirrored file was exported from
|
|
72
|
-
* (`packages/
|
|
72
|
+
* (`packages/volter-cli/src/blender-mcp.ts`, class Mirror). Null until the
|
|
73
73
|
* session's first present.
|
|
74
74
|
*/
|
|
75
75
|
export interface PresentedState {
|
|
@@ -186,7 +186,7 @@ export class BlenderRuntime {
|
|
|
186
186
|
// The page console is the editor's ledger (`installEditorConsoleReporting`
|
|
187
187
|
// captures it, source-blind), and this package may import nothing of the
|
|
188
188
|
// editor to say it any other way. Without this line the failure reached
|
|
189
|
-
// a toast and `
|
|
189
|
+
// a toast and `volter-model-editor console` read 0/0 while no Model document could open
|
|
190
190
|
// (measured 2026-09-20 from a registry install).
|
|
191
191
|
console.error(error.message);
|
|
192
192
|
for (const id of [...this.#pending.keys()]) this.#settled(id);
|
package/browser/session.py
CHANGED
|
@@ -122,7 +122,7 @@ import _blender_web
|
|
|
122
122
|
_UNKNOWN_GEOMETRY = "RUNTIME_FRAME_UNKNOWN_GEOMETRY"
|
|
123
123
|
_UNKNOWN_IMAGE = "RUNTIME_FRAME_UNKNOWN_IMAGE"
|
|
124
124
|
|
|
125
|
-
ROOT = os.environ.get("
|
|
125
|
+
ROOT = os.environ.get("VOLTER_SESSION_ROOT", "/work/.volter-session")
|
|
126
126
|
IN = os.path.join(ROOT, "in")
|
|
127
127
|
OUT = os.path.join(ROOT, "out")
|
|
128
128
|
ASK = os.path.join(ROOT, "ask")
|
|
@@ -142,7 +142,7 @@ REPLY = os.path.join(ROOT, "reply")
|
|
|
142
142
|
# whose host cannot reach the module's memory at all
|
|
143
143
|
# (`blender-wali-engine.mts`), where the file is not a fallback but the
|
|
144
144
|
# native door onto the same bytes.
|
|
145
|
-
EXPORT_BUFFER_PATH = os.environ.get("
|
|
145
|
+
EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
|
|
146
146
|
_real_stderr = sys.stderr
|
|
147
147
|
|
|
148
148
|
|
|
@@ -172,7 +172,7 @@ def _prepare_directories():
|
|
|
172
172
|
except FileExistsError:
|
|
173
173
|
pass
|
|
174
174
|
except OSError as error:
|
|
175
|
-
_say("@@
|
|
175
|
+
_say("@@VOLTER-ERROR cannot create %s: %r" % (directory, error))
|
|
176
176
|
try:
|
|
177
177
|
os.chmod(directory, 0o777)
|
|
178
178
|
except OSError:
|
|
@@ -215,7 +215,7 @@ def warn(what):
|
|
|
215
215
|
return
|
|
216
216
|
_WARNED.add(what)
|
|
217
217
|
_WARNINGS.append(what)
|
|
218
|
-
_say("@@
|
|
218
|
+
_say("@@VOLTER-WARN " + what)
|
|
219
219
|
|
|
220
220
|
|
|
221
221
|
# ---------------------------------------------------------------- the world
|
|
@@ -523,6 +523,10 @@ def draw_camera(obj):
|
|
|
523
523
|
"shift_x": float(camera.shift_x),
|
|
524
524
|
"shift_y": float(camera.shift_y),
|
|
525
525
|
"matrix": [[float(v) for v in row] for row in obj.matrix_world],
|
|
526
|
+
# What the 3D View's camera view darkens outside the frame (`drawviewborder`).
|
|
527
|
+
"passepartout": float(camera.passepartout_alpha) if camera.show_passepartout else 0.0,
|
|
528
|
+
# The size the overlay draws the camera at (`Camera.display_size`, `cam->drawsize`).
|
|
529
|
+
"display_size": float(camera.display_size),
|
|
526
530
|
}
|
|
527
531
|
|
|
528
532
|
|
|
@@ -732,6 +736,107 @@ def _refusal(node):
|
|
|
732
736
|
return None
|
|
733
737
|
|
|
734
738
|
|
|
739
|
+
|
|
740
|
+
def _empty_display(obj):
|
|
741
|
+
"""An empty's display (`overlay_empty.hh`): its type and size, and for an image empty the
|
|
742
|
+
picture, where it sits on the object (`empty_image_offset`), how it meets depth, which side
|
|
743
|
+
shows, in which projections, its opacity when it blends, the object colour the picture is
|
|
744
|
+
multiplied by (`ucolor`) and whether the image stores premultiplied alpha."""
|
|
745
|
+
display = {"display": obj.empty_display_type, "size": float(obj.empty_display_size)}
|
|
746
|
+
if obj.empty_display_type == "IMAGE":
|
|
747
|
+
display["image"] = {
|
|
748
|
+
"name": obj.data.name if obj.data is not None else None,
|
|
749
|
+
"offset": [float(v) for v in obj.empty_image_offset],
|
|
750
|
+
"depth": obj.empty_image_depth,
|
|
751
|
+
"side": obj.empty_image_side,
|
|
752
|
+
"perspective": bool(obj.show_empty_image_perspective),
|
|
753
|
+
"orthographic": bool(obj.show_empty_image_orthographic),
|
|
754
|
+
"axis_aligned": bool(obj.show_empty_image_only_axis_aligned),
|
|
755
|
+
"opacity": float(obj.color[3]) if obj.use_empty_image_alpha else None,
|
|
756
|
+
"tint": [float(v) for v in obj.color[:3]],
|
|
757
|
+
"premultiplied": obj.data is not None and obj.data.alpha_mode == "PREMUL",
|
|
758
|
+
}
|
|
759
|
+
return display
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _saved_view():
|
|
763
|
+
"""The 3D View the file saved, which is where Blender opens it: the `Modeling` workspace's
|
|
764
|
+
(this editor's Model workspace is Blender's Modeling), else the first 3D View any screen
|
|
765
|
+
holds. `RegionView3D`'s pivot, rotation (view to world, `(w, x, y, z)`), distance and
|
|
766
|
+
projection (`PERSP`, `ORTHO`, or `CAMERA` for a view through the scene camera), and the
|
|
767
|
+
`View3D`'s lens."""
|
|
768
|
+
workspace = bpy.data.workspaces.get("Modeling")
|
|
769
|
+
screens = list(workspace.screens) if workspace is not None else []
|
|
770
|
+
screens.extend(bpy.data.screens)
|
|
771
|
+
for screen in screens:
|
|
772
|
+
for area in screen.areas:
|
|
773
|
+
if area.type != "VIEW_3D":
|
|
774
|
+
continue
|
|
775
|
+
space = area.spaces[0]
|
|
776
|
+
region = space.region_3d
|
|
777
|
+
return {
|
|
778
|
+
"lens": float(space.lens),
|
|
779
|
+
"location": [float(v) for v in region.view_location],
|
|
780
|
+
"rotation": [float(v) for v in region.view_rotation],
|
|
781
|
+
"distance": float(region.view_distance),
|
|
782
|
+
"perspective": region.view_perspective,
|
|
783
|
+
}
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _subject_line(scene, view_layer):
|
|
788
|
+
"""The 3D Viewport's second overlay line, as `draw_selected_name` (`view3d_draw.cc`) writes it,
|
|
789
|
+
less the two parts that follow the playhead: the tab draws `(frame)` and the marker on it
|
|
790
|
+
from the frame it is showing, which during playback is not `frame_current` (written once, on
|
|
791
|
+
pause). Here: in Object Mode (or with nothing active) the active collection and a bar, then
|
|
792
|
+
the active object; outside Object Mode its data's name; the active edit or pose bone, or the
|
|
793
|
+
active shape key (` (Soloed)` when pinned). The scene's markers go with it, in list order,
|
|
794
|
+
for `BKE_scene_find_marker_name`. Blender tints the line on a keyframe; that colour is not
|
|
795
|
+
carried, and the ` (Viewer)` suffix belongs to a viewer path this tab does not show."""
|
|
796
|
+
parts = []
|
|
797
|
+
ob = view_layer.objects.active
|
|
798
|
+
if ob is None or ob.mode == "OBJECT":
|
|
799
|
+
# `BKE_collection_ui_name_get`: the master collection reads "Scene Collection", which is
|
|
800
|
+
# also what its RNA name returns.
|
|
801
|
+
parts.append(" " + view_layer.active_layer_collection.collection.name)
|
|
802
|
+
if ob is not None:
|
|
803
|
+
parts.append(" |")
|
|
804
|
+
if ob is not None:
|
|
805
|
+
parts.append(" " + ob.name)
|
|
806
|
+
if ob.mode != "OBJECT" and ob.data is not None:
|
|
807
|
+
parts.append(" | " + ob.data.name)
|
|
808
|
+
if ob.type == "ARMATURE":
|
|
809
|
+
arm = ob.data
|
|
810
|
+
if ob.mode == "EDIT":
|
|
811
|
+
if arm.edit_bones.active is not None:
|
|
812
|
+
parts.append(" : " + arm.edit_bones.active.name)
|
|
813
|
+
elif ob.mode == "POSE" and arm.bones.active is not None and _bone_shown(arm.bones.active):
|
|
814
|
+
parts.append(" : " + arm.bones.active.name)
|
|
815
|
+
elif ob.type in ("MESH", "LATTICE", "CURVE"):
|
|
816
|
+
if ob.type == "MESH" and ob.mode == "WEIGHT_PAINT":
|
|
817
|
+
rig = ob.find_armature()
|
|
818
|
+
if rig is not None and rig.mode == "POSE":
|
|
819
|
+
bone = rig.data.bones.active
|
|
820
|
+
if bone is not None and _bone_shown(bone):
|
|
821
|
+
parts.append(" : " + bone.name)
|
|
822
|
+
key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
|
|
823
|
+
if key is not None:
|
|
824
|
+
parts.append(" : " + key.name)
|
|
825
|
+
if ob.show_only_shape_key:
|
|
826
|
+
parts.append(" (Soloed)")
|
|
827
|
+
return {
|
|
828
|
+
"frame": int(scene.frame_current),
|
|
829
|
+
"body": "".join(parts),
|
|
830
|
+
"markers": [[int(m.frame), m.name] for m in scene.timeline_markers],
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _bone_shown(bone):
|
|
835
|
+
"""`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
|
|
836
|
+
collections = getattr(bone, "collections", None)
|
|
837
|
+
return not collections or any(c.is_visible for c in collections)
|
|
838
|
+
|
|
839
|
+
|
|
735
840
|
class _GraphRefusal(Exception):
|
|
736
841
|
pass
|
|
737
842
|
|
|
@@ -1231,9 +1336,13 @@ class Session:
|
|
|
1231
1336
|
"""
|
|
1232
1337
|
scene = bpy.context.scene
|
|
1233
1338
|
graphs = material_graphs(scene)
|
|
1339
|
+
# An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
|
|
1340
|
+
# (`overlay_empty.hh` `image_sync`).
|
|
1341
|
+
empty_images = {obj.data.name for obj in scene.objects
|
|
1342
|
+
if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
|
|
1234
1343
|
options = {"session": self.session, "evaluate": True, "known": self._known,
|
|
1235
1344
|
"graph_materials": sorted(graphs),
|
|
1236
|
-
"graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
|
|
1345
|
+
"graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
|
|
1237
1346
|
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
1238
1347
|
# THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
|
|
1239
1348
|
# an ordered stream of filesystem patches that is the whole
|
|
@@ -1288,14 +1397,55 @@ class Session:
|
|
|
1288
1397
|
default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
|
|
1289
1398
|
if default_color:
|
|
1290
1399
|
row["default_color"] = default_color
|
|
1400
|
+
# THE VIEWPORT DISPLAY of each material -- what Blender's Solid shading colours a surface
|
|
1401
|
+
# by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
|
|
1402
|
+
# `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
|
|
1403
|
+
for entry in frame["materials"].values():
|
|
1404
|
+
material = bpy.data.materials.get(entry.get("name", ""))
|
|
1405
|
+
if material is not None:
|
|
1406
|
+
entry["viewport"] = {
|
|
1407
|
+
"color": [float(v) for v in material.diffuse_color],
|
|
1408
|
+
"roughness": float(material.roughness),
|
|
1409
|
+
"metallic": float(material.metallic),
|
|
1410
|
+
}
|
|
1291
1411
|
frame["world"] = draw_world(scene)
|
|
1292
1412
|
frame["cameras"] = {
|
|
1293
1413
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
1294
1414
|
}
|
|
1295
1415
|
frame["volumes"] = {}
|
|
1416
|
+
# THE CAMERA A CAMERA VIEW LOOKS THROUGH (`view3d.view_camera`: the scene's, else a
|
|
1417
|
+
# camera that is active, else the view layer's first) and the frame it draws, the
|
|
1418
|
+
# render's shape with its pixel aspect.
|
|
1419
|
+
render = scene.render
|
|
1420
|
+
frame["camera_view"] = {
|
|
1421
|
+
"scene_camera": scene.camera.name if scene.camera is not None else None,
|
|
1422
|
+
"view_layer_cameras": [o.name for o in bpy.context.view_layer.objects if o.type == "CAMERA"],
|
|
1423
|
+
"aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
|
|
1424
|
+
}
|
|
1425
|
+
# THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
|
|
1426
|
+
# operators place and snap, drawn by the tab as Blender's overlay does
|
|
1427
|
+
# (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
|
|
1428
|
+
# turn with the cursor's rotation in whichever `rotation_mode` it has.
|
|
1429
|
+
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1296
1430
|
# THE OVERLAYS, after the door's frame stands: `_weights` reads the
|
|
1297
1431
|
# mesh's own revision out of it (see there), so it cannot run before.
|
|
1298
1432
|
view_layer = bpy.context.view_layer
|
|
1433
|
+
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1434
|
+
# both from the scene, so they come from here (`_subject_line`).
|
|
1435
|
+
frame["subject"] = _subject_line(scene, view_layer)
|
|
1436
|
+
# A LIGHT'S DISTANCES, for the overlay's direction line (`overlay_light.hh`: from
|
|
1437
|
+
# `clipsta` to `att_dist` down the light's -Z, for a spot or an area light).
|
|
1438
|
+
frame["light_distances"] = {
|
|
1439
|
+
light.name: [float(light.shadow_buffer_clip_start), float(light.cutoff_distance)]
|
|
1440
|
+
for light in bpy.data.lights
|
|
1441
|
+
}
|
|
1442
|
+
# WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
|
|
1443
|
+
frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
|
|
1444
|
+
frame["view"] = _saved_view()
|
|
1445
|
+
frame["units"] = {
|
|
1446
|
+
"system": scene.unit_settings.system,
|
|
1447
|
+
"scale_length": float(scene.unit_settings.scale_length),
|
|
1448
|
+
}
|
|
1299
1449
|
frame["armatures"] = _armatures(view_layer)
|
|
1300
1450
|
frame["weights"] = _weights(scene, view_layer, frame, self._known)
|
|
1301
1451
|
warnings = list(frame.get("warnings", ()))
|
|
@@ -1670,11 +1820,11 @@ _ASK_SEQUENCE = [0]
|
|
|
1670
1820
|
def _drop(path):
|
|
1671
1821
|
"""Remove one of THIS side's own channel files. A failure here means the
|
|
1672
1822
|
ownership rule was broken by the other side, so it is named rather than
|
|
1673
|
-
swallowed -- and named where `
|
|
1823
|
+
swallowed -- and named where `volter-model-editor console` reads it, not into a log."""
|
|
1674
1824
|
try:
|
|
1675
1825
|
os.unlink(path)
|
|
1676
1826
|
except OSError as error:
|
|
1677
|
-
_say("@@
|
|
1827
|
+
_say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
|
|
1678
1828
|
|
|
1679
1829
|
|
|
1680
1830
|
def ask(payload):
|
|
@@ -1829,7 +1979,7 @@ def _assert_photographed_from(reported, position, target, up):
|
|
|
1829
1979
|
)
|
|
1830
1980
|
|
|
1831
1981
|
|
|
1832
|
-
class
|
|
1982
|
+
class VolterRenderEngine(bpy.types.RenderEngine):
|
|
1833
1983
|
"""The scene's renderer, so `write_still`, `save_render` and Render Result
|
|
1834
1984
|
behave as Blender's own.
|
|
1835
1985
|
|
|
@@ -1839,7 +1989,7 @@ class VgaiRenderEngine(bpy.types.RenderEngine):
|
|
|
1839
1989
|
starts a path trace nobody asked for inside the tab.
|
|
1840
1990
|
"""
|
|
1841
1991
|
|
|
1842
|
-
bl_idname = "
|
|
1992
|
+
bl_idname = "VOLTER_THREE"
|
|
1843
1993
|
bl_label = "three.js"
|
|
1844
1994
|
bl_use_preview = False
|
|
1845
1995
|
|
|
@@ -1937,7 +2087,7 @@ def _register_engine():
|
|
|
1937
2087
|
("BLENDER_WORKBENCH", "Workbench"),
|
|
1938
2088
|
):
|
|
1939
2089
|
existing = _engine_class(identifier)
|
|
1940
|
-
if existing is not None and issubclass(existing,
|
|
2090
|
+
if existing is not None and issubclass(existing, VolterRenderEngine):
|
|
1941
2091
|
made.append(identifier)
|
|
1942
2092
|
continue
|
|
1943
2093
|
if existing is not None:
|
|
@@ -1948,8 +2098,8 @@ def _register_engine():
|
|
|
1948
2098
|
else:
|
|
1949
2099
|
_release_from_owning_addon(existing)
|
|
1950
2100
|
engine = type(
|
|
1951
|
-
"
|
|
1952
|
-
(
|
|
2101
|
+
"Volter" + identifier.title().replace("_", ""),
|
|
2102
|
+
(VolterRenderEngine,),
|
|
1953
2103
|
{"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
|
|
1954
2104
|
)
|
|
1955
2105
|
try:
|
|
@@ -1965,17 +2115,17 @@ def _register_engine():
|
|
|
1965
2115
|
# in the start reply and refused by name at the render, never a
|
|
1966
2116
|
# warning on every boot.
|
|
1967
2117
|
unavailable.append(identifier)
|
|
1968
|
-
# `
|
|
2118
|
+
# `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
|
|
1969
2119
|
# again after every file load (the ids have to be retaken), and Blender
|
|
1970
2120
|
# refuses a class it already holds -- which is not a capability anyone
|
|
1971
2121
|
# lost, so it is not a warning.
|
|
1972
|
-
if _engine_class(
|
|
2122
|
+
if _engine_class(VolterRenderEngine.bl_idname) is None:
|
|
1973
2123
|
try:
|
|
1974
|
-
bpy.utils.register_class(
|
|
2124
|
+
bpy.utils.register_class(VolterRenderEngine)
|
|
1975
2125
|
except Exception as error: # noqa: BLE001
|
|
1976
2126
|
warn("could not register the three.js engine: %s" % error)
|
|
1977
|
-
if _engine_class(
|
|
1978
|
-
made.append(
|
|
2127
|
+
if _engine_class(VolterRenderEngine.bl_idname) is not None:
|
|
2128
|
+
made.append(VolterRenderEngine.bl_idname)
|
|
1979
2129
|
return made, unavailable
|
|
1980
2130
|
|
|
1981
2131
|
|
|
@@ -2548,7 +2698,7 @@ def _py_row(target, name, descriptor, owner):
|
|
|
2548
2698
|
from what it answers -- an ID is a POINTER, a sequence of them a
|
|
2549
2699
|
COLLECTION with its count and names, a scalar its scalar. A value no RNA
|
|
2550
2700
|
type covers is not invented into one: the property is dropped, and
|
|
2551
|
-
nothing here is a
|
|
2701
|
+
nothing here is a volter field over Blender's data."""
|
|
2552
2702
|
row = {
|
|
2553
2703
|
"identifier": name,
|
|
2554
2704
|
"name": name,
|
|
@@ -3744,7 +3894,7 @@ def _node_row(node):
|
|
|
3744
3894
|
|
|
3745
3895
|
|
|
3746
3896
|
def _node_tree_of(path, material):
|
|
3747
|
-
"""WHICH TREE. Either an explicit RNA address (so a `
|
|
3897
|
+
"""WHICH TREE. Either an explicit RNA address (so a `volter-model-editor eval` can open a
|
|
3748
3898
|
world's or a group's tree with the engine's own spelling), or a material by
|
|
3749
3899
|
name, or -- given neither -- the active object's active material, which is
|
|
3750
3900
|
what Blender's own Shading header resolves (`space_node.py:89-93`,
|
|
@@ -4002,7 +4152,7 @@ def rna_rig(object_name=None):
|
|
|
4002
4152
|
presented frame carries every object at once, and the presenter has to know
|
|
4003
4153
|
which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
|
|
4004
4154
|
per object would be one round trip per mesh; asking by NAME stays available
|
|
4005
|
-
for a `
|
|
4155
|
+
for a `volter-model-editor eval` that wants to read one.
|
|
4006
4156
|
"""
|
|
4007
4157
|
scene_frame = int(bpy.context.scene.frame_current)
|
|
4008
4158
|
if object_name:
|
|
@@ -4625,7 +4775,32 @@ def outliner_set(path, column, value):
|
|
|
4625
4775
|
% (column, target.bl_rna.identifier, path))
|
|
4626
4776
|
|
|
4627
4777
|
|
|
4778
|
+
# REQUESTS THAT CAN REMOVE OBJECTS, watched: a removal is reported with its cause, because an
|
|
4779
|
+
# object that disappears without a named act is the loss this session must never hide. A scene
|
|
4780
|
+
# was once found emptied and saved over its file by a burst of three edits at boot, with nothing
|
|
4781
|
+
# recording which requests they were.
|
|
4782
|
+
_WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step")
|
|
4783
|
+
|
|
4784
|
+
|
|
4628
4785
|
def dispatch(request):
|
|
4786
|
+
op = request.get("op")
|
|
4787
|
+
if op not in _WATCHED_OPS:
|
|
4788
|
+
return _dispatch_request(request)
|
|
4789
|
+
before = {o.name for o in bpy.data.objects}
|
|
4790
|
+
try:
|
|
4791
|
+
return _dispatch_request(request)
|
|
4792
|
+
finally:
|
|
4793
|
+
removed = before - {o.name for o in bpy.data.objects}
|
|
4794
|
+
if removed:
|
|
4795
|
+
cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
|
|
4796
|
+
if k in request}
|
|
4797
|
+
if op == "execute":
|
|
4798
|
+
cause["code"] = str(request.get("code", ""))[:160]
|
|
4799
|
+
_say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
|
|
4800
|
+
op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
|
|
4801
|
+
|
|
4802
|
+
|
|
4803
|
+
def _dispatch_request(request):
|
|
4629
4804
|
op = request.get("op")
|
|
4630
4805
|
if op == "history-begin":
|
|
4631
4806
|
HISTORY.begin()
|
|
@@ -4690,14 +4865,14 @@ def _dispatch(request):
|
|
|
4690
4865
|
except Exception as thrown: # noqa: BLE001
|
|
4691
4866
|
# LOUD, because a present that fails leaves the Model document
|
|
4692
4867
|
# showing the state BEFORE this call and nothing else says so.
|
|
4693
|
-
# `@@
|
|
4868
|
+
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
4694
4869
|
# escalate (`blender-emscripten-engine.mts:159`,
|
|
4695
|
-
# `blender-wali-engine.mts:266` raise `@@
|
|
4870
|
+
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
4696
4871
|
# to the console and nothing else), so it logged at `log` level and
|
|
4697
4872
|
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
4698
4873
|
# the overlay walk froze the viewport through a dozen successful
|
|
4699
|
-
# `blender-execute` calls with `
|
|
4700
|
-
_say("@@
|
|
4874
|
+
# `blender-execute` calls with `volter-model-editor console` silent throughout.
|
|
4875
|
+
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
4701
4876
|
"showing the state before it: " + repr(thrown))
|
|
4702
4877
|
return answer
|
|
4703
4878
|
if op == "scene-info":
|
|
@@ -4740,14 +4915,14 @@ def _dispatch(request):
|
|
|
4740
4915
|
except Exception as thrown: # noqa: BLE001
|
|
4741
4916
|
# LOUD, because a present that fails leaves the Model document
|
|
4742
4917
|
# showing the state BEFORE this call and nothing else says so.
|
|
4743
|
-
# `@@
|
|
4918
|
+
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
4744
4919
|
# escalate (`blender-emscripten-engine.mts:159`,
|
|
4745
|
-
# `blender-wali-engine.mts:266` raise `@@
|
|
4920
|
+
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
4746
4921
|
# to the console and nothing else), so it logged at `log` level and
|
|
4747
4922
|
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
4748
4923
|
# the overlay walk froze the viewport through a dozen successful
|
|
4749
|
-
# `blender-execute` calls with `
|
|
4750
|
-
_say("@@
|
|
4924
|
+
# `blender-execute` calls with `volter-model-editor console` silent throughout.
|
|
4925
|
+
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
4751
4926
|
"showing the state before it: " + repr(thrown))
|
|
4752
4927
|
return answer
|
|
4753
4928
|
if op == "rna-set":
|
|
@@ -4761,14 +4936,14 @@ def _dispatch(request):
|
|
|
4761
4936
|
except Exception as thrown: # noqa: BLE001
|
|
4762
4937
|
# LOUD, because a present that fails leaves the Model document
|
|
4763
4938
|
# showing the state BEFORE this call and nothing else says so.
|
|
4764
|
-
# `@@
|
|
4939
|
+
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
4765
4940
|
# escalate (`blender-emscripten-engine.mts:159`,
|
|
4766
|
-
# `blender-wali-engine.mts:266` raise `@@
|
|
4941
|
+
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
4767
4942
|
# to the console and nothing else), so it logged at `log` level and
|
|
4768
4943
|
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
4769
4944
|
# the overlay walk froze the viewport through a dozen successful
|
|
4770
|
-
# `blender-execute` calls with `
|
|
4771
|
-
_say("@@
|
|
4945
|
+
# `blender-execute` calls with `volter-model-editor console` silent throughout.
|
|
4946
|
+
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
4772
4947
|
"showing the state before it: " + repr(thrown))
|
|
4773
4948
|
return answer
|
|
4774
4949
|
if op == "save-document":
|
|
@@ -4796,7 +4971,7 @@ bpy.app.handlers.load_post.append(_load_post)
|
|
|
4796
4971
|
bpy.app.handlers.undo_post.append(_history_post)
|
|
4797
4972
|
bpy.app.handlers.redo_post.append(_history_post)
|
|
4798
4973
|
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
4799
|
-
_say("@@
|
|
4974
|
+
_say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
4800
4975
|
"unavailableEngines": UNAVAILABLE_ENGINES}))
|
|
4801
4976
|
|
|
4802
4977
|
# Markers already dispatched, and the answers whose `out/` files this loop
|
|
@@ -4879,7 +5054,7 @@ try:
|
|
|
4879
5054
|
except BaseException as _thrown: # noqa: BLE001
|
|
4880
5055
|
# The one place a channel death can still be named. Without this the
|
|
4881
5056
|
# program ends 0 and the page reports only that Blender is gone.
|
|
4882
|
-
_say("@@
|
|
5057
|
+
_say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
|
|
4883
5058
|
"every outstanding call will go unanswered. %s"
|
|
4884
5059
|
% (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
|
|
4885
5060
|
raise
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ONE PRESENTER, ATTACHED TO ONE BLENDER (WS-AC, cut 3).
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `volter_three.py` ships inside our Blender and turns every
|
|
5
5
|
* `bpy.ops.render.render` into an ASK on a directory it owns; this is the
|
|
6
6
|
* other half, for a host that holds that directory as a filesystem in its own
|
|
7
7
|
* realm. It is the whole of what a host must do: hand it the filesystem, the
|
|
@@ -823,15 +823,24 @@ class Compiler {
|
|
|
823
823
|
if (extension === 'CLIP')
|
|
824
824
|
body.push(`if (any(lessThan(${co}.xy, vec2(0.0))) || any(greaterThan(${co}.xy, vec2(1.0)))) { ${color} = vec4(0.0); ${alpha} = 0.0; }`);
|
|
825
825
|
}
|
|
826
|
-
// node_shader_gpu_tex_image's Color output
|
|
827
|
-
//
|
|
828
|
-
//
|
|
826
|
+
// node_shader_gpu_tex_image's Color output over the file's own texels (straight, or
|
|
827
|
+
// premultiplied for a PREMUL image, as Blender's GPU texture holds them): alpha cleared for
|
|
828
|
+
// ignored, packed or data alpha; otherwise straight when Alpha is used, so a factor does
|
|
829
|
+
// not multiply it in twice, and premultiplied when it is not. Measured in Blender 5.2's
|
|
830
|
+
// EEVEE: a straight red texel at alpha 0.5 emits 0.502 with Alpha unused, 1.0 with it used.
|
|
831
|
+
// A PREMUL sRGB image differs: the sampler decodes the premultiplied bytes, `decode(r·a)`,
|
|
832
|
+
// where Blender's texture holds `linear(r)·a`, so its partly transparent texels read darker.
|
|
829
833
|
const mode = prop<string>(node, 'image_alpha_mode');
|
|
830
834
|
const alphaUsed = this.uses(key, 1);
|
|
831
|
-
|
|
832
|
-
|
|
835
|
+
const c = outs([0]);
|
|
836
|
+
if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data'))
|
|
837
|
+
body.push(`${c}.a = 1.0;`);
|
|
838
|
+
else if (mode === 'PREMUL')
|
|
839
|
+
body.push(alphaUsed
|
|
840
|
+
? `if (${c}.a != 0.0 && ${c}.a != 1.0) ${c}.rgb /= ${c}.a;`
|
|
841
|
+
: `${c}.a = 1.0;`);
|
|
833
842
|
else
|
|
834
|
-
body.push(`${
|
|
843
|
+
body.push(alphaUsed ? `${c}.a = 1.0;` : `${c}.rgb *= ${c}.a;`);
|
|
835
844
|
break;
|
|
836
845
|
}
|
|
837
846
|
}
|
|
@@ -240,7 +240,7 @@ const BONE_SOLID_VERTEX = /* glsl */ `
|
|
|
240
240
|
* ONLY THE `_fragment` HALF: three PREPENDS `colorspace_pars_fragment` to every
|
|
241
241
|
* `ShaderMaterial` already, so including it here declared `LinearTransferOETF`,
|
|
242
242
|
* `sRGBTransferEOTF` and `sRGBTransferOETF` twice and the whole program refused
|
|
243
|
-
* to compile ("function already has a body", measured live in `
|
|
243
|
+
* to compile ("function already has a body", measured live in `volter-model-editor console`).
|
|
244
244
|
*/
|
|
245
245
|
const BONE_SOLID_FRAGMENT = /* glsl */ `
|
|
246
246
|
varying vec3 vColor;
|