@volter/editor-blender 0.1.136 → 0.1.138

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.
@@ -191,6 +191,26 @@ const MAPPING = {
191
191
  // gizmos carry all three (`gizmo.*`).
192
192
  'viewport.selection': rgb('space_view3d.select'),
193
193
  'viewport.active': rgb('space_view3d.active'),
194
+ // The box-select rectangle is not themed in Blender at all: `wm_gesture_draw_rect`
195
+ // (`source/blender/windowmanager/intern/wm_gesture.cc`) states its colours as literals, so
196
+ // each is held with that citation. Its dash (8 framebuffer px at `udash_factor` 0.5) is the
197
+ // style's `stage.marqueeDash`.
198
+ 'viewport.marqueeLine': held(
199
+ '#666666',
200
+ "`wm_gesture_draw_rect`'s dashed outline, `immUniform4f(\"color\", 0.4f, 0.4f, 0.4f, 1.0f)` " +
201
+ '— the first half of each dash period. A literal in `wm_gesture.cc`, not a theme member.',
202
+ ),
203
+ 'viewport.marqueeGap': held(
204
+ '#ffffff',
205
+ 'the same outline\'s `immUniform4f("color2", 1.0f, 1.0f, 1.0f, 1.0f)` — the second half of ' +
206
+ 'each period (`colors_len` 2, so the gap is drawn rather than discarded).',
207
+ ),
208
+ 'viewport.marqueeFill': held(
209
+ 'rgba(255,255,255,0.05)',
210
+ "`wm_gesture_draw_rect`'s fill, `immUniformColor4f(1.0f, 1.0f, 1.0f, 0.05f)` under " +
211
+ '`GPU_BLEND_ALPHA`. Not the accent: `.tui.wcol_regular.inner_sel` is opaque, and as the ' +
212
+ 'fill it drew the band as a solid blue square.',
213
+ ),
194
214
 
195
215
  // The transform gizmo draws the theme's axis colours as they are
196
216
  // (`transform_gizmo_3d.cc`, `gizmo_get_axis_color`), and so does the navigation gizmo, mixed
@@ -1,61 +1,106 @@
1
1
  /**
2
- * ANIMATION IN A GAME: actions played on the skeletons of the game's copy of the model.
2
+ * ANIMATION IN A GAME: Blender's own animation stack, evaluated on the game's copy of the model.
3
3
  *
4
4
  * The copy binds its own skins (`BlenderRuntimeView.detach` keeps each skinned mesh's weights and
5
- * the frame's armatures, and its view's `skeletons` builds and binds them exactly as the editing
6
- * view does), so nothing here binds or reads a rig. What a game plays is Blender's: each armature
7
- * starts on the action the file assigns it (`animation_data.action`), and a script sets another by
8
- * name (`play.setAction`), as Blender's own assignment does. Any action in the file may be set; one
9
- * that animates none of an armature's bones refuses, with the reason, once.
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:
10
9
  *
11
- * A CLIP IS BAKED ONCE PER ACTION, through the clip door, the first time it is wanted. The copy's
12
- * frame is the frame it was detached from, so its revisions hold for the run. Until a bake lands
13
- * the armature keeps what it showed before (at first, the pose Blender exported).
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.
14
19
  *
15
- * LAYERS, BLENDS AND LOOK-AT: each armature has a layered mixer (`blender-play-mixer.ts`), so a
16
- * game plays actions on parts of a body, at weights, and turns a bone toward what only it knows.
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
- * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by each update's `dt`
25
+ * THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
19
26
  * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
20
27
  * animation exactly as they do for the script.
21
28
  */
22
29
  import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
30
+ import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
23
31
  import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
24
32
  import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
25
- import * as THREE from 'three';
26
- import { clipTracks } from './blender-runtime-skin';
27
- import { LayeredMixer, type LayerOptions, type LookAtOptions } from './blender-play-mixer';
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';
28
37
 
29
- export type PlayAnimateOptions = LayerOptions;
38
+ export interface PlayActionOptions {
39
+ /** Seconds to crossfade from what played before (default 0.2). */
40
+ readonly fade?: number;
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. */
44
+ readonly speed?: number;
45
+ /** Start again from the first frame when this action is already playing. */
46
+ readonly restart?: boolean;
47
+ }
48
+
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
+ }
30
62
 
