From da876e3545011d55e08bf1658bfa851712048d24 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 1 Nov 2026 17:52:25 +0100 Subject: [PATCH] Add a gear-vs-body fit report to the Blender asset tool `blender_assets.py fit` measures each character head width (skin vertices weighted to Head in y 1.55-1.81) and clothing radius (cloth skirt in y 0.30-0.52, pants hip fallback), then prints every attached head/back/chest gear bounding box with head-band status and x/radial skirt clearance. The report confirms the glasses width and shoulder-bag coat clip, giving the next fit fixes numbers. Python tooling 87/87; asset audit clean. --- MEMORY.md | 4 + TODO.md | 9 ++- docs/blender-assets.md | 1 + tools/blender_assets.py | 151 +++++++++++++++++++++++++++++++++++ tools/test_blender_assets.py | 19 +++++ 5 files changed, 180 insertions(+), 4 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index ddd281a..58bea3c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -13,6 +13,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o (46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked. - `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01 (7 match / 4 review / 0 diff); `--model` remains available. +- Gear fit is measured with `python3 tools/blender_assets.py fit` (stdlib, reads the GLBs): + head width uses skin vertices weighted to Head in y 1.55–1.81; clothing radius is the cloth + skirt in y 0.30–0.52 with a pants hip fallback. The command reports per-character gear boxes, + head-band status and x/radial skirt clearance; `test_blender_assets` covers its shape. - Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit, venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added diff --git a/TODO.md b/TODO.md index 621d014..51a6118 100644 --- a/TODO.md +++ b/TODO.md @@ -11,10 +11,11 @@ sunglasses span the whole upper face and the shoulder bag cuts through `npc_coat live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md` (gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`). -- [ ] Measure the fit in Blender, not by eye: extend `blender_assets.py audit` (or add a `fit` - subcommand) to print, per head/back/chest gear piece, its bounding box against the avatar - head width (measured 0.314 m on `npc_coat`), the head height band (y 1.55–1.81) and the - clothing radius (coat/dress skirt flare 0.225–0.26 m), for every character variant. +- [x] Measure the fit in Blender, not by eye: `python3 tools/blender_assets.py fit` prints each + character's measured head width and hip/skirt radius, then every head/back/chest gear + bounding box with head-band status or skirt clearance. Current measurements: head width + 0.315 m; coat skirt 0.225 m, dress 0.260 m; sunglasses 0.282 m (0.90x head); shoulder-bag + x-clearance -0.025 m on the coat (radial -0.031 m on the dress). - [ ] Narrow `sunglasses` from 0.28 m to ~0.20 m. The width is driven by the arms/hinges (`Hinge` at `x = ±0.11` with a 0.06 box, `Arm` at `±0.138`), not by the 0.03 m lenses, so pull those in first and shrink the lenses to ~0.024 m radius; keep the lens plane at diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 2df0a85..3aa8315 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -41,6 +41,7 @@ flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- render python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_.jpg python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue +python3 tools/blender_assets.py fit # head/clothing fit, per character python3 tools/blender_assets.py list godot --headless --path . --import # after any rebuild ``` diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 2f9c7ee..2f92e32 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -14,6 +14,7 @@ integration: nothing in the game loads them yet (see --python "$PWD/tools/blender_assets.py" -- render # outside Blender (stdlib + Pillow) python3 tools/blender_assets.py audit # GLB contract checks + python3 tools/blender_assets.py fit # gear-vs-body fit measurements python3 tools/blender_assets.py sheet # sheet_.jpg (committed) python3 tools/blender_assets.py list @@ -104,6 +105,14 @@ GEAR_ATTACH = { # Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)). ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78), "back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)} +# Attach points in the authored GLB space (Godot coordinates), used by `fit`. +ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02), + "back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)} +HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres +FIT_HEAD_MATERIALS = ("skin",) +FIT_CLOTHING_MATERIAL = "cloth" +FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare +FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests GEAR = { "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "firefighter_helmet": "head", @@ -1672,6 +1681,117 @@ def audit_asset(category, name): return res +def character_fit_reference(name): + """Measure a character's head width and hip/skirt radius from its GLB. + + Head width uses skin/hair vertices whose strongest joint is Head inside the + fixed head band. Clothing radius uses the cloth vertices in the hip/skirt + band. Both are reported in metres and rounded to millimetres. + """ + path = asset_path("npc", name) + if not path.is_file(): + return None + try: + _magic, _version, _length, _size, gltf, binary = _read_glb(path) + except (struct.error, ValueError, KeyError): + return None + nodes = gltf.get("nodes", []) + joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])] + materials = [m.get("name", "") for m in gltf.get("materials", [])] + head_x: list[float] = [] + head_y: list[float] = [] + cloth_x: list[float] = [] + hip_x: list[float] = [] + for mesh in gltf.get("meshes", []): + for prim in mesh.get("primitives", []): + attrs = prim.get("attributes", {}) + if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs: + continue + mat_index = prim.get("material") + mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else "" + positions = _accessor(gltf, binary, attrs["POSITION"]) + joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"]) + weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"]) + for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows): + strongest = max(range(4), key=lambda i: weight_row[i]) + bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else "" + if (mat_name in FIT_HEAD_MATERIALS and bone == "Head" + and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]): + head_x.append(pos[0]) + head_y.append(pos[1]) + if (mat_name == FIT_CLOTHING_MATERIAL + and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]): + cloth_x.append(pos[0]) + if (mat_name == "pants" + and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]): + hip_x.append(pos[0]) + skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0 + hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0 + clothing_radius = max(skirt_radius, hip_radius) + return { + "head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0, + "head_y_min": round(min(head_y), 3) if head_y else 0.0, + "head_y_max": round(max(head_y), 3) if head_y else 0.0, + "clothing_radius": round(clothing_radius, 3), + "clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip", + "head_band": HEAD_BAND, + } + + +def _gear_fit(name, reference): + attach = GEAR.get(name) + if attach not in ("head", "back", "chest"): + return None + audit = audit_asset("npc_gear", name) + bounds = audit.get("bounds") + if not bounds: + return None + offset = ATTACH_GODOT[attach] + lo = [bounds["min"][i] + offset[i] for i in range(3)] + hi = [bounds["max"][i] + offset[i] for i in range(3)] + result = { + "name": name, + "attach": attach, + "min": [round(v, 3) for v in lo], + "max": [round(v, 3) for v in hi], + } + if attach == "head": + result.update({ + "width": round(hi[0] - lo[0], 3), + "width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3) + if reference["head_width"] else 0.0, + "inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005, + }) + else: + # Compare the widest point (|x|) and the corner radial extent with the + # skirt flare. The radial value catches bags that sit diagonally out. + x_extent = max(abs(lo[0]), abs(hi[0])) + radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2])) + result.update({ + "x_extent": round(x_extent, 3), + "radial": round(radial, 3), + "x_clearance": round(reference["clothing_radius"] - x_extent, 3), + "radial_clearance": round(reference["clothing_radius"] - radial, 3), + }) + return result + + +def fit_report(): + """Return per-character gear fit measurements for the `fit` command/tests.""" + report = [] + for character, spec in CHARACTERS.items(): + reference = character_fit_reference(character) + if reference is None: + continue + rows = [] + for gear_name in spec.get("gear", []): + row = _gear_fit(gear_name, reference) + if row: + rows.append(row) + report.append({"character": character, "reference": reference, "gear": rows}) + return report + + def write_manifest(results): out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH, "assets": {}} @@ -1735,6 +1855,8 @@ def main(argv=None): s.add_argument("--only", default="") a = sub.add_parser("audit") a.add_argument("--json", action="store_true") + fit = sub.add_parser("fit") + fit.add_argument("--json", action="store_true") sub.add_parser("sheet") sub.add_parser("list") args = ap.parse_args(raw) @@ -1766,6 +1888,35 @@ def main(argv=None): if args.cmd == "sheet": contact_sheets() return 0 + if args.cmd == "fit": + report = fit_report() + if not report: + print("no built character assets; run build first", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(report, indent=2)) + else: + for entry in report: + ref = entry["reference"] + print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m " + f"clothing_radius={ref['clothing_radius']:.3f} m " + f"({ref['clothing_source']})") + for row in entry["gear"]: + lo, hi = row["min"], row["max"] + box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] " + f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] " + f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]") + if row["attach"] == "head": + flag = "inside" if row["inside_band"] else "OUTSIDE" + print(f" {row['name']:20s} head {box} " + f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) " + f"band={flag}") + else: + clip = "CLIP" if row["x_clearance"] < 0 else "clear" + print(f" {row['name']:20s} {row['attach']:5s} {box} " + f"clearance x={row['x_clearance']:+.3f} " + f"r={row['radial_clearance']:+.3f} {clip}") + return 0 results = [audit_asset(c, n) for c, n in catalogue()] if args.json: print(json.dumps(results, indent=2)) diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index dd23240..7e94abf 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -45,6 +45,25 @@ class BuiltAssetTests(unittest.TestCase): bad[name] = result["issues"] self.assertEqual(bad, {}) + def test_fit_report_measures_gear_against_body(self): + report = {entry["character"]: entry for entry in ba.fit_report()} + self.assertIn("npc_coat", report) + coat = report["npc_coat"] + self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2) + self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3) + sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") + self.assertEqual(sunglasses["attach"], "head") + self.assertLess(sunglasses["width"], coat["reference"]["head_width"]) + bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") + self.assertEqual(bag["attach"], "back") + self.assertGreaterEqual(bag["x_extent"], 0.2) + for character in report.values(): + for row in character["gear"]: + self.assertEqual(len(row["min"]), 3) + self.assertEqual(len(row["max"]), 3) + self.assertIn("npc_dress", report) + self.assertAlmostEqual(report["npc_dress"]["reference"]["clothing_radius"], 0.26, places=3) + def test_manifest_covers_catalogue(self): manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})