@volter/editor-blender 0.1.150 → 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
package/LICENSE
CHANGED
|
@@ -29,18 +29,27 @@ ARTWORK is not.
|
|
|
29
29
|
provenance file and emits `blender.icons.json`.
|
|
30
30
|
|
|
31
31
|
The remaining 149 glyphs are our own drawings in Blender's idiom, and
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
`themes/blender.json` is a transcription of Blender's default theme colours.
|
|
33
|
+
Neither the traced paths nor anything derived from them may be copied into an
|
|
34
|
+
Apache-2.0 or MIT part of this repository — `packages/editor`'s own icon set
|
|
35
|
+
included.
|
|
36
|
+
|
|
37
|
+
THE PRODUCT ICON FONT CARRIES THE SAME TRACED GLYPHS.
|
|
38
|
+
|
|
39
|
+
`producticons/blender-icons.otf` (with `producticons/blender-product-icon-theme.json`)
|
|
40
|
+
is generated from `contributions/blender.icons.json` by
|
|
41
|
+
`scripts/workbench/generate-icons.mjs`, so it includes the traced glyphs and is
|
|
42
|
+
under GPL-3.0-or-later with them. `themes/blender.json`, the colour theme, and the
|
|
43
|
+
product icon theme are this package's Code-OSS contributions.
|
|
36
44
|
|
|
37
45
|
ONE SOURCE FILE FOLLOWS BLENDER'S OWN CODE.
|
|
38
46
|
|
|
39
|
-
`contributions/blender-pose.ts`
|
|
40
|
-
written from Blender's source:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
its header names.
|
|
47
|
+
`contributions/blender-pose.ts` places the NLA's strips as Blender does, and
|
|
48
|
+
is written from Blender's source: which strip a frame falls on, the action
|
|
49
|
+
frame it shows, its influence (one keyed influence F-Curve evaluated as
|
|
50
|
+
`fcurve_eval_keyframes` does) and where the active action sits follow the
|
|
51
|
+
Blender functions its header names. The pose itself is three.js's
|
|
52
|
+
(`blender-mixer-pose.ts`, the package's own licence). It is a derivative work of GPL-2.0-or-later code and,
|
|
44
53
|
exercising Blender's "or later", is conveyed under the GNU General Public
|
|
45
54
|
License, version 3 or later, not the AGPL. The file says so in its header.
|
|
46
55
|
It may not be copied into an Apache-2.0 or MIT part of this repository.
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Blender integration
|
|
2
2
|
|
|
3
3
|
Blender modeling documents, inspectors, commands, layouts and presentation for
|
|
4
|
-
|
|
4
|
+
Cyclotron. The integration talks to the independent Blender engine over its
|
|
5
5
|
worker wire and contributes its editing surfaces through the SDK.
|
|
6
6
|
|
|
7
7
|
The package uses the project contracts and shared Three rendering/capture
|
|
8
8
|
utilities. It imports no editor-core internals and no game runtime. Its
|
|
9
|
-
contributions are enumerated in package.json;
|
|
9
|
+
contributions are enumerated in package.json; Cyclotron composes
|
|
10
10
|
that list through the host's contribution loader.
|
|
11
11
|
|
|
12
12
|
Run `npm run typecheck -w @volter/editor-blender` through the active World.
|
|
@@ -24,6 +24,6 @@ While opening, the document shows the last available model photograph as a
|
|
|
24
24
|
labeled read-only preview and a loading panel. Without a preview it shows a
|
|
25
25
|
full status surface. A failed open offers Retry and Return to previous model.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
A package build does not establish product readiness. Verify file persistence,
|
|
28
|
+
native history, visible presentation and resource cleanup through Cyclotron's
|
|
29
|
+
own document and command doors.
|
|
@@ -7,15 +7,9 @@
|
|
|
7
7
|
* character plays it; the channel list is one row per bone with the frames it keys.
|
|
8
8
|
* - **NLA** (`SPACE_NLA`): each armature's tracks, bottom track last as Blender lists them, their
|
|
9
9
|
* strips over scene frames, and the active action's own line on top.
|
|
10
|
-
*
|
|
11
|
-
* WHILE A GAME RUNS both show what the game is animating, read-only, the way a game engine's
|
|
12
|
-
* animator view highlights the live state: each character's clips as its last update posed them
|
|
13
|
-
* (`blender-play-skin.ts`'s `live`), with the frame and influence of each. Nothing is written
|
|
14
|
-
* then: the game owns its copy.
|
|
15
10
|
*/
|
|
16
11
|
import { type ReactNode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
17
12
|
import { blenderRnaSet, blenderRnaVersion, subscribeBlenderRna } from '../host/blender-runtime-host';
|
|
18
|
-
import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
|
|
19
13
|
import {
|
|
20
14
|
type AnimationEditor,
|
|
21
15
|
setTimelineViewState,
|
|
@@ -23,7 +17,7 @@ import {
|
|
|
23
17
|
timelineViewState,
|
|
24
18
|
timelineViewVersion,
|
|
25
19
|
} from '../src/timeline-view-state';
|
|
26
|
-
import { PlayModeSwitch } from './blender-
|
|
20
|
+
import { PlayModeSwitch } from './blender-play-area';
|
|
27
21
|
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
28
22
|
import {
|
|
29
23
|
CHANNEL_HEIGHT,
|
|
@@ -69,21 +63,6 @@ export function AnimationEditorMenu() {
|
|
|
69
63
|
);
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
/** Re-render on every frame while a game runs (its animation changes each update), else never. */
|
|
73
|
-
function useLive() {
|
|
74
|
-
const live = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null);
|
|
75
|
-
const [, setTick] = useState(0);
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (!live) return;
|
|
78
|
-
let frame = requestAnimationFrame(function tick() {
|
|
79
|
-
setTick((n) => (n + 1) % 1_000_000);
|
|
80
|
-
frame = requestAnimationFrame(tick);
|
|
81
|
-
});
|
|
82
|
-
return () => cancelAnimationFrame(frame);
|
|
83
|
-
}, [live]);
|
|
84
|
-
return live;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
66
|
function useMeasured() {
|
|
88
67
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
89
68
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
@@ -190,36 +169,27 @@ const address = (armature: string) => `bpy.data.objects[${JSON.stringify(armatur
|
|
|
190
169
|
export function BlenderActionEditor() {
|
|
191
170
|
useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
192
171
|
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
193
|
-
const live = useLive();
|
|
194
172
|
const { ref, size } = useMeasured();
|
|
195
173
|
const [refusal, setRefusal] = useState<string | null>(null);
|
|
196
174
|
const play = blenderSkin.state();
|
|
197
175
|
const armature = blenderSkin.subject ?? play.armature;
|
|
198
|
-
const
|
|
199
|
-
//
|
|
200
|
-
const
|
|
201
|
-
const top = running?.layers.at(-1) ?? null;
|
|
202
|
-
const action = running ? top?.action ?? null : play.action;
|
|
203
|
-
const channels = useMemo(
|
|
204
|
-
() => (running && action ? live!.channels(running.armature, action) : blenderSkin.channels()),
|
|
205
|
-
// The live clip's keys do not change while it plays; the edited clip's change with its revision.
|
|
206
|
-
[running ? action : play.action, play.engineCalls, live],
|
|
207
|
-
);
|
|
176
|
+
const action = play.action;
|
|
177
|
+
// The edited clip's keys change with its revision.
|
|
178
|
+
const channels = useMemo(() => blenderSkin.channels(), [play.action, play.engineCalls]);
|
|
208
179
|
const frames = channels.flatMap((channel) => channel.keys);
|
|
209
180
|
const start = frames.length ? Math.min(...frames) : play.start;
|
|
210
181
|
const end = frames.length ? Math.max(...frames) : play.end;
|
|
211
182
|
const width = Math.max(0, size.w - CHANNELS_WIDTH);
|
|
212
183
|
const { toX, majors, toFrame } = ruler(start, end, width);
|
|
213
|
-
const frame =
|
|
184
|
+
const frame = play.frame;
|
|
214
185
|
const rowTop = (i: number) => SCRUB_HEIGHT + CHANNEL_HEIGHT * (i + 1);
|
|
215
186
|
const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (channels.length + 2);
|
|
216
187
|
const summary = [...new Set(frames)].sort((a, b) => a - b);
|
|
217
188
|
|
|
218
189
|
const pick = async (name: string) => {
|
|
219
|
-
if (!armature
|
|
190
|
+
if (!armature) return;
|
|
220
191
|
setRefusal(null);
|
|
221
|
-
//
|
|
222
|
-
// slot that fits this armature (measured: `OBArmature` for a UAL1 clip on a renamed rig).
|
|
192
|
+
// Assign the action once and let Blender choose the slot for this armature.
|
|
223
193
|
try {
|
|
224
194
|
await blenderRnaSet(address(armature), 'action', `bpy.data.actions[${JSON.stringify(name)}]`);
|
|
225
195
|
} catch (error) {
|
|
@@ -234,8 +204,8 @@ export function BlenderActionEditor() {
|
|
|
234
204
|
aria-label="Action"
|
|
235
205
|
data-testid="action-editor-action"
|
|
236
206
|
value={action ?? ''}
|
|
237
|
-
disabled={!armature
|
|
238
|
-
title=
|
|
207
|
+
disabled={!armature}
|
|
208
|
+
title="The action this armature plays (animation_data.action)"
|
|
239
209
|
onChange={(event) => void pick(event.target.value)}
|
|
240
210
|
style={{
|
|
241
211
|
height: TIMELINE_CHROME.unit,
|
|
@@ -248,13 +218,8 @@ export function BlenderActionEditor() {
|
|
|
248
218
|
}}
|
|
249
219
|
>
|
|
250
220
|
{action === null ? <option value="">No action</option> : null}
|
|
251
|
-
{
|
|
221
|
+
{blenderSkin.actionNames().map((name) => <option key={name} value={name}>{name}</option>)}
|
|
252
222
|
</select>
|
|
253
|
-
{running ? (
|
|
254
|
-
<span data-testid="action-editor-live" style={{ color: TIMELINE_THEME.playhead }}>
|
|
255
|
-
● game running: {running.layers.map((layer) => `${layer.action} @${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}`).join(' + ')}
|
|
256
|
-
</span>
|
|
257
|
-
) : null}
|
|
258
223
|
<div style={{ flex: 1 }} />
|
|
259
224
|
{refusal ? <span style={{ color: TIMELINE_CHROME.refusal }}>{refusal}</span> : null}
|
|
260
225
|
</Header>
|
|
@@ -274,10 +239,9 @@ export function BlenderActionEditor() {
|
|
|
274
239
|
<svg
|
|
275
240
|
width={width}
|
|
276
241
|
height={height}
|
|
277
|
-
style={{ display: 'block', cursor:
|
|
242
|
+
style={{ display: 'block', cursor: 'ew-resize' }}
|
|
278
243
|
aria-label="Action Editor"
|
|
279
244
|
onPointerDown={(event) => {
|
|
280
|
-
if (gameRunning) return;
|
|
281
245
|
const box = event.currentTarget.getBoundingClientRect();
|
|
282
246
|
blenderSkin.transport?.seekFrame(Math.round(toFrame(event.clientX - box.left)));
|
|
283
247
|
}}
|
|
@@ -298,42 +262,27 @@ export function BlenderActionEditor() {
|
|
|
298
262
|
export function BlenderNlaEditor() {
|
|
299
263
|
useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
300
264
|
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
301
|
-
const live = useLive();
|
|
302
265
|
const { ref, size } = useMeasured();
|
|
303
266
|
const play = blenderSkin.state();
|
|
304
267
|
const stacks = blenderSkin.stacks();
|
|
305
|
-
const running = live?.live() ?? null;
|
|
306
268
|
const width = Math.max(0, size.w - CHANNELS_WIDTH);
|
|
307
269
|
const { toX, majors } = ruler(play.start, play.end, width);
|
|
308
|
-
type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[];
|
|
270
|
+
type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[]; mute?: boolean };
|
|
309
271
|
const rows: Row[] = [];
|
|
310
272
|
for (const stack of stacks) {
|
|
311
|
-
const layers = running?.find((one) => one.armature === stack.armature)?.layers ?? null;
|
|
312
|
-
const influence = (track: string | null) => {
|
|
313
|
-
const layer = layers?.find((one) => one.track === track);
|
|
314
|
-
return layer ? `@${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}` : undefined;
|
|
315
|
-
};
|
|
316
273
|
rows.push({ label: stack.armature, kind: 'armature', strips: [] });
|
|
317
|
-
|
|
318
|
-
const activeLive = influence(null);
|
|
319
|
-
rows.push({ label: activeAction ? `[Action] ${activeAction}` : '[Action] (none)', kind: 'action', strips: [], ...(activeLive ? { live: activeLive } : {}) });
|
|
274
|
+
rows.push({ label: stack.action ? `[Action] ${stack.action}` : '[Action] (none)', kind: 'action', strips: [] });
|
|
320
275
|
for (const track of [...(stack.animation?.tracks ?? [])].reverse()) {
|
|
321
|
-
const playingHere = influence(track.name);
|
|
322
276
|
rows.push({
|
|
323
277
|
label: track.name,
|
|
324
278
|
kind: 'track',
|
|
325
279
|
mute: track.mute,
|
|
326
280
|
strips: track.strips.map((strip) => ({ name: strip.name, start: strip.start, end: strip.end, mute: strip.mute })),
|
|
327
|
-
...(playingHere ? { live: playingHere } : {}),
|
|
328
281
|
});
|
|
329
282
|
}
|
|
330
283
|
}
|
|
331
284
|
const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (rows.length + 1);
|
|
332
|
-
const header =
|
|
333
|
-
<Header>
|
|
334
|
-
{running ? <span data-testid="nla-live" style={{ color: TIMELINE_THEME.playhead }}>● game running: each playing track shows the game's frame and influence; the strips are the file's layout</span> : null}
|
|
335
|
-
</Header>
|
|
336
|
-
);
|
|
285
|
+
const header = <Header />;
|
|
337
286
|
return (
|
|
338
287
|
<Frame header={header}>
|
|
339
288
|
<div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
|
@@ -353,7 +302,7 @@ export function BlenderNlaEditor() {
|
|
|
353
302
|
color: row.kind === 'armature' ? TIMELINE_CHROME.textHi : row.mute ? '#6a6a6a' : TIMELINE_CHROME.text,
|
|
354
303
|
}}
|
|
355
304
|
>
|
|
356
|
-
{row.label}
|
|
305
|
+
{row.label}
|
|
357
306
|
</div>
|
|
358
307
|
))}
|
|
359
308
|
</div>
|
|
@@ -370,15 +319,14 @@ export function BlenderNlaEditor() {
|
|
|
370
319
|
width={Math.max(2, toX(strip.end) - toX(strip.start))}
|
|
371
320
|
height={CHANNEL_HEIGHT - 4}
|
|
372
321
|
rx={2}
|
|
373
|
-
fill={strip.mute || row.mute ? '#4a4a4a' :
|
|
374
|
-
opacity={running ? 0.6 : 1}
|
|
322
|
+
fill={strip.mute || row.mute ? '#4a4a4a' : '#7b7b7b'}
|
|
375
323
|
stroke="#1a1a1a"
|
|
376
324
|
/>
|
|
377
325
|
<text x={toX(strip.start) + 4} y={y + CHANNEL_HEIGHT - 7} fill="#ffffff" fontSize={10}>{strip.name}</text>
|
|
378
326
|
</g>
|
|
379
327
|
));
|
|
380
328
|
})}
|
|
381
|
-
|
|
329
|
+
<Playhead x={toX(play.frame)} frame={play.frame} height={height} />
|
|
382
330
|
</svg>
|
|
383
331
|
</div>
|
|
384
332
|
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCENE'S DISPLAY TRANSFORM AS BLENDER HOLDS IT: `scene.view_settings` (view transform, look,
|
|
3
|
+
* exposure in stops, gamma) read through the RNA door, as `BlenderDisplaySettings`. The Rendered
|
|
4
|
+
* areas and Play draw through it (`blender-runtime.document.tsx`), and a web export carries it so
|
|
5
|
+
* the page draws through the same OCIO processor (`blender-export.verbs.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { BlenderDisplaySettings } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
8
|
+
import { blenderRna, blenderRnaContext } from '../host/blender-runtime-host';
|
|
9
|
+
|
|
10
|
+
/** Null while Blender's session has no scene to read; throws for a display device the browser
|
|
11
|
+
* has no processor for. */
|
|
12
|
+
export async function readBlenderDisplaySettings(): Promise<BlenderDisplaySettings | null> {
|
|
13
|
+
const context = await blenderRnaContext();
|
|
14
|
+
if (!context) return null;
|
|
15
|
+
const [settings, displaySettings] = await Promise.all([
|
|
16
|
+
blenderRna(`${context.scene}.view_settings`),
|
|
17
|
+
blenderRna(`${context.scene}.display_settings`),
|
|
18
|
+
]);
|
|
19
|
+
if (settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return null;
|
|
20
|
+
if (displaySettings?.kind === 'struct') {
|
|
21
|
+
const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
|
|
22
|
+
if (typeof device === 'string' && device !== 'sRGB')
|
|
23
|
+
throw new Error(`Blender display device ${device} has no browser display processor yet`);
|
|
24
|
+
}
|
|
25
|
+
const rows = settings.groups.flatMap(group => group.rows);
|
|
26
|
+
const transform = rows.find(row => row.identifier === 'view_transform')?.value;
|
|
27
|
+
const stops = rows.find(row => row.identifier === 'exposure')?.value;
|
|
28
|
+
const look = rows.find(row => row.identifier === 'look')?.value;
|
|
29
|
+
const gamma = rows.find(row => row.identifier === 'gamma')?.value;
|
|
30
|
+
return {
|
|
31
|
+
transform: typeof transform === 'string' ? transform : 'AgX',
|
|
32
|
+
look: typeof look === 'string' ? look : 'None',
|
|
33
|
+
exposure: typeof stops === 'number' ? 2 ** stops : 1,
|
|
34
|
+
gamma: typeof gamma === 'number' ? gamma : 1,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCENE AS GLTF, ANIMATION INCLUDED (`blender-export-gltf`, `docs/SCENE-ANIMATION.md` step 6): the
|
|
3
|
+
* clips the editor plays, written so another engine (Godot, Unity, a viewer) plays the same.
|
|
4
|
+
*
|
|
5
|
+
* - THE MODEL is the presented scene (`BlenderRuntimeView.root`: meshes with their skins, materials,
|
|
6
|
+
* cameras, lights), through three.js's `GLTFExporter`.
|
|
7
|
+
* - EVERY ARMATURE'S ACTIONS (its assigned action and its NLA strips' actions)
|
|
8
|
+
* become one glTF animation each, `<armature>|<action>`: its bones' transforms as Blender sampled
|
|
9
|
+
* them (`blenderActionClip`), on node channels.
|
|
10
|
+
* - THE SCENE'S MOVIE (`blender-playback-movie.ts`) becomes the animation `Scene`: the moving objects and
|
|
11
|
+
* cameras on node channels, the shape keys as morph target weights.
|
|
12
|
+
* - A MATERIAL'S KEYED INPUTS (base colour, alpha, emission) become `KHR_animation_pointer` channels on
|
|
13
|
+
* the material in the animation `Materials`; an emission over 1 is carried by
|
|
14
|
+
* `KHR_materials_emissive_strength`.
|
|
15
|
+
*
|
|
16
|
+
* Constraints are held at the presented frame: a reader receives TRS, never constraints.
|
|
17
|
+
*/
|
|
18
|
+
import * as THREE from 'three';
|
|
19
|
+
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
|
|
20
|
+
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
21
|
+
import { mixerClip } from './blender-mixer-pose';
|
|
22
|
+
import { playMovie } from './blender-playback-movie';
|
|
23
|
+
import type { BlenderActionClip, BlenderSceneMovie } from '@volter/blender-engine/browser/rna';
|
|
24
|
+
|
|
25
|
+
/** A glTF document as JSON, as far as this file writes into it. */
|
|
26
|
+
interface GltfJson {
|
|
27
|
+
materials?: { name?: string; alphaMode?: string; pbrMetallicRoughness?: { baseColorFactor?: number[] }; emissiveFactor?: number[]; extensions?: Record<string, unknown> }[];
|
|
28
|
+
animations?: { name?: string; samplers: { input: number; output: number; interpolation: string }[]; channels: unknown[] }[];
|
|
29
|
+
accessors?: unknown[];
|
|
30
|
+
bufferViews?: unknown[];
|
|
31
|
+
buffers?: { byteLength: number; uri?: string }[];
|
|
32
|
+
extensionsUsed?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What a model's geometry carries into glTF: the standard attributes. Everything else on a drawn
|
|
36
|
+
* geometry is the presenter's (the drawn vertex's Blender vertex, its orco, a shading's inputs). */
|
|
37
|
+
const MODEL_ATTRIBUTES = new Set(['position', 'normal', 'tangent', 'uv', 'uv1', 'uv2', 'uv3', 'color', 'skinIndex', 'skinWeight']);
|
|
38
|
+
|
|
39
|
+
/** One keyed material input, as the movie plays it on a presented mesh. */
|
|
40
|
+
interface Pointer { readonly material: THREE.Material; readonly prop: 'color' | 'emissive' | 'opacity'; readonly track: THREE.KeyframeTrack }
|
|
41
|
+
|
|
42
|
+
export interface GltfExport {
|
|
43
|
+
readonly json: GltfJson;
|
|
44
|
+
readonly animations: readonly string[];
|
|
45
|
+
readonly pointers: number;
|
|
46
|
+
readonly skipped: readonly string[];
|
|
47
|
+
readonly failed: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write the scene and its animation as inline JSON glTF. */
|
|
51
|
+
export async function exportSceneGltf(view: BlenderRuntimeView,
|
|
52
|
+
read: {
|
|
53
|
+
clip(armature: string, action: string): Promise<BlenderActionClip | null>;
|
|
54
|
+
movie(): Promise<BlenderSceneMovie | null>;
|
|
55
|
+
}): Promise<GltfExport> {
|
|
56
|
+
const clips: THREE.AnimationClip[] = [];
|
|
57
|
+
const failed: string[] = [];
|
|
58
|
+
const skipped = new Set<string>();
|
|
59
|
+
// EVERY ARMATURE'S ACTIONS: one animation each
|
|
60
|
+
const facts = view.animationFacts();
|
|
61
|
+
for (const rig of view.skeletons.rigs()) {
|
|
62
|
+
const entry = facts.armatures[rig.armature];
|
|
63
|
+
if (!entry) continue;
|
|
64
|
+
const names = new Set<string>();
|
|
65
|
+
if (entry.action) names.add(entry.action);
|
|
66
|
+
for (const track of entry.animation?.tracks ?? []) for (const strip of track.strips) if (strip.action) names.add(strip.action);
|
|
67
|
+
for (const action of [...names].sort()) {
|
|
68
|
+
try {
|
|
69
|
+
const baked = await read.clip(rig.armature, action);
|
|
70
|
+
const clip = baked ? mixerClip(baked) : null;
|
|
71
|
+
if (!clip) continue;
|
|
72
|
+
const frames = Math.max(1, Math.round(clip.end - clip.start) + 1);
|
|
73
|
+
const times = Float32Array.from({ length: frames }, (_, i) => i / clip.fps);
|
|
74
|
+
const column = (values: Float32Array, stride: number): [Float32Array, Float32Array] =>
|
|
75
|
+
values.length === stride ? [new Float32Array([0]), values] : [times.slice(0, values.length / stride), values];
|
|
76
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
77
|
+
for (const [name, sampled] of clip.bones) {
|
|
78
|
+
const bone = rig.bones.get(name);
|
|
79
|
+
if (!bone) continue;
|
|
80
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.position`, ...column(sampled.position, 3)));
|
|
81
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(`${bone.uuid}.quaternion`, ...column(sampled.quaternion, 4)));
|
|
82
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.scale`, ...column(sampled.scale, 3)));
|
|
83
|
+
}
|
|
84
|
+
if (tracks.length) clips.push(new THREE.AnimationClip(`${rig.armature}|${action}`, (frames - 1) / clip.fps, tracks));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
failed.push(`${rig.armature} / ${action}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// THE SCENE'S MOVIE: objects and cameras, shape keys (morph weights), and materials (pointers below),
|
|
91
|
+
// with the meshes in their own materials whatever the viewport shows
|
|
92
|
+
const data = await read.movie();
|
|
93
|
+
return view.withAuthoredMaterials(async () => {
|
|
94
|
+
const movie = data ? playMovie(view, data, new THREE.AnimationMixer(view.root)) : null;
|
|
95
|
+
for (const warning of movie?.warnings ?? []) skipped.add(warning);
|
|
96
|
+
const pointers: Pointer[] = [];
|
|
97
|
+
const sceneTracks: THREE.KeyframeTrack[] = [];
|
|
98
|
+
for (const clip of movie?.clips() ?? []) {
|
|
99
|
+
for (const track of clip.tracks) {
|
|
100
|
+
const material = /^([^.]+)\.material(?:\[(\d+)\])?\.(color|emissive|opacity)$/.exec(track.name);
|
|
101
|
+
if (material) {
|
|
102
|
+
const mesh = view.root.getObjectByProperty('uuid', material[1]!) as THREE.Mesh | undefined;
|
|
103
|
+
const worn = mesh ? (Array.isArray(mesh.material) ? mesh.material[Number(material[2] ?? 0)] : mesh.material) : undefined;
|
|
104
|
+
if (worn) pointers.push({ material: worn, prop: material[3] as Pointer['prop'], track });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (track.name.endsWith('.visible')) { skipped.add('keyed visibility is not written to the glTF export'); continue; }
|
|
108
|
+
sceneTracks.push(track);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (sceneTracks.length) clips.push(new THREE.AnimationClip('Scene', -1, sceneTracks));
|
|
112
|
+
view.root.updateMatrixWorld(true);
|
|
113
|
+
// THE PRESENTER'S OWN ATTRIBUTES (the drawn vertex's Blender vertex, its orco, the Solid shading's
|
|
114
|
+
// hints) are the editor's bookkeeping, not the model's: set aside while it writes
|
|
115
|
+
const aside: [THREE.BufferGeometry, string, THREE.BufferAttribute | THREE.InterleavedBufferAttribute][] = [];
|
|
116
|
+
let json!: GltfJson;
|
|
117
|
+
try {
|
|
118
|
+
view.root.traverse((object) => {
|
|
119
|
+
const geometry = (object as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
|
|
120
|
+
if (!geometry?.attributes) return;
|
|
121
|
+
for (const name of Object.keys(geometry.attributes)) {
|
|
122
|
+
if (MODEL_ATTRIBUTES.has(name)) continue;
|
|
123
|
+
aside.push([geometry, name, geometry.getAttribute(name)]);
|
|
124
|
+
geometry.deleteAttribute(name);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// The presenter carries no mesh tangents. GLTFExporter inverts no-tangent OpenGL normal maps, matching GLTFLoader’s negative normalScale.y and preserving Blender’s orientation; DirectX maps use the opposite sign.
|
|
128
|
+
const exporter = new GLTFExporter();
|
|
129
|
+
json = await exporter.parseAsync(view.root, { binary: false, animations: clips, onlyVisible: false }) as GltfJson;
|
|
130
|
+
writePointers(json, pointers);
|
|
131
|
+
} finally {
|
|
132
|
+
for (const [geometry, name, attribute] of aside) geometry.setAttribute(name, attribute);
|
|
133
|
+
movie?.dispose();
|
|
134
|
+
}
|
|
135
|
+
return { json, animations: (json.animations ?? []).map((one) => one.name ?? ''), pointers: pointers.length, skipped: [...skipped], failed };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** THE MATERIALS' ANIMATION as `KHR_animation_pointer` channels, with their keys in a buffer of their own. */
|
|
140
|
+
function writePointers(json: GltfJson, pointers: readonly Pointer[]): void {
|
|
141
|
+
// AN EMISSION OVER 1 (a glow the movie set, an emission strength) is a factor of at most 1 times a strength
|
|
142
|
+
for (const material of json.materials ?? []) {
|
|
143
|
+
const factor = material.emissiveFactor;
|
|
144
|
+
const top = factor ? Math.max(...factor) : 0;
|
|
145
|
+
if (!factor || top <= 1) continue;
|
|
146
|
+
material.emissiveFactor = factor.map((one) => one / top);
|
|
147
|
+
const had = (material.extensions?.['KHR_materials_emissive_strength'] as { emissiveStrength?: number } | undefined)?.emissiveStrength ?? 1;
|
|
148
|
+
material.extensions = { ...material.extensions, KHR_materials_emissive_strength: { emissiveStrength: had * top } };
|
|
149
|
+
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), 'KHR_materials_emissive_strength'])];
|
|
150
|
+
}
|
|
151
|
+
if (!pointers.length) return;
|
|
152
|
+
const accessors = (json.accessors ??= []);
|
|
153
|
+
const views = (json.bufferViews ??= []);
|
|
154
|
+
const buffers = (json.buffers ??= []);
|
|
155
|
+
const chunks: Float32Array[] = [];
|
|
156
|
+
let offset = 0;
|
|
157
|
+
const buffer = buffers.length;
|
|
158
|
+
const accessor = (values: Float32Array, type: 'SCALAR' | 'VEC3' | 'VEC4', bounds = false): number => {
|
|
159
|
+
views.push({ buffer, byteOffset: offset, byteLength: values.byteLength });
|
|
160
|
+
const size = type === 'SCALAR' ? 1 : type === 'VEC3' ? 3 : 4;
|
|
161
|
+
accessors.push({
|
|
162
|
+
bufferView: views.length - 1, componentType: 5126, count: values.length / size, type,
|
|
163
|
+
...(bounds ? { min: [Math.min(...values)], max: [Math.max(...values)] } : {}),
|
|
164
|
+
});
|
|
165
|
+
chunks.push(values);
|
|
166
|
+
offset += values.byteLength;
|
|
167
|
+
return accessors.length - 1;
|
|
168
|
+
};
|
|
169
|
+
const samplers: { input: number; output: number; interpolation: string }[] = [];
|
|
170
|
+
const channels: unknown[] = [];
|
|
171
|
+
const used = new Set<string>(['KHR_animation_pointer']);
|
|
172
|
+
const channel = (times: Float32Array, values: Float32Array, type: 'SCALAR' | 'VEC3' | 'VEC4', pointer: string): void => {
|
|
173
|
+
samplers.push({ input: accessor(times, 'SCALAR', true), output: accessor(values, type), interpolation: 'LINEAR' });
|
|
174
|
+
channels.push({ sampler: samplers.length - 1, target: { path: 'pointer', extensions: { KHR_animation_pointer: { pointer } } } });
|
|
175
|
+
};
|
|
176
|
+
const byMaterial = new Map<THREE.Material, Partial<Record<Pointer['prop'], THREE.KeyframeTrack>>>();
|
|
177
|
+
for (const one of pointers) byMaterial.set(one.material, { ...byMaterial.get(one.material), [one.prop]: one.track });
|
|
178
|
+
for (const [material, props] of byMaterial) {
|
|
179
|
+
const index = (json.materials ?? []).findIndex((one) => one.name === material.name);
|
|
180
|
+
if (index < 0) continue;
|
|
181
|
+
const own = material as THREE.MeshStandardMaterial;
|
|
182
|
+
// BASE COLOUR AND ALPHA are one glTF factor
|
|
183
|
+
if (props.color || props.opacity) {
|
|
184
|
+
if (props.opacity?.values.some(value => Number(value) < 1)) json.materials![index]!.alphaMode = 'BLEND';
|
|
185
|
+
const times = (props.color ?? props.opacity)!.times as Float32Array;
|
|
186
|
+
const color = props.color?.values as Float32Array | undefined;
|
|
187
|
+
const alpha = props.opacity?.values as Float32Array | undefined;
|
|
188
|
+
const values = new Float32Array(times.length * 4);
|
|
189
|
+
for (let i = 0; i < times.length; i++) {
|
|
190
|
+
values[i * 4] = color ? color[Math.min(i, color.length / 3 - 1) * 3]! : own.color?.r ?? 1;
|
|
191
|
+
values[i * 4 + 1] = color ? color[Math.min(i, color.length / 3 - 1) * 3 + 1]! : own.color?.g ?? 1;
|
|
192
|
+
values[i * 4 + 2] = color ? color[Math.min(i, color.length / 3 - 1) * 3 + 2]! : own.color?.b ?? 1;
|
|
193
|
+
values[i * 4 + 3] = alpha ? alpha[Math.min(i, alpha.length - 1)]! : own.opacity ?? 1;
|
|
194
|
+
}
|
|
195
|
+
channel(times, values, 'VEC4', `/materials/${index}/pbrMetallicRoughness/baseColorFactor`);
|
|
196
|
+
}
|
|
197
|
+
// EMISSION: glTF's factor is at most 1; the strength over it goes in KHR_materials_emissive_strength
|
|
198
|
+
if (props.emissive) {
|
|
199
|
+
const times = props.emissive.times as Float32Array;
|
|
200
|
+
const glow = props.emissive.values as Float32Array;
|
|
201
|
+
const count = Math.min(times.length, glow.length / 3);
|
|
202
|
+
const factor = new Float32Array(count * 3);
|
|
203
|
+
const strength = new Float32Array(count);
|
|
204
|
+
for (let i = 0; i < count; i++) {
|
|
205
|
+
const top = Math.max(1, glow[i * 3]!, glow[i * 3 + 1]!, glow[i * 3 + 2]!);
|
|
206
|
+
strength[i] = top;
|
|
207
|
+
for (let c = 0; c < 3; c++) factor[i * 3 + c] = glow[i * 3 + c]! / top;
|
|
208
|
+
}
|
|
209
|
+
channel(times.slice(0, count), factor, 'VEC3', `/materials/${index}/emissiveFactor`);
|
|
210
|
+
if (strength.some((one) => one > 1)) {
|
|
211
|
+
const entry = json.materials![index]!;
|
|
212
|
+
entry.extensions = { ...entry.extensions, KHR_materials_emissive_strength: { emissiveStrength: Math.max(...strength) } };
|
|
213
|
+
used.add('KHR_materials_emissive_strength');
|
|
214
|
+
channel(times.slice(0, count), strength, 'SCALAR', `/materials/${index}/extensions/KHR_materials_emissive_strength/emissiveStrength`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!channels.length) return;
|
|
219
|
+
// THE KEYS, in a buffer of their own, inline as the rest of the JSON export is
|
|
220
|
+
const bytes = new Uint8Array(offset);
|
|
221
|
+
let at = 0;
|
|
222
|
+
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), at); at += chunk.byteLength; }
|
|
223
|
+
let binary = '';
|
|
224
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
225
|
+
buffers.push({ byteLength: bytes.byteLength, uri: `data:application/octet-stream;base64,${btoa(binary)}` });
|
|
226
|
+
(json.animations ??= []).push({ name: 'Materials', samplers, channels });
|
|
227
|
+
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), ...used])];
|
|
228
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
2
|
+
import type { EditorCommandResult, VerbHandler } from '@volter/sdk/commands';
|
|
3
|
+
import { editorHost } from '@volter/sdk/host';
|
|
4
|
+
import { blenderActionClip, blenderSceneMovie } from '../host/blender-runtime-host';
|
|
5
|
+
import { modelDocumentSource, servedModelDocument } from '../src/play-mode';
|
|
6
|
+
import { exportSceneGltf } from './blender-export-gltf';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* THE SCENE AS GLTF (`blender-export-gltf`, docs/SCENE-ANIMATION.md step 6): the presented model with
|
|
10
|
+
* every armature's actions, the scene's movie and its keyed materials (`blender-export-gltf.ts`),
|
|
11
|
+
* written to `out` (project-relative, `.gltf`; default `exports/<name>.gltf`).
|
|
12
|
+
*/
|
|
13
|
+
async function exportGltf(command: Record<string, unknown>): Promise<EditorCommandResult> {
|
|
14
|
+
const documentId = servedModelDocument();
|
|
15
|
+
const blend = documentId ? modelDocumentSource(documentId) ?? modelDocumentSource(documentId.replace(/#.*$/, '')) : null;
|
|
16
|
+
if (!blend) return { ok: false, error: 'No model document is open: open the .blend to export, then export again.' };
|
|
17
|
+
if (blenderModelView.snapshot() === null)
|
|
18
|
+
return { ok: false, error: `${blend} has not been presented yet: wait for the model to show, then export again.` };
|
|
19
|
+
const named = command['out'];
|
|
20
|
+
if (named !== undefined && (typeof named !== 'string' || !/\.gltf$/i.test(named)))
|
|
21
|
+
return { ok: false, error: `blender-export-gltf's out is a project-relative .gltf path; it was given ${JSON.stringify(named)}.` };
|
|
22
|
+
const out = (named as string | undefined) ?? `exports/${blend.replace(/^.*\//, '').replace(/\.blend$/i, '')}.gltf`;
|
|
23
|
+
const started = performance.now();
|
|
24
|
+
const written = await exportSceneGltf(blenderModelView, {
|
|
25
|
+
clip: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
|
|
26
|
+
movie: () => blenderSceneMovie(),
|
|
27
|
+
});
|
|
28
|
+
const text = JSON.stringify(written.json);
|
|
29
|
+
await editorHost().files.write(out, text);
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
data: { out, blend, bytes: text.length, animations: written.animations, pointers: written.pointers, skipped: written.skipped, failed: written.failed, ms: Math.round(performance.now() - started) },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const exportVerbs: Readonly<Record<string, VerbHandler>> = {
|
|
37
|
+
// The model and its animation as glTF: every armature's clips read from Blender.
|
|
38
|
+
'blender-export-gltf': (command) => exportGltf(command as Record<string, unknown>),
|
|
39
|
+
};
|