@volter/editor-blender 0.1.150 → 0.1.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +99 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +64 -0
- package/contributions/blender.status.tsx +8 -150
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +3 -30
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,508 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ANIMATION IN A GAME: Blender's own animation stack, evaluated on the game's copy of the model.
|
|
3
|
-
*
|
|
4
|
-
* The copy binds its own skins (`BlenderRuntimeView.detach` keeps each skinned mesh's weights and
|
|
5
|
-
* the frame's armatures), and every armature is posed by the same evaluator the Timeline uses
|
|
6
|
-
* (`blender-pose.ts`): its NLA tracks bottom to top, then its active action over them, blended as
|
|
7
|
-
* Blender blends them, then its Damped Track constraints. So a game shows what Blender shows, and
|
|
8
|
-
* what a game changes is Blender's own data, by Blender's names:
|
|
9
|
-
*
|
|
10
|
-
* - `play` sets the ACTIVE ACTION (`animation_data.action`), crossfaded from the last; each
|
|
11
|
-
* armature starts on the one the file assigns it. A game's actions run from when they are set
|
|
12
|
-
* and repeat unless told not to.
|
|
13
|
-
* - `track` sets an NLA TRACK by name: an action for it (over the tracks the file has, or as a
|
|
14
|
-
* new track over them), its influence, or its mute. An action that keys only some bones covers
|
|
15
|
-
* only those, so an upper-body action on a track over a running action is an upper body that
|
|
16
|
-
* aims while the legs run, as in Blender.
|
|
17
|
-
* - `constraint` sets a bone constraint's influence, or the object it aims at. A constraint aims
|
|
18
|
-
* at the game's copy of its target, so moving that object in the game moves the look.
|
|
19
|
-
*
|
|
20
|
-
* The file's own NLA strips play on the game's clock as scene frames, looping the scene's range as
|
|
21
|
-
* Blender's own playback does. Every
|
|
22
|
-
* clip is baked once per action, the first time it is wanted; until the clips a pose needs are
|
|
23
|
-
* baked, the armature keeps the pose it has. The clips each character STARTS on are baked before
|
|
24
|
-
* the game starts (`prepare`), and a bake that could not be read is tried again, so a slow or
|
|
25
|
-
* failed read at Play's start does not leave a character frozen for the run.
|
|
26
|
-
*
|
|
27
|
-
* THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
|
|
28
|
-
* (`@volter/play`'s `play-script.ts`), so pause, step, speed and Restart hold for
|
|
29
|
-
* animation exactly as they do for the script.
|
|
30
|
-
*/
|
|
31
|
-
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
32
|
-
import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
33
|
-
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
34
|
-
import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
|
|
35
|
-
import type * as THREE from 'three';
|
|
36
|
-
import { ArmaturePose, nlaLayers, poseClip, type ConstraintOverride, type PoseClip, type PoseLayer } from './blender-pose';
|
|
37
|
-
import { editorHost } from '@volter/sdk/host';
|
|
38
|
-
import { remedy } from './blender-runtime-skin';
|
|
39
|
-
|
|
40
|
-
export interface PlayActionOptions {
|
|
41
|
-
/** Seconds to crossfade from what played before (default 0.2). */
|
|
42
|
-
readonly fade?: number;
|
|
43
|
-
/** Repeat until something else plays (default true); `false` plays once and holds the end. */
|
|
44
|
-
readonly loop?: boolean;
|
|
45
|
-
/** Playback rate, 1 is the action's own. */
|
|
46
|
-
readonly speed?: number;
|
|
47
|
-
/** Start again from the first frame when this action is already playing. */
|
|
48
|
-
readonly restart?: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface PlayTrackOptions extends PlayActionOptions {
|
|
52
|
-
/** The action the track plays from now; absent, the track plays what the file gives it. */
|
|
53
|
-
readonly action?: string;
|
|
54
|
-
/** The track's influence, 0 to 1, reached over `fade` (default 0, at once). */
|
|
55
|
-
readonly influence?: number;
|
|
56
|
-
readonly mute?: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface PlayConstraintOptions {
|
|
60
|
-
readonly influence?: number;
|
|
61
|
-
/** The object it aims at instead of the file's target. */
|
|
62
|
-
readonly target?: THREE.Object3D | null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type Answer = { ok: true; armature: string } | { ok: false; why: string };
|
|
66
|
-
|
|
67
|
-
/** The game's door to its characters' animation (`ModelPlayContext.setAction`, `setTrack`, `setConstraint`). */
|
|
68
|
-
export interface PlayAnimation {
|
|
69
|
-
/** The actions in the file, which an object's armature may play. */
|
|
70
|
-
clips(object: THREE.Object3D): readonly string[];
|
|
71
|
-
play(object: THREE.Object3D, action: string, options?: PlayActionOptions): Answer;
|
|
72
|
-
stop(object: THREE.Object3D, fade?: number): void;
|
|
73
|
-
/** The active action, as last set. */
|
|
74
|
-
playing(object: THREE.Object3D): string | null;
|
|
75
|
-
/** Set an NLA track; `null` gives it back to the file (a track the game made goes). */
|
|
76
|
-
track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
|
|
77
|
-
constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
|
|
78
|
-
update(dt: number): void;
|
|
79
|
-
/**
|
|
80
|
-
* THE LOADING PHASE: bakes every clip a character starts on (its assigned action and its NLA
|
|
81
|
-
* strips' actions) before the game's first update, so no character stands frozen while Blender
|
|
82
|
-
* answers. Resolves with what could not be loaded; clips the game asks for later still bake on
|
|
83
|
-
* first use.
|
|
84
|
-
*/
|
|
85
|
-
prepare(): Promise<{ readonly failed: readonly string[] }>;
|
|
86
|
-
/** The starting clips `prepare` is still waiting on Blender for, as `armature / action`. */
|
|
87
|
-
pending(): readonly string[];
|
|
88
|
-
/**
|
|
89
|
-
* THE CLIP LIBRARY, read in the background once the game runs: every action on each armature's
|
|
90
|
-
* NLA strips, muted tracks included (a game's clip library), most-shared first, one read at a
|
|
91
|
-
* time and only while no other read is waiting, so a clip the game asks for waits behind at most
|
|
92
|
-
* one. Without it the first switch to a library clip stood the character still for a read.
|
|
93
|
-
*/
|
|
94
|
-
prefetch(): void;
|
|
95
|
-
/** What each character is playing now, bottom layer first: the animation editors' live view. */
|
|
96
|
-
live(): readonly LiveArmature[];
|
|
97
|
-
/** The frames an action keys, per bone, for one armature (empty until it was baked). */
|
|
98
|
-
channels(armature: string, action: string): { bone: string; keys: number[] }[];
|
|
99
|
-
readonly warnings: readonly string[];
|
|
100
|
-
dispose(): void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** One character's animation at the game's last update. */
|
|
104
|
-
export interface LiveArmature {
|
|
105
|
-
readonly armature: string;
|
|
106
|
-
/** The layers it was posed from, bottom first: each clip, its frame, influence and blend. */
|
|
107
|
-
readonly layers: readonly {
|
|
108
|
-
/** Where the layer comes from: an NLA track's name, or `null` for the active action. */
|
|
109
|
-
readonly track: string | null;
|
|
110
|
-
readonly action: string;
|
|
111
|
-
readonly frame: number;
|
|
112
|
-
readonly influence: number;
|
|
113
|
-
readonly blend: string;
|
|
114
|
-
}[];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** A weight that moves toward its target at a rate (a crossfade). */
|
|
118
|
-
interface Ramp { weight: number; target: number; rate: number }
|
|
119
|
-
const ramp = (weight: number, target: number, fade: number): Ramp => ({ weight, target, rate: fade > 0 ? 1 / fade : 0 });
|
|
120
|
-
const step = (r: Ramp, dt: number): void => {
|
|
121
|
-
r.weight = r.rate <= 0 ? r.target : r.weight < r.target ? Math.min(r.target, r.weight + dt * r.rate) : Math.max(r.target, r.weight - dt * r.rate);
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/** An action a game set, on its own clock from when it was set. */
|
|
125
|
-
interface Playing { readonly action: string; readonly from: number; readonly loop: boolean; readonly speed: number; readonly weight: Ramp }
|
|
126
|
-
|
|
127
|
-
interface Track { action: Playing | null; previous: Playing | null; readonly influence: Ramp; mute: boolean; readonly own: boolean }
|
|
128
|
-
|
|
129
|
-
interface Armature {
|
|
130
|
-
readonly rig: ArmatureRig;
|
|
131
|
-
readonly pose: ArmaturePose;
|
|
132
|
-
readonly facts: BlenderArmature;
|
|
133
|
-
/** Baked clips by action: the clip, undefined while baking, null when it plays nothing. */
|
|
134
|
-
readonly clips: Map<string, PoseClip | null | undefined>;
|
|
135
|
-
readonly failed: Map<string, string>;
|
|
136
|
-
/** The active action and the ones fading out under it, oldest first. */
|
|
137
|
-
line: Playing[];
|
|
138
|
-
readonly tracks: Map<string, Track>;
|
|
139
|
-
readonly constraints: Map<string, ConstraintOverride>;
|
|
140
|
-
/** The layers of the last pose, for `live`. */
|
|
141
|
-
posed: LiveArmature['layers'];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The copy's animation. `bake` reads one action of one armature through the clip door. */
|
|
145
|
-
/** Reads that outlive one Play: a document's next Play reuses them (see `shared` below). */
|
|
146
|
-
export type PlayClipCache = Map<string, Promise<BlenderActionClip | null>>;
|
|
147
|
-
|
|
148
|
-
export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>, cache: PlayClipCache = new Map()): PlayAnimation {
|
|
149
|
-
const warnings: string[] = [];
|
|
150
|
-
const facts = view.animationFacts();
|
|
151
|
-
const actions = Object.keys(facts.actions);
|
|
152
|
-
const fps = facts.clock?.fps || 24;
|
|
153
|
-
const sceneStart = facts.clock?.start ?? 1;
|
|
154
|
-
const sceneLength = Math.max(1, (facts.clock?.end ?? sceneStart + 249) - sceneStart + 1);
|
|
155
|
-
const armatures = new Map<string, Armature>();
|
|
156
|
-
let time = 0;
|
|
157
|
-
let disposed = false;
|
|
158
|
-
const lastSources = new Map<Armature, (string | null)[]>();
|
|
159
|
-
|
|
160
|
-
const fail = (armature: Armature, action: string, why: string): void => {
|
|
161
|
-
armature.clips.set(action, null);
|
|
162
|
-
armature.failed.set(action, why);
|
|
163
|
-
warnings.push(`${armature.rig.armature} / ${action}: ${why}`);
|
|
164
|
-
};
|
|
165
|
-
/** Reads that failed (Blender busy, the call refused) per clip: tried again up to this many times. */
|
|
166
|
-
const READ_ATTEMPTS = 3;
|
|
167
|
-
const attempts = new Map<string, number>();
|
|
168
|
-
const baking = new Map<string, Promise<void>>();
|
|
169
|
-
/**
|
|
170
|
-
* ONE BAKE PER ACTION AND SKELETON. A bake depends on the action, the armature's bones and the
|
|
171
|
-
* action slot Blender picks for it (`rna_action_clip`), never on the armature otherwise; each
|
|
172
|
-
* read holds the Blender worker for about 1.5 s. Read per armature, fifteen enemies placed from
|
|
173
|
-
* one piece baked the same clip fifteen times, and an action the game switched one to waited
|
|
174
|
-
* behind all of them: measured on Heck Plungers, half the characters still unposed 20 s in with
|
|
175
|
-
* Blender idle. So armatures with the same bones share a read when the action has at most one
|
|
176
|
-
* object slot (`objectSlots`, from Blender): with several, which slot plays depends on the
|
|
177
|
-
* armature, and each armature reads its own.
|
|
178
|
-
*/
|
|
179
|
-
// KEPT ACROSS PLAYS by the caller (`cache`): keyed by the action's REVISION too, so an action
|
|
180
|
-
// edited between Plays is read again and an unchanged one is not read twice.
|
|
181
|
-
const shared = cache;
|
|
182
|
-
/** A clip whose slot Blender picked for its armature among several: never another armature's answer. */
|
|
183
|
-
const ownSlot = (clip: BlenderActionClip | null): boolean => !!clip && (clip.objectSlots ?? 2) > 1;
|
|
184
|
-
const readClip = (armature: Armature, action: string): Promise<BlenderActionClip | null> => {
|
|
185
|
-
const name = armature.rig.armature;
|
|
186
|
-
const skeleton = armature.facts.bones.map((bone) => bone.name).sort().join('\u0001');
|
|
187
|
-
const key = `${facts.actions[action] ?? ''}\u0000${action}\u0000${skeleton}`;
|
|
188
|
-
const known = shared.get(key);
|
|
189
|
-
if (known) return known.then((clip) => (ownSlot(clip) && clip!.armature !== name ? bake(name, action) : clip));
|
|
190
|
-
const read = bake(name, action).then((clip) => {
|
|
191
|
-
if (ownSlot(clip)) shared.delete(key);
|
|
192
|
-
return clip;
|
|
193
|
-
}, (error: unknown) => {
|
|
194
|
-
shared.delete(key);
|
|
195
|
-
throw error;
|
|
196
|
-
});
|
|
197
|
-
shared.set(key, read);
|
|
198
|
-
return read;
|
|
199
|
-
};
|
|
200
|
-
const clipOf = (armature: Armature, action: string): PoseClip | null | undefined => {
|
|
201
|
-
if (armature.clips.has(action)) return armature.clips.get(action);
|
|
202
|
-
void bakeClip(armature, action);
|
|
203
|
-
return undefined;
|
|
204
|
-
};
|
|
205
|
-
const bakeClip = (armature: Armature, action: string): Promise<void> => {
|
|
206
|
-
const key = `${armature.rig.armature}\u0000${action}`;
|
|
207
|
-
const running = baking.get(key);
|
|
208
|
-
if (running) return running;
|
|
209
|
-
if (armature.clips.has(action) && armature.clips.get(action) !== undefined) return Promise.resolve();
|
|
210
|
-
armature.clips.set(action, undefined);
|
|
211
|
-
const done = readClip(armature, action).then((baked) => {
|
|
212
|
-
// A clip that cannot be posed is that clip's failure, named like the others; it never
|
|
213
|
-
// rejects the bake, so the loading phase that awaits it always settles.
|
|
214
|
-
try {
|
|
215
|
-
const clip = baked ? poseClip(baked) : null;
|
|
216
|
-
for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
|
|
217
|
-
if (clip) armature.clips.set(action, clip);
|
|
218
|
-
else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
|
|
219
|
-
} catch (error) {
|
|
220
|
-
fail(armature, action, `it could not be posed: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
-
}
|
|
222
|
-
}, (error: unknown) => {
|
|
223
|
-
const tried = (attempts.get(key) ?? 0) + 1;
|
|
224
|
-
attempts.set(key, tried);
|
|
225
|
-
const why = `it could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
226
|
-
// A READ THAT FAILED is not the clip's answer: the next pose asks again, up to READ_ATTEMPTS.
|
|
227
|
-
if (tried < READ_ATTEMPTS && !disposed) armature.clips.delete(action);
|
|
228
|
-
else fail(armature, action, why);
|
|
229
|
-
}).finally(() => baking.delete(key));
|
|
230
|
-
baking.set(key, done);
|
|
231
|
-
return done;
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Each armature's assigned action and the strips' actions of the NLA tracks it plays (enabled as
|
|
236
|
-
* `layersOf` reads them: NLA on, under a solo only the soloed track, else the unmuted ones): the
|
|
237
|
-
* clips it starts on. A muted track's strips are a clip library, baked when the game asks.
|
|
238
|
-
*/
|
|
239
|
-
const startingClips = (): { armature: Armature; action: string }[] => [...armatures.values()].flatMap((armature) => {
|
|
240
|
-
const names = new Set<string>();
|
|
241
|
-
if (armature.facts.action) names.add(armature.facts.action);
|
|
242
|
-
const animation = armature.facts.animation;
|
|
243
|
-
const solo = animation?.tracks.some((track) => track.solo) ?? false;
|
|
244
|
-
for (const track of animation && animation.useNla ? animation.tracks : [])
|
|
245
|
-
if (solo ? track.solo : !track.mute)
|
|
246
|
-
for (const strip of track.strips) if (strip.action) names.add(strip.action);
|
|
247
|
-
return [...names].filter((name) => name in facts.actions).map((action) => ({ armature, action }));
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
for (const rig of view.skeletons.rigs()) {
|
|
251
|
-
const entry = facts.armatures[rig.armature];
|
|
252
|
-
if (!entry) continue;
|
|
253
|
-
const pose = new ArmaturePose(rig);
|
|
254
|
-
pose.facts(entry);
|
|
255
|
-
for (const thing of pose.unsupported()) warnings.push(`${rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
|
|
256
|
-
const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map(), posed: [] };
|
|
257
|
-
// EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
|
|
258
|
-
if (entry.action) armature.line = [{ action: entry.action, from: 0, loop: true, speed: 1, weight: ramp(1, 1, 0) }];
|
|
259
|
-
armatures.set(rig.armature, armature);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** A game's action as a layer now: its frame on its own clock, repeated or held at its end. */
|
|
263
|
-
const playedLayer = (armature: Armature, played: Playing, influence: number, blend: string): PoseLayer | null | undefined => {
|
|
264
|
-
const clip = clipOf(armature, played.action);
|
|
265
|
-
if (!clip) return clip;
|
|
266
|
-
const length = clip.end - clip.start;
|
|
267
|
-
const elapsed = (time - played.from) * fps * played.speed;
|
|
268
|
-
const frame = length > 0 && played.loop ? clip.start + (((elapsed % length) + length) % length) : Math.min(clip.end, clip.start + elapsed);
|
|
269
|
-
return { clip, frame, influence: influence * played.weight.weight, blend };
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
/** Every layer of one armature now, bottom to top; null while a clip it needs is being baked. */
|
|
273
|
-
const layersOf = (armature: Armature): PoseLayer[] | null => {
|
|
274
|
-
const layers: PoseLayer[] = [];
|
|
275
|
-
/** Each layer's source, by index: a track's name, or null for the active action. */
|
|
276
|
-
const sources: (string | null)[] = [];
|
|
277
|
-
let source: string | null = null;
|
|
278
|
-
let waiting = false;
|
|
279
|
-
/** Whether an enabled track has strips (or a game's track plays): what places the active action. */
|
|
280
|
-
let evaluated = false;
|
|
281
|
-
const take = (layer: PoseLayer | null | undefined): void => {
|
|
282
|
-
if (layer === undefined) waiting = true;
|
|
283
|
-
else if (layer) { layers.push(layer); sources.push(source); }
|
|
284
|
-
};
|
|
285
|
-
const animation = armature.facts.animation;
|
|
286
|
-
// THE SCENE FRAME, wrapping at the scene's end as Blender's playback wraps.
|
|
287
|
-
const frame = sceneStart + ((time * fps) % sceneLength);
|
|
288
|
-
const solo = animation?.tracks.some((track) => track.solo) ?? false;
|
|
289
|
-
for (const track of animation && animation.useNla ? animation.tracks : []) {
|
|
290
|
-
const set = armature.tracks.get(track.name);
|
|
291
|
-
// ENABLED as Blender says: under a solo only the soloed track, muted or not.
|
|
292
|
-
if (solo ? !track.solo : (set ? set.mute : track.mute)) continue;
|
|
293
|
-
const influence = set ? set.influence.weight : 1;
|
|
294
|
-
source = track.name;
|
|
295
|
-
if (set?.action) {
|
|
296
|
-
const blend = track.strips[0]?.blendType ?? 'REPLACE';
|
|
297
|
-
if (set.previous) take(playedLayer(armature, set.previous, influence, blend));
|
|
298
|
-
take(playedLayer(armature, set.action, influence, blend));
|
|
299
|
-
evaluated = true;
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
const stack = nlaLayers({ ...animation!, tracks: [{ ...track, mute: false, solo: false }] }, frame, (action) => clipOf(armature, action));
|
|
303
|
-
if (stack.waiting) waiting = true;
|
|
304
|
-
if (stack.evaluated) evaluated = true;
|
|
305
|
-
for (const layer of stack.layers) { layers.push({ ...layer, influence: layer.influence * influence }); sources.push(track.name); }
|
|
306
|
-
}
|
|
307
|
-
for (const [name, set] of armature.tracks) {
|
|
308
|
-
if (!set.own || set.mute || !set.action) continue;
|
|
309
|
-
source = name;
|
|
310
|
-
if (set.previous) take(playedLayer(armature, set.previous, set.influence.weight, 'REPLACE'));
|
|
311
|
-
take(playedLayer(armature, set.action, set.influence.weight, 'REPLACE'));
|
|
312
|
-
evaluated = true;
|
|
313
|
-
}
|
|
314
|
-
// THE ACTIVE ACTION as Blender places it: not at all under a soloed track, alone and whole when
|
|
315
|
-
// no enabled track has strips, else at its influence and blend type over them.
|
|
316
|
-
const nla = !!animation?.useNla;
|
|
317
|
-
const strips = nla && evaluated;
|
|
318
|
-
source = null;
|
|
319
|
-
for (const played of nla && solo ? [] : armature.line)
|
|
320
|
-
take(playedLayer(armature, played, strips ? animation!.influence : 1, strips ? animation!.blendType : 'REPLACE'));
|
|
321
|
-
lastSources.set(armature, sources);
|
|
322
|
-
return waiting ? null : layers;
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
/** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
|
|
326
|
-
* armature among its ancestors and descendants (a character's root holds its armature). */
|
|
327
|
-
const own = (candidate: THREE.Object3D): Armature | null => {
|
|
328
|
-
for (const armature of armatures.values()) if (armature.rig.object === candidate) return armature;
|
|
329
|
-
const skin = (candidate as THREE.Mesh).geometry?.userData['blenderSkin'] as { armature: string } | undefined;
|
|
330
|
-
return skin ? armatures.get(skin.armature) ?? null : null;
|
|
331
|
-
};
|
|
332
|
-
const armatureOf = (object: THREE.Object3D): Armature | null => {
|
|
333
|
-
for (let at: THREE.Object3D | null = object; at; at = at.parent) {
|
|
334
|
-
const found = own(at);
|
|
335
|
-
if (found) return found;
|
|
336
|
-
}
|
|
337
|
-
let found: Armature | null = null;
|
|
338
|
-
object.traverse((child) => { if (!found) found = own(child); });
|
|
339
|
-
return found;
|
|
340
|
-
};
|
|
341
|
-
const refusal = (armature: Armature, action: string): string | null =>
|
|
342
|
-
!(action in facts.actions) ? `the file has no action "${action}"` : armature.failed.get(action) ?? null;
|
|
343
|
-
const start = (action: string, options: PlayActionOptions, weight: Ramp): Playing => ({
|
|
344
|
-
action, from: time, loop: options.loop ?? true, speed: options.speed ?? 1, weight,
|
|
345
|
-
});
|
|
346
|
-
const ok = (armature: Armature): Answer => ({ ok: true, armature: armature.rig.armature });
|
|
347
|
-
const warned = new Set<string>();
|
|
348
|
-
/** An action nothing keeps (no user, no fake user) vanishes on the file's next save: said once. */
|
|
349
|
-
const kept = (action: string): void => {
|
|
350
|
-
if (!facts.unkept.includes(action) || warned.has(action)) return;
|
|
351
|
-
warned.add(action);
|
|
352
|
-
const said = `The game plays "${action}", which nothing in the file uses and has no fake user, so Blender drops it the next time the file saves. Give it a fake user (action.use_fake_user = True) or assign it.`;
|
|
353
|
-
warnings.push(said);
|
|
354
|
-
editorHost().console.warn(said, 'blender-animation');
|
|
355
|
-
};
|
|
356
|
-
const none = (object: THREE.Object3D): Answer => ({ ok: false, why: `${object.name || 'this object'} has no armature` });
|
|
357
|
-
|
|
358
|
-
return {
|
|
359
|
-
warnings,
|
|
360
|
-
clips: (object) => (armatureOf(object) ? actions : []),
|
|
361
|
-
play(object, action, options = {}) {
|
|
362
|
-
const armature = armatureOf(object);
|
|
363
|
-
if (!armature) return none(object);
|
|
364
|
-
const refused = refusal(armature, action);
|
|
365
|
-
if (refused) return { ok: false, why: refused };
|
|
366
|
-
kept(action);
|
|
367
|
-
const current = armature.line.at(-1);
|
|
368
|
-
if (current?.action === action && current.weight.target > 0) {
|
|
369
|
-
if (options.restart) armature.line[armature.line.length - 1] = { ...current, from: time };
|
|
370
|
-
return ok(armature);
|
|
371
|
-
}
|
|
372
|
-
const fade = Math.max(0, options.fade ?? 0.2);
|
|
373
|
-
// A CROSSFADE is the new action over the old at a rising influence; the old goes once covered.
|
|
374
|
-
armature.line = current && fade > 0 ? [...armature.line, start(action, options, ramp(0, 1, fade))] : [start(action, options, ramp(1, 1, 0))];
|
|
375
|
-
return ok(armature);
|
|
376
|
-
},
|
|
377
|
-
stop(object, fade = 0.2) {
|
|
378
|
-
const armature = armatureOf(object);
|
|
379
|
-
if (!armature) return;
|
|
380
|
-
if (fade <= 0) armature.line = [];
|
|
381
|
-
else for (const played of armature.line) Object.assign(played.weight, ramp(played.weight.weight, 0, fade));
|
|
382
|
-
},
|
|
383
|
-
playing: (object) => {
|
|
384
|
-
const current = armatureOf(object)?.line.at(-1);
|
|
385
|
-
return current && current.weight.target > 0 ? current.action : null;
|
|
386
|
-
},
|
|
387
|
-
track(object, name, options) {
|
|
388
|
-
const armature = armatureOf(object);
|
|
389
|
-
if (!armature) return none(object);
|
|
390
|
-
const inFile = armature.facts.animation?.tracks.some((track) => track.name === name) ?? false;
|
|
391
|
-
const set = armature.tracks.get(name);
|
|
392
|
-
if (options === null) {
|
|
393
|
-
if (set?.own) Object.assign(set.influence, ramp(set.influence.weight, 0, 0.2));
|
|
394
|
-
else armature.tracks.delete(name);
|
|
395
|
-
return ok(armature);
|
|
396
|
-
}
|
|
397
|
-
if (options.action) {
|
|
398
|
-
const refused = refusal(armature, options.action);
|
|
399
|
-
if (refused) return { ok: false, why: refused };
|
|
400
|
-
kept(options.action);
|
|
401
|
-
} else if (!inFile && !set) {
|
|
402
|
-
return { ok: false, why: `${armature.rig.armature} has no NLA track "${name}"; give it an \`action\` to make one` };
|
|
403
|
-
}
|
|
404
|
-
const track: Track = set ?? { action: null, previous: null, influence: ramp(1, 1, 0), mute: false, own: !inFile };
|
|
405
|
-
if (options.influence !== undefined)
|
|
406
|
-
Object.assign(track.influence, ramp(track.influence.weight, Math.max(0, Math.min(1, options.influence)), Math.max(0, options.fade ?? 0)));
|
|
407
|
-
if (options.mute !== undefined) track.mute = options.mute;
|
|
408
|
-
if (options.action && (track.action?.action !== options.action || options.restart)) {
|
|
409
|
-
const crossfade = Math.max(0, options.fade ?? 0.2);
|
|
410
|
-
track.previous = track.action && crossfade > 0 ? track.action : null;
|
|
411
|
-
track.action = start(options.action, options, ramp(track.previous ? 0 : 1, 1, crossfade));
|
|
412
|
-
}
|
|
413
|
-
armature.tracks.set(name, track);
|
|
414
|
-
return ok(armature);
|
|
415
|
-
},
|
|
416
|
-
constraint(object, bone, name, options) {
|
|
417
|
-
const armature = armatureOf(object);
|
|
418
|
-
if (!armature) return none(object);
|
|
419
|
-
const declared = armature.facts.bones.find((one) => one.name === bone);
|
|
420
|
-
if (!declared) return { ok: false, why: `${armature.rig.armature} has no bone "${bone}"` };
|
|
421
|
-
const constraint = declared.constraints?.find((one) => one.name === name);
|
|
422
|
-
if (!constraint) return { ok: false, why: `bone "${bone}" has no constraint "${name}"; it has ${(declared.constraints ?? []).map((one) => `"${one.name}"`).join(', ') || 'none'}` };
|
|
423
|
-
if (constraint.type !== 'DAMPED_TRACK') return { ok: false, why: `"${name}" is a ${constraint.type} constraint, which plays only in Blender; a game plays Damped Track` };
|
|
424
|
-
const key = `${bone}\u0000${name}`;
|
|
425
|
-
armature.constraints.set(key, { ...armature.constraints.get(key), ...options });
|
|
426
|
-
return ok(armature);
|
|
427
|
-
},
|
|
428
|
-
async prepare() {
|
|
429
|
-
const wanted = startingClips();
|
|
430
|
-
for (let round = 0; round < READ_ATTEMPTS && !disposed; round += 1) {
|
|
431
|
-
const open = wanted.filter(({ armature, action }) => armature.clips.get(action) === undefined);
|
|
432
|
-
if (open.length === 0) break;
|
|
433
|
-
await Promise.all(open.map(({ armature, action }) => bakeClip(armature, action)));
|
|
434
|
-
}
|
|
435
|
-
const failed = wanted
|
|
436
|
-
.filter(({ armature, action }) => armature.clips.get(action) == null && armature.failed.has(action) && !/animates none/.test(armature.failed.get(action) ?? ''))
|
|
437
|
-
.map(({ armature, action }) => `${armature.rig.armature} / ${action}: ${armature.failed.get(action)}`);
|
|
438
|
-
return { failed };
|
|
439
|
-
},
|
|
440
|
-
prefetch() {
|
|
441
|
-
const users = new Map<string, Armature[]>();
|
|
442
|
-
for (const armature of armatures.values()) {
|
|
443
|
-
const names = new Set<string>();
|
|
444
|
-
if (armature.facts.action) names.add(armature.facts.action);
|
|
445
|
-
for (const track of armature.facts.animation?.tracks ?? [])
|
|
446
|
-
for (const strip of track.strips) if (strip.action) names.add(strip.action);
|
|
447
|
-
for (const action of names) if (action in facts.actions) users.set(action, [...(users.get(action) ?? []), armature]);
|
|
448
|
-
}
|
|
449
|
-
const queue = [...users].sort((a, b) => b[1].length - a[1].length)
|
|
450
|
-
.flatMap(([action, list]) => list.map((armature) => ({ armature, action })));
|
|
451
|
-
void (async () => {
|
|
452
|
-
while (!disposed) {
|
|
453
|
-
if (baking.size > 0) { await Promise.allSettled([...baking.values()]); continue; }
|
|
454
|
-
const next = queue.find(({ armature, action }) => !armature.clips.has(action));
|
|
455
|
-
if (!next) return;
|
|
456
|
-
await bakeClip(next.armature, next.action);
|
|
457
|
-
}
|
|
458
|
-
})();
|
|
459
|
-
},
|
|
460
|
-
pending() {
|
|
461
|
-
return startingClips()
|
|
462
|
-
.filter(({ armature, action }) => armature.clips.get(action) === undefined && !armature.failed.has(action))
|
|
463
|
-
.map(({ armature, action }) => `${armature.rig.armature} / ${action}`);
|
|
464
|
-
},
|
|
465
|
-
update(dt) {
|
|
466
|
-
if (disposed) return;
|
|
467
|
-
time += dt;
|
|
468
|
-
for (const armature of armatures.values()) {
|
|
469
|
-
for (const played of armature.line) step(played.weight, dt);
|
|
470
|
-
// What the newest action covers whole is gone; so is what has faded to nothing.
|
|
471
|
-
let covering = -1;
|
|
472
|
-
armature.line.forEach((played, index) => { if (played.weight.weight >= 1) covering = index; });
|
|
473
|
-
if (covering > 0) armature.line = armature.line.slice(covering);
|
|
474
|
-
armature.line = armature.line.filter((played) => played.weight.weight > 0 || played.weight.target > 0);
|
|
475
|
-
for (const [name, track] of armature.tracks) {
|
|
476
|
-
step(track.influence, dt);
|
|
477
|
-
if (track.action) step(track.action.weight, dt);
|
|
478
|
-
if (track.previous && track.action && track.action.weight.weight >= 1) track.previous = null;
|
|
479
|
-
if (track.own && track.influence.weight === 0 && track.influence.target === 0) armature.tracks.delete(name);
|
|
480
|
-
}
|
|
481
|
-
const layers = layersOf(armature);
|
|
482
|
-
if (!layers) continue;
|
|
483
|
-
const sources = lastSources.get(armature) ?? [];
|
|
484
|
-
armature.posed = layers.map((layer, i) => ({ track: sources[i] ?? null, action: layer.clip.action, frame: layer.frame, influence: layer.influence, blend: layer.blend }));
|
|
485
|
-
armature.pose.apply(layers, {
|
|
486
|
-
object: (name) => view.objectForBlenderName(name),
|
|
487
|
-
override: (bone, name) => armature.constraints.get(`${bone}\u0000${name}`),
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
},
|
|
491
|
-
channels(name, action) {
|
|
492
|
-
const clip = armatures.get(name)?.clips.get(action);
|
|
493
|
-
if (!clip) return [];
|
|
494
|
-
const bones = new Map<string, Set<number>>();
|
|
495
|
-
for (const channel of clip.channels) {
|
|
496
|
-
let frames = bones.get(channel.bone);
|
|
497
|
-
if (!frames) bones.set(channel.bone, frames = new Set());
|
|
498
|
-
for (const curve of channel.curves) if (curve) for (let i = 0; i < curve.keys.length; i += 6) frames.add(Math.round(curve.keys[i]! * 1000) / 1000);
|
|
499
|
-
}
|
|
500
|
-
return [...bones].map(([bone, frames]) => ({ bone, keys: [...frames].sort((a, b) => a - b) }));
|
|
501
|
-
},
|
|
502
|
-
live: () => [...armatures.values()].map((armature) => ({ armature: armature.rig.armature, layers: armature.posed })),
|
|
503
|
-
dispose() {
|
|
504
|
-
disposed = true;
|
|
505
|
-
armatures.clear();
|
|
506
|
-
},
|
|
507
|
-
};
|
|
508
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Window ▸ Save Screenshot (Editor)… — Blender's own row (`TOPBAR_MT_window`, `screen.screenshot_area`),
|
|
3
|
-
* the person's door to the photograph an agent takes with `get_viewport_screenshot`.
|
|
4
|
-
*
|
|
5
|
-
* Blender asks for a path in its file browser; the files door has no file picker, so the picture
|
|
6
|
-
* goes to the project's `screenshots/` folder and the console says which file it is. Like every
|
|
7
|
-
* photograph of the document it flashes the document and shows itself in the corner.
|
|
8
|
-
*/
|
|
9
|
-
import type { MenuContribution } from '@volter/sdk/chrome';
|
|
10
|
-
import { editorHost } from '@volter/sdk/host';
|
|
11
|
-
import { announcePhotograph } from '@volter/sdk/kit/photograph-notice';
|
|
12
|
-
|
|
13
|
-
export const point = 'workspace.menu';
|
|
14
|
-
|
|
15
|
-
export const menu: MenuContribution = {
|
|
16
|
-
menu: 'window',
|
|
17
|
-
items: [
|
|
18
|
-
{
|
|
19
|
-
id: 'blender-save-screenshot-editor',
|
|
20
|
-
label: 'Save Screenshot (Editor)…',
|
|
21
|
-
testId: 'menu-save-screenshot-editor',
|
|
22
|
-
execute: async () => {
|
|
23
|
-
const host = editorHost();
|
|
24
|
-
const capture = await host.documents.captureActive();
|
|
25
|
-
announcePhotograph(capture.base64);
|
|
26
|
-
const path = `screenshots/screenshot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
27
|
-
await host.files.write(path, Uint8Array.from(atob(capture.base64), (c) => c.charCodeAt(0)));
|
|
28
|
-
host.console.log(`Saved the screenshot to ${path}.`, 'blender-screenshot');
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
],
|
|
32
|
-
};
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE BLENDER VERBS of the session wire (`@volter/sdk/commands`, a
|
|
3
|
-
* `workspace.command` contribution): `blender-start`, `blender-stop`,
|
|
4
|
-
* `blender-execute`, `blender-scene-info`, `blender-object-info`,
|
|
5
|
-
* `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
|
|
6
|
-
* `blender-list-files`, `blender-status`, the RNA door's `blender-rna`,
|
|
7
|
-
* `blender-rna-context` and `blender-rna-set`, and the tree door's
|
|
8
|
-
* `blender-outliner` and `blender-outliner-set`.
|
|
9
|
-
*
|
|
10
|
-
* These left `command-listener.ts`'s switch (and their rows left
|
|
11
|
-
* `command-table.ts`) when Blender's editor half left the host — WORK.md
|
|
12
|
-
* §The workbench, item D. `volter blender-mcp` is transport only: every
|
|
13
|
-
* `execute_blender_code`, `get_scene_info`, `get_object_info` and
|
|
14
|
-
* `get_viewport_screenshot` still arrives as the same `blender-*` command
|
|
15
|
-
* through the same relay, and is answered here instead of there.
|
|
16
|
-
*
|
|
17
|
-
* EACH ROW CARRIES ITS OWN RELAY BUDGET, transcribed from the host table it
|
|
18
|
-
* left — the page reports the rows it registered and the server sizes its
|
|
19
|
-
* wait from them, so a contributed verb never falls to the generic 5s
|
|
20
|
-
* default (the failure `command-table.ts`'s header records). Blender
|
|
21
|
-
* runs in this tab's worker; a chunk can model for a while (the engine
|
|
22
|
-
* boots on the first call), and every one presents a frame.
|
|
23
|
-
*
|
|
24
|
-
* THIS LANE HOLDS NO HOST INTERNAL (2026-09-19, WORK.md §Skews as packages
|
|
25
|
-
* under the Code-OSS frame, item 6). It took the shell store from
|
|
26
|
-
* `@editor/shell-store-door` to hand to the runtime host, which needed it for
|
|
27
|
-
* one call — presenting the Model document — and that call is now
|
|
28
|
-
* `host.workspace.open`, an address the door routes. A handler's whole context
|
|
29
|
-
* is the command it is given plus `@volter/sdk/host`.
|
|
30
|
-
*/
|
|
31
|
-
import type {
|
|
32
|
-
CommandContribution,
|
|
33
|
-
CommandDerivedRefresh,
|
|
34
|
-
CommandSpec,
|
|
35
|
-
} from '@volter/sdk/commands';
|
|
36
|
-
import { handleBlenderCommand } from '../host/blender-runtime-host';
|
|
37
|
-
|
|
38
|
-
export const point = 'workspace.command';
|
|
39
|
-
|
|
40
|
-
/** One Blender verb: its budget, and the lane's one handler. A row with no
|
|
41
|
-
* `timeoutMs` takes the host's own generic budget — the value the rows it
|
|
42
|
-
* left spelled as `noDerivedRefresh()`'s default, and not restated here so
|
|
43
|
-
* the two cannot drift. `derivedRefresh` IS always stated: the registry's
|
|
44
|
-
* default for a contributed row is `'always'`, and only the two verbs that
|
|
45
|
-
* change what the Model document shows owe a derivation.
|
|
46
|
-
*/
|
|
47
|
-
const verb = (derivedRefresh: CommandDerivedRefresh, timeoutMs?: number): CommandSpec => ({
|
|
48
|
-
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
49
|
-
derivedRefresh,
|
|
50
|
-
handle: (cmd) => handleBlenderCommand(cmd as { type: string; [key: string]: unknown }),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
export const commands: CommandContribution['commands'] = {
|
|
54
|
-
// Presentation-only comparison, no engine mutation or derived frame refresh.
|
|
55
|
-
'blender-draw-batching': verb('none'),
|
|
56
|
-
'blender-start': verb('none', 120_000),
|
|
57
|
-
// Stop drains accepted modeling work and persists it before teardown.
|
|
58
|
-
'blender-stop': verb('none', 30 * 60_000),
|
|
59
|
-
'blender-execute': verb('always', 30 * 60_000),
|
|
60
|
-
'blender-native-preview': verb('none', 120_000),
|
|
61
|
-
'blender-scene-info': verb('none', 60_000),
|
|
62
|
-
'blender-object-info': verb('none', 60_000),
|
|
63
|
-
'blender-screenshot-view': verb('always', 60_000),
|
|
64
|
-
'blender-read-file': verb('none', 60_000),
|
|
65
|
-
'blender-write-file': verb('none', 60_000),
|
|
66
|
-
// Listing waits on the worker like a read: after an execute the MCP mirror lists
|
|
67
|
-
// while the page is still presenting the frame and refreshing its RNA and
|
|
68
|
-
// outliner reads, which on a large document outlasts the generic 5s default and
|
|
69
|
-
// turned a successful execute into a timeout.
|
|
70
|
-
'blender-list-files': verb('none', 60_000),
|
|
71
|
-
// THE RNA DOOR (WORK.md §Blender in the tab is Blender, "Inspection
|
|
72
|
-
// parity", I1). Two READS — a datablock's `bl_rna.properties` and the
|
|
73
|
-
// Properties context — and one WRITE. The reads present nothing, so they
|
|
74
|
-
// owe no derivation; `blender-rna-set` mutates the model and the session
|
|
75
|
-
// presents the frame, so it derives like `blender-execute`.
|
|
76
|
-
'blender-rna': verb('none', 60_000),
|
|
77
|
-
'blender-rna-context': verb('none', 60_000),
|
|
78
|
-
'blender-rna-set': verb('always', 60_000),
|
|
79
|
-
// THE TREE DOOR (I3): Blender's View Layer tree, and one restriction column
|
|
80
|
-
// written. Same split — the read presents nothing; the column write mutates
|
|
81
|
-
// the model and presents, so it derives.
|
|
82
|
-
'blender-outliner': verb('none', 60_000),
|
|
83
|
-
// THE NODE-TREE DOOR (I5). A READ, so it presents nothing and owes no
|
|
84
|
-
// derivation; there is deliberately no writer beside it.
|
|
85
|
-
'blender-node-tree': verb('none', 60_000),
|
|
86
|
-
// THE UV DOOR (I5, UV Editing). A READ of one mesh's UV layout; pinning,
|
|
87
|
-
// unwrapping and selecting are edits and there is no writer beside it.
|
|
88
|
-
'blender-uv-layout': verb('none', 60_000),
|
|
89
|
-
// THE RIG AND CLIP DOORS (the Timeline). Both READS — the skin binding a
|
|
90
|
-
// `THREE.SkinnedMesh` is built from and the action as three.js tracks — so
|
|
91
|
-
// neither presents and neither owes a derivation. There is deliberately no
|
|
92
|
-
// writer beside them: keying, moving a key and setting a range are edits,
|
|
93
|
-
// and the ONE write the Timeline makes (`scene.frame_current`, once on
|
|
94
|
-
// pause and at scrub-end) goes through `blender-rna-set`, which already
|
|
95
|
-
// exists and already presents.
|
|
96
|
-
'blender-action-clip': verb('none', 120_000),
|
|
97
|
-
// THE NODE VIEW'S OWN ACTIONS as a session verb — the keyboard ruling's
|
|
98
|
-
// `volter.*`-command-per-action pattern, and the only way the drawer's view
|
|
99
|
-
// can be read or driven at all: `editor.document.*` is scoped to the active
|
|
100
|
-
// CENTER document by its own contract, and a utility is not one. Read-only
|
|
101
|
-
// over the MODEL: `look` and the view transform are inspection state, and a
|
|
102
|
-
// gesture that would edit is answered with the same refusal the pointer
|
|
103
|
-
// handlers give.
|
|
104
|
-
'blender-node-view': verb('none'),
|
|
105
|
-
'blender-outliner-set': verb('always', 60_000),
|
|
106
|
-
// A read of whether this tab HAS a session; it never starts one — and the
|
|
107
|
-
// one verb that answers before a project session exists.
|
|
108
|
-
'blender-status': verb('none'),
|
|
109
|
-
};
|