import re, os, json, math, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fbx_bounds import read_fbx

_meshcache = {}
_metaidx = {}


def mesh_index_for(fbx_path, file_id):
    """Which mesh inside a multi-mesh FBX a MeshFilter's fileID refers to.

    A model file can hold many meshes — one statue pack here held eight statues spread over 141
    units — and a MeshFilter picks exactly one. Unioning them all gives an object the size of the
    whole collection, centred on the collection's centroid: the statue came out 141 units long and
    10.8 units off the origin it is placed at.

    Unity's .meta lists each mesh by fileID in the file's own order, so the position of this fileID
    among the 43000xx (Mesh class) entries is the index of the mesh to use.
    """
    if fbx_path not in _metaidx:
        order = []
        try:
            with open(fbx_path + '.meta', encoding='utf-8', errors='ignore') as fh:
                text = fh.read()
        except OSError:
            text = ''
        for m in re.finditer(r'^\s{4}(43000\d\d): (.+)$', text, re.M):
            name = m.group(2).strip()
            if not name.startswith('//'):
                order.append(m.group(1))
        _metaidx[fbx_path] = {fid: i for i, fid in enumerate(sorted(order, key=int))}
    return _metaidx[fbx_path].get(str(file_id))


_axis_map = None
_axis_ready = False

def _axis():
    """Unity's mesh axes are not always the raw FBX axes; derive the per-pack correction once."""
    global _axis_map, _axis_ready
    if not _axis_ready:
        _axis_ready = True   # set first: build_axis_map calls back into mesh_bounds_raw
        try:
            from fbx_axis import build_axis_map
            _axis_map = build_axis_map(sys.modules[__name__], os.path.join(ROOT, 'Assets'))
        except Exception as exc:
            print(f'warning: could not derive FBX axis map ({exc}); assuming identity', file=sys.stderr)
            _axis_map = {}
    return _axis_map

def mesh_bounds(guid, guid2path, file_id=None):
    """Mesh bounds in UNITY's mesh space, which may be a permutation of the raw FBX axes."""
    res = mesh_bounds_raw(guid, guid2path, file_id)
    if not res: return res
    p = guid2path.get(guid)
    if not p: return res
    # fbx_bounds.read_fbx already delivers Unity's mesh: geometric transform applied and X negated.
    # Applying any further axis change here would double it.
    return res

# The depth band the player occupies, in Unity's Z. PLANE_HALF is the player's collider radius.
#
# PLANE_CENTER is NOT zero, and that was the bug behind "I collide with air where I can see an
# opening". The arena's walls are building facades with archways cut through them, and those
# archways sit at z -5..0 with the lintel from 0 up — a ship sliced at z=0 meets the lintel and
# every base seals shut (a depth sweep on the corrected geometry: base reachability 0.8% at z 0
# and -0.5). Between -2.5 and -1.0 the wall cross-section was flat in the origin game (~68 units² either
# way); -1 sits in the walls' densest band (41% of their area lies in -1..0) and every base reaches
# 100% of the level there, so that was the plane. Measure yours the same way: slice at a few
# depths and pick the one where walls are flat and every spawn reaches everything.
#
# `planeZ` in unity-import.json; re-measure if the level changes.
#
# NOTE: for a 2D-physics game walls do not come from this slice at all — the player collides with Unity's 2D colliders
# (see unity_colliders2d.py). This band now only classifies backdrop/foreground and the land-check
# floors.
from import_config import CFG as _CFG, scene_path as scene_path_from_config
PLANE_HALF = float(_CFG['planeHalf'])
PLANE_CENTER = float(_CFG['planeZ'])

_vertcache = {}

