@volter/editor-blender 0.1.127 → 0.1.129

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.
@@ -30,6 +30,7 @@ import {
30
30
  type BlenderRuntimeView,
31
31
  blenderModelView,
32
32
  } from '@volter/blender-engine/browser/three/blender-runtime-view';
33
+ import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
33
34
  import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
34
35
  import { editorHost } from '@volter/editor-sdk/host';
35
36
  import {
@@ -328,7 +329,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
328
329
  const requestOpen = () => {
329
330
  if (cancelled || queued) return;
330
331
  queued = true;
331
- queueMicrotask(() => { queued = false; void open(); });
332
+ setTimeout(() => { queued = false; void open(); }, 0);
332
333
  };
333
334
  const unsubscribe = documents.subscribe(requestOpen);
334
335
  requestOpen();
@@ -432,6 +433,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
432
433
  () => false,
433
434
  );
434
435
  const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
436
+ const playAspect = useSyncExternalStore(
437
+ subscribeDocumentPlayExtensions,
438
+ () => (documentId ? documentPlayExtension('model')?.aspectRatio?.(documentId) ?? null : null),
439
+ () => null,
440
+ );
435
441
  const [playedReady, setPlayedReady] = useState(false);
436
442
  useEffect(() => {
437
443
  if (!playing || !documentId) return;
@@ -482,8 +488,14 @@ function BlenderModelViewport(props: ToolContributionProps) {
482
488
  <BlenderViewportArea {...props} view={view} main />
483
489
  </div>
484
490
  {game && (
485
- <div key="play" style={{ position: 'absolute', inset: 12 }} data-testid="blender-play-area">
491
+ <div key="play" style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
492
+ <div data-volter-play-frame style={playAspect === null ? { position: 'absolute', inset: 0 } : {
493
+ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
494
+ width: `min(100cqw, calc(100cqh * ${playAspect}))`,
495
+ height: `min(100cqh, calc(100cqw / ${playAspect}))`,
496
+ }}>
486
497
  <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
498
+ </div>
487
499
  </div>
488
500
  )}
489
501
  {second && (
@@ -694,7 +706,7 @@ function BlenderViewportArea({
694
706
  preparing = true;
695
707
  orbit = stage.rig().orbit;
696
708
  orbit.enabled = false;
697
- void view.prepareRendered(stage.rig().drawCamera()).then(() => {
709
+ void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
698
710
  if (stopped) return;
699
711
  stopScript = documentPlayExtension('model')?.run({
700
712
  documentId: modelId,
@@ -706,6 +718,7 @@ function BlenderViewportArea({
706
718
  camera: () => stage.rig().drawCamera(),
707
719
  editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
708
720
  onFrame: (fn) => stage.onFrame(fn),
721
+ ownMaterial: (material) => ownedSurfaceMaterial(material as THREE.Material),
709
722
  report: (title, detail) => {
710
723
  editorHost().console.error(`${title}: ${detail}`, 'blender-play');
711
724
  notifyRef.current?.({ tone: 'error', title, detail });
@@ -864,7 +877,7 @@ function BlenderViewportArea({
864
877
  * BLENDER'S RENDERED SHADING IS THE SCENE'S OWN LIGHT. When this stage's view lights by the
865
878
  * `scene` (the Rendered shading cell, or the Lighting row's Scene), the presenter holds the viewport
866
879
  * 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
880
+ * scene's lights, its World and shadows, through the camera the stage draws
868
881
  * with, re-applied from the stage's frame loop when that camera or the World changes. Any
869
882
  * other source returns it to modeling. The stage itself draws a `scene` source unlit
870
883
  * (`standard-viewport-dressing.ts`), because Blender's lights live in the presenter.
@@ -889,7 +902,9 @@ function BlenderViewportArea({
889
902
  const preview = drawMode === 'preview';
890
903
  const sceneLighting = preview ? { world: lighting.source === 'scene', lights: lighting.preview.sceneLights } : undefined;
891
904
  const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
892
- view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, preview ? 'viewport' : 'render');
905
+ // Shading changes lighting, not which objects an editing viewport shows.
906
+ // Only the detached game uses render visibility, as a clean capture does.
907
+ view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, playing ? 'render' : 'viewport');
893
908
  // BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
894
909
  // presenter draws every surface by it (`blender-workbench-material.ts`).
895
910
  view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
@@ -904,7 +919,7 @@ function BlenderViewportArea({
904
919
  view.holdRendered(null);
905
920
  view.setWorkbench(false);
906
921
  };
907
- }, [documentId, view]);
922
+ }, [documentId, view, playing]);
908
923
  /**
909
924
  * SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
910
925
  * `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
@@ -976,6 +991,7 @@ function BlenderViewportArea({
976
991
  >
977
992
  <Surface
978
993
  active={active ?? true}
994
+ fillContainer={playing}
979
995
  documentId={documentId}
980
996
  sourcePath={blend ?? 'blender:runtime'}
981
997
  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.129",
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.127",
74
+ "@volter/editor-threejs": "0.5.190",
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.190",
79
+ "@volter/editor-project": "0.5.190",
80
+ "@volter/editor-sdk": "0.5.190",
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();