fix(hair): cards follow the scalp normal, three overlapping rows

The first card build anchored every card on a fixed outward vector, so the silhouette
was a spiked crown. Each card is now oriented from the scalp normal at its anchor: the
length runs along the downward tangent, the width across it, and a small tip lift gives
the cut volume. Three overlapping rows (7/9/10) replace the single ring; overlap is
what stops cards reading as separate spikes.

26 cards, 401 verts, 224 quads, 468 tris, still all quads on one head bone.

Recorded as still open: the skin renders darker than the baked diffuse suggests, and
the generated pore and strand textures remain unwired because the material-editing
tool segfaults in the headless commandlet.
This commit is contained in:
zwitschi committed 2026-11-09 13:25:40 +01:00
1 parent 39f07b0f5f
commit 1fc5835327
39 files changed
+84 -68

No files matched your search

+33 -21
View File
@@ -69,8 +69,14 @@ def hair_material() -> bpy.types.Material:
return mat
def card(name, anchor, outward, up, width, height, curl, segments=4):
"""One curved card: a strip of quads that arcs away from the scalp."""
def card(name, anchor, along, across, normal, width, height, lift, segments=4):
"""A card that lies ALONG the scalp rather than sticking out of it.
`along` is the direction the hair falls over the skull (the downward tangent at
the anchor), `across` the card's width direction and `normal` the scalp normal.
The first version passed a fixed global up and an outward vector, which made every
card stand away from the head - the spiked crown in the review shot.
"""
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
@@ -79,13 +85,11 @@ def card(name, anchor, outward, up, width, height, curl, segments=4):
faces = []
for i in range(segments + 1):
t = i / segments
# Taper to a point at the tip and curl outward as the strand falls.
w = width * (1.0 - 0.75 * t)
h = height * t
bulge = curl * math.sin(t * math.pi * 0.85)
base = anchor + up * h + outward * bulge
left = base - outward.cross(up).normalized() * w
right = base + outward.cross(up).normalized() * w
w = width * (1.0 - 0.65 * t)
# Follow the skull, then lift a little at the tip so the cut has volume.
base = anchor + along * (height * t) + normal * (lift * math.sin(t * math.pi * 0.7))
left = base - across * w
right = base + across * w
verts.append(left)
verts.append(right)
for i in range(segments):
@@ -133,21 +137,29 @@ def main(argv=None) -> int:
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
up = Vector((0.0, 0.0, 1.0))
rings = ((CARDS_INNER, 1.00, 0.70), (CARDS_MIDDLE, 0.90, 0.85), (CARDS_OUTER, 0.78, 1.0))
down = Vector((0.0, 0.0, -1.0))
# Two rows: an inner row that covers the crown and an outer row that overlaps it
# down to the hairline. Overlap is what stops the cards reading as separate spikes.
rings = (
# count, radius scale, anchor height, length, width, lift
(7, 0.55, 0.80, 0.085, 0.30, 0.25),
(9, 0.72, 0.62, 0.070, 0.26, 0.18),
(10, 0.86, 0.42, 0.055, 0.24, 0.12),
)
index = 0
for ring, (count, radius_scale, length_scale) in enumerate(rings):
for ring, (count, radius_scale, height_scale, length, width_scale, lift_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
outward = Vector((math.cos(angle), math.sin(angle), 0.22)).normalized()
anchor = Vector((
centre.x + outward.x * radius * radius_scale * 0.92,
centre.y + outward.y * radius * radius_scale * 0.92,
centre.z + radius * (0.52 - 0.16 * ring),
))
pieces.append(card(f"HairCard_{index}", anchor, outward, up,
width=radius * 0.30, height=CARD_HEIGHT * length_scale,
curl=radius * 0.34))
horizontal = Vector((math.cos(angle), math.sin(angle), 0.0))
normal = (horizontal * radius_scale + Vector((0.0, 0.0, height_scale))).normalized()
anchor = Vector((centre.x, centre.y, centre.z)) + normal * radius
# Downward tangent: the part of -Z that lies in the scalp's tangent plane.
along = (down - normal * down.dot(normal)).normalized()
across = along.cross(normal).normalized()
pieces.append(card(f"HairCard_{index}", anchor, along, across, normal,
width=radius * width_scale,
height=radius * length * 10.0,
lift=radius * lift_scale))
index += 1
log(f"built {len(pieces)} pieces: 1 scalp cap + {index} cards")