@volter/editor-blender 0.1.128 → 0.1.130
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 +764 -0
- package/contributions/blender-header-menus.tsx +7 -6
- package/contributions/blender-model-opening.tsx +12 -2
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +6 -7
- package/contributions/blender-properties-context.ts +45 -0
- package/contributions/blender-properties-model.ts +15 -3
- package/contributions/blender-properties-view.tsx +84 -10
- package/contributions/blender-runtime.document.tsx +238 -29
- package/contributions/blender-timeline.document.tsx +33 -3
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/model.layout.ts +4 -0
- package/contributions/models.finder.ts +5 -1
- package/contributions/properties-object.inspector.tsx +26 -27
- package/contributions/properties-scene.inspector.tsx +9 -2
- package/dist-node/serving.mjs +17 -1
- package/host/blender-runtime-host.ts +39 -99
- package/node/blender-mcp.ts +58 -12
- package/package.json +11 -8
- package/serving/index.ts +29 -0
- package/src/node-view-state.ts +1 -1
- package/src/play-mode.ts +146 -0
- package/src/timeline-view-state.ts +1 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +180 -0
|
@@ -25,11 +25,14 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
// How the `model` stage this document builds behaves (its starting presentation).
|
|
28
|
+
import './blender-properties-context';
|
|
28
29
|
import { blenderViewFieldOfView } from '../src/presentation';
|
|
29
30
|
import {
|
|
30
31
|
type BlenderRuntimeView,
|
|
31
32
|
blenderModelView,
|
|
32
33
|
} from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
34
|
+
import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
|
|
35
|
+
import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
33
36
|
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
|
|
34
37
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
35
38
|
import {
|
|
@@ -72,6 +75,7 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
|
72
75
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
73
76
|
import { blenderSkin } from './blender-runtime-skin';
|
|
74
77
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
78
|
+
import { noteModelDocument } from '../src/play-mode';
|
|
75
79
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
76
80
|
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
77
81
|
import {
|
|
@@ -90,6 +94,14 @@ import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-s
|
|
|
90
94
|
import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
|
|
91
95
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
92
96
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
97
|
+
import { commandLine } from '@volter/editor-sdk/kit/product-command';
|
|
98
|
+
import { fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
|
|
99
|
+
import { clearStartupFailure, reportStartupFailure } from '@volter/editor-sdk/kit/startup-failure';
|
|
100
|
+
|
|
101
|
+
/** This document's model open, as a source in the page's startup-failure registry. */
|
|
102
|
+
const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
|
|
103
|
+
/** How long "Opening model…" stands before the pane also says what it is waiting on. */
|
|
104
|
+
const MODEL_OPEN_NOTE_AFTER_MS = 30_000;
|
|
93
105
|
import {
|
|
94
106
|
clearModelDocumentPreview,
|
|
95
107
|
modelDocumentPreview,
|
|
@@ -98,6 +110,15 @@ import {
|
|
|
98
110
|
type ModelDocumentPreview,
|
|
99
111
|
} from '../src/model-document-preview';
|
|
100
112
|
|
|
113
|
+
/** A detached copy's identity as a React key (the play area is keyed on the copy it draws). */
|
|
114
|
+
const playCopyKeys = new WeakMap<object, number>();
|
|
115
|
+
let nextPlayCopyKey = 0;
|
|
116
|
+
function playCopyKey(copy: object): number {
|
|
117
|
+
let key = playCopyKeys.get(copy);
|
|
118
|
+
if (key === undefined) { key = ++nextPlayCopyKey; playCopyKeys.set(copy, key); }
|
|
119
|
+
return key;
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
/** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
|
|
102
123
|
const overlaysOpened = new WeakSet<object>();
|
|
103
124
|
|
|
@@ -222,6 +243,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
222
243
|
const entryId = document?.id;
|
|
223
244
|
const key = JSON.stringify([documentId, entryId, blend]);
|
|
224
245
|
const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
|
|
246
|
+
/** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
|
|
247
|
+
const [stillOpening, setStillOpening] = useState<string | null>(null);
|
|
225
248
|
const [attempt, setAttempt] = useState(0);
|
|
226
249
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
227
250
|
const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
|
|
@@ -287,6 +310,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
287
310
|
const unbind = bindModelDocument(binding);
|
|
288
311
|
const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
|
|
289
312
|
setOpened(null);
|
|
313
|
+
setStillOpening(null);
|
|
290
314
|
// Native focus can arrive after the contributed pane mounts. A declined
|
|
291
315
|
// open is not an in-flight load: retry when the host activates this model,
|
|
292
316
|
// rather than leaving "Opening model…" latched forever. Utility focus
|
|
@@ -294,18 +318,23 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
294
318
|
const documents = editorHost().documents;
|
|
295
319
|
let starting = false;
|
|
296
320
|
let finished = false;
|
|
321
|
+
let declined = false;
|
|
322
|
+
const path = blend ?? 'the model';
|
|
297
323
|
const open = async () => {
|
|
298
|
-
if (cancelled || starting || finished
|
|
324
|
+
if (cancelled || starting || finished) return;
|
|
325
|
+
if (binding && !modelDocumentMayOpen(binding)) { declined = true; return; }
|
|
299
326
|
starting = true;
|
|
327
|
+
declined = false;
|
|
300
328
|
try {
|
|
301
329
|
if (binding) {
|
|
302
|
-
if (!await openModelDocumentBlend(binding, publish)) return;
|
|
330
|
+
if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
|
|
303
331
|
} else {
|
|
304
332
|
// The standing Model document is the explicit blender-start target.
|
|
305
333
|
publish();
|
|
306
334
|
}
|
|
307
335
|
if (!cancelled) {
|
|
308
336
|
finished = true;
|
|
337
|
+
clearStartupFailure(MODEL_OPEN_STARTUP_SOURCE);
|
|
309
338
|
setOpened({ key, error: null });
|
|
310
339
|
}
|
|
311
340
|
} catch (error) {
|
|
@@ -316,11 +345,29 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
316
345
|
const detail = error instanceof Error ? error.message : String(error);
|
|
317
346
|
setOpened({ key, error: detail });
|
|
318
347
|
editorHost().console.error(detail, 'blender-open');
|
|
319
|
-
callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${
|
|
348
|
+
callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${path}`, detail: modelOpeningErrorMessage(detail) });
|
|
349
|
+
// AND TO THE PRODUCT'S COVER, which is still up while the first model opens and
|
|
350
|
+
// would otherwise go on saying "Opening the first model…" over this pane's error
|
|
351
|
+
// until its own budget ran out. After the cover has lifted, this changes nothing.
|
|
352
|
+
reportStartupFailure(MODEL_OPEN_STARTUP_SOURCE, {
|
|
353
|
+
message: `Blender could not open ${path}: ${detail}`,
|
|
354
|
+
guidance: `${modelOpeningErrorMessage(detail)} Dismiss this to reach the model's Retry.`,
|
|
355
|
+
command: null,
|
|
356
|
+
});
|
|
320
357
|
} finally {
|
|
321
358
|
starting = false;
|
|
322
359
|
}
|
|
323
360
|
};
|
|
361
|
+
// A SLOW OPEN SAYS WHAT IT IS WAITING ON (2026-10-06 audit: "Opening model…" had no word
|
|
362
|
+
// past its spinner, and a declined open sat there indefinitely). It is progress, never an
|
|
363
|
+
// error, and it offers no Retry (#147 review): the open is still running, and a second one
|
|
364
|
+
// would queue behind it. A real failure arrives through the catch above.
|
|
365
|
+
const slow = setInterval(() => {
|
|
366
|
+
if (cancelled || finished) return;
|
|
367
|
+
setStillOpening(declined
|
|
368
|
+
? `Still waiting: Blender is holding another model, and opens ${path} when it is free.`
|
|
369
|
+
: `Still opening ${path}: Blender is working (a large file, or a cold start). ${commandLine('status')} shows what it is busy with.`);
|
|
370
|
+
}, MODEL_OPEN_NOTE_AFTER_MS);
|
|
324
371
|
// Opening publishes context to other React roots. Start outside this
|
|
325
372
|
// effect (or a host activation listener's synchronous notification stack)
|
|
326
373
|
// so those roots cannot synchronously commit through a pending commit here.
|
|
@@ -334,6 +381,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
334
381
|
requestOpen();
|
|
335
382
|
return () => {
|
|
336
383
|
cancelled = true;
|
|
384
|
+
clearInterval(slow);
|
|
337
385
|
unsubscribe();
|
|
338
386
|
unpublish?.();
|
|
339
387
|
unbind();
|
|
@@ -343,6 +391,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
343
391
|
if (active === false || !documentId) return null;
|
|
344
392
|
if (opened?.key !== key || opened.error) return <BlenderModelOpening
|
|
345
393
|
documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
|
|
394
|
+
note={stillOpening}
|
|
346
395
|
retry={() => { setOpened(null); setAttempt(value => value + 1); }}
|
|
347
396
|
returnToPreview={() => {
|
|
348
397
|
if (!preview) return;
|
|
@@ -373,6 +422,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
373
422
|
*/
|
|
374
423
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
375
424
|
const { documentId } = props;
|
|
425
|
+
const blend = props.document?.source?.path;
|
|
426
|
+
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
|
|
427
|
+
// the Game panel drives this id's run, and Game or Movie is chosen by this file's play script.
|
|
428
|
+
useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
|
|
376
429
|
// Rendered areas and Play use the scene's display transform, just as a
|
|
377
430
|
// photograph does. Solid keeps its studio transform. RNA also announces
|
|
378
431
|
// colour settings that change without changing the presented geometry.
|
|
@@ -380,31 +433,62 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
380
433
|
if (!documentId) return;
|
|
381
434
|
let cancelled = false;
|
|
382
435
|
let revision = 0;
|
|
383
|
-
let applied: {
|
|
436
|
+
let applied: { key: string; transform: BlenderDisplayTransform } | null = null;
|
|
437
|
+
const ids = [documentId, `${documentId}#area-2`, `${documentId}#play`];
|
|
438
|
+
const attach = () => {
|
|
439
|
+
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied?.transform ?? null);
|
|
440
|
+
};
|
|
384
441
|
const read = async () => {
|
|
385
442
|
const mine = ++revision;
|
|
386
443
|
const context = await blenderRnaContext();
|
|
387
444
|
if (!context) return;
|
|
388
|
-
const settings = await
|
|
445
|
+
const [settings, displaySettings] = await Promise.all([
|
|
446
|
+
blenderRna(`${context.scene}.view_settings`),
|
|
447
|
+
blenderRna(`${context.scene}.display_settings`),
|
|
448
|
+
]);
|
|
389
449
|
if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
|
|
450
|
+
if (displaySettings?.kind === 'struct') {
|
|
451
|
+
const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
|
|
452
|
+
if (typeof device === 'string' && device !== 'sRGB')
|
|
453
|
+
throw new Error(`Blender display device ${device} has no browser display processor yet`);
|
|
454
|
+
}
|
|
390
455
|
const rows = settings.groups.flatMap(group => group.rows);
|
|
391
456
|
const transform = rows.find(row => row.identifier === 'view_transform')?.value;
|
|
392
457
|
const stops = rows.find(row => row.identifier === 'exposure')?.value;
|
|
393
|
-
const
|
|
458
|
+
const look = rows.find(row => row.identifier === 'look')?.value;
|
|
459
|
+
const gamma = rows.find(row => row.identifier === 'gamma')?.value;
|
|
394
460
|
const exposure = typeof stops === 'number' ? 2 ** stops : 1;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
461
|
+
const display = {
|
|
462
|
+
transform: typeof transform === 'string' ? transform : 'AgX',
|
|
463
|
+
look: typeof look === 'string' ? look : 'None',
|
|
464
|
+
exposure, gamma: typeof gamma === 'number' ? gamma : 1,
|
|
465
|
+
};
|
|
466
|
+
const key = JSON.stringify(display);
|
|
467
|
+
if (applied?.key === key) return;
|
|
468
|
+
const resolved = await createBlenderDisplayTransform(display);
|
|
469
|
+
if (cancelled || mine !== revision) { resolved.dispose(); return; }
|
|
470
|
+
const previous = applied;
|
|
471
|
+
applied = { key, transform: resolved };
|
|
472
|
+
attach();
|
|
473
|
+
previous?.transform.dispose();
|
|
474
|
+
for (const id of ids) {
|
|
398
475
|
setViewPresentation(id, { modes: {
|
|
399
|
-
rendered: { lighting: { tone: { mapper, exposure } } },
|
|
400
|
-
preview: { lighting: { tone: { mapper, exposure } } },
|
|
476
|
+
rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
477
|
+
preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
401
478
|
} });
|
|
402
479
|
}
|
|
403
480
|
};
|
|
404
481
|
const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
|
|
405
482
|
const stopRna = subscribeBlenderRna(update);
|
|
483
|
+
const stopSessions = subscribeObject3DDocumentSessions(attach);
|
|
406
484
|
update();
|
|
407
|
-
return () => {
|
|
485
|
+
return () => {
|
|
486
|
+
cancelled = true; stopRna(); stopSessions();
|
|
487
|
+
const previous = applied;
|
|
488
|
+
applied = null;
|
|
489
|
+
attach();
|
|
490
|
+
previous?.transform.dispose();
|
|
491
|
+
};
|
|
408
492
|
}, [documentId]);
|
|
409
493
|
const split = useSyncExternalStore(
|
|
410
494
|
subscribeAreaSplit,
|
|
@@ -438,30 +522,121 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
438
522
|
() => null,
|
|
439
523
|
);
|
|
440
524
|
const [playedReady, setPlayedReady] = useState(false);
|
|
525
|
+
/**
|
|
526
|
+
* RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
|
|
527
|
+
* below is detached per generation, so a restart disposes the played copy and detaches a fresh
|
|
528
|
+
* one from the model, which never moved, while playing stays true throughout.
|
|
529
|
+
*
|
|
530
|
+
* THE OLD GAME STAYS ON SCREEN UNTIL THE NEW ONE HAS DRAWN. Between the two the new copy's
|
|
531
|
+
* stage is still preparing its rendered draw, and the model area is what lies under the play
|
|
532
|
+
* area — so a restart would flash the editing model between two games. The generation is
|
|
533
|
+
* therefore taken in two steps: a layout effect photographs the old play stage while it still
|
|
534
|
+
* stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
|
|
535
|
+
* detaches the new copy. The photograph covers the play area until the new runner's first
|
|
536
|
+
* frame (`onPlayReady`), the model area stays hidden throughout, and the old runner, no longer
|
|
537
|
+
* the current generation, stands still meanwhile (`play-script.ts`).
|
|
538
|
+
*/
|
|
539
|
+
const generation = useSyncExternalStore(
|
|
540
|
+
subscribeDocumentPlayExtensions,
|
|
541
|
+
() => (documentId ? documentPlayExtension('model')?.transport?.generation(documentId) ?? 0 : 0),
|
|
542
|
+
() => 0,
|
|
543
|
+
);
|
|
544
|
+
const [shownGeneration, setShownGeneration] = useState(generation);
|
|
545
|
+
const [restartStill, setRestartStill] = useState<string | null>(null);
|
|
546
|
+
const restarting = useRef(false);
|
|
547
|
+
// A restarted game that has not drawn yet: its stage holds no game to photograph, so a second
|
|
548
|
+
// Restart in that window keeps the cover it has (or none) rather than photographing a blank.
|
|
549
|
+
const awaitingDraw = useRef(false);
|
|
550
|
+
useLayoutEffect(() => {
|
|
551
|
+
if (generation === shownGeneration) return;
|
|
552
|
+
if (playing && documentId) {
|
|
553
|
+
restarting.current = true;
|
|
554
|
+
if (!awaitingDraw.current) {
|
|
555
|
+
let still: string | null = null;
|
|
556
|
+
try { still = documentViewport(`${documentId}#play`)?.capture?.() ?? null; }
|
|
557
|
+
catch { /* No photograph: the new stage shows as it prepares, still never the model. */ }
|
|
558
|
+
setRestartStill(still);
|
|
559
|
+
}
|
|
560
|
+
awaitingDraw.current = true;
|
|
561
|
+
}
|
|
562
|
+
setShownGeneration(generation);
|
|
563
|
+
}, [generation, shownGeneration, playing, documentId]);
|
|
564
|
+
/**
|
|
565
|
+
* A RESTARTED GAME THAT FAILS never draws (its script did not start, or threw on its first
|
|
566
|
+
* update), so the cover would stand over it while the panel said Playing. The runner says so
|
|
567
|
+
* on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
|
|
568
|
+
*/
|
|
569
|
+
const transport = documentPlayExtension('model')?.transport;
|
|
570
|
+
const subscribeFailure = useCallback(
|
|
571
|
+
(listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
|
|
572
|
+
[transport],
|
|
573
|
+
);
|
|
574
|
+
const failure = useSyncExternalStore(
|
|
575
|
+
subscribeFailure,
|
|
576
|
+
() => (documentId && transport ? transport.clock(documentId).failure ?? null : null),
|
|
577
|
+
() => null,
|
|
578
|
+
);
|
|
441
579
|
useEffect(() => {
|
|
442
|
-
if (
|
|
580
|
+
if (failure === null) return;
|
|
581
|
+
awaitingDraw.current = false;
|
|
582
|
+
setRestartStill(null);
|
|
583
|
+
}, [failure]);
|
|
584
|
+
/**
|
|
585
|
+
* COPIES A RESTART RETIRED, disposed only once the play area that drew them has unmounted:
|
|
586
|
+
* the commit that renders the new copy removes the old area first, and this runs after it.
|
|
587
|
+
* Disposed earlier, the old stage could draw a disposed copy for a frame.
|
|
588
|
+
*/
|
|
589
|
+
const retired = useRef<ReturnType<AreaView['detach']>[]>([]);
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
if (!playing || !documentId) { restarting.current = false; return; }
|
|
443
592
|
const playId = `${documentId}#play`;
|
|
444
593
|
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
445
594
|
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
446
595
|
const copy = view.detach();
|
|
447
|
-
|
|
596
|
+
const restart = restarting.current;
|
|
597
|
+
restarting.current = false;
|
|
598
|
+
// A restart keeps the model area hidden: the old game's photograph stands in until ready.
|
|
599
|
+
if (!restart) setPlayedReady(false);
|
|
448
600
|
setPlayed(copy);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
601
|
+
if (!restart) {
|
|
602
|
+
// The stage's mode is PLAY for as long as a copy stands, so the keys are the player's.
|
|
603
|
+
view.setPlaying(true);
|
|
604
|
+
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
605
|
+
}
|
|
452
606
|
return () => {
|
|
453
|
-
|
|
607
|
+
// Read at cleanup: the layout effect above has already marked a restart, whose new copy
|
|
608
|
+
// takes over in this same commit with the play mode unchanged.
|
|
609
|
+
if (restarting.current && documentPlayExtension('model')?.playing(documentId)) {
|
|
610
|
+
retired.current.push(copy);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
454
613
|
copy.dispose();
|
|
614
|
+
for (const old of retired.current.splice(0)) old.dispose();
|
|
615
|
+
awaitingDraw.current = false;
|
|
616
|
+
setPlayed(null);
|
|
617
|
+
setRestartStill(null);
|
|
455
618
|
view.setPlaying(false);
|
|
456
619
|
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
457
620
|
};
|
|
458
|
-
}, [playing, documentId]);
|
|
621
|
+
}, [playing, documentId, shownGeneration]);
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
for (const old of retired.current.filter(one => one !== played)) old.dispose();
|
|
624
|
+
retired.current = retired.current.filter(one => one === played);
|
|
625
|
+
}, [played]);
|
|
626
|
+
// And whatever is still retired when the document itself goes (declared after the copy's own
|
|
627
|
+
// effect, so its cleanup has already handed over anything mid-restart).
|
|
628
|
+
useEffect(() => () => { for (const old of retired.current.splice(0)) old.dispose(); }, []);
|
|
459
629
|
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
460
630
|
useEffect(() => {
|
|
461
631
|
if (!documentId) return;
|
|
462
632
|
return () => documentPlayExtension('model')?.setPlaying(documentId, false);
|
|
463
633
|
}, [documentId]);
|
|
464
634
|
const game = playing ? played : null;
|
|
635
|
+
const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
|
|
636
|
+
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
|
|
637
|
+
width: `min(100cqw, calc(100cqh * ${playAspect}))`,
|
|
638
|
+
height: `min(100cqh, calc(100cqw / ${playAspect}))`,
|
|
639
|
+
} as const;
|
|
465
640
|
useEffect(() => {
|
|
466
641
|
if (!game || !documentId) return;
|
|
467
642
|
const modelViewport = documentViewport(documentId);
|
|
@@ -481,22 +656,46 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
481
656
|
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
482
657
|
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
483
658
|
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
659
|
+
// THE HIDDEN MODEL AREA IS INERT, not merely `pointer-events: none`. It lies OVER the play
|
|
660
|
+
// area (z-index 2, faded out once the game has drawn), and the stage inside it sets its own
|
|
661
|
+
// `pointer-events: auto` (`StageHost`'s viewport box), which a parent's `none` does not reach:
|
|
662
|
+
// a real click on the game hit the invisible editing canvas, never the play surface, so the
|
|
663
|
+
// runner's takeover (`play-script.ts`, which asks whether the press landed in the game) never
|
|
664
|
+
// saw it and the game never got it — while keys, read off the window, took over fine. `inert`
|
|
665
|
+
// takes the whole subtree out of hit testing and focus.
|
|
666
|
+
const hidden = game !== null && playedReady;
|
|
484
667
|
return (
|
|
485
668
|
<div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
486
|
-
<div key="model" style={game ? { position: 'absolute', inset: 12, zIndex: 2, opacity: playedReady ? 0 : 1, pointerEvents: playedReady ? 'none' : 'auto', transition: 'opacity 160ms ease-out' } : second ? area : { display: 'contents' }}>
|
|
669
|
+
<div key="model" inert={hidden || undefined} style={game ? { position: 'absolute', inset: 12, zIndex: 2, opacity: playedReady ? 0 : 1, pointerEvents: playedReady ? 'none' : 'auto', transition: 'opacity 160ms ease-out' } : second ? area : { display: 'contents' }}>
|
|
487
670
|
<BlenderViewportArea {...props} view={view} main />
|
|
488
671
|
</div>
|
|
489
672
|
{game && (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
<BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
|
|
673
|
+
// Keyed on the COPY, so a restart mounts a new stage and runner exactly when the new copy
|
|
674
|
+
// is the one rendered — never over the old, disposed one.
|
|
675
|
+
<div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
|
|
676
|
+
<div data-volter-play-frame style={playFrame}>
|
|
677
|
+
<BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
|
|
678
|
+
onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }} onPlayReturn={() => setPlayedReady(false)} />
|
|
497
679
|
</div>
|
|
498
680
|
</div>
|
|
499
681
|
)}
|
|
682
|
+
{game && failure !== null && (
|
|
683
|
+
// A PLAY WITH NO GAME, said over the area itself. When the script did not start (or the
|
|
684
|
+
// stage could not be prepared) the game never draws and the model area stays in front,
|
|
685
|
+
// so the area looked like plain editing while Play was on and the reason was a toast
|
|
686
|
+
// that had already gone (2026-10-06 audit). The Game panel says the same in its line.
|
|
687
|
+
<div key="play-failure" role="alert" data-testid="blender-play-failure"
|
|
688
|
+
style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
689
|
+
background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
|
|
690
|
+
color: themeVars.content.primary, fontSize: fontSizeVar.sm, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }}>
|
|
691
|
+
<strong style={{ color: themeVars.semantic.danger }}>Play is not running.</strong> {failure} Restart (or save the script) to try again, or Stop.
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
{game && restartStill !== null && (
|
|
695
|
+
<div key="restart-still" style={{ position: 'absolute', inset: 12, containerType: 'size', zIndex: 3, pointerEvents: 'none' }} data-testid="blender-play-restart-still">
|
|
696
|
+
<img alt="" src={restartStill} style={{ ...playFrame, display: 'block', objectFit: 'fill' }} />
|
|
697
|
+
</div>
|
|
698
|
+
)}
|
|
500
699
|
{second && (
|
|
501
700
|
<div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
|
|
502
701
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
@@ -717,6 +916,7 @@ function BlenderViewportArea({
|
|
|
717
916
|
camera: () => stage.rig().drawCamera(),
|
|
718
917
|
editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
|
|
719
918
|
onFrame: (fn) => stage.onFrame(fn),
|
|
919
|
+
ownMaterial: (material) => ownedSurfaceMaterial(material as THREE.Material),
|
|
720
920
|
report: (title, detail) => {
|
|
721
921
|
editorHost().console.error(`${title}: ${detail}`, 'blender-play');
|
|
722
922
|
notifyRef.current?.({ tone: 'error', title, detail });
|
|
@@ -724,8 +924,17 @@ function BlenderViewportArea({
|
|
|
724
924
|
}) ?? null;
|
|
725
925
|
}, error => {
|
|
726
926
|
if (stopped) return;
|
|
727
|
-
|
|
728
|
-
|
|
927
|
+
const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
|
|
928
|
+
editorHost().console.error(failure, 'model-play');
|
|
929
|
+
// SAID WHERE THE PERSON IS LOOKING: on the run's clock, which the Game panel and the
|
|
930
|
+
// play area draw, and in the play log. Turning Play off (what this did until
|
|
931
|
+
// 2026-10-06) erased the only trace a person could have seen.
|
|
932
|
+
const extension = documentPlayExtension('model');
|
|
933
|
+
if (extension?.transport?.fail) extension.transport.fail(modelId, blend, failure);
|
|
934
|
+
else {
|
|
935
|
+
notifyRef.current?.({ tone: 'error', title: 'Play could not start', detail: failure });
|
|
936
|
+
extension?.setPlaying(modelId, false);
|
|
937
|
+
}
|
|
729
938
|
});
|
|
730
939
|
};
|
|
731
940
|
start();
|
|
@@ -67,6 +67,9 @@ import {
|
|
|
67
67
|
timelineViewVersion,
|
|
68
68
|
} from '../src/timeline-view-state';
|
|
69
69
|
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
70
|
+
import { BlenderGamePanel, GAME_PANEL_RATIO, PlayModeSwitch } from './blender-game-panel';
|
|
71
|
+
import { setWorkspaceAreaRatio } from '@volter/editor-sdk/kit/workspace-areas';
|
|
72
|
+
import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
|
|
70
73
|
import {
|
|
71
74
|
CHANNEL_HEIGHT,
|
|
72
75
|
DIAMOND_RADIUS,
|
|
@@ -228,7 +231,7 @@ function report(): unknown {
|
|
|
228
231
|
* no mixer (`blender-runtime-skin.ts`), so the seek was a no-op and
|
|
229
232
|
* `report()` then answered `frame: 1` — the playhead's honest position and a
|
|
230
233
|
* complete lie about the gesture. Measured on walk 5 over a fresh
|
|
231
|
-
* `
|
|
234
|
+
* `cyclotron create` scaffold: `volter.timeline.frame {frame:120}` and
|
|
232
235
|
* `volter.timeline.jump-end` (frame 250) both answered `frame: 1`,
|
|
233
236
|
* `refusal: null`. This is the half of walk 4's W5 (#7740) that the header
|
|
234
237
|
* got and the VERBS did not — there the buttons were disabled wearing this
|
|
@@ -360,7 +363,32 @@ registerViewVerbs({
|
|
|
360
363
|
],
|
|
361
364
|
});
|
|
362
365
|
|
|
363
|
-
|
|
366
|
+
/** This area's id in `model.layout.ts`, the one workspace that places this document. */
|
|
367
|
+
const BOTTOM_AREA = 'timeline';
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* THE BOTTOM AREA IS THE TIMELINE IN MOVIE MODE AND THE GAME PANEL IN GAME MODE
|
|
371
|
+
* (`../src/play-mode.ts`, for the model document on screen). Same area: the
|
|
372
|
+
* layout's `areas` entry names this document either way. NOT THE SAME SIZE:
|
|
373
|
+
* Blender's measured Timeline strip (`model.layout.ts`) left the Game panel's
|
|
374
|
+
* play log one clipped line, so Game mode asks the area for the panel's own
|
|
375
|
+
* share (`GAME_PANEL_RATIO`) and Movie mode hands Blender's back. Each switch
|
|
376
|
+
* stands the area up at its mode's size; a person's resize holds until the
|
|
377
|
+
* next switch. The Timeline unmounts in Game mode; a document that opens as a
|
|
378
|
+
* game therefore never pays for the rig bind (the cost note in
|
|
379
|
+
* `BlenderTimeline`), and one bound earlier keeps its bind, which the Timeline
|
|
380
|
+
* never undoes.
|
|
381
|
+
*/
|
|
382
|
+
export default function BlenderBottomArea() {
|
|
383
|
+
useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
|
|
384
|
+
const documentId = servedModelDocument();
|
|
385
|
+
const game = documentId !== null && modelPlayMode(documentId) === 'game';
|
|
386
|
+
useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, game ? GAME_PANEL_RATIO : null), [game]);
|
|
387
|
+
useEffect(() => () => setWorkspaceAreaRatio(BOTTOM_AREA, null), []);
|
|
388
|
+
return game ? <BlenderGamePanel /> : <BlenderTimeline />;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function BlenderTimeline() {
|
|
364
392
|
// THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
|
|
365
393
|
// every presented frame bumps it, which is exactly when a rig or an action
|
|
366
394
|
// can have moved.
|
|
@@ -919,7 +947,9 @@ function TimelineHeader() {
|
|
|
919
947
|
borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
|
|
920
948
|
}}
|
|
921
949
|
>
|
|
922
|
-
|
|
950
|
+
{/* GAME | MOVIE in place of the title, as in the Game panel, so the person switches back
|
|
951
|
+
from either (`blender-game-panel.tsx`); without a Play tool or a model, the title. */}
|
|
952
|
+
<PlayModeSwitch title="Timeline" />
|
|
923
953
|
<TimelineViewMenu />
|
|
924
954
|
{/* TWO SPACERS, which is what CENTRES the transport. Blender's Timeline
|
|
925
955
|
header is three clusters: the menus at the leading edge, the
|
|
@@ -94,7 +94,7 @@ const REFUSALS = {
|
|
|
94
94
|
|
|
95
95
|
/** THE VIEW'S VERBS, published once, here — U8's ruling 1 (2026-09-19). Under
|
|
96
96
|
* the Code-OSS frame each becomes a `volter.blender-uv-view.<verb>` command the
|
|
97
|
-
* bridge dispatches into the view, and standalone `
|
|
97
|
+
* bridge dispatches into the view, and standalone `cyclotron edit` reaches the
|
|
98
98
|
* SAME table through the session. **This view adds no `blender-*` session
|
|
99
99
|
* verb at all**, which is exactly what the ruling asked the remaining I5
|
|
100
100
|
* views to stop paying for. */
|
|
@@ -49,5 +49,9 @@ export const layout: WorkspaceLayoutContribution = {
|
|
|
49
49
|
// It is stated to four places because it is a measurement, not a preference;
|
|
50
50
|
// the dock uses it once, to size the group the first time this workspace
|
|
51
51
|
// stands up, and a capture of the settled grid wins afterwards.
|
|
52
|
+
//
|
|
53
|
+
// IN GAME MODE THE SAME AREA HOLDS THE GAME PANEL (`../src/play-mode.ts`):
|
|
54
|
+
// the document below draws the Timeline or the panel by the model's mode,
|
|
55
|
+
// so the area, its proportion and a person's resize all carry over.
|
|
52
56
|
areas: [{ id: 'timeline', document: 'blender-timeline.document', place: 'below', ratio: 0.0719 }],
|
|
53
57
|
};
|
|
@@ -25,13 +25,17 @@
|
|
|
25
25
|
* This module is a plain object the HOST registers; it mounts no UI. It reads
|
|
26
26
|
* the project only through `input.files` — the PATHS matching those globs.
|
|
27
27
|
* Deliberately not `input.sources`: a `.blend` is binary, there is nothing in
|
|
28
|
-
* it a text walk could honestly read, and the entry is the file itself.
|
|
28
|
+
* it a text walk could honestly read, and the entry is the file itself. It says
|
|
29
|
+
* so (`reads: 'files'`), and the host then lists the files without fetching them.
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
import { z } from 'zod';
|
|
32
33
|
|
|
33
34
|
export const finder = {
|
|
34
35
|
name: 'modelsFromBlendFiles',
|
|
36
|
+
// PATHS ONLY, so the host never fetches a `.blend` through its text-source route (a
|
|
37
|
+
// multi-megabyte binary there is a `413`, and nothing here would read the text).
|
|
38
|
+
reads: 'files',
|
|
35
39
|
schema: z
|
|
36
40
|
.object({
|
|
37
41
|
finder: z.literal('modelsFromBlendFiles'),
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* this tab shows, and our widgets draw them from the door's rows.
|
|
14
14
|
*
|
|
15
15
|
* WHAT THE TRANSCRIPTION DELIBERATELY DROPS:
|
|
16
|
-
* -
|
|
16
|
+
* - type-only `poll`/`if` branches. `show_all_edges` is mesh-only in Python; here
|
|
17
17
|
* it is simply not on a Camera's RNA, so it does not draw. Same outcome,
|
|
18
18
|
* from the datablock rather than from a copy of Blender's conditions.
|
|
19
|
+
* Rotation mode is a value on every object, so its draw branch is retained.
|
|
19
20
|
* - `OBJECT_PT_context_object` (`bl_label = ""`) — the breadcrumb row, which
|
|
20
21
|
* is the host's identity row here.
|
|
21
22
|
* - the four panels whose datablock is NOT the object: Motion Paths reads
|
|
@@ -35,35 +36,33 @@ import type { BlenderCuratedPanel } from './blender-properties-view';
|
|
|
35
36
|
/** `properties_object.py`, panel by panel. Line numbers are that file's. */
|
|
36
37
|
const CURATED: readonly BlenderCuratedPanel[] = [
|
|
37
38
|
{
|
|
38
|
-
// OBJECT_PT_transform, :36
|
|
39
|
-
//
|
|
40
|
-
// not in are still real RNA, so all three draw. That is MORE than Blender
|
|
41
|
-
// shows and it is the honest reading: the door reports what the object
|
|
42
|
-
// holds, and a quaternion object's euler is not a fiction.
|
|
39
|
+
// OBJECT_PT_transform, :36-83: only the current rotation channel, with
|
|
40
|
+
// lock toggles aligned beside its components and the Mode label.
|
|
43
41
|
title: 'Transform',
|
|
44
42
|
properties: [
|
|
45
|
-
'location',
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
'rotation_axis_angle',
|
|
49
|
-
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
'rotation_mode',
|
|
53
|
-
'scale',
|
|
54
|
-
'lock_scale',
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
// OBJECT_PT_delta_transform, :86.
|
|
59
|
-
title: 'Delta Transform',
|
|
60
|
-
closed: true,
|
|
61
|
-
properties: [
|
|
62
|
-
'delta_location',
|
|
63
|
-
'delta_rotation_quaternion',
|
|
64
|
-
'delta_rotation_euler',
|
|
65
|
-
'delta_scale',
|
|
43
|
+
{ property: 'location', lock: 'lock_location' },
|
|
44
|
+
{ property: 'rotation_quaternion', label: 'Rotation', lock: 'lock_rotation', firstLock: 'lock_rotation_w',
|
|
45
|
+
when: { property: 'rotation_mode', is: ['QUATERNION'] } },
|
|
46
|
+
{ property: 'rotation_axis_angle', label: 'Rotation', lock: 'lock_rotation', firstLock: 'lock_rotation_w',
|
|
47
|
+
when: { property: 'rotation_mode', is: ['AXIS_ANGLE'] } },
|
|
48
|
+
{ property: 'rotation_euler', label: 'Rotation', lock: 'lock_rotation',
|
|
49
|
+
when: { property: 'rotation_mode', isNot: ['QUATERNION', 'AXIS_ANGLE'] } },
|
|
50
|
+
{ property: 'rotation_mode', label: 'Mode' },
|
|
51
|
+
{ property: 'scale', lock: 'lock_scale' },
|
|
66
52
|
],
|
|
53
|
+
sub: [{
|
|
54
|
+
// OBJECT_PT_delta_transform, :86-107: a child of Transform, with no
|
|
55
|
+
// rotation field for AXIS_ANGLE, exactly as its draw branch specifies.
|
|
56
|
+
title: 'Delta Transform', closed: true,
|
|
57
|
+
properties: [
|
|
58
|
+
{ property: 'delta_location', label: 'Location' },
|
|
59
|
+
{ property: 'delta_rotation_quaternion', label: 'Rotation',
|
|
60
|
+
when: { property: 'rotation_mode', is: ['QUATERNION'] } },
|
|
61
|
+
{ property: 'delta_rotation_euler', label: 'Rotation',
|
|
62
|
+
when: { property: 'rotation_mode', isNot: ['QUATERNION', 'AXIS_ANGLE'] } },
|
|
63
|
+
{ property: 'delta_scale', label: 'Scale' },
|
|
64
|
+
],
|
|
65
|
+
}],
|
|
67
66
|
},
|
|
68
67
|
{
|
|
69
68
|
// OBJECT_PT_relations, :132.
|
|
@@ -35,13 +35,15 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { blenderPropertiesTabMatch, blenderPropertiesTabSection } from './blender-properties-tab';
|
|
37
37
|
import type { BlenderCuratedPanel } from './blender-properties-view';
|
|
38
|
+
import { resolveBlenderSubject } from './blender-properties-model';
|
|
39
|
+
import type { ToolContributionNode } from '@volter/editor-sdk/contributions';
|
|
38
40
|
|
|
39
41
|
/** `properties_scene.py`, panel by panel. Line numbers are that file's. */
|
|
40
42
|
const CURATED: readonly BlenderCuratedPanel[] = [
|
|
41
43
|
{
|
|
42
44
|
// SCENE_PT_scene, :49.
|
|
43
45
|
title: 'Scene',
|
|
44
|
-
properties: ['camera', 'background_set', 'active_clip'],
|
|
46
|
+
properties: ['camera', 'background_set', { property: 'active_clip', label: 'Active Clip' }],
|
|
45
47
|
},
|
|
46
48
|
{
|
|
47
49
|
// SCENE_PT_unit, :64 — `unit = scene.unit_settings` (:74).
|
|
@@ -64,7 +66,8 @@ const CURATED: readonly BlenderCuratedPanel[] = [
|
|
|
64
66
|
// (`draw_header`, :307) and `gravity` its one row.
|
|
65
67
|
title: 'Gravity',
|
|
66
68
|
closed: true,
|
|
67
|
-
|
|
69
|
+
headerProperty: 'use_gravity',
|
|
70
|
+
properties: ['gravity'],
|
|
68
71
|
},
|
|
69
72
|
{
|
|
70
73
|
// SCENE_PT_simulation, :320.
|
|
@@ -170,5 +173,9 @@ export const order = 40;
|
|
|
170
173
|
* object group (Object … Material), then Texture. The presentation draws a
|
|
171
174
|
* separator wherever this changes. */
|
|
172
175
|
export const railGroup = 'scene';
|
|
176
|
+
/** buttons_context_compute falls back to SCENE when no object context exists.
|
|
177
|
+
* Keep the remembered tab when valid; otherwise use Blender's scene fallback. */
|
|
178
|
+
export const railDefault = (node: ToolContributionNode | null, adapter: unknown): boolean =>
|
|
179
|
+
resolveBlenderSubject(node, adapter)?.kind === 'scene';
|
|
173
180
|
export const match = blenderPropertiesTabMatch(TAB);
|
|
174
181
|
export default blenderPropertiesTabSection(TAB);
|