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.
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bpy
11import numpy as np
12from mathutils import Matrix, Vector
13
14model_id, source, output = sys.argv[sys.argv.index("--") + 1 :]
15manifest_path = (
16 Path(__file__).resolve().parents[1]
17 / "apps/web/src/components/device/models/sources.json"
18)
19manifest = json.loads(manifest_path.read_text())
20model = next(m for m in manifest["models"] if m["id"] == model_id)
21if hashlib.sha256(Path(source).read_bytes()).hexdigest() != model["sourceSha256"]:
22 raise ValueError("Source checksum differs from the recorded Apple asset")
23body_name, screen_name = model["bodyNode"], model["screenNode"]
24bpy.ops.wm.read_factory_settings(use_empty=True)
25bpy.ops.wm.usd_import(filepath=source, import_materials=True)
26body = bpy.data.objects[model.get("accessoryNode", body_name)]
27screen = bpy.data.objects[screen_name]
28objects = [o for o in body.children_recursive if o.type == "MESH"]
29if body.type == "MESH":
30 objects.append(body)
31uv = screen.data.uv_layers.active
32points = []
33coords = []
34for 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))
39coef = np.linalg.lstsq(np.array(coords), np.array(points), rcond=None)[0]
40right = Vector(coef[0]).normalized()
41up = Vector(coef[1]).normalized()
42normal = 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.
45rot = Matrix((right, up, normal)).to_4x4()
46world = [rot @ Vector(p) for p in points]
47lo = Vector(tuple(min(p[i] for p in world) for i in range(3)))
48hi = Vector(tuple(max(p[i] for p in world) for i in range(3)))
49center = (lo + hi) / 2
50scale = 2.2 / (hi.y - lo.y)
51transform = (
52 Matrix.Translation(Vector((0, 0, 0.043)))
53 @ Matrix.Diagonal(Vector((scale, scale, scale, 1)))
54 @ Matrix.Translation(-center)
55 @ rot
56)
57for 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)
78for o in list(bpy.data.objects):
79 if o not in objects:
80 bpy.data.objects.remove(o, do_unlink=True)
81for 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)))
85for o in objects:
86 o.select_set(True)
87bpy.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)
97print(
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)