def mesh_geometry(guid, guid2path, file_id=None):
    """(vertices in Unity mesh space, triangle index triples) for a model, or None."""
    key = ('geo', guid, file_id)
    if key in _vertcache: return _vertcache[key]
    res = None
    p = guid2path.get(guid)
    if p and os.path.exists(p) and p.lower().endswith('.fbx'):
        try:
            r = read_fbx(p, keep_vertices=True)
            if r:
                ms, us = r
                gscale, usefile = 1.0, True
                meta = p + '.meta'
                if os.path.exists(meta):
                    t = open(meta, encoding='utf-8', errors='ignore').read()
                    m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
                    if m1: gscale = float(m1.group(1))
                    m2 = re.search(r'useFileScale: (\d)', t)
                    if m2: usefile = m2.group(1) == '1'
                sc = gscale * ((us / 100.0) if (usefile and us) else 1.0)
                tf = None   # reader output is already Unity's mesh; see mesh_bounds
                want = mesh_index_for(p, file_id) if file_id is not None else None
                verts, tris, base = [], [], 0
                for mi, entry in enumerate(ms):
                    if want is not None and mi != want:
                        continue
                    ev = entry[3] if len(entry) > 3 else None
                    ei = entry[4] if len(entry) > 4 else None
                    if not ev: continue
                    verts.extend(tuple(v * sc for v in pt) for pt in ev)
                    if ei:
                        face = []
                        for raw in ei:
                            i = int(raw)
                            if i < 0:
                                face.append(-i - 1)
                                for k in range(1, len(face) - 1):   # fan-triangulate
                                    tris.append((base + face[0], base + face[k], base + face[k + 1]))
                                face = []
                            else:
                                face.append(i)
                    base += len(ev)
                if verts: res = (verts, tris)
        except Exception:
            res = None
    _vertcache[key] = res
    return res


def mesh_vertices(guid, guid2path):
    """Mesh vertices in UNITY's mesh space, or None."""
    if guid in _vertcache: return _vertcache[guid]
    res = None
    p = guid2path.get(guid)
    if p and os.path.exists(p) and p.lower().endswith('.fbx'):
        try:
            r = read_fbx(p, keep_vertices=True)
            if r:
                ms, us = r
                pts = []
                for entry in ms:
                    if len(entry) > 3 and entry[3]:
                        pts.extend(entry[3])
                if pts:
                    gscale, usefile = 1.0, True
                    meta = p + '.meta'
                    if os.path.exists(meta):
                        t = open(meta, encoding='utf-8', errors='ignore').read()
                        m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
                        if m1: gscale = float(m1.group(1))
                        m2 = re.search(r'useFileScale: (\d)', t)
                        if m2: usefile = m2.group(1) == '1'
                    sc = gscale * ((us / 100.0) if (usefile and us) else 1.0)
                    res = [tuple(v * sc for v in pt) for pt in pts]
        except Exception:
            res = None
    _vertcache[guid] = res
    return res


# How finely a mesh's cross-section is traced. Small enough to keep a doorway open, coarse enough
# that a whole level's worth of geometry stays cheap.
SLICE_CELL = 0.5


def _clip_to_slab(tri, lo, hi):
    """Clip a triangle to the slab lo <= z <= hi. Returns a convex polygon, possibly empty."""
    poly = list(tri)
    for sign, bound in ((1.0, hi), (-1.0, lo)):
        out = []
        n = len(poly)
        for i in range(n):
            a = poly[i]
            b = poly[(i + 1) % n]
            da = sign * (a[2] - bound)
            db = sign * (b[2] - bound)
            if da <= 0:
                out.append(a)
            if (da <= 0) != (db <= 0):
                t = da / (da - db)
                out.append((a[0] + (b[0] - a[0]) * t,
                            a[1] + (b[1] - a[1]) * t,
                            a[2] + (b[2] - a[2]) * t))
        poly = out
        if not poly:
            return []
    return poly


def _point_in_poly(px, py, poly):
    inside = False
    n = len(poly)
    for i in range(n):
        x0, y0 = poly[i][0], poly[i][1]
        x1, y1 = poly[(i + 1) % n][0], poly[(i + 1) % n][1]
        if (y0 > py) != (y1 > py):
            xx = x0 + (py - y0) * (x1 - x0) / (y1 - y0)
            if xx > px:
                inside = not inside
    return inside


