A script scanned every tracked doc, rule, skill, agent, hook and source file for tooling/ paths that no longer exist, skipping historical records (sprints, discussions, workshops, governance, generated wiki pages). It found 62. The ones that tell a reader what to RUN now name the reach verb: - The atlas skill still sent agents to tooling/atlas, atlas-verify, atlas-update-field and atlas-commit-and-sync — about forty lines, all retired in T-1285. They now name the `reach atlas` verbs, and the skill records that commit-and-sync STAGES by default (--commit to commit) and takes --corridor as an option. - The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si and clerk briefings sent those agents to the retired tooling/db/decision and sqlite-query CLIs and to decisions/*.md paths that moved to governance/ in the pql migration. They now name pql. - The ticket-cli rule documented `pql decisions read`, which does not exist; `show` already includes the body. - The culture authoring guide and the RON sources name `reach validate ron`, with the same arguments as before. - The 41 Blender payloads' usage lines ran the retired tooling/blender wrapper, and the docstrings still cited pre-carve-out paths. They now read `reach blender run <payload>`. - Doc comments in server/, client/, wiki TOMLs and the domain modules. What is left is deliberate: "Formerly …" provenance, dated plans and findings docs, the retired-pipeline doc, and a build-artefact path. project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched files are in the canvas-version registry (trait_catalog_reader.rs, since T-1289, canvas_sources.py itself, and two client files). The gate is path-based and has no override. The previous push was rejected on exactly this. Three of the edits are stamped ledger sources, so systems.db is regenerated and the stamp is fresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
649 lines
27 KiB
Python
649 lines
27 KiB
Python
"""
|
||
blender_author_swimsuit.py (T-1089 wave 2, swimsuit_onepiece — swim family)
|
||
|
||
Authors a one-piece SWIMSUIT as per-body offset shells, reusing
|
||
blender_author_offset_shell.py as a library (scene build, join, offset,
|
||
solidify, GLB export) and blender_author_offset_coverall.py's per-texel
|
||
rasterizer (_tri_texels) for crisp mask/albedo boundaries. Coverage: torso +
|
||
torso_upper + hips — NO arms, NO legs. The torso_upper is cut down to narrow
|
||
SHOULDER STRAPS (front scoop + back scoop + open armholes); the bottom gets
|
||
HIGH-CUT leg openings that rise from the crotch gusset to the hip line at the
|
||
sides. What this companion adds, as reusable parameters:
|
||
|
||
* seg_leg_upper_l/r are INCLUDED in the covered set purely as cut stock:
|
||
the seg_hips lower boundary is splitter teeth (probe: 9-13 cm jag,
|
||
z 0.884..1.015 on average_m — worse than the waist teeth denim flattens).
|
||
Welding the legs in makes that seam interior, so the high-cut leg cut
|
||
slices through CLEAN thigh geometry and no natural teeth survive; the
|
||
tubes below the cut are deleted whole.
|
||
* measurement-guarded straps: the strap corridor is proportional
|
||
(STRAP_X0/X1_FRAC of shoulder |x|) but clamped per body against the
|
||
MEASURED neck-seam ring (max |x| + guard) and armscye ring (min |x| −
|
||
guard), because the armscye reaches |x| = 0.79..0.92 x shoulder depending
|
||
on body fork (probe) and a fixed fraction would slice into the seam.
|
||
* measurement-derived armhole plane: z_arm = (armscye ring min z) − drop,
|
||
so the whole jagged arm-seam ring is guaranteed deleted on every body.
|
||
* analytic high-cut leg surface z_leg(x, y): crotch gusset (below-crotch at
|
||
|x| < gusset half-width, so the gusset never opens), rising outward to
|
||
the thigh-head line, with a front/back blend that keeps the seat covered
|
||
(RISE_BACK < RISE_FRONT). The SAME function drives the cut, the rim
|
||
flattening and the trim band in the mask, so they always agree.
|
||
* open-rim FLATTENING onto the analytic lines (denim practice, post-offset):
|
||
scoop rims -> scoop plane, armhole rims -> z_arm, leg rims -> z_leg,
|
||
strap side edges -> |x| snapped to the exact strap planes.
|
||
|
||
Regions (RGBA mask, toon_garment.gdshader; spec: straps+trim=R, body=G,
|
||
side color-block=B):
|
||
R = shoulder straps + edge trim along every opening (scoop/armhole/leg)
|
||
G = main body fabric
|
||
B = side color-block panels (normal-gated: |n.x| >= BLOCK_NX_MIN, below the
|
||
armholes) — the saturated-color-block default lives in the tints.
|
||
|
||
Painted albedo: flat per-region luminance + woven noise + dark stitch lines on
|
||
region boundaries (toon-friendly; identity carried by the texture). A parked
|
||
logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
|
||
|
||
Per-body mode only (offset shells author per body, Q-060):
|
||
|
||
reach blender run blender_author_swimsuit \
|
||
client/assets/characters/bodies \
|
||
client/assets/characters/clothing/swimsuit_onepiece \
|
||
[--bodies average_m,child,...] [--offset 0.012]
|
||
|
||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||
<out_dir>/<body>_base_albedo.png
|
||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||
|
||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||
wardrobe), Q-060 (per-body offset shells).
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import sys
|
||
|
||
import bmesh
|
||
import bpy
|
||
import numpy as np
|
||
|
||
# Make the sibling modules importable when Blender runs this file directly.
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
import blender_author_offset_shell as base # noqa: E402
|
||
import blender_author_offset_coverall as cov # noqa: E402 (per-texel rasterizer)
|
||
|
||
log = base.log
|
||
FRONT = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Parameters
|
||
# --------------------------------------------------------------------------
|
||
|
||
COVERED_SEGMENTS = [
|
||
"seg_torso", "seg_torso_upper", "seg_hips",
|
||
# cut stock only — welded in so the hips|leg seam teeth become interior,
|
||
# then everything below the high-cut line is deleted.
|
||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||
]
|
||
|
||
WELD_DIST = 5e-4 # boundary weld tolerance (0.5 mm, denim practice)
|
||
MIN_LOOP_VERTS = 6 # ignore sliver boundary loops when measuring rings
|
||
|
||
# Straps — proportional corridor over the shoulder top, clamped per body
|
||
# against the measured neck ring (outside it) and armscye ring (inside it).
|
||
STRAP_X0_FRAC = 0.52 # inner strap edge, fraction of shoulder |x|
|
||
STRAP_X1_FRAC = 0.74 # outer strap edge
|
||
STRAP_MIN_W_FRAC = 0.10 # minimum acceptable strap width (of shoulder |x|)
|
||
NECK_GUARD_M = 0.004 # keep this far outside the neck-seam ring
|
||
ARM_GUARD_M = 0.004 # keep this far inside the armscye ring
|
||
|
||
# Necklines — fractions of the spine_01->neck_01 span above spine_01 head.
|
||
FRONT_SCOOP_FRAC = 0.66 # front neckline (covers the seg_torso top band)
|
||
BACK_SCOOP_FRAC = 0.54 # back scoop, slightly deeper
|
||
ARMHOLE_DROP_M_REF = 0.015 # armhole plane below the measured armscye min z
|
||
|
||
# High-cut leg openings — all spans derive from (thigh head z − crotch z).
|
||
GUSSET_HALF_FRAC = 0.50 # crotch gusset half-width, fraction of thigh |x|
|
||
LEG_OUT_X_FRAC = 1.55 # |x| where the rise reaches its max (of thigh |x|)
|
||
LEG_RISE_FRONT_FRAC = 1.00 # front/side rise: up to the thigh-head line
|
||
LEG_RISE_BACK_FRAC = 0.58 # back rise: lower, keeps the seat covered
|
||
LEG_RISE_POW = 1.35 # >1 = convex sweep (classic high-cut)
|
||
LEG_YBLEND_FRAC = 0.35 # front/back blend half-width (of thigh |x|)
|
||
GUSSET_DROP_M_REF = 0.004 # gusset cut sits below the crotch -> never opens
|
||
|
||
# Region mask / painted albedo.
|
||
TRIM_W_M_REF = 0.016 # edge-trim band width along the openings
|
||
BLOCK_NX_MIN = 0.74 # side panel: |normal.x| threshold (normal-gated)
|
||
ALBEDO_LUMA = {"body": 0.62, "trim": 0.56, "block": 0.67}
|
||
STITCH_LUMA = 0.30
|
||
ALBEDO_NOISE = 0.02
|
||
NOISE_SEED = 3089
|
||
|
||
LABELS = ["body", "trim", "block"]
|
||
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
|
||
LABEL_RGBA_ARR = np.array(
|
||
[
|
||
(0.0, 1.0, 0.0, 0.0), # body -> G
|
||
(1.0, 0.0, 0.0, 0.0), # trim -> R (straps + edge trim)
|
||
(0.0, 0.0, 1.0, 0.0), # block -> B (side color-block)
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
|
||
|
||
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Per-body landmarks + measured rings
|
||
# --------------------------------------------------------------------------
|
||
|
||
class SuitLandmarks:
|
||
"""Cut/mask parameters from one body's bones, crotch probe and rings."""
|
||
|
||
def __init__(self, armature, crotch_z):
|
||
bones = armature.data.bones
|
||
|
||
def bone(name):
|
||
b = bones.get(name)
|
||
if b is None:
|
||
raise RuntimeError(f"landmark bone {name} missing")
|
||
return b
|
||
|
||
ua_l, ua_r = bone("upperarm_l"), bone("upperarm_r")
|
||
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||
self.scale = self.shoulder_x / _REF_SHOULDER_X
|
||
neck = bone("neck_01")
|
||
spine01 = bone("spine_01")
|
||
span = neck.head_local.z - spine01.head_local.z
|
||
self.scoop_front = spine01.head_local.z + FRONT_SCOOP_FRAC * span
|
||
self.scoop_back = spine01.head_local.z + BACK_SCOOP_FRAC * span
|
||
|
||
thigh = bone("thigh_l")
|
||
self.thigh_x = abs(thigh.head_local.x)
|
||
self.thigh_z = thigh.head_local.z
|
||
self.crotch_z = crotch_z
|
||
self.rise_span = self.thigh_z - self.crotch_z
|
||
self.gx = GUSSET_HALF_FRAC * self.thigh_x
|
||
self.out_x = LEG_OUT_X_FRAC * self.thigh_x
|
||
self.yb = LEG_YBLEND_FRAC * self.thigh_x
|
||
self.gusset_drop = GUSSET_DROP_M_REF * self.scale
|
||
self.trim_w = TRIM_W_M_REF * self.scale
|
||
|
||
# Filled by apply_ring_measurements():
|
||
self.sx0 = STRAP_X0_FRAC * self.shoulder_x
|
||
self.sx1 = STRAP_X1_FRAC * self.shoulder_x
|
||
self.z_arm = self.scoop_front # placeholder until rings are measured
|
||
|
||
def apply_ring_measurements(self, neck_max_ax, arm_min_ax, arm_min_z):
|
||
self.sx0 = max(STRAP_X0_FRAC * self.shoulder_x,
|
||
neck_max_ax + NECK_GUARD_M * self.scale)
|
||
self.sx1 = min(STRAP_X1_FRAC * self.shoulder_x,
|
||
arm_min_ax - ARM_GUARD_M * self.scale)
|
||
min_w = STRAP_MIN_W_FRAC * self.shoulder_x
|
||
if self.sx1 - self.sx0 < min_w:
|
||
log(f"WARNING: strap corridor pinched "
|
||
f"({(self.sx1 - self.sx0) * 1000:.1f} mm) — widening inward")
|
||
self.sx0 = max(neck_max_ax + NECK_GUARD_M * self.scale,
|
||
self.sx1 - min_w)
|
||
self.z_arm = arm_min_z - ARMHOLE_DROP_M_REF * self.scale
|
||
log(f"landmarks: straps |x|=[{self.sx0:.3f},{self.sx1:.3f}] "
|
||
f"scoop_f={self.scoop_front:.3f} scoop_b={self.scoop_back:.3f} "
|
||
f"z_arm={self.z_arm:.3f} crotch={self.crotch_z:.3f} "
|
||
f"thigh_z={self.thigh_z:.3f} gusset<|x|<{self.gx:.3f} "
|
||
f"trim={self.trim_w * 1000:.1f}mm")
|
||
|
||
# ---- analytic surfaces (numpy-vectorised; scalars work too) ----------
|
||
|
||
def z_leg(self, x, y):
|
||
"""High-cut leg-opening surface: gusset floor below the crotch,
|
||
rising outward to the thigh-head line; back rises less (seat)."""
|
||
t = np.clip((np.abs(x) - self.gx) / max(self.out_x - self.gx, 1e-6),
|
||
0.0, 1.0) ** LEG_RISE_POW
|
||
f = np.clip((np.asarray(y) * FRONT + self.yb) / (2.0 * self.yb),
|
||
0.0, 1.0)
|
||
rise = self.rise_span * (
|
||
LEG_RISE_BACK_FRAC + (LEG_RISE_FRONT_FRAC - LEG_RISE_BACK_FRAC) * f)
|
||
return (self.crotch_z - self.gusset_drop) + t * rise
|
||
|
||
def scoop(self, y):
|
||
"""Neckline level: front scoop on the front side, back scoop behind."""
|
||
return np.where(np.asarray(y) * FRONT > 0.0,
|
||
self.scoop_front, self.scoop_back)
|
||
|
||
|
||
def probe_crotch(body_dir):
|
||
"""Import seg_hips alone to measure the crotch (its lowest point) exactly
|
||
(denim practice — the joined mesh's min z is the knee cut stock)."""
|
||
base.clear_scene()
|
||
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
|
||
zs = []
|
||
for o in objs:
|
||
if base.is_body_mesh(o):
|
||
zs.extend(v.co.z for v in o.data.vertices)
|
||
if not zs:
|
||
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
|
||
return min(zs)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Geometry: weld, ring measurement, cuts, rim flattening
|
||
# --------------------------------------------------------------------------
|
||
|
||
def weld_boundaries(shell):
|
||
"""Merge coincident segment-boundary verts so the offset can't open cracks
|
||
(weights/UVs identical on coincident verts, so skinning is unaffected)."""
|
||
me = shell.data
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
before = len(bm.verts)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
|
||
bm.to_mesh(me)
|
||
bm.free()
|
||
me.update()
|
||
log(f"welded segment boundaries: {before} -> {len(me.vertices)} verts")
|
||
|
||
|
||
def _boundary_loops(bm):
|
||
"""Connected open-boundary loops as lists of vert indices."""
|
||
adj = {}
|
||
for e in bm.edges:
|
||
if len(e.link_faces) != 1:
|
||
continue
|
||
a, b = e.verts[0].index, e.verts[1].index
|
||
adj.setdefault(a, set()).add(b)
|
||
adj.setdefault(b, set()).add(a)
|
||
seen = set()
|
||
loops = []
|
||
for start in adj:
|
||
if start in seen:
|
||
continue
|
||
stack, comp = [start], []
|
||
while stack:
|
||
v = stack.pop()
|
||
if v in seen:
|
||
continue
|
||
seen.add(v)
|
||
comp.append(v)
|
||
stack.extend(adj[v] - seen)
|
||
loops.append(comp)
|
||
return loops
|
||
|
||
|
||
def measure_rings(shell, lm):
|
||
"""Locate the neck-seam and armscye boundary rings on the welded shell.
|
||
|
||
Expected loops: neck ring, 2 armscye rings, 2 knee rings (cut stock;
|
||
median z below the crotch — ignored). Sliver loops (< MIN_LOOP_VERTS)
|
||
are skipped. Returns (neck_max_ax, arm_min_ax, arm_min_z).
|
||
"""
|
||
bm = bmesh.new()
|
||
bm.from_mesh(shell.data)
|
||
bm.verts.ensure_lookup_table()
|
||
candidates = []
|
||
for comp in _boundary_loops(bm):
|
||
if len(comp) < MIN_LOOP_VERTS:
|
||
continue
|
||
zs = sorted(bm.verts[i].co.z for i in comp)
|
||
med_z = zs[len(zs) // 2]
|
||
if med_z < lm.crotch_z:
|
||
continue # knee ring on the leg cut stock
|
||
axs = [abs(bm.verts[i].co.x) for i in comp]
|
||
candidates.append({
|
||
"med_ax": sorted(axs)[len(axs) // 2],
|
||
"max_ax": max(axs),
|
||
"min_ax": min(axs),
|
||
"min_z": min(bm.verts[i].co.z for i in comp),
|
||
"n": len(comp),
|
||
})
|
||
bm.free()
|
||
if len(candidates) < 3:
|
||
raise RuntimeError(
|
||
f"expected neck + 2 armscye rings, found {len(candidates)}")
|
||
candidates.sort(key=lambda c: c["med_ax"])
|
||
neck = candidates[0]
|
||
arms = candidates[-2:]
|
||
arm_min_ax = min(a["min_ax"] for a in arms)
|
||
arm_min_z = min(a["min_z"] for a in arms)
|
||
log(f"rings: neck max|x|={neck['max_ax']:.3f} ({neck['n']}v) "
|
||
f"armscye min|x|={arm_min_ax:.3f} min z={arm_min_z:.3f}")
|
||
return neck["max_ax"], arm_min_ax, arm_min_z
|
||
|
||
|
||
def suit_cuts(shell, lm):
|
||
"""Delete everything the swimsuit doesn't cover:
|
||
- neckline scoops between the straps (|x| < sx0, z above scoop level)
|
||
- armholes outside the straps (|x| > sx1, z above the armhole plane)
|
||
- legs below the analytic high-cut surface z_leg(x, y)
|
||
The strap corridor (sx0 <= |x| <= sx1) survives over the shoulder."""
|
||
bm = bmesh.new()
|
||
bm.from_mesh(shell.data)
|
||
bm.verts.ensure_lookup_table()
|
||
doomed = []
|
||
for v in bm.verts:
|
||
x, y, z = v.co.x, v.co.y, v.co.z
|
||
ax = abs(x)
|
||
if z < float(lm.z_leg(x, y)):
|
||
doomed.append(v)
|
||
elif ax < lm.sx0 and z > float(lm.scoop(y)):
|
||
doomed.append(v)
|
||
elif ax > lm.sx1 and z > lm.z_arm:
|
||
doomed.append(v)
|
||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||
bm.to_mesh(shell.data)
|
||
bm.free()
|
||
shell.data.update()
|
||
log(f"suit cuts removed {len(doomed)} verts; "
|
||
f"{len(shell.data.vertices)} remain")
|
||
|
||
|
||
def flatten_rims(shell, lm):
|
||
"""Pull every open rim onto its analytic line (post-offset, denim
|
||
practice): leg rims -> z_leg surface, scoop rims -> scoop plane, armhole
|
||
rims -> z_arm plane, strap side edges -> |x| snapped to the strap planes.
|
||
Only boundary verts move; weights/UVs ride along.
|
||
|
||
The leg/top split plane sits MID-GAP between the thigh line (the leg
|
||
rims' analytic maximum) and the lowest top opening (armhole plane or
|
||
back scoop) — a dead zone with no legitimate boundary verts. A tight
|
||
margin (thigh_z + 2 cm) is NOT enough: the 12 mm outward offset runs
|
||
before flattening and drifts the high-cut side-apex verts upward, and on
|
||
thin_f two thigh-weighted apex verts crossed it, were classified as
|
||
armhole rim and teleported ~36 cm up to z_arm — QA showed them as sliver
|
||
spikes off the seat in deep Crouch_Fwd (worst vert-from-centroid 294 mm)."""
|
||
me = shell.data
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
bm.verts.ensure_lookup_table()
|
||
snap_eps = 0.006 * lm.scale
|
||
leg_z_max = 0.5 * (lm.thigh_z + min(lm.z_arm, lm.scoop_back))
|
||
counts = {"leg": 0, "scoop": 0, "armhole": 0, "strap": 0}
|
||
boundary = set()
|
||
for e in bm.edges:
|
||
if len(e.link_faces) == 1:
|
||
boundary.update(v.index for v in e.verts)
|
||
for i in boundary:
|
||
v = bm.verts[i]
|
||
x, y, z = v.co.x, v.co.y, v.co.z
|
||
ax = abs(x)
|
||
if z < leg_z_max:
|
||
v.co.z = float(lm.z_leg(x, y))
|
||
counts["leg"] += 1
|
||
elif ax < lm.sx0 - snap_eps:
|
||
v.co.z = float(lm.scoop(y))
|
||
counts["scoop"] += 1
|
||
elif ax > lm.sx1 + snap_eps:
|
||
v.co.z = lm.z_arm
|
||
counts["armhole"] += 1
|
||
else:
|
||
edge = lm.sx0 if abs(ax - lm.sx0) <= abs(ax - lm.sx1) else lm.sx1
|
||
v.co.x = edge if x >= 0.0 else -edge
|
||
counts["strap"] += 1
|
||
bm.to_mesh(me)
|
||
bm.free()
|
||
me.update()
|
||
log("flattened rims: " + " ".join(f"{k}={v}" for k, v in counts.items()))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Region classification (per texel — interpolated position + normal)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def classify_texels(pos, nrm, lm):
|
||
"""Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,)
|
||
uint8 label ids. Precedence: trim (straps + opening edges), block, body."""
|
||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||
ax = np.abs(x)
|
||
tw = lm.trim_w
|
||
scoop = lm.scoop(y)
|
||
zl = lm.z_leg(x, y)
|
||
|
||
strap = (ax >= lm.sx0 - 0.5 * tw) & (ax <= lm.sx1 + 0.5 * tw) \
|
||
& (z >= scoop - tw)
|
||
scoop_trim = (ax < lm.sx0) & (z >= scoop - tw)
|
||
arm_trim = (ax > lm.sx1) & (z >= lm.z_arm - tw)
|
||
leg_trim = z <= zl + tw
|
||
trim = strap | scoop_trim | arm_trim | leg_trim
|
||
|
||
block = (np.abs(nrm[:, 0]) >= BLOCK_NX_MIN) & (z <= lm.z_arm - tw)
|
||
|
||
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
|
||
lab[block] = LABEL_ID["block"]
|
||
lab[trim] = LABEL_ID["trim"]
|
||
return lab
|
||
|
||
|
||
def bake_mask_and_albedo(shell, lm, mask_path, albedo_name, seed):
|
||
"""One pass over the faces (pre-solidify — exactly one face per texel):
|
||
bake the RGBA region mask AND the painted albedo (flat per-region
|
||
luminance + stitch lines on region boundaries + woven noise)."""
|
||
W = H = base.MASK_SIZE
|
||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||
mask[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||
albedo = np.zeros((H, W, 4), dtype=np.float32)
|
||
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
|
||
albedo[:, :, 3] = 1.0
|
||
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
|
||
covered = np.zeros((H, W), dtype=bool)
|
||
|
||
me = shell.data
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
bm.faces.ensure_lookup_table()
|
||
bm.normal_update()
|
||
uv_layer = bm.loops.layers.uv.active
|
||
if uv_layer is None:
|
||
raise RuntimeError("no active UV layer for bake")
|
||
|
||
for face in bm.faces:
|
||
loops = face.loops[:]
|
||
uvs = [loop[uv_layer].uv for loop in loops]
|
||
pos = [loop.vert.co for loop in loops]
|
||
nrm = [loop.vert.normal for loop in loops]
|
||
for i in range(1, len(loops) - 1):
|
||
tri = (0, i, i + 1)
|
||
ys, xs, w0, w1, w2 = cov._tri_texels(
|
||
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||
if ys.size == 0:
|
||
continue
|
||
p = np.empty((ys.size, 3), dtype=np.float32)
|
||
n = np.empty((ys.size, 3), dtype=np.float32)
|
||
for axis in range(3):
|
||
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
|
||
+ w2 * pos[tri[2]][axis])
|
||
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
|
||
+ w2 * nrm[tri[2]][axis])
|
||
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
|
||
lab = classify_texels(p, n, lm)
|
||
label_map[ys, xs] = lab
|
||
covered[ys, xs] = True
|
||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
|
||
bm.free()
|
||
total = max(int(covered.sum()), 1)
|
||
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
|
||
log("region texels: " + " ".join(
|
||
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||
for i, c in enumerate(tex_counts) if c > 0))
|
||
|
||
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
|
||
rng = np.random.default_rng(seed)
|
||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
|
||
|
||
# Stitch lines: label transitions where BOTH texels belong to rasterised
|
||
# geometry (skipping UV-island borders against background).
|
||
edge = np.zeros((H, W), dtype=bool)
|
||
dh = (label_map[:, 1:] != label_map[:, :-1]) & covered[:, 1:] & covered[:, :-1]
|
||
edge[:, 1:] |= dh
|
||
edge[:, :-1] |= dh
|
||
dv = (label_map[1:, :] != label_map[:-1, :]) & covered[1:, :] & covered[:-1, :]
|
||
edge[1:, :] |= dv
|
||
edge[:-1, :] |= dv
|
||
albedo[edge, 0:3] = STITCH_LUMA
|
||
log(f"stitch lines on {int(edge.sum())} boundary texels")
|
||
|
||
# Alpha floor 2/255: keeps Godot's fix_alpha_border import pass a no-op
|
||
# (channel-packed region data, not transparency — coverall lesson).
|
||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||
|
||
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
|
||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||
img_mask.update()
|
||
img_mask.filepath_raw = mask_path
|
||
img_mask.file_format = 'PNG'
|
||
img_mask.save()
|
||
log(f"baked region mask -> {mask_path}")
|
||
|
||
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
|
||
img_albedo.pixels.foreach_set(albedo.reshape(-1))
|
||
img_albedo.update()
|
||
return img_albedo
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Parked logo UV2 (not logo-capable; the shader still samples UV2)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def author_parked_uv2(shell):
|
||
me = shell.data
|
||
while len(me.uv_layers) > 1:
|
||
me.uv_layers.remove(me.uv_layers[-1])
|
||
me.uv_layers.new(name="logo_uv")
|
||
me.uv_layers.active = me.uv_layers[0]
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||
for face in bm.faces:
|
||
for loop in face.loops:
|
||
loop[uvl].uv = (2.0, 2.0)
|
||
bm.to_mesh(me)
|
||
bm.free()
|
||
me.update()
|
||
log("logo UV2 authored fully parked (not logo-capable)")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Per-body authoring
|
||
# --------------------------------------------------------------------------
|
||
|
||
def purge_strays(shell, armature):
|
||
"""Drop authoring-scene leftovers before export (defensive hygiene).
|
||
|
||
Investigated for T-1089 wave 2: the 'Icosphere' seen when re-importing
|
||
any garment GLB is NOT file content — the raw glTF JSON contains exactly
|
||
one mesh (the shell). It is a bone-display widget the Blender IMPORTER
|
||
fabricates (bone_heuristic), which also leaks one such object into the
|
||
authoring scene per body-segment import session (base.clear_scene()'s
|
||
bpy.ops select_all can miss it). Exports were never polluted
|
||
(use_selection holds), but removing every object that is not the shell
|
||
or its armature keeps each per-body authoring cycle hermetic. Bone
|
||
custom_shape references are cleared for the same reason (display-only,
|
||
no glTF effect)."""
|
||
cleared = 0
|
||
for pb in armature.pose.bones:
|
||
if pb.custom_shape is not None:
|
||
pb.custom_shape = None
|
||
cleared += 1
|
||
strays = [o for o in bpy.data.objects if o not in (shell, armature)]
|
||
for o in strays:
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
if cleared or strays:
|
||
log(f"purged {len(strays)} stray objects, cleared {cleared} bone "
|
||
f"custom shapes before export")
|
||
|
||
|
||
def author_swimsuit(body_dir, out_dir, body, offset, seed):
|
||
crotch_z = probe_crotch(body_dir)
|
||
|
||
base.clear_scene()
|
||
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
|
||
shell, armature = base.build_covered_mesh(body_dir)
|
||
weld_boundaries(shell)
|
||
|
||
lm = SuitLandmarks(armature, crotch_z)
|
||
lm.apply_ring_measurements(*measure_rings(shell, lm))
|
||
|
||
suit_cuts(shell, lm)
|
||
base.offset_outward(shell, offset)
|
||
flatten_rims(shell, lm)
|
||
|
||
# Bake + UV2 BEFORE solidify: the inner shell duplicates every face with
|
||
# the same atlas UVs but flipped normals — pre-solidify there is exactly
|
||
# one face per texel (coverall lesson; the block region is normal-gated).
|
||
author_parked_uv2(shell)
|
||
albedo_img = bake_mask_and_albedo(
|
||
shell, lm, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
|
||
|
||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||
base.assign_fabric_material(shell, albedo_img)
|
||
|
||
# Shared-name albedo before export so the GLB-embedded texture extracts
|
||
# to the <body>_base_albedo.png convention (coverall lesson).
|
||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||
purge_strays(shell, armature)
|
||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||
|
||
|
||
def main():
|
||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||
if len(argv) < 2:
|
||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
|
||
sys.exit(1)
|
||
bodies_root = argv[0]
|
||
out_dir = argv[1]
|
||
offset = base.PER_BODY_OFFSET_M
|
||
if "--offset" in argv:
|
||
offset = float(argv[argv.index("--offset") + 1])
|
||
bodies = base.BODY_TYPES
|
||
if "--bodies" in argv:
|
||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
log(f"swimsuit per-body mode: {len(bodies)} bodies, "
|
||
f"offset {offset * 1000:.0f} mm")
|
||
|
||
results = []
|
||
for i, body in enumerate(bodies):
|
||
body_dir = os.path.join(bodies_root, body)
|
||
log(f"=== {body} ===")
|
||
if not os.path.isdir(body_dir):
|
||
results.append((body, "skipped: body dir missing"))
|
||
continue
|
||
try:
|
||
author_swimsuit(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
|
||
results.append((body, "ok"))
|
||
except Exception as exc:
|
||
log(f"ERROR {body}: {exc}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
results.append((body, f"error: {exc}"))
|
||
|
||
# Runtime fallback + shared sidecars mirror the reference body.
|
||
ref = base.REFERENCE_BODY
|
||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||
if os.path.isfile(ref_mask):
|
||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||
if os.path.isfile(ref_alb):
|
||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||
|
||
log("=" * 50)
|
||
for body, status in results:
|
||
log(f" {body:12s} {status}")
|
||
ok = sum(1 for _, s in results if s == "ok")
|
||
log(f"OK={ok}/{len(results)}")
|
||
if ok != len(results):
|
||
sys.exit(1)
|
||
log("DONE")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|