Reverb commited on
Commit
4537d39
·
1 Parent(s): 4fab588

Milestones 6+8: albedo/material/AO baking + ORM pack, LODs, collision, pivot, scale

Browse files

Milestone 6 — Stage 2G-2I (stage2_bake_albedo.py, stage2_bake_ao.py):
- bake_albedo(): vertex-colour albedo to UV atlas via nvdiffrast + trimesh proximity
- bake_material(): metallic/roughness maps; reads mesh metadata attrs from TRELLIS.2
GLB, falls back to dielectric defaults (met=0, rough=0.5)
- bake_ao(): hemisphere ray casting via trimesh RayMesh; 16/64/256 rays per quality
preset; uses nvdiffrast only for UV rasterization, CPU for actual ray tests

Milestone 8 — Stage 2K-2O (stage2_finalize.py):
- pack_orm(): AO→R, Roughness→G, Metallic→B for UE5
- generate_lods(): LOD0/1/2 via PyMeshLab quadric at 100%/50%/25%
- generate_collision(): CoACD convex decomposition (convex_hull fallback)
- set_pivot(): bottom_center / geometric_center pivot correction
- validate_scale(): rescale to real-world height in cm (UE5 units)

app.py: wire all stages into run_post_process; only SDXL inpaint (M7) is a stub
requirements.txt: add coacd==1.0.4