def mesh_plane_rects(guid, world, file_id=None):
    """The XY rectangles a MeshCollider actually occupies AT the gameplay plane, in world space.

    Two things matter here, and both were learned from walls that behaved wrongly in play:

    * A MeshCollider collides with triangles, not with its bounding box, and these walls are
      building facades with doorways cut through them. Reducing one to a rectangle paves the
      doorway over and seals a ship inside its base.
    * A triangle that *crosses* the plane must be clipped TO the plane before its footprint is
      taken. A facade panel runs from z=-6 to z=+2; only a thin band of it is at the height a ship
      flies at, but its full XY extent spans the doorway. Rasterising the whole triangle therefore
      fills the opening back in even though nothing is there at the player's level — which is what
      put walls across the gaps either side of every base.

    So each triangle is clipped to the slab the player occupies, and only what survives is rasterised.
    Returns None when nothing reaches the plane.
    """
    geo = mesh_geometry(guid, guid2path, file_id)
    if not geo:
        return None
    verts, tris = geo
    if not tris:
        return None
    (p, q, sc) = world

    def to_world(v):
        rv = qrot(q, (v[0] * sc[0], v[1] * sc[1], v[2] * sc[2]))
        return (p[0] + rv[0], p[1] + rv[1], p[2] + rv[2])

    cells = set()
    nv = len(verts)
    for (ia, ib, ic) in tris:
        if ia >= nv or ib >= nv or ic >= nv:
            continue
        a, b, c = to_world(verts[ia]), to_world(verts[ib]), to_world(verts[ic])
        if (min(a[2], b[2], c[2]) > PLANE_CENTER + PLANE_HALF
                or max(a[2], b[2], c[2]) < PLANE_CENTER - PLANE_HALF):
            continue
        poly = _clip_to_slab((a, b, c), PLANE_CENTER - PLANE_HALF, PLANE_CENTER + PLANE_HALF)
        if len(poly) < 2:
            continue

        xs = [v[0] for v in poly]
        ys = [v[1] for v in poly]
        gx0 = int(math.floor(min(xs) / SLICE_CELL)); gx1 = int(math.floor(max(xs) / SLICE_CELL))
        gy0 = int(math.floor(min(ys) / SLICE_CELL)); gy1 = int(math.floor(max(ys) / SLICE_CELL))
        if (gx1 - gx0) > 400 or (gy1 - gy0) > 400:
            continue

        # Edges first: a clipped triangle is often a sliver whose cell centres all miss.
        n = len(poly)
        for i in range(n):
            x0, y0 = poly[i][0], poly[i][1]
            x1, y1 = poly[(i + 1) % n][0], poly[(i + 1) % n][1]
            steps = int(max(abs(x1 - x0), abs(y1 - y0)) / (SLICE_CELL * 0.5)) + 1
            for k in range(steps + 1):
                t = k / steps
                cells.add((int(math.floor((x0 + (x1 - x0) * t) / SLICE_CELL)),
                           int(math.floor((y0 + (y1 - y0) * t) / SLICE_CELL))))
        # Then the interior.
        if n >= 3 and (gx1 - gx0) + (gy1 - gy0) > 1:
            for gx in range(gx0, gx1 + 1):
                for gy in range(gy0, gy1 + 1):
                    if (gx, gy) in cells:
                        continue
                    if _point_in_poly((gx + 0.5) * SLICE_CELL, (gy + 0.5) * SLICE_CELL, poly):
                        cells.add((gx, gy))

    if not cells:
        return None

    rows = {}
    for (gx, gy) in cells:
        rows.setdefault(gy, []).append(gx)
    out = []
    for gy, xs in rows.items():
        xs.sort()
        start = prev = xs[0]
        for x in xs[1:] + [None]:
            if x is not None and x == prev + 1:
                prev = x
                continue
            out.append((start * SLICE_CELL, gy * SLICE_CELL,
                        (prev + 1) * SLICE_CELL, (gy + 1) * SLICE_CELL))
            if x is None:
                break
            start = prev = x
    return out


