master
Drew Owens Publish 2.10.0 2840c19 2mo ago
#!/usr/bin/env python3
"""hero_camera.py - DECLARE a component's canonical hero camera (adom/wiki#87).

The wiki does not render component heroes; the pipeline commits `hero.png` and
the viewer resolves its live pose by precedence `?camera=` > page declaration
(`component.viewer_3d.camera`) > platform home view. The platform home view MOVED
(wiki#102) and can move again, so a component must DECLARE its own pose on the
page - otherwise the hero (baked at publish) and the live 3D tab drift apart and
every page reads as "posed wrong."

Ray's constraint (measured on the workcell boards, #87): the canonical iso pose is
`alpha 1.571, beta 0.785` (45 deg off top-down) and you **vary RADIUS only**. So
this tool writes that fixed alpha/beta and a bounds-informed `radiusScale`, giving
one stable, page-owned pose that the hero capture and the 3D tab both honor.

  pose <glb> --page <page.json>   compute radiusScale from the GLB bounds, merge
                                  {alpha, beta, radiusScale} into
                                  page.json.component.viewer_3d.camera, and print
                                  the `?camera=` string to capture the hero at the
                                  SAME pose. --alpha/--beta/--radius-scale override
                                  (e.g. a board with a known-good non-default
                                  orientation). Nothing else in page.json changes.

Stdlib only. Bounds come from POSITION accessor min/max (no mesh decode); node
transforms are not composed - fine for framing a single-component page.
"""
import argparse
import json
import struct
import sys

# Ray's canonical iso (wiki#87). Radius is the only free parameter.
CANON_ALPHA = 1.571   # ~pi/2
CANON_BETA = 0.785    # ~pi/4, 45 deg off top-down


def glb_json(path):
    with open(path, "rb") as f:
        b = f.read()
    if b[:4] != b"glTF":
        raise ValueError(f"{path} is not a GLB")
    jlen = struct.unpack_from("<I", b, 12)[0]
    return json.loads(b[20:20 + jlen])


def bounds(doc):
    """Axis-aligned bbox from every POSITION accessor's declared min/max."""
    lo = [float("inf")] * 3
    hi = [float("-inf")] * 3
    accs = doc.get("accessors", [])
    seen = False
    for mesh in doc.get("meshes", []):
        for prim in mesh.get("primitives", []):
            i = prim.get("attributes", {}).get("POSITION")
            if i is None or i >= len(accs):
                continue
            a = accs[i]
            mn, mx = a.get("min"), a.get("max")
            if not (mn and mx and len(mn) >= 3 and len(mx) >= 3):
                continue
            seen = True
            for k in range(3):
                lo[k] = min(lo[k], mn[k])
                hi[k] = max(hi[k], mx[k])
    if not seen:
        return None
    return lo, hi


def radius_scale(lo, hi):
    """Vary radius only (Ray). The viewer auto-frames to the bbox; radiusScale is
    a margin multiplier. A part that is flat+wide seen at 45 deg needs a touch more
    room than a cube, so scale mildly with the horizontal:vertical aspect. Clamped
    to a sane band so it never zooms to a dot or flies out."""
    size = [hi[k] - lo[k] for k in range(3)]
    # glTF is Y-up: horizontal extent = max(x, z), vertical = y
    horiz = max(size[0], size[2]) or 1e-6
    vert = size[1] or 1e-6
    aspect = horiz / vert
    scale = 1.05 + 0.06 * max(0.0, min(6.0, aspect - 1.0))  # band 1.05 .. ~1.41
    return round(scale, 3)


def cmd_pose(a):
    doc = glb_json(a.glb)
    bb = bounds(doc)
    alpha = a.alpha if a.alpha is not None else CANON_ALPHA
    beta = a.beta if a.beta is not None else CANON_BETA
    if a.radius_scale is not None:
        rscale = a.radius_scale
    elif bb:
        rscale = radius_scale(*bb)
    else:
        rscale = 1.1
        print("WARN: no POSITION min/max in GLB - using default radiusScale 1.1")

    cam = {"alpha": round(alpha, 4), "beta": round(beta, 4), "radiusScale": rscale}

    pkg = json.load(open(a.page))
    comp = pkg.setdefault("component", {})
    v3d = comp.setdefault("viewer_3d", {})
    old = v3d.get("camera")
    v3d["camera"] = cam
    if not a.dry_run:
        json.dump(pkg, open(a.page, "w"), indent=2)
        open(a.page, "a").write("\n")

    print(f"component.viewer_3d.camera = {json.dumps(cam)}"
          + (f"  (was {json.dumps(old)})" if old else "")
          + ("  [dry-run]" if a.dry_run else ""))
    if bb:
        size = [round(bb[1][k] - bb[0][k], 2) for k in range(3)]
        print(f"bounds size (x,y,z) = {size}")
    print("capture the hero at THIS pose so it matches the live 3D tab:")
    print(f"  ?camera={cam['alpha']},{cam['beta']},{cam['radiusScale']}")
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("pose", help="declare component.viewer_3d.camera (#87)")
    p.add_argument("glb")
    p.add_argument("--page", required=True, help="page.json to merge the camera into")
    p.add_argument("--alpha", type=float, help="override canonical alpha (1.571)")
    p.add_argument("--beta", type=float, help="override canonical beta (0.785)")
    p.add_argument("--radius-scale", type=float, help="override computed radiusScale")
    p.add_argument("--dry-run", action="store_true")
    p.set_defaults(fn=cmd_pose)
    a = ap.parse_args()
    sys.exit(a.fn(a))


if __name__ == "__main__":
    main()