@volter/blender-engine 0.1.130 → 0.1.131
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/protocol.ts +5 -0
- package/browser/runtime.ts +16 -1
- package/browser/session.py +65 -4
- package/browser/three/blender-physical-material.ts +14 -1
- package/browser/three/blender-runtime-lighting.ts +1 -0
- package/browser/three/blender-runtime-shadows.ts +5 -0
- package/browser/three/blender-runtime-view.ts +15 -1
- package/browser/three/blender-world-visibility.ts +264 -0
- package/browser/worker.ts +40 -2
- package/package.json +1 -1
package/browser/protocol.ts
CHANGED
|
@@ -105,6 +105,11 @@ export type WorkerRequest =
|
|
|
105
105
|
export type WorkerReply =
|
|
106
106
|
| { op: 'frame-stream'; id: number; chunk: FrameChunk }
|
|
107
107
|
| { op: 'document-dirty'; dirty: boolean }
|
|
108
|
+
/** THE DOCUMENT FOLLOWED BLENDER: a script's `save_as_mainfile` (or `open_mainfile`) left
|
|
109
|
+
* another project `.blend` open, and the session's saves now go there
|
|
110
|
+
* (`session.py::follow_open_file`). Both paths are project-relative. Posted before the
|
|
111
|
+
* call's own answer, so the tab's handle already names the new file when the call returns. */
|
|
112
|
+
| { op: 'document-moved'; from: string; document: string }
|
|
108
113
|
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
109
114
|
| { id: number; result: unknown }
|
|
110
115
|
| { id: number; error: string }
|
package/browser/runtime.ts
CHANGED
|
@@ -52,6 +52,11 @@ export interface BlenderRuntimeOptions {
|
|
|
52
52
|
capture?: CaptureRequest,
|
|
53
53
|
): Promise<PresentAnswer> | PresentAnswer;
|
|
54
54
|
log?(level: 'log' | 'warn' | 'error', text: string): void;
|
|
55
|
+
/** The session's document followed Blender to another project `.blend` (a script's Save As,
|
|
56
|
+
* `protocol.ts`'s `document-moved`). Called after {@link BlenderRuntime.document} already
|
|
57
|
+
* names the new file and before the call that moved it answers. An observer: a throw here
|
|
58
|
+
* changes nothing. */
|
|
59
|
+
documentMoved?(moved: { readonly from: string; readonly document: string }): void;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -237,7 +242,9 @@ export class BlenderRuntime {
|
|
|
237
242
|
return this.#project;
|
|
238
243
|
}
|
|
239
244
|
|
|
240
|
-
/** The
|
|
245
|
+
/** The document owned by this worker, including while boot is pending: the startup one, until
|
|
246
|
+
* a script's Save As leaves Blender holding another project `.blend` and the session follows
|
|
247
|
+
* it there (`session.py::follow_open_file`). */
|
|
241
248
|
get document(): string | null {
|
|
242
249
|
return this.#document;
|
|
243
250
|
}
|
|
@@ -674,6 +681,14 @@ export class BlenderRuntime {
|
|
|
674
681
|
this.#dirty = reply.dirty;
|
|
675
682
|
return;
|
|
676
683
|
}
|
|
684
|
+
if (reply.op === 'document-moved') {
|
|
685
|
+
// The worker already saves to the new file; this handle must claim it too, or the
|
|
686
|
+
// editor refuses the new file's own Model tab as a resource conflict.
|
|
687
|
+
this.#document = reply.document;
|
|
688
|
+
try { this.#options.documentMoved?.({ from: reply.from, document: reply.document }); }
|
|
689
|
+
catch { /* an observer cannot undo the move Blender made */ }
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
677
692
|
if (reply.op === 'history') {
|
|
678
693
|
this.#options.history?.(reply.entries);
|
|
679
694
|
return;
|
package/browser/session.py
CHANGED
|
@@ -1464,6 +1464,9 @@ class Session:
|
|
|
1464
1464
|
# Set by a present that left the document behind the model; cleared by
|
|
1465
1465
|
# the save. The tab reads it as `saveDue` beside the frame.
|
|
1466
1466
|
self.save_due = False
|
|
1467
|
+
# The OUTSIDE-THE-PROJECT file Blender was last found holding, once said
|
|
1468
|
+
# (`follow_open_file`): the refusal is told once per file, not per call.
|
|
1469
|
+
self._outside_file = None
|
|
1467
1470
|
# WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
|
|
1468
1471
|
# `(session, revision)` it held BEFORE the last frame, None when it
|
|
1469
1472
|
# held nothing, and the string "unreported" for a presenter that does
|
|
@@ -1964,10 +1967,11 @@ class Session:
|
|
|
1964
1967
|
def bind_document(self, relative_path):
|
|
1965
1968
|
"""Name the session's document and OPEN it when the project has one.
|
|
1966
1969
|
|
|
1967
|
-
One session holds one document. The path is stated at start and
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1970
|
+
One session holds one document. The path is stated at start and moves
|
|
1971
|
+
only where Blender itself moved: a script's own `save_as_mainfile` (or
|
|
1972
|
+
`open_mainfile`) to another `.blend` in the project retargets
|
|
1973
|
+
`bpy.data.filepath`, and `follow_open_file` moves the document with it
|
|
1974
|
+
after the call.
|
|
1971
1975
|
|
|
1972
1976
|
`open_mainfile` runs the session's `_load_post` like any other load
|
|
1973
1977
|
(the export door's revision table is reset, the render engine ids are
|
|
@@ -2011,6 +2015,58 @@ class Session:
|
|
|
2011
2015
|
if self.document is not None:
|
|
2012
2016
|
self.save_due = True
|
|
2013
2017
|
|
|
2018
|
+
def follow_open_file(self):
|
|
2019
|
+
"""FOLLOW BLENDER'S SAVE AS: the document is the file Blender has open.
|
|
2020
|
+
|
|
2021
|
+
Read after every `execute`, because a script is the one request that can
|
|
2022
|
+
change which file Blender holds. `save_as_mainfile(filepath=X)` writes X
|
|
2023
|
+
and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
|
|
2024
|
+
Save goes there. The session used to keep saving to the file it was
|
|
2025
|
+
started on, so the model a script had just saved as X was written over
|
|
2026
|
+
the OLD file on the next idle save, and the editor refused X's own tab
|
|
2027
|
+
because the worker still claimed the old one. SEEN 2026-10-07: an
|
|
2028
|
+
agent built an obby through the Blender MCP, saved it as
|
|
2029
|
+
`src/models/obby.blend`, and its level landed in `src/models/cube.blend`
|
|
2030
|
+
instead -- so Play looked for `cube.play.ts`.
|
|
2031
|
+
|
|
2032
|
+
What does NOT move, by Blender's own rules rather than ours:
|
|
2033
|
+
`save_mainfile()` and a Save As to the same path leave the path where it
|
|
2034
|
+
is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
|
|
2035
|
+
original open (which is also how `save_document` writes, so the
|
|
2036
|
+
session's own save never moves anything). `open_mainfile` does move it:
|
|
2037
|
+
Blender then holds that file, and saving the old path would write one
|
|
2038
|
+
file's model over another's.
|
|
2039
|
+
|
|
2040
|
+
Answers None when nothing moved, `{"moved": True, "from", "document"}`
|
|
2041
|
+
when the document followed Blender to another project `.blend`, and
|
|
2042
|
+
`{"moved": False, "from", "outside"}` -- once per file -- when Blender
|
|
2043
|
+
holds a file outside the project: the editor has no document there, so
|
|
2044
|
+
the session keeps its own and keeps saving to it.
|
|
2045
|
+
"""
|
|
2046
|
+
if self.document is None or not bpy.data.filepath:
|
|
2047
|
+
return None
|
|
2048
|
+
current = os.path.normpath(os.path.abspath(bpy.data.filepath))
|
|
2049
|
+
if current == os.path.normpath(self.document):
|
|
2050
|
+
self._outside_file = None
|
|
2051
|
+
return None
|
|
2052
|
+
root = os.path.normpath(self.project)
|
|
2053
|
+
relative = current[len(root) + 1:] if current.startswith(root + "/") else None
|
|
2054
|
+
# The same shape the worker and the server check (`worker.ts::isDocumentPath`).
|
|
2055
|
+
if (relative is not None and relative.endswith(".blend") and "\\" not in relative
|
|
2056
|
+
and all(part not in ("", ".", "..") for part in relative.split("/"))):
|
|
2057
|
+
previous = self.document_relative
|
|
2058
|
+
self.document = current
|
|
2059
|
+
self.document_relative = relative
|
|
2060
|
+
self._outside_file = None
|
|
2061
|
+
# The file Blender wrote sits only in the engine's filesystem until the
|
|
2062
|
+
# session's own save carries it to the project.
|
|
2063
|
+
self.save_due = True
|
|
2064
|
+
return {"moved": True, "from": previous, "document": relative}
|
|
2065
|
+
if current == self._outside_file:
|
|
2066
|
+
return None
|
|
2067
|
+
self._outside_file = current
|
|
2068
|
+
return {"moved": False, "from": self.document_relative, "outside": current}
|
|
2069
|
+
|
|
2014
2070
|
def save_document(self):
|
|
2015
2071
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
2016
2072
|
|
|
@@ -5329,6 +5385,11 @@ def _dispatch(request):
|
|
|
5329
5385
|
HISTORY.changed()
|
|
5330
5386
|
SESSION.mark_changed()
|
|
5331
5387
|
answer = execute(request["code"])
|
|
5388
|
+
# Read before the present, so the save that present asks for goes to the
|
|
5389
|
+
# file Blender now holds (`Session.follow_open_file`).
|
|
5390
|
+
followed = SESSION.follow_open_file()
|
|
5391
|
+
if followed is not None:
|
|
5392
|
+
answer["document"] = followed
|
|
5332
5393
|
# Every mutation is presented, the rule: the Model
|
|
5333
5394
|
# document is what the agent is looking at.
|
|
5334
5395
|
try:
|
|
@@ -7,6 +7,7 @@ import {z} from 'zod';
|
|
|
7
7
|
import {applyGraphShader, bindGraphDraw, bindPaletteGraph, borrowGraphDrawBinding, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
|
|
8
8
|
import type {CompiledGraph} from './blender-node-graph';
|
|
9
9
|
import {bindNamedUvChannels} from './blender-texture-samplers';
|
|
10
|
+
import {copyWorldVisibility,worldVisibilityShader,worldVisibilityUniforms} from './blender-world-visibility';
|
|
10
11
|
|
|
11
12
|
const scalar = z.number().finite();
|
|
12
13
|
const color = z.tuple([scalar, scalar, scalar]);
|
|
@@ -55,6 +56,7 @@ function graphShadow(material: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMa
|
|
|
55
56
|
// which `copy` replaces with the material's own (it leaves hooks alone).
|
|
56
57
|
applyPhysicalMaterial(shadow);
|
|
57
58
|
shadow.copy(material);
|
|
59
|
+
copyWorldVisibility(material,shadow);
|
|
58
60
|
const values = uniforms.get(shadow)!;
|
|
59
61
|
if (held) {
|
|
60
62
|
values.blenderCoatIor.value = held.blenderCoatIor.value;
|
|
@@ -149,6 +151,7 @@ export function syncPhysicalDrawMaterial(source: THREE.Material, target?: THREE.
|
|
|
149
151
|
return null;
|
|
150
152
|
}
|
|
151
153
|
copy.copy(source);
|
|
154
|
+
copyWorldVisibility(source,copy);
|
|
152
155
|
const held = uniforms.get(source)!;
|
|
153
156
|
const values = uniforms.get(copy)!;
|
|
154
157
|
values.blenderCoatIor.value = held.blenderCoatIor.value;
|
|
@@ -192,7 +195,7 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
192
195
|
blenderWorldExtinction: {value: new THREE.Vector3()}};
|
|
193
196
|
uniforms.set(material, values);
|
|
194
197
|
const held = values;
|
|
195
|
-
material.customProgramCacheKey = () => `blender-principled-physical-
|
|
198
|
+
material.customProgramCacheKey = () => `blender-principled-physical-v6${graphProgramKey(material)}`;
|
|
196
199
|
material.onBeforeRender = (renderer, scene, camera, geometry, object) => {
|
|
197
200
|
bindNamedUvChannels(material, geometry);
|
|
198
201
|
bindGraphDraw(material, geometry, renderer, scene, camera, object, graphShadow);
|
|
@@ -210,6 +213,12 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
210
213
|
.map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
|
|
211
214
|
.join('\n') + '\n' + shader.vertexShader;
|
|
212
215
|
Object.assign(shader.uniforms, held);
|
|
216
|
+
worldVisibilityUniforms(material,shader.uniforms);
|
|
217
|
+
shader.vertexShader='varying vec3 blenderWorldPosition;\n'+shader.vertexShader;
|
|
218
|
+
shader.vertexShader=shader.vertexShader.replace('#include <worldpos_vertex>',
|
|
219
|
+
'#include <worldpos_vertex>\n#if defined(USE_ENVMAP) || defined(USE_SHADOWMAP) || defined(USE_TRANSMISSION)\nblenderWorldPosition=worldPosition.xyz;\n#else\nblenderWorldPosition=(modelMatrix*vec4(transformed,1.)).xyz;\n#endif');
|
|
220
|
+
shader.fragmentShader=shader.fragmentShader.replace('#include <envmap_physical_pars_fragment>',
|
|
221
|
+
'#include <envmap_physical_pars_fragment>\n'+worldVisibilityShader);
|
|
213
222
|
shader.fragmentShader = `uniform float blenderCoatIor;
|
|
214
223
|
uniform vec3 blenderWorldExtinction;
|
|
215
224
|
vec3 blenderInfiniteTransmission() { return vec3(
|
|
@@ -255,6 +264,10 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
255
264
|
'rectAreaLight = rectAreaLights[ i ]; rectAreaLight.color *= exp(-blenderWorldExtinction * length(rectAreaLight.position-geometryPosition));')
|
|
256
265
|
.replace('getAmbientLightIrradiance( ambientLightColor )', 'getAmbientLightIrradiance( ambientLightColor ) * blenderInfiniteTransmission()');
|
|
257
266
|
const environment = THREE.ShaderChunk.lights_fragment_maps
|
|
267
|
+
.replace('getIBLIrradiance( geometryNormal )',
|
|
268
|
+
'blenderWorldIrradiance( geometryNormal, getIBLIrradiance( geometryNormal ) )')
|
|
269
|
+
.replaceAll(/(getIBL(?:Radiance|AnisotropyRadiance)\([^;]+\))/g,
|
|
270
|
+
'$1 * blenderWorldReflectionVisibility(geometryViewDir,geometryNormal,material.roughness)')
|
|
258
271
|
.replaceAll(/(getIBL(?:Irradiance|Radiance|AnisotropyRadiance)\([^;]+\))/g, '$1 * blenderInfiniteTransmission()');
|
|
259
272
|
shader.fragmentShader = shader.fragmentShader.replace('#include <lights_physical_fragment>', physical)
|
|
260
273
|
.replace('#include <lights_fragment_begin>', lighting)
|
|
@@ -724,6 +724,7 @@ export type WorldData = z.infer<typeof worldSchema>;
|
|
|
724
724
|
* Constants and linked colors use the same radiometric path. The environment
|
|
725
725
|
* samples the field at 256×128; features below a texel are filtered. */
|
|
726
726
|
export class WorldBackground {
|
|
727
|
+
lightingTexture():THREE.Texture|null {return this.environmentTexture??this.texture;}
|
|
727
728
|
readonly solarLights: THREE.DirectionalLight[] = [];
|
|
728
729
|
private readonly solarDirections = new Map<THREE.DirectionalLight, THREE.Vector3>();
|
|
729
730
|
|
|
@@ -58,6 +58,11 @@ export function fitModelDirectionalShadow(
|
|
|
58
58
|
casters: THREE.Box3[],
|
|
59
59
|
): void {
|
|
60
60
|
if (!light.castShadow || !receivers.length) return;
|
|
61
|
+
// A detached photograph fits before its first render. Newly composed suns
|
|
62
|
+
// and their targets have not yet passed through Scene.updateMatrixWorld;
|
|
63
|
+
// fitting in those stale axes produces a different map on the first draw.
|
|
64
|
+
light.updateWorldMatrix(true, false);
|
|
65
|
+
light.target.updateWorldMatrix(true, false);
|
|
61
66
|
light.shadow.updateMatrices(light);
|
|
62
67
|
const camera = light.shadow.camera;
|
|
63
68
|
const view = camera.matrixWorldInverse;
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
worldSchema,
|
|
27
27
|
} from './blender-runtime-lighting';
|
|
28
28
|
import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
|
|
29
|
+
import {BlenderWorldVisibility,bindWorldVisibility} from './blender-world-visibility';
|
|
29
30
|
import { volumeMesh, volumeSchema } from './blender-runtime-volume';
|
|
30
31
|
import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
|
|
31
32
|
import { prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
|
|
@@ -681,6 +682,7 @@ export class BlenderRuntimeView {
|
|
|
681
682
|
private readonly lights = new Map<string, THREE.Light>();
|
|
682
683
|
private readonly lighting = new ViewportLighting();
|
|
683
684
|
private readonly world = new WorldBackground();
|
|
685
|
+
private readonly worldVisibility=new BlenderWorldVisibility();
|
|
684
686
|
/**
|
|
685
687
|
* THE INSPECTION OVERLAYS (I4). They are NOT children of {@link root}, and
|
|
686
688
|
* that is the whole design: an overlay is EDITOR FURNITURE, so its
|
|
@@ -1333,6 +1335,13 @@ export class BlenderRuntimeView {
|
|
|
1333
1335
|
// objects. Fit shadows for that draw, rather than the camera's pose
|
|
1334
1336
|
// when Rendered shading was first entered. Captures use this path too.
|
|
1335
1337
|
if (this.rendered) this.applyShadows(true, camera);
|
|
1338
|
+
this.worldVisibility.sync(this.objects.values(),this.world.lightingTexture(),this.root,this.rendered);
|
|
1339
|
+
for(const material of [...this.materials.values(),this.fallback])bindWorldVisibility(material,this.worldVisibility);
|
|
1340
|
+
for(const object of this.objects.values()) {
|
|
1341
|
+
const mesh=object as THREE.Mesh;if(!mesh.isMesh)continue;
|
|
1342
|
+
for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])bindWorldVisibility(material,this.worldVisibility);
|
|
1343
|
+
}
|
|
1344
|
+
if(options?.renderer)this.worldVisibility.prepare(options.renderer);
|
|
1336
1345
|
const opaqueDone = performance.now();
|
|
1337
1346
|
const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
|
|
1338
1347
|
options?.interactive === true, options?.height ?? 0);
|
|
@@ -2222,7 +2231,7 @@ export class BlenderRuntimeView {
|
|
|
2222
2231
|
}
|
|
2223
2232
|
|
|
2224
2233
|
drawStatistics() {
|
|
2225
|
-
return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
|
|
2234
|
+
return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, worldVisibility:this.worldVisibility.diagnostics(), materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
|
|
2226
2235
|
}
|
|
2227
2236
|
|
|
2228
2237
|
snapshot() {
|
|
@@ -2404,6 +2413,10 @@ export class BlenderRuntimeView {
|
|
|
2404
2413
|
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2405
2414
|
return detached.setRendered(true, camera);
|
|
2406
2415
|
},
|
|
2416
|
+
prepareDraw:(renderer:THREE.WebGLRenderer,camera:THREE.Camera)=>{
|
|
2417
|
+
if(disposed)throw new Error('Blender capture snapshot is disposed');
|
|
2418
|
+
return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer});
|
|
2419
|
+
},
|
|
2407
2420
|
dispose,
|
|
2408
2421
|
};
|
|
2409
2422
|
}
|
|
@@ -2452,6 +2465,7 @@ export class BlenderRuntimeView {
|
|
|
2452
2465
|
}
|
|
2453
2466
|
|
|
2454
2467
|
private clear() {
|
|
2468
|
+
this.worldVisibility.dispose();
|
|
2455
2469
|
this.objectInfoMaterials.clear();
|
|
2456
2470
|
this.materialRanges.clear();
|
|
2457
2471
|
this.motionGeometry.clear();
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Directional world-light visibility. One shared depth atlas replaces repeated
|
|
2
|
+
* per-pixel triangle traversal. Geometry/placements are derived draw resources;
|
|
3
|
+
* Blender data and the visible canvas are never changed. This is finite
|
|
4
|
+
* directional quadrature, not indirect light transport. */
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
|
|
7
|
+
const SAMPLE_COUNT=16, TILES=4, TILE_SIZE=512;
|
|
8
|
+
type Segment={mesh:THREE.Mesh; geometry:THREE.BufferGeometry; position:THREE.BufferAttribute|THREE.InterleavedBufferAttribute;
|
|
9
|
+
index:THREE.BufferAttribute|null; positionVersion:number; indexVersion:number; start:number; count:number;
|
|
10
|
+
offset:number; matrix:THREE.Matrix4; shown:boolean; bounds:THREE.Box3; dynamic:boolean};
|
|
11
|
+
const version=(attribute:THREE.BufferAttribute|THREE.InterleavedBufferAttribute)=>
|
|
12
|
+
'isInterleavedBufferAttribute' in attribute ? attribute.data.version : attribute.version;
|
|
13
|
+
function shown(mesh:THREE.Mesh):boolean {
|
|
14
|
+
if(!mesh.castShadow || !mesh.layers.isEnabled(0))return false;
|
|
15
|
+
for(let parent:THREE.Object3D|null=mesh;parent;parent=parent.parent)if(!parent.visible)return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class BlenderWorldVisibility {
|
|
20
|
+
readonly uniforms={
|
|
21
|
+
blenderWorldVisibilityEnabled:{value:false},
|
|
22
|
+
blenderWorldDepth:{value:null as THREE.Texture|null},
|
|
23
|
+
blenderWorldDynamicDepth:{value:null as THREE.Texture|null},
|
|
24
|
+
blenderWorldHasDynamicDepth:{value:false},
|
|
25
|
+
blenderWorldRadiance:{value:null as THREE.Texture|null},
|
|
26
|
+
blenderWorldDirections:{value:Array.from({length:SAMPLE_COUNT},(_,i)=>{
|
|
27
|
+
const z=1-2*(i+.5)/SAMPLE_COUNT,angle=i*2.399963229728653;
|
|
28
|
+
return new THREE.Vector3(Math.sqrt(1-z*z)*Math.cos(angle),z,Math.sqrt(1-z*z)*Math.sin(angle));
|
|
29
|
+
})},
|
|
30
|
+
blenderWorldMatrices:{value:Array.from({length:SAMPLE_COUNT},()=>new THREE.Matrix4())},
|
|
31
|
+
blenderWorldToNative:{value:new THREE.Matrix3()},
|
|
32
|
+
blenderWorldRayOffset:{value:1e-5},
|
|
33
|
+
};
|
|
34
|
+
private target:THREE.WebGLRenderTarget|null=null;
|
|
35
|
+
private dynamicTarget:THREE.WebGLRenderTarget|null=null;
|
|
36
|
+
private readonly dynamicScene=new THREE.Scene();
|
|
37
|
+
private readonly dynamicMeshes=new Map<THREE.Mesh,THREE.Mesh>();
|
|
38
|
+
private readonly scene=new THREE.Scene();
|
|
39
|
+
private readonly geometry=new THREE.BufferGeometry();
|
|
40
|
+
private readonly material=new THREE.MeshDepthMaterial({side:THREE.DoubleSide});
|
|
41
|
+
private readonly mesh=new THREE.Mesh(this.geometry,this.material);
|
|
42
|
+
private readonly camera=new THREE.OrthographicCamera();
|
|
43
|
+
private segments:Segment[]=[];
|
|
44
|
+
private dirty=true;
|
|
45
|
+
private staticDirty=true;
|
|
46
|
+
private dynamicDirty=false;
|
|
47
|
+
private fittedBounds=new THREE.Box3();
|
|
48
|
+
private passes=0;
|
|
49
|
+
private staticPasses=0;
|
|
50
|
+
private dynamicPasses=0;
|
|
51
|
+
constructor(){this.mesh.frustumCulled=false;this.scene.add(this.mesh);}
|
|
52
|
+
|
|
53
|
+
sync(objects:Iterable<THREE.Object3D>,world:THREE.Texture|null,root:THREE.Object3D,enabled:boolean):void {
|
|
54
|
+
this.uniforms.blenderWorldVisibilityEnabled.value=enabled && world!==null;
|
|
55
|
+
this.uniforms.blenderWorldRadiance.value=world;
|
|
56
|
+
this.uniforms.blenderWorldToNative.value.setFromMatrix4(root.matrixWorld).invert();
|
|
57
|
+
if(!enabled || !world)return;
|
|
58
|
+
const meshes=[...objects].filter((object):object is THREE.Mesh=>(object as THREE.Mesh).isMesh===true &&
|
|
59
|
+
!!(object as THREE.Mesh).geometry.getAttribute('position'));
|
|
60
|
+
const layoutChanged=meshes.length!==this.segments.length || meshes.some((mesh,i)=>{
|
|
61
|
+
const previous=this.segments[i],geometry=mesh.geometry;
|
|
62
|
+
return !previous || previous.mesh!==mesh || previous.geometry!==geometry ||
|
|
63
|
+
previous.position!==geometry.getAttribute('position') || previous.index!==geometry.index ||
|
|
64
|
+
previous.start!==geometry.drawRange.start || previous.count!==Math.min(geometry.drawRange.count,
|
|
65
|
+
(geometry.index?.count ?? geometry.getAttribute('position').count)-geometry.drawRange.start);
|
|
66
|
+
});
|
|
67
|
+
if(layoutChanged) {
|
|
68
|
+
for(const mesh of this.dynamicMeshes.values()){mesh.removeFromParent();mesh.geometry.dispose();}
|
|
69
|
+
this.dynamicMeshes.clear();this.dynamicDirty=true;this.fittedBounds.makeEmpty();
|
|
70
|
+
let offset=0;
|
|
71
|
+
this.segments=meshes.map(mesh=>{
|
|
72
|
+
const geometry=mesh.geometry,position=geometry.getAttribute('position'),index=geometry.index;
|
|
73
|
+
const start=geometry.drawRange.start,count=Math.max(0,Math.min(geometry.drawRange.count,(index?.count??position.count)-start));
|
|
74
|
+
const segment={mesh,geometry,position,index,positionVersion:-1,indexVersion:-1,start,count,
|
|
75
|
+
offset,matrix:new THREE.Matrix4(),shown:!shown(mesh),bounds:new THREE.Box3(),dynamic:false};
|
|
76
|
+
offset+=count;return segment;
|
|
77
|
+
});
|
|
78
|
+
this.geometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(offset*3),3));
|
|
79
|
+
this.dirty=true;this.staticDirty=true;
|
|
80
|
+
}
|
|
81
|
+
const attribute=this.geometry.getAttribute('position') as THREE.BufferAttribute|undefined;
|
|
82
|
+
if(!attribute)return;
|
|
83
|
+
const point=new THREE.Vector3();
|
|
84
|
+
for(const segment of this.segments) {
|
|
85
|
+
const visible=shown(segment.mesh),pv=version(segment.position),iv=segment.index?.version??0;
|
|
86
|
+
if(segment.shown===visible && segment.matrix.equals(segment.mesh.matrixWorld) &&
|
|
87
|
+
segment.positionVersion===pv && segment.indexVersion===iv)continue;
|
|
88
|
+
// Once an object moves/deforms, keep it in the small dynamic atlas. Its
|
|
89
|
+
// later poses must never redraw every static triangle in the environment.
|
|
90
|
+
if(!segment.dynamic && segment.positionVersion>=0 &&
|
|
91
|
+
(!segment.matrix.equals(segment.mesh.matrixWorld) || segment.positionVersion!==pv || segment.indexVersion!==iv)) {
|
|
92
|
+
const geometry=new THREE.BufferGeometry();
|
|
93
|
+
geometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(segment.count*3),3));
|
|
94
|
+
const mesh=new THREE.Mesh(geometry,this.material);mesh.frustumCulled=false;
|
|
95
|
+
this.dynamicScene.add(mesh);this.dynamicMeshes.set(segment.mesh,mesh);segment.dynamic=true;
|
|
96
|
+
(attribute.array as Float32Array).fill(0,segment.offset*3,(segment.offset+segment.count)*3);
|
|
97
|
+
attribute.addUpdateRange(segment.offset*3,segment.count*3);attribute.needsUpdate=true;this.staticDirty=true;
|
|
98
|
+
}
|
|
99
|
+
const dynamicMesh=this.dynamicMeshes.get(segment.mesh);
|
|
100
|
+
const written=(dynamicMesh?.geometry.getAttribute('position')??attribute) as THREE.BufferAttribute;
|
|
101
|
+
if(dynamicMesh)dynamicMesh.visible=visible;
|
|
102
|
+
segment.bounds.makeEmpty();
|
|
103
|
+
for(let i=0;i<segment.count;i++) {
|
|
104
|
+
if(visible) {
|
|
105
|
+
const vertex=segment.index?.getX(segment.start+i) ?? segment.start+i;
|
|
106
|
+
point.fromBufferAttribute(segment.position,vertex).applyMatrix4(segment.mesh.matrixWorld);
|
|
107
|
+
segment.bounds.expandByPoint(point);
|
|
108
|
+
} else point.set(0,0,0); // Degenerate the hidden range without changing its layout.
|
|
109
|
+
written.setXYZ(dynamicMesh?i:segment.offset+i,point.x,point.y,point.z);
|
|
110
|
+
}
|
|
111
|
+
written.addUpdateRange(dynamicMesh?0:segment.offset*3,segment.count*3);written.needsUpdate=true;
|
|
112
|
+
if(dynamicMesh)this.dynamicDirty=true;else this.staticDirty=true;
|
|
113
|
+
segment.shown=visible;segment.matrix.copy(segment.mesh.matrixWorld);
|
|
114
|
+
segment.positionVersion=pv;segment.indexVersion=iv;this.dirty=true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Draw the atlas only when a caster changes. All directional samples share
|
|
119
|
+
* one texture sampler and one merged depth draw, retaining offscreen casters. */
|
|
120
|
+
prepare(renderer:THREE.WebGLRenderer):void {
|
|
121
|
+
if(!this.uniforms.blenderWorldVisibilityEnabled.value || !this.dirty)return;
|
|
122
|
+
if(!this.target) {
|
|
123
|
+
const size=TILES*TILE_SIZE;
|
|
124
|
+
this.target=new THREE.WebGLRenderTarget(size,size,{depthBuffer:true,stencilBuffer:false});
|
|
125
|
+
this.target.depthTexture=new THREE.DepthTexture(size,size,THREE.UnsignedIntType);
|
|
126
|
+
this.target.depthTexture.minFilter=this.target.depthTexture.magFilter=THREE.NearestFilter;
|
|
127
|
+
this.uniforms.blenderWorldDepth.value=this.target.depthTexture;
|
|
128
|
+
}
|
|
129
|
+
if(this.dynamicMeshes.size && !this.dynamicTarget) {
|
|
130
|
+
const size=TILES*TILE_SIZE;
|
|
131
|
+
this.dynamicTarget=new THREE.WebGLRenderTarget(size,size,{depthBuffer:true,stencilBuffer:false});
|
|
132
|
+
this.dynamicTarget.depthTexture=new THREE.DepthTexture(size,size,THREE.UnsignedIntType);
|
|
133
|
+
this.dynamicTarget.depthTexture.minFilter=this.dynamicTarget.depthTexture.magFilter=THREE.NearestFilter;
|
|
134
|
+
this.uniforms.blenderWorldDynamicDepth.value=this.dynamicTarget.depthTexture;
|
|
135
|
+
}
|
|
136
|
+
this.uniforms.blenderWorldHasDynamicDepth.value=this.dynamicMeshes.size>0;
|
|
137
|
+
const bounds=this.segments.reduce((all,segment)=>all.union(segment.bounds),new THREE.Box3());
|
|
138
|
+
if(this.fittedBounds.isEmpty() || !this.fittedBounds.containsBox(bounds)) {
|
|
139
|
+
this.fittedBounds.union(bounds);this.staticDirty=true;
|
|
140
|
+
}
|
|
141
|
+
const sphere=this.fittedBounds.getBoundingSphere(new THREE.Sphere());
|
|
142
|
+
this.mesh.visible=(this.geometry.getAttribute('position')?.count??0)>0;
|
|
143
|
+
const radius=Math.max(sphere.radius,1e-3),distance=radius*2+1;
|
|
144
|
+
this.uniforms.blenderWorldRayOffset.value=radius*1e-5;
|
|
145
|
+
this.camera.left=-radius;this.camera.right=radius;this.camera.top=radius;this.camera.bottom=-radius;
|
|
146
|
+
this.camera.near=.001;this.camera.far=distance+radius*2;
|
|
147
|
+
this.camera.updateProjectionMatrix();
|
|
148
|
+
const target=renderer.getRenderTarget(),autoClear=renderer.autoClear,shadowMap=renderer.shadowMap.enabled;
|
|
149
|
+
try {
|
|
150
|
+
renderer.autoClear=false;renderer.shadowMap.enabled=false;
|
|
151
|
+
const targets:[THREE.WebGLRenderTarget,THREE.Scene,boolean][]=[[this.target,this.scene,this.staticDirty]];
|
|
152
|
+
if(this.dynamicTarget)targets.push([this.dynamicTarget,this.dynamicScene,this.dynamicDirty||this.staticDirty]);
|
|
153
|
+
for(const [target,,draw] of targets)if(draw) {
|
|
154
|
+
target.viewport.set(0,0,TILES*TILE_SIZE,TILES*TILE_SIZE);target.scissorTest=false;
|
|
155
|
+
renderer.setRenderTarget(target);renderer.clear(true,true,false);
|
|
156
|
+
}
|
|
157
|
+
for(let i=0;i<SAMPLE_COUNT;i++) {
|
|
158
|
+
const direction=this.uniforms.blenderWorldDirections.value[i]!;
|
|
159
|
+
this.camera.position.copy(sphere.center).addScaledVector(direction,distance);
|
|
160
|
+
this.camera.up.set(0,1,0);this.camera.lookAt(sphere.center);this.camera.updateMatrixWorld(true);
|
|
161
|
+
this.uniforms.blenderWorldMatrices.value[i]!.multiplyMatrices(this.camera.projectionMatrix,this.camera.matrixWorldInverse);
|
|
162
|
+
const x=(i%TILES)*TILE_SIZE,y=Math.floor(i/TILES)*TILE_SIZE;
|
|
163
|
+
// Render-target viewports use device pixels. Renderer.setViewport uses
|
|
164
|
+
// logical pixels even offscreen, so a HiDPI view would overlap the tiles.
|
|
165
|
+
for(const [target,scene,draw] of targets)if(draw) {
|
|
166
|
+
target.viewport.set(x,y,TILE_SIZE,TILE_SIZE);target.scissor.set(x,y,TILE_SIZE,TILE_SIZE);target.scissorTest=true;
|
|
167
|
+
renderer.setRenderTarget(target);renderer.render(scene,this.camera);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if(this.staticDirty){this.passes+=SAMPLE_COUNT;this.staticPasses+=SAMPLE_COUNT;}
|
|
171
|
+
if(this.dynamicTarget && (this.dynamicDirty||this.staticDirty)){this.passes+=SAMPLE_COUNT;this.dynamicPasses+=SAMPLE_COUNT;}
|
|
172
|
+
this.dirty=false;this.staticDirty=false;this.dynamicDirty=false;
|
|
173
|
+
} finally {
|
|
174
|
+
renderer.setRenderTarget(target);renderer.autoClear=autoClear;renderer.shadowMap.enabled=shadowMap;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
diagnostics(){return {enabled:this.uniforms.blenderWorldVisibilityEnabled.value,world:!!this.uniforms.blenderWorldRadiance.value,
|
|
178
|
+
depth:!!this.uniforms.blenderWorldDepth.value,passes:this.passes,staticPasses:this.staticPasses,dynamicPasses:this.dynamicPasses,
|
|
179
|
+
dynamicObjects:this.dynamicMeshes.size,vertices:this.geometry.getAttribute('position')?.count??0,
|
|
180
|
+
casters:this.segments.filter(segment=>segment.shown).length,dirty:this.dirty};}
|
|
181
|
+
dispose():void {
|
|
182
|
+
this.uniforms.blenderWorldVisibilityEnabled.value=false;
|
|
183
|
+
this.target?.dispose();this.target=null;this.uniforms.blenderWorldDepth.value=null;
|
|
184
|
+
this.dynamicTarget?.dispose();this.dynamicTarget=null;this.uniforms.blenderWorldDynamicDepth.value=null;
|
|
185
|
+
this.uniforms.blenderWorldHasDynamicDepth.value=false;
|
|
186
|
+
for(const mesh of this.dynamicMeshes.values()){mesh.removeFromParent();mesh.geometry.dispose();}this.dynamicMeshes.clear();
|
|
187
|
+
this.geometry.dispose();this.material.dispose();this.segments=[];
|
|
188
|
+
this.geometry.deleteAttribute('position');this.dirty=true;this.staticDirty=true;this.dynamicDirty=false;this.fittedBounds.makeEmpty();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bindings=new WeakMap<THREE.Material,BlenderWorldVisibility>();
|
|
193
|
+
const compiled=new WeakMap<THREE.Material,Set<THREE.WebGLProgramParametersWithUniforms['uniforms']>>();
|
|
194
|
+
const inactive=new BlenderWorldVisibility();
|
|
195
|
+
export function bindWorldVisibility(material:THREE.Material,visibility:BlenderWorldVisibility):void {
|
|
196
|
+
if(bindings.get(material)===visibility)return;
|
|
197
|
+
bindings.set(material,visibility);
|
|
198
|
+
for(const uniforms of compiled.get(material)??[])Object.assign(uniforms,visibility.uniforms);
|
|
199
|
+
}
|
|
200
|
+
export function copyWorldVisibility(source:THREE.Material,target:THREE.Material):void {
|
|
201
|
+
const visibility=bindings.get(source);if(visibility)bindWorldVisibility(target,visibility);
|
|
202
|
+
}
|
|
203
|
+
export function worldVisibilityUniforms(material:THREE.Material,uniforms:THREE.WebGLProgramParametersWithUniforms['uniforms']):void {
|
|
204
|
+
let held=compiled.get(material);if(!held){held=new Set();compiled.set(material,held);}held.add(uniforms);
|
|
205
|
+
Object.assign(uniforms,(bindings.get(material)??inactive).uniforms);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const worldVisibilityShader=/* glsl */`
|
|
209
|
+
varying vec3 blenderWorldPosition;
|
|
210
|
+
uniform bool blenderWorldVisibilityEnabled;
|
|
211
|
+
uniform sampler2D blenderWorldDepth, blenderWorldDynamicDepth, blenderWorldRadiance;
|
|
212
|
+
uniform bool blenderWorldHasDynamicDepth;
|
|
213
|
+
uniform vec3 blenderWorldDirections[16];
|
|
214
|
+
uniform mat4 blenderWorldMatrices[16];
|
|
215
|
+
uniform mat3 blenderWorldToNative;
|
|
216
|
+
uniform float blenderWorldRayOffset;
|
|
217
|
+
float blenderWorldVisible(int i, vec3 position, vec3 worldDx, vec3 worldDy) {
|
|
218
|
+
vec4 clip=blenderWorldMatrices[i]*vec4(position,1.);
|
|
219
|
+
vec3 point=clip.xyz/clip.w*.5+.5;
|
|
220
|
+
if(any(lessThan(point,vec3(0.))) || any(greaterThan(point,vec3(1.))))return 1.;
|
|
221
|
+
vec2 tile=vec2(float(i%4),float(i/4));
|
|
222
|
+
vec2 samplePoint=clamp((floor(point.xy*512.)+.5)/512.,vec2(.5/512.),vec2(1.-.5/512.));
|
|
223
|
+
vec2 uv=(tile+samplePoint)/4.;
|
|
224
|
+
float depth=texture2D(blenderWorldDepth,uv).x;
|
|
225
|
+
if(blenderWorldHasDynamicDepth)depth=min(depth,texture2D(blenderWorldDynamicDepth,uv).x);
|
|
226
|
+
// Compare the receiver plane AT THE DEPTH TEXEL, not at its nearby fragment.
|
|
227
|
+
// A fixed depth bias falsely shadows an unoccluded tilted plane; increasing
|
|
228
|
+
// that bias leaks through thin walls. Derivatives retain its actual slope.
|
|
229
|
+
vec3 dx=(blenderWorldMatrices[i]*vec4(worldDx,0.)).xyz*.5;
|
|
230
|
+
vec3 dy=(blenderWorldMatrices[i]*vec4(worldDy,0.)).xyz*.5;
|
|
231
|
+
float determinant=dx.x*dy.y-dx.y*dy.x;
|
|
232
|
+
vec2 gradient=abs(determinant)>1e-12 ? vec2(dx.z*dy.y-dy.z*dx.y,dy.z*dx.x-dx.z*dy.x)/determinant : vec2(0.);
|
|
233
|
+
float receiverDepth=point.z+dot(gradient,samplePoint-point.xy);
|
|
234
|
+
return receiverDepth<=depth+1e-6 ? 1. : 0.;
|
|
235
|
+
}
|
|
236
|
+
vec3 blenderWorldIrradiance(vec3 normal, vec3 fallbackIrradiance) {
|
|
237
|
+
if(!blenderWorldVisibilityEnabled)return fallbackIrradiance;
|
|
238
|
+
vec3 n=inverseTransformDirection(normal,viewMatrix),sum=vec3(0.);
|
|
239
|
+
float weights=0.;vec3 origin=blenderWorldPosition+n*blenderWorldRayOffset;
|
|
240
|
+
vec3 worldDx=dFdx(blenderWorldPosition),worldDy=dFdy(blenderWorldPosition);
|
|
241
|
+
for(int i=0;i<16;i++) {
|
|
242
|
+
vec3 direction=blenderWorldDirections[i];float weight=max(0.,dot(n,direction));weights+=weight;
|
|
243
|
+
if(weight>0. && blenderWorldVisible(i,origin,worldDx,worldDy)>0.) {
|
|
244
|
+
vec3 nativeDirection=normalize(blenderWorldToNative*direction);
|
|
245
|
+
vec2 uv=vec2(atan(-nativeDirection.y,nativeDirection.x)/6.28318530718+.5,
|
|
246
|
+
asin(clamp(nativeDirection.z,-1.,1.))/3.14159265359+.5);
|
|
247
|
+
sum+=texture2D(blenderWorldRadiance,uv).rgb*weight;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sum*(3.14159265359/max(weights,1e-8));
|
|
251
|
+
}
|
|
252
|
+
float blenderWorldReflectionVisibility(vec3 viewDirection,vec3 normal,float roughness) {
|
|
253
|
+
if(!blenderWorldVisibilityEnabled)return 1.;
|
|
254
|
+
vec3 n=inverseTransformDirection(normal,viewMatrix),r=inverseTransformDirection(reflect(-viewDirection,normal),viewMatrix);
|
|
255
|
+
vec3 origin=blenderWorldPosition+n*blenderWorldRayOffset;
|
|
256
|
+
vec3 worldDx=dFdx(blenderWorldPosition),worldDy=dFdy(blenderWorldPosition);
|
|
257
|
+
float sum=0.,weights=0.;
|
|
258
|
+
for(int i=0;i<16;i++) {
|
|
259
|
+
float weight=pow(max(0.,dot(r,blenderWorldDirections[i])),mix(64.,1.,roughness));
|
|
260
|
+
weights+=weight;sum+=weight*blenderWorldVisible(i,origin,worldDx,worldDy);
|
|
261
|
+
}
|
|
262
|
+
return sum/max(weights,1e-8);
|
|
263
|
+
}
|
|
264
|
+
`;
|
package/browser/worker.ts
CHANGED
|
@@ -577,6 +577,43 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
577
577
|
return failed;
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
+
/** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
|
|
581
|
+
type FollowedDocument =
|
|
582
|
+
| { moved: true; from: string; document: string }
|
|
583
|
+
| { moved: false; from: string; outside: string };
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
|
|
587
|
+
*
|
|
588
|
+
* A move retargets the session's saves here (the save this call ends with lands the new file
|
|
589
|
+
* in the project, which is where the editor's Model tab for it opens from) and tells the tab,
|
|
590
|
+
* whose runtime handle holds the same path. A file outside the project is refused: the editor
|
|
591
|
+
* has no document there, so the session keeps its own and keeps saving to it, and says so
|
|
592
|
+
* naming both files, since the script believes it is now working in the other one.
|
|
593
|
+
*/
|
|
594
|
+
function followDocument(followed: FollowedDocument | undefined): string {
|
|
595
|
+
if (!followed) return '';
|
|
596
|
+
if (!followed.moved) {
|
|
597
|
+
const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
|
|
598
|
+
`follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
|
|
599
|
+
`${followed.from}. To move the model to another file, save it inside the project (src/models/<name>.blend).`;
|
|
600
|
+
log('warn', line);
|
|
601
|
+
return `\n\n${line}`;
|
|
602
|
+
}
|
|
603
|
+
if (!isDocumentPath(followed.document)) {
|
|
604
|
+
// The session checks the same shape, so this is a broken promise, not a refusal to explain.
|
|
605
|
+
throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
|
|
606
|
+
}
|
|
607
|
+
documentPath = followed.document;
|
|
608
|
+
// Even a present that failed must not leave the new file only in the engine's filesystem.
|
|
609
|
+
setDocumentDirty(true);
|
|
610
|
+
post({ op: 'document-moved', from: followed.from, document: followed.document });
|
|
611
|
+
const line = `Blender now has ${followed.document} open (this code saved or opened it), so the editor follows it: ` +
|
|
612
|
+
`the model saves to ${followed.document} from now on, and ${followed.from} keeps what it last saved.`;
|
|
613
|
+
log('log', `@@VOLTER-DOCUMENT-MOVED ${JSON.stringify({ from: followed.from, document: followed.document })}`);
|
|
614
|
+
return `\n\n${line}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
580
617
|
/** The failures `stageProjectFiles` answered, as one paragraph a script's caller can read. */
|
|
581
618
|
function stagingNote(failed: readonly string[]): string {
|
|
582
619
|
return failed.length === 0 ? '' :
|
|
@@ -678,9 +715,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
678
715
|
const note = stagingNote(await stageProjectFiles(files, projectRoot));
|
|
679
716
|
const answer = await ask({ op: 'execute', code: request.code, history: request.history ?? true,
|
|
680
717
|
label: request.label ?? 'Blender Python' }) as {
|
|
681
|
-
error?: string; result: string;
|
|
718
|
+
error?: string; result: string; document?: FollowedDocument;
|
|
682
719
|
};
|
|
683
|
-
return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) +
|
|
720
|
+
return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) +
|
|
721
|
+
note + followDocument(answer.document);
|
|
684
722
|
}
|
|
685
723
|
case 'present':
|
|
686
724
|
// Straight through to `session.py`'s own `present` op — the worker adds
|