31
63
  type Answer = { ok: true; armature: string } | { ok: false; why: string };
32
64
 
33
- /** The game's door to its characters' actions (`ModelPlayContext.setAction`, `blend`, `lookAt`). */
65
+ /** The game's door to its characters' animation (`ModelPlayContext.setAction`, `setTrack`, `setConstraint`). */
34
66
  export interface PlayAnimation {
35
- /** The actions in the file, which an object's armature may be set to play. */
67
+ /** The actions in the file, which an object's armature may play. */
36
68
  clips(object: THREE.Object3D): readonly string[];
37
- /** Play one action on a layer of an object's armature; the reason when it cannot. */
38
- play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): Answer;
39
- /** Set a layer's actions at weights. */
40
- blend(object: THREE.Object3D, weights: Readonly<Record<string, number>>, options?: PlayAnimateOptions): Answer;
41
- /** Fade out what a layer plays. */
42
- stop(object: THREE.Object3D, fade?: number, layer?: string): void;
43
- /** The action that weighs most on a layer, as last set, or null. */
44
- playing(object: THREE.Object3D, layer?: string): string | null;
45
- /** Turn one of an object's bones toward a point (or object) every update, until `null`. */
46
- lookAt(object: THREE.Object3D, bone: string, target: THREE.Vector3 | THREE.Object3D | null, options?: LookAtOptions): Answer;
69
+ play(object: THREE.Object3D, action: string, options?: PlayActionOptions): Answer;
70
+ stop(object: THREE.Object3D, fade?: number): void;
71
+ /** The active action, as last set. */
72
+ playing(object: THREE.Object3D): string | null;
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;
47
76
  update(dt: number): void;
48
77
  readonly warnings: readonly string[];
49
78
  dispose(): void;
50
79
  }
51
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
+
52
93
  interface Armature {
53
94
  readonly rig: ArmatureRig;
54
- readonly mixer: LayeredMixer;
55
- readonly clips: Map<string, THREE.AnimationClip>;
56
- readonly baking: Set<string>;
57
- /** Actions with nothing this armature can play: asked once, never re-baked every update. */
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>;
58
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>;
59
104
  }
60
105
 
61
106
  /** The copy's animation. `bake` reads one action of one armature through the clip door. */
@@ -63,49 +108,97 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
63
108
  const warnings: string[] = [];
64
109
  const facts = view.animationFacts();
65
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);
66
114
  const armatures = new Map<string, Armature>();
115
+ let time = 0;
67
116
  let disposed = false;
68
117
 
