Files
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

566 lines
23 KiB
Python

"""
blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell)
Sleeveless tee authored per body via the offset-shell route. Imports
blender_author_offset_shell.py as the shared library (scene build, join,
offset, solidify, logo UV2, export), blender_author_denim_pants.py for the
required boundary-WELD practice, and blender_author_buttondown.py for the
per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank
adds beyond the tee base, as reusable parameters:
* coverage is torso + torso_upper ONLY — no arm segments. The armhole is a
real CUT at the shoulder: verts outboard of the strap (|x| > strap_out)
and above the underarm plane are deleted, leaving a shoulder strap between
the neck scoop and the armhole.
* scoop NECKLINE cut — front scoop lower than the back, blended smoothly
across the +-y transition, both guaranteed below the jagged natural neck
ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary
survives except the hem.
* ring-swallowing thresholds — the natural neck ring and shoulder/arm rings
are MEASURED per body (open-boundary probe after weld) and the strap /
scoop / underarm cut planes are clamped so every jagged ring vert falls in
the deleted zone. Proportional defaults derive from bone landmarks exactly
like base.derive_thresholds.
* open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to
a clean plane (the ring's deepest tooth, so the hem overlaps a pants
waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the
boundary loops into fair curves (a plane can't represent a curved scoop).
* region mask is distance-to-opening based: texels within TRIM_W of the
neckline edge -> collar band (R), within TRIM_W of an armhole edge ->
armhole trim (B), everything else -> body (G). The painted albedo shares
the same distance fields (binding bands + stitch lines + hem stitch), so
mask and albedo always agree.
* logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled
(LOGO_SCALE, aspect preserved) and dropped below the front scoop so the
decal never crosses the neckline.
Region convention (spec): collar band=R (tint_0), body=G (tint_1),
armhole trim=B (tint_2). A unused. Bright default tints live in the manifest;
the albedo is a bright neutral so tints carry the colour (style pin: modern,
texture carries identity, flat toon-friendly).
Run (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_tank_top.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/tank_top \
[--bodies average_m,child,...] [--offset 0.012] \
[--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020]
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
<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 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
(per-body authoring for offset shells).
"""
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
import blender_author_buttondown as bdn # noqa: E402 (per-pixel bake helpers)
import blender_author_denim_pants as denim # noqa: E402 (boundary weld)
# --------------------------------------------------------------------------
# Garment parameters (average_m metres; scaled per body by bone landmarks)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"]
OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal)
CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee
# Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop
# drops are fractions of the spine_01->neck_01 span — the same landmark
# language as base.derive_thresholds.
STRAP_IN_FRAC = 0.54 # inner strap edge |x|
STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here)
FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head
BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head
MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this
RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring
SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width
# Open-rim treatment.
SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops
SMOOTH_LAM = 0.5
HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring
# Painted trim (distances measured to the opening edges, post-offset).
TRIM_W_M = 0.020 # collar / armhole binding band width
STITCH_W_M = 0.0028 # stitch line half-width
HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane
LOGO_SCALE = 0.85 # chest box rescale (aspect preserved)
LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop
MASK_SIZE = 512
ALBEDO_SIZE = 1024
# Bright neutral jersey — the manifest default tints carry the actual colour.
FABRIC_RGB = (0.70, 0.71, 0.73)
FABRIC_NOISE = 0.025
TRIM_MUL = 0.90 # binding bands read slightly denser than the body
STITCH_MUL = 0.55 # dark stitch lines
ALBEDO_SEED = 1094 # deterministic, distinct from other garments
def log(msg):
print(f"[tank-top] {msg}")
# --------------------------------------------------------------------------
# Landmarks + ring probe + cut parameter derivation
# --------------------------------------------------------------------------
class TankLandmarks:
"""Bone landmarks the proportional parameters scale from."""
def __init__(self, armature):
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if not all([ua_l, ua_r, neck, spine01]):
raise RuntimeError("landmark bones missing — not the 65-bone rig?")
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
self.neck_z = neck.head_local.z
self.spine_lo = spine01.head_local.z
self.span = self.neck_z - self.spine_lo
self.scale = self.shoulder_x / base._REF_SHOULDER_X
log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} "
f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}")
def probe_rings(shell, lm):
"""Measure the natural open-boundary rings of the welded torso shell.
The segment splitter cuts along weight thresholds, so all three natural
boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut
thresholds below are clamped so the neck + arm rings fall entirely inside
the deleted zone; the hem ring is flattened to a plane instead.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
bverts = set()
for e in bm.edges:
if len(e.link_faces) == 1:
bverts.update(v.index for v in e.verts)
hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span
hem, neck, arm = [], [], []
for i in bverts:
co = bm.verts[i].co
if co.z < hem_test_z:
hem.append(co.copy())
elif abs(co.x) < 0.5 * lm.shoulder_x:
neck.append(co.copy())
else:
arm.append(co.copy())
bm.free()
if not hem or not neck or not arm:
raise RuntimeError(
f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}")
rings = {
"hem_min_z": min(c.z for c in hem),
"hem_max_z": max(c.z for c in hem),
"hem_test_z": hem_test_z,
"neck_max_ax": max(abs(c.x) for c in neck),
"neck_min_z": min(c.z for c in neck),
"arm_min_ax": min(abs(c.x) for c in arm),
"arm_min_z": min(c.z for c in arm),
}
log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; "
f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; "
f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}")
return rings
def derive_cut(lm, rings):
"""Cut planes from proportional defaults, clamped to swallow the rings."""
strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x,
rings["arm_min_ax"] - RING_MARGIN_M)
strap_in = max(STRAP_IN_FRAC * lm.shoulder_x,
rings["neck_max_ax"] + RING_MARGIN_M)
min_w = MIN_STRAP_W_M * lm.scale
if strap_out - strap_in < min_w:
squeezed = strap_out - min_w
floor = rings["neck_max_ax"] + 0.004
if squeezed < floor:
log(f"WARNING: strap squeezed against the neck ring "
f"(in={squeezed:.3f} floor={floor:.3f}) — using floor")
squeezed = floor
strap_in = squeezed
back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span,
rings["neck_min_z"] - RING_MARGIN_M)
front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop)
armhole_z = rings["arm_min_z"] - RING_MARGIN_M
params = {
"strap_in": strap_in,
"strap_out": strap_out,
"strap_mid": 0.5 * (strap_in + strap_out),
"front_scoop": front_scoop,
"back_scoop": back_scoop,
"armhole_z": armhole_z,
"hem_plane": rings["hem_min_z"],
"hem_test_z": rings["hem_test_z"],
}
log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} "
f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}")
return params
# --------------------------------------------------------------------------
# Geometry: tank cut + hem flatten + boundary smoothing
# --------------------------------------------------------------------------
def _scoop_z(y, params):
"""Neckline height at this y — front scoop blended into the back scoop."""
t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5
t = min(max(t, 0.0), 1.0)
return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t
def tank_cut(shell, params):
"""Delete the neck scoop and the armholes.
Neck zone: |x| < strap_in AND z above the (front/back blended) scoop.
Armhole zone: |x| > strap_out AND z above the underarm plane.
Both thresholds were clamped so the jagged natural neck/arm rings fall
entirely inside the deleted zone — the only natural boundary that
survives is the hem ring.
"""
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 ax < params["strap_in"] and z > _scoop_z(y, params):
doomed.append(v)
elif ax > params["strap_out"] and z > params["armhole_z"]:
doomed.append(v)
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain")
def flatten_hem(shell, params):
"""Pull the hem ring's jagged teeth onto one clean plane (denim practice).
The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps
overlapping a pants waistband instead of retreating to the shallowest
notch (a tank tucks over the waist; extra length is correct here).
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
hem_idx = set()
for e in bm.edges:
if len(e.link_faces) == 1:
for v in e.verts:
if v.co.z < params["hem_test_z"]:
hem_idx.add(v.index)
for i in hem_idx:
bm.verts[i].co.z = params["hem_plane"]
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}")
def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM):
"""Laplacian-relax the scoop + armhole boundary loops into fair curves.
Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane
flatten (denim) can't express a curved scoop, so each boundary vert is
repeatedly pulled toward the midpoint of its two loop neighbours. Hem
verts (already on their plane) are pinned; interior verts never move, so
weights/UVs are untouched.
"""
iters = SMOOTH_ITERS if iters is None else iters
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
nbr = {}
for e in bm.edges:
if len(e.link_faces) == 1:
a, b = e.verts
nbr.setdefault(a.index, []).append(b.index)
nbr.setdefault(b.index, []).append(a.index)
hem_lim = params["hem_plane"] + 0.001
movable = [i for i, ns in nbr.items()
if len(ns) == 2 and bm.verts[i].co.z > hem_lim]
for _ in range(iters):
moved = {}
for i in movable:
n1, n2 = nbr[i]
mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5
moved[i] = bm.verts[i].co.lerp(mid, lam)
for i, co in moved.items():
bm.verts[i].co = co
bm.to_mesh(me)
bm.free()
me.update()
log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)")
def collect_trim_edges(shell, params):
"""Opening-edge vert positions (post-offset) for the distance-based trim.
Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the
hem gets a painted stitch line, not a tinted band (spec: 3 regions).
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
neck, arm = [], []
seen = set()
for e in bm.edges:
if len(e.link_faces) != 1:
continue
for v in e.verts:
if v.index in seen:
continue
seen.add(v.index)
if v.co.z < params["hem_test_z"]:
continue
if abs(v.co.x) < params["strap_mid"]:
neck.append(v.co[:])
else:
arm.append(v.co[:])
bm.free()
log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts")
return (np.array(neck, dtype=np.float32),
np.array(arm, dtype=np.float32))
# --------------------------------------------------------------------------
# Per-pixel bake: region mask + painted albedo share the distance fields
# --------------------------------------------------------------------------
def _min_dist(pos, pts):
"""Min euclidean distance from each row of pos (N,3) to the set pts (K,3)."""
if pts.size == 0:
return np.full(pos.shape[0], np.inf, dtype=np.float32)
d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2)
return np.sqrt(d2.min(axis=1))
def _classify_px(dn, da, trim_w):
"""One-hot RGBA rows: collar band R / armhole trim B / body G."""
n = dn.shape[0]
rgba = np.zeros((n, 4), dtype=np.float32)
is_r = (dn < trim_w) & (dn <= da)
is_b = (da < trim_w) & (da < dn)
rgba[:, 1] = 1.0
rgba[is_r] = (1.0, 0.0, 0.0, 0.0)
rgba[is_b] = (0.0, 0.0, 1.0, 0.0)
return rgba
def _paint_px(pos, rows, dn, da, dims):
"""Painted albedo: binding bands, band stitch lines, hem stitch (in-place)."""
mul = np.ones(pos.shape[0], dtype=np.float32)
d = np.minimum(dn, da)
mul[d < dims["trim_w"]] = TRIM_MUL
mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL
hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"]
mul[hem_line] = STITCH_MUL
out = rows * mul[:, None]
np.clip(out, 0.0, 1.0, out)
return out
def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path):
"""One pass over UV0: bake <body>_mask.png + <body>_base_albedo.png."""
dims = {
"trim_w": TRIM_W_M * lm.scale,
"stitch_w": STITCH_W_M * lm.scale,
"hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale,
}
tris = bdn._gather_tris(shell)
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
mbuf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
rng = np.random.default_rng(ALBEDO_SEED)
fabric = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
- 0.5) * 2.0 * FABRIC_NOISE
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
if cover is not None:
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
dn = _min_dist(pos, neck_pts)
da = _min_dist(pos, arm_pts)
mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"])
cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
if cover is not None:
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
dn = _min_dist(pos, neck_pts)
da = _min_dist(pos, arm_pts)
abuf[cover[0], cover[1], :] = _paint_px(
pos, abuf[cover[0], cover[1], :], dn, da, dims)
tot = MASK_SIZE * MASK_SIZE
log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.1f}%".format(
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
def _save(buf, name, path, alpha):
arr = buf
if not alpha:
arr = np.concatenate(
[buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2)
img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha)
img.pixels.foreach_set(arr.reshape(-1))
img.update()
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
return img
_save(mbuf, "tank_region_mask", mask_path, alpha=True)
log(f"baked region mask -> {mask_path}")
albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False)
log(f"saved painted albedo -> {albedo_path}")
return albedo_img
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_tank(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
lm = TankLandmarks(armature)
denim.weld_boundaries(shell) # required practice: weld seam rings
rings = probe_rings(shell, lm)
params = derive_cut(lm, rings)
tank_cut(shell, params)
flatten_hem(shell, params)
smooth_open_rims(shell, params)
base.offset_outward(shell, offset)
neck_pts, arm_pts = collect_trim_edges(shell, params)
base.solidify(shell, CLOTH_THICKNESS_M)
# Logo UV2: chest box rescaled (aspect preserved) and dropped below the
# front scoop so the decal sits fully on cloth.
thr = base.derive_thresholds(armature)
half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0
box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE
top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale
thr_logo = dict(thr)
thr_logo["chest_x"] = (-half_w, half_w)
thr_logo["chest_z"] = (top - box_h, top)
base.author_logo_uv(shell, thr_logo)
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts,
mask_path, albedo_path)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_M, FABRIC_RGB
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] "
"[--front-scoop-frac F] [--strap-out-frac F] [--trim-w M] "
"[--base-rgb r,g,b]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--front-scoop-frac" in argv:
FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1])
if "--strap-out-frac" in argv:
STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1])
if "--trim-w" in argv:
TRIM_W_M = float(argv[argv.index("--trim-w") + 1])
if "--base-rgb" in argv:
FABRIC_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
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"per-body mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, "
f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}")
results = []
for body in 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_tank(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc: # noqa: BLE001 — per-body isolation
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
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()