@volter/editor-blender 0.1.134 → 0.1.136
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-outliner-authoring.ts +33 -6
- package/contributions/blender-play-mixer.ts +256 -0
- package/contributions/blender-play-skin.ts +168 -0
- package/contributions/blender-runtime-skin.ts +93 -260
- package/contributions/blender-runtime.document.tsx +25 -0
- package/contributions/blender-timeline.document.tsx +4 -28
- package/contributions/blender.command.ts +0 -1
- package/host/blender-runtime-host.ts +5 -22
- package/package.json +6 -6
|
@@ -1280,7 +1280,11 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1280
1280
|
// is the other end of the same seam `hierarchy.node` already normalizes.
|
|
1281
1281
|
// An id that resolves to no row is DROPPED rather than stored: a row id
|
|
1282
1282
|
// is this provider's whole currency and a foreign one is not a selection.
|
|
1283
|
-
|
|
1283
|
+
// The WRITE below still names a presented object that has no row yet.
|
|
1284
|
+
// One row lookup per id: `rowIdForObject` scans the object rows, and a marquee over a
|
|
1285
|
+
// large scene passes every object it covers.
|
|
1286
|
+
const rowIds = ids.map(rowIdFor);
|
|
1287
|
+
selected = rowIds.filter((id): id is string => id !== null);
|
|
1284
1288
|
publishPresentation();
|
|
1285
1289
|
// AND IT WRITES BLENDER'S (see {@link writeBlenderSelection}). The
|
|
1286
1290
|
// subject is the OWNING OBJECT of each selected row, which is Blender's
|
|
@@ -1294,14 +1298,37 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1294
1298
|
// its object selection, and a person who clicked one and saw the
|
|
1295
1299
|
// Properties rail keep the last object deserves the sentence.
|
|
1296
1300
|
const table = rows();
|
|
1301
|
+
const view = presented();
|
|
1297
1302
|
const names: string[] = [];
|
|
1298
1303
|
const ownerless: string[] = [];
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const
|
|
1302
|
-
if (
|
|
1304
|
+
const unnamed: string[] = [];
|
|
1305
|
+
ids.forEach((id, index) => {
|
|
1306
|
+
const rowId = rowIds[index]!;
|
|
1307
|
+
if (rowId !== null) {
|
|
1308
|
+
const row = table.get(rowId);
|
|
1309
|
+
const name = row?.object;
|
|
1310
|
+
if (name === undefined) ownerless.push(row?.name ?? rowId);
|
|
1311
|
+
else if (!names.includes(name)) names.push(name);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
// A PRESENTED OBJECT PAST THE TREE'S PAGE IS STILL BLENDER'S. `rna_outliner`
|
|
1315
|
+
// answers the first `_OUTLINER_PAGE` rows of every child list, so in a larger
|
|
1316
|
+
// scene the viewport picks objects that have no row yet (Canyon Comet: 1,146 rows
|
|
1317
|
+
// under its one collection, 64 answered, all six karts among the rest). The view
|
|
1318
|
+
// names the object by identity; the tree read for the frame this write presents
|
|
1319
|
+
// keeps the selection's rows (`page_tree`), and `syncFromEngine` takes the row up.
|
|
1320
|
+
const object = threeObject(defaultAdapter.hierarchy, id);
|
|
1321
|
+
const name = object === null ? null : (view?.blenderObjectName(object) ?? null);
|
|
1322
|
+
if (name === null) unnamed.push(id);
|
|
1303
1323
|
else if (!names.includes(name)) names.push(name);
|
|
1304
|
-
}
|
|
1324
|
+
});
|
|
1325
|
+
// Said, not swallowed: a picked object the view can't name is a selection Blender never gets.
|
|
1326
|
+
if (unnamed.length > 0)
|
|
1327
|
+
editorHost().console.warn(
|
|
1328
|
+
`No Blender object answers to ${unnamed.join(', ')}, so Blender's selection leaves ` +
|
|
1329
|
+
`${unnamed.length === 1 ? 'it' : 'them'} out.`,
|
|
1330
|
+
'blender-outliner',
|
|
1331
|
+
);
|
|
1305
1332
|
if (ownerless.length > 0)
|
|
1306
1333
|
editorHost().console.warn(
|
|
1307
1334
|
`${ownerless.join(', ')} ${ownerless.length === 1 ? 'is' : 'are'} not an object, so ` +
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CHARACTER'S ANIMATION IN A GAME: layered, weighted actions on one armature's bones, and a
|
|
3
|
+
* look-at for targets only the game knows.
|
|
4
|
+
*
|
|
5
|
+
* LAYERS: the base layer plays on every bone; a named layer plays on the bones from one bone down
|
|
6
|
+
* (`from: 'spine_01'`: the upper body), and takes those bones from the base. So legs can run while
|
|
7
|
+
* the upper body aims. A layer holds any number of actions at weights (`blend`); setting one action
|
|
8
|
+
* is a blend of that one, crossfaded (`set`).
|
|
9
|
+
*
|
|
10
|
+
* LOOK-AT: turns one bone so its axis points at a point, after the actions, by a weight and within
|
|
11
|
+
* an angle. The axis is the bone's FORWARD unless given: the direction in the bone that pointed the
|
|
12
|
+
* way the character faces (Blender's convention, the armature's -Y) in the pose the file was in. A
|
|
13
|
+
* bone's own Y runs along it, out of the top of a head, so it is not what "look" means. It is the game's, not Blender's: Blender's own IK and constraints are what an action was
|
|
14
|
+
* authored with; this aims at what exists only while the game runs (an aim target, the player).
|
|
15
|
+
*
|
|
16
|
+
* Everything advances on the game's clock (`update(dt)`).
|
|
17
|
+
*/
|
|
18
|
+
import * as THREE from 'three';
|
|
19
|
+
|
|
20
|
+
export interface LayerOptions {
|
|
21
|
+
/** The layer: `base` (default) or a name of the game's choosing. */
|
|
22
|
+
readonly layer?: string;
|
|
23
|
+
/** For a layer other than `base`: the bone it plays from, with every bone below it. */
|
|
24
|
+
readonly from?: string;
|
|
25
|
+
/** Seconds for weights to reach what was set (default 0.2 for `set`, 0 for `blend`). */
|
|
26
|
+
readonly fade?: number;
|
|
27
|
+
/** Repeat (default true); `false` plays once and holds the last frame. */
|
|
28
|
+
readonly loop?: boolean;
|
|
29
|
+
/** Playback rate, 1 is the action's own. */
|
|
30
|
+
readonly speed?: number;
|
|
31
|
+
/** Start again from the first frame when this action already plays on the layer. */
|
|
32
|
+
readonly restart?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LookAtOptions {
|
|
36
|
+
/** How far toward the target, 0 to 1 (default 1). */
|
|
37
|
+
readonly weight?: number;
|
|
38
|
+
/** The bone's local axis that should point at the target: `forward` (default, see the header)
|
|
39
|
+
* or one of the bone's own axes (`y` is a Blender bone's length). */
|
|
40
|
+
readonly axis?: 'forward' | 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
|
|
41
|
+
/** The most it turns away from the animated pose, in degrees (default 75). */
|
|
42
|
+
readonly limit?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Entry {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
action: THREE.AnimationAction | null;
|
|
48
|
+
weight: number;
|
|
49
|
+
target: number;
|
|
50
|
+
rate: number;
|
|
51
|
+
loop: boolean;
|
|
52
|
+
speed: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Layer {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
from: string | null;
|
|
58
|
+
/** The bones it plays on, by uuid; null for the base (everything other layers do not hold). */
|
|
59
|
+
bones: Set<string> | null;
|
|
60
|
+
readonly entries: Map<string, Entry>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const AXES: Record<Exclude<NonNullable<LookAtOptions['axis']>, 'forward'>, THREE.Vector3> = {
|
|
64
|
+
x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1),
|
|
65
|
+
'-x': new THREE.Vector3(-1, 0, 0), '-y': new THREE.Vector3(0, -1, 0), '-z': new THREE.Vector3(0, 0, -1),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export class LayeredMixer {
|
|
69
|
+
readonly #mixer: THREE.AnimationMixer;
|
|
70
|
+
readonly #layers = new Map<string, Layer>();
|
|
71
|
+
readonly #masked = new Map<string, THREE.AnimationClip>();
|
|
72
|
+
readonly #looks = new Map<string, { bone: THREE.Bone; target: THREE.Vector3 | THREE.Object3D; options: LookAtOptions }>();
|
|
73
|
+
/** Each bone's forward in its own space, from the pose the file was in (before any action). */
|
|
74
|
+
readonly #forward = new Map<string, THREE.Vector3>();
|
|
75
|
+
|
|
76
|
+
/** `clipFor` answers an action's full clip, or null while it is not available yet (a bake in
|
|
77
|
+
* flight); the mixer asks again on later updates. */
|
|
78
|
+
constructor(
|
|
79
|
+
readonly root: THREE.Object3D,
|
|
80
|
+
readonly bones: ReadonlyMap<string, THREE.Bone>,
|
|
81
|
+
private readonly clipFor: (action: string) => THREE.AnimationClip | null,
|
|
82
|
+
) {
|
|
83
|
+
this.#mixer = new THREE.AnimationMixer(root);
|
|
84
|
+
// The character faces the armature's -Y, as in Blender; each bone's forward is that direction
|
|
85
|
+
// in the bone's frame at the pose the file was in.
|
|
86
|
+
root.updateWorldMatrix(true, true);
|
|
87
|
+
const rootWorld = root.getWorldQuaternion(new THREE.Quaternion());
|
|
88
|
+
const facing = new THREE.Vector3(0, -1, 0).applyQuaternion(rootWorld);
|
|
89
|
+
for (const [name, bone] of bones) {
|
|
90
|
+
const boneWorld = bone.getWorldQuaternion(new THREE.Quaternion());
|
|
91
|
+
this.#forward.set(name, facing.clone().applyQuaternion(boneWorld.invert()).normalize());
|
|
92
|
+
}
|
|
93
|
+
this.#layers.set('base', { name: 'base', from: null, bones: null, entries: new Map() });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Play one action on a layer, crossfading the layer's others out. */
|
|
97
|
+
set(action: string, options: LayerOptions = {}): string | null {
|
|
98
|
+
const layer = this.#layer(options);
|
|
99
|
+
if (typeof layer === 'string') return layer;
|
|
100
|
+
const held = layer.entries.get(action);
|
|
101
|
+
if (held && options.restart) held.action?.reset();
|
|
102
|
+
this.#weigh(layer, { [action]: 1 }, options.fade ?? 0.2, options);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Set a layer's actions at weights; an action left out fades to nothing. */
|
|
107
|
+
blend(weights: Readonly<Record<string, number>>, options: LayerOptions = {}): string | null {
|
|
108
|
+
const layer = this.#layer(options);
|
|
109
|
+
if (typeof layer === 'string') return layer;
|
|
110
|
+
this.#weigh(layer, weights, options.fade ?? 0, options);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fade out what a layer plays (the base: everything the character plays). */
|
|
115
|
+
stop(options: LayerOptions = {}): void {
|
|
116
|
+
const layer = this.#layers.get(options.layer ?? 'base');
|
|
117
|
+
if (layer) this.#weigh(layer, {}, options.fade ?? 0.2, options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The action that weighs most on a layer, as set (what a script asked for), or null. */
|
|
121
|
+
playing(layer = 'base'): string | null {
|
|
122
|
+
let best: Entry | null = null;
|
|
123
|
+
for (const entry of this.#layers.get(layer)?.entries.values() ?? []) if (entry.target > 0 && (!best || entry.target > best.target)) best = entry;
|
|
124
|
+
return best?.name ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Turn `bone` toward `target` (world point or object) every update until set to null. */
|
|
128
|
+
lookAt(bone: string, target: THREE.Vector3 | THREE.Object3D | null, options: LookAtOptions = {}): string | null {
|
|
129
|
+
if (target === null) { this.#looks.delete(bone); return null; }
|
|
130
|
+
const found = this.bones.get(bone);
|
|
131
|
+
if (!found) return `no bone "${bone}"`;
|
|
132
|
+
this.#looks.set(bone, { bone: found, target, options: { ...options, axis: options.axis ?? 'forward' } });
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
update(dt: number): void {
|
|
137
|
+
for (const layer of this.#layers.values()) {
|
|
138
|
+
for (const [name, entry] of [...layer.entries]) {
|
|
139
|
+
if (!entry.action) entry.action = this.#action(layer, name, entry);
|
|
140
|
+
entry.weight = entry.rate <= 0 ? entry.target
|
|
141
|
+
: entry.weight < entry.target ? Math.min(entry.target, entry.weight + dt * entry.rate)
|
|
142
|
+
: Math.max(entry.target, entry.weight - dt * entry.rate);
|
|
143
|
+
if (entry.action) entry.action.setEffectiveWeight(entry.weight);
|
|
144
|
+
if (entry.weight === 0 && entry.target === 0) {
|
|
145
|
+
entry.action?.stop();
|
|
146
|
+
layer.entries.delete(name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.#mixer.update(dt);
|
|
151
|
+
for (const look of this.#looks.values()) this.#turn(look.bone, look.target, look.options);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
dispose(): void {
|
|
155
|
+
this.#mixer.stopAllAction();
|
|
156
|
+
this.#looks.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- internals
|
|
160
|
+
|
|
161
|
+
#layer(options: LayerOptions): Layer | string {
|
|
162
|
+
const name = options.layer ?? 'base';
|
|
163
|
+
if (name === 'base') return this.#layers.get('base')!;
|
|
164
|
+
let layer = this.#layers.get(name);
|
|
165
|
+
if (!layer || (options.from !== undefined && options.from !== layer.from)) {
|
|
166
|
+
if (!options.from) return `layer "${name}" needs the bone it plays from (\`from\`)`;
|
|
167
|
+
const root = this.bones.get(options.from);
|
|
168
|
+
if (!root) return `no bone "${options.from}" for layer "${name}"`;
|
|
169
|
+
const bones = new Set<string>();
|
|
170
|
+
root.traverse((child) => { if ((child as THREE.Bone).isBone) bones.add(child.uuid); });
|
|
171
|
+
if (layer) for (const entry of layer.entries.values()) entry.action?.stop();
|
|
172
|
+
layer = { name, from: options.from, bones, entries: layer?.entries ?? new Map() };
|
|
173
|
+
for (const entry of layer.entries.values()) entry.action = null;
|
|
174
|
+
this.#layers.set(name, layer);
|
|
175
|
+
// The base gives up these bones: its actions are rebuilt over what it still holds.
|
|
176
|
+
for (const entry of this.#layers.get('base')!.entries.values()) { entry.action?.stop(); entry.action = null; }
|
|
177
|
+
}
|
|
178
|
+
return layer;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#weigh(layer: Layer, weights: Readonly<Record<string, number>>, fade: number, options: LayerOptions): void {
|
|
182
|
+
const rate = fade > 0 ? 1 / fade : 0;
|
|
183
|
+
for (const entry of layer.entries.values()) { entry.target = 0; entry.rate = rate; }
|
|
184
|
+
for (const [name, raw] of Object.entries(weights)) {
|
|
185
|
+
const target = Math.max(0, Math.min(1, raw));
|
|
186
|
+
const entry = layer.entries.get(name) ?? { name, action: null, weight: 0, target: 0, rate, loop: true, speed: 1 };
|
|
187
|
+
entry.target = target;
|
|
188
|
+
entry.rate = rate;
|
|
189
|
+
entry.loop = options.loop ?? entry.loop;
|
|
190
|
+
entry.speed = options.speed ?? entry.speed;
|
|
191
|
+
if (entry.action) this.#configure(entry.action, entry);
|
|
192
|
+
layer.entries.set(name, entry);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The action of `name` on `layer`: its clip limited to the layer's bones. */
|
|
197
|
+
#action(layer: Layer, name: string, entry: Entry): THREE.AnimationAction | null {
|
|
198
|
+
const full = this.clipFor(name);
|
|
199
|
+
if (!full) return null;
|
|
200
|
+
const allowed = layer.bones ?? this.#baseBones();
|
|
201
|
+
const key = `${name}\u0000${layer.name}\u0000${allowed.size}`;
|
|
202
|
+
let clip = this.#masked.get(key);
|
|
203
|
+
if (!clip) {
|
|
204
|
+
const tracks = full.tracks.filter((track) => allowed.has(track.name.slice(0, track.name.indexOf('.'))));
|
|
205
|
+
clip = new THREE.AnimationClip(`${name}@${layer.name}`, full.duration, tracks);
|
|
206
|
+
this.#masked.set(key, clip);
|
|
207
|
+
}
|
|
208
|
+
const action = this.#mixer.clipAction(clip);
|
|
209
|
+
this.#configure(action, entry);
|
|
210
|
+
action.setEffectiveWeight(entry.weight);
|
|
211
|
+
action.play();
|
|
212
|
+
return action;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#configure(action: THREE.AnimationAction, entry: Entry): void {
|
|
216
|
+
action.setLoop(entry.loop ? THREE.LoopRepeat : THREE.LoopOnce, Number.POSITIVE_INFINITY);
|
|
217
|
+
action.clampWhenFinished = !entry.loop;
|
|
218
|
+
action.setEffectiveTimeScale(entry.speed);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Every bone no named layer holds. */
|
|
222
|
+
#baseBones(): Set<string> {
|
|
223
|
+
const taken = new Set<string>();
|
|
224
|
+
for (const layer of this.#layers.values()) for (const uuid of layer.bones ?? []) taken.add(uuid);
|
|
225
|
+
const bones = new Set<string>();
|
|
226
|
+
for (const bone of this.bones.values()) if (!taken.has(bone.uuid)) bones.add(bone.uuid);
|
|
227
|
+
return bones;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#turn(bone: THREE.Bone, target: THREE.Vector3 | THREE.Object3D, options: LookAtOptions): void {
|
|
231
|
+
const weight = Math.max(0, Math.min(1, options.weight ?? 1));
|
|
232
|
+
if (weight === 0) return;
|
|
233
|
+
bone.updateWorldMatrix(true, false);
|
|
234
|
+
// A script's Vector3 may come from its own copy of three, so it is recognised by its flag.
|
|
235
|
+
const at = (target as THREE.Vector3).isVector3 ? new THREE.Vector3().copy(target as THREE.Vector3) : (target as THREE.Object3D).getWorldPosition(new THREE.Vector3());
|
|
236
|
+
const position = new THREE.Vector3();
|
|
237
|
+
const world = new THREE.Quaternion();
|
|
238
|
+
bone.matrixWorld.decompose(position, world, new THREE.Vector3());
|
|
239
|
+
const desired = at.sub(position);
|
|
240
|
+
if (desired.lengthSq() < 1e-10) return;
|
|
241
|
+
desired.normalize();
|
|
242
|
+
const axis = options.axis ?? 'forward';
|
|
243
|
+
const local = axis === 'forward' ? this.#forward.get(bone.name) ?? AXES.y : AXES[axis];
|
|
244
|
+
const current = local.clone().applyQuaternion(world).normalize();
|
|
245
|
+
const turn = new THREE.Quaternion().setFromUnitVectors(current, desired);
|
|
246
|
+
const limit = THREE.MathUtils.degToRad(options.limit ?? 75);
|
|
247
|
+
const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
|
|
248
|
+
const amount = angle > limit ? (limit / angle) * weight : weight;
|
|
249
|
+
const applied = new THREE.Quaternion().slerp(turn, amount);
|
|
250
|
+
const next = applied.multiply(world);
|
|
251
|
+
const parent = new THREE.Quaternion();
|
|
252
|
+
bone.parent?.getWorldQuaternion(parent);
|
|
253
|
+
bone.quaternion.copy(parent.invert().multiply(next));
|
|
254
|
+
bone.updateMatrixWorld(true);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANIMATION IN A GAME: actions played on the skeletons of the game's copy of the model.
|
|
3
|
+
*
|
|
4
|
+
* The copy binds its own skins (`BlenderRuntimeView.detach` keeps each skinned mesh's weights and
|
|
5
|
+
* the frame's armatures, and its view's `skeletons` builds and binds them exactly as the editing
|
|
6
|
+
* view does), so nothing here binds or reads a rig. What a game plays is Blender's: each armature
|
|
7
|
+
* starts on the action the file assigns it (`animation_data.action`), and a script sets another by
|
|
8
|
+
* name (`play.setAction`), as Blender's own assignment does. Any action in the file may be set; one
|
|
9
|
+
* that animates none of an armature's bones refuses, with the reason, once.
|
|
10
|
+
*
|
|
11
|
+
* A CLIP IS BAKED ONCE PER ACTION, through the clip door, the first time it is wanted. The copy's
|
|
12
|
+
* frame is the frame it was detached from, so its revisions hold for the run. Until a bake lands
|
|
13
|
+
* the armature keeps what it showed before (at first, the pose Blender exported).
|
|
14
|
+
*
|
|
15
|
+
* LAYERS, BLENDS AND LOOK-AT: each armature has a layered mixer (`blender-play-mixer.ts`), so a
|
|
16
|
+
* game plays actions on parts of a body, at weights, and turns a bone toward what only it knows.
|
|
17
|
+
*
|
|
18
|
+
* THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by each update's `dt`
|
|
19
|
+
* (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
|
|
20
|
+
* animation exactly as they do for the script.
|
|
21
|
+
*/
|
|
22
|
+
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
23
|
+
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
24
|
+
import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
|
|
25
|
+
import * as THREE from 'three';
|
|
26
|
+
import { clipTracks } from './blender-runtime-skin';
|
|
27
|
+
import { LayeredMixer, type LayerOptions, type LookAtOptions } from './blender-play-mixer';
|
|
28
|
+
|
|
29
|
+
export type PlayAnimateOptions = LayerOptions;
|
|
30
|
+
|
|
31
|
+
type Answer = { ok: true; armature: string } | { ok: false; why: string };
|
|
32
|
+
|
|
33
|
+
/** The game's door to its characters' actions (`ModelPlayContext.setAction`, `blend`, `lookAt`). */
|
|
34
|
+
export interface PlayAnimation {
|
|
35
|
+
/** The actions in the file, which an object's armature may be set to play. */
|
|
36
|
+
clips(object: THREE.Object3D): readonly string[];
|
|
37
|
+
/** Play one action on a layer of an object's armature; the reason when it cannot. */
|
|
38
|
+
play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): Answer;
|
|
39
|
+
/** Set a layer's actions at weights. */
|
|
40
|
+
blend(object: THREE.Object3D, weights: Readonly<Record<string, number>>, options?: PlayAnimateOptions): Answer;
|
|
41
|
+
/** Fade out what a layer plays. */
|
|
42
|
+
stop(object: THREE.Object3D, fade?: number, layer?: string): void;
|
|
43
|
+
/** The action that weighs most on a layer, as last set, or null. */
|
|
44
|
+
playing(object: THREE.Object3D, layer?: string): string | null;
|
|
45
|
+
/** Turn one of an object's bones toward a point (or object) every update, until `null`. */
|
|
46
|
+
lookAt(object: THREE.Object3D, bone: string, target: THREE.Vector3 | THREE.Object3D | null, options?: LookAtOptions): Answer;
|
|
47
|
+
update(dt: number): void;
|
|
48
|
+
readonly warnings: readonly string[];
|
|
49
|
+
dispose(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Armature {
|
|
53
|
+
readonly rig: ArmatureRig;
|
|
54
|
+
readonly mixer: LayeredMixer;
|
|
55
|
+
readonly clips: Map<string, THREE.AnimationClip>;
|
|
56
|
+
readonly baking: Set<string>;
|
|
57
|
+
/** Actions with nothing this armature can play: asked once, never re-baked every update. */
|
|
58
|
+
readonly failed: Map<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The copy's animation. `bake` reads one action of one armature through the clip door. */
|
|
62
|
+
export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>): PlayAnimation {
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
const facts = view.animationFacts();
|
|
65
|
+
const actions = Object.keys(facts.actions);
|
|
66
|
+
const armatures = new Map<string, Armature>();
|
|
67
|
+
let disposed = false;
|
|
68
|
+
|
|
69
|
+
const refuse = (armature: Armature, clip: string, why: string): void => {
|
|
70
|
+
armature.failed.set(clip, why);
|
|
71
|
+
warnings.push(`${armature.rig.armature} / ${clip}: ${why}`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The clip the mixer asks for: baked, or baked now (once) and given on a later update. */
|
|
75
|
+
const clipFor = (armature: Armature, clip: string): THREE.AnimationClip | null => {
|
|
76
|
+
const held = armature.clips.get(clip);
|
|
77
|
+
if (held || disposed || armature.failed.has(clip) || armature.baking.has(clip)) return held ?? null;
|
|
78
|
+
armature.baking.add(clip);
|
|
79
|
+
void bake(armature.rig.armature, clip).then((baked) => {
|
|
80
|
+
armature.baking.delete(clip);
|
|
81
|
+
const tracks = baked && baked.clipStart !== undefined && baked.clipEnd !== undefined
|
|
82
|
+
? clipTracks(baked, armature.rig.bones, warnings) : [];
|
|
83
|
+
if (!baked || !tracks.length) {
|
|
84
|
+
refuse(armature, clip, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const duration = baked.duration ?? (baked.clipEnd! - baked.clipStart!) / baked.fps;
|
|
88
|
+
armature.clips.set(clip, new THREE.AnimationClip(clip, duration, tracks));
|
|
89
|
+
}, (error: unknown) => {
|
|
90
|
+
armature.baking.delete(clip);
|
|
91
|
+
refuse(armature, clip, `it could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
});
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const rig of view.skeletons.rigs()) {
|
|
97
|
+
const armature: Armature = {
|
|
98
|
+
rig, clips: new Map(), baking: new Set(), failed: new Map(),
|
|
99
|
+
mixer: new LayeredMixer(rig.object, rig.bones, (clip) => clipFor(armature, clip)),
|
|
100
|
+
};
|
|
101
|
+
armatures.set(rig.armature, armature);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
|
|
105
|
+
for (const armature of armatures.values()) {
|
|
106
|
+
const assigned = facts.armatures[armature.rig.armature]?.action;
|
|
107
|
+
if (assigned) armature.mixer.set(assigned, { fade: 0 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
|
|
111
|
+
* armature among its ancestors and descendants (a character's root holds its armature). */
|
|
112
|
+
const own = (candidate: THREE.Object3D): Armature | null => {
|
|
113
|
+
for (const armature of armatures.values()) if (armature.rig.object === candidate) return armature;
|
|
114
|
+
const skin = (candidate as THREE.Mesh).geometry?.userData['blenderSkin'] as { armature: string } | undefined;
|
|
115
|
+
return skin ? armatures.get(skin.armature) ?? null : null;
|
|
116
|
+
};
|
|
117
|
+
const armatureOf = (object: THREE.Object3D): Armature | null => {
|
|
118
|
+
for (let at: THREE.Object3D | null = object; at; at = at.parent) {
|
|
119
|
+
const found = own(at);
|
|
120
|
+
if (found) return found;
|
|
121
|
+
}
|
|
122
|
+
let found: Armature | null = null;
|
|
123
|
+
object.traverse((child) => { if (!found) found = own(child); });
|
|
124
|
+
return found;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The armature, after checking every action named is one it can play. */
|
|
128
|
+
const resolve = (object: THREE.Object3D, clips: readonly string[]): Armature | { why: string } => {
|
|
129
|
+
const armature = armatureOf(object);
|
|
130
|
+
if (!armature) return { why: `${object.name || 'this object'} has no armature` };
|
|
131
|
+
for (const clip of clips) {
|
|
132
|
+
if (!(clip in facts.actions)) return { why: `the file has no action "${clip}"` };
|
|
133
|
+
const failed = armature.failed.get(clip);
|
|
134
|
+
if (failed) return { why: failed };
|
|
135
|
+
}
|
|
136
|
+
return armature;
|
|
137
|
+
};
|
|
138
|
+
const answer = (armature: Armature, refused: string | null): Answer =>
|
|
139
|
+
refused ? { ok: false, why: refused } : { ok: true, armature: armature.rig.armature };
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
warnings,
|
|
143
|
+
clips: (object) => (armatureOf(object) ? actions : []),
|
|
144
|
+
play(object, clip, options = {}) {
|
|
145
|
+
const armature = resolve(object, [clip]);
|
|
146
|
+
return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.set(clip, options));
|
|
147
|
+
},
|
|
148
|
+
blend(object, weights, options = {}) {
|
|
149
|
+
const armature = resolve(object, Object.keys(weights));
|
|
150
|
+
return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.blend(weights, options));
|
|
151
|
+
},
|
|
152
|
+
stop(object, fade = 0.2, layer) {
|
|
153
|
+
armatureOf(object)?.mixer.stop({ fade, ...(layer ? { layer } : {}) });
|
|
154
|
+
},
|
|
155
|
+
// What was last set, so a script setting the same action every update while it bakes asks once.
|
|
156
|
+
playing: (object, layer) => armatureOf(object)?.mixer.playing(layer) ?? null,
|
|
157
|
+
lookAt(object, bone, target, options = {}) {
|
|
158
|
+
const armature = armatureOf(object);
|
|
159
|
+
if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature` };
|
|
160
|
+
return answer(armature, armature.mixer.lookAt(bone, target, options));
|
|
161
|
+
},
|
|
162
|
+
update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
|
|
163
|
+
dispose() {
|
|
164
|
+
disposed = true;
|
|
165
|
+
for (const armature of armatures.values()) armature.mixer.dispose();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* it and re-exports the mesh columns. That is the wrong architecture: it puts
|
|
9
9
|
* a WASM depsgraph evaluation and a full geometry round trip between the
|
|
10
10
|
* person's pointer and the picture, and it moves `scene.frame_current` sixty
|
|
11
|
-
* times a second under every bpy reader in the session. So
|
|
12
|
-
*
|
|
13
|
-
* (`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* times a second under every bpy reader in the session. So the skin travels in the
|
|
12
|
+
* frame itself: the export door ships each Armature-deformed mesh's weights per
|
|
13
|
+
* exported vertex (`bpy_web_export.cc` `write_skin`), the frame names every bone's
|
|
14
|
+
* pose, and the presenter builds the `THREE.SkinnedMesh`es and their skeletons
|
|
15
|
+
* (`blender-runtime-skeleton.ts`). This director only plays the assigned action on
|
|
16
|
+
* those bones with an `AnimationMixer`: every scrub and every played frame costs
|
|
17
|
+
* ZERO calls into Blender, and the clip is baked again exactly when the frame says
|
|
18
|
+
* its action's revision moved.
|
|
16
19
|
*
|
|
17
20
|
* ## The bind pose is the EXPORT pose, and that is the whole trick
|
|
18
21
|
*
|
|
@@ -36,11 +39,8 @@
|
|
|
36
39
|
* from the F-Curves alone.
|
|
37
40
|
*/
|
|
38
41
|
|
|
39
|
-
import type {
|
|
40
|
-
|
|
41
|
-
BlenderRig,
|
|
42
|
-
BlenderRigBinding,
|
|
43
|
-
} from '@volter/blender-engine/browser/rna';
|
|
42
|
+
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
43
|
+
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
44
44
|
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
45
45
|
import * as THREE from 'three';
|
|
46
46
|
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
@@ -59,48 +59,31 @@ function float32Of(base64: string): Float32Array {
|
|
|
59
59
|
const bytes = bytesOf(base64);
|
|
60
60
|
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
interface BoundRig {
|
|
88
|
-
/** The MESH object's Blender name. */
|
|
89
|
-
readonly object: string;
|
|
90
|
-
readonly armature: string;
|
|
91
|
-
readonly mesh: THREE.SkinnedMesh;
|
|
92
|
-
readonly boneRoot: THREE.Group;
|
|
93
|
-
readonly bones: readonly THREE.Bone[];
|
|
94
|
-
readonly skeleton: THREE.Skeleton;
|
|
95
|
-
/** `scene.frame_current` the binding was read at. */
|
|
96
|
-
readonly frame: number;
|
|
97
|
-
/** The identity a re-read is compared against: rebuilding a skeleton on
|
|
98
|
-
* every present would throw away the mixer's time sixty times a minute. */
|
|
99
|
-
readonly signature: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function rigSignature(rig: BlenderRigBinding, frame: number): string {
|
|
103
|
-
return [rig.object, rig.mesh, rig.armature, rig.vertexCount, rig.bones.length, frame].join('|');
|
|
62
|
+
/**
|
|
63
|
+
* A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
|
|
64
|
+
* name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
|
|
65
|
+
* is an ordinary bone name. A track for a bone this binding lacks is named in `warnings`.
|
|
66
|
+
*/
|
|
67
|
+
export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>, warnings: string[]): THREE.KeyframeTrack[] {
|
|
68
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
69
|
+
const missing = new Set<string>();
|
|
70
|
+
for (const track of clip.tracks) {
|
|
71
|
+
const bone = bones.get(track.bone);
|
|
72
|
+
if (!bone) {
|
|
73
|
+
missing.add(track.bone);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const times = float32Of(track.timeBase64);
|
|
77
|
+
const values = float32Of(track.valueBase64);
|
|
78
|
+
const path = `${bone.uuid}.${track.property}`;
|
|
79
|
+
tracks.push(
|
|
80
|
+
track.property === 'quaternion'
|
|
81
|
+
? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
|
|
82
|
+
: new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (missing.size) warnings.push(`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`);
|
|
86
|
+
return tracks;
|
|
104
87
|
}
|
|
105
88
|
|
|
106
89
|
/**
|
|
@@ -111,7 +94,12 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
|
|
|
111
94
|
* copy of the playback state anywhere.
|
|
112
95
|
*/
|
|
113
96
|
export class BlenderSkinDirector {
|
|
114
|
-
|
|
97
|
+
/** The presented view whose skeletons the clip plays on (`sync`). */
|
|
98
|
+
#view: BlenderRuntimeView | null = null;
|
|
99
|
+
/** The armature the clip plays on, and what the clip was read for: armature, action, the
|
|
100
|
+
* action's revision, the scene's clock and frame. Read again only when this changes. */
|
|
101
|
+
#armature: string | null = null;
|
|
102
|
+
#clipKey: string | null = null;
|
|
115
103
|
#mixer: THREE.AnimationMixer | null = null;
|
|
116
104
|
#action: THREE.AnimationAction | null = null;
|
|
117
105
|
#clip: BlenderActionClip | null = null;
|
|
@@ -194,13 +182,14 @@ export class BlenderSkinDirector {
|
|
|
194
182
|
action: clip?.action ?? null,
|
|
195
183
|
object: clip?.object ?? null,
|
|
196
184
|
armature: clip?.armature ?? null,
|
|
197
|
-
bones:
|
|
185
|
+
bones: this.#armature ? this.#view?.skeletons.rig(this.#armature)?.bones.size ?? 0 : 0,
|
|
198
186
|
keyframes: clip?.keyframes ?? [],
|
|
199
187
|
summary: clip?.summary ?? [],
|
|
200
188
|
clipStart: clip?.clipStart ?? null,
|
|
201
189
|
clipEnd: clip?.clipEnd ?? null,
|
|
202
190
|
tracks: clip?.tracks.length ?? 0,
|
|
203
|
-
|
|
191
|
+
// The armature the clip plays on, when the presenter holds its skeleton.
|
|
192
|
+
bound: this.#armature && this.#view?.skeletons.rig(this.#armature) ? [this.#armature] : [],
|
|
204
193
|
warnings: this.#warnings,
|
|
205
194
|
engineCalls: this.#engineCalls,
|
|
206
195
|
blenderFrame: clip?.frameCurrent ?? null,
|
|
@@ -239,229 +228,77 @@ export class BlenderSkinDirector {
|
|
|
239
228
|
return this.#clip !== null && this.#transport !== null;
|
|
240
229
|
}
|
|
241
230
|
|
|
242
|
-
// ------------------------------------------------------------ the
|
|
231
|
+
// ------------------------------------------------------------ the frame
|
|
243
232
|
|
|
244
233
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* without the picture jumping back to the bind pose each time something
|
|
251
|
-
* unrelated moved.
|
|
234
|
+
* FOLLOW THE PRESENTED FRAME. The armature played is the active object's (itself, or the one its
|
|
235
|
+
* skin is bound to), else the one armature that has an action. Its assigned action is read
|
|
236
|
+
* through the clip door when the frame says something about it moved: another armature or
|
|
237
|
+
* action, the action's revision, the scene's clock, or Blender's own frame. Otherwise this costs
|
|
238
|
+
* nothing, which is what lets the model call it after every present.
|
|
252
239
|
*/
|
|
253
|
-
async
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
240
|
+
async sync(view: BlenderRuntimeView, read: { clip(armature: string): Promise<BlenderActionClip | null> }): Promise<void> {
|
|
241
|
+
this.#view = view;
|
|
242
|
+
const facts = view.animationFacts();
|
|
243
|
+
const names = Object.keys(facts.armatures);
|
|
244
|
+
const active = facts.active;
|
|
245
|
+
const armature = active && facts.armatures[active] ? active
|
|
246
|
+
: (active ? facts.skinArmature(active) : null)
|
|
247
|
+
?? (names.filter((name) => facts.armatures[name]!.action).length === 1
|
|
248
|
+
? names.find((name) => facts.armatures[name]!.action)! : null);
|
|
249
|
+
const action = armature ? facts.armatures[armature]?.action ?? null : null;
|
|
250
|
+
const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame,
|
|
251
|
+
armature ? view.skeletons.rig(armature) !== null : false]);
|
|
252
|
+
if (key === this.#clipKey && (!armature || this.#rig() === view.skeletons.rig(armature))) return;
|
|
253
|
+
this.#clipKey = key;
|
|
254
|
+
if (this.#armature && this.#armature !== armature) view.skeletons.restorePose(this.#armature);
|
|
255
|
+
this.#armature = armature;
|
|
256
|
+
this.#boundRig = armature ? view.skeletons.rig(armature) : null;
|
|
263
257
|
const warnings: string[] = [];
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (rig.reason) warnings.push(rig.reason);
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
wanted.add(rig.object);
|
|
272
|
-
const signature = rigSignature(rig, answer.frame);
|
|
273
|
-
if (this.#rigs.get(rig.object)?.signature === signature) continue;
|
|
274
|
-
const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
|
|
275
|
-
if (bound) {
|
|
276
|
-
this.#rigs.get(rig.object)?.skeleton.dispose();
|
|
277
|
-
this.#rigs.set(rig.object, bound);
|
|
278
|
-
changed = true;
|
|
279
|
-
}
|
|
258
|
+
let clip: BlenderActionClip | null = null;
|
|
259
|
+
if (armature) {
|
|
260
|
+
this.#engineCalls++;
|
|
261
|
+
clip = await read.clip(armature);
|
|
280
262
|
}
|
|
281
|
-
for (const [name, rig] of [...this.#rigs])
|
|
282
|
-
if (!wanted.has(name)) {
|
|
283
|
-
rig.skeleton.dispose();
|
|
284
|
-
rig.boneRoot.removeFromParent();
|
|
285
|
-
this.#rigs.delete(name);
|
|
286
|
-
changed = true;
|
|
287
|
-
}
|
|
288
|
-
this.#engineCalls++;
|
|
289
|
-
const clip = await read.clip();
|
|
290
263
|
const previous = this.#clip;
|
|
291
264
|
const rangeChanged = clip?.frameStart !== previous?.frameStart ||
|
|
292
265
|
clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
|
|
293
|
-
const movedAction =
|
|
294
|
-
clip?.action !== previous?.action ||
|
|
295
|
-
clip?.tracks.length !== previous?.tracks.length ||
|
|
296
|
-
clip?.clipStart !== previous?.clipStart ||
|
|
297
|
-
clip?.clipEnd !== previous?.clipEnd;
|
|
298
266
|
this.#clip = clip ?? null;
|
|
299
267
|
if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
|
|
300
268
|
if (clip?.reason) warnings.push(clip.reason);
|
|
301
|
-
if (clip
|
|
302
|
-
|
|
303
|
-
|
|
269
|
+
if (clip) this.#loadClip(clip, warnings);
|
|
270
|
+
else {
|
|
271
|
+
this.#mixer?.stopAllAction();
|
|
272
|
+
this.#mixer = null;
|
|
273
|
+
this.#action = null;
|
|
274
|
+
}
|
|
304
275
|
const activeSubject = this.#transport?.snapshot().activeSubject;
|
|
305
276
|
if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
|
|
306
|
-
// BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
|
|
307
|
-
// `scene.frame_current` in bpy has said where it wants to be, and a
|
|
308
|
-
// present is how we hear about it; while the transport is PLAYING it would
|
|
309
|
-
// be the Timeline arguing with itself, so it is honoured only at rest.
|
|
310
277
|
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
311
|
-
if (clip && !playing
|
|
278
|
+
if (clip && !playing) {
|
|
312
279
|
this.#seek(clip.frameCurrent);
|
|
313
|
-
// THE BOOKMARK READ, once per bind: the file says where it was left, and
|
|
314
|
-
// the transport is what everything else now asks.
|
|
315
280
|
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
316
281
|
}
|
|
317
282
|
this.#warnings = warnings;
|
|
318
283
|
this.#publish();
|
|
319
284
|
}
|
|
320
285
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
warnings: string[],
|
|
326
|
-
): BoundRig | null {
|
|
327
|
-
const meshObject = presentation.objectForBlenderName(rig.object ?? '');
|
|
328
|
-
const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
|
|
329
|
-
if (!meshObject || !armatureObject) return null;
|
|
330
|
-
const source = meshObject as THREE.Mesh;
|
|
331
|
-
const geometry = source.geometry;
|
|
332
|
-
if (!geometry) return null;
|
|
333
|
-
const blenderVertex = geometry.getAttribute('blenderVertex');
|
|
334
|
-
if (!blenderVertex) {
|
|
335
|
-
warnings.push(
|
|
336
|
-
`"${rig.object}" cannot be skinned: its presented geometry carries no \`blenderVertex\` attribute, so a per-Blender-vertex weight cannot be expanded onto its drawn vertices.`,
|
|
337
|
-
);
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
if (rig.vertexCount === 0) return null;
|
|
341
|
-
// THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
|
|
342
|
-
// ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
|
|
343
|
-
// where the weights live); a generative modifier — Subdivision, Mirror,
|
|
344
|
-
// Array — makes the EVALUATED mesh the export door ships a different
|
|
345
|
-
// vertex set, and a skin bound across that mismatch would weight the wrong
|
|
346
|
-
// vertices. Named, never silently drawn.
|
|
347
|
-
let highest = 0;
|
|
348
|
-
for (let i = 0; i < blenderVertex.count; i++)
|
|
349
|
-
highest = Math.max(highest, blenderVertex.getX(i));
|
|
350
|
-
if (highest >= rig.vertexCount) {
|
|
351
|
-
warnings.push(
|
|
352
|
-
`"${rig.object}" is not skinned here: its presented geometry references Blender vertex ${highest} while the mesh declares ${rig.vertexCount}, which is a generative modifier (Subdivision, Mirror, Array…) between the two. Blender's own viewport shows the evaluated result; this presenter shows the exported columns unskinned.`,
|
|
353
|
-
);
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
|
|
357
|
-
const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
|
|
358
|
-
const drawn = blenderVertex.count;
|
|
359
|
-
const indices = new Uint16Array(drawn * 4);
|
|
360
|
-
const weights = new Float32Array(drawn * 4);
|
|
361
|
-
for (let i = 0; i < drawn; i++) {
|
|
362
|
-
const vertex = blenderVertex.getX(i);
|
|
363
|
-
for (let k = 0; k < 4; k++) {
|
|
364
|
-
indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
|
|
365
|
-
weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
369
|
-
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
370
|
-
|
|
371
|
-
const boneRoot = new THREE.Group();
|
|
372
|
-
boneRoot.name = `${rig.armature}:bones`;
|
|
373
|
-
const bones: THREE.Bone[] = [];
|
|
374
|
-
const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
|
|
375
|
-
const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
|
|
376
|
-
rig.bones.forEach((declared, index) => {
|
|
377
|
-
const bone = new THREE.Bone();
|
|
378
|
-
// THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
|
|
379
|
-
// arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
|
|
380
|
-
// therefore addresses bones by UUID rather than by name — three's
|
|
381
|
-
// `PropertyBinding` track-name grammar splits on `.`, and Blender's
|
|
382
|
-
// `hand.L` is an ordinary bone name.
|
|
383
|
-
bone.name = declared.name;
|
|
384
|
-
bones.push(bone);
|
|
385
|
-
const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
|
|
386
|
-
const local =
|
|
387
|
-
parentIndex === undefined
|
|
388
|
-
? armatureSpace[index]!.clone()
|
|
389
|
-
: armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
|
|
390
|
-
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
391
|
-
(parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
|
|
392
|
-
});
|
|
393
|
-
armatureObject.add(boneRoot);
|
|
394
|
-
|
|
395
|
-
const skinned = new THREE.SkinnedMesh(geometry, source.material);
|
|
396
|
-
skinned.name = source.name;
|
|
397
|
-
skinned.matrixAutoUpdate = false;
|
|
398
|
-
skinned.matrix.copy(source.matrix);
|
|
399
|
-
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
400
|
-
skinned.userData = source.userData;
|
|
401
|
-
// A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
|
|
402
|
-
// bounding sphere from the bind pose, so a raised arm at frame 24 is
|
|
403
|
-
// culled while its bind-pose sphere is off screen — a picture that
|
|
404
|
-
// vanishes mid-scrub with no error anywhere.
|
|
405
|
-
skinned.frustumCulled = false;
|
|
406
|
-
presentation.replacePresentedObject(source, skinned);
|
|
407
|
-
// THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
|
|
408
|
-
// WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
|
|
409
|
-
// at this instant and `bindMatrix` is the mesh's.
|
|
410
|
-
presentation.root.updateMatrixWorld(true);
|
|
411
|
-
const skeleton = new THREE.Skeleton(bones);
|
|
412
|
-
skinned.bind(skeleton, skinned.matrixWorld.clone());
|
|
413
|
-
if (rig.constrainedBones?.length)
|
|
414
|
-
warnings.push(
|
|
415
|
-
`Bone constraints on ${rig.constrainedBones.join(', ')}: the clip is derived from the F-Curves alone, so those bones play their channels rather than Blender's solved pose.`,
|
|
416
|
-
);
|
|
417
|
-
if (rig.unmappedGroups?.length)
|
|
418
|
-
warnings.push(
|
|
419
|
-
`${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
|
|
420
|
-
);
|
|
421
|
-
return {
|
|
422
|
-
object: rig.object ?? '',
|
|
423
|
-
armature: rig.armature ?? '',
|
|
424
|
-
mesh: skinned,
|
|
425
|
-
boneRoot,
|
|
426
|
-
bones,
|
|
427
|
-
skeleton,
|
|
428
|
-
frame,
|
|
429
|
-
signature: rigSignature(rig, frame),
|
|
430
|
-
};
|
|
286
|
+
/** The rig the clip was loaded over: a rebuilt rig (new bones) needs the clip loaded again. */
|
|
287
|
+
#boundRig: object | null = null;
|
|
288
|
+
#rig(): object | null {
|
|
289
|
+
return this.#boundRig;
|
|
431
290
|
}
|
|
432
291
|
|
|
433
|
-
#loadClip(
|
|
292
|
+
#loadClip(clip: BlenderActionClip, warnings: string[]): void {
|
|
434
293
|
this.#mixer?.stopAllAction();
|
|
435
294
|
this.#mixer = null;
|
|
436
295
|
this.#action = null;
|
|
437
296
|
if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const tracks: THREE.KeyframeTrack[] = [];
|
|
444
|
-
const missing = new Set<string>();
|
|
445
|
-
for (const track of clip.tracks) {
|
|
446
|
-
const bone = byName.get(track.bone);
|
|
447
|
-
if (!bone) {
|
|
448
|
-
missing.add(track.bone);
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
451
|
-
const times = float32Of(track.timeBase64);
|
|
452
|
-
const values = float32Of(track.valueBase64);
|
|
453
|
-
// ADDRESSED BY UUID, not by name — see the bone-naming note above.
|
|
454
|
-
const path = `${bone.uuid}.${track.property}`;
|
|
455
|
-
tracks.push(
|
|
456
|
-
track.property === 'quaternion'
|
|
457
|
-
? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
|
|
458
|
-
: new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
if (missing.size)
|
|
462
|
-
warnings.push(
|
|
463
|
-
`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`,
|
|
464
|
-
);
|
|
297
|
+
const rig = clip.armature ? this.#view?.skeletons.rig(clip.armature) : null;
|
|
298
|
+
if (!rig) return;
|
|
299
|
+
const armature = rig.object;
|
|
300
|
+
const byName = rig.bones;
|
|
301
|
+
const tracks = clipTracks(clip, byName, warnings);
|
|
465
302
|
if (!tracks.length) return;
|
|
466
303
|
const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
467
304
|
const mixer = new THREE.AnimationMixer(armature);
|
|
@@ -506,10 +343,8 @@ export class BlenderSkinDirector {
|
|
|
506
343
|
* render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
|
|
507
344
|
* pass over a handful of nodes rather than a recomposition of the scene. */
|
|
508
345
|
#refresh(): void {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
rig.skeleton.update();
|
|
512
|
-
}
|
|
346
|
+
const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
|
|
347
|
+
rig?.object.updateMatrixWorld(true);
|
|
513
348
|
}
|
|
514
349
|
|
|
515
350
|
/** The scene's name, for the one address the Timeline writes to. */
|
|
@@ -617,11 +452,9 @@ export class BlenderSkinDirector {
|
|
|
617
452
|
this.#action = null;
|
|
618
453
|
this.#clip = null;
|
|
619
454
|
this.#seeked = null;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
this.#rigs.clear();
|
|
455
|
+
if (this.#armature) this.#view?.skeletons.restorePose(this.#armature);
|
|
456
|
+
this.#armature = null;
|
|
457
|
+
this.#clipKey = null;
|
|
625
458
|
}
|
|
626
459
|
}
|
|
627
460
|
|
|
@@ -70,10 +70,13 @@ import {
|
|
|
70
70
|
openModelDocumentBlend,
|
|
71
71
|
modelDocumentMayOpen,
|
|
72
72
|
modelDocumentOwnsPresentation,
|
|
73
|
+
blenderActionClip,
|
|
74
|
+
blenderRnaVersion,
|
|
73
75
|
} from '../host/blender-runtime-host';
|
|
74
76
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
75
77
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
76
78
|
import { blenderSkin } from './blender-runtime-skin';
|
|
79
|
+
import { playAnimation, type PlayAnimation } from './blender-play-skin';
|
|
77
80
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
78
81
|
import { noteModelDocument } from '../src/play-mode';
|
|
79
82
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
@@ -897,6 +900,7 @@ function BlenderViewportArea({
|
|
|
897
900
|
let preparing = false;
|
|
898
901
|
let stopScript: (() => void) | null = null;
|
|
899
902
|
let orbit: { enabled: boolean } | null = null;
|
|
903
|
+
let animation: PlayAnimation | null = null;
|
|
900
904
|
const start = (): void => {
|
|
901
905
|
if (stopScript || preparing || stopped) return;
|
|
902
906
|
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
@@ -904,9 +908,14 @@ function BlenderViewportArea({
|
|
|
904
908
|
preparing = true;
|
|
905
909
|
orbit = stage.rig().orbit;
|
|
906
910
|
orbit.enabled = false;
|
|
911
|
+
// THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
|
|
912
|
+
// armature plays the action the file assigns it until the game sets another
|
|
913
|
+
// (`blender-play-skin.ts`). Nothing is read before the game starts.
|
|
907
914
|
void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
|
|
908
915
|
if (stopped) return;
|
|
916
|
+
animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
|
|
909
917
|
stopScript = documentPlayExtension('model')?.run({
|
|
918
|
+
...(animation ? { animation } : {}),
|
|
910
919
|
documentId: modelId,
|
|
911
920
|
container: layers,
|
|
912
921
|
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
@@ -964,6 +973,8 @@ function BlenderViewportArea({
|
|
|
964
973
|
stopPrepareFrames?.();
|
|
965
974
|
window.removeEventListener('keydown', onEscape, true);
|
|
966
975
|
stopScript?.();
|
|
976
|
+
animation?.dispose();
|
|
977
|
+
animation = null;
|
|
967
978
|
layers.remove();
|
|
968
979
|
if (orbit) orbit.enabled = true;
|
|
969
980
|
};
|
|
@@ -1035,6 +1046,20 @@ function BlenderViewportArea({
|
|
|
1035
1046
|
for (const { kind } of groups) stage?.setHelper(kind, null);
|
|
1036
1047
|
};
|
|
1037
1048
|
}, [documentId, view]);
|
|
1049
|
+
/**
|
|
1050
|
+
* THE ASSIGNED ACTION ON THE PRESENTED SKELETONS. The skins themselves come with every frame (the
|
|
1051
|
+
* presenter binds them, `blender-runtime-skeleton.ts`); after each present the director follows
|
|
1052
|
+
* the frame, and reads a clip only when the frame says its action, revision or clock moved.
|
|
1053
|
+
*/
|
|
1054
|
+
const rnaVersion = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
1055
|
+
useEffect(() => {
|
|
1056
|
+
if (!documentId || !main) return;
|
|
1057
|
+
let live = true;
|
|
1058
|
+
void blenderSkin.sync(view, { clip: (armature) => blenderActionClip({ object: armature }) }).catch((thrown: unknown) => {
|
|
1059
|
+
if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1060
|
+
});
|
|
1061
|
+
return () => { live = false; };
|
|
1062
|
+
}, [documentId, main, view, rnaVersion]);
|
|
1038
1063
|
/**
|
|
1039
1064
|
* ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
|
|
1040
1065
|
*
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
* you are looking.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
42
41
|
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
43
42
|
import { registerViewVerbs } from '@volter/editor-sdk/views';
|
|
44
43
|
import {
|
|
@@ -51,8 +50,6 @@ import {
|
|
|
51
50
|
useSyncExternalStore,
|
|
52
51
|
} from 'react';
|
|
53
52
|
import {
|
|
54
|
-
blenderActionClip,
|
|
55
|
-
blenderRig,
|
|
56
53
|
blenderRnaVersion,
|
|
57
54
|
subscribeBlenderRna,
|
|
58
55
|
} from '../host/blender-runtime-host';
|
|
@@ -392,7 +389,7 @@ function BlenderTimeline() {
|
|
|
392
389
|
// THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
|
|
393
390
|
// every presented frame bumps it, which is exactly when a rig or an action
|
|
394
391
|
// can have moved.
|
|
395
|
-
|
|
392
|
+
useSyncExternalStore(
|
|
396
393
|
subscribeBlenderRna,
|
|
397
394
|
blenderRnaVersion,
|
|
398
395
|
blenderRnaVersion,
|
|
@@ -404,30 +401,9 @@ function BlenderTimeline() {
|
|
|
404
401
|
const { transform, size, refusal, onlySelected } = timelineViewState();
|
|
405
402
|
const play = blenderSkin.state();
|
|
406
403
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// paying for both on each of a session's hundreds of `blender-execute`
|
|
411
|
-
// presents would be a round trip nobody asked for. A project with no
|
|
412
|
-
// Timeline open therefore presents exactly as it did before this unit, with
|
|
413
|
-
// ordinary `THREE.Mesh`es.
|
|
414
|
-
useEffect(() => {
|
|
415
|
-
let live = true;
|
|
416
|
-
void blenderSkin
|
|
417
|
-
.bind(blenderModelView, {
|
|
418
|
-
rig: () => blenderRig(),
|
|
419
|
-
clip: () => blenderActionClip(),
|
|
420
|
-
})
|
|
421
|
-
.catch((thrown: unknown) => {
|
|
422
|
-
if (!live) return;
|
|
423
|
-
refuseTimelineGesture(
|
|
424
|
-
`The Timeline could not read this file's rig: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
|
425
|
-
);
|
|
426
|
-
});
|
|
427
|
-
return () => {
|
|
428
|
-
live = false;
|
|
429
|
-
};
|
|
430
|
-
}, [rnaVersion]);
|
|
404
|
+
// The skins and the assigned action are the presented model's (`blender-runtime.document.tsx`
|
|
405
|
+
// binds them whatever is on screen, as Blender's viewport evaluates whatever editor is open);
|
|
406
|
+
// the Timeline only shows and drives their clock.
|
|
431
407
|
|
|
432
408
|
useEffect(() => {
|
|
433
409
|
const element = canvas.current;
|
|
@@ -93,7 +93,6 @@ export const commands: CommandContribution['commands'] = {
|
|
|
93
93
|
// and the ONE write the Timeline makes (`scene.frame_current`, once on
|
|
94
94
|
// pause and at scrub-end) goes through `blender-rna-set`, which already
|
|
95
95
|
// exists and already presents.
|
|
96
|
-
'blender-rig': verb('none', 60_000),
|
|
97
96
|
'blender-action-clip': verb('none', 120_000),
|
|
98
97
|
// THE NODE VIEW'S OWN ACTIONS as a session verb — the keyboard ruling's
|
|
99
98
|
// `volter.*`-command-per-action pattern, and the only way the drawer's view
|
|
@@ -36,7 +36,6 @@ import type {
|
|
|
36
36
|
BlenderNodeTree,
|
|
37
37
|
BlenderOutlinerTree,
|
|
38
38
|
BlenderOutlinerWrite,
|
|
39
|
-
BlenderRig,
|
|
40
39
|
BlenderRnaContext,
|
|
41
40
|
BlenderRnaView,
|
|
42
41
|
BlenderRnaWrite,
|
|
@@ -661,15 +660,6 @@ export async function blenderUvLayout(options?: {
|
|
|
661
660
|
return blenderRuntime().uvLayout(options);
|
|
662
661
|
}
|
|
663
662
|
|
|
664
|
-
/** THE RIG DOOR, in-page — one mesh's skin binding, which the presenter turns
|
|
665
|
-
* into a `THREE.SkinnedMesh` (`../contributions/blender-runtime-skin.ts`).
|
|
666
|
-
* WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20): this is
|
|
667
|
-
* called once per rig, never per played frame. Same rule as the doors above:
|
|
668
|
-
* it never starts the engine. */
|
|
669
|
-
export async function blenderRig(options?: { object?: string }): Promise<BlenderRig | null> {
|
|
670
|
-
if (!blenderSessionStarted()) return null;
|
|
671
|
-
return blenderRuntime().rig(options);
|
|
672
|
-
}
|
|
673
663
|
|
|
674
664
|
/** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
|
|
675
665
|
* scene's frame range and the Timeline's summary columns. Called when the
|
|
@@ -677,11 +667,14 @@ export async function blenderRig(options?: { object?: string }): Promise<Blender
|
|
|
677
667
|
export async function blenderActionClip(options?: {
|
|
678
668
|
object?: string;
|
|
679
669
|
bake?: boolean;
|
|
670
|
+
/** A named action baked against `object`'s armature, assigned or not. */
|
|
671
|
+
action?: string;
|
|
680
672
|
}): Promise<BlenderActionClip | null> {
|
|
681
673
|
if (!blenderSessionStarted()) return null;
|
|
682
674
|
return blenderRuntime().actionClip(options);
|
|
683
675
|
}
|
|
684
676
|
|
|
677
|
+
|
|
685
678
|
export async function blenderOutlinerSet(
|
|
686
679
|
path: string,
|
|
687
680
|
column: string,
|
|
@@ -1448,18 +1441,8 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1448
1441
|
}),
|
|
1449
1442
|
},
|
|
1450
1443
|
};
|
|
1451
|
-
// THE
|
|
1452
|
-
//
|
|
1453
|
-
// exactly what the presenter bound and what the Timeline plays.
|
|
1454
|
-
case 'blender-rig':
|
|
1455
|
-
return {
|
|
1456
|
-
ok: true,
|
|
1457
|
-
data: {
|
|
1458
|
-
result: await session.rig({
|
|
1459
|
-
...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
|
|
1460
|
-
}),
|
|
1461
|
-
},
|
|
1462
|
-
};
|
|
1444
|
+
// THE CLIP DOOR on the wire, beside the UV one: an action as three.js tracks, so a
|
|
1445
|
+
// `cyclotron eval` reads exactly what the Timeline and a game play.
|
|
1463
1446
|
case 'blender-action-clip':
|
|
1464
1447
|
return {
|
|
1465
1448
|
ok: true,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-blender",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.136",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -72,15 +72,15 @@
|
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
75
|
-
"@volter/blender-engine": "0.1.
|
|
76
|
-
"@volter/editor-threejs": "0.5.
|
|
75
|
+
"@volter/blender-engine": "0.1.134",
|
|
76
|
+
"@volter/editor-threejs": "0.5.197",
|
|
77
77
|
"zod": "^4.3.6",
|
|
78
78
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@volter/editor-live": "0.5.
|
|
82
|
-
"@volter/editor-project": "0.5.
|
|
83
|
-
"@volter/editor-sdk": "0.5.
|
|
81
|
+
"@volter/editor-live": "0.5.197",
|
|
82
|
+
"@volter/editor-project": "0.5.197",
|
|
83
|
+
"@volter/editor-sdk": "0.5.197",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|