@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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
-
"@volter/editor-threejs": "0.5.
|
|
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": {
|