reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena

REGISTRY rewritten around the central rule: a character folder is born only
when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal).
lena_nude dissolves accordingly:
- characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier,
  T-pose/rig FBX + JSON, previews, frozen README
- characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47:
  refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC),
  masters (athletic_v04 blend + textures, accurig blend), lane-history README
- hires_claude/hires_work intermediates (blends, logs, probes) pruned

Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum,
originals README, prune_lane.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 07:17:15 -07:00
parent 4bbd1a1732
commit 3d8825f5a9
263 changed files with 3262 additions and 26313 deletions
+152
View File
@@ -0,0 +1,152 @@
# Stage 18: is the line network SHADING (custom split normals) rather than shape?
#
# blender --background --python 18_normal_probe.py -- <in.blend> <out.blend> <review_dir>
#
# THE SUSPICION. Every stage from 11 to 17 moved vertices along the seam network and the lines
# did not change; 17's membrane moved 12.6k verts for no visible difference, and 16c proved there
# is no crack to weld. What survives vertex movement is SHADING: this mesh carries custom split
# normals from the Tripo import (has_custom_normals=True). If those baked normals encode the scan
# panel borders as creases, the lines are painted into the normals and geometry work cannot touch
# them — which would explain the whole failure streak at once, including why the lines appear in
# clay renders (clay swaps the MATERIAL, so no normal map is involved, but custom split normals
# belong to the MESH and survive the swap).
#
# Note every prior stage ended with normals_split_custom_set_from_vertices(...), which SHOULD have
# smoothed them. This probe measures whether that actually took effect, then clears the custom
# normals outright and re-renders. Measurement first, then the change, so we learn which it was.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[nrm {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
n_l = len(me.loops)
log(f"{ob.name}: {n_v}v {len(me.polygons)}f {n_l} loops "
f"has_custom_normals={me.has_custom_normals}")
flat = np.empty(len(me.polygons), dtype=bool)
me.polygons.foreach_get("use_smooth", flat)
log(f"flat-shaded polygons: {int((~flat).sum())} of {len(flat)}")
# ---- how far do the corner normals deviate from the smooth vertex normals? ----
vn = np.empty(n_v * 3)
me.vertices.foreach_get("normal", vn)
vn = vn.reshape(-1, 3)
lv = np.empty(n_l, dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
cn = np.empty(n_l * 3)
try:
me.corner_normals.foreach_get("vector", cn)
cn = cn.reshape(-1, 3)
dot = np.clip((cn * vn[lv]).sum(axis=1), -1, 1)
dev = np.degrees(np.arccos(dot))
print(f"CORNER-vs-VERTEX normal deviation: mean {dev.mean():.3f}deg "
f"p50 {np.percentile(dev,50):.3f} p95 {np.percentile(dev,95):.3f} "
f"p99.9 {np.percentile(dev,99.9):.3f} max {dev.max():.3f}")
print(f" loops deviating >5deg: {int((dev>5).sum())} ({100.0*(dev>5).mean():.3f}%)")
print(f" loops deviating >15deg: {int((dev>15).sum())} ({100.0*(dev>15).mean():.3f}%)")
# per-vertex spread between its own corner normals = a shading crease at that vertex
spread = np.zeros(n_v)
np.maximum.at(spread, lv, dev)
torso = None
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
print(f" torso verts whose corner normals deviate >10deg from smooth: "
f"{int((torso & (spread > 10)).sum())}")
except Exception as ex:
log(f"corner_normals read failed: {ex}")
# =============================================================================
# clear the custom split normals and force smooth shading
# =============================================================================
had = me.has_custom_normals
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
cleared = False
try:
bpy.ops.mesh.customdata_custom_splitnormals_clear()
cleared = True
except Exception as ex:
log(f"operator clear failed: {ex}")
if not cleared:
for nm in ("custom_normal",):
if nm in me.attributes:
me.attributes.remove(me.attributes[nm])
cleared = True
log(f"removed attribute '{nm}'")
sm = np.ones(len(me.polygons), dtype=bool)
me.polygons.foreach_set("use_smooth", sm)
me.update()
log(f"custom normals: had={had} now has_custom_normals={me.has_custom_normals} cleared={cleared}")
# =============================================================================
# clay renders, same framing as 04_review so they compare 1:1
# =============================================================================
scn = bpy.context.scene
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
scn.world = w
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.0
fill.data.use_shadow = False
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, use_clay):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
HIP = (0.0, 0.0, 0.53)
shoot("chest_clay_0", CHEST, 0.22, 0, True)
shoot("chest_clay_40", CHEST, 0.22, 40, True)
shoot("chest_tex_0", CHEST, 0.22, 0, False)
shoot("hip_clay_0", HIP, 0.22, 0, True)
shoot("full_clay_0", FULL, 0.55, 0, True)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("NRM_DONE")