@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.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +3 -7
- package/contributions/blender-node-editor.document.tsx +28 -180
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +100 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +15 -90
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +15 -147
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +217 -339
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +142 -332
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +43 -23
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +63 -0
- package/contributions/blender.status.tsx +9 -145
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +12 -32
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -30
- package/host/blender-runtime-host.ts +541 -372
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +49 -93
- package/package.json +1209 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -43
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/model-play-extension.ts +173 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +64 -38
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
32
|
-
import type {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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:
|
|
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
|
|
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) =>
|
|
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 (
|
|
340
|
-
//
|
|
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/
|
|
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
|
|
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: {
|
|
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?.
|
|
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 — `
|
|
131
|
-
//
|
|
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.
|
|
99
|
+
const object = adapter.hierarchy.nativeObject(node.id);
|
|
137
100
|
if (object === null) return null;
|
|
138
|
-
//
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
//
|
|
239
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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;
|