@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
@@ -1,25 +1,16 @@
1
1
  /**
2
- * BLENDER'S POSE, EVALUATED ON THE PRESENTER'S BONES. One evaluator, used by the Timeline
3
- * (`blender-runtime-skin.ts`) and by a game (`blender-play-skin.ts`), so the two can never disagree
4
- * with each other, and both follow what Blender itself does:
2
+ * THE NLA'S TIMING: which strip of each track a scene frame falls on, the action frame that strip
3
+ * shows, its influence, and where the active action is placed over the tracks, for the Timeline
4
+ * (`blender-runtime-skin.ts`), following what Blender itself does:
5
5
  *
6
6
  * - THE STACK is `BKE_animsys_evaluate_animdata`'s: the NLA tracks bottom to top (mute and solo as
7
7
  * Blender reads them), then the active action over them. A strip's time is
8
8
  * `nlastrip_get_frame_actionclip`'s (scale, repeat, reverse), its influence its blend in and out
9
- * or its own `influence`, and outside it the track holds as its extrapolation says.
10
- * - EACH CHANNEL blends as `nla_blend_value` / `nla_combine_*` blend it: per component, Replace,
11
- * Combine, Add, Subtract or Multiply at the layer's influence. A component no layer animates
12
- * keeps the bone's own value; one that is animated starts from the property's default. So an
13
- * action that keys only the upper body layers over one that keys the legs, with no masks.
14
- * - THE MATRIX is `BKE_pchan_to_mat4` then `BKE_armature_mat_bone_to_pose`: rotation by the
15
- * bone's own mode, and `parent @ rest @ basis` down the hierarchy.
16
- * - CONSTRAINTS run per bone after its parent, in stack order, blended by their influence as
17
- * `BKE_constraints_solve` blends them. Damped Track is evaluated (`damptrack_do_transform`).
18
- * Any other type is not, and is named (`unsupported`): its effect is Blender's alone.
9
+ * or its keyed `influence` (that one F-Curve evaluated as `fcurve_eval_keyframes` does), and
10
+ * outside it the track holds as its extrapolation says.
19
11
  *
20
- * A clip holds an action's F-Curves whole (`session.py`'s `rna_action_clip`): keys, handles,
21
- * interpolation, extrapolation and Cycles modifiers, evaluated as `fcurve_eval_keyframes` and
22
- * `fcm_cycles_time` evaluate them, so a pose is Blender's at any frame, whole or fractional.
12
+ * The layers it answers are played by three.js (`blender-mixer-pose.ts`): Blender samples each
13
+ * action, and an `AnimationMixer` blends them.
23
14
  *
24
15
  * LICENCE. This file follows Blender's source (the functions named above), so it is a derivative
25
16
  * work of Blender and is GPL-3.0-or-later, not the AGPL the package's own code carries
@@ -28,31 +19,13 @@
28
19
  *
29
20
  * SPDX-License-Identifier: GPL-3.0-or-later
30
21
  */
31
- import type { BlenderActionClip, BlenderClipCurve } from '@volter/blender-engine/browser/rna';
32
- import type {
33
- BlenderArmature,
34
- BlenderArmatureAnimation,
35
- BlenderArmatureBone,
36
- BlenderNlaStrip,
37
- } from '@volter/blender-engine/browser/three/blender-runtime-armature';
38
- import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
39
- import * as THREE from 'three';
40
-
41
- type Channel = 'location' | 'rotation_quaternion' | 'rotation_euler' | 'rotation_axis_angle' | 'scale';
42
- const CHANNELS: readonly Channel[] = ['location', 'rotation_quaternion', 'rotation_euler', 'rotation_axis_angle', 'scale'];
43
- const DEFAULTS: Record<Channel, readonly number[]> = {
44
- location: [0, 0, 0],
45
- rotation_quaternion: [1, 0, 0, 0],
46
- rotation_euler: [0, 0, 0],
47
- // Blender's NLA default for an axis-angle channel is all zeros (`anim_sys.cc`, zero_v4).
48
- rotation_axis_angle: [0, 0, 0, 0],
49
- scale: [1, 1, 1],
50
- };
22
+ import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
23
+ import type { BlenderClipCurve } from '@volter/blender-engine/browser/rna';
24
+ import type { BlenderArmatureAnimation, BlenderNlaStrip } from '@volter/blender-engine/browser/three/blender-runtime-armature';
25
+ import type { MixerClip, MixerLayer } from './blender-mixer-pose';
51
26
 
52
27
  function float32Of(base64: string): Float32Array {
53
- const binary = atob(base64);
54
- const bytes = new Uint8Array(binary.length);
55
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
28
+ const bytes = bytesFromBase64(base64);
56
29
  return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
57
30
  }
58
31
 
@@ -65,49 +38,6 @@ interface PoseCurve {
65
38
  readonly cycles: readonly [number, number, number, number] | null;
66
39
  }
67
40
 
