Files
settled-reach/tooling/scripts/blender/blender_author_offset_coverall.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

534 lines
22 KiB
Python

"""
blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof)
Full-body COVERALL authored via the offset-shell technique, per body. Companion
to blender_author_offset_shell.py (imported as a module): reuses its scene
plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and
extends it with the coverall-specific geometry and the four-region utility
mask that this garment exists to prove:
ONE garment covering torso + torso_upper + arms + hips + legs. The upper and
lower shells join at the waist for free: adjacent body segments duplicate a
coincident overlap band (probe: median nearest-vertex distance 0.0 in every
seam band), so the joined mesh has no gap by construction and the duplicated
faces offset identically (same verts, same normals, same atlas UVs) and
render invisibly.
Geometry beyond the base script (parameterised, not hard-coded):
- WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone
(full sleeve ending in a cuff above the hand).
- ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in
a cuff above the boot line; feet stay free for footwear garments).
Both are bone-landmark fractions, so every body derives its own planes.
Region mask (the multi-region showcase, R/G/B/A -> tint_0..3):
G = main body fabric
R = trim: collar band + arm cuffs + leg cuffs
B = belt line (waist band, hides the segment seam) + chest patch (logo box,
logo-capable) + right-thigh utility patch
A = shoulder marks (top-facing epaulette strap on each shoulder)
Painted albedo details (flat, toon-friendly; identity lives in the texture):
per-region flat luminance fills (dark belt webbing, bright patches, mid
shoulder marks), a brighter buckle plate at the front-centre of the belt,
and dark stitch lines rasterised along every region-boundary edge (patch
borders, collar seam, cuff seams, belt edges). The toon_garment shader is
luminance-preserving, so these survive any region recolor.
Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_coverall.py -- \
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body:
<out_dir>/<body>.glb skinned coverall authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's atlas UVs)
<out_dir>/<body>_base_albedo.png painted detail albedo (also in the GLB)
Plus:
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
<out_dir>/base_albedo.png copy of average_m's albedo (convention)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
(per-body authoring for offset shells).
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Make the sibling base module 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
# --------------------------------------------------------------------------
# Coverall parameters (all bone-landmark fractions unless noted)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist)
ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle)
CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm
CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf
# Belt: centred between pelvis head and spine_01 head (the torso|hips seam
# band sits at ~0.90 of that span on average_m), half-height in ref metres
# scaled by each body's pelvis->spine_01 span.
BELT_CENTER_FRAC = 0.90
BELT_HALF_M_REF = 0.032
BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate
# Shoulder marks: top-facing band above the upperarm head, outside the collar.
SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head
SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x|
SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward
# Chest patch: the logo box inset by this fraction of its width/height per
# side, so the amber patch frames the decal instead of touching the box edge.
CHEST_PATCH_INSET_FRAC = 0.10
# Right-thigh utility patch: box along the thigh bone, front-facing.
THIGH_PATCH_SIDE = "thigh_r"
THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone
THIGH_PATCH_HALF_X_REF = 0.058
THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN)
# Painted-albedo luminance per region label (flat toon fills).
ALBEDO_LUMA = {
"body": 0.62,
"collar": 0.66,
"cuff": 0.66,
"shoulder": 0.50,
"belt": 0.34,
"buckle": 0.82,
"patch": 0.72,
}
STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries
ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script)
# Label ids index the vectorised classifier's output arrays.
LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"]
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
LABEL_RGBA = {
"collar": (1.0, 0.0, 0.0, 0.0), # R trim
"cuff": (1.0, 0.0, 0.0, 0.0), # R trim
"body": (0.0, 1.0, 0.0, 0.0), # G main fabric
"belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches
"buckle": (0.0, 0.0, 1.0, 0.0), # B
"patch": (0.0, 0.0, 1.0, 0.0), # B
"shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks
}
LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], 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)
log = base.log
# --------------------------------------------------------------------------
# Threshold derivation (extends base.derive_thresholds with coverall zones)
# --------------------------------------------------------------------------
def derive_coverall_thresholds(armature):
thr = base.derive_thresholds(armature)
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 = bone("upperarm_l")
neck = bone("neck_01")
pelvis = bone("pelvis")
spine01 = bone("spine_01")
shoulder_x = abs(ua.head_local.x)
scale = shoulder_x / _REF_SHOULDER_X
# Wrist cut planes + arm cuff start, per side (arm runs along +/-X).
for side, sign in (("l", +1), ("r", -1)):
la = bone(f"lowerarm_{side}")
hx, tx = la.head_local.x, la.tail_local.x
thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx)
thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx)
# Ankle cut + leg cuff start (leg runs down -Z; both calves share z).
calf = bone("calf_l")
hz, tz = calf.head_local.z, calf.tail_local.z
thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz)
thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz)
# Belt band.
pz, sz = pelvis.head_local.z, spine01.head_local.z
span = sz - pz
thr["belt_z"] = pz + BELT_CENTER_FRAC * span
thr["belt_half"] = BELT_HALF_M_REF * scale
thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale
# Shoulder marks.
uz = ua.head_local.z
thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz)
thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC
# Chest patch = logo box inset a little on every side.
cx0, cx1 = thr["chest_x"]
cz0, cz1 = thr["chest_z"]
dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC
dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC
thr["patch_x"] = (cx0 + dx, cx1 - dx)
thr["patch_z"] = (cz0 + dz, cz1 - dz)
# Right-thigh patch box.
thigh = bone(THIGH_PATCH_SIDE)
thz, ttz = thigh.head_local.z, thigh.tail_local.z
f0, f1 = THIGH_PATCH_Z_FRACS
thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz))
tx_ctr = thigh.head_local.x
half = THIGH_PATCH_HALF_X_REF * scale
thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half)
log(
"coverall thresholds: "
f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} "
f"ankle z<{thr['ankle_cut_z']:.3f} "
f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} "
f"shoulder z>={thr['shoulder_z_min']:.3f} "
f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) "
f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})"
)
return thr
# --------------------------------------------------------------------------
# Limb cuts (wrists + ankles)
# --------------------------------------------------------------------------
def limb_cuts(shell, thr):
"""Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z)."""
wl = thr["wrist_cut_x_l"]
wr = thr["wrist_cut_x_r"]
az = thr["ankle_cut_z"]
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [
v for v in bm.verts
if v.co.x > wl or v.co.x < wr or v.co.z < az
]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"limb cuts removed {len(to_delete)} verts "
f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region classification (vectorised, per texel)
#
# Face-granular classification made the patch/belt/shoulder boundaries follow
# the triangulation (jagged, torn-looking zones on the first preview). The
# bake therefore classifies every TEXEL: barycentric interpolation gives each
# covered texel a body-space position + smoothed vertex normal, and the zone
# boundaries land exactly where the thresholds say — crisp at mask resolution.
# --------------------------------------------------------------------------
def classify_texels(pos, nrm, thr):
"""Classify N texels. pos/nrm are (N,3) body-local arrays.
Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar,
cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body.
"""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
fy = y * base.FRONT_Y_SIGN
fwd = nrm[:, 1] * base.FRONT_Y_SIGN
ax = np.abs(x)
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
remaining = np.ones(x.shape, dtype=bool)
def take(cond, name):
m = cond & remaining
lab[m] = LABEL_ID[name]
remaining[m] = False
take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar")
take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff")
take(z <= thr["cuff_leg_z"], "cuff")
take(
(z >= thr["shoulder_z_min"])
& (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"])
& (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN),
"shoulder",
)
belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"]
take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle")
take(belt, "belt")
px0, px1 = thr["patch_x"]
pz0, pz1 = thr["patch_z"]
take(
(fy > base.CHEST_FRONT_Y)
& (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1)
& (fwd > base.CHEST_NORMAL_Y),
"patch",
)
tx0, tx1 = thr["thigh_patch_x"]
tz0, tz1 = thr["thigh_patch_z"]
take(
(fy > 0.0)
& (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1)
& (fwd > THIGH_PATCH_NORMAL_Y_MIN),
"patch",
)
return lab
# --------------------------------------------------------------------------
# Combined mask + painted-albedo bake (one classification pass)
# --------------------------------------------------------------------------
def _tri_texels(a, b, c, W, H):
"""Texels covered by UV triangle (a,b,c) with barycentric weights.
Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays
instead of writing a flat colour, so the caller can interpolate per-texel
attributes (position, normal) across the triangle.
"""
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
ax, ay = a.x * (W - 1), a.y * (H - 1)
bx, by = b.x * (W - 1), b.y * (H - 1)
cx, cy = c.x * (W - 1), c.y * (H - 1)
minx = max(int(np.floor(min(ax, bx, cx))), 0)
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
miny = max(int(np.floor(min(ay, by, cy))), 0)
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
if minx > maxx or miny > maxy:
return empty
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return empty
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = xs + 0.5
py = ys + 0.5
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return empty
return (
ys[inside], xs[inside],
w0[inside].astype(np.float32),
w1[inside].astype(np.float32),
w2[inside].astype(np.float32),
)
def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed):
"""One pass over the faces: bake the RGBA region mask AND the painted
detail albedo (flat per-region luminance + stitch lines + woven noise).
Classification is per TEXEL (interpolated position + smoothed vertex
normal), so zone boundaries are crisp at mask resolution instead of
following the triangulation.
"""
W = H = base.MASK_SIZE
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # green background = main 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 = _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, thr)
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]
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: texel-space label transitions where BOTH texels belong to
# rasterised geometry (skipping UV-island borders against background).
# Buckle|belt stays seamless (same physical strap).
lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map)
edge = np.zeros((H, W), dtype=bool)
dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1]
edge[:, 1:] |= dh
edge[:, :-1] |= dh
dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :]
edge[1:, :] |= dv
edge[:-1, :] |= dv
albedo[edge, 0:3] = STITCH_LUMA
bm.free()
log(f"stitch lines on {int(edge.sum())} boundary texels")
# Floor the alpha channel at 2/255: Godot's default texture import runs
# process/fix_alpha_border, which rewrites the RGB of fully-transparent
# texels bordering opaque ones — that would smear the shoulder-mark island
# edges into the surrounding body-green weights. With no alpha-0 texels the
# pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible.
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
# The mask is channel-packed DATA (R/G/B/A region weights), not imagery
# with transparency: without this, Blender's straight-alpha PNG save
# zeroes the A channel (verified: shoulder-mark texels came back A=0).
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
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_coverall(body_dir, out_dir, body, offset, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_coverall_thresholds(armature)
limb_cuts(shell, thr)
base.offset_outward(shell, offset)
# Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that
# solidify adds duplicates every face with the SAME atlas UVs but a
# flipped normal — the normal-gated rules (shoulder marks, patches) would
# classify those copies as body and overwrite the very texels the outer
# faces just wrote. Pre-solidify there is exactly one face per texel.
base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless)
albedo_img = bake_mask_and_albedo(
shell, thr, 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)
# Save the albedo under the SHARED name before export: the glTF exporter
# names the embedded texture after the image filepath basename, and the
# Godot GLB import extracts it as <glb>_<texname>.png — with the shared
# name that lands on the tshirt-convention <body>_base_albedo.png (a
# <body>-specific filepath here would yield <body>_<body>_base_albedo.png).
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# Keep a deterministic authored per-body sidecar as well.
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"coverall per-body mode: {len(bodies)} bodies, 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_coverall(body_dir, out_dir, body, offset, seed=1089 + 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: reference_mask.png + base_albedo.png mirror average_m.
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
# base_albedo.png currently holds the LAST body's albedo; restore the
# reference body's copy so the shared sidecar is deterministic.
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> base_albedo.png")
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()