@volter/editor-blender 0.1.149 → 0.1.151
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.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +3 -7
- package/contributions/blender-node-editor.document.tsx +28 -180
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +100 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +15 -90
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +15 -147
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +217 -339
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +142 -332
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +43 -23
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +63 -0
- package/contributions/blender.status.tsx +9 -145
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +12 -32
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -30
- package/host/blender-runtime-host.ts +541 -372
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +49 -93
- package/package.json +1209 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -43
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/model-play-extension.ts +173 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +64 -38
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0007'S EXPORTS (`blender-export-component`, `blender-export-level`, session
|
|
3
|
+
* verbs): the model on screen, written by Blender's own glTF exporter (`bpy.ops.export_scene.gltf`
|
|
4
|
+
* in this tab's Blender, `@volter/blender-engine`'s `gltf_export.py`) into the project.
|
|
5
|
+
*
|
|
6
|
+
* - A COMPONENT (`.volter/export/components/<name>.glb`): one collection named after it, every
|
|
7
|
+
* action as a glTF animation, Y-up, the authoring collections `Notes` and `References` left out.
|
|
8
|
+
* - A LEVEL (`.volter/export/levels/<name>.glb`): the level's own objects, a placement marker in
|
|
9
|
+
* place of every library-override placement and every instance of a linked collection
|
|
10
|
+
* (`extras.component`, `extras.action`, `extras.tint`, `KHR_node_visibility`), and the timeline
|
|
11
|
+
* as one scene animation plus the scene's `extras.timeline`. Its answer names the components the
|
|
12
|
+
* markers reference (`cyclotron export level` exports or confirms each).
|
|
13
|
+
*
|
|
14
|
+
* A MANIFEST beside each file (`<name>.json`) records the `.blend`'s SHA-256, the exporter and its
|
|
15
|
+
* settings, and the file's SHA-256: the project's earlier manifest is handed to Blender, and when
|
|
16
|
+
* nothing it records has changed, nothing is exported (`current`).
|
|
17
|
+
*
|
|
18
|
+
* BOUNDED STEPS. An export of a large model holds Blender for longer than one call should last,
|
|
19
|
+
* so a call waits at most {@link STEP_MS} for it: an export still running answers
|
|
20
|
+
* `{ pending: true, token }`, and the same verb with that `token` waits again for the same export.
|
|
21
|
+
* Blender is never asked twice; the file is written once, when the export is done.
|
|
22
|
+
*
|
|
23
|
+
* `blend` names the `.blend` the caller means (project-relative); a model not yet presented is
|
|
24
|
+
* refused with "has not been presented yet", which the CLI waits on. `force` exports even when
|
|
25
|
+
* the manifest says the earlier export stands.
|
|
26
|
+
*/
|
|
27
|
+
import type { GltfExportAnswer, GltfExportKind, GltfExportManifest } from '@volter/blender-engine/browser/protocol';
|
|
28
|
+
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
29
|
+
import type { EditorCommandResult, VerbHandler } from '@volter/sdk/commands';
|
|
30
|
+
import { editorHost } from '@volter/sdk/host';
|
|
31
|
+
import { blenderEngineFile, blenderGltfExport, flushModelDocument, presentedModelBlend } from '../host/blender-runtime-host';
|
|
32
|
+
|
|
33
|
+
/** The longest one call waits for an export before answering `pending` (well inside a 95 s call). */
|
|
34
|
+
const STEP_MS = 60_000;
|
|
35
|
+
/** How long a finished export's answer is kept for a caller that has not asked for it yet. */
|
|
36
|
+
const KEEP_MS = 5 * 60_000;
|
|
37
|
+
|
|
38
|
+
/** Where each kind is written in the project. */
|
|
39
|
+
export const GLTF_EXPORT_DIRS: Readonly<Record<GltfExportKind, string>> = {
|
|
40
|
+
component: '.volter/export/components',
|
|
41
|
+
level: '.volter/export/levels',
|
|
42
|
+
};
|
|
43
|
+
/** One display LUT per project and per view settings, named by their key (`gltf_export.py`). */
|
|
44
|
+
export const GLTF_DISPLAY_DIR = '.volter/export/display';
|
|
45
|
+
|
|
46
|
+
/** The record of the LUTs carried in: each key and the SHA-256 its file had (read as one file: the standalone
|
|
47
|
+
* editor's file door lists only `public/**`, so the folder is never listed). */
|
|
48
|
+
const LUT_RECORD = `${GLTF_DISPLAY_DIR}/luts.json`;
|
|
49
|
+
|
|
50
|
+
async function lutRecord(): Promise<Record<string, string>> {
|
|
51
|
+
try {
|
|
52
|
+
const record = JSON.parse(await editorHost().files.read(LUT_RECORD)) as unknown;
|
|
53
|
+
return record && typeof record === 'object' ? record as Record<string, string> : {};
|
|
54
|
+
} catch { return {}; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** THE DISPLAY LUTS THE PROJECT HOLDS INTACT, by key, each with its SHA-256 as recorded when it was carried
|
|
58
|
+
* in (`luts.json` beside them). Checked on every export: a LUT whose bytes are not the ones recorded
|
|
59
|
+
* (damaged, replaced, gone) or that has no record is left out, so Blender bakes it again when the view
|
|
60
|
+
* settings name it. Blender bakes one only for view settings that have none. */
|
|
61
|
+
async function heldLuts(): Promise<Record<string, string>> {
|
|
62
|
+
const held: Record<string, string> = {};
|
|
63
|
+
await Promise.all(Object.entries(await lutRecord()).map(async ([key, recorded]) => {
|
|
64
|
+
// (a LUT that is gone has no hash: left out)
|
|
65
|
+
if (typeof recorded === 'string' && await fileSha256(`${GLTF_DISPLAY_DIR}/${key}.cube`).catch(() => null) === recorded) held[key] = recorded;
|
|
66
|
+
}));
|
|
67
|
+
return held;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Run {
|
|
71
|
+
readonly kind: GltfExportKind;
|
|
72
|
+
readonly blend: string;
|
|
73
|
+
readonly started: number;
|
|
74
|
+
readonly done: Promise<EditorCommandResult>;
|
|
75
|
+
settled: boolean;
|
|
76
|
+
expiry?: ReturnType<typeof setTimeout>;
|
|
77
|
+
}
|
|
78
|
+
const runs = new Map<string, Run>();
|
|
79
|
+
|
|
80
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
81
|
+
|
|
82
|
+
async function sha256(bytes: Uint8Array): Promise<string> {
|
|
83
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
|
|
84
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Cache each project file’s SHA-256 by size and modification time, persisting hashes in HASH_CACHE across reloads. A null file stat requires hashing on every read. */
|
|
88
|
+
const knownHashes = new Map<string, { readonly size: number; readonly mtime: number; readonly sha256: string }>();
|
|
89
|
+
const HASH_CACHE = '.volter/cache/file-hashes.json';
|
|
90
|
+
let hashesRead: Promise<void> | null = null;
|
|
91
|
+
let hashesDue: ReturnType<typeof setTimeout> | null = null;
|
|
92
|
+
function readHashes(): Promise<void> {
|
|
93
|
+
return hashesRead ??= (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const kept = JSON.parse(await editorHost().files.read(HASH_CACHE)) as Record<string, { size?: unknown; mtime?: unknown; sha256?: unknown }>;
|
|
96
|
+
for (const [path, entry] of Object.entries(kept))
|
|
97
|
+
if (typeof entry?.size === 'number' && typeof entry.mtime === 'number' && typeof entry.sha256 === 'string' && !knownHashes.has(path))
|
|
98
|
+
knownHashes.set(path, { size: entry.size, mtime: entry.mtime, sha256: entry.sha256 });
|
|
99
|
+
} catch { /* none kept yet, or unreadable: each file is hashed when asked */ }
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
/** The hashes written back a second after the last new one (one write for an export's several). */
|
|
103
|
+
function keepHashes(): void {
|
|
104
|
+
if (hashesDue !== null) return;
|
|
105
|
+
hashesDue = setTimeout(() => {
|
|
106
|
+
hashesDue = null;
|
|
107
|
+
void editorHost().files.write(HASH_CACHE, `${JSON.stringify(Object.fromEntries(knownHashes))}\n`).catch(() => {});
|
|
108
|
+
}, 1000);
|
|
109
|
+
}
|
|
110
|
+
async function fileSha256(path: string): Promise<string> {
|
|
111
|
+
const files = editorHost().files;
|
|
112
|
+
await readHashes();
|
|
113
|
+
const stat = await files.stat(path).catch(() => null);
|
|
114
|
+
const known = knownHashes.get(path);
|
|
115
|
+
if (stat && known && known.size === stat.size && known.mtime === stat.mtime) return known.sha256;
|
|
116
|
+
const hash = await sha256(await files.readBytes(path));
|
|
117
|
+
// (stat again after the read: a write landing between the two leaves no entry rather than a stale one)
|
|
118
|
+
const after = stat ? await files.stat(path).catch(() => null) : null;
|
|
119
|
+
if (stat && after && after.size === stat.size && after.mtime === stat.mtime) knownHashes.set(path, { size: stat.size, mtime: stat.mtime, sha256: hash });
|
|
120
|
+
else knownHashes.delete(path);
|
|
121
|
+
keepHashes();
|
|
122
|
+
return hash;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Cache a project manifest by its size and modification time; return undefined when absent. */
|
|
126
|
+
const knownManifests = new Map<string, { readonly size: number; readonly mtime: number; readonly manifest: GltfExportManifest }>();
|
|
127
|
+
async function readManifest(path: string): Promise<GltfExportManifest | undefined> {
|
|
128
|
+
const files = editorHost().files;
|
|
129
|
+
const stat = await files.stat(path).catch(() => null);
|
|
130
|
+
if (!stat) return undefined;
|
|
131
|
+
const known = knownManifests.get(path);
|
|
132
|
+
if (known && known.size === stat.size && known.mtime === stat.mtime) return known.manifest;
|
|
133
|
+
try {
|
|
134
|
+
const manifest = JSON.parse(await files.read(path)) as GltfExportManifest;
|
|
135
|
+
knownManifests.set(path, { size: stat.size, mtime: stat.mtime, manifest });
|
|
136
|
+
return manifest;
|
|
137
|
+
} catch { return undefined; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Cache an imported export’s SHA-256 and parsed manifest while its size matches the written bytes. Re-read any differing file. */
|
|
141
|
+
async function heldAsWritten(path: string, bytes: Uint8Array, manifest?: GltfExportManifest): Promise<void> {
|
|
142
|
+
const stat = await editorHost().files.stat(path).catch(() => null);
|
|
143
|
+
if (!stat || stat.size !== bytes.length) return;
|
|
144
|
+
if (manifest) {
|
|
145
|
+
if (typeof manifest.sha256 === 'string' && manifest.bytes === bytes.length) {
|
|
146
|
+
knownHashes.set(path, { size: stat.size, mtime: stat.mtime, sha256: manifest.sha256 });
|
|
147
|
+
keepHashes();
|
|
148
|
+
}
|
|
149
|
+
} else if (path.endsWith('.json')) {
|
|
150
|
+
try { knownManifests.set(path, { size: stat.size, mtime: stat.mtime, manifest: JSON.parse(new TextDecoder().decode(bytes)) as GltfExportManifest }); }
|
|
151
|
+
catch { knownManifests.delete(path); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What Blender's currency check reads of an earlier manifest (`gltf_export.py`'s `_current` and
|
|
156
|
+
* `_lut_missing`), so a request carries that and not the whole manifest; a current answer echoes it back. */
|
|
157
|
+
function currencyOf(manifest: GltfExportManifest): GltfExportManifest {
|
|
158
|
+
const whole = manifest as unknown as Record<string, unknown>;
|
|
159
|
+
const kept: Record<string, unknown> = {};
|
|
160
|
+
for (const key of ['format', 'blendSha256', 'exporter', 'settings', 'sha256', 'display']) if (key in whole) kept[key] = whole[key];
|
|
161
|
+
return kept as unknown as GltfExportManifest;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The project's earlier export of this file, as Blender compares it: its manifest and its file's hash. */
|
|
165
|
+
async function previousExport(dir: string, name: string): Promise<{ manifest: GltfExportManifest; sha256: string | null } | undefined> {
|
|
166
|
+
const files = editorHost().files;
|
|
167
|
+
// (the manifest and the file's hash at once: neither waits on the other)
|
|
168
|
+
const [manifest, sha256] = await Promise.all([readManifest(`${dir}/${name}.json`), fileSha256(`${dir}/${name}.glb`).catch(() => null)]);
|
|
169
|
+
if (!manifest) return undefined;
|
|
170
|
+
// A level's placements' own actions live beside it: one gone, the level is exported again.
|
|
171
|
+
const own = Object.values((manifest as { placementActions?: Record<string, { file?: unknown }> }).placementActions ?? {});
|
|
172
|
+
const present = await Promise.all(own.map(async (entry) =>
|
|
173
|
+
typeof entry.file !== 'string' || (await files.stat(`${dir}/${entry.file}`).catch(() => null)) !== null));
|
|
174
|
+
return present.every(Boolean) ? { manifest, sha256 } : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What a caller is told about an export: its paths and the manifest's counts, not the whole manifest. */
|
|
178
|
+
function summary(blend: string, dir: string, answer: GltfExportAnswer): Record<string, unknown> {
|
|
179
|
+
const manifest = answer.manifest;
|
|
180
|
+
return {
|
|
181
|
+
kind: answer.kind,
|
|
182
|
+
blend,
|
|
183
|
+
out: `${dir}/${answer.name}.glb`,
|
|
184
|
+
manifest: `${dir}/${answer.name}.json`,
|
|
185
|
+
current: answer.current,
|
|
186
|
+
ms: answer.ms,
|
|
187
|
+
bytes: manifest.bytes,
|
|
188
|
+
sha256: manifest.sha256,
|
|
189
|
+
exporter: manifest.exporter,
|
|
190
|
+
nodes: manifest.nodes,
|
|
191
|
+
animations: manifest.animations.length,
|
|
192
|
+
hiddenNodes: manifest.hiddenNodes,
|
|
193
|
+
warnings: manifest.warnings,
|
|
194
|
+
...(manifest.markers ? { markers: manifest.markers.length } : {}),
|
|
195
|
+
...(manifest.components ? { components: manifest.components } : {}),
|
|
196
|
+
...(manifest.unsupported ? { unsupported: manifest.unsupported } : {}),
|
|
197
|
+
...(manifest.collections ? { collections: manifest.collections } : {}),
|
|
198
|
+
// (what a component of this Blender is exported with: `cyclotron export level` checks the
|
|
199
|
+
// components' manifests against it without opening them)
|
|
200
|
+
componentSettings: answer.settings.component,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runExport(kind: GltfExportKind, blend: string, force: boolean): Promise<EditorCommandResult> {
|
|
205
|
+
const dir = GLTF_EXPORT_DIRS[kind];
|
|
206
|
+
const name = (blend.split('/').pop() ?? blend).replace(/\.blend$/i, '');
|
|
207
|
+
// (where the round trip's time goes, in ms: the earlier export read and hashed, the `.blend` hashed, the LUTs
|
|
208
|
+
// checked, Blender's answer, and its files carried into the project)
|
|
209
|
+
// (the first three run at once, each timed on its own)
|
|
210
|
+
const steps: Record<string, number> = {};
|
|
211
|
+
const began = performance.now();
|
|
212
|
+
const timed = async <T>(step: string, work: Promise<T>): Promise<T> => {
|
|
213
|
+
try { return await work; } finally { steps[step] = Math.round(performance.now() - began); }
|
|
214
|
+
};
|
|
215
|
+
await flushModelDocument(blend);
|
|
216
|
+
const [previous, blendSha256, luts] = await Promise.all([
|
|
217
|
+
timed('previous', force ? Promise.resolve(undefined) : previousExport(dir, name)),
|
|
218
|
+
// The `.blend` as the project holds it: Blender streamed it in and keeps no copy to hash.
|
|
219
|
+
timed('blendHash', fileSha256(blend)),
|
|
220
|
+
// The project's LUTs that are intact, with their recorded SHA-256s (which every manifest records): a damaged one is baked again.
|
|
221
|
+
timed('luts', heldLuts()),
|
|
222
|
+
]);
|
|
223
|
+
let at = performance.now();
|
|
224
|
+
const lap = (step: string): void => { const now = performance.now(); steps[step] = Math.round(now - at); at = now; };
|
|
225
|
+
// (Blender is sent what its currency check reads of the earlier manifest; a current answer echoes that back,
|
|
226
|
+
// and stands for the whole one held here)
|
|
227
|
+
const sent = await blenderGltfExport({ kind, blendSha256, luts, ...(force ? { force: true } : {}),
|
|
228
|
+
...(previous ? { previous: { manifest: currencyOf(previous.manifest), sha256: previous.sha256 } } : {}) });
|
|
229
|
+
lap('blenderAnswer');
|
|
230
|
+
const answer = sent !== null && sent.current && previous ? { ...sent, manifest: previous.manifest } : sent;
|
|
231
|
+
if (answer === null) return { ok: false, error: "Blender's session is not started: open the model, then export again." };
|
|
232
|
+
if (answer.document !== blend)
|
|
233
|
+
return { ok: false, error: `Blender exported ${answer.document}, not ${blend}: the view moved to another file during the export.` };
|
|
234
|
+
// The export is in Blender's own filesystem: carry it into the project, the file before its manifest.
|
|
235
|
+
const files = editorHost().files;
|
|
236
|
+
// (the carry's time split in its reads from Blender's filesystem and its writes into the project)
|
|
237
|
+
let reads = 0, writes = 0;
|
|
238
|
+
for (const path of [...answer.files].sort((a, b) => Number(a.endsWith('.json')) - Number(b.endsWith('.json')))) {
|
|
239
|
+
const reading = performance.now();
|
|
240
|
+
const bytes = await blenderEngineFile(path);
|
|
241
|
+
reads += performance.now() - reading;
|
|
242
|
+
const writing = performance.now();
|
|
243
|
+
const name = path.split('/').pop()!;
|
|
244
|
+
if (name.endsWith('.cube')) {
|
|
245
|
+
// A LUT carried in is recorded as it arrived: the next exports check it against this.
|
|
246
|
+
await files.write(`${GLTF_DISPLAY_DIR}/${name}`, bytes);
|
|
247
|
+
const record = await lutRecord();
|
|
248
|
+
record[name.slice(0, -'.cube'.length)] = await sha256(bytes);
|
|
249
|
+
await files.write(LUT_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
|
250
|
+
} else {
|
|
251
|
+
await files.write(`${dir}/${name}`, bytes);
|
|
252
|
+
const own = name === `${answer.name}.glb`;
|
|
253
|
+
await heldAsWritten(`${dir}/${name}`, bytes, own ? answer.manifest : undefined);
|
|
254
|
+
}
|
|
255
|
+
writes += performance.now() - writing;
|
|
256
|
+
}
|
|
257
|
+
lap('carry');
|
|
258
|
+
if (answer.files.length > 0) { steps['carryRead'] = Math.round(reads); steps['carryWrite'] = Math.round(writes); }
|
|
259
|
+
return { ok: true, data: { ...summary(blend, dir, answer), steps } };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function step(token: string, run: Run): Promise<EditorCommandResult> {
|
|
263
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
+
const waiting = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), STEP_MS); });
|
|
265
|
+
const result = await Promise.race([run.done, waiting]).finally(() => clearTimeout(timer));
|
|
266
|
+
if (result === null)
|
|
267
|
+
return { ok: true, data: { pending: true, token, kind: run.kind, blend: run.blend, elapsedMs: Math.round(performance.now() - run.started) } };
|
|
268
|
+
if (run.expiry !== undefined) clearTimeout(run.expiry);
|
|
269
|
+
runs.delete(token);
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function exportModel(kind: GltfExportKind, command: Record<string, unknown>): Promise<EditorCommandResult> {
|
|
274
|
+
const verb = `blender-export-${kind}`;
|
|
275
|
+
const token = command['token'];
|
|
276
|
+
if (token !== undefined) {
|
|
277
|
+
if (typeof token !== 'string') return { ok: false, error: `${verb}'s token is the one its pending answer gave, a string.` };
|
|
278
|
+
const run = runs.get(token);
|
|
279
|
+
if (!run || run.kind !== kind) return { ok: false, error: `${verb} has no export with token ${token}: start the export again.` };
|
|
280
|
+
return step(token, run);
|
|
281
|
+
}
|
|
282
|
+
const wanted = command['blend'];
|
|
283
|
+
if (wanted !== undefined && (typeof wanted !== 'string' || !/\.blend$/i.test(wanted)))
|
|
284
|
+
return { ok: false, error: `${verb}'s blend is a project-relative .blend path; it was given ${JSON.stringify(wanted)}.` };
|
|
285
|
+
const force = command['force'];
|
|
286
|
+
if (force !== undefined && typeof force !== 'boolean') return { ok: false, error: `${verb}'s force is true or false.` };
|
|
287
|
+
// WHAT THE VIEW SHOWS NOW, not what was asked to open: just after a switch the view still holds the last file.
|
|
288
|
+
const blend = presentedModelBlend(blenderModelView);
|
|
289
|
+
if (blend === null || (wanted !== undefined && blend !== wanted))
|
|
290
|
+
return { ok: false, error: `${wanted ?? 'The model'} has not been presented yet${blend ? ` (the view still shows ${blend})` : ''}: wait for it to show, then export it.` };
|
|
291
|
+
// A caller that lost its token (it was stopped while it waited) joins the export it started.
|
|
292
|
+
const running = [...runs.entries()].find(([, one]) => !one.settled);
|
|
293
|
+
if (running && running[1].kind === kind && running[1].blend === blend && force !== true) return step(running[0], running[1]);
|
|
294
|
+
if (running) return { ok: false, error: `An export of ${running[1].blend} is still running; ask ${`blender-export-${running[1].kind}`} with its token, then export again.` };
|
|
295
|
+
const id = crypto.randomUUID();
|
|
296
|
+
const run: Run = {
|
|
297
|
+
kind, blend, started: performance.now(), settled: false,
|
|
298
|
+
done: runExport(kind, blend, force === true).catch((error: unknown) => ({ ok: false, error: `${verb} ${blend}: ${message(error)}` })),
|
|
299
|
+
};
|
|
300
|
+
void run.done.then(() => {
|
|
301
|
+
run.settled = true;
|
|
302
|
+
run.expiry = setTimeout(() => { if (runs.get(id) === run) runs.delete(id); }, KEEP_MS);
|
|
303
|
+
});
|
|
304
|
+
runs.set(id, run);
|
|
305
|
+
return step(id, run);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const gltfExportVerbs: Readonly<Record<string, VerbHandler>> = {
|
|
309
|
+
// Each call waits at most STEP_MS for its export, then answers pending with a token.
|
|
310
|
+
'blender-export-component': (command) => exportModel('component', command as Record<string, unknown>),
|
|
311
|
+
'blender-export-level': (command) => exportModel('level', command as Record<string, unknown>),
|
|
312
|
+
};
|
|
@@ -1,50 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* THE MODEL DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER, in Object Mode
|
|
3
|
-
* (WORK.md §THE BLENDER RELEASE, B6; ARCHITECTURE-CORE §Blender north star
|
|
4
|
-
* goal 3 as amended — the person "can make MACRO adjustments at the screen …
|
|
5
|
-
* each a bpy call through the doors that exist").
|
|
6
|
-
*
|
|
7
|
-
* `VIEW3D_MT_editor_menus` (`scripts/startup/bl_ui/space_view3d.py:1143-1210`)
|
|
8
|
-
* draws four menu words in Object Mode, in this order: **View**
|
|
9
|
-
* (`:1153`), **Select** (`:1166`, `VIEW3D_MT_select_object`), **Add**
|
|
10
|
-
* (`:1169`, `VIEW3D_MT_add`) and **Object** (`:1210`, `VIEW3D_MT_object`).
|
|
11
|
-
* All four are here.
|
|
12
|
-
*
|
|
13
|
-
* VIEW CARRIES ITS AREA ROWS AND NOTHING ELSE. Its camera-view rows — Front / Right / Top /
|
|
14
|
-
* Perspective 3/4, and the Perspective–Orthographic pair — are already a menu in THIS SAME HEADER
|
|
15
|
-
* STRIP: the host draws `Object3DDocumentToolbar` at the strip's trailing edge for every document
|
|
16
|
-
* with a 3D session, which is exactly where Blender's viewport header carries them too
|
|
17
|
-
* (`DocumentHeaderStrip`'s own note), so repeating them here would be the same control twice in
|
|
18
|
-
* one row. What View owns that no other control does is **Area** (`VIEW3D_MT_view`'s
|
|
19
|
-
* `layout.menu("INFO_MT_area")`, `space_view3d.py:1520`; `space_info.py`'s `INFO_MT_area`): the
|
|
20
|
-
* split that puts a second area beside this one. Blender's standard way to watch the render while
|
|
21
|
-
* modeling is exactly that — split the viewport and set the new area to Rendered shading through
|
|
22
|
-
* the camera — so Vertical Split opens a second 3D viewport beside the model, set up that way
|
|
23
|
-
* (`blender-runtime.document.tsx`), and Close Area closes it. The other rows of `INFO_MT_area` (Horizontal Split, the new-window, maximize and fullscreen toggles) and
|
|
24
|
-
* the rest of View — the sidebar and tool-shelf toggles, local view, the view-point and view-axis
|
|
25
|
-
* submenus, Frame Selected, Viewport Render — are not drawn.
|
|
26
|
-
*
|
|
27
|
-
* WHAT IS DRAWN IS WHAT ACTS. A row Blender draws that this unit does not act
|
|
28
|
-
* on is simply not here — never a row that opens a dialog we do not have, and
|
|
29
|
-
* never one that no-ops. Where a row exists in Blender and is REFUSED here it
|
|
30
|
-
* is drawn disabled with the refusal as its reason: the four Add rows that want a FILE
|
|
31
|
-
* (Image ▸ Reference…/Background…/Mesh Plane…, Volume ▸ Import OpenVDB… —
|
|
32
|
-
* `BlenderCreatableKind.refusal` carries each sentence). Add is the whole of
|
|
33
|
-
* `VIEW3D_MT_add` now, so "not here" means Blender's menu-search row and
|
|
34
|
-
* nothing else.
|
|
35
|
-
*
|
|
36
|
-
* EVERY ROW IS BLENDER RUNNING ITS OWN OPERATOR, through the doors B6 built:
|
|
37
|
-
* `StructureProvider` for Add / Duplicate / Delete, and
|
|
38
|
-
* `runBlenderObjectOperator` for Apply / Clear / Set Origin. Nothing here
|
|
39
|
-
* reimplements a transform apply or an origin move.
|
|
40
|
-
*
|
|
41
|
-
* NO GAME CONTROLS (owner, 2026-10-06: "it's very confusing that the timeline at the bottom has
|
|
42
|
-
* a play button AND there's a play button at the top"). The header carries no Play of its own,
|
|
43
|
-
* nor View's Play row: in Game mode the one Play is the Game panel's (`blender-game-panel.tsx`),
|
|
44
|
-
* in Movie mode the one transport is the Timeline's. The Game / Movie switch that chooses
|
|
45
|
-
* between them is the bottom area's, at the leading edge of its header row (`PlayModeSwitch`),
|
|
46
|
-
* because switching modes swaps that area and nothing here.
|
|
47
|
-
*/
|
|
1
|
+
|
|
48
2
|
|
|
49
3
|
import type { ToolNotice } from '@volter/sdk/contributions';
|
|
50
4
|
import {
|
|
@@ -70,14 +24,7 @@ import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
|
70
24
|
|
|
71
25
|
type MenuId = 'view' | 'select' | 'add' | 'object';
|
|
72
26
|
|
|
73
|
-
/**
|
|
74
|
-
* BLENDER'S OWN ROWS for the three transform submenus of `VIEW3D_MT_object`,
|
|
75
|
-
* each citing the line it is transcribed from. The rows Blender draws in those
|
|
76
|
-
* submenus that this unit does not act on — Apply's five delta rows, its
|
|
77
|
-
* Visual Transform / Visual Geometry to Mesh / Parent Inverse block, Clear's
|
|
78
|
-
* Origin row, Set Origin's two centre-of-mass rows — are not here, for the
|
|
79
|
-
* reason the header's own note gives.
|
|
80
|
-
*/
|
|
27
|
+
/** Supported transform rows from Blender's VIEW3D_MT_object submenus. */
|
|
81
28
|
const APPLY_ROWS: readonly { readonly label: string; readonly call: string }[] = [
|
|
82
29
|
// `VIEW3D_MT_object_apply`, `space_view3d.py:3206-3219`: one
|
|
83
30
|
// `object.transform_apply` per row with the three booleans set.
|
|
@@ -98,7 +45,6 @@ const CLEAR_ROWS: readonly { readonly label: string; readonly call: string }[] =
|
|
|
98
45
|
const ORIGIN_ROWS: readonly {
|
|
99
46
|
readonly label: string;
|
|
100
47
|
readonly type: string;
|
|
101
|
-
readonly refusal?: string;
|
|
102
48
|
}[] = [
|
|
103
49
|
// `VIEW3D_MT_object`, `space_view3d.py:2817` — one
|
|
104
50
|
// `operator_menu_enum("object.origin_set", "type")`, so the labels are the
|
|
@@ -110,67 +56,47 @@ const ORIGIN_ROWS: readonly {
|
|
|
110
56
|
{ label: 'Origin to 3D Cursor', type: 'ORIGIN_CURSOR' },
|
|
111
57
|
];
|
|
112
58
|
|
|
113
|
-
/**
|
|
114
|
-
*
|
|
115
|
-
* (`space_view3d.py:2699-2764`, read at the engine's pin) — EIGHTEEN
|
|
116
|
-
* top-level rows, each carrying the line it is transcribed from.
|
|
117
|
-
*
|
|
118
|
-
* Eighteen and not seventeen: `:2707` is Point Cloud
|
|
119
|
-
* (`object.pointcloud_random_add`), a flat row between Text and Volume that a
|
|
120
|
-
* count taken from Blender's manual misses. The pinned source is the
|
|
121
|
-
* reference, so it is drawn.
|
|
122
|
-
*
|
|
123
|
-
* A SUBMENU NAMES A GROUP OF {@link CREATABLE_KINDS}, which is where each
|
|
124
|
-
* row's label, call and refusal live; a flat row names one kind. The one
|
|
125
|
-
* dynamic submenu is Collection Instance, whose rows are the file's own
|
|
126
|
-
* collections (`liveCollectionKinds`).
|
|
127
|
-
*
|
|
128
|
-
* WHAT BLENDER DRAWS THAT IS NOT HERE, and why — the same rule the header's
|
|
129
|
-
* note states: `Search…` (`:2689`) draws only under `EXEC_REGION_WIN` and
|
|
130
|
-
* opens the menu-search dialog this editor has no door for. Everything else
|
|
131
|
-
* `VIEW3D_MT_add` draws is here, including the rows that refuse (they are
|
|
132
|
-
* drawn disabled with the engine's own reason rather than omitted, because a
|
|
133
|
-
* person looking for Reference… should find it and be told why it cannot run).
|
|
134
|
-
*/
|
|
59
|
+
/** Blender's VIEW3D_MT_add row order. Groups resolve current blenderCreatableKinds;
|
|
60
|
+
* Collection Instance resolves the file's live collections. Unsupported operations expose their refusal. */
|
|
135
61
|
type AddRow =
|
|
136
62
|
/** `layout.separator()`, at this line — which is also the row's key. */
|
|
137
63
|
| { readonly separatorAt: string }
|
|
138
64
|
/** A row `VIEW3D_MT_add` draws directly, by its kind id. */
|
|
139
|
-
| { readonly kind: string
|
|
140
|
-
/** A submenu: the label Blender gives it, and the
|
|
65
|
+
| { readonly kind: string }
|
|
66
|
+
/** A submenu: the label Blender gives it, and the creatable-kind group
|
|
141
67
|
* whose rows fill it (the same string). */
|
|
142
|
-
| { readonly submenu: string
|
|
68
|
+
| { readonly submenu: string };
|
|
143
69
|
|
|
144
70
|
const ADD_MENU: readonly AddRow[] = [
|
|
145
|
-
{ submenu: 'Mesh'
|
|
146
|
-
{ submenu: 'Curve'
|
|
147
|
-
{ submenu: 'Surface'
|
|
148
|
-
{ submenu: 'Metaball'
|
|
149
|
-
{ kind: 'text'
|
|
150
|
-
{ kind: 'point-cloud'
|
|
151
|
-
{ submenu: 'Volume'
|
|
152
|
-
{ submenu: 'Grease Pencil'
|
|
71
|
+
{ submenu: 'Mesh' },
|
|
72
|
+
{ submenu: 'Curve' },
|
|
73
|
+
{ submenu: 'Surface' },
|
|
74
|
+
{ submenu: 'Metaball' },
|
|
75
|
+
{ kind: 'text' },
|
|
76
|
+
{ kind: 'point-cloud' },
|
|
77
|
+
{ submenu: 'Volume' },
|
|
78
|
+
{ submenu: 'Grease Pencil' },
|
|
153
79
|
{ separatorAt: '2711' },
|
|
154
80
|
// `:2713` asks `VIEW3D_MT_armature_add.is_extended()`; no add-on extends it
|
|
155
81
|
// in this engine, so Blender's own `else` branch — the flat row — is drawn.
|
|
156
|
-
{ kind: 'armature'
|
|
157
|
-
{ submenu: 'Lattice'
|
|
82
|
+
{ kind: 'armature' },
|
|
83
|
+
{ submenu: 'Lattice' },
|
|
158
84
|
{ separatorAt: '2720' },
|
|
159
|
-
{ submenu: 'Empty'
|
|
160
|
-
{ submenu: 'Image'
|
|
85
|
+
{ submenu: 'Empty' },
|
|
86
|
+
{ submenu: 'Image' },
|
|
161
87
|
{ separatorAt: '2725' },
|
|
162
|
-
{ submenu: 'Light'
|
|
163
|
-
{ submenu: 'Light Probe'
|
|
88
|
+
{ submenu: 'Light' },
|
|
89
|
+
{ submenu: 'Light Probe' },
|
|
164
90
|
{ separatorAt: '2730' },
|
|
165
91
|
// `:2732`'s `is_extended()` is false the same way, so `VIEW3D_MT_camera_add`
|
|
166
92
|
// draws its single row INTO this menu (`:2735`).
|
|
167
|
-
{ kind: 'camera'
|
|
93
|
+
{ kind: 'camera' },
|
|
168
94
|
{ separatorAt: '2737' },
|
|
169
|
-
{ kind: 'speaker'
|
|
95
|
+
{ kind: 'speaker' },
|
|
170
96
|
{ separatorAt: '2741' },
|
|
171
|
-
{ submenu: 'Force Field'
|
|
97
|
+
{ submenu: 'Force Field' },
|
|
172
98
|
{ separatorAt: '2745' },
|
|
173
|
-
{ submenu: 'Collection Instance'
|
|
99
|
+
{ submenu: 'Collection Instance' },
|
|
174
100
|
];
|
|
175
101
|
|
|
176
102
|
/** BLENDER'S OWN TEXT for a Collection Instance row with no collection to
|
|
@@ -199,7 +125,7 @@ function MenuWord({
|
|
|
199
125
|
aria-expanded={open === id}
|
|
200
126
|
onClick={() => setOpen(open === id ? null : id)}
|
|
201
127
|
// BLENDER'S OWN BAR BEHAVIOUR: with one menu open, moving across the
|
|
202
|
-
// bar opens the next. The same rule
|
|
128
|
+
// bar opens the next. The same rule the native menu follows for
|
|
203
129
|
// the title bar's words.
|
|
204
130
|
onPointerEnter={() => {
|
|
205
131
|
if (open) setOpen(id);
|
|
@@ -210,7 +136,7 @@ function MenuWord({
|
|
|
210
136
|
</MenuTrigger>
|
|
211
137
|
{open === id && (
|
|
212
138
|
// PORTALED, not `position: absolute` — the document header strip is
|
|
213
|
-
// 26 px tall and clips, the same fact
|
|
139
|
+
// 26 px tall and clips, the same fact the menu documents about
|
|
214
140
|
// the workbench's own title bar.
|
|
215
141
|
<AnchoredMenu
|
|
216
142
|
anchorRef={anchorRef}
|
|
@@ -228,20 +154,7 @@ function MenuWord({
|
|
|
228
154
|
);
|
|
229
155
|
}
|
|
230
156
|
|
|
231
|
-
/**
|
|
232
|
-
* WHAT A ROW DID, SAID OUT LOUD. Every row here is a bpy call whose success and
|
|
233
|
-
* whose REFUSAL are both invisible on screen — an added object appears, but a
|
|
234
|
-
* refused `transform_apply` on a linked mesh, a Blender traceback, or an
|
|
235
|
-
* operator the headless build has no context for all look exactly like a menu
|
|
236
|
-
* that closed. So each row's `WriteAck` is read and reported through the host's
|
|
237
|
-
* own notice door (`ToolContributionProps.notify`).
|
|
238
|
-
*
|
|
239
|
-
* MEASURED 2026-09-21, which is why this exists rather than being tidy: Add ▸
|
|
240
|
-
* Mesh ▸ UV Sphere created the sphere and never selected it, and the ONLY
|
|
241
|
-
* evidence was `bpy.data.objects` read from a second door — the menu itself
|
|
242
|
-
* reported nothing either way. An ack that says `persisted: false` now carries
|
|
243
|
-
* Blender's own sentence to the screen.
|
|
244
|
-
*/
|
|
157
|
+
/** Report each menu row’s WriteAck through the host notice door. A refused Blender operator or unpersisted write must remain visible after the menu closes. */
|
|
245
158
|
function reportAck(
|
|
246
159
|
notify: ((notice: ToolNotice) => () => void) | undefined,
|
|
247
160
|
label: string,
|
|
@@ -270,7 +183,6 @@ export function BlenderObjectModeHeader({
|
|
|
270
183
|
// And on the set of live Outliners: the handle this bar acts through may arrive after its first render.
|
|
271
184
|
useSyncExternalStore(subscribeBlenderOutlinerHandles, blenderOutlinerHandlesVersion, blenderOutlinerHandlesVersion);
|
|
272
185
|
const [open, setOpen] = useState<MenuId | null>(null);
|
|
273
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
274
186
|
const viewRef = useRef<HTMLDivElement>(null);
|
|
275
187
|
const selectRef = useRef<HTMLDivElement>(null);
|
|
276
188
|
const addRef = useRef<HTMLDivElement>(null);
|
|
@@ -281,9 +193,7 @@ export function BlenderObjectModeHeader({
|
|
|
281
193
|
() => false,
|
|
282
194
|
);
|
|
283
195
|
const handle = blenderOutlinerHandle(documentId);
|
|
284
|
-
//
|
|
285
|
-
// document there is no subject for any of these rows, and a menu of rows
|
|
286
|
-
// that cannot act is the inert control this file's header rules out.
|
|
196
|
+
// Without a published Outliner this document offers no menu subject.
|
|
287
197
|
if (handle === null) return null;
|
|
288
198
|
|
|
289
199
|
const run = (
|
|
@@ -299,14 +209,9 @@ export function BlenderObjectModeHeader({
|
|
|
299
209
|
const all = handle.allObjectNames();
|
|
300
210
|
const nothingSelected = selected.length === 0 ? 'Nothing is selected.' : undefined;
|
|
301
211
|
|
|
302
|
-
//
|
|
303
|
-
// submenu's rows are the file's own collections, which change as the person
|
|
304
|
-
// works (`liveCollectionKinds`) — and this bar already re-renders on the
|
|
305
|
-
// Outliner's version, which is where that tree comes from.
|
|
212
|
+
// Resolve current creatable kinds, including the file's live collections, on each Outliner render.
|
|
306
213
|
const kinds = blenderCreatableKinds();
|
|
307
|
-
/**
|
|
308
|
-
* drawn DISABLED with that sentence as its reason, the shape Set Origin ▸
|
|
309
|
-
* Origin to 3D Cursor set. */
|
|
214
|
+
/** Draw each kind's refusal as a disabled row with its reason. */
|
|
310
215
|
const addItem = (kind: BlenderCreatableKind): ReactNode => (
|
|
311
216
|
<MenuItem
|
|
312
217
|
key={kind.kind}
|
|
@@ -358,10 +263,9 @@ export function BlenderObjectModeHeader({
|
|
|
358
263
|
compact
|
|
359
264
|
label="Blender Object Mode menus"
|
|
360
265
|
data-testid="blender-object-mode-menus"
|
|
361
|
-
ref={rootRef}
|
|
362
266
|
>
|
|
363
267
|
{/* VIEW ▸ AREA — `INFO_MT_area`'s split and close, for the second 3D viewport beside the
|
|
364
|
-
model
|
|
268
|
+
model. */}
|
|
365
269
|
<MenuWord id="view" open={open} setOpen={setOpen} anchorRef={viewRef}>
|
|
366
270
|
<MenuSubmenu label="Area" data-testid="blender-view-area">
|
|
367
271
|
<MenuItem
|
|
@@ -423,7 +327,7 @@ export function BlenderObjectModeHeader({
|
|
|
423
327
|
|
|
424
328
|
{/* ADD — `VIEW3D_MT_add`, `space_view3d.py:2679-2764`, EVERY ROW IT
|
|
425
329
|
DRAWS, in its order, with its separators and its submenus. The shape
|
|
426
|
-
is
|
|
330
|
+
is ADD_MENU; blenderCreatableKinds supplies each operation. */}
|
|
427
331
|
<MenuWord id="add" open={open} setOpen={setOpen} anchorRef={addRef}>
|
|
428
332
|
{ADD_MENU.map((row) => addRowNode(row))}
|
|
429
333
|
</MenuWord>
|
|
@@ -443,8 +347,8 @@ export function BlenderObjectModeHeader({
|
|
|
443
347
|
<MenuItem
|
|
444
348
|
key={row.type}
|
|
445
349
|
data-testid={`blender-origin-${row.type}`}
|
|
446
|
-
disabled={
|
|
447
|
-
title={
|
|
350
|
+
disabled={selected.length === 0}
|
|
351
|
+
title={nothingSelected}
|
|
448
352
|
onSelect={run(`Set Origin ▸ ${row.label}`, (outliner) =>
|
|
449
353
|
runBlenderObjectOperator(
|
|
450
354
|
outliner.selectedObjectNames(),
|
|
@@ -498,7 +402,7 @@ export function BlenderObjectModeHeader({
|
|
|
498
402
|
const ids = outliner.selectedObjectNames()
|
|
499
403
|
.map(name => outliner.rowIdForObject(name))
|
|
500
404
|
.filter((id): id is string => id !== null);
|
|
501
|
-
return outliner.structure.duplicateMany
|
|
405
|
+
return outliner.structure.duplicateMany(ids);
|
|
502
406
|
})}
|
|
503
407
|
>
|
|
504
408
|
Duplicate Objects
|
|
@@ -512,7 +416,7 @@ export function BlenderObjectModeHeader({
|
|
|
512
416
|
.selectedObjectNames()
|
|
513
417
|
.map((name) => outliner.rowIdForObject(name))
|
|
514
418
|
.filter((id): id is string => id !== null);
|
|
515
|
-
return outliner.structure.removeMany
|
|
419
|
+
return outliner.structure.removeMany(ids);
|
|
516
420
|
})}
|
|
517
421
|
>
|
|
518
422
|
Delete
|