@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 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
@@ -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" // generator script, relative to the GLB
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
- export const BAKE_VERSION = 2;
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",