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
+291
View File
@@ -0,0 +1,291 @@
# Stage 17: heal the cut/scan lines — pinhole fill + thin-relief membrane. NO topology weld.
#
# blender --background --python 17_line_heal.py -- <in.blend> <out.blend> [rounds]
#
# WHAT THE MEASUREMENTS ACTUALLY SAY (16c_topology.py, and they contradict stages 15 + 17-weld)
# * The lines are NOT cracks. For kink verts the nearest vertex outside the 3-ring sits at
# 1.58x the local edge length (median); only 3.2% are closer than 0.35x, against 0.1% for
# control skin. There is no disconnected-panel network to weld. Welding on that false premise
# (eps 1.2 mm ~= the 1.85 mm mean edge length) merged ordinary neighbours and tore the mesh:
# 830 -> 24k boundary edges. Do not try it again.
# * The mesh is nearly closed: 830 boundary edges in 689 loops of 3-5 edges each — scattered
# PINHOLES, which is what the dotted black dashes in clay renders are. There is no sternum
# hole; that gash is a sharp crease (see 18_cleavage.py), not an opening.
# * The lines are thin GEOMETRIC relief: along kink verts |offset from the locally smooth
# surface| is 0.22 mm median / 1.55 mm p95, versus 0.05 mm on quiet skin.
#
# So: fill the pinholes per-loop (never as one edge net — one net is what produced 39k
# non-manifold edges), then remove the thin relief with a collar-fixed bi-harmonic membrane.
#
# WHY A MEMBRANE AND NOT SMOOTHING. Taubin (stage 11) sheds high frequencies but PRESERVES low
# ones, and a 1.5 mm ridge riding on a curved torso is not purely high-frequency — that is why
# 30 Taubin pairs left the lines legible. A bi-harmonic membrane with two fixed collar rings
# matches position AND slope at the band edge, so the broad shape (underbust fold, hip curve,
# clavicle) is reproduced exactly while the thin ridge inside is replaced by the interpolant.
#
# WHY THE MASK IS SELF-DETECTED. Stages 11/14/15 all chose their masks by mapping the raw glb's
# boundary verts onto the working mesh; by then the sculpt had moved those verts past the
# tolerance, so most of the line network was never in the mask — the membranes were solving over
# the wrong verts, which is the real reason "the lines survived every pass". Detection here runs
# on the CURRENT geometry: very-small-scale relief (4-ring) AND a shading kink. Broad anatomy has
# low relief at that scale by construction, so it is excluded automatically rather than by hand.
import bpy, bmesh, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ROUNDS = int(argv[2]) if len(argv) > 2 else 3
t0 = time.time()
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m
DEV_MM = 0.20 # thin-relief threshold, real mm
KINK_DEG = 8.0
GROW = 2 # band half-width, rings
COLLAR = 2 # fixed rings outside the band (position + slope BC)
Z_LO, Z_HI = 0.04, 0.90 # below the chin: the face keeps its own detail
X_MAX = 0.36 # exclude hands/wrists
PIN_MAX_EDGES = 8 # a "pinhole" — bigger openings are left for a human to look at
CLAMP_MM = 2.5 # cap per-vertex displacement, real mm
# WIDTH IS SETTLED — DO NOT WIDEN THE BAND. 19_wide_heal.py measured the seam cross-section: the
# disturbance is one vertex wide (|offset| 0.23 mm median at the centre, already at the 0.074 mm
# background level by ring 2). Trials at GROW=4 and GROW=8 were no better than this narrow band
# (kink>12deg 6674 and 6751 vs 6994) while moving material up to 38 mm. Narrow is correct.
#
# CLAMP_MM exists because the solver is free to do something dramatic wherever a band happens to
# span a real feature rather than a seam — the unclamped run made a 17 mm excursion. Seam relief
# tops out around 1.5 mm, so 2.5 mm is generous for the defect and still forbids reshaping her.
def log(m):
print(f"[line {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
log(f"in: {len(me.vertices)}v {len(me.polygons)}f")
# =============================================================================
# 1. pinhole fill — per loop
# =============================================================================
bm = bmesh.new()
bm.from_mesh(me)
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
eset = {e.index for e in open_e}
emap = {e.index: e for e in open_e}
from collections import deque
seen = set()
loops = []
for e in open_e:
if e.index in seen:
continue
q = deque([e.index])
seen.add(e.index)
comp = []
while q:
cur = emap[q.popleft()]
comp.append(cur)
for v in cur.verts:
for e2 in v.link_edges:
if e2.index in eset and e2.index not in seen:
seen.add(e2.index)
q.append(e2.index)
loops.append(comp)
sizes = sorted((len(lp) for lp in loops), reverse=True)
log(f"boundary loops: {len(loops)} (edges: {len(open_e)}, largest {sizes[:6]})")
bm.free()
# bmesh.ops.holes_fill silently refused every one of these loops (830 -> 839 boundary edges, no
# new faces), so use the edit-mode operator, which handles the small non-manifold fans these
# pinholes actually are.
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type='EDGE')
bpy.ops.mesh.select_non_manifold(extend=False, use_boundary=True, use_wire=True,
use_multi_face=False, use_non_contiguous=False, use_verts=False)
bpy.ops.mesh.fill_holes(sides=PIN_MAX_EDGES)
bpy.ops.object.mode_set(mode='OBJECT')
me.update()
bm = bmesh.new()
bm.from_mesh(me)
n_open_after = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm_after = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
bm.to_mesh(me)
me.update()
log(f"deleted {len(dgn)} degenerate faces")
bm.free()
log(f"after fill: {len(me.vertices)}v {len(me.polygons)}f "
f"boundary_edges={n_open_after} nonmanifold={n_nm_after}")
# =============================================================================
# 2. thin-relief membrane, re-detected each round
# =============================================================================
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
protect = navel
log(f"zone {int(zone.sum())} verts, protected {int(protect.sum())}")
def detect(P):
"""Thin relief + shading kink, both measured on P."""
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
dev = ((P - smooth_n(P, 4)) * N).sum(axis=1) * UNIT_MM # real mm, very local
m = zone & ~protect & (np.abs(dev) > DEV_MM) & (ang > KINK_DEG)
return m, ang, dev
def bilaplacian_solve(P, free_m):
"""Collar-fixed bi-harmonic membrane over free_m, solved with CG on the S-subgraph."""
collar = grow(free_m, COLLAR) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = P[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = P[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(200000):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-14:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Q = P.copy()
Q[S[free]] = U
return Q, len(S), int(free.sum()), it, rs / rs0
P = co.copy()
for rnd in range(1, ROUNDS + 1):
mask, ang, dev = detect(P)
if not mask.sum():
log(f"round {rnd}: nothing left to heal")
break
band = grow(mask, GROW) & zone & ~protect
Q, ns, nf, it, rel = bilaplacian_solve(P, band)
# clamp: a seam is <=1.5 mm of relief, so anything larger is the solver reshaping anatomy
disp = Q - P
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
over = dmag > CLAMP_MM
if over.any():
disp[over] *= (CLAMP_MM / dmag[over])[:, None]
Q = P + disp
log(f"round {rnd}: clamped {int(over.sum())} verts to {CLAMP_MM} mm "
f"(largest pre-clamp {dmag.max():.1f} mm)")
d = np.linalg.norm(Q - P, axis=1) * UNIT_MM
log(f"round {rnd}: seed {int(mask.sum())} -> band {nf} (S={ns}) CG it={it} rel={rel:.1e} "
f"moved max {d.max():.3f} mm, median(band) {np.median(d[band]):.3f} mm")
P = Q
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
m2, ang2, dev2 = detect(P)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
log(f" after: seed mask {int(m2.sum())}, kink>12deg {int((torso & (ang2 > 12)).sum())}, "
f"kink>20deg {int((torso & (ang2 > 20)).sum())}")
# final normals + save
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
d_tot = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"TOTAL displacement: max {d_tot.max():.3f} mm, "
f"{int((d_tot > 0.05).sum())} verts moved >0.05 mm")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("LINE_DONE")