Files
settled-reach/tooling/scripts/blender/blender_author_swimsuit.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
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>
2026-09-02 20:55:52 +02:00

650 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()