diff --git a/TODO.md b/TODO.md index 4cdb859..1cf9303 100644 --- a/TODO.md +++ b/TODO.md @@ -21,10 +21,10 @@ live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blend Rebuilt GLB/AO: measured width **0.202 m** (0.64x head), still inside the head band. Also fixed `capture_realism.gd` so `--portrait-type=N` is actually parsed (it was documented but ignored). -- [ ] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: the bag body spans `|x|` - 0.17–0.25 at the hip while the skirt flares to 0.225–0.26, so its inner half is inside the - cloth. Move it outboard (or up onto the belt line) until the inner face clears the flare, - and keep the strap tube outside the same radius. +- [x] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: narrowed the bag to 0.06 m and + moved it outboard (centre x=0.292); the body's inner face is now 0.262 m, clearing the coat + (0.225) by 0.037 m and the dress (0.260) by 0.002 m. The strap's lower attach points sit on + the same outboard line. `fit` now measures the bag body separately from the torso strap. - [ ] Same pass for `cap`/`beanie`/`hood`/`firefighter_helmet`: they must not clip the new brow/ear geometry, and `hood` must still leave the face readable for hoodie avatars. - [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index e975096..0b3e278 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -1001,7 +1001,7 @@ "attach": "back", "bounds_godot": { "max": [ - 0.25, + 0.322, 0.348, 0.139 ], @@ -1017,7 +1017,7 @@ ], "path": "assets/models/blender/npc_gear/shoulder_bag.glb", "triangles": 188, - "vertices": 212 + "vertices": 209 }, "stanchion": { "bounds_godot": { diff --git a/assets/models/blender/npc_gear/shoulder_bag.glb b/assets/models/blender/npc_gear/shoulder_bag.glb index 720dfd6..490afc2 100644 Binary files a/assets/models/blender/npc_gear/shoulder_bag.glb and b/assets/models/blender/npc_gear/shoulder_bag.glb differ diff --git a/assets/models/blender/npc_gear/shoulder_bag_ao.png b/assets/models/blender/npc_gear/shoulder_bag_ao.png index 754b7cf..65f4a14 100644 Binary files a/assets/models/blender/npc_gear/shoulder_bag_ao.png and b/assets/models/blender/npc_gear/shoulder_bag_ao.png differ diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 9f168eb..1ba60d3 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -113,6 +113,9 @@ 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 +# Side bags include a strap that crosses the torso, so the full gear bbox cannot +# tell where the bag body starts. Measure the body-only y band for this fit. +FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)} GEAR = { "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "firefighter_helmet": "head", @@ -1005,11 +1008,13 @@ def build_gear(name): (0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)], 0.012, M("plastic_black"), seg=6)) elif name == "shoulder_bag": - p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"), + # Keep the bag's inner face outside the widest skirt flare (dress = 0.26): + # 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line. + p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"), bevel=0.025, segs=2)) - p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26), + p.append(tube("Strap", [(0.262, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26), (-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05), - (0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6)) + (0.262, 0.03, -0.12)], 0.009, M("leather"), seg=6)) elif name == "scba_tank": p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18)) p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8, @@ -1741,6 +1746,23 @@ def character_fit_reference(name): } +def _gear_local_positions(name): + path = asset_path("npc_gear", name) + if not path.is_file(): + return [] + try: + _magic, _version, _length, _size, gltf, binary = _read_glb(path) + except (struct.error, ValueError, KeyError): + return [] + out = [] + for mesh in gltf.get("meshes", []): + for prim in mesh.get("primitives", []): + attrs = prim.get("attributes", {}) + if "POSITION" in attrs: + out.extend(_accessor(gltf, binary, attrs["POSITION"])) + return out + + def _gear_fit(name, reference): attach = GEAR.get(name) if attach not in ("head", "back", "chest"): @@ -1768,12 +1790,21 @@ def _gear_fit(name, reference): else: # Compare the widest point (|x|) and the corner radial extent with the # skirt flare. The radial value catches bags that sit diagonally out. + inner_x = min(abs(lo[0]), abs(hi[0])) + if name in FIT_BODY_BANDS: + band = FIT_BODY_BANDS[name] + body_x = [abs(p[0]) for p in _gear_local_positions(name) + if band[0] <= p[1] <= band[1]] + if body_x: + inner_x = min(body_x) 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({ + "inner_x": round(inner_x, 3), "x_extent": round(x_extent, 3), "radial": round(radial, 3), - "x_clearance": round(reference["clothing_radius"] - x_extent, 3), + # Positive clearance means the bag's inner face is outside the body. + "x_clearance": round(inner_x - reference["clothing_radius"], 3), "radial_clearance": round(reference["clothing_radius"] - radial, 3), }) return result @@ -1917,6 +1948,7 @@ def main(argv=None): else: clip = "CLIP" if row["x_clearance"] < 0 else "clear" print(f" {row['name']:20s} {row['attach']:5s} {box} " + f"inner_x={row['inner_x']:.3f} " f"clearance x={row['x_clearance']:+.3f} " f"r={row['radial_clearance']:+.3f} {clip}") return 0 diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index 7e94abf..e87499f 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -57,6 +57,8 @@ class BuiltAssetTests(unittest.TestCase): 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) + self.assertGreater(bag["x_clearance"], 0) + self.assertGreater(bag["inner_x"], coat["reference"]["clothing_radius"]) for character in report.values(): for row in character["gear"]: self.assertEqual(len(row["min"]), 3)