diff --git a/TODO.md b/TODO.md index bd7ef00..68a50da 100644 --- a/TODO.md +++ b/TODO.md @@ -97,8 +97,15 @@ the rest of §C remains the broad review checklist. and 1.5 m, front/three-quarter/profile. Vision-judged: **s48 wins** — s24 is too faceted (nose/lips melt, jagged silhouette), s64 is visually identical to s48 at both distances (extra ~900 verts wasted). Rejected variants stay in `tmp/npc-parts/`. -- [ ] Verify head weights stay on the Head bone, headgear clearance still passes `fit`, and the +- [-] Verify head weights stay on the Head bone, headgear clearance still passes `fit`, and the face does not bake recessed creases into dark AO patches. + Weight check done: `npc_parts.py assemble --head-proto` passes `inspect` (npc_base, 14 bones, + skinned, no issues) and the head join is weighted 100% Head by construction + (`rigid=lambda p: "Head"`). Headgear clearance and the AO no-blotch check are pending the + §5 rollout (the `fit`/`bake_ao` tools read the committed assets; the ad-hoc AO bake on the + imported GLB came out black and is superseded by the proper `.blend`-source bake). Flag: the + prototype ears reach x ±0.162 vs the 0.1575 head-radius reference, so headgear may clip and + must be re-measured at rollout. - [ ] Get owner sign-off on the one-variant prototype, then roll the approved head/face into the other 11 variants one asset family at a time, with a before/after sheet per batch. diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 515983f..cb45421 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -855,6 +855,116 @@ def _face(mats, spec): return parts +def _head_proto(mats, spec, subdiv=48): + """Connected head shell with sculpted sockets, nose, lips, ears and brow/cheek/jaw. + + The model-overhaul prototype: the skin features are displaced out of one + head mesh instead of floating spheres, so the face is a single connected + surface with loop-based eye sockets and protruding nose/lip/ear volumes. The + eyeballs (sclera/iris/lids) stay separate — they are not skin. Blender Z-up, + face front −Y. `subdiv` is the UV-sphere u-segments (24/48/64 variants); s48 + is the chosen default after the 1.5 m/1.85 m comparison. + """ + skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] + white = material("eye_white", textured_solid=True) + rings = max(14, subdiv // 2 + 2) + head = sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=subdiv, rings=rings, + scale=(0.90, 0.96, 1.08)) + _shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes + bm = bmesh.new() + bm.from_mesh(head.data) + + # Pass 1: skull shape — flatten the face plane and taper the jaw/chin. + for v in bm.verts: + x, y, z = v.co + if y < -0.03 and 1.44 < z < 1.76: + v.co.y = -0.13 + (y + 0.13) * 0.10 # flatten the forward bulge + if y < -0.03 and z > 1.70: + v.co.y += (z - 1.70) * 0.35 # forehead slopes back to the crown + if z < 1.58: + taper = max(0.0, 1.0 - (1.58 - z) / 0.12) + v.co.x *= 1.0 - 0.16 * taper # narrow the lower face + + # Pass 2: features on the flattened face. + for v in bm.verts: + x, y, z = v.co + ax = abs(x) + # --- brow step --- + brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.022) + * max(0.0, 1.0 - abs(ax - 0.050) / 0.045)) + if brow > 0: + v.co.y -= 0.016 * brow + # --- eye sockets: recess the floor, leave a raised orbital rim --- + for s in (-1.0, 1.0): + r = math.hypot(x - 0.052 * s, z - 1.664) + if r < 0.036: + v.co.y += 0.018 * (1.0 - r / 0.036) + elif r < 0.046: + v.co.y -= 0.010 * (1.0 - (r - 0.036) / 0.010) + # --- nose: a wide bridge/tip with ala wings --- + if y < 0 and ax < 0.075: + band = max(0.0, 1.0 - abs(z - 1.632) / 0.055) + if band > 0: + tip = max(0.0, 1.0 - abs(z - 1.615) / 0.032) + v.co.y -= (0.060 * tip + 0.018 * max(0.0, band - tip)) \ + * (1.0 - ax / 0.075) + for s in (-1.0, 1.0): + ar = math.hypot((x - 0.024 * s) / 0.014, (z - 1.604) / 0.013) + if ar < 1.0: + v.co.y -= 0.022 * (1.0 - ar) + # --- chin bump + jaw step --- + if y < 0 and ax < 0.040: + chin = max(0.0, 1.0 - abs(z - 1.548) / 0.022) + if chin > 0: + v.co.y -= 0.030 * chin + step = max(0.0, 1.0 - abs(z - 1.516) / 0.016) + if step > 0: + v.co.y += 0.024 * step + # --- mouth: recessed line + upper/lower lip volumes --- + if y < 0 and ax < 0.030: + if abs(z - 1.583) < 0.012: + v.co.y += 0.008 + if abs(z - 1.590) < 0.008: + v.co.y -= 0.014 + if abs(z - 1.577) < 0.010: + v.co.y -= 0.018 + # --- ears: helix rim + concha recess + a lobe --- + for v in bm.verts: + x, y, z = v.co + r = math.hypot((z - 1.60) / 0.055, (y - 0.002) / 0.045) + near = abs(x) - 0.138 + sign = 1.0 if x >= 0 else -1.0 + if -0.02 < near < 0.06 and r < 1.0: + if r < 0.30: + v.co.x -= 0.018 * (1.0 - r / 0.30) * sign + else: + v.co.x += 0.045 * (1.0 - r) * sign + lr = math.hypot((z - 1.575) / 0.016, (y + 0.012) / 0.016) + if -0.02 < near < 0.05 and lr < 1.0: + v.co.x += 0.032 * (1.0 - lr) * sign + bm.normal_update() + bm.to_mesh(head.data) + bm.free() + + # Eyeballs stay separate for readability; lids wrap the socket rim. + parts = [head] + for s in (-1.0, 1.0): + ex, ey, ez = 0.052 * s, -0.134, 1.664 + parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, + rings=6, scale=(1.25, 0.62, 0.85))) + parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, + rings=5, scale=(1.0, 0.55, 1.0))) + parts.append(sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), + skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), + rot=(math.radians(-10), 0, 0))) + parts.append(sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), + skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), + rot=(math.radians(6), 0, 0))) + parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), + hair, rot=(0, 0.14 * s, 0.0))) + return parts + + def _hands_and_shoes(mats, spec): skin = mats["skin"] shoe = mats["accessory"] @@ -986,7 +1096,7 @@ def _clothing(spec, mats): return parts -def build_character(name): +def build_character(name, head_proto=False): spec = dict(CHARACTERS[name]) if name == "npc_hoodie": spec["beard"] = True @@ -1001,7 +1111,7 @@ def build_character(name): _assign_body_slots(body, spec, body_mats) _auto_weights(body, layout) - head_parts = _face(mats, spec) + head_parts = _head_proto(mats, spec) if head_proto else _face(mats, spec) hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz" head_parts += _hair(hair_style, mats["hair"]) head = join(head_parts, "head") diff --git a/tools/npc_parts.py b/tools/npc_parts.py index 7b6a40f..f3f121c 100644 --- a/tools/npc_parts.py +++ b/tools/npc_parts.py @@ -93,118 +93,6 @@ def _export_obj(obj, path): return path -def _head_proto(mats, spec, subdiv=48): - """Connected head shell with sculpted sockets, nose, lips, ears and brow/cheek/jaw. - - This is the §3 prototype: the skin features are displaced out of one head - mesh instead of floating spheres, so the face is a single connected surface - with loop-based eye sockets and protruding nose/lip/ear volumes. The eyeballs - (sclera/iris/lids) stay separate — they are not skin. Blender Z-up, face - front −Y. `subdiv` is the UV-sphere u-segments (24/48/64 variants). - """ - skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] - white = ba.material("eye_white", textured_solid=True) - rings = max(14, subdiv // 2 + 2) - head = ba.sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=subdiv, rings=rings, - scale=(0.90, 0.96, 1.08)) - ba._shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes - bm = ba.bmesh.new() - bm.from_mesh(head.data) - - # Pass 1: skull shape — flatten the face plane and taper the jaw/chin so the - # head stops reading as an egg. - for v in bm.verts: - x, y, z = v.co - if y < -0.03 and 1.44 < z < 1.76: - v.co.y = -0.13 + (y + 0.13) * 0.10 # flatten the forward bulge - if y < -0.03 and z > 1.70: - v.co.y += (z - 1.70) * 0.35 # forehead slopes back to the crown - if z < 1.58: - taper = max(0.0, 1.0 - (1.58 - z) / 0.12) - v.co.x *= 1.0 - 0.16 * taper # narrow the lower face - - # Pass 2: features on the flattened face. - for v in bm.verts: - x, y, z = v.co - ax = abs(x) - # --- brow step: a clear ridge where the forehead meets the nose --- - brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.022) - * max(0.0, 1.0 - abs(ax - 0.050) / 0.045)) - if brow > 0: - v.co.y -= 0.016 * brow - # --- eye sockets: recess the floor, leave a raised orbital rim --- - for s in (-1.0, 1.0): - r = math.hypot(x - 0.052 * s, z - 1.664) - if r < 0.036: - v.co.y += 0.018 * (1.0 - r / 0.036) # recess inward (+y) - elif r < 0.046: - v.co.y -= 0.010 * (1.0 - (r - 0.036) / 0.010) # rim forward - # --- nose: a wide bridge/tip with ala wings, not a spike --- - if y < 0 and ax < 0.075: - band = max(0.0, 1.0 - abs(z - 1.632) / 0.055) # 1.577..1.687 - if band > 0: - tip = max(0.0, 1.0 - abs(z - 1.615) / 0.032) - v.co.y -= (0.060 * tip + 0.018 * max(0.0, band - tip)) \ - * (1.0 - ax / 0.075) - for s in (-1.0, 1.0): - ar = math.hypot((x - 0.024 * s) / 0.014, (z - 1.604) / 0.013) - if ar < 1.0: - v.co.y -= 0.022 * (1.0 - ar) - # --- chin: a distinct forward bump below the mouth --- - if y < 0 and ax < 0.040: - chin = max(0.0, 1.0 - abs(z - 1.548) / 0.022) - if chin > 0: - v.co.y -= 0.030 * chin - # jaw step: recess just below the chin so it reads as a chin-to-neck - step = max(0.0, 1.0 - abs(z - 1.516) / 0.016) - if step > 0: - v.co.y += 0.024 * step - # --- mouth: recessed line + upper/lower lip volumes --- - if y < 0 and ax < 0.030: - if abs(z - 1.583) < 0.012: - v.co.y += 0.008 # mouth slit recess - if abs(z - 1.590) < 0.008: - v.co.y -= 0.014 # upper lip - if abs(z - 1.577) < 0.010: - v.co.y -= 0.018 # fuller lower lip - # --- ears: helix rim + concha recess + a lobe --- - for v in bm.verts: - x, y, z = v.co - r = math.hypot((z - 1.60) / 0.055, (y - 0.002) / 0.045) - near = abs(x) - 0.138 - sign = 1.0 if x >= 0 else -1.0 - if -0.02 < near < 0.06 and r < 1.0: - if r < 0.30: - v.co.x -= 0.018 * (1.0 - r / 0.30) * sign # concha recess (inward) - else: - v.co.x += 0.045 * (1.0 - r) * sign # helix rim outward - # lobe: a distinct bump at the lower front of the ear - lr = math.hypot((z - 1.575) / 0.016, (y + 0.012) / 0.016) - if -0.02 < near < 0.05 and lr < 1.0: - v.co.x += 0.032 * (1.0 - lr) * sign - bm.normal_update() - bm.to_mesh(head.data) - bm.free() - - # Eyeballs stay separate for readability; lids wrap the socket rim. - parts = [head] - for s in (-1.0, 1.0): - ex, ey, ez = 0.052 * s, -0.134, 1.664 - parts.append(ba.sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, - rings=6, scale=(1.25, 0.62, 0.85))) - parts.append(ba.sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, - rings=5, scale=(1.0, 0.55, 1.0))) - parts.append(ba.sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), - skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), - rot=(math.radians(-10), 0, 0))) - parts.append(ba.sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), - skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), - rot=(math.radians(6), 0, 0))) - parts.append(ba.box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), - hair, rot=(0, 0.14 * s, 0.0))) - return parts - - def build_part(name, variant, out, subdiv=48): """Build one part fresh from the current builders (Blender-only).""" mats, spec = _mats_and_spec(variant) @@ -215,7 +103,7 @@ def build_part(name, variant, out, subdiv=48): elif name == "face": obj = ba.join(ba._face(mats, spec), "face") elif name == "head_proto": - obj = ba.join(_head_proto(mats, spec, subdiv=subdiv), "head_proto") + obj = ba.join(ba._head_proto(mats, spec, subdiv=subdiv), "head_proto") else: side = -1.0 if name == "hand_l" else 1.0 hs = ba._hands_and_shoes(mats, spec) @@ -271,13 +159,14 @@ def extract_part(name, variant, out): return _extract_group(mesh, group, out) -def assemble(variant, out): +def assemble(variant, out, head_proto=False): """Assemble a variant (currently rebuilds the procedural character). This proves the end-to-end wiring; once parts are approved, this swaps the - new part GLBs in instead of calling ``build_character`` directly. + new part GLBs in instead of calling ``build_character`` directly. Pass + ``head_proto=True`` to build with the connected-shell prototype head. """ - objs = ba.build_character(variant) + objs = ba.build_character(variant, head_proto=head_proto) mesh = next(o for o in objs if o.type == "MESH") arm = next(o for o in objs if o.type == "ARMATURE") out = Path(out) @@ -385,6 +274,8 @@ def main(argv=None): asm = sub.add_parser("assemble") asm.add_argument("--variant", default=PROTOTYPE) asm.add_argument("--out", required=True) + asm.add_argument("--head-proto", action="store_true", + help="use the connected-shell prototype head") insp = sub.add_parser("inspect") insp.add_argument("--file", required=True) @@ -420,7 +311,7 @@ def main(argv=None): extract_part(args.part, args.variant, args.out) print(f"extracted {args.part} -> {args.out}") elif args.cmd == "assemble": - assemble(args.variant, args.out) + assemble(args.variant, args.out, head_proto=args.head_proto) print(f"assembled {args.variant} -> {args.out}") elif args.cmd == "inspect": inspect(args.file)