"""HomeCamp static scene assets: Studio low-poly -> Studio UV -> bake -> conditional UV-preserving reduction. Step 01 uses Tools/hunyuan_3d_generate.py, with explicit user upload authorization. Run steps 02-05 in the LLM3DGraphics virtualenv; no Unity serialized assets are written here. """ from __future__ import annotations import argparse import hashlib import json import shutil import subprocess import sys from pathlib import Path sys.dont_write_bytecode = True ROOT = Path(__file__).resolve().parents[1] WORKBENCH = Path('D:/UD/AI/XModel/LLM3DGraphics') BLENDER = Path('D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe') MANIFEST = ROOT / 'Doc/Design/Scene/HomeCamp/homecamp_scene_manifest.json' def read(path: Path) -> dict: return json.loads(path.read_text(encoding='utf-8-sig')) def save(path: Path, data: dict) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') def sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def update(**values) -> None: print(json.dumps(values, ensure_ascii=True), flush=True) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--part', required=True, choices=('ground', 'mainhouse', 'cottage', 'fountain')) parser.add_argument('--refine-bake', action='store_true', help='After step 05, rebake onto its retained UV to repair projection seams') parser.add_argument('--revise-budget', action='store_true', help='Archive the old receipt when only the reviewed triangle budget changes') args = parser.parse_args() manifest = read(MANIFEST) if manifest['uploadAuthorization']['status'] != 'authorized_by_user': raise RuntimeError('Explicit Hunyuan image/model upload authorization is required') part = next(p for p in manifest['components'] if p['id'] == args.part) source = ROOT / f'Tools/output/homecamp_3d/{args.part}/{args.part}.glb' image_path = ROOT / part['designImage'] expected_image = next(p for p in manifest['designImageAudit'] if p['id'] == args.part) if sha(image_path) != expected_image['sha256']: raise RuntimeError('Design input changed after authorization; review the new input first') if not source.is_file(): raise FileNotFoundError(f'Complete Hunyuan web high mesh first: {source}') sys.path.insert(0, str(ROOT / 'Tools')) from hunyuan_3d import auth from hunyuan_3d.glb_check import assert_glb_textures_ok assert_glb_textures_ok(source) sys.path.insert(0, str(WORKBENCH)) from backend import hunyuan_studio as studio from backend.retopology import _run_blender_step, compress_bake_maps import trimesh sys.path.insert(0, str(ROOT / 'Tools/model_tools')) from mt_run import run_blender_script from png_compress import compress_dir_pngs out = ROOT / 'Tools/output/homecamp_studio' / args.part out.mkdir(parents=True, exist_ok=True) receipt_path = out / 'production.json' receipt = read(receipt_path) if receipt_path.exists() else {} target = int(part['targetTriangles']) size = int(manifest['modelPipeline']['textureSize']) settings = dict(source=source.relative_to(ROOT).as_posix(), sourceSha256=sha(source), targetTriangles=target, textureSize=size) if receipt and any(receipt.get(k) != v for k, v in settings.items()): if (args.revise_budget and receipt.get('sourceSha256') == settings['sourceSha256'] and receipt.get('textureSize') == size): save(out / ('production_target_' + str(receipt['targetTriangles']) + '.json'), receipt) receipt['previousTargetTriangles'] = receipt['targetTriangles'] receipt['targetTriangles'] = target receipt['status'] = 'budget_revised_for_surface_and_uv_integrity' else: raise RuntimeError('Source/settings changed; keep the old receipt and use a new production directory') if receipt.get('status') == 'ready_for_unity' and not args.refine_bake: for path, digest in receipt['outputs'].items(): if sha(ROOT / path) != digest: raise RuntimeError(f'Completed output changed: {path}') update(part=args.part, status='ready_for_unity', cached=True) return 0 receipt.update(settings, part=args.part, status='studio', designImage=part['designImage'], designSha256=sha(image_path)) receipt.setdefault('steps', {})['01'] = 'highpoly_validated' save(receipt_path, receipt) shutil.copy2(source, out / 'model.glb') studio.is_logged_in = lambda client_id=None: auth.is_logged_in() studio.load_storage_state = lambda client_id=None: (read(auth.storage_state_path()), None) uv = out / 'model_lowpoly_uv.glb' if uv.is_file(): studio.validate_uv_glb(uv) else: meta_path = out / 'hunyuan_studio.json' meta = read(meta_path) if meta_path.exists() else {} studio.process_existing(source, out, update, target_faces=target, resume_low_id=meta.get('lowpoly_works_id'), resume_uv_id=meta.get('uv_works_id')) receipt.update(status='baking', studio=read(out / 'hunyuan_studio.json')) receipt['steps'].update({'02': 'studio_lowpoly_completed', '03': 'studio_uv_completed'}) save(receipt_path, receipt) def blender(script: str, script_args: list[str], log_name: str) -> None: with (out / log_name).open('w', encoding='utf-8') as log: subprocess.run([str(BLENDER), '-b', '-t', '2', '--factory-startup', '--python-exit-code', '1', '--python', str(ROOT / 'Tools/model_tools' / script), '--', *script_args], check=True, stdout=log, stderr=subprocess.STDOUT, timeout=240) aligned = out / 'model_lowpoly_uv_aligned.glb' if not aligned.is_file(): blender('bl_align_studio_uv.py', [str(out)], 'alignment.log') if not receipt.get('bakeAligned'): _run_blender_step(out, str(BLENDER), 'blender_lowpoly_bake.py', (out / 'model.glb', out / 'model_baked.glb', 1, size, aligned), 'bake.log', marker='Lowpoly bake saved to') compress_bake_maps(out) receipt['bakeAligned'] = True receipt['steps']['04'] = 'blender_bake_completed_on_studio_uv' save(receipt_path, receipt) receipt['steps']['04'] = 'blender_bake_completed_on_studio_uv' shutil.copy2(out / 'model_baked.glb', out / 'model_ex.glb') mesh = trimesh.load(out / 'model_ex.glb', force='mesh', process=False) reduction = dict(triggered=False, faces_before=len(mesh.faces), faces_after=len(mesh.faces), target_faces=target, threshold_percent=30) if len(mesh.faces) * 10 > target * 13: update(stage='Blender welded geometry / preserved corner UV reduction', part=args.part) blender('bl_decimate_homecamp_uv.py', [str(out / 'model_ex.glb'), str(out / 'model_decimated_welded.glb'), str(target)], 'decimate_welded.log') reduction = read(out / 'model_decimated_welded.json') shutil.copy2(out / 'model_decimated_welded.glb', out / 'model_ex.glb') mesh = studio.validate_uv_glb(out / 'model_ex.glb') if len(mesh.faces) * 10 > target * 13: raise RuntimeError('Final mesh still exceeds the target by more than 30 percent') receipt['steps']['05'] = 'uv_preserving_decimation_completed' if reduction['triggered'] else 'skipped_within_130_percent' receipt.update(triangles=len(mesh.faces), vertices=len(mesh.vertices), decimation=reduction, bakeAlignment=read(out / 'bake_alignment.json')) save(receipt_path, receipt) blender('bl_repair_homecamp_surface.py', [str(out / 'model_ex.glb'), str(out / 'model_surface_repaired.glb'), args.part], 'surface_repair.log') receipt['surfaceRepair'] = read(out / 'model_surface_repaired.json') shutil.copy2(out / 'model_surface_repaired.glb', out / 'model_ex.glb') receipt['triangles'] = receipt['surfaceRepair']['triangles'] if args.refine_bake: # Collapse retains charts, but coarse triangles can move the sampled # texels. Correct the projection on the final mesh, without re-unwrap. original_final = out / 'model_decimated_before_rebake.glb' shutil.copy2(out / 'model_ex.glb', original_final) _run_blender_step(out, str(BLENDER), 'blender_lowpoly_bake.py', (out / 'model.glb', out / 'model_refined.glb', 1, size, original_final), 'refine_bake.log', marker='Lowpoly bake saved to') compress_bake_maps(out) refined = studio.validate_uv_glb(out / 'model_refined.glb') if len(refined.faces) != receipt['triangles']: raise RuntimeError('Refinement changed the final mesh topology') shutil.copy2(out / 'model_refined.glb', out / 'model_ex.glb') receipt['projectionRefinement'] = 'Same source high -> final reduced low; UV preserved, no unwrap' blender('bl_prepare_studio_trial.py', [str(out), args.part], 'static_export.log') receipt['xpbr'] = run_blender_script(str(BLENDER), 'bl_convert.py', [str(out / 'static' / (args.part + '.fbx')), str(out / 'xpbr'), str(size)], blender_args=('-t', '2')) compress_dir_pngs(str(out / 'xpbr'), suffixes=('_base.png', '_mix.png')) # Only these three source files are imported. Unity owns .mat and .meta generation. outputs = [out / 'xpbr' / (args.part + suffix) for suffix in ('.fbx', '_base.png', '_mix.png')] receipt.update(status='ready_for_unity', staticExport=read(out / 'static_export.json'), outputs={p.relative_to(ROOT).as_posix(): sha(p) for p in outputs}) save(receipt_path, receipt) update(part=args.part, status=receipt['status'], triangles=receipt['triangles']) return 0 if __name__ == '__main__': raise SystemExit(main())