3131from .helpers import effective_face_ratio
3232from .manifest import ManifestRow
3333from .paths import (
34+ _clean_existing ,
3435 _clean_path ,
36+ _rigged_hi_existing ,
3537 _intermediate_dir ,
3638 _lod_animated_path ,
3739 _lod_path ,
@@ -266,24 +268,29 @@ def _run(name: str, argv: list[str], output: Path | None = None) -> StageResult:
266268 # (após uma run anterior). Resolve dinamicamente para permitir resume.
267269 shape_p = _shape_existing (mesh_final ) or _shape_path (mesh_final )
268270 painted_p = _painted_existing (mesh_final ) or _painted_path (mesh_final )
269- clean_p = _clean_path (mesh_final )
271+ clean_existing = _clean_existing (mesh_final )
272+ clean_p = clean_existing if clean_existing is not None else _clean_path (mesh_final )
270273
271274 if not shape_p .is_file ():
272275 res .ok = False
273276 res .stages .append (StageResult ("preflight" , False , 0.0 , f"shape ausente: { shape_p } " ))
274277 return res
275278
276- # Stage 2 — topology-fix (shape → clean)
279+ # Stage 2 — topology-fix (shape → clean). Skip se já temos um clean
280+ # válido (em meshes/ ou _intermediate/) — resume-friendly.
277281 clean_p .parent .mkdir (parents = True , exist_ok = True )
278- s = _run (
279- "topology-fix" ,
280- [text3d_bin , "topology-fix" , str (shape_p ), "-o" , str (clean_p )],
281- clean_p ,
282- )
283- res .stages .append (s )
284- if not s .ok :
285- res .ok = False
286- return res
282+ if clean_existing is not None and clean_existing .is_file ():
283+ res .stages .append (StageResult ("topology-fix" , True , 0.0 , "skipped (clean existente)" , clean_p ))
284+ else :
285+ s = _run (
286+ "topology-fix" ,
287+ [text3d_bin , "topology-fix" , str (shape_p ), "-o" , str (clean_p )],
288+ clean_p ,
289+ )
290+ res .stages .append (s )
291+ if not s .ok :
292+ res .ok = False
293+ return res
287294
288295 # Stage 4 — bake-master (painted → lod0 com tangents/KTX2/meshopt)
289296 if not painted_p .is_file ():
@@ -309,6 +316,15 @@ def _run(name: str, argv: list[str], output: Path | None = None) -> StageResult:
309316 ]
310317 if bake_normals :
311318 bake_argv .append ("--bake-normals" )
319+ # Quando há rigging/anim downstream, o LOD0 vai ser re-importado por bpy
320+ # (rigging3d, animator3d). bpy não suporta EXT_meshopt_compression,
321+ # portanto saltamos a compressão em bake-master e re-aplicamos
322+ # gltf_transform_finish na promoção (Stage 9.5) sobre o output final.
323+ needs_bpy_downstream = (with_rig and rigging3d_bin is not None ) or (
324+ with_animate and animator3d_bin is not None
325+ )
326+ if needs_bpy_downstream :
327+ bake_argv .extend (["--no-meshopt" , "--no-ktx2" ])
312328 s = _run ("bake-master" , bake_argv , lod0_p )
313329 res .stages .append (s )
314330 if not s .ok :
@@ -340,6 +356,10 @@ def _run(name: str, argv: list[str], output: Path | None = None) -> StageResult:
340356 "--min-faces-lod2" ,
341357 str (lod_target_lod2 ),
342358 ]
359+ # Igual a bake-master: salta finish quando bpy precisa importar
360+ # depois (rigging/animação por LOD). Re-comprimimos na promoção.
361+ if needs_bpy_downstream :
362+ lod_argv .append ("--no-finish" )
343363 s = _run ("lod" , lod_argv )
344364 res .stages .append (s )
345365
@@ -372,32 +392,61 @@ def _run(name: str, argv: list[str], output: Path | None = None) -> StageResult:
372392 except Exception as exc : # noqa: BLE001
373393 log .warning ("master: finish collision falhou: %s" , exc )
374394
375- # Stage 7 — rigging3d pipeline sobre _clean.glb
376- rigged_hi_p = _rigged_hi_path (mesh_final )
395+ # Stage 7 — rigging3d pipeline sobre _clean.glb. Skip se já temos um
396+ # rigged_hi válido (em meshes/ ou _intermediate/) — resume-friendly.
397+ rigged_hi_existing = _rigged_hi_existing (mesh_final )
398+ rigged_hi_p = rigged_hi_existing if rigged_hi_existing is not None else _rigged_hi_path (mesh_final )
377399 if with_rig and rigging3d_bin :
378- rig_argv = [rigging3d_bin , "pipeline" , "--input" , str (clean_p ), "--output" , str (rigged_hi_p )]
379- s = _run ("rigging3d-hi" , rig_argv , rigged_hi_p )
380- res .stages .append (s )
381- if not s .ok :
382- with_rig = False # bloqueia stages dependentes mas não aborta o asset
383-
384- # Stage 8 — transfer-weights para LOD0/1/2 (CLI já aplica gltf_transform_finish
385- # em cada output rigged: KTX2+meshopt+tangents+dedup+prune — Round 2 Fase 1.4).
400+ if rigged_hi_existing is not None and rigged_hi_existing .is_file ():
401+ res .stages .append (StageResult ("rigging3d-hi" , True , 0.0 , "skipped (rigged_hi existente)" , rigged_hi_p ))
402+ else :
403+ rig_argv = [rigging3d_bin , "pipeline" , "--input" , str (clean_p ), "--output" , str (rigged_hi_p )]
404+ s = _run ("rigging3d-hi" , rig_argv , rigged_hi_p )
405+ res .stages .append (s )
406+ if not s .ok :
407+ with_rig = False # bloqueia stages dependentes mas não aborta o asset
408+
409+ # Stage 8 — Rigging por LOD via ``rigging3d merge``.
410+ #
411+ # Estratégia: re-usa o skeleton+skin do ``_rigged_hi.glb`` e fá-lo
412+ # "merge" com cada LOD low-poly, produzindo um GLB com Armature real
413+ # (re-importável por bpy → animator3d funciona). Não corre o modelo
414+ # de inferência de novo (sem GPU), apenas faz re-skinning analítico.
415+ #
416+ # ``rigging3d transfer-weights`` (bpy.data_transfer) é alternativa mas
417+ # o exportador GLTF do Blender não detecta o output como skinned —
418+ # mantido apenas como ferramenta experimental.
386419 rigged_lods : list [Path ] = []
387420 if with_rig and rigging3d_bin and rigged_hi_p .is_file ():
388421 targets : list [Path ] = [lod0_p ]
389422 if lod1_p .is_file ():
390423 targets .append (lod1_p )
391424 if lod2_p .is_file ():
392425 targets .append (lod2_p )
393- outs = [_lod_rigged_path (mesh_final , i ) for i in range (len (targets ))]
394- tw_argv = [rigging3d_bin , "transfer-weights" , "--source" , str (rigged_hi_p )]
395- for t , o in zip (targets , outs , strict = False ):
396- tw_argv .extend (["--target" , str (t ), "--output" , str (o )])
397- s = _run ("transfer-weights" , tw_argv )
398- res .stages .append (s )
399- if s .ok :
400- rigged_lods = [o for o in outs if o .is_file ()]
426+ for i , tgt in enumerate (targets ):
427+ out = _lod_rigged_path (mesh_final , i )
428+ merge_argv = [
429+ rigging3d_bin ,
430+ "merge" ,
431+ "--source" ,
432+ str (rigged_hi_p ),
433+ "--target" ,
434+ str (tgt ),
435+ "--output" ,
436+ str (out ),
437+ ]
438+ s = _run (f"rigging3d-merge-lod{ i } " , merge_argv , out )
439+ res .stages .append (s )
440+ if s .ok and out .is_file ():
441+ # Aplica gltf_transform_finish para alinhar com regras
442+ # rigged.yaml (KTX2+meshopt+tangents+dedup+prune).
443+ try :
444+ from text3d .utils .gltf_finish import gltf_transform_finish
445+
446+ gltf_transform_finish (out , out )
447+ except Exception as exc : # noqa: BLE001
448+ log .warning ("master: finish rigged-lod%d falhou: %s" , i , exc )
449+ rigged_lods .append (out )
401450
402451 # Stage 9 — animate cada LOD rigged. Round 2: preset por categoria.
403452 animated_lods : list [Path ] = []
@@ -475,6 +524,17 @@ def _run(name: str, argv: list[str], output: Path | None = None) -> StageResult:
475524 promoted_levels .add (level )
476525 except OSError as exc :
477526 log .warning ("master: promoção %s→%s falhou: %s" , w , target , exc )
527+ continue
528+ # Garante que o output final está totalmente optimizado
529+ # (KTX2+meshopt+tangents+dedup+prune). Bake-master e
530+ # rigging/animação correm com finish minimal quando há bpy
531+ # downstream — aplicamos a finalização pesada aqui no fim.
532+ try :
533+ from text3d .utils .gltf_finish import gltf_transform_finish
534+
535+ gltf_transform_finish (target , target )
536+ except Exception as exc : # noqa: BLE001
537+ log .warning ("master: finish promoted lod%d falhou: %s" , level , exc )
478538
479539 # Se animated promovido, mover _rigged.glb para _intermediate/.
480540 if promotion_kind == "animated" :
0 commit comments