@vgai/engine 0.4.0 → 0.4.1

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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Package-contributed project tool for baking the procedural humanoid into
3
+ * ordinary project assets. The editor discovers this module through
4
+ * @vgai/engine's package.json; the editor itself contains no humanoid branch.
5
+ */
6
+
7
+ import { readFile, realpath } from 'node:fs/promises';
8
+ import { dirname, resolve, sep } from 'node:path';
9
+ import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
10
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
11
+ import { z } from 'zod';
12
+ import { createHumanoidClipLibrary, retargetClipToHumanoid } from '../src/humanoid/clips';
13
+ import { generateHumanoid } from '../src/humanoid/generate';
14
+ import { HumanoidParamsSchema } from '../src/humanoid/schema';
15
+
16
+ export const HumanoidBakeInputSchema = z.object({
17
+ name: z
18
+ .string()
19
+ .regex(/^[a-z0-9][a-z0-9-_]{0,63}$/)
20
+ .default('humanoid')
21
+ .describe('Stable lowercase asset name used for the generated GLB and prefab files.'),
22
+ params: HumanoidParamsSchema.default(HumanoidParamsSchema.parse({})).describe(
23
+ 'Procedural body proportions.',
24
+ ),
25
+ clipSource: z
26
+ .string()
27
+ .optional()
28
+ .describe(
29
+ 'Optional project-public GLB path containing Mixamo-named clips. When supplied, Idle, Walk, and Run are retargeted and baked into the generated humanoid GLB.',
30
+ ),
31
+ clipNames: z
32
+ .array(z.string())
33
+ .min(1)
34
+ .default(['Idle', 'Walk', 'Run'])
35
+ .describe('Exact standard clip names to retarget from clipSource.'),
36
+ dryRun: z
37
+ .boolean()
38
+ .default(false)
39
+ .describe('Generate and validate the complete output batch without writing project files.'),
40
+ });
41
+
42
+ const OutputFileSchema = z.object({
43
+ path: z.string(),
44
+ bytes: z.number(),
45
+ mediaType: z.string().optional(),
46
+ role: z.enum(['asset', 'prefab', 'provenance', 'other']).optional(),
47
+ });
48
+
49
+ export const HumanoidBakeResultSchema = z.object({
50
+ model: z.string(),
51
+ prefab: z.string(),
52
+ provenanceOperationId: z.string().optional(),
53
+ vertexCount: z.number(),
54
+ clips: z.array(z.string()),
55
+ files: z.array(OutputFileSchema),
56
+ totalBytes: z.number(),
57
+ dryRun: z.boolean(),
58
+ });
59
+
60
+ interface OutputWriter {
61
+ write(
62
+ files: ReadonlyArray<{
63
+ path: string;
64
+ content: string | Uint8Array;
65
+ mediaType?: string;
66
+ role?: 'asset' | 'prefab' | 'provenance' | 'other';
67
+ }>,
68
+ options?: { dryRun?: boolean },
69
+ ): Promise<{
70
+ files: z.infer<typeof OutputFileSchema>[];
71
+ totalBytes: number;
72
+ dryRun: boolean;
73
+ provenanceOperationId?: string;
74
+ }>;
75
+ }
76
+
77
+ interface ProjectToolContext {
78
+ projectRoot?: string;
79
+ projectOutputs?: OutputWriter;
80
+ signal?: AbortSignal;
81
+ }
82
+
83
+ const nodeThreePolyfillKeys = ['self', 'ProgressEvent', 'createImageBitmap', 'FileReader'] as const;
84
+
85
+ function installNodeThreePolyfills(): () => void {
86
+ const target = globalThis as unknown as Record<string, unknown>;
87
+ const previous = new Map(
88
+ nodeThreePolyfillKeys.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
89
+ );
90
+ if (!target['self']) target['self'] = globalThis;
91
+ if (!target['ProgressEvent']) {
92
+ target['ProgressEvent'] = class {
93
+ readonly type: string;
94
+ constructor(type: string, init: Record<string, unknown> = {}) {
95
+ this.type = type;
96
+ Object.assign(this, init);
97
+ }
98
+ };
99
+ }
100
+ if (!target['createImageBitmap']) {
101
+ target['createImageBitmap'] = async () => ({ close() {} });
102
+ }
103
+ if (!target['FileReader']) {
104
+ target['FileReader'] = class {
105
+ result: string | ArrayBuffer | null = null;
106
+ onloadend: (() => void) | null = null;
107
+ readAsArrayBuffer(blob: Blob): void {
108
+ void blob.arrayBuffer().then((value) => {
109
+ this.result = value;
110
+ this.onloadend?.();
111
+ });
112
+ }
113
+ readAsDataURL(blob: Blob): void {
114
+ void blob.arrayBuffer().then((value) => {
115
+ this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(value).toString('base64')}`;
116
+ this.onloadend?.();
117
+ });
118
+ }
119
+ };
120
+ }
121
+ return () => {
122
+ for (const key of nodeThreePolyfillKeys) {
123
+ const descriptor = previous.get(key);
124
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
125
+ else Reflect.deleteProperty(globalThis, key);
126
+ }
127
+ };
128
+ }
129
+
130
+ async function resolvePublicFile(projectRoot: string, path: string): Promise<string> {
131
+ const relativePath = path.replace(/^\//, '');
132
+ const publicRoot = await realpath(resolve(projectRoot, 'public'));
133
+ const candidate = await realpath(resolve(publicRoot, relativePath));
134
+ if (candidate !== publicRoot && !candidate.startsWith(`${publicRoot}${sep}`)) {
135
+ throw new Error(`clipSource must resolve inside the project's public/ directory: ${path}`);
136
+ }
137
+ return candidate;
138
+ }
139
+
140
+ async function loadRetargetedClips(
141
+ projectRoot: string,
142
+ sourcePath: string,
143
+ names: string[],
144
+ rig: ReturnType<typeof generateHumanoid>,
145
+ ) {
146
+ const absolute = await resolvePublicFile(projectRoot, sourcePath);
147
+ const bytes = await readFile(absolute);
148
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
149
+ const gltf = await new GLTFLoader().parseAsync(arrayBuffer, `${dirname(absolute)}${sep}`);
150
+ const library = createHumanoidClipLibrary(gltf.animations);
151
+ const clips = names.map((name) => {
152
+ const clip = retargetClipToHumanoid(library.get(name), rig);
153
+ clip.name = name;
154
+ return clip;
155
+ });
156
+ gltf.scene.traverse((object) => {
157
+ const mesh = object as typeof object & {
158
+ geometry?: { dispose(): void };
159
+ material?: { dispose(): void } | Array<{ dispose(): void }>;
160
+ };
161
+ mesh.geometry?.dispose();
162
+ for (const material of Array.isArray(mesh.material)
163
+ ? mesh.material
164
+ : mesh.material
165
+ ? [mesh.material]
166
+ : []) {
167
+ material.dispose();
168
+ }
169
+ });
170
+ return { clips };
171
+ }
172
+
173
+ async function exportGlb(
174
+ root: ReturnType<typeof generateHumanoid>['root'],
175
+ animations: Awaited<ReturnType<typeof loadRetargetedClips>>['clips'],
176
+ ): Promise<Uint8Array> {
177
+ const result = await new Promise<ArrayBuffer>((resolveResult, reject) => {
178
+ new GLTFExporter().parse(
179
+ root,
180
+ (value) => {
181
+ if (value instanceof ArrayBuffer) resolveResult(value);
182
+ else reject(new Error('GLTFExporter returned JSON while binary output was requested.'));
183
+ },
184
+ reject,
185
+ {
186
+ binary: true,
187
+ animations,
188
+ onlyVisible: false,
189
+ },
190
+ );
191
+ });
192
+ return new Uint8Array(result);
193
+ }
194
+
195
+ export const tool = {
196
+ name: 'project.humanoid.bake',
197
+ summary: 'Bake a procedural humanoid into an ordinary GLB asset and prefab.',
198
+ description:
199
+ 'Generates VGAI’s skinned procedural humanoid from body parameters. Optionally retargets ' +
200
+ 'Mixamo-named clips from a project-public GLB, then atomically writes the generated GLB, ' +
201
+ 'a normal .prefab.json referencing it, with automatic project provenance. Use the prefab as ' +
202
+ 'the authored scene object; do not regenerate the visible body every gameplay run. The ' +
203
+ 'baked body keeps the multicolored engineering-reference material: it is not a styled ' +
204
+ 'production character. A prominent player character should receive an intentional, ' +
205
+ 'project-owned material/composition treatment unless that reference look is deliberate.',
206
+ input: HumanoidBakeInputSchema,
207
+ result: HumanoidBakeResultSchema,
208
+ errors: [],
209
+ requires: { project: true },
210
+ host: 'node' as const,
211
+ mutates: true,
212
+ supportsDryRun: true,
213
+ longRunning: true,
214
+ permission: {
215
+ risk: 'write' as const,
216
+ summary: 'Writes or replaces a generated model and prefab under public/.',
217
+ },
218
+ async impl(input: z.infer<typeof HumanoidBakeInputSchema>, ctx: ProjectToolContext) {
219
+ if (!ctx.projectRoot || !ctx.projectOutputs) {
220
+ throw new Error('project.humanoid.bake requires a project root and generated-output writer.');
221
+ }
222
+ if (ctx.signal?.aborted) throw new Error('project.humanoid.bake was cancelled.');
223
+
224
+ const rig = generateHumanoid(input.params);
225
+ const restoreNodeGlobals = installNodeThreePolyfills();
226
+ try {
227
+ const loaded = input.clipSource
228
+ ? await loadRetargetedClips(ctx.projectRoot, input.clipSource, input.clipNames, rig)
229
+ : { clips: [] };
230
+ const glb = await exportGlb(rig.root, loaded.clips);
231
+ const base = input.name;
232
+ const model = `public/models/generated/${base}.glb`;
233
+ const prefab = `public/prefabs/generated/${base}.prefab.json`;
234
+ const modelUrl = `/models/generated/${base}.glb`;
235
+ const prefabDocument = {
236
+ version: 1,
237
+ name: base,
238
+ root: {
239
+ name: base,
240
+ mesh: { type: 'gltf', src: modelUrl },
241
+ ...(loaded.clips.length > 0 ? { animation: {} } : {}),
242
+ shadow: { enabled: true },
243
+ },
244
+ };
245
+ const output = await ctx.projectOutputs.write(
246
+ [
247
+ {
248
+ path: model,
249
+ content: glb,
250
+ mediaType: 'model/gltf-binary',
251
+ role: 'asset',
252
+ },
253
+ {
254
+ path: prefab,
255
+ content: `${JSON.stringify(prefabDocument, null, 2)}\n`,
256
+ mediaType: 'application/json',
257
+ role: 'prefab',
258
+ },
259
+ ],
260
+ { dryRun: input.dryRun },
261
+ );
262
+ return {
263
+ model,
264
+ prefab,
265
+ vertexCount: rig.vertexCount,
266
+ clips: loaded.clips.map((clip) => clip.name),
267
+ ...output,
268
+ };
269
+ } finally {
270
+ rig.dispose();
271
+ restoreNodeGlobals();
272
+ }
273
+ },
274
+ };
@@ -1,326 +0,0 @@
1
- /**
2
- * Package-contributed Project Command for baking the procedural humanoid into
3
- * ordinary project assets. The editor discovers this module through
4
- * @vgai/engine's package.json; the editor itself contains no humanoid branch.
5
- */
6
-
7
- import { createHash } from 'node:crypto';
8
- import { readFile, realpath } from 'node:fs/promises';
9
- import { dirname, resolve, sep } from 'node:path';
10
- import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
11
- import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
12
- import { z } from 'zod';
13
- import { createHumanoidClipLibrary, retargetClipToHumanoid } from './clips';
14
- import { generateHumanoid } from './generate';
15
- import { HumanoidParamsSchema } from './schema';
16
-
17
- const InputSchema = z.object({
18
- name: z
19
- .string()
20
- .regex(/^[a-z0-9][a-z0-9-_]{0,63}$/)
21
- .default('humanoid')
22
- .describe(
23
- 'Stable lowercase asset name used for the generated GLB, prefab, and provenance files.',
24
- ),
25
- params: HumanoidParamsSchema.default(HumanoidParamsSchema.parse({})).describe(
26
- 'Procedural body proportions.',
27
- ),
28
- clipSource: z
29
- .string()
30
- .optional()
31
- .describe(
32
- 'Optional project-public GLB path containing Mixamo-named clips. When supplied, Idle, Walk, and Run are retargeted and baked into the generated humanoid GLB.',
33
- ),
34
- clipNames: z
35
- .array(z.string())
36
- .min(1)
37
- .default(['Idle', 'Walk', 'Run'])
38
- .describe('Exact standard clip names to retarget from clipSource.'),
39
- dryRun: z
40
- .boolean()
41
- .default(false)
42
- .describe('Generate and validate the complete output batch without writing project files.'),
43
- });
44
-
45
- const OutputFileSchema = z.object({
46
- path: z.string(),
47
- bytes: z.number(),
48
- mediaType: z.string().optional(),
49
- role: z.enum(['asset', 'prefab', 'provenance', 'other']).optional(),
50
- });
51
-
52
- const ResultSchema = z.object({
53
- model: z.string(),
54
- prefab: z.string(),
55
- provenance: z.string(),
56
- vertexCount: z.number(),
57
- clips: z.array(z.string()),
58
- files: z.array(OutputFileSchema),
59
- totalBytes: z.number(),
60
- dryRun: z.boolean(),
61
- });
62
-
63
- interface OutputWriter {
64
- write(
65
- files: ReadonlyArray<{
66
- path: string;
67
- content: string | Uint8Array;
68
- mediaType?: string;
69
- role?: 'asset' | 'prefab' | 'provenance' | 'other';
70
- }>,
71
- options?: { dryRun?: boolean },
72
- ): Promise<{
73
- files: z.infer<typeof OutputFileSchema>[];
74
- totalBytes: number;
75
- dryRun: boolean;
76
- }>;
77
- }
78
-
79
- interface ProjectOperationContext {
80
- projectRoot?: string;
81
- projectOutputs?: OutputWriter;
82
- signal?: AbortSignal;
83
- }
84
-
85
- const NODE_THREE_POLYFILL_KEYS = [
86
- 'self',
87
- 'ProgressEvent',
88
- 'createImageBitmap',
89
- 'FileReader',
90
- ] as const;
91
- let nodeThreePolyfillDepth = 0;
92
- let nodeThreePreviousDescriptors: Record<
93
- (typeof NODE_THREE_POLYFILL_KEYS)[number],
94
- PropertyDescriptor | undefined
95
- > | null = null;
96
-
97
- /**
98
- * Three's official GLTFLoader/GLTFExporter addons expect a small browser API
99
- * surface even when invoked by this declared Node-only project operation.
100
- * Install that surface for the duration of overlapping bakes and restore the
101
- * editor-server process exactly when the last caller releases it.
102
- */
103
- function installNodeThreePolyfills(): () => void {
104
- const target = globalThis as unknown as Record<string, unknown>;
105
- if (nodeThreePolyfillDepth === 0) {
106
- nodeThreePreviousDescriptors = Object.fromEntries(
107
- NODE_THREE_POLYFILL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(target, key)]),
108
- ) as NonNullable<typeof nodeThreePreviousDescriptors>;
109
- if (!target['self']) target['self'] = globalThis;
110
- if (!target['ProgressEvent']) {
111
- target['ProgressEvent'] = class {
112
- readonly type: string;
113
- constructor(type: string, init: Record<string, unknown> = {}) {
114
- this.type = type;
115
- Object.assign(this, init);
116
- }
117
- };
118
- }
119
- if (!target['createImageBitmap']) {
120
- target['createImageBitmap'] = async () => ({ close() {} });
121
- }
122
- if (!target['FileReader']) {
123
- target['FileReader'] = class {
124
- result: string | ArrayBuffer | null = null;
125
- onloadend: (() => void) | null = null;
126
- readAsArrayBuffer(blob: Blob): void {
127
- void blob.arrayBuffer().then((value) => {
128
- this.result = value;
129
- this.onloadend?.();
130
- });
131
- }
132
- readAsDataURL(blob: Blob): void {
133
- void blob.arrayBuffer().then((value) => {
134
- this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(value).toString('base64')}`;
135
- this.onloadend?.();
136
- });
137
- }
138
- };
139
- }
140
- }
141
- nodeThreePolyfillDepth++;
142
- let released = false;
143
- return () => {
144
- if (released) return;
145
- released = true;
146
- nodeThreePolyfillDepth--;
147
- if (nodeThreePolyfillDepth !== 0 || !nodeThreePreviousDescriptors) return;
148
- for (const key of NODE_THREE_POLYFILL_KEYS) {
149
- const descriptor = nodeThreePreviousDescriptors[key];
150
- if (descriptor) Object.defineProperty(target, key, descriptor);
151
- else delete target[key];
152
- }
153
- nodeThreePreviousDescriptors = null;
154
- };
155
- }
156
-
157
- async function resolvePublicFile(projectRoot: string, path: string): Promise<string> {
158
- const relativePath = path.replace(/^\//, '');
159
- const publicRoot = await realpath(resolve(projectRoot, 'public'));
160
- const candidate = await realpath(resolve(publicRoot, relativePath));
161
- if (candidate !== publicRoot && !candidate.startsWith(`${publicRoot}${sep}`)) {
162
- throw new Error(`clipSource must resolve inside the project's public/ directory: ${path}`);
163
- }
164
- return candidate;
165
- }
166
-
167
- async function loadRetargetedClips(
168
- projectRoot: string,
169
- sourcePath: string,
170
- names: string[],
171
- rig: ReturnType<typeof generateHumanoid>,
172
- ) {
173
- const releasePolyfills = installNodeThreePolyfills();
174
- try {
175
- const absolute = await resolvePublicFile(projectRoot, sourcePath);
176
- const bytes = await readFile(absolute);
177
- const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
178
- const gltf = await new GLTFLoader().parseAsync(arrayBuffer, `${dirname(absolute)}${sep}`);
179
- const library = createHumanoidClipLibrary(gltf.animations);
180
- const clips = names.map((name) => {
181
- const clip = retargetClipToHumanoid(library.get(name), rig);
182
- clip.name = name;
183
- return clip;
184
- });
185
- gltf.scene.traverse((object) => {
186
- const mesh = object as typeof object & {
187
- geometry?: { dispose(): void };
188
- material?: { dispose(): void } | Array<{ dispose(): void }>;
189
- };
190
- mesh.geometry?.dispose();
191
- for (const material of Array.isArray(mesh.material)
192
- ? mesh.material
193
- : mesh.material
194
- ? [mesh.material]
195
- : []) {
196
- material.dispose();
197
- }
198
- });
199
- return { clips, sourceSha256: createHash('sha256').update(bytes).digest('hex') };
200
- } finally {
201
- releasePolyfills();
202
- }
203
- }
204
-
205
- async function exportGlb(
206
- root: ReturnType<typeof generateHumanoid>['root'],
207
- animations: Awaited<ReturnType<typeof loadRetargetedClips>>['clips'],
208
- ): Promise<Uint8Array> {
209
- const releasePolyfills = installNodeThreePolyfills();
210
- try {
211
- const result = await new Promise<ArrayBuffer>((resolveResult, reject) => {
212
- new GLTFExporter().parse(
213
- root,
214
- (value) => {
215
- if (value instanceof ArrayBuffer) resolveResult(value);
216
- else reject(new Error('GLTFExporter returned JSON while binary output was requested.'));
217
- },
218
- reject,
219
- {
220
- binary: true,
221
- animations,
222
- onlyVisible: false,
223
- },
224
- );
225
- });
226
- return new Uint8Array(result);
227
- } finally {
228
- releasePolyfills();
229
- }
230
- }
231
-
232
- export const operation = {
233
- name: 'project.humanoid.bake',
234
- summary: 'Bake a procedural humanoid into an ordinary GLB asset and prefab.',
235
- description:
236
- 'Generates VGAI’s skinned procedural humanoid from body parameters. Optionally retargets ' +
237
- 'Mixamo-named clips from a project-public GLB, then atomically writes the generated GLB, ' +
238
- 'a normal .prefab.json referencing it, and reproducibility provenance. Use the prefab as ' +
239
- 'the authored scene object; do not regenerate the visible body every gameplay run. The ' +
240
- 'baked body keeps the multicolored engineering-reference material: it is not a styled ' +
241
- 'production character. A prominent player character should receive an intentional, ' +
242
- 'project-owned material/composition treatment unless that reference look is deliberate.',
243
- input: InputSchema,
244
- result: ResultSchema,
245
- errors: [],
246
- requires: { project: true },
247
- host: 'node' as const,
248
- mutates: true,
249
- supportsDryRun: true,
250
- longRunning: true,
251
- permission: {
252
- risk: 'write' as const,
253
- summary: 'Writes or replaces generated model, prefab, and provenance files under public/.',
254
- },
255
- async impl(input: z.infer<typeof InputSchema>, ctx: ProjectOperationContext) {
256
- if (!ctx.projectRoot || !ctx.projectOutputs) {
257
- throw new Error('project.humanoid.bake requires a project root and generated-output writer.');
258
- }
259
- if (ctx.signal?.aborted) throw new Error('project.humanoid.bake was cancelled.');
260
-
261
- const rig = generateHumanoid(input.params);
262
- try {
263
- const loaded = input.clipSource
264
- ? await loadRetargetedClips(ctx.projectRoot, input.clipSource, input.clipNames, rig)
265
- : { clips: [], sourceSha256: null };
266
- const glb = await exportGlb(rig.root, loaded.clips);
267
- const base = input.name;
268
- const model = `public/models/generated/${base}.glb`;
269
- const prefab = `public/prefabs/generated/${base}.prefab.json`;
270
- const provenance = `public/models/generated/${base}.provenance.json`;
271
- const modelUrl = `/models/generated/${base}.glb`;
272
- const prefabDocument = {
273
- version: 1,
274
- name: base,
275
- root: {
276
- name: base,
277
- mesh: { type: 'gltf', src: modelUrl },
278
- ...(loaded.clips.length > 0 ? { animation: {} } : {}),
279
- shadow: { enabled: true },
280
- },
281
- };
282
- const provenanceDocument = {
283
- version: 1,
284
- generator: '@vgai/engine:project.humanoid.bake',
285
- params: rig.params,
286
- clipSource: input.clipSource ?? null,
287
- clipSourceSha256: loaded.sourceSha256,
288
- clips: loaded.clips.map((clip) => clip.name),
289
- modelSha256: createHash('sha256').update(glb).digest('hex'),
290
- };
291
- const output = await ctx.projectOutputs.write(
292
- [
293
- {
294
- path: model,
295
- content: glb,
296
- mediaType: 'model/gltf-binary',
297
- role: 'asset',
298
- },
299
- {
300
- path: prefab,
301
- content: `${JSON.stringify(prefabDocument, null, 2)}\n`,
302
- mediaType: 'application/json',
303
- role: 'prefab',
304
- },
305
- {
306
- path: provenance,
307
- content: `${JSON.stringify(provenanceDocument, null, 2)}\n`,
308
- mediaType: 'application/json',
309
- role: 'provenance',
310
- },
311
- ],
312
- { dryRun: input.dryRun },
313
- );
314
- return {
315
- model,
316
- prefab,
317
- provenance,
318
- vertexCount: rig.vertexCount,
319
- clips: loaded.clips.map((clip) => clip.name),
320
- ...output,
321
- };
322
- } finally {
323
- rig.dispose();
324
- }
325
- },
326
- };