@volter/editor-threejs 0.5.64 → 0.5.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +22 -4
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/user-data.ts +1 -8
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/animation/animation-clock.ts +0 -479
- package/src/animation/runtime-inspection.ts +0 -45
package/NOTICE
CHANGED
|
@@ -2,6 +2,8 @@ Copyright 2026 Volter AI, Inc.
|
|
|
2
2
|
|
|
3
3
|
Modules under src/viewport and src/capture were imported from the
|
|
4
4
|
AGPL-3.0-only @vgai/threejs package; LICENSE contains that license.
|
|
5
|
+
serving/model-import-conversion.ts was imported from the AGPL-3.0-only
|
|
6
|
+
@vgai/editor package's server and retains that license.
|
|
5
7
|
|
|
6
8
|
Modules under src/adapter, src/animation, src/ecs and src/render, together
|
|
7
9
|
with src/asset-loaders.ts, src/asset-parse-error.ts and src/loader.ts, were
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The animation stamp's status facet (`@volter/editor-sdk/services`, a `workspace.service`
|
|
3
|
+
* contribution): `vgai status` reports the mixers the running world's own code made, with the
|
|
4
|
+
* clips it has played through each and the objects each animates, so "the timeline found
|
|
5
|
+
* nothing" is answerable from the session.
|
|
6
|
+
*/
|
|
7
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
8
|
+
import { liveMixers } from '../src/animation/live-mixers';
|
|
9
|
+
|
|
10
|
+
export const point = 'workspace.service';
|
|
11
|
+
|
|
12
|
+
export function start(): () => void {
|
|
13
|
+
return editorHost().session.reportFacet(() => ({
|
|
14
|
+
liveMixers: liveMixers().map((entry) => ({
|
|
15
|
+
site: entry.key,
|
|
16
|
+
clips: [...entry.clips.keys()],
|
|
17
|
+
animates: [...entry.roots].map((root) => (root as { name?: string }).name || '(unnamed)'),
|
|
18
|
+
})),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// serving/animation-stamp.ts
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
// serving/animation-live-module.ts
|
|
6
|
+
var ANIMATION_LIVE_MODULE_ID = "virtual:vgai-three-animation-live";
|
|
7
|
+
var animationLiveModuleSource = `
|
|
8
|
+
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= {
|
|
9
|
+
mixers: new Map(),
|
|
10
|
+
listeners: new Set(),
|
|
11
|
+
version: 0,
|
|
12
|
+
});
|
|
13
|
+
function notify() {
|
|
14
|
+
live.version++;
|
|
15
|
+
for (const listener of live.listeners) {
|
|
16
|
+
try { listener(); } catch (error) { console.error(error); }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function __vgaiMixer(mixer, key) {
|
|
20
|
+
if (!mixer || typeof mixer.clipAction !== 'function') return mixer;
|
|
21
|
+
if (mixer.__vgaiMixerKey) return mixer;
|
|
22
|
+
Object.defineProperty(mixer, '__vgaiMixerKey', { value: key });
|
|
23
|
+
const entry = { key, mixer, clips: new Map(), roots: new Set() };
|
|
24
|
+
live.mixers.set(mixer, entry);
|
|
25
|
+
const clipAction = mixer.clipAction;
|
|
26
|
+
mixer.clipAction = function (clip, root, blendMode) {
|
|
27
|
+
const action = clipAction.call(this, clip, root, blendMode);
|
|
28
|
+
if (action) {
|
|
29
|
+
const played = action.getClip();
|
|
30
|
+
const target = action.getRoot();
|
|
31
|
+
let changed = false;
|
|
32
|
+
if (played && !entry.clips.has(played.name)) { entry.clips.set(played.name, played); changed = true; }
|
|
33
|
+
if (target && !entry.roots.has(target)) { entry.roots.add(target); changed = true; }
|
|
34
|
+
if (changed) notify();
|
|
35
|
+
}
|
|
36
|
+
return action;
|
|
37
|
+
};
|
|
38
|
+
const uncacheRoot = mixer.uncacheRoot;
|
|
39
|
+
mixer.uncacheRoot = function (root) {
|
|
40
|
+
const result = uncacheRoot.call(this, root);
|
|
41
|
+
entry.roots.delete(root);
|
|
42
|
+
if (root === this.getRoot()) live.mixers.delete(this);
|
|
43
|
+
notify();
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
notify();
|
|
47
|
+
return mixer;
|
|
48
|
+
}
|
|
49
|
+
/** drei's \`useAnimations\` result: its mixer, and the clips it was handed. */
|
|
50
|
+
export function __vgaiAnimations(result, key) {
|
|
51
|
+
if (!result || !result.mixer) return result;
|
|
52
|
+
__vgaiMixer(result.mixer, key);
|
|
53
|
+
const entry = live.mixers.get(result.mixer);
|
|
54
|
+
if (entry && Array.isArray(result.clips)) {
|
|
55
|
+
let changed = false;
|
|
56
|
+
for (const clip of result.clips) {
|
|
57
|
+
if (clip && clip.name && !entry.clips.has(clip.name)) { entry.clips.set(clip.name, clip); changed = true; }
|
|
58
|
+
}
|
|
59
|
+
if (changed) notify();
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
// serving/animation-stamp.ts
|
|
66
|
+
var VIRTUAL_ID = `\0${ANIMATION_LIVE_MODULE_ID}`;
|
|
67
|
+
var NODE_MODULES = /[\\/]node_modules[\\/]/;
|
|
68
|
+
var SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
69
|
+
function isMixerConstruction(node) {
|
|
70
|
+
if (!ts.isNewExpression(node)) return false;
|
|
71
|
+
const callee = node.expression;
|
|
72
|
+
if (ts.isIdentifier(callee)) return callee.text === "AnimationMixer";
|
|
73
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === "AnimationMixer";
|
|
74
|
+
}
|
|
75
|
+
function isUseAnimations(node) {
|
|
76
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useAnimations";
|
|
77
|
+
}
|
|
78
|
+
function stampAnimation(code, file, relativeFile) {
|
|
79
|
+
if (!code.includes("AnimationMixer") && !code.includes("useAnimations")) return null;
|
|
80
|
+
const kind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : file.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.TS;
|
|
81
|
+
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
|
|
82
|
+
const sites = [];
|
|
83
|
+
const visit = (node) => {
|
|
84
|
+
if (isMixerConstruction(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: "__vgaiMixer" });
|
|
85
|
+
else if (isUseAnimations(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: "__vgaiAnimations" });
|
|
86
|
+
ts.forEachChild(node, visit);
|
|
87
|
+
};
|
|
88
|
+
visit(source);
|
|
89
|
+
if (sites.length === 0) return null;
|
|
90
|
+
let out = code;
|
|
91
|
+
for (const site of [...sites].sort((a, b) => b.start - a.start)) {
|
|
92
|
+
const { line, character } = source.getLineAndCharacterOfPosition(site.start);
|
|
93
|
+
const key = `${relativeFile}:${line + 1}:${character + 1}`;
|
|
94
|
+
out = `${out.slice(0, site.start)}${site.wrapper}(${out.slice(site.start, site.end)}, ${JSON.stringify(key)})${out.slice(site.end)}`;
|
|
95
|
+
}
|
|
96
|
+
return `import { __vgaiMixer, __vgaiAnimations } from ${JSON.stringify(ANIMATION_LIVE_MODULE_ID)};
|
|
97
|
+
${out}`;
|
|
98
|
+
}
|
|
99
|
+
function animationStampPlugin(services) {
|
|
100
|
+
let serverRoot = process.cwd();
|
|
101
|
+
const isProjectFile = (file) => {
|
|
102
|
+
if (NODE_MODULES.test(file) || !SCRIPT.test(file)) return false;
|
|
103
|
+
const roots = [...services.projectRoots(), services.currentProjectRoot()].filter((root) => !!root);
|
|
104
|
+
return roots.some((root) => file.startsWith(root + sep) || file.startsWith(root + "/"));
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
name: "vgai-three-animation",
|
|
108
|
+
enforce: "pre",
|
|
109
|
+
resolveId(id) {
|
|
110
|
+
return id === ANIMATION_LIVE_MODULE_ID ? VIRTUAL_ID : null;
|
|
111
|
+
},
|
|
112
|
+
load(id) {
|
|
113
|
+
return id === VIRTUAL_ID ? animationLiveModuleSource : null;
|
|
114
|
+
},
|
|
115
|
+
configureServer(server) {
|
|
116
|
+
serverRoot = server.config.root;
|
|
117
|
+
},
|
|
118
|
+
transform(code, id) {
|
|
119
|
+
if (id.startsWith("\0")) return null;
|
|
120
|
+
const file = id.split("?")[0] ?? id;
|
|
121
|
+
if (!isProjectFile(file)) return null;
|
|
122
|
+
const root = services.currentProjectRoot() ?? serverRoot;
|
|
123
|
+
const stamped = stampAnimation(code, file, relative(root, file).split(sep).join("/"));
|
|
124
|
+
return stamped === null ? null : { code: stamped, map: null };
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// serving/model-import-conversion.ts
|
|
130
|
+
import { readFile } from "node:fs/promises";
|
|
131
|
+
import { createRequire } from "node:module";
|
|
132
|
+
import { dirname, extname } from "node:path";
|
|
133
|
+
import * as THREE from "three";
|
|
134
|
+
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
|
|
135
|
+
import { ColladaLoader } from "three/addons/loaders/ColladaLoader.js";
|
|
136
|
+
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
|
|
137
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
138
|
+
import { MTLLoader } from "three/addons/loaders/MTLLoader.js";
|
|
139
|
+
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
|
|
140
|
+
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
|
|
141
|
+
import { STLLoader } from "three/addons/loaders/STLLoader.js";
|
|
142
|
+
import { TDSLoader } from "three/addons/loaders/TDSLoader.js";
|
|
143
|
+
import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";
|
|
144
|
+
var threeModelConverter = {
|
|
145
|
+
formats: ["fbx", "obj", "dae", "stl", "ply", "3ds"],
|
|
146
|
+
convert: (primaryPath, format, settings) => convertStagedModelToGlb(primaryPath, format, settings)
|
|
147
|
+
};
|
|
148
|
+
async function convertStagedModelToGlb(primaryPath, format, settings) {
|
|
149
|
+
installNodeDom();
|
|
150
|
+
const bytes = new Uint8Array(await readFile(primaryPath));
|
|
151
|
+
const object = await parseSourceModel(primaryPath, format.toLowerCase(), bytes);
|
|
152
|
+
discardUnloadedTextures(object);
|
|
153
|
+
applyModelImportSettings(object, settings);
|
|
154
|
+
installNodeFileReader();
|
|
155
|
+
try {
|
|
156
|
+
const result = await new Promise((resolve, reject) => {
|
|
157
|
+
new GLTFExporter().parse(
|
|
158
|
+
object,
|
|
159
|
+
(output2) => output2 instanceof ArrayBuffer ? resolve(output2) : reject(new Error("Model conversion produced JSON instead of binary GLB.")),
|
|
160
|
+
reject,
|
|
161
|
+
{ binary: true, animations: object.animations, onlyVisible: false }
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
const output = new Uint8Array(result);
|
|
165
|
+
await validateConvertedGlb(output);
|
|
166
|
+
return output;
|
|
167
|
+
} finally {
|
|
168
|
+
disposeObject(object);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
var materialTextureSlots = [
|
|
172
|
+
"map",
|
|
173
|
+
"alphaMap",
|
|
174
|
+
"aoMap",
|
|
175
|
+
"bumpMap",
|
|
176
|
+
"displacementMap",
|
|
177
|
+
"emissiveMap",
|
|
178
|
+
"envMap",
|
|
179
|
+
"lightMap",
|
|
180
|
+
"metalnessMap",
|
|
181
|
+
"normalMap",
|
|
182
|
+
"roughnessMap",
|
|
183
|
+
"specularMap"
|
|
184
|
+
];
|
|
185
|
+
function applyModelImportSettings(object, settings) {
|
|
186
|
+
object.scale.multiplyScalar(settings.scale * sourceUnitScale(settings.sourceUnits));
|
|
187
|
+
object.quaternion.premultiply(importAxisRotation(settings.upAxis, settings.forwardAxis));
|
|
188
|
+
const discardedTextures = /* @__PURE__ */ new Set();
|
|
189
|
+
object.traverse((node) => {
|
|
190
|
+
const mesh = node;
|
|
191
|
+
if (!mesh.geometry) return;
|
|
192
|
+
let geometry = mesh.geometry;
|
|
193
|
+
if (settings.meshOptimization !== "none") {
|
|
194
|
+
const optimized = mergeVertices(
|
|
195
|
+
geometry,
|
|
196
|
+
settings.meshOptimization === "aggressive" ? 1e-3 : 1e-6
|
|
197
|
+
);
|
|
198
|
+
if (optimized !== geometry) {
|
|
199
|
+
mesh.geometry = optimized;
|
|
200
|
+
geometry.dispose();
|
|
201
|
+
geometry = optimized;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (settings.normals !== "preserve") {
|
|
205
|
+
geometry.deleteAttribute("normal");
|
|
206
|
+
geometry.computeVertexNormals();
|
|
207
|
+
}
|
|
208
|
+
if (settings.tangents === "discard") geometry.deleteAttribute("tangent");
|
|
209
|
+
if (settings.tangents === "generate" && geometry.index && geometry.hasAttribute("position") && geometry.hasAttribute("normal") && geometry.hasAttribute("uv")) {
|
|
210
|
+
geometry.computeTangents();
|
|
211
|
+
}
|
|
212
|
+
geometry.computeBoundingBox();
|
|
213
|
+
geometry.computeBoundingSphere();
|
|
214
|
+
const priorMaterials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
|
|
215
|
+
if (settings.materials === "discard") {
|
|
216
|
+
mesh.material = Array.isArray(mesh.material) ? priorMaterials.map(() => new THREE.MeshStandardMaterial()) : new THREE.MeshStandardMaterial();
|
|
217
|
+
for (const material of priorMaterials) material.dispose();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (settings.textures !== "discard") return;
|
|
221
|
+
for (const material of priorMaterials) {
|
|
222
|
+
const textured = material;
|
|
223
|
+
for (const slot of materialTextureSlots) {
|
|
224
|
+
const texture = textured[slot];
|
|
225
|
+
if (texture instanceof THREE.Texture) discardedTextures.add(texture);
|
|
226
|
+
textured[slot] = null;
|
|
227
|
+
}
|
|
228
|
+
material.needsUpdate = true;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
for (const texture of discardedTextures) texture.dispose();
|
|
232
|
+
if (settings.animations.length > 0) {
|
|
233
|
+
const requested = new Set(settings.animations);
|
|
234
|
+
const selected = object.animations.filter((clip) => requested.has(clip.name));
|
|
235
|
+
const missing = settings.animations.filter(
|
|
236
|
+
(name) => !selected.some((clip) => clip.name === name)
|
|
237
|
+
);
|
|
238
|
+
if (missing.length > 0) throw new Error(`Animation clips not found: ${missing.join(", ")}.`);
|
|
239
|
+
object.animations = selected;
|
|
240
|
+
}
|
|
241
|
+
object.updateWorldMatrix(true, true);
|
|
242
|
+
}
|
|
243
|
+
function sourceUnitScale(units) {
|
|
244
|
+
return { auto: 1, mm: 1e-3, cm: 0.01, m: 1, in: 0.0254, ft: 0.3048 }[units];
|
|
245
|
+
}
|
|
246
|
+
function importAxisRotation(upAxis, forwardAxis) {
|
|
247
|
+
const up = upAxis === "auto" ? null : axisVector(upAxis);
|
|
248
|
+
const forward = forwardAxis === "auto" ? null : axisVector(forwardAxis);
|
|
249
|
+
if (up && forward) {
|
|
250
|
+
if (Math.abs(up.dot(forward)) > 1e-3)
|
|
251
|
+
throw new Error("Import up and forward axes must be perpendicular.");
|
|
252
|
+
const right = new THREE.Vector3().crossVectors(forward, up).normalize();
|
|
253
|
+
const correctedForward = new THREE.Vector3().crossVectors(up, right).normalize();
|
|
254
|
+
const sourceBasis = new THREE.Matrix4().makeBasis(right, up, correctedForward.clone().negate());
|
|
255
|
+
return new THREE.Quaternion().setFromRotationMatrix(sourceBasis.invert());
|
|
256
|
+
}
|
|
257
|
+
if (up) return new THREE.Quaternion().setFromUnitVectors(up, new THREE.Vector3(0, 1, 0));
|
|
258
|
+
if (forward)
|
|
259
|
+
return new THREE.Quaternion().setFromUnitVectors(forward, new THREE.Vector3(0, 0, -1));
|
|
260
|
+
return new THREE.Quaternion();
|
|
261
|
+
}
|
|
262
|
+
function axisVector(axis) {
|
|
263
|
+
const sign = axis.startsWith("-") ? -1 : 1;
|
|
264
|
+
const name = axis.replace("-", "");
|
|
265
|
+
return new THREE.Vector3(
|
|
266
|
+
name === "x" ? sign : 0,
|
|
267
|
+
name === "y" ? sign : 0,
|
|
268
|
+
name === "z" ? sign : 0
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
async function parseSourceModel(primaryPath, format, bytes) {
|
|
272
|
+
const resourcePath = `${dirname(primaryPath)}/`;
|
|
273
|
+
switch (format) {
|
|
274
|
+
case "fbx":
|
|
275
|
+
return new FBXLoader().parse(bytes.buffer, resourcePath);
|
|
276
|
+
case "obj": {
|
|
277
|
+
const loader = new OBJLoader();
|
|
278
|
+
const materialPath = primaryPath.replace(/\.obj$/i, ".mtl");
|
|
279
|
+
try {
|
|
280
|
+
const materials = new MTLLoader().parse(await readFile(materialPath, "utf8"), resourcePath);
|
|
281
|
+
materials.preload();
|
|
282
|
+
loader.setMaterials(materials);
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
return loader.parse(new TextDecoder().decode(bytes));
|
|
286
|
+
}
|
|
287
|
+
case "dae": {
|
|
288
|
+
return new ColladaLoader().parse(new TextDecoder().decode(bytes), resourcePath).scene;
|
|
289
|
+
}
|
|
290
|
+
case "stl": {
|
|
291
|
+
const geometry = new STLLoader().parse(bytes.buffer);
|
|
292
|
+
return new THREE.Mesh(
|
|
293
|
+
geometry,
|
|
294
|
+
new THREE.MeshStandardMaterial({ color: 9418202, roughness: 0.75 })
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
case "ply": {
|
|
298
|
+
const geometry = new PLYLoader().parse(bytes.buffer);
|
|
299
|
+
if (!geometry.hasAttribute("normal")) geometry.computeVertexNormals();
|
|
300
|
+
return new THREE.Mesh(
|
|
301
|
+
geometry,
|
|
302
|
+
new THREE.MeshStandardMaterial({
|
|
303
|
+
color: geometry.hasAttribute("color") ? 16777215 : 12112027,
|
|
304
|
+
vertexColors: geometry.hasAttribute("color"),
|
|
305
|
+
roughness: 0.8,
|
|
306
|
+
side: THREE.DoubleSide
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
case "3ds":
|
|
311
|
+
return new TDSLoader().parse(bytes.buffer, resourcePath);
|
|
312
|
+
default:
|
|
313
|
+
throw new Error(`Built-in conversion does not support ${format || extname(primaryPath)}.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function installNodeDom() {
|
|
317
|
+
if (typeof document !== "undefined" && typeof DOMParser !== "undefined") return;
|
|
318
|
+
const { JSDOM } = createRequire(import.meta.url)("jsdom");
|
|
319
|
+
const window = new JSDOM().window;
|
|
320
|
+
Object.assign(globalThis, {
|
|
321
|
+
document: window.document,
|
|
322
|
+
DOMParser: window.DOMParser,
|
|
323
|
+
HTMLImageElement: window.HTMLImageElement,
|
|
324
|
+
HTMLCanvasElement: window.HTMLCanvasElement,
|
|
325
|
+
Image: window.Image
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function discardUnloadedTextures(root) {
|
|
329
|
+
root.traverse((object) => {
|
|
330
|
+
const mesh = object;
|
|
331
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
|
|
332
|
+
for (const material of materials) {
|
|
333
|
+
const textured = material;
|
|
334
|
+
for (const slot of materialTextureSlots) {
|
|
335
|
+
const value = textured[slot];
|
|
336
|
+
if (!(value instanceof THREE.Texture)) continue;
|
|
337
|
+
const image = value.image;
|
|
338
|
+
if (image && (image.width ?? 0) > 0 && (image.height ?? 0) > 0) continue;
|
|
339
|
+
textured[slot] = null;
|
|
340
|
+
value.dispose();
|
|
341
|
+
}
|
|
342
|
+
material.needsUpdate = true;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function validateConvertedGlb(bytes) {
|
|
347
|
+
const parsed = await new GLTFLoader().parseAsync(Uint8Array.from(bytes).buffer, "");
|
|
348
|
+
let renderable = false;
|
|
349
|
+
parsed.scene.traverse((object) => {
|
|
350
|
+
renderable ||= Boolean(object.isMesh || object.isPoints);
|
|
351
|
+
});
|
|
352
|
+
disposeObject(parsed.scene);
|
|
353
|
+
if (!renderable && parsed.animations.length === 0) {
|
|
354
|
+
throw new Error("Converted GLB contains neither renderable geometry nor animation clips.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function installNodeFileReader() {
|
|
358
|
+
if (typeof FileReader !== "undefined") return;
|
|
359
|
+
class NodeFileReader {
|
|
360
|
+
result = null;
|
|
361
|
+
error = null;
|
|
362
|
+
onloadend = null;
|
|
363
|
+
onerror = null;
|
|
364
|
+
readAsArrayBuffer(blob) {
|
|
365
|
+
void blob.arrayBuffer().then(
|
|
366
|
+
(result) => {
|
|
367
|
+
this.result = result;
|
|
368
|
+
this.onloadend?.({ target: this });
|
|
369
|
+
},
|
|
370
|
+
(error) => {
|
|
371
|
+
this.error = error instanceof Error ? error : new Error(String(error));
|
|
372
|
+
this.onerror?.({ target: this });
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
readAsDataURL(blob) {
|
|
377
|
+
void blob.arrayBuffer().then((result) => {
|
|
378
|
+
this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(result).toString("base64")}`;
|
|
379
|
+
this.onloadend?.({ target: this });
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
Object.assign(globalThis, { FileReader: NodeFileReader });
|
|
384
|
+
}
|
|
385
|
+
function disposeObject(root) {
|
|
386
|
+
root.traverse((object) => {
|
|
387
|
+
const renderable = object;
|
|
388
|
+
renderable.geometry?.dispose();
|
|
389
|
+
const materials = Array.isArray(renderable.material) ? renderable.material : renderable.material ? [renderable.material] : [];
|
|
390
|
+
for (const material of materials) {
|
|
391
|
+
for (const value of Object.values(material))
|
|
392
|
+
if (value instanceof THREE.Texture) value.dispose();
|
|
393
|
+
material.dispose();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// serving/index.ts
|
|
399
|
+
var servingPlugins = (services) => {
|
|
400
|
+
services.registerModelConverter(threeModelConverter);
|
|
401
|
+
return [animationStampPlugin(services)];
|
|
402
|
+
};
|
|
403
|
+
export {
|
|
404
|
+
servingPlugins
|
|
405
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/editor-threejs",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.66",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
|
+
"serving",
|
|
19
|
+
"contributions",
|
|
20
|
+
"dist-node",
|
|
18
21
|
"LICENSE",
|
|
19
22
|
"NOTICE",
|
|
20
23
|
"LICENSE-APACHE"
|
|
21
24
|
],
|
|
22
25
|
"exports": {
|
|
23
26
|
"./package.json": "./package.json",
|
|
27
|
+
"./contributions/*": "./contributions/*",
|
|
24
28
|
"./*": {
|
|
25
29
|
"types": "./src/*.ts",
|
|
26
30
|
"default": "./src/*"
|
|
@@ -29,15 +33,29 @@
|
|
|
29
33
|
"./loader": "./src/loader.ts"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
|
-
"three": "^0.180.0"
|
|
36
|
+
"three": "^0.180.0",
|
|
37
|
+
"vite": "^6.4.3"
|
|
33
38
|
},
|
|
34
39
|
"scripts": {
|
|
40
|
+
"build": "node scripts/build-serving.mjs",
|
|
41
|
+
"prepack": "npm run build",
|
|
35
42
|
"typecheck": "tsc -p tsconfig.json"
|
|
36
43
|
},
|
|
44
|
+
"vgai": {
|
|
45
|
+
"contributions": [
|
|
46
|
+
"./contributions/animation-mixers.service.ts"
|
|
47
|
+
],
|
|
48
|
+
"serving": "./dist-node/serving.mjs"
|
|
49
|
+
},
|
|
37
50
|
"dependencies": {
|
|
38
|
-
"@volter/editor-project": "0.5.
|
|
51
|
+
"@volter/editor-project": "0.5.66",
|
|
39
52
|
"@sparkjsdev/spark": "^2.1.0",
|
|
40
53
|
"three.quarks": "^0.16.0",
|
|
41
|
-
"zod": "^4.3.6"
|
|
54
|
+
"zod": "^4.3.6",
|
|
55
|
+
"typescript": "5.9.3",
|
|
56
|
+
"@volter/editor-sdk": "0.5.66"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"esbuild": "0.25.12"
|
|
42
60
|
}
|
|
43
61
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module the animation stamp imports into a project's modules (`animation-stamp.ts`), served
|
|
3
|
+
* as source into the GAME's own graph. It imports no three: it wraps the mixer the game's own
|
|
4
|
+
* three built, so the game keeps exactly the library it installed.
|
|
5
|
+
*
|
|
6
|
+
* A stamped mixer lists itself with every clip the game plays through it (`clipAction`) and every
|
|
7
|
+
* root those actions animate, so a mixer drei's `useAnimations` makes without a root is still
|
|
8
|
+
* found by the object it moves. The registry lives on `globalThis` under a `Symbol.for` key, where
|
|
9
|
+
* the editor's bundle reads it (`src/animation/live-mixers.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ANIMATION_LIVE_MODULE_ID = 'virtual:vgai-three-animation-live';
|
|
13
|
+
|
|
14
|
+
export const animationLiveModuleSource = `
|
|
15
|
+
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= {
|
|
16
|
+
mixers: new Map(),
|
|
17
|
+
listeners: new Set(),
|
|
18
|
+
version: 0,
|
|
19
|
+
});
|
|
20
|
+
function notify() {
|
|
21
|
+
live.version++;
|
|
22
|
+
for (const listener of live.listeners) {
|
|
23
|
+
try { listener(); } catch (error) { console.error(error); }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function __vgaiMixer(mixer, key) {
|
|
27
|
+
if (!mixer || typeof mixer.clipAction !== 'function') return mixer;
|
|
28
|
+
if (mixer.__vgaiMixerKey) return mixer;
|
|
29
|
+
Object.defineProperty(mixer, '__vgaiMixerKey', { value: key });
|
|
30
|
+
const entry = { key, mixer, clips: new Map(), roots: new Set() };
|
|
31
|
+
live.mixers.set(mixer, entry);
|
|
32
|
+
const clipAction = mixer.clipAction;
|
|
33
|
+
mixer.clipAction = function (clip, root, blendMode) {
|
|
34
|
+
const action = clipAction.call(this, clip, root, blendMode);
|
|
35
|
+
if (action) {
|
|
36
|
+
const played = action.getClip();
|
|
37
|
+
const target = action.getRoot();
|
|
38
|
+
let changed = false;
|
|
39
|
+
if (played && !entry.clips.has(played.name)) { entry.clips.set(played.name, played); changed = true; }
|
|
40
|
+
if (target && !entry.roots.has(target)) { entry.roots.add(target); changed = true; }
|
|
41
|
+
if (changed) notify();
|
|
42
|
+
}
|
|
43
|
+
return action;
|
|
44
|
+
};
|
|
45
|
+
const uncacheRoot = mixer.uncacheRoot;
|
|
46
|
+
mixer.uncacheRoot = function (root) {
|
|
47
|
+
const result = uncacheRoot.call(this, root);
|
|
48
|
+
entry.roots.delete(root);
|
|
49
|
+
if (root === this.getRoot()) live.mixers.delete(this);
|
|
50
|
+
notify();
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
notify();
|
|
54
|
+
return mixer;
|
|
55
|
+
}
|
|
56
|
+
/** drei's \`useAnimations\` result: its mixer, and the clips it was handed. */
|
|
57
|
+
export function __vgaiAnimations(result, key) {
|
|
58
|
+
if (!result || !result.mixer) return result;
|
|
59
|
+
__vgaiMixer(result.mixer, key);
|
|
60
|
+
const entry = live.mixers.get(result.mixer);
|
|
61
|
+
if (entry && Array.isArray(result.clips)) {
|
|
62
|
+
let changed = false;
|
|
63
|
+
for (const clip of result.clips) {
|
|
64
|
+
if (clip && clip.name && !entry.clips.has(clip.name)) { entry.clips.set(clip.name, clip); changed = true; }
|
|
65
|
+
}
|
|
66
|
+
if (changed) notify();
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@volter/editor-threejs`'s server half: the ANIMATION STAMP over the project's served modules.
|
|
3
|
+
*
|
|
4
|
+
* THE GAME REGISTERS NOTHING (ARCHITECTURE.md rule 4). A module animates with three's own API
|
|
5
|
+
* (`new THREE.AnimationMixer(model)`, `mixer.clipAction(clip).play()`) or drei's
|
|
6
|
+
* (`useAnimations(animations, ref)`); in the editor's served graph only, each of those call sites
|
|
7
|
+
* is wrapped so the mixer it makes reports itself (`animation-live-module.ts`). The editor's
|
|
8
|
+
* animation instruments drive the mixer the game made instead of minting a second writer over
|
|
9
|
+
* the same skeleton. A standalone build never passes through this plugin.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { relative, sep } from 'node:path';
|
|
13
|
+
import ts from 'typescript';
|
|
14
|
+
import type { Plugin } from 'vite';
|
|
15
|
+
import { ANIMATION_LIVE_MODULE_ID, animationLiveModuleSource } from './animation-live-module';
|
|
16
|
+
|
|
17
|
+
/** The kit services this plugin reads (`@volter/editor-sdk/session/project-serving`), by shape. */
|
|
18
|
+
export interface AnimationServingServices {
|
|
19
|
+
readonly projectRoots: () => ReadonlySet<string>;
|
|
20
|
+
readonly currentProjectRoot: () => string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const VIRTUAL_ID = `\0${ANIMATION_LIVE_MODULE_ID}`;
|
|
24
|
+
const NODE_MODULES = /[\\/]node_modules[\\/]/;
|
|
25
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
26
|
+
|
|
27
|
+
function isMixerConstruction(node: ts.Node): node is ts.NewExpression {
|
|
28
|
+
if (!ts.isNewExpression(node)) return false;
|
|
29
|
+
const callee = node.expression;
|
|
30
|
+
if (ts.isIdentifier(callee)) return callee.text === 'AnimationMixer';
|
|
31
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === 'AnimationMixer';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isUseAnimations(node: ts.Node): node is ts.CallExpression {
|
|
35
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'useAnimations';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Wrap every mixer a module makes; `null` when it makes none. */
|
|
39
|
+
export function stampAnimation(code: string, file: string, relativeFile: string): string | null {
|
|
40
|
+
if (!code.includes('AnimationMixer') && !code.includes('useAnimations')) return null;
|
|
41
|
+
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.TS;
|
|
42
|
+
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
|
|
43
|
+
const sites: { start: number; end: number; wrapper: '__vgaiMixer' | '__vgaiAnimations' }[] = [];
|
|
44
|
+
const visit = (node: ts.Node): void => {
|
|
45
|
+
if (isMixerConstruction(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: '__vgaiMixer' });
|
|
46
|
+
else if (isUseAnimations(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: '__vgaiAnimations' });
|
|
47
|
+
ts.forEachChild(node, visit);
|
|
48
|
+
};
|
|
49
|
+
visit(source);
|
|
50
|
+
if (sites.length === 0) return null;
|
|
51
|
+
let out = code;
|
|
52
|
+
for (const site of [...sites].sort((a, b) => b.start - a.start)) {
|
|
53
|
+
const { line, character } = source.getLineAndCharacterOfPosition(site.start);
|
|
54
|
+
const key = `${relativeFile}:${line + 1}:${character + 1}`;
|
|
55
|
+
out = `${out.slice(0, site.start)}${site.wrapper}(${out.slice(site.start, site.end)}, ${JSON.stringify(key)})${out.slice(site.end)}`;
|
|
56
|
+
}
|
|
57
|
+
return `import { __vgaiMixer, __vgaiAnimations } from ${JSON.stringify(ANIMATION_LIVE_MODULE_ID)};\n${out}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function animationStampPlugin(services: AnimationServingServices): Plugin {
|
|
61
|
+
let serverRoot = process.cwd();
|
|
62
|
+
const isProjectFile = (file: string): boolean => {
|
|
63
|
+
if (NODE_MODULES.test(file) || !SCRIPT.test(file)) return false;
|
|
64
|
+
const roots = [...services.projectRoots(), services.currentProjectRoot()].filter((root): root is string => !!root);
|
|
65
|
+
return roots.some((root) => file.startsWith(root + sep) || file.startsWith(root + '/'));
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
name: 'vgai-three-animation',
|
|
69
|
+
enforce: 'pre',
|
|
70
|
+
resolveId(id) {
|
|
71
|
+
return id === ANIMATION_LIVE_MODULE_ID ? VIRTUAL_ID : null;
|
|
72
|
+
},
|
|
73
|
+
load(id) {
|
|
74
|
+
return id === VIRTUAL_ID ? animationLiveModuleSource : null;
|
|
75
|
+
},
|
|
76
|
+
configureServer(server) {
|
|
77
|
+
serverRoot = server.config.root;
|
|
78
|
+
},
|
|
79
|
+
transform(code, id) {
|
|
80
|
+
if (id.startsWith('\0')) return null;
|
|
81
|
+
const file = id.split('?')[0] ?? id;
|
|
82
|
+
if (!isProjectFile(file)) return null;
|
|
83
|
+
const root = services.currentProjectRoot() ?? serverRoot;
|
|
84
|
+
const stamped = stampAnimation(code, file, relative(root, file).split(sep).join('/'));
|
|
85
|
+
return stamped === null ? null : { code: stamped, map: null };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/serving/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@volter/editor-threejs`'s server half (`package.json#vgai.serving`): the animation stamp that
|
|
3
|
+
* lets the editor drive the mixers a project's own code makes, and the conversion of source model
|
|
4
|
+
* formats to runtime GLB for the asset library's imports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ProjectServingServices } from '@volter/editor-sdk/session/project-serving';
|
|
8
|
+
import { animationStampPlugin } from './animation-stamp';
|
|
9
|
+
import { threeModelConverter } from './model-import-conversion';
|
|
10
|
+
|
|
11
|
+
export const servingPlugins = (services: ProjectServingServices): readonly unknown[] => {
|
|
12
|
+
services.registerModelConverter(threeModelConverter);
|
|
13
|
+
return [animationStampPlugin(services)];
|
|
14
|
+
};
|