| 1 | # Run with Blender 4.5.9: |
| 2 | # blender --background --python scripts/convert-device-model.py -- <model-id> <source.usdz> <output.glb> |
| 3 | # Then optimize using @gltf-transform/cli 4.2.1 with --compress false --simplify false |
| 4 | # --join false --palette false --flatten false --texture-size 1024 --texture-compress webp. |
| 5 | import hashlib |
| 6 | import json |
| 7 | import sys |
| 8 | from pathlib import Path |
| 9 | |
| 10 | import bpy |
| 11 | import numpy as np |
| 12 | from mathutils import Matrix, Vector |
| 13 | |
| 14 | model_id, source, output = sys.argv[sys.argv.index("--") + 1 :] |
| 15 | manifest_path = ( |
| 16 | Path(__file__).resolve().parents[1] |
| 17 | / "apps/web/src/components/device/models/sources.json" |
| 18 | ) |
| 19 | manifest = json.loads(manifest_path.read_text()) |
| 20 | model = next(m for m in manifest["models"] if m["id"] == model_id) |
| 21 | if hashlib.sha256(Path(source).read_bytes()).hexdigest() != model["sourceSha256"]: |
| 22 | raise ValueError("Source checksum differs from the recorded Apple asset") |
| 23 | body_name, screen_name = model["bodyNode"], model["screenNode"] |
| 24 | bpy.ops.wm.read_factory_settings(use_empty=True) |
| 25 | bpy.ops.wm.usd_import(filepath=source, import_materials=True) |
| 26 | body = bpy.data.objects[model.get("accessoryNode", body_name)] |
| 27 | screen = bpy.data.objects[screen_name] |
| 28 | objects = [o for o in body.children_recursive if o.type == "MESH"] |
| 29 | if body.type == "MESH": |
| 30 | objects.append(body) |
| 31 | uv = screen.data.uv_layers.active |
| 32 | points = [] |
| 33 | coords = [] |
| 34 | for loop in screen.data.loops: |
| 35 | points.append( |
| 36 | tuple(screen.matrix_world @ screen.data.vertices[loop.vertex_index].co) |
| 37 | ) |
| 38 | coords.append((*uv.data[loop.index].uv, 1)) |
| 39 | coef = np.linalg.lstsq(np.array(coords), np.array(points), rcond=None)[0] |
| 40 | right = Vector(coef[0]).normalized() |
| 41 | up = Vector(coef[1]).normalized() |
| 42 | normal = right.cross(up).normalized() |
| 43 | # The authored UVs establish the device's front and its physical portrait axes. |
| 44 | # No body proportions are changed. The runtime uses a standard planar framebuffer UV. |
| 45 | rot = Matrix((right, up, normal)).to_4x4() |
| 46 | world = [rot @ Vector(p) for p in points] |
| 47 | lo = Vector(tuple(min(p[i] for p in world) for i in range(3))) |
| 48 | hi = Vector(tuple(max(p[i] for p in world) for i in range(3))) |
| 49 | center = (lo + hi) / 2 |
| 50 | scale = 2.2 / (hi.y - lo.y) |
| 51 | transform = ( |
| 52 | Matrix.Translation(Vector((0, 0, 0.043))) |
| 53 | @ Matrix.Diagonal(Vector((scale, scale, scale, 1))) |
| 54 | @ Matrix.Translation(-center) |
| 55 | @ rot |
| 56 | ) |
| 57 | for o in objects: |
| 58 | matrix = transform @ o.matrix_world |
| 59 | o.parent = None |
| 60 | o.matrix_world = Matrix.Identity(4) |
| 61 | o.data.transform(matrix) |
| 62 | # Blender is Z-up; glTF export converts to Y-up. Store canonical Y-up geometry as Blender Z-up. |
| 63 | o.data.transform(Matrix.Rotation(np.pi / 2, 4, "X")) |
| 64 | if o == screen: |
| 65 | o.name = "device-screen" |
| 66 | o.data.name = "device-screen" |
| 67 | for loop in o.data.loops: |
| 68 | p = o.data.vertices[loop.vertex_index].co |
| 69 | uv.data[loop.index].uv = ( |
| 70 | (p.x / ((hi.x - lo.x) * scale)) + 0.5, |
| 71 | p.z / 2.2 + 0.5, |
| 72 | ) |
| 73 | # This material is replaced by the viewer and must not retain the marketing screenshot. |
| 74 | mat = bpy.data.materials.new("device-screen") |
| 75 | mat.diffuse_color = (0, 0, 0, 1) |
| 76 | o.data.materials.clear() |
| 77 | o.data.materials.append(mat) |
| 78 | for o in list(bpy.data.objects): |
| 79 | if o not in objects: |
| 80 | bpy.data.objects.remove(o, do_unlink=True) |
| 81 | for image in bpy.data.images: |
| 82 | if image.size[0] > 1024 or image.size[1] > 1024: |
| 83 | k = 1024 / max(image.size) |
| 84 | image.scale(max(1, round(image.size[0] * k)), max(1, round(image.size[1] * k))) |
| 85 | for o in objects: |
| 86 | o.select_set(True) |
| 87 | bpy.ops.export_scene.gltf( |
| 88 | filepath=output, |
| 89 | export_format="GLB", |
| 90 | use_selection=True, |
| 91 | export_yup=True, |
| 92 | export_animations=False, |
| 93 | export_extras=False, |
| 94 | export_image_format="AUTO", |
| 95 | export_copyright="Apple Inc. Original device assets; see models/sources.json for provenance.", |
| 96 | ) |
| 97 | print( |
| 98 | "MODEL_METRICS", |
| 99 | json.dumps( |
| 100 | { |
| 101 | "width": (hi.x - lo.x) * scale, |
| 102 | "height": 2.2, |
| 103 | "bodyMeshes": len(objects), |
| 104 | "screen": screen.name, |
| 105 | "source": source, |
| 106 | } |
| 107 | ), |
| 108 | ) |