@volter/editor-blender 0.1.135 → 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 +121 -189
- package/contributions/blender-runtime-skin.ts +67 -307
- package/contributions/blender-runtime.document.tsx +13 -35
- package/contributions/blender.command.ts +0 -1
- package/host/blender-runtime-host.ts +2 -27
- package/package.json +6 -6
|
@@ -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,50 +59,6 @@ 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
|
-
function uint16Of(base64: string): Uint16Array {
|
|
63
|
-
const bytes = bytesOf(base64);
|
|
64
|
-
return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** A row-major four-row matrix, as every matrix in the RNA doors crosses —
|
|
68
|
-
* `THREE.Matrix4.set` takes its arguments row-major too, so this is a spread
|
|
69
|
-
* and not a transpose. */
|
|
70
|
-
function matrixOf(rows: readonly (readonly [number, number, number, number])[]): THREE.Matrix4 {
|
|
71
|
-
return new THREE.Matrix4().set(...(rows.flat() as unknown as Parameters<THREE.Matrix4['set']>));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** What the presenter needs of the view to swap a Mesh for a SkinnedMesh and
|
|
75
|
-
* to hang bones off an armature. Deliberately three methods and not the view:
|
|
76
|
-
* this module has no business knowing about frames, materials or overlays. */
|
|
77
|
-
export interface SkinPresentation {
|
|
78
|
-
/** The presented `Object3D` for a Blender object NAME, or null. */
|
|
79
|
-
objectForBlenderName(name: string): THREE.Object3D | null;
|
|
80
|
-
/** Put `next` where `previous` was — same parent, same place in the object
|
|
81
|
-
* table — so the next frame's reuse check finds it. */
|
|
82
|
-
replacePresentedObject(previous: THREE.Object3D, next: THREE.Object3D): void;
|
|
83
|
-
/** The presented root, for the one forced `updateMatrixWorld` a bind needs. */
|
|
84
|
-
root: THREE.Object3D;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export 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('|');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
62
|
/**
|
|
107
63
|
* A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
|
|
108
64
|
* name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
|
|
@@ -130,123 +86,6 @@ export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, T
|
|
|
130
86
|
return tracks;
|
|
131
87
|
}
|
|
132
88
|
|
|
133
|
-
/**
|
|
134
|
-
* BIND ONE RIGGED MESH of a presentation: its drawn mesh becomes a `THREE.SkinnedMesh` over a
|
|
135
|
-
* `Skeleton` built from Blender's bones, in the pose the columns were exported at (so the bind
|
|
136
|
-
* is an identity there). Shared by the Timeline's director and Play's copy (`blender-play-skin.ts`).
|
|
137
|
-
*/
|
|
138
|
-
export function bindRig(
|
|
139
|
-
presentation: SkinPresentation,
|
|
140
|
-
rig: BlenderRigBinding,
|
|
141
|
-
frame: number,
|
|
142
|
-
warnings: string[],
|
|
143
|
-
): BoundRig | null {
|
|
144
|
-
const meshObject = presentation.objectForBlenderName(rig.object ?? '');
|
|
145
|
-
const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
|
|
146
|
-
if (!meshObject || !armatureObject) return null;
|
|
147
|
-
const source = meshObject as THREE.Mesh;
|
|
148
|
-
const geometry = source.geometry;
|
|
149
|
-
if (!geometry) return null;
|
|
150
|
-
const blenderVertex = geometry.getAttribute('blenderVertex');
|
|
151
|
-
if (!blenderVertex) {
|
|
152
|
-
warnings.push(
|
|
153
|
-
`"${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 (presented as ${(meshObject as THREE.Object3D).type} with ${Object.keys(geometry.attributes).join(', ') || 'no attributes'}).`,
|
|
154
|
-
);
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
if (rig.vertexCount === 0) return null;
|
|
158
|
-
// THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
|
|
159
|
-
// ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
|
|
160
|
-
// where the weights live); a generative modifier — Subdivision, Mirror,
|
|
161
|
-
// Array — makes the EVALUATED mesh the export door ships a different
|
|
162
|
-
// vertex set, and a skin bound across that mismatch would weight the wrong
|
|
163
|
-
// vertices. Named, never silently drawn.
|
|
164
|
-
let highest = 0;
|
|
165
|
-
for (let i = 0; i < blenderVertex.count; i++)
|
|
166
|
-
highest = Math.max(highest, blenderVertex.getX(i));
|
|
167
|
-
if (highest >= rig.vertexCount) {
|
|
168
|
-
warnings.push(
|
|
169
|
-
`"${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.`,
|
|
170
|
-
);
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
|
|
174
|
-
const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
|
|
175
|
-
const drawn = blenderVertex.count;
|
|
176
|
-
const indices = new Uint16Array(drawn * 4);
|
|
177
|
-
const weights = new Float32Array(drawn * 4);
|
|
178
|
-
for (let i = 0; i < drawn; i++) {
|
|
179
|
-
const vertex = blenderVertex.getX(i);
|
|
180
|
-
for (let k = 0; k < 4; k++) {
|
|
181
|
-
indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
|
|
182
|
-
weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
186
|
-
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
187
|
-
|
|
188
|
-
const boneRoot = new THREE.Group();
|
|
189
|
-
boneRoot.name = `${rig.armature}:bones`;
|
|
190
|
-
const bones: THREE.Bone[] = [];
|
|
191
|
-
const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
|
|
192
|
-
const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
|
|
193
|
-
rig.bones.forEach((declared, index) => {
|
|
194
|
-
const bone = new THREE.Bone();
|
|
195
|
-
// THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
|
|
196
|
-
// arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
|
|
197
|
-
// therefore addresses bones by UUID rather than by name — three's
|
|
198
|
-
// `PropertyBinding` track-name grammar splits on `.`, and Blender's
|
|
199
|
-
// `hand.L` is an ordinary bone name.
|
|
200
|
-
bone.name = declared.name;
|
|
201
|
-
bones.push(bone);
|
|
202
|
-
const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
|
|
203
|
-
const local =
|
|
204
|
-
parentIndex === undefined
|
|
205
|
-
? armatureSpace[index]!.clone()
|
|
206
|
-
: armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
|
|
207
|
-
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
208
|
-
(parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
|
|
209
|
-
});
|
|
210
|
-
armatureObject.add(boneRoot);
|
|
211
|
-
|
|
212
|
-
const skinned = new THREE.SkinnedMesh(geometry, source.material);
|
|
213
|
-
skinned.name = source.name;
|
|
214
|
-
skinned.matrixAutoUpdate = false;
|
|
215
|
-
skinned.matrix.copy(source.matrix);
|
|
216
|
-
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
217
|
-
skinned.userData = source.userData;
|
|
218
|
-
// A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
|
|
219
|
-
// bounding sphere from the bind pose, so a raised arm at frame 24 is
|
|
220
|
-
// culled while its bind-pose sphere is off screen — a picture that
|
|
221
|
-
// vanishes mid-scrub with no error anywhere.
|
|
222
|
-
skinned.frustumCulled = false;
|
|
223
|
-
presentation.replacePresentedObject(source, skinned);
|
|
224
|
-
// THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
|
|
225
|
-
// WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
|
|
226
|
-
// at this instant and `bindMatrix` is the mesh's.
|
|
227
|
-
presentation.root.updateMatrixWorld(true);
|
|
228
|
-
const skeleton = new THREE.Skeleton(bones);
|
|
229
|
-
skinned.bind(skeleton, skinned.matrixWorld.clone());
|
|
230
|
-
if (rig.constrainedBones?.length)
|
|
231
|
-
warnings.push(
|
|
232
|
-
`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.`,
|
|
233
|
-
);
|
|
234
|
-
if (rig.unmappedGroups?.length)
|
|
235
|
-
warnings.push(
|
|
236
|
-
`${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
|
|
237
|
-
);
|
|
238
|
-
return {
|
|
239
|
-
object: rig.object ?? '',
|
|
240
|
-
armature: rig.armature ?? '',
|
|
241
|
-
mesh: skinned,
|
|
242
|
-
boneRoot,
|
|
243
|
-
bones,
|
|
244
|
-
skeleton,
|
|
245
|
-
frame,
|
|
246
|
-
signature: rigSignature(rig, frame),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
89
|
/**
|
|
251
90
|
* THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
|
|
252
91
|
* is (`blender-runtime.document.tsx`): the Python session outlives workspace
|
|
@@ -255,18 +94,12 @@ export function bindRig(
|
|
|
255
94
|
* copy of the playback state anywhere.
|
|
256
95
|
*/
|
|
257
96
|
export class BlenderSkinDirector {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
* refusal (removing a generative modifier, showing a hidden object) changes the presentation,
|
|
265
|
-
* not the rig, and must be retried. */
|
|
266
|
-
static #tried(presentation: SkinPresentation, mesh: string, signature: string): string {
|
|
267
|
-
const object = presentation.objectForBlenderName(mesh) as THREE.Mesh | null;
|
|
268
|
-
return `${signature}|${object?.uuid ?? 'absent'}|${object?.geometry?.uuid ?? 'none'}`;
|
|
269
|
-
}
|
|
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;
|
|
270
103
|
#mixer: THREE.AnimationMixer | null = null;
|
|
271
104
|
#action: THREE.AnimationAction | null = null;
|
|
272
105
|
#clip: BlenderActionClip | null = null;
|
|
@@ -349,13 +182,14 @@ export class BlenderSkinDirector {
|
|
|
349
182
|
action: clip?.action ?? null,
|
|
350
183
|
object: clip?.object ?? null,
|
|
351
184
|
armature: clip?.armature ?? null,
|
|
352
|
-
bones:
|
|
185
|
+
bones: this.#armature ? this.#view?.skeletons.rig(this.#armature)?.bones.size ?? 0 : 0,
|
|
353
186
|
keyframes: clip?.keyframes ?? [],
|
|
354
187
|
summary: clip?.summary ?? [],
|
|
355
188
|
clipStart: clip?.clipStart ?? null,
|
|
356
189
|
clipEnd: clip?.clipEnd ?? null,
|
|
357
190
|
tracks: clip?.tracks.length ?? 0,
|
|
358
|
-
|
|
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] : [],
|
|
359
193
|
warnings: this.#warnings,
|
|
360
194
|
engineCalls: this.#engineCalls,
|
|
361
195
|
blenderFrame: clip?.frameCurrent ?? null,
|
|
@@ -390,150 +224,80 @@ export class BlenderSkinDirector {
|
|
|
390
224
|
}
|
|
391
225
|
|
|
392
226
|
/** Blender's scene clock exists even when the scene has no action. */
|
|
393
|
-
/** Whether a bind would change anything: nothing is bound yet, or a bound mesh is no longer the
|
|
394
|
-
* object presented for its name (the presenter replaced it with a new draw). Lets the model
|
|
395
|
-
* skip the rig read on the edits that touch no rig. */
|
|
396
|
-
stale(presentation: SkinPresentation): boolean {
|
|
397
|
-
// An unbindable rig whose presentation changed since it was tried is worth trying again.
|
|
398
|
-
for (const [mesh, tried] of this.#unbindable) {
|
|
399
|
-
const signature = tried.split('|').slice(0, -2).join('|');
|
|
400
|
-
if (BlenderSkinDirector.#tried(presentation, mesh, signature) !== tried) return true;
|
|
401
|
-
}
|
|
402
|
-
if (this.#rigs.size === 0) return this.#unbindable.size === 0;
|
|
403
|
-
for (const rig of this.#rigs.values()) {
|
|
404
|
-
if (presentation.objectForBlenderName(rig.object) !== rig.mesh) return true;
|
|
405
|
-
// A redraw that kept the object but replaced its geometry (a weight or mesh edit with the same
|
|
406
|
-
// vertex count) leaves the skinned mesh without its skin attributes.
|
|
407
|
-
if (!rig.mesh.geometry.getAttribute('skinIndex')) return true;
|
|
408
|
-
}
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
227
|
get playable(): boolean {
|
|
413
228
|
return this.#clip !== null && this.#transport !== null;
|
|
414
229
|
}
|
|
415
230
|
|
|
416
|
-
// ------------------------------------------------------------ the
|
|
231
|
+
// ------------------------------------------------------------ the frame
|
|
417
232
|
|
|
418
233
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* without the picture jumping back to the bind pose each time something
|
|
425
|
-
* 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.
|
|
426
239
|
*/
|
|
427
|
-
async
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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;
|
|
437
257
|
const warnings: string[] = [];
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (rig.reason) warnings.push(rig.reason);
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
wanted.add(rig.object);
|
|
446
|
-
const signature = rigSignature(rig, answer.frame);
|
|
447
|
-
const held = this.#rigs.get(rig.object);
|
|
448
|
-
// Unchanged only while what it bound is still what is presented, skinned: a redraw that
|
|
449
|
-
// replaced the object or its geometry (same vertex count, same signature) is bound again.
|
|
450
|
-
if (held?.signature === signature && presentation.objectForBlenderName(rig.object) === held.mesh
|
|
451
|
-
&& held.mesh.geometry.getAttribute('skinIndex')) continue;
|
|
452
|
-
const tried = BlenderSkinDirector.#tried(presentation, rig.object, signature);
|
|
453
|
-
if (this.#unbindable.get(rig.object) === tried) continue;
|
|
454
|
-
const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
|
|
455
|
-
if (!bound) {
|
|
456
|
-
this.#unbindable.set(rig.object, tried);
|
|
457
|
-
if (held) {
|
|
458
|
-
held.skeleton.dispose();
|
|
459
|
-
held.boneRoot.removeFromParent();
|
|
460
|
-
// A skinned mesh still presented with no skeleton would hold its last pose; it draws as
|
|
461
|
-
// the plain mesh it is again.
|
|
462
|
-
if (presentation.objectForBlenderName(rig.object) === held.mesh) {
|
|
463
|
-
const plain = new THREE.Mesh(held.mesh.geometry, held.mesh.material);
|
|
464
|
-
plain.name = held.mesh.name;
|
|
465
|
-
plain.matrixAutoUpdate = false;
|
|
466
|
-
plain.matrix.copy(held.mesh.matrix);
|
|
467
|
-
plain.matrix.decompose(plain.position, plain.quaternion, plain.scale);
|
|
468
|
-
plain.userData = held.mesh.userData;
|
|
469
|
-
presentation.replacePresentedObject(held.mesh, plain);
|
|
470
|
-
}
|
|
471
|
-
this.#rigs.delete(rig.object);
|
|
472
|
-
changed = true;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
if (bound) {
|
|
476
|
-
this.#unbindable.delete(rig.object);
|
|
477
|
-
held?.skeleton.dispose();
|
|
478
|
-
held?.boneRoot.removeFromParent();
|
|
479
|
-
this.#rigs.set(rig.object, bound);
|
|
480
|
-
changed = true;
|
|
481
|
-
}
|
|
258
|
+
let clip: BlenderActionClip | null = null;
|
|
259
|
+
if (armature) {
|
|
260
|
+
this.#engineCalls++;
|
|
261
|
+
clip = await read.clip(armature);
|
|
482
262
|
}
|
|
483
|
-
for (const [name, rig] of [...this.#rigs])
|
|
484
|
-
if (!wanted.has(name)) {
|
|
485
|
-
rig.skeleton.dispose();
|
|
486
|
-
rig.boneRoot.removeFromParent();
|
|
487
|
-
this.#rigs.delete(name);
|
|
488
|
-
changed = true;
|
|
489
|
-
}
|
|
490
|
-
this.#engineCalls++;
|
|
491
|
-
const clip = await read.clip();
|
|
492
263
|
const previous = this.#clip;
|
|
493
264
|
const rangeChanged = clip?.frameStart !== previous?.frameStart ||
|
|
494
265
|
clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
|
|
495
|
-
const movedAction =
|
|
496
|
-
clip?.action !== previous?.action ||
|
|
497
|
-
clip?.tracks.length !== previous?.tracks.length ||
|
|
498
|
-
clip?.clipStart !== previous?.clipStart ||
|
|
499
|
-
clip?.clipEnd !== previous?.clipEnd;
|
|
500
266
|
this.#clip = clip ?? null;
|
|
501
267
|
if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
|
|
502
268
|
if (clip?.reason) warnings.push(clip.reason);
|
|
503
|
-
if (clip
|
|
504
|
-
|
|
505
|
-
|
|
269
|
+
if (clip) this.#loadClip(clip, warnings);
|
|
270
|
+
else {
|
|
271
|
+
this.#mixer?.stopAllAction();
|
|
272
|
+
this.#mixer = null;
|
|
273
|
+
this.#action = null;
|
|
274
|
+
}
|
|
506
275
|
const activeSubject = this.#transport?.snapshot().activeSubject;
|
|
507
276
|
if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
|
|
508
|
-
// BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
|
|
509
|
-
// `scene.frame_current` in bpy has said where it wants to be, and a
|
|
510
|
-
// present is how we hear about it; while the transport is PLAYING it would
|
|
511
|
-
// be the Timeline arguing with itself, so it is honoured only at rest.
|
|
512
277
|
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
513
|
-
if (clip && !playing
|
|
278
|
+
if (clip && !playing) {
|
|
514
279
|
this.#seek(clip.frameCurrent);
|
|
515
|
-
// THE BOOKMARK READ, once per bind: the file says where it was left, and
|
|
516
|
-
// the transport is what everything else now asks.
|
|
517
280
|
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
518
281
|
}
|
|
519
282
|
this.#warnings = warnings;
|
|
520
283
|
this.#publish();
|
|
521
284
|
}
|
|
522
285
|
|
|
523
|
-
|
|
524
|
-
|
|
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;
|
|
525
290
|
}
|
|
526
291
|
|
|
527
|
-
#loadClip(
|
|
292
|
+
#loadClip(clip: BlenderActionClip, warnings: string[]): void {
|
|
528
293
|
this.#mixer?.stopAllAction();
|
|
529
294
|
this.#mixer = null;
|
|
530
295
|
this.#action = null;
|
|
531
296
|
if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
|
|
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;
|
|
537
301
|
const tracks = clipTracks(clip, byName, warnings);
|
|
538
302
|
if (!tracks.length) return;
|
|
539
303
|
const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
@@ -579,10 +343,8 @@ export class BlenderSkinDirector {
|
|
|
579
343
|
* render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
|
|
580
344
|
* pass over a handful of nodes rather than a recomposition of the scene. */
|
|
581
345
|
#refresh(): void {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
rig.skeleton.update();
|
|
585
|
-
}
|
|
346
|
+
const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
|
|
347
|
+
rig?.object.updateMatrixWorld(true);
|
|
586
348
|
}
|
|
587
349
|
|
|
588
350
|
/** The scene's name, for the one address the Timeline writes to. */
|
|
@@ -690,11 +452,9 @@ export class BlenderSkinDirector {
|
|
|
690
452
|
this.#action = null;
|
|
691
453
|
this.#clip = null;
|
|
692
454
|
this.#seeked = null;
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
}
|
|
697
|
-
this.#rigs.clear();
|
|
455
|
+
if (this.#armature) this.#view?.skeletons.restorePose(this.#armature);
|
|
456
|
+
this.#armature = null;
|
|
457
|
+
this.#clipKey = null;
|
|
698
458
|
}
|
|
699
459
|
}
|
|
700
460
|
|
|
@@ -71,14 +71,12 @@ import {
|
|
|
71
71
|
modelDocumentMayOpen,
|
|
72
72
|
modelDocumentOwnsPresentation,
|
|
73
73
|
blenderActionClip,
|
|
74
|
-
blenderArmatureActions,
|
|
75
|
-
blenderRig,
|
|
76
74
|
blenderRnaVersion,
|
|
77
75
|
} from '../host/blender-runtime-host';
|
|
78
76
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
79
77
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
80
78
|
import { blenderSkin } from './blender-runtime-skin';
|
|
81
|
-
import {
|
|
79
|
+
import { playAnimation, type PlayAnimation } from './blender-play-skin';
|
|
82
80
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
83
81
|
import { noteModelDocument } from '../src/play-mode';
|
|
84
82
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
@@ -910,19 +908,12 @@ function BlenderViewportArea({
|
|
|
910
908
|
preparing = true;
|
|
911
909
|
orbit = stage.rig().orbit;
|
|
912
910
|
orbit.enabled = false;
|
|
913
|
-
// THE COPY'S CHARACTERS ANIMATE: their skins
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
void
|
|
917
|
-
view.prepareRendered(stage.rig().drawCamera(), 'render'),
|
|
918
|
-
readPlayClipLibrary().catch((error: unknown) => {
|
|
919
|
-
editorHost().console.warn(`The game's animation could not be read: ${error instanceof Error ? error.message : String(error)}`, 'blender-play');
|
|
920
|
-
return null;
|
|
921
|
-
}),
|
|
922
|
-
]).then(([, library]) => {
|
|
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.
|
|
914
|
+
void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
|
|
923
915
|
if (stopped) return;
|
|
924
|
-
animation =
|
|
925
|
-
for (const warning of animation?.warnings ?? []) editorHost().console.warn(warning, 'blender-play');
|
|
916
|
+
animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
|
|
926
917
|
stopScript = documentPlayExtension('model')?.run({
|
|
927
918
|
...(animation ? { animation } : {}),
|
|
928
919
|
documentId: modelId,
|
|
@@ -1056,31 +1047,18 @@ function BlenderViewportArea({
|
|
|
1056
1047
|
};
|
|
1057
1048
|
}, [documentId, view]);
|
|
1058
1049
|
/**
|
|
1059
|
-
* THE
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
* every rigged mesh; a file with no armature pays only the cheap listing.
|
|
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.
|
|
1063
1053
|
*/
|
|
1064
1054
|
const rnaVersion = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
1065
|
-
/** The armatures, their actions and what each is assigned, as last bound: an edit that changes
|
|
1066
|
-
* none of them and replaced no skinned mesh costs no rig read and no bake. */
|
|
1067
|
-
const boundListing = useRef<string | null>(null);
|
|
1068
1055
|
useEffect(() => {
|
|
1069
1056
|
if (!documentId || !main) return;
|
|
1070
1057
|
let live = true;
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const key = JSON.stringify([listing.armatures, listing.scene ?? null]);
|
|
1076
|
-
if (key === boundListing.current && !blenderSkin.stale(view)) return;
|
|
1077
|
-
await blenderSkin.bind(view, { rig: () => blenderRig(), clip: () => blenderActionClip() });
|
|
1078
|
-
boundListing.current = key;
|
|
1079
|
-
})().catch((thrown: unknown) => {
|
|
1080
|
-
if (live) editorHost().console.warn(`This file's rig could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1081
|
-
});
|
|
1082
|
-
}, 400);
|
|
1083
|
-
return () => { live = false; clearTimeout(timer); };
|
|
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; };
|
|
1084
1062
|
}, [documentId, main, view, rnaVersion]);
|
|
1085
1063
|
/**
|
|
1086
1064
|
* ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
|
|
@@ -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
|
|
@@ -684,11 +674,6 @@ export async function blenderActionClip(options?: {
|
|
|
684
674
|
return blenderRuntime().actionClip(options);
|
|
685
675
|
}
|
|
686
676
|
|
|
687
|
-
/** Every armature and the actions that can play on it, for Play's clip library. Never starts the engine. */
|
|
688
|
-
export async function blenderArmatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } } | null> {
|
|
689
|
-
if (!blenderSessionStarted()) return null;
|
|
690
|
-
return blenderRuntime().armatureActions();
|
|
691
|
-
}
|
|
692
677
|
|
|
693
678
|
export async function blenderOutlinerSet(
|
|
694
679
|
path: string,
|
|
@@ -1456,18 +1441,8 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1456
1441
|
}),
|
|
1457
1442
|
},
|
|
1458
1443
|
};
|
|
1459
|
-
// THE
|
|
1460
|
-
//
|
|
1461
|
-
// exactly what the presenter bound and what the Timeline plays.
|
|
1462
|
-
case 'blender-rig':
|
|
1463
|
-
return {
|
|
1464
|
-
ok: true,
|
|
1465
|
-
data: {
|
|
1466
|
-
result: await session.rig({
|
|
1467
|
-
...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
|
|
1468
|
-
}),
|
|
1469
|
-
},
|
|
1470
|
-
};
|
|
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.
|
|
1471
1446
|
case 'blender-action-clip':
|
|
1472
1447
|
return {
|
|
1473
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",
|