@volter/editor-blender 0.1.135 → 0.1.136

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 ` +
@@ -0,0 +1,256 @@
1
+ /**
2
+ * A CHARACTER'S ANIMATION IN A GAME: layered, weighted actions on one armature's bones, and a
3
+ * look-at for targets only the game knows.
4
+ *
5
+ * LAYERS: the base layer plays on every bone; a named layer plays on the bones from one bone down
6
+ * (`from: 'spine_01'`: the upper body), and takes those bones from the base. So legs can run while
7
+ * the upper body aims. A layer holds any number of actions at weights (`blend`); setting one action
8
+ * is a blend of that one, crossfaded (`set`).
9
+ *
10
+ * LOOK-AT: turns one bone so its axis points at a point, after the actions, by a weight and within
11
+ * an angle. The axis is the bone's FORWARD unless given: the direction in the bone that pointed the
12
+ * way the character faces (Blender's convention, the armature's -Y) in the pose the file was in. A
13
+ * bone's own Y runs along it, out of the top of a head, so it is not what "look" means. It is the game's, not Blender's: Blender's own IK and constraints are what an action was
14
+ * authored with; this aims at what exists only while the game runs (an aim target, the player).
15
+ *
16
+ * Everything advances on the game's clock (`update(dt)`).
17
+ */
18
+ import * as THREE from 'three';
19
+
20
+ export interface LayerOptions {
21
+ /** The layer: `base` (default) or a name of the game's choosing. */
22
+ readonly layer?: string;
23
+ /** For a layer other than `base`: the bone it plays from, with every bone below it. */
24
+ readonly from?: string;
25
+ /** Seconds for weights to reach what was set (default 0.2 for `set`, 0 for `blend`). */
26
+ readonly fade?: number;
27
+ /** Repeat (default true); `false` plays once and holds the last frame. */
28
+ readonly loop?: boolean;
29
+ /** Playback rate, 1 is the action's own. */
30
+ readonly speed?: number;
31
+ /** Start again from the first frame when this action already plays on the layer. */
32
+ readonly restart?: boolean;
33
+ }
34
+
35
+ export interface LookAtOptions {
36
+ /** How far toward the target, 0 to 1 (default 1). */
37
+ readonly weight?: number;
38
+ /** The bone's local axis that should point at the target: `forward` (default, see the header)
39
+ * or one of the bone's own axes (`y` is a Blender bone's length). */
40
+ readonly axis?: 'forward' | 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
41
+ /** The most it turns away from the animated pose, in degrees (default 75). */
42
+ readonly limit?: number;
43
+ }
44
+
45
+ interface Entry {
46
+ readonly name: string;
47
+ action: THREE.AnimationAction | null;
48
+ weight: number;
49
+ target: number;
50
+ rate: number;
51
+ loop: boolean;
52
+ speed: number;
53
+ }
54
+
55
+ interface Layer {
56
+ readonly name: string;
57
+ from: string | null;
58
+ /** The bones it plays on, by uuid; null for the base (everything other layers do not hold). */
59
+ bones: Set<string> | null;
60
+ readonly entries: Map<string, Entry>;
61
+ }
62
+
63
+ const AXES: Record<Exclude<NonNullable<LookAtOptions['axis']>, 'forward'>, THREE.Vector3> = {
64
+ x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1),
65
+ '-x': new THREE.Vector3(-1, 0, 0), '-y': new THREE.Vector3(0, -1, 0), '-z': new THREE.Vector3(0, 0, -1),
66
+ };
67
+
68
+ export class LayeredMixer {
69
+ readonly #mixer: THREE.AnimationMixer;
70
+ readonly #layers = new Map<string, Layer>();
71
+ readonly #masked = new Map<string, THREE.AnimationClip>();
72
+ readonly #looks = new Map<string, { bone: THREE.Bone; target: THREE.Vector3 | THREE.Object3D; options: LookAtOptions }>();
73
+ /** Each bone's forward in its own space, from the pose the file was in (before any action). */
74
+ readonly #forward = new Map<string, THREE.Vector3>();
75
+
76
+ /** `clipFor` answers an action's full clip, or null while it is not available yet (a bake in
77
+ * flight); the mixer asks again on later updates. */
78
+ constructor(
79
+ readonly root: THREE.Object3D,
80
+ readonly bones: ReadonlyMap<string, THREE.Bone>,
81
+ private readonly clipFor: (action: string) => THREE.AnimationClip | null,
82
+ ) {
83
+ this.#mixer = new THREE.AnimationMixer(root);
84
+ // The character faces the armature's -Y, as in Blender; each bone's forward is that direction
85
+ // in the bone's frame at the pose the file was in.
86
+ root.updateWorldMatrix(true, true);
87
+ const rootWorld = root.getWorldQuaternion(new THREE.Quaternion());
88
+ const facing = new THREE.Vector3(0, -1, 0).applyQuaternion(rootWorld);
89
+ for (const [name, bone] of bones) {
90
+ const boneWorld = bone.getWorldQuaternion(new THREE.Quaternion());
91
+ this.#forward.set(name, facing.clone().applyQuaternion(boneWorld.invert()).normalize());
92
+ }
93
+ this.#layers.set('base', { name: 'base', from: null, bones: null, entries: new Map() });
94
+ }
95
+
96
+ /** Play one action on a layer, crossfading the layer's others out. */
97
+ set(action: string, options: LayerOptions = {}): string | null {
98
+ const layer = this.#layer(options);
99
+ if (typeof layer === 'string') return layer;
100
+ const held = layer.entries.get(action);
101
+ if (held && options.restart) held.action?.reset();
102
+ this.#weigh(layer, { [action]: 1 }, options.fade ?? 0.2, options);
103
+ return null;
104
+ }
105
+
106
+ /** Set a layer's actions at weights; an action left out fades to nothing. */
107
+ blend(weights: Readonly<Record<string, number>>, options: LayerOptions = {}): string | null {
108
+ const layer = this.#layer(options);
109
+ if (typeof layer === 'string') return layer;
110
+ this.#weigh(layer, weights, options.fade ?? 0, options);
111
+ return null;
112
+ }
113
+
114
+ /** Fade out what a layer plays (the base: everything the character plays). */
115
+ stop(options: LayerOptions = {}): void {
116
+ const layer = this.#layers.get(options.layer ?? 'base');
117
+ if (layer) this.#weigh(layer, {}, options.fade ?? 0.2, options);
118
+ }
119
+
120
+ /** The action that weighs most on a layer, as set (what a script asked for), or null. */
121
+ playing(layer = 'base'): string | null {
122
+ let best: Entry | null = null;
123
+ for (const entry of this.#layers.get(layer)?.entries.values() ?? []) if (entry.target > 0 && (!best || entry.target > best.target)) best = entry;
124
+ return best?.name ?? null;
125
+ }
126
+
127
+ /** Turn `bone` toward `target` (world point or object) every update until set to null. */
128
+ lookAt(bone: string, target: THREE.Vector3 | THREE.Object3D | null, options: LookAtOptions = {}): string | null {
129
+ if (target === null) { this.#looks.delete(bone); return null; }
130
+ const found = this.bones.get(bone);
131
+ if (!found) return `no bone "${bone}"`;
132
+ this.#looks.set(bone, { bone: found, target, options: { ...options, axis: options.axis ?? 'forward' } });
133
+ return null;
134
+ }
135
+
136
+ update(dt: number): void {
137
+ for (const layer of this.#layers.values()) {
138
+ for (const [name, entry] of [...layer.entries]) {
139
+ if (!entry.action) entry.action = this.#action(layer, name, entry);
140
+ entry.weight = entry.rate <= 0 ? entry.target
141
+ : entry.weight < entry.target ? Math.min(entry.target, entry.weight + dt * entry.rate)
142
+ : Math.max(entry.target, entry.weight - dt * entry.rate);
143
+ if (entry.action) entry.action.setEffectiveWeight(entry.weight);
144
+ if (entry.weight === 0 && entry.target === 0) {
145
+ entry.action?.stop();
146
+ layer.entries.delete(name);
147
+ }
148
+ }
149
+ }
150
+ this.#mixer.update(dt);
151
+ for (const look of this.#looks.values()) this.#turn(look.bone, look.target, look.options);
152
+ }
153
+
154
+ dispose(): void {
155
+ this.#mixer.stopAllAction();
156
+ this.#looks.clear();
157
+ }
158
+
159
+ // ---------------------------------------------------------------- internals
160
+
161
+ #layer(options: LayerOptions): Layer | string {
162
+ const name = options.layer ?? 'base';
163
+ if (name === 'base') return this.#layers.get('base')!;
164
+ let layer = this.#layers.get(name);
165
+ if (!layer || (options.from !== undefined && options.from !== layer.from)) {
166
+ if (!options.from) return `layer "${name}" needs the bone it plays from (\`from\`)`;
167
+ const root = this.bones.get(options.from);
168
+ if (!root) return `no bone "${options.from}" for layer "${name}"`;
169
+ const bones = new Set<string>();
170
+ root.traverse((child) => { if ((child as THREE.Bone).isBone) bones.add(child.uuid); });
171
+ if (layer) for (const entry of layer.entries.values()) entry.action?.stop();
172
+ layer = { name, from: options.from, bones, entries: layer?.entries ?? new Map() };
173
+ for (const entry of layer.entries.values()) entry.action = null;
174
+ this.#layers.set(name, layer);
175
+ // The base gives up these bones: its actions are rebuilt over what it still holds.
176
+ for (const entry of this.#layers.get('base')!.entries.values()) { entry.action?.stop(); entry.action = null; }
177
+ }
178
+ return layer;
179
+ }
180
+
181
+ #weigh(layer: Layer, weights: Readonly<Record<string, number>>, fade: number, options: LayerOptions): void {
182
+ const rate = fade > 0 ? 1 / fade : 0;
183
+ for (const entry of layer.entries.values()) { entry.target = 0; entry.rate = rate; }
184
+ for (const [name, raw] of Object.entries(weights)) {
185
+ const target = Math.max(0, Math.min(1, raw));
186
+ const entry = layer.entries.get(name) ?? { name, action: null, weight: 0, target: 0, rate, loop: true, speed: 1 };
187
+ entry.target = target;
188
+ entry.rate = rate;
189
+ entry.loop = options.loop ?? entry.loop;
190
+ entry.speed = options.speed ?? entry.speed;
191
+ if (entry.action) this.#configure(entry.action, entry);
192
+ layer.entries.set(name, entry);
193
+ }
194
+ }
195
+
196
+ /** The action of `name` on `layer`: its clip limited to the layer's bones. */
197
+ #action(layer: Layer, name: string, entry: Entry): THREE.AnimationAction | null {
198
+ const full = this.clipFor(name);
199
+ if (!full) return null;
200
+ const allowed = layer.bones ?? this.#baseBones();
201
+ const key = `${name}\u0000${layer.name}\u0000${allowed.size}`;
202
+ let clip = this.#masked.get(key);
203
+ if (!clip) {
204
+ const tracks = full.tracks.filter((track) => allowed.has(track.name.slice(0, track.name.indexOf('.'))));
205
+ clip = new THREE.AnimationClip(`${name}@${layer.name}`, full.duration, tracks);
206
+ this.#masked.set(key, clip);
207
+ }
208
+ const action = this.#mixer.clipAction(clip);
209
+ this.#configure(action, entry);
210
+ action.setEffectiveWeight(entry.weight);
211
+ action.play();
212
+ return action;
213
+ }
214
+
215
+ #configure(action: THREE.AnimationAction, entry: Entry): void {
216
+ action.setLoop(entry.loop ? THREE.LoopRepeat : THREE.LoopOnce, Number.POSITIVE_INFINITY);
217
+ action.clampWhenFinished = !entry.loop;
218
+ action.setEffectiveTimeScale(entry.speed);
219
+ }
220
+
221
+ /** Every bone no named layer holds. */
222
+ #baseBones(): Set<string> {
223
+ const taken = new Set<string>();
224
+ for (const layer of this.#layers.values()) for (const uuid of layer.bones ?? []) taken.add(uuid);
225
+ const bones = new Set<string>();
226
+ for (const bone of this.bones.values()) if (!taken.has(bone.uuid)) bones.add(bone.uuid);
227
+ return bones;
228
+ }
229
+
230
+ #turn(bone: THREE.Bone, target: THREE.Vector3 | THREE.Object3D, options: LookAtOptions): void {
231
+ const weight = Math.max(0, Math.min(1, options.weight ?? 1));
232
+ if (weight === 0) return;
233
+ bone.updateWorldMatrix(true, false);
234
+ // A script's Vector3 may come from its own copy of three, so it is recognised by its flag.
235
+ const at = (target as THREE.Vector3).isVector3 ? new THREE.Vector3().copy(target as THREE.Vector3) : (target as THREE.Object3D).getWorldPosition(new THREE.Vector3());
236
+ const position = new THREE.Vector3();
237
+ const world = new THREE.Quaternion();
238
+ bone.matrixWorld.decompose(position, world, new THREE.Vector3());
239
+ const desired = at.sub(position);
240
+ if (desired.lengthSq() < 1e-10) return;
241
+ desired.normalize();
242
+ const axis = options.axis ?? 'forward';
243
+ const local = axis === 'forward' ? this.#forward.get(bone.name) ?? AXES.y : AXES[axis];
244
+ const current = local.clone().applyQuaternion(world).normalize();
245
+ const turn = new THREE.Quaternion().setFromUnitVectors(current, desired);
246
+ const limit = THREE.MathUtils.degToRad(options.limit ?? 75);
247
+ const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
248
+ const amount = angle > limit ? (limit / angle) * weight : weight;
249
+ const applied = new THREE.Quaternion().slerp(turn, amount);
250
+ const next = applied.multiply(world);
251
+ const parent = new THREE.Quaternion();
252
+ bone.parent?.getWorldQuaternion(parent);
253
+ bone.quaternion.copy(parent.invert().multiply(next));
254
+ bone.updateMatrixWorld(true);
255
+ }
256
+ }