Last week I wrote about a GLB that shipped as a 134-meter headphone. This week is the sequel: we converted that same model to USDZ — the format Apple's AR Quick Look actually loads — and the auditor caught a different set of bombs. Same model, different container, new ways to embarrass yourself in front of a client.
Here are the three USDZ packaging rules that trip up pipelines, each with a ~15-line stdlib check. All verified against Apple's published USDZ packaging spec.
1. USDZ is a zip, but it's not your zip
The temptation: "it's a zip, let me deflate it." Apple's spec requires every entry stored uncompressed (ZIP_STORED). Compressed entries are the classic build-machine-works-phone-fails bug:
import zipfile
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
if info.compress_type != zipfile.ZIP_STORED:
fail("container/compression",
f"'{info.filename}' is compressed; USDZ requires all entries ZIP_STORED")
A desktop preview tool will happily open a deflated USDZ. The iPhone will not. This is exactly the class of bug that passes your CI and explodes in the client's demo.
2. The 64-byte alignment rule nobody reads
The spec requires each file's data to start on a 64-byte boundary. Zip writers pad with an extra field between the local header and the data — if your zip library doesn't do this (most don't, because plain zips don't care), offsets drift:
# data start = local header offset + 30 + name + extra
data_off = info.header_offset + 30 + len(info.filename.encode()) + len(info.extra)
if data_off % 64:
warn("container/alignment", f"'{info.filename}' data at byte {data_off}, not 64-byte aligned")
Build your USDZ with the alignment (pad extra with zeros so the data lands on the boundary) and check it on every build. Alignment problems are silent until a loader with stricter memory mapping gets involved.
3. Scale drift is a mm away
glTF says 1 unit = 1 meter. USD says metersPerUnit = 1. But a Blender scene set to millimeters exports a model 1000× too big, and both formats will happily accept it. Our own catalog model, converted honestly, spans:
USDZcheck — real_headphone.usdz
container : 2 entries, stored=True, aligned=True, 0.03 MB
structure : defaultPrim=Root, 1 meshes, 1 materials, 1 bindings, 1 textures
geometry : 716 tris / 573 verts, span 71.333 units
! [WARN] geometry/scale: model spans ~71.33 units - AR Quick Look expects real-world meters
verdict : PASS_WITH_WARNINGS (0 fails, 1 warnings)
71 meters of headphone. Same defect GLBcheck caught on the GLB side — which is the point: audit both containers, because conversion doesn't fix scale, it just re-wraps it. The bbox check is five lines: read point3f[] points, take the max spread across axes, and warn outside 0.01–10 units for product-scale assets.
Bonus: two structure checks that catch hand-made packages
-
defaultPrimdeclared and actually present (structure/no-defaultprim) — AR Quick Look picks the first root otherwise, which may not be your model. -
faceVertexIndiceslength must equalsum(faceVertexCounts)— hand-edited or buggy exports get this wrong and crash triangulation (geometry/index-mismatch).
The gate, not the viewer
Desktop viewers are forgiving because they want to render something. Your delivery gate is allowed to be rude:
python usdzcheck.py model.usdz --json --strict && ship_to_client.sh
The full auditor (~200 lines, stdlib only, exit codes 0/1/2 for CI) plus its 14-check self-test is USDZcheck ($29), and the GLB-side twin is GLBcheck. Together they're the pre-flight for "GLB to the web, USDZ to the iPhone" — the two containers your clients will hand you in 2026.
The deeper point stands from last week: the bugs that cost you the client are the ones the desktop can't see. Audit for the runtime your customer actually runs.
Top comments (0)