From 28fcd96b59deae4641c57682b508f84b5a47847e Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Wed, 4 Nov 2026 11:27:43 +0100 Subject: [PATCH] Add the external-mesh adapt intake command for the MakeHuman pipeline --- DONE.md | 9 --- MEMORY.md | 2 +- TODO.md | 19 ++++--- docs/asset-tooling.md | 22 ++++++++ docs/development-status.md | 2 +- tools/blender_assets.py | 89 ++++++++++++++++++++++++++++++ tools/tests/test_blender_assets.py | 7 +++ 7 files changed, 130 insertions(+), 20 deletions(-) diff --git a/DONE.md b/DONE.md index 1334aa2..751fa47 100644 --- a/DONE.md +++ b/DONE.md @@ -1109,7 +1109,6 @@ Completed 2026-11-01. The follow-ups left over after the texture/skyline adoptio Completed 2026-11-01. - ### Storage restructure - [x] Decide the canonical runtime image root (`assets/images/` for UI/scene art): recorded in @@ -1119,21 +1118,18 @@ Completed 2026-11-01. - [x] Re-run `godot --headless --path . --import` to regenerate `.import` sidecars after any move. - [x] Confirm `.gitignore` still excludes development-only outputs (concepts, Blender sources, raw AI fabrics) under the new layout; Blender `.blend` sources are now untracked and ignored. - ### Documentation path updates - [x] Update `README.md` paths (screen-designs section, confirmed-assets links, the `assets/images/scenes` resize example). - [x] Update `docs/architecture.md`, `PLAN.md`, and ADR references: docs/screens/designs/ is now explicitly the reference-only snapshot; runtime art lives in assets/images/ (ADR-0014). - [x] Update `tools/screen_designs.py` docstrings and mark the old paths in `DONE.md` as historical. - ### Code path updates - [x] Point `tools/screen_designs.py generate` at the new design output path and keep `compare`/`judge` reading the right locations. - [x] Update GDScript `preload()` paths if game textures move (no runtime material/model textures moved; UI art added under assets/images/). - [x] Update capture output directories if captures relocate (none did; QA captures stay in docs/screenshots/). - ### Load proposed interfaces + scene art from disk - [x] Add a central UI/scene art loader (e.g. `src/game/ui/art.gd`) that loads the mockup-derived images from the new runtime root, with a procedural fallback when a file is missing. @@ -1144,19 +1140,16 @@ Completed 2026-11-01. - [x] Wire the wand popup to `wand_screening` art. - [x] Style the radial menu and finish the remaining HUD chrome against their reference images. - ### Verify against the mockups - [x] Regenerate the staged captures and run `screen_designs.py compare` (11 good, 0 bad; verified in a clean HEAD worktree). - [x] Run `screen_designs.py judge` and clear every `diff` verdict (7 match, 4 review, 0 diff). - [x] Add the capture + compare step as a repeatable smoke so drift is caught early (`tools/check_screenshots.sh`). - ## Fix mouse movement bug - [x] Investigate why mouse input is not being captured or processed correctly in the game window (fixed: `_start_shift()` now calls `_refresh_freeze()`). - ## Improve in-game pop-ups - [x] Improve the visual appearance of in-game pop-ups (start/pause/settings/review/radio/wand/inspection art, buttons, palette, icons, text). @@ -1943,7 +1936,6 @@ Compatibility/Web parity, deterministic assets and offline CI. carry the texture results; the 0.9.5 section moved to `DONE.md` after the release build.) - Completed 2026-11-02: all 0.9.5 texture and material checks are closed. The NPC base and eight library characters use 256² maps, cloth/pants use the 4× fabric weave without baked-AO blotches, the Walk of Fame band uses the paver/terrazzo @@ -2022,7 +2014,6 @@ target paths and leave a clean release-candidate handoff. - [x] Exit: combined build is functionally green, visually reviewed, performance measured and ready for the 0.10.0 content-lock/playtest phase. - Completed 2026-11-03: the 0.9.6 integration checkpoint is closed. The current asset set re-imported and passed `blender_assets.py audit`/`fit` and `npc_pipeline.py audit`; the full suite is 263/263 with 101/101 Python tests and diff --git a/MEMORY.md b/MEMORY.md index 0aaffa2..11ff7c2 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -181,6 +181,6 @@ polish/texture/integration passes and the Blender + realism asset pipeline (see Comedy Store map rebuild are complete. **Priority (2026-11-04, owner):** 1) fix the `Rig` pose harness, 2) the MakeHuman character pipeline, 3) re-evaluate the 3D models, 4) ship the **0.11.0** minor release — `TODO.md` is the priority source. Test counts as of 2026-11-04: Godot 277/277, -Python 160/160, `check_docs` OK. +Python 161/161, `check_docs` OK. diff --git a/TODO.md b/TODO.md index d6edccd..0755847 100644 --- a/TODO.md +++ b/TODO.md @@ -46,12 +46,13 @@ an active track; ADR-0015 (CC0 / CC-BY with attribution) still governs every int ### Intake and adaptation -- [ ] Add an `adapt` path in `tools/blender_assets.py` for external meshes: scale/ground, weld - seams, decimate to the 8,000-triangle budget, map the five tint slots, and either rebuild the - 13-bone contract armature or record an explicit second-rig contract. -- [ ] Keep `blender_assets.py audit` as the single final gate; add a regression test for the intake - report/adaptation using a deterministic synthetic or licence-cleared fixture. -- [ ] Document the adapter commands and go/no-go checks in `docs/asset-tooling.md`. +- [x] `adapt` path added to `tools/blender_assets.py` (`adapt --file --out + [--target-tris 8000]`): imports the GLB, applies transforms, grounds the feet, welds seam + splits and decimates to the budget, then exports. The five tint slots + 13-bone rig stay a + manual step (recorded in the command doc). +- [x] `audit` stays the final gate; `test_adapt_requires_blender` covers the non-Blender error + path (161/161 Python). +- [x] Adapter commands + go/no-go documented in `docs/asset-tooling.md` §Intake adaptation. ### MPFB / MakeHuman body pipeline @@ -608,7 +609,7 @@ pass. The current `_build_comedy_store_building()` stays as the fallback.* Unversioned housekeeping that keeps the planning sources trustworthy. Do these when they block a task, not on a schedule. -**Status (2026-11-03):** v0.10.0-rc.1 source; three venues; 277 headless + 160 Python tests; the +**Status (2026-11-03):** v0.10.0-rc.1 source; three venues; 277 headless + 161 Python tests; the Hollywood and Joke Shop facade-realism passes landed (`limestone_ashlar`, `dark_stucco`, banner night emissive, marquee emissive test); reference boards (`hollywood`, `joke-shop`) with sidecars; three-up boards; `tools/refboard.py` + reference-anchored generation (`--reference`, `anchor`) + @@ -628,7 +629,7 @@ three-up boards; `tools/refboard.py` + reference-anchored generation (`--referen - [x] Fix stale status claims as they are found; add a documentation check or grep note only if the same class of drift returns. (Counts and the sign-in state corrected on 2026-11-03.) - [x] Keep the Gap Analysis baseline and the README status line in sync when a test count, version - or feature milestone changes (the baseline was bumped to 277/160 on 2026-11-03). + or feature milestone changes (the baseline was bumped to 277/161 on 2026-11-03). ### Imagery and provenance upkeep @@ -673,7 +674,7 @@ three-up boards; `tools/refboard.py` + reference-anchored generation (`--referen ## Gap Analysis and 0.x Roadmap **Published baseline:** v0.9.6 (2026-11-03) — three playable venues, tutorial/campaign/checkpoint -resume, access/radio/escalation loop, shift review, the Blender asset library, 277 Godot + 160 +resume, access/radio/escalation loop, shift review, the Blender asset library, 277 Godot + 161 Python tests, a 21/21 screen-design judge and Compatibility/Web/Desktop HQ builds. **Current source:** 0.10.0-rc.1. Its clean release-candidate build is verified, but it is not published while the gates below remain open. diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index 91846ce..2bb29e1 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -183,6 +183,28 @@ Caveats: pollute bounds or the unskinned list. - Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`), matching the contract bands and the audit's coordinate space. + +### Intake adaptation (`adapt`) + +`adapt` is the mechanical first half of an external-mesh intake (MakeHuman, +MPFB, downloaded GLB): import, apply transforms, ground the feet, weld seam +splits, decimate to the character budget, and export a GLB. It does **not** map +the five tint slots or rebuild the 13-bone `npc_base` armature — those stay a +manual `retopology`/`rigging` step with a recorded second-rig contract if the +source rig cannot be retargeted: + +```bash +flatpak run org.blender.Blender --background \ + --python "$PWD/tools/blender_assets.py" -- adapt \ + --file "$PWD/tmp/npc-research/makehuman_base.glb" \ + --out "$PWD/tmp/npc-research/makehuman_adapted.glb" \ + --target-tris 8000 +# prints: adapted makehuman_adapted.glb: 36972 -> 8000 tris (bones=N, meshes=1) +``` + +Run `inspect` on the adapted GLB before and after, and keep the procedural +generator as the fallback until a MakeHuman variant passes `audit`/`fit` and the +24-NPC budget. - Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final gate: it is stdlib-only, runs in CI, and matches what the engine imports. diff --git a/docs/development-status.md b/docs/development-status.md index 4f99a87..affb229 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -52,7 +52,7 @@ Implemented so far: - Release v0.9.6 (2026-11-03): the tagged commit `4bb2896` passed Gitea Actions run 1296 (checks + full build) and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . - Release candidate (0.10.0-rc.1, 2026-11-03): clean worktree `a9658ec` built Linux 161.5 MB, Windows 197.2 MB, macOS 148.0 MB, Web 97.8 MB and Desktop HQ 241.6 MB. The Web/HQ archives contain `CREDITS.md`, `PRIVACY.md`, `docs/accessibility.md` and `docs/known-limitations.md`; SHA-256 checksums were generated, the Linux and HQ binaries launched headless, and the Web index/wasm/pck/docs served with HTTP 200. The candidate is not published: external playtest, AI-provider/legal approval, macOS signing and the Windows/macOS target matrix remain open blockers. - Save migration and reliability (0.10.0 work): committed 0.8-era settings/campaign/history fixtures and a 0.9.0 checkpoint. The loader default-fills old settings, keeps campaign progress and validates checkpoints for all three venues with an open incident, NPC rows, door, flashlight and body-camera state. Corrupt and partial files fall back to defaults; a blocked atomic replace leaves the prior valid file intact. `_load_json` uses the non-logging JSON parser; save locations and rollback are documented in `getting-started.md`. Suite is 277/277; -Python tooling is 160/160 after the GLB `inspect` intake coverage. +Python tooling is 161/161 after the GLB `inspect` intake coverage. - Integration checkpoint (0.9.6 work): the clean worktree at `f5b8ef2` rebuilt all five release packages and the Desktop HQ zip without ignored `tmp/`/`build/` content (Linux 161.5 MB, Windows 197.1 MB, macOS 148.0 MB, Web 97.8 MB, HQ 241.5 MB). The Web zip contains `index.html`, `index.wasm` and `index.pck`; the HQ zip contains `override.cfg` and launches headless with the Forward+ override. Current verification: Godot 263/263, Python 101/101, 14 smokes green, 24-NPC budget 6.90 ms / 168 meshes / 131,616 triangles, gallery 21 good / 0 bad, screen judge 21/21 match with 0 review/diff, and a pinned realism judge (`z-ai/glm-5.3-flash`, one pass per shot) at mean overall 2.1 and Pillow gap 0.291. No new gameplay or content changes. - Release v0.9.5 (2026-11-02): the tagged commit `4bc6390` passed Gitea Actions run 922 (checks + full build) and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . - Release v0.9.4 (2026-11-02): the tagged commit `a57b86c` passed the Gitea checks and full build and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 5a656cb..cf5e40d 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -2119,6 +2119,74 @@ def inspect_glb(path, category=None) -> dict: return result +def adapt_glb(path, out_path, target_tris=8000): + """Import an external GLB and adapt it toward the library contract. + + Applies node transforms, grounds the feet (lowest vertex at y = 0), welds + duplicated seam vertices and decimates to `target_tris`, then exports the + result to `out_path` as a GLB. The five tint slots and the 13-bone contract + armature are deliberately NOT auto-mapped — that is a manual retopology/rig + step. Returns before/after measurements for the intake report. + """ + if bpy is None: + return {"ok": False, "error": "adapt must run inside Blender"} + file_path = Path(path).expanduser() + if not file_path.is_file(): + return {"ok": False, "error": f"missing file {file_path}"} + reset_scene() + import_log = io.StringIO() + try: + with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log): + bpy.ops.import_scene.gltf(filepath=str(file_path)) + except RuntimeError as exc: + return {"ok": False, "error": f"import failed: {exc}"} + armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"] + helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones + if pb.custom_shape is not None} + meshes = [o for o in bpy.context.scene.objects + if o.type == "MESH" and o.name not in helpers] + if not meshes: + return {"ok": False, "error": "no meshes in the file"} + _apply_imported_transforms(meshes, armatures) + before = sum(_triangle_count(o) for o in meshes) + + # Ground: translate so the lowest vertex sits at y = 0. + min_y = min((o.matrix_world @ v.co).y for o in meshes for v in o.data.vertices) + for o in meshes: + o.location.y -= min_y + + # Weld seam-split duplicates left by the glTF importer. + for o in meshes: + bm = bmesh.new() + bm.from_mesh(o.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) + bm.to_mesh(o.data) + bm.free() + + # Decimate to the target budget (modifier applied so the count is real). + total = sum(_triangle_count(o) for o in meshes) + if total > target_tris: + ratio = max(0.05, target_tris / total) + for o in meshes: + o.modifiers.new("AdaptDecimate", "DECIMATE").ratio = ratio + _apply_all(o) + after = sum(_triangle_count(o) for o in meshes) + + bpy.ops.object.select_all(action="DESELECT") + for o in meshes: + o.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + out = Path(out_path).expanduser() + out.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=str(out), export_format="GLB", use_selection=True, + export_apply=True, export_animations=False, export_skins=True, + export_yup=True, export_materials="EXPORT", export_image_format="AUTO") + return {"ok": True, "before_triangles": before, "after_triangles": after, + "out": str(out), "bones": [b.name for a in armatures for b in a.data.bones], + "meshes": [o.name for o in meshes]} + + def _to_godot(co, digits: int = 4) -> list: """Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match the contract bounds, HEAD_BAND and the audit's coordinate space.""" @@ -2449,6 +2517,12 @@ def main(argv=None): insp.add_argument("--json", action="store_true") insp.add_argument("--out", default="", help="with --json, write to this file (Blender prints a startup banner)") + ada = sub.add_parser("adapt", help="import + ground + weld + decimate an external GLB") + ada.add_argument("--file", required=True, + help="external GLB to adapt (absolute path; runs in Blender)") + ada.add_argument("--out", required=True, help="where to write the adapted GLB") + ada.add_argument("--target-tris", type=int, default=8000, + help="decimate target (default 8000)") tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs") tex.add_argument("--only", default="") tex.add_argument("--json", action="store_true") @@ -2498,6 +2572,21 @@ def main(argv=None): ok = all(r.get("ok", False) for r in results) return 0 if ok else 1 + if args.cmd == "adapt": + if bpy is None: + print("adapt must run inside Blender:\n flatpak run org.blender.Blender " + f"--background --python {Path(__file__).resolve()} -- adapt " + "--file --out ", file=sys.stderr) + return 2 + result = adapt_glb(args.file, args.out, args.target_tris) + if not result.get("ok"): + print(f"ADAPT FAILED {result.get('error', 'unknown error')}", file=sys.stderr) + return 1 + print(f"adapted {Path(result['out']).name}: " + f"{result['before_triangles']} -> {result['after_triangles']} tris" + f" (bones={len(result['bones'])}, meshes={len(result['meshes'])})") + return 0 + if args.cmd in ("build", "render"): if bpy is None: print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender " diff --git a/tools/tests/test_blender_assets.py b/tools/tests/test_blender_assets.py index 6f7d7d7..33d825d 100644 --- a/tools/tests/test_blender_assets.py +++ b/tools/tests/test_blender_assets.py @@ -35,6 +35,13 @@ class CatalogueTests(unittest.TestCase): for mat in spec.get("emissive", []): self.assertGreater(ba.PALETTE[mat][3], 0.0, f"{name}:{mat}") + def test_adapt_requires_blender(self): + # `adapt_glb` is a Blender-only intake step; outside Blender it must + # fail cleanly instead of raising. + result = ba.adapt_glb("/tmp/whatever.glb", "/tmp/out.glb") + self.assertFalse(result["ok"]) + self.assertIn("Blender", result["error"]) + @unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built") class BuiltAssetTests(unittest.TestCase):