Move the shoulder bag clear of coat and dress skirts

Narrow the bag to 0.06 m and move it outboard to centre x=0.292. The body inner
face is now 0.262 m, clearing the coat skirt (0.225) and dress (0.260); the
strap lower points start on the same line. The fit report measures the bag body
separately from the torso strap and uses a signed inner-face clearance. Run
Blender build/bake and Godot import. Python tooling 87/87.
This commit is contained in:
zwitschi committed 2026-11-01 17:58:47 +01:00
1 parent bbf4693475
commit 702f54ae53
6 files changed
+44 -10

No files matched your search

+4 -4
View File
@@ -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. 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 Also fixed `capture_realism.gd` so `--portrait-type=N` is actually parsed (it was documented
but ignored). but ignored).
- [ ] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: the bag body spans `|x|` - [x] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: narrowed the bag to 0.06 m and
0.17–0.25 at the hip while the skirt flares to 0.225–0.26, so its inner half is inside the moved it outboard (centre x=0.292); the body's inner face is now 0.262 m, clearing the coat
cloth. Move it outboard (or up onto the belt line) until the inner face clears the flare, (0.225) by 0.037 m and the dress (0.260) by 0.002 m. The strap's lower attach points sit on
and keep the strap tube outside the same radius. 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 - [ ] 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. 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 - [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then
+2 -2
View File
@@ -1001,7 +1001,7 @@
"attach": "back", "attach": "back",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.25, 0.322,
0.348, 0.348,
0.139 0.139
], ],
@@ -1017,7 +1017,7 @@
], ],
"path": "assets/models/blender/npc_gear/shoulder_bag.glb", "path": "assets/models/blender/npc_gear/shoulder_bag.glb",
"triangles": 188, "triangles": 188,
"vertices": 212 "vertices": 209
}, },
"stanchion": { "stanchion": {
"bounds_godot": { "bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+36 -4
View File
@@ -113,6 +113,9 @@ FIT_HEAD_MATERIALS = ("skin",)
FIT_CLOTHING_MATERIAL = "cloth" FIT_CLOTHING_MATERIAL = "cloth"
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare 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 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 = { GEAR = {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "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.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)],
0.012, M("plastic_black"), seg=6)) 0.012, M("plastic_black"), seg=6))
elif name == "shoulder_bag": 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)) 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.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": 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(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, 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): def _gear_fit(name, reference):
attach = GEAR.get(name) attach = GEAR.get(name)
if attach not in ("head", "back", "chest"): if attach not in ("head", "back", "chest"):
@@ -1768,12 +1790,21 @@ def _gear_fit(name, reference):
else: else:
# Compare the widest point (|x|) and the corner radial extent with the # Compare the widest point (|x|) and the corner radial extent with the
# skirt flare. The radial value catches bags that sit diagonally out. # 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])) 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])) radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
result.update({ result.update({
"inner_x": round(inner_x, 3),
"x_extent": round(x_extent, 3), "x_extent": round(x_extent, 3),
"radial": round(radial, 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), "radial_clearance": round(reference["clothing_radius"] - radial, 3),
}) })
return result return result
@@ -1917,6 +1948,7 @@ def main(argv=None):
else: else:
clip = "CLIP" if row["x_clearance"] < 0 else "clear" clip = "CLIP" if row["x_clearance"] < 0 else "clear"
print(f" {row['name']:20s} {row['attach']:5s} {box} " print(f" {row['name']:20s} {row['attach']:5s} {box} "
f"inner_x={row['inner_x']:.3f} "
f"clearance x={row['x_clearance']:+.3f} " f"clearance x={row['x_clearance']:+.3f} "
f"r={row['radial_clearance']:+.3f} {clip}") f"r={row['radial_clearance']:+.3f} {clip}")
return 0 return 0
+2
View File
@@ -57,6 +57,8 @@ class BuiltAssetTests(unittest.TestCase):
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
self.assertEqual(bag["attach"], "back") self.assertEqual(bag["attach"], "back")
self.assertGreaterEqual(bag["x_extent"], 0.2) 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 character in report.values():
for row in character["gear"]: for row in character["gear"]:
self.assertEqual(len(row["min"]), 3) self.assertEqual(len(row["min"]), 3)