@volter/editor-blender 0.1.129 → 0.1.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +236 -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,12 +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';
|
|
33
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';
|
|
34
36
|
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
|
|
35
37
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
36
38
|
import {
|
|
@@ -73,6 +75,7 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
|
73
75
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
74
76
|
import { blenderSkin } from './blender-runtime-skin';
|
|
75
77
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
78
|
+
import { noteModelDocument } from '../src/play-mode';
|
|
76
79
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
77
80
|
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
78
81
|
import {
|
|
@@ -91,6 +94,14 @@ import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-s
|
|
|
91
94
|
import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
|
|
92
95
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
93
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;
|
|
94
105
|
import {
|
|
95
106
|
clearModelDocumentPreview,
|
|
96
107
|
modelDocumentPreview,
|
|
@@ -99,6 +110,15 @@ import {
|
|
|
99
110
|
type ModelDocumentPreview,
|
|
100
111
|
} from '../src/model-document-preview';
|
|
101
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
|
+
|
|
102
122
|
/** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
|
|
103
123
|
const overlaysOpened = new WeakSet<object>();
|
|
104
124
|
|
|
@@ -223,6 +243,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
223
243
|
const entryId = document?.id;
|
|
224
244
|
const key = JSON.stringify([documentId, entryId, blend]);
|
|
225
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);
|
|
226
248
|
const [attempt, setAttempt] = useState(0);
|
|
227
249
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
228
250
|
const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
|
|
@@ -288,6 +310,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
288
310
|
const unbind = bindModelDocument(binding);
|
|
289
311
|
const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
|
|
290
312
|
setOpened(null);
|
|
313
|
+
setStillOpening(null);
|
|
291
314
|
// Native focus can arrive after the contributed pane mounts. A declined
|
|
292
315
|
// open is not an in-flight load: retry when the host activates this model,
|
|
293
316
|
// rather than leaving "Opening model…" latched forever. Utility focus
|
|
@@ -295,18 +318,23 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
295
318
|
const documents = editorHost().documents;
|
|
296
319
|
let starting = false;
|
|
297
320
|
let finished = false;
|
|
321
|
+
let declined = false;
|
|
322
|
+
const path = blend ?? 'the model';
|
|
298
323
|
const open = async () => {
|
|
299
|
-
if (cancelled || starting || finished
|
|
324
|
+
if (cancelled || starting || finished) return;
|
|
325
|
+
if (binding && !modelDocumentMayOpen(binding)) { declined = true; return; }
|
|
300
326
|
starting = true;
|
|
327
|
+
declined = false;
|
|
301
328
|
try {
|
|
302
329
|
if (binding) {
|
|
303
|
-
if (!await openModelDocumentBlend(binding, publish)) return;
|
|
330
|
+
if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
|
|
304
331
|
} else {
|
|
305
332
|
// The standing Model document is the explicit blender-start target.
|
|
306
333
|
publish();
|
|
307
334
|
}
|
|
308
335
|
if (!cancelled) {
|
|
309
336
|
finished = true;
|
|
337
|
+
clearStartupFailure(MODEL_OPEN_STARTUP_SOURCE);
|
|
310
338
|
setOpened({ key, error: null });
|
|
311
339
|
}
|
|
312
340
|
} catch (error) {
|
|
@@ -317,11 +345,29 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
317
345
|
const detail = error instanceof Error ? error.message : String(error);
|
|
318
346
|
setOpened({ key, error: detail });
|
|
319
347
|
editorHost().console.error(detail, 'blender-open');
|
|
320
|
-
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
|
+
});
|
|
321
357
|
} finally {
|
|
322
358
|
starting = false;
|
|
323
359
|
}
|
|
324
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);
|
|
325
371
|
// Opening publishes context to other React roots. Start outside this
|
|
326
372
|
// effect (or a host activation listener's synchronous notification stack)
|
|
327
373
|
// so those roots cannot synchronously commit through a pending commit here.
|
|
@@ -335,6 +381,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
335
381
|
requestOpen();
|
|
336
382
|
return () => {
|
|
337
383
|
cancelled = true;
|
|
384
|
+
clearInterval(slow);
|
|
338
385
|
unsubscribe();
|
|
339
386
|
unpublish?.();
|
|
340
387
|
unbind();
|
|
@@ -344,6 +391,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
344
391
|
if (active === false || !documentId) return null;
|
|
345
392
|
if (opened?.key !== key || opened.error) return <BlenderModelOpening
|
|
346
393
|
documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
|
|
394
|
+
note={stillOpening}
|
|
347
395
|
retry={() => { setOpened(null); setAttempt(value => value + 1); }}
|
|
348
396
|
returnToPreview={() => {
|
|
349
397
|
if (!preview) return;
|
|
@@ -374,6 +422,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
374
422
|
*/
|
|
375
423
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
376
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]);
|
|
377
429
|
// Rendered areas and Play use the scene's display transform, just as a
|
|
378
430
|
// photograph does. Solid keeps its studio transform. RNA also announces
|
|
379
431
|
// colour settings that change without changing the presented geometry.
|
|
@@ -381,31 +433,62 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
381
433
|
if (!documentId) return;
|
|
382
434
|
let cancelled = false;
|
|
383
435
|
let revision = 0;
|
|
384
|
-
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
|
+
};
|
|
385
441
|
const read = async () => {
|
|
386
442
|
const mine = ++revision;
|
|
387
443
|
const context = await blenderRnaContext();
|
|
388
444
|
if (!context) return;
|
|
389
|
-
const settings = await
|
|
445
|
+
const [settings, displaySettings] = await Promise.all([
|
|
446
|
+
blenderRna(`${context.scene}.view_settings`),
|
|
447
|
+
blenderRna(`${context.scene}.display_settings`),
|
|
448
|
+
]);
|
|
390
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
|
+
}
|
|
391
455
|
const rows = settings.groups.flatMap(group => group.rows);
|
|
392
456
|
const transform = rows.find(row => row.identifier === 'view_transform')?.value;
|
|
393
457
|
const stops = rows.find(row => row.identifier === 'exposure')?.value;
|
|
394
|
-
const
|
|
458
|
+
const look = rows.find(row => row.identifier === 'look')?.value;
|
|
459
|
+
const gamma = rows.find(row => row.identifier === 'gamma')?.value;
|
|
395
460
|
const exposure = typeof stops === 'number' ? 2 ** stops : 1;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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) {
|
|
399
475
|
setViewPresentation(id, { modes: {
|
|
400
|
-
rendered: { lighting: { tone: { mapper, exposure } } },
|
|
401
|
-
preview: { lighting: { tone: { mapper, exposure } } },
|
|
476
|
+
rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
477
|
+
preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
402
478
|
} });
|
|
403
479
|
}
|
|
404
480
|
};
|
|
405
481
|
const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
|
|
406
482
|
const stopRna = subscribeBlenderRna(update);
|
|
483
|
+
const stopSessions = subscribeObject3DDocumentSessions(attach);
|
|
407
484
|
update();
|
|
408
|
-
return () => {
|
|
485
|
+
return () => {
|
|
486
|
+
cancelled = true; stopRna(); stopSessions();
|
|
487
|
+
const previous = applied;
|
|
488
|
+
applied = null;
|
|
489
|
+
attach();
|
|
490
|
+
previous?.transform.dispose();
|
|
491
|
+
};
|
|
409
492
|
}, [documentId]);
|
|
410
493
|
const split = useSyncExternalStore(
|
|
411
494
|
subscribeAreaSplit,
|
|
@@ -439,30 +522,121 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
439
522
|
() => null,
|
|
440
523
|
);
|
|
441
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
|
+
);
|
|
442
579
|
useEffect(() => {
|
|
443
|
-
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; }
|
|
444
592
|
const playId = `${documentId}#play`;
|
|
445
593
|
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
446
594
|
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
447
595
|
const copy = view.detach();
|
|
448
|
-
|
|
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);
|
|
449
600
|
setPlayed(copy);
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
}
|
|
453
606
|
return () => {
|
|
454
|
-
|
|
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
|
+
}
|
|
455
613
|
copy.dispose();
|
|
614
|
+
for (const old of retired.current.splice(0)) old.dispose();
|
|
615
|
+
awaitingDraw.current = false;
|
|
616
|
+
setPlayed(null);
|
|
617
|
+
setRestartStill(null);
|
|
456
618
|
view.setPlaying(false);
|
|
457
619
|
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
458
620
|
};
|
|
459
|
-
}, [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(); }, []);
|
|
460
629
|
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
461
630
|
useEffect(() => {
|
|
462
631
|
if (!documentId) return;
|
|
463
632
|
return () => documentPlayExtension('model')?.setPlaying(documentId, false);
|
|
464
633
|
}, [documentId]);
|
|
465
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;
|
|
466
640
|
useEffect(() => {
|
|
467
641
|
if (!game || !documentId) return;
|
|
468
642
|
const modelViewport = documentViewport(documentId);
|
|
@@ -482,22 +656,46 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
482
656
|
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
483
657
|
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
484
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;
|
|
485
667
|
return (
|
|
486
668
|
<div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
487
|
-
<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' }}>
|
|
488
670
|
<BlenderViewportArea {...props} view={view} main />
|
|
489
671
|
</div>
|
|
490
672
|
{game && (
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
<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)} />
|
|
498
679
|
</div>
|
|
499
680
|
</div>
|
|
500
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
|
+
)}
|
|
501
699
|
{second && (
|
|
502
700
|
<div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
|
|
503
701
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
@@ -726,8 +924,17 @@ function BlenderViewportArea({
|
|
|
726
924
|
}) ?? null;
|
|
727
925
|
}, error => {
|
|
728
926
|
if (stopped) return;
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
}
|
|
731
938
|
});
|
|
732
939
|
};
|
|
733
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);
|