app.py CHANGED
@@ -156,25 +156,89 @@ def run_post_process(
156
  except Exception as e:
157
  log.append(f"⚠️ Normal bake error: {e}")
158
 
159
- # Later milestones — stubs with informative messages
160
- pending = []
161
- if do_albedo: pending.append("Albedo bake (M6)")
162
- if do_material: pending.append("Material bake (M6)")
163
- if do_ao: pending.append("AO bake (M6)")
164
- if do_inpaint: pending.append("SDXL inpaint (M7)")
165
- if do_lods: pending.append("LOD generation (M8)")
166
- if do_collision: pending.append("Collision (M8)")
167
- if pending:
168
- log.append(f"\n🚧 **Not yet implemented:** {', '.join(pending)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  if not log:
171
  return "No processing steps selected."
172
 
173
  final = workspace.get_state()
174
- out = final.low_poly_glb or final.cleaned_glb or final.repaired_glb
175
  if out and out.exists():
176
- fc = final.face_count
177
- log.append(f"\n**Output:** `{out.name}`" + (f" · {fc:,} faces" if fc else ""))
178
 
179
  return "\n".join(log)
180
 
 
156
  except Exception as e:
157
  log.append(f"⚠️ Normal bake error: {e}")
158
 
159
+ st = workspace.get_state()
160
+ hp = st.high_poly_glb
161
+ lo = st.unwrapped_glb or current_glb
162
+
163
+ if do_albedo:
164
+ try:
165
+ from src.stages.stage2_bake_albedo import bake_albedo
166
+ if not hp or not hp.exists():
167
+ log.append("⚠️ Albedo bake: no high-poly. Generate first.")
168
+ else:
169
+ _, msg = bake_albedo(hp, lo, map_size=2048)
170
+ log.append(f"✅ {msg}")
171
+ except Exception as e:
172
+ log.append(f"⚠️ Albedo bake error: {e}")
173
+
174
+ if do_material:
175
+ try:
176
+ from src.stages.stage2_bake_albedo import bake_material
177
+ if not hp or not hp.exists():
178
+ log.append("⚠️ Material bake: no high-poly. Generate first.")
179
+ else:
180
+ _, _, msg = bake_material(hp, lo, map_size=2048)
181
+ log.append(f"✅ {msg}")
182
+ except Exception as e:
183
+ log.append(f"⚠️ Material bake error: {e}")
184
+
185
+ if do_ao:
186
+ try:
187
+ from src.stages.stage2_bake_ao import bake_ao
188
+ _, msg = bake_ao(current_glb, lo, map_size=2048, quality=ao_quality)
189
+ log.append(f"✅ {msg}")
190
+ except Exception as e:
191
+ log.append(f"⚠️ AO bake error: {e}")
192
+
193
+ # Pack ORM if we have the component maps
194
+ st2 = workspace.get_state()
195
+ if do_albedo or do_material or do_ao:
196
+ try:
197
+ from src.stages.stage2_finalize import pack_orm
198
+ _, msg = pack_orm(st2.ao_png, st2.roughness_png, st2.metallic_png)
199
+ log.append(f"✅ {msg}")
200
+ except Exception as e:
201
+ log.append(f"⚠️ ORM pack error: {e}")
202
+
203
+ if do_lods:
204
+ try:
205
+ from src.stages.stage2_finalize import generate_lods
206
+ lod_src = st2.final_glb or st2.low_poly_glb or current_glb
207
+ _, msg = generate_lods(lod_src)
208
+ log.append(f"✅ {msg}")
209
+ except Exception as e:
210
+ log.append(f"⚠️ LOD error: {e}")
211
+
212
+ if do_collision:
213
+ try:
214
+ from src.stages.stage2_finalize import generate_collision
215
+ col_src = st2.low_poly_glb or current_glb
216
+ _, msg = generate_collision(col_src)
217
+ log.append(f"✅ {msg}")
218
+ except Exception as e:
219
+ log.append(f"⚠️ Collision error: {e}")
220
+
221
+ # Pivot + scale (always run if we have a final mesh)
222
+ try:
223
+ from src.stages.stage2_finalize import set_pivot, validate_scale
224
+ piv_src = st2.low_poly_glb or current_glb
225
+ piv_src, msg = set_pivot(piv_src, pivot)
226
+ log.append(f"✅ {msg}")
227
+ _, msg = validate_scale(piv_src, float(scale_m))
228
+ log.append(f"✅ {msg}")
229
+ except Exception as e:
230
+ log.append(f"⚠️ Pivot/scale error: {e}")
231
+
232
+ if do_inpaint:
233
+ log.append("🚧 SDXL inpaint — Milestone 7 (not yet implemented)")
234
 
235
  if not log:
236
  return "No processing steps selected."
237
 
238
  final = workspace.get_state()
239
+ out = final.final_glb or final.low_poly_glb or final.cleaned_glb or final.repaired_glb
240
  if out and out.exists():
241
+ log.append(f"\n**Output:** `{out.name}`")
 
242
 
243
  return "\n".join(log)
244
 
requirements.txt CHANGED
@@ -40,7 +40,7 @@ scipy # normal map dilation
40
  # ===== Milestone 5: Stage 2F Normal Baking =====
41
  nvdiffrast # PyPI version — compiles against installed CUDA at runtime
42
 
43
- # coacd==1.0.4 Milestone 8
44
 
45
  # ===== Milestone 4: Stage 2 GPU baking =====
46
  # nvdiffrast (install from PyPI — compatible with current torch)
 
40
  # ===== Milestone 5: Stage 2F Normal Baking =====
41
  nvdiffrast # PyPI version — compiles against installed CUDA at runtime
42
 
43
+ coacd==1.0.4 # Milestone 8 collision
44
 
45
  # ===== Milestone 4: Stage 2 GPU baking =====
46
  # nvdiffrast (install from PyPI — compatible with current torch)
src/stages/stage2_bake_albedo.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 2G — Albedo bake and Stage 2H — Material (metallic/roughness) bake.
3
+
4
+ Both follow the same nvdiffrast UV-rasterize + high-poly proximity pattern as
5
+ the normal baker, but sample colour/material attributes instead of normals.
6
+
7
+ The TRELLIS.2 GLB returned by gradio_client already encodes PBR attributes as
8
+ vertex colours (or material attributes). We read them from the high-poly mesh
9
+ and bake them to UV textures on the low-poly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import spaces
17
+
18
+ from src import workspace
19
+ from src.workspace import CURRENT
20
+
21
+
22
+ def _ensure_tex_dir() -> Path:
23
+ d = CURRENT / "textures"
24
+ d.mkdir(parents=True, exist_ok=True)
25
+ return d
26
+
27
+
28
+ def _dilate(img: np.ndarray, mask: np.ndarray, n: int = 8) -> np.ndarray:
29
+ from scipy.ndimage import binary_dilation, convolve
30
+
31
+ result = img.astype(np.float32)
32
+ valid = mask.astype(bool)
33
+ kernel = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]], dtype=np.float32)
34
+
35
+ for _ in range(n):
36
+ expanded = binary_dilation(valid)
37
+ border = expanded & ~valid
38
+ if not border.any():
39
+ break
40
+ for c in range(result.shape[2]):
41
+ w = convolve(result[:, :, c] * valid, kernel)
42
+ wn = convolve(valid.astype(np.float32), kernel)
43
+ result[:, :, c] = np.where(border, np.where(wn > 0, w / wn, 0), result[:, :, c])
44
+ valid = expanded
45
+
46
+ return result.clip(0, 255).astype(np.uint8)
47
+
48
+
49
+ @spaces.GPU(duration=60)
50
+ def bake_albedo(
51
+ high_poly_path: Path,
52
+ unwrapped_glb_path: Path,
53
+ map_size: int = 2048,
54
+ ) -> tuple[Path, str]:
55
+ """
56
+ Bake vertex-colour albedo from the high-poly to the low-poly UV atlas.
57
+ Falls back to a neutral grey if the high-poly has no vertex colours.
58
+ """
59
+ import torch
60
+ import trimesh
61
+ import nvdiffrast.torch as dr
62
+ from PIL import Image
63
+
64
+ device = torch.device("cuda")
65
+ ctx = dr.RasterizeCudaContext()
66
+
67
+ hi = trimesh.load(str(high_poly_path), force="mesh")
68
+ lo = trimesh.load(str(unwrapped_glb_path), force="mesh")
69
+
70
+ if not isinstance(lo.visual, trimesh.visual.TextureVisuals) or lo.visual.uv is None:
71
+ raise ValueError("Low-poly has no UV coordinates — run UV unwrap first.")
72
+
73
+ uvs = np.array(lo.visual.uv, dtype=np.float32)
74
+ lo_v = np.array(lo.vertices, dtype=np.float32)
75
+ lo_f = np.array(lo.faces, dtype=np.int32)
76
+
77
+ # Get high-poly vertex colours (albedo)
78
+ if hasattr(hi.visual, "vertex_colors") and hi.visual.vertex_colors is not None:
79
+ hi_colors = np.array(hi.visual.vertex_colors, dtype=np.float32)[:, :3] / 255.0
80
+ else:
81
+ # No vertex colours — use neutral grey
82
+ hi_colors = np.full((len(hi.vertices), 3), 0.5, dtype=np.float32)
83
+
84
+ pos_clip = np.zeros((len(uvs), 4), dtype=np.float32)
85
+ pos_clip[:, 0] = uvs[:, 0] * 2.0 - 1.0
86
+ pos_clip[:, 1] = (1.0 - uvs[:, 1]) * 2.0 - 1.0
87
+ pos_clip[:, 3] = 1.0
88
+
89
+ v_clip = torch.tensor(pos_clip, device=device).unsqueeze(0)
90
+ faces_t = torch.tensor(lo_f, device=device, dtype=torch.int32)
91
+ lo_v_t = torch.tensor(lo_v, device=device).unsqueeze(0)
92
+
93
+ rast, _ = dr.rasterize(ctx, v_clip, faces_t, resolution=[map_size, map_size])
94
+ world_pos, _ = dr.interpolate(lo_v_t, rast, faces_t)
95
+
96
+ mask = (rast[..., 3] > 0).squeeze(0).cpu().numpy()
97
+ wp = world_pos.squeeze(0).cpu().numpy()
98
+
99
+ ys, xs = np.where(mask)
100
+ prox = trimesh.proximity.ProximityQuery(hi)
101
+ _, _, tri_ids = prox.on_surface(wp[ys, xs])
102
+
103
+ # Interpolate vertex colours at face centroids (simple approximation)
104
+ face_verts = hi.faces[tri_ids]
105
+ albedo_vals = hi_colors[face_verts].mean(axis=1) # [P, 3]
106
+
107
+ albedo_map = np.full((map_size, map_size, 3), 127, dtype=np.uint8)
108
+ packed = np.clip(albedo_vals * 255, 0, 255).astype(np.uint8)
109
+ albedo_map[ys, xs] = packed
110
+ albedo_map = _dilate(albedo_map, mask)
111
+
112
+ tex_dir = _ensure_tex_dir()
113
+ out_path = tex_dir / "albedo.png"
114
+ Image.fromarray(albedo_map).save(str(out_path))
115
+
116
+ state = workspace.get_state()
117
+ state.albedo_png = out_path
118
+
119
+ return out_path, f"Albedo baked: {map_size}×{map_size}"
120
+
121
+
122
+ @spaces.GPU(duration=60)
123
+ def bake_material(
124
+ high_poly_path: Path,
125
+ unwrapped_glb_path: Path,
126
+ map_size: int = 2048,
127
+ ) -> tuple[Path, Path, str]:
128
+ """
129
+ Bake metallic and roughness maps from high-poly vertex attributes.
130
+ Returns (metallic_path, roughness_path, message).
131
+ """
132
+ import torch
133
+ import trimesh
134
+ import nvdiffrast.torch as dr
135
+ from PIL import Image
136
+
137
+ device = torch.device("cuda")
138
+ ctx = dr.RasterizeCudaContext()
139
+
140
+ hi = trimesh.load(str(high_poly_path), force="mesh")
141
+ lo = trimesh.load(str(unwrapped_glb_path), force="mesh")
142
+
143
+ if not isinstance(lo.visual, trimesh.visual.TextureVisuals) or lo.visual.uv is None:
144
+ raise ValueError("Low-poly has no UV coordinates — run UV unwrap first.")
145
+
146
+ uvs = np.array(lo.visual.uv, dtype=np.float32)
147
+ lo_v = np.array(lo.vertices, dtype=np.float32)
148
+ lo_f = np.array(lo.faces, dtype=np.int32)
149
+
150
+ # Check for PBR attributes in high-poly metadata
151
+ # TRELLIS.2 stores metallic/roughness as vertex attributes in custom extras
152
+ # Fallback: metallic=0 (dielectric), roughness=0.5
153
+ hi_metallic = np.zeros(len(hi.vertices), dtype=np.float32)
154
+ hi_roughness = np.full(len(hi.vertices), 0.5, dtype=np.float32)
155
+
156
+ if hasattr(hi, "metadata") and hi.metadata:
157
+ m = hi.metadata.get("metallic")
158
+ r = hi.metadata.get("roughness")
159
+ if m is not None:
160
+ hi_metallic = np.array(m, dtype=np.float32)
161
+ if r is not None:
162
+ hi_roughness = np.array(r, dtype=np.float32)
163
+
164
+ pos_clip = np.zeros((len(uvs), 4), dtype=np.float32)
165
+ pos_clip[:, 0] = uvs[:, 0] * 2.0 - 1.0
166
+ pos_clip[:, 1] = (1.0 - uvs[:, 1]) * 2.0 - 1.0
167
+ pos_clip[:, 3] = 1.0
168
+
169
+ v_clip = torch.tensor(pos_clip, device=device).unsqueeze(0)
170
+ faces_t = torch.tensor(lo_f, device=device, dtype=torch.int32)
171
+ lo_v_t = torch.tensor(lo_v, device=device).unsqueeze(0)
172
+
173
+ rast, _ = dr.rasterize(ctx, v_clip, faces_t, resolution=[map_size, map_size])
174
+ world_pos, _ = dr.interpolate(lo_v_t, rast, faces_t)
175
+
176
+ mask = (rast[..., 3] > 0).squeeze(0).cpu().numpy()
177
+ wp = world_pos.squeeze(0).cpu().numpy()
178
+
179
+ ys, xs = np.where(mask)
180
+ prox = trimesh.proximity.ProximityQuery(hi)
181
+ _, _, tri_ids = prox.on_surface(wp[ys, xs])
182
+
183
+ face_verts = hi.faces[tri_ids]
184
+ met_vals = hi_metallic[face_verts].mean(axis=1)
185
+ rou_vals = hi_roughness[face_verts].mean(axis=1)
186
+
187
+ tex_dir = _ensure_tex_dir()
188
+
189
+ def _save_grey(vals: np.ndarray, path: Path) -> None:
190
+ img = np.full((map_size, map_size), 127, dtype=np.uint8)
191
+ img[ys, xs] = np.clip(vals * 255, 0, 255).astype(np.uint8)
192
+ img = _dilate(img[:, :, None], mask)[:, :, 0]
193
+ Image.fromarray(img).save(str(path))
194
+
195
+ met_path = tex_dir / "metallic.png"
196
+ rou_path = tex_dir / "roughness.png"
197
+ _save_grey(met_vals, met_path)
198
+ _save_grey(rou_vals, rou_path)
199
+
200
+ state = workspace.get_state()
201
+ state.metallic_png = met_path
202
+ state.roughness_png = rou_path
203
+
204
+ return met_path, rou_path, f"Material maps baked: {map_size}×{map_size}"
src/stages/stage2_bake_ao.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 2I — Ambient Occlusion bake.
3
+
4
+ Casts hemisphere rays from each UV-covered pixel's world position along the
5
+ surface normal. Occlusion is estimated by counting how many rays hit the mesh
6
+ within a configurable radius (using trimesh's ray intersection).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import spaces
14
+
15
+ from src import workspace
16
+ from src.workspace import CURRENT
17
+
18
+
19
+ def _dilate(img: np.ndarray, mask: np.ndarray, n: int = 8) -> np.ndarray:
20
+ from scipy.ndimage import binary_dilation, convolve
21
+
22
+ result = img.astype(np.float32)
23
+ valid = mask.astype(bool)
24
+ kernel = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]], dtype=np.float32)
25
+
26
+ for _ in range(n):
27
+ expanded = binary_dilation(valid)
28
+ border = expanded & ~valid
29
+ if not border.any():
30
+ break
31
+ for c in range(result.shape[2]):
32
+ w = convolve(result[:, :, c] * valid, kernel)
33
+ wn = convolve(valid.astype(np.float32), kernel)
34
+ result[:, :, c] = np.where(border, np.where(wn > 0, w / wn, 0), result[:, :, c])
35
+ valid = expanded
36
+
37
+ return result.clip(0, 255).astype(np.uint8)
38
+
39
+
40
+ def _hemisphere_rays(normal: np.ndarray, n_rays: int, rng: np.random.Generator):
41
+ """Generate n_rays directions distributed over the hemisphere around normal."""
42
+ # Random cosine-weighted hemisphere samples
43
+ u1 = rng.random(n_rays)
44
+ u2 = rng.random(n_rays)
45
+ r = np.sqrt(u1)
46
+ theta = 2 * np.pi * u2
47
+ x = r * np.cos(theta)
48
+ y = r * np.sin(theta)
49
+ z = np.sqrt(np.maximum(0.0, 1.0 - u1))
50
+ local_dirs = np.stack([x, y, z], axis=1) # hemisphere in Z-up frame
51
+
52
+ # Build orthonormal basis from normal
53
+ up = np.array([0.0, 1.0, 0.0])
54
+ if abs(np.dot(normal, up)) > 0.99:
55
+ up = np.array([1.0, 0.0, 0.0])
56
+ T = np.cross(up, normal)
57
+ T /= np.linalg.norm(T)
58
+ B = np.cross(normal, T)
59
+ return local_dirs @ np.stack([T, B, normal]).T # [n_rays, 3]
60
+
61
+
62
+ @spaces.GPU(duration=120)
63
+ def bake_ao(
64
+ mesh_path: Path,
65
+ unwrapped_glb_path: Path,
66
+ map_size: int = 2048,
67
+ quality: str = "Standard", # "Fast" | "Standard" | "High"
68
+ ) -> tuple[Path, str]:
69
+ """Bake AO by hemisphere ray casting on the CPU (trimesh RayMesh intersector)."""
70
+ import torch
71
+ import trimesh
72
+ import nvdiffrast.torch as dr
73
+ from PIL import Image
74
+
75
+ n_rays_map = {"Fast": 16, "Standard": 64, "High": 256}
76
+ n_rays = n_rays_map.get(quality, 64)
77
+ max_dist = 0.5 # AO search radius (in mesh units)
78
+
79
+ device = torch.device("cuda")
80
+ ctx = dr.RasterizeCudaContext()
81
+
82
+ # Use the unwrapped mesh for both source positions and AO scene
83
+ scene_mesh = trimesh.load(str(mesh_path), force="mesh")
84
+ lo = trimesh.load(str(unwrapped_glb_path), force="mesh")
85
+
86
+ if not isinstance(lo.visual, trimesh.visual.TextureVisuals) or lo.visual.uv is None:
87
+ raise ValueError("Low-poly has no UV coordinates — run UV unwrap first.")
88
+
89
+ uvs = np.array(lo.visual.uv, dtype=np.float32)
90
+ lo_v = np.array(lo.vertices, dtype=np.float32)
91
+ lo_f = np.array(lo.faces, dtype=np.int32)
92
+ lo_n = np.array(lo.vertex_normals, dtype=np.float32)
93
+
94
+ pos_clip = np.zeros((len(uvs), 4), dtype=np.float32)
95
+ pos_clip[:, 0] = uvs[:, 0] * 2.0 - 1.0
96
+ pos_clip[:, 1] = (1.0 - uvs[:, 1]) * 2.0 - 1.0
97
+ pos_clip[:, 3] = 1.0
98
+
99
+ v_clip = torch.tensor(pos_clip, device=device).unsqueeze(0)
100
+ faces_t = torch.tensor(lo_f, device=device, dtype=torch.int32)
101
+ lo_v_t = torch.tensor(lo_v, device=device).unsqueeze(0)
102
+ lo_n_t = torch.tensor(lo_n, device=device).unsqueeze(0)
103
+
104
+ rast, _ = dr.rasterize(ctx, v_clip, faces_t, resolution=[map_size, map_size])
105
+ world_pos, _ = dr.interpolate(lo_v_t, rast, faces_t)
106
+ world_nrm, _ = dr.interpolate(lo_n_t, rast, faces_t)
107
+
108
+ mask = (rast[..., 3] > 0).squeeze(0).cpu().numpy()
109
+ wp = world_pos.squeeze(0).cpu().numpy()
110
+ wn = world_nrm.squeeze(0).cpu().numpy()
111
+
112
+ ys, xs = np.where(mask)
113
+ if len(ys) == 0:
114
+ raise ValueError("No UV-covered pixels.")
115
+
116
+ pts = wp[ys, xs] # [P, 3]
117
+ nrms = wn[ys, xs] # [P, 3]
118
+ norms = np.linalg.norm(nrms, axis=1, keepdims=True)
119
+ nrms = nrms / np.where(norms < 1e-8, 1.0, norms)
120
+
121
+ # CPU ray casting via trimesh (GPU not needed for rays)
122
+ ray_mesh = trimesh.ray.ray_pyembree.RayMeshIntersector(scene_mesh) \
123
+ if trimesh.ray.has_embree else trimesh.ray.ray_triangle.RayMeshIntersector(scene_mesh)
124
+
125
+ rng = np.random.default_rng(seed=42)
126
+ occlusion = np.zeros(len(pts), dtype=np.float32)
127
+
128
+ # Process in batches to avoid OOM
129
+ batch = 512
130
+ eps = 1e-4
131
+ for i in range(0, len(pts), batch):
132
+ p_batch = pts[i:i + batch]
133
+ n_batch = nrms[i:i + batch]
134
+
135
+ # Generate rays for all points in batch
136
+ dirs_list = [_hemisphere_rays(n_batch[j], n_rays, rng) for j in range(len(p_batch))]
137
+ all_dirs = np.concatenate(dirs_list, axis=0)
138
+ all_origins = np.repeat(p_batch + n_batch * eps, n_rays, axis=0)
139
+
140
+ hits = ray_mesh.intersects_any(all_origins, all_dirs)
141
+ # Reshape and average
142
+ hit_mat = hits.reshape(len(p_batch), n_rays)
143
+ occlusion[i:i + batch] = hit_mat.mean(axis=1)
144
+
145
+ ao_vals = 1.0 - occlusion # 1 = fully lit, 0 = fully occluded
146
+
147
+ ao_map = np.full((map_size, map_size, 1), 255, dtype=np.uint8)
148
+ ao_map[ys, xs, 0] = np.clip(ao_vals * 255, 0, 255).astype(np.uint8)
149
+ ao_map = _dilate(ao_map, mask)
150
+
151
+ tex_dir = CURRENT / "textures"
152
+ tex_dir.mkdir(parents=True, exist_ok=True)
153
+ out_path = tex_dir / "ao.png"
154
+ Image.fromarray(ao_map[:, :, 0]).save(str(out_path))
155
+
156
+ state = workspace.get_state()
157
+ state.ao_png = out_path
158
+
159
+ return out_path, f"AO baked ({quality}, {n_rays} rays): {map_size}×{map_size}"
src/stages/stage2_finalize.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 2K–2O — Finalization: ORM channel pack, LODs, collision, pivot, scale.
3
+ All CPU-side, no GPU required.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ from src import workspace
12
+ from src.workspace import CURRENT, CURRENT_LODS
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # 2K ORM channel packing
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def pack_orm(ao_path: Path | None, roughness_path: Path | None, metallic_path: Path | None) -> tuple[Path, str]:
20
+ """Pack AO(R), Roughness(G), Metallic(B) → ORM texture for UE5."""
21
+ from PIL import Image
22
+
23
+ def _load_grey(p: Path | None, size: int) -> np.ndarray:
24
+ if p and p.exists():
25
+ img = Image.open(p).convert("L")
26
+ return np.array(img)
27
+ return np.full((size, size), 255, dtype=np.uint8)
28
+
29
+ # Determine output size from whichever map exists
30
+ size = 2048
31
+ for p in (ao_path, roughness_path, metallic_path):
32
+ if p and p.exists():
33
+ img = Image.open(p)
34
+ size = max(img.size)
35
+ break
36
+
37
+ r = _load_grey(ao_path, size) # AO → Red
38
+ g = _load_grey(roughness_path, size) # Roughness → Green
39
+ b = _load_grey(metallic_path, size) # Metallic → Blue
40
+
41
+ orm = np.stack([r, g, b], axis=2)
42
+ out_path = CURRENT / "textures" / "orm.png"
43
+ Image.fromarray(orm).save(str(out_path))
44
+
45
+ state = workspace.get_state()
46
+ state.orm_png = out_path
47
+
48
+ return out_path, "ORM packed (AO→R, Roughness→G, Metallic→B)"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 2L LOD generation
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def generate_lods(input_glb: Path, lod_ratios: tuple = (1.0, 0.5, 0.25)) -> tuple[list[Path], str]:
56
+ """
57
+ Generate LOD0/LOD1/LOD2 via PyMeshLab quadric decimation.
58
+ lod_ratios: face-count multipliers relative to input (1.0 = full).
59
+ """
60
+ import trimesh
61
+ import pymeshlab
62
+ import tempfile
63
+
64
+ mesh = trimesh.load(str(input_glb), force="mesh")
65
+ base_faces = len(mesh.faces)
66
+
67
+ with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
68
+ ply_in = tmp.name
69
+ mesh.export(ply_in)
70
+
71
+ CURRENT_LODS.mkdir(parents=True, exist_ok=True)
72
+
73
+ lod_paths = []
74
+ for idx, ratio in enumerate(lod_ratios):
75
+ target = max(100, int(base_faces * ratio))
76
+
77
+ if ratio == 1.0:
78
+ out = CURRENT_LODS / f"LOD{idx}.glb"
79
+ mesh.export(str(out))
80
+ else:
81
+ ms = pymeshlab.MeshSet()
82
+ ms.load_new_mesh(ply_in)
83
+ ms.apply_filter(
84
+ "meshing_decimation_quadric_edge_collapse",
85
+ targetfacenum=target,
86
+ preservenormal=True,
87
+ preservetopology=True,
88
+ autoclean=True,
89
+ )
90
+ with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
91
+ ply_out = tmp.name
92
+ ms.save_current_mesh(ply_out)
93
+ lod_mesh = trimesh.load(ply_out, force="mesh")
94
+ out = CURRENT_LODS / f"LOD{idx}.glb"
95
+ lod_mesh.export(str(out))
96
+
97
+ lod_paths.append(out)
98
+
99
+ state = workspace.get_state()
100
+ state.lod_glbs = lod_paths
101
+
102
+ counts = [int(base_faces * r) for r in lod_ratios]
103
+ return lod_paths, f"LODs: {' / '.join(str(c) for c in counts)} faces"
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # 2M Collision mesh (CoACD)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def generate_collision(input_glb: Path) -> tuple[Path, str]:
111
+ """Generate convex decomposition collision mesh via CoACD."""
112
+ import trimesh
113
+
114
+ try:
115
+ import coacd
116
+ mesh = trimesh.load(str(input_glb), force="mesh")
117
+ m = coacd.Mesh(mesh.vertices, mesh.faces)
118
+ parts = coacd.run_coacd(m)
119
+ hulls = [trimesh.Trimesh(p[0], p[1]) for p in parts]
120
+ collision = trimesh.util.concatenate(hulls)
121
+ except ImportError:
122
+ # Fallback: single convex hull
123
+ mesh = trimesh.load(str(input_glb), force="mesh")
124
+ collision = mesh.convex_hull
125
+
126
+ out_path = CURRENT / "collision.glb"
127
+ collision.export(str(out_path))
128
+
129
+ state = workspace.get_state()
130
+ state.collision_glb = out_path
131
+
132
+ return out_path, f"Collision: {len(collision.faces):,} faces"
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # 2N Pivot correction
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def set_pivot(input_glb: Path, mode: str = "bottom_center") -> tuple[Path, str]:
140
+ """Shift mesh so the pivot is at the origin."""
141
+ import trimesh
142
+
143
+ mesh = trimesh.load(str(input_glb), force="mesh")
144
+ bounds = mesh.bounds # [[min_x, min_y, min_z], [max_x, max_y, max_z]]
145
+ center = (bounds[0] + bounds[1]) / 2.0
146
+
147
+ if mode == "bottom_center":
148
+ offset = np.array([center[0], bounds[0][1], center[2]])
149
+ elif mode == "geometric_center":
150
+ offset = center
151
+ else:
152
+ offset = np.zeros(3)
153
+
154
+ mesh.apply_translation(-offset)
155
+
156
+ out_path = CURRENT / "pivoted.glb"
157
+ mesh.export(str(out_path))
158
+
159
+ state = workspace.get_state()
160
+ state.final_glb = out_path
161
+
162
+ return out_path, f"Pivot set to {mode}"
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # 2O Scale validation (UE5: cm units)
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def validate_scale(input_glb: Path, real_height_m: float = 1.8) -> tuple[Path, str]:
170
+ """
171
+ Scale the mesh so its bounding-box height equals real_height_m in metres.
172
+ For UE5 export, 1 unit = 1 cm, so height_m * 100 = height in UE5 units.
173
+ """
174
+ import trimesh
175
+
176
+ mesh = trimesh.load(str(input_glb), force="mesh")
177
+ bounds = mesh.bounds
178
+ current_height = bounds[1][1] - bounds[0][1]
179
+
180
+ if current_height < 1e-6:
181
+ return input_glb, "Scale: mesh has zero height, skipped"
182
+
183
+ # Target height in cm (UE5 units)
184
+ target_cm = real_height_m * 100.0
185
+ scale_factor = target_cm / current_height
186
+
187
+ mesh.apply_scale(scale_factor)
188
+
189
+ out_path = CURRENT / "scaled.glb"
190
+ mesh.export(str(out_path))
191
+
192
+ state = workspace.get_state()
193
+ state.final_glb = out_path
194
+
195
+ new_h = current_height * scale_factor
196
+ return out_path, f"Scale: {new_h:.1f} cm ({real_height_m} m) for UE5"