Files
settled-reach/tooling/scripts/blender/blender_author_tank_top.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
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>
2026-09-23 20:13:58 +02:00

565 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):
reach blender run blender_author_tank_top \
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()