@volter/editor-blender 0.1.132 → 0.1.134
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/contributions/blender-game-panel.tsx +56 -12
- package/host/blender-runtime-host.ts +151 -16
- package/package.json +6 -6
|
@@ -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>
|
|
@@ -43,7 +43,7 @@ import type {
|
|
|
43
43
|
BlenderUvLayout,
|
|
44
44
|
} from '@volter/blender-engine/browser/rna';
|
|
45
45
|
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
46
|
-
import
|
|
46
|
+
import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
47
47
|
import type { EditorCommandResult } from '@volter/editor-sdk/commands';
|
|
48
48
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
49
49
|
import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
|
|
@@ -158,6 +158,7 @@ export async function photographSnapshot(
|
|
|
158
158
|
await snapshot.prepare(renderCamera);
|
|
159
159
|
assertCurrent();
|
|
160
160
|
const captureOptions = {
|
|
161
|
+
prepareDraw:snapshot.prepareDraw,
|
|
161
162
|
effect: snapshot.effect,
|
|
162
163
|
width: render.width,
|
|
163
164
|
height: render.height,
|
|
@@ -218,16 +219,29 @@ export function bindModelDocument(bound: ModelDocumentBinding | null): () => voi
|
|
|
218
219
|
};
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 {
|
|
226
227
|
const activeId = editorHost().documents.activeId();
|
|
227
|
-
|
|
228
|
+
return activeId?.startsWith('document:model:')
|
|
228
229
|
? activeId.slice('document:model:'.length)
|
|
229
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();
|
|
230
243
|
if (!requested || !held || requested === held) return null;
|
|
244
|
+
if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
|
|
231
245
|
// Say how to get Blender onto the document the editor shows, not only how to go back: an agent told
|
|
232
246
|
// just "return to <held>" took it as the Blender door being unusable and built its level by
|
|
233
247
|
// another route (2026-10-06, the WSL obby run).
|
|
@@ -513,7 +527,10 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
|
|
|
513
527
|
* reads rather than this execute path, which always presents a mutation.
|
|
514
528
|
*/
|
|
515
529
|
export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
|
|
516
|
-
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);
|
|
517
534
|
noteBlenderRnaChanged();
|
|
518
535
|
const failed = /^Error executing code:/.exec(text);
|
|
519
536
|
return {
|
|
@@ -524,6 +541,71 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
524
541
|
};
|
|
525
542
|
}
|
|
526
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
|
+
|
|
527
609
|
/** Coalesce a human gesture; Blender retains the states, Code-OSS the ordering. */
|
|
528
610
|
export function beginBlenderGesture(): void {
|
|
529
611
|
void blenderRuntime().historyGesture('history-begin').catch(error =>
|
|
@@ -739,6 +821,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
739
821
|
}
|
|
740
822
|
|
|
741
823
|
interface RuntimeView {
|
|
824
|
+
readonly root: THREE.Group;
|
|
742
825
|
stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
|
|
743
826
|
applyFrame(frame: unknown): unknown;
|
|
744
827
|
snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
|
|
@@ -821,6 +904,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
|
821
904
|
typeof (value as RuntimeView).applyFrame === 'function' &&
|
|
822
905
|
typeof (value as RuntimeView).stageFrame === 'function' &&
|
|
823
906
|
typeof (value as RuntimeView).snapshot === 'function' &&
|
|
907
|
+
(value as RuntimeView).root?.isObject3D === true &&
|
|
824
908
|
typeof (value as RuntimeView).captureSnapshot === 'function' &&
|
|
825
909
|
typeof (value as RuntimeView).recordPresentation === 'function' &&
|
|
826
910
|
typeof (value as RuntimeView).recordPhotograph === 'function';
|
|
@@ -869,11 +953,20 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
869
953
|
captureLifetime = lifetime;
|
|
870
954
|
let photographing = false;
|
|
871
955
|
let streamedFrame: { view: RuntimeView; session: string; revision: number } | null = null;
|
|
956
|
+
let renderFrame: { view: BlenderRuntimeView; source: RuntimeView; sourceFrame: ReturnType<RuntimeView['snapshot']>; documentId: string; session: string; revision: number } | null = null;
|
|
957
|
+
const discardRenderFrame = () => {
|
|
958
|
+
const owned = renderFrame;
|
|
959
|
+
renderFrame = null;
|
|
960
|
+
owned?.view.dispose();
|
|
961
|
+
owned?.view.root.removeFromParent();
|
|
962
|
+
};
|
|
872
963
|
const discardStreamedFrame = () => {
|
|
873
964
|
const staged = streamedFrame;
|
|
874
965
|
streamedFrame = null;
|
|
875
966
|
if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
|
|
967
|
+
discardRenderFrame();
|
|
876
968
|
};
|
|
969
|
+
lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
|
|
877
970
|
runtime = new BlenderRuntime({
|
|
878
971
|
work: beginBlenderWork,
|
|
879
972
|
history: (entries) => {
|
|
@@ -915,6 +1008,20 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
915
1008
|
const view = await runtimeView();
|
|
916
1009
|
lifetime.signal.throwIfAborted();
|
|
917
1010
|
if (presentationDocumentId() !== documentId) throw new Error('Blender document changed during frame transfer');
|
|
1011
|
+
if (part.evaluation === 'render') {
|
|
1012
|
+
if (!renderFrame) {
|
|
1013
|
+
if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
|
|
1014
|
+
const detached = BlenderRuntimeView.forPhotograph();
|
|
1015
|
+
view.root.updateWorldMatrix(true, false);
|
|
1016
|
+
detached.root.matrix.copy(view.root.matrixWorld);
|
|
1017
|
+
renderFrame = { view: detached, source: view, sourceFrame: view.snapshot(), documentId, session: part.session, revision: part.revision };
|
|
1018
|
+
}
|
|
1019
|
+
if (renderFrame.source !== view || renderFrame.sourceFrame !== view.snapshot() || renderFrame.documentId !== documentId || renderFrame.session !== part.session || renderFrame.revision !== part.revision)
|
|
1020
|
+
throw new Error('Blender capture source changed during render transfer');
|
|
1021
|
+
renderFrame.view.stageFrame(part);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
|
|
918
1025
|
if (streamedFrame && streamedFrame.view !== view)
|
|
919
1026
|
throw new Error('Blender presenter changed during frame transfer');
|
|
920
1027
|
view.stageFrame(part);
|
|
@@ -945,6 +1052,12 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
945
1052
|
throw new Error('Blender document changed before its frame could be presented');
|
|
946
1053
|
if (streamedFrame && streamedFrame.view !== view)
|
|
947
1054
|
throw new Error('Blender presenter changed before its frame could be presented');
|
|
1055
|
+
const rendering = capture?.evaluation === 'render' ? renderFrame : null;
|
|
1056
|
+
if (capture?.evaluation === 'render' && !rendering)
|
|
1057
|
+
throw new Error('Render-evaluated frame arrived without its staged resources');
|
|
1058
|
+
if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
|
|
1059
|
+
throw new Error('Blender capture source changed before render presentation');
|
|
1060
|
+
const presenter = rendering?.view ?? view;
|
|
948
1061
|
// WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
|
|
949
1062
|
// beside whatever this present produced. The session only has a RECORD of
|
|
950
1063
|
// what it sent; this view is the authority on what it actually holds, and
|
|
@@ -954,7 +1067,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
954
1067
|
// all, rather than a wrong `null`.
|
|
955
1068
|
const endApply = beginBlenderWork('applying frame to Model');
|
|
956
1069
|
const applied = (() => {
|
|
957
|
-
try { return
|
|
1070
|
+
try { return presenter.applyFrame(frame) as { held?: unknown } | null | undefined; }
|
|
958
1071
|
finally { endApply(); }
|
|
959
1072
|
})();
|
|
960
1073
|
streamedFrame = null;
|
|
@@ -967,7 +1080,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
967
1080
|
// THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
|
|
968
1081
|
// a refused frame is not displayed and must not be described as if it
|
|
969
1082
|
// were.
|
|
970
|
-
|
|
1083
|
+
presenter.recordPresentation(description);
|
|
971
1084
|
if (!capture) return answer(undefined);
|
|
972
1085
|
lastCapture = capture;
|
|
973
1086
|
if (!capture.render) return answer(undefined);
|
|
@@ -1009,7 +1122,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1009
1122
|
'A Blender render capture must carry the scene camera position, target and up',
|
|
1010
1123
|
);
|
|
1011
1124
|
if (photographing) throw new Error('A Blender render capture is already in progress');
|
|
1012
|
-
const snapshot = view.captureSnapshot();
|
|
1125
|
+
const snapshot = rendering ? rendering.view.takeCaptureSnapshot() : view.captureSnapshot();
|
|
1013
1126
|
photographing = true;
|
|
1014
1127
|
lifetime.signal.addEventListener('abort', snapshot.dispose, { once: true });
|
|
1015
1128
|
try {
|
|
@@ -1068,6 +1181,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1068
1181
|
photographing = false;
|
|
1069
1182
|
lifetime.signal.removeEventListener('abort', snapshot.dispose);
|
|
1070
1183
|
snapshot.dispose();
|
|
1184
|
+
if (rendering && renderFrame === rendering) renderFrame = null;
|
|
1071
1185
|
}
|
|
1072
1186
|
},
|
|
1073
1187
|
// The worker's OWN stdout and stderr, forwarded so a developer can read
|
|
@@ -1077,6 +1191,19 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1077
1191
|
// traceback out of it.
|
|
1078
1192
|
// biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
|
|
1079
1193
|
log: (level, text) => console[level](`[blender] ${text}`),
|
|
1194
|
+
// A SCRIPT'S SAVE AS MOVED THE DOCUMENT. Said in the editor's console, where the change of
|
|
1195
|
+
// file is a fact a person needs; the tab follows once the script's call answers
|
|
1196
|
+
// (`followMovedDocument`), when the new file is in the project to open.
|
|
1197
|
+
documentMoved: (moved) => {
|
|
1198
|
+
const owner = runtime;
|
|
1199
|
+
if (!owner) return;
|
|
1200
|
+
unfollowedMove = { owner, from: moved.from, document: moved.document };
|
|
1201
|
+
editorHost().console.log(
|
|
1202
|
+
`Blender now has ${moved.document} open (a script saved or opened it there), so the model saves to ` +
|
|
1203
|
+
`${moved.document} from now on; ${moved.from} keeps what it last saved.`,
|
|
1204
|
+
'blender-document',
|
|
1205
|
+
);
|
|
1206
|
+
},
|
|
1080
1207
|
});
|
|
1081
1208
|
const session = runtime;
|
|
1082
1209
|
// MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
|
|
@@ -1122,7 +1249,8 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
|
|
|
1122
1249
|
/**
|
|
1123
1250
|
* The project-relative path of the `.blend` the SESSION'S PYTHON currently
|
|
1124
1251
|
* holds — `bpy.data.filepath`, which a bake script's own `open_mainfile`
|
|
1125
|
-
* retargets
|
|
1252
|
+
* retargets as it runs (the session's document follows only once the call has
|
|
1253
|
+
* ended, and only inside the project). Answers `{}` when there
|
|
1126
1254
|
* is no file (a scene modelled from scratch has never been saved) or when the
|
|
1127
1255
|
* path is outside the project, because a record is better absent than wrong.
|
|
1128
1256
|
*/
|
|
@@ -1219,10 +1347,17 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1219
1347
|
// project's document is its own `src/models/<name>.blend`, and a
|
|
1220
1348
|
// `blender-start` that named none is asking for the session this tab
|
|
1221
1349
|
// is showing, not for a second file beside it.
|
|
1350
|
+
// After a script's Save As to a file no Model lists, the session this tab
|
|
1351
|
+
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1352
|
+
// asks for it, not for the file the tab was opened on.
|
|
1353
|
+
const shown =
|
|
1354
|
+
unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
|
|
1355
|
+
? unlistedFollow.document
|
|
1356
|
+
: boundModel?.blend;
|
|
1222
1357
|
const document =
|
|
1223
1358
|
requestedDocument !== undefined
|
|
1224
1359
|
? requestedDocument
|
|
1225
|
-
: (
|
|
1360
|
+
: (shown ?? DEFAULT_BLENDER_DOCUMENT);
|
|
1226
1361
|
return { ok: true, data: { ...(await session.start(project!, document)) } };
|
|
1227
1362
|
}
|
|
1228
1363
|
case 'blender-execute': {
|
|
@@ -1439,9 +1574,9 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1439
1574
|
// IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
|
|
1440
1575
|
// is a measurement rather than a preference. `boundModel` is the
|
|
1441
1576
|
// document the TAB opened; a bake script opens its own file with
|
|
1442
|
-
// `wm.open_mainfile`, which `
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1577
|
+
// `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
|
|
1578
|
+
// the tab only after the call (`followMovedDocument`), and never
|
|
1579
|
+
// for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
|
|
1445
1580
|
// the SAME `.blend` as the input of all three of first-person's
|
|
1446
1581
|
// characters — whichever one the tab happened to show — which is
|
|
1447
1582
|
// worse than no record, because it is a confident wrong answer.
|
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.134",
|
|
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.132",
|
|
76
|
+
"@volter/editor-threejs": "0.5.195",
|
|
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.195",
|
|
82
|
+
"@volter/editor-project": "0.5.195",
|
|
83
|
+
"@volter/editor-sdk": "0.5.195",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|