@joystack/cli 0.0.0-stage → 0.1.43
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 +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
// model bake — turn a rigged GLB into the artifacts the room needs to hit the
|
|
2
|
+
// model's true animated surface:
|
|
3
|
+
//
|
|
4
|
+
// <model>.skeleton.json bones (parent, rest transform) + baked clips — what
|
|
5
|
+
// shared/ui/room/lib/rig.js poses
|
|
6
|
+
// <model>.mesh.json vertices, skin weights, triangles, per-triangle zone
|
|
7
|
+
// bytes, broadphase radius — what skin-hit.js shoots
|
|
8
|
+
//
|
|
9
|
+
// Both are emitted TOGETHER from one read of one loaded model, because they share
|
|
10
|
+
// a bone ordering: the mesh's skin indices are meaningless against a differently
|
|
11
|
+
// ordered skeleton.
|
|
12
|
+
//
|
|
13
|
+
// The GLB is parsed by three.js's own GLTFLoader in a REAL browser (playwright),
|
|
14
|
+
// not by a node-side parser. The browser's loader is the same code that will draw
|
|
15
|
+
// the model at runtime, so whatever it decides about the asset (bone order, node
|
|
16
|
+
// transforms, interleaved buffers) is by construction what the drawn model does.
|
|
17
|
+
// A second parser would be a second opinion, and a second opinion is exactly the
|
|
18
|
+
// drift class this pipeline exists to delete. The page is hermetic: every request
|
|
19
|
+
// (three, the loader, the GLB bytes) is fulfilled from local disk via route
|
|
20
|
+
// interception — no network, no server.
|
|
21
|
+
//
|
|
22
|
+
// Baking is normally AUTOMATIC: dev/test/publish call `ensureBaked()` and re-bake
|
|
23
|
+
// any referenced GLB whose content hash no longer matches the artifact's recorded
|
|
24
|
+
// source hash. `joystack model bake <glb>` is the explicit escape hatch (debugging,
|
|
25
|
+
// forcing a re-bake). Artifacts are committed next to the model like any other
|
|
26
|
+
// game file.
|
|
27
|
+
//
|
|
28
|
+
// ── Optional bake config: <model>.bake.json next to the GLB ──────────────────
|
|
29
|
+
// {
|
|
30
|
+
// "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
|
|
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]
|
|
35
|
+
// Zones use the dominant-bone rule (a triangle belongs to whichever bone most
|
|
36
|
+
// influences its vertices — the same rule the big engines use to size their
|
|
37
|
+
// bone-attached proxy shapes, pointed at triangles). Patterns match bone
|
|
38
|
+
// names, `*` is a wildcard. Without config the mesh gets NO zones: hits still land
|
|
39
|
+
// on the exact surface, damage is uniform, and `hitRay` reports `zone: null`.
|
|
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
|
+
//
|
|
48
|
+
// ── What a bake rejects, loudly ──────────────────────────────────────────────
|
|
49
|
+
// - scale tracks in a clip (the runtime rig is uniform-scale; a scaling clip
|
|
50
|
+
// cannot be reproduced, so it fails the bake instead of silently drifting)
|
|
51
|
+
// - >4 skin influences per vertex, non-uniform bone scale, non-unit rotations
|
|
52
|
+
// (scale left in a matrix — the drifter's 17° bug, caught at bake)
|
|
53
|
+
// - skin weights outside [0,1] or not summing to ~1 (the fingerprint of reading
|
|
54
|
+
// interleaved attributes through `.array` instead of the accessors)
|
|
55
|
+
// Root-bone POSITION tracks are stripped by default and reported: the entity's
|
|
56
|
+
// own movement owns where the creature is, and keeping root motion would apply
|
|
57
|
+
// it twice.
|
|
58
|
+
|
|
59
|
+
import { createHash } from 'node:crypto';
|
|
60
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
61
|
+
import { join, resolve, basename, dirname } from 'node:path';
|
|
62
|
+
import { pathToFileURL } from 'node:url';
|
|
63
|
+
import { resolveEngine } from './project.mjs';
|
|
64
|
+
|
|
65
|
+
// Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
|
|
66
|
+
// meaning — it participates in the staleness hash, so old artifacts re-bake.
|
|
67
|
+
// v2: the staleness hash gained the generator-script component, and skeletons
|
|
68
|
+
// may carry generated additive clips.
|
|
69
|
+
// v3: skeletons carry a resolved `pivot`, and the broadphase radius is
|
|
70
|
+
// measured about it instead of the model origin.
|
|
71
|
+
export const BAKE_VERSION = 3;
|
|
72
|
+
|
|
73
|
+
// ── pure helpers (unit-tested without a browser) ─────────────────────────────
|
|
74
|
+
|
|
75
|
+
// Sort bones parent-first and produce old→new remap. rig.js poses in one forward
|
|
76
|
+
// pass, so every parent must precede its children; a GLB does not promise that.
|
|
77
|
+
export function sortBonesParentFirst(raw) {
|
|
78
|
+
const order = [];
|
|
79
|
+
const placed = new Set();
|
|
80
|
+
for (let guard = 0; order.length < raw.length && guard <= raw.length; guard++) {
|
|
81
|
+
for (let i = 0; i < raw.length; i++) {
|
|
82
|
+
if (placed.has(i)) continue;
|
|
83
|
+
if (raw[i].parent < 0 || placed.has(raw[i].parent)) { order.push(i); placed.add(i); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (order.length !== raw.length) throw new Error('skeleton is not a tree (parent cycle)');
|
|
87
|
+
const remap = new Int32Array(raw.length);
|
|
88
|
+
order.forEach((old, neu) => { remap[old] = neu; });
|
|
89
|
+
return { order, remap };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Bone-name pattern matching for the zones config. `*` matches any run of
|
|
93
|
+
// characters; matching is case-sensitive (bone names are authored identifiers).
|
|
94
|
+
export function matchesPattern(name, pattern) {
|
|
95
|
+
if (!pattern.includes('*')) return name === pattern;
|
|
96
|
+
const rx = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
|
|
97
|
+
return rx.test(name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Map each bone to a zone byte from the config. Zone 0 is always the implicit
|
|
101
|
+
// "body" (every unmatched bone); config keys become zones 1..n in key order.
|
|
102
|
+
export function boneZonesFromConfig(boneNames, zonesConfig) {
|
|
103
|
+
const names = ['body', ...Object.keys(zonesConfig)];
|
|
104
|
+
const byBone = new Uint8Array(boneNames.length);
|
|
105
|
+
boneNames.forEach((bn, i) => {
|
|
106
|
+
for (let z = 1; z < names.length; z++) {
|
|
107
|
+
if (zonesConfig[names[z]].some((p) => matchesPattern(bn, p))) { byBone[i] = z; return; }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return { names, byBone };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resolve the config's `pivot` — the model-space point that lands ON the
|
|
114
|
+
// entity's body.(x,y,z) — against the bind-pose bounding box. There is no
|
|
115
|
+
// universal right anchor (a flyer wants its centre, a walker its feet, a turret
|
|
116
|
+
// its base), so it's a per-model choice; "origin" reproduces the GLB's own
|
|
117
|
+
// origin and is the default. The resolved vector is written into the skeleton
|
|
118
|
+
// artifact; the runtime composes it into PLACEMENT only, never the inverse
|
|
119
|
+
// bind, so skinning is unaffected.
|
|
120
|
+
export function resolvePivot(pivotCfg, bbox) {
|
|
121
|
+
if (pivotCfg == null || pivotCfg === 'origin') return [0, 0, 0];
|
|
122
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
123
|
+
if (pivotCfg === 'center') return [mid(0), mid(1), mid(2)];
|
|
124
|
+
if (pivotCfg === 'feet') return [mid(0), bbox.min[1], mid(2)];
|
|
125
|
+
if (Array.isArray(pivotCfg) && pivotCfg.length === 3 && pivotCfg.every((n) => Number.isFinite(n))) {
|
|
126
|
+
return [pivotCfg[0], pivotCfg[1], pivotCfg[2]];
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(pivotCfg)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The dominant bone of a triangle: the bone with the highest summed skin weight
|
|
132
|
+
// across its three vertices. `acc` is scratch sized to the bone count.
|
|
133
|
+
export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
|
|
134
|
+
acc.fill(0);
|
|
135
|
+
for (let k = 0; k < 3; k++) {
|
|
136
|
+
const v = index[tri * 3 + k];
|
|
137
|
+
for (let j = 0; j < influences; j++) acc[skinIndex[v * influences + j]] += skinWeight[v * influences + j];
|
|
138
|
+
}
|
|
139
|
+
let best = 0, bestW = -1;
|
|
140
|
+
for (let b = 0; b < acc.length; b++) if (acc[b] > bestW) { bestW = acc[b]; best = b; }
|
|
141
|
+
return best;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function sha256(buf) {
|
|
145
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The staleness key an artifact records: GLB bytes + bake config + generator
|
|
149
|
+
// script + bake version. If any of the four changes, the artifact is stale and
|
|
150
|
+
// auto-bake re-runs — editing the generator's wing maths re-bakes exactly like
|
|
151
|
+
// editing the GLB does.
|
|
152
|
+
export function sourceHash(glbBytes, configText, generatorText = '') {
|
|
153
|
+
return sha256(Buffer.concat([
|
|
154
|
+
Buffer.from(`bake${BAKE_VERSION}!`),
|
|
155
|
+
Buffer.from(sha256(glbBytes)),
|
|
156
|
+
Buffer.from(sha256(Buffer.from(configText ?? ''))),
|
|
157
|
+
Buffer.from(sha256(Buffer.from(generatorText ?? ''))),
|
|
158
|
+
]));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function artifactPaths(glbPath) {
|
|
162
|
+
const stem = glbPath.replace(/\.glb$/i, '');
|
|
163
|
+
return { skeleton: `${stem}.skeleton.json`, mesh: `${stem}.mesh.json`, config: `${stem}.bake.json` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The generator script's current text, resolved from the bake config — '' when
|
|
167
|
+
// the config names none. A named-but-missing script reads as null so staleness
|
|
168
|
+
// can answer "stale" and the bake itself can fail with the real error.
|
|
169
|
+
function generatorText(glbPath, configText) {
|
|
170
|
+
if (!configText) return '';
|
|
171
|
+
let gen;
|
|
172
|
+
try { gen = JSON.parse(configText)?.generate; } catch { return ''; } // bad JSON fails in bakeModel with the real message
|
|
173
|
+
if (!gen) return '';
|
|
174
|
+
const p = resolve(dirname(glbPath), gen);
|
|
175
|
+
if (!existsSync(p)) return null;
|
|
176
|
+
return readFileSync(p, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Is the artifact pair current for this GLB + config + generator? (Missing → stale.)
|
|
180
|
+
export function isStale(glbPath) {
|
|
181
|
+
const { skeleton, mesh, config } = artifactPaths(glbPath);
|
|
182
|
+
if (!existsSync(skeleton) || !existsSync(mesh)) return true;
|
|
183
|
+
try {
|
|
184
|
+
const configText = existsSync(config) ? readFileSync(config, 'utf8') : '';
|
|
185
|
+
const genText = generatorText(glbPath, configText);
|
|
186
|
+
if (genText === null) return true; // config names a script that is gone
|
|
187
|
+
const want = sourceHash(readFileSync(glbPath), configText, genText);
|
|
188
|
+
const have = JSON.parse(readFileSync(skeleton, 'utf8'))?._source?.hash;
|
|
189
|
+
return have !== want;
|
|
190
|
+
} catch {
|
|
191
|
+
return true; // unreadable artifact = stale artifact
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── generator scripts: game code runs at bake time, the engine ships data ────
|
|
196
|
+
// bake.json may name a game-committed script: { "generate": "drifter-gen.mjs" }
|
|
197
|
+
// (path relative to the GLB). The script receives the parsed model and returns
|
|
198
|
+
// DATA — additive clip samples and/or per-triangle zone bytes. The engine
|
|
199
|
+
// validates and carries what it returns; it learns no geometric language and
|
|
200
|
+
// runs no game code after the bake. Two exports, both optional, each either the
|
|
201
|
+
// value or a function of the model:
|
|
202
|
+
//
|
|
203
|
+
// export function clips(model) {
|
|
204
|
+
// return { beat: { duration: 1, additive: true, keys: 64, sample: (t) => ({
|
|
205
|
+
// WingL: { rot: [x, y, z, w] }, // delta from rest, unit quaternion
|
|
206
|
+
// Chest: { pos: [x, y, z] }, // delta from rest, model units
|
|
207
|
+
// }) } };
|
|
208
|
+
// }
|
|
209
|
+
// export function zones(model) {
|
|
210
|
+
// return { names: ['body', 'wing'], tri: bytes }; // one byte per triangle
|
|
211
|
+
// }
|
|
212
|
+
//
|
|
213
|
+
// `model` = { bones, clips, mesh: { vertexCount, triangleCount, influences,
|
|
214
|
+
// position, skinIndex, skinWeight, index } } — everything the bake read from
|
|
215
|
+
// the GLB, typed arrays decoded.
|
|
216
|
+
//
|
|
217
|
+
// sample(t) must be DETERMINISTIC (no Math.random(), no Date.now()): the tracks
|
|
218
|
+
// it produces are replicated pose truth, and a re-bake must reproduce them
|
|
219
|
+
// byte-identically or the staleness hash lies.
|
|
220
|
+
|
|
221
|
+
// Sample each generated clip definition into baked additive tracks.
|
|
222
|
+
export function buildGeneratedClips(defs, boneNames, existingClipNames = []) {
|
|
223
|
+
const known = new Set(boneNames);
|
|
224
|
+
const existing = new Set(existingClipNames);
|
|
225
|
+
const clips = {};
|
|
226
|
+
for (const [name, def] of Object.entries(defs ?? {})) {
|
|
227
|
+
if (existing.has(name)) {
|
|
228
|
+
throw new Error(`generated clip "${name}" collides with a clip already in the GLB — rename one of them`);
|
|
229
|
+
}
|
|
230
|
+
if (def?.additive !== true) {
|
|
231
|
+
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`);
|
|
232
|
+
}
|
|
233
|
+
if (typeof def.sample !== 'function') {
|
|
234
|
+
throw new Error(`generated clip "${name}": "sample" must be a function of time`);
|
|
235
|
+
}
|
|
236
|
+
const duration = def.duration ?? 0;
|
|
237
|
+
// Key times are INCLUSIVE of the end (0..duration), so a looping clip
|
|
238
|
+
// interpolates across the seam instead of stepping at the wrap.
|
|
239
|
+
const keys = duration > 0 ? Math.max(2, Math.round(def.keys ?? 64)) : 0;
|
|
240
|
+
const times = duration > 0
|
|
241
|
+
? Array.from({ length: keys + 1 }, (_, k) => (k / keys) * duration)
|
|
242
|
+
: [0];
|
|
243
|
+
|
|
244
|
+
// Determinism probe: the cheapest check that catches the whole class of
|
|
245
|
+
// Math.random()/Date.now() mistakes before they become flickering re-bakes.
|
|
246
|
+
if (JSON.stringify(def.sample(times[0])) !== JSON.stringify(def.sample(times[0]))) {
|
|
247
|
+
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`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let shape = null; // bone -> { rot: bool, pos: bool }, fixed by the first sample
|
|
251
|
+
const acc = new Map(); // bone -> { times, rot[], pos[] }
|
|
252
|
+
for (const t of times) {
|
|
253
|
+
const frame = def.sample(t) ?? {};
|
|
254
|
+
const frameBones = Object.keys(frame);
|
|
255
|
+
if (shape === null) {
|
|
256
|
+
shape = new Map();
|
|
257
|
+
for (const bn of frameBones) {
|
|
258
|
+
if (!known.has(bn)) {
|
|
259
|
+
throw new Error(`generated clip "${name}": sample() returned bone "${bn}" — the skeleton has no such bone. Bones are: ${boneNames.join(', ')}`);
|
|
260
|
+
}
|
|
261
|
+
shape.set(bn, { rot: !!frame[bn]?.rot, pos: !!frame[bn]?.pos });
|
|
262
|
+
}
|
|
263
|
+
if (!shape.size) throw new Error(`generated clip "${name}": sample() returned no bones`);
|
|
264
|
+
} else if (frameBones.length !== shape.size || !frameBones.every((bn) => shape.has(bn))) {
|
|
265
|
+
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]}`);
|
|
266
|
+
}
|
|
267
|
+
for (const [bn, spec] of shape) {
|
|
268
|
+
const v = frame[bn];
|
|
269
|
+
if (!!v?.rot !== spec.rot || !!v?.pos !== spec.pos) {
|
|
270
|
+
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`);
|
|
271
|
+
}
|
|
272
|
+
let entry = acc.get(bn);
|
|
273
|
+
if (!entry) { entry = { times: [], rot: spec.rot ? [] : null, pos: spec.pos ? [] : null }; acc.set(bn, entry); }
|
|
274
|
+
entry.times.push(t);
|
|
275
|
+
if (spec.rot) {
|
|
276
|
+
const q = v.rot;
|
|
277
|
+
const L = Math.hypot(q[0], q[1], q[2], q[3]);
|
|
278
|
+
if (!(L > 0) || Math.abs(L - 1) > 1e-3) {
|
|
279
|
+
throw new Error(`generated clip "${name}", bone "${bn}" at t=${t}: rotation is not unit length (${L.toFixed(4)}) — normalize your quaternions`);
|
|
280
|
+
}
|
|
281
|
+
entry.rot.push(q[0] / L, q[1] / L, q[2] / L, q[3] / L);
|
|
282
|
+
}
|
|
283
|
+
if (spec.pos) entry.pos.push(v.pos[0], v.pos[1], v.pos[2]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
clips[name] = {
|
|
287
|
+
duration,
|
|
288
|
+
additive: true,
|
|
289
|
+
tracks: [...acc.entries()].map(([bone, e]) => ({
|
|
290
|
+
bone, times: e.times,
|
|
291
|
+
...(e.rot ? { rot: e.rot } : {}),
|
|
292
|
+
...(e.pos ? { pos: e.pos } : {}),
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return clips;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Validate a zone generator's return: bring-your-own-bytes, checked hard.
|
|
300
|
+
export function validateGeneratedZones(z, triangleCount) {
|
|
301
|
+
if (!z || !Array.isArray(z.names) || z.names.length === 0 || !z.names.every((n) => typeof n === 'string')) {
|
|
302
|
+
throw new Error('zone generator: "names" must be a non-empty array of zone name strings (index 0 is the default zone)');
|
|
303
|
+
}
|
|
304
|
+
if (z.names.length > 256) {
|
|
305
|
+
throw new Error(`zone generator: at most 256 zone names (got ${z.names.length}) — zones are stored one byte per triangle`);
|
|
306
|
+
}
|
|
307
|
+
const tri = z.tri;
|
|
308
|
+
const len = tri?.length ?? -1;
|
|
309
|
+
if (len !== triangleCount) {
|
|
310
|
+
throw new Error(`zone generator: "tri" must carry one byte per triangle — the mesh has ${triangleCount} triangles, got ${len}`);
|
|
311
|
+
}
|
|
312
|
+
const out = new Uint8Array(triangleCount);
|
|
313
|
+
for (let i = 0; i < triangleCount; i++) {
|
|
314
|
+
const v = tri[i];
|
|
315
|
+
if (!Number.isInteger(v) || v < 0 || v >= z.names.length) {
|
|
316
|
+
throw new Error(`zone generator: zone byte ${v} at triangle ${i} is out of range — must be an integer 0..${z.names.length - 1}`);
|
|
317
|
+
}
|
|
318
|
+
out[i] = v;
|
|
319
|
+
}
|
|
320
|
+
return { names: [...z.names], tri: out };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── the in-page bake ─────────────────────────────────────────────────────────
|
|
324
|
+
// Runs inside the browser with three + GLTFLoader importable. Everything it
|
|
325
|
+
// returns is plain JSON (typed arrays as b64).
|
|
326
|
+
|
|
327
|
+
const PAGE_HTML = `<!doctype html><html><head>
|
|
328
|
+
</head><body><script type="module">
|
|
329
|
+
import { THREE, GLTFLoader } from '/vendor/three-gltf.js';
|
|
330
|
+
window.THREE = THREE; window.GLTFLoader = GLTFLoader; window.__ready = true;
|
|
331
|
+
</script></body></html>`;
|
|
332
|
+
|
|
333
|
+
// Serialized into the page. `cfg` = { zones, clips } (already parsed).
|
|
334
|
+
async function bakeInPage(page, cfg) {
|
|
335
|
+
return page.evaluate(async (config) => {
|
|
336
|
+
const THREE = window.THREE;
|
|
337
|
+
const notes = [];
|
|
338
|
+
|
|
339
|
+
const buf = await (await fetch('/model.glb')).arrayBuffer();
|
|
340
|
+
const gltf = await new window.GLTFLoader().parseAsync(buf, '/');
|
|
341
|
+
const scene = gltf.scene;
|
|
342
|
+
scene.updateMatrixWorld(true);
|
|
343
|
+
|
|
344
|
+
// The mesh: the largest SkinnedMesh (a model may carry extras — eyes, teeth).
|
|
345
|
+
const skinned = [];
|
|
346
|
+
scene.traverse((o) => { if (o.isSkinnedMesh) skinned.push(o); });
|
|
347
|
+
if (!skinned.length) return { error: 'no skinned mesh in this GLB — is the model rigged?' };
|
|
348
|
+
skinned.sort((a, b) => b.geometry.attributes.position.count - a.geometry.attributes.position.count);
|
|
349
|
+
const sk = skinned[0];
|
|
350
|
+
if (skinned.length > 1) {
|
|
351
|
+
notes.push(`model has ${skinned.length} skinned meshes — baked the largest ` +
|
|
352
|
+
`(${sk.geometry.attributes.position.count} verts); the others are visual-only`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// REST = BIND. skin-hit derives the inverse bind from the rig's rest pose, so
|
|
356
|
+
// the rest transforms recorded here MUST be the bind pose — not whatever pose
|
|
357
|
+
// the file happened to save. three's Skeleton.pose() restores exactly that.
|
|
358
|
+
sk.skeleton.pose();
|
|
359
|
+
scene.updateMatrixWorld(true);
|
|
360
|
+
|
|
361
|
+
const bones = sk.skeleton.bones;
|
|
362
|
+
const srcIdx = new Map(bones.map((b, i) => [b, i]));
|
|
363
|
+
const raw = bones.map((b) => ({
|
|
364
|
+
name: b.name,
|
|
365
|
+
parent: srcIdx.has(b.parent) ? srcIdx.get(b.parent) : -1,
|
|
366
|
+
t: [b.position.x, b.position.y, b.position.z],
|
|
367
|
+
r: [b.quaternion.x, b.quaternion.y, b.quaternion.z, b.quaternion.w],
|
|
368
|
+
s: [b.scale.x, b.scale.y, b.scale.z],
|
|
369
|
+
}));
|
|
370
|
+
|
|
371
|
+
// Parent-first sort (rig.js poses in one forward pass); remap carried into
|
|
372
|
+
// skinIndex below. Mirrors sortBonesParentFirst — this copy runs in-page.
|
|
373
|
+
const order = [];
|
|
374
|
+
const placed = new Set();
|
|
375
|
+
for (let guard = 0; order.length < raw.length && guard <= raw.length; guard++) {
|
|
376
|
+
for (let i = 0; i < raw.length; i++) {
|
|
377
|
+
if (placed.has(i)) continue;
|
|
378
|
+
if (raw[i].parent < 0 || placed.has(raw[i].parent)) { order.push(i); placed.add(i); }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (order.length !== raw.length) return { error: 'skeleton is not a tree (parent cycle)' };
|
|
382
|
+
const remap = new Int32Array(raw.length);
|
|
383
|
+
order.forEach((old, neu) => { remap[old] = neu; });
|
|
384
|
+
const outBones = order.map((old) => {
|
|
385
|
+
const b = raw[old];
|
|
386
|
+
return { name: b.name, parent: b.parent < 0 ? -1 : remap[b.parent], t: b.t, r: b.r, s: b.s };
|
|
387
|
+
});
|
|
388
|
+
const boneNames = outBones.map((b) => b.name);
|
|
389
|
+
|
|
390
|
+
// Model-space root: from scene origin to the skeleton's parent. The artifact
|
|
391
|
+
// is baked in the GLB's own space; the GAME places it in the world at runtime
|
|
392
|
+
// (rig.evaluate's `root`), so the artifact stays game-agnostic.
|
|
393
|
+
const topBone = bones.find((b) => !srcIdx.has(b.parent)) || bones[0];
|
|
394
|
+
const rootM = topBone.parent ? topBone.parent.matrixWorld.clone() : new THREE.Matrix4();
|
|
395
|
+
const rp = new THREE.Vector3(), rq = new THREE.Quaternion(), rs = new THREE.Vector3();
|
|
396
|
+
rootM.decompose(rp, rq, rs);
|
|
397
|
+
|
|
398
|
+
// ── mesh, in scene space at bind pose ────────────────────────────────────
|
|
399
|
+
const geo = sk.geometry;
|
|
400
|
+
const V = geo.attributes.position.count;
|
|
401
|
+
const v3 = new THREE.Vector3();
|
|
402
|
+
const position = new Float32Array(V * 3);
|
|
403
|
+
for (let i = 0; i < V; i++) {
|
|
404
|
+
sk.getVertexPosition(i, v3); // bind-pose surface (skeleton is posed to bind)
|
|
405
|
+
position[i * 3] = v3.x; position[i * 3 + 1] = v3.y; position[i * 3 + 2] = v3.z;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const si = geo.attributes.skinIndex, sw = geo.attributes.skinWeight;
|
|
409
|
+
if (geo.attributes.skinIndex1) return { error: 'mesh uses >4 skin influences (skinIndex1) — unsupported' };
|
|
410
|
+
const inf = si.itemSize;
|
|
411
|
+
const skinIndex = new Uint16Array(V * inf);
|
|
412
|
+
const skinWeight = new Float32Array(V * inf);
|
|
413
|
+
// Through the ACCESSORS, never `.array`: interleaved buffers make `.array`
|
|
414
|
+
// return neighbouring attributes — silently, with the right length.
|
|
415
|
+
const get = (attr, i, k) => (k === 0 ? attr.getX(i) : k === 1 ? attr.getY(i) : k === 2 ? attr.getZ(i) : attr.getW(i));
|
|
416
|
+
for (let v = 0; v < V; v++) {
|
|
417
|
+
for (let k = 0; k < inf; k++) {
|
|
418
|
+
skinIndex[v * inf + k] = remap[get(si, v, k)];
|
|
419
|
+
skinWeight[v * inf + k] = get(sw, v, k);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const idxAttr = geo.index;
|
|
424
|
+
const index = new Uint32Array(idxAttr ? idxAttr.count : V);
|
|
425
|
+
if (idxAttr) for (let i = 0; i < idxAttr.count; i++) index[i] = idxAttr.getX(i);
|
|
426
|
+
else for (let i = 0; i < V; i++) index[i] = i;
|
|
427
|
+
const T = index.length / 3;
|
|
428
|
+
|
|
429
|
+
// ── zones: dominant bone → configured zone, per triangle ─────────────────
|
|
430
|
+
let zones = null;
|
|
431
|
+
if (config.zones && Object.keys(config.zones).length) {
|
|
432
|
+
const names = ['body', ...Object.keys(config.zones)];
|
|
433
|
+
const match = (name, pattern) => {
|
|
434
|
+
if (!pattern.includes('*')) return name === pattern;
|
|
435
|
+
const rx = new RegExp('^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
|
|
436
|
+
return rx.test(name);
|
|
437
|
+
};
|
|
438
|
+
const byBone = new Uint8Array(outBones.length);
|
|
439
|
+
const unmatchedPatterns = [];
|
|
440
|
+
for (let z = 1; z < names.length; z++) {
|
|
441
|
+
for (const p of config.zones[names[z]]) {
|
|
442
|
+
if (!boneNames.some((bn) => match(bn, p))) unmatchedPatterns.push(p);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (unmatchedPatterns.length) {
|
|
446
|
+
return { error: `zone patterns match no bone: ${unmatchedPatterns.join(', ')} — bones are: ${boneNames.join(', ')}` };
|
|
447
|
+
}
|
|
448
|
+
boneNames.forEach((bn, i) => {
|
|
449
|
+
for (let z = 1; z < names.length; z++) {
|
|
450
|
+
if (config.zones[names[z]].some((p) => match(bn, p))) { byBone[i] = z; return; }
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
const zoneTri = new Uint8Array(T);
|
|
454
|
+
const acc = new Float64Array(outBones.length);
|
|
455
|
+
for (let t = 0; t < T; t++) {
|
|
456
|
+
acc.fill(0);
|
|
457
|
+
for (let k = 0; k < 3; k++) {
|
|
458
|
+
const v = index[t * 3 + k];
|
|
459
|
+
for (let j = 0; j < inf; j++) acc[skinIndex[v * inf + j]] += skinWeight[v * inf + j];
|
|
460
|
+
}
|
|
461
|
+
let best = 0, bestW = -1;
|
|
462
|
+
for (let b = 0; b < acc.length; b++) if (acc[b] > bestW) { bestW = acc[b]; best = b; }
|
|
463
|
+
zoneTri[t] = byBone[best];
|
|
464
|
+
}
|
|
465
|
+
const counts = {};
|
|
466
|
+
names.forEach((n) => { counts[n] = 0; });
|
|
467
|
+
for (let t = 0; t < T; t++) counts[names[zoneTri[t]]]++;
|
|
468
|
+
zones = { names, zoneTri, counts };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── clips: every GLB animation, rotation + position tracks ───────────────
|
|
472
|
+
const wanted = config.clips ?? null;
|
|
473
|
+
const clips = {};
|
|
474
|
+
const rootBoneName = outBones.find((b) => b.parent === -1)?.name;
|
|
475
|
+
for (const anim of gltf.animations ?? []) {
|
|
476
|
+
if (wanted && !wanted.includes(anim.name)) continue;
|
|
477
|
+
const tracks = [];
|
|
478
|
+
for (const tr of anim.tracks) {
|
|
479
|
+
const m = tr.name.match(/^(.*)\.(quaternion|position|scale)$/);
|
|
480
|
+
if (!m) { notes.push(`clip "${anim.name}": skipped unrecognised track ${tr.name}`); continue; }
|
|
481
|
+
const [, nodeName, prop] = m;
|
|
482
|
+
if (!boneNames.includes(nodeName)) {
|
|
483
|
+
notes.push(`clip "${anim.name}": track for "${nodeName}" targets a non-bone node — skipped`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (prop === 'scale') {
|
|
487
|
+
return { error: `clip "${anim.name}" has a SCALE track on bone "${nodeName}". The room's rig is uniform-scale ` +
|
|
488
|
+
`and cannot reproduce it, so the hit surface would drift off the drawn model. Strip scale tracks in your export.` };
|
|
489
|
+
}
|
|
490
|
+
if (prop === 'position' && nodeName === rootBoneName) {
|
|
491
|
+
notes.push(`clip "${anim.name}": stripped root-motion position track on "${nodeName}" — ` +
|
|
492
|
+
`the entity's own movement carries the creature; keeping it would apply the motion twice`);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const entry = { bone: nodeName, times: Array.from(tr.times) };
|
|
496
|
+
if (prop === 'quaternion') entry.rot = Array.from(tr.values);
|
|
497
|
+
else entry.pos = Array.from(tr.values);
|
|
498
|
+
tracks.push(entry);
|
|
499
|
+
}
|
|
500
|
+
clips[anim.name] = { duration: anim.duration, tracks };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── pivot: where body.(x,y,z) sits in the model ──────────────────────────
|
|
504
|
+
// Resolved against the bind-pose bbox of the extracted positions. Mirrors
|
|
505
|
+
// resolvePivot — this copy runs in-page, because the broadphase radius
|
|
506
|
+
// below must be measured about the pivot: the runtime centres its sphere on
|
|
507
|
+
// body, and body IS the pivot point, so a radius measured about the origin
|
|
508
|
+
// could undersize the sphere and silently reject true edge hits.
|
|
509
|
+
const bbox = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
|
510
|
+
for (let i = 0; i < V * 3; i += 3) {
|
|
511
|
+
for (let a = 0; a < 3; a++) {
|
|
512
|
+
const c = position[i + a];
|
|
513
|
+
if (c < bbox.min[a]) bbox.min[a] = c;
|
|
514
|
+
if (c > bbox.max[a]) bbox.max[a] = c;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
let pivot;
|
|
518
|
+
{
|
|
519
|
+
const p = config.pivot;
|
|
520
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
521
|
+
if (p == null || p === 'origin') pivot = [0, 0, 0];
|
|
522
|
+
else if (p === 'center') pivot = [mid(0), mid(1), mid(2)];
|
|
523
|
+
else if (p === 'feet') pivot = [mid(0), bbox.min[1], mid(2)];
|
|
524
|
+
else if (Array.isArray(p) && p.length === 3 && p.every((n) => Number.isFinite(n))) pivot = [p[0], p[1], p[2]];
|
|
525
|
+
else return { error: `"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(p)}` };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── broadphase radius: worst case across rest + every baked clip ─────────
|
|
529
|
+
// The sphere exists to REJECT cheaply, so it must never reject a true hit:
|
|
530
|
+
// measure every pose the clips can produce, then carry 3% slack (a ray
|
|
531
|
+
// grazing an extremity is near-tangent, and float error then discards a hit
|
|
532
|
+
// that visibly connects). Distances are about the pivot — the sphere's
|
|
533
|
+
// runtime centre.
|
|
534
|
+
let radius = 0;
|
|
535
|
+
const measure = () => {
|
|
536
|
+
scene.updateMatrixWorld(true);
|
|
537
|
+
sk.skeleton.update?.();
|
|
538
|
+
for (let i = 0; i < V; i++) {
|
|
539
|
+
sk.getVertexPosition(i, v3);
|
|
540
|
+
const d = Math.hypot(v3.x - pivot[0], v3.y - pivot[1], v3.z - pivot[2]);
|
|
541
|
+
if (d > radius) radius = d;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
measure(); // rest pose
|
|
545
|
+
const mixer = new THREE.AnimationMixer(scene);
|
|
546
|
+
for (const anim of gltf.animations ?? []) {
|
|
547
|
+
if (wanted && !wanted.includes(anim.name)) continue;
|
|
548
|
+
const action = mixer.clipAction(anim);
|
|
549
|
+
action.play();
|
|
550
|
+
const STEPS = 24;
|
|
551
|
+
for (let s = 0; s <= STEPS; s++) {
|
|
552
|
+
mixer.setTime((anim.duration * s) / STEPS);
|
|
553
|
+
measure();
|
|
554
|
+
}
|
|
555
|
+
action.stop();
|
|
556
|
+
}
|
|
557
|
+
sk.skeleton.pose(); // back to bind
|
|
558
|
+
scene.updateMatrixWorld(true);
|
|
559
|
+
radius *= 1.03;
|
|
560
|
+
|
|
561
|
+
// ── weight sanity, while the source is still in hand ─────────────────────
|
|
562
|
+
let min = Infinity, max = -Infinity, outOfRange = 0, badSums = 0;
|
|
563
|
+
for (let i = 0; i < skinWeight.length; i++) {
|
|
564
|
+
const w = skinWeight[i];
|
|
565
|
+
if (w < min) min = w; if (w > max) max = w;
|
|
566
|
+
if (w < -1e-4 || w > 1 + 1e-4) outOfRange++;
|
|
567
|
+
}
|
|
568
|
+
for (let v = 0; v < V; v++) {
|
|
569
|
+
let s = 0;
|
|
570
|
+
for (let k = 0; k < inf; k++) s += skinWeight[v * inf + k];
|
|
571
|
+
if (Math.abs(s - 1) > 0.02) badSums++;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const b64 = (ta) => {
|
|
575
|
+
const u8 = new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
576
|
+
let s = '';
|
|
577
|
+
const CH = 0x8000;
|
|
578
|
+
for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
|
|
579
|
+
return btoa(s);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
notes,
|
|
584
|
+
root: { t: [rp.x, rp.y, rp.z], r: [rq.x, rq.y, rq.z, rq.w], s: [rs.x, rs.y, rs.z] },
|
|
585
|
+
pivot,
|
|
586
|
+
bones: outBones,
|
|
587
|
+
clips,
|
|
588
|
+
mesh: {
|
|
589
|
+
vertexCount: V, triangleCount: T, influences: inf, radius,
|
|
590
|
+
position: { b64: b64(position) },
|
|
591
|
+
skinIndex: { b64: b64(skinIndex) },
|
|
592
|
+
skinWeight: { b64: b64(skinWeight) },
|
|
593
|
+
index: { b64: b64(index) },
|
|
594
|
+
...(zones ? { zones: { names: zones.names, tri: { b64: b64(zones.zoneTri) } } } : {}),
|
|
595
|
+
},
|
|
596
|
+
weightCheck: { min, max, outOfRange, badSums },
|
|
597
|
+
stats: {
|
|
598
|
+
V, T, inf, radius, bones: outBones.length,
|
|
599
|
+
clips: Object.keys(clips),
|
|
600
|
+
...(zones ? { zoneCounts: zones.counts } : {}),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}, cfg);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── locating the loader bundle (served to the hermetic page) ─────────────────
|
|
607
|
+
// three + GLTFLoader ship VENDORED inside the engine (shared/ui/room/vendor/
|
|
608
|
+
// three-gltf.js, one self-contained ESM file) rather than as a dependency of
|
|
609
|
+
// this CLI — a 2D game must never download three just to own a `joystack` binary.
|
|
610
|
+
// The engine is also the right owner of the version pin: the code that DRAWS
|
|
611
|
+
// models lives there, so the loader that bakes and the loader that draws move
|
|
612
|
+
// together, on engine releases. The bake never touches the network.
|
|
613
|
+
const VENDOR_REL = 'shared/ui/room/vendor/three-gltf.js';
|
|
614
|
+
function engineFile(dir, rel) {
|
|
615
|
+
const candidates = [
|
|
616
|
+
// A standalone game: the engine installed under node_modules.
|
|
617
|
+
join(dir, 'node_modules/@joystack/engine', rel),
|
|
618
|
+
// This CLI running from inside the engine repo itself.
|
|
619
|
+
join(import.meta.dirname, '../../..', rel),
|
|
620
|
+
];
|
|
621
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
622
|
+
throw new Error(
|
|
623
|
+
`the installed engine does not include ${rel} — run \`npx joystack update\` to get a newer engine`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const threeBundle = (dir) => engineFile(dir, VENDOR_REL);
|
|
627
|
+
|
|
628
|
+
// ── the driver ───────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
export async function bakeModel(glbPath, { dir = process.cwd(), log = console.log } = {}) {
|
|
631
|
+
const abs = resolve(glbPath);
|
|
632
|
+
if (!existsSync(abs)) throw new Error(`no such file: ${glbPath}`);
|
|
633
|
+
const paths = artifactPaths(abs);
|
|
634
|
+
const glbBytes = readFileSync(abs);
|
|
635
|
+
const configText = existsSync(paths.config) ? readFileSync(paths.config, 'utf8') : '';
|
|
636
|
+
let cfg = {};
|
|
637
|
+
if (configText) {
|
|
638
|
+
try { cfg = JSON.parse(configText); } catch (e) {
|
|
639
|
+
throw new Error(`${basename(paths.config)} is not valid JSON: ${e.message}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const { chromium } = await import('playwright');
|
|
644
|
+
const bundle = threeBundle(dir);
|
|
645
|
+
const browser = await chromium.launch();
|
|
646
|
+
try {
|
|
647
|
+
const page = await browser.newPage();
|
|
648
|
+
const pageErrors = [];
|
|
649
|
+
page.on('pageerror', (e) => pageErrors.push(e.message));
|
|
650
|
+
await page.route('**/*', (route) => {
|
|
651
|
+
const url = new URL(route.request().url());
|
|
652
|
+
const p = url.pathname;
|
|
653
|
+
if (p === '/' || p === '/bake.html') return route.fulfill({ contentType: 'text/html', body: PAGE_HTML });
|
|
654
|
+
if (p === '/model.glb') return route.fulfill({ contentType: 'model/gltf-binary', body: glbBytes });
|
|
655
|
+
if (p === '/vendor/three-gltf.js') {
|
|
656
|
+
return route.fulfill({ contentType: 'text/javascript', body: readFileSync(bundle) });
|
|
657
|
+
}
|
|
658
|
+
return route.fulfill({ status: 404, body: 'not baked in' });
|
|
659
|
+
});
|
|
660
|
+
await page.goto('http://bake.joystack.invalid/bake.html');
|
|
661
|
+
await page.waitForFunction(() => window.__ready === true, { timeout: 30_000 });
|
|
662
|
+
|
|
663
|
+
const baked = await bakeInPage(page, cfg);
|
|
664
|
+
if (baked.error) {
|
|
665
|
+
throw new Error(`${baked.error}${pageErrors.length ? `\n(page errors: ${pageErrors.join('; ')})` : ''}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── validate before writing — a bad artifact must never reach the room ───
|
|
669
|
+
const problems = [];
|
|
670
|
+
const uni = (s, what) => {
|
|
671
|
+
const m = Math.max(Math.abs(s[0]), Math.abs(s[1]), Math.abs(s[2])) || 1;
|
|
672
|
+
if (Math.abs(s[0] - s[1]) / m > 1e-3 || Math.abs(s[0] - s[2]) / m > 1e-3) problems.push(`${what} scale is non-uniform: ${s}`);
|
|
673
|
+
return (s[0] + s[1] + s[2]) / 3;
|
|
674
|
+
};
|
|
675
|
+
const rootS = uni(baked.root.s, 'root');
|
|
676
|
+
baked.bones.forEach((b, i) => {
|
|
677
|
+
if (b.parent >= i) problems.push(`bone ${i} ${b.name}: parent ${b.parent} does not precede it`);
|
|
678
|
+
const L = Math.hypot(...b.r);
|
|
679
|
+
if (Math.abs(L - 1) > 1e-3) problems.push(`bone ${i} ${b.name}: rotation not unit (${L.toFixed(4)}) — scale left in the matrix`);
|
|
680
|
+
b.sAvg = uni(b.s, `bone ${i} ${b.name}`);
|
|
681
|
+
});
|
|
682
|
+
if (!(baked.mesh.radius > 0)) problems.push('broadphase radius is not positive');
|
|
683
|
+
const w = baked.weightCheck;
|
|
684
|
+
if (w.outOfRange > 0) problems.push(`${w.outOfRange} skin weights outside [0,1] (range ${w.min.toFixed(3)}..${w.max.toFixed(3)}) — attributes read wrongly?`);
|
|
685
|
+
if (w.badSums > 0) problems.push(`${w.badSums} vertices whose weights do not sum to ~1`);
|
|
686
|
+
if (problems.length) throw new Error(`BAKE INVALID:\n ${problems.join('\n ')}`);
|
|
687
|
+
|
|
688
|
+
const skeletonJson = {
|
|
689
|
+
root: { t: baked.root.t, r: baked.root.r, s: rootS },
|
|
690
|
+
pivot: baked.pivot,
|
|
691
|
+
bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
|
|
692
|
+
clips: baked.clips,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// ── the generator script, if the config names one ────────────────────────
|
|
696
|
+
let genText = '';
|
|
697
|
+
let generatedNames = [];
|
|
698
|
+
let genZones = null;
|
|
699
|
+
if (cfg.generate) {
|
|
700
|
+
const scriptPath = resolve(dirname(abs), cfg.generate);
|
|
701
|
+
if (!existsSync(scriptPath)) {
|
|
702
|
+
throw new Error(`${basename(paths.config)} names generate: "${cfg.generate}" but ${scriptPath} does not exist`);
|
|
703
|
+
}
|
|
704
|
+
genText = readFileSync(scriptPath, 'utf8');
|
|
705
|
+
// The hash query re-imports a changed script within one process (node
|
|
706
|
+
// caches module URLs forever; ensureBaked may bake twice in one run).
|
|
707
|
+
const mod = await import(`${pathToFileURL(scriptPath).href}?v=${sha256(Buffer.from(genText)).slice(0, 12)}`);
|
|
708
|
+
if (!mod.clips && !mod.zones) {
|
|
709
|
+
throw new Error(`generator ${cfg.generate} exports neither clips nor zones — nothing to generate`);
|
|
710
|
+
}
|
|
711
|
+
const fromB64 = (Ctor, entry) => {
|
|
712
|
+
const buf = Buffer.from(entry.b64, 'base64');
|
|
713
|
+
return new Ctor(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
714
|
+
};
|
|
715
|
+
const model = {
|
|
716
|
+
bones: skeletonJson.bones,
|
|
717
|
+
clips: baked.clips,
|
|
718
|
+
mesh: {
|
|
719
|
+
vertexCount: baked.mesh.vertexCount,
|
|
720
|
+
triangleCount: baked.mesh.triangleCount,
|
|
721
|
+
influences: baked.mesh.influences,
|
|
722
|
+
position: fromB64(Float32Array, baked.mesh.position),
|
|
723
|
+
skinIndex: fromB64(Uint16Array, baked.mesh.skinIndex),
|
|
724
|
+
skinWeight: fromB64(Float32Array, baked.mesh.skinWeight),
|
|
725
|
+
index: fromB64(Uint32Array, baked.mesh.index),
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
const boneNames = skeletonJson.bones.map((b) => b.name);
|
|
729
|
+
if (mod.clips) {
|
|
730
|
+
const defs = typeof mod.clips === 'function' ? mod.clips(model) : mod.clips;
|
|
731
|
+
const generated = buildGeneratedClips(defs, boneNames, Object.keys(baked.clips));
|
|
732
|
+
generatedNames = Object.keys(generated);
|
|
733
|
+
Object.assign(skeletonJson.clips, generated);
|
|
734
|
+
|
|
735
|
+
// The broadphase sphere must contain every pose the LAYERS can reach —
|
|
736
|
+
// the in-page measure only saw the GLB's own clips. Skin the mesh
|
|
737
|
+
// through each generated clip alone at weight 1 and add each clip's
|
|
738
|
+
// worst vertex displacement to the radius. A sum over clips is
|
|
739
|
+
// deliberately conservative (layers combine): an oversized sphere only
|
|
740
|
+
// costs broadphase efficiency, an undersized one silently discards
|
|
741
|
+
// true hits on a flared wing. Weights are expected in [0, 1].
|
|
742
|
+
const rigLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/rig.js')).href);
|
|
743
|
+
const skinLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/skin-hit.js')).href);
|
|
744
|
+
const rig = rigLib.createRig(skeletonJson);
|
|
745
|
+
const root = { t: skeletonJson.root.t, r: skeletonJson.root.r, s: skeletonJson.root.s };
|
|
746
|
+
const skinned = skinLib.createSkinnedMesh({
|
|
747
|
+
vertexCount: model.mesh.vertexCount, influences: model.mesh.influences,
|
|
748
|
+
position: model.mesh.position, skinIndex: model.mesh.skinIndex,
|
|
749
|
+
skinWeight: model.mesh.skinWeight, index: model.mesh.index,
|
|
750
|
+
invBind: skinLib.restInvBind(rig, root),
|
|
751
|
+
});
|
|
752
|
+
const rest = Float32Array.from(skinLib.skin(skinned, rigLib.evaluate(rig, { root })));
|
|
753
|
+
let inflate = 0;
|
|
754
|
+
for (const name of generatedNames) {
|
|
755
|
+
const clip = skeletonJson.clips[name];
|
|
756
|
+
const STEPS = clip.duration > 0 ? 24 : 1;
|
|
757
|
+
let worst = 0;
|
|
758
|
+
for (let s = 0; s <= STEPS; s++) {
|
|
759
|
+
const t = clip.duration > 0 ? (clip.duration * s) / STEPS : 0;
|
|
760
|
+
const sample = rigLib.sampleAdditive(rig, name, t, 1);
|
|
761
|
+
const posed = skinLib.skin(skinned, rigLib.evaluate(rig, { ...sample, root }));
|
|
762
|
+
for (let v = 0; v < model.mesh.vertexCount * 3; v += 3) {
|
|
763
|
+
const d = Math.hypot(posed[v] - rest[v], posed[v + 1] - rest[v + 1], posed[v + 2] - rest[v + 2]);
|
|
764
|
+
if (d > worst) worst = d;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
inflate += worst;
|
|
768
|
+
}
|
|
769
|
+
if (inflate > 0) baked.mesh.radius = (baked.mesh.radius + inflate) * 1.03;
|
|
770
|
+
}
|
|
771
|
+
if (mod.zones) {
|
|
772
|
+
if (cfg.zones && Object.keys(cfg.zones).length) {
|
|
773
|
+
throw new Error(`${basename(paths.config)} has BOTH a "zones" bone-name map and a generator exporting zones() — pick one strategy per model`);
|
|
774
|
+
}
|
|
775
|
+
const raw = typeof mod.zones === 'function' ? mod.zones(model) : mod.zones;
|
|
776
|
+
genZones = validateGeneratedZones(raw, baked.mesh.triangleCount);
|
|
777
|
+
const b64 = Buffer.from(genZones.tri).toString('base64');
|
|
778
|
+
baked.mesh.zones = { names: genZones.names, tri: { b64 } };
|
|
779
|
+
const counts = {};
|
|
780
|
+
genZones.names.forEach((n) => { counts[n] = 0; });
|
|
781
|
+
for (const z of genZones.tri) counts[genZones.names[z]]++;
|
|
782
|
+
baked.stats.zoneCounts = counts;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const source = { hash: sourceHash(glbBytes, configText, genText), glb: basename(abs), bakeVersion: BAKE_VERSION };
|
|
787
|
+
const note = 'GENERATED by the Joystack model baker — do not hand-edit; edit the GLB or its .bake.json and re-bake.';
|
|
788
|
+
writeFileSync(paths.skeleton, JSON.stringify({
|
|
789
|
+
_generated: note,
|
|
790
|
+
_source: source,
|
|
791
|
+
...skeletonJson,
|
|
792
|
+
}, null, 1) + '\n');
|
|
793
|
+
writeFileSync(paths.mesh, JSON.stringify({ _generated: note, _source: source, ...baked.mesh }) + '\n');
|
|
794
|
+
|
|
795
|
+
const s = baked.stats;
|
|
796
|
+
log(`baked ${basename(abs)} → ${basename(paths.skeleton)} + ${basename(paths.mesh)}`);
|
|
797
|
+
log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${baked.mesh.radius.toFixed(3)}`);
|
|
798
|
+
log(` clips: ${s.clips.length ? s.clips.join(', ') : '(none in this GLB)'}`);
|
|
799
|
+
if (generatedNames.length) log(` generated additive clips: ${generatedNames.join(', ')}`);
|
|
800
|
+
if (s.zoneCounts) log(` zones: ${Object.entries(s.zoneCounts).map(([k, v]) => `${k} ${v}`).join(' ')}`);
|
|
801
|
+
for (const n of baked.notes) log(` note: ${n}`);
|
|
802
|
+
return { paths, stats: s, notes: baked.notes };
|
|
803
|
+
} finally {
|
|
804
|
+
await browser.close();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ── auto-bake: the staleness sweep dev/test/publish run ──────────────────────
|
|
809
|
+
// The game's DECLARATION says which models matter: every `src` in `assets.js`
|
|
810
|
+
// that names a `.glb`. Paths there are GAME-ROOT-relative — the same string is
|
|
811
|
+
// the presenter's fetch URL (the page sits at the game root) — so they resolve
|
|
812
|
+
// against the game dir here too. Each declared GLB whose artifacts are missing
|
|
813
|
+
// or stale re-bakes. A bake failure is a real failure, surfaced with the model
|
|
814
|
+
// named and never swallowed: a stale artifact means the server shoots at a shape
|
|
815
|
+
// the player no longer sees.
|
|
816
|
+
//
|
|
817
|
+
// Entity definitions are NOT scanned. A `rig-hitbox.model` is an asset id, so
|
|
818
|
+
// there is no path in an entity.json left to find — and the declaration is the
|
|
819
|
+
// better list anyway, because it also holds models a game loads from its own
|
|
820
|
+
// code, which the entity scan never saw.
|
|
821
|
+
//
|
|
822
|
+
// Read as TEXT rather than imported, by the engine's own reader
|
|
823
|
+
// (`shared/ui/assets/declared.js`) — the same one the entity lint uses, so the
|
|
824
|
+
// two gates cannot disagree about what a game declares. Its blind spot is a
|
|
825
|
+
// computed `src`, which mirrors the one the entity scan had for a computed model
|
|
826
|
+
// reference.
|
|
827
|
+
|
|
828
|
+
// The reader is passed IN rather than imported: it lives in the engine tree
|
|
829
|
+
// (which the CLI resolves at run time — in a creator's repo it sits under
|
|
830
|
+
// node_modules), and handing it over keeps this function a pure filter over a
|
|
831
|
+
// parse somebody else did.
|
|
832
|
+
export function referencedModels(gameDir, readDeclaration) {
|
|
833
|
+
const manifest = join(gameDir, 'assets.js');
|
|
834
|
+
if (!existsSync(manifest) || typeof readDeclaration !== 'function') return [];
|
|
835
|
+
let declared;
|
|
836
|
+
try {
|
|
837
|
+
declared = readDeclaration(readFileSync(manifest, 'utf8'));
|
|
838
|
+
} catch {
|
|
839
|
+
return []; // unreadable assets.js fails its own gate
|
|
840
|
+
}
|
|
841
|
+
const found = new Set();
|
|
842
|
+
for (const src of declared.sources.values()) {
|
|
843
|
+
if (/\.glb$/i.test(src)) found.add(resolve(gameDir, src));
|
|
844
|
+
}
|
|
845
|
+
return [...found].filter(existsSync);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// The engine's declaration reader, or null when there is no engine to read with.
|
|
849
|
+
async function declarationReader(gameDir) {
|
|
850
|
+
try {
|
|
851
|
+
const { dir } = resolveEngine(gameDir);
|
|
852
|
+
const mod = await import(pathToFileURL(join(dir, 'shared', 'ui', 'assets', 'declared.js')).href);
|
|
853
|
+
return mod.readDeclaration;
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export async function ensureBaked({ dir = process.cwd(), log = console.log } = {}) {
|
|
860
|
+
const models = referencedModels(dir, await declarationReader(dir));
|
|
861
|
+
const baked = [];
|
|
862
|
+
for (const glb of models) {
|
|
863
|
+
if (!isStale(glb)) continue;
|
|
864
|
+
log(`model ${basename(glb)} changed — re-baking so the server hits the surface you see…`);
|
|
865
|
+
await bakeModel(glb, { dir, log });
|
|
866
|
+
baked.push(glb);
|
|
867
|
+
}
|
|
868
|
+
return { models, baked };
|
|
869
|
+
}
|