"""Signed axes for spacecraft moving parts, measured from the geometry (17 Oct 2026). Authors write axes without a sign
convention: 105 of 109 guns had Blender's +X as their elevation axis, which makes the kit's `turn(gun, +pitch)` (positive =
up) drive the barrels DOWN into the hull, and a door's +angle may swing it inward. Rules (axes are in the node's own frame,
the kit applies them after the node's rest rotation):
  gun    barrel = the longest horizontal extent of its mesh from the pivot (+-X or +-Z); axis = barrel x up, so +angle raises it
  hinge  keep the author's axis line, sign chosen so +angle moves the part's centre AWAY from the craft's centre (opens)
Everything else keeps its axis (spinners and symmetric parts do not care about the sign).
usage (library side): measured_axes(glb_path, moving) -> {name: axis}; tools/space/fix_axes.py writes new card versions."""
import math, os, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(ROOT, 'lib'))
import glb  # noqa: E402

UP = (0.0, 1.0, 0.0)


def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _dot(a, b): return sum(x * y for x, y in zip(a, b))
def _sub(a, b): return tuple(x - y for x, y in zip(a, b))
def _unit(v):
    n = math.sqrt(_dot(v, v)); return tuple(round(x / n, 4) for x in v) if n > 1e-9 else None


def _worlds(doc):
    """{node index: world 4x4 matrix}"""
    out = {}
    def walk(i, parent):
        m = glb._mul(parent, glb._mat(doc['nodes'][i])); out[i] = m
        for c in doc['nodes'][i].get('children', []): walk(c, m)
    ident = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
    for s in doc.get('scenes', [{}])[doc.get('scene', 0)].get('nodes', []): walk(s, ident)
    return out


def _apply(m, p, w=1.0): return tuple(sum(m[r][k] * (p[k] if k < 3 else w) for k in range(4)) for r in range(3))


def _bounds(doc, node):
    acc = [doc['accessors'][pr['attributes']['POSITION']] for pr in doc['meshes'][node['mesh']]['primitives'] if 'POSITION' in pr['attributes']]
    return [min(a['min'][k] for a in acc) for k in range(3)], [max(a['max'][k] for a in acc) for k in range(3)]


def measured_axes(path, moving):
    """moving = card nodes.moving {name: {kind, axis}} -> {name: (axis, how)} for the parts whose axis this measures"""
    doc = glb.read(path)[0]; W = _worlds(doc)
    idx = {n.get('name'): i for i, n in enumerate(doc['nodes'])}
    m = glb.measure(path)['bounds']; centre = tuple((m['min'][k] + m['max'][k]) / 2 for k in range(3))
    out = {}
    for name, info in moving.items():
        i = idx.get(name)
        if i is None or doc['nodes'][i].get('mesh') is None: continue
        lo, hi = _bounds(doc, doc['nodes'][i])
        if info['kind'] == 'gun':
            reach = {(1, 0, 0): hi[0], (-1, 0, 0): -lo[0], (0, 0, 1): hi[2], (0, 0, -1): -lo[2]}
            barrel = max(reach, key=reach.get)
            out[name] = (_unit(_cross(barrel, UP)), f'barrel {barrel}')
        elif info['kind'] == 'hinge':
            a = tuple(info['axis']); c_loc = tuple((lo[k] + hi[k]) / 2 for k in range(3))
            Mw = W[i]; Cw = _apply(Mw, c_loc); Pw = _apply(Mw, (0, 0, 0)); Aw = _apply(Mw, a, 0.0)
            move = _cross(Aw, _sub(Cw, Pw)); away = _sub(Cw, centre)
            d = _dot(move, away)
            if abs(d) > 1e-6 * (1 + _dot(away, away)):
                out[name] = (_unit(a if d > 0 else tuple(-x for x in a)), 'opens away from the hull')
    return out