def mesh_bounds_raw(guid, guid2path, file_id=None):
    key = (guid, file_id)
    if key in _meshcache: return _meshcache[key]
    p = guid2path.get(guid)
    res = None
    if p and os.path.exists(p) and p.lower().endswith(('.fbx',)):
        try:
            r = read_fbx(p)
            if r:
                ms, us = r
                # One model file can hold many meshes; a MeshFilter picks exactly one by fileID.
                want = mesh_index_for(p, file_id) if file_id is not None else None
                if want is not None and want < len(ms):
                    ms = [ms[want]]
                if ms:
                    mn = [min(m[1][k] for m in ms) for k in range(3)]
                    mx = [max(m[2][k] for m in ms) for k in range(3)]
                    scale = 1.0
                    meta = p + '.meta'
                    gscale = 1.0; usefile = True
                    if os.path.exists(meta):
                        t = open(meta, encoding='utf-8', errors='ignore').read()
                        m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
                        if m1: gscale = float(m1.group(1))
                        m2 = re.search(r'useFileScale: (\d)', t)
                        if m2: usefile = m2.group(1) == '1'
                    fs = (us / 100.0) if (usefile and us) else 1.0
                    scale = gscale * fs
                    res = ([v*scale for v in mn], [v*scale for v in mx])
        except Exception as e:
            res = None
    _meshcache[key] = res
    return res


def _find_unity_root(start):
    """Walk up from the scene file to the folder that holds `Assets/`."""
    d = os.path.dirname(os.path.abspath(start))
    while d != os.path.dirname(d):
        if os.path.isdir(os.path.join(d, 'Assets')):
            return d
        d = os.path.dirname(d)
    raise SystemExit('could not find the Unity project root above ' + start)


_SCENE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.expanduser(_CFG['unityRoot']), _CFG['scene'])
ROOT = _find_unity_root(_SCENE)

# ---------- guid map ----------
guid2path = {}
for root, dirs, files in os.walk(os.path.join(ROOT, 'Assets')):
    for f in files:
        if f.endswith('.meta'):
            p = os.path.join(root, f)
            try:
                t = open(p, encoding='utf-8', errors='ignore').read(500)
            except Exception:
                continue
            m = re.search(r'guid: ([0-9a-f]{32})', t)
            if m:
                guid2path[m.group(1)] = p[:-5]

DOC_RE = re.compile(r'\n--- !u!(\d+) &(\d+)(?: stripped)?\n')

_cache = {}
def parse_file(path):
    if path in _cache: return _cache[path]
    try:
        txt = open(path, encoding='utf-8', errors='ignore').read()
    except Exception:
        _cache[path] = {}
        return {}
    parts = DOC_RE.split(txt)
    docs = {}
    i = 1
    while i + 2 <= len(parts) - 1:
        docs[parts[i+1]] = (parts[i], parts[i+2])
        i += 3
    _cache[path] = docs
    return docs

# ---------- math ----------
def qmul(a, b):
    ax,ay,az,aw=a; bx,by,bz,bw=b
    return (aw*bx+ax*bw+ay*bz-az*by,
            aw*by-ax*bz+ay*bw+az*bx,
            aw*bz+ax*by-ay*bx+az*bw,
            aw*bw-ax*bx-ay*by-az*bz)
def qrot(q, v):
    x,y,z,w=q; vx,vy,vz=v
    # t = 2*q_vec x v
    tx = 2*(y*vz - z*vy); ty = 2*(z*vx - x*vz); tz = 2*(x*vy - y*vx)
    return (vx + w*tx + (y*tz - z*ty),
            vy + w*ty + (z*tx - x*tz),
            vz + w*tz + (x*ty - y*tx))

def compose(parent, local):
    # parent/local = (pos, rot, scale)
    pp, pr, ps = parent
    lp, lr, ls = local
    sp = (lp[0]*ps[0], lp[1]*ps[1], lp[2]*ps[2])
    rp = qrot(pr, sp)
    pos = (pp[0]+rp[0], pp[1]+rp[1], pp[2]+rp[2])
    rot = qmul(pr, lr)
    scale = (ps[0]*ls[0], ps[1]*ls[1], ps[2]*ls[2])
    return (pos, rot, scale)

def quat_euler_z(q):
    x,y,z,w = q
    return math.degrees(math.atan2(2*(w*z+x*y), 1-2*(y*y+z*z)))

def vec3(body, key, default):
    m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+)\}', body)
    return (float(m.group(1)), float(m.group(2)), float(m.group(3))) if m else default

def quat(body, key):
    m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+), w: ([-\d.eE+]+)\}', body)
    return (float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))) if m else (0.0,0.0,0.0,1.0)

