@volter/editor-blender 0.1.135 → 0.1.137

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.
@@ -1280,7 +1280,11 @@ export function blenderOutlinerAuthoringFor(
1280
1280
  // is the other end of the same seam `hierarchy.node` already normalizes.
1281
1281
  // An id that resolves to no row is DROPPED rather than stored: a row id
1282
1282
  // is this provider's whole currency and a foreign one is not a selection.
1283
- selected = ids.map(rowIdFor).filter((id): id is string => id !== null);
1283
+ // The WRITE below still names a presented object that has no row yet.
1284
+ // One row lookup per id: `rowIdForObject` scans the object rows, and a marquee over a
1285
+ // large scene passes every object it covers.
1286
+ const rowIds = ids.map(rowIdFor);
1287
+ selected = rowIds.filter((id): id is string => id !== null);
1284
1288
  publishPresentation();
1285
1289
  // AND IT WRITES BLENDER'S (see {@link writeBlenderSelection}). The
1286
1290
  // subject is the OWNING OBJECT of each selected row, which is Blender's
@@ -1294,14 +1298,37 @@ export function blenderOutlinerAuthoringFor(
1294
1298
  // its object selection, and a person who clicked one and saw the
1295
1299
  // Properties rail keep the last object deserves the sentence.
1296
1300
  const table = rows();
1301
+ const view = presented();
1297
1302
  const names: string[] = [];
1298
1303
  const ownerless: string[] = [];
1299
- for (const id of selected) {
1300
- const row = table.get(id);
1301
- const name = row?.object;
1302
- if (name === undefined) ownerless.push(row?.name ?? id);
1304
+ const unnamed: string[] = [];
1305
+ ids.forEach((id, index) => {
1306
+ const rowId = rowIds[index]!;
1307
+ if (rowId !== null) {
1308
+ const row = table.get(rowId);
1309
+ const name = row?.object;
1310
+ if (name === undefined) ownerless.push(row?.name ?? rowId);
1311
+ else if (!names.includes(name)) names.push(name);
1312
+ return;
1313
+ }
1314
+ // A PRESENTED OBJECT PAST THE TREE'S PAGE IS STILL BLENDER'S. `rna_outliner`
1315
+ // answers the first `_OUTLINER_PAGE` rows of every child list, so in a larger
1316
+ // scene the viewport picks objects that have no row yet (Canyon Comet: 1,146 rows
1317
+ // under its one collection, 64 answered, all six karts among the rest). The view
1318
+ // names the object by identity; the tree read for the frame this write presents
1319
+ // keeps the selection's rows (`page_tree`), and `syncFromEngine` takes the row up.
1320
+ const object = threeObject(defaultAdapter.hierarchy, id);
1321
+ const name = object === null ? null : (view?.blenderObjectName(object) ?? null);
1322
+ if (name === null) unnamed.push(id);
1303
1323
  else if (!names.includes(name)) names.push(name);
1304
- }
1324
+ });
1325
+ // Said, not swallowed: a picked object the view can't name is a selection Blender never gets.
1326
+ if (unnamed.length > 0)
1327
+ editorHost().console.warn(
1328
+ `No Blender object answers to ${unnamed.join(', ')}, so Blender's selection leaves ` +
1329
+ `${unnamed.length === 1 ? 'it' : 'them'} out.`,
1330
+ 'blender-outliner',
1331
+ );
1305
1332
  if (ownerless.length > 0)
1306
1333
  editorHost().console.warn(
1307
1334
  `${ownerless.join(', ')} ${ownerless.length === 1 ? 'is' : 'are'} not an object, so ` +
@@ -1,236 +1,335 @@
1
1
  /**
2
- * ANIMATION IN PLAY: the same skin and clips the Timeline plays (`blender-runtime-skin.ts`), bound
3
- * to Play's detached copy and driven by the game.
2
+ * ANIMATION IN A GAME: Blender's own animation stack, evaluated on the game's copy of the model.
4
3
  *
5
- * Play runs a game on a copy of the model (`BlenderRuntimeView.detach`). Until this module the copy
6
- * was built without its skins, so every rigged character was a frozen mesh in a game while the same
7
- * character played in Movie. Nothing here is a second animation system: the rigs are bound by
8
- * `bindRig` and the clips built by `clipTracks`, the Timeline's own code, over the copy's graph.
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
9
  *
10
- * WHAT A GAME PLAYS is any action that animates an armature's bones, assigned or not, so a file can
11
- * hold a clip library (Idle, Run, Attack, Die side by side) and a game chooses among them by name.
12
- * Each armature has its own `THREE.AnimationMixer`; a play crossfades from what played before.
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.
13
19
  *
14
- * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by the `dt` each `update` is handed
15
- * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
16
- * animation exactly as they do for the script. Nothing advances on the page's own time.
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.
17
24
  *
18
- * READ ONCE PER PLAY: the rig and every clip are read from the engine when Play starts (`rna_rig`
19
- * walks every vertex of every rigged mesh, a clip bakes every bone per frame), never per frame.
25
+ * THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
26
+ * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
27
+ * animation exactly as they do for the script.
20
28
  */
21
- import type { BlenderActionClip, BlenderRig } from '@volter/blender-engine/browser/rna';
22
- import * as THREE from 'three';
23
- import { blenderActionClip, blenderArmatureActions, blenderRig } from '../host/blender-runtime-host';
24
- import { bindRig, clipTracks, type BoundRig, type SkinPresentation } from './blender-runtime-skin';
25
-
26
- /** What Play reads from the engine: the rigs, every armature's action names, and the assigned
27
- * actions baked. Any other action is baked the first time a game sets it (`bake`). */
28
- export interface PlayClipLibrary {
29
- readonly rig: BlenderRig | null;
30
- /** Armature name -> the actions that can play on it. */
31
- readonly names: ReadonlyMap<string, readonly string[]>;
32
- /** Armature name -> the action assigned to it in the file (`animation_data.action`), which it
33
- * plays from the start of a run, as Blender would. */
34
- readonly assigned: ReadonlyMap<string, string>;
35
- /** Armature name -> its baked clips by action name (the assigned ones at first). */
36
- readonly clips: ReadonlyMap<string, ReadonlyMap<string, BlenderActionClip>>;
37
- /** Bake one more action for an armature; null, with the reason in `warnings`, when it cannot play. */
38
- bake(armature: string, action: string): Promise<BlenderActionClip | null>;
39
- readonly warnings: string[];
40
- }
41
-
42
- /** Read the rigs and the assigned clips a game starts with. Empty when the file has no armature. */
43
- export async function readPlayClipLibrary(): Promise<PlayClipLibrary> {
44
- const warnings: string[] = [];
45
- const [rig, listing] = await Promise.all([blenderRig(), blenderArmatureActions()]);
46
- const names = new Map<string, readonly string[]>();
47
- const assigned = new Map<string, string>();
48
- const clips = new Map<string, Map<string, BlenderActionClip>>();
49
- const bake = async (armature: string, action: string): Promise<BlenderActionClip | null> => {
50
- try {
51
- const clip = await blenderActionClip({ object: armature, action });
52
- if (clip?.reason) warnings.push(`${armature} / ${action}: ${clip.reason}`);
53
- if (clip?.tracks.length && clip.clipStart !== undefined && clip.clipEnd !== undefined) return clip;
54
- } catch (error) {
55
- warnings.push(`${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
56
- }
57
- return null;
58
- };
59
- await Promise.all((listing?.armatures ?? []).map(async ({ armature, actions, current }) => {
60
- names.set(armature, actions);
61
- if (!current) return;
62
- assigned.set(armature, current);
63
- const clip = await bake(armature, current);
64
- if (clip) clips.set(armature, new Map([[current, clip]]));
65
- }));
66
- return { rig, names, assigned, clips, bake, warnings };
67
- }
29
+ import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
30
+ import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
31
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
32
+ import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
33
+ import type * as THREE from 'three';
34
+ import { ArmaturePose, nlaLayers, poseClip, type ConstraintOverride, type PoseClip, type PoseLayer } from './blender-pose';
35
+ import { editorHost } from '@volter/editor-sdk/host';
36
+ import { remedy } from './blender-runtime-skin';
68
37
 
69
- export interface PlayAnimateOptions {
70
- /** Repeat until something else plays (default true). */
71
- readonly loop?: boolean;
72
- /** Seconds to crossfade from what this armature played before (default 0.2). */
38
+ export interface PlayActionOptions {
39
+ /** Seconds to crossfade from what played before (default 0.2). */
73
40
  readonly fade?: number;
74
- /** Playback rate, 1 is the clip's own speed. */
41
+ /** Repeat until something else plays (default true); `false` plays once and holds the end. */
42
+ readonly loop?: boolean;
43
+ /** Playback rate, 1 is the action's own. */
75
44
  readonly speed?: number;
76
- /** Start again from the first frame when this clip is already playing (default false). */
45
+ /** Start again from the first frame when this action is already playing. */
77
46
  readonly restart?: boolean;
78
47
  }
79
48
 
80
- /** The game's door to its characters' clips (`ModelPlayContext.animate`). */
49
+ export interface PlayTrackOptions extends PlayActionOptions {
50
+ /** The action the track plays from now; absent, the track plays what the file gives it. */
51
+ readonly action?: string;
52
+ /** The track's influence, 0 to 1, reached over `fade` (default 0, at once). */
53
+ readonly influence?: number;
54
+ readonly mute?: boolean;
55
+ }
56
+
57
+ export interface PlayConstraintOptions {
58
+ readonly influence?: number;
59
+ /** The object it aims at instead of the file's target. */
60
+ readonly target?: THREE.Object3D | null;
61
+ }
62
+
63
+ type Answer = { ok: true; armature: string } | { ok: false; why: string };
64
+
65
+ /** The game's door to its characters' animation (`ModelPlayContext.setAction`, `setTrack`, `setConstraint`). */
81
66
  export interface PlayAnimation {
82
- /** The clips an object can play: its own armature's, or the armature its rigged mesh is bound to. */
67
+ /** The actions in the file, which an object's armature may play. */
83
68
  clips(object: THREE.Object3D): readonly string[];
84
- /** Play a clip on an object's armature; the reason when it cannot. */
85
- play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): { ok: true; armature: string } | { ok: false; why: string };
86
- /** Fade out whatever the object's armature plays. */
69
+ play(object: THREE.Object3D, action: string, options?: PlayActionOptions): Answer;
87
70
  stop(object: THREE.Object3D, fade?: number): void;
88
- /** The clip the object's armature plays now, or null. */
71
+ /** The active action, as last set. */
89
72
  playing(object: THREE.Object3D): string | null;
90
- /** Advance every mixer by one update's `dt` (the game's clock). */
73
+ /** Set an NLA track; `null` gives it back to the file (a track the game made goes). */
74
+ track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
75
+ constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
91
76
  update(dt: number): void;
92
77
  readonly warnings: readonly string[];
93
78
  dispose(): void;
94
79
  }
95
80
 
81
+ /** A weight that moves toward its target at a rate (a crossfade). */
82
+ interface Ramp { weight: number; target: number; rate: number }
83
+ const ramp = (weight: number, target: number, fade: number): Ramp => ({ weight, target, rate: fade > 0 ? 1 / fade : 0 });
84
+ const step = (r: Ramp, dt: number): void => {
85
+ 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);
86
+ };
87
+
88
+ /** An action a game set, on its own clock from when it was set. */
89
+ interface Playing { readonly action: string; readonly from: number; readonly loop: boolean; readonly speed: number; readonly weight: Ramp }
90
+
91
+ interface Track { action: Playing | null; previous: Playing | null; readonly influence: Ramp; mute: boolean; readonly own: boolean }
92
+
96
93
  interface Armature {
97
- readonly name: string;
98
- readonly object: THREE.Object3D;
99
- readonly mixer: THREE.AnimationMixer;
100
- /** Every action that can play on it, baked or not. */
101
- readonly names: readonly string[];
102
- readonly bones: ReadonlyMap<string, THREE.Bone>;
103
- readonly clips: Map<string, THREE.AnimationClip>;
104
- /** Bakes in flight, by action name. */
105
- readonly baking: Map<string, Promise<void>>;
106
- /** Actions whose bake gave nothing to play: asked once, never re-baked every update. */
107
- readonly failed: Set<string>;
108
- current: { readonly name: string; readonly action: THREE.AnimationAction } | null;
109
- /** The action most recently set, which a bake in flight starts when it lands. */
110
- wanted: { readonly name: string; readonly options: PlayAnimateOptions } | null;
94
+ readonly rig: ArmatureRig;
95
+ readonly pose: ArmaturePose;
96
+ readonly facts: BlenderArmature;
97
+ /** Baked clips by action: the clip, undefined while baking, null when it plays nothing. */
98
+ readonly clips: Map<string, PoseClip | null | undefined>;
99
+ readonly failed: Map<string, string>;
100
+ /** The active action and the ones fading out under it, oldest first. */
101
+ line: Playing[];
102
+ readonly tracks: Map<string, Track>;
103
+ readonly constraints: Map<string, ConstraintOverride>;
111
104
  }
112
105
 
113
- /** Bind the library's rigs and clips to Play's copy. */
114
- export function bindPlayAnimation(presentation: SkinPresentation, library: PlayClipLibrary): PlayAnimation {
115
- const warnings = [...library.warnings];
116
- const rigs: BoundRig[] = [];
117
- for (const rig of library.rig?.rigs ?? []) {
118
- if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
119
- if (rig.reason) warnings.push(rig.reason);
120
- continue;
121
- }
122
- const bound = bindRig(presentation, rig, library.rig!.frame, warnings);
123
- if (bound) rigs.push(bound);
124
- }
106
+ /** The copy's animation. `bake` reads one action of one armature through the clip door. */
107
+ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>): PlayAnimation {
108
+ const warnings: string[] = [];
109
+ const facts = view.animationFacts();
110
+ const actions = Object.keys(facts.actions);
111
+ const fps = facts.clock?.fps || 24;
112
+ const sceneStart = facts.clock?.start ?? 1;
113
+ const sceneLength = Math.max(1, (facts.clock?.end ?? sceneStart + 249) - sceneStart + 1);
125
114
  const armatures = new Map<string, Armature>();
126
- /** A mesh's presented object → the armature its skin is bound to. */
127
- const meshArmature = new Map<THREE.Object3D, string>();
128
- for (const rig of rigs) meshArmature.set(rig.mesh, rig.armature);
129
- const toClip = (action: string, clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>): THREE.AnimationClip | null => {
130
- const tracks = clipTracks(clip, bones, warnings);
131
- if (!tracks.length) return null;
132
- return new THREE.AnimationClip(action, clip.duration ?? (clip.clipEnd! - clip.clipStart!) / clip.fps, tracks);
115
+ let time = 0;
116
+ let disposed = false;
117
+
118
+ const fail = (armature: Armature, action: string, why: string): void => {
119
+ armature.clips.set(action, null);
120
+ armature.failed.set(action, why);
121
+ warnings.push(`${armature.rig.armature} / ${action}: ${why}`);
133
122
  };
134
- for (const [name, actions] of library.names) {
135
- const object = presentation.objectForBlenderName(name);
136
- if (!object || !actions.length) continue;
137
- const bones = new Map<string, THREE.Bone>();
138
- for (const rig of rigs) if (rig.armature === name) for (const bone of rig.bones) bones.set(bone.name, bone);
139
- if (!bones.size) { warnings.push(`${name} has actions but no skinned mesh is bound to it, so they would move nothing.`); continue; }
140
- const clips = new Map<string, THREE.AnimationClip>();
141
- for (const [action, clip] of library.clips.get(name) ?? []) {
142
- const built = toClip(action, clip, bones);
143
- if (built) clips.set(action, built);
144
- }
145
- armatures.set(name, { name, object, mixer: new THREE.AnimationMixer(object), names: actions, bones, clips, baking: new Map(), failed: new Set(), current: null, wanted: null });
146
- }
147
- const objectArmature = new Map<THREE.Object3D, Armature>();
148
- for (const armature of armatures.values()) objectArmature.set(armature.object, armature);
149
- // EACH ARMATURE PLAYS ITS ASSIGNED ACTION from the first update, as Blender's viewport does.
150
- for (const armature of armatures.values()) {
151
- const name = library.assigned.get(armature.name);
152
- const source = name ? armature.clips.get(name) : undefined;
153
- if (!name || !source) continue;
154
- const action = armature.mixer.clipAction(source);
155
- action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
156
- action.play();
157
- armature.current = { name, action };
123
+ const clipOf = (armature: Armature, action: string): PoseClip | null | undefined => {
124
+ if (armature.clips.has(action)) return armature.clips.get(action);
125
+ armature.clips.set(action, undefined);
126
+ void bake(armature.rig.armature, action).then((baked) => {
127
+ const clip = baked ? poseClip(baked) : null;
128
+ for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
129
+ if (clip) armature.clips.set(action, clip);
130
+ else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
131
+ }, (error: unknown) => fail(armature, action, `it could not be read: ${error instanceof Error ? error.message : String(error)}`));
132
+ return undefined;
133
+ };
134
+
135
+ for (const rig of view.skeletons.rigs()) {
136
+ const entry = facts.armatures[rig.armature];
137
+ if (!entry) continue;
138
+ const pose = new ArmaturePose(rig);
139
+ pose.facts(entry);
140
+ for (const thing of pose.unsupported()) warnings.push(`${rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
141
+ const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map() };
142
+ // EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
143
+ if (entry.action) armature.line = [{ action: entry.action, from: 0, loop: true, speed: 1, weight: ramp(1, 1, 0) }];
144
+ armatures.set(rig.armature, armature);
158
145
  }
159
- /** The armature that animates `object`: itself, the one its skin is bound to, or the nearest
160
- * armature among its ancestors and descendants (a character's root empty holds its armature). */
146
+
147
+ /** A game's action as a layer now: its frame on its own clock, repeated or held at its end. */
148
+ const playedLayer = (armature: Armature, played: Playing, influence: number, blend: string): PoseLayer | null | undefined => {
149
+ const clip = clipOf(armature, played.action);
150
+ if (!clip) return clip;
151
+ const length = clip.end - clip.start;
152
+ const elapsed = (time - played.from) * fps * played.speed;
153
+ const frame = length > 0 && played.loop ? clip.start + (((elapsed % length) + length) % length) : Math.min(clip.end, clip.start + elapsed);
154
+ return { clip, frame, influence: influence * played.weight.weight, blend };
155
+ };
156
+
157
+ /** Every layer of one armature now, bottom to top; null while a clip it needs is being baked. */
158
+ const layersOf = (armature: Armature): PoseLayer[] | null => {
159
+ const layers: PoseLayer[] = [];
160
+ let waiting = false;
161
+ /** Whether an enabled track has strips (or a game's track plays): what places the active action. */
162
+ let evaluated = false;
163
+ const take = (layer: PoseLayer | null | undefined): void => {
164
+ if (layer === undefined) waiting = true;
165
+ else if (layer) layers.push(layer);
166
+ };
167
+ const animation = armature.facts.animation;
168
+ // THE SCENE FRAME, wrapping at the scene's end as Blender's playback wraps.
169
+ const frame = sceneStart + ((time * fps) % sceneLength);
170
+ const solo = animation?.tracks.some((track) => track.solo) ?? false;
171
+ for (const track of animation && animation.useNla ? animation.tracks : []) {
172
+ const set = armature.tracks.get(track.name);
173
+ // ENABLED as Blender says: under a solo only the soloed track, muted or not.
174
+ if (solo ? !track.solo : (set ? set.mute : track.mute)) continue;
175
+ const influence = set ? set.influence.weight : 1;
176
+ if (set?.action) {
177
+ const blend = track.strips[0]?.blendType ?? 'REPLACE';
178
+ if (set.previous) take(playedLayer(armature, set.previous, influence, blend));
179
+ take(playedLayer(armature, set.action, influence, blend));
180
+ evaluated = true;
181
+ continue;
182
+ }
183
+ const stack = nlaLayers({ ...animation!, tracks: [{ ...track, mute: false, solo: false }] }, frame, (action) => clipOf(armature, action));
184
+ if (stack.waiting) waiting = true;
185
+ if (stack.evaluated) evaluated = true;
186
+ for (const layer of stack.layers) layers.push({ ...layer, influence: layer.influence * influence });
187
+ }
188
+ for (const set of armature.tracks.values()) {
189
+ if (!set.own || set.mute || !set.action) continue;
190
+ if (set.previous) take(playedLayer(armature, set.previous, set.influence.weight, 'REPLACE'));
191
+ take(playedLayer(armature, set.action, set.influence.weight, 'REPLACE'));
192
+ evaluated = true;
193
+ }
194
+ // THE ACTIVE ACTION as Blender places it: not at all under a soloed track, alone and whole when
195
+ // no enabled track has strips, else at its influence and blend type over them.
196
+ const nla = !!animation?.useNla;
197
+ const strips = nla && evaluated;
198
+ for (const played of nla && solo ? [] : armature.line)
199
+ take(playedLayer(armature, played, strips ? animation!.influence : 1, strips ? animation!.blendType : 'REPLACE'));
200
+ return waiting ? null : layers;
201
+ };
202
+
203
+ /** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
204
+ * armature among its ancestors and descendants (a character's root holds its armature). */
205
+ const own = (candidate: THREE.Object3D): Armature | null => {
206
+ for (const armature of armatures.values()) if (armature.rig.object === candidate) return armature;
207
+ const skin = (candidate as THREE.Mesh).geometry?.userData['blenderSkin'] as { armature: string } | undefined;
208
+ return skin ? armatures.get(skin.armature) ?? null : null;
209
+ };
161
210
  const armatureOf = (object: THREE.Object3D): Armature | null => {
162
- const own = objectArmature.get(object) ?? armatures.get(meshArmature.get(object) ?? '');
163
- if (own) return own;
164
- for (let parent = object.parent; parent; parent = parent.parent) {
165
- const found = objectArmature.get(parent);
211
+ for (let at: THREE.Object3D | null = object; at; at = at.parent) {
212
+ const found = own(at);
166
213
  if (found) return found;
167
214
  }
168
215
  let found: Armature | null = null;
169
- object.traverse((child) => { if (!found) found = objectArmature.get(child) ?? armatures.get(meshArmature.get(child) ?? '') ?? null; });
216
+ object.traverse((child) => { if (!found) found = own(child); });
170
217
  return found;
171
218
  };
172
- let disposed = false;
173
- /** Crossfade an armature to a baked action. */
174
- const start = (armature: Armature, clip: string, options: PlayAnimateOptions): void => {
175
- const source = armature.clips.get(clip)!;
176
- const fade = Math.max(0, options.fade ?? 0.2);
177
- const action = armature.mixer.clipAction(source);
178
- action.setLoop(options.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Number.POSITIVE_INFINITY);
179
- action.clampWhenFinished = options.loop === false;
180
- action.setEffectiveTimeScale(options.speed ?? 1);
181
- if (armature.current?.name === clip) {
182
- if (options.restart) action.reset().play();
183
- return;
184
- }
185
- action.reset().setEffectiveWeight(1).play();
186
- if (armature.current && fade > 0) armature.current.action.crossFadeTo(action, fade, false);
187
- else armature.current?.action.stop();
188
- armature.current = { name: clip, action };
219
+ const refusal = (armature: Armature, action: string): string | null =>
220
+ !(action in facts.actions) ? `the file has no action "${action}"` : armature.failed.get(action) ?? null;
221
+ const start = (action: string, options: PlayActionOptions, weight: Ramp): Playing => ({
222
+ action, from: time, loop: options.loop ?? true, speed: options.speed ?? 1, weight,
223
+ });
224
+ const ok = (armature: Armature): Answer => ({ ok: true, armature: armature.rig.armature });
225
+ const warned = new Set<string>();
226
+ /** An action nothing keeps (no user, no fake user) vanishes on the file's next save: said once. */
227
+ const kept = (action: string): void => {
228
+ if (!facts.unkept.includes(action) || warned.has(action)) return;
229
+ warned.add(action);
230
+ 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.`;
231
+ warnings.push(said);
232
+ editorHost().console.warn(said, 'blender-animation');
189
233
  };
234
+ const none = (object: THREE.Object3D): Answer => ({ ok: false, why: `${object.name || 'this object'} has no armature` });
235
+
190
236
  return {
191
237
  warnings,
192
- clips: (object) => [...(armatureOf(object)?.names ?? [])],
193
- play(object, clip, options = {}) {
238
+ clips: (object) => (armatureOf(object) ? actions : []),
239
+ play(object, action, options = {}) {
194
240
  const armature = armatureOf(object);
195
- if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature with actions` };
196
- if (!armature.names.includes(clip)) return { ok: false, why: `${armature.name} has no action "${clip}"; it has ${armature.names.join(', ')}` };
197
- if (armature.failed.has(clip)) return { ok: false, why: `${armature.name}'s action "${clip}" has nothing this armature can play (see the console)` };
198
- armature.wanted = { name: clip, options };
199
- if (!armature.clips.has(clip)) {
200
- // FIRST USE: baked now, and started when it lands if it is still the one wanted.
201
- if (!armature.baking.has(clip))
202
- armature.baking.set(clip, library.bake(armature.name, clip).then((baked) => {
203
- armature.baking.delete(clip);
204
- const built = baked ? toClip(clip, baked, armature.bones) : null;
205
- if (built) armature.clips.set(clip, built);
206
- else {
207
- armature.failed.add(clip);
208
- if (armature.wanted?.name === clip) armature.wanted = null;
209
- }
210
- if (built && !disposed && armature.wanted?.name === clip) start(armature, clip, armature.wanted.options);
211
- }));
212
- return { ok: true, armature: armature.name };
241
+ if (!armature) return none(object);
242
+ const refused = refusal(armature, action);
243
+ if (refused) return { ok: false, why: refused };
244
+ kept(action);
245
+ const current = armature.line.at(-1);
246
+ if (current?.action === action && current.weight.target > 0) {
247
+ if (options.restart) armature.line[armature.line.length - 1] = { ...current, from: time };
248
+ return ok(armature);
213
249
  }
214
- start(armature, clip, options);
215
- return { ok: true, armature: armature.name };
250
+ const fade = Math.max(0, options.fade ?? 0.2);
251
+ // A CROSSFADE is the new action over the old at a rising influence; the old goes once covered.
252
+ armature.line = current && fade > 0 ? [...armature.line, start(action, options, ramp(0, 1, fade))] : [start(action, options, ramp(1, 1, 0))];
253
+ return ok(armature);
216
254
  },
217
255
  stop(object, fade = 0.2) {
218
256
  const armature = armatureOf(object);
219
- if (armature) armature.wanted = null;
220
- if (!armature?.current) return;
221
- if (fade > 0) armature.current.action.fadeOut(fade); else armature.current.action.stop();
222
- armature.current = null;
257
+ if (!armature) return;
258
+ if (fade <= 0) armature.line = [];
259
+ else for (const played of armature.line) Object.assign(played.weight, ramp(played.weight.weight, 0, fade));
223
260
  },
224
- // What was last set, so a script that sets the same action every update while it bakes asks once.
225
261
  playing: (object) => {
262
+ const current = armatureOf(object)?.line.at(-1);
263
+ return current && current.weight.target > 0 ? current.action : null;
264
+ },
265
+ track(object, name, options) {
226
266
  const armature = armatureOf(object);
227
- return armature?.wanted?.name ?? armature?.current?.name ?? null;
267
+ if (!armature) return none(object);
268
+ const inFile = armature.facts.animation?.tracks.some((track) => track.name === name) ?? false;
269
+ const set = armature.tracks.get(name);
270
+ if (options === null) {
271
+ if (set?.own) Object.assign(set.influence, ramp(set.influence.weight, 0, 0.2));
272
+ else armature.tracks.delete(name);
273
+ return ok(armature);
274
+ }
275
+ if (options.action) {
276
+ const refused = refusal(armature, options.action);
277
+ if (refused) return { ok: false, why: refused };
278
+ kept(options.action);
279
+ } else if (!inFile && !set) {
280
+ return { ok: false, why: `${armature.rig.armature} has no NLA track "${name}"; give it an \`action\` to make one` };
281
+ }
282
+ const track: Track = set ?? { action: null, previous: null, influence: ramp(1, 1, 0), mute: false, own: !inFile };
283
+ if (options.influence !== undefined)
284
+ Object.assign(track.influence, ramp(track.influence.weight, Math.max(0, Math.min(1, options.influence)), Math.max(0, options.fade ?? 0)));
285
+ if (options.mute !== undefined) track.mute = options.mute;
286
+ if (options.action && (track.action?.action !== options.action || options.restart)) {
287
+ const crossfade = Math.max(0, options.fade ?? 0.2);
288
+ track.previous = track.action && crossfade > 0 ? track.action : null;
289
+ track.action = start(options.action, options, ramp(track.previous ? 0 : 1, 1, crossfade));
290
+ }
291
+ armature.tracks.set(name, track);
292
+ return ok(armature);
293
+ },
294
+ constraint(object, bone, name, options) {
295
+ const armature = armatureOf(object);
296
+ if (!armature) return none(object);
297
+ const declared = armature.facts.bones.find((one) => one.name === bone);
298
+ if (!declared) return { ok: false, why: `${armature.rig.armature} has no bone "${bone}"` };
299
+ const constraint = declared.constraints?.find((one) => one.name === name);
300
+ if (!constraint) return { ok: false, why: `bone "${bone}" has no constraint "${name}"; it has ${(declared.constraints ?? []).map((one) => `"${one.name}"`).join(', ') || 'none'}` };
301
+ 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` };
302
+ const key = `${bone}\u0000${name}`;
303
+ armature.constraints.set(key, { ...armature.constraints.get(key), ...options });
304
+ return ok(armature);
305
+ },
306
+ update(dt) {
307
+ if (disposed) return;
308
+ time += dt;
309
+ for (const armature of armatures.values()) {
310
+ for (const played of armature.line) step(played.weight, dt);
311
+ // What the newest action covers whole is gone; so is what has faded to nothing.
312
+ let covering = -1;
313
+ armature.line.forEach((played, index) => { if (played.weight.weight >= 1) covering = index; });
314
+ if (covering > 0) armature.line = armature.line.slice(covering);
315
+ armature.line = armature.line.filter((played) => played.weight.weight > 0 || played.weight.target > 0);
316
+ for (const [name, track] of armature.tracks) {
317
+ step(track.influence, dt);
318
+ if (track.action) step(track.action.weight, dt);
319
+ if (track.previous && track.action && track.action.weight.weight >= 1) track.previous = null;
320
+ if (track.own && track.influence.weight === 0 && track.influence.target === 0) armature.tracks.delete(name);
321
+ }
322
+ const layers = layersOf(armature);
323
+ if (!layers) continue;
324
+ armature.pose.apply(layers, {
325
+ object: (name) => view.objectForBlenderName(name),
326
+ override: (bone, name) => armature.constraints.get(`${bone}\u0000${name}`),
327
+ });
328
+ }
228
329
  },
229
- update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
230
330
  dispose() {
231
331
  disposed = true;
232
- for (const armature of armatures.values()) armature.mixer.stopAllAction();
233
- for (const rig of rigs) rig.skeleton.dispose();
332
+ armatures.clear();
234
333
  },
235
334
  };
236
335
  }