@volter/editor-blender 0.1.128 → 0.1.130
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/contributions/blender-game-panel.tsx +764 -0
- package/contributions/blender-header-menus.tsx +7 -6
- package/contributions/blender-model-opening.tsx +12 -2
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +6 -7
- package/contributions/blender-properties-context.ts +45 -0
- package/contributions/blender-properties-model.ts +15 -3
- package/contributions/blender-properties-view.tsx +84 -10
- package/contributions/blender-runtime.document.tsx +238 -29
- package/contributions/blender-timeline.document.tsx +33 -3
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/model.layout.ts +4 -0
- package/contributions/models.finder.ts +5 -1
- package/contributions/properties-object.inspector.tsx +26 -27
- package/contributions/properties-scene.inspector.tsx +9 -2
- package/dist-node/serving.mjs +17 -1
- package/host/blender-runtime-host.ts +39 -99
- package/node/blender-mcp.ts +58 -12
- package/package.json +11 -8
- package/serving/index.ts +29 -0
- package/src/node-view-state.ts +1 -1
- package/src/play-mode.ts +146 -0
- package/src/timeline-view-state.ts +1 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +180 -0
package/src/play-mode.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAME OR MOVIE — what a model document's bottom area and transport are for (owner, 2026-10-06:
|
|
3
|
+
* "it's very confusing that the timeline at the bottom has a play button AND there's a play
|
|
4
|
+
* button at the top … allow the user to flip between game and movie mode").
|
|
5
|
+
*
|
|
6
|
+
* - **Movie** is Blender's own Layout screen: the Timeline at the bottom, its transport playing
|
|
7
|
+
* the file's animation, and no game Play anywhere.
|
|
8
|
+
* - **Game** replaces the Timeline with the Game panel (`../contributions/blender-game-panel.tsx`):
|
|
9
|
+
* the ONE Play, and the run's pause, step, speed, restart and clock.
|
|
10
|
+
*
|
|
11
|
+
* The switch is the bottom area's own, at the leading edge of its header row in the Game panel
|
|
12
|
+
* and the Timeline alike (`PlayModeSwitch`, `blender-game-panel.tsx`); this module is the state
|
|
13
|
+
* both sides read. A document opens as a
|
|
14
|
+
* Game when its model has a play script (`*.play.ts` beside the `.blend`, as the Play tool finds
|
|
15
|
+
* it — `DocumentPlayExtension.hasScript`) and as a Movie otherwise, and the person's own pick
|
|
16
|
+
* then stands for that document for the browser session, kept the way `area-split.ts` keeps the
|
|
17
|
+
* split, per document id — in session storage rather than local, because the default is what a
|
|
18
|
+
* fresh session should start from.
|
|
19
|
+
*
|
|
20
|
+
* THE BOTTOM AREA SERVES ONE DOCUMENT, the model document on screen. The Timeline was always
|
|
21
|
+
* that (it binds the presented-view singleton, and cannot name a document), but a game panel
|
|
22
|
+
* needs the id to drive a run, and clicking into the panel makes the PANEL the active workspace
|
|
23
|
+
* document — so the model document announces itself here ({@link noteModelDocument}) rather
|
|
24
|
+
* than the panel asking the workspace which document is active.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
documentPlayExtension,
|
|
28
|
+
subscribeDocumentPlayExtensions,
|
|
29
|
+
} from '@volter/editor-sdk/kit/document-play-extension';
|
|
30
|
+
|
|
31
|
+
export type ModelPlayMode = 'game' | 'movie';
|
|
32
|
+
|
|
33
|
+
const STORAGE_PREFIX = 'volter.blender.play-mode:';
|
|
34
|
+
const listeners = new Set<() => void>();
|
|
35
|
+
/** The person's pick per document; `null` once storage was read and held none. */
|
|
36
|
+
const chosen = new Map<string, ModelPlayMode | null>();
|
|
37
|
+
/** Each model document's `.blend`, as the document reported it. */
|
|
38
|
+
const sources = new Map<string, string>();
|
|
39
|
+
let served: string | null = null;
|
|
40
|
+
let version = 0;
|
|
41
|
+
|
|
42
|
+
function publish(): void {
|
|
43
|
+
version += 1;
|
|
44
|
+
for (const listener of [...listeners]) listener();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stored(documentId: string): ModelPlayMode | null {
|
|
48
|
+
try {
|
|
49
|
+
const value = globalThis.sessionStorage?.getItem(STORAGE_PREFIX + documentId);
|
|
50
|
+
return value === 'game' || value === 'movie' ? value : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The person's own pick for a document, or `null` when the default stands. */
|
|
57
|
+
export function chosenPlayMode(documentId: string): ModelPlayMode | null {
|
|
58
|
+
let value = chosen.get(documentId);
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
value = stored(documentId);
|
|
61
|
+
chosen.set(documentId, value);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setChosenPlayMode(documentId: string, mode: ModelPlayMode): void {
|
|
67
|
+
if (chosenPlayMode(documentId) === mode) return;
|
|
68
|
+
chosen.set(documentId, mode);
|
|
69
|
+
try {
|
|
70
|
+
globalThis.sessionStorage?.setItem(STORAGE_PREFIX + documentId, mode);
|
|
71
|
+
} catch {
|
|
72
|
+
// A page without storage keeps the pick for its own life.
|
|
73
|
+
}
|
|
74
|
+
publish();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The model document on screen announces itself and its `.blend`; the return withdraws it,
|
|
79
|
+
* and only while it is still the one announced (a newer document's announcement stands).
|
|
80
|
+
*/
|
|
81
|
+
export function noteModelDocument(documentId: string, blend: string | undefined): () => void {
|
|
82
|
+
if (served !== documentId || sources.get(documentId) !== blend) {
|
|
83
|
+
served = documentId;
|
|
84
|
+
if (blend === undefined) sources.delete(documentId);
|
|
85
|
+
else sources.set(documentId, blend);
|
|
86
|
+
publish();
|
|
87
|
+
}
|
|
88
|
+
return () => {
|
|
89
|
+
if (served !== documentId) return;
|
|
90
|
+
served = null;
|
|
91
|
+
publish();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The model document the bottom area serves, or `null` with none on screen. */
|
|
96
|
+
export function servedModelDocument(): string | null {
|
|
97
|
+
return served;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The `.blend` a model document reported, if it has a file of its own. */
|
|
101
|
+
export function modelDocumentSource(documentId: string): string | undefined {
|
|
102
|
+
return sources.get(documentId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* THE MODE A DOCUMENT IS IN: Movie whenever no Play tool is installed (there is no game to
|
|
107
|
+
* switch to), else the person's pick, else Game exactly when the model has a play script. A
|
|
108
|
+
* script not yet looked for counts as none, so a document opens on the Timeline and turns to
|
|
109
|
+
* the Game panel when the answer arrives.
|
|
110
|
+
*/
|
|
111
|
+
export function modelPlayMode(documentId: string): ModelPlayMode {
|
|
112
|
+
const extension = documentPlayExtension('model');
|
|
113
|
+
if (extension === null) return 'movie';
|
|
114
|
+
const pick = chosenPlayMode(documentId);
|
|
115
|
+
if (pick !== null) return pick;
|
|
116
|
+
const blend = sources.get(documentId);
|
|
117
|
+
return blend !== undefined && extension.hasScript?.(blend) === true ? 'game' : 'movie';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function playModeVersion(): number {
|
|
121
|
+
return version;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Changes to the picks, to the served document, and to anything the Play tool answers.
|
|
126
|
+
*
|
|
127
|
+
* THE PLAY TOOL'S ANSWERS MOVE THE VERSION TOO, because the version is the snapshot every reader
|
|
128
|
+
* takes (`useSyncExternalStore(subscribePlayMode, playModeVersion)`): a script found after the
|
|
129
|
+
* first render, a run starting or stopping, a restart. Handing the tool's publications straight to
|
|
130
|
+
* the reader's listener re-ran a snapshot that had not changed, so React kept the old render — a
|
|
131
|
+
* document with a play script stayed in Movie, and the Game panel kept showing Stop after the game
|
|
132
|
+
* had stopped. One subscription to the tool's registry, held while anything reads this store,
|
|
133
|
+
* bumps the version and then tells the readers.
|
|
134
|
+
*/
|
|
135
|
+
let stopToolWatch: (() => void) | null = null;
|
|
136
|
+
export function subscribePlayMode(listener: () => void): () => void {
|
|
137
|
+
listeners.add(listener);
|
|
138
|
+
stopToolWatch ??= subscribeDocumentPlayExtensions(publish);
|
|
139
|
+
return () => {
|
|
140
|
+
listeners.delete(listener);
|
|
141
|
+
if (listeners.size === 0 && stopToolWatch) {
|
|
142
|
+
stopToolWatch();
|
|
143
|
+
stopToolWatch = null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* WHAT DRIVES IT IS NOT A SESSION VERB. U8's ruling 1 (2026-09-19): every view
|
|
13
13
|
* publishes `volter.<view>.<verb>` commands through `@volter/editor-sdk/views`, one
|
|
14
|
-
* table behind the frame's command service and standalone `
|
|
14
|
+
* table behind the frame's command service and standalone `cyclotron edit`'s
|
|
15
15
|
* session verb — so this view adds no `blender-*` verb of its own.
|
|
16
16
|
*
|
|
17
17
|
* It imports NOTHING, for the reason the node and UV stores do: the view and
|
package/src/uv-view-state.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* WHAT DRIVES IT IS NOT A SESSION VERB. U8's ruling 1 (2026-09-19): every view
|
|
9
9
|
* publishes `volter.<view>.<verb>` commands through `@volter/editor-sdk/views`,
|
|
10
|
-
* one table behind the frame's command service and standalone `
|
|
10
|
+
* one table behind the frame's command service and standalone `cyclotron edit`'s
|
|
11
11
|
* session verb — so this view adds NO `blender-*` verb of its own, which is
|
|
12
12
|
* exactly what the ruling asked the remaining I5 views to stop paying for.
|
|
13
13
|
*
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@volter/editor-blender`'s routes in a LIMITED VIEW (`package.json#volter.viewServing`,
|
|
3
|
+
* `@volter/editor-sdk/session/limited-view`): the same requests the Blender in the tab makes of
|
|
4
|
+
* its session (`serving/blender-routes.ts`), answered in the page against the project's files in
|
|
5
|
+
* memory. The engine's own bytes are not here: they are fixed for a build, so the serving module
|
|
6
|
+
* declares them (`viewSnapshotRoutes`) and the view ships what the session answered.
|
|
7
|
+
*
|
|
8
|
+
* Shapes are the session's. What differs is where bytes go: a `.blend` the document saves, an
|
|
9
|
+
* output Blender writes under `public/`, a transfer the page spools — all stay in this page's
|
|
10
|
+
* memory, readable by the editor and the workbench like any other file, gone on reload.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ViewRoute, ViewServingModule, ViewServingServices } from '@volter/editor-sdk/session/limited-view';
|
|
14
|
+
|
|
15
|
+
/** The largest chunk a document save sends (`document-chunks.mts`'s cut bound). */
|
|
16
|
+
const DOCUMENT_CHUNK_MAX = 4 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const contained = (path: string): boolean =>
|
|
19
|
+
path !== '' && !path.startsWith('/') && !path.includes('\\') && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
20
|
+
|
|
21
|
+
/** The session's rule: a project-relative `.blend`, no dot segment. */
|
|
22
|
+
const isDocumentPath = (path: string): boolean =>
|
|
23
|
+
path.endsWith('.blend') && contained(path) && path.split('/').every((segment) => !segment.startsWith('.'));
|
|
24
|
+
|
|
25
|
+
async function sha256(bytes: Uint8Array): Promise<string> {
|
|
26
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>);
|
|
27
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bytesResponse(bytes: Uint8Array): Response {
|
|
31
|
+
return new Response(bytes as Uint8Array<ArrayBuffer>, {
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/octet-stream',
|
|
35
|
+
'Content-Length': String(bytes.byteLength),
|
|
36
|
+
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
37
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function viewRoutes(services: ViewServingServices): readonly ViewRoute[] {
|
|
43
|
+
const { files, json } = services;
|
|
44
|
+
/** Transfers the page spools for a requester, by the uuid it minted: read once, then gone. */
|
|
45
|
+
const spool = new Map<string, Uint8Array>();
|
|
46
|
+
/** Chunks of the document save in flight, by path then hash. */
|
|
47
|
+
const held = new Map<string, Map<string, Uint8Array>>();
|
|
48
|
+
const transferId = (value: string | null): string | null => (value !== null && /^[0-9a-f-]{36}$/.test(value) ? value : null);
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
method: 'POST',
|
|
53
|
+
match: /^\/__editor\/blender-file$/,
|
|
54
|
+
handle: async (request, url) => {
|
|
55
|
+
const id = transferId(url.searchParams.get('id'));
|
|
56
|
+
if (id === null) return json({ error: 'blender-file requires a uuid `id`.' }, 400);
|
|
57
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
58
|
+
spool.set(id, body);
|
|
59
|
+
return json({ bytes: body.byteLength });
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
method: 'GET',
|
|
64
|
+
match: /^\/__editor\/blender-file$/,
|
|
65
|
+
handle: (_request, url) => {
|
|
66
|
+
const id = transferId(url.searchParams.get('id'));
|
|
67
|
+
if (id === null) return json({ error: 'blender-file requires a uuid `id`.' }, 400);
|
|
68
|
+
const body = spool.get(id);
|
|
69
|
+
if (!body) return json({ error: 'No transfer is spooled under that id.' }, 404);
|
|
70
|
+
spool.delete(id);
|
|
71
|
+
return bytesResponse(body);
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: 'GET',
|
|
76
|
+
match: /^\/__editor\/blender-project-index$/,
|
|
77
|
+
handle: async () => {
|
|
78
|
+
const index: { path: string; size: number; mtime: number }[] = [];
|
|
79
|
+
const walk = async (dir: string): Promise<void> => {
|
|
80
|
+
for (const entry of await files.list(dir)) {
|
|
81
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
82
|
+
if (entry.type === 'dir') await walk(entry.path);
|
|
83
|
+
else index.push({ path: entry.path, size: entry.size ?? 0, mtime: entry.mtime ?? 0 });
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
await walk('');
|
|
87
|
+
return json({ root: services.projectRoot, files: index });
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
method: 'GET',
|
|
92
|
+
match: /^\/__editor\/blender-project-file$/,
|
|
93
|
+
handle: async (_request, url) => {
|
|
94
|
+
const path = url.searchParams.get('path') ?? '';
|
|
95
|
+
const scratchDocument = contained(path) && path.startsWith('.volter/tmp/') && path.endsWith('.blend');
|
|
96
|
+
if (!contained(path) || (path.split('/').some((segment) => segment.startsWith('.')) && !scratchDocument)) {
|
|
97
|
+
return json({ error: 'Not a project-owned path.' }, 400);
|
|
98
|
+
}
|
|
99
|
+
const found = await files.stat(path);
|
|
100
|
+
if (!found || found.type !== 'file') return json({ error: 'Not found.' }, 404);
|
|
101
|
+
return bytesResponse(await files.readBytes(path));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
method: 'POST',
|
|
106
|
+
match: /^\/__editor\/blender-output$/,
|
|
107
|
+
handle: async (request, url) => {
|
|
108
|
+
const path = url.searchParams.get('path') ?? '';
|
|
109
|
+
if (!contained(path) || !path.startsWith('public/')) {
|
|
110
|
+
return json({ error: `A shipped output is a project-relative path under public/ with no traversal; got ${JSON.stringify(path)}.` }, 400);
|
|
111
|
+
}
|
|
112
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
113
|
+
if (body.byteLength === 0) return json({ error: 'The output body was empty; nothing was written.' }, 400);
|
|
114
|
+
await files.write(path, body);
|
|
115
|
+
// No provenance ledger in a limited view: the output is real, its record is not kept.
|
|
116
|
+
return json({ ok: true, bytes: body.byteLength, provenanceOperationId: null });
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
method: 'POST',
|
|
121
|
+
match: /^\/__editor\/blender-document-chunk$/,
|
|
122
|
+
handle: async (request, url) => {
|
|
123
|
+
const path = url.searchParams.get('path') ?? '';
|
|
124
|
+
const hash = url.searchParams.get('hash') ?? '';
|
|
125
|
+
if (!isDocumentPath(path) || !/^[0-9a-f]{64}$/.test(hash)) {
|
|
126
|
+
return json({ error: 'A document chunk names its .blend `path` and its SHA-256 `hash` (64 hex digits).' }, 400);
|
|
127
|
+
}
|
|
128
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
129
|
+
if (body.byteLength === 0 || body.byteLength > DOCUMENT_CHUNK_MAX) {
|
|
130
|
+
return json({ error: `A document chunk is 1 to ${DOCUMENT_CHUNK_MAX} bytes; got ${body.byteLength}.` }, 400);
|
|
131
|
+
}
|
|
132
|
+
if ((await sha256(body)) !== hash) return json({ error: `The chunk's bytes do not hash to ${hash}; it was not kept.` }, 400);
|
|
133
|
+
let chunks = held.get(path);
|
|
134
|
+
if (!chunks) held.set(path, (chunks = new Map()));
|
|
135
|
+
chunks.set(hash, body);
|
|
136
|
+
return json({ ok: true, bytes: body.byteLength });
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
method: 'POST',
|
|
141
|
+
match: /^\/__editor\/blender-document$/,
|
|
142
|
+
handle: async (request, url) => {
|
|
143
|
+
const path = url.searchParams.get('path') ?? '';
|
|
144
|
+
if (!isDocumentPath(path)) return json({ error: `A Blender document is a project-relative .blend path; got ${JSON.stringify(path)}.` }, 400);
|
|
145
|
+
let manifest: { chunks?: unknown } | null = null;
|
|
146
|
+
try {
|
|
147
|
+
manifest = (await request.json()) as { chunks?: unknown };
|
|
148
|
+
} catch {
|
|
149
|
+
manifest = null;
|
|
150
|
+
}
|
|
151
|
+
const listed = manifest?.chunks;
|
|
152
|
+
if (
|
|
153
|
+
!Array.isArray(listed) ||
|
|
154
|
+
listed.length === 0 ||
|
|
155
|
+
!listed.every((chunk) => Array.isArray(chunk) && typeof chunk[0] === 'string' && Number.isInteger(chunk[1]) && chunk[1] > 0 && chunk[1] <= DOCUMENT_CHUNK_MAX)
|
|
156
|
+
) {
|
|
157
|
+
return json({ error: 'A document manifest is `{ chunks: [[sha256, length], ...] }`, at least one chunk.' }, 400);
|
|
158
|
+
}
|
|
159
|
+
// Every chunk is sent on a view's first save: there is no earlier file on a disk to reuse
|
|
160
|
+
// from, only what this page holds.
|
|
161
|
+
const chunks = held.get(path) ?? new Map<string, Uint8Array>();
|
|
162
|
+
const missing = (listed as [string, number][]).filter(([hash, length]) => chunks.get(hash)?.byteLength !== length).map(([hash]) => hash);
|
|
163
|
+
if (missing.length > 0) return json({ ok: false, missing: [...new Set(missing)], ms: {} });
|
|
164
|
+
const total = (listed as [string, number][]).reduce((sum, [, length]) => sum + length, 0);
|
|
165
|
+
const body = new Uint8Array(total);
|
|
166
|
+
let offset = 0;
|
|
167
|
+
for (const [hash, length] of listed as [string, number][]) {
|
|
168
|
+
body.set(chunks.get(hash)!, offset);
|
|
169
|
+
offset += length;
|
|
170
|
+
}
|
|
171
|
+
await files.write(path, body);
|
|
172
|
+
held.delete(path);
|
|
173
|
+
return json({ ok: true, bytes: total, sent: total, revision: null, ms: {} });
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const module: ViewServingModule = { viewRoutes };
|
|
180
|
+
export default module;
|