def fid(body, key):
    m = re.search(re.escape(key) + r': \{fileID: (-?\d+)', body)
    return m.group(1) if m else '0'

MAX_DEPTH = 8

# ---------- level patches (see tools/level_patches.json) ----------
def _load_patches():
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'level_patches.json')
    try:
        return json.load(open(path))
    except Exception:
        return {"add_instances": [], "remove_instances": []}
LEVEL_PATCHES = _load_patches()

def patch_skips_instance(prefab_name, world_pos):
    """True when a level patch removes this scene instance (matched by prefab and position)."""
    for r in LEVEL_PATCHES.get('remove_instances', []):
        if r.get('prefab') == prefab_name and 'near' in r:
            if abs(world_pos[0] - r['near'][0]) < 2.0 and abs(world_pos[1] - r['near'][1]) < 2.0:
                return True
    return False

def patch_move_for(object_name, world_pos):
    """Offset a scene object by a `move_objects` patch entry, or (0,0,0).

    `{"name": "CornerTowerTopLeft", "toward_center": 3.0}` slides the object 3 units toward
    the map centre on each horizontal axis (sign taken from its own position), so a corner block
    overlaps the wall runs it is meant to close. `"dpos": [x, y, z]` is an explicit offset.
    """
    for m in LEVEL_PATCHES.get('move_objects', []) or []:
        if m.get('name') != object_name: continue
        if 'dpos' in m:
            d = m['dpos']; return (float(d[0]), float(d[1]), float(d[2]) if len(d) > 2 else 0.0)
        t = float(m.get('toward_center', 0.0))
        sx = -1.0 if world_pos[0] > 0 else (1.0 if world_pos[0] < 0 else 0.0)
        sy = -1.0 if world_pos[1] > 0 else (1.0 if world_pos[1] < 0 else 0.0)
        return (sx * t, sy * t, 0.0)
    return (0.0, 0.0, 0.0)

def patch_added_instances(prefab_name):
    """Extra scene-level placements a level patch adds for this prefab: [(pos, quat)]."""
    out = []
    for a in LEVEL_PATCHES.get('add_instances', []):
        if a.get('prefab') == prefab_name:
            zr = math.radians(a.get('zrot', 0.0)) / 2.0
            out.append((tuple(a['pos']), (0.0, 0.0, math.sin(zr), math.cos(zr))))
    return out

results = []   # collected colliders
visited_gos = set()   # (file path, gameObject fileID) actually walked

def parse_mods(body):
    """Return dict: targetFileID -> {property: value} and prefab guid overrides."""
    mods = {}
    # each modification entry
    for m in re.finditer(r'- target: \{fileID: (-?\d+), guid: ([0-9a-f]{32}), type: \d+\}\s*\n\s*propertyPath: (\S+)\s*\n\s*value: (.*?)\n\s*objectReference:', body):
        tgt, guid, prop, val = m.group(1), m.group(2), m.group(3), m.group(4).strip()
        mods.setdefault((guid, tgt), {})[prop] = val
    return mods

def local_trs(body):
    return (vec3(body, 'm_LocalPosition', (0,0,0)),
            quat(body, 'm_LocalRotation'),
            vec3(body, 'm_LocalScale', (1,1,1)))

def apply_mods(trs, mod):
    (p, r, s) = trs
    p = list(p); r = list(r); s = list(s)
    for k, v in mod.items():
        try: fv = float(v)
        except Exception: continue
        if k == 'm_LocalPosition.x': p[0]=fv
        elif k == 'm_LocalPosition.y': p[1]=fv
        elif k == 'm_LocalPosition.z': p[2]=fv
        elif k == 'm_LocalRotation.x': r[0]=fv
        elif k == 'm_LocalRotation.y': r[1]=fv
        elif k == 'm_LocalRotation.z': r[2]=fv
        elif k == 'm_LocalRotation.w': r[3]=fv
        elif k == 'm_LocalScale.x': s[0]=fv
        elif k == 'm_LocalScale.y': s[1]=fv
        elif k == 'm_LocalScale.z': s[2]=fv
    return (tuple(p), tuple(r), tuple(s))

