@looop-games/cli 0.1.20 → 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 +17 -0
- package/lib/model-bake.mjs +54 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,23 @@ 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
|
+
|
|
17
34
|
## [0.1.20] - 2026-07-29
|
|
18
35
|
|
|
19
36
|
### Added
|
package/lib/model-bake.mjs
CHANGED
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
// {
|
|
30
30
|
// "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
|
|
31
31
|
// "clips": ["fly", "idle"], // subset to bake; default = all
|
|
32
|
-
// "generate": "drifter-gen.mjs"
|
|
33
|
-
//
|
|
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]
|
|
34
35
|
// Zones use the dominant-bone rule (a triangle belongs to whichever bone most
|
|
35
36
|
// influences its vertices — the same rule the big engines use to size their
|
|
36
37
|
// bone-attached proxy shapes, pointed at triangles). Patterns match bone
|
|
@@ -64,7 +65,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
64
65
|
// meaning — it participates in the staleness hash, so old artifacts re-bake.
|
|
65
66
|
// v2: the staleness hash gained the generator-script component, and skeletons
|
|
66
67
|
// may carry generated additive clips.
|
|
67
|
-
|
|
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;
|
|
68
71
|
|
|
69
72
|
// ── pure helpers (unit-tested without a browser) ─────────────────────────────
|
|
70
73
|
|
|
@@ -106,6 +109,24 @@ export function boneZonesFromConfig(boneNames, zonesConfig) {
|
|
|
106
109
|
return { names, byBone };
|
|
107
110
|
}
|
|
108
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
|
+
|
|
109
130
|
// The dominant bone of a triangle: the bone with the highest summed skin weight
|
|
110
131
|
// across its three vertices. `acc` is scratch sized to the bone count.
|
|
111
132
|
export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
|
|
@@ -478,18 +499,44 @@ async function bakeInPage(page, cfg) {
|
|
|
478
499
|
clips[anim.name] = { duration: anim.duration, tracks };
|
|
479
500
|
}
|
|
480
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
|
+
|
|
481
527
|
// ── broadphase radius: worst case across rest + every baked clip ─────────
|
|
482
528
|
// The sphere exists to REJECT cheaply, so it must never reject a true hit:
|
|
483
529
|
// measure every pose the clips can produce, then carry 3% slack (a ray
|
|
484
530
|
// grazing an extremity is near-tangent, and float error then discards a hit
|
|
485
|
-
// that visibly connects).
|
|
531
|
+
// that visibly connects). Distances are about the pivot — the sphere's
|
|
532
|
+
// runtime centre.
|
|
486
533
|
let radius = 0;
|
|
487
534
|
const measure = () => {
|
|
488
535
|
scene.updateMatrixWorld(true);
|
|
489
536
|
sk.skeleton.update?.();
|
|
490
537
|
for (let i = 0; i < V; i++) {
|
|
491
538
|
sk.getVertexPosition(i, v3);
|
|
492
|
-
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]);
|
|
493
540
|
if (d > radius) radius = d;
|
|
494
541
|
}
|
|
495
542
|
};
|
|
@@ -534,6 +581,7 @@ async function bakeInPage(page, cfg) {
|
|
|
534
581
|
return {
|
|
535
582
|
notes,
|
|
536
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,
|
|
537
585
|
bones: outBones,
|
|
538
586
|
clips,
|
|
539
587
|
mesh: {
|
|
@@ -638,6 +686,7 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
|
|
|
638
686
|
|
|
639
687
|
const skeletonJson = {
|
|
640
688
|
root: { t: baked.root.t, r: baked.root.r, s: rootS },
|
|
689
|
+
pivot: baked.pivot,
|
|
641
690
|
bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
|
|
642
691
|
clips: baked.clips,
|
|
643
692
|
};
|