@volter/editor-blender 0.1.141 → 0.1.142
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 +11 -0
- package/README.md +1 -1
- package/contributions/blender-animation-editors.tsx +387 -0
- package/contributions/blender-game-panel.tsx +25 -24
- package/contributions/blender-header-menus.tsx +2 -2
- package/contributions/blender-icons.source.mjs +1 -1
- package/contributions/blender-model-opening.tsx +3 -3
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +4 -4
- package/contributions/blender-outliner-model.ts +1 -1
- package/contributions/blender-play-skin.ts +48 -6
- package/contributions/blender-pose.ts +7 -0
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +2 -2
- package/contributions/blender-properties-tab.tsx +2 -2
- package/contributions/blender-properties-view.tsx +1 -1
- package/contributions/blender-runtime-skin.ts +35 -1
- package/contributions/blender-runtime.document.tsx +17 -13
- package/contributions/blender-screenshot.menu.ts +3 -3
- package/contributions/blender-timeline-geometry.ts +2 -2
- package/contributions/blender-timeline.document.tsx +41 -10
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/blender-version.status.tsx +2 -2
- package/contributions/blender.command.ts +3 -3
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.keymap.ts +2 -2
- package/contributions/blender.status.tsx +2 -2
- package/contributions/blender.style.ts +3 -3
- package/contributions/model.layout.ts +2 -2
- package/contributions/models.finder.ts +1 -1
- package/contributions/properties-data.inspector.tsx +1 -1
- package/contributions/properties-scene.inspector.tsx +1 -1
- package/contributions/sculpt.layout.ts +1 -1
- package/contributions/shading.layout.ts +1 -1
- package/contributions/texture.layout.ts +1 -1
- package/contributions/uv-editing.layout.ts +1 -1
- package/dist-node/serving.mjs +2 -2
- package/host/blender-runtime-host.ts +4 -4
- package/node/blender-mcp.ts +3 -3
- package/package.json +6 -6
- package/serving/blender-routes.ts +4 -4
- package/serving/index.ts +2 -2
- package/src/layouts.tsx +3 -3
- package/src/looks.ts +1 -1
- package/src/play-live.ts +27 -0
- package/src/play-mode.ts +23 -15
- package/src/presentation.ts +2 -2
- package/src/regions.ts +1 -1
- package/src/timeline-view-state.ts +7 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +2 -2
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* baked, the armature keeps the pose it has.
|
|
24
24
|
*
|
|
25
25
|
* THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
|
|
26
|
-
* (`@volter/
|
|
26
|
+
* (`@volter/play`'s `play-script.ts`), so pause, step, speed and Restart hold for
|
|
27
27
|
* animation exactly as they do for the script.
|
|
28
28
|
*/
|
|
29
29
|
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
@@ -32,7 +32,7 @@ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/bl
|
|
|
32
32
|
import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
|
|
33
33
|
import type * as THREE from 'three';
|
|
34
34
|
import { ArmaturePose, nlaLayers, poseClip, type ConstraintOverride, type PoseClip, type PoseLayer } from './blender-pose';
|
|
35
|
-
import { editorHost } from '@volter/
|
|
35
|
+
import { editorHost } from '@volter/sdk/host';
|
|
36
36
|
import { remedy } from './blender-runtime-skin';
|
|
37
37
|
|
|
38
38
|
export interface PlayActionOptions {
|
|
@@ -74,10 +74,28 @@ export interface PlayAnimation {
|
|
|
74
74
|
track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
|
|
75
75
|
constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
|
|
76
76
|
update(dt: number): void;
|
|
77
|
+
/** What each character is playing now, bottom layer first: the animation editors' live view. */
|
|
78
|
+
live(): readonly LiveArmature[];
|
|
79
|
+
/** The frames an action keys, per bone, for one armature (empty until it was baked). */
|
|
80
|
+
channels(armature: string, action: string): { bone: string; keys: number[] }[];
|
|
77
81
|
readonly warnings: readonly string[];
|
|
78
82
|
dispose(): void;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
/** One character's animation at the game's last update. */
|
|
86
|
+
export interface LiveArmature {
|
|
87
|
+
readonly armature: string;
|
|
88
|
+
/** The layers it was posed from, bottom first: each clip, its frame, influence and blend. */
|
|
89
|
+
readonly layers: readonly {
|
|
90
|
+
/** Where the layer comes from: an NLA track's name, or `null` for the active action. */
|
|
91
|
+
readonly track: string | null;
|
|
92
|
+
readonly action: string;
|
|
93
|
+
readonly frame: number;
|
|
94
|
+
readonly influence: number;
|
|
95
|
+
readonly blend: string;
|
|
96
|
+
}[];
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
/** A weight that moves toward its target at a rate (a crossfade). */
|
|
82
100
|
interface Ramp { weight: number; target: number; rate: number }
|
|
83
101
|
const ramp = (weight: number, target: number, fade: number): Ramp => ({ weight, target, rate: fade > 0 ? 1 / fade : 0 });
|
|
@@ -101,6 +119,8 @@ interface Armature {
|
|
|
101
119
|
line: Playing[];
|
|
102
120
|
readonly tracks: Map<string, Track>;
|
|
103
121
|
readonly constraints: Map<string, ConstraintOverride>;
|
|
122
|
+
/** The layers of the last pose, for `live`. */
|
|
123
|
+
posed: LiveArmature['layers'];
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/** The copy's animation. `bake` reads one action of one armature through the clip door. */
|
|
@@ -114,6 +134,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
114
134
|
const armatures = new Map<string, Armature>();
|
|
115
135
|
let time = 0;
|
|
116
136
|
let disposed = false;
|
|
137
|
+
const lastSources = new Map<Armature, (string | null)[]>();
|
|
117
138
|
|
|
118
139
|
const fail = (armature: Armature, action: string, why: string): void => {
|
|
119
140
|
armature.clips.set(action, null);
|
|
@@ -138,7 +159,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
138
159
|
const pose = new ArmaturePose(rig);
|
|
139
160
|
pose.facts(entry);
|
|
140
161
|
for (const thing of pose.unsupported()) warnings.push(`${rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
|
|
141
|
-
const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map() };
|
|
162
|
+
const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map(), posed: [] };
|
|
142
163
|
// EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
|
|
143
164
|
if (entry.action) armature.line = [{ action: entry.action, from: 0, loop: true, speed: 1, weight: ramp(1, 1, 0) }];
|
|
144
165
|
armatures.set(rig.armature, armature);
|
|
@@ -157,12 +178,15 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
157
178
|
/** Every layer of one armature now, bottom to top; null while a clip it needs is being baked. */
|
|
158
179
|
const layersOf = (armature: Armature): PoseLayer[] | null => {
|
|
159
180
|
const layers: PoseLayer[] = [];
|
|
181
|
+
/** Each layer's source, by index: a track's name, or null for the active action. */
|
|
182
|
+
const sources: (string | null)[] = [];
|
|
183
|
+
let source: string | null = null;
|
|
160
184
|
let waiting = false;
|
|
161
185
|
/** Whether an enabled track has strips (or a game's track plays): what places the active action. */
|
|
162
186
|
let evaluated = false;
|
|
163
187
|
const take = (layer: PoseLayer | null | undefined): void => {
|
|
164
188
|
if (layer === undefined) waiting = true;
|
|
165
|
-
else if (layer) layers.push(layer);
|
|
189
|
+
else if (layer) { layers.push(layer); sources.push(source); }
|
|
166
190
|
};
|
|
167
191
|
const animation = armature.facts.animation;
|
|
168
192
|
// THE SCENE FRAME, wrapping at the scene's end as Blender's playback wraps.
|
|
@@ -173,6 +197,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
173
197
|
// ENABLED as Blender says: under a solo only the soloed track, muted or not.
|
|
174
198
|
if (solo ? !track.solo : (set ? set.mute : track.mute)) continue;
|
|
175
199
|
const influence = set ? set.influence.weight : 1;
|
|
200
|
+
source = track.name;
|
|
176
201
|
if (set?.action) {
|
|
177
202
|
const blend = track.strips[0]?.blendType ?? 'REPLACE';
|
|
178
203
|
if (set.previous) take(playedLayer(armature, set.previous, influence, blend));
|
|
@@ -183,10 +208,11 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
183
208
|
const stack = nlaLayers({ ...animation!, tracks: [{ ...track, mute: false, solo: false }] }, frame, (action) => clipOf(armature, action));
|
|
184
209
|
if (stack.waiting) waiting = true;
|
|
185
210
|
if (stack.evaluated) evaluated = true;
|
|
186
|
-
for (const layer of stack.layers) layers.push({ ...layer, influence: layer.influence * influence });
|
|
211
|
+
for (const layer of stack.layers) { layers.push({ ...layer, influence: layer.influence * influence }); sources.push(track.name); }
|
|
187
212
|
}
|
|
188
|
-
for (const set of armature.tracks
|
|
213
|
+
for (const [name, set] of armature.tracks) {
|
|
189
214
|
if (!set.own || set.mute || !set.action) continue;
|
|
215
|
+
source = name;
|
|
190
216
|
if (set.previous) take(playedLayer(armature, set.previous, set.influence.weight, 'REPLACE'));
|
|
191
217
|
take(playedLayer(armature, set.action, set.influence.weight, 'REPLACE'));
|
|
192
218
|
evaluated = true;
|
|
@@ -195,8 +221,10 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
195
221
|
// no enabled track has strips, else at its influence and blend type over them.
|
|
196
222
|
const nla = !!animation?.useNla;
|
|
197
223
|
const strips = nla && evaluated;
|
|
224
|
+
source = null;
|
|
198
225
|
for (const played of nla && solo ? [] : armature.line)
|
|
199
226
|
take(playedLayer(armature, played, strips ? animation!.influence : 1, strips ? animation!.blendType : 'REPLACE'));
|
|
227
|
+
lastSources.set(armature, sources);
|
|
200
228
|
return waiting ? null : layers;
|
|
201
229
|
};
|
|
202
230
|
|
|
@@ -321,12 +349,26 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
321
349
|
}
|
|
322
350
|
const layers = layersOf(armature);
|
|
323
351
|
if (!layers) continue;
|
|
352
|
+
const sources = lastSources.get(armature) ?? [];
|
|
353
|
+
armature.posed = layers.map((layer, i) => ({ track: sources[i] ?? null, action: layer.clip.action, frame: layer.frame, influence: layer.influence, blend: layer.blend }));
|
|
324
354
|
armature.pose.apply(layers, {
|
|
325
355
|
object: (name) => view.objectForBlenderName(name),
|
|
326
356
|
override: (bone, name) => armature.constraints.get(`${bone}\u0000${name}`),
|
|
327
357
|
});
|
|
328
358
|
}
|
|
329
359
|
},
|
|
360
|
+
channels(name, action) {
|
|
361
|
+
const clip = armatures.get(name)?.clips.get(action);
|
|
362
|
+
if (!clip) return [];
|
|
363
|
+
const bones = new Map<string, Set<number>>();
|
|
364
|
+
for (const channel of clip.channels) {
|
|
365
|
+
let frames = bones.get(channel.bone);
|
|
366
|
+
if (!frames) bones.set(channel.bone, frames = new Set());
|
|
367
|
+
for (const curve of channel.curves) if (curve) for (let i = 0; i < curve.keys.length; i += 6) frames.add(Math.round(curve.keys[i]! * 1000) / 1000);
|
|
368
|
+
}
|
|
369
|
+
return [...bones].map(([bone, frames]) => ({ bone, keys: [...frames].sort((a, b) => a - b) }));
|
|
370
|
+
},
|
|
371
|
+
live: () => [...armatures.values()].map((armature) => ({ armature: armature.rig.armature, layers: armature.posed })),
|
|
330
372
|
dispose() {
|
|
331
373
|
disposed = true;
|
|
332
374
|
armatures.clear();
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
* A clip holds an action's F-Curves whole (`session.py`'s `rna_action_clip`): keys, handles,
|
|
21
21
|
* interpolation, extrapolation and Cycles modifiers, evaluated as `fcurve_eval_keyframes` and
|
|
22
22
|
* `fcm_cycles_time` evaluate them, so a pose is Blender's at any frame, whole or fractional.
|
|
23
|
+
*
|
|
24
|
+
* LICENCE. This file follows Blender's source (the functions named above), so it is a derivative
|
|
25
|
+
* work of Blender and is GPL-3.0-or-later, not the AGPL the package's own code carries
|
|
26
|
+
* (`../LICENSE`). Blender, Copyright (C) Blender Authors, GPL-2.0-or-later. It must not be
|
|
27
|
+
* copied into an Apache-2.0 or MIT package.
|
|
28
|
+
*
|
|
29
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
23
30
|
*/
|
|
24
31
|
import type { BlenderActionClip, BlenderClipCurve } from '@volter/blender-engine/browser/rna';
|
|
25
32
|
import type {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Blender's Properties context remains inspectable without a selection.
|
|
2
2
|
* Reuse the same RNA sections and Properties projection as selected objects. */
|
|
3
|
-
import { registerNullSubjectProvider } from '@volter/
|
|
3
|
+
import { registerNullSubjectProvider } from '@volter/sdk/kit/inspection/null-subject';
|
|
4
4
|
import { blenderOutlinerState } from './blender-outliner-model';
|
|
5
5
|
import { blenderOutlinerKind } from './blender-outliner-authoring';
|
|
6
6
|
import {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import type { BlenderRnaContext, BlenderRnaView } from '@volter/blender-engine/browser/rna';
|
|
37
|
-
import { editorHost } from '@volter/
|
|
37
|
+
import { editorHost } from '@volter/sdk/host';
|
|
38
38
|
import type * as THREE from 'three';
|
|
39
39
|
import {
|
|
40
40
|
blenderPresentationDocumentId,
|
|
@@ -192,7 +192,7 @@ function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = fal
|
|
|
192
192
|
for (const listener of [...listeners]) listener();
|
|
193
193
|
// THE TAB RAIL IS THE SECTION LIST, and section matching is the host's
|
|
194
194
|
// composition — which no store of ours can move. This is the one door that
|
|
195
|
-
// re-derives it (`@volter/
|
|
195
|
+
// re-derives it (`@volter/sdk/host`, `documents.contextChanged`).
|
|
196
196
|
if (tabsMayHaveChanged) editorHost().documents.contextChanged(blenderPresentationDocumentId());
|
|
197
197
|
}
|
|
198
198
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* WHY A TAB IS A SECTION, and not a rail of our own: under the `properties`
|
|
7
7
|
* presentation the inspector's sections ARE the vertical icon rail, 1:1
|
|
8
|
-
* (`packages/
|
|
8
|
+
* (`packages/sdk/src/kit/components/InspectionProjection.tsx`,
|
|
9
9
|
* `PropertiesColumn`). So the way to make Blender's tab rail appear is to
|
|
10
10
|
* contribute Blender's tabs as sections — which is also exactly the shape I2
|
|
11
11
|
* needs, because each of these modules then grows a CURATED body over the same
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
import type {
|
|
39
39
|
ToolContributionNode,
|
|
40
40
|
ToolInspectorContributionProps,
|
|
41
|
-
} from '@volter/
|
|
41
|
+
} from '@volter/sdk/contributions';
|
|
42
42
|
import {
|
|
43
43
|
blenderPropertiesState,
|
|
44
44
|
resolveBlenderSubject,
|
|
@@ -55,7 +55,7 @@ import type {
|
|
|
55
55
|
} from '@volter/blender-engine/browser/rna';
|
|
56
56
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
57
57
|
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
|
|
58
|
-
import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/
|
|
58
|
+
import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/sdk/widgets';
|
|
59
59
|
import {
|
|
60
60
|
type BlenderSubject,
|
|
61
61
|
blenderPropertiesState,
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
43
43
|
import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
44
44
|
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
45
|
-
import { editorHost, type StageTransportHandle } from '@volter/
|
|
45
|
+
import { editorHost, type StageTransportHandle } from '@volter/sdk/host';
|
|
46
46
|
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
47
47
|
import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
|
|
48
48
|
|
|
@@ -370,6 +370,40 @@ export class BlenderSkinDirector {
|
|
|
370
370
|
this.#pose();
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
// ------------------------------------------------------------ what the animation editors read
|
|
374
|
+
|
|
375
|
+
/** The armature the editors describe (the active object's, else the one with an action). */
|
|
376
|
+
get subject(): string | null {
|
|
377
|
+
return this.#armature;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Every action in the file, by name: what the Action Editor's selector offers. */
|
|
381
|
+
actionNames(): string[] {
|
|
382
|
+
return Object.keys(this.#view?.animationFacts().actions ?? {}).sort((a, b) => a.localeCompare(b));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The Action Editor's channels: the subject's active action, per bone, the frames it keys. */
|
|
386
|
+
channels(): { bone: string; keys: number[] }[] {
|
|
387
|
+
const bones = new Map<string, Set<number>>();
|
|
388
|
+
for (const track of this.#clip?.tracks ?? []) {
|
|
389
|
+
const raw = atob(track.keysBase64);
|
|
390
|
+
const bytes = new Uint8Array(raw.length);
|
|
391
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
392
|
+
const keys = new Float32Array(bytes.buffer, 0, bytes.length >> 2);
|
|
393
|
+
let frames = bones.get(track.bone);
|
|
394
|
+
if (!frames) bones.set(track.bone, frames = new Set());
|
|
395
|
+
for (let i = 0; i < keys.length; i += 6) frames.add(Math.round(keys[i]! * 1000) / 1000);
|
|
396
|
+
}
|
|
397
|
+
return [...bones].map(([bone, frames]) => ({ bone, keys: [...frames].sort((a, b) => a - b) }));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Every armature with its NLA stack and active action, as the frame describes them. */
|
|
401
|
+
stacks(): { armature: string; action: string | null; animation: BlenderArmature['animation'] }[] {
|
|
402
|
+
const facts = this.#view?.animationFacts();
|
|
403
|
+
if (!facts) return [];
|
|
404
|
+
return Object.entries(facts.armatures).map(([armature, entry]) => ({ armature, action: entry.action ?? null, animation: entry.animation }));
|
|
405
|
+
}
|
|
406
|
+
|
|
373
407
|
/** The scene's name, for the one address the Timeline writes to. */
|
|
374
408
|
get scene(): string | null {
|
|
375
409
|
return this.#clip?.scene ?? null;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
// How the `model` stage this document builds behaves (its starting presentation).
|
|
28
28
|
import './blender-properties-context';
|
|
29
|
+
import { liveAnimation, setLiveAnimation } from '../src/play-live';
|
|
29
30
|
import { blenderViewFieldOfView } from '../src/presentation';
|
|
30
31
|
import {
|
|
31
32
|
type BlenderRuntimeView,
|
|
@@ -33,20 +34,20 @@ import {
|
|
|
33
34
|
} from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
34
35
|
import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
|
|
35
36
|
import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
36
|
-
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/
|
|
37
|
-
import { editorHost } from '@volter/
|
|
37
|
+
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
|
|
38
|
+
import { editorHost } from '@volter/sdk/host';
|
|
38
39
|
import {
|
|
39
40
|
documentViewport,
|
|
40
41
|
documentViewportsVersion,
|
|
41
42
|
subscribeDocumentViewports,
|
|
42
|
-
} from '@volter/
|
|
43
|
+
} from '@volter/sdk/kit/document-viewports';
|
|
43
44
|
import {
|
|
44
45
|
DOCUMENT_STUDIO_PRESET,
|
|
45
46
|
setViewPresentation,
|
|
46
47
|
subscribeViewportPresentation,
|
|
47
48
|
viewPresentation,
|
|
48
49
|
viewPresentationSnapshot,
|
|
49
|
-
} from '@volter/
|
|
50
|
+
} from '@volter/sdk/kit/viewport-presentation';
|
|
50
51
|
import {
|
|
51
52
|
type MouseEvent as ReactMouseEvent,
|
|
52
53
|
type PointerEvent as ReactPointerEvent,
|
|
@@ -79,13 +80,13 @@ import { blenderSkin } from './blender-runtime-skin';
|
|
|
79
80
|
import { playAnimation, type PlayAnimation } from './blender-play-skin';
|
|
80
81
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
81
82
|
import { noteModelDocument } from '../src/play-mode';
|
|
82
|
-
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/
|
|
83
|
-
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/
|
|
83
|
+
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
|
|
84
|
+
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/kit/workspace-document-registry';
|
|
84
85
|
import {
|
|
85
86
|
documentSecondAreaHeader,
|
|
86
87
|
setDocumentSecondArea,
|
|
87
88
|
subscribeDocumentAreas,
|
|
88
|
-
} from '@volter/
|
|
89
|
+
} from '@volter/sdk/kit/document-areas';
|
|
89
90
|
import { createPortal } from 'react-dom';
|
|
90
91
|
import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
91
92
|
import {
|
|
@@ -93,13 +94,13 @@ import {
|
|
|
93
94
|
subscribeObject3DDocumentSessions,
|
|
94
95
|
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
95
96
|
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
96
|
-
import { stageStore, subscribeStageStores } from '@volter/
|
|
97
|
-
import { surfaceAcceptsKey } from '@volter/
|
|
97
|
+
import { stageStore, subscribeStageStores } from '@volter/sdk/kit/stage-store-registry';
|
|
98
|
+
import { surfaceAcceptsKey } from '@volter/sdk/kit/surface-keyboard';
|
|
98
99
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
99
100
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
100
|
-
import { commandLine } from '@volter/
|
|
101
|
-
import { fontSizeVar, spaceVar, themeVars } from '@volter/
|
|
102
|
-
import { clearStartupFailure, reportStartupFailure } from '@volter/
|
|
101
|
+
import { commandLine } from '@volter/sdk/kit/product-command';
|
|
102
|
+
import { fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
|
|
103
|
+
import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/kit/startup-failure';
|
|
103
104
|
|
|
104
105
|
/** This document's model open, as a source in the page's startup-failure registry. */
|
|
105
106
|
const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
|
|
@@ -719,7 +720,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
719
720
|
* THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
|
|
720
721
|
*
|
|
721
722
|
* The area is declared to the host as the document's second area
|
|
722
|
-
* (`@volter/
|
|
723
|
+
* (`@volter/sdk/kit/document-areas`). Its HEADER stands in the document's header row over
|
|
723
724
|
* this area: the host reserves this area's width at the row's trailing edge, so the first area's
|
|
724
725
|
* header ends where the first area does, and this renders into the region it draws there: the
|
|
725
726
|
* editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
|
|
@@ -914,6 +915,7 @@ function BlenderViewportArea({
|
|
|
914
915
|
void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
|
|
915
916
|
if (stopped) return;
|
|
916
917
|
animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
|
|
918
|
+
setLiveAnimation(animation);
|
|
917
919
|
stopScript = documentPlayExtension('model')?.run({
|
|
918
920
|
...(animation ? { animation } : {}),
|
|
919
921
|
documentId: modelId,
|
|
@@ -973,6 +975,8 @@ function BlenderViewportArea({
|
|
|
973
975
|
stopPrepareFrames?.();
|
|
974
976
|
window.removeEventListener('keydown', onEscape, true);
|
|
975
977
|
stopScript?.();
|
|
978
|
+
// Only this run's animation is cleared: a restart's new run may already hold the slot.
|
|
979
|
+
if (liveAnimation() === animation) setLiveAnimation(null);
|
|
976
980
|
animation?.dispose();
|
|
977
981
|
animation = null;
|
|
978
982
|
layers.remove();
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* goes to the project's `screenshots/` folder and the console says which file it is. Like every
|
|
7
7
|
* photograph of the document it flashes the document and shows itself in the corner.
|
|
8
8
|
*/
|
|
9
|
-
import type { MenuContribution } from '@volter/
|
|
10
|
-
import { editorHost } from '@volter/
|
|
11
|
-
import { announcePhotograph } from '@volter/
|
|
9
|
+
import type { MenuContribution } from '@volter/sdk/chrome';
|
|
10
|
+
import { editorHost } from '@volter/sdk/host';
|
|
11
|
+
import { announcePhotograph } from '@volter/sdk/kit/photograph-notice';
|
|
12
12
|
|
|
13
13
|
export const point = 'workspace.menu';
|
|
14
14
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* own frame (5.2.1 on this box, `scripts/blender-reference-frames.py`).
|
|
5
5
|
*
|
|
6
6
|
* This is the first unit of the inspection arc with a sighted read behind it.
|
|
7
|
-
* I1-I5 each recorded
|
|
8
|
-
* against Blender's SOURCE alone
|
|
7
|
+
* I1-I5 each recorded that the reference frames were not at hand, so each was graded
|
|
8
|
+
* against Blender's SOURCE alone; Blender is installed here, the script above
|
|
9
9
|
* drives its real GUI, and the numbers below carry three things each: the
|
|
10
10
|
* source that defines them, the pixel that confirms them, and what we draw.
|
|
11
11
|
*
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
* you are looking.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import type { StageTransportHandle } from '@volter/
|
|
42
|
-
import { registerViewVerbs } from '@volter/
|
|
41
|
+
import type { StageTransportHandle } from '@volter/sdk/host';
|
|
42
|
+
import { registerViewVerbs } from '@volter/sdk/views';
|
|
43
43
|
import {
|
|
44
44
|
type ReactNode,
|
|
45
45
|
useCallback,
|
|
@@ -65,7 +65,9 @@ import {
|
|
|
65
65
|
} from '../src/timeline-view-state';
|
|
66
66
|
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
67
67
|
import { BlenderGamePanel, GAME_PANEL_RATIO, PlayModeSwitch } from './blender-game-panel';
|
|
68
|
-
import {
|
|
68
|
+
import { AnimationEditorMenu, BlenderActionEditor, BlenderNlaEditor } from './blender-animation-editors';
|
|
69
|
+
import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
|
|
70
|
+
import { setWorkspaceAreaRatio } from '@volter/sdk/kit/workspace-areas';
|
|
69
71
|
import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
|
|
70
72
|
import {
|
|
71
73
|
CHANNEL_HEIGHT,
|
|
@@ -114,6 +116,7 @@ const REFUSALS = {
|
|
|
114
116
|
|
|
115
117
|
/** The scene clock is available with or without an action. Only a missing
|
|
116
118
|
* scene/transport prevents timeline navigation. */
|
|
119
|
+
const GAME_RUNNING = 'A game is running; the Timeline is read-only until it stops (Stop in the Game panel).';
|
|
117
120
|
const NOT_PLAYABLE =
|
|
118
121
|
'No scene transport is attached yet; open the model document first.';
|
|
119
122
|
|
|
@@ -216,6 +219,7 @@ function report(): unknown {
|
|
|
216
219
|
size: view.size,
|
|
217
220
|
refusal: view.refusal,
|
|
218
221
|
drawn: view.drawn,
|
|
222
|
+
editor: view.editor,
|
|
219
223
|
};
|
|
220
224
|
}
|
|
221
225
|
|
|
@@ -237,6 +241,7 @@ function report(): unknown {
|
|
|
237
241
|
* are fixed by this one guard: `frame`, `jump-start`, `jump-end`,
|
|
238
242
|
* `next-keyframe`, `prev-keyframe`. */
|
|
239
243
|
function scrubTo(frame: number): unknown {
|
|
244
|
+
if (liveAnimation() !== null) return refuse(GAME_RUNNING);
|
|
240
245
|
if (!blenderSkin.playable) return refuse(NOT_PLAYABLE);
|
|
241
246
|
const handle = transport();
|
|
242
247
|
if (!handle) return refuse('No stage transport is attached yet; open the model document first.');
|
|
@@ -249,6 +254,18 @@ registerViewVerbs({
|
|
|
249
254
|
title,
|
|
250
255
|
verbs: [
|
|
251
256
|
{ id: 'state', run: () => report() },
|
|
257
|
+
{
|
|
258
|
+
// BLENDER'S EDITOR-TYPE MENU for this area: the Timeline, the Action Editor or the NLA.
|
|
259
|
+
id: 'editor',
|
|
260
|
+
title: 'Animation: Show Editor',
|
|
261
|
+
run: (args) => {
|
|
262
|
+
const editor = args?.['editor'] ?? args?.['to'];
|
|
263
|
+
if (editor !== 'timeline' && editor !== 'action' && editor !== 'nla')
|
|
264
|
+
return refuse('editor is `timeline`, `action` (the Action Editor) or `nla`.');
|
|
265
|
+
setTimelineViewState({ editor });
|
|
266
|
+
return report();
|
|
267
|
+
},
|
|
268
|
+
},
|
|
252
269
|
{
|
|
253
270
|
id: 'frame',
|
|
254
271
|
title: 'Timeline: Set Frame',
|
|
@@ -263,6 +280,7 @@ registerViewVerbs({
|
|
|
263
280
|
id: 'play',
|
|
264
281
|
title: 'Timeline: Play',
|
|
265
282
|
run: () => {
|
|
283
|
+
if (liveAnimation() !== null) throw new Error(GAME_RUNNING);
|
|
266
284
|
if (!blenderSkin.playable) throw new Error(NOT_PLAYABLE);
|
|
267
285
|
const handle = transport();
|
|
268
286
|
if (!handle)
|
|
@@ -364,7 +382,8 @@ registerViewVerbs({
|
|
|
364
382
|
const BOTTOM_AREA = 'timeline';
|
|
365
383
|
|
|
366
384
|
/**
|
|
367
|
-
* THE BOTTOM AREA IS
|
|
385
|
+
* THE BOTTOM AREA IS BLENDER'S ANIMATION EDITORS IN ANIMATION MODE (the Timeline, the Action
|
|
386
|
+
* Editor or the NLA, by the editor-type menu) AND THE GAME PANEL IN GAME MODE
|
|
368
387
|
* (`../src/play-mode.ts`, for the model document on screen). Same area: the
|
|
369
388
|
* layout's `areas` entry names this document either way. NOT THE SAME SIZE:
|
|
370
389
|
* Blender's measured Timeline strip (`model.layout.ts`) left the Game panel's
|
|
@@ -378,11 +397,19 @@ const BOTTOM_AREA = 'timeline';
|
|
|
378
397
|
*/
|
|
379
398
|
export default function BlenderBottomArea() {
|
|
380
399
|
useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
|
|
400
|
+
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
381
401
|
const documentId = servedModelDocument();
|
|
382
402
|
const game = documentId !== null && modelPlayMode(documentId) === 'game';
|
|
383
|
-
|
|
403
|
+
const { editor } = timelineViewState();
|
|
404
|
+
// The Timeline keeps Blender's strip; the Action Editor and NLA list channels, so they take the
|
|
405
|
+
// Game panel's share, as a person would drag the area up to read them.
|
|
406
|
+
const tall = game || editor !== 'timeline';
|
|
407
|
+
useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, tall ? GAME_PANEL_RATIO : null), [tall]);
|
|
384
408
|
useEffect(() => () => setWorkspaceAreaRatio(BOTTOM_AREA, null), []);
|
|
385
|
-
|
|
409
|
+
if (game) return <BlenderGamePanel />;
|
|
410
|
+
if (editor === 'action') return <BlenderActionEditor />;
|
|
411
|
+
if (editor === 'nla') return <BlenderNlaEditor />;
|
|
412
|
+
return <BlenderTimeline />;
|
|
386
413
|
}
|
|
387
414
|
|
|
388
415
|
function BlenderTimeline() {
|
|
@@ -562,7 +589,7 @@ function BlenderTimeline() {
|
|
|
562
589
|
const scrubFromPointer = useCallback(
|
|
563
590
|
(clientX: number) => {
|
|
564
591
|
const element = canvas.current;
|
|
565
|
-
if (!element) return;
|
|
592
|
+
if (!element || liveAnimation() !== null) return;
|
|
566
593
|
const box = element.getBoundingClientRect();
|
|
567
594
|
transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
|
|
568
595
|
},
|
|
@@ -878,14 +905,17 @@ function TimelineHeader() {
|
|
|
878
905
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
879
906
|
const play = blenderSkin.state();
|
|
880
907
|
const unit = TIMELINE_CHROME.unit;
|
|
908
|
+
// WHILE A GAME RUNS the transport is held: the game owns its copy, and moving the document's
|
|
909
|
+
// frame under it would put two clocks on one picture.
|
|
910
|
+
const gameRunning = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null) !== null;
|
|
881
911
|
// Static scenes have a timeline too. Disable only while its scene/transport
|
|
882
912
|
// is unavailable, using the same condition as the command door.
|
|
883
|
-
const playable = blenderSkin.playable;
|
|
913
|
+
const playable = blenderSkin.playable && !gameRunning;
|
|
884
914
|
const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
|
|
885
915
|
<button
|
|
886
916
|
key={key}
|
|
887
917
|
type="button"
|
|
888
|
-
title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
|
|
918
|
+
title={playable ? label : `${label} — ${gameRunning ? GAME_RUNNING : NOT_PLAYABLE}`}
|
|
889
919
|
aria-label={label}
|
|
890
920
|
disabled={!playable}
|
|
891
921
|
onClick={onClick}
|
|
@@ -923,9 +953,10 @@ function TimelineHeader() {
|
|
|
923
953
|
borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
|
|
924
954
|
}}
|
|
925
955
|
>
|
|
926
|
-
{/* GAME |
|
|
956
|
+
{/* GAME | ANIMATION in place of the title, as in the Game panel, so the person switches back
|
|
927
957
|
from either (`blender-game-panel.tsx`); without a Play tool or a model, the title. */}
|
|
928
958
|
<PlayModeSwitch title="Timeline" />
|
|
959
|
+
<AnimationEditorMenu />
|
|
929
960
|
<TimelineViewMenu />
|
|
930
961
|
{/* TWO SPACERS, which is what CENTRES the transport. Blender's Timeline
|
|
931
962
|
header is three clusters: the menus at the leading edge, the
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import type { BlenderUvLayout } from '@volter/blender-engine/browser/rna';
|
|
33
|
-
import { registerViewVerbs } from '@volter/
|
|
33
|
+
import { registerViewVerbs } from '@volter/sdk/views';
|
|
34
34
|
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
35
35
|
import {
|
|
36
36
|
blenderRnaVersion,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* host imports (`validate-editor-closure.mjs`), the first package to reach
|
|
21
21
|
* that destination, and a token NAME is the published contract while the
|
|
22
22
|
* handle object is the host's own sugar. The widget kit's move to
|
|
23
|
-
* `@volter/
|
|
23
|
+
* `@volter/sdk/widgets` is deferred (WORK.md §Skews as packages, item 5);
|
|
24
24
|
* until it lands, a package styling inline writes the `var()`.
|
|
25
25
|
*
|
|
26
26
|
* Nothing before a project is open (there is no version to tell the truth
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* only the version, and the host's counters are untouched.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { editorHost } from '@volter/
|
|
36
|
+
import { editorHost } from '@volter/sdk/host';
|
|
37
37
|
import { useSyncExternalStore } from 'react';
|
|
38
38
|
|
|
39
39
|
export const point = 'workspace.status';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE BLENDER VERBS of the session wire (`@volter/
|
|
2
|
+
* THE BLENDER VERBS of the session wire (`@volter/sdk/commands`, a
|
|
3
3
|
* `workspace.command` contribution): `blender-start`, `blender-stop`,
|
|
4
4
|
* `blender-execute`, `blender-scene-info`, `blender-object-info`,
|
|
5
5
|
* `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
* `@editor/shell-store-door` to hand to the runtime host, which needed it for
|
|
27
27
|
* one call — presenting the Model document — and that call is now
|
|
28
28
|
* `host.workspace.open`, an address the door routes. A handler's whole context
|
|
29
|
-
* is the command it is given plus `@volter/
|
|
29
|
+
* is the command it is given plus `@volter/sdk/host`.
|
|
30
30
|
*/
|
|
31
31
|
import type {
|
|
32
32
|
CommandContribution,
|
|
33
33
|
CommandDerivedRefresh,
|
|
34
34
|
CommandSpec,
|
|
35
|
-
} from '@volter/
|
|
35
|
+
} from '@volter/sdk/commands';
|
|
36
36
|
import { handleBlenderCommand } from '../host/blender-runtime-host';
|
|
37
37
|
|
|
38
38
|
export const point = 'workspace.command';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BLENDER'S WORLD STUDIO LIGHTS: the eight HDRIs Blender ships for Material Preview
|
|
3
3
|
* (`datafiles/studiolights/world`, copied from Blender 5.2), registered as environment images
|
|
4
|
-
* (`@volter/
|
|
4
|
+
* (`@volter/sdk/kit/environment-images`) so a view can light by one and draw it behind
|
|
5
5
|
* the scene. Material Preview opens on Forest. All CC0, by Greg Zaal (Poly Haven); the terms
|
|
6
6
|
* and each original are in `studiolights/world/license.txt`.
|
|
7
7
|
*/
|
|
8
|
-
import type { EnvironmentImageSet } from '@volter/
|
|
8
|
+
import type { EnvironmentImageSet } from '@volter/sdk/kit/environment-images';
|
|
9
9
|
import city from './studiolights/world/city.exr?url';
|
|
10
10
|
import courtyard from './studiolights/world/courtyard.exr?url';
|
|
11
11
|
import forest from './studiolights/world/forest.exr?url';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The BLENDER keymap (`@volter/
|
|
2
|
+
* The BLENDER keymap (`@volter/sdk/looks`, a `workspace.keymap`
|
|
3
3
|
* contribution). Only the chords that differ from the editor's own are
|
|
4
4
|
* declared; the host fills the rest from its default table.
|
|
5
5
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `scripts/presets/keyconfig/keymap_data/blender_default.py` at the engine's
|
|
8
8
|
* pin (v5.2.0) — the file that IS the "Blender" keyconfig.
|
|
9
9
|
*/
|
|
10
|
-
import type { KeymapContribution } from '@volter/
|
|
10
|
+
import type { KeymapContribution } from '@volter/sdk/looks';
|
|
11
11
|
|
|
12
12
|
export const point = 'workspace.keymap';
|
|
13
13
|
export const keymap: KeymapContribution = {
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* about THIS editor, so the labels are read from this editor's own contract,
|
|
23
23
|
* never transcribed from the reference:
|
|
24
24
|
*
|
|
25
|
-
* - the KEYMAP (`keymap-presets.ts`, `@volter/
|
|
25
|
+
* - the KEYMAP (`keymap-presets.ts`, `@volter/sdk/looks`) binds no
|
|
26
26
|
* mouse buttons and structurally cannot: a `KeyChord` is
|
|
27
|
-
* `{ key, code?, mod?, shift?, alt? }` (`
|
|
27
|
+
* `{ key, code?, mod?, shift?, alt? }` (`sdk/src/looks.ts:76-82`)
|
|
28
28
|
* with no button field, so no keymap — the Blender one included — can
|
|
29
29
|
* move a mouse binding. Nothing to read there.
|
|
30
30
|
* - so the source is the VIEWPORT's own mouse contract:
|