mxmxmx2tiles 0.1.0

Convert Bentley ContextCapture 3mx photogrammetry data to Cesium 3D Tiles
Documentation
#!/usr/bin/env python3
"""Rigorous structural validator for mxmxmx2tiles output: GLB/glTF 2.0 invariants
and 3D Tiles 1.1 tree invariants. Dependency-free. Samples glbs + walks every
tileset.json. Usage: validate.py <out-dir> [sample-per-area]"""
import json, struct, os, sys, random

OUT = sys.argv[1]
NS = int(sys.argv[2]) if len(sys.argv) > 2 else 3
errs = []
warns = []
def err(m): errs.append(m)
def warn(m): warns.append(m)

COMP = {5120:1,5121:1,5122:2,5123:2,5125:4,5126:4}  # componentType -> byte size
NC = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}

def check_glb(path):
    d = open(path, "rb").read()
    if len(d) < 12: return err(f"{path}: too short")
    magic, ver, total = struct.unpack("<III", d[:12])
    if magic != 0x46546C67: return err(f"{path}: bad GLB magic")
    if ver != 2: err(f"{path}: GLB version {ver} != 2")
    if total != len(d): err(f"{path}: GLB length {total} != file {len(d)}")
    jlen, jtype = struct.unpack("<II", d[12:20])
    if jtype != 0x4E4F534A: return err(f"{path}: chunk0 not JSON")
    if jlen % 4: err(f"{path}: JSON chunk not 4-aligned")
    jchunk = d[20:20+jlen]
    if jchunk[-1:] not in (b" ", b"}", b"]"):
        if jchunk.rstrip(b" ")[-1:] not in (b"}", b"]"): err(f"{path}: JSON pad not spaces")
    g = json.loads(jchunk)
    off = 20 + jlen
    blen, btype = struct.unpack("<II", d[off:off+8])
    if btype != 0x004E4942: return err(f"{path}: chunk1 not BIN")
    if blen % 4: err(f"{path}: BIN chunk not 4-aligned")
    binoff = off + 8
    used = g.get("extensionsUsed", [])
    compressed = "EXT_meshopt_compression" in used
    buffers = g["buffers"]
    if buffers[0]["byteLength"] > blen: err(f"{path}: buffer0.byteLength > BIN {blen}")

    if compressed:
        req = g.get("extensionsRequired", [])
        need = ["EXT_meshopt_compression"]
        if "KHR_mesh_quantization" in used:
            need.append("KHR_mesh_quantization")
        for e in need:
            if e not in req: err(f"{path}: {e} must be in extensionsRequired")
        fb = buffers[1].get("extensions", {}).get("EXT_meshopt_compression", {}).get("fallback") \
            if len(buffers) > 1 else None
        if fb is not True: err(f"{path}: missing fallback buffer (buffer 1, fallback:true)")

    # bufferViews: EXT-compressed ones point their *source* into BIN (buffer 0)
    # and their decompressed layout into the fallback buffer; raw ones live in BIN.
    for i, bv in enumerate(g["bufferViews"]):
        ext = bv.get("extensions", {}).get("EXT_meshopt_compression")
        if ext:
            if ext.get("buffer") != 0: err(f"{path}: bufferView {i} EXT buffer != 0")
            if ext["byteOffset"] + ext["byteLength"] > blen:
                err(f"{path}: bufferView {i} compressed source exceeds BIN")
            fbi = bv.get("buffer")
            if bv.get("byteOffset", 0) + bv["byteLength"] > buffers[fbi]["byteLength"]:
                err(f"{path}: bufferView {i} decompressed range exceeds fallback buffer")
            if ext.get("mode") not in ("ATTRIBUTES", "TRIANGLES", "INDICES"):
                err(f"{path}: bufferView {i} bad EXT mode {ext.get('mode')}")
            if ext.get("mode") == "ATTRIBUTES" and ext["count"] * ext.get("byteStride", 0) != bv["byteLength"]:
                err(f"{path}: bufferView {i} count*stride != decompressed byteLength")
        else:
            if bv.get("byteOffset", 0) + bv["byteLength"] > blen:
                err(f"{path}: bufferView {i} exceeds BIN")

    POS_CT = (5126, 5123, 5122)   # float, or quantized (u)short
    IDX_CT = (5125, 5123)         # uint or ushort
    for i, a in enumerate(g["accessors"]):
        ct, ty = a["componentType"], a["type"]
        if ct not in COMP: err(f"{path}: accessor {i} bad componentType {ct}"); continue
        stride = COMP[ct] * NC[ty]
        bv = g["bufferViews"][a["bufferView"]]
        need = a.get("byteOffset", 0) + a["count"] * stride
        if need > bv["byteLength"]:
            err(f"{path}: accessor {i} overflows bufferView ({need}>{bv['byteLength']})")

    for m in g.get("meshes", []):
        for p in m["primitives"]:
            attrs = p["attributes"]
            pa = g["accessors"][attrs["POSITION"]]
            vc = pa["count"]
            if pa["type"] != "VEC3" or pa["componentType"] not in POS_CT:
                err(f"{path}: POSITION not VEC3 / unexpected componentType {pa['componentType']}")
            if "min" not in pa or "max" not in pa or len(pa["min"]) != 3:
                err(f"{path}: POSITION missing min/max[3]")
            elif any(pa["min"][k] > pa["max"][k] for k in range(3)):
                err(f"{path}: POSITION min>max")
            if "TEXCOORD_0" in attrs and g["accessors"][attrs["TEXCOORD_0"]]["count"] != vc:
                err(f"{path}: TEXCOORD_0 count != POSITION count")
            if "indices" in p:
                ia = g["accessors"][p["indices"]]
                if ia["componentType"] not in IDX_CT or ia["type"] != "SCALAR":
                    err(f"{path}: indices not SCALAR/uint")
                # index-range spot-check only when readable (uncompressed)
                bv = g["bufferViews"][ia["bufferView"]]
                if "EXT_meshopt_compression" not in bv.get("extensions", {}):
                    s = binoff + bv.get("byteOffset", 0) + ia.get("byteOffset", 0)
                    n = ia["count"]; mx = 0
                    for k in range(0, n, max(1, n // 500)):
                        mx = max(mx, struct.unpack("<I", d[s+k*4:s+k*4+4])[0])
                    if mx >= vc: err(f"{path}: index {mx} >= vertex count {vc}")
            mat = p.get("material")
            if mat is not None:
                bct = g["materials"][mat].get("pbrMetallicRoughness", {}).get("baseColorTexture")
                if bct:
                    if "TEXCOORD_0" not in attrs: err(f"{path}: textured prim lacks TEXCOORD_0")
                    img = g["images"][g["textures"][bct["index"]]["source"]]
                    bv = g["bufferViews"][img["bufferView"]]  # images are raw in BIN
                    s = binoff + bv.get("byteOffset", 0)
                    if img.get("mimeType") != "image/jpeg": err(f"{path}: image not image/jpeg")
                    if d[s:s+2] != b"\xff\xd8": err(f"{path}: image not valid JPEG")
    if "materials" in g and "KHR_materials_unlit" not in used:
        err(f"{path}: uses unlit material but extensionsUsed missing it")
    return g

def box_ok(b):
    return isinstance(b, list) and len(b) == 12

def walk_tileset(ts_path, parent_ge, glb_samples):
    ts = json.load(open(ts_path))
    a = ts.get("asset", {})
    if a.get("version") != "1.1": warn(f"{ts_path}: asset.version {a.get('version')}")
    base = os.path.dirname(ts_path)
    seen_glb = {}  # within a tileset, two tiles must not share a glb (collision)
    def walk(t, pge):
        bv = t.get("boundingVolume", {})
        if "box" not in bv or not box_ok(bv["box"]):
            err(f"{ts_path}: tile boundingVolume.box invalid")
        ge = t.get("geometricError")
        if ge is None or ge < 0: err(f"{ts_path}: bad geometricError {ge}")
        elif pge is not None and ge > pge + 1e-6:
            err(f"{ts_path}: GE {ge} > parent {pge} (non-monotonic)")
        if t.get("refine", "REPLACE") not in ("REPLACE", "ADD"):
            err(f"{ts_path}: bad refine {t.get('refine')}")
        c = t.get("content")
        if c:
            uri = c["uri"]
            p = os.path.join(base, uri)
            if not os.path.exists(p):
                err(f"{ts_path}: content uri missing: {uri}")
            elif uri.endswith(".json"):
                walk_tileset(p, ge, glb_samples)  # external tileset
            elif uri.endswith(".glb"):
                seen_glb[uri] = seen_glb.get(uri, 0) + 1
                if seen_glb[uri] == 2:
                    err(f"{ts_path}: glb '{uri}' referenced by multiple tiles (filename collision)")
                glb_samples.append(p)
        for ch in t.get("children", []):
            walk(ch, ge)
    walk(ts["root"], parent_ge)
    if "transform" in ts["root"] and len(ts["root"]["transform"]) != 16:
        err(f"{ts_path}: root transform not 16 floats")

# Walk the whole tileset tree (cheap: JSON only), collecting glb paths.
glb_samples = []
walk_tileset(os.path.join(OUT, "tileset.json"), None, glb_samples)
print(f"tilesets walked OK; {len(glb_samples)} glb content refs found")

# Sample glbs per area for the (expensive) binary checks.
random.seed(42)
by_area = {}
for p in glb_samples:
    by_area.setdefault(os.path.dirname(p), []).append(p)
sample = []
for area, ps in by_area.items():
    sample += random.sample(ps, min(NS, len(ps)))
print(f"deep-checking {len(sample)} glbs across {len(by_area)} areas...")
for p in sample:
    try: check_glb(p)
    except Exception as e: err(f"{p}: exception {e}")

print(f"\n=== {len(errs)} errors, {len(warns)} warnings ===")
for m in errs[:40]: print("ERR ", m)
for m in warns[:10]: print("WARN", m)
sys.exit(1 if errs else 0)