@volter/editor-blender 0.1.97 → 0.1.99

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.
@@ -60,6 +60,7 @@ import {
60
60
  } from './blender-outliner-authoring';
61
61
  import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
62
62
  import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
63
+ import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
63
64
 
64
65
  type MenuId = 'view' | 'select' | 'add' | 'object';
65
66
 
@@ -273,6 +274,9 @@ export function BlenderObjectModeHeader({
273
274
  () => (documentId === undefined ? false : areaSplit(documentId)),
274
275
  () => false,
275
276
  );
277
+ const playExtension = useSyncExternalStore(subscribeDocumentPlayExtensions, () => documentPlayExtension('model'), () => null);
278
+ const PlayControl = playExtension?.Control;
279
+ const PlayMenu = playExtension?.Menu;
276
280
  const handle = blenderOutlinerHandle(documentId);
277
281
  // NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
278
282
  // document there is no subject for any of these rows, and a menu of rows
@@ -381,6 +385,7 @@ export function BlenderObjectModeHeader({
381
385
  Close Area
382
386
  </MenuItem>
383
387
  </MenuSubmenu>
388
+ {PlayMenu && <><MenuSeparator /><PlayMenu documentId={documentId} onClose={() => setOpen(null)} /></>}
384
389
  </MenuWord>
385
390
  {/* SELECT — `VIEW3D_MT_select_object`, `space_view3d.py:1713-1715`. The
386
391
  three rows are `object.select_all` with action SELECT / DESELECT /
@@ -511,6 +516,7 @@ export function BlenderObjectModeHeader({
511
516
  Delete
512
517
  </MenuItem>
513
518
  </MenuWord>
519
+ {PlayControl && <PlayControl documentId={documentId} onClose={() => setOpen(null)} />}
514
520
  </EditorToolbar>
515
521
  );
516
522
  }
@@ -1862,7 +1862,14 @@ export function blenderOutlinerAuthoringFor(
1862
1862
  },
1863
1863
  };
1864
1864
  liveOutliners.add(handle);
1865
- liveOutlinersChanged();
1865
+ // A factory is called during the stage's React render. Notify the other roots
1866
+ // after that render finishes; synchronously publishing here re-enters a commit.
1867
+ let disposed = false;
1868
+ const announce = setTimeout(() => {
1869
+ if (disposed) return;
1870
+ liveOutlinersChanged();
1871
+ syncFromEngine();
1872
+ }, 0);
1866
1873
 
1867
1874
  // THE ENGINE'S SELECTION ARRIVES ON ITS OWN, and this is what listens for
1868
1875
  // it: a frame (every present carries `selected`/`active`) and a tree read
@@ -1874,11 +1881,12 @@ export function blenderOutlinerAuthoringFor(
1874
1881
  // follow the engine whether or not a panel is currently mounted over it.
1875
1882
  const stopEngineFrames = onBlenderFrame(syncFromEngine);
1876
1883
  const stopEngineTree = subscribeBlenderOutliner(syncFromEngine);
1877
- syncFromEngine();
1878
1884
 
1879
1885
  return {
1880
1886
  adapter,
1881
1887
  dispose: () => {
1888
+ disposed = true;
1889
+ clearTimeout(announce);
1882
1890
  stopEngineFrames();
1883
1891
  stopEngineTree();
1884
1892
  liveOutliners.delete(handle);
@@ -59,6 +59,9 @@ import * as THREE from 'three';
59
59
  import {
60
60
  bindModelDocument,
61
61
  blenderExecute,
62
+ blenderRna,
63
+ blenderRnaContext,
64
+ subscribeBlenderRna,
62
65
  blenderViewShading,
63
66
  openModelDocumentBlend,
64
67
  modelDocumentMayOpen,
@@ -67,6 +70,8 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
67
70
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
68
71
  import { blenderSkin } from './blender-runtime-skin';
69
72
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
73
+ import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
74
+ import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
70
75
  import {
71
76
  documentSecondAreaHeader,
72
77
  setDocumentSecondArea,
@@ -80,6 +85,7 @@ import {
80
85
  } from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
81
86
  import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
82
87
  import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
88
+ import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
83
89
 
84
90
  /** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
85
91
  const overlaysOpened = new WeakSet<object>();
@@ -281,9 +287,47 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
281
287
  * Blender's standard always-on render preview is set up: Rendered shading, through the scene
282
288
  * camera. Each area has its own stage, shading, navigation and camera view; the model, its
283
289
  * selection and its history are the document's.
290
+ *
291
+ * PLAYING (the composed Play tool), the area shows a DETACHED copy of the model
292
+ * (`BlenderRuntimeView.detach`) on a stage of its own, which the project's play script moves
293
+ * (the document Play extension). The model's own stage stays mounted underneath, hidden, so Stop
294
+ * returns to the same view of the same model: nothing a game does reaches Blender.
284
295
  */
285
296
  function BlenderModelViewport(props: ToolContributionProps) {
286
297
  const { documentId } = props;
298
+ // Rendered areas and Play use the scene's display transform, just as a
299
+ // photograph does. Solid keeps its studio transform. RNA also announces
300
+ // colour settings that change without changing the presented geometry.
301
+ useEffect(() => {
302
+ if (!documentId) return;
303
+ let cancelled = false;
304
+ let revision = 0;
305
+ let applied: { mapper: string; exposure: number } | null = null;
306
+ const read = async () => {
307
+ const mine = ++revision;
308
+ const context = await blenderRnaContext();
309
+ if (!context) return;
310
+ const settings = await blenderRna(`${context.scene}.view_settings`);
311
+ if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
312
+ const rows = settings.groups.flatMap(group => group.rows);
313
+ const transform = rows.find(row => row.identifier === 'view_transform')?.value;
314
+ const stops = rows.find(row => row.identifier === 'exposure')?.value;
315
+ const mapper = transform === 'Standard' ? 'none' : transform === 'Filmic' ? 'filmic' : 'agx';
316
+ const exposure = typeof stops === 'number' ? 2 ** stops : 1;
317
+ if (applied?.mapper === mapper && applied.exposure === exposure) return;
318
+ applied = { mapper, exposure };
319
+ for (const id of [documentId, `${documentId}#area-2`, `${documentId}#play`]) {
320
+ setViewPresentation(id, { modes: {
321
+ rendered: { lighting: { tone: { mapper, exposure } } },
322
+ preview: { lighting: { tone: { mapper, exposure } } },
323
+ } });
324
+ }
325
+ };
326
+ const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
327
+ const stopRna = subscribeBlenderRna(update);
328
+ update();
329
+ return () => { cancelled = true; stopRna(); };
330
+ }, [documentId]);
287
331
  const split = useSyncExternalStore(
288
332
  subscribeAreaSplit,
289
333
  () => (documentId ? areaSplit(documentId) : false),
@@ -304,22 +348,67 @@ function BlenderModelViewport(props: ToolContributionProps) {
304
348
  second.dispose();
305
349
  };
306
350
  }, [split, documentId]);
351
+ const playing = useSyncExternalStore(
352
+ subscribeDocumentPlayExtensions,
353
+ () => (documentId ? documentPlayExtension('model')?.playing(documentId) ?? false : false),
354
+ () => false,
355
+ );
356
+ const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
357
+ const [playedReady, setPlayedReady] = useState(false);
358
+ useEffect(() => {
359
+ if (!playing || !documentId) return;
360
+ const playId = `${documentId}#play`;
361
+ // A game is seen as the render is: Rendered shading, chosen before the stage binds.
362
+ setViewPresentation(playId, { drawMode: 'rendered' });
363
+ const copy = view.detach();
364
+ setPlayedReady(false);
365
+ setPlayed(copy);
366
+ // The stage's mode is PLAY for as long as the copy stands, so the keys are the player's.
367
+ view.setPlaying(true);
368
+ notifyWorkspaceDocumentSelectionChanged(documentId);
369
+ return () => {
370
+ setPlayed(null);
371
+ copy.dispose();
372
+ view.setPlaying(false);
373
+ notifyWorkspaceDocumentSelectionChanged(documentId);
374
+ };
375
+ }, [playing, documentId]);
376
+ // The document closing or going inactive ends its game; a game never outlives its stage.
377
+ useEffect(() => {
378
+ if (!documentId) return;
379
+ return () => documentPlayExtension('model')?.setPlaying(documentId, false);
380
+ }, [documentId]);
381
+ const game = playing ? played : null;
382
+ useEffect(() => {
383
+ if (!game || !documentId) return;
384
+ const modelViewport = documentViewport(documentId);
385
+ if (!modelViewport) return;
386
+ // registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
387
+ const capture = modelViewport.capture;
388
+ modelViewport.capture = (size) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
389
+ return () => {
390
+ modelViewport.capture = capture;
391
+ };
392
+ }, [game, documentId]);
307
393
  if (!documentId) return null;
308
394
  const second = split ? follower : null;
309
- // SPLIT, THE TWO AREAS SHARE THE SLOT the stage alone fills otherwise: each stage's root bleeds
310
- // 12 px past its positioned parent (`inset: -12px`), so each area is that parent inset by 12,
311
- // and the two stand 2 px apart, Blender's gap between areas. Unsplit, both wrappers are
312
- // `contents` and the one stage is placed as it always was.
395
+ // Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
396
+ // its pose/session and repeats the second area's initial scene-camera setup.
313
397
  const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
314
398
  return (
315
- <div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
316
- <div style={second ? area : { display: 'contents' }}>
399
+ <div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
400
+ <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' }}>
317
401
  <BlenderViewportArea {...props} view={view} main />
318
402
  </div>
403
+ {game && (
404
+ <div key="play" style={{ position: 'absolute', inset: 12 }} data-testid="blender-play-area">
405
+ <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} play onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
406
+ </div>
407
+ )}
319
408
  {second && (
320
- <div style={area} data-testid="blender-second-area">
409
+ <div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
321
410
  <BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
322
- <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
411
+ {!game && <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />}
323
412
  </div>
324
413
  )}
325
414
  </div>
@@ -396,28 +485,38 @@ function BlenderViewportArea({
396
485
  active,
397
486
  document,
398
487
  documentId,
488
+ notify,
399
489
  surfaces,
400
490
  view,
401
491
  main,
402
- }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
492
+ play = false,
493
+ onPlayReady,
494
+ onPlayReturn,
495
+ }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly play?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
403
496
  const reads = readsOf(view);
404
497
  const build = useCallback(
405
498
  () => ({
406
499
  root: view.root,
407
500
  prepareDraw: (camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}) => view.prepareDraw(camera, options),
408
501
  // What the view draws changes with each frame and the work after it,
409
- // and with the skin's poses when the Timeline scrubs.
410
- onChange(listener: () => void) {
411
- const stopView = view.onChange(listener);
412
- const stopSkin = blenderSkin.subscribe(listener);
413
- return () => {
414
- stopView();
415
- stopSkin();
416
- };
417
- },
502
+ // and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing:
503
+ // its script moves it every frame, and a source that announces nothing is drawn every
504
+ // frame (`ToolObject3DPreviewSource.onChange`).
505
+ ...(play
506
+ ? {}
507
+ : {
508
+ onChange(listener: () => void) {
509
+ const stopView = view.onChange(listener);
510
+ const stopSkin = blenderSkin.subscribe(listener);
511
+ return () => {
512
+ stopView();
513
+ stopSkin();
514
+ };
515
+ },
516
+ }),
418
517
  dispose() {},
419
518
  }),
420
- [view],
519
+ [view, play],
421
520
  );
422
521
  const blend = document?.source?.path;
423
522
  // Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
@@ -457,7 +556,8 @@ function BlenderViewportArea({
457
556
  * person's.
458
557
  */
459
558
  useEffect(() => {
460
- if (main || !documentId) return;
559
+ // A playing copy's camera is its script's.
560
+ if (main || play || !documentId) return;
461
561
  let entered = false;
462
562
  const enter = (): void => {
463
563
  if (entered) return;
@@ -472,7 +572,101 @@ function BlenderViewportArea({
472
572
  stopSessions();
473
573
  stopFrames();
474
574
  };
475
- }, [main, documentId, view]);
575
+ }, [main, play, documentId, view]);
576
+ /**
577
+ * THE PLAYING COPY IS MOVED BY THE PROJECT'S PLAY SCRIPT (the document Play extension), on this
578
+ * stage's own frame hook, once the stage stands. The stage's orbit stands down while it runs,
579
+ * so the script's camera is not argued with, and Escape stops the game.
580
+ */
581
+ const notifyRef = useRef(notify);
582
+ const surfaceRef = useRef<HTMLDivElement>(null);
583
+ const [playReady, setPlayReady] = useState(false);
584
+ notifyRef.current = notify;
585
+ const playReadyRef = useRef(onPlayReady);
586
+ playReadyRef.current = onPlayReady;
587
+ const playReturnRef = useRef(onPlayReturn);
588
+ playReturnRef.current = onPlayReturn;
589
+ useEffect(() => {
590
+ if (!play || !documentId) return;
591
+ if (blend === undefined) {
592
+ notifyRef.current?.({
593
+ tone: 'error',
594
+ title: 'This model has no play script',
595
+ detail: 'A play script stands beside a model\'s .blend file, and this model has no file of its own.',
596
+ });
597
+ return;
598
+ }
599
+ const modelId = documentId.replace(/#play$/, '');
600
+ const layers = window.document.createElement('div');
601
+ Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
602
+ layers.dataset.testid = 'model-play-roots';
603
+ surfaceRef.current?.appendChild(layers);
604
+ setPlayReady(false);
605
+ let stopped = false;
606
+ let preparing = false;
607
+ let stopScript: (() => void) | null = null;
608
+ let orbit: { enabled: boolean } | null = null;
609
+ const start = (): void => {
610
+ if (stopScript || preparing || stopped) return;
611
+ const stage = viewportStages().find((one) => one.documentId === documentId);
612
+ if (!stage || !view.root.parent) return;
613
+ preparing = true;
614
+ orbit = stage.rig().orbit;
615
+ orbit.enabled = false;
616
+ void view.prepareRendered(stage.rig().drawCamera()).then(() => {
617
+ if (stopped) return;
618
+ stopScript = documentPlayExtension('model')?.run({
619
+ documentId: modelId,
620
+ container: layers,
621
+ ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
622
+ returning: () => { if (!stopped) playReturnRef.current?.(); },
623
+ sourcePath: blend,
624
+ root: view.root,
625
+ camera: () => stage.rig().drawCamera(),
626
+ editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
627
+ onFrame: (fn) => stage.onFrame(fn),
628
+ report: (title, detail) => {
629
+ editorHost().console.error(`${title}: ${detail}`, 'blender-play');
630
+ notifyRef.current?.({ tone: 'error', title, detail });
631
+ },
632
+ }) ?? null;
633
+ }, error => {
634
+ if (stopped) return;
635
+ editorHost().console.error(`Rendered stage preparation failed: ${String(error)}`, 'model-play');
636
+ documentPlayExtension('model')?.setPlaying(modelId, false);
637
+ });
638
+ };
639
+ start();
640
+ let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
641
+ let stopPrepareFrames: (() => void) | undefined;
642
+ const waitForDraw = () => {
643
+ const stage = viewportStages().find(one => one.documentId === documentId);
644
+ if (stage === waitingStage) return;
645
+ stopPrepareFrames?.();
646
+ waitingStage = stage;
647
+ stopPrepareFrames = stage?.onFrame(start);
648
+ start();
649
+ };
650
+ waitForDraw();
651
+ const stopStages = onViewportStages(waitForDraw);
652
+ const onEscape = (event: KeyboardEvent): void => {
653
+ if (event.key === 'Escape' && surfaceAcceptsKey(event)) {
654
+ const extension = documentPlayExtension('model');
655
+ if (extension?.escape) extension.escape(modelId);
656
+ else extension?.setPlaying(modelId, false);
657
+ }
658
+ };
659
+ window.addEventListener('keydown', onEscape, true);
660
+ return () => {
661
+ stopped = true;
662
+ stopStages();
663
+ stopPrepareFrames?.();
664
+ window.removeEventListener('keydown', onEscape, true);
665
+ stopScript?.();
666
+ layers.remove();
667
+ if (orbit) orbit.enabled = true;
668
+ };
669
+ }, [play, documentId, blend, view]);
476
670
  /**
477
671
  * A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
478
672
  * on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
@@ -610,8 +804,11 @@ function BlenderViewportArea({
610
804
  stopFrame = stage ? stage.onFrame(() => view.refreshRendered()) : null;
611
805
  framed = stage;
612
806
  }
613
- const { lighting } = viewPresentation(documentId);
614
- view.holdRendered(lighting.source === 'scene' && stage ? drawCamera : null);
807
+ const { lighting, drawMode } = viewPresentation(documentId);
808
+ const preview = drawMode === 'preview';
809
+ const sceneLighting = preview ? { world: lighting.source === 'scene', lights: lighting.preview.sceneLights } : undefined;
810
+ const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
811
+ view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, preview ? 'viewport' : 'render');
615
812
  // BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
616
813
  // presenter draws every surface by it (`blender-workbench-material.ts`).
617
814
  view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
@@ -639,7 +836,7 @@ function BlenderViewportArea({
639
836
  */
640
837
  const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
641
838
  const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
642
- event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
839
+ !play && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
643
840
  const onPointerDownCapture = (event: ReactPointerEvent): void => {
644
841
  if (!cursorChord(event)) {
645
842
  cursorPress.current = null;
@@ -687,7 +884,9 @@ function BlenderViewportArea({
687
884
  const Surface = surfaces.Object3DAuthoring;
688
885
  return (
689
886
  <div
690
- style={{ display: 'contents' }}
887
+ ref={surfaceRef}
888
+ data-play-ready={play ? playReady : undefined}
889
+ style={play ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
691
890
  onPointerDownCapture={onPointerDownCapture}
692
891
  onPointerUpCapture={onPointerUpCapture}
693
892
  onPointerCancelCapture={dropCursorPress}
@@ -714,7 +913,9 @@ function BlenderViewportArea({
714
913
  // provider; the default adapter it delegates to still answers everything
715
914
  // about the presentation (WORK.md §Blender in the tab is Blender,
716
915
  // "Inspection parity", I3).
717
- authoring={authoring}
916
+ // A PLAYING COPY HAS NO AUTHORING: it is read-only to the editor, and what moves it is its
917
+ // script. With none given the stage is the native read-only one.
918
+ {...(play ? {} : { authoring })}
718
919
  cameraDirection={[0.8187, 0.4458, 0.3617]}
719
920
  // FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
720
921
  // three fits.
@@ -730,7 +931,13 @@ function BlenderViewportArea({
730
931
  const saved = view.savedView();
731
932
  if (!saved) return null;
732
933
  const camera = { fov: blenderViewFieldOfView(saved.lens) };
733
- return main && saved.drawMode ? { camera, drawMode: saved.drawMode } : { camera };
934
+ return main && saved.drawMode ? {
935
+ camera, drawMode: saved.drawMode,
936
+ modes: { preview: {
937
+ lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
938
+ backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
939
+ } },
940
+ } : { camera };
734
941
  })()}
735
942
  // Every entry this document opens is a `model` stage, the standing `blender:runtime`
736
943
  // address included (its id carries no `model:` prefix).
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.97",
5
+ "version": "0.1.99",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -70,14 +70,14 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "1.30.0",
73
- "@volter/blender-engine": "0.1.95",
74
- "@volter/editor-threejs": "0.5.157",
73
+ "@volter/blender-engine": "0.1.97",
74
+ "@volter/editor-threejs": "0.5.159",
75
75
  "zod": "^4.3.6"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/editor-live": "0.5.157",
79
- "@volter/editor-project": "0.5.157",
80
- "@volter/editor-sdk": "0.5.157",
78
+ "@volter/editor-live": "0.5.159",
79
+ "@volter/editor-project": "0.5.159",
80
+ "@volter/editor-sdk": "0.5.159",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
83
  "three": "^0.180.0",