def go_name(docs, goid):
    d = docs.get(goid)
    if not d: return ''
    m = re.search(r'm_Name: (.*)', d[1])
    return m.group(1).strip() if m else ''

def go_active(docs, goid, mods=None):
    """Is this GameObject active — honouring the prefab instance's m_IsActive override.

    A scene can switch whole prefab instances off this way (in the scene this was built on, two
    wall prefabs were root-inactive in every placement), so reading the source prefab alone
    builds a level Unity never shows.
    """
    if mods:
        over = mods.get(goid, {}).get('m_IsActive')
        if over is not None: return str(over).strip() == '1'
    d = docs.get(goid)
    if not d: return True
    m = re.search(r'm_IsActive: (\d)', d[1])
    return (m.group(1) == '1') if m else True

def comp_enabled(cbody, cid=None, mods=None):
    """A component's m_Enabled, honouring the instance override that targets it."""
    if mods and cid is not None:
        over = mods.get(cid, {}).get('m_Enabled')
        if over is not None: return str(over).strip() == '1'
    m = re.search(r'm_Enabled: (\d)', cbody)
    return (m.group(1) == '1') if m else True

NESTED_ID_MASK = 0x7FFFFFFFFFFFFFFF

def instance_mods(body, docs, pid, pdocs, outer_mods=None):
    """The modification map a prefab instance applies to its source file.

    Overrides written on the instance itself come from its m_Modifications. Overrides that an
    OUTER instance wrote against objects inside this nested one are keyed, in the outer file's
    namespace, by (nestedObjectFileID ^ nestedInstanceFileID) & 0x7FFF..., so they are
    re-keyed here into the nested file's own ids. Verified on the origin scene: an instance's
    m_Enabled override resolved to a MeshCollider inside a nested prefab, exactly as Unity shows it.
    """
    modmap = {}
    for (_g, t), props in parse_mods(body).items():
        modmap.setdefault(t, {}).update(props)
    if outer_mods:
        try: p_int = int(pid)
        except ValueError: return modmap
        pending = {t: props for t, props in outer_mods.items() if t not in docs}
        if pending:
            for oid in pdocs:
                try: comp = (int(oid) ^ p_int) & NESTED_ID_MASK
                except ValueError: continue
                props = pending.get(str(comp))
                if props: modmap.setdefault(oid, {}).update(props)
    return modmap

def go_layer(docs, goid, mods=None):
    """A GameObject's collision layer, honouring the instance's override.

    Unity stores "this copy lives on Terrain" as an `m_Layer` modification on the prefab instance,
    not in the source prefab, so reading the source alone reports every piece as Default and the
    real wall set disappears into the decoration.
    """
    if mods:
        over = mods.get(goid, {}).get('m_Layer')
        if over is not None:
            try: return int(float(over))
            except ValueError: pass
    d = docs.get(goid)
    if not d: return 0
    m = re.search(r'm_Layer: (\d+)', d[1])
    return int(m.group(1)) if m else 0