69
- const refuse = (armature: Armature, clip: string, why: string): void => {
70
- armature.failed.set(clip, why);
71
- warnings.push(`${armature.rig.armature} / ${clip}: ${why}`);
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}`);
72
122
  };
73
-
74
- /** The clip the mixer asks for: baked, or baked now (once) and given on a later update. */
75
- const clipFor = (armature: Armature, clip: string): THREE.AnimationClip | null => {
76
- const held = armature.clips.get(clip);
77
- if (held || disposed || armature.failed.has(clip) || armature.baking.has(clip)) return held ?? null;
78
- armature.baking.add(clip);
79
- void bake(armature.rig.armature, clip).then((baked) => {
80
- armature.baking.delete(clip);
81
- const tracks = baked && baked.clipStart !== undefined && baked.clipEnd !== undefined
82
- ? clipTracks(baked, armature.rig.bones, warnings) : [];
83
- if (!baked || !tracks.length) {
84
- refuse(armature, clip, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
85
- return;
86
- }
87
- const duration = baked.duration ?? (baked.clipEnd! - baked.clipStart!) / baked.fps;
88
- armature.clips.set(clip, new THREE.AnimationClip(clip, duration, tracks));
89
- }, (error: unknown) => {
90
- armature.baking.delete(clip);
91
- refuse(armature, clip, `it could not be read: ${error instanceof Error ? error.message : String(error)}`);
92
- });
93
- return null;
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;
94
133
  };
95
134
 
96
135
  for (const rig of view.skeletons.rigs()) {
97
- const armature: Armature = {
98
- rig, clips: new Map(), baking: new Set(), failed: new Map(),
99
- mixer: new LayeredMixer(rig.object, rig.bones, (clip) => clipFor(armature, clip)),
100
- };
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) }];
101
144
  armatures.set(rig.armature, armature);
102
145
  }
103
146
 
104
- // EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
105
- for (const armature of armatures.values()) {
106
- const assigned = facts.armatures[armature.rig.armature]?.action;
107
- if (assigned) armature.mixer.set(assigned, { fade: 0 });
108
- }
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
+ };
109
202
 
110
203
  /** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
111
204
  * armature among its ancestors and descendants (a character's root holds its armature). */
@@ -123,46 +216,120 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
123
216
  object.traverse((child) => { if (!found) found = own(child); });
124
217
  return found;
125
218
  };
126
-
127
- /** The armature, after checking every action named is one it can play. */
128
- const resolve = (object: THREE.Object3D, clips: readonly string[]): Armature | { why: string } => {
129
- const armature = armatureOf(object);
130
- if (!armature) return { why: `${object.name || 'this object'} has no armature` };
131
- for (const clip of clips) {
132
- if (!(clip in facts.actions)) return { why: `the file has no action "${clip}"` };
133
- const failed = armature.failed.get(clip);
134
- if (failed) return { why: failed };
135
- }
136
- return armature;
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');
137
233
  };
138
- const answer = (armature: Armature, refused: string | null): Answer =>
139
- refused ? { ok: false, why: refused } : { ok: true, armature: armature.rig.armature };
234
+ const none = (object: THREE.Object3D): Answer => ({ ok: false, why: `${object.name || 'this object'} has no armature` });
140
235
 
141
236
  return {
142
237
  warnings,
143
238
  clips: (object) => (armatureOf(object) ? actions : []),
144
- play(object, clip, options = {}) {
145
- const armature = resolve(object, [clip]);
146
- return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.set(clip, options));
239
+ play(object, action, options = {}) {
240
+ const armature = armatureOf(object);
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);
249
+ }
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);
147
254
  },
148
- blend(object, weights, options = {}) {
149
- const armature = resolve(object, Object.keys(weights));
150
- return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.blend(weights, options));
255
+ stop(object, fade = 0.2) {
256
+ const armature = armatureOf(object);
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));
151
260
  },
152
- stop(object, fade = 0.2, layer) {
153
- armatureOf(object)?.mixer.stop({ fade, ...(layer ? { layer } : {}) });
261
+ playing: (object) => {
262
+ const current = armatureOf(object)?.line.at(-1);
263
+ return current && current.weight.target > 0 ? current.action : null;
154
264
  },
155
- // What was last set, so a script setting the same action every update while it bakes asks once.
156
- playing: (object, layer) => armatureOf(object)?.mixer.playing(layer) ?? null,
157
- lookAt(object, bone, target, options = {}) {
265
+ track(object, name, options) {
158
266
  const armature = armatureOf(object);
159
- if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature` };
160
- return answer(armature, armature.mixer.lookAt(bone, target, options));
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
+ }
161
329
  },
162
- update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
163
330
  dispose() {
164
331
  disposed = true;
165
- for (const armature of armatures.values()) armature.mixer.dispose();
332
+ armatures.clear();
166
333
  },
167
334
  };
168
335
  }