@volter/editor-blender 0.1.149 → 0.1.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +3 -7
- package/contributions/blender-node-editor.document.tsx +28 -180
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +100 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +15 -90
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +15 -147
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +217 -339
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +142 -332
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +43 -23
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +63 -0
- package/contributions/blender.status.tsx +9 -145
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +12 -32
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -30
- package/host/blender-runtime-host.ts +541 -372
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +49 -93
- package/package.json +1209 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -43
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/model-play-extension.ts +173 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +64 -38
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCENE'S MOVIE, on three.js's own `AnimationMixer`: what Blender's playback shows (every animated object and
|
|
3
|
+
* camera, and the camera cuts at the Timeline's markers), played on a view of the model at a scene frame: the
|
|
4
|
+
* Timeline's (`blender-runtime-skin.ts`), and the glTF export's clips (`blender-export-gltf.ts`).
|
|
5
|
+
*
|
|
6
|
+
* - BLENDER SAMPLES, THREE.JS PLAYS. Blender evaluates every animated object's transform at every
|
|
7
|
+
* scene frame (`session.py`'s `_sample_object`: its channels through `FCurve.evaluate`, with its
|
|
8
|
+
* delta transform, placed in its parent's space), and the movie is one `THREE.AnimationClip` of
|
|
9
|
+
* position, quaternion, scale (and visibility, where it is keyed) tracks on the copy's objects,
|
|
10
|
+
* played on the scene's one mixer beside the armatures (`blender-scene-mixer.ts`): `place` sets
|
|
11
|
+
* the clips' time, the scene evaluates, `settle` places a sequence and turns the aimed objects.
|
|
12
|
+
* - A CAMERA'S PROJECTION is sampled the same way (lens, sensor, shift, clip range) and fitted to
|
|
13
|
+
* the preview's own frame when it is looked through.
|
|
14
|
+
* - THE CAMERA AT A FRAME is the camera of the last marker at or before it that is bound to one
|
|
15
|
+
* (and not disabled in renders), else the earliest such marker's, else the scene's camera.
|
|
16
|
+
* - Track To and Damped Track on an object (a camera aimed at a subject) turn it after the mixer,
|
|
17
|
+
* toward the target as it stands this frame.
|
|
18
|
+
*/
|
|
19
|
+
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
20
|
+
import * as THREE from 'three';
|
|
21
|
+
import type { BlenderClipColumn, BlenderMovieObject, BlenderSceneMovie } from '@volter/blender-engine/browser/rna';
|
|
22
|
+
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
23
|
+
|
|
24
|
+
/** One Timeline marker, by frame, with the camera Blender cuts to there. */
|
|
25
|
+
interface MovieMarker {
|
|
26
|
+
readonly frame: number;
|
|
27
|
+
readonly camera: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The scene's movie on a presented view, for the Timeline and the glTF export. */
|
|
31
|
+
export interface PlayMovie {
|
|
32
|
+
/** Every object it moves, by Blender name. */
|
|
33
|
+
readonly objects: readonly string[];
|
|
34
|
+
/** Set the frame on the mixer (nothing moves until it evaluates); null holds the movie off it, so
|
|
35
|
+
* an evaluation for others leaves its objects where they stand (its materials still take the
|
|
36
|
+
* `presented` frame: the presenter does not follow a material's animation). `presented` is the
|
|
37
|
+
* frame the drawn geometry was evaluated at (Blender's own): a shape key
|
|
38
|
+
* plays relative to the value it already has there. */
|
|
39
|
+
place(frame: number | null, presented: number): void;
|
|
40
|
+
/** After the mixer: the moved objects' matrices composed, and the aimed objects turned. */
|
|
41
|
+
settle(): void;
|
|
42
|
+
/** Its actions off the mixer. */
|
|
43
|
+
dispose(): void;
|
|
44
|
+
/** Every clip it plays (objects and cameras, materials, shape keys), as three.js tracks on the
|
|
45
|
+
* presented objects: what an export writes (`blender-export-gltf`). */
|
|
46
|
+
clips(): readonly THREE.AnimationClip[];
|
|
47
|
+
/** The camera Blender's playback looks through at `frame`; null when the scene has none. */
|
|
48
|
+
cameraAt(frame: number): string | null;
|
|
49
|
+
/** A camera as it stands at the last posed frame, in the shape of the frame's camera data (its
|
|
50
|
+
* world matrix in Blender's space, by rows, and its sampled projection): what the editor's
|
|
51
|
+
* camera view draws through while the Timeline plays. Null when the movie has no entry for that camera. */
|
|
52
|
+
cameraData(name: string): ({ name: string; type: string; sensor_fit: string; matrix: [number, number, number, number][] } & CameraProps) | null;
|
|
53
|
+
/** Animation behavior omitted by the three.js preview, each condition named once. */
|
|
54
|
+
readonly warnings: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CAMERA_PROPS = ['lens', 'sensor_width', 'sensor_height', 'ortho_scale', 'shift_x', 'shift_y', 'clip_start', 'clip_end'] as const;
|
|
58
|
+
type CameraProps = Record<(typeof CAMERA_PROPS)[number], number>;
|
|
59
|
+
|
|
60
|
+
function float32Of(column: BlenderClipColumn): Float32Array {
|
|
61
|
+
const bytes = bytesFromBase64(column.base64);
|
|
62
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const AXES: Record<string, readonly [number, number]> = {
|
|
66
|
+
TRACK_X: [0, 1], TRACK_Y: [1, 1], TRACK_Z: [2, 1], TRACK_NEGATIVE_X: [0, -1], TRACK_NEGATIVE_Y: [1, -1], TRACK_NEGATIVE_Z: [2, -1],
|
|
67
|
+
UP_X: [0, 1], UP_Y: [1, 1], UP_Z: [2, 1],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The rotation that points `trackAxis` (signed) along `forward` and `upAxis` as near `up` as it can. */
|
|
71
|
+
function aimed(forward: THREE.Vector3, up: THREE.Vector3, trackAxis: string, upAxis: string): THREE.Quaternion | null {
|
|
72
|
+
const [track, sign] = AXES[trackAxis] ?? AXES['TRACK_NEGATIVE_Z']!;
|
|
73
|
+
const [upIndex] = AXES[upAxis] ?? AXES['UP_Y']!;
|
|
74
|
+
if (upIndex === track) return null;
|
|
75
|
+
const f = forward.clone().normalize();
|
|
76
|
+
const u = up.clone().sub(f.clone().multiplyScalar(up.dot(f)));
|
|
77
|
+
if (u.lengthSq() < 1e-10) return null;
|
|
78
|
+
u.normalize();
|
|
79
|
+
const columns: THREE.Vector3[] = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
|
|
80
|
+
columns[track] = f.multiplyScalar(sign);
|
|
81
|
+
columns[upIndex] = u;
|
|
82
|
+
const third = 3 - track - upIndex;
|
|
83
|
+
columns[third] = new THREE.Vector3().crossVectors(columns[(third + 1) % 3]!, columns[(third + 2) % 3]!);
|
|
84
|
+
return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(columns[0]!, columns[1]!, columns[2]!));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface MovieObject {
|
|
88
|
+
readonly data: BlenderMovieObject;
|
|
89
|
+
readonly object: THREE.Object3D;
|
|
90
|
+
/** Its sampled transform, played on the mixer; null when it does not move. */
|
|
91
|
+
readonly action: THREE.AnimationAction | null;
|
|
92
|
+
/** The camera's sampled projection, by property. */
|
|
93
|
+
readonly projection: Partial<Record<(typeof CAMERA_PROPS)[number], Float32Array>>;
|
|
94
|
+
readonly projectionStart: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Bind the scene’s movie to its caller’s mixer. */
|
|
98
|
+
export function playMovie(view: BlenderRuntimeView, data: BlenderSceneMovie, mixer: THREE.AnimationMixer): PlayMovie {
|
|
99
|
+
const warnings: string[] = [];
|
|
100
|
+
const objects: MovieObject[] = [];
|
|
101
|
+
const { start, end, fps } = data.scene;
|
|
102
|
+
const frames = Math.max(1, end - start + 1);
|
|
103
|
+
const times = Float32Array.from({ length: frames }, (_, i) => i / fps);
|
|
104
|
+
const unbound: string[] = [];
|
|
105
|
+
for (const entry of data.objects) {
|
|
106
|
+
const object = view.objectForBlenderName(entry.name);
|
|
107
|
+
// Report animated objects whose named presentation object is absent.
|
|
108
|
+
if (!object) { if (entry.sampled || entry.camera?.sampled) unbound.push(entry.name); continue; }
|
|
109
|
+
for (const thing of entry.unsupported ?? []) warnings.push(`${entry.name}: ${thing} plays only in Blender, not in this animation preview.`);
|
|
110
|
+
const sampled = entry.sampled;
|
|
111
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
112
|
+
if (sampled) {
|
|
113
|
+
const column = (c: BlenderClipColumn, stride: number): [Float32Array, Float32Array] => {
|
|
114
|
+
const values = float32Of(c);
|
|
115
|
+
return [values.length === stride ? new Float32Array([0]) : times.slice(0, values.length / stride), values];
|
|
116
|
+
};
|
|
117
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.position`, ...column(sampled.position, 3)));
|
|
118
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(`${object.uuid}.quaternion`, ...column(sampled.quaternion, 4)));
|
|
119
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, ...column(sampled.scale, 3)));
|
|
120
|
+
if (sampled.hidden) {
|
|
121
|
+
const [at, hidden] = column(sampled.hidden, 1);
|
|
122
|
+
tracks.push(new THREE.BooleanKeyframeTrack(`${object.uuid}.visible`, at, Array.from(hidden, (one) => one < 0.5)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const projection: MovieObject['projection'] = {};
|
|
126
|
+
const lens = entry.camera?.sampled;
|
|
127
|
+
if (lens) for (const prop of CAMERA_PROPS) projection[prop] = float32Of(lens[prop]);
|
|
128
|
+
let action: THREE.AnimationAction | null = null;
|
|
129
|
+
if (tracks.length) {
|
|
130
|
+
action = mixer.clipAction(new THREE.AnimationClip(`movie:${entry.name}`, (frames - 1) / fps, tracks));
|
|
131
|
+
action.setLoop(THREE.LoopOnce, 1);
|
|
132
|
+
action.clampWhenFinished = true;
|
|
133
|
+
action.timeScale = 0;
|
|
134
|
+
}
|
|
135
|
+
objects.push({ data: entry, object, action, projection, projectionStart: lens?.start ?? start });
|
|
136
|
+
}
|
|
137
|
+
if (unbound.length) {
|
|
138
|
+
warnings.push(`The movie animates ${unbound.length} object(s) this copy does not draw by that name, so they hold still: ${unbound.slice(0, 12).join(', ')}${unbound.length > 12 ? ', …' : ''}.`);
|
|
139
|
+
}
|
|
140
|
+
// MATERIALS AND SHAPE KEYS (`docs/SCENE-ANIMATION.md` step 5): more clips on the same mixer, placed
|
|
141
|
+
// with the objects'. A material's keyed inputs drive the presented material of its name (through one
|
|
142
|
+
// mesh that wears it: the material is one object, shared); a mesh's keyed shape keys become morph
|
|
143
|
+
// targets on its drawn geometry (each drawn vertex takes its Blender vertex's offset, `blenderVertex`,
|
|
144
|
+
// and the normals the offset gives it), driven by the value Blender gives each key there minus the
|
|
145
|
+
// value the drawn geometry already holds (its value at the presented frame, subtracted in `settle`).
|
|
146
|
+
const materialActions: THREE.AnimationAction[] = [];
|
|
147
|
+
const shapeActions: THREE.AnimationAction[] = [];
|
|
148
|
+
const shapeMeshes: { mesh: THREE.Mesh; keys: Float32Array[] }[] = [];
|
|
149
|
+
const extraClip = (name: string, tracks: THREE.KeyframeTrack[], into: THREE.AnimationAction[]): void => {
|
|
150
|
+
if (!tracks.length) return;
|
|
151
|
+
const action = mixer.clipAction(new THREE.AnimationClip(name, (frames - 1) / fps, tracks));
|
|
152
|
+
action.setLoop(THREE.LoopOnce, 1);
|
|
153
|
+
action.clampWhenFinished = true;
|
|
154
|
+
action.timeScale = 0;
|
|
155
|
+
into.push(action);
|
|
156
|
+
};
|
|
157
|
+
const extras = (): THREE.AnimationAction[] => [...materialActions, ...shapeActions];
|
|
158
|
+
/** A key's value at a scene frame, from its sampled column. */
|
|
159
|
+
const valueAt = (values: Float32Array, frame: number): number =>
|
|
160
|
+
values.length <= 1 ? values[0] ?? 0 : values[Math.max(0, Math.min(values.length - 1, Math.round(frame - start)))]!;
|
|
161
|
+
const timesOf = (values: Float32Array, stride: number): Float32Array =>
|
|
162
|
+
values.length === stride ? new Float32Array([0]) : times.slice(0, values.length / stride);
|
|
163
|
+
for (const entry of data.materials) {
|
|
164
|
+
let path: string | null = null;
|
|
165
|
+
let material: THREE.Material | null = null;
|
|
166
|
+
view.root.traverse((object) => {
|
|
167
|
+
const mesh = object as THREE.Mesh;
|
|
168
|
+
if (path || !mesh.isMesh) return;
|
|
169
|
+
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
170
|
+
const index = list.findIndex((one) => one?.name === entry.name);
|
|
171
|
+
if (index < 0) return;
|
|
172
|
+
path = Array.isArray(mesh.material) ? `${mesh.uuid}.material[${index}]` : `${mesh.uuid}.material`;
|
|
173
|
+
material = list[index]!;
|
|
174
|
+
});
|
|
175
|
+
const worn = material as THREE.Material | null;
|
|
176
|
+
if (!path || !worn) { warnings.push(`The keyed material "${entry.name}" is not drawn here, so its animation does not play.`); continue; }
|
|
177
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
178
|
+
if (entry.color) { const values = float32Of(entry.color); tracks.push(new THREE.ColorKeyframeTrack(`${path}.color`, timesOf(values, 3), values)); }
|
|
179
|
+
if (entry.emissive) {
|
|
180
|
+
const values = float32Of(entry.emissive);
|
|
181
|
+
tracks.push(new THREE.ColorKeyframeTrack(`${path}.emissive`, timesOf(values, 3), values));
|
|
182
|
+
if ('emissiveIntensity' in worn) (worn as THREE.MeshStandardMaterial).emissiveIntensity = 1;
|
|
183
|
+
}
|
|
184
|
+
if (entry.opacity) {
|
|
185
|
+
const values = float32Of(entry.opacity);
|
|
186
|
+
tracks.push(new THREE.NumberKeyframeTrack(`${path}.opacity`, timesOf(values, 1), values));
|
|
187
|
+
worn.transparent = true;
|
|
188
|
+
worn.needsUpdate = true;
|
|
189
|
+
}
|
|
190
|
+
extraClip(`movie-material:${entry.name}`, tracks, materialActions);
|
|
191
|
+
}
|
|
192
|
+
for (const entry of data.shapes) {
|
|
193
|
+
const owner = view.objectForBlenderName(entry.object);
|
|
194
|
+
if (!owner) continue;
|
|
195
|
+
if (!entry.mapped) { warnings.push(`${entry.object}: its shape keys play only in Blender, not in this animation preview: its modifiers change its vertices.`); continue; }
|
|
196
|
+
const meshes: THREE.Mesh[] = [];
|
|
197
|
+
owner.traverse((object) => { const mesh = object as THREE.Mesh; if (mesh.isMesh && mesh.geometry.getAttribute('blenderVertex')) meshes.push(mesh); });
|
|
198
|
+
for (const mesh of meshes) {
|
|
199
|
+
const geometry = mesh.geometry;
|
|
200
|
+
const source = geometry.getAttribute('blenderVertex') as THREE.BufferAttribute;
|
|
201
|
+
const drawn = geometry.getAttribute('position').count;
|
|
202
|
+
const morphs: THREE.BufferAttribute[] = [];
|
|
203
|
+
const normals: THREE.BufferAttribute[] = [];
|
|
204
|
+
const dictionary: Record<string, number> = {};
|
|
205
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
206
|
+
const keys: Float32Array[] = [];
|
|
207
|
+
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
|
|
208
|
+
const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined;
|
|
209
|
+
let fits = true;
|
|
210
|
+
entry.keys.forEach((key, k) => {
|
|
211
|
+
const delta = float32Of(key.delta);
|
|
212
|
+
const offsets = new Float32Array(drawn * 3);
|
|
213
|
+
for (let j = 0; j < drawn; j++) {
|
|
214
|
+
const from = source.getX(j);
|
|
215
|
+
if (from >= entry.vertices) { fits = false; continue; }
|
|
216
|
+
offsets[j * 3] = delta[from * 3]!;
|
|
217
|
+
offsets[j * 3 + 1] = delta[from * 3 + 1]!;
|
|
218
|
+
offsets[j * 3 + 2] = delta[from * 3 + 2]!;
|
|
219
|
+
}
|
|
220
|
+
const morph = new THREE.BufferAttribute(offsets, 3);
|
|
221
|
+
morph.name = key.name;
|
|
222
|
+
morphs.push(morph);
|
|
223
|
+
// THE NORMALS THE OFFSET GIVES: the shape's own, less the drawn ones (smooth over the drawn faces)
|
|
224
|
+
if (normal) {
|
|
225
|
+
const shaped = new THREE.BufferGeometry();
|
|
226
|
+
shaped.setAttribute('position', new THREE.BufferAttribute(Float32Array.from(position.array as Float32Array, (v, i) => v + offsets[i]!), 3));
|
|
227
|
+
if (geometry.index) shaped.setIndex(geometry.index);
|
|
228
|
+
shaped.computeVertexNormals();
|
|
229
|
+
const turned = shaped.getAttribute('normal') as THREE.BufferAttribute;
|
|
230
|
+
normals.push(new THREE.BufferAttribute(Float32Array.from(turned.array as Float32Array, (v, i) => v - (normal.array as Float32Array)[i]!), 3));
|
|
231
|
+
shaped.dispose();
|
|
232
|
+
}
|
|
233
|
+
dictionary[key.name] = k;
|
|
234
|
+
const values = float32Of(key.values);
|
|
235
|
+
keys.push(values);
|
|
236
|
+
// (by the key's name, through the mesh's dictionary: an exporter writes the target by it)
|
|
237
|
+
tracks.push(new THREE.NumberKeyframeTrack(`${mesh.uuid}.morphTargetInfluences[${key.name}]`, timesOf(values, 1), values));
|
|
238
|
+
});
|
|
239
|
+
if (!fits) { warnings.push(`${entry.object}: its drawn vertices do not match its mesh, so its shape keys play only in Blender.`); continue; }
|
|
240
|
+
geometry.morphAttributes['position'] = morphs;
|
|
241
|
+
if (normal && normals.length === morphs.length) geometry.morphAttributes['normal'] = normals;
|
|
242
|
+
geometry.morphTargetsRelative = true;
|
|
243
|
+
mesh.updateMorphTargets();
|
|
244
|
+
mesh.morphTargetDictionary = dictionary;
|
|
245
|
+
for (const one of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) one.needsUpdate = true;
|
|
246
|
+
shapeMeshes.push({ mesh, keys });
|
|
247
|
+
extraClip(`movie-shapes:${entry.object}:${mesh.uuid}`, tracks, shapeActions);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// PARENTS BEFORE CHILDREN, so an object aimed by a constraint is turned from its parent's pose.
|
|
251
|
+
const depth = (object: THREE.Object3D): number => { let d = 0; for (let at = object.parent; at; at = at.parent) d += 1; return d; };
|
|
252
|
+
const aiming = objects
|
|
253
|
+
.filter((one) => one.data.constraints.some((c) => c.enabled && c.influence > 0 && c.target && (c.type === 'TRACK_TO' || c.type === 'DAMPED_TRACK')))
|
|
254
|
+
.sort((a, b) => depth(a.object) - depth(b.object));
|
|
255
|
+
const byName = new Map(objects.map((one) => [one.data.name, one]));
|
|
256
|
+
const markers = data.markers.map((marker) => ({ frame: marker.frame, camera: marker.camera }));
|
|
257
|
+
const cutMarkers = markers.filter((marker) => marker.camera !== null && !(byName.get(marker.camera)?.data.camera?.renderHidden ?? false));
|
|
258
|
+
|
|
259
|
+
let frameNow = data.scene.current;
|
|
260
|
+
/** Whether the last `place` put the movie on the mixer (false: held off it), for `settle`. */
|
|
261
|
+
let placed = false;
|
|
262
|
+
/** The frame the drawn geometry was evaluated at: Blender's own (the director says), else the read's. */
|
|
263
|
+
let presentedFrame = data.scene.current;
|
|
264
|
+
/** Track To and Damped Track, in Blender's space (the copy's root holds the turn to Y up). */
|
|
265
|
+
const constrain = (): void => {
|
|
266
|
+
if (aiming.length === 0) return;
|
|
267
|
+
view.root.updateMatrixWorld(true);
|
|
268
|
+
const inverseRoot = view.root.matrixWorld.clone().invert();
|
|
269
|
+
const [position, rotation, size] = [new THREE.Vector3(), new THREE.Quaternion(), new THREE.Vector3()];
|
|
270
|
+
for (const entry of aiming) {
|
|
271
|
+
for (const constraint of entry.data.constraints) {
|
|
272
|
+
if (!constraint.enabled || constraint.influence <= 0 || !constraint.target) continue;
|
|
273
|
+
const target = view.objectForBlenderName(constraint.target);
|
|
274
|
+
if (!target) continue;
|
|
275
|
+
target.updateWorldMatrix(true, false);
|
|
276
|
+
entry.object.updateWorldMatrix(true, false);
|
|
277
|
+
const own = inverseRoot.clone().multiply(entry.object.matrixWorld);
|
|
278
|
+
const aim = inverseRoot.clone().multiply(target.matrixWorld);
|
|
279
|
+
own.decompose(position, rotation, size);
|
|
280
|
+
const forward = new THREE.Vector3().setFromMatrixPosition(aim).sub(position);
|
|
281
|
+
if (forward.lengthSq() === 0) continue;
|
|
282
|
+
let turned: THREE.Quaternion | null;
|
|
283
|
+
if (constraint.type === 'TRACK_TO') {
|
|
284
|
+
const up = constraint.useTargetZ ? new THREE.Vector3(0, 0, 1).transformDirection(aim) : new THREE.Vector3(0, 0, 1);
|
|
285
|
+
turned = aimed(forward, up, constraint.trackAxis!, constraint.upAxis!);
|
|
286
|
+
} else {
|
|
287
|
+
const [index, sign] = AXES[constraint.trackAxis!]!;
|
|
288
|
+
const axis = new THREE.Vector3().setComponent(index, sign).applyQuaternion(rotation);
|
|
289
|
+
turned = new THREE.Quaternion().setFromUnitVectors(axis, forward.normalize()).multiply(rotation);
|
|
290
|
+
}
|
|
291
|
+
if (!turned) continue;
|
|
292
|
+
const world = new THREE.Matrix4().compose(position, rotation.clone().slerp(turned, Math.min(1, constraint.influence)), size);
|
|
293
|
+
const parentWorld = entry.object.parent ? inverseRoot.clone().multiply(entry.object.parent.matrixWorld) : new THREE.Matrix4();
|
|
294
|
+
parentWorld.invert().multiply(world).decompose(entry.object.position, entry.object.quaternion, entry.object.scale);
|
|
295
|
+
entry.object.updateMatrixWorld(true);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** A camera's projection at the posed frame, interpolated between Blender's samples. */
|
|
301
|
+
const projectionOf = (entry: MovieObject): CameraProps => {
|
|
302
|
+
const own = entry.data.camera!;
|
|
303
|
+
const props = Object.fromEntries(CAMERA_PROPS.map((prop) => [prop, own[prop]])) as CameraProps;
|
|
304
|
+
const at = frameNow - entry.projectionStart;
|
|
305
|
+
for (const prop of CAMERA_PROPS) {
|
|
306
|
+
const column = entry.projection[prop];
|
|
307
|
+
if (!column) continue;
|
|
308
|
+
if (column.length === 1) { props[prop] = column[0]!; continue; }
|
|
309
|
+
const i = Math.max(0, Math.min(column.length - 1, Math.floor(at)));
|
|
310
|
+
const j = Math.min(column.length - 1, i + 1);
|
|
311
|
+
const t = Math.max(0, Math.min(1, at - i));
|
|
312
|
+
props[prop] = column[i]! + (column[j]! - column[i]!) * t;
|
|
313
|
+
}
|
|
314
|
+
return props;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
objects: objects.filter((one) => one.data.sampled).map((one) => one.data.name),
|
|
319
|
+
warnings,
|
|
320
|
+
place(frame, presented) {
|
|
321
|
+
presentedFrame = presented;
|
|
322
|
+
if (frame === null) {
|
|
323
|
+
for (const entry of objects) if (entry.action) entry.action.enabled = false;
|
|
324
|
+
for (const action of shapeActions) action.enabled = false;
|
|
325
|
+
for (const { mesh } of shapeMeshes) mesh.morphTargetInfluences?.fill(0);
|
|
326
|
+
// (the presenter draws a material's file value, not its animation: the movie keeps it)
|
|
327
|
+
const held = Math.max(0, Math.min(end, presentedFrame) - start) / fps;
|
|
328
|
+
for (const action of materialActions) {
|
|
329
|
+
action.enabled = true;
|
|
330
|
+
if (!action.isRunning()) action.play();
|
|
331
|
+
action.paused = false;
|
|
332
|
+
action.time = held;
|
|
333
|
+
}
|
|
334
|
+
placed = false;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
frameNow = frame;
|
|
338
|
+
placed = true;
|
|
339
|
+
const time = Math.max(0, Math.min(end, frame) - start) / fps;
|
|
340
|
+
for (const entry of objects) {
|
|
341
|
+
if (!entry.action) continue;
|
|
342
|
+
entry.action.enabled = true;
|
|
343
|
+
if (!entry.action.isRunning()) entry.action.play();
|
|
344
|
+
entry.action.paused = false;
|
|
345
|
+
entry.action.time = time;
|
|
346
|
+
}
|
|
347
|
+
for (const action of extras()) {
|
|
348
|
+
action.enabled = true;
|
|
349
|
+
if (!action.isRunning()) action.play();
|
|
350
|
+
action.paused = false;
|
|
351
|
+
action.time = time;
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
settle() {
|
|
355
|
+
if (!placed) return;
|
|
356
|
+
// THE MOVING OBJECTS' MATRICES, composed here from what the mixer wrote: the presenter states each
|
|
357
|
+
// object's matrix outright (`matrixAutoUpdate = false`, again at every Blender redraw), so the
|
|
358
|
+
// mixer's position, rotation and scale reach no drawn matrix by themselves.
|
|
359
|
+
for (const entry of objects) if (entry.action && entry.action.enabled) entry.object.updateMatrix();
|
|
360
|
+
// SHAPE KEYS relative to what the drawn geometry already holds (its value at the presented frame)
|
|
361
|
+
for (const { mesh, keys } of shapeMeshes) {
|
|
362
|
+
const influences = mesh.morphTargetInfluences;
|
|
363
|
+
if (!influences) continue;
|
|
364
|
+
keys.forEach((values, k) => { influences[k] = (influences[k] ?? 0) - valueAt(values, presentedFrame); });
|
|
365
|
+
}
|
|
366
|
+
constrain();
|
|
367
|
+
},
|
|
368
|
+
clips() {
|
|
369
|
+
return [...objects.flatMap((entry) => (entry.action ? [entry.action.getClip()] : [])), ...extras().map((action) => action.getClip())];
|
|
370
|
+
},
|
|
371
|
+
dispose() {
|
|
372
|
+
for (const entry of objects) {
|
|
373
|
+
if (!entry.action) continue;
|
|
374
|
+
const clip = entry.action.getClip();
|
|
375
|
+
entry.action.stop();
|
|
376
|
+
mixer.uncacheAction(clip);
|
|
377
|
+
mixer.uncacheClip(clip);
|
|
378
|
+
}
|
|
379
|
+
for (const action of extras()) {
|
|
380
|
+
const clip = action.getClip();
|
|
381
|
+
action.stop();
|
|
382
|
+
mixer.uncacheAction(clip);
|
|
383
|
+
mixer.uncacheClip(clip);
|
|
384
|
+
}
|
|
385
|
+
placed = false;
|
|
386
|
+
},
|
|
387
|
+
cameraAt(frame) {
|
|
388
|
+
// Compared with the whole frame, as Blender's playback compares it.
|
|
389
|
+
const now = Math.floor(frame);
|
|
390
|
+
let best: MovieMarker | null = null;
|
|
391
|
+
let first: MovieMarker | null = null;
|
|
392
|
+
for (const marker of cutMarkers) {
|
|
393
|
+
if (marker.frame <= now && (!best || marker.frame > best.frame)) best = marker;
|
|
394
|
+
if (!first || marker.frame < first.frame) first = marker;
|
|
395
|
+
}
|
|
396
|
+
return (best ?? first)?.camera ?? data.scene.camera;
|
|
397
|
+
},
|
|
398
|
+
cameraData(name) {
|
|
399
|
+
const entry = byName.get(name);
|
|
400
|
+
if (!entry?.data.camera) return null;
|
|
401
|
+
view.root.updateMatrixWorld(true);
|
|
402
|
+
entry.object.updateWorldMatrix(true, false);
|
|
403
|
+
const e = view.root.matrixWorld.clone().invert().multiply(entry.object.matrixWorld).elements;
|
|
404
|
+
const matrix = [0, 1, 2, 3].map((r) => [e[r]!, e[4 + r]!, e[8 + r]!, e[12 + r]!] as [number, number, number, number]);
|
|
405
|
+
const own = entry.data.camera;
|
|
406
|
+
return { name, type: own.type, sensor_fit: own.sensor_fit, matrix, ...projectionOf(entry) };
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|