def walk(path, docs, tid, world, depth, mods_for_file, tag):
    """Walk transform tid in file `path`, world = parent world TRS."""
    if depth > MAX_DEPTH: return
    cls, body = docs[tid]
    if cls == '1001':
        # nested prefab instance
        handle_prefab_instance(path, docs, tid, body, world, depth, mods_for_file, tag)
        return
    if 'm_LocalPosition' not in body:
        return   # stripped stub for a nested prefab; expand_file/sweep_orphan_colliders place it
    trs = local_trs(body)
    key = mods_for_file.get(tid)
    if key: trs = apply_mods(trs, key)
    w = compose(world, trs)
    goid = fid(body, 'm_GameObject')
    if not go_active(docs, goid, mods_for_file):
        return
    name = go_name(docs, goid)
    layer = go_layer(docs, goid, mods_for_file)
    visited_gos.add((path, goid))
    # find components on this GameObject
    gd = docs.get(goid)
    if gd:
        for m in re.finditer(r'- component: \{fileID: (\d+)\}', gd[1]):
            cid = m.group(1)
            cd = docs.get(cid)
            if not cd: continue
            ccls, cbody = cd
            if ccls == '65':  # BoxCollider
                if not comp_enabled(cbody, cid, mods_for_file): continue
                size = vec3(cbody, 'm_Size', (1,1,1))
                center = vec3(cbody, 'm_Center', (0,0,0))
                trig = re.search(r'm_IsTrigger: (\d)', cbody)
                results.append(dict(kind='box', name=name, tag=tag, layer=layer,
                                    world=w, size=size, center=center,
                                    trigger=(trig.group(1)=='1') if trig else False))
            elif ccls == '64':  # MeshCollider
                if not comp_enabled(cbody, cid, mods_for_file): continue
                mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cbody)
                if not mm: continue
                rects = mesh_plane_rects(mm.group(2), w, mm.group(1))
                if rects is not None:
                    for (rx0, ry0, rx1, ry1) in rects:
                        cw = ((rx0 + rx1) / 2, (ry0 + ry1) / 2, PLANE_CENTER)
                        results.append(dict(kind='box', name=name, tag=tag, layer=layer,
                                            world=(cw, (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0)),
                                            size=(rx1 - rx0, ry1 - ry0, 2 * PLANE_HALF),
                                            center=(0.0, 0.0, 0.0), trigger=False, src='mesh'))
                    continue
                mb = mesh_bounds(mm.group(2), guid2path, mm.group(1))
                if not mb: continue
                mn, mx = mb
                if mn[2] > PLANE_CENTER + PLANE_HALF or mx[2] < PLANE_CENTER - PLANE_HALF:
                    pass
                size = (mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2])
                center = ((mx[0]+mn[0])/2, (mx[1]+mn[1])/2, (mx[2]+mn[2])/2)
                results.append(dict(kind='box', name=name, tag=tag, layer=layer,
                                    world=w, size=size, center=center, trigger=False, src='meshbox'))
            elif ccls == '135':  # SphereCollider
                if not comp_enabled(cbody, cid, mods_for_file): continue
                rad = re.search(r'm_Radius: ([-\d.eE+]+)', cbody)
                center = vec3(cbody, 'm_Center', (0,0,0))
                trig = re.search(r'm_IsTrigger: (\d)', cbody)
                results.append(dict(kind='sphere', name=name, tag=tag, layer=layer,
                                    world=w, radius=float(rad.group(1)) if rad else 0.5,
                                    center=center, trigger=(trig.group(1)=='1') if trig else False))
    # children
    for m in re.finditer(r'- \{fileID: (\d+)\}', body[body.find('m_Children:'):body.find('m_Father:')] if 'm_Children:' in body and 'm_Father:' in body else ''):
        cid = m.group(1)
        if cid in docs:
            walk(path, docs, cid, w, depth+1, mods_for_file, tag)

def handle_prefab_instance(path, docs, tid, body, world, depth, outer_mods, tag):
    if depth > MAX_DEPTH: return
    m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
    if not m: return
    src = guid2path.get(m.group(1))
    if not src or not os.path.exists(src): return
    pdocs = parse_file(src)
    if not pdocs: return
    modmap = instance_mods(body, docs, tid, pdocs, outer_mods)
    # find prefab root transform: the transform with m_Father 0
    root = None
    for pid, (pcls, pbody) in pdocs.items():
        if pcls in ('4',) and fid(pbody, 'm_Father') == '0':
            root = pid; break
    if root is None:
        for pid, (pcls, pbody) in pdocs.items():
            if pcls == '1001':
                root = pid; break
    if root is None: return
    newtag = tag or os.path.basename(src).replace('.prefab','')
    expand_file(src, pdocs, world, depth+1, modmap, newtag)
    # Orphan colliders are stored relative to the prefab ROOT, so they need the root's world
    # transform (parent chain + this instance's own placement), not the parent's.
    root_world = world
    rcls, rbody = pdocs[root]
    if rcls == '4':
        root_world = compose(world, apply_mods(local_trs(rbody), modmap.get(root, {})))
    sweep_orphan_colliders(src, pdocs, root_world, newtag)


