@volter/editor-blender 0.1.150 → 0.1.151

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.
Files changed (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +100 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +142 -332
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +43 -23
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +12 -32
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -43
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -0,0 +1,369 @@
1
+ /**
2
+ * ANIMATION ON THREE.JS'S OWN MIXER. Blender holds the animation and samples it; three.js plays it.
3
+ *
4
+ * - A CLIP is an action as Blender evaluates it, sampled once by Blender itself (`session.py`'s
5
+ * `_three_bones`: every channel through `FCurve.evaluate`, composed into each bone's local
6
+ * transform at every integer frame), and played as a `THREE.AnimationClip` of position,
7
+ * quaternion and scale tracks. Keys, handles, easing, extrapolation and modifiers are therefore
8
+ * Blender's at every frame; between frames three.js interpolates (linear, slerp).
9
+ * - A POSE is the layers the animation stack asks for (the NLA's strips and active action),
10
+ * each an `AnimationAction` bound to the armature's rig on the scene's one
11
+ * `AnimationMixer` (`blender-scene-mixer.ts`) at the frame and influence the stack names. The
12
+ * mixer blends them, with every other armature's and the movie's, in one evaluation: `place`
13
+ * sets the actions, the scene evaluates, `settle` turns the constraints and reads the pose.
14
+ * - What the mixer does not do on its own is filled in here:
15
+ * - REPLACE OVER REPLACE. A layer replaces what is under it only for the bones it animates, at
16
+ * its influence: a bone's lower layer is weighted by `1 - influence` of every higher layer that
17
+ * animates that bone. So each layer plays as groups of its tracks, one per set of higher layers
18
+ * covering them (an upper-body action over a run covers the arms, not the legs).
19
+ * - ADD and COMBINE play additively over what is under them, relative to the bone's rest.
20
+ * - DAMPED TRACK turns a bone, after the mixer, by the smallest rotation that points its track
21
+ * axis at the target, taken by the constraint's influence.
22
+ * SUBTRACT and MULTIPLY blends, other constraints, drivers and non-default parenting are named
23
+ * (`unsupported`), not played.
24
+ */
25
+ import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
26
+ import * as THREE from 'three';
27
+ import type { BlenderActionClip, BlenderClipColumn } from '@volter/blender-engine/browser/rna';
28
+ import type { BlenderArmature, BlenderArmatureBone } from '@volter/blender-engine/browser/three/blender-runtime-armature';
29
+ import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
30
+
31
+ function float32Of(base64: string): Float32Array {
32
+ const bytes = bytesFromBase64(base64);
33
+ return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
34
+ }
35
+
36
+ /** One bone of a clip: its sampled local transform, and the frames it is keyed on. */
37
+ interface ClipBone {
38
+ readonly keys: readonly number[];
39
+ readonly position: Float32Array;
40
+ readonly quaternion: Float32Array;
41
+ readonly scale: Float32Array;
42
+ }
43
+
44
+ /** One action as three.js plays it, for any armature with the bones it names. */
45
+ export interface MixerClip {
46
+ readonly action: string;
47
+ readonly start: number;
48
+ readonly end: number;
49
+ readonly fps: number;
50
+ /** Where Blender holds the active action over NLA strips; infinite where Cycles repeats it. */
51
+ readonly keysStart: number;
52
+ readonly keysEnd: number;
53
+ /** Its bones by name. */
54
+ readonly bones: ReadonlyMap<string, ClipBone>;
55
+ /** What its curves do in Blender that is not played. */
56
+ readonly unsupported: readonly string[];
57
+ }
58
+
59
+ /** The clip door's sampled bones as a clip, or null when it holds nothing a pose can take. */
60
+ export function mixerClip(clip: BlenderActionClip): MixerClip | null {
61
+ if (!clip.action || clip.clipStart === undefined || clip.clipEnd === undefined || !clip.bones?.length) return null;
62
+ const column = (c: BlenderClipColumn): Float32Array => float32Of(c.base64);
63
+ const bones = new Map<string, ClipBone>(clip.bones.map((bone) => [bone.bone, {
64
+ keys: bone.keys, position: column(bone.position), quaternion: column(bone.quaternion), scale: column(bone.scale),
65
+ }]));
66
+ const unsupported = new Set<string>();
67
+ for (const track of clip.tracks) for (const thing of track.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
68
+ for (const thing of clip.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
69
+ return {
70
+ action: clip.action, start: clip.clipStart, end: clip.clipEnd, fps: clip.fps,
71
+ keysStart: clip.keysStart === null ? -Infinity : clip.keysStart!,
72
+ keysEnd: clip.keysEnd === null ? Infinity : clip.keysEnd!,
73
+ bones, unsupported: [...unsupported],
74
+ };
75
+ }
76
+
77
+ /** One layer of the stack: an action at a frame of its own, an influence, a blend type. */
78
+ export interface MixerLayer {
79
+ readonly clip: MixerClip;
80
+ readonly frame: number;
81
+ readonly influence: number;
82
+ readonly blend: string;
83
+ }
84
+
85
+ /** Where a constraint's targets are, in the scene the bones are in. */
86
+ export interface ConstraintWorld {
87
+ /** An object by its Blender name (its presented copy). */
88
+ object(name: string): THREE.Object3D | null;
89
+ }
90
+
91
+ const ADDITIVE = new Set(['ADD', 'COMBINE']);
92
+ const PLAYED_BLENDS = new Set(['REPLACE', 'ADD', 'COMBINE']);
93
+ const TRACK_VECTORS: Record<string, readonly [number, number, number]> = {
94
+ TRACK_X: [1, 0, 0], TRACK_Y: [0, 1, 0], TRACK_Z: [0, 0, 1],
95
+ TRACK_NEGATIVE_X: [-1, 0, 0], TRACK_NEGATIVE_Y: [0, -1, 0], TRACK_NEGATIVE_Z: [0, 0, -1],
96
+ };
97
+
98
+ const matrixOf = (rows: readonly (readonly number[])[]): THREE.Matrix4 =>
99
+ new THREE.Matrix4().set(...(rows.flat() as Parameters<THREE.Matrix4['set']>));
100
+
101
+ /** A bone's channels as its basis matrix (location, rotation by its mode, scale). */
102
+ function basisOf(channels: BlenderArmatureBone['channels']): THREE.Matrix4 {
103
+ const rotation = new THREE.Quaternion();
104
+ if (channels.mode === 'QUATERNION') {
105
+ const [w, x, y, z] = channels.rotation_quaternion;
106
+ rotation.set(x!, y!, z!, w!).normalize();
107
+ } else if (channels.mode === 'AXIS_ANGLE') {
108
+ const [angle, x, y, z] = channels.rotation_axis_angle;
109
+ const axis = new THREE.Vector3(x, y, z);
110
+ rotation.setFromAxisAngle(axis.lengthSq() > 0 ? axis.normalize() : new THREE.Vector3(0, 1, 0), angle!);
111
+ } else {
112
+ // Blender's `XYZ` turns about X first; three names an order by its matrix product, reversed.
113
+ const [x, y, z] = channels.rotation_euler;
114
+ rotation.setFromEuler(new THREE.Euler(x, y, z, channels.mode.split('').reverse().join('') as THREE.EulerOrder));
115
+ }
116
+ return new THREE.Matrix4().compose(new THREE.Vector3(...channels.location), rotation, new THREE.Vector3(...channels.scale));
117
+ }
118
+
119
+ const label = (type: string): string =>
120
+ type.toLowerCase().split('_').map((word) => (word === 'ik' ? 'IK' : word[0]!.toUpperCase() + word.slice(1))).join(' ');
121
+
122
+ /** One armature's bones, posed by the stack: its actions on the scene's mixer, bound to its rig. */
123
+ export class MixerPose {
124
+ #armature: BlenderArmature | null = null;
125
+ #bones: readonly BlenderArmatureBone[] = [];
126
+ /** Each bone's local transform with no action on it: its rest, then its own channels. */
127
+ #still = new Map<string, THREE.Matrix4>();
128
+ /** Each bone's local rest (basis identity): what an additive layer adds to. */
129
+ #rest = new Map<string, THREE.Matrix4>();
130
+ /** Clip pieces by clip, then by the bones they hold (a key of sorted names). */
131
+ #pieces = new WeakMap<MixerClip, Map<string, THREE.AnimationClip>>();
132
+ #actions = new Map<THREE.AnimationClip, THREE.AnimationAction>();
133
+ #warned = new Set<string>();
134
+ readonly warnings: string[] = [];
135
+
136
+ constructor(readonly rig: ArmatureRig, private readonly mixer: THREE.AnimationMixer) {}
137
+
138
+ /** Take the frame's facts for this armature (its bones' rest, channels and constraints). */
139
+ facts(armature: BlenderArmature): void {
140
+ if (armature === this.#armature) return;
141
+ const first = this.#armature === null;
142
+ this.#armature = armature;
143
+ this.#bones = armature.bones;
144
+ const rest = new Map(armature.bones.map((bone) => [bone.name, matrixOf(bone.rest)]));
145
+ this.#rest = new Map(armature.bones.map((bone) => {
146
+ const own = rest.get(bone.name)!;
147
+ const parent = bone.parent === null ? undefined : rest.get(bone.parent);
148
+ return [bone.name, parent ? parent.clone().invert().multiply(own) : own.clone()];
149
+ }));
150
+ this.#still = new Map(armature.bones.map((bone) => [bone.name, this.#rest.get(bone.name)!.clone().multiply(basisOf(bone.channels))]));
151
+ // THE MIXER'S ORIGINAL STATE is the still pose: a binding saves the bone's value when it is
152
+ // first played, and blends back to it where the layers' weights fall short of one.
153
+ if (first) this.#placeStill();
154
+ }
155
+
156
+ get armature(): BlenderArmature | null {
157
+ return this.#armature;
158
+ }
159
+
160
+ #placeStill(): void {
161
+ for (const [name, matrix] of this.#still) {
162
+ const bone = this.rig.bones.get(name);
163
+ if (bone) matrix.decompose(bone.position, bone.quaternion, bone.scale);
164
+ }
165
+ }
166
+
167
+ /** Armature behavior omitted by the three.js preview, each condition named once. */
168
+ unsupported(): string[] {
169
+ const out: string[] = [];
170
+ for (const bone of this.#bones) {
171
+ for (const constraint of bone.constraints ?? [])
172
+ if (constraint.enabled && constraint.type !== 'DAMPED_TRACK') out.push(`the ${label(constraint.type)} constraint "${constraint.name}" on bone "${bone.name}"`);
173
+ if (bone.inherit?.length) out.push(`bone "${bone.name}"'s parenting (${bone.inherit.join(', ')})`);
174
+ }
175
+ for (const bone of this.#armature?.animation?.drivers ?? []) out.push(`the drivers on bone "${bone}"`);
176
+ if (this.#armature?.animation?.tweak) out.push('the NLA in tweak mode (leave it with Tab in the NLA editor)');
177
+ for (const track of this.#armature?.animation?.tracks ?? [])
178
+ for (const strip of track.strips)
179
+ if (!strip.mute && strip.animatedTime) out.push(`the animated strip time of "${strip.name}"`);
180
+ return out;
181
+ }
182
+
183
+ /** A piece of a clip holding only `bones`, as a three.js clip on this rig's bones. */
184
+ #piece(clip: MixerClip, bones: readonly string[], additive: boolean): THREE.AnimationClip {
185
+ let byKey = this.#pieces.get(clip);
186
+ if (!byKey) this.#pieces.set(clip, byKey = new Map());
187
+ const key = `${additive ? '+' : '='}${bones.join('\u0000')}`;
188
+ const found = byKey.get(key);
189
+ if (found) return found;
190
+ const frames = Math.max(1, Math.round(clip.end - clip.start) + 1);
191
+ const times = Float32Array.from({ length: frames }, (_, i) => i / clip.fps);
192
+ const tracks: THREE.KeyframeTrack[] = [];
193
+ for (const name of bones) {
194
+ const sampled = clip.bones.get(name);
195
+ const bone = this.rig.bones.get(name);
196
+ if (!sampled || !bone) continue;
197
+ const column = (values: Float32Array, stride: number): { times: Float32Array; values: Float32Array } =>
198
+ values.length === stride ? { times: new Float32Array([0]), values } : { times: times.slice(0, values.length / stride), values };
199
+ const p = column(sampled.position, 3);
200
+ const q = column(sampled.quaternion, 4);
201
+ const s = column(sampled.scale, 3);
202
+ tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.position`, p.times, p.values));
203
+ tracks.push(new THREE.QuaternionKeyframeTrack(`${bone.uuid}.quaternion`, q.times, q.values));
204
+ tracks.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.scale`, s.times, s.values));
205
+ }
206
+ let made = new THREE.AnimationClip(`${clip.action}${key}`, (frames - 1) / clip.fps, tracks);
207
+ if (additive) {
208
+ // ADDED TO THE BONE'S REST: the reference is each bone at its rest (basis identity).
209
+ const reference: THREE.KeyframeTrack[] = [];
210
+ const [p, q, s] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
211
+ for (const name of bones) {
212
+ const bone = this.rig.bones.get(name);
213
+ const rest = this.#rest.get(name);
214
+ if (!bone || !rest || !clip.bones.has(name)) continue;
215
+ rest.decompose(p, q, s);
216
+ reference.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.position`, [0], p.toArray()));
217
+ reference.push(new THREE.QuaternionKeyframeTrack(`${bone.uuid}.quaternion`, [0], q.toArray()));
218
+ reference.push(new THREE.VectorKeyframeTrack(`${bone.uuid}.scale`, [0], s.toArray()));
219
+ }
220
+ made = THREE.AnimationUtils.makeClipAdditive(made, 0, new THREE.AnimationClip('rest', 0, reference));
221
+ }
222
+ byKey.set(key, made);
223
+ return made;
224
+ }
225
+
226
+ #action(clip: THREE.AnimationClip, additive: boolean): THREE.AnimationAction {
227
+ let action = this.#actions.get(clip);
228
+ if (!action) {
229
+ action = this.mixer.clipAction(clip, this.rig.object, additive ? THREE.AdditiveAnimationBlendMode : THREE.NormalAnimationBlendMode);
230
+ action.setLoop(THREE.LoopOnce, 1);
231
+ action.clampWhenFinished = true;
232
+ action.timeScale = 0;
233
+ this.#actions.set(clip, action);
234
+ }
235
+ return action;
236
+ }
237
+
238
+ /** SET THE ACTIONS for these layers (bottom to top): their times and weights; nothing moves until
239
+ * the mixer evaluates. The actions no layer uses now stop. */
240
+ place(layers: readonly MixerLayer[]): void {
241
+ const used = new Set<THREE.AnimationAction>();
242
+ layers.forEach((layer, index) => {
243
+ const influence = Math.max(0, Math.min(1, layer.influence));
244
+ if (influence <= 0) return;
245
+ if (!PLAYED_BLENDS.has(layer.blend)) {
246
+ const said = `the ${layer.blend.toLowerCase()} blend of "${layer.clip.action}" (played as Replace)`;
247
+ if (!this.#warned.has(said)) { this.#warned.add(said); this.warnings.push(said); }
248
+ }
249
+ const additive = ADDITIVE.has(layer.blend);
250
+ const time = Math.max(0, Math.min(layer.clip.end, layer.frame) - layer.clip.start) / layer.clip.fps;
251
+ // EACH BONE'S WEIGHT under the replacing layers above it that animate that bone.
252
+ const groups = new Map<string, { bones: string[]; weight: number }>();
253
+ for (const bone of layer.clip.bones.keys()) {
254
+ let weight = influence;
255
+ let key = '';
256
+ if (!additive) {
257
+ for (let above = index + 1; above < layers.length; above++) {
258
+ const upper = layers[above]!;
259
+ if (ADDITIVE.has(upper.blend) || !upper.clip.bones.has(bone)) continue;
260
+ weight *= 1 - Math.max(0, Math.min(1, upper.influence));
261
+ key += `${above},`;
262
+ }
263
+ }
264
+ const group = groups.get(key);
265
+ if (group) group.bones.push(bone);
266
+ else groups.set(key, { bones: [bone], weight });
267
+ }
268
+ for (const group of groups.values()) {
269
+ if (group.weight <= 0) continue;
270
+ const action = this.#action(this.#piece(layer.clip, group.bones, additive), additive);
271
+ if (!action.isRunning()) action.play();
272
+ action.paused = false;
273
+ action.time = time;
274
+ action.setEffectiveWeight(group.weight);
275
+ used.add(action);
276
+ }
277
+ });
278
+ for (const action of this.#actions.values()) if (!used.has(action) && action.isScheduled()) action.stop();
279
+ }
280
+
281
+ /** AFTER THE MIXER: the constraints, then each bone's pose in the armature's space. */
282
+ settle(world: ConstraintWorld): Map<string, THREE.Matrix4> {
283
+ this.rig.object.updateMatrixWorld(true);
284
+ this.#constraints(world);
285
+ return this.#poses();
286
+ }
287
+
288
+ /** DAMPED TRACK, after the mixer, parents first: the bone turned by the smallest rotation that
289
+ * points its track axis at the target, at the constraint's influence. */
290
+ #constraints(world: ConstraintWorld): void {
291
+ const [position, rotation, size] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
292
+ for (const declared of this.#bones) {
293
+ for (const constraint of declared.constraints ?? []) {
294
+ if (!constraint.enabled || constraint.type !== 'DAMPED_TRACK') continue;
295
+ const influence = constraint.influence;
296
+ const bone = this.rig.bones.get(declared.name);
297
+ if (influence <= 0 || !bone) continue;
298
+ const target = this.#target(constraint, world);
299
+ if (!target) continue;
300
+ bone.matrixWorld.decompose(position, rotation, size);
301
+ const axis = new THREE.Vector3(...TRACK_VECTORS[constraint.trackAxis!]!).applyQuaternion(rotation).normalize();
302
+ const toward = target.clone().sub(position);
303
+ if (toward.lengthSq() === 0) continue;
304
+ const turn = new THREE.Quaternion().setFromUnitVectors(axis, toward.normalize());
305
+ const taken = new THREE.Quaternion().slerp(turn, Math.min(1, influence));
306
+ const turnedWorld = new THREE.Matrix4().compose(position, taken.multiply(rotation), size);
307
+ const parentWorld = bone.parent ? bone.parent.matrixWorld : new THREE.Matrix4();
308
+ parentWorld.clone().invert().multiply(turnedWorld).decompose(bone.position, bone.quaternion, bone.scale);
309
+ bone.updateMatrixWorld(true);
310
+ }
311
+ }
312
+ }
313
+
314
+ /** A constraint's target point in world space: an object's origin, or a point along a bone. */
315
+ #target(constraint: NonNullable<BlenderArmatureBone['constraints']>[number], world: ConstraintWorld): THREE.Vector3 | null {
316
+ if (!constraint.target) return null;
317
+ if (constraint.subtarget && constraint.target === this.rig.armature) {
318
+ const bone = this.rig.bones.get(constraint.subtarget);
319
+ const declared = this.#bones.find((one) => one.name === constraint.subtarget);
320
+ if (!bone || !declared) return null;
321
+ return new THREE.Vector3(0, declared.length * constraint.headTail!, 0).applyMatrix4(bone.matrixWorld);
322
+ }
323
+ const object = world.object(constraint.target);
324
+ if (!object) return null;
325
+ object.updateWorldMatrix(true, false);
326
+ const presented = constraint.subtarget ? object.getObjectByName(constraint.subtarget) : object;
327
+ return presented ? new THREE.Vector3().setFromMatrixPosition(presented.matrixWorld) : null;
328
+ }
329
+
330
+ /** Each bone's pose in the armature object's space. */
331
+ #poses(): Map<string, THREE.Matrix4> {
332
+ const toArmature = this.rig.object.matrixWorld.clone().invert();
333
+ const poses = new Map<string, THREE.Matrix4>();
334
+ for (const [name, bone] of this.rig.bones) poses.set(name, toArmature.clone().multiply(bone.matrixWorld));
335
+ return poses;
336
+ }
337
+
338
+ /** Its actions off the mixer (only its own: on the scene's, the others play on). */
339
+ dispose(): void {
340
+ for (const [clip, action] of this.#actions) {
341
+ action.stop();
342
+ this.mixer.uncacheAction(clip, this.rig.object);
343
+ this.mixer.uncacheClip(clip);
344
+ }
345
+ this.#actions.clear();
346
+ }
347
+ }
348
+
349
+ /**
350
+ * THE FIDELITY CHECK: how far a played pose is from Blender's own at the same frame (the frame's
351
+ * `PoseBone.matrix`). Answers the bone that is furthest off, or null when every bone is within a
352
+ * degree and a hundredth of its length (three.js interpolates between Blender's frames).
353
+ */
354
+ export function poseDivergence(played: ReadonlyMap<string, THREE.Matrix4>, armature: BlenderArmature):
355
+ { bone: string; degrees: number; offset: number } | null {
356
+ let worst: { bone: string; degrees: number; offset: number; score: number } | null = null;
357
+ const [p0, q0, s0, p1, q1, s1] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
358
+ for (const bone of armature.bones) {
359
+ const ours = played.get(bone.name);
360
+ if (!ours) continue;
361
+ ours.decompose(p0, q0, s0);
362
+ matrixOf(bone.matrix).decompose(p1, q1, s1);
363
+ const degrees = THREE.MathUtils.radToDeg(q0.angleTo(q1));
364
+ const offset = p0.distanceTo(p1) / Math.max(bone.length, 0.1);
365
+ const score = Math.max(degrees / 1, offset / 1e-2);
366
+ if (score > 1 && (!worst || score > worst.score)) worst = { bone: bone.name, degrees, offset, score };
367
+ }
368
+ return worst && { bone: worst.bone, degrees: worst.degrees, offset: worst.offset };
369
+ }
@@ -2,7 +2,7 @@ import type { ModelDocumentPreview } from '../src/model-document-preview';
2
2
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
3
3
  import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
4
4
  import type { AuthoringAdapter, EditorNode } from '@volter/project/adapter';
5
- import { AssetEditorSubject } from '@volter/sdk/kit/components/AssetEditorShell';
5
+ import { AssetEditorSubject } from '@volter/sdk/components/AssetEditorShell';
6
6
  import { useEffect, useMemo, useState } from 'react';
7
7
 
8
8
  /** How long an open may run before the surface says it may have stalled and offers a retry. A
@@ -22,9 +22,7 @@ export function BlenderModelOpening({ documentId, path, error, preview, retry, r
22
22
  readonly note?: string | null;
23
23
  }) {
24
24
  const title = error ? 'Could not open model' : preparingView ? 'Preparing model view' : 'Opening model';
25
- // A STALLED OPEN SAYS SO: a playtester waited twenty minutes on "Preparing Blender" with no error,
26
- // no progress and no way out but reloading the page. After STALL_AFTER_MS the surface names the
27
- // wait and offers the same retry an error does.
25
+ // A stalled open offers the same retry as an error.
28
26
  const [startedAt] = useState(() => Date.now());
29
27
  const [now, setNow] = useState(() => Date.now());
30
28
  useEffect(() => {
@@ -62,9 +60,7 @@ export function BlenderModelOpening({ documentId, path, error, preview, retry, r
62
60
  description={<>
63
61
  <div style={{ color: themeVars.content.primary, marginBottom: spaceVar[3] }}>{path}</div>
64
62
  {error ? modelOpeningErrorMessage(error) : preparingView ? 'The file is open. Preparing its viewport.' : preview ? 'The previous model stays visible while this file opens.' : 'Preparing Blender and opening this file. The model will appear here when it is ready.'}
65
- {/* THE CAUSE, VERBATIM, where the person is looking: the sentence above is a
66
- category, and "details are available in the console" sent people to a console
67
- they could not see (2026-10-06 audit). Selectable, so it can be copied. */}
63
+ {/* Show the exact cause as selectable text where the person is looking. */}
68
64
  {stalled && <div data-testid="blender-model-opening-stalled" style={{ marginTop: spaceVar[3], color: themeVars.content.primary }}>
69
65
  Still opening after {Math.floor(waited / 60_000)} minutes. It may have stalled: retry opening, or reload the page.
70
66
  </div>}