skill
molecule-publish
Public Made by Adomby adom
Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
#!/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()