Intake the modular outfit pack and ship the Suit base for npc_coat
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 19:02:38 +01:00
1 parent f6bb2b1de3
commit 3b9e3b61d8
12 files changed
+253 -36

No files matched your search

+2
View File
@@ -14,6 +14,8 @@ Semantic Versioning.
- Hands, head gear and carried items were re-fitted to the new characters: hand items sit in the - Hands, head gear and carried items were re-fitted to the new characters: hand items sit in the
palm instead of floating at the wrist, the lanyard moved up to the sternum, back gear moved up to palm instead of floating at the wrist, the lanyard moved up to the sternum, back gear moved up to
shoulder-blade height, and the clipboard is carried by its top edge. shoulder-blade height, and the clipboard is carried by its top edge.
- The influencer character now wears a formal suit from the modular outfit pack instead of a
tinted hoodie, the first of the modular outfits to ship.
- NPCs no longer all share one hairstyle: the roster now uses short, buzz and long hair silhouettes - NPCs no longer all share one hairstyle: the roster now uses short, buzz and long hair silhouettes
instead of a single shell in twelve colours. instead of a single shell in twelve colours.
- Eyes now have whites and pupils instead of flat dark rectangles. - Eyes now have whites and pupils instead of flat dark rectangles.
+11
View File
@@ -130,6 +130,17 @@ canonical implemented-features list in `docs/development-status.md`.
gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback
(`build --source procedural`), not in the shipped roster. Silhouette variety is the known (`build --source procedural`), not in the shipped roster. Silhouette variety is the known
regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix. regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix.
- **FBX modular parts need five fixes before they are a base (2026-11-06):** (1) every FBX file
carries its own armature — keep one and repoint the meshes; (2) FBX adds `_end` leaf bones and a
`neutral_bone` root, and **Godot refuses the GLB entirely** (`resource_format_binary` / missing
`.scn`) until they are stripped to the 62 joints; (3) bake object transforms before export or the
glTF node transforms change the rest pose and the head renders several times too large;
(4) the pack reuses one fabric material for jacket and trousers — copy it per leg mesh
(`*_legs`) so the slot map can send it to `pants`; (5) imported parts share seams, so dissolve
and drop zero-area triangles or the audit fails.
- **`tmp/` needs a `.gdignore`.** Godot scans the project tree, so a downloaded asset pack under
`tmp/quaternius-intake/` gets imported (it flooded the import log and broke `--import` for the
rest of the project). An empty `tmp/.gdignore` stops it; capture output there is unaffected.
- **Modular part intake is a one-time manual download (2026-11-06, owner):** the upstream pack is a - **Modular part intake is a one-time manual download (2026-11-06, owner):** the upstream pack is a
Google Drive folder, so the owner downloads it once into the gitignored Google Drive folder, so the owner downloads it once into the gitignored
`tmp/quaternius-intake/` and `tools/quaternius_parts.py` does the rest `tmp/quaternius-intake/` and `tools/quaternius_parts.py` does the rest
+15 -19
View File
@@ -770,25 +770,21 @@ regressing gear fit, budget or readability.
place (`short` identity, `buzz` squashed toward the hairline, `long` stretched from the crown), place (`short` identity, `buzz` squashed toward the hairline, `long` stretched from the crown),
and each role picks a style through the existing `CHARACTERS[...]["hair"]` field. The T-pose and each role picks a style through the existing `CHARACTERS[...]["hair"]` field. The T-pose
cache is keyed on the style. The roster now reads as three silhouettes instead of one. cache is keyed on the style. The roster now reads as three silhouettes instead of one.
- [-] **Outfit part library (owner chose the one-time manual download).** The upstream modular pack is - [x] **Outfit part library — intake landed (`Suit` pilot).** The owner downloaded the
a Google Drive folder (<https://drive.google.com/drive/folders/1USAAquX2JJWuA2m6zol0KUkFe3UkZ8zX>); *Ultimate Modular Men* pack (CC0, 15 families, FBX only) into `tmp/quaternius-intake/`.
the owner downloads it once, everything after that is scripted. Steps: `tools/quaternius_parts.py make-base` composes a family into a skinned base GLB, undoing five
1. download the pack and drop the archive (or its extracted folder) into FBX artefacts (per-file armatures, `_end`/`neutral_bone`, node transforms, the shared
`tmp/quaternius-intake/` — that directory is gitignored; jacket/trouser fabric, seam degenerates). `Suit.glb` is the first base: `npc_coat` is now a
2. `python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake` — lists every GLB dark formal suit instead of a tinted hoodie. The pitfalls are recorded in
with its meshes, triangles, bones and materials, so the usable parts are picked by `docs/npc-model-sourcing.md` and `MEMORY.md`.
measurement instead of by filename; - [ ] **Remaining outfit families.** The pack also has Adventurer, Beach, Casual_2, Farmer, King,
3. `python3 tools/quaternius_parts.py extract --file <glb> --mesh <name> --out parts/<part>.glb` Punk, SpaceSuit, Swat and Weapons (pistol/sword — unused, the game has no weapon system).
writes one skinned part onto the 62-joint rig (the only accepted part shape); Pick the roles each one fits, add a `QUATERNIUS_SOURCES` entry per family with its slot map,
4. `python3 tools/quaternius_parts.py verify --dir assets/models/blender_src/quaternius/parts` then rebuild. `Swat` and `SpaceSuit` have **no hair or eye geometry** (helmeted heads), so
records the SHA-256 of what was kept, then copy the accepted sources into they need the five-slot question answered first: hair would be an invisible patch under the
`assets/models/blender_src/quaternius/parts/` and add the rows to `CREDITS.md`; helmet, or the audit loses a slot.
5. assign parts per role in `CHARACTERS[...]["parts"]` and rebuild. - [x] Intake tooling: `inventory` / `extract` / `verify` / `make-base` in
Nothing lands in `assets/` until step 5, and the pack itself is never committed. `tools/quaternius_parts.py`, all exercised against the real pack.
Tooling is ready and proven: `inventory`/`extract`/`verify` work, and a scratch part joined
onto a base and exported cleanly before the pack arrived. The part contract is in
`assets/models/blender_src/quaternius/parts/README.md`. **Waiting on the owner's download.**
A `bald` hair variant also needs a skull-covering crown on the `worker` base first.
- [ ] Bake per-slot albedo/normal/ORM maps and a per-slot atlas; the five slots currently share one - [ ] Bake per-slot albedo/normal/ORM maps and a per-slot atlas; the five slots currently share one
whole-body UV set, which caps how much the cloth/pants tints can differ. whole-body UV set, which caps how much the cloth/pants tints can differ.
+12 -11
View File
@@ -429,30 +429,31 @@
"vertices": 13100 "vertices": 13100
}, },
"npc_coat": { "npc_coat": {
"base": "hoodie", "base": "suit",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.262, 0.255,
1.866, 1.809,
0.287 0.279
], ],
"min": [ "min": [
-0.262, -0.255,
-0.004, -0.001,
-0.092 -0.083
] ]
}, },
"category": "npc", "category": "npc",
"layer": "coat", "layer": "coat",
"materials": [ "materials": [
"skin", "skin",
"Tie",
"cloth", "cloth",
"White",
"accessory", "accessory",
"Eyebrows", "Eyebrows",
"sclera",
"hair", "hair",
"sclera",
"pants", "pants",
"mouth",
"pupil" "pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
@@ -469,8 +470,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6242, "triangles": 6582,
"vertices": 13110 "vertices": 13607
}, },
"npc_crowd_a": { "npc_crowd_a": {
"base": "casual", "base": "casual",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.
+30
View File
@@ -281,6 +281,36 @@ Head gear is authored for this head: `HEAD_GEAR_SCALE = 0.64` and `HEAD_GEAR_LIF
0.315 m procedural head onto the 0.201–0.214 m Quaternius head. The procedural fallback therefore 0.315 m procedural head onto the 0.201–0.214 m Quaternius head. The procedural fallback therefore
wears slightly small hats. wears slightly small hats.
## Modular outfit intake (2026-11-06)
The owner downloaded the *Ultimate Modular Men* pack once (CC0 1.0, 54.5 MB zip, 15 outfit families
plus weapons and a horse) into the gitignored `tmp/quaternius-intake/`. The pack ships FBX, not GLB,
so the intake composes each family into a single skinned base:
python3 tools/quaternius_parts.py make-base \
--family "tmp/quaternius-intake/Ultimate Modular Men- Feb 2022/Separate Skeletal Meshes and Animations/Suit" \
--out assets/models/blender_src/quaternius/Suit.glb
What `make-base` has to undo, and why it is not a straight import:
1. each of the four FBX files carries its own copy of the armature — one is kept and the meshes are
repointed at it, otherwise `join` leaves three meshes bound to deleted armatures;
2. the FBX rig adds zero-length `_end` leaf bones and a `neutral_bone` root; Godot's importer fails
the exported GLB with them (`resource_format_binary` error), so they are stripped back to the
accepted 62 joints;
3. the object transforms are baked before export, or the glTF node transforms change the rest pose
on import and the character renders with a giant head;
4. the pack reuses one fabric material for jacket and trousers, so the leg meshes get their own
`*_legs` copies that the slot map sends to `pants`;
5. imported parts share seams, which leaves zero-area triangles the audit rejects.
The pack itself is never committed. Only the composed bases are, with their SHA-256 here and in
`CREDITS.md`.
| Base | Source family | SHA-256 | Used by |
| --- | --- | --- | --- |
| `Suit.glb` | Suit | `674f2a63…` | `npc_coat` |
## Contract mismatch ## Contract mismatch
The expensive part is the rig and proportions, not the mesh. The source has a The expensive part is the rig and proportions, not the mesh. The source has a
+5
View File
@@ -413,6 +413,11 @@ func _build_skinned_body(npc_type: int) -> bool:
"npc_vest": "npc_vest":
# The hi-vis vest is a fixed material; cloth is the undershirt. # The hi-vis vest is a fixed material; cloth is the undershirt.
pass pass
"npc_coat":
# The Suit base is the only formal outfit in the roster; the seeded
# street palette turns it pink, so it keeps a dark suit tone.
cloth = Color(0.13, 0.15, 0.20)
pants = Color(0.11, 0.12, 0.15)
"npc_firefighter": "npc_firefighter":
cloth = Color(0.16, 0.17, 0.20) cloth = Color(0.16, 0.17, 0.20)
pants = Color(0.13, 0.14, 0.17) pants = Color(0.13, 0.14, 0.17)
+14 -2
View File
@@ -97,7 +97,7 @@ CHARACTERS = {
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"], "npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"base": "casual", "base": "casual",
"roles": ["authorized_staff", "protester", "guest"]}, "roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie", "npc_coat": {"layer": "coat", "hair": "short", "base": "suit",
"gear": ["sunglasses", "shoulder_bag", "phone"], "gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]}, "roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["clipboard"], "npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["clipboard"],
@@ -1513,6 +1513,14 @@ QUATERNIUS_SOURCES = {
"Eye": "keep", "Eyebrows": "keep"}, "Eye": "keep", "Eyebrows": "keep"},
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)", "notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
}, },
"suit": {
"file": "Suit.glb",
"slots": {"Skin": "skin", "Suit": "cloth", "Suit_legs": "pants",
"Black": "accessory",
"Hair": "hair",
"Tie": "keep", "White": "keep", "Eye": "keep", "Eyebrows": "keep"},
"notes": "formal suit: jacket+trousers one fabric, white shirt and tie as fixed detail",
},
"worker": { "worker": {
"file": "Worker.glb", "file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants", "slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
@@ -2083,7 +2091,11 @@ def _quaternius_slot_map(obj, slot_map):
index_of_slot = {} index_of_slot = {}
resolved = [] resolved = []
for name, z in zip(source_names, centroids): for name, z in zip(source_names, centroids):
rule = slot_map.get(name, "keep") # Imported parts carry `.001` duplicates of the base material; fall back to the
# base name so those still reach their slot instead of staying untinted.
rule = slot_map.get(name)
if rule is None:
rule = slot_map.get(_base_name(name), "keep")
if isinstance(rule, dict) and rule.get("split"): if isinstance(rule, dict) and rule.get("split"):
slot = rule["above"] if z >= float(rule["z"]) else rule["below"] slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
key = ("keep", name) if slot == "keep" else ("slot", slot) key = ("keep", name) if slot == "keep" else ("slot", slot)
+159 -1
View File
@@ -117,10 +117,28 @@ def inventory_file(path: Path):
} }
def inventory_fbx(files):
"""The pack ships FBX parts; name-level listing, detail comes from Blender."""
families = {}
for path in files:
family = path.parent.name
families.setdefault(family, []).append(path.stem)
print(f"{'outfit family':24s} parts")
for family in sorted(families):
print(f"{family[:24]:24s} {', '.join(sorted(families[family]))}")
print(f"\n{len(files)} FBX parts in {len(families)} families.")
print("Next: `extract --file <fbx> --mesh <name> --part <part>`; the mesh name is the "
"node name inside the FBX (check with `--list`).")
return 0
def inventory(root: Path): def inventory(root: Path):
fbx = sorted(p for p in root.rglob("*.fbx"))
files = sorted(p for p in root.rglob("*.glb")) files = sorted(p for p in root.rglob("*.glb"))
if not files and fbx:
return inventory_fbx(fbx)
if not files: if not files:
print(f"no .glb under {root}\n" print(f"no .glb or .fbx under {root}\n"
f" download the pack into {INTAKE.relative_to(ROOT)}/ first " f" download the pack into {INTAKE.relative_to(ROOT)}/ first "
f"(see TODO.md, 'Outfit part library')") f"(see TODO.md, 'Outfit part library')")
return 1 return 1
@@ -175,6 +193,9 @@ def extract(source: Path, mesh_name: str, part: str, out_dir: Path):
file=sys.stderr) file=sys.stderr)
return 2 return 2
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
if source.suffix.lower() == ".fbx":
bpy.ops.import_scene.fbx(filepath=str(source))
else:
bpy.ops.import_scene.gltf(filepath=str(source)) bpy.ops.import_scene.gltf(filepath=str(source))
target = None target = None
for obj in bpy.context.scene.objects: for obj in bpy.context.scene.objects:
@@ -215,6 +236,138 @@ def extract(source: Path, mesh_name: str, part: str, out_dir: Path):
return 0 return 0
def _base_name(name: str) -> str:
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
return head if sep and tail.isdigit() else name
def make_base(family_dir: Path, out: Path):
"""Compose one outfit family from its FBX parts into a single skinned GLB base.
The pack ships each outfit as four FBX files (Body/Legs/Feet/Head) that share one
skeleton. Each file carries its own copy of that armature, so this keeps one and
repoints every mesh at it before joining — otherwise `join` would leave three
meshes bound to armatures that no longer exist.
"""
try:
import bpy # type: ignore
import bmesh # type: ignore
except ImportError:
print("make-base must run inside Blender", file=sys.stderr)
return 2
parts = sorted(family_dir.glob("*.fbx"))
if not parts:
print(f"no FBX under {family_dir}")
return 1
bpy.ops.wm.read_factory_settings(use_empty=True)
for path in parts:
bpy.ops.import_scene.fbx(filepath=str(path))
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not armatures or not meshes:
print(f"{family_dir.name}: import produced no armature/mesh")
return 1
keep = armatures[0]
for extra in armatures[1:]:
bpy.data.objects.remove(extra, do_unlink=True)
# FBX adds artefacts the runtime rig does not have: zero-length `_end` leaf bones
# and a `neutral_bone` root. Godot's importer failed on the exported file with them
# (`resource_format_binary` error), and the accepted rig is the 62 joints only.
bpy.context.view_layer.objects.active = keep
bpy.ops.object.mode_set(mode="EDIT")
for bone in list(keep.data.edit_bones):
if bone.name.endswith("_end") or bone.name == "neutral_bone":
for child in list(bone.children):
child.parent = bone.parent
child.use_connect = False
keep.data.edit_bones.remove(bone)
bpy.ops.object.mode_set(mode="OBJECT")
for mesh in meshes:
mesh.parent = keep
mesh.matrix_parent_inverse.identity()
for modifier in list(mesh.modifiers):
if modifier.type == "ARMATURE":
mesh.modifiers.remove(modifier)
modifier = mesh.modifiers.new("Armature", "ARMATURE")
modifier.object = keep
# The pack reuses one fabric material for jacket and trousers, so the joined base
# would have no `pants` surface. Give the leg meshes their own material copies;
# the slot map in blender_assets.py then maps those to `pants`.
leg_copies = {}
for mesh in meshes:
if "Legs" not in mesh.name:
continue
for slot, material in enumerate(mesh.data.materials):
if material is None:
continue
# `Suit` and `Suit.001` are the same fabric arriving from two FBX files;
# normalise the name so every leg mesh shares one `Suit_legs` material.
base = _base_name(material.name)
if base not in leg_copies:
copy = material.copy()
copy.name = f"{base}_legs"
leg_copies[base] = copy
mesh.data.materials[slot] = leg_copies[base]
for obj in bpy.context.selected_objects:
obj.select_set(False)
for mesh in meshes:
mesh.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
if len(meshes) > 1:
bpy.ops.object.join()
body = bpy.context.object
body.name = "npc_base"
body.data.name = "npc_base"
# Imported FBX parts share seams, which leaves zero-area triangles that the audit
# rejects (and Godot warns about during LOD generation). Dissolve and drop them
# after the join: nothing that small carries a meaningful weight.
bm = bmesh.new()
bm.from_mesh(body.data)
bmesh.ops.dissolve_degenerate(bm, dist=2e-4, edges=bm.edges)
bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY",
ngon_method="BEAUTY")
bad = [f for f in bm.faces if f.calc_area() < 1e-10]
if bad:
bmesh.ops.delete(bm, geom=bad, context="FACES_ONLY")
bm.to_mesh(body.data)
bm.free()
# The FBX rig comes in at the pack's own scale; normalise to the 1.81 m the roster
# uses so gear attach points and the pose maths stay valid.
evaluated = body.evaluated_get(bpy.context.evaluated_depsgraph_get())
zs = [(body.matrix_world @ v.co).z for v in evaluated.data.vertices]
height = max(zs) - min(zs)
scale = 1.81 / height if height > 0 else 1.0
if abs(scale - 1.0) > 0.02:
keep.scale = [component * scale for component in keep.scale]
bpy.context.view_layer.update()
print(f" scaled {family_dir.name} by {scale:.3f} (was {height:.3f} m)")
# Bake the armature and mesh object transforms into their data. An FBX import
# keeps a scale/rotation on the objects, and the glTF exporter then writes node
# transforms that change the rest pose on import (Godot rendered the head at
# several times its size before this).
for obj in bpy.context.selected_objects:
obj.select_set(False)
for obj in (keep, body):
obj.select_set(True)
bpy.context.view_layer.objects.active = keep
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
out.parent.mkdir(parents=True, exist_ok=True)
for obj in bpy.context.scene.objects:
obj.select_set(obj is body or obj is keep)
bpy.context.view_layer.objects.active = body
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB",
use_selection=True, export_apply=False,
export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT")
print(f"wrote {out.relative_to(ROOT)} ({len(body.data.vertices)} verts, "
f"{len(keep.data.bones)} bones)")
return 0
def main(argv=None): def main(argv=None):
parser = argparse.ArgumentParser(description="Quaternius modular part intake") parser = argparse.ArgumentParser(description="Quaternius modular part intake")
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
@@ -227,6 +380,9 @@ def main(argv=None):
ext.add_argument("--mesh", required=True) ext.add_argument("--mesh", required=True)
ext.add_argument("--part", required=True) ext.add_argument("--part", required=True)
ext.add_argument("--out", default=str(PARTS)) ext.add_argument("--out", default=str(PARTS))
base = sub.add_parser("make-base", help="compose one outfit family as a base GLB")
base.add_argument("--family", required=True, help="folder under the pack's Separate Skeletal Meshes")
base.add_argument("--out", required=True)
argv = list(argv if argv is not None else sys.argv[1:]) argv = list(argv if argv is not None else sys.argv[1:])
if "--" in argv: # Blender hands the script path over before its own `--` if "--" in argv: # Blender hands the script path over before its own `--`
argv = argv[argv.index("--") + 1:] argv = argv[argv.index("--") + 1:]
@@ -235,6 +391,8 @@ def main(argv=None):
return inventory(Path(args.dir)) return inventory(Path(args.dir))
if args.cmd == "verify": if args.cmd == "verify":
return verify(Path(args.dir)) return verify(Path(args.dir))
if args.cmd == "make-base":
return make_base(Path(args.family), Path(args.out))
return extract(Path(args.file), args.mesh, args.part, Path(args.out)) return extract(Path(args.file), args.mesh, args.part, Path(args.out))
+5 -3
View File
@@ -60,13 +60,15 @@ class BuiltAssetTests(unittest.TestCase):
# (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is # (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is
# authored for the shipped roster, so the ratio checks below are what # authored for the shipped roster, so the ratio checks below are what
# must stay true; the absolute numbers are a drift guard. # must stay true; the absolute numbers are a drift guard.
self.assertAlmostEqual(coat["reference"]["head_width"], 0.201, places=2) # npc_coat moved to the Suit base, whose head is 0.209 m wide; the ratio checks
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.19, places=3) # below are what must hold, the absolute numbers are a drift guard.
self.assertAlmostEqual(coat["reference"]["head_width"], 0.209, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.168, places=3)
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
self.assertEqual(sunglasses["attach"], "head") self.assertEqual(sunglasses["attach"], "head")
self.assertLessEqual(sunglasses["width"], self.assertLessEqual(sunglasses["width"],
0.70 * coat["reference"]["head_width"]) 0.70 * coat["reference"]["head_width"])
self.assertAlmostEqual(sunglasses["width_ratio"], 0.65, places=2) self.assertAlmostEqual(sunglasses["width_ratio"], 0.62, places=2)
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)