Move _head_proto into blender_assets.py and add assemble --head-proto; verify prototype weights
This commit is contained in:
1 parent
f0f609fe1b
commit
2a53ec9aac
3 files changed
+128
-120
No files matched your search
@@ -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
|
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
|
(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/`.
|
(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.
|
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
|
- [ ] 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.
|
other 11 variants one asset family at a time, with a before/after sheet per batch.
|
||||||
|
|
||||||
|
|||||||
+112
-2
@@ -855,6 +855,116 @@ def _face(mats, spec):
|
|||||||
return parts
|
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):
|
def _hands_and_shoes(mats, spec):
|
||||||
skin = mats["skin"]
|
skin = mats["skin"]
|
||||||
shoe = mats["accessory"]
|
shoe = mats["accessory"]
|
||||||
@@ -986,7 +1096,7 @@ def _clothing(spec, mats):
|
|||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def build_character(name):
|
def build_character(name, head_proto=False):
|
||||||
spec = dict(CHARACTERS[name])
|
spec = dict(CHARACTERS[name])
|
||||||
if name == "npc_hoodie":
|
if name == "npc_hoodie":
|
||||||
spec["beard"] = True
|
spec["beard"] = True
|
||||||
@@ -1001,7 +1111,7 @@ def build_character(name):
|
|||||||
_assign_body_slots(body, spec, body_mats)
|
_assign_body_slots(body, spec, body_mats)
|
||||||
_auto_weights(body, layout)
|
_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"
|
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
|
||||||
head_parts += _hair(hair_style, mats["hair"])
|
head_parts += _hair(hair_style, mats["hair"])
|
||||||
head = join(head_parts, "head")
|
head = join(head_parts, "head")
|
||||||
|
|||||||
+8
-117
@@ -93,118 +93,6 @@ def _export_obj(obj, path):
|
|||||||
return 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):
|
def build_part(name, variant, out, subdiv=48):
|
||||||
"""Build one part fresh from the current builders (Blender-only)."""
|
"""Build one part fresh from the current builders (Blender-only)."""
|
||||||
mats, spec = _mats_and_spec(variant)
|
mats, spec = _mats_and_spec(variant)
|
||||||
@@ -215,7 +103,7 @@ def build_part(name, variant, out, subdiv=48):
|
|||||||
elif name == "face":
|
elif name == "face":
|
||||||
obj = ba.join(ba._face(mats, spec), "face")
|
obj = ba.join(ba._face(mats, spec), "face")
|
||||||
elif name == "head_proto":
|
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:
|
else:
|
||||||
side = -1.0 if name == "hand_l" else 1.0
|
side = -1.0 if name == "hand_l" else 1.0
|
||||||
hs = ba._hands_and_shoes(mats, spec)
|
hs = ba._hands_and_shoes(mats, spec)
|
||||||
@@ -271,13 +159,14 @@ def extract_part(name, variant, out):
|
|||||||
return _extract_group(mesh, group, 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).
|
"""Assemble a variant (currently rebuilds the procedural character).
|
||||||
|
|
||||||
This proves the end-to-end wiring; once parts are approved, this swaps the
|
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")
|
mesh = next(o for o in objs if o.type == "MESH")
|
||||||
arm = next(o for o in objs if o.type == "ARMATURE")
|
arm = next(o for o in objs if o.type == "ARMATURE")
|
||||||
out = Path(out)
|
out = Path(out)
|
||||||
@@ -385,6 +274,8 @@ def main(argv=None):
|
|||||||
asm = sub.add_parser("assemble")
|
asm = sub.add_parser("assemble")
|
||||||
asm.add_argument("--variant", default=PROTOTYPE)
|
asm.add_argument("--variant", default=PROTOTYPE)
|
||||||
asm.add_argument("--out", required=True)
|
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 = sub.add_parser("inspect")
|
||||||
insp.add_argument("--file", required=True)
|
insp.add_argument("--file", required=True)
|
||||||
@@ -420,7 +311,7 @@ def main(argv=None):
|
|||||||
extract_part(args.part, args.variant, args.out)
|
extract_part(args.part, args.variant, args.out)
|
||||||
print(f"extracted {args.part} -> {args.out}")
|
print(f"extracted {args.part} -> {args.out}")
|
||||||
elif args.cmd == "assemble":
|
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}")
|
print(f"assembled {args.variant} -> {args.out}")
|
||||||
elif args.cmd == "inspect":
|
elif args.cmd == "inspect":
|
||||||
inspect(args.file)
|
inspect(args.file)
|
||||||
|
|||||||
Reference in new issue
Block a user