@looop-games/cli 0.1.19 → 0.1.21
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/CHANGELOG.md +39 -0
- package/lib/model-bake.mjs +324 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,45 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.21] - 2026-07-30
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`looop model bake` resolves a pivot for the model.** `<model>.bake.json`
|
|
22
|
+
may declare `"pivot": "origin"` (default) | `"center"` | `"feet"` |
|
|
23
|
+
`[x, y, z]` — the model-space point that lands on your entity's
|
|
24
|
+
`body.(x,y,z)`. The bake resolves it against the bind-pose bounding box,
|
|
25
|
+
writes it into the skeleton artifact, and measures the broadphase radius
|
|
26
|
+
about it (a sphere centred on the pivot but measured from the origin could
|
|
27
|
+
silently reject true edge hits). Every model re-bakes once on your next
|
|
28
|
+
`looop dev`/`test`/`publish` to pick up the new artifact format; without a
|
|
29
|
+
`pivot` entry nothing changes in how it plays. Opting into a non-origin
|
|
30
|
+
pivot needs an engine that reads it (0.1.35+) — run `npx looop update`
|
|
31
|
+
first, or the room will place the model by its origin while the broadphase
|
|
32
|
+
is measured about the pivot.
|
|
33
|
+
|
|
34
|
+
## [0.1.20] - 2026-07-29
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Generator scripts: `looop model bake` can run your code at bake time.**
|
|
39
|
+
Name a game-committed script in `<model>.bake.json`
|
|
40
|
+
(`{ "generate": "creature-gen.mjs" }`) and the bake hands it the parsed
|
|
41
|
+
model. Export `clips(model)` to bake a procedural pose function into
|
|
42
|
+
additive clips (sampled into delta tracks the engine's layer slots play —
|
|
43
|
+
see the engine changelog), and/or `zones(model)` to label triangles your
|
|
44
|
+
own way when bone names can't describe the mesh (one byte per triangle +
|
|
45
|
+
a name table). The script must be deterministic — the bake refuses
|
|
46
|
+
`Math.random()`-shaped output — and its text joins the staleness hash, so
|
|
47
|
+
editing the maths re-bakes automatically, exactly like editing the GLB.
|
|
48
|
+
The broadphase radius grows to contain every generated pose at weight 1.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- Baked artifacts re-bake once after this update (the artifact format version
|
|
53
|
+
advanced to carry generated clips). No action needed — the next
|
|
54
|
+
`looop dev`/`test`/`publish` does it.
|
|
55
|
+
|
|
17
56
|
## [0.1.19] - 2026-07-22
|
|
18
57
|
|
|
19
58
|
### Added
|
package/lib/model-bake.mjs
CHANGED
|
@@ -28,14 +28,23 @@
|
|
|
28
28
|
// ── Optional bake config: <model>.bake.json next to the GLB ──────────────────
|
|
29
29
|
// {
|
|
30
30
|
// "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
|
|
31
|
-
// "clips": ["fly", "idle"]
|
|
32
|
-
//
|
|
31
|
+
// "clips": ["fly", "idle"], // subset to bake; default = all
|
|
32
|
+
// "generate": "drifter-gen.mjs", // generator script, relative to the GLB
|
|
33
|
+
// "pivot": "center" // where body.(x,y,z) sits in the model:
|
|
34
|
+
// } // "origin" (default) | "center" | "feet" | [x,y,z]
|
|
33
35
|
// Zones use the dominant-bone rule (a triangle belongs to whichever bone most
|
|
34
36
|
// influences its vertices — the same rule the big engines use to size their
|
|
35
37
|
// bone-attached proxy shapes, pointed at triangles). Patterns match bone
|
|
36
38
|
// names, `*` is a wildcard. Without config the mesh gets NO zones: hits still land
|
|
37
39
|
// on the exact surface, damage is uniform, and `hitRay` reports `zone: null`.
|
|
38
40
|
//
|
|
41
|
+
// `generate` names a game-committed script that runs AT BAKE TIME with the
|
|
42
|
+
// parsed model and returns data the engine carries: additive clips baked from a
|
|
43
|
+
// procedural pose function (`export clips`) and/or per-triangle zone bytes for
|
|
44
|
+
// meshes bone names can't describe (`export zones`). See the generator-script
|
|
45
|
+
// section below for the exact contract. The script's text participates in the
|
|
46
|
+
// staleness hash, so editing it re-bakes exactly like editing the GLB.
|
|
47
|
+
//
|
|
39
48
|
// ── What a bake rejects, loudly ──────────────────────────────────────────────
|
|
40
49
|
// - scale tracks in a clip (the runtime rig is uniform-scale; a scaling clip
|
|
41
50
|
// cannot be reproduced, so it fails the bake instead of silently drifting)
|
|
@@ -49,11 +58,16 @@
|
|
|
49
58
|
|
|
50
59
|
import { createHash } from 'node:crypto';
|
|
51
60
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
-
import { join, resolve, basename } from 'node:path';
|
|
61
|
+
import { join, resolve, basename, dirname } from 'node:path';
|
|
62
|
+
import { pathToFileURL } from 'node:url';
|
|
53
63
|
|
|
54
64
|
// Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
|
|
55
65
|
// meaning — it participates in the staleness hash, so old artifacts re-bake.
|
|
56
|
-
|
|
66
|
+
// v2: the staleness hash gained the generator-script component, and skeletons
|
|
67
|
+
// may carry generated additive clips.
|
|
68
|
+
// v3: skeletons carry a resolved `pivot`, and the broadphase radius is
|
|
69
|
+
// measured about it instead of the model origin.
|
|
70
|
+
export const BAKE_VERSION = 3;
|
|
57
71
|
|
|
58
72
|
// ── pure helpers (unit-tested without a browser) ─────────────────────────────
|
|
59
73
|
|
|
@@ -95,6 +109,24 @@ export function boneZonesFromConfig(boneNames, zonesConfig) {
|
|
|
95
109
|
return { names, byBone };
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
// Resolve the config's `pivot` — the model-space point that lands ON the
|
|
113
|
+
// entity's body.(x,y,z) — against the bind-pose bounding box. There is no
|
|
114
|
+
// universal right anchor (a flyer wants its centre, a walker its feet, a turret
|
|
115
|
+
// its base), so it's a per-model choice; "origin" reproduces the GLB's own
|
|
116
|
+
// origin and is the default. The resolved vector is written into the skeleton
|
|
117
|
+
// artifact; the runtime composes it into PLACEMENT only, never the inverse
|
|
118
|
+
// bind, so skinning is unaffected.
|
|
119
|
+
export function resolvePivot(pivotCfg, bbox) {
|
|
120
|
+
if (pivotCfg == null || pivotCfg === 'origin') return [0, 0, 0];
|
|
121
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
122
|
+
if (pivotCfg === 'center') return [mid(0), mid(1), mid(2)];
|
|
123
|
+
if (pivotCfg === 'feet') return [mid(0), bbox.min[1], mid(2)];
|
|
124
|
+
if (Array.isArray(pivotCfg) && pivotCfg.length === 3 && pivotCfg.every((n) => Number.isFinite(n))) {
|
|
125
|
+
return [pivotCfg[0], pivotCfg[1], pivotCfg[2]];
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(pivotCfg)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
// The dominant bone of a triangle: the bone with the highest summed skin weight
|
|
99
131
|
// across its three vertices. `acc` is scratch sized to the bone count.
|
|
100
132
|
export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
|
|
@@ -112,13 +144,16 @@ export function sha256(buf) {
|
|
|
112
144
|
return createHash('sha256').update(buf).digest('hex');
|
|
113
145
|
}
|
|
114
146
|
|
|
115
|
-
// The staleness key an artifact records: GLB bytes + bake config +
|
|
116
|
-
// If any of the
|
|
117
|
-
|
|
147
|
+
// The staleness key an artifact records: GLB bytes + bake config + generator
|
|
148
|
+
// script + bake version. If any of the four changes, the artifact is stale and
|
|
149
|
+
// auto-bake re-runs — editing the generator's wing maths re-bakes exactly like
|
|
150
|
+
// editing the GLB does.
|
|
151
|
+
export function sourceHash(glbBytes, configText, generatorText = '') {
|
|
118
152
|
return sha256(Buffer.concat([
|
|
119
153
|
Buffer.from(`bake${BAKE_VERSION}!`),
|
|
120
154
|
Buffer.from(sha256(glbBytes)),
|
|
121
155
|
Buffer.from(sha256(Buffer.from(configText ?? ''))),
|
|
156
|
+
Buffer.from(sha256(Buffer.from(generatorText ?? ''))),
|
|
122
157
|
]));
|
|
123
158
|
}
|
|
124
159
|
|
|
@@ -127,12 +162,28 @@ export function artifactPaths(glbPath) {
|
|
|
127
162
|
return { skeleton: `${stem}.skeleton.json`, mesh: `${stem}.mesh.json`, config: `${stem}.bake.json` };
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
//
|
|
165
|
+
// The generator script's current text, resolved from the bake config — '' when
|
|
166
|
+
// the config names none. A named-but-missing script reads as null so staleness
|
|
167
|
+
// can answer "stale" and the bake itself can fail with the real error.
|
|
168
|
+
function generatorText(glbPath, configText) {
|
|
169
|
+
if (!configText) return '';
|
|
170
|
+
let gen;
|
|
171
|
+
try { gen = JSON.parse(configText)?.generate; } catch { return ''; } // bad JSON fails in bakeModel with the real message
|
|
172
|
+
if (!gen) return '';
|
|
173
|
+
const p = resolve(dirname(glbPath), gen);
|
|
174
|
+
if (!existsSync(p)) return null;
|
|
175
|
+
return readFileSync(p, 'utf8');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Is the artifact pair current for this GLB + config + generator? (Missing → stale.)
|
|
131
179
|
export function isStale(glbPath) {
|
|
132
180
|
const { skeleton, mesh, config } = artifactPaths(glbPath);
|
|
133
181
|
if (!existsSync(skeleton) || !existsSync(mesh)) return true;
|
|
134
182
|
try {
|
|
135
|
-
const
|
|
183
|
+
const configText = existsSync(config) ? readFileSync(config, 'utf8') : '';
|
|
184
|
+
const genText = generatorText(glbPath, configText);
|
|
185
|
+
if (genText === null) return true; // config names a script that is gone
|
|
186
|
+
const want = sourceHash(readFileSync(glbPath), configText, genText);
|
|
136
187
|
const have = JSON.parse(readFileSync(skeleton, 'utf8'))?._source?.hash;
|
|
137
188
|
return have !== want;
|
|
138
189
|
} catch {
|
|
@@ -140,6 +191,134 @@ export function isStale(glbPath) {
|
|
|
140
191
|
}
|
|
141
192
|
}
|
|
142
193
|
|
|
194
|
+
// ── generator scripts: game code runs at bake time, the engine ships data ────
|
|
195
|
+
// bake.json may name a game-committed script: { "generate": "drifter-gen.mjs" }
|
|
196
|
+
// (path relative to the GLB). The script receives the parsed model and returns
|
|
197
|
+
// DATA — additive clip samples and/or per-triangle zone bytes. The engine
|
|
198
|
+
// validates and carries what it returns; it learns no geometric language and
|
|
199
|
+
// runs no game code after the bake. Two exports, both optional, each either the
|
|
200
|
+
// value or a function of the model:
|
|
201
|
+
//
|
|
202
|
+
// export function clips(model) {
|
|
203
|
+
// return { beat: { duration: 1, additive: true, keys: 64, sample: (t) => ({
|
|
204
|
+
// WingL: { rot: [x, y, z, w] }, // delta from rest, unit quaternion
|
|
205
|
+
// Chest: { pos: [x, y, z] }, // delta from rest, model units
|
|
206
|
+
// }) } };
|
|
207
|
+
// }
|
|
208
|
+
// export function zones(model) {
|
|
209
|
+
// return { names: ['body', 'wing'], tri: bytes }; // one byte per triangle
|
|
210
|
+
// }
|
|
211
|
+
//
|
|
212
|
+
// `model` = { bones, clips, mesh: { vertexCount, triangleCount, influences,
|
|
213
|
+
// position, skinIndex, skinWeight, index } } — everything the bake read from
|
|
214
|
+
// the GLB, typed arrays decoded.
|
|
215
|
+
//
|
|
216
|
+
// sample(t) must be DETERMINISTIC (no Math.random(), no Date.now()): the tracks
|
|
217
|
+
// it produces are replicated pose truth, and a re-bake must reproduce them
|
|
218
|
+
// byte-identically or the staleness hash lies.
|
|
219
|
+
|
|
220
|
+
// Sample each generated clip definition into baked additive tracks.
|
|
221
|
+
export function buildGeneratedClips(defs, boneNames, existingClipNames = []) {
|
|
222
|
+
const known = new Set(boneNames);
|
|
223
|
+
const existing = new Set(existingClipNames);
|
|
224
|
+
const clips = {};
|
|
225
|
+
for (const [name, def] of Object.entries(defs ?? {})) {
|
|
226
|
+
if (existing.has(name)) {
|
|
227
|
+
throw new Error(`generated clip "${name}" collides with a clip already in the GLB — rename one of them`);
|
|
228
|
+
}
|
|
229
|
+
if (def?.additive !== true) {
|
|
230
|
+
throw new Error(`generated clip "${name}" must declare additive: true — generated clips are deltas layered over the base pose; base clips come from the GLB`);
|
|
231
|
+
}
|
|
232
|
+
if (typeof def.sample !== 'function') {
|
|
233
|
+
throw new Error(`generated clip "${name}": "sample" must be a function of time`);
|
|
234
|
+
}
|
|
235
|
+
const duration = def.duration ?? 0;
|
|
236
|
+
// Key times are INCLUSIVE of the end (0..duration), so a looping clip
|
|
237
|
+
// interpolates across the seam instead of stepping at the wrap.
|
|
238
|
+
const keys = duration > 0 ? Math.max(2, Math.round(def.keys ?? 64)) : 0;
|
|
239
|
+
const times = duration > 0
|
|
240
|
+
? Array.from({ length: keys + 1 }, (_, k) => (k / keys) * duration)
|
|
241
|
+
: [0];
|
|
242
|
+
|
|
243
|
+
// Determinism probe: the cheapest check that catches the whole class of
|
|
244
|
+
// Math.random()/Date.now() mistakes before they become flickering re-bakes.
|
|
245
|
+
if (JSON.stringify(def.sample(times[0])) !== JSON.stringify(def.sample(times[0]))) {
|
|
246
|
+
throw new Error(`generated clip "${name}": sample(t) is not deterministic — identical t must return identical output (no Math.random(), no Date.now()); these tracks are replicated pose truth and a re-bake must reproduce them exactly`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let shape = null; // bone -> { rot: bool, pos: bool }, fixed by the first sample
|
|
250
|
+
const acc = new Map(); // bone -> { times, rot[], pos[] }
|
|
251
|
+
for (const t of times) {
|
|
252
|
+
const frame = def.sample(t) ?? {};
|
|
253
|
+
const frameBones = Object.keys(frame);
|
|
254
|
+
if (shape === null) {
|
|
255
|
+
shape = new Map();
|
|
256
|
+
for (const bn of frameBones) {
|
|
257
|
+
if (!known.has(bn)) {
|
|
258
|
+
throw new Error(`generated clip "${name}": sample() returned bone "${bn}" — the skeleton has no such bone. Bones are: ${boneNames.join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
shape.set(bn, { rot: !!frame[bn]?.rot, pos: !!frame[bn]?.pos });
|
|
261
|
+
}
|
|
262
|
+
if (!shape.size) throw new Error(`generated clip "${name}": sample() returned no bones`);
|
|
263
|
+
} else if (frameBones.length !== shape.size || !frameBones.every((bn) => shape.has(bn))) {
|
|
264
|
+
throw new Error(`generated clip "${name}": sample() must return the same bones (with the same rot/pos properties) at every time — t=${t} differs from t=${times[0]}`);
|
|
265
|
+
}
|
|
266
|
+
for (const [bn, spec] of shape) {
|
|
267
|
+
const v = frame[bn];
|
|
268
|
+
if (!!v?.rot !== spec.rot || !!v?.pos !== spec.pos) {
|
|
269
|
+
throw new Error(`generated clip "${name}": sample() must return the same bones (with the same rot/pos properties) at every time — bone "${bn}" at t=${t} differs`);
|
|
270
|
+
}
|
|
271
|
+
let entry = acc.get(bn);
|
|
272
|
+
if (!entry) { entry = { times: [], rot: spec.rot ? [] : null, pos: spec.pos ? [] : null }; acc.set(bn, entry); }
|
|
273
|
+
entry.times.push(t);
|
|
274
|
+
if (spec.rot) {
|
|
275
|
+
const q = v.rot;
|
|
276
|
+
const L = Math.hypot(q[0], q[1], q[2], q[3]);
|
|
277
|
+
if (!(L > 0) || Math.abs(L - 1) > 1e-3) {
|
|
278
|
+
throw new Error(`generated clip "${name}", bone "${bn}" at t=${t}: rotation is not unit length (${L.toFixed(4)}) — normalize your quaternions`);
|
|
279
|
+
}
|
|
280
|
+
entry.rot.push(q[0] / L, q[1] / L, q[2] / L, q[3] / L);
|
|
281
|
+
}
|
|
282
|
+
if (spec.pos) entry.pos.push(v.pos[0], v.pos[1], v.pos[2]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
clips[name] = {
|
|
286
|
+
duration,
|
|
287
|
+
additive: true,
|
|
288
|
+
tracks: [...acc.entries()].map(([bone, e]) => ({
|
|
289
|
+
bone, times: e.times,
|
|
290
|
+
...(e.rot ? { rot: e.rot } : {}),
|
|
291
|
+
...(e.pos ? { pos: e.pos } : {}),
|
|
292
|
+
})),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return clips;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validate a zone generator's return: bring-your-own-bytes, checked hard.
|
|
299
|
+
export function validateGeneratedZones(z, triangleCount) {
|
|
300
|
+
if (!z || !Array.isArray(z.names) || z.names.length === 0 || !z.names.every((n) => typeof n === 'string')) {
|
|
301
|
+
throw new Error('zone generator: "names" must be a non-empty array of zone name strings (index 0 is the default zone)');
|
|
302
|
+
}
|
|
303
|
+
if (z.names.length > 256) {
|
|
304
|
+
throw new Error(`zone generator: at most 256 zone names (got ${z.names.length}) — zones are stored one byte per triangle`);
|
|
305
|
+
}
|
|
306
|
+
const tri = z.tri;
|
|
307
|
+
const len = tri?.length ?? -1;
|
|
308
|
+
if (len !== triangleCount) {
|
|
309
|
+
throw new Error(`zone generator: "tri" must carry one byte per triangle — the mesh has ${triangleCount} triangles, got ${len}`);
|
|
310
|
+
}
|
|
311
|
+
const out = new Uint8Array(triangleCount);
|
|
312
|
+
for (let i = 0; i < triangleCount; i++) {
|
|
313
|
+
const v = tri[i];
|
|
314
|
+
if (!Number.isInteger(v) || v < 0 || v >= z.names.length) {
|
|
315
|
+
throw new Error(`zone generator: zone byte ${v} at triangle ${i} is out of range — must be an integer 0..${z.names.length - 1}`);
|
|
316
|
+
}
|
|
317
|
+
out[i] = v;
|
|
318
|
+
}
|
|
319
|
+
return { names: [...z.names], tri: out };
|
|
320
|
+
}
|
|
321
|
+
|
|
143
322
|
// ── the in-page bake ─────────────────────────────────────────────────────────
|
|
144
323
|
// Runs inside the browser with three + GLTFLoader importable. Everything it
|
|
145
324
|
// returns is plain JSON (typed arrays as b64).
|
|
@@ -320,18 +499,44 @@ async function bakeInPage(page, cfg) {
|
|
|
320
499
|
clips[anim.name] = { duration: anim.duration, tracks };
|
|
321
500
|
}
|
|
322
501
|
|
|
502
|
+
// ── pivot: where body.(x,y,z) sits in the model ──────────────────────────
|
|
503
|
+
// Resolved against the bind-pose bbox of the extracted positions. Mirrors
|
|
504
|
+
// resolvePivot — this copy runs in-page, because the broadphase radius
|
|
505
|
+
// below must be measured about the pivot: the runtime centres its sphere on
|
|
506
|
+
// body, and body IS the pivot point, so a radius measured about the origin
|
|
507
|
+
// could undersize the sphere and silently reject true edge hits.
|
|
508
|
+
const bbox = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
|
509
|
+
for (let i = 0; i < V * 3; i += 3) {
|
|
510
|
+
for (let a = 0; a < 3; a++) {
|
|
511
|
+
const c = position[i + a];
|
|
512
|
+
if (c < bbox.min[a]) bbox.min[a] = c;
|
|
513
|
+
if (c > bbox.max[a]) bbox.max[a] = c;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
let pivot;
|
|
517
|
+
{
|
|
518
|
+
const p = config.pivot;
|
|
519
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
520
|
+
if (p == null || p === 'origin') pivot = [0, 0, 0];
|
|
521
|
+
else if (p === 'center') pivot = [mid(0), mid(1), mid(2)];
|
|
522
|
+
else if (p === 'feet') pivot = [mid(0), bbox.min[1], mid(2)];
|
|
523
|
+
else if (Array.isArray(p) && p.length === 3 && p.every((n) => Number.isFinite(n))) pivot = [p[0], p[1], p[2]];
|
|
524
|
+
else return { error: `"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(p)}` };
|
|
525
|
+
}
|
|
526
|
+
|
|
323
527
|
// ── broadphase radius: worst case across rest + every baked clip ─────────
|
|
324
528
|
// The sphere exists to REJECT cheaply, so it must never reject a true hit:
|
|
325
529
|
// measure every pose the clips can produce, then carry 3% slack (a ray
|
|
326
530
|
// grazing an extremity is near-tangent, and float error then discards a hit
|
|
327
|
-
// that visibly connects).
|
|
531
|
+
// that visibly connects). Distances are about the pivot — the sphere's
|
|
532
|
+
// runtime centre.
|
|
328
533
|
let radius = 0;
|
|
329
534
|
const measure = () => {
|
|
330
535
|
scene.updateMatrixWorld(true);
|
|
331
536
|
sk.skeleton.update?.();
|
|
332
537
|
for (let i = 0; i < V; i++) {
|
|
333
538
|
sk.getVertexPosition(i, v3);
|
|
334
|
-
const d = Math.hypot(v3.x, v3.y, v3.z);
|
|
539
|
+
const d = Math.hypot(v3.x - pivot[0], v3.y - pivot[1], v3.z - pivot[2]);
|
|
335
540
|
if (d > radius) radius = d;
|
|
336
541
|
}
|
|
337
542
|
};
|
|
@@ -376,6 +581,7 @@ async function bakeInPage(page, cfg) {
|
|
|
376
581
|
return {
|
|
377
582
|
notes,
|
|
378
583
|
root: { t: [rp.x, rp.y, rp.z], r: [rq.x, rq.y, rq.z, rq.w], s: [rs.x, rs.y, rs.z] },
|
|
584
|
+
pivot,
|
|
379
585
|
bones: outBones,
|
|
380
586
|
clips,
|
|
381
587
|
mesh: {
|
|
@@ -404,19 +610,19 @@ async function bakeInPage(page, cfg) {
|
|
|
404
610
|
// models lives there, so the loader that bakes and the loader that draws move
|
|
405
611
|
// together, on engine releases. The bake never touches the network.
|
|
406
612
|
const VENDOR_REL = 'shared/ui/room/vendor/three-gltf.js';
|
|
407
|
-
function
|
|
613
|
+
function engineFile(dir, rel) {
|
|
408
614
|
const candidates = [
|
|
409
615
|
// A standalone game: the engine installed under node_modules.
|
|
410
|
-
join(dir, 'node_modules/@looop-games/engine',
|
|
616
|
+
join(dir, 'node_modules/@looop-games/engine', rel),
|
|
411
617
|
// This CLI running from inside the engine repo itself.
|
|
412
|
-
join(import.meta.dirname, '../../..',
|
|
618
|
+
join(import.meta.dirname, '../../..', rel),
|
|
413
619
|
];
|
|
414
620
|
for (const c of candidates) if (existsSync(c)) return c;
|
|
415
621
|
throw new Error(
|
|
416
|
-
|
|
417
|
-
+ `(${VENDOR_REL}) — run \`npx looop update\` to get a newer engine`,
|
|
622
|
+
`the installed engine does not include ${rel} — run \`npx looop update\` to get a newer engine`,
|
|
418
623
|
);
|
|
419
624
|
}
|
|
625
|
+
const threeBundle = (dir) => engineFile(dir, VENDOR_REL);
|
|
420
626
|
|
|
421
627
|
// ── the driver ───────────────────────────────────────────────────────────────
|
|
422
628
|
|
|
@@ -478,21 +684,118 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
|
|
|
478
684
|
if (w.badSums > 0) problems.push(`${w.badSums} vertices whose weights do not sum to ~1`);
|
|
479
685
|
if (problems.length) throw new Error(`BAKE INVALID:\n ${problems.join('\n ')}`);
|
|
480
686
|
|
|
481
|
-
const
|
|
687
|
+
const skeletonJson = {
|
|
688
|
+
root: { t: baked.root.t, r: baked.root.r, s: rootS },
|
|
689
|
+
pivot: baked.pivot,
|
|
690
|
+
bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
|
|
691
|
+
clips: baked.clips,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
// ── the generator script, if the config names one ────────────────────────
|
|
695
|
+
let genText = '';
|
|
696
|
+
let generatedNames = [];
|
|
697
|
+
let genZones = null;
|
|
698
|
+
if (cfg.generate) {
|
|
699
|
+
const scriptPath = resolve(dirname(abs), cfg.generate);
|
|
700
|
+
if (!existsSync(scriptPath)) {
|
|
701
|
+
throw new Error(`${basename(paths.config)} names generate: "${cfg.generate}" but ${scriptPath} does not exist`);
|
|
702
|
+
}
|
|
703
|
+
genText = readFileSync(scriptPath, 'utf8');
|
|
704
|
+
// The hash query re-imports a changed script within one process (node
|
|
705
|
+
// caches module URLs forever; ensureBaked may bake twice in one run).
|
|
706
|
+
const mod = await import(`${pathToFileURL(scriptPath).href}?v=${sha256(Buffer.from(genText)).slice(0, 12)}`);
|
|
707
|
+
if (!mod.clips && !mod.zones) {
|
|
708
|
+
throw new Error(`generator ${cfg.generate} exports neither clips nor zones — nothing to generate`);
|
|
709
|
+
}
|
|
710
|
+
const fromB64 = (Ctor, entry) => {
|
|
711
|
+
const buf = Buffer.from(entry.b64, 'base64');
|
|
712
|
+
return new Ctor(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
713
|
+
};
|
|
714
|
+
const model = {
|
|
715
|
+
bones: skeletonJson.bones,
|
|
716
|
+
clips: baked.clips,
|
|
717
|
+
mesh: {
|
|
718
|
+
vertexCount: baked.mesh.vertexCount,
|
|
719
|
+
triangleCount: baked.mesh.triangleCount,
|
|
720
|
+
influences: baked.mesh.influences,
|
|
721
|
+
position: fromB64(Float32Array, baked.mesh.position),
|
|
722
|
+
skinIndex: fromB64(Uint16Array, baked.mesh.skinIndex),
|
|
723
|
+
skinWeight: fromB64(Float32Array, baked.mesh.skinWeight),
|
|
724
|
+
index: fromB64(Uint32Array, baked.mesh.index),
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
const boneNames = skeletonJson.bones.map((b) => b.name);
|
|
728
|
+
if (mod.clips) {
|
|
729
|
+
const defs = typeof mod.clips === 'function' ? mod.clips(model) : mod.clips;
|
|
730
|
+
const generated = buildGeneratedClips(defs, boneNames, Object.keys(baked.clips));
|
|
731
|
+
generatedNames = Object.keys(generated);
|
|
732
|
+
Object.assign(skeletonJson.clips, generated);
|
|
733
|
+
|
|
734
|
+
// The broadphase sphere must contain every pose the LAYERS can reach —
|
|
735
|
+
// the in-page measure only saw the GLB's own clips. Skin the mesh
|
|
736
|
+
// through each generated clip alone at weight 1 and add each clip's
|
|
737
|
+
// worst vertex displacement to the radius. A sum over clips is
|
|
738
|
+
// deliberately conservative (layers combine): an oversized sphere only
|
|
739
|
+
// costs broadphase efficiency, an undersized one silently discards
|
|
740
|
+
// true hits on a flared wing. Weights are expected in [0, 1].
|
|
741
|
+
const rigLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/rig.js')).href);
|
|
742
|
+
const skinLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/skin-hit.js')).href);
|
|
743
|
+
const rig = rigLib.createRig(skeletonJson);
|
|
744
|
+
const root = { t: skeletonJson.root.t, r: skeletonJson.root.r, s: skeletonJson.root.s };
|
|
745
|
+
const skinned = skinLib.createSkinnedMesh({
|
|
746
|
+
vertexCount: model.mesh.vertexCount, influences: model.mesh.influences,
|
|
747
|
+
position: model.mesh.position, skinIndex: model.mesh.skinIndex,
|
|
748
|
+
skinWeight: model.mesh.skinWeight, index: model.mesh.index,
|
|
749
|
+
invBind: skinLib.restInvBind(rig, root),
|
|
750
|
+
});
|
|
751
|
+
const rest = Float32Array.from(skinLib.skin(skinned, rigLib.evaluate(rig, { root })));
|
|
752
|
+
let inflate = 0;
|
|
753
|
+
for (const name of generatedNames) {
|
|
754
|
+
const clip = skeletonJson.clips[name];
|
|
755
|
+
const STEPS = clip.duration > 0 ? 24 : 1;
|
|
756
|
+
let worst = 0;
|
|
757
|
+
for (let s = 0; s <= STEPS; s++) {
|
|
758
|
+
const t = clip.duration > 0 ? (clip.duration * s) / STEPS : 0;
|
|
759
|
+
const sample = rigLib.sampleAdditive(rig, name, t, 1);
|
|
760
|
+
const posed = skinLib.skin(skinned, rigLib.evaluate(rig, { ...sample, root }));
|
|
761
|
+
for (let v = 0; v < model.mesh.vertexCount * 3; v += 3) {
|
|
762
|
+
const d = Math.hypot(posed[v] - rest[v], posed[v + 1] - rest[v + 1], posed[v + 2] - rest[v + 2]);
|
|
763
|
+
if (d > worst) worst = d;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
inflate += worst;
|
|
767
|
+
}
|
|
768
|
+
if (inflate > 0) baked.mesh.radius = (baked.mesh.radius + inflate) * 1.03;
|
|
769
|
+
}
|
|
770
|
+
if (mod.zones) {
|
|
771
|
+
if (cfg.zones && Object.keys(cfg.zones).length) {
|
|
772
|
+
throw new Error(`${basename(paths.config)} has BOTH a "zones" bone-name map and a generator exporting zones() — pick one strategy per model`);
|
|
773
|
+
}
|
|
774
|
+
const raw = typeof mod.zones === 'function' ? mod.zones(model) : mod.zones;
|
|
775
|
+
genZones = validateGeneratedZones(raw, baked.mesh.triangleCount);
|
|
776
|
+
const b64 = Buffer.from(genZones.tri).toString('base64');
|
|
777
|
+
baked.mesh.zones = { names: genZones.names, tri: { b64 } };
|
|
778
|
+
const counts = {};
|
|
779
|
+
genZones.names.forEach((n) => { counts[n] = 0; });
|
|
780
|
+
for (const z of genZones.tri) counts[genZones.names[z]]++;
|
|
781
|
+
baked.stats.zoneCounts = counts;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const source = { hash: sourceHash(glbBytes, configText, genText), glb: basename(abs), bakeVersion: BAKE_VERSION };
|
|
482
786
|
const note = 'GENERATED by the Looop model baker — do not hand-edit; edit the GLB or its .bake.json and re-bake.';
|
|
483
787
|
writeFileSync(paths.skeleton, JSON.stringify({
|
|
484
788
|
_generated: note,
|
|
485
789
|
_source: source,
|
|
486
|
-
|
|
487
|
-
bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
|
|
488
|
-
clips: baked.clips,
|
|
790
|
+
...skeletonJson,
|
|
489
791
|
}, null, 1) + '\n');
|
|
490
792
|
writeFileSync(paths.mesh, JSON.stringify({ _generated: note, _source: source, ...baked.mesh }) + '\n');
|
|
491
793
|
|
|
492
794
|
const s = baked.stats;
|
|
493
795
|
log(`baked ${basename(abs)} → ${basename(paths.skeleton)} + ${basename(paths.mesh)}`);
|
|
494
|
-
log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${
|
|
796
|
+
log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${baked.mesh.radius.toFixed(3)}`);
|
|
495
797
|
log(` clips: ${s.clips.length ? s.clips.join(', ') : '(none in this GLB)'}`);
|
|
798
|
+
if (generatedNames.length) log(` generated additive clips: ${generatedNames.join(', ')}`);
|
|
496
799
|
if (s.zoneCounts) log(` zones: ${Object.entries(s.zoneCounts).map(([k, v]) => `${k} ${v}`).join(' ')}`);
|
|
497
800
|
for (const n of baked.notes) log(` note: ${n}`);
|
|
498
801
|
return { paths, stats: s, notes: baked.notes };
|