def expand_file(path, docs, world, depth, mods, tag):
    """Every collider one file places: its root transforms, then its own nested prefab instances.

    The second half is what was missing. A nested instance is listed in its parent's m_Children only
    as a stripped stub the transform walk cannot follow, so a prefab assembled out of other prefabs
    contributed only the colliders stored directly in its own file — one prefab arrived with no
    collision at all, another with 6 of its 56 boxes.
    """
    if depth > MAX_DEPTH: return
    for tid, (cls, body) in docs.items():
        if cls == '4' and fid(body, 'm_Father') == '0' and 'm_LocalPosition' in body:
            walk(path, docs, tid, world, depth, mods, tag)
    for tid, (cls, body) in docs.items():
        if cls != '1001': continue
        chain = []
        cur = fid(body, 'm_TransformParent')
        guard = 0
        while cur != '0' and cur in docs and guard < 40:
            ccls, cbody = docs[cur]
            if ccls != '4' or 'm_LocalPosition' not in cbody: break
            chain.append((cur, cbody))
            cur = fid(cbody, 'm_Father')
            guard += 1
        w = world
        for cid, cbody in reversed(chain):
            trs = local_trs(cbody)
            m = mods.get(cid)
            if m: trs = apply_mods(trs, m)
            w = compose(w, trs)
        handle_prefab_instance(path, docs, tid, body, w, depth, mods, tag)


def sweep_orphan_colliders(path, docs, world, tag):
    """Colliders defined in a prefab file but attached to a GameObject that lives inside a nested
    prefab instance never get reached by the transform walk (their Transform is a stripped stub).
    Unity stores their center/size relative to the object, which for these cases is the prefab
    root, so attaching them there is correct. Dedupe identical boxes: destructible props carry a
    second, inactive copy for their fracture pieces."""
    seen = set()
    for cid, (ccls, cbody) in docs.items():
        if ccls not in ('65',):
            continue
        goid = fid(cbody, 'm_GameObject')
        if (path, goid) in visited_gos:
            continue
        gd = docs.get(goid)
        if gd is not None and go_active(docs, goid) is False:
            continue
        enabled = re.search(r'm_Enabled: (\d)', cbody)
        if enabled and enabled.group(1) != '1':
            continue
        size = vec3(cbody, 'm_Size', (1, 1, 1))
        center = vec3(cbody, 'm_Center', (0, 0, 0))
        key = (round(size[0], 3), round(size[1], 3), round(size[2], 3),
               round(center[0], 3), round(center[1], 3), round(center[2], 3))
        if key in seen:
            continue
        seen.add(key)
        trig = re.search(r'm_IsTrigger: (\d)', cbody)
        results.append(dict(kind='box', name=tag, tag=tag, layer=7,
                            world=world, size=size, center=center,
                            trigger=(trig.group(1) == '1') if trig else False))

def extract_scene(scene_path):
    docs = parse_file(scene_path)
    IDENT = ((0.0,0.0,0.0), (0.0,0.0,0.0,1.0), (1.0,1.0,1.0))
    expand_file(scene_path, docs, IDENT, 0, {}, None)


def dedupe(items):
    """One collider reached by two paths is still one collider."""
    seen = set()
    out = []
    for it in items:
        (p, q, sc) = it['world']
        key = (it['kind'], it['tag'],
               tuple(round(v, 4) for v in p), tuple(round(v, 4) for v in q),
               tuple(round(v, 4) for v in sc),
               tuple(round(v, 4) for v in it.get('size', (0,0,0))),
               tuple(round(v, 4) for v in it.get('center', (0,0,0))),
               round(it.get('radius', 0), 4), it.get('trigger', False))
        if key in seen: continue
        seen.add(key)
        out.append(it)
    return out


if __name__ == '__main__':
    scene = _SCENE
    extract_scene(scene)
    out = []
    for r in dedupe(results):
        (p, q, s) = r['world']
        e = dict(r)
        e['pos'] = [round(v,4) for v in p]
        e['scale'] = [round(v,4) for v in s]
        e['zrot'] = round(quat_euler_z(q), 2)
        e['quat'] = [round(v,5) for v in q]
        del e['world']
        e['size'] = [round(v,4) for v in r.get('size',(0,0,0))]
        e['center'] = [round(v,4) for v in r.get('center',(0,0,0))]
        out.append(e)
    print(json.dumps(out))
