Skip to content

Commit a4cb63e

Browse files
committed
feat(animator3d): add GLB decompression support for EXT_meshopt_compression
Implement a function to decompress GLB files with EXT_meshopt_compression using `gltf-transform copy`, ensuring compatibility with Blender's GLTF importer. Update asset import functions to utilize this decompression step, enhancing the import process for GLB files. Additionally, refactor related functions to improve file handling and streamline the asset processing workflow.
1 parent 23490d8 commit a4cb63e

7 files changed

Lines changed: 268 additions & 51 deletions

File tree

Animator3D/src/animator3d/bpy_ops.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ def clear_scene() -> None:
1919
bpy.ops.wm.read_factory_settings(use_empty=True)
2020

2121

22+
def _decompress_meshopt_glb(src: Path) -> Path:
23+
"""Se ``src`` é GLB com EXT_meshopt_compression (que bpy não importa),
24+
descompressa via ``gltf-transform copy`` para um tmpfile e devolve o path
25+
novo. Caso contrário, devolve ``src``.
26+
"""
27+
import shutil as _sh
28+
import subprocess as _sp
29+
import tempfile as _tf
30+
31+
if _sh.which("npx") is None:
32+
return src
33+
with _tf.NamedTemporaryFile(suffix=".glb", delete=False) as _tmp:
34+
out = Path(_tmp.name)
35+
try:
36+
r = _sp.run(
37+
["npx", "--yes", "@gltf-transform/cli", "copy", str(src), str(out)],
38+
capture_output=True, text=True, timeout=300, check=False,
39+
)
40+
except (FileNotFoundError, _sp.TimeoutExpired):
41+
return src
42+
if r.returncode != 0 or not out.is_file():
43+
return src
44+
return out
45+
46+
2247
def import_asset(path: Path) -> list[str]:
2348
"""Importa GLB/GLTF ou FBX. Devolve nomes de objectos de topo criados."""
2449
bpy = _bpy()
@@ -30,6 +55,9 @@ def import_asset(path: Path) -> list[str]:
3055
before = {o.name for o in bpy.context.scene.objects}
3156

