@@ -39,7 +39,7 @@
|
|
39
39
|
if (userSettings.noFacebookInstantGames === true) return;
|
40
40
|
|
41
41
|
// If the config is not present it means that we don't want to use fb instant games
|
42
|
-
if (!config.facebookInstantGames) return;
|
42
|
+
if (!config || !config.facebookInstantGames) return;
|
43
43
|
|
44
44
|
log("Setup Facebook Instant Games", config.facebookInstantGames);
|
45
45
|
|
@@ -1,11 +1,10 @@
|
|
1
1
|
import { GameObject } from "../../../Component.js";
|
2
2
|
import { getParam } from "../../../../engine/engine_utils.js";
|
3
|
-
|
4
|
-
import { USDObject, buildMatrix } from "../ThreeUSDZExporter.js";
|
3
|
+
import { USDObject, buildMatrix, findStructuralNodesInBoneHierarchy, usdNumberFormatting as fn, getPathToSkeleton } from "../ThreeUSDZExporter.js";
|
5
4
|
import type { IUSDExporterExtension } from "../Extension.js";
|
5
|
+
import { Object3D, Matrix4, Vector3, Quaternion, Interpolant, AnimationClip, KeyframeTrack, PropertyBinding, Bone } from "three";
|
6
|
+
import { Animator } from "../../../Animator.js";
|
6
7
|
|
7
|
-
import { Object3D, Matrix4, Vector3, Quaternion, Interpolant, AnimationClip, KeyframeTrack, PropertyBinding } from "three";
|
8
|
-
|
9
8
|
const debug = getParam("debugusdzanimation");
|
10
9
|
const debugSerialization = getParam("debugusdzanimationserialization");
|
11
10
|
|
@@ -17,7 +16,13 @@
|
|
17
16
|
|
18
17
|
export class RegisteredAnimationInfo {
|
19
18
|
|
20
|
-
|
19
|
+
private _start?: number;
|
20
|
+
get start(): number {
|
21
|
+
if (this._start === undefined) {
|
22
|
+
this._start = this.ext.getStartTime01(this.root, this.clip);
|
23
|
+
}
|
24
|
+
return this._start;
|
25
|
+
}
|
21
26
|
get duration(): number { return this.clip.duration; }
|
22
27
|
|
23
28
|
private ext: AnimationExtension;
|
@@ -32,34 +37,50 @@
|
|
32
37
|
}
|
33
38
|
|
34
39
|
export class TransformData {
|
35
|
-
clip
|
40
|
+
clip?: AnimationClip;
|
36
41
|
pos?: KeyframeTrack;
|
37
42
|
rot?: KeyframeTrack;
|
38
43
|
scale?: KeyframeTrack;
|
39
44
|
get frameRate(): number { return 60; }
|
40
45
|
|
41
|
-
private
|
42
|
-
private root: Object3D;
|
46
|
+
private root: Object3D | null;
|
43
47
|
private target: Object3D;
|
44
48
|
private duration = 0;
|
49
|
+
private useRootMotion = false;
|
45
50
|
|
46
|
-
|
47
|
-
|
51
|
+
static animationDurationPadding = 1;
|
52
|
+
|
53
|
+
constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | undefined) {
|
48
54
|
this.root = root;
|
49
55
|
this.target = target;
|
50
56
|
this.clip = clip;
|
57
|
+
|
58
|
+
// this is a rest pose clip.
|
59
|
+
// we assume duration 1/60 and no tracks, and when queried for times we just return [0, duration]
|
60
|
+
if (!clip) {
|
61
|
+
this.duration = 1 / this.frameRate;
|
62
|
+
}
|
63
|
+
else {
|
64
|
+
this.duration = clip.duration;
|
65
|
+
}
|
66
|
+
|
67
|
+
const animator = GameObject.getComponent(root, Animator);
|
68
|
+
if (animator) this.useRootMotion = animator.applyRootMotion;
|
51
69
|
}
|
52
70
|
|
53
71
|
addTrack(track) {
|
72
|
+
if (!this.clip) {
|
73
|
+
console.error("This is a rest clip but you're trying to add tracks to it – this is likely a bug");
|
74
|
+
return;
|
75
|
+
}
|
76
|
+
|
54
77
|
if (track.name.endsWith("position")) this.pos = track;
|
55
78
|
if (track.name.endsWith("quaternion")) this.rot = track;
|
56
79
|
if (track.name.endsWith("scale")) this.scale = track;
|
57
|
-
|
58
|
-
// this.duration = Math.max(this.duration, track.times[track.times.length - 1]);
|
59
|
-
this.duration = this.clip.duration;
|
60
80
|
}
|
61
81
|
|
62
82
|
getFrames(): number {
|
83
|
+
if (!this.clip) return 2;
|
63
84
|
return Math.max(this.pos?.times?.length ?? 0, this.rot?.times?.length ?? 0, this.scale?.times?.length ?? 0);
|
64
85
|
}
|
65
86
|
|
@@ -67,46 +88,81 @@
|
|
67
88
|
return this.duration;
|
68
89
|
}
|
69
90
|
|
70
|
-
|
91
|
+
getSortedTimesArray(generatePos: boolean = true, generateRot: boolean = true, generateScale: boolean = true) {
|
92
|
+
if (!this.clip) return [0, this.duration];
|
71
93
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
94
|
+
const posTimesArray = this.pos?.times;
|
95
|
+
const rotTimesArray = this.rot?.times;
|
96
|
+
const scaleTimesArray = this.scale?.times;
|
97
|
+
|
98
|
+
// timesArray is the sorted union of all time values
|
99
|
+
const timesArray: number[] = [];
|
100
|
+
if (generatePos && posTimesArray) for (const t of posTimesArray) timesArray.push(t);
|
101
|
+
if (generateRot && rotTimesArray) for (const t of rotTimesArray) timesArray.push(t);
|
102
|
+
if (generateScale && scaleTimesArray) for (const t of scaleTimesArray) timesArray.push(t);
|
103
|
+
|
104
|
+
// we also need to make sure we have start and end times for these tracks
|
105
|
+
// TODO seems we can't get track duration from the KeyframeTracks
|
106
|
+
if (!timesArray.includes(0)) timesArray.push(0);
|
107
|
+
|
108
|
+
// sort times so it's increasing
|
109
|
+
timesArray.sort((a, b) => a - b);
|
110
|
+
// make sure time values are unique
|
111
|
+
return [...new Set(timesArray)];
|
85
112
|
}
|
86
|
-
*/
|
87
113
|
|
88
|
-
|
114
|
+
/**
|
115
|
+
* Returns an iterator that yields the values for each time sample.
|
116
|
+
* Values are reused objects - if you want to append them to some array
|
117
|
+
* instead of processing them right away, clone() them.
|
118
|
+
* @param timesArray
|
119
|
+
* @param generatePos
|
120
|
+
* @param generateRot
|
121
|
+
* @param generateScale
|
122
|
+
*/
|
123
|
+
*getValues(timesArray: number[], generatePos: boolean = true, generateRot: boolean = true, generateScale: boolean = true) {
|
89
124
|
|
90
|
-
|
125
|
+
const translation = new Vector3();
|
126
|
+
const rotation = new Quaternion();
|
127
|
+
const scale = new Vector3(1, 1, 1);
|
128
|
+
const object = this.target;
|
91
129
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
130
|
+
const positionInterpolant: Interpolant | undefined = generatePos ? this.pos?.createInterpolant() : undefined;
|
131
|
+
const rotationInterpolant: Interpolant | undefined = generateRot ? this.rot?.createInterpolant() : undefined;
|
132
|
+
const scaleInterpolant: Interpolant | undefined = generateScale ? this.scale?.createInterpolant() : undefined;
|
133
|
+
|
134
|
+
if (!positionInterpolant) translation.set(object.position.x, object.position.y, object.position.z);
|
135
|
+
if (!rotationInterpolant) rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
|
136
|
+
if (!scaleInterpolant) scale.set(object.scale.x, object.scale.y, object.scale.z);
|
137
|
+
|
138
|
+
for (let index = 0; index < timesArray.length; index++) {
|
139
|
+
const time = timesArray[index];
|
140
|
+
|
141
|
+
if (positionInterpolant) {
|
142
|
+
const pos = positionInterpolant.evaluate(time);
|
143
|
+
translation.set(pos[0], pos[1], pos[2]);
|
99
144
|
}
|
100
|
-
|
145
|
+
if (rotationInterpolant) {
|
146
|
+
const quat = rotationInterpolant.evaluate(time);
|
147
|
+
rotation.set(quat[0], quat[1], quat[2], quat[3]);
|
148
|
+
}
|
149
|
+
if (scaleInterpolant) {
|
150
|
+
const scaleVal = scaleInterpolant.evaluate(time);
|
151
|
+
scale.set(scaleVal[0], scaleVal[1], scaleVal[2]);
|
152
|
+
}
|
101
153
|
|
102
|
-
|
154
|
+
// Apply basic root motion offset – non-animated transformation data is applied to the node again.
|
155
|
+
// We're doing this because clips that animate their own root are (typically) not in world space,
|
156
|
+
// but in local space and moved to a specific spot in the world.
|
157
|
+
if (this.useRootMotion && object === this.root) {
|
158
|
+
const rootMatrix = new Matrix4();
|
159
|
+
rootMatrix.compose(translation, rotation, scale);
|
160
|
+
rootMatrix.multiply(object.matrix);
|
161
|
+
rootMatrix.decompose(translation, rotation, scale);
|
162
|
+
}
|
103
163
|
|
104
|
-
|
105
|
-
for (const clip of clips) {
|
106
|
-
if (clip === this.clip) break;
|
107
|
-
sum += clip.duration + TransformData.animationDurationPadding;
|
164
|
+
yield { time, translation, rotation, scale, index };
|
108
165
|
}
|
109
|
-
return sum;
|
110
166
|
}
|
111
167
|
}
|
112
168
|
|
@@ -114,45 +170,79 @@
|
|
114
170
|
|
115
171
|
export class AnimationExtension implements IUSDExporterExtension {
|
116
172
|
|
173
|
+
|
117
174
|
get extensionName(): string { return "animation" }
|
175
|
+
|
118
176
|
private dict: AnimationDict = new Map();
|
119
|
-
|
120
|
-
private
|
177
|
+
private rootTargetMap = new Map<Object3D, Object3D[]>();
|
178
|
+
private rootAndClipToRegisteredAnimationMap = new Map<string, RegisteredAnimationInfo>();
|
179
|
+
private rootToRegisteredClipCount = new Map<Object3D, number>();
|
121
180
|
|
181
|
+
private serializers: SerializeAnimation[] = [];
|
182
|
+
|
183
|
+
// determines if we inject a rest pose clip for each root - only makes sense for QuickLook
|
184
|
+
injectRestPoses = false;
|
185
|
+
// determines if we inject a PlayAnimationOnClick component with "scenestart" trigger - only makes sense for QuickLook
|
186
|
+
injectImplicitBehaviours = false;
|
187
|
+
|
188
|
+
constructor(quickLookCompatible: boolean) {
|
189
|
+
this.injectRestPoses = quickLookCompatible;
|
190
|
+
this.injectImplicitBehaviours = quickLookCompatible;
|
191
|
+
}
|
192
|
+
|
193
|
+
getClipCount(root: Object3D): number {
|
194
|
+
// don't count the rest pose
|
195
|
+
let currentCount = this.rootToRegisteredClipCount.get(root);
|
196
|
+
if (this.injectRestPoses) currentCount = currentCount ? currentCount - 1 : 0;
|
197
|
+
return currentCount ?? 0;
|
198
|
+
}
|
199
|
+
|
200
|
+
// TODO why do we have this here and on TransformData? Can RegisteredAnimationInfo not cache this value?
|
201
|
+
// TODO we probably want to assert here that this is the same value on all nodes
|
122
202
|
getStartTime01(root: Object3D, clip: AnimationClip) {
|
203
|
+
|
123
204
|
const targets = this.rootTargetMap.get(root);
|
124
|
-
if (!targets) return
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
let startTimeInSeconds = 0;
|
130
|
-
if (data?.length) {
|
131
|
-
let dataContainedClip = false;
|
132
|
-
for (const entry of data) {
|
133
|
-
if (debug) console.log(" ", entry.clip.name, entry.getDuration());
|
134
|
-
if (entry.clip === clip) {
|
135
|
-
dataContainedClip = true;
|
136
|
-
break;
|
137
|
-
}
|
138
|
-
startTimeInSeconds += entry.getDuration() + TransformData.animationDurationPadding;
|
139
|
-
}
|
140
|
-
if (dataContainedClip)
|
141
|
-
longestStartTime = Math.max(longestStartTime, startTimeInSeconds);
|
142
|
-
}
|
143
|
-
else {
|
144
|
-
console.warn("No animation found on root", root, clip, data);
|
145
|
-
}
|
205
|
+
if (!targets) return 0;
|
206
|
+
const transformDatas = this.dict.get(targets[0]);
|
207
|
+
if (!transformDatas) {
|
208
|
+
console.error("Trying to get start time for root that has no animation data", root, clip, ...this.dict);
|
209
|
+
return 0;
|
146
210
|
}
|
147
|
-
|
148
|
-
|
211
|
+
|
212
|
+
let currentStartTime = 0;
|
213
|
+
for (let i = 0; i < transformDatas.length; i++) {
|
214
|
+
if (transformDatas[i].clip === clip) break;
|
215
|
+
currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
|
216
|
+
}
|
217
|
+
|
218
|
+
return currentStartTime;
|
149
219
|
}
|
150
220
|
|
221
|
+
// The same clip could be registered for different roots. All of them need written animation data then.
|
222
|
+
// The same root could have multiple clips registered to it.
|
151
223
|
registerAnimation(root: Object3D, clip: AnimationClip): RegisteredAnimationInfo | null {
|
152
224
|
if (!clip || !root) return null;
|
153
225
|
if (!this.rootTargetMap.has(root)) this.rootTargetMap.set(root, []);
|
154
|
-
// this.rootTargetMap.get(root)?.push(clip);
|
155
226
|
|
227
|
+
// if we registered that exact pair already, just return the info
|
228
|
+
// no proper tuples in JavaScript, but we can use the uuids for a unique key here
|
229
|
+
const hash = root.uuid + clip.uuid;
|
230
|
+
if (this.rootAndClipToRegisteredAnimationMap.has(hash)) {
|
231
|
+
return this.rootAndClipToRegisteredAnimationMap.get(hash)!;
|
232
|
+
}
|
233
|
+
|
234
|
+
if (debug) console.log("registerAnimation", root, clip);
|
235
|
+
|
236
|
+
// When injecting a rest clip, the rest clip has ALL animated nodes as targets.
|
237
|
+
// So all other nodes will already have at least one animated clip registered, and their own
|
238
|
+
// animations need to start at index 1. Otherwise we're getting overlap where everything is
|
239
|
+
// in animation 0 and some data overrides each other incorrectly.
|
240
|
+
// When we don't inject a rest clip, we start at 0.
|
241
|
+
const startIndex = this.injectRestPoses ? 1 : 0;
|
242
|
+
const currentCount = this.rootToRegisteredClipCount.get(root) ?? startIndex;
|
243
|
+
|
244
|
+
const targets = this.rootTargetMap.get(root);
|
245
|
+
const unregisteredNodesForThisClip = new Set(targets);
|
156
246
|
if (clip.tracks) {
|
157
247
|
for (const track of clip.tracks) {
|
158
248
|
const parsedPath = PropertyBinding.parseTrackName(track.name);
|
@@ -168,24 +258,69 @@
|
|
168
258
|
if (!this.dict.has(animationTarget)) {
|
169
259
|
this.dict.set(animationTarget, []);
|
170
260
|
}
|
171
|
-
const
|
172
|
-
if (!
|
261
|
+
const transformDataForTarget = this.dict.get(animationTarget);
|
262
|
+
if (!transformDataForTarget) continue;
|
173
263
|
|
174
|
-
|
264
|
+
// this node has animation data for this clip – no need for additional padding
|
265
|
+
unregisteredNodesForThisClip.delete(animationTarget);
|
266
|
+
|
267
|
+
// Since we're interleaving animations, we need to ensure that for the same root,
|
268
|
+
// all clips that "touch" it are written in the same order for all animated nodes.
|
269
|
+
// this means we need to pad the TransformData array with empty entries when a particular
|
270
|
+
// node inside that root is not animated by a particular clip.
|
271
|
+
// It also means that when we encounter a clip that contains animation data for a new node,
|
272
|
+
// We need to pad that one's array as well so it starts at the same point.
|
273
|
+
// TODO most likely doesn't work for overlapping clips (clips where a root is a child of another root)
|
274
|
+
// TODO in that case we may need to pad globally, not per root
|
275
|
+
|
276
|
+
// check if we have a rest pose already
|
277
|
+
if (this.injectRestPoses && !transformDataForTarget[0]) {
|
278
|
+
const model = new TransformData(null, animationTarget, undefined);
|
279
|
+
transformDataForTarget[0] = model;
|
280
|
+
}
|
281
|
+
// These all need to be at the same index, otherwise our padding went wrong
|
282
|
+
let model = transformDataForTarget[currentCount];
|
283
|
+
|
284
|
+
// validation (for debugging)
|
285
|
+
// let modelFoundByClip = transformDataForTarget.find(x => x.clip === clip);
|
286
|
+
// let modelFoundByClipIndex = modelFoundByClip ? transformDataForTarget.indexOf(modelFoundByClip) : -1;
|
287
|
+
// console.assert(model === modelFoundByClip, "We should find the same model by index and by clip; " + currentCount + " !== " + modelFoundByClipIndex);
|
288
|
+
|
175
289
|
if (!model) {
|
176
|
-
model = new TransformData(
|
177
|
-
|
290
|
+
model = new TransformData(root, animationTarget, clip);
|
291
|
+
transformDataForTarget[currentCount] = model;
|
178
292
|
}
|
179
293
|
model.addTrack(track);
|
180
294
|
|
181
|
-
|
295
|
+
// We're keeping track of all animated nodes per root, needed for proper padding
|
182
296
|
if (!targets?.includes(animationTarget)) targets?.push(animationTarget);
|
183
297
|
}
|
184
298
|
}
|
185
299
|
|
300
|
+
// add padding for nodes that are not animated by this clip
|
301
|
+
for (const target of unregisteredNodesForThisClip) {
|
302
|
+
const transformDataForTarget = this.dict.get(target);
|
303
|
+
if (!transformDataForTarget) continue;
|
304
|
+
|
305
|
+
// inject rest pose
|
306
|
+
if (this.injectRestPoses && !transformDataForTarget[0]) {
|
307
|
+
const model = new TransformData(null, target, undefined);
|
308
|
+
transformDataForTarget[0] = model;
|
309
|
+
}
|
310
|
+
|
311
|
+
let model = transformDataForTarget[currentCount];
|
312
|
+
if (!model) {
|
313
|
+
if (debug) console.log("Adding padding clip for ", target, clip);
|
314
|
+
model = new TransformData(root, target, clip);
|
315
|
+
transformDataForTarget[currentCount] = model;
|
316
|
+
}
|
317
|
+
}
|
318
|
+
|
186
319
|
// get the entry for this object.
|
187
320
|
// This doesnt work if we have clips animating multiple objects
|
188
321
|
const info = new RegisteredAnimationInfo(this, root, clip);
|
322
|
+
this.rootAndClipToRegisteredAnimationMap.set(hash, info);
|
323
|
+
this.rootToRegisteredClipCount.set(root, currentCount + 1);
|
189
324
|
return info;
|
190
325
|
}
|
191
326
|
|
@@ -193,8 +328,6 @@
|
|
193
328
|
if (debug) console.log("Animation clips per animation target node", this.dict);
|
194
329
|
}
|
195
330
|
|
196
|
-
private serializers: SerializeAnimation[] = [];
|
197
|
-
|
198
331
|
onAfterBuildDocument(_context: any) {
|
199
332
|
for (const ser of this.serializers) {
|
200
333
|
const parent = ser.model?.parent;
|
@@ -223,6 +356,8 @@
|
|
223
356
|
|
224
357
|
}
|
225
358
|
|
359
|
+
declare type TransformDataByObject = Map<Object3D, TransformData[]>;
|
360
|
+
declare type AnimationClipFrameTimes = { pos: number[], rot: number[], scale: number[], timeOffset: number };
|
226
361
|
|
227
362
|
class SerializeAnimation {
|
228
363
|
|
@@ -248,20 +383,346 @@
|
|
248
383
|
this.model.addEventListener("serialize", this.callback);
|
249
384
|
}
|
250
385
|
|
386
|
+
skinnedMeshExport(writer, _context) {
|
387
|
+
const model = this.model;
|
388
|
+
const dict = this.dict;
|
389
|
+
if (!model) return;
|
390
|
+
|
391
|
+
if ( model.skinnedMesh ) {
|
392
|
+
|
393
|
+
const skeleton = model.skinnedMesh.skeleton;
|
394
|
+
|
395
|
+
const boneAndInverse = new Array<{bone: Object3D, inverse: Matrix4}>();
|
396
|
+
for (const index in skeleton.bones) {
|
397
|
+
const bone = skeleton.bones[index];
|
398
|
+
const inverse = skeleton.boneInverses[index];
|
399
|
+
boneAndInverse.push({bone, inverse});
|
400
|
+
}
|
401
|
+
|
402
|
+
for (const bone of findStructuralNodesInBoneHierarchy(skeleton.bones)) {
|
403
|
+
boneAndInverse.push({bone, inverse: bone.matrixWorld.clone().invert()});
|
404
|
+
}
|
405
|
+
|
406
|
+
// sort bones by path – need to be sorted in the same order as during mesh export
|
407
|
+
const assumedRoot = boneAndInverse[0].bone.parent!;
|
408
|
+
if (!assumedRoot) console.error("No bone parent found for skinned mesh during USDZ export", model.skinnedMesh);
|
409
|
+
boneAndInverse.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
|
410
|
+
|
411
|
+
function createVector3TimeSampleLines_( values ) {
|
412
|
+
|
413
|
+
const lines:string[] = []
|
414
|
+
for (const [frame, frameValues] of values) {
|
415
|
+
let line = `${frame} : [`;
|
416
|
+
const boneRotations: Array<string> = [];
|
417
|
+
for ( const v of frameValues ) {
|
418
|
+
boneRotations.push( `(${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
|
419
|
+
}
|
420
|
+
line = line.concat( boneRotations.join( ', ' ) );
|
421
|
+
line = line.concat( '],' );
|
422
|
+
lines.push( line );
|
423
|
+
}
|
424
|
+
|
425
|
+
return lines;
|
426
|
+
|
427
|
+
}
|
428
|
+
|
429
|
+
function createVector4TimeSampleLines_( rotations ) {
|
430
|
+
|
431
|
+
const lines:string[] = []
|
432
|
+
|
433
|
+
for (const [frame, frameRotations] of rotations) {
|
434
|
+
let line = `${frame} : [`;
|
435
|
+
const boneRotations: Array<string> = [];
|
436
|
+
for ( const v of frameRotations ) {
|
437
|
+
boneRotations.push( `(${fn( v.w )}, ${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
|
438
|
+
}
|
439
|
+
line = line.concat( boneRotations.join( ', ' ) );
|
440
|
+
line = line.concat( '],' );
|
441
|
+
lines.push( line );
|
442
|
+
}
|
443
|
+
|
444
|
+
return lines;
|
445
|
+
|
446
|
+
}
|
447
|
+
|
448
|
+
function getSortedFrameTimes( boneToTransformData: TransformDataByObject ): AnimationClipFrameTimes[] {
|
449
|
+
|
450
|
+
// We should have a proper rectangular array,
|
451
|
+
// Where for each bone we have the same number of TransformData entries.
|
452
|
+
let allBonesHaveSameNumberOfTransformDataEntries = true;
|
453
|
+
let numberOfEntries: number | undefined = undefined;
|
454
|
+
for (const [bone, transformDatas] of boneToTransformData) {
|
455
|
+
if (numberOfEntries === undefined) numberOfEntries = transformDatas.length;
|
456
|
+
if (numberOfEntries !== transformDatas.length) {
|
457
|
+
allBonesHaveSameNumberOfTransformDataEntries = false;
|
458
|
+
break;
|
459
|
+
}
|
460
|
+
}
|
461
|
+
|
462
|
+
// TODO not working yet for multiple skinned characters at the same time
|
463
|
+
if (debug) console.log("Bone count: ", boneToTransformData.size, "TransformData entries per bone: ", numberOfEntries);//, ...dict);
|
464
|
+
console.assert(allBonesHaveSameNumberOfTransformDataEntries, "All bones should have the same number of TransformData entries", boneToTransformData);
|
465
|
+
|
466
|
+
const times: AnimationClipFrameTimes[] = [];
|
467
|
+
|
468
|
+
for (const [bone, transformDatas] of boneToTransformData) {
|
469
|
+
|
470
|
+
// calculate start times from the transformDatas
|
471
|
+
const startTimes = new Array<number>();
|
472
|
+
let currentStartTime = 0;
|
473
|
+
for (let i = 0; i < transformDatas.length; i++) {
|
474
|
+
startTimes.push(currentStartTime);
|
475
|
+
currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
|
476
|
+
}
|
477
|
+
|
478
|
+
for (let i = 0; i < transformDatas.length; i++) {
|
479
|
+
const transformData = transformDatas[i];
|
480
|
+
// const timeOffset = transformData.getStartTime(dict);
|
481
|
+
const timeOffset = startTimes[i];
|
482
|
+
if (times.length <= i) {
|
483
|
+
times.push({pos: [], rot: [], scale: [], timeOffset});
|
484
|
+
}
|
485
|
+
|
486
|
+
const perTransfromDataTimes = times[i];
|
487
|
+
|
488
|
+
perTransfromDataTimes.pos.push( ...transformData.getSortedTimesArray(true, false, false));
|
489
|
+
perTransfromDataTimes.rot.push( ...transformData.getSortedTimesArray(false, true, false));
|
490
|
+
perTransfromDataTimes.scale.push( ...transformData.getSortedTimesArray(false, false, true));
|
491
|
+
}
|
492
|
+
}
|
493
|
+
|
494
|
+
for (const perTransfromDataTimes of times) {
|
495
|
+
|
496
|
+
/*
|
497
|
+
// TODO we're doing that in animation export as well
|
498
|
+
if (!times.pos.includes(0)) times.pos.push(0);
|
499
|
+
if (!times.rot.includes(0)) times.rot.push(0);
|
500
|
+
if (!times.scale.includes(0)) times.scale.push(0);
|
501
|
+
*/
|
502
|
+
|
503
|
+
// sort times so it's increasing
|
504
|
+
perTransfromDataTimes.pos.sort((a, b) => a - b);
|
505
|
+
perTransfromDataTimes.rot.sort((a, b) => a - b);
|
506
|
+
perTransfromDataTimes.scale.sort((a, b) => a - b);
|
507
|
+
|
508
|
+
// make sure time values are unique
|
509
|
+
perTransfromDataTimes.pos = [...new Set(perTransfromDataTimes.pos)];
|
510
|
+
perTransfromDataTimes.rot = [...new Set(perTransfromDataTimes.rot)];
|
511
|
+
perTransfromDataTimes.scale = [...new Set(perTransfromDataTimes.scale)];
|
512
|
+
}
|
513
|
+
|
514
|
+
return times;
|
515
|
+
}
|
516
|
+
|
517
|
+
function createTimeSamplesObject_( data: TransformDataByObject, sortedComponentFrameNumbers: AnimationClipFrameTimes[], bones ) {
|
518
|
+
const positionTimeSamples = new Map<number, Array<Vector3>>();
|
519
|
+
const quaternionTimeSamples = new Map<number, Array<Quaternion>>();
|
520
|
+
const scaleTimeSamples = new Map<number, Array<Vector3>>();
|
521
|
+
|
522
|
+
const count = sortedComponentFrameNumbers.length;
|
523
|
+
|
524
|
+
// return sampled data for each bone
|
525
|
+
for ( const bone of bones ) {
|
526
|
+
const boneEntryInData = data.get( bone );
|
527
|
+
|
528
|
+
let emptyTransformData: TransformData | undefined = undefined;
|
529
|
+
// if we have animation data for this bone, check that it's the right amount.
|
530
|
+
if (boneEntryInData)
|
531
|
+
console.assert(boneEntryInData.length === count, "We should have the same number of TransformData entries for each bone", boneEntryInData, sortedComponentFrameNumbers);
|
532
|
+
// if we don't have animation data, create an empty one –
|
533
|
+
// it will automatically map to the rest pose, albeit inefficiently
|
534
|
+
else
|
535
|
+
emptyTransformData = new TransformData(null, bone, undefined);
|
536
|
+
|
537
|
+
for (let i = 0; i < count; i++) {
|
538
|
+
const transformData = boneEntryInData ? boneEntryInData[i] : emptyTransformData!;
|
539
|
+
const timeData = sortedComponentFrameNumbers[i];
|
540
|
+
|
541
|
+
for (const { time, translation } of transformData.getValues(timeData.pos, true, false, false)) {
|
542
|
+
const shiftedTime = time + timeData.timeOffset;
|
543
|
+
const t = shiftedTime * 60;
|
544
|
+
|
545
|
+
if (!positionTimeSamples.has(t)) positionTimeSamples.set(t, new Array<Vector3>());
|
546
|
+
positionTimeSamples.get(t)!.push( translation.clone() );
|
547
|
+
}
|
548
|
+
for (const { time, rotation } of transformData.getValues(timeData.rot, false, true, false)) {
|
549
|
+
const shiftedTime = time + timeData.timeOffset;
|
550
|
+
const t = shiftedTime * 60;
|
551
|
+
|
552
|
+
if (!quaternionTimeSamples.has(t)) quaternionTimeSamples.set(t, new Array<Quaternion>());
|
553
|
+
quaternionTimeSamples.get(t)!.push( rotation.clone() );
|
554
|
+
}
|
555
|
+
for (const { time, scale } of transformData.getValues(timeData.scale, false, false, true)) {
|
556
|
+
const shiftedTime = time + timeData.timeOffset;
|
557
|
+
const t = shiftedTime * 60;
|
558
|
+
|
559
|
+
if (!scaleTimeSamples.has(t)) scaleTimeSamples.set(t, new Array<Vector3>());
|
560
|
+
scaleTimeSamples.get(t)!.push( scale.clone() );
|
561
|
+
}
|
562
|
+
}
|
563
|
+
}
|
564
|
+
|
565
|
+
return {
|
566
|
+
position: positionTimeSamples.size == 0 ? undefined : positionTimeSamples,
|
567
|
+
quaternion: quaternionTimeSamples.size == 0 ? undefined: quaternionTimeSamples,
|
568
|
+
scale: scaleTimeSamples.size == 0 ? undefined : scaleTimeSamples,
|
569
|
+
};
|
570
|
+
}
|
571
|
+
|
572
|
+
function buildVector3Array_( array: Array<Vector3> ) {
|
573
|
+
const lines: Array<string> = [];
|
574
|
+
for ( const v of array ) {
|
575
|
+
lines.push( `(${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
|
576
|
+
}
|
577
|
+
return lines.join( ', ' );
|
578
|
+
}
|
579
|
+
|
580
|
+
function buildVector4Array_( array: Array<Quaternion> ) {
|
581
|
+
const lines: Array<string> = [];
|
582
|
+
for ( const v of array ) {
|
583
|
+
lines.push( `(${fn( v.w )}, ${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
|
584
|
+
}
|
585
|
+
return lines.join( ', ' );
|
586
|
+
}
|
587
|
+
|
588
|
+
function getPerBoneTransformData( bones ): TransformDataByObject {
|
589
|
+
|
590
|
+
const boneToTransformData = new Map<Object3D, TransformData[]>();
|
591
|
+
if (debug) {
|
592
|
+
const logData = new Array<string>();
|
593
|
+
for (const [key, val] of dict) {
|
594
|
+
logData.push(key.uuid + ": " + val.length + " " + val.map(x => x.clip?.uuid.substring(0, 6)).join(" "));
|
595
|
+
}
|
596
|
+
console.log("getPerBoneTransformData\n" + logData.join("\n"));
|
597
|
+
}
|
598
|
+
|
599
|
+
for (const bone of bones) {
|
600
|
+
const data = dict.get(bone);
|
601
|
+
if (!data) continue;
|
602
|
+
boneToTransformData.set(bone, data);
|
603
|
+
}
|
604
|
+
|
605
|
+
return boneToTransformData;
|
606
|
+
}
|
607
|
+
|
608
|
+
function createAllTimeSampleObjects( bones: Array<Object3D> ) {
|
609
|
+
const perBoneTransformData = getPerBoneTransformData( bones );
|
610
|
+
const sortedFrameNumbers = getSortedFrameTimes( perBoneTransformData );
|
611
|
+
return createTimeSamplesObject_( perBoneTransformData, sortedFrameNumbers, bones );
|
612
|
+
}
|
613
|
+
|
614
|
+
const rest: Array<Matrix4> = [];
|
615
|
+
const translations: Array<Vector3> = [];
|
616
|
+
const rotations: Array<Quaternion> = [];
|
617
|
+
const scales: Array<Vector3> = [];
|
618
|
+
for ( const { bone } of boneAndInverse ){
|
619
|
+
|
620
|
+
rest.push( bone.matrix.clone() );
|
621
|
+
translations.push( bone.position ) ;
|
622
|
+
rotations.push( bone.quaternion );
|
623
|
+
scales.push( bone.scale );
|
624
|
+
|
625
|
+
}
|
626
|
+
|
627
|
+
const bonesArray = boneAndInverse.map( x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"" ).join( ', ' );
|
628
|
+
const bindTransforms = boneAndInverse.map( x => buildMatrix( x.inverse.clone().invert() ) ).join( ', ' );
|
629
|
+
|
630
|
+
writer.beginBlock( `def Skeleton "Rig"`);
|
631
|
+
|
632
|
+
writer.appendLine( `uniform matrix4d[] bindTransforms = [${bindTransforms}]` );
|
633
|
+
writer.appendLine( `uniform token[] joints = [${bonesArray}]` );
|
634
|
+
writer.appendLine( `uniform token purpose = "guide"` );
|
635
|
+
writer.appendLine( `uniform matrix4d[] restTransforms = [${rest.map( m => buildMatrix( m ) ).join( ', ' )}]` );
|
636
|
+
// In glTF, transformations on the Skeleton are ignored (NODE_SKINNED_MESH_LOCAL_TRANSFORMS validator warning)
|
637
|
+
// So here we also just write an identity transform.
|
638
|
+
writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix( new Matrix4() )}` );
|
639
|
+
// writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix( matrix )}` );
|
640
|
+
writer.appendLine( `uniform token[] xformOpOrder = ["xformOp:transform"]` );
|
641
|
+
|
642
|
+
const timeSampleObjects = createAllTimeSampleObjects ( boneAndInverse.map( x => x.bone ) );
|
643
|
+
|
644
|
+
writer.beginBlock( `def SkelAnimation "_anim"` );
|
645
|
+
|
646
|
+
// TODO if we include blendshapes we likely need subdivision?
|
647
|
+
// writer.appendLine( `uniform token[] blendShapes` )
|
648
|
+
// writer.appendLine( `float[] blendShapeWeights` )
|
649
|
+
|
650
|
+
writer.appendLine( `uniform token[] joints = [${bonesArray}]` );
|
651
|
+
|
652
|
+
writer.appendLine( `quatf[] rotations = [${buildVector4Array_( rotations )}]` );
|
653
|
+
|
654
|
+
if ( timeSampleObjects && timeSampleObjects.quaternion ) {
|
655
|
+
writer.beginBlock( `quatf[] rotations.timeSamples = {`, '');
|
656
|
+
const rotationTimeSampleLines = createVector4TimeSampleLines_( timeSampleObjects['quaternion'] );
|
657
|
+
for ( const line of rotationTimeSampleLines ) {
|
658
|
+
writer.appendLine( line );
|
659
|
+
}
|
660
|
+
writer.closeBlock();
|
661
|
+
|
662
|
+
}
|
663
|
+
|
664
|
+
writer.appendLine( `half3[] scales = [${buildVector3Array_( scales )}]` );
|
665
|
+
|
666
|
+
if ( timeSampleObjects && timeSampleObjects.scale ) {
|
667
|
+
writer.beginBlock( `half3[] scales.timeSamples = {`, '' );
|
668
|
+
const scaleTimeSampleLines = createVector3TimeSampleLines_( timeSampleObjects['scale'] );
|
669
|
+
|
670
|
+
for ( const line of scaleTimeSampleLines ) {
|
671
|
+
writer.appendLine( line );
|
672
|
+
}
|
673
|
+
writer.closeBlock();
|
674
|
+
|
675
|
+
}
|
676
|
+
|
677
|
+
writer.appendLine( `float3[] translations = [${buildVector3Array_( translations )}]` );
|
678
|
+
|
679
|
+
if ( timeSampleObjects && timeSampleObjects.position) {
|
680
|
+
writer.beginBlock( `float3[] translations.timeSamples = {`, '' );
|
681
|
+
const positionTimeSampleLines = createVector3TimeSampleLines_( timeSampleObjects['position'] );
|
682
|
+
|
683
|
+
for ( const line of positionTimeSampleLines ) {
|
684
|
+
writer.appendLine( line );
|
685
|
+
}
|
686
|
+
writer.closeBlock();
|
687
|
+
|
688
|
+
}
|
689
|
+
|
690
|
+
writer.closeBlock();
|
691
|
+
writer.closeBlock();
|
692
|
+
|
693
|
+
}
|
694
|
+
}
|
695
|
+
|
696
|
+
|
697
|
+
|
251
698
|
onSerialize(writer, _context) {
|
252
699
|
if (!this.model) return;
|
253
700
|
|
701
|
+
this.skinnedMeshExport(writer, _context);
|
702
|
+
|
254
703
|
const object = this.object;
|
704
|
+
|
255
705
|
// do we have animation data for this node? if not, return
|
256
706
|
const arr = this.dict.get(object);
|
257
707
|
if (!arr) return;
|
258
708
|
|
709
|
+
// Skinned meshes are handled separately by the method above.
|
710
|
+
// They need to be handled first (before checking for animation data) because animation needs to be exported
|
711
|
+
// as part of the skinned mesh and that may not be animated at all – if any bone is animated we need to export.
|
712
|
+
//@ts-ignore
|
713
|
+
if (object.isSkinnedMesh) return;
|
714
|
+
|
715
|
+
// Excluding the concrete bone xform hierarchy animation his is mostly useful for debugging,
|
716
|
+
// as otherwise we're getting a ton of extra animation data per-bone xform
|
717
|
+
// TODO if this turns out to be slow/large (lots of duplicated animation data)
|
718
|
+
// we can look into optimizing it, and only including both the xform hierarchy and the animation
|
719
|
+
// only when we need it – when anywhere below it in the hierarchy are animated visible meshes.
|
720
|
+
//@ts-ignore
|
721
|
+
// if (object.isBone) return;
|
722
|
+
|
259
723
|
if (debugSerialization) console.log("SERIALIZE", this.model.name, this.object.type, arr);
|
260
724
|
|
261
725
|
const composedTransform = new Matrix4();
|
262
|
-
const translation = new Vector3();
|
263
|
-
const rotation = new Quaternion();
|
264
|
-
const scale = new Vector3(1, 1, 1);
|
265
726
|
|
266
727
|
writer.appendLine("matrix4d xformOp:transform.timeSamples = {");
|
267
728
|
writer.indent++;
|
@@ -270,71 +731,42 @@
|
|
270
731
|
// We need to make sure that the same underlying animation clip ends up
|
271
732
|
// at the same start time in the USD file, and that we're not getting overlaps to other clips.
|
272
733
|
// That means that the same clip (transformData.clip) should end up at the same start time for all nodes.
|
273
|
-
for (const transformData of arr) {
|
274
|
-
const posTimesArray = transformData.pos?.times;
|
275
|
-
const rotTimesArray = transformData.rot?.times;
|
276
|
-
const scaleTimesArray = transformData.scale?.times;
|
277
|
-
|
278
|
-
// timesArray is the sorted union of all time values
|
279
|
-
let timesArray: number[] = [];
|
280
|
-
if (posTimesArray) for (const t of posTimesArray) timesArray.push(t);
|
281
|
-
if (rotTimesArray) for (const t of rotTimesArray) timesArray.push(t);
|
282
|
-
if (scaleTimesArray) for (const t of scaleTimesArray) timesArray.push(t);
|
283
734
|
|
284
|
-
|
285
|
-
|
286
|
-
|
735
|
+
// calculate start times
|
736
|
+
// calculate start times from the transformDatas
|
737
|
+
const startTimes = new Array<number>();
|
738
|
+
let currentStartTime = 0;
|
739
|
+
for (let i = 0; i < arr.length; i++) {
|
740
|
+
startTimes.push(currentStartTime);
|
741
|
+
currentStartTime += arr[i].getDuration() + TransformData.animationDurationPadding;
|
742
|
+
}
|
287
743
|
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
if (debug) {
|
300
|
-
const clipName = transformData.clip.name;
|
301
|
-
const duration = transformData.getDuration();
|
302
|
-
console.log("Write .timeSamples:", clipName, startTime, duration, arr);
|
303
|
-
writer.appendLine("# " + clipName + ": start=" + (startTime * transformData.frameRate).toFixed(3) + ", length=" + (duration * transformData.frameRate).toFixed(3) + ", frames=" + transformData.getFrames());
|
304
|
-
}
|
305
|
-
|
306
|
-
const positionInterpolant: Interpolant | undefined = transformData.pos?.createInterpolant();
|
307
|
-
const rotationInterpolant: Interpolant | undefined = transformData.rot?.createInterpolant();
|
308
|
-
const scaleInterpolant: Interpolant | undefined = transformData.scale?.createInterpolant();
|
309
|
-
|
310
|
-
if (!positionInterpolant) translation.set(object.position.x, object.position.y, object.position.z);
|
311
|
-
if (!rotationInterpolant) rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
|
312
|
-
if (!scaleInterpolant) scale.set(object.scale.x, object.scale.y, object.scale.z);
|
313
|
-
|
314
|
-
for (let index = 0; index < timesArray.length; index++) {
|
315
|
-
const time = timesArray[index];
|
316
|
-
|
317
|
-
if (positionInterpolant) {
|
318
|
-
const pos = positionInterpolant.evaluate(time);
|
319
|
-
translation.set(pos[0], pos[1], pos[2]);
|
744
|
+
for (let i = 0; i < arr.length; i++) {
|
745
|
+
const transformData = arr[i];
|
746
|
+
if (!transformData) continue;
|
747
|
+
const startTime = startTimes[i];
|
748
|
+
{
|
749
|
+
const timesArray = transformData.getSortedTimesArray();
|
750
|
+
|
751
|
+
if (!timesArray || timesArray.length === 0) {
|
752
|
+
console.error("got an animated object but no time values?", object, transformData);
|
753
|
+
continue;
|
320
754
|
}
|
321
|
-
|
322
|
-
|
323
|
-
|
755
|
+
|
756
|
+
if (debug) {
|
757
|
+
const clipName = transformData.clip?.name ?? "rest";
|
758
|
+
const duration = transformData.getDuration();
|
759
|
+
console.log("Write .timeSamples:", clipName, startTime, duration, arr);
|
760
|
+
writer.appendLine("# " + clipName + ": start=" + (startTime * transformData.frameRate).toFixed(3) + ", length=" + (duration * transformData.frameRate).toFixed(3) + ", frames=" + transformData.getFrames());
|
324
761
|
}
|
325
|
-
|
326
|
-
|
327
|
-
|
762
|
+
|
763
|
+
for (const { time, translation, rotation, scale } of transformData.getValues(timesArray)) {
|
764
|
+
composedTransform.compose(translation, rotation, scale);
|
765
|
+
|
766
|
+
const line = `${(startTime + time) * transformData.frameRate}: ${buildMatrix(composedTransform)},`;
|
767
|
+
writer.appendLine(line);
|
328
768
|
}
|
329
|
-
|
330
|
-
composedTransform.compose(translation, rotation, scale);
|
331
|
-
|
332
|
-
const line = `${(startTime + time) * transformData.frameRate}: ${buildMatrix(composedTransform)},`;
|
333
|
-
if (debug)
|
334
|
-
writer.appendLine("# " + index);
|
335
|
-
writer.appendLine(line);
|
336
769
|
}
|
337
|
-
|
338
770
|
}
|
339
771
|
writer.indent--;
|
340
772
|
writer.appendLine("}");
|
@@ -1,112 +1,81 @@
|
|
1
1
|
import { Animator } from "../../../Animator.js";
|
2
2
|
import { Animation } from "../../../Animation.js";
|
3
|
-
import { Object3D, AnimationClip
|
3
|
+
import { Object3D, AnimationClip } from "three";
|
4
4
|
import { AnimationExtension } from "../extensions/Animation.js";
|
5
|
-
import { GameObject } from "../../../Component.js";
|
5
|
+
import { Behaviour, GameObject } from "../../../Component.js";
|
6
6
|
import { getParam } from "../../../../engine/engine_utils.js";
|
7
|
+
import { PlayAnimationOnClick } from "../extensions/behavior/BehaviourComponents.js";
|
7
8
|
|
8
9
|
const debug = getParam("debugusdz");
|
9
10
|
|
10
|
-
export function registerAnimatorsImplictly(root: Object3D, ext: AnimationExtension) {
|
11
|
+
export function registerAnimatorsImplictly(root: Object3D, ext: AnimationExtension): Array<Object3D> {
|
11
12
|
|
13
|
+
// TODO: currently we're simply appending all animations to each other.
|
14
|
+
// We are not currently emitting PlayAnimation actions that would make sure
|
15
|
+
// that only a specific clip or a set of clips is playing.
|
16
|
+
|
17
|
+
// Better would be to scratch the functionality here entirely, and instead
|
18
|
+
// inject PlayAnimationOnStart actions. This way, animations can loop independently, and
|
19
|
+
// Animator state chaining would be respected out-of-the-box.
|
20
|
+
// For non-QuickLook USDZ export it's still useful to have implicit registration per root.
|
21
|
+
|
12
22
|
// collect animators and their clips
|
13
23
|
const animationClips: { root: Object3D, clips: AnimationClip[] }[] = [];
|
14
24
|
const animators = GameObject.getComponentsInChildren(root, Animator);
|
15
25
|
const animationComponents = GameObject.getComponentsInChildren(root, Animation);
|
16
26
|
|
17
|
-
|
18
|
-
let injectedRestPose = false;
|
19
|
-
const restPoseClip = new AnimationClip("rest", .01, []);
|
20
|
-
const nodesWithRestPoseTracks: Map<Object3D, string[]> = new Map();
|
27
|
+
const constructedObjects = new Array<Object3D>();
|
21
28
|
|
22
|
-
|
23
|
-
//
|
24
|
-
//
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
/*
|
29
|
-
const track = clip.tracks[0];
|
30
|
-
const trackBaseName = track.name.substring(0, track.name.lastIndexOf("."));
|
31
|
-
const currentPositionTrack = new KeyframeTrack(trackBaseName + ".position", [0, .01], [0, 0, 0, 0, 0, 0]);
|
32
|
-
const currentRotationTrack = new KeyframeTrack(trackBaseName + ".quaternion", [0, .01], [0, 0, 0, 1, 0, 0, 0, 1]);
|
33
|
-
restPoseClip.tracks = [currentPositionTrack, currentRotationTrack];
|
34
|
-
*/
|
35
|
-
clips.push(restPoseClip);
|
36
|
-
}
|
29
|
+
if (ext.injectImplicitBehaviours) {
|
30
|
+
// We're registering animators with implicit PlayAnimationOnClick (with hacked "start" trigger) here.
|
31
|
+
// TODO need to remove these extra components again after export.
|
32
|
+
for (const animator of animators) {
|
33
|
+
if (!animator || !animator.runtimeAnimatorController) continue;
|
34
|
+
const activeState = animator.runtimeAnimatorController.activeState;
|
37
35
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
propertyNames.push("position");
|
57
|
-
restPoseClip.tracks.push(newTrack);
|
58
|
-
break;
|
59
|
-
}
|
60
|
-
case "quaternion": {
|
61
|
-
if (propertyNames.includes("quaternion")) continue;
|
62
|
-
const currentData = targetNode.quaternion;
|
63
|
-
const newTrack = new KeyframeTrack(track.name, [0, .01], [
|
64
|
-
currentData.x, currentData.y, currentData.z, currentData.w,
|
65
|
-
currentData.x, currentData.y, currentData.z, currentData.w
|
66
|
-
]);
|
67
|
-
propertyNames.push("quaternion");
|
68
|
-
restPoseClip.tracks.push(newTrack);
|
69
|
-
break;
|
70
|
-
}
|
71
|
-
case "scale": {
|
72
|
-
if (propertyNames.includes("scale")) continue;
|
73
|
-
const currentData = targetNode.scale;
|
74
|
-
const newTrack = new KeyframeTrack(track.name, [0, .01], [
|
75
|
-
currentData.x, currentData.y, currentData.z,
|
76
|
-
currentData.x, currentData.y, currentData.z
|
77
|
-
]);
|
78
|
-
propertyNames.push("scale");
|
79
|
-
restPoseClip.tracks.push(newTrack);
|
80
|
-
break;
|
81
|
-
}
|
82
|
-
}
|
83
|
-
}
|
84
|
-
}
|
36
|
+
// skip missing data, empty states or motions
|
37
|
+
if (!activeState) continue;
|
38
|
+
if (!activeState.motion || !activeState.motion.clip) continue;
|
39
|
+
if (activeState.motion.clip.tracks?.length < 1) continue;
|
40
|
+
|
41
|
+
// Create a PlayAnimationOnClick component that will play the animation on start
|
42
|
+
// This is a bit hacky right now (we're using the internal "start" trigger)
|
43
|
+
const newComponent = new PlayAnimationOnClick();
|
44
|
+
newComponent.animator = animator;
|
45
|
+
newComponent.stateName = activeState.name;
|
46
|
+
newComponent.trigger = "start";
|
47
|
+
newComponent.name = "PlayAnimationOnClick_implicitAtStart_" + newComponent.stateName;
|
48
|
+
const go = new Object3D();
|
49
|
+
GameObject.addComponent(go, newComponent);
|
50
|
+
constructedObjects.push(go);
|
51
|
+
|
52
|
+
// the behaviour can be anywhere in the hierarchy
|
53
|
+
root.add(go);
|
85
54
|
}
|
86
55
|
}
|
56
|
+
else {
|
57
|
+
for (const animator of animators) {
|
58
|
+
if (!animator || !animator.runtimeAnimatorController) continue;
|
87
59
|
|
88
|
-
|
89
|
-
if (!animator || !animator.runtimeAnimatorController) continue;
|
60
|
+
if (debug) console.log(animator);
|
90
61
|
|
91
|
-
|
92
|
-
console.log(animator);
|
62
|
+
const clips: AnimationClip[] = [];
|
93
63
|
|
94
|
-
|
64
|
+
for (const action of animator.runtimeAnimatorController.enumerateActions()) {
|
65
|
+
if (debug)
|
66
|
+
console.log(action);
|
67
|
+
const clip = action.getClip();
|
95
68
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
const clip = action.getClip();
|
69
|
+
if (!clips.includes(clip))
|
70
|
+
clips.push(clip);
|
71
|
+
}
|
100
72
|
|
101
|
-
|
102
|
-
|
103
|
-
if (!clips.includes(clip))
|
104
|
-
clips.push(clip);
|
73
|
+
animationClips.push({ root: animator.gameObject, clips: clips });
|
105
74
|
}
|
106
|
-
|
107
|
-
animationClips.push({ root: animator.gameObject, clips: clips });
|
108
75
|
}
|
109
76
|
|
77
|
+
// TODO once PlayAnimationOnClick can use animation components as well,
|
78
|
+
// we can treat them the same as we treat Animators above.
|
110
79
|
for (const animationComponent of animationComponents) {
|
111
80
|
if (debug)
|
112
81
|
console.log(animationComponent);
|
@@ -114,22 +83,19 @@
|
|
114
83
|
const clips: AnimationClip[] = [];
|
115
84
|
|
116
85
|
for (const clip of animationComponent.animations) {
|
117
|
-
checkInjectRestPose(clips, clip);
|
118
|
-
|
119
86
|
if (!clips.includes(clip))
|
120
87
|
clips.push(clip);
|
121
88
|
}
|
122
89
|
|
123
90
|
animationClips.push({ root: animationComponent.gameObject, clips: clips });
|
124
91
|
}
|
92
|
+
|
93
|
+
if (debug) console.log("USDZ Animation Clips", animationClips);
|
125
94
|
|
126
|
-
if (debug) {
|
127
|
-
console.log("Rest Pose Clip", restPoseClip);
|
128
|
-
console.log("USDZ Animation Clips", animationClips);
|
129
|
-
}
|
130
|
-
|
131
95
|
for (const pair of animationClips) {
|
132
96
|
for (const clip of pair.clips)
|
133
97
|
ext.registerAnimation(pair.root, clip);
|
134
98
|
}
|
99
|
+
|
100
|
+
return constructedObjects;
|
135
101
|
}
|
@@ -355,13 +355,13 @@
|
|
355
355
|
console.log("transition to " + transition.destinationState, transition, normalizedTime, transition.exitTime, transition.hasExitTime);
|
356
356
|
// console.log(action.time, transition.exitTime);
|
357
357
|
}
|
358
|
-
this.transitionTo(transition.destinationState
|
358
|
+
this.transitionTo(transition.destinationState, transition.duration, transition.offset);
|
359
359
|
// use the first transition that matches all conditions and make the transition as soon as in range
|
360
360
|
return;
|
361
361
|
}
|
362
362
|
}
|
363
363
|
else {
|
364
|
-
this.transitionTo(transition.destinationState
|
364
|
+
this.transitionTo(transition.destinationState, transition.duration, transition.offset);
|
365
365
|
return;
|
366
366
|
}
|
367
367
|
// if none of the transitions can be made continue searching for another transition meeting the conditions
|
@@ -1,17 +1,16 @@
|
|
1
1
|
import { GameObject } from "../../../../Component.js";
|
2
|
-
import type { IContext } from "../../../../../engine/engine_types.js";
|
3
2
|
import type { IUSDExporterExtension } from "../../Extension.js";
|
4
|
-
import { USDObject, USDWriter } from "../../ThreeUSDZExporter.js";
|
3
|
+
import { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
|
5
4
|
import { BehaviorModel } from "./BehavioursBuilder.js";
|
6
5
|
import { getParam } from "../../../../../engine/engine_utils.js";
|
7
6
|
|
8
7
|
const debug = getParam("debugusdz");
|
9
8
|
|
10
9
|
export interface UsdzBehaviour {
|
11
|
-
createBehaviours?(ext: BehaviorExtension, model: USDObject, context:
|
12
|
-
beforeCreateDocument?(ext: BehaviorExtension, context:
|
13
|
-
afterCreateDocument?(ext: BehaviorExtension, context:
|
14
|
-
afterSerialize?(ext: BehaviorExtension, context:
|
10
|
+
createBehaviours?(ext: BehaviorExtension, model: USDObject, context: USDZExporterContext): void;
|
11
|
+
beforeCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>;
|
12
|
+
afterCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>
|
13
|
+
afterSerialize?(ext: BehaviorExtension, context: USDZExporterContext): void;
|
15
14
|
}
|
16
15
|
|
17
16
|
/** internal USDZ behaviours extension */
|
@@ -73,9 +72,6 @@
|
|
73
72
|
|
74
73
|
onAfterHierarchy(context, writer: USDWriter) {
|
75
74
|
if (this.behaviours?.length) {
|
76
|
-
|
77
|
-
// this.combineBehavioursWithSameTapActions();
|
78
|
-
|
79
75
|
writer.beginBlock('def Scope "Behaviors"');
|
80
76
|
|
81
77
|
for (const beh of this.behaviours)
|
@@ -92,8 +88,6 @@
|
|
92
88
|
|
93
89
|
if (typeof beh.afterSerialize === "function") {
|
94
90
|
|
95
|
-
if (debug) console.log("behaviour has afterSerialize", beh)
|
96
|
-
|
97
91
|
const isAsync = beh.afterSerialize.constructor.name === "AsyncFunction";
|
98
92
|
|
99
93
|
if ( isAsync ) {
|
@@ -103,6 +97,9 @@
|
|
103
97
|
}
|
104
98
|
}
|
105
99
|
}
|
100
|
+
|
101
|
+
// cleanup
|
102
|
+
this.behaviours.length = 0;
|
106
103
|
}
|
107
104
|
|
108
105
|
// combine behaviours that have tap triggers on the same object
|
@@ -3,18 +3,19 @@
|
|
3
3
|
import { Renderer } from "../../../../Renderer.js";
|
4
4
|
import { serializable } from "../../../../../engine/engine_serialization_decorator.js";
|
5
5
|
import type { IPointerClickHandler, PointerEventData } from "../../../../ui/PointerEvents.js";
|
6
|
-
import { RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
|
6
|
+
import { AnimationExtension, RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
|
7
7
|
import { getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../../../../../engine/engine_three_utils.js";
|
8
8
|
|
9
9
|
import { Object3D, Material, Vector3, Quaternion, Mesh, Group } from "three";
|
10
|
-
import { USDDocument, USDObject } from "../../ThreeUSDZExporter.js";
|
10
|
+
import { USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
|
11
11
|
|
12
12
|
import type { BehaviorExtension, UsdzBehaviour } from "./Behaviour.js";
|
13
13
|
import { ActionBuilder, ActionModel, AuralMode, BehaviorModel, type IBehaviorElement, MotionType, PlayAction, Space, TriggerBuilder } from "./BehavioursBuilder.js";
|
14
14
|
import { AudioSource } from "../../../../AudioSource.js";
|
15
15
|
import { NEEDLE_progressive } from "../../../../../engine/extensions/NEEDLE_progressive.js";
|
16
|
-
import { isDevEnvironment } from "../../../../../engine/debug/index.js";
|
16
|
+
import { isDevEnvironment, showBalloonWarning } from "../../../../../engine/debug/index.js";
|
17
17
|
import { Raycaster, ObjectRaycaster } from "../../../../ui/Raycaster.js";
|
18
|
+
import type { State } from "../../../../../engine/extensions/NEEDLE_animator_controller_model.js";
|
18
19
|
|
19
20
|
function ensureRaycaster(obj: GameObject) {
|
20
21
|
if (!obj) return;
|
@@ -551,12 +552,11 @@
|
|
551
552
|
@serializable()
|
552
553
|
stateName?: string;
|
553
554
|
|
554
|
-
|
555
|
-
|
555
|
+
// Not editable from the outside yet, but from code
|
556
|
+
// we want to expose this once we have a nice drawer for "Triggers" (e.g. shows proximity distance)
|
557
|
+
// and once we rename the component to "PlayAnimation" or "PlayAnimationOnTrigger"
|
558
|
+
trigger: "tap" | "start" = "tap"; // "proximity"
|
556
559
|
|
557
|
-
@serializable()
|
558
|
-
loopAfterPlaying: boolean = false;
|
559
|
-
|
560
560
|
private get target() { return this.animator?.gameObject }
|
561
561
|
|
562
562
|
start(): void {
|
@@ -566,17 +566,20 @@
|
|
566
566
|
onPointerClick(args: PointerEventData) {
|
567
567
|
args.use();
|
568
568
|
if (!this.target) return;
|
569
|
-
if (this.stateName)
|
569
|
+
if (this.stateName) {
|
570
|
+
// TODO this is currently quite annoying to use,
|
571
|
+
// as for the web we use the Animator component and its states directly,
|
572
|
+
// while in QuickLook we use explicit animations / states.
|
570
573
|
this.animator?.play(this.stateName, 0, 0, .1);
|
574
|
+
}
|
571
575
|
}
|
572
576
|
|
573
577
|
private selfModel: any;
|
574
578
|
|
575
579
|
private stateAnimationModel: any;
|
576
|
-
private stateAnimation?: RegisteredAnimationInfo;
|
577
580
|
|
578
|
-
private
|
579
|
-
private
|
581
|
+
private animationSequence? = new Array<RegisteredAnimationInfo>();
|
582
|
+
private animationLoopAfterSequence? = new Array<RegisteredAnimationInfo>();
|
580
583
|
|
581
584
|
createBehaviours(_ext, model, _context) {
|
582
585
|
if (model.uuid === this.gameObject.uuid)
|
@@ -584,61 +587,176 @@
|
|
584
587
|
}
|
585
588
|
|
586
589
|
private static animationActions: ActionModel[] = [];
|
590
|
+
private static rootsWithExclusivePlayback: Set<Object3D> = new Set();
|
587
591
|
|
588
|
-
|
592
|
+
// Cleanup. TODO This is not the best way as it's called multiple times (once for each component).
|
593
|
+
afterSerialize() {
|
594
|
+
if (PlayAnimationOnClick.rootsWithExclusivePlayback.size > 1) {
|
595
|
+
const message = "Multiple root objects targeted by more than one animation. To work around QuickLook bug FB13410767, animations will be set as \"exclusive\" and activating them will stop other animations being marked as exclusive.";
|
596
|
+
if (isDevEnvironment()) showBalloonWarning(message);
|
597
|
+
console.warn(message, ...PlayAnimationOnClick.rootsWithExclusivePlayback);
|
598
|
+
}
|
589
599
|
PlayAnimationOnClick.animationActions = [];
|
600
|
+
PlayAnimationOnClick.rootsWithExclusivePlayback = new Set();
|
590
601
|
}
|
591
602
|
|
592
|
-
afterCreateDocument(ext, context) {
|
593
|
-
if (!this.
|
603
|
+
afterCreateDocument(ext: BehaviorExtension, context: USDZExporterContext) {
|
604
|
+
if (!this.animationSequence || !this.stateAnimationModel) return;
|
605
|
+
if (!this.target) return;
|
606
|
+
|
594
607
|
const document = context.document;
|
608
|
+
|
609
|
+
// check if the AnimationExtension has been attached and what data it has for the current object
|
610
|
+
const animationExt = context.extensions.find(ext => ext instanceof AnimationExtension) as AnimationExtension;
|
611
|
+
if (!animationExt) return;
|
612
|
+
|
613
|
+
// This is a workaround for FB13410767 - StartAnimationAction in USDZ preliminary behaviours does not stop when another StartAnimationAction is called on the same prim
|
614
|
+
// When we play multiple animations on the same root, QuickLook just overlaps them and glitches around instead of stopping an earlier one.
|
615
|
+
// Once this is fixed, we can relax this check and just always make it non-exclusive again.
|
616
|
+
// Setting exclusive playback has the side effect of unfortunately canceling all other playing actions that are exclusive too -
|
617
|
+
// seems there is no finer-grained control over which actions should stop which other actions...
|
618
|
+
const requiresExclusivePlayback = animationExt.getClipCount(this.target) > 1;
|
619
|
+
if (requiresExclusivePlayback) {
|
620
|
+
if (isDevEnvironment())
|
621
|
+
console.warn("Setting exclusive playback for " + this.target.name + "@" + this.stateName + " because it has " + animationExt.getClipCount(this.target) + " animations. This works around QuickLook bug FB13410767.");
|
622
|
+
|
623
|
+
PlayAnimationOnClick.rootsWithExclusivePlayback.add(this.target);
|
624
|
+
}
|
625
|
+
|
626
|
+
const getOrCacheAction = (model: USDObject, anim: RegisteredAnimationInfo) => {
|
627
|
+
let action = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == anim.start && a.duration == anim.duration);
|
628
|
+
if (!action) {
|
629
|
+
action = ActionBuilder.startAnimationAction(model, anim.start, anim.duration) as ActionModel;
|
630
|
+
PlayAnimationOnClick.animationActions.push(action);
|
631
|
+
}
|
632
|
+
return action;
|
633
|
+
}
|
634
|
+
|
595
635
|
document.traverse(model => {
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
startAction = ActionBuilder.startAnimationAction(model, this.stateAnimation.start, this.stateAnimation.duration) as ActionModel;
|
603
|
-
PlayAnimationOnClick.animationActions.push(startAction);
|
636
|
+
if (model.uuid === this.target?.uuid) {
|
637
|
+
const sequence = ActionBuilder.sequence();
|
638
|
+
|
639
|
+
if (this.animationSequence !== undefined)
|
640
|
+
for (const anim of this.animationSequence) {
|
641
|
+
sequence.addAction(getOrCacheAction(model, anim));
|
604
642
|
}
|
605
|
-
sequence.push(startAction);
|
606
643
|
|
607
|
-
if (this.
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
644
|
+
if (this.animationLoopAfterSequence !== undefined) {
|
645
|
+
// only make a new action group if there's already stuff in the existing one
|
646
|
+
const loopSequence = sequence.actions.length == 0 ? sequence : ActionBuilder.sequence();
|
647
|
+
for (const anim of this.animationLoopAfterSequence) {
|
648
|
+
loopSequence.addAction(getOrCacheAction(model, anim));
|
612
649
|
}
|
613
|
-
|
614
|
-
if (
|
615
|
-
|
616
|
-
sequence.push(idleAnim);
|
650
|
+
loopSequence.makeLooping();
|
651
|
+
if (sequence !== loopSequence)
|
652
|
+
sequence.addAction(loopSequence);
|
617
653
|
}
|
654
|
+
|
618
655
|
const playAnimationOnTap = new BehaviorModel("tap " + this.name + " for " + this.stateName + " on " + this.target?.name,
|
619
|
-
TriggerBuilder.tapTrigger(this.selfModel),
|
620
|
-
|
656
|
+
this.trigger == "tap" ? TriggerBuilder.tapTrigger(this.selfModel) : TriggerBuilder.sceneStartTrigger(),
|
657
|
+
sequence
|
621
658
|
);
|
659
|
+
|
660
|
+
// See comment above for why exclusive playback is currently required when playing multiple animations on the same root.
|
661
|
+
if (requiresExclusivePlayback)
|
662
|
+
playAnimationOnTap.makeExclusive(true);
|
622
663
|
ext.addBehavior(playAnimationOnTap);
|
623
664
|
}
|
624
665
|
});
|
625
666
|
}
|
626
667
|
|
627
668
|
createAnimation(ext, model, _context) {
|
628
|
-
if (this.target
|
669
|
+
if (!this.target || !this.animator) return;
|
629
670
|
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
671
|
+
// If there's a separate state specified to play after this one, we
|
672
|
+
// play it automatically. Theoretically an animator state machine flow could be encoded here.
|
673
|
+
|
674
|
+
// We're parsing the Animator states here and follow the transition chain until we find a loop.
|
675
|
+
// There are some edge cases:
|
676
|
+
// - (0 > 1.looping) should keep looping (1).
|
677
|
+
// - (0 > 1 > 1) should keep looping (1).
|
678
|
+
// - (0 > 1 > 2 > 3 > 2) should keep looping (2,3).
|
679
|
+
// - (0 > 1 > 2 > 3 > 0) should keep looping (0,1,2,3).
|
680
|
+
const runtimeController = this.animator?.runtimeAnimatorController;
|
681
|
+
let currentState = runtimeController?.findState(this.stateName);
|
682
|
+
let statesUntilLoop: State[] = [];
|
683
|
+
let statesLooping: State[] = [];
|
684
|
+
|
685
|
+
if (runtimeController && currentState) {
|
686
|
+
// starting point – we have set this above already as startAction
|
687
|
+
const visitedStates = new Array<State>;
|
688
|
+
visitedStates.push(currentState);
|
689
|
+
let foundLoop = false;
|
690
|
+
|
691
|
+
while (true && visitedStates.length < 100) {
|
692
|
+
if (!currentState || currentState === null || !currentState.transitions || currentState.transitions.length === 0) {
|
693
|
+
if (currentState.motion?.isLooping)
|
694
|
+
foundLoop = true;
|
695
|
+
break;
|
696
|
+
}
|
697
|
+
|
698
|
+
// find the first transition without parameters
|
699
|
+
// TODO we could also find the first _valid_ transition here instead based on the current parameters.
|
700
|
+
const transition = currentState.transitions.find(t => t.conditions.length === 0);
|
701
|
+
const nextState = transition ? runtimeController["getState"](transition.destinationState, 0) : null;
|
702
|
+
// abort: we found a state loop
|
703
|
+
if (nextState && visitedStates.includes(nextState)) {
|
704
|
+
currentState = nextState;
|
705
|
+
foundLoop = true;
|
706
|
+
break;
|
707
|
+
}
|
708
|
+
// keep looking: transition to another state
|
709
|
+
else if (transition) {
|
710
|
+
currentState = nextState;
|
711
|
+
if (!currentState)
|
712
|
+
break;
|
713
|
+
visitedStates.push(currentState);
|
714
|
+
}
|
715
|
+
// abort: no transition found. check if last state is looping
|
716
|
+
else {
|
717
|
+
foundLoop = currentState.motion?.isLooping ?? false;
|
718
|
+
break;
|
719
|
+
}
|
634
720
|
}
|
635
|
-
this.stateAnimationModel = model;
|
636
|
-
this.stateAnimation = ext.registerAnimation(this.target, state?.motion.clip);
|
637
721
|
|
638
|
-
|
639
|
-
|
640
|
-
|
722
|
+
if (foundLoop && currentState) {
|
723
|
+
// check what the first state in the loop is – it must be matching the last one we added
|
724
|
+
const firstStateInLoop = visitedStates.indexOf(currentState);
|
725
|
+
statesUntilLoop = visitedStates.slice(0, firstStateInLoop); // can be empty, which means we're looping all
|
726
|
+
statesLooping = visitedStates.slice(firstStateInLoop); // can be empty, which means nothing is looping
|
727
|
+
console.log("found loop from " + this.stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
|
728
|
+
}
|
729
|
+
else {
|
730
|
+
statesUntilLoop = visitedStates;
|
731
|
+
statesLooping = [];
|
732
|
+
console.log("found no loop from " + this.stateName, "states", statesUntilLoop);
|
733
|
+
}
|
641
734
|
}
|
735
|
+
|
736
|
+
// filter out any states that don't have motion data
|
737
|
+
statesUntilLoop = statesUntilLoop.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
|
738
|
+
statesLooping = statesLooping.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
|
739
|
+
|
740
|
+
// If none of the found states have motion, we need to warn
|
741
|
+
if (statesUntilLoop.length === 0 && statesLooping.length === 0) {
|
742
|
+
console.warn("No clips found for state " + this.stateName + " on " + this.animator?.name + ", can't export animation data");
|
743
|
+
return;
|
744
|
+
}
|
745
|
+
this.stateAnimationModel = model;
|
746
|
+
|
747
|
+
// Register all the animation states we found.
|
748
|
+
if (statesUntilLoop.length > 0) {
|
749
|
+
this.animationSequence = new Array<RegisteredAnimationInfo>();
|
750
|
+
for (const state of statesUntilLoop) {
|
751
|
+
this.animationSequence.push(ext.registerAnimation(this.target, state.motion.clip));
|
752
|
+
}
|
753
|
+
}
|
754
|
+
if (statesLooping.length > 0) {
|
755
|
+
this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
|
756
|
+
for (const state of statesLooping) {
|
757
|
+
this.animationLoopAfterSequence.push(ext.registerAnimation(this.target, state.motion.clip));
|
758
|
+
}
|
759
|
+
}
|
642
760
|
}
|
643
761
|
|
644
762
|
}
|
@@ -210,7 +210,8 @@
|
|
210
210
|
writer.beginArray("rel actions");
|
211
211
|
for (const act of this.actions) {
|
212
212
|
if (!act) continue;
|
213
|
-
|
213
|
+
const isLast = act === this.actions[this.actions.length - 1];
|
214
|
+
writer.appendLine("<" + act.id + ">" + (isLast ? "" : ", "));
|
214
215
|
}
|
215
216
|
writer.closeArray();
|
216
217
|
writer.appendLine();
|
@@ -433,13 +434,14 @@
|
|
433
434
|
static startAnimationAction(targetObject: Target, start: number, duration: number = 0, animationSpeed: number = 1, reversed: boolean = false, pingPong: boolean = false): IBehaviorElement {
|
434
435
|
const act = new ActionModel(targetObject);
|
435
436
|
act.tokenId = "StartAnimation";
|
437
|
+
// start is time in seconds, the documentation is not right here
|
436
438
|
act.start = start;
|
437
|
-
//
|
439
|
+
// duration of 0 is play to end
|
438
440
|
act.duration = duration;
|
439
|
-
// duration of 0 is play to end
|
440
441
|
act.animationSpeed = animationSpeed;
|
441
442
|
act.reversed = reversed;
|
442
443
|
act.pingPong = pingPong;
|
444
|
+
act.multiplePerformOperation = MultiplePerformOperation.Allow;
|
443
445
|
if (reversed) {
|
444
446
|
act.start -= duration;
|
445
447
|
//console.warn("Reversed animation does currently not work. The resulting file will most likely not playback.", act.id, targetObject);
|
@@ -455,7 +457,7 @@
|
|
455
457
|
const group = ActionBuilder.sequence(act, back);
|
456
458
|
return group;
|
457
459
|
}
|
458
|
-
if (debug) console.log("Start Animation Action", act);
|
460
|
+
// if (debug) console.log("Start Animation Action", act);
|
459
461
|
return act;
|
460
462
|
}
|
461
463
|
|
@@ -656,14 +656,17 @@
|
|
656
656
|
}
|
657
657
|
|
658
658
|
private static _forward: Vector3 = new Vector3();
|
659
|
+
/** Forward (0,0,-1) vector in world space */
|
659
660
|
public get forward(): Vector3 {
|
660
661
|
return Component._forward.set(0, 0, -1).applyQuaternion(this.worldQuaternion);
|
661
662
|
}
|
662
663
|
private static _right: Vector3 = new Vector3();
|
664
|
+
/** Right (1,0,0) vector in world space */
|
663
665
|
public get right(): Vector3 {
|
664
666
|
return Component._right.set(1, 0, 0).applyQuaternion(this.worldQuaternion);
|
665
667
|
}
|
666
668
|
private static _up: Vector3 = new Vector3();
|
669
|
+
/** Up (0,1,0) vector in world space */
|
667
670
|
public get up(): Vector3 {
|
668
671
|
return Component._up.set(0, 1, 0).applyQuaternion(this.worldQuaternion);
|
669
672
|
}
|
@@ -4,6 +4,7 @@
|
|
4
4
|
import type { Vec3, Vec4 } from './engine_types.js';
|
5
5
|
import ThreeMeshUI, { Inline, Text } from "three-mesh-ui"
|
6
6
|
import { getParam } from './engine_utils.js';
|
7
|
+
import { type Options } from 'three-mesh-ui/build/types/core/elements/MeshUIBaseElement.js';
|
7
8
|
|
8
9
|
const _tmp = new Vector3();
|
9
10
|
const _tmp2 = new Vector3();
|
@@ -176,20 +177,22 @@
|
|
176
177
|
opacity = backgroundColor["a"]
|
177
178
|
}
|
178
179
|
|
180
|
+
const props: Options = {
|
181
|
+
boxSizing: 'border-box',
|
182
|
+
fontFamily: this.familyName,
|
183
|
+
width: "auto",
|
184
|
+
fontSize: size,
|
185
|
+
color: color,
|
186
|
+
lineHeight: .75,
|
187
|
+
backgroundColor: backgroundColor ?? undefined,
|
188
|
+
backgroundOpacity: opacity,
|
189
|
+
textContent: text,
|
190
|
+
borderRadius: 1 * size,
|
191
|
+
padding: 1 * size,
|
192
|
+
};
|
193
|
+
|
179
194
|
if (!element) {
|
180
|
-
element = new Text(
|
181
|
-
boxSizing: 'border-box',
|
182
|
-
fontFamily: this.familyName,
|
183
|
-
width: "auto",
|
184
|
-
fontSize: size,
|
185
|
-
color: color,
|
186
|
-
lineHeight: .75,
|
187
|
-
backgroundColor: backgroundColor ?? undefined,
|
188
|
-
backgroundOpacity: opacity,
|
189
|
-
textContent: text,
|
190
|
-
borderRadius: 1 * size,
|
191
|
-
padding: 1 * size,
|
192
|
-
});
|
195
|
+
element = new Text(props);
|
193
196
|
const global = this;
|
194
197
|
const labelHandle = element as LabelHandle & Text;
|
195
198
|
labelHandle.setText = function (str: string) {
|
@@ -198,14 +201,7 @@
|
|
198
201
|
};
|
199
202
|
}
|
200
203
|
else {
|
201
|
-
element.set(
|
202
|
-
color: color,
|
203
|
-
fontSize: size,
|
204
|
-
backgroundColor: backgroundColor ?? undefined,
|
205
|
-
backgroundOpacity: opacity,
|
206
|
-
textContent: text,
|
207
|
-
whiteSpace: 'pre',
|
208
|
-
});
|
204
|
+
element.set(props);
|
209
205
|
// const handle = element as any as LabelHandle;
|
210
206
|
// handle.setText(text);
|
211
207
|
}
|
@@ -1,5 +1,5 @@
|
|
1
1
|
import { TypeStore } from "./../engine_typestore.js"
|
2
|
-
|
2
|
+
|
3
3
|
// Import types
|
4
4
|
import { __Ignore } from "../../engine-components/codegen/components.js";
|
5
5
|
import { ActionBuilder } from "../../engine-components/export/usdz/extensions/behavior/BehavioursBuilder.js";
|
@@ -219,7 +219,7 @@
|
|
219
219
|
import { XRGrabRendering } from "../../engine-components/webxr/WebXRGrabRendering.js";
|
220
220
|
import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
|
221
221
|
import { XRState } from "../../engine-components/XRFlag.js";
|
222
|
-
|
222
|
+
|
223
223
|
// Register types
|
224
224
|
TypeStore.add("__Ignore", __Ignore);
|
225
225
|
TypeStore.add("ActionBuilder", ActionBuilder);
|
@@ -468,7 +468,7 @@
|
|
468
468
|
}
|
469
469
|
|
470
470
|
private tryGetSceneEventListener(obj: Object3D, level: number = 0): ISceneEventListener | null {
|
471
|
-
const sceneListener = GameObject.foreachComponent(obj, c => (c as any as ISceneEventListener).sceneClosing ? c :
|
471
|
+
const sceneListener = GameObject.foreachComponent(obj, c => (c as any as ISceneEventListener).sceneClosing ? c : undefined) as ISceneEventListener | undefined;
|
472
472
|
// if we didnt find any component with the listener on the root object
|
473
473
|
// we also check the first level of its children because a scene might be a group
|
474
474
|
if (level === 0 && !sceneListener && obj.children.length) {
|
@@ -477,6 +477,7 @@
|
|
477
477
|
if (res) return res;
|
478
478
|
}
|
479
479
|
}
|
480
|
+
if (!sceneListener) return null;
|
480
481
|
return sceneListener;
|
481
482
|
}
|
482
483
|
}
|
@@ -11,19 +11,20 @@
|
|
11
11
|
WebGLRenderer,
|
12
12
|
MathUtils,
|
13
13
|
Matrix4,
|
14
|
-
RepeatWrapping,
|
15
|
-
MirroredRepeatWrapping,
|
16
14
|
DoubleSide,
|
17
15
|
BufferGeometry,
|
18
16
|
Material,
|
19
|
-
Camera,
|
20
17
|
Color,
|
21
18
|
MeshStandardMaterial,
|
22
19
|
MeshPhysicalMaterial,
|
23
20
|
Object3D,
|
24
21
|
MeshBasicMaterial,
|
22
|
+
Bone,
|
25
23
|
SkinnedMesh,
|
26
24
|
SRGBColorSpace,
|
25
|
+
AnimationClip,
|
26
|
+
OrthographicCamera,
|
27
|
+
BufferAttribute
|
27
28
|
} from 'three';
|
28
29
|
import * as fflate from 'three/examples/jsm/libs/fflate.module.js';
|
29
30
|
|
@@ -37,6 +38,45 @@
|
|
37
38
|
return str;
|
38
39
|
}
|
39
40
|
|
41
|
+
// TODO check if this works when bones in the skeleton are completely unordered
|
42
|
+
function findCommonAncestor(objects: Object3D[]): Object3D | null {
|
43
|
+
if (objects.length === 0) return null;
|
44
|
+
|
45
|
+
const ancestors = objects.map((obj) => {
|
46
|
+
const objAncestors = new Array<Object3D>();
|
47
|
+
while (obj.parent) {
|
48
|
+
objAncestors.unshift(obj.parent);
|
49
|
+
obj = obj.parent;
|
50
|
+
}
|
51
|
+
return objAncestors;
|
52
|
+
});
|
53
|
+
|
54
|
+
//@ts-ignore – findLast seems to be missing in TypeScript types pre-5.x
|
55
|
+
const commonAncestor = ancestors[0].findLast((ancestor) => {
|
56
|
+
return ancestors.every((a) => a.includes(ancestor));
|
57
|
+
});
|
58
|
+
|
59
|
+
return commonAncestor || null;
|
60
|
+
}
|
61
|
+
|
62
|
+
function findStructuralNodesInBoneHierarchy(bones: Array<Object3D>) {
|
63
|
+
|
64
|
+
const commonAncestor = findCommonAncestor(bones);
|
65
|
+
// find all structural nodes – parents of bones that are not bones themselves
|
66
|
+
const structuralNodes = new Set<Object3D>();
|
67
|
+
for ( const bone of bones ) {
|
68
|
+
let current = bone.parent;
|
69
|
+
while ( current && current !== commonAncestor ) {
|
70
|
+
if ( !bones.includes(current) ) {
|
71
|
+
structuralNodes.add(current);
|
72
|
+
}
|
73
|
+
current = current.parent;
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
return structuralNodes;
|
78
|
+
}
|
79
|
+
|
40
80
|
// TODO: remove once we update to TypeScript 5 that has proper types for OffscreenCanvas
|
41
81
|
declare type OffsetCanvasExt = OffscreenCanvas & {
|
42
82
|
convertToBlob: (options?: any) => Promise<Blob>;
|
@@ -54,10 +94,12 @@
|
|
54
94
|
get isDynamic() { return this._isDynamic; }
|
55
95
|
private set isDynamic( value ) { this._isDynamic = value; }
|
56
96
|
geometry: BufferGeometry | null;
|
57
|
-
material: Material | null;
|
58
|
-
camera:
|
97
|
+
material: MeshStandardMaterial | MeshBasicMaterial | Material | null;
|
98
|
+
camera: PerspectiveCamera | OrthographicCamera | null;
|
59
99
|
parent: USDObject | null;
|
100
|
+
skinnedMesh: SkinnedMesh | null;
|
60
101
|
children: Array<USDObject | null> = [];
|
102
|
+
animations: AnimationClip[] | null;
|
61
103
|
_eventListeners: {};
|
62
104
|
|
63
105
|
static createEmptyParent( object ) {
|
@@ -79,7 +121,7 @@
|
|
79
121
|
return empty;
|
80
122
|
}
|
81
123
|
|
82
|
-
constructor( id, name, matrix, mesh: BufferGeometry | null = null, material: Material | null = null, camera:
|
124
|
+
constructor( id, name, matrix, mesh: BufferGeometry | null = null, material: MeshStandardMaterial | MeshBasicMaterial | Material | null = null, camera: PerspectiveCamera | OrthographicCamera | null = null, skinnedMesh: SkinnedMesh | null = null, animations: AnimationClip[] | null = null ) {
|
83
125
|
|
84
126
|
this.uuid = id;
|
85
127
|
this.name = makeNameSafe( name );
|
@@ -91,6 +133,8 @@
|
|
91
133
|
this.children = [];
|
92
134
|
this._eventListeners = {};
|
93
135
|
this._isDynamic = false;
|
136
|
+
this.skinnedMesh = skinnedMesh;
|
137
|
+
this.animations = animations;
|
94
138
|
|
95
139
|
}
|
96
140
|
|
@@ -230,7 +274,7 @@
|
|
230
274
|
|
231
275
|
}
|
232
276
|
|
233
|
-
traverse( callback, current: USDObject | null = null ) {
|
277
|
+
traverse( callback: ( object: USDObject ) => void, current: USDObject | null = null ) {
|
234
278
|
|
235
279
|
if ( current !== null ) callback( current );
|
236
280
|
else current = this;
|
@@ -246,7 +290,7 @@
|
|
246
290
|
|
247
291
|
}
|
248
292
|
|
249
|
-
findById( uuid ) {
|
293
|
+
findById( uuid: string ) {
|
250
294
|
|
251
295
|
let found = false;
|
252
296
|
function search( current ) {
|
@@ -277,7 +321,7 @@
|
|
277
321
|
}
|
278
322
|
|
279
323
|
|
280
|
-
buildHeader() {
|
324
|
+
buildHeader( endTimeCode ) {
|
281
325
|
|
282
326
|
return `#usda 1.0
|
283
327
|
(
|
@@ -288,7 +332,7 @@
|
|
288
332
|
metersPerUnit = 1
|
289
333
|
upAxis = "Y"
|
290
334
|
startTimeCode = 0
|
291
|
-
endTimeCode = ${
|
335
|
+
endTimeCode = ${endTimeCode}
|
292
336
|
timeCodesPerSecond = 60
|
293
337
|
framesPerSecond = 60
|
294
338
|
)
|
@@ -319,21 +363,32 @@
|
|
319
363
|
|
320
364
|
}
|
321
365
|
|
322
|
-
beginBlock( str ) {
|
366
|
+
beginBlock( str: string | undefined = undefined, char = '{', createNewLine = true ) {
|
323
367
|
|
324
|
-
|
325
|
-
|
368
|
+
if ( str !== undefined ) {
|
369
|
+
str = this.applyIndent( str );
|
370
|
+
this.str += str;
|
371
|
+
if ( createNewLine ) {
|
372
|
+
this.str += newLine;
|
373
|
+
this.str += this.applyIndent( char );
|
374
|
+
}
|
375
|
+
else {
|
376
|
+
this.str += " " + char;
|
377
|
+
}
|
378
|
+
}
|
379
|
+
else {
|
380
|
+
this.str += this.applyIndent( char );
|
381
|
+
}
|
382
|
+
|
326
383
|
this.str += newLine;
|
327
|
-
this.str += this.applyIndent( '{' );
|
328
|
-
this.str += newLine;
|
329
384
|
this.indent += 1;
|
330
385
|
|
331
386
|
}
|
332
387
|
|
333
|
-
closeBlock() {
|
388
|
+
closeBlock( char = '}' ) {
|
334
389
|
|
335
390
|
this.indent -= 1;
|
336
|
-
this.str += this.applyIndent(
|
391
|
+
this.str += this.applyIndent( char ) + newLine;
|
337
392
|
|
338
393
|
}
|
339
394
|
|
@@ -386,6 +441,7 @@
|
|
386
441
|
files: {};
|
387
442
|
document: USDDocument;
|
388
443
|
output: string;
|
444
|
+
animations: AnimationClip[];
|
389
445
|
|
390
446
|
constructor( root, exporter, extensions ) {
|
391
447
|
|
@@ -400,6 +456,7 @@
|
|
400
456
|
this.files = {};
|
401
457
|
this.document = new USDDocument();
|
402
458
|
this.output = '';
|
459
|
+
this.animations = [];
|
403
460
|
|
404
461
|
}
|
405
462
|
|
@@ -420,6 +477,7 @@
|
|
420
477
|
};
|
421
478
|
quickLookCompatible: boolean = false;
|
422
479
|
extensions: any[] = [];
|
480
|
+
maxTextureSize: number = 4096;
|
423
481
|
}
|
424
482
|
|
425
483
|
class USDZExporter {
|
@@ -433,6 +491,20 @@
|
|
433
491
|
|
434
492
|
}
|
435
493
|
|
494
|
+
getEndTimeCode( animations ) {
|
495
|
+
let endTimeCode = 0;
|
496
|
+
|
497
|
+
for( const animation of animations ) {
|
498
|
+
const currentEndTimeCode = animation.duration * 60;
|
499
|
+
|
500
|
+
if ( endTimeCode < currentEndTimeCode ) {
|
501
|
+
endTimeCode = currentEndTimeCode;
|
502
|
+
}
|
503
|
+
}
|
504
|
+
|
505
|
+
return endTimeCode;
|
506
|
+
}
|
507
|
+
|
436
508
|
async parse( scene, options: USDZExporterOptions = new USDZExporterOptions() ) {
|
437
509
|
|
438
510
|
options = Object.assign( new USDZExporterOptions(), options );
|
@@ -453,6 +525,26 @@
|
|
453
525
|
|
454
526
|
await invokeAll( context, 'onBeforeBuildDocument' );
|
455
527
|
|
528
|
+
// HACK let's find all skeletons and reparent them to their skelroot / armature / uppermost bone parent
|
529
|
+
const reparentings: Array<any> = [];
|
530
|
+
scene.traverseVisible(object => {
|
531
|
+
|
532
|
+
if (object.isSkinnedMesh) {
|
533
|
+
const bones = object.skeleton.bones as Bone[];
|
534
|
+
|
535
|
+
const commonAncestor = findCommonAncestor(bones);
|
536
|
+
if (commonAncestor) {
|
537
|
+
reparentings.push( { object, originalParent: object.parent, newParent: commonAncestor } );
|
538
|
+
}
|
539
|
+
}
|
540
|
+
});
|
541
|
+
|
542
|
+
for ( const reparenting of reparentings ) {
|
543
|
+
const { object, originalParent, newParent } = reparenting;
|
544
|
+
if (this.debug) console.log("REPARENTING", object, "from", originalParent, "to", newParent);
|
545
|
+
newParent.add( object );
|
546
|
+
}
|
547
|
+
|
456
548
|
traverseVisible( scene, context.document, context );
|
457
549
|
|
458
550
|
await invokeAll( context, 'onAfterBuildDocument' );
|
@@ -465,10 +557,18 @@
|
|
465
557
|
|
466
558
|
await invokeAll( context, 'onAfterSerialize' );
|
467
559
|
|
560
|
+
// repair the parenting again
|
561
|
+
for ( const reparenting of reparentings ) {
|
562
|
+
const { object, originalParent, newParent } = reparenting;
|
563
|
+
originalParent.add( object );
|
564
|
+
}
|
565
|
+
|
468
566
|
// Moved into parseDocument callback for proper defaultPrim encapsulation
|
469
567
|
// context.output += buildMaterials( materials, textures, options.quickLookCompatible );
|
470
568
|
|
471
|
-
const
|
569
|
+
const endTimeCode = this.getEndTimeCode( context.animations );
|
570
|
+
|
571
|
+
const header = context.document.buildHeader( endTimeCode );
|
472
572
|
const final = header + '\n' + context.output;
|
473
573
|
|
474
574
|
// full output file
|
@@ -486,14 +586,16 @@
|
|
486
586
|
|
487
587
|
const isRGBA = formatsWithAlphaChannel.includes( texture.format );
|
488
588
|
|
489
|
-
if ( texture.isCompressedTexture ) {
|
589
|
+
if ( texture.isCompressedTexture || texture.isRenderTargetTexture ) {
|
490
590
|
|
491
|
-
texture = decompressGpuTexture( texture,
|
591
|
+
texture = decompressGpuTexture( texture, options.maxTextureSize, decompressionRenderer );
|
492
592
|
|
493
593
|
}
|
494
594
|
|
495
595
|
// TODO add readback options for textures that don't have texture.image
|
496
|
-
const canvas = await imageToCanvas( texture.image )
|
596
|
+
const canvas = await imageToCanvas( texture.image ).catch( err => {
|
597
|
+
console.error("Error converting texture to canvas", texture, err);
|
598
|
+
});
|
497
599
|
|
498
600
|
if ( canvas ) {
|
499
601
|
|
@@ -551,7 +653,7 @@
|
|
551
653
|
let geometry: BufferGeometry | undefined = undefined;
|
552
654
|
let material: Material | Material[] | undefined = undefined;
|
553
655
|
|
554
|
-
if (object instanceof Mesh) {
|
656
|
+
if (object instanceof Mesh || object instanceof SkinnedMesh) {
|
555
657
|
geometry = object.geometry;
|
556
658
|
material = object.material;
|
557
659
|
}
|
@@ -564,12 +666,13 @@
|
|
564
666
|
material = undefined;
|
565
667
|
}
|
566
668
|
|
567
|
-
if ( object instanceof Mesh && material && (material instanceof MeshStandardMaterial || material instanceof MeshBasicMaterial)
|
669
|
+
if ( (object instanceof Mesh || object instanceof SkinnedMesh) && material && (material instanceof MeshStandardMaterial || material instanceof MeshBasicMaterial)) {
|
568
670
|
|
569
671
|
const name = getObjectId( object );
|
570
|
-
|
672
|
+
const skinnedMeshObject = object instanceof SkinnedMesh ? object : null;
|
673
|
+
model = new USDObject( object.uuid, name, object.matrix, geometry, material, undefined, skinnedMeshObject, object.animations );
|
571
674
|
|
572
|
-
} else if ( object instanceof
|
675
|
+
} else if ( object instanceof PerspectiveCamera || object instanceof OrthographicCamera ) {
|
573
676
|
|
574
677
|
const name = getObjectId( object );
|
575
678
|
model = new USDObject( object.uuid, name, object.matrix, undefined, undefined, object );
|
@@ -577,7 +680,7 @@
|
|
577
680
|
} else {
|
578
681
|
|
579
682
|
const name = getObjectId( object );
|
580
|
-
model = new USDObject( object.uuid, name, object.matrix );
|
683
|
+
model = new USDObject( object.uuid, name, object.matrix, undefined, undefined, undefined, undefined, object.animations );
|
581
684
|
|
582
685
|
}
|
583
686
|
|
@@ -673,20 +776,23 @@
|
|
673
776
|
|
674
777
|
}
|
675
778
|
|
676
|
-
function addResources( object, context: USDZExporterContext ) {
|
779
|
+
function addResources( object: USDObject | null, context: USDZExporterContext ) {
|
677
780
|
|
781
|
+
if ( object == null ) {
|
782
|
+
return;
|
783
|
+
}
|
678
784
|
const geometry = object.geometry;
|
679
785
|
const material = object.material;
|
680
786
|
|
681
787
|
if ( geometry ) {
|
682
788
|
|
683
|
-
if ( material.isMeshStandardMaterial || material.isMeshBasicMaterial ) { // TODO convert unlit to lit+emissive
|
789
|
+
if ( material && ( 'isMeshStandardMaterial' in material && material.isMeshStandardMaterial || 'isMeshBasicMaterial' in material && material.isMeshBasicMaterial ) ) { // TODO convert unlit to lit+emissive
|
684
790
|
|
685
791
|
const geometryFileName = 'geometries/Geometry_' + geometry.id + '.usd';
|
686
792
|
|
687
793
|
if ( ! ( geometryFileName in context.files ) ) {
|
688
794
|
|
689
|
-
const meshObject = buildMeshObject( geometry );
|
795
|
+
const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones );
|
690
796
|
context.files[ geometryFileName ] = buildUSDFileAsString( meshObject, context );
|
691
797
|
|
692
798
|
}
|
@@ -824,12 +930,12 @@
|
|
824
930
|
|
825
931
|
}
|
826
932
|
|
827
|
-
async function imageToCanvas( image, color: string | undefined = undefined, flipY = false ) {
|
933
|
+
async function imageToCanvas( image, color: string | undefined = undefined, flipY = false, maxTextureSize = 4096 ) {
|
828
934
|
|
829
935
|
if ( isImageBitmap( image ) ) {
|
830
936
|
|
831
937
|
// max. canvas size on Safari is still 4096x4096
|
832
|
-
const scale =
|
938
|
+
const scale = maxTextureSize / Math.max( image.width, image.height );
|
833
939
|
|
834
940
|
const canvas = new OffscreenCanvas( image.width * Math.min( 1, scale ), image.height * Math.min( 1, scale ) );
|
835
941
|
|
@@ -911,21 +1017,37 @@
|
|
911
1017
|
|
912
1018
|
}
|
913
1019
|
|
1020
|
+
function getBoneName(bone) {
|
1021
|
+
return makeNameSafe(bone.name || 'bone_' + bone.uuid);
|
1022
|
+
}
|
1023
|
+
|
1024
|
+
function getPathToSkeleton(bone: Object3D, assumedRoot: Object3D) {
|
1025
|
+
let path = getBoneName(bone);
|
1026
|
+
let current = bone.parent;
|
1027
|
+
while ( current && current !== assumedRoot ) {
|
1028
|
+
path = getBoneName(current) + '/' + path;
|
1029
|
+
current = current.parent;
|
1030
|
+
}
|
1031
|
+
return path;
|
1032
|
+
}
|
1033
|
+
|
914
1034
|
// Xform
|
915
1035
|
|
916
|
-
export function buildXform( model, writer, context ) {
|
1036
|
+
export function buildXform( model: USDObject | null, writer, context ) {
|
917
1037
|
|
1038
|
+
if ( model == null)
|
1039
|
+
return;
|
1040
|
+
|
918
1041
|
const matrix = model.matrix;
|
919
1042
|
const geometry = model.geometry;
|
920
1043
|
const material = model.material;
|
921
1044
|
const camera = model.camera;
|
922
1045
|
const name = model.name;
|
923
1046
|
|
924
|
-
|
925
|
-
|
926
|
-
|
927
|
-
|
928
|
-
|
1047
|
+
if ( model.animations ) {
|
1048
|
+
for ( const animation of model.animations ) {
|
1049
|
+
context.animations.push( animation )
|
1050
|
+
}
|
929
1051
|
}
|
930
1052
|
|
931
1053
|
const transform = buildMatrix( matrix );
|
@@ -936,11 +1058,16 @@
|
|
936
1058
|
|
937
1059
|
}
|
938
1060
|
|
1061
|
+
const isSkinnedMesh = geometry && geometry.isBufferGeometry && geometry.attributes.skinIndex !== undefined && geometry.attributes.skinIndex.count > 0;
|
1062
|
+
const objType = isSkinnedMesh ? 'SkelRoot' : 'Xform';
|
1063
|
+
const apiSchemas = isSkinnedMesh ? '"MaterialBindingAPI", "SkelBindingAPI"' : '"MaterialBindingAPI"';
|
1064
|
+
|
939
1065
|
if ( geometry ) {
|
940
|
-
writer.beginBlock( `def
|
941
|
-
prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry
|
942
|
-
|
943
|
-
)
|
1066
|
+
writer.beginBlock( `def ${objType} "${name}"`, "(", false );
|
1067
|
+
writer.appendLine(`prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry>`);
|
1068
|
+
writer.appendLine(`prepend apiSchemas = [${apiSchemas}]`);
|
1069
|
+
writer.closeBlock( ")" );
|
1070
|
+
writer.beginBlock();
|
944
1071
|
}
|
945
1072
|
else if ( camera )
|
946
1073
|
writer.beginBlock( `def Camera "${name}"` );
|
@@ -948,20 +1075,29 @@
|
|
948
1075
|
writer.beginBlock( `def Xform "${name}"` );
|
949
1076
|
|
950
1077
|
if ( geometry && material )
|
951
|
-
writer.appendLine( `rel material:binding =
|
952
|
-
|
1078
|
+
writer.appendLine( `rel material:binding = </StageRoot/Materials/Material_${material.id}>` );
|
1079
|
+
|
1080
|
+
if ( isSkinnedMesh ) {
|
1081
|
+
writer.appendLine( `rel skel:skeleton = <Rig>` );
|
1082
|
+
writer.appendLine( `rel skel:animationSource = <Rig/_anim>`);
|
1083
|
+
// writer.appendLine( `matrix4d xformOp:transform = ${transform}` );
|
1084
|
+
writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix(new Matrix4())}` ); // seems to always be identity / in world space
|
1085
|
+
}
|
1086
|
+
else {
|
1087
|
+
writer.appendLine( `matrix4d xformOp:transform = ${transform}` );
|
1088
|
+
}
|
953
1089
|
writer.appendLine( 'uniform token[] xformOpOrder = ["xformOp:transform"]' );
|
954
1090
|
|
955
1091
|
if ( camera ) {
|
956
1092
|
|
957
|
-
if ( camera.isOrthographicCamera ) {
|
1093
|
+
if ( 'isOrthographicCamera' in camera && camera.isOrthographicCamera ) {
|
958
1094
|
|
959
1095
|
writer.appendLine( `float2 clippingRange = (${camera.near}, ${camera.far})` );
|
960
1096
|
writer.appendLine( `float horizontalAperture = ${( ( Math.abs( camera.left ) + Math.abs( camera.right ) ) * 10 ).toPrecision( PRECISION )}` );
|
961
1097
|
writer.appendLine( `float verticalAperture = ${( ( Math.abs( camera.top ) + Math.abs( camera.bottom ) ) * 10 ).toPrecision( PRECISION )}` );
|
962
1098
|
writer.appendLine( 'token projection = "orthographic"' );
|
963
1099
|
|
964
|
-
} else {
|
1100
|
+
} else if ( 'isPerspectiveCamera' in camera && camera.isPerspectiveCamera) {
|
965
1101
|
|
966
1102
|
writer.appendLine( `float2 clippingRange = (${camera.near.toPrecision( PRECISION )}, ${camera.far.toPrecision( PRECISION )})` );
|
967
1103
|
writer.appendLine( `float focalLength = ${camera.getFocalLength().toPrecision( PRECISION )}` );
|
@@ -969,7 +1105,6 @@
|
|
969
1105
|
writer.appendLine( `float horizontalAperture = ${camera.getFilmWidth().toPrecision( PRECISION )}` );
|
970
1106
|
writer.appendLine( 'token projection = "perspective"' );
|
971
1107
|
writer.appendLine( `float verticalAperture = ${camera.getFilmHeight().toPrecision( PRECISION )}` );
|
972
|
-
|
973
1108
|
}
|
974
1109
|
|
975
1110
|
}
|
@@ -995,9 +1130,9 @@
|
|
995
1130
|
|
996
1131
|
}
|
997
1132
|
|
998
|
-
function fn( num ) {
|
1133
|
+
function fn( num:number ): string {
|
999
1134
|
|
1000
|
-
return num.toFixed( 10 );
|
1135
|
+
return Number.isInteger(num) ? num.toString() : num.toFixed( 10 );
|
1001
1136
|
|
1002
1137
|
}
|
1003
1138
|
|
@@ -1017,9 +1152,9 @@
|
|
1017
1152
|
|
1018
1153
|
// Mesh
|
1019
1154
|
|
1020
|
-
function buildMeshObject( geometry ) {
|
1155
|
+
function buildMeshObject( geometry, bonesArray: Bone[] = [] ) {
|
1021
1156
|
|
1022
|
-
const mesh = buildMesh( geometry );
|
1157
|
+
const mesh = buildMesh( geometry, bonesArray );
|
1023
1158
|
return `
|
1024
1159
|
def "Geometry"
|
1025
1160
|
{
|
@@ -1029,14 +1164,69 @@
|
|
1029
1164
|
|
1030
1165
|
}
|
1031
1166
|
|
1032
|
-
function buildMesh( geometry ) {
|
1167
|
+
function buildMesh( geometry, bones: Bone[] = [] ) {
|
1033
1168
|
|
1034
1169
|
const name = 'Geometry';
|
1035
1170
|
const attributes = geometry.attributes;
|
1036
1171
|
const count = attributes.position.count;
|
1037
1172
|
|
1173
|
+
const hasBones = bones && bones.length > 0;
|
1174
|
+
|
1175
|
+
// We need to sort bones and all skinning data by path –
|
1176
|
+
// Neither glTF nor three.js care, but in USD they must be sorted
|
1177
|
+
// since the array defines the virtual hierarchy and is evaluated in that order
|
1178
|
+
const sortedBones: Array<{bone: Object3D, index: number}> = [];
|
1179
|
+
const indexMapping: number[] = [];
|
1180
|
+
let sortedSkinIndex = new Array<number>();
|
1181
|
+
let sortedSkinIndexAttribute: BufferAttribute | null = attributes.skinIndex;
|
1182
|
+
let bonesArray = "";
|
1183
|
+
if (hasBones) {
|
1184
|
+
|
1185
|
+
for ( const index in bones ) {
|
1186
|
+
sortedBones.push( { bone: bones[index], index: parseInt(index) } );
|
1187
|
+
}
|
1188
|
+
|
1189
|
+
// add structural nodes to the list of bones
|
1190
|
+
for ( const structuralNode of findStructuralNodesInBoneHierarchy(bones) ) {
|
1191
|
+
sortedBones.push( { bone: structuralNode, index: sortedBones.length } );
|
1192
|
+
}
|
1193
|
+
|
1194
|
+
// sort bones by path – need to be sorted in the same order as during mesh export
|
1195
|
+
const assumedRoot = bones[0].parent!;
|
1196
|
+
sortedBones.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
|
1197
|
+
bonesArray = sortedBones.map( x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"" ).join( ', ' );
|
1198
|
+
|
1199
|
+
// TODO we can probably skip the expensive attribute re-ordering if the bones were already in a correct order.
|
1200
|
+
// That doesn't mean that they are strictly sorted by path – just that all parents strictly need to come first.
|
1201
|
+
|
1202
|
+
// build index mapping
|
1203
|
+
for (const i in sortedBones) {
|
1204
|
+
indexMapping[sortedBones[i].index] = parseInt(i);
|
1205
|
+
}
|
1206
|
+
|
1207
|
+
// remap skin index attributes
|
1208
|
+
const skinIndex = attributes.skinIndex;
|
1209
|
+
sortedSkinIndex = new Array<number>();
|
1210
|
+
for ( let i = 0; i < skinIndex.count; i ++ ) {
|
1211
|
+
|
1212
|
+
const x = skinIndex.getX( i );
|
1213
|
+
const y = skinIndex.getY( i );
|
1214
|
+
const z = skinIndex.getZ( i );
|
1215
|
+
const w = skinIndex.getW( i );
|
1216
|
+
|
1217
|
+
sortedSkinIndex.push( indexMapping[x], indexMapping[y], indexMapping[z], indexMapping[w] );
|
1218
|
+
}
|
1219
|
+
|
1220
|
+
// turn it back into an attribute so the rest of the code doesn't need to learn a new thing
|
1221
|
+
sortedSkinIndexAttribute = new BufferAttribute( new Uint16Array( sortedSkinIndex ), 4 );
|
1222
|
+
}
|
1223
|
+
|
1224
|
+
const isSkinnedMesh = attributes.skinWeight && attributes.skinIndex;
|
1225
|
+
|
1038
1226
|
return `
|
1039
|
-
def Mesh "${name}"
|
1227
|
+
def Mesh "${name}" ${isSkinnedMesh ? `(
|
1228
|
+
prepend apiSchemas = ["SkelBindingAPI"]
|
1229
|
+
)` : ''}
|
1040
1230
|
{
|
1041
1231
|
int[] faceVertexCounts = [${buildMeshVertexCount( geometry )}]
|
1042
1232
|
int[] faceVertexIndices = [${buildMeshVertexIndices( geometry )}]
|
@@ -1052,6 +1242,24 @@
|
|
1052
1242
|
`texCoord2f[] primvars:st2 = [${buildVector2Array( attributes.uv2, count )}] (
|
1053
1243
|
interpolation = "vertex"
|
1054
1244
|
)` : '' }
|
1245
|
+
${isSkinnedMesh ?
|
1246
|
+
`matrix4d primvars:skel:geomBindTransform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1) ) (
|
1247
|
+
elementSize = 1
|
1248
|
+
interpolation = "constant"
|
1249
|
+
)` : '' }
|
1250
|
+
${attributes.skinIndex ?
|
1251
|
+
`int[] primvars:skel:jointIndices = [${buildVector4Array( sortedSkinIndexAttribute, true )}] (
|
1252
|
+
elementSize = 4
|
1253
|
+
interpolation = "vertex"
|
1254
|
+
)` : '' }
|
1255
|
+
${attributes.skinWeight ?
|
1256
|
+
`float[] primvars:skel:jointWeights = [${buildVector4Array( attributes.skinWeight )}] (
|
1257
|
+
elementSize = 4
|
1258
|
+
interpolation = "vertex"
|
1259
|
+
)` : '' }
|
1260
|
+
${hasBones ?
|
1261
|
+
//`uniform token[] skel:blendShapes
|
1262
|
+
`uniform token[] skel:joints = [${bonesArray}]` : '' }
|
1055
1263
|
uniform token subdivisionScheme = "none"
|
1056
1264
|
}
|
1057
1265
|
`;
|
@@ -1120,6 +1328,27 @@
|
|
1120
1328
|
|
1121
1329
|
}
|
1122
1330
|
|
1331
|
+
function buildVector4Array( attribute, ints = false ) {
|
1332
|
+
const array: Array<string> = [];
|
1333
|
+
|
1334
|
+
for ( let i = 0; i < attribute.count; i ++ ) {
|
1335
|
+
|
1336
|
+
const x = attribute.getX( i );
|
1337
|
+
const y = attribute.getY( i );
|
1338
|
+
const z = attribute.getZ( i );
|
1339
|
+
const w = attribute.getW( i );
|
1340
|
+
|
1341
|
+
array.push( `${ints ? x : x.toPrecision( PRECISION )}` );
|
1342
|
+
array.push( `${ints ? y : y.toPrecision( PRECISION )}` );
|
1343
|
+
array.push( `${ints ? z : z.toPrecision( PRECISION )}` );
|
1344
|
+
array.push( `${ints ? w : w.toPrecision( PRECISION )}` );
|
1345
|
+
|
1346
|
+
}
|
1347
|
+
|
1348
|
+
return array.join( ', ' );
|
1349
|
+
|
1350
|
+
}
|
1351
|
+
|
1123
1352
|
function buildVector2Array( attribute, count ) {
|
1124
1353
|
|
1125
1354
|
if ( attribute === undefined ) {
|
@@ -1480,4 +1709,18 @@
|
|
1480
1709
|
36492, // RGBA_BPTC_Format
|
1481
1710
|
];
|
1482
1711
|
|
1483
|
-
export {
|
1712
|
+
export {
|
1713
|
+
USDZExporter,
|
1714
|
+
USDZExporterContext,
|
1715
|
+
USDWriter,
|
1716
|
+
USDObject,
|
1717
|
+
buildMatrix,
|
1718
|
+
getBoneName,
|
1719
|
+
getPathToSkeleton,
|
1720
|
+
fn as usdNumberFormatting,
|
1721
|
+
USDDocument,
|
1722
|
+
makeNameSafe as makeNameSafeForUSD,
|
1723
|
+
imageToCanvas,
|
1724
|
+
decompressGpuTexture,
|
1725
|
+
findStructuralNodesInBoneHierarchy,
|
1726
|
+
};
|
@@ -63,11 +63,15 @@
|
|
63
63
|
@serializable()
|
64
64
|
interactive: boolean = true;
|
65
65
|
|
66
|
-
extensions: IUSDExporterExtension[] = [];
|
67
|
-
|
68
66
|
@serializable()
|
69
67
|
allowCreateQuicklookButton: boolean = true;
|
70
68
|
|
69
|
+
@serializable()
|
70
|
+
quickLookCompatible: boolean = true;
|
71
|
+
|
72
|
+
// Registered extensions. Add your own extensions here
|
73
|
+
extensions: IUSDExporterExtension[] = [];
|
74
|
+
|
71
75
|
private link!: HTMLAnchorElement;
|
72
76
|
private webxr?: WebXR;
|
73
77
|
|
@@ -137,50 +141,55 @@
|
|
137
141
|
this.exportAsync();
|
138
142
|
}
|
139
143
|
|
144
|
+
/**
|
145
|
+
* Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook.
|
146
|
+
* Use the various public properties of USDZExporter to customize export behaviour.
|
147
|
+
*/
|
140
148
|
async exportAsync() {
|
141
149
|
|
142
150
|
let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
|
143
|
-
|
151
|
+
name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
|
152
|
+
|
144
153
|
if (!hasProLicense()) {
|
145
154
|
if (name !== "") name += "-";
|
146
155
|
name += "MadeWithNeedle";
|
147
156
|
}
|
148
|
-
name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
|
149
157
|
|
150
158
|
if (!this.link) this.link = ensureQuicklookLinkIsCreated(this.context);
|
151
159
|
|
152
160
|
// ability to specify a custom USDZ file to be used instead of a dynamic one
|
153
161
|
if (this.customUsdzFile) {
|
154
|
-
if(debug) console.log("Exporting custom usdz", this.customUsdzFile)
|
155
|
-
|
156
|
-
// see https://developer.apple.com/documentation/arkit/adding_an_apple_pay_button_or_a_custom_action_in_ar_quick_look
|
157
|
-
const overlay = this.buildQuicklookOverlay();
|
158
|
-
if (debug) console.log("QuickLook Overlay", overlay);
|
159
|
-
const callToAction = overlay.callToAction ? encodeURIComponent(overlay.callToAction) : "";
|
160
|
-
const checkoutTitle = overlay.checkoutTitle ? encodeURIComponent(overlay.checkoutTitle) : "";
|
161
|
-
const checkoutSubtitle = overlay.checkoutSubtitle ? encodeURIComponent(overlay.checkoutSubtitle) : "";
|
162
|
-
this.link.href = this.customUsdzFile + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`;
|
163
|
-
|
164
|
-
if(debug) console.log(this.link.href)
|
165
|
-
|
166
|
-
if (!this.lastCallback) {
|
167
|
-
this.lastCallback = this.quicklookCallback.bind(this)
|
168
|
-
this.link.addEventListener('message', this.lastCallback);
|
169
|
-
}
|
170
|
-
|
171
|
-
// open quicklook
|
172
|
-
this.link.download = name + ".usdz";
|
173
|
-
this.link.click();
|
162
|
+
if (debug) console.log("Exporting custom usdz", this.customUsdzFile)
|
163
|
+
this.openInQuickLook(this.customUsdzFile, name);
|
174
164
|
return;
|
175
165
|
}
|
176
166
|
|
177
|
-
|
167
|
+
const blob = await this.export(this.objectToExport);
|
168
|
+
if (!blob) {
|
178
169
|
console.warn("No object to export", this);
|
179
170
|
return;
|
180
171
|
}
|
181
172
|
|
173
|
+
if (debug) console.log("USDZ generation done. Downloading as " + this.link.download);
|
174
|
+
|
175
|
+
// TODO detect QuickLook availability:
|
176
|
+
// https://webkit.org/blog/8421/viewing-augmented-reality-assets-in-safari-for-ios/#:~:text=inside%20the%20anchor.-,Feature%20Detection,-To%20detect%20support
|
177
|
+
|
178
|
+
|
179
|
+
this.openInQuickLook(blob, name);
|
180
|
+
}
|
181
|
+
|
182
|
+
/**
|
183
|
+
* Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook.
|
184
|
+
* @returns a Promise<Blob> containing the USDZ file
|
185
|
+
*/
|
186
|
+
async export(objectToExport: Object3D | undefined) : Promise<Blob | null> {
|
187
|
+
|
188
|
+
if (!objectToExport)
|
189
|
+
return null;
|
190
|
+
|
182
191
|
// trigger progressive textures to be loaded:
|
183
|
-
const renderers = GameObject.getComponentsInChildren(
|
192
|
+
const renderers = GameObject.getComponentsInChildren(objectToExport, Renderer);
|
184
193
|
const progressiveLoading = new Array<Promise<any>>();
|
185
194
|
for (const rend of renderers) {
|
186
195
|
for (const mat of rend.sharedMaterials) {
|
@@ -206,15 +215,19 @@
|
|
206
215
|
const extensions: any = [...this.extensions]
|
207
216
|
|
208
217
|
// collect animators and their clips
|
209
|
-
const animExt = new AnimationExtension();
|
218
|
+
const animExt = new AnimationExtension(this.quickLookCompatible);
|
210
219
|
extensions.push(animExt);
|
211
220
|
|
212
|
-
|
213
|
-
registerAnimatorsImplictly(this.objectToExport, animExt);
|
214
|
-
|
215
|
-
const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: this.objectToExport };
|
221
|
+
const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport };
|
216
222
|
this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs }))
|
217
223
|
|
224
|
+
// Implicit registration and actions for Animators and Animation components
|
225
|
+
// Currently, Animators properly build PlayAnimation actions, but Animation components don't.
|
226
|
+
const implicitAnimationBehaviors = new Array<Object3D>();
|
227
|
+
if (this.autoExportAnimations) {
|
228
|
+
implicitAnimationBehaviors.push(...registerAnimatorsImplictly(objectToExport, animExt));
|
229
|
+
}
|
230
|
+
|
218
231
|
//@ts-ignore
|
219
232
|
exporter.debug = debug;
|
220
233
|
|
@@ -235,12 +248,20 @@
|
|
235
248
|
},
|
236
249
|
},
|
237
250
|
extensions: extensions,
|
238
|
-
quickLookCompatible:
|
251
|
+
quickLookCompatible: this.quickLookCompatible,
|
239
252
|
});
|
240
253
|
const blob = new Blob([arraybuffer], { type: 'application/octet-stream' });
|
241
254
|
|
255
|
+
// cleanup – implicit animation behaviors need to be removed again
|
256
|
+
for (const go of implicitAnimationBehaviors) {
|
257
|
+
GameObject.destroy(go);
|
258
|
+
}
|
259
|
+
|
242
260
|
this.dispatchEvent(new CustomEvent("after-export", { detail: eventArgs }))
|
243
261
|
|
262
|
+
// restore XR flags
|
263
|
+
XRState.Global.Set(currentXRState);
|
264
|
+
|
244
265
|
// second file: USDA (without assets)
|
245
266
|
//@ts-ignore
|
246
267
|
// const usda = exporter.lastUsda;
|
@@ -249,15 +270,26 @@
|
|
249
270
|
// this.link.href = URL.createObjectURL(blob2);
|
250
271
|
// this.link.click();
|
251
272
|
|
273
|
+
return blob;
|
274
|
+
}
|
275
|
+
|
276
|
+
/**
|
277
|
+
* Opens QuickLook on iOS/iPadOS/visionOS with the given content in AR mode.
|
278
|
+
* @param content The URL to the .usdz or .reality file or a blob containing an USDZ file.
|
279
|
+
* @param name Download filename
|
280
|
+
*/
|
281
|
+
openInQuickLook(content: Blob | string, name: string) {
|
282
|
+
|
283
|
+
const url = content instanceof Blob ? URL.createObjectURL(content) : content;
|
284
|
+
|
252
285
|
// see https://developer.apple.com/documentation/arkit/adding_an_apple_pay_button_or_a_custom_action_in_ar_quick_look
|
253
286
|
const overlay = this.buildQuicklookOverlay();
|
254
287
|
if (debug) console.log("QuickLook Overlay", overlay);
|
255
288
|
const callToAction = overlay.callToAction ? encodeURIComponent(overlay.callToAction) : "";
|
256
289
|
const checkoutTitle = overlay.checkoutTitle ? encodeURIComponent(overlay.checkoutTitle) : "";
|
257
290
|
const checkoutSubtitle = overlay.checkoutSubtitle ? encodeURIComponent(overlay.checkoutSubtitle) : "";
|
258
|
-
this.link.href =
|
291
|
+
this.link.href = url + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`;
|
259
292
|
|
260
|
-
|
261
293
|
if (!this.lastCallback) {
|
262
294
|
this.lastCallback = this.quicklookCallback.bind(this)
|
263
295
|
this.link.addEventListener('message', this.lastCallback);
|
@@ -267,15 +299,35 @@
|
|
267
299
|
this.link.download = name + ".usdz";
|
268
300
|
this.link.click();
|
269
301
|
|
270
|
-
|
302
|
+
// cleanup
|
303
|
+
// TODO check if we can do that immediately or need to wait until the user returns
|
304
|
+
// if (content instanceof Blob) URL.revokeObjectURL(url);
|
305
|
+
}
|
271
306
|
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
307
|
+
/**
|
308
|
+
* Downloads the given blob as a file.
|
309
|
+
*/
|
310
|
+
download(blob: Blob, name: string) {
|
311
|
+
USDZExporter.save(blob, name);
|
277
312
|
}
|
278
313
|
|
314
|
+
// Matches GltfExport.save(blob, filename)
|
315
|
+
private static save(blob, filename) {
|
316
|
+
const link = document.createElement('a');
|
317
|
+
link.style.display = 'none';
|
318
|
+
document.body.appendChild(link); // Firefox workaround, see #6594
|
319
|
+
if (typeof blob === "string")
|
320
|
+
link.href = blob;
|
321
|
+
else
|
322
|
+
link.href = URL.createObjectURL(blob);
|
323
|
+
link.download = filename;
|
324
|
+
link.click();
|
325
|
+
link.remove();
|
326
|
+
// console.log(link.href);
|
327
|
+
// URL.revokeObjectURL( url ); breaks Firefox...
|
328
|
+
}
|
329
|
+
|
330
|
+
|
279
331
|
private lastCallback?: any;
|
280
332
|
|
281
333
|
private quicklookCallback(event: Event) {
|
@@ -75,9 +75,10 @@
|
|
75
75
|
|
76
76
|
writeTo(_document: USDDocument | undefined, writer: USDWriter) {
|
77
77
|
|
78
|
-
writer.beginBlock( `def Preliminary_Text "${this.id}" (
|
79
|
-
|
80
|
-
|
78
|
+
writer.beginBlock( `def Preliminary_Text "${this.id}"`, "(", false);
|
79
|
+
writer.appendLine(`prepend apiSchemas = ["MaterialBindingAPI"]`);
|
80
|
+
writer.closeBlock( ")" );
|
81
|
+
writer.beginBlock();
|
81
82
|
|
82
83
|
if (this.content)
|
83
84
|
writer.appendLine(`string content = "${this.content}"`);
|