68
- /** One action read for one armature: its F-Curves, by bone and channel, one per component. */
69
- export interface PoseClip {
70
- readonly action: string;
71
- readonly start: number;
72
- readonly end: number;
73
- /** Where Blender holds the active action over NLA strips; infinite where Cycles repeats it. */
74
- readonly keysStart: number;
75
- readonly keysEnd: number;
76
- readonly cyclic: boolean;
77
- readonly channels: readonly {
78
- readonly bone: string;
79
- readonly channel: Channel;
80
- readonly mask: number;
81
- readonly stride: number;
82
- readonly curves: readonly (PoseCurve | null)[];
83
- }[];
84
- /** What its curves do in Blender that is not played. */
85
- readonly unsupported: readonly string[];
86
- }
87
-
88
- /** The clip door's curves as a clip, or null when it holds nothing a pose can take. */
89
- export function poseClip(clip: BlenderActionClip): PoseClip | null {
90
- if (!clip.action || clip.clipStart === undefined || clip.clipEnd === undefined || !clip.tracks.length) return null;
91
- const channels = new Map<string, { bone: string; channel: Channel; mask: number; stride: number; curves: (PoseCurve | null)[] }>();
92
- const unsupported = new Set<string>();
93
- for (const track of clip.tracks) {
94
- const key = `${track.bone}\u0000${track.property}`;
95
- const stride = DEFAULTS[track.property].length;
96
- let entry = channels.get(key);
97
- if (!entry) channels.set(key, entry = { bone: track.bone, channel: track.property, mask: 0, stride, curves: Array.from({ length: stride }, () => null) });
98
- if (track.index < 0 || track.index >= stride) continue;
99
- entry.mask |= 1 << track.index;
100
- entry.curves[track.index] = poseCurve(track);
101
- for (const thing of track.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
102
- }
103
- for (const thing of clip.unsupported ?? []) unsupported.add(`${clip.action}'s ${thing}`);
104
- return {
105
- action: clip.action, start: clip.clipStart, end: clip.clipEnd,
106
- keysStart: clip.keysStart === null ? -Infinity : clip.keysStart ?? clip.clipStart,
107
- keysEnd: clip.keysEnd === null ? Infinity : clip.keysEnd ?? clip.clipEnd,
108
- cyclic: clip.cyclic ?? false, channels: [...channels.values()], unsupported: [...unsupported] };
109
- }
110
-
111
41
  function poseCurve(curve: Pick<BlenderClipCurve, 'extrapolation' | 'interpolation' | 'keysBase64'> & { readonly cycles?: BlenderClipCurve['cycles'] | undefined }): PoseCurve {
112
42
  return { extrapolation: curve.extrapolation, interpolation: curve.interpolation, keys: float32Of(curve.keysBase64), cycles: curve.cycles ?? null };
113
43
  }
@@ -246,14 +176,13 @@ export function curveAt(curve: PoseCurve, frame: number): number {
246
176
  return keyframesAt(curve, time) + offset;
247
177
  }
248
178
 
249
-
250
179
  /**
251
180
  * THE ACTIVE ACTION'S LAYER at a scene frame, placed as `animsys_calculate_nla` places it: a soloed
252
181
  * track leaves it out; when no enabled track has strips (or the NLA is off) it is evaluated alone,
253
182
  * whole, at full influence; otherwise it is one more strip spanning its own range, at its influence
254
183
  * and blend type, holding or not past that range as its extrapolation says.
255
184
  */
256
- export function actionLayer(animation: BlenderArmatureAnimation | undefined, clip: PoseClip, frame: number,
185
+ export function actionLayer(animation: BlenderArmatureAnimation | undefined, clip: MixerClip, frame: number,
257
186
  stripsEvaluated: boolean): PoseLayer | null {
258
187
  const nla = animation?.useNla ?? true;
259
188
  if (nla && animation!.tracks.some((track) => track.solo)) return null;
@@ -271,15 +200,10 @@ export function actionLayer(animation: BlenderArmatureAnimation | undefined, cli
271
200
  }
272
201
 
273
202
  /** One layer of the stack: an action at a frame of its own, an influence, a blend type. */
274
- export interface PoseLayer {
275
- readonly clip: PoseClip;
276
- readonly frame: number;
277
- readonly influence: number;
278
- readonly blend: string;
279
- }
203
+ export type PoseLayer = MixerLayer;
280
204
 
281
205
  /** A clip by action name: the clip, `undefined` while it is being baked, null when it has none. */
282
- export type ClipSource = (action: string) => PoseClip | null | undefined;
206
+ export type ClipSource = (action: string) => MixerClip | null | undefined;
283
207
 
284
208
  /** The strip a track plays at `frame`, and the time it plays it at (`nlastrips_ctime_get_strip`). */
285
209
  function stripAt(strips: readonly BlenderNlaStrip[], frame: number): { strip: BlenderNlaStrip; time: number } | null {
@@ -326,8 +250,7 @@ function stripInfluence(strip: BlenderNlaStrip, time: number): number {
326
250
  * clip it needs is still being baked; `skipped` names what plays in Blender and not here;
327
251
  * `evaluated` says an enabled track has strips (what places the active action over the NLA).
328
252
  */
329
- export function nlaLayers(animation: BlenderArmatureAnimation | undefined, frame: number, clips: ClipSource,
330
- tracks?: (name: string) => boolean): { layers: PoseLayer[]; waiting: boolean; skipped: string[]; evaluated: boolean } {
253
+ export function nlaLayers(animation: BlenderArmatureAnimation | undefined, frame: number, clips: ClipSource): { layers: PoseLayer[]; waiting: boolean; skipped: string[]; evaluated: boolean } {
331
254
  const layers: PoseLayer[] = [];
332
255
  const skipped: string[] = [];
333
256
  let waiting = false;
@@ -336,9 +259,8 @@ export function nlaLayers(animation: BlenderArmatureAnimation | undefined, frame
336
259
  const solo = animation.tracks.some((track) => track.solo);
337
260
  for (const track of animation.tracks) {
338
261
  // ENABLED as `BKE_nlatrack_is_enabled` says: under a solo only the soloed track, muted or not.
339
- if ((solo ? !track.solo : track.mute) || (tracks && !tracks(track.name))) continue;
340
- // AN ENABLED TRACK WITH STRIPS places the active action over the NLA, whether or not the frame
341
- // falls on one of them (`animsys_evaluate_nla_for_flush`'s `has_strips`; measured in 5.2).
262
+ if (solo ? !track.solo : track.mute) continue;
263
+ // An enabled NLA track with strips places the active action over the NLA even when the current frame lies outside its strips (animsys_evaluate_nla_for_flush).
342
264
  if (track.strips.length) evaluated = true;
343
265
  const hit = stripAt(track.strips, frame);
344
266
  if (!hit) continue;
@@ -354,275 +276,3 @@ export function nlaLayers(animation: BlenderArmatureAnimation | undefined, frame
354
276
  }
355
277
  return { layers, waiting, skipped, evaluated };
356
278
  }
357
-
358
- type Values = Record<Channel, number[]>;
359
-
360
- const quatMul = (a: readonly number[], b: readonly number[]): number[] => [
361
- a[0]! * b[0]! - a[1]! * b[1]! - a[2]! * b[2]! - a[3]! * b[3]!,
362
- a[0]! * b[1]! + a[1]! * b[0]! + a[2]! * b[3]! - a[3]! * b[2]!,
363
- a[0]! * b[2]! + a[2]! * b[0]! + a[3]! * b[1]! - a[1]! * b[3]!,
364
- a[0]! * b[3]! + a[3]! * b[0]! + a[1]! * b[2]! - a[2]! * b[1]!,
365
- ];
366
- const quatNormal = (q: readonly number[]): number[] => {
367
- const length = Math.hypot(q[0]!, q[1]!, q[2]!, q[3]!);
368
- return length > 0 ? q.map((v) => v / length) : [1, 0, 0, 0];
369
- };
370
- /** `pow_qt_fl_normalized`. */
371
- const quatPow = (q: readonly number[], k: number): number[] => {
372
- const angle = k * Math.acos(Math.max(-1, Math.min(1, q[0]!)));
373
- const length = Math.hypot(q[1]!, q[2]!, q[3]!);
374
- const s = Math.sin(angle);
375
- return length > 0 ? [Math.cos(angle), (q[1]! / length) * s, (q[2]! / length) * s, (q[3]! / length) * s] : [Math.cos(angle), 0, 0, 0];
376
- };
377
- /** One layer blended into the values (`nla_blend_value`, `nla_combine_value`, `nla_combine_quaternion`). */
378
- function blendLayer(values: Map<string, Values>, touched: Map<string, Record<Channel, number>>, layer: PoseLayer): void {
379
- const k = Math.max(0, Math.min(1, layer.influence));
380
- if (k === 0) return;
381
- const frame = layer.frame;
382
- for (const channel of layer.clip.channels) {
383
- const bone = values.get(channel.bone);
384
- if (!bone) continue;
385
- const marks = touched.get(channel.bone)!;
386
- const out = bone[channel.channel];
387
- // A COMPONENT AN ACTION ANIMATES starts from the property's default, as Blender's NLA does.
388
- for (let i = 0; i < channel.stride; i++) if (channel.mask & (1 << i) && !(marks[channel.channel] & (1 << i))) out[i] = DEFAULTS[channel.channel][i]!;
389
- marks[channel.channel] |= channel.mask;
390
- const sample = channel.curves.map((curve) => (curve ? curveAt(curve, frame) : 0));
391
- if (layer.blend === 'COMBINE' && channel.channel === 'rotation_quaternion') {
392
- const result = quatMul(quatNormal(out), quatPow(quatNormal(sample), k));
393
- for (let i = 0; i < channel.stride; i++) if (channel.mask & (1 << i)) out[i] = result[i]!;
394
- continue;
395
- }
396
- for (let i = 0; i < channel.stride; i++) {
397
- if (!(channel.mask & (1 << i))) continue;
398
- const lower = out[i]!;
399
- const upper = sample[i]!;
400
- switch (layer.blend) {
401
- case 'ADD': out[i] = lower + upper * k; break;
402
- case 'SUBTRACT': out[i] = lower - upper * k; break;
403
- case 'MULTIPLY': out[i] = k * (lower * upper) + (1 - k) * lower; break;
404
- case 'COMBINE':
405
- out[i] = channel.channel === 'scale' ? lower * Math.pow(upper, k) : lower + (upper - DEFAULTS[channel.channel][i]!) * k;
406
- break;
407
- default: out[i] = lower * (1 - k) + upper * k;
408
- }
409
- }
410
- }
411
- }
412
-
413
- /** `BKE_pchan_to_mat4`: the channels as the bone's basis matrix, rotation by its own mode. */
414
- function basisMatrix(mode: string, values: Values): THREE.Matrix4 {
415
- const rotation = new THREE.Quaternion();
416
- if (mode === 'QUATERNION') {
417
- const q = quatNormal(values.rotation_quaternion);
418
- rotation.set(q[1]!, q[2]!, q[3]!, q[0]!);
419
- } else if (mode === 'AXIS_ANGLE') {
420
- const v = values.rotation_axis_angle;
421
- rotation.setFromAxisAngle(new THREE.Vector3(v[1], v[2], v[3]).normalize(), v[0]!);
422
- } else {
423
- // Blender's `XYZ` applies X first; three's Euler order names the matrix product, so it reads
424
- // the same rotation reversed.
425
- const e = values.rotation_euler;
426
- rotation.setFromEuler(new THREE.Euler(e[0], e[1], e[2], mode.split('').reverse().join('') as THREE.EulerOrder));
427
- }
428
- const [x, y, z] = values.location;
429
- const [sx, sy, sz] = values.scale;
430
- return new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
431
- }
432
-
433
- const TRACK_AXES = ['TRACK_X', 'TRACK_Y', 'TRACK_Z', 'TRACK_NEGATIVE_X', 'TRACK_NEGATIVE_Y', 'TRACK_NEGATIVE_Z'];
434
- const TRACK_VECTORS = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]] as const;
435
-
436
- /** `damptrack_do_transform`: the smallest rotation that points the owner's axis at the target. */
437
- function dampedTrack(owner: THREE.Matrix4, target: THREE.Vector3, axis: string): THREE.Matrix4 {
438
- const flag = Math.max(0, TRACK_AXES.indexOf(axis));
439
- const basis = new THREE.Matrix3().setFromMatrix4(owner);
440
- const obvec = new THREE.Vector3(...TRACK_VECTORS[flag]!).applyMatrix3(basis);
441
- if (obvec.lengthSq() === 0) return owner.clone();
442
- obvec.normalize();
443
- const obloc = new THREE.Vector3().setFromMatrixPosition(owner);
444
- const tarvec = target.clone().sub(obloc);
445
- if (tarvec.lengthSq() === 0) tarvec.copy(obvec);
446
- else tarvec.normalize();
447
- const raxis = new THREE.Vector3().crossVectors(obvec, tarvec);
448
- let rangle = Math.acos(Math.max(-1, Math.min(1, obvec.dot(tarvec))));
449
- const norm = raxis.length();
450
- if (norm < 1.1920929e-7) {
451
- if (Math.abs(rangle) < Math.PI - 0.01) return owner.clone();
452
- rangle = Math.PI;
453
- const other = new THREE.Vector3(...TRACK_VECTORS[(flag + 1) % 6]!).applyMatrix3(basis);
454
- raxis.crossVectors(obvec, other);
455
- if (raxis.lengthSq() === 0) return owner.clone();
456
- } else if (norm < 0.1) {
457
- rangle = rangle > Math.PI / 2 ? Math.PI - Math.asin(norm) : Math.asin(norm);
458
- }
459
- const turned = new THREE.Matrix4().makeRotationAxis(raxis.normalize(), rangle).multiply(owner);
460
- return turned.setPosition(obloc);
461
- }
462
-
463
- /** `interp_m4_m4m4`: a constraint's result taken by its influence. */
464
- function blendMatrix(before: THREE.Matrix4, after: THREE.Matrix4, k: number): THREE.Matrix4 {
465
- if (k >= 1) return after;
466
- const [p0, q0, s0] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
467
- const [p1, q1, s1] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
468
- before.decompose(p0, q0, s0);
469
- after.decompose(p1, q1, s1);
470
- return new THREE.Matrix4().compose(p0.lerp(p1, k), q0.slerp(q1, k), s0.lerp(s1, k));
471
- }
472
-
473
- /** What a game changes on a constraint while it runs. */
474
- export interface ConstraintOverride {
475
- readonly influence?: number;
476
- /** The object it aims at instead of the file's target. */
477
- readonly target?: THREE.Object3D | null;
478
- }
479
-
480
- /** Where a constraint's targets are, in the scene the bones are in. */
481
- export interface ConstraintWorld {
482
- /** An object by its Blender name (its presented copy). */
483
- object(name: string): THREE.Object3D | null;
484
- /** A game's change to one constraint of one bone, if it made one. */
485
- override?(bone: string, constraint: string): ConstraintOverride | undefined;
486
- }
487
-
488
- /** One armature's bones, posed by the stack: Blender's evaluation, on the presenter's skeleton. */
489
- export class ArmaturePose {
490
- #bones: readonly BlenderArmatureBone[] = [];
491
- #restRelative = new Map<string, THREE.Matrix4>();
492
- #armature: BlenderArmature | null = null;
493
-
494
- constructor(readonly rig: ArmatureRig) {}
495
-
496
- /** Take the frame's facts for this armature (its bones' rest, channels and constraints). */
497
- facts(armature: BlenderArmature): void {
498
- if (armature === this.#armature) return;
499
- this.#armature = armature;
500
- this.#bones = armature.bones;
501
- const rest = new Map(armature.bones.map((bone) => [bone.name, matrixOf(bone.rest ?? bone.matrix)]));
502
- this.#restRelative = new Map(armature.bones.map((bone) => {
503
- const own = rest.get(bone.name)!;
504
- const parent = bone.parent === null ? undefined : rest.get(bone.parent);
505
- return [bone.name, parent ? parent.clone().invert().multiply(own) : own.clone()];
506
- }));
507
- }
508
-
509
- get armature(): BlenderArmature | null {
510
- return this.#armature;
511
- }
512
-
513
- /** What this armature does in Blender that the evaluator does not, besides its clips' own
514
- * (`PoseClip.unsupported`): each named once. */
515
- unsupported(): string[] {
516
- const out: string[] = [];
517
- for (const bone of this.#bones) {
518
- for (const constraint of bone.constraints ?? []) {
519
- if (constraint.enabled && constraint.type !== 'DAMPED_TRACK') out.push(`the ${constraintLabel(constraint.type)} constraint "${constraint.name}" on bone "${bone.name}"`);
520
- }
521
- if (bone.inherit?.length) out.push(`bone "${bone.name}"'s parenting (${bone.inherit.join(', ')})`);
522
- }
523
- for (const bone of this.#armature?.animation?.drivers ?? []) out.push(`the drivers on bone "${bone}"`);
524
- if (this.#armature?.animation?.tweak) out.push('the NLA in tweak mode (leave it with Tab in the NLA editor)');
525
- for (const track of this.#armature?.animation?.tracks ?? [])
526
- for (const strip of track.strips)
527
- if (!strip.mute && strip.animatedTime) out.push(`the animated strip time of "${strip.name}"`);
528
- return out;
529
- }
530
-
531
- /** Pose the bones from these layers and constraints; answers each bone's armature-space pose.
532
- * `write: false` only answers it, leaving the bones as they are (the fidelity check). */
533
- apply(layers: readonly PoseLayer[], world: ConstraintWorld, write = true): Map<string, THREE.Matrix4> {
534
- const values = new Map<string, Values>();
535
- const touched = new Map<string, Record<Channel, number>>();
536
- for (const bone of this.#bones) {
537
- const own = bone.channels;
538
- values.set(bone.name, Object.fromEntries(CHANNELS.map((channel) => [channel, [...(own?.[channel] ?? DEFAULTS[channel])]])) as Values);
539
- touched.set(bone.name, { location: 0, rotation_quaternion: 0, rotation_euler: 0, rotation_axis_angle: 0, scale: 0 });
540
- }
541
- for (const layer of layers) blendLayer(values, touched, layer);
542
-
543
- this.rig.object.updateWorldMatrix(true, false);
544
- const armatureWorld = this.rig.object.matrixWorld;
545
- const toArmature = armatureWorld.clone().invert();
546
- const poses = new Map<string, THREE.Matrix4>();
547
- for (const bone of this.#bones) {
548
- const parent = bone.parent === null ? null : poses.get(bone.parent) ?? null;
549
- const basis = basisMatrix(bone.channels?.mode ?? 'QUATERNION', values.get(bone.name)!);
550
- let pose = (parent ? parent.clone() : new THREE.Matrix4()).multiply(this.#restRelative.get(bone.name)!).multiply(basis);
551
- for (const constraint of bone.constraints ?? []) {
552
- if (!constraint.enabled || constraint.type !== 'DAMPED_TRACK') continue;
553
- const change = world.override?.(bone.name, constraint.name);
554
- const influence = change?.influence ?? constraint.influence;
555
- if (influence <= 0) continue;
556
- const target = this.#target(constraint, change, poses, world);
557
- if (!target) continue;
558
- const owner = armatureWorld.clone().multiply(pose);
559
- const solved = dampedTrack(owner, target, constraint.trackAxis ?? 'TRACK_Y');
560
- pose = toArmature.clone().multiply(blendMatrix(owner, solved, influence));
561
- }
562
- poses.set(bone.name, pose);
563
- }
564
- if (!write) return poses;
565
- for (const bone of this.#bones) {
566
- const own = poses.get(bone.name)!;
567
- const parent = bone.parent === null ? undefined : poses.get(bone.parent);
568
- const local = parent ? parent.clone().invert().multiply(own) : own;
569
- const presented = this.rig.bones.get(bone.name);
570
- if (presented) local.decompose(presented.position, presented.quaternion, presented.scale);
571
- }
572
- this.rig.object.updateMatrixWorld(true);
573
- return poses;
574
- }
575
-
576
- /** A constraint's target point in world space: an object's origin, or a point along a bone. */
577
- #target(constraint: NonNullable<BlenderArmatureBone['constraints']>[number], change: ConstraintOverride | undefined,
578
- poses: ReadonlyMap<string, THREE.Matrix4>, world: ConstraintWorld): THREE.Vector3 | null {
579
- if (change?.target) {
580
- change.target.updateWorldMatrix(true, false);
581
- return new THREE.Vector3().setFromMatrixPosition(change.target.matrixWorld);
582
- }
583
- if (change?.target === null || !constraint.target) return null;
584
- const object = world.object(constraint.target);
585
- if (!object) return null;
586
- object.updateWorldMatrix(true, false);
587
- if (!constraint.subtarget) return new THREE.Vector3().setFromMatrixPosition(object.matrixWorld);
588
- const own = constraint.target === this.rig.armature;
589
- const pose = own ? poses.get(constraint.subtarget) : undefined;
590
- const bone = this.#bones.find((one) => one.name === constraint.subtarget);
591
- if (own && pose && bone) {
592
- const along = new THREE.Vector3(0, bone.length * (constraint.headTail ?? 0), 0).applyMatrix4(pose);
593
- return along.applyMatrix4(this.rig.object.matrixWorld);
594
- }
595
- const presented = object.getObjectByName(constraint.subtarget);
596
- return presented ? new THREE.Vector3().setFromMatrixPosition(presented.matrixWorld) : null;
597
- }
598
- }
599
-
600
- const matrixOf = (rows: readonly (readonly number[])[]): THREE.Matrix4 =>
601
- new THREE.Matrix4().set(...(rows.flat() as Parameters<THREE.Matrix4['set']>));
602
-
603
- const constraintLabel = (type: string): string =>
604
- type.toLowerCase().split('_').map((word) => (word === 'ik' ? 'IK' : word[0]!.toUpperCase() + word.slice(1))).join(' ');
605
-
606
- /**
607
- * THE FIDELITY CHECK: how far an evaluated pose is from Blender's own at the same frame (the
608
- * frame's `PoseBone.matrix`). Answers the bone that is furthest off, or null when every bone is
609
- * within a tenth of a degree and a thousandth of its length.
610
- */
611
- export function poseDivergence(evaluated: ReadonlyMap<string, THREE.Matrix4>, armature: BlenderArmature):
612
- { bone: string; degrees: number; offset: number } | null {
613
- let worst: { bone: string; degrees: number; offset: number; score: number } | null = null;
614
- 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()];
615
- for (const bone of armature.bones) {
616
- const ours = evaluated.get(bone.name);
617
- if (!ours) continue;
618
- ours.decompose(p0, q0, s0);
619
- matrixOf(bone.matrix).decompose(p1, q1, s1);
620
- const degrees = THREE.MathUtils.radToDeg(q0.angleTo(q1));
621
- // Relative to the bone, but never to less than a tenth of a metre: a fingertip bone a
622
- // centimetre long is not off by a tenth of a millimetre's float noise.
623
- const offset = p0.distanceTo(p1) / Math.max(bone.length, 0.1);
624
- const score = Math.max(degrees / 0.1, offset / 1e-3);
625
- if (score > 1 && (!worst || score > worst.score)) worst = { bone: bone.name, degrees, offset, score };
626
- }
627
- return worst && { bone: worst.bone, degrees: worst.degrees, offset: worst.offset };
628
- }
@@ -1,6 +1,6 @@
1
1
  /** Blender's Properties context remains inspectable without a selection.
2
2
  * Reuse the same RNA sections and Properties projection as selected objects. */
3
- import { registerNullSubjectProvider } from '@volter/sdk/kit/inspection/null-subject';
3
+ import { registerNullSubjectProvider } from '@volter/sdk/inspection/null-subject';
4
4
  import { blenderOutlinerState } from './blender-outliner-model';
5
5
  import { blenderOutlinerKind } from './blender-outliner-authoring';
6
6
  import {
@@ -1,37 +1,4 @@
1
- /**
2
- * WHAT THE PROPERTIES SECTIONS ARE LOOKING AT — the page-side half of the RNA
3
- * door (WORK.md §Blender in the tab is Blender, "Inspection parity", I1).
4
- *
5
- * React-free ON PURPOSE: this module is a plain store with `subscribe` /
6
- * `version` / `state`, so the host's Blender side can import it without
7
- * pulling React into its closure, and the sections read it through one
8
- * `useSyncExternalStore` (`blender-properties-view.tsx`).
9
- *
10
- * THE SUBJECT IS THE PRESENTED BLENDER CONTEXT. The person clicks a node
11
- * in our viewport; the adapter hands that node's `THREE.Object3D`; the
12
- * presented view answers which Blender object that three object IS
13
- * (`BlenderRuntimeView.blenderObjectName`, a lookup by object identity in the
14
- * frame's own table — never a name match). That name goes to
15
- * `rna_context(object=…)`, which builds the Properties context around it
16
- * exactly as `buttons_context.cc` builds one around
17
- * `BKE_view_layer_active_object_get`. Reading our selection never WRITES the
18
- * engine's active object: that is a mutation, and the document would save it.
19
- * With no selected node, the presented frame's active object remains the
20
- * Properties subject; no active object resolves the scene instead.
21
- *
22
- * WHEN IT RE-READS. Three times, and each is a signal rather than a poll:
23
- * - a PRESENT (`BlenderRuntimeView.subscribeFrames`) — every mutation
24
- * presents (`session.py::dispatch`), so a frame is "what you are showing is
25
- * stale";
26
- * - the RNA DOOR'S OWN VERSION (`subscribeBlenderRna`, ruling 3 of
27
- * 2026-09-19) — because a write can change what RNA answers and nothing the
28
- * presenter draws, so no frame ships: `Node.panel_states[n].is_collapsed`
29
- * and `Object.hide_render` are the two measured cases;
30
- * - the SELECTION moving to another datablock.
31
- * A re-read that changes which TABS exist also calls the host door's
32
- * `documents.contextChanged`, because the tab rail IS the section list and
33
- * nothing in a host store moved.
34
- */
1
+ /** React-free Properties store over the RNA door. Resolve the subject from the presented Blender context without mutating selection. Re-read on presented frames, RNA writes and selection changes; notify documents.contextChanged whenever the resolved tab context changes. */
35
2
 
36
3
  import type { BlenderRnaContext, BlenderRnaView } from '@volter/blender-engine/browser/rna';
37
4
  import { editorHost } from '@volter/sdk/host';
@@ -65,13 +32,10 @@ const isPresentedView = (value: unknown): value is PresentedView =>
65
32
  typeof (value as PresentedView).blenderObjectName === 'function' &&
66
33
  typeof (value as PresentedView).blenderSelection === 'function';
67
34
 
68
- /** The active authoring adapter, as much of it as this module reads. The
69
- * adapter is deliberately `unknown` at the contribution point (adapter-native
70
- * API, never a fabricated projection), and `@volter/editor-blender` holds no host
71
- * import — so the shape it needs is stated here and checked at runtime. */
35
+ /** The adapter-native fields this SDK-host reader uses, checked at runtime. */
72
36
  interface Object3DAuthoringLike {
73
37
  readonly documentId: string;
74
- readonly hierarchy: { object3D(id: string): THREE.Object3D | null };
38
+ readonly hierarchy: { nativeObject(id: string): THREE.Object3D | null };
75
39
  readonly documentRootObject: THREE.Object3D | null;
76
40
  }
77
41
 
@@ -79,7 +43,7 @@ const isObject3DAuthoring = (value: unknown): value is Object3DAuthoringLike =>
79
43
  typeof value === 'object' &&
80
44
  value !== null &&
81
45
  typeof (value as Object3DAuthoringLike).documentId === 'string' &&
82
- typeof (value as Object3DAuthoringLike).hierarchy?.object3D === 'function';
46
+ typeof (value as Object3DAuthoringLike).hierarchy?.nativeObject === 'function';
83
47
 
84
48
  function presentedView(): PresentedView | null {
85
49
  const published = editorHost().documents.context(blenderPresentationDocumentId());
@@ -127,20 +91,14 @@ export function resolveBlenderSubject(
127
91
  if (node.id === adapter.documentId)
128
92
  return adapter.documentRootObject === view.root ? { kind: 'scene' } : null;
129
93
  // A COLLECTION ROW HAS NO OBJECT, so it has to be resolved before the object
130
- // lookup rather than after it — `object3D` answers null for one, which is the
131
- // honest answer and used to end the resolution here (I3's standing OPEN: the
132
- // rail simply stayed on the previous subject). The row table is this
94
+ // lookup rather than after it — `nativeObject` answers null for one, which would
95
+ // leave the rail on the previous subject. The row table is this
133
96
  // package's own (`blender-outliner-model.ts`), so this is a map lookup.
134
97
  const row = blenderOutlinerState().byId.get(node.id);
135
98
  if (row?.type === 'TSE_LAYER_COLLECTION') return { kind: 'collection', path: row.path };
136
- const object = adapter.hierarchy.object3D(node.id);
99
+ const object = adapter.hierarchy.nativeObject(node.id);
137
100
  if (object === null) return null;
138
- // THE SCENE COLLECTION IS THE SCENE SUBJECT. Under I3 the hierarchy is
139
- // Blender's Outliner, whose root row is the Scene Collection and NOT a
140
- // document row of the host's — so the row that stands for the whole file is
141
- // the one whose object is the presented ROOT, and it stands for exactly what
142
- // the document row did: the tabs that need no object (Render, Output, View
143
- // Layer, Scene, World, Collection).
101
+ // The Scene Collection root resolves the scene subject and its object-independent Properties tabs.
144
102
  if (object === view.root) return { kind: 'scene' };
145
103
  const name = view.blenderObjectName(object);
146
104
  return name === null ? null : { kind: 'object', name };
@@ -211,22 +169,12 @@ export function blenderPropertiesState(): BlenderPropertiesState {
211
169
  return state;
212
170
  }
213
171
 
214
- /** THE ENGINE MOVED: everything read off it is stale. The CONTEXT is
215
- * re-read immediately (the tabs may have changed — a modifier was added, a
216
- * bone became active); the datablock views are dropped and re-read by
217
- * whichever section is still showing them.
218
- *
219
- * Two things call this, and the second is why it is no longer named
220
- * `onFrame`: a PRESENTED FRAME, and the RNA door's own version (ruling 3,
221
- * 2026-09-19 — `blenderRnaVersion`). A write that changes nothing the
222
- * presenter draws ships no frame, and before this the rail simply kept the
223
- * values it had. */
172
+ /** Invalidate datablock caches and re-read context on RNA version changes, including writes that produce no presented-frame change. */
224
173
  function onEngineMoved(): void {
225
174
  // STALE, NOT EMPTY. The generation moves, so every answer already in flight
226
175
  // is dropped when it lands (a deleted duplicate's late read cannot overwrite
227
176
  // the new selection) -- but the panel keeps showing what it holds until the
228
- // re-read answers. Clearing here blanked and repainted the whole Properties
229
- // panel on every edit, because every edit presents a frame.
177
+ // re-read answers. Keep the settled panel visible while each edit presents its frame.
230
178
  const subject = readFor;
231
179
  readGeneration += 1;
232
180
  inFlight.clear();
@@ -235,26 +183,15 @@ function onEngineMoved(): void {
235
183
  else publish({});
236
184
  }
237
185
 
238
- // THE FRAME IS ONE SIGNAL AND THE RNA DOOR IS THE OTHER. This subscription is
239
- // module-level rather than taken beside the frame one, because the RNA door
240
- // exists from the first import while the document's view arrives later — and a
241
- // write made before the first present is exactly the case that went unseen.
186
+ // Subscribe on module import so RNA writes before the first presented frame
187
+ // also invalidate the context. Frame changes provide the other signal.
242
188
  subscribeBlenderRna(() => {
243
189
  onEngineMoved();
244
190
  });
245
191
 
246
- /**
247
- * THE READ NEVER STARTS INSIDE A RENDER. `showBlenderSubject` is called from a
248
- * tab's `match()`, which the host runs while it is RENDERING the workspace —
249
- * and the read's first act is to publish `loading`, which is a `setState` in
250
- * every subscribed component. React said so by name ("Cannot update a
251
- * component (BlenderPropertiesSection) while rendering a different
252
- * component"), measured live 2026-09-19. The WANT is recorded
253
- * synchronously so a second matcher in the same pass does not queue a second
254
- * read; the read itself runs on the next turn of the loop, outside the render.
255
- */
256
192
  let scheduledKey: string | null = null;
257
193
 
194
+ /** Queue the context read outside React render; record the request synchronously to deduplicate matchers in the same composition. */
258
195
  function scheduleContextRead(key: string, refresh = false): void {
259
196
  if (scheduledKey === key) return;
260
197
  scheduledKey = key;
@@ -278,12 +215,7 @@ function watchFrames(): void {
278
215
  if (frameSubscription !== null) return;
279
216
  const view = presentedView();
280
217
  if (view === null) return;
281
- // A FRAME IS REPORTED TO THE RNA DOOR'S VERSION, not consumed here. The
282
- // subscription lives in this module because this module is what the document
283
- // publishes its view to, but the signal belongs to every view over the
284
- // engine — so it is minted in ONE place (ruling 3) and this module hears its
285
- // own frame back through `subscribeBlenderRna` above, exactly as the node
286
- // view and the UV view do.
218
+ // Report frames through the shared RNA version so Properties, node and UV views receive the same engine-change signal.
287
219
  frameSubscription = view.subscribeFrames(noteBlenderRnaChanged);
288
220
  }
289
221
 
@@ -311,14 +243,7 @@ async function readContext(key: string, refresh = false): Promise<void> {
311
243
  publish({ loading: false, error: null });
312
244
  return;
313
245
  }
314
- // ALWAYS a tab-rail notification, and that is a correction rather than a
315
- // convenience: the gate a tab matches on is BOTH the subject this context
316
- // was read for and the tab list, so comparing only the list missed the
317
- // case that actually happens — selecting the Cube after the document row
318
- // reads the SAME thirteen tabs for a different subject, the gated tabs'
319
- // `state.object !== object` went false, and nothing ever said so. Measured
320
- // live 2026-09-19: Collection/Modifiers/Particles/Object Data/Material
321
- // disappeared on the first selection and only came back on a re-select.
246
+ // Notify the tab rail after every context read; both the subject identity and tab list determine matches.
322
247
  publish({ context, loading: false, error: null }, true);
323
248
  } catch (error) {
324
249
  if (generation !== readGeneration || request !== contextRequest) return;