@volter/editor-blender 0.1.0 → 0.1.2

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.
@@ -452,13 +452,10 @@ export function BlenderObjectModeHeader({
452
452
  disabled={selected.length === 0}
453
453
  title={nothingSelected}
454
454
  onSelect={run('Duplicate Objects', async (outliner) => {
455
- // One at a time, for the reason `duplicateSelection` awaits its own
456
- // loop: each call runs a bpy script and the next must start from
457
- // what the last produced.
458
- for (const name of [...outliner.selectedObjectNames()]) {
459
- const id = outliner.rowIdForObject(name);
460
- if (id !== null) await outliner.structure.duplicate(id).ack;
461
- }
455
+ const ids = outliner.selectedObjectNames()
456
+ .map(name => outliner.rowIdForObject(name))
457
+ .filter((id): id is string => id !== null);
458
+ return outliner.structure.duplicateMany?.(ids);
462
459
  })}
463
460
  >
464
461
  Duplicate Objects
@@ -75,7 +75,7 @@ import type {
75
75
  WriteAck,
76
76
  } from '@volter/editor-project/adapter';
77
77
  import type * as THREE from 'three';
78
- import { blenderExecute, blenderRnaSet } from '../host/blender-runtime-host';
78
+ import { blenderExecute, blenderRnaSet, beginBlenderGesture, endBlenderGesture } from '../host/blender-runtime-host';
79
79
  import {
80
80
  blenderEngineSelection,
81
81
  blenderOutlinerState,
@@ -858,6 +858,7 @@ function py(name: string): string {
858
858
  */
859
859
  async function runBlenderOperator(
860
860
  body: string,
861
+ label = 'Blender Python',
861
862
  ): Promise<{ readonly made: readonly string[]; readonly error: string | null }> {
862
863
  const code = [
863
864
  'before = {o.name for o in bpy.data.objects}',
@@ -865,7 +866,7 @@ async function runBlenderOperator(
865
866
  'made = [o.name for o in bpy.data.objects if o.name not in before]',
866
867
  'print("\\n".join(made))',
867
868
  ].join('\n');
868
- const answer = await blenderExecute(code);
869
+ const answer = await blenderExecute(code, true, label);
869
870
  // THE ENGINE'S REFUSAL, VERBATIM. `session.py::execute` answers with the
870
871
  // traceback in `error` rather than raising, and a paraphrase here is how a
871
872
  // refusal becomes a shrug.
@@ -919,7 +920,7 @@ async function writeBlenderSelection(
919
920
  ? '_vl.objects.active = None'
920
921
  : `_vl.objects.active = bpy.data.objects.get(${py(active)})`,
921
922
  ].join('\n');
922
- const answer = await blenderExecute(body);
923
+ const answer = await blenderExecute(body, false);
923
924
  // THE ENGINE'S REFUSAL, VERBATIM — never a shrug. A selection that did not
924
925
  // land is the difference between the Properties rail showing this object and
925
926
  // showing the last one, so it is said out loud on the editor's console.
@@ -1004,6 +1005,9 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1004
1005
  let lastEngineKey: string | null = null;
1005
1006
  /** The world matrix each live gesture started from — see `beginEdit`. */
1006
1007
  const gestureStart = new Map<THREE.Object3D, THREE.Matrix4>();
1008
+ // Retain the subject until endEdit, even if a concurrent native edit removes
1009
+ // it from the presented frame. The history group must still be closed.
1010
+ const gestureObjects = new Map<string, THREE.Object3D>();
1007
1011
 
1008
1012
  const notify = (): void => {
1009
1013
  for (const listener of [...listeners]) listener();
@@ -1386,7 +1390,11 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1386
1390
  * one gesture in both id spaces (see the header). */
1387
1391
  beginEdit: (id) => {
1388
1392
  const object = objectFor(id);
1389
- if (object !== null) gestureStart.set(object, object.matrixWorld.clone());
1393
+ if (object !== null && !gestureStart.has(object)) {
1394
+ gestureStart.set(object, object.matrixWorld.clone());
1395
+ gestureObjects.set(id, object);
1396
+ beginBlenderGesture();
1397
+ }
1390
1398
  },
1391
1399
  apply: (id, transform) => {
1392
1400
  const object = objectFor(id);
@@ -1404,29 +1412,35 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1404
1412
  object.updateMatrixWorld(true);
1405
1413
  },
1406
1414
  endEdit: async (id): Promise<WriteAck | undefined> => {
1407
- const object = objectFor(id);
1415
+ const object = gestureObjects.get(id) ?? objectFor(id);
1416
+ gestureObjects.delete(id);
1408
1417
  const view = blenderPresentedView();
1409
- if (object === null || view === null) return undefined;
1418
+ if (object === null) return undefined;
1410
1419
  const started = gestureStart.get(object);
1411
1420
  gestureStart.delete(object);
1412
- object.updateWorldMatrix(true, false);
1413
- if (started?.equals(object.matrixWorld) === true) return undefined;
1414
- const name = view.blenderObjectName(object);
1415
- if (name === null) return undefined;
1416
- // The engine's own address, quoted the way `_rna_resolve` parses it.
1417
- const path = `bpy.data.objects[${JSON.stringify(name)}]`;
1418
1421
  try {
1419
- await blenderRnaSet(path, 'matrix_world', blenderWorldMatrixRows(object, view.root));
1420
- return {
1421
- destination: `Blender — ${path}.matrix_world; the session saves the .blend`,
1422
- persisted: true,
1423
- };
1424
- } catch (error) {
1425
- // The engine's refusal, verbatim — never paraphrased and never acked.
1426
- return {
1427
- destination: error instanceof Error ? error.message : String(error),
1428
- persisted: false,
1429
- };
1422
+ if (view === null) return undefined;
1423
+ object.updateWorldMatrix(true, false);
1424
+ if (started?.equals(object.matrixWorld) === true) return undefined;
1425
+ const name = view.blenderObjectName(object);
1426
+ if (name === null) return undefined;
1427
+ // The engine's own address, quoted the way `_rna_resolve` parses it.
1428
+ const path = `bpy.data.objects[${JSON.stringify(name)}]`;
1429
+ try {
1430
+ await blenderRnaSet(path, 'matrix_world', blenderWorldMatrixRows(object, view.root));
1431
+ return {
1432
+ destination: `Blender — ${path}.matrix_world; the session saves the .blend`,
1433
+ persisted: true,
1434
+ };
1435
+ } catch (error) {
1436
+ // The engine's refusal, verbatim — never paraphrased and never acked.
1437
+ return {
1438
+ destination: error instanceof Error ? error.message : String(error),
1439
+ persisted: false,
1440
+ };
1441
+ }
1442
+ } finally {
1443
+ if (started) await endBlenderGesture();
1430
1444
  }
1431
1445
  },
1432
1446
  };
@@ -1484,11 +1498,9 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1484
1498
  * writing the engine's selection first — a write the document would then
1485
1499
  * save.
1486
1500
  *
1487
- * THERE IS NO UNDO, and that is recorded rather than silently absent: the
1488
- * engine runs `--background` with no undo stack, and this editor's history
1489
- * has no door a contributed package can push an element through (WORK.md
1490
- * §THE BLENDER RELEASE, B5's OPEN (a)). Every verb here is as un-undoable as
1491
- * the gizmo drag beside it.
1501
+ * The script door checkpoints Blender's native undo stack and records its
1502
+ * callback in Code-OSS. These operators are undone by restoring Blender's
1503
+ * state, never by constructing an inverse operator or replaying the script.
1492
1504
  */
1493
1505
  const structure: StructureProvider = {
1494
1506
  /**
@@ -1572,9 +1584,10 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1572
1584
  const body = [
1573
1585
  `targets = ${targets}`,
1574
1586
  'with bpy.context.temp_override(selected_objects=targets, active_object=targets[0]):',
1575
- ' bpy.ops.object.delete(use_global=False)',
1587
+ ' result = bpy.ops.object.delete(use_global=False)',
1588
+ 'if "FINISHED" not in result: raise RuntimeError("Blender cancelled deleting the selected objects")',
1576
1589
  ].join('\n');
1577
- const { error } = await runBlenderOperator(body);
1590
+ const { error } = await runBlenderOperator(body, 'Delete Objects');
1578
1591
  if (error !== null) return refuse(error);
1579
1592
  // The deleted rows are gone; re-read with NO selection so nothing points
1580
1593
  // at a name `bpy.data.objects` no longer has.
@@ -1608,12 +1621,13 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1608
1621
  const body = [
1609
1622
  `source = bpy.data.objects[${py(name)}]`,
1610
1623
  'with bpy.context.temp_override(selected_objects=[source], active_object=source):',
1611
- ' bpy.ops.object.duplicate(linked=False)',
1624
+ ' result = bpy.ops.object.duplicate(linked=False)',
1625
+ 'if "FINISHED" not in result: raise RuntimeError("Blender cancelled duplicating the selected object")',
1612
1626
  ].join('\n');
1613
1627
  return {
1614
1628
  id,
1615
1629
  ack: (async (): Promise<WriteAck> => {
1616
- const { made, error } = await runBlenderOperator(body);
1630
+ const { made, error } = await runBlenderOperator(body, 'Duplicate Object');
1617
1631
  if (error !== null) return refuse(error);
1618
1632
  await selectAfterOperator(made);
1619
1633
  return {
@@ -1623,6 +1637,24 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1623
1637
  })(),
1624
1638
  };
1625
1639
  },
1640
+ duplicateMany: async (ids): Promise<WriteAck> => {
1641
+ const names = [...new Set(ids.map(objectNameFor))];
1642
+ if (names.length === 0 || names.some(name => name === null))
1643
+ return refuse('Select objects to duplicate; collections and data rows cannot be duplicated here.');
1644
+ const body = [
1645
+ `sources = [bpy.data.objects[name] for name in ${JSON.stringify(names)}]`,
1646
+ 'with bpy.context.temp_override(selected_objects=sources, active_object=sources[0]):',
1647
+ ' result = bpy.ops.object.duplicate(linked=False)',
1648
+ 'if "FINISHED" not in result: raise RuntimeError("Blender cancelled duplicating the selected objects")',
1649
+ ].join('\n');
1650
+ const { made, error } = await runBlenderOperator(body, 'Duplicate Objects');
1651
+ if (error !== null) return refuse(error);
1652
+ await selectAfterOperator(made);
1653
+ return {
1654
+ destination: 'Blender — duplicate selected objects as one native operation; the session saves the .blend',
1655
+ persisted: true,
1656
+ };
1657
+ },
1626
1658
  /** REFUSED BY NAME. Parenting in Blender is `object.parent_set`, and it is
1627
1659
  * a gesture of its own with a type to choose (Object, Object Keep
1628
1660
  * Transform, Armature Deform, …) and a `matrix_parent_inverse` to settle.
@@ -521,7 +521,8 @@ export class BlenderSkinDirector {
521
521
  this.#engineCalls++;
522
522
  // `rna_set` refuses `bpy.context.scene` — the scene must be addressed by
523
523
  // name through `bpy.data.scenes[...]`.
524
- await blenderRnaSet(`bpy.data.scenes[${JSON.stringify(scene)}]`, 'frame_current', frame);
524
+ // Timeline navigation, like selection, must not erase an available redo.
525
+ await blenderRnaSet(`bpy.data.scenes[${JSON.stringify(scene)}]`, 'frame_current', frame, undefined, false);
525
526
  this.#clip = { ...clip, frameCurrent: frame };
526
527
  this.#publish();
527
528
  return frame;
@@ -52,7 +52,8 @@ const verb = (derivedRefresh: CommandDerivedRefresh, timeoutMs?: number): Comman
52
52
 
53
53
  export const commands: CommandContribution['commands'] = {
54
54
  'blender-start': verb('none', 120_000),
55
- 'blender-stop': verb('none'),
55
+ // Stop drains accepted modeling work and persists it before teardown.
56
+ 'blender-stop': verb('none', 30 * 60_000),
56
57
  'blender-execute': verb('always', 30 * 60_000),
57
58
  'blender-scene-info': verb('none', 60_000),
58
59
  'blender-object-info': verb('none', 60_000),
@@ -338,9 +338,10 @@ export async function blenderRnaSet(
338
338
  property: string,
339
339
  value: unknown,
340
340
  index?: number,
341
+ history = true,
341
342
  ): Promise<BlenderRnaWrite | null> {
342
343
  if (!blenderSessionStarted()) return null;
343
- const written = await blenderRuntime().rnaSet(path, property, value, index);
344
+ const written = await blenderRuntime().rnaSet(path, property, value, index, history);
344
345
  noteBlenderRnaChanged();
345
346
  return written;
346
347
  }
@@ -391,8 +392,8 @@ export async function blenderRnaSet(
391
392
  * two prefixes and records the same lesson from its own bug; this is the
392
393
  * second time, so the parse lives at the door now and both read it.
393
394
  */
394
- export async function blenderExecute(code: string): Promise<BlenderExecuteAnswer> {
395
- const text = await blenderRuntime().execute(code);
395
+ export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
396
+ const text = await blenderRuntime().execute(code, history, label);
396
397
  noteBlenderRnaChanged();
397
398
  const failed = /^Error executing code:/.exec(text);
398
399
  return {
@@ -403,6 +404,16 @@ export async function blenderExecute(code: string): Promise<BlenderExecuteAnswer
403
404
  };
404
405
  }
405
406
 
407
+ /** Coalesce a human gesture; Blender retains the states, Code-OSS the ordering. */
408
+ export function beginBlenderGesture(): void {
409
+ void blenderRuntime().historyGesture('history-begin').catch(error =>
410
+ editorHost().console.error(`Could not begin Blender undo gesture: ${String(error)}`, 'blender-history'));
411
+ }
412
+
413
+ export async function endBlenderGesture(): Promise<void> {
414
+ await blenderRuntime().historyGesture('history-end');
415
+ }
416
+
406
417
  /** One script's answer, with the MCP door's text split from what it means.
407
418
  * `text` is what the wire and an MCP client get, verbatim; the other three are
408
419
  * what a UI caller needs, and the `error` half is the one it must not drop. */
@@ -626,6 +637,12 @@ interface RuntimeView {
626
637
  }
627
638
 
628
639
  let runtime: BlenderRuntime | null = null;
640
+ const historyResources = new Set<string>();
641
+
642
+ function invalidateBlenderHistory(): void {
643
+ if (historyResources.size) editorHost().history.invalidate([...historyResources]);
644
+ historyResources.clear();
645
+ }
629
646
  let captureLifetime: AbortController | null = null;
630
647
  /** Whether this module has asked the host to tell it when the session ends.
631
648
  * Once per page, taken on the first runtime — before one there is nothing to
@@ -633,6 +650,7 @@ let captureLifetime: AbortController | null = null;
633
650
  let watchingSessionEnd = false;
634
651
 
635
652
  function terminateBlenderRuntime(): void {
653
+ invalidateBlenderHistory();
636
654
  captureLifetime?.abort();
637
655
  captureLifetime = null;
638
656
  runtime?.terminate();
@@ -652,6 +670,10 @@ function terminateBlenderRuntime(): void {
652
670
  function watchSessionEnd(): void {
653
671
  if (watchingSessionEnd) return;
654
672
  watchingSessionEnd = true;
673
+ editorHost().session.onBeforeClose(async () => {
674
+ await runtime?.stop();
675
+ terminateBlenderRuntime();
676
+ });
655
677
  editorHost().session.onEnded(terminateBlenderRuntime);
656
678
  }
657
679
  let lastCapture: CaptureRequest | null = null;
@@ -684,6 +706,29 @@ export function blenderRuntime(): BlenderRuntime {
684
706
  captureLifetime = lifetime;
685
707
  let photographing = false;
686
708
  runtime = new BlenderRuntime({
709
+ history: (entries) => {
710
+ const owner = runtime;
711
+ if (!owner) throw new Error('Blender history arrived before its runtime');
712
+ for (const entry of entries) {
713
+ if ('reset' in entry) {
714
+ invalidateBlenderHistory();
715
+ continue;
716
+ }
717
+ if (!entry.resource) throw new Error('A Blender edit has no document resource');
718
+ historyResources.add(entry.resource);
719
+ const restore = async (direction: 'undo' | 'redo'): Promise<boolean> => {
720
+ if (runtime !== owner) throw new Error('This Blender history belongs to a closed worker');
721
+ const moved = await owner.historyStep(entry.id, direction);
722
+ noteBlenderRnaChanged();
723
+ return moved;
724
+ };
725
+ editorHost().history.record({
726
+ id: entry.id, label: entry.label, resources: [entry.resource],
727
+ document: presentationDocumentId(),
728
+ undo: () => restore('undo'), redo: () => restore('redo'),
729
+ });
730
+ }
731
+ },
687
732
  present: async (frame, description, capture) => {
688
733
  const documentId = presentationDocumentId();
689
734
  const view = await runtimeView();
@@ -941,13 +986,13 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
941
986
  * path is outside the project, because a record is better absent than wrong.
942
987
  */
943
988
  async function sessionDocumentPath(session: {
944
- execute(code: string): Promise<string>;
989
+ execute(code: string, history?: boolean): Promise<string>;
945
990
  }): Promise<{ document?: string }> {
946
991
  const project = editorHost().projectLocalState.projectRootPath();
947
992
  if (project === null) return {};
948
993
  let answer: string;
949
994
  try {
950
- answer = await session.execute('import bpy\nprint(bpy.data.filepath)\n');
995
+ answer = await session.execute('import bpy\nprint(bpy.data.filepath)\n', false);
951
996
  } catch {
952
997
  return {};
953
998
  }
@@ -1001,6 +1046,8 @@ export async function handleBlenderCommand(cmd: {
1001
1046
  try {
1002
1047
  const project = cmd.type === 'blender-start' ? string(cmd, 'project') : null;
1003
1048
  if (cmd.type === 'blender-stop' || (cmd.type === 'blender-start' && cmd['fresh'] === true)) {
1049
+ // Do not invalidate history or discard the worker if persistence fails.
1050
+ await runtime?.stop();
1004
1051
  terminateBlenderRuntime();
1005
1052
  if (cmd.type === 'blender-stop') return { ok: true, data: { stopped: true } };
1006
1053
  }
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.0",
5
+ "version": "0.1.2",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -61,8 +61,8 @@
61
61
  ]
62
62
  },
63
63
  "dependencies": {
64
- "@volter/blender-engine": "0.1.0",
65
- "@volter/editor-threejs": "0.5.57",
64
+ "@volter/blender-engine": "0.1.2",
65
+ "@volter/editor-threejs": "0.5.59",
66
66
  "zod": "^4.3.6"
67
67
  },
68
68
  "peerDependencies": {