@volter/editor-blender 0.1.136 → 0.1.137
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.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE
|
|
2
|
+
* THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
|
|
3
3
|
* as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
|
|
4
4
|
*
|
|
5
5
|
* ## Why this exists at all
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* times a second under every bpy reader in the session. So the skin travels in the
|
|
12
12
|
* frame itself: the export door ships each Armature-deformed mesh's weights per
|
|
13
13
|
* exported vertex (`bpy_web_export.cc` `write_skin`), the frame names every bone's
|
|
14
|
-
*
|
|
15
|
-
* (`blender-runtime-skeleton.ts`). This director
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* rest, channels, constraints and pose and each armature's NLA stack, and the presenter
|
|
15
|
+
* builds the skeletons (`blender-runtime-skeleton.ts`). This director poses EVERY armature
|
|
16
|
+
* at the scene frame with Blender's own evaluation (`blender-pose.ts`): its NLA tracks and
|
|
17
|
+
* active action, blended as Blender blends them, and its Damped Track constraints. Every scrub
|
|
18
|
+
* and every played frame costs ZERO calls into Blender; an action is baked again exactly when
|
|
19
|
+
* the frame says its revision moved.
|
|
19
20
|
*
|
|
20
21
|
* ## The bind pose is the EXPORT pose, and that is the whole trick
|
|
21
22
|
*
|
|
@@ -27,63 +28,60 @@
|
|
|
27
28
|
* own evaluation of the same skin over the same columns. Nothing has to move
|
|
28
29
|
* Blender's frame, ever.
|
|
29
30
|
*
|
|
30
|
-
* ##
|
|
31
|
+
* ## The fidelity check, and what is not played
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* from the F-Curves alone.
|
|
33
|
+
* At Blender's own frame, standing still, the picture IS Blender's pose (the frame's), and the
|
|
34
|
+
* evaluator's answer for that frame is compared with it bone by bone (`poseDivergence`). A bone
|
|
35
|
+
* further off than a tenth of a degree is said in the console with what would explain it, so a
|
|
36
|
+
* difference between this Timeline (and a game, which uses the same evaluator) and Blender is
|
|
37
|
+
* never silent. What the evaluator does not play — a constraint other than Damped Track, a
|
|
38
|
+
* driver, non-default parenting — is named once as well. Anything Blender's depsgraph does to
|
|
39
|
+
* GEOMETRY per frame (shape keys, a Displace or Cloth modifier) does not follow the bones.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
43
|
+
import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
43
44
|
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
44
|
-
import type
|
|
45
|
-
import * as THREE from 'three';
|
|
45
|
+
import { editorHost, type StageTransportHandle } from '@volter/editor-sdk/host';
|
|
46
46
|
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
47
|
+
import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const binary = atob(base64);
|
|
54
|
-
const out = new Uint8Array(binary.length);
|
|
55
|
-
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
56
|
-
return out;
|
|
49
|
+
/** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
|
|
50
|
+
* one action baked for one armature. */
|
|
51
|
+
export interface SkinReader {
|
|
52
|
+
clip(armature: string): Promise<BlenderActionClip | null>;
|
|
53
|
+
bake(armature: string, action: string): Promise<BlenderActionClip | null>;
|
|
57
54
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
);
|
|
55
|
+
|
|
56
|
+
/** An armature's layers at a scene frame: its NLA, then its active action over them. */
|
|
57
|
+
export function sceneLayers(armature: BlenderArmature, frame: number,
|
|
58
|
+
clips: (action: string) => PoseClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
|
|
59
|
+
const stack = nlaLayers(armature.animation, frame, clips);
|
|
60
|
+
const action = armature.action ?? null;
|
|
61
|
+
if (action) {
|
|
62
|
+
const clip = clips(action);
|
|
63
|
+
if (clip === undefined) stack.waiting = true;
|
|
64
|
+
const layer = clip ? actionLayer(armature.animation, clip, frame, stack.evaluated) : null;
|
|
65
|
+
if (layer) stack.layers.push(layer);
|
|
84
66
|
}
|
|
85
|
-
|
|
86
|
-
|
|
67
|
+
return stack;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What to do about what plays only in Blender, by kind. */
|
|
71
|
+
export function remedy(unsupported: readonly string[]): string {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
if (unsupported.some((thing) => thing.includes(' constraint ') || thing.includes('drivers')))
|
|
74
|
+
out.push('Bake a constraint or driver into the action (Pose ▸ Animation ▸ Bake Action, Visual Keying), or aim with a Damped Track.');
|
|
75
|
+
if (unsupported.some((thing) => thing.includes('curves on ')))
|
|
76
|
+
out.push("Key the motion on a bone (the root bone) instead of the armature object, or move the object from the game.");
|
|
77
|
+
if (unsupported.some((thing) => thing.includes('interpolation') || thing.includes(' modifier')))
|
|
78
|
+
out.push('Use Bezier, Linear or Constant keys and the Cycles modifier only, or bake the curves (Key ▸ Bake Keyframes).');
|
|
79
|
+
return out.length ? ` ${out.join(' ')}` : '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when an armature has anything to evaluate: an action, or NLA strips. */
|
|
83
|
+
export function animated(armature: BlenderArmature): boolean {
|
|
84
|
+
return !!armature.action || !!armature.animation?.tracks.some((track) => track.strips.length);
|
|
87
85
|
}
|
|
88
86
|
|
|
89
87
|
/**
|
|
@@ -94,15 +92,17 @@ export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, T
|
|
|
94
92
|
* copy of the playback state anywhere.
|
|
95
93
|
*/
|
|
96
94
|
export class BlenderSkinDirector {
|
|
97
|
-
/** The presented view whose skeletons
|
|
95
|
+
/** The presented view whose skeletons are posed (`sync`). */
|
|
98
96
|
#view: BlenderRuntimeView | null = null;
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
#reader: SkinReader | null = null;
|
|
98
|
+
/** The Timeline's subject: the armature its keys and header describe, and what its header was
|
|
99
|
+
* read for. Read again only when that changes. */
|
|
101
100
|
#armature: string | null = null;
|
|
102
101
|
#clipKey: string | null = null;
|
|
103
|
-
#mixer: THREE.AnimationMixer | null = null;
|
|
104
|
-
#action: THREE.AnimationAction | null = null;
|
|
105
102
|
#clip: BlenderActionClip | null = null;
|
|
103
|
+
/** Each posed armature's evaluator, and every action baked for it, at the revision baked. */
|
|
104
|
+
#poses = new Map<string, ArmaturePose>();
|
|
105
|
+
#baked = new Map<string, { revision: number | null; clip: PoseClip | null | undefined }>();
|
|
106
106
|
/** The frame the last SEEK asked for — see {@link frame}. */
|
|
107
107
|
#seeked: number | null = null;
|
|
108
108
|
/** The stage transport this skin is attached to, or null before `attachTo`.
|
|
@@ -112,6 +112,8 @@ export class BlenderSkinDirector {
|
|
|
112
112
|
#listeners = new Set<() => void>();
|
|
113
113
|
#version = 0;
|
|
114
114
|
#warnings: string[] = [];
|
|
115
|
+
/** What was already said in the console, so each finding is said once. */
|
|
116
|
+
#said = new Set<string>();
|
|
115
117
|
/** Engine calls this director has made, counted so a walk can prove that a
|
|
116
118
|
* scrub costs none (the acceptance the owner named). */
|
|
117
119
|
#engineCalls = 0;
|
|
@@ -134,9 +136,8 @@ export class BlenderSkinDirector {
|
|
|
134
136
|
for (const listener of [...this.#listeners]) listener();
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
/** The
|
|
138
|
-
*
|
|
139
|
-
* person or an agent ever names. */
|
|
139
|
+
/** The subject's facts plus the playhead, expressed in BLENDER FRAMES — which is the only
|
|
140
|
+
* number the Timeline draws, and the only one a person or an agent ever names. */
|
|
140
141
|
state(): {
|
|
141
142
|
frame: number;
|
|
142
143
|
start: number;
|
|
@@ -148,9 +149,8 @@ export class BlenderSkinDirector {
|
|
|
148
149
|
bones: number;
|
|
149
150
|
keyframes: readonly { frame: number; type: string; select: boolean }[];
|
|
150
151
|
/** Every animated object's own columns plus its SELECTION — what the
|
|
151
|
-
* summary row's `show_only_selected` filter chooses between.
|
|
152
|
-
*
|
|
153
|
-
* exactly as in Blender. */
|
|
152
|
+
* summary row's `show_only_selected` filter chooses between. What plays
|
|
153
|
+
* is every armature's stack, filter or no filter, exactly as in Blender. */
|
|
154
154
|
summary: readonly {
|
|
155
155
|
object: string;
|
|
156
156
|
action: string;
|
|
@@ -165,20 +165,17 @@ export class BlenderSkinDirector {
|
|
|
165
165
|
engineCalls: number;
|
|
166
166
|
/** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
|
|
167
167
|
* which is a different number from `frame` on purpose. `frame` is where
|
|
168
|
-
* the person is looking
|
|
169
|
-
*
|
|
170
|
-
*
|
|
168
|
+
* the person is looking; this is where bpy and the Properties rail are.
|
|
169
|
+
* They agree after a pause or a scrub-end, and they are deliberately
|
|
170
|
+
* allowed to differ in between. */
|
|
171
171
|
blenderFrame: number | null;
|
|
172
172
|
} {
|
|
173
173
|
const clip = this.#clip;
|
|
174
|
-
const start = clip?.frameStart ?? 1;
|
|
175
|
-
const end = clip?.frameEnd ?? 250;
|
|
176
|
-
const fps = clip?.fps ?? 24;
|
|
177
174
|
return {
|
|
178
175
|
frame: this.frame(),
|
|
179
|
-
start,
|
|
180
|
-
end,
|
|
181
|
-
fps,
|
|
176
|
+
start: clip?.frameStart ?? 1,
|
|
177
|
+
end: clip?.frameEnd ?? 250,
|
|
178
|
+
fps: clip?.fps ?? 24,
|
|
182
179
|
action: clip?.action ?? null,
|
|
183
180
|
object: clip?.object ?? null,
|
|
184
181
|
armature: clip?.armature ?? null,
|
|
@@ -188,8 +185,7 @@ export class BlenderSkinDirector {
|
|
|
188
185
|
clipStart: clip?.clipStart ?? null,
|
|
189
186
|
clipEnd: clip?.clipEnd ?? null,
|
|
190
187
|
tracks: clip?.tracks.length ?? 0,
|
|
191
|
-
|
|
192
|
-
bound: this.#armature && this.#view?.skeletons.rig(this.#armature) ? [this.#armature] : [],
|
|
188
|
+
bound: [...this.#poses.keys()],
|
|
193
189
|
warnings: this.#warnings,
|
|
194
190
|
engineCalls: this.#engineCalls,
|
|
195
191
|
blenderFrame: clip?.frameCurrent ?? null,
|
|
@@ -202,25 +198,9 @@ export class BlenderSkinDirector {
|
|
|
202
198
|
return this.#clip ? this.frame() : null;
|
|
203
199
|
}
|
|
204
200
|
|
|
205
|
-
/** The
|
|
206
|
-
* frame stands, which is Blender's answer for a file with no animation. */
|
|
201
|
+
/** The frame on screen: the last seek's, else Blender's own. */
|
|
207
202
|
frame(): number {
|
|
208
|
-
|
|
209
|
-
if (this.#seeked !== null) return this.#seeked;
|
|
210
|
-
if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
|
|
211
|
-
// A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
|
|
212
|
-
// `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
|
|
213
|
-
// `time === duration` and reads back as the FIRST — measured on the first
|
|
214
|
-
// walk, where `jump-end` answered frame 1 over a frame-48 pose. The
|
|
215
|
-
// wrapping is right for playback and wrong for a question, so a standing
|
|
216
|
-
// seek answers with the frame it was given and the play tick clears it.
|
|
217
|
-
// THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
|
|
218
|
-
// looping: `AnimationMixer.time` is monotonic and never wraps, while
|
|
219
|
-
// `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
|
|
220
|
-
// Measured on the first walk: two seconds of playback over a 47-frame clip
|
|
221
|
-
// read as frame 63.5 off the mixer, where the picture was correctly back
|
|
222
|
-
// near the start.
|
|
223
|
-
return clip.clipStart + this.#action.time * clip.fps;
|
|
203
|
+
return this.#seeked ?? this.#clip?.frameCurrent ?? 1;
|
|
224
204
|
}
|
|
225
205
|
|
|
226
206
|
/** Blender's scene clock exists even when the scene has no action. */
|
|
@@ -231,14 +211,15 @@ export class BlenderSkinDirector {
|
|
|
231
211
|
// ------------------------------------------------------------ the frame
|
|
232
212
|
|
|
233
213
|
/**
|
|
234
|
-
* FOLLOW THE PRESENTED FRAME.
|
|
235
|
-
*
|
|
236
|
-
* through the clip door when the frame says
|
|
237
|
-
*
|
|
238
|
-
* nothing, which is what lets the model call it after every present.
|
|
214
|
+
* FOLLOW THE PRESENTED FRAME. Every armature the frame names is posed; the Timeline's subject is
|
|
215
|
+
* the active object's armature (itself, or the one its skin is bound to), else the one armature
|
|
216
|
+
* that has an action, and its header is read through the clip door when the frame says
|
|
217
|
+
* something about it moved. Actions are baked once per revision, the first time a frame needs
|
|
218
|
+
* them. Otherwise this costs nothing, which is what lets the model call it after every present.
|
|
239
219
|
*/
|
|
240
|
-
async sync(view: BlenderRuntimeView, read:
|
|
220
|
+
async sync(view: BlenderRuntimeView, read: SkinReader): Promise<void> {
|
|
241
221
|
this.#view = view;
|
|
222
|
+
this.#reader = read;
|
|
242
223
|
const facts = view.animationFacts();
|
|
243
224
|
const names = Object.keys(facts.armatures);
|
|
244
225
|
const active = facts.active;
|
|
@@ -246,79 +227,138 @@ export class BlenderSkinDirector {
|
|
|
246
227
|
: (active ? facts.skinArmature(active) : null)
|
|
247
228
|
?? (names.filter((name) => facts.armatures[name]!.action).length === 1
|
|
248
229
|
? names.find((name) => facts.armatures[name]!.action)! : null);
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
230
|
+
// EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
|
|
231
|
+
for (const [name, pose] of [...this.#poses])
|
|
232
|
+
if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
|
|
233
|
+
for (const [name, entry] of Object.entries(facts.armatures)) {
|
|
234
|
+
const rig = view.skeletons.rig(name);
|
|
235
|
+
if (!rig || !animated(entry)) {
|
|
236
|
+
this.#poses.delete(name);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
let pose = this.#poses.get(name);
|
|
240
|
+
if (!pose) this.#poses.set(name, pose = new ArmaturePose(rig));
|
|
241
|
+
pose.facts(entry);
|
|
242
|
+
}
|
|
243
|
+
// A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
|
|
244
|
+
for (const [key, entry] of [...this.#baked]) {
|
|
245
|
+
const action = key.slice(key.indexOf('\u0000') + 1);
|
|
246
|
+
if ((facts.actions[action] ?? null) !== entry.revision) this.#baked.delete(key);
|
|
262
247
|
}
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
248
|
+
const action = armature ? facts.armatures[armature]?.action ?? null : null;
|
|
249
|
+
const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame]);
|
|
250
|
+
if (key !== this.#clipKey) {
|
|
251
|
+
this.#clipKey = key;
|
|
252
|
+
this.#armature = armature;
|
|
253
|
+
let clip: BlenderActionClip | null = null;
|
|
254
|
+
if (armature) {
|
|
255
|
+
this.#engineCalls++;
|
|
256
|
+
clip = await read.clip(armature);
|
|
257
|
+
if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: poseClip(clip) });
|
|
258
|
+
}
|
|
259
|
+
const previous = this.#clip;
|
|
260
|
+
const rangeChanged = clip?.frameStart !== previous?.frameStart ||
|
|
261
|
+
clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
|
|
262
|
+
this.#clip = clip ?? null;
|
|
263
|
+
if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
|
|
264
|
+
const activeSubject = this.#transport?.snapshot().activeSubject;
|
|
265
|
+
if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
|
|
266
|
+
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
267
|
+
if (clip && !playing) {
|
|
268
|
+
this.#seeked = null;
|
|
269
|
+
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
270
|
+
}
|
|
274
271
|
}
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
this.#pose();
|
|
273
|
+
this.#publish();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One action of one armature: baked, or being baked now (once), or null when it plays nothing. */
|
|
277
|
+
#clipOf(armature: string, action: string): PoseClip | null | undefined {
|
|
278
|
+
const key = `${armature}\u0000${action}`;
|
|
279
|
+
const held = this.#baked.get(key);
|
|
280
|
+
if (held) return held.clip;
|
|
281
|
+
const reader = this.#reader;
|
|
282
|
+
const revision = this.#view?.animationFacts().actions[action] ?? null;
|
|
283
|
+
if (!reader) return null;
|
|
284
|
+
const entry: { revision: number | null; clip: PoseClip | null | undefined } = { revision, clip: undefined };
|
|
285
|
+
this.#baked.set(key, entry);
|
|
286
|
+
this.#engineCalls++;
|
|
287
|
+
void reader.bake(armature, action).then((baked) => {
|
|
288
|
+
entry.clip = baked ? poseClip(baked) : null;
|
|
289
|
+
if (this.#baked.get(key) !== entry) return;
|
|
290
|
+
this.#pose();
|
|
291
|
+
this.#publish();
|
|
292
|
+
}, (error: unknown) => {
|
|
293
|
+
entry.clip = null;
|
|
294
|
+
this.#say(`bake:${key}`, `${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
295
|
+
});
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* POSE EVERY ARMATURE AT THE FRAME ON SCREEN. At Blender's own frame, standing still, the bones
|
|
301
|
+
* take Blender's pose (the frame's, exact) and the evaluator's answer is checked against it;
|
|
302
|
+
* anywhere else they take the evaluator's.
|
|
303
|
+
*/
|
|
304
|
+
#pose(): void {
|
|
305
|
+
const view = this.#view;
|
|
306
|
+
if (!view) return;
|
|
307
|
+
const facts = view.animationFacts();
|
|
308
|
+
const frame = this.frame();
|
|
309
|
+
// BLENDER'S OWN FRAME is the one the presented pose was evaluated at, not the bookmark: a pause
|
|
310
|
+
// writes the bookmark first and the frame evaluated there arrives after.
|
|
311
|
+
const atBlender = frame === facts.frame;
|
|
277
312
|
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
313
|
+
const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
|
|
314
|
+
const world = { object: (name: string) => view.objectForBlenderName(name) };
|
|
315
|
+
for (const [name, pose] of this.#poses) {
|
|
316
|
+
const armature = facts.armatures[name];
|
|
317
|
+
if (!armature) continue;
|
|
318
|
+
const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
|
|
319
|
+
if (unsupported.length) {
|
|
320
|
+
const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline or a game.${remedy(unsupported)}`;
|
|
321
|
+
warnings.push(said);
|
|
322
|
+
this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
|
|
323
|
+
}
|
|
324
|
+
const stack = sceneLayers(armature, frame, (action) => this.#clipOf(name, action));
|
|
325
|
+
for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline or a game.`);
|
|
326
|
+
if (stack.waiting) continue;
|
|
327
|
+
if (atBlender && !playing) {
|
|
328
|
+
view.skeletons.restorePose(name);
|
|
329
|
+
pose.rig.object.updateMatrixWorld(true);
|
|
330
|
+
this.#check(name, pose, armature, stack.layers, world, unsupported);
|
|
331
|
+
} else {
|
|
332
|
+
pose.apply(stack.layers, world);
|
|
333
|
+
}
|
|
281
334
|
}
|
|
282
335
|
this.#warnings = warnings;
|
|
283
|
-
this.#publish();
|
|
284
336
|
}
|
|
285
337
|
|
|
286
|
-
/** The
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
/** The clips baked so far for one armature. */
|
|
339
|
+
#clipsOf(armature: string): PoseClip[] {
|
|
340
|
+
const out: PoseClip[] = [];
|
|
341
|
+
for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
|
|
342
|
+
return out;
|
|
290
343
|
}
|
|
291
344
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (!
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
action.play();
|
|
310
|
-
this.#mixer = mixer;
|
|
311
|
-
this.#action = action;
|
|
312
|
-
// THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
|
|
313
|
-
// the invariant this whole design rests on: the mesh columns were exported
|
|
314
|
-
// at `frameCurrent` and the skeleton was bound in that pose, so seeking
|
|
315
|
-
// anywhere else would make the first picture after a re-bind disagree with
|
|
316
|
-
// the geometry underneath it. Measured on the first walk: a pause wrote
|
|
317
|
-
// frame 16, the write presented, the present re-exported the mesh at 16
|
|
318
|
-
// and re-bound — and `setTime(0)` snapped the playhead back to frame 1
|
|
319
|
-
// over a frame-16 mesh.
|
|
320
|
-
this.#seek(clip.frameCurrent);
|
|
321
|
-
this.#refresh();
|
|
345
|
+
/** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose against Blender's. */
|
|
346
|
+
#check(name: string, pose: ArmaturePose, armature: BlenderArmature, layers: readonly PoseLayer[],
|
|
347
|
+
world: { object(name: string): ReturnType<BlenderRuntimeView['objectForBlenderName']> }, unsupported: readonly string[]): void {
|
|
348
|
+
const off = poseDivergence(pose.apply(layers, world, false), armature);
|
|
349
|
+
if (!off) return;
|
|
350
|
+
const frame = this.#clip?.frameCurrent ?? this.frame();
|
|
351
|
+
const why = unsupported.length
|
|
352
|
+
? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
|
|
353
|
+
: 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
|
|
354
|
+
this.#say(`divergence:${name}:${frame}:${off.bone}:${Math.round(off.degrees)}`,
|
|
355
|
+
`${name} at frame ${frame}: bone "${off.bone}" is ${off.degrees.toFixed(1)}° and ${(off.offset * 100).toFixed(1)}% of its length off Blender's own pose, so the Timeline and a game show it differently from Blender. ${why}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#say(key: string, message: string): void {
|
|
359
|
+
if (this.#said.has(key)) return;
|
|
360
|
+
this.#said.add(key);
|
|
361
|
+
editorHost().console.warn(message, 'blender-animation');
|
|
322
362
|
}
|
|
323
363
|
|
|
324
364
|
// ------------------------------------------------------------ the playback
|
|
@@ -326,25 +366,8 @@ export class BlenderSkinDirector {
|
|
|
326
366
|
#seek(frame: number): void {
|
|
327
367
|
const clip = this.#clip;
|
|
328
368
|
if (!clip) return;
|
|
329
|
-
|
|
330
|
-
this.#
|
|
331
|
-
// An action supplies a pose at this scene time; it does not own whether
|
|
332
|
-
// the scene clock can move. Empty/static scenes still play and scrub.
|
|
333
|
-
if (!this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
334
|
-
// The TIME is nudged inside the clip so the last frame evaluates as the
|
|
335
|
-
// last frame rather than wrapping to the first; the number REPORTED is the
|
|
336
|
-
// one asked for (see `frame`).
|
|
337
|
-
const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
338
|
-
this.#mixer.setTime(Math.max(0, Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps)));
|
|
339
|
-
this.#refresh();
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** The bones moved; make the world matrices agree before the stage's next
|
|
343
|
-
* render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
|
|
344
|
-
* pass over a handful of nodes rather than a recomposition of the scene. */
|
|
345
|
-
#refresh(): void {
|
|
346
|
-
const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
|
|
347
|
-
rig?.object.updateMatrixWorld(true);
|
|
369
|
+
this.#seeked = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
|
|
370
|
+
this.#pose();
|
|
348
371
|
}
|
|
349
372
|
|
|
350
373
|
/** The scene's name, for the one address the Timeline writes to. */
|
|
@@ -447,12 +470,11 @@ export class BlenderSkinDirector {
|
|
|
447
470
|
|
|
448
471
|
dispose(): void {
|
|
449
472
|
this.#detach?.();
|
|
450
|
-
this.#mixer?.stopAllAction();
|
|
451
|
-
this.#mixer = null;
|
|
452
|
-
this.#action = null;
|
|
453
473
|
this.#clip = null;
|
|
454
474
|
this.#seeked = null;
|
|
455
|
-
|
|
475
|
+
for (const name of this.#poses.keys()) this.#view?.skeletons.restorePose(name);
|
|
476
|
+
this.#poses.clear();
|
|
477
|
+
this.#baked.clear();
|
|
456
478
|
this.#armature = null;
|
|
457
479
|
this.#clipKey = null;
|
|
458
480
|
}
|
|
@@ -1055,7 +1055,10 @@ function BlenderViewportArea({
|
|
|
1055
1055
|
useEffect(() => {
|
|
1056
1056
|
if (!documentId || !main) return;
|
|
1057
1057
|
let live = true;
|
|
1058
|
-
void blenderSkin.sync(view, {
|
|
1058
|
+
void blenderSkin.sync(view, {
|
|
1059
|
+
clip: (armature) => blenderActionClip({ object: armature }),
|
|
1060
|
+
bake: (armature, action) => blenderActionClip({ object: armature, action }),
|
|
1061
|
+
}).catch((thrown: unknown) => {
|
|
1059
1062
|
if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1060
1063
|
});
|
|
1061
1064
|
return () => { live = false; };
|
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.137",
|
|
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.135",
|
|
76
|
+
"@volter/editor-threejs": "0.5.198",
|
|
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.198",
|
|
82
|
+
"@volter/editor-project": "0.5.198",
|
|
83
|
+
"@volter/editor-sdk": "0.5.198",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|