"""ABSOLUTE camera aim: measure the camera_body board-plane angle in gripper.glb and rotate the camera
assembly (mount/body/cover) about the seat line so the optical axis lands at exactly TARGET_DEG below
horizontal. Idempotent — safe to run any number of times, regardless of what tilt is baked in the source."""
import numpy as np, trimesh
TARGET_DEG = 20.0
s = trimesh.load("gripper.glb")
def geom(node): return s.geometry[s.graph[node][1]]
body = geom("camera_body")
n, a = body.face_normals, body.area_faces
key = np.round(n, 2)
uniq, inv = np.unique(key, axis=0, return_inverse=True)
w = np.bincount(inv, weights=a)
axis = uniq[np.argmax(w)]; axis = axis / np.linalg.norm(axis)
V = body.vertices * 100.0
t = V @ axis
fd = np.isclose(np.abs(body.face_normals @ axis), 1.0, atol=0.05)
t_board = float(np.median(body.triangles_center[fd] * 100.0 @ axis))
optical = axis if (t.max() - t_board) > (t_board - t.min()) else -axis
# current angle below horizontal (servo frame: optical ~(-cos,-sin,0); world-down = servo -y)
cur = np.degrees(np.arctan2(-optical[1], -optical[0]))
delta = TARGET_DEG - cur
mlo = (geom("camera_mount").vertices * 100.0).min(0)
pivot = np.array([mlo[0], mlo[1], 0.0])
R = trimesh.transformations.rotation_matrix(np.radians(delta), [0, 0, 1], pivot)
for node in ("camera_mount", "camera_body", "camera_cover"):
    g = geom(node)
    g.vertices = trimesh.transform_points(g.vertices * 100.0, R) / 100.0
s.export("gripper.glb")
for node in ("camera_mount", "camera_body", "camera_cover"):
    g = geom(node).copy(); g.apply_scale(100.0); g.export(f"gnode_{node}.stl")
print(f"aim: was {cur:.1f}deg -> target {TARGET_DEG:.0f}deg (rotated {delta:+.1f}deg about seat)")