3257
if suffix in {".glb", ".gltf"}:
58+
# bpy GLTF importer não suporta EXT_meshopt_compression; descompressa
59+
# silenciosamente quando preciso (no-op se já descompresso).
60+
path = _decompress_meshopt_glb(path)
3361
bpy.ops.import_scene.gltf(filepath=str(path))
3462
elif suffix == ".fbx":
3563
bpy.ops.import_scene.fbx(filepath=str(path))
@@ -2350,10 +2378,10 @@ def project_texture_to_parts(
23502378
bpy = _bpy()
23512379
clear_scene()
23522380

2353-
bpy.ops.import_scene.gltf(filepath=str(original_glb.resolve()))
2381+
bpy.ops.import_scene.gltf(filepath=str(_decompress_meshopt_glb(original_glb.resolve())))
23542382
source_objs = [o for o in bpy.context.selected_objects if o.type == "MESH"]
23552383

2356-
bpy.ops.import_scene.gltf(filepath=str(parts_glb.resolve()))
2384+
bpy.ops.import_scene.gltf(filepath=str(_decompress_meshopt_glb(parts_glb.resolve())))
23572385
all_objs = list(bpy.context.selected_objects)
23582386
part_objs = [o for o in all_objs if o not in source_objs and o.type == "MESH"]
23592387

GameAssets/src/gameassets/paths.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,16 @@ def _resolve_intermediate_or_main(canonical: Path, mesh_final: Path) -> Path | N
210210
return None
211211

212212

213+
def _clean_existing(mesh_final: Path) -> Path | None:
214+
"""Encontra o GLB ``_clean`` em ``meshes/`` ou ``_intermediate/``."""
215+
return _resolve_intermediate_or_main(_clean_path(mesh_final), mesh_final)
216+
217+
218+
def _rigged_hi_existing(mesh_final: Path) -> Path | None:
219+
"""Encontra o GLB ``_rigged_hi`` em ``meshes/`` ou ``_intermediate/``."""
220+
return _resolve_intermediate_or_main(_rigged_hi_path(mesh_final), mesh_final)
221+
222+
213223
def _shape_existing(mesh_final: Path) -> Path | None:
214224
"""Devolve o ``id_shape.glb`` existente em ``meshes/`` ou ``_intermediate/``."""
215225
return _resolve_intermediate_or_main(_shape_path(mesh_final), mesh_final)
@@ -273,25 +283,25 @@ def _classify_row_state_master(
273283
"""
274284
shape_any = _shape_existing(mesh_final)
275285
painted_any = _painted_existing(mesh_final)
276-
clean = _clean_path(mesh_final)
286+
clean_any = _clean_existing(mesh_final)
277287
lod0 = _lod_path(mesh_final, 0)
278288
lod1 = _lod_path(mesh_final, 1)
279289
lod2 = _lod_path(mesh_final, 2)
280-
rigged_hi = _rigged_hi_path(mesh_final)
290+
rigged_hi_any = _rigged_hi_existing(mesh_final)
281291

282292
if not _valid_file(img_final) and shape_any is None:
283293
return _ROW_NEED_IMAGE
284294
if shape_any is None:
285295
return _ROW_NEED_SHAPE
286-
if not _valid_file(clean):
296+
if clean_any is None:
287297
return _ROW_NEED_TOPOLOGY_FIX
288298
if want_texture and painted_any is None:
289299
return _ROW_NEED_PAINT
290300
if not _valid_file(lod0):
291301
return _ROW_NEED_BAKE_MASTER
292302
if wants_lod and (not _valid_file(lod1) or not _valid_file(lod2)):
293303
return _ROW_NEED_LOD_GEN
294-
if wants_rig and not _valid_file(rigged_hi):
304+
if wants_rig and rigged_hi_any is None:
295305
return _ROW_NEED_RIG_HI
296306
if wants_rig:
297307
# checa transfers

GameAssets/src/gameassets/pipeline_master.py

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
from .helpers import effective_face_ratio
3232
from .manifest import ManifestRow
3333
from .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":

GameAssets/src/gameassets/resume_cmd.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,10 @@ def append_log(rec: dict) -> None:
212212
row_wants_rig = _row_wants_rig(row, has_rigging_profile)
213213
row_wants_animate = _row_wants_animate(row, want_rig, has_rigging_profile)
214214

215+
master_state: str | None = None
215216
if getattr(profile, "master_pipeline", False):
216217
# Round 2: usa classificador do DAG novo.
217-
state = _classify_row_state_master(
218+
master_state = _classify_row_state_master(
218219
img_final=img_final,
219220
mesh_final=mesh_final,
220221
want_texture=row.generate_paint,
@@ -223,6 +224,28 @@ def append_log(rec: dict) -> None:
223224
wants_lod=row.generate_lod,
224225
wants_collision=row.generate_collision,
225226
)
227+
# Mapeia para os 6 buckets clássicos usados pelo planeador/loop
228+
# do resume_cmd. O bucket determina onde a execução vai retomar:
229+
# need_paint cobre topology-fix/bake-master/lod_gen (todos
230+
# rodam dentro do pipeline_master, que é despachado a partir do
231+
# passo paint).
232+
_master_to_legacy = {
233+
"need_image": _ROW_NEED_IMAGE,
234+
"need_shape": _ROW_NEED_SHAPE,
235+
"need_topology_fix": _ROW_NEED_PAINT,
236+
"need_paint": _ROW_NEED_PAINT,
237+
"need_bake_master": _ROW_NEED_PAINT,
238+
"need_lod_gen": _ROW_NEED_PAINT,
239+
"need_collision": _ROW_NEED_PAINT,
240+
"need_rig_hi": _ROW_NEED_RIG,
241+
"need_rig": _ROW_NEED_RIG,
242+
"need_transfer": _ROW_NEED_RIG,
243+
"need_animate_lod": _ROW_NEED_ANIMATE,
244+
"need_animate": _ROW_NEED_ANIMATE,
245+
"need_validate": _ROW_DONE,
246+
_ROW_DONE: _ROW_DONE,
247+
}
248+
state = _master_to_legacy.get(master_state, _ROW_NEED_PAINT)
226249
else:
227250
state = _classify_row_state(
228251
img_final=img_final,
@@ -243,6 +266,7 @@ def append_log(rec: dict) -> None:
243266
"idx": idx,
244267
"row": row,
245268
"state": state,
269+
"master_state": master_state,
246270
"img_final": img_final,
247271
"mesh_final": mesh_final,
248272
"row_work": row_work,
@@ -254,7 +278,7 @@ def append_log(rec: dict) -> None:
254278
)
255279

256280
# --- Relatório ---
257-
counts = {
281+
counts: dict[str, int] = {
258282
_ROW_NEED_IMAGE: 0,
259283
_ROW_NEED_SHAPE: 0,
260284
_ROW_NEED_PAINT: 0,
@@ -263,7 +287,7 @@ def append_log(rec: dict) -> None:
263287
_ROW_DONE: 0,
264288
}
265289
for it in items:
266-
counts[it["state"]] += 1
290+
counts[it["state"]] = counts.get(it["state"], 0) + 1
267291

268292
plan_table = Table(title="[bold]Plano de execução[/bold]", box=box.ROUNDED, show_header=True)
269293
plan_table.add_column("Fase", style="bold")

0 commit comments

Comments
 (0)