@volter/editor-blender 0.1.150 → 0.1.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +99 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +64 -0
- package/contributions/blender.status.tsx +8 -150
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +3 -30
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,61 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
|
|
3
|
-
* as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
|
|
4
|
-
*
|
|
5
|
-
* ## Why this exists at all
|
|
6
|
-
*
|
|
7
|
-
* The obvious Timeline asks Blender for frame N, lets the depsgraph evaluate
|
|
8
|
-
* it and re-exports the mesh columns. That is the wrong architecture: it puts
|
|
9
|
-
* a WASM depsgraph evaluation and a full geometry round trip between the
|
|
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 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
|
-
* 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.
|
|
20
|
-
*
|
|
21
|
-
* ## The bind pose is the EXPORT pose, and that is the whole trick
|
|
22
|
-
*
|
|
23
|
-
* The export door runs with `evaluate: True`, so the columns the presenter
|
|
24
|
-
* holds are the mesh ALREADY DEFORMED at whatever frame Blender sits on. Bind
|
|
25
|
-
* a skeleton whose bones are in that same pose and the skinning is an identity
|
|
26
|
-
* there — `skinMatrix = Σ wᵢ·Bᵢ·Bᵢ⁻¹ = I` — so the picture at the bind frame is
|
|
27
|
-
* byte-for-byte the frame Blender presented, and every other frame is three's
|
|
28
|
-
* own evaluation of the same skin over the same columns. Nothing has to move
|
|
29
|
-
* Blender's frame, ever.
|
|
30
|
-
*
|
|
31
|
-
* ## The fidelity check, and what is not played
|
|
32
|
-
*
|
|
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
|
-
*/
|
|
1
|
+
/** The Timeline poses Blender-sampled animation on the scene’s three.js mixer. Exported weights bind against the presented export pose, making skinning an identity at that frame. Each action is sampled again only when its revision changes; scrubs make no engine calls. Compare the mixed pose against Blender’s presented pose and report divergence and unsupported evaluation features. */
|
|
41
2
|
|
|
42
|
-
import
|
|
3
|
+
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
4
|
+
import type { BlenderActionClip, BlenderSceneMovie } from '@volter/blender-engine/browser/rna';
|
|
43
5
|
import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
44
6
|
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
45
7
|
import { editorHost, type StageTransportHandle } from '@volter/sdk/host';
|
|
46
8
|
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
47
|
-
import {
|
|
9
|
+
import { remedy } from './blender-remedy';
|
|
10
|
+
import { actionLayer, nlaLayers, type PoseLayer } from './blender-pose';
|
|
11
|
+
import { MixerPose, mixerClip, poseDivergence, type MixerClip } from './blender-mixer-pose';
|
|
12
|
+
import { playMovie, type PlayMovie } from './blender-playback-movie';
|
|
13
|
+
import { sceneMixer } from './blender-scene-mixer';
|
|
14
|
+
import { invalidateStages } from '@volter/sdk/stage-invalidation';
|
|
48
15
|
|
|
49
16
|
/** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
|
|
50
17
|
* one action baked for one armature. */
|
|
51
18
|
export interface SkinReader {
|
|
52
|
-
|
|
19
|
+
/** The scene's clock and range, and `armature`'s action when there is one (null: the scene alone). */
|
|
20
|
+
clip(armature: string | null): Promise<BlenderActionClip | null>;
|
|
53
21
|
bake(armature: string, action: string): Promise<BlenderActionClip | null>;
|
|
22
|
+
/** Animated objects, cameras and marker cuts sampled at each scene frame for Timeline playback. */
|
|
23
|
+
movie(): Promise<BlenderSceneMovie | null>;
|
|
54
24
|
}
|
|
55
25
|
|
|
56
26
|
/** An armature's layers at a scene frame: its NLA, then its active action over them. */
|
|
57
27
|
export function sceneLayers(armature: BlenderArmature, frame: number,
|
|
58
|
-
clips: (action: string) =>
|
|
28
|
+
clips: (action: string) => MixerClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
|
|
59
29
|
const stack = nlaLayers(armature.animation, frame, clips);
|
|
60
30
|
const action = armature.action ?? null;
|
|
61
31
|
if (action) {
|
|
@@ -67,17 +37,7 @@ export function sceneLayers(armature: BlenderArmature, frame: number,
|
|
|
67
37
|
return stack;
|
|
68
38
|
}
|
|
69
39
|
|
|
70
|
-
|
|
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
|
-
}
|
|
40
|
+
export { remedy } from './blender-remedy';
|
|
81
41
|
|
|
82
42
|
/** True when an armature has anything to evaluate: an action, or NLA strips. */
|
|
83
43
|
export function animated(armature: BlenderArmature): boolean {
|
|
@@ -95,14 +55,21 @@ export class BlenderSkinDirector {
|
|
|
95
55
|
/** The presented view whose skeletons are posed (`sync`). */
|
|
96
56
|
#view: BlenderRuntimeView | null = null;
|
|
97
57
|
#reader: SkinReader | null = null;
|
|
58
|
+
/** THE SCENE'S MOVIE on the document's view (`docs/SCENE-ANIMATION.md`, step 2): its objects and
|
|
59
|
+
* cameras posed at every Timeline frame beside the armatures, and its camera cuts. */
|
|
60
|
+
#movie: PlayMovie | null = null;
|
|
61
|
+
#movieKey: string | null = null;
|
|
62
|
+
#moviePosed: number | null = null;
|
|
63
|
+
/** What the last movie read held (null before one): said in `state()`, so an empty movie says why. */
|
|
64
|
+
#movieRead: { objects: number; materials: number; shapes: number } | null = null;
|
|
98
65
|
/** The Timeline's subject: the armature its keys and header describe, and what its header was
|
|
99
66
|
* read for. Read again only when that changes. */
|
|
100
67
|
#armature: string | null = null;
|
|
101
68
|
#clipKey: string | null = null;
|
|
102
69
|
#clip: BlenderActionClip | null = null;
|
|
103
70
|
/** Each posed armature's evaluator, and every action baked for it, at the revision baked. */
|
|
104
|
-
#poses = new Map<string,
|
|
105
|
-
#baked = new Map<string, { revision: number | null; clip:
|
|
71
|
+
#poses = new Map<string, MixerPose>();
|
|
72
|
+
#baked = new Map<string, { revision: number | null; clip: MixerClip | null | undefined }>();
|
|
106
73
|
/** The frame the last SEEK asked for — see {@link frame}. */
|
|
107
74
|
#seeked: number | null = null;
|
|
108
75
|
/** The stage transport this skin is attached to, or null before `attachTo`.
|
|
@@ -114,8 +81,7 @@ export class BlenderSkinDirector {
|
|
|
114
81
|
#warnings: string[] = [];
|
|
115
82
|
/** What was already said in the console, so each finding is said once. */
|
|
116
83
|
#said = new Set<string>();
|
|
117
|
-
/** Engine
|
|
118
|
-
* scrub costs none (the acceptance the owner named). */
|
|
84
|
+
/** Engine-call count exposed in the Timeline state. */
|
|
119
85
|
#engineCalls = 0;
|
|
120
86
|
|
|
121
87
|
get version(): number {
|
|
@@ -169,6 +135,12 @@ export class BlenderSkinDirector {
|
|
|
169
135
|
* They agree after a pause or a scrub-end, and they are deliberately
|
|
170
136
|
* allowed to differ in between. */
|
|
171
137
|
blenderFrame: number | null;
|
|
138
|
+
/** The scene's movie on the Timeline: how many objects it moves, the camera it cuts to now, the
|
|
139
|
+
* frame it last posed (null: posed by Blender, standing at its own frame), and the camera the
|
|
140
|
+
* camera view looks through. */
|
|
141
|
+
movie: { objects: number; camera: string | null; posed: number | null; viewCamera: string | null } | null;
|
|
142
|
+
/** What the last read of the scene's movie held: objects moved, materials and meshes keyed. */
|
|
143
|
+
movieRead: { objects: number; materials: number; shapes: number } | null;
|
|
172
144
|
} {
|
|
173
145
|
const clip = this.#clip;
|
|
174
146
|
return {
|
|
@@ -189,6 +161,8 @@ export class BlenderSkinDirector {
|
|
|
189
161
|
warnings: this.#warnings,
|
|
190
162
|
engineCalls: this.#engineCalls,
|
|
191
163
|
blenderFrame: clip?.frameCurrent ?? null,
|
|
164
|
+
movieRead: this.#movieRead,
|
|
165
|
+
movie: this.#movie ? { objects: this.#movie.objects.length, camera: this.#moviePosed === null ? null : this.#movie.cameraAt(this.#moviePosed), posed: this.#moviePosed, viewCamera: this.#view?.cameraViewCamera() ?? null } : null,
|
|
192
166
|
};
|
|
193
167
|
}
|
|
194
168
|
|
|
@@ -229,15 +203,16 @@ export class BlenderSkinDirector {
|
|
|
229
203
|
? names.find((name) => facts.armatures[name]!.action)! : null);
|
|
230
204
|
// EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
|
|
231
205
|
for (const [name, pose] of [...this.#poses])
|
|
232
|
-
if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
|
|
206
|
+
if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) { pose.dispose(); this.#poses.delete(name); }
|
|
233
207
|
for (const [name, entry] of Object.entries(facts.armatures)) {
|
|
234
208
|
const rig = view.skeletons.rig(name);
|
|
235
209
|
if (!rig || !animated(entry)) {
|
|
210
|
+
this.#poses.get(name)?.dispose();
|
|
236
211
|
this.#poses.delete(name);
|
|
237
212
|
continue;
|
|
238
213
|
}
|
|
239
214
|
let pose = this.#poses.get(name);
|
|
240
|
-
if (!pose) this.#poses.set(name, pose = new
|
|
215
|
+
if (!pose) this.#poses.set(name, pose = new MixerPose(rig, sceneMixer(view).mixer));
|
|
241
216
|
pose.facts(entry);
|
|
242
217
|
}
|
|
243
218
|
// A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
|
|
@@ -250,12 +225,11 @@ export class BlenderSkinDirector {
|
|
|
250
225
|
if (key !== this.#clipKey) {
|
|
251
226
|
this.#clipKey = key;
|
|
252
227
|
this.#armature = armature;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
228
|
+
// THE SCENE'S CLOCK IS READ WITH OR WITHOUT AN ARMATURE (`docs/SCENE-ANIMATION.md`, step 1): a
|
|
229
|
+
// film of keyed cameras and props has a range, a frame and markers, and the Timeline plays it
|
|
230
|
+
this.#engineCalls++;
|
|
231
|
+
const clip: BlenderActionClip | null = await read.clip(armature);
|
|
232
|
+
if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: mixerClip(clip) });
|
|
259
233
|
const previous = this.#clip;
|
|
260
234
|
const rangeChanged = clip?.frameStart !== previous?.frameStart ||
|
|
261
235
|
clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
|
|
@@ -269,23 +243,36 @@ export class BlenderSkinDirector {
|
|
|
269
243
|
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
270
244
|
}
|
|
271
245
|
}
|
|
246
|
+
// THE MOVIE IS READ AGAIN WHEN AN ACTION OR THE SCENE'S CLOCK MOVED (a key, a marker, the range), OR
|
|
247
|
+
// ITS CAMERAS DID: the scene camera switched, a camera's lens or placement changed unkeyed (the movie
|
|
248
|
+
// carries the scene camera and its settings as they were read)
|
|
249
|
+
const movieKey = JSON.stringify([facts.actions, facts.clock, this.#clip?.frameStart, this.#clip?.frameEnd, facts.cameras]);
|
|
250
|
+
if (movieKey !== this.#movieKey) {
|
|
251
|
+
this.#movieKey = movieKey;
|
|
252
|
+
this.#engineCalls++;
|
|
253
|
+
const data = await read.movie();
|
|
254
|
+
this.#movieRead = data ? { objects: data.objects.length, materials: data.materials.length, shapes: data.shapes.length } : null;
|
|
255
|
+
this.#movie?.dispose();
|
|
256
|
+
this.#movie = data && (data.objects.length || data.materials.length || data.shapes.length) ? playMovie(view, data, sceneMixer(view).mixer) : null;
|
|
257
|
+
for (const warning of this.#movie?.warnings ?? []) this.#say(`movie:${warning}`, warning);
|
|
258
|
+
}
|
|
272
259
|
this.#pose();
|
|
273
260
|
this.#publish();
|
|
274
261
|
}
|
|
275
262
|
|
|
276
263
|
/** One action of one armature: baked, or being baked now (once), or null when it plays nothing. */
|
|
277
|
-
#clipOf(armature: string, action: string):
|
|
264
|
+
#clipOf(armature: string, action: string): MixerClip | null | undefined {
|
|
278
265
|
const key = `${armature}\u0000${action}`;
|
|
279
266
|
const held = this.#baked.get(key);
|
|
280
267
|
if (held) return held.clip;
|
|
281
268
|
const reader = this.#reader;
|
|
282
269
|
const revision = this.#view?.animationFacts().actions[action] ?? null;
|
|
283
270
|
if (!reader) return null;
|
|
284
|
-
const entry: { revision: number | null; clip:
|
|
271
|
+
const entry: { revision: number | null; clip: MixerClip | null | undefined } = { revision, clip: undefined };
|
|
285
272
|
this.#baked.set(key, entry);
|
|
286
273
|
this.#engineCalls++;
|
|
287
274
|
void reader.bake(armature, action).then((baked) => {
|
|
288
|
-
entry.clip = baked ?
|
|
275
|
+
entry.clip = baked ? mixerClip(baked) : null;
|
|
289
276
|
if (this.#baked.get(key) !== entry) return;
|
|
290
277
|
this.#pose();
|
|
291
278
|
this.#publish();
|
|
@@ -312,47 +299,70 @@ export class BlenderSkinDirector {
|
|
|
312
299
|
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
313
300
|
const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
|
|
314
301
|
const world = { object: (name: string) => view.objectForBlenderName(name) };
|
|
302
|
+
const scene = sceneMixer(view);
|
|
303
|
+
// ONE FRAME, THREE STEPS (`blender-scene-mixer.ts`): every player places its actions, the scene's
|
|
304
|
+
// mixer evaluates them all at once, then each settles (constraints, the fidelity check).
|
|
305
|
+
// OBJECTS AND CAMERAS (an armature may ride on one): standing still at Blender's own frame the
|
|
306
|
+
// picture is Blender's, as for the armatures (an object moved by hand and not keyed stays where it
|
|
307
|
+
// was put until the frame changes, as in Blender), so the movie holds off the mixer there.
|
|
308
|
+
const movieHere = !!this.#movie && !(atBlender && !playing);
|
|
309
|
+
// (the drawn geometry is Blender's evaluation at its own frame: a shape key plays relative to it)
|
|
310
|
+
this.#movie?.place(movieHere ? frame : null, facts.frame);
|
|
311
|
+
this.#moviePosed = movieHere ? frame : null;
|
|
312
|
+
const placed: { name: string; pose: MixerPose; armature: BlenderArmature; check: boolean; unsupported: string[] }[] = [];
|
|
315
313
|
for (const [name, pose] of this.#poses) {
|
|
316
314
|
const armature = facts.armatures[name];
|
|
317
315
|
if (!armature) continue;
|
|
318
316
|
const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
|
|
319
317
|
if (unsupported.length) {
|
|
320
|
-
const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline
|
|
318
|
+
const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline preview.${remedy(unsupported)}`;
|
|
321
319
|
warnings.push(said);
|
|
322
320
|
this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
|
|
323
321
|
}
|
|
324
322
|
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
|
|
323
|
+
for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline preview.`);
|
|
326
324
|
if (stack.waiting) continue;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
325
|
+
pose.place(stack.layers);
|
|
326
|
+
placed.push({ name, pose, armature, check: atBlender && !playing, unsupported });
|
|
327
|
+
}
|
|
328
|
+
scene.evaluate();
|
|
329
|
+
this.#movie?.settle();
|
|
330
|
+
if (this.#movie) {
|
|
331
|
+
// THE CUT ALWAYS FOLLOWS THE MARKERS, at Blender's own frame too: Blender switches `scene.camera`
|
|
332
|
+
// at bound markers as it plays, which a frame written back to it does not do
|
|
333
|
+
const cut = this.#movie.cameraAt(frame);
|
|
334
|
+
view.setPlaybackCamera(cut, cut === null ? null : this.#movie.cameraData(cut));
|
|
335
|
+
// (a scrub that moved only objects and cameras asks the stage for the frame it now shows)
|
|
336
|
+
invalidateStages();
|
|
337
|
+
} else view.setPlaybackCamera(null);
|
|
338
|
+
for (const one of placed) {
|
|
339
|
+
const posed = one.pose.settle(world);
|
|
340
|
+
if (!one.check) continue;
|
|
341
|
+
// AT BLENDER'S OWN FRAME the picture is Blender's pose, and the evaluator's answer is checked against it
|
|
342
|
+
this.#check(one.name, posed, one.armature, one.unsupported);
|
|
343
|
+
view.skeletons.restorePose(one.name);
|
|
344
|
+
one.pose.rig.object.updateMatrixWorld(true);
|
|
334
345
|
}
|
|
335
346
|
this.#warnings = warnings;
|
|
336
347
|
}
|
|
337
348
|
|
|
338
349
|
/** The clips baked so far for one armature. */
|
|
339
|
-
#clipsOf(armature: string):
|
|
340
|
-
const out:
|
|
350
|
+
#clipsOf(armature: string): MixerClip[] {
|
|
351
|
+
const out: MixerClip[] = [];
|
|
341
352
|
for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
|
|
342
353
|
return out;
|
|
343
354
|
}
|
|
344
355
|
|
|
345
|
-
/** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose against Blender's. */
|
|
346
|
-
#check(name: string,
|
|
347
|
-
|
|
348
|
-
const off = poseDivergence(pose.apply(layers, world, false), armature);
|
|
356
|
+
/** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose (as settled) against Blender's. */
|
|
357
|
+
#check(name: string, posed: ReadonlyMap<string, import('three').Matrix4>, armature: BlenderArmature, unsupported: readonly string[]): void {
|
|
358
|
+
const off = poseDivergence(posed, armature);
|
|
349
359
|
if (!off) return;
|
|
350
360
|
const frame = this.#clip?.frameCurrent ?? this.frame();
|
|
351
361
|
const why = unsupported.length
|
|
352
362
|
? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
|
|
353
363
|
: 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
|
|
354
364
|
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
|
|
365
|
+
`${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 preview differs from Blender. ${why}`);
|
|
356
366
|
}
|
|
357
367
|
|
|
358
368
|
#say(key: string, message: string): void {
|
|
@@ -386,9 +396,7 @@ export class BlenderSkinDirector {
|
|
|
386
396
|
channels(): { bone: string; keys: number[] }[] {
|
|
387
397
|
const bones = new Map<string, Set<number>>();
|
|
388
398
|
for (const track of this.#clip?.tracks ?? []) {
|
|
389
|
-
const
|
|
390
|
-
const bytes = new Uint8Array(raw.length);
|
|
391
|
-
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
399
|
+
const bytes = bytesFromBase64(track.keysBase64);
|
|
392
400
|
const keys = new Float32Array(bytes.buffer, 0, bytes.length >> 2);
|
|
393
401
|
let frames = bones.get(track.bone);
|
|
394
402
|
if (!frames) bones.set(track.bone, frames = new Set());
|
|
@@ -417,14 +425,13 @@ export class BlenderSkinDirector {
|
|
|
417
425
|
* session; writing it sixty times a second would be the architecture this
|
|
418
426
|
* module exists to avoid. The transport's `onSettled` is what calls this —
|
|
419
427
|
* a pause, or the quiet at the end of a scrub — so a slider drag writes
|
|
420
|
-
* once.
|
|
421
|
-
* playhead write belongs. */
|
|
428
|
+
* once. */
|
|
422
429
|
async writeBookmark(): Promise<number> {
|
|
423
430
|
const frame = Math.round(this.frame());
|
|
424
431
|
const clip = this.#clip;
|
|
425
432
|
if (!clip) return frame;
|
|
426
433
|
const scene = clip.scene;
|
|
427
|
-
if (
|
|
434
|
+
if (clip.frameCurrent === frame) return frame;
|
|
428
435
|
this.#engineCalls++;
|
|
429
436
|
// `rna_set` refuses `bpy.context.scene` — the scene must be addressed by
|
|
430
437
|
// name through `bpy.data.scenes[...]`.
|
|
@@ -506,8 +513,10 @@ export class BlenderSkinDirector {
|
|
|
506
513
|
this.#detach?.();
|
|
507
514
|
this.#clip = null;
|
|
508
515
|
this.#seeked = null;
|
|
509
|
-
for (const name of this.#poses.
|
|
516
|
+
for (const [name, pose] of this.#poses) { pose.dispose(); this.#view?.skeletons.restorePose(name); }
|
|
510
517
|
this.#poses.clear();
|
|
518
|
+
this.#movie?.dispose();
|
|
519
|
+
this.#movie = null;
|
|
511
520
|
this.#baked.clear();
|
|
512
521
|
this.#armature = null;
|
|
513
522
|
this.#clipKey = null;
|
|
@@ -517,12 +526,7 @@ export class BlenderSkinDirector {
|
|
|
517
526
|
/** THE ONE DIRECTOR — see the class comment for why it is module-scoped. */
|
|
518
527
|
export const blenderSkin = new BlenderSkinDirector();
|
|
519
528
|
|
|
520
|
-
/**
|
|
521
|
-
* ceremony: passing `blenderSkin.subscribe.bind(blenderSkin)` inline mints a
|
|
522
|
-
* NEW subscribe and a NEW getSnapshot on every render, so React tears the
|
|
523
|
-
* subscription down and rebuilds it each time — measured on the Timeline's
|
|
524
|
-
* first walk, where the view bound its rig and its published `drawn` never
|
|
525
|
-
* moved again because no re-render ever arrived. */
|
|
529
|
+
/** Stable subscribe and snapshot function identities preserve useSyncExternalStore subscriptions across renders. */
|
|
526
530
|
export function subscribeBlenderSkin(listener: () => void): () => void {
|
|
527
531
|
return blenderSkin.subscribe(listener);
|
|
528
532
|
}
|