@volter/editor-blender 0.1.0
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 +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- package/src/uv-view-state.ts +125 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SKIN AND THE CLIP — 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 instead the tab
|
|
12
|
+
* reads the rig ONCE (`session.py`'s `rna_rig`) and the action ONCE
|
|
13
|
+
* (`rna_action_clip`), builds a real `THREE.SkinnedMesh` + `Skeleton` +
|
|
14
|
+
* `AnimationMixer`, and every scrub and every played frame after that costs
|
|
15
|
+
* ZERO calls into Blender.
|
|
16
|
+
*
|
|
17
|
+
* ## The bind pose is the EXPORT pose, and that is the whole trick
|
|
18
|
+
*
|
|
19
|
+
* The export door runs with `evaluate: True`, so the columns the presenter
|
|
20
|
+
* holds are the mesh ALREADY DEFORMED at whatever frame Blender sits on. Bind
|
|
21
|
+
* a skeleton whose bones are in that same pose and the skinning is an identity
|
|
22
|
+
* there — `skinMatrix = Σ wᵢ·Bᵢ·Bᵢ⁻¹ = I` — so the picture at the bind frame is
|
|
23
|
+
* byte-for-byte the frame Blender presented, and every other frame is three's
|
|
24
|
+
* own evaluation of the same skin over the same columns. Nothing has to move
|
|
25
|
+
* Blender's frame, ever.
|
|
26
|
+
*
|
|
27
|
+
* ## What that costs, stated rather than implied
|
|
28
|
+
*
|
|
29
|
+
* While a clip plays, the picture is three.js's skinning of the EXPORT-FRAME
|
|
30
|
+
* mesh. Anything else Blender's depsgraph would do per frame — shape keys, a
|
|
31
|
+
* Displace or Cast or Cloth modifier reading the frame, a driver on geometry —
|
|
32
|
+
* does NOT follow the mixer. The Timeline's status line names any such
|
|
33
|
+
* modifier on the played object rather than showing a confident picture of the
|
|
34
|
+
* wrong thing (`modifiersNotPlayed`). A bone whose pose is a CONSTRAINT's
|
|
35
|
+
* rather than its channels' is named the same way, because the clip is derived
|
|
36
|
+
* from the F-Curves alone.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type {
|
|
40
|
+
BlenderActionClip,
|
|
41
|
+
BlenderRig,
|
|
42
|
+
BlenderRigBinding,
|
|
43
|
+
} from '@volter/blender-engine/browser/rna';
|
|
44
|
+
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
45
|
+
import * as THREE from 'three';
|
|
46
|
+
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
47
|
+
|
|
48
|
+
/** Base64 → the typed array it holds. The same three lines every payload in
|
|
49
|
+
* this package decodes with (`blender-uv-geometry.ts`'s `uvBytes`); kept
|
|
50
|
+
* local because this module is in the presenter's closure and that one is in
|
|
51
|
+
* the UV view's. */
|
|
52
|
+
function bytesOf(base64: string): Uint8Array {
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
function float32Of(base64: string): Float32Array {
|
|
59
|
+
const bytes = bytesOf(base64);
|
|
60
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
|
|
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
|
+
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
|
+
/**
|
|
107
|
+
* THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
|
|
108
|
+
* is (`blender-runtime.document.tsx`): the Python session outlives workspace
|
|
109
|
+
* switches and document remounts, so the skeleton it bound must too. The
|
|
110
|
+
* Timeline document reads and drives this exact instance; there is no second
|
|
111
|
+
* copy of the playback state anywhere.
|
|
112
|
+
*/
|
|
113
|
+
export class BlenderSkinDirector {
|
|
114
|
+
#rigs = new Map<string, BoundRig>();
|
|
115
|
+
#mixer: THREE.AnimationMixer | null = null;
|
|
116
|
+
#action: THREE.AnimationAction | null = null;
|
|
117
|
+
#clip: BlenderActionClip | null = null;
|
|
118
|
+
/** The frame the last SEEK asked for — see {@link frame}. */
|
|
119
|
+
#seeked: number | null = null;
|
|
120
|
+
/** The stage transport this skin is attached to, or null before `attachTo`.
|
|
121
|
+
* The Timeline look drives THIS (Step 7); the skin holds no clock. */
|
|
122
|
+
#transport: StageTransportHandle | null = null;
|
|
123
|
+
#detach: (() => void) | null = null;
|
|
124
|
+
#listeners = new Set<() => void>();
|
|
125
|
+
#version = 0;
|
|
126
|
+
#warnings: string[] = [];
|
|
127
|
+
/** Engine calls this director has made, counted so a walk can prove that a
|
|
128
|
+
* scrub costs none (the acceptance the owner named). */
|
|
129
|
+
#engineCalls = 0;
|
|
130
|
+
|
|
131
|
+
get version(): number {
|
|
132
|
+
return this.#version;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get engineCalls(): number {
|
|
136
|
+
return this.#engineCalls;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
subscribe(listener: () => void): () => void {
|
|
140
|
+
this.#listeners.add(listener);
|
|
141
|
+
return () => this.#listeners.delete(listener);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#publish(): void {
|
|
145
|
+
this.#version++;
|
|
146
|
+
for (const listener of [...this.#listeners]) listener();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The clip's own facts plus the mixer's live time, expressed in BLENDER
|
|
150
|
+
* FRAMES — which is the only number the Timeline draws, and the only one a
|
|
151
|
+
* person or an agent ever names. */
|
|
152
|
+
state(): {
|
|
153
|
+
frame: number;
|
|
154
|
+
start: number;
|
|
155
|
+
end: number;
|
|
156
|
+
fps: number;
|
|
157
|
+
action: string | null;
|
|
158
|
+
object: string | null;
|
|
159
|
+
armature: string | null;
|
|
160
|
+
bones: number;
|
|
161
|
+
keyframes: readonly { frame: number; type: string; select: boolean }[];
|
|
162
|
+
/** Every animated object's own columns plus its SELECTION — what the
|
|
163
|
+
* summary row's `show_only_selected` filter chooses between. The mixer
|
|
164
|
+
* never reads it: what plays is the bound action, filter or no filter,
|
|
165
|
+
* exactly as in Blender. */
|
|
166
|
+
summary: readonly {
|
|
167
|
+
object: string;
|
|
168
|
+
action: string;
|
|
169
|
+
selected: boolean;
|
|
170
|
+
keyframes: readonly { frame: number; type: string; select: boolean }[];
|
|
171
|
+
}[];
|
|
172
|
+
clipStart: number | null;
|
|
173
|
+
clipEnd: number | null;
|
|
174
|
+
tracks: number;
|
|
175
|
+
bound: readonly string[];
|
|
176
|
+
warnings: readonly string[];
|
|
177
|
+
engineCalls: number;
|
|
178
|
+
/** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
|
|
179
|
+
* which is a different number from `frame` on purpose. `frame` is where
|
|
180
|
+
* the person is looking (the mixer's); this is where bpy and the
|
|
181
|
+
* Properties rail are. They agree after a pause or a scrub-end, and they
|
|
182
|
+
* are deliberately allowed to differ in between. */
|
|
183
|
+
blenderFrame: number | null;
|
|
184
|
+
} {
|
|
185
|
+
const clip = this.#clip;
|
|
186
|
+
const start = clip?.frameStart ?? 1;
|
|
187
|
+
const end = clip?.frameEnd ?? 250;
|
|
188
|
+
const fps = clip?.fps ?? 24;
|
|
189
|
+
return {
|
|
190
|
+
frame: this.frame(),
|
|
191
|
+
start,
|
|
192
|
+
end,
|
|
193
|
+
fps,
|
|
194
|
+
action: clip?.action ?? null,
|
|
195
|
+
object: clip?.object ?? null,
|
|
196
|
+
armature: clip?.armature ?? null,
|
|
197
|
+
bones: [...this.#rigs.values()].reduce((sum, rig) => sum + rig.bones.length, 0),
|
|
198
|
+
keyframes: clip?.keyframes ?? [],
|
|
199
|
+
summary: clip?.summary ?? [],
|
|
200
|
+
clipStart: clip?.clipStart ?? null,
|
|
201
|
+
clipEnd: clip?.clipEnd ?? null,
|
|
202
|
+
tracks: clip?.tracks.length ?? 0,
|
|
203
|
+
bound: [...this.#rigs.keys()],
|
|
204
|
+
warnings: this.#warnings,
|
|
205
|
+
engineCalls: this.#engineCalls,
|
|
206
|
+
blenderFrame: clip?.frameCurrent ?? null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The mixer's time as a Blender frame. With no clip the scene's own current
|
|
211
|
+
* frame stands, which is Blender's answer for a file with no animation. */
|
|
212
|
+
frame(): number {
|
|
213
|
+
const clip = this.#clip;
|
|
214
|
+
if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
|
|
215
|
+
// A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
|
|
216
|
+
// `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
|
|
217
|
+
// `time === duration` and reads back as the FIRST — measured on the first
|
|
218
|
+
// walk, where `jump-end` answered frame 1 over a frame-48 pose. The
|
|
219
|
+
// wrapping is right for playback and wrong for a question, so a standing
|
|
220
|
+
// seek answers with the frame it was given and the play tick clears it.
|
|
221
|
+
if (this.#seeked !== null) return this.#seeked;
|
|
222
|
+
// THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
|
|
223
|
+
// looping: `AnimationMixer.time` is monotonic and never wraps, while
|
|
224
|
+
// `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
|
|
225
|
+
// Measured on the first walk: two seconds of playback over a 47-frame clip
|
|
226
|
+
// read as frame 63.5 off the mixer, where the picture was correctly back
|
|
227
|
+
// near the start.
|
|
228
|
+
return clip.clipStart + this.#action.time * clip.fps;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether anything is actually playable — a Timeline over a file with no
|
|
232
|
+
* action still draws its ruler and its range. */
|
|
233
|
+
get playable(): boolean {
|
|
234
|
+
return this.#action !== null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ------------------------------------------------------------ the binding
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Bind every rigged mesh the frame carries, and load the active action.
|
|
241
|
+
*
|
|
242
|
+
* IDEMPOTENT BY SIGNATURE: a rig whose mesh, armature, vertex count, bone
|
|
243
|
+
* count and export frame are unchanged is left exactly as it is, mixer time
|
|
244
|
+
* included. That is what lets the presenter call this after EVERY frame
|
|
245
|
+
* without the picture jumping back to the bind pose each time something
|
|
246
|
+
* unrelated moved.
|
|
247
|
+
*/
|
|
248
|
+
async bind(
|
|
249
|
+
presentation: SkinPresentation,
|
|
250
|
+
read: {
|
|
251
|
+
rig(): Promise<BlenderRig | null>;
|
|
252
|
+
clip(): Promise<BlenderActionClip | null>;
|
|
253
|
+
},
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
this.#engineCalls++;
|
|
256
|
+
const answer = await read.rig();
|
|
257
|
+
if (!answer) return;
|
|
258
|
+
const warnings: string[] = [];
|
|
259
|
+
const wanted = new Set<string>();
|
|
260
|
+
let changed = false;
|
|
261
|
+
for (const rig of answer.rigs) {
|
|
262
|
+
if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
|
|
263
|
+
if (rig.reason) warnings.push(rig.reason);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
wanted.add(rig.object);
|
|
267
|
+
const signature = rigSignature(rig, answer.frame);
|
|
268
|
+
if (this.#rigs.get(rig.object)?.signature === signature) continue;
|
|
269
|
+
const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
|
|
270
|
+
if (bound) {
|
|
271
|
+
this.#rigs.get(rig.object)?.skeleton.dispose();
|
|
272
|
+
this.#rigs.set(rig.object, bound);
|
|
273
|
+
changed = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const [name, rig] of [...this.#rigs])
|
|
277
|
+
if (!wanted.has(name)) {
|
|
278
|
+
rig.skeleton.dispose();
|
|
279
|
+
rig.boneRoot.removeFromParent();
|
|
280
|
+
this.#rigs.delete(name);
|
|
281
|
+
changed = true;
|
|
282
|
+
}
|
|
283
|
+
this.#engineCalls++;
|
|
284
|
+
const clip = await read.clip();
|
|
285
|
+
const previous = this.#clip;
|
|
286
|
+
const movedAction =
|
|
287
|
+
clip?.action !== previous?.action ||
|
|
288
|
+
clip?.tracks.length !== previous?.tracks.length ||
|
|
289
|
+
clip?.clipStart !== previous?.clipStart ||
|
|
290
|
+
clip?.clipEnd !== previous?.clipEnd;
|
|
291
|
+
this.#clip = clip ?? null;
|
|
292
|
+
if (clip?.reason) warnings.push(clip.reason);
|
|
293
|
+
if (clip && (movedAction || changed)) this.#loadClip(presentation, clip, warnings);
|
|
294
|
+
// BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
|
|
295
|
+
// `scene.frame_current` in bpy has said where it wants to be, and a
|
|
296
|
+
// present is how we hear about it; while the transport is PLAYING it would
|
|
297
|
+
// be the Timeline arguing with itself, so it is honoured only at rest.
|
|
298
|
+
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
299
|
+
if (clip && !playing && previous?.frameCurrent !== clip.frameCurrent) {
|
|
300
|
+
this.#seek(clip.frameCurrent);
|
|
301
|
+
// THE BOOKMARK READ, once per bind: the file says where it was left, and
|
|
302
|
+
// the transport is what everything else now asks.
|
|
303
|
+
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
304
|
+
}
|
|
305
|
+
this.#warnings = warnings;
|
|
306
|
+
this.#publish();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#bindOne(
|
|
310
|
+
presentation: SkinPresentation,
|
|
311
|
+
rig: BlenderRigBinding,
|
|
312
|
+
frame: number,
|
|
313
|
+
warnings: string[],
|
|
314
|
+
): BoundRig | null {
|
|
315
|
+
const meshObject = presentation.objectForBlenderName(rig.object ?? '');
|
|
316
|
+
const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
|
|
317
|
+
if (!meshObject || !armatureObject) return null;
|
|
318
|
+
const source = meshObject as THREE.Mesh;
|
|
319
|
+
const geometry = source.geometry;
|
|
320
|
+
if (!geometry) return null;
|
|
321
|
+
const blenderVertex = geometry.getAttribute('blenderVertex');
|
|
322
|
+
if (!blenderVertex) {
|
|
323
|
+
warnings.push(
|
|
324
|
+
`"${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.`,
|
|
325
|
+
);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
if (rig.vertexCount === 0) return null;
|
|
329
|
+
// THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
|
|
330
|
+
// ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
|
|
331
|
+
// where the weights live); a generative modifier — Subdivision, Mirror,
|
|
332
|
+
// Array — makes the EVALUATED mesh the export door ships a different
|
|
333
|
+
// vertex set, and a skin bound across that mismatch would weight the wrong
|
|
334
|
+
// vertices. Named, never silently drawn.
|
|
335
|
+
let highest = 0;
|
|
336
|
+
for (let i = 0; i < blenderVertex.count; i++)
|
|
337
|
+
highest = Math.max(highest, blenderVertex.getX(i));
|
|
338
|
+
if (highest >= rig.vertexCount) {
|
|
339
|
+
warnings.push(
|
|
340
|
+
`"${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.`,
|
|
341
|
+
);
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
|
|
345
|
+
const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
|
|
346
|
+
const drawn = blenderVertex.count;
|
|
347
|
+
const indices = new Uint16Array(drawn * 4);
|
|
348
|
+
const weights = new Float32Array(drawn * 4);
|
|
349
|
+
for (let i = 0; i < drawn; i++) {
|
|
350
|
+
const vertex = blenderVertex.getX(i);
|
|
351
|
+
for (let k = 0; k < 4; k++) {
|
|
352
|
+
indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
|
|
353
|
+
weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
357
|
+
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
358
|
+
|
|
359
|
+
const boneRoot = new THREE.Group();
|
|
360
|
+
boneRoot.name = `${rig.armature}:bones`;
|
|
361
|
+
const bones: THREE.Bone[] = [];
|
|
362
|
+
const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
|
|
363
|
+
const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
|
|
364
|
+
rig.bones.forEach((declared, index) => {
|
|
365
|
+
const bone = new THREE.Bone();
|
|
366
|
+
// THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
|
|
367
|
+
// arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
|
|
368
|
+
// therefore addresses bones by UUID rather than by name — three's
|
|
369
|
+
// `PropertyBinding` track-name grammar splits on `.`, and Blender's
|
|
370
|
+
// `hand.L` is an ordinary bone name.
|
|
371
|
+
bone.name = declared.name;
|
|
372
|
+
bones.push(bone);
|
|
373
|
+
const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
|
|
374
|
+
const local =
|
|
375
|
+
parentIndex === undefined
|
|
376
|
+
? armatureSpace[index]!.clone()
|
|
377
|
+
: armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
|
|
378
|
+
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
379
|
+
(parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
|
|
380
|
+
});
|
|
381
|
+
armatureObject.add(boneRoot);
|
|
382
|
+
|
|
383
|
+
const skinned = new THREE.SkinnedMesh(geometry, source.material);
|
|
384
|
+
skinned.name = source.name;
|
|
385
|
+
skinned.matrixAutoUpdate = false;
|
|
386
|
+
skinned.matrix.copy(source.matrix);
|
|
387
|
+
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
388
|
+
skinned.userData = source.userData;
|
|
389
|
+
// A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
|
|
390
|
+
// bounding sphere from the bind pose, so a raised arm at frame 24 is
|
|
391
|
+
// culled while its bind-pose sphere is off screen — a picture that
|
|
392
|
+
// vanishes mid-scrub with no error anywhere.
|
|
393
|
+
skinned.frustumCulled = false;
|
|
394
|
+
presentation.replacePresentedObject(source, skinned);
|
|
395
|
+
// THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
|
|
396
|
+
// WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
|
|
397
|
+
// at this instant and `bindMatrix` is the mesh's.
|
|
398
|
+
presentation.root.updateMatrixWorld(true);
|
|
399
|
+
const skeleton = new THREE.Skeleton(bones);
|
|
400
|
+
skinned.bind(skeleton, skinned.matrixWorld.clone());
|
|
401
|
+
if (rig.constrainedBones?.length)
|
|
402
|
+
warnings.push(
|
|
403
|
+
`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.`,
|
|
404
|
+
);
|
|
405
|
+
if (rig.unmappedGroups?.length)
|
|
406
|
+
warnings.push(
|
|
407
|
+
`${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
|
|
408
|
+
);
|
|
409
|
+
return {
|
|
410
|
+
object: rig.object ?? '',
|
|
411
|
+
armature: rig.armature ?? '',
|
|
412
|
+
mesh: skinned,
|
|
413
|
+
boneRoot,
|
|
414
|
+
bones,
|
|
415
|
+
skeleton,
|
|
416
|
+
frame,
|
|
417
|
+
signature: rigSignature(rig, frame),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
|
|
422
|
+
this.#mixer?.stopAllAction();
|
|
423
|
+
this.#mixer = null;
|
|
424
|
+
this.#action = null;
|
|
425
|
+
if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
426
|
+
const armature = clip.armature ? presentation.objectForBlenderName(clip.armature) : null;
|
|
427
|
+
if (!armature) return;
|
|
428
|
+
const byName = new Map<string, THREE.Bone>();
|
|
429
|
+
for (const rig of this.#rigs.values())
|
|
430
|
+
if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
|
|
431
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
432
|
+
const missing = new Set<string>();
|
|
433
|
+
for (const track of clip.tracks) {
|
|
434
|
+
const bone = byName.get(track.bone);
|
|
435
|
+
if (!bone) {
|
|
436
|
+
missing.add(track.bone);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const times = float32Of(track.timeBase64);
|
|
440
|
+
const values = float32Of(track.valueBase64);
|
|
441
|
+
// ADDRESSED BY UUID, not by name — see the bone-naming note above.
|
|
442
|
+
const path = `${bone.uuid}.${track.property}`;
|
|
443
|
+
tracks.push(
|
|
444
|
+
track.property === 'quaternion'
|
|
445
|
+
? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
|
|
446
|
+
: new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (missing.size)
|
|
450
|
+
warnings.push(
|
|
451
|
+
`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`,
|
|
452
|
+
);
|
|
453
|
+
if (!tracks.length) return;
|
|
454
|
+
const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
455
|
+
const mixer = new THREE.AnimationMixer(armature);
|
|
456
|
+
const action = mixer.clipAction(
|
|
457
|
+
new THREE.AnimationClip(clip.action ?? 'action', duration, tracks),
|
|
458
|
+
);
|
|
459
|
+
action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
|
460
|
+
action.play();
|
|
461
|
+
this.#mixer = mixer;
|
|
462
|
+
this.#action = action;
|
|
463
|
+
// THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
|
|
464
|
+
// the invariant this whole design rests on: the mesh columns were exported
|
|
465
|
+
// at `frameCurrent` and the skeleton was bound in that pose, so seeking
|
|
466
|
+
// anywhere else would make the first picture after a re-bind disagree with
|
|
467
|
+
// the geometry underneath it. Measured on the first walk: a pause wrote
|
|
468
|
+
// frame 16, the write presented, the present re-exported the mesh at 16
|
|
469
|
+
// and re-bound — and `setTime(0)` snapped the playhead back to frame 1
|
|
470
|
+
// over a frame-16 mesh.
|
|
471
|
+
this.#seek(clip.frameCurrent);
|
|
472
|
+
this.#refresh();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ------------------------------------------------------------ the playback
|
|
476
|
+
|
|
477
|
+
#seek(frame: number): void {
|
|
478
|
+
const clip = this.#clip;
|
|
479
|
+
if (!clip || !this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
480
|
+
const clamped = Math.min(clip.clipEnd, Math.max(clip.clipStart, frame));
|
|
481
|
+
this.#seeked = clamped;
|
|
482
|
+
// The TIME is nudged inside the clip so the last frame evaluates as the
|
|
483
|
+
// last frame rather than wrapping to the first; the number REPORTED is the
|
|
484
|
+
// one asked for (see `frame`).
|
|
485
|
+
const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
486
|
+
this.#mixer.setTime(Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps));
|
|
487
|
+
this.#refresh();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The bones moved; make the world matrices agree before the stage's next
|
|
491
|
+
* render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
|
|
492
|
+
* pass over a handful of nodes rather than a recomposition of the scene. */
|
|
493
|
+
#refresh(): void {
|
|
494
|
+
for (const rig of this.#rigs.values()) {
|
|
495
|
+
rig.boneRoot.updateMatrixWorld(true);
|
|
496
|
+
rig.skeleton.update();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The scene's name, for the one address the Timeline writes to. */
|
|
501
|
+
get scene(): string | null {
|
|
502
|
+
return this.#clip?.scene ?? null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Hand Blender back the frame the person is looking at — THE BOOKMARK.
|
|
506
|
+
*
|
|
507
|
+
* ONCE, HERE — never per played frame. `scene.frame_current` is what every
|
|
508
|
+
* bpy reader and the whole Properties rail agree with, so leaving it behind
|
|
509
|
+
* while the picture moved would be the Timeline lying to the rest of the
|
|
510
|
+
* session; writing it sixty times a second would be the architecture this
|
|
511
|
+
* module exists to avoid. The transport's `onSettled` is what calls this —
|
|
512
|
+
* a pause, or the quiet at the end of a scrub — so a slider drag writes
|
|
513
|
+
* once. Moved verbatim from the Timeline look, which is no longer where a
|
|
514
|
+
* playhead write belongs. */
|
|
515
|
+
async writeBookmark(): Promise<number> {
|
|
516
|
+
const frame = Math.round(this.frame());
|
|
517
|
+
const clip = this.#clip;
|
|
518
|
+
if (!clip) return frame;
|
|
519
|
+
const scene = clip.scene;
|
|
520
|
+
if (!scene || clip.frameCurrent === frame) return frame;
|
|
521
|
+
this.#engineCalls++;
|
|
522
|
+
// `rna_set` refuses `bpy.context.scene` — the scene must be addressed by
|
|
523
|
+
// name through `bpy.data.scenes[...]`.
|
|
524
|
+
await blenderRnaSet(`bpy.data.scenes[${JSON.stringify(scene)}]`, 'frame_current', frame);
|
|
525
|
+
this.#clip = { ...clip, frameCurrent: frame };
|
|
526
|
+
this.#publish();
|
|
527
|
+
return frame;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The handle this skin was attached to, for the Timeline look to drive.
|
|
531
|
+
* Published through the existing `subscribe`/`version`. */
|
|
532
|
+
get transport(): StageTransportHandle | null {
|
|
533
|
+
return this.#transport;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* ATTACH THIS SKIN TO A STAGE'S TRANSPORT — the Model document calls it with
|
|
538
|
+
* its own document id's handle, because that document is the one that HAS
|
|
539
|
+
* the id (the Timeline binds to this singleton and cannot name one).
|
|
540
|
+
*
|
|
541
|
+
* The subject is the action: Blender has no several-clips-per-subject
|
|
542
|
+
* question, so `clips`/`setClip` are deliberately absent. Seconds are the
|
|
543
|
+
* seam; the frames↔seconds conversion is this file's edge and the look's,
|
|
544
|
+
* and nowhere in between.
|
|
545
|
+
*/
|
|
546
|
+
attachTo(transport: StageTransportHandle): () => void {
|
|
547
|
+
this.#detach?.();
|
|
548
|
+
this.#transport = transport;
|
|
549
|
+
const detachSubject = transport.attach({
|
|
550
|
+
id: this.#clip?.action ?? 'blender-action',
|
|
551
|
+
label: this.#clip?.action ?? 'Action',
|
|
552
|
+
range: () => {
|
|
553
|
+
const clip = this.#clip;
|
|
554
|
+
const fps = clip?.fps || 24;
|
|
555
|
+
return {
|
|
556
|
+
start: (clip?.frameStart ?? 1) / fps,
|
|
557
|
+
end: (clip?.frameEnd ?? 250) / fps,
|
|
558
|
+
fps,
|
|
559
|
+
};
|
|
560
|
+
},
|
|
561
|
+
seek: (seconds) => this.seekSeconds(seconds),
|
|
562
|
+
});
|
|
563
|
+
const stopSettled = transport.onSettled(() => {
|
|
564
|
+
void this.writeBookmark();
|
|
565
|
+
});
|
|
566
|
+
const detach = () => {
|
|
567
|
+
stopSettled();
|
|
568
|
+
detachSubject();
|
|
569
|
+
if (this.#transport === transport) this.#transport = null;
|
|
570
|
+
this.#detach = null;
|
|
571
|
+
};
|
|
572
|
+
this.#detach = detach;
|
|
573
|
+
// The bookmark READ, if the clip is already loaded when we attach.
|
|
574
|
+
const clip = this.#clip;
|
|
575
|
+
if (clip && clip.fps > 0) transport.seek(clip.frameCurrent / clip.fps);
|
|
576
|
+
return detach;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The transport's one write, in ITS unit. Frames are Blender's; seconds are
|
|
580
|
+
* the seam's; this is the single conversion on this side. */
|
|
581
|
+
seekSeconds(seconds: number): void {
|
|
582
|
+
const fps = this.#clip?.fps || 24;
|
|
583
|
+
this.#seek(Math.round(seconds * fps));
|
|
584
|
+
this.#publish();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The frames the summary row draws a diamond at, sorted. */
|
|
588
|
+
keyframes(): readonly number[] {
|
|
589
|
+
return (this.#clip?.keyframes ?? []).map((column) => column.frame);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
dispose(): void {
|
|
593
|
+
this.#detach?.();
|
|
594
|
+
this.#mixer?.stopAllAction();
|
|
595
|
+
this.#mixer = null;
|
|
596
|
+
this.#action = null;
|
|
597
|
+
for (const rig of this.#rigs.values()) {
|
|
598
|
+
rig.skeleton.dispose();
|
|
599
|
+
rig.boneRoot.removeFromParent();
|
|
600
|
+
}
|
|
601
|
+
this.#rigs.clear();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** THE ONE DIRECTOR — see the class comment for why it is module-scoped. */
|
|
606
|
+
export const blenderSkin = new BlenderSkinDirector();
|
|
607
|
+
|
|
608
|
+
/** STABLE FUNCTION IDENTITIES for `useSyncExternalStore`, and they are not
|
|
609
|
+
* ceremony: passing `blenderSkin.subscribe.bind(blenderSkin)` inline mints a
|
|
610
|
+
* NEW subscribe and a NEW getSnapshot on every render, so React tears the
|
|
611
|
+
* subscription down and rebuilds it each time — measured on the Timeline's
|
|
612
|
+
* first walk, where the view bound its rig and its published `drawn` never
|
|
613
|
+
* moved again because no re-render ever arrived. */
|
|
614
|
+
export function subscribeBlenderSkin(listener: () => void): () => void {
|
|
615
|
+
return blenderSkin.subscribe(listener);
|
|
616
|
+
}
|
|
617
|
+
export function blenderSkinVersion(): number {
|
|
618
|
+
return blenderSkin.version;
|
|
619
|
+
}
|