Files
jeremy eae8327811 feat(characters): ship lena_leafbikini_base_v01 as female Ariki default
Lena with the leaf bikini sculpted into the mesh — a separate character
descending from her own Tripo original, not a lena_base_v02. Full-res
(1,029,360 v) at 1.777 m, now Ariki_Female_QuatSkin.glb + LOD1.

First nearest-surface graft in the rig lane: AccuRig had to be fed a 20:1
decimated bait, so the index-exact graft the two earlier ships used was
impossible. The bait recovers AccuRig's 9.346 mm offset in closed form, which
makes it a required input forever — copied into the ship folder, and rig-work's
"disposable" rule amended to say so. Mesh moved 0 mm, 0 unweighted verts.

lena_base_v01 goes superseded, not rolled-back: she lost the default slot but
Ariki_Female_QuatSkin_Nude.glb is untouched, so per rule 9 her folder stays put.

Also lands the leaf-cut lane (work/lena_leafbikini): hue-keyed seam detection
that removes the leaf shell and leaves both holes open by request, registered
as a lane milestone with its sha256. Corrects the rig-work trap note — baits do
carry embedded textures; it is AccuRig that strips them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:09:45 -07:00

103 lines
4.0 KiB
Python

# lena_leafbikini lane, stage 03: look at her. Textured AND clay, full and close.
#
# blender --background --factory-startup --python 03_render_leaves.py -- <mesh.glb|.blend> <outdir> [tag]
#
# The clay pass is the whole point (same reasoning as tools/_render_body_closeup.py --clay):
# a painted leaf and a sculpted leaf are indistinguishable in a textured render, and they need
# completely different removal methods. Whatever survives with all materials stripped is
# geometry; whatever vanishes was paint.
#
# Framing is in FRACTIONS OF BODY HEIGHT, not metres: the Tripo original is 0.979 units tall,
# not 1.777, so tools/_render_body_closeup.py's metre bands (torso = 1.00..1.52) would frame
# empty space above her head.
import bpy, sys, os, math
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, OUT = os.path.abspath(argv[0]), os.path.abspath(argv[1])
TAG = argv[2] if len(argv) > 2 else "leaf"
os.makedirs(OUT, exist_ok=True)
# name -> (z_lo, z_hi as fraction of body height, yaw degrees)
VIEWS = [
("full_front", 0.00, 1.00, 0),
("full_back", 0.00, 1.00, 180),
("chest_front", 0.60, 0.82, 0),
("chest_34", 0.60, 0.82, 40),
("hip_front", 0.38, 0.60, 0),
("hip_34", 0.38, 0.60, 40),
]
RES = 1000
def load():
bpy.ops.wm.read_factory_settings(use_empty=True)
if SRC.lower().endswith(".blend"):
bpy.ops.wm.open_mainfile(filepath=SRC)
else:
bpy.ops.import_scene.gltf(filepath=SRC)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
body = max(meshes, key=lambda o: len(o.data.vertices))
for o in meshes:
o.hide_render = o is not body
return body
def render(body, clay):
if clay:
body.data.materials.clear()
m = bpy.data.materials.new("Clay")
m.use_nodes = True
b = m.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1.0)
b.inputs["Roughness"].default_value = 0.42
body.data.materials.append(m)
pts = [body.matrix_world @ v.co for v in body.data.vertices]
zmin = min(p.z for p in pts); zmax = max(p.z for p in pts)
H = zmax - zmin
w = bpy.data.worlds.new("W"); w.color = (0.20, 0.20, 0.22)
bpy.context.scene.world = w
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = RES
scn.render.film_transparent = False
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.2
bpy.context.collection.objects.link(fill)
for name, f_lo, f_hi, yawdeg in VIEWS:
z_lo, z_hi = zmin + f_lo * H, zmin + f_hi * H
band = [p for p in pts if z_lo <= p.z <= z_hi] or pts
xs = [p.x for p in band]
ctr = Vector((0.0, sum(p.y for p in band) / len(band), (z_lo + z_hi) / 2))
span = max(max(xs) - min(xs), z_hi - z_lo)
yaw = math.radians(yawdeg)
# closeups: ignore the T-pose arm span, frame the torso width only
if f_hi - f_lo < 0.5:
span = min(span, (z_hi - z_lo) * 1.25)
dist = span * 2.9
cam.location = ctr + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.0))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(60), 0, math.radians(35 + yawdeg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yawdeg - 110))
path = os.path.join(OUT, f"{TAG}_{name}_{'clay' if clay else 'tex'}.png")
scn.render.filepath = path
bpy.ops.render.render(write_still=True)
print("RENDER_OK", path, flush=True)
b = load()
render(b, clay=False)
b = load()
render(b, clay=True)