@volter/editor-blender 0.1.127 → 0.1.128

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.
@@ -328,7 +328,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
328
328
  const requestOpen = () => {
329
329
  if (cancelled || queued) return;
330
330
  queued = true;
331
- queueMicrotask(() => { queued = false; void open(); });
331
+ setTimeout(() => { queued = false; void open(); }, 0);
332
332
  };
333
333
  const unsubscribe = documents.subscribe(requestOpen);
334
334
  requestOpen();
@@ -432,6 +432,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
432
432
  () => false,
433
433
  );
434
434
  const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
435
+ const playAspect = useSyncExternalStore(
436
+ subscribeDocumentPlayExtensions,
437
+ () => (documentId ? documentPlayExtension('model')?.aspectRatio?.(documentId) ?? null : null),
438
+ () => null,
439
+ );
435
440
  const [playedReady, setPlayedReady] = useState(false);
436
441
  useEffect(() => {
437
442
  if (!playing || !documentId) return;
@@ -482,8 +487,14 @@ function BlenderModelViewport(props: ToolContributionProps) {
482
487
  <BlenderViewportArea {...props} view={view} main />
483
488
  </div>
484
489
  {game && (
485
- <div key="play" style={{ position: 'absolute', inset: 12 }} data-testid="blender-play-area">
490
+ <div key="play" style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
491
+ <div data-volter-play-frame style={playAspect === null ? { position: 'absolute', inset: 0 } : {
492
+ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
493
+ width: `min(100cqw, calc(100cqh * ${playAspect}))`,
494
+ height: `min(100cqh, calc(100cqw / ${playAspect}))`,
495
+ }}>
486
496
  <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
497
+ </div>
487
498
  </div>
488
499
  )}
489
500
  {second && (
@@ -694,7 +705,7 @@ function BlenderViewportArea({
694
705
  preparing = true;
695
706
  orbit = stage.rig().orbit;
696
707
  orbit.enabled = false;
697
- void view.prepareRendered(stage.rig().drawCamera()).then(() => {
708
+ void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
698
709
  if (stopped) return;
699
710
  stopScript = documentPlayExtension('model')?.run({
700
711
  documentId: modelId,
@@ -864,7 +875,7 @@ function BlenderViewportArea({
864
875
  * BLENDER'S RENDERED SHADING IS THE SCENE'S OWN LIGHT. When this stage's view lights by the
865
876
  * `scene` (the Rendered shading cell, or the Lighting row's Scene), the presenter holds the viewport
866
877
  * in the lighting its render photographs with (`BlenderRuntimeView.holdRendered`): the
867
- * scene's lights, its World, `hide_render` and shadows, through the camera the stage draws
878
+ * scene's lights, its World and shadows, through the camera the stage draws
868
879
  * with, re-applied from the stage's frame loop when that camera or the World changes. Any
869
880
  * other source returns it to modeling. The stage itself draws a `scene` source unlit
870
881
  * (`standard-viewport-dressing.ts`), because Blender's lights live in the presenter.
@@ -889,7 +900,9 @@ function BlenderViewportArea({
889
900
  const preview = drawMode === 'preview';
890
901
  const sceneLighting = preview ? { world: lighting.source === 'scene', lights: lighting.preview.sceneLights } : undefined;
891
902
  const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
892
- view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, preview ? 'viewport' : 'render');
903
+ // Shading changes lighting, not which objects an editing viewport shows.
904
+ // Only the detached game uses render visibility, as a clean capture does.
905
+ view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, playing ? 'render' : 'viewport');
893
906
  // BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
894
907
  // presenter draws every surface by it (`blender-workbench-material.ts`).
895
908
  view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
@@ -904,7 +917,7 @@ function BlenderViewportArea({
904
917
  view.holdRendered(null);
905
918
  view.setWorkbench(false);
906
919
  };
907
- }, [documentId, view]);
920
+ }, [documentId, view, playing]);
908
921
  /**
909
922
  * SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
910
923
  * `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
@@ -976,6 +989,7 @@ function BlenderViewportArea({
976
989
  >
977
990
  <Surface
978
991
  active={active ?? true}
992
+ fillContainer={playing}
979
993
  documentId={documentId}
980
994
  sourcePath={blend ?? 'blender:runtime'}
981
995
  displayName={document?.label ?? 'Model'}
@@ -560,8 +560,12 @@ function blenderRoutesPlugin(services) {
560
560
  }
561
561
  res.setHeader("content-type", "application/octet-stream");
562
562
  res.setHeader("content-length", String(size));
563
+ if (size === 0) {
564
+ res.end();
565
+ return;
566
+ }
563
567
  await new Promise((resolve) => {
564
- const stream = createReadStream2(absolute);
568
+ const stream = createReadStream2(absolute, { start: 0, end: size - 1 });
565
569
  stream.once("error", () => {
566
570
  res.destroy();
567
571
  resolve();
@@ -901,16 +901,26 @@ async function runtimeView(): Promise<RuntimeView> {
901
901
  // command (3 s of boot, then the full wait).
902
902
  const deadline = Date.now() + 15_000;
903
903
  let id = presentationDocumentId();
904
+ let publication = 'no context';
904
905
  for (;;) {
905
906
  id = presentationDocumentId();
906
907
  const published = await documents.waitForContext(id, Math.max(0, Math.min(250, deadline - Date.now())));
907
908
  if (isRuntimeView(published)) return published;
909
+ if (published !== undefined) {
910
+ const required = ['applyFrame', 'stageFrame', 'snapshot', 'captureSnapshot', 'recordPresentation', 'recordPhotograph'];
911
+ const handle = published as Record<string, unknown> | null;
912
+ publication = `${typeof published} context missing ${required.filter(method => typeof handle?.[method] !== 'function').join(', ')}`;
913
+ // An incompatible published context resolves waitForContext immediately.
914
+ // Yield so a pending document commit can replace it; a microtask loop
915
+ // otherwise prevents that publication for the whole timeout window.
916
+ await new Promise<void>(resolve => setTimeout(resolve, Math.max(0, Math.min(25, deadline - Date.now()))));
917
+ }
908
918
  if (Date.now() >= deadline) break;
909
919
  }
910
920
  throw new Error(
911
921
  `The Blender Model document is not open, or the open one is not a Model this engine can present ` +
912
922
  `to (it must answer stageFrame, applyFrame, captureSnapshot, recordPresentation and recordPhotograph): nothing ` +
913
- `published a presentable view as ${id} within 15 s (bound model: ${boundModel?.documentId ?? 'none'}). ` +
923
+ `published a presentable view as ${id} within 15 s (bound model: ${boundModel?.documentId ?? 'none'}; last publication: ${publication}). ` +
914
924
  'Open the Model document first (`volter blender-mcp` opens it before its first call).',
915
925
  );
916
926
  }
@@ -304,7 +304,8 @@ export async function serveBlenderMcp(
304
304
  // it goes rendered one, wrote it into the project and ran the OS `open` on it: Preview started
305
305
  // outside the editor. The editor already shows a picture as one of its documents.
306
306
  const photographs = `A photograph is shown to the person in the editor, never in another app: get_viewport_screenshot returns one, and an image file written inside the project opens as an editor document with \`npx ${command} eval 'editor.openAsset("<project-relative path>")'\`. Do not run the OS \`open\` on it.`;
307
- const instructions = `${runtimeIdentity}\n${photographs}`;
307
+ const filesystem = `Project files are mounted in Blender at the project's actual absolute path: ${project}. Use that root for image loads, saves and exports; /project is not an alias.`;
308
+ const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
308
309
  const tools = (blenderTools as ToolShape[]).map((tool) =>
309
310
  tool.name === 'execute_blender_code'
310
311
  ? { ...tool, description: `${instructions}\n${tool.description}` }
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.127",
5
+ "version": "0.1.128",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -70,14 +70,14 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "1.32.1",
73
- "@volter/blender-engine": "0.1.125",
74
- "@volter/editor-threejs": "0.5.188",
73
+ "@volter/blender-engine": "0.1.126",
74
+ "@volter/editor-threejs": "0.5.189",
75
75
  "zod": "^4.3.6"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/editor-live": "0.5.188",
79
- "@volter/editor-project": "0.5.188",
80
- "@volter/editor-sdk": "0.5.188",
78
+ "@volter/editor-live": "0.5.189",
79
+ "@volter/editor-project": "0.5.189",
80
+ "@volter/editor-sdk": "0.5.189",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
83
  "three": "^0.180.0",
@@ -333,8 +333,10 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
333
333
  }
334
334
  res.setHeader('content-type', 'application/octet-stream');
335
335
  res.setHeader('content-length', String(size));
336
+ if (size === 0) { res.end(); return; }
336
337
  await new Promise<void>((resolve) => {
337
- const stream = createReadStream(absolute);
338
+ // Bound an append-only file to the exact response length advertised above.
339
+ const stream = createReadStream(absolute, { start: 0, end: size - 1 });
338
340
  stream.once('error', () => {
339
341
  res.destroy();
340
342
  resolve();