35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
650 lines
27 KiB
Python
650 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):
|
||
|
||
tooling/blender --background --python \
|
||
tooling/garment-fit/blender_author_swimsuit.py -- \
|
||
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()
|