@volter/editor-blender 0.1.131 → 0.1.133
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.
|
@@ -163,15 +163,24 @@ function gameState(documentId: string): unknown {
|
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
168
|
-
* `
|
|
166
|
+
/** Who drives a playing game: the bot while autoplay is on; the person once their key or pointer
|
|
167
|
+
* has reached the game this run; otherwise nobody — the game runs on no input. A tool that
|
|
168
|
+
* predates `person` cannot say, and counts autoplay off as the person driving, as it always did. */
|
|
169
|
+
function driverOf(playing: boolean, autoplay: DocumentPlayAutoplay): 'bot' | 'person' | 'none' | null {
|
|
170
|
+
if (!playing) return null;
|
|
171
|
+
if (autoplay.on) return 'bot';
|
|
172
|
+
return autoplay.person === false ? 'none' : 'person';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The bot's switch and who drives (`driverOf`), null when nothing plays. `by` is who made the
|
|
176
|
+
* last change (`takeover`: a person's key or pointer; `limit`: the run's seconds spent); `state`
|
|
177
|
+
* what the bot says it is doing; `why` says why there is no bot to switch, null when there is one. */
|
|
169
178
|
function autoplayState(documentId: string): unknown {
|
|
170
179
|
const found = extension();
|
|
171
180
|
const autoplay = found?.transport?.autoplay?.(documentId) ?? NO_BOT;
|
|
172
181
|
const playing = found?.playing(documentId) ?? false;
|
|
173
182
|
const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
|
|
174
|
-
return { ...autoplay, driver: playing
|
|
183
|
+
return { ...autoplay, driver: driverOf(playing, autoplay), why };
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
/**
|
|
@@ -299,8 +308,9 @@ registerViewVerbs({
|
|
|
299
308
|
},
|
|
300
309
|
},
|
|
301
310
|
{
|
|
302
|
-
// THE EDITOR'S AUTOPLAY SWITCH: on drives the game's
|
|
303
|
-
// the game back to the person. Off at every
|
|
311
|
+
// THE EDITOR'S AUTOPLAY SWITCH: on drives one of the game's bot behaviours (`play.autoplay`)
|
|
312
|
+
// for at most `for` simulation seconds, off hands the game back to the person. Off at every
|
|
313
|
+
// Play and Restart. `behavior` is required when the bot offers several.
|
|
304
314
|
id: 'autoplay',
|
|
305
315
|
run: (args) => {
|
|
306
316
|
const documentId = verbDocument(args);
|
|
@@ -308,6 +318,11 @@ registerViewVerbs({
|
|
|
308
318
|
const on = raw === true || raw === 'on' || raw === 'true' ? true
|
|
309
319
|
: raw === false || raw === 'off' || raw === 'false' ? false : undefined;
|
|
310
320
|
if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on|off\`); got ${String(raw)}.`);
|
|
321
|
+
const behavior = typeof args?.['behavior'] === 'string' && args['behavior'] !== '' ? args['behavior'] : null;
|
|
322
|
+
const rawLimit = args?.['for'] ?? args?.['limit'];
|
|
323
|
+
const limit = rawLimit === undefined || rawLimit === null ? null : Number(rawLimit);
|
|
324
|
+
if (limit !== null && !(Number.isFinite(limit) && limit > 0))
|
|
325
|
+
throw new Error(`autoplay's \`for\` is a positive number of simulation seconds; got ${JSON.stringify(rawLimit)}.`);
|
|
311
326
|
const found = verbExtension();
|
|
312
327
|
const { transport } = found;
|
|
313
328
|
if (transport.setAutoplay === undefined)
|
|
@@ -315,7 +330,7 @@ registerViewVerbs({
|
|
|
315
330
|
// Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
|
|
316
331
|
const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
|
|
317
332
|
if (why !== null) throw new Error(why);
|
|
318
|
-
transport.setAutoplay(documentId, on, 'cli');
|
|
333
|
+
transport.setAutoplay(documentId, on, 'cli', on ? { behavior, limit } : undefined);
|
|
319
334
|
return gameState(documentId);
|
|
320
335
|
},
|
|
321
336
|
},
|
|
@@ -635,8 +650,32 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
635
650
|
: unavailable === 'not-running'
|
|
636
651
|
? armed ? 'Armed — turns on once the game is running' : 'Available once the game is running'
|
|
637
652
|
: null;
|
|
653
|
+
// THE BEHAVIOUR TO RUN, when the bot offers several: chosen here, as `play autoplay on <behaviour>`
|
|
654
|
+
// names it. Shown with the one driving (or armed) selected, else the bot's first.
|
|
655
|
+
const behaviors = autoplay.behaviors ?? [];
|
|
656
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
657
|
+
const behavior = autoplay.on || armed ? autoplay.behavior ?? null : picked !== null && behaviors.includes(picked) ? picked : behaviors[0] ?? null;
|
|
658
|
+
const driver = driverOf(playing, autoplay);
|
|
659
|
+
// Said while nobody drives; once the person's input reaches the game, the driver line says they are.
|
|
660
|
+
const limitNote = driver !== 'none' ? null
|
|
661
|
+
: autoplay.by === 'limit' && autoplay.limit ? `Autoplay stopped at its ${Math.round(autoplay.limit)} s limit`
|
|
662
|
+
// An arm the start could not take says why, rather than leaving the person to wonder.
|
|
663
|
+
: autoplay.refused ? `Autoplay didn’t start: ${autoplay.refused}` : null;
|
|
664
|
+
const botLine = [autoplay.behavior, autoplay.state].filter(Boolean).join(' — ');
|
|
638
665
|
return (
|
|
639
666
|
<div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
|
|
667
|
+
{behaviors.length > 1 && (
|
|
668
|
+
<select
|
|
669
|
+
data-testid="model-play-autoplay-behavior"
|
|
670
|
+
title="The bot behaviour Autoplay runs"
|
|
671
|
+
value={behavior ?? ''}
|
|
672
|
+
disabled={autoplay.on}
|
|
673
|
+
onChange={(event) => setPicked(event.target.value)}
|
|
674
|
+
style={{ background: TIMELINE_CHROME.widget, color: TIMELINE_CHROME.widgetText, border: 'none', borderRadius: TIMELINE_CHROME.widgetRadius, font: 'inherit', minWidth: 0 }}
|
|
675
|
+
>
|
|
676
|
+
{behaviors.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
677
|
+
</select>
|
|
678
|
+
)}
|
|
640
679
|
{/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
|
|
641
680
|
<span title={label} style={{ display: 'inline-flex' }}>
|
|
642
681
|
{/* KEEP THE KEYBOARD OFF THIS BUTTON (`GameButton`'s `keepFocus`). Left focused, a person
|
|
@@ -652,8 +691,8 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
652
691
|
// between the render and the click.
|
|
653
692
|
onClick={() => {
|
|
654
693
|
const now = transport.autoplay?.(documentId) ?? NO_BOT;
|
|
655
|
-
if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed);
|
|
656
|
-
else transport.setAutoplay?.(documentId, !now.on, 'panel');
|
|
694
|
+
if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed, { behavior });
|
|
695
|
+
else transport.setAutoplay?.(documentId, !now.on, 'panel', now.on ? undefined : { behavior });
|
|
657
696
|
}}
|
|
658
697
|
>
|
|
659
698
|
Autoplay
|
|
@@ -664,12 +703,17 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
664
703
|
{note}
|
|
665
704
|
</span>
|
|
666
705
|
) : playing && (
|
|
706
|
+
// WHO DRIVES, AND WHAT THE BOT SAYS IT IS DOING: "Bot driving · win — heading to nest 2".
|
|
707
|
+
// Nobody drives a game that runs on no input; the person only once their input reached it.
|
|
667
708
|
<span
|
|
668
709
|
data-testid="model-play-driver"
|
|
669
|
-
data-driver={
|
|
670
|
-
|
|
710
|
+
data-driver={driver ?? undefined}
|
|
711
|
+
title={autoplay.state ? `The bot: ${autoplay.state}` : undefined}
|
|
712
|
+
style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
|
|
671
713
|
>
|
|
672
|
-
{
|
|
714
|
+
{driver === 'bot' ? `Bot driving${botLine ? ` · ${botLine}` : ''}`
|
|
715
|
+
: limitNote ? `${limitNote}${autoplay.state ? ` — ${autoplay.state}` : ''}`
|
|
716
|
+
: driver === 'person' ? 'You’re driving' : 'Nobody’s driving'}
|
|
673
717
|
</span>
|
|
674
718
|
)}
|
|
675
719
|
</div>
|
|
@@ -57,6 +57,7 @@ export const commands: CommandContribution['commands'] = {
|
|
|
57
57
|
// Stop drains accepted modeling work and persists it before teardown.
|
|
58
58
|
'blender-stop': verb('none', 30 * 60_000),
|
|
59
59
|
'blender-execute': verb('always', 30 * 60_000),
|
|
60
|
+
'blender-native-preview': verb('none', 120_000),
|
|
60
61
|
'blender-scene-info': verb('none', 60_000),
|
|
61
62
|
'blender-object-info': verb('none', 60_000),
|
|
62
63
|
'blender-screenshot-view': verb('always', 60_000),
|
package/dist-node/serving.mjs
CHANGED
|
@@ -117,9 +117,13 @@ async function blenderWasmStatus(env = process.env) {
|
|
|
117
117
|
`${dir} holds neither ${BLENDER_WASM_FILES.join(", ")} (the standalone skew) nor ${BLENDER_WALI_ARTIFACT} (the substrate skew)`
|
|
118
118
|
]
|
|
119
119
|
};
|
|
120
|
-
return skew === "wali" ? waliStatus(dir) : emscriptenStatus(dir);
|
|
120
|
+
return skew === "wali" ? waliStatus(dir) : emscriptenStatus(dir, env);
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
var BROWSER_SUBSTRATE_VARIABLE = "BROWSER_SUBSTRATE_URL";
|
|
123
|
+
function servedFromTab(env = process.env) {
|
|
124
|
+
return (env[BROWSER_SUBSTRATE_VARIABLE] ?? "").trim() !== "";
|
|
125
|
+
}
|
|
126
|
+
async function emscriptenStatus(dir, env) {
|
|
123
127
|
const sizes = {};
|
|
124
128
|
const encoded = {};
|
|
125
129
|
const missing = [];
|
|
@@ -132,7 +136,7 @@ async function emscriptenStatus(dir) {
|
|
|
132
136
|
sizes[file] = found.size;
|
|
133
137
|
if (found.encoding) encoded[file] = found.encoding;
|
|
134
138
|
}
|
|
135
|
-
return { available: missing.length === 0, skew: "emscripten", dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
|
|
139
|
+
return { available: missing.length === 0, skew: "emscripten", dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab(env) };
|
|
136
140
|
}
|
|
137
141
|
async function emscriptenDigests(dir) {
|
|
138
142
|
const digests = {};
|
|
@@ -20,12 +20,9 @@
|
|
|
20
20
|
* through `capture-active-document`, after `blender-screenshot-view` has
|
|
21
21
|
* presented the model with the viewport's own camera.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
26
|
-
* whose capture carries the SCENE camera and `scene.render`'s resolution, and
|
|
27
|
-
* the PNG goes straight back to the operator that asked — the one case where
|
|
28
|
-
* presenting answers with pixels instead of remembering a view.
|
|
23
|
+
* VOLTER_THREE photographs a detached revision through its own camera.
|
|
24
|
+
* Native CYCLES renders inside the Blender worker. A native preview is a
|
|
25
|
+
* read of that document through Cycles, separate from its Three presentation.
|
|
29
26
|
*/
|
|
30
27
|
|
|
31
28
|
import {
|
|
@@ -56,8 +53,7 @@ import {
|
|
|
56
53
|
resolveSceneLinearDisplay,
|
|
57
54
|
type LinearCaptureFrame,
|
|
58
55
|
} from '@volter/editor-threejs/capture/scene';
|
|
59
|
-
import {
|
|
60
|
-
import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
|
|
56
|
+
import {blenderRenderCamera} from '@volter/blender-engine/browser/three/blender-render-camera';
|
|
61
57
|
import * as THREE from 'three';
|
|
62
58
|
import { blenderEngineSelection, refreshBlenderOutliner } from '../contributions/blender-outliner-model';
|
|
63
59
|
import {
|
|
@@ -162,6 +158,7 @@ export async function photographSnapshot(
|
|
|
162
158
|
await snapshot.prepare(renderCamera);
|
|
163
159
|
assertCurrent();
|
|
164
160
|
const captureOptions = {
|
|
161
|
+
prepareDraw:snapshot.prepareDraw,
|
|
165
162
|
effect: snapshot.effect,
|
|
166
163
|
width: render.width,
|
|
167
164
|
height: render.height,
|
|
@@ -222,16 +219,29 @@ export function bindModelDocument(bound: ModelDocumentBinding | null): () => voi
|
|
|
222
219
|
};
|
|
223
220
|
}
|
|
224
221
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
/** The `.blend` the editor is showing as the Model: the active tab's own file, or the bound one.
|
|
223
|
+
* The finder owns model:<path>, wrapped by the host as document:<entry>. Read the active address
|
|
224
|
+
* too: activation precedes the contributed pane's effect, so an immediate command must not slip
|
|
225
|
+
* through that binding gap. */
|
|
226
|
+
function shownModelBlend(): string | undefined {
|
|
230
227
|
const activeId = editorHost().documents.activeId();
|
|
231
|
-
|
|
228
|
+
return activeId?.startsWith('document:model:')
|
|
232
229
|
? activeId.slice('document:model:'.length)
|
|
233
230
|
: boundModel?.blend;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A script's Save As into a project `.blend` that no Model document lists (`followMovedDocument`): Blender edits
|
|
235
|
+
* that file and saves there, and the tab it left (`from`) keeps showing Blender, since there is no tab to move to.
|
|
236
|
+
* Without this, every later call was refused as a conflict naming a file no tab can show.
|
|
237
|
+
*/
|
|
238
|
+
let unlistedFollow: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
239
|
+
|
|
240
|
+
function modelDocumentConflict(): string | null {
|
|
241
|
+
const held = runtime?.document;
|
|
242
|
+
const requested = shownModelBlend();
|
|
234
243
|
if (!requested || !held || requested === held) return null;
|
|
244
|
+
if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
|
|
235
245
|
// Say how to get Blender onto the document the editor shows, not only how to go back: an agent told
|
|
236
246
|
// just "return to <held>" took it as the Blender door being unusable and built its level by
|
|
237
247
|
// another route (2026-10-06, the WSL obby run).
|
|
@@ -513,12 +523,14 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
|
|
|
513
523
|
* a first version read `answer.executed`/`answer.error` off the string, where
|
|
514
524
|
* both are `undefined` — every operator RAN and every caller was told it had
|
|
515
525
|
* failed, which is how an added sphere reached `bpy.data.objects` and the
|
|
516
|
-
* Outliner and was never selected.
|
|
517
|
-
*
|
|
518
|
-
* second time, so the parse lives at the door now and both read it.
|
|
526
|
+
* Outliner and was never selected. Read-only metadata uses dedicated native
|
|
527
|
+
* reads rather than this execute path, which always presents a mutation.
|
|
519
528
|
*/
|
|
520
529
|
export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
|
|
521
|
-
const
|
|
530
|
+
const session = blenderRuntime();
|
|
531
|
+
const text = await session.execute(code, history, label);
|
|
532
|
+
// Before the caller hears back: its next call must find the tab on the file Blender now holds.
|
|
533
|
+
await followMovedDocument(session);
|
|
522
534
|
noteBlenderRnaChanged();
|
|
523
535
|
const failed = /^Error executing code:/.exec(text);
|
|
524
536
|
return {
|
|
@@ -529,6 +541,71 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
529
541
|
};
|
|
530
542
|
}
|
|
531
543
|
|
|
544
|
+
/**
|
|
545
|
+
* A Save As the worker reported (`BlenderRuntimeOptions.documentMoved`) that the open Model tab
|
|
546
|
+
* has not followed yet. Taken by the {@link blenderExecute} whose script moved it, once that
|
|
547
|
+
* call's own save has landed the new file in the project.
|
|
548
|
+
*/
|
|
549
|
+
let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* THE MODEL TAB FOLLOWS BLENDER'S SAVE AS. The runtime already holds the new file and its saves
|
|
553
|
+
* go there (`session.py::follow_open_file`); what is left is the tab. Until it moves, the editor
|
|
554
|
+
* is showing the old file while Blender edits the new one, which `modelDocumentConflict` refuses
|
|
555
|
+
* on every later call. So the new file's own Model document is opened: it binds to the session
|
|
556
|
+
* that already holds that file (no stop, no reload), and the old tab, no longer active, releases
|
|
557
|
+
* its binding. Re-activating the old tab later opens the old file the ordinary way.
|
|
558
|
+
*
|
|
559
|
+
* Only a tab that was showing the old file (or the standing Model of a project that had no
|
|
560
|
+
* `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A new file
|
|
561
|
+
* no Model document lists (outside the project's model finder) is said, not guessed at.
|
|
562
|
+
*/
|
|
563
|
+
async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
|
|
564
|
+
const move = unfollowedMove;
|
|
565
|
+
if (!move || move.owner !== owner) return;
|
|
566
|
+
unfollowedMove = null;
|
|
567
|
+
const host = editorHost();
|
|
568
|
+
// The tab showing Blender: the file Blender left, or, after an earlier Save As to an unlisted file, the tab
|
|
569
|
+
// that one left.
|
|
570
|
+
const shownFrom = unlistedFollow !== null && unlistedFollow.owner === owner && unlistedFollow.document === move.from ? unlistedFollow.from : move.from;
|
|
571
|
+
unlistedFollow = null;
|
|
572
|
+
const stillShowingOld = () => {
|
|
573
|
+
if (runtime !== owner || owner.document !== move.document) return false;
|
|
574
|
+
const shown = shownModelBlend();
|
|
575
|
+
return shown === shownFrom || (shown === undefined && host.documents.activeId() === BLENDER_RUNTIME_DOCUMENT_ID);
|
|
576
|
+
};
|
|
577
|
+
if (!stillShowingOld()) return;
|
|
578
|
+
try {
|
|
579
|
+
// The table re-reads the project's files, which now include the file this call saved.
|
|
580
|
+
const table = await host.project.documentTable();
|
|
581
|
+
const entry = table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
|
|
582
|
+
if (!entry) {
|
|
583
|
+
if (!stillShowingOld()) return;
|
|
584
|
+
unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
585
|
+
host.console.warn(
|
|
586
|
+
`Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
|
|
587
|
+
`(the project's model finder does not include its folder), so the ${shownFrom} tab keeps showing ` +
|
|
588
|
+
`Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
|
|
589
|
+
`under src/models/ to give it a Model tab of its own.`,
|
|
590
|
+
'blender-document',
|
|
591
|
+
);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (!stillShowingOld()) return;
|
|
595
|
+
if (!await host.workspace.open({ kind: 'document', id: entry.id })) {
|
|
596
|
+
keepShowing();
|
|
597
|
+
host.console.warn(`Blender now has ${move.document} open, but its Model document ${entry.id} did not open; the ${shownFrom} tab keeps showing Blender, which edits ${move.document}.`, 'blender-document');
|
|
598
|
+
}
|
|
599
|
+
} catch (error) {
|
|
600
|
+
keepShowing();
|
|
601
|
+
host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
|
|
602
|
+
}
|
|
603
|
+
/** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
|
|
604
|
+
function keepShowing(): void {
|
|
605
|
+
if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move!.document };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
532
609
|
/** Coalesce a human gesture; Blender retains the states, Code-OSS the ordering. */
|
|
533
610
|
export function beginBlenderGesture(): void {
|
|
534
611
|
void blenderRuntime().historyGesture('history-begin').catch(error =>
|
|
@@ -1033,35 +1110,10 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1033
1110
|
);
|
|
1034
1111
|
const focus = new THREE.Vector3(target[0], target[1], target[2]).applyMatrix4(toDocument);
|
|
1035
1112
|
const upward = new THREE.Vector3(up[0], up[1], up[2]).applyMatrix3(documentBasis);
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const bounds = contentWorldBounds(snapshot.root).getBoundingSphere(new THREE.Sphere());
|
|
1041
|
-
const documentView = fitClipPlanes(eye.distanceTo(bounds.center), bounds.radius);
|
|
1042
|
-
let renderCamera: THREE.Camera;
|
|
1043
|
-
if (render.orthographic) {
|
|
1044
|
-
// An ORTHO scene camera. `fov` carries `ortho_scale` as the VERTICAL
|
|
1045
|
-
// extent in Blender units (`_vertical_extent` applies `sensor_fit`), so
|
|
1046
|
-
// the frustum is stated outright instead of being reverse-derived from
|
|
1047
|
-
// a perspective camera's distance and angle.
|
|
1048
|
-
const halfHeight = render.fov / 2;
|
|
1049
|
-
renderCamera = new THREE.OrthographicCamera(
|
|
1050
|
-
-halfHeight * aspect,
|
|
1051
|
-
halfHeight * aspect,
|
|
1052
|
-
halfHeight,
|
|
1053
|
-
-halfHeight,
|
|
1054
|
-
documentView.near,
|
|
1055
|
-
documentView.far,
|
|
1056
|
-
);
|
|
1057
|
-
} else {
|
|
1058
|
-
renderCamera = new THREE.PerspectiveCamera(
|
|
1059
|
-
render.fov,
|
|
1060
|
-
aspect,
|
|
1061
|
-
documentView.near,
|
|
1062
|
-
documentView.far,
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1113
|
+
// The native camera's near/far and lens shift describe this picture.
|
|
1114
|
+
// Geometry bounds are for interactive navigation, not render framing.
|
|
1115
|
+
const documentScale = eye.distanceTo(focus);
|
|
1116
|
+
const renderCamera = blenderRenderCamera(render, documentScale);
|
|
1065
1117
|
renderCamera.up.copy(upward);
|
|
1066
1118
|
renderCamera.position.copy(eye);
|
|
1067
1119
|
renderCamera.lookAt(focus);
|
|
@@ -1107,6 +1159,19 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1107
1159
|
// traceback out of it.
|
|
1108
1160
|
// biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
|
|
1109
1161
|
log: (level, text) => console[level](`[blender] ${text}`),
|
|
1162
|
+
// A SCRIPT'S SAVE AS MOVED THE DOCUMENT. Said in the editor's console, where the change of
|
|
1163
|
+
// file is a fact a person needs; the tab follows once the script's call answers
|
|
1164
|
+
// (`followMovedDocument`), when the new file is in the project to open.
|
|
1165
|
+
documentMoved: (moved) => {
|
|
1166
|
+
const owner = runtime;
|
|
1167
|
+
if (!owner) return;
|
|
1168
|
+
unfollowedMove = { owner, from: moved.from, document: moved.document };
|
|
1169
|
+
editorHost().console.log(
|
|
1170
|
+
`Blender now has ${moved.document} open (a script saved or opened it there), so the model saves to ` +
|
|
1171
|
+
`${moved.document} from now on; ${moved.from} keeps what it last saved.`,
|
|
1172
|
+
'blender-document',
|
|
1173
|
+
);
|
|
1174
|
+
},
|
|
1110
1175
|
});
|
|
1111
1176
|
const session = runtime;
|
|
1112
1177
|
// MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
|
|
@@ -1152,36 +1217,14 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
|
|
|
1152
1217
|
/**
|
|
1153
1218
|
* The project-relative path of the `.blend` the SESSION'S PYTHON currently
|
|
1154
1219
|
* holds — `bpy.data.filepath`, which a bake script's own `open_mainfile`
|
|
1155
|
-
* retargets
|
|
1220
|
+
* retargets as it runs (the session's document follows only once the call has
|
|
1221
|
+
* ended, and only inside the project). Answers `{}` when there
|
|
1156
1222
|
* is no file (a scene modelled from scratch has never been saved) or when the
|
|
1157
1223
|
* path is outside the project, because a record is better absent than wrong.
|
|
1158
1224
|
*/
|
|
1159
|
-
|
|
1160
|
-
execute(code: string, history?: boolean): Promise<string>;
|
|
1161
|
-
}): Promise<{ document?: string }> {
|
|
1225
|
+
function sessionDocumentPath(filepath: string): { document?: string } {
|
|
1162
1226
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
1163
1227
|
if (project === null) return {};
|
|
1164
|
-
let answer: string;
|
|
1165
|
-
try {
|
|
1166
|
-
answer = await session.execute('import bpy\nprint(bpy.data.filepath)\n', false);
|
|
1167
|
-
} catch {
|
|
1168
|
-
return {};
|
|
1169
|
-
}
|
|
1170
|
-
// PARSE WHAT `execute` ACTUALLY ANSWERS, which is the MCP door's shape and
|
|
1171
|
-
// not raw stdout: `Code executed successfully: <stdout>` on ONE line, or
|
|
1172
|
-
// `Error executing code: …`. A first version filtered for lines starting
|
|
1173
|
-
// with `/` and therefore matched nothing, because the path sits after that
|
|
1174
|
-
// prefix on the same line — and a document that cannot be read is
|
|
1175
|
-
// indistinguishable from a session that has none, so the failure was silent
|
|
1176
|
-
// and the nine bakes recorded no input at all.
|
|
1177
|
-
if (/^Error executing code:/m.test(answer)) return {};
|
|
1178
|
-
const filepath =
|
|
1179
|
-
answer
|
|
1180
|
-
.replace(/^Code executed successfully:/, '')
|
|
1181
|
-
.split('\n')
|
|
1182
|
-
.map((line) => line.trim())
|
|
1183
|
-
.filter((line) => line.startsWith('/'))
|
|
1184
|
-
.pop() ?? '';
|
|
1185
1228
|
if (filepath === '') return {};
|
|
1186
1229
|
const root = project.endsWith('/') ? project : `${project}/`;
|
|
1187
1230
|
if (!filepath.startsWith(root)) return {};
|
|
@@ -1272,10 +1315,17 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1272
1315
|
// project's document is its own `src/models/<name>.blend`, and a
|
|
1273
1316
|
// `blender-start` that named none is asking for the session this tab
|
|
1274
1317
|
// is showing, not for a second file beside it.
|
|
1318
|
+
// After a script's Save As to a file no Model lists, the session this tab
|
|
1319
|
+
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1320
|
+
// asks for it, not for the file the tab was opened on.
|
|
1321
|
+
const shown =
|
|
1322
|
+
unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
|
|
1323
|
+
? unlistedFollow.document
|
|
1324
|
+
: boundModel?.blend;
|
|
1275
1325
|
const document =
|
|
1276
1326
|
requestedDocument !== undefined
|
|
1277
1327
|
? requestedDocument
|
|
1278
|
-
: (
|
|
1328
|
+
: (shown ?? DEFAULT_BLENDER_DOCUMENT);
|
|
1279
1329
|
return { ok: true, data: { ...(await session.start(project!, document)) } };
|
|
1280
1330
|
}
|
|
1281
1331
|
case 'blender-execute': {
|
|
@@ -1435,6 +1485,17 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1435
1485
|
const document = { kind: 'document', id: boundModel?.entryId ?? 'blender:runtime' };
|
|
1436
1486
|
return { ok: true, data: { ...await session.screenshotView(maxSize), document } };
|
|
1437
1487
|
}
|
|
1488
|
+
case 'blender-native-preview': {
|
|
1489
|
+
const camera = cmd['camera'];
|
|
1490
|
+
if (camera !== undefined && typeof camera !== 'string') throw new Error('Native preview camera must be a name');
|
|
1491
|
+
const preview = await session.nativePreview({
|
|
1492
|
+
width: cmd['width'] as number,
|
|
1493
|
+
height: cmd['height'] as number,
|
|
1494
|
+
samples: cmd['samples'] as number,
|
|
1495
|
+
...(camera === undefined ? {} : { camera }),
|
|
1496
|
+
});
|
|
1497
|
+
return { ok: true, data: { ...preview } };
|
|
1498
|
+
}
|
|
1438
1499
|
case 'blender-read-file': {
|
|
1439
1500
|
// The bytes go out as a BODY, not inside this command's answer -- see
|
|
1440
1501
|
// `/__editor/blender-file` in `server/routes/relay.ts` for the
|
|
@@ -1464,13 +1525,13 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1464
1525
|
// what it can honestly name is this session and the model state it had
|
|
1465
1526
|
// reached — taken here, at the instant of the listing, rather than
|
|
1466
1527
|
// asked for afterwards when another call may have moved it.
|
|
1467
|
-
const entries = await session.
|
|
1468
|
-
const presented = session.presented;
|
|
1528
|
+
const { entries, document } = await session.listFilesSnapshot(string(cmd, 'path'));
|
|
1469
1529
|
return {
|
|
1470
1530
|
ok: true,
|
|
1471
1531
|
data: {
|
|
1472
1532
|
entries,
|
|
1473
|
-
|
|
1533
|
+
session: document.session,
|
|
1534
|
+
revision: document.revision,
|
|
1474
1535
|
// AND WHAT THE BYTES WERE MADE FROM. A session-written `.glb` is
|
|
1475
1536
|
// exported from a `.blend`, and that path is the one fact that
|
|
1476
1537
|
// lets a reader go DOWN by kind later — from a prefab's glTF to
|
|
@@ -1481,14 +1542,14 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1481
1542
|
// IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
|
|
1482
1543
|
// is a measurement rather than a preference. `boundModel` is the
|
|
1483
1544
|
// document the TAB opened; a bake script opens its own file with
|
|
1484
|
-
// `wm.open_mainfile`, which `
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1545
|
+
// `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
|
|
1546
|
+
// the tab only after the call (`followMovedDocument`), and never
|
|
1547
|
+
// for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
|
|
1487
1548
|
// the SAME `.blend` as the input of all three of first-person's
|
|
1488
1549
|
// characters — whichever one the tab happened to show — which is
|
|
1489
1550
|
// worse than no record, because it is a confident wrong answer.
|
|
1490
1551
|
// `bpy.data.filepath` is what the export actually came out of.
|
|
1491
|
-
...
|
|
1552
|
+
...sessionDocumentPath(document.filepath),
|
|
1492
1553
|
},
|
|
1493
1554
|
};
|
|
1494
1555
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-blender",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.133",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -72,15 +72,15 @@
|
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
75
|
-
"@volter/blender-engine": "0.1.
|
|
76
|
-
"@volter/editor-threejs": "0.5.
|
|
75
|
+
"@volter/blender-engine": "0.1.131",
|
|
76
|
+
"@volter/editor-threejs": "0.5.194",
|
|
77
77
|
"zod": "^4.3.6",
|
|
78
78
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@volter/editor-live": "0.5.
|
|
82
|
-
"@volter/editor-project": "0.5.
|
|
83
|
-
"@volter/editor-sdk": "0.5.
|
|
81
|
+
"@volter/editor-live": "0.5.194",
|
|
82
|
+
"@volter/editor-project": "0.5.194",
|
|
83
|
+
"@volter/editor-sdk": "0.5.194",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|
|
@@ -190,6 +190,10 @@ export interface BlenderWasmStatus {
|
|
|
190
190
|
/** Emscripten: each file's SHA-256 as it decodes (`BUNDLE.json#rawFiles`,
|
|
191
191
|
* `essentials.json`), so a page can keep the bytes it fetched once. */
|
|
192
192
|
digests?: Record<string, string>;
|
|
193
|
+
/** Emscripten: this server runs in the page's own tab ({@link servedFromTab}). Only then does
|
|
194
|
+
* the page keep the files in its Cache Storage; served by a process on this machine, the
|
|
195
|
+
* browser's HTTP cache keeps them (`blender-emscripten-engine.mts`, `cachedArtifact`). */
|
|
196
|
+
inTab?: boolean;
|
|
193
197
|
/** WALI only: the `sha256-` SRI of `blender.wasm`. The program loader
|
|
194
198
|
* refuses any non-blob URL without one, so the session names the exact
|
|
195
199
|
* bytes it ran. */
|
|
@@ -236,10 +240,22 @@ export async function blenderWasmStatus(
|
|
|
236
240
|
`${BLENDER_WALI_ARTIFACT} (the substrate skew)`,
|
|
237
241
|
],
|
|
238
242
|
};
|
|
239
|
-
return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir);
|
|
243
|
+
return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir, env);
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
|
|
246
|
+
/**
|
|
247
|
+
* WHETHER THIS SERVER RUNS IN THE BROWSER TAB IT SERVES. browser-substrate names its page to the
|
|
248
|
+
* first process it starts (`BROWSER_SUBSTRATE_URL`, its `openProject`), the project's Procfile runs
|
|
249
|
+
* `edit` as that process, and `edit` hands its environment to the session it spawns
|
|
250
|
+
* (`launcher/launch.ts`). A session started on this machine has no such variable.
|
|
251
|
+
*/
|
|
252
|
+
export const BROWSER_SUBSTRATE_VARIABLE = 'BROWSER_SUBSTRATE_URL';
|
|
253
|
+
|
|
254
|
+
export function servedFromTab(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
255
|
+
return (env[BROWSER_SUBSTRATE_VARIABLE] ?? '').trim() !== '';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function emscriptenStatus(dir: string, env: NodeJS.ProcessEnv): Promise<BlenderWasmStatus> {
|
|
243
259
|
const sizes: Record<string, number> = {};
|
|
244
260
|
const encoded: Record<string, 'br'> = {};
|
|
245
261
|
const missing: string[] = [];
|
|
@@ -252,7 +268,7 @@ async function emscriptenStatus(dir: string): Promise<BlenderWasmStatus> {
|
|
|
252
268
|
sizes[file] = found.size;
|
|
253
269
|
if (found.encoding) encoded[file] = found.encoding;
|
|
254
270
|
}
|
|
255
|
-
return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
|
|
271
|
+
return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab(env) };
|
|
256
272
|
}
|
|
257
273
|
|
|
258
274
|
/** The decoded bytes' digests the build records, for the files it records them for. */
|