Spaces:
Sleeping
Milestones 6+8: albedo/material/AO baking + ORM pack, LODs, collision, pivot, scale
Browse filesMilestone 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 +77 -13
- requirements.txt +1 -1
- src/stages/stage2_bake_albedo.py +204 -0
- src/stages/stage2_bake_ao.py +159 -0
- src/stages/stage2_finalize.py +196 -0
|
@@ -156,25 +156,89 @@ def run_post_process(
|
|
| 156 |
except Exception as e:
|
| 157 |
log.append(f"⚠️ Normal bake error: {e}")
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
if
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
|
|
@@ -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 |
-
|
| 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)
|
|
@@ -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}"
|
|
@@ -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}"
|
|
@@ -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"
|