Files
settled-reach/tooling/scripts/blender/blender_author_offset_coverall.py
T
jpmschweitzerandClaude Opus 5.5 26cc8de7f3 refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled
`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.

Parity, from baselines taken before anything moved:

- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
  (the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
  summary

run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.

The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.

Archived, with reasons in tooling/archive/README.md:

- setup_clothing_metadata.py wrote coverage data for five garments that no
  longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db

segment_reference_distribution.md moved to docs/assets/visual/.

Behaviour changes:

- The QA analyzer exited 0 whatever it found, though its own README says
  clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
  on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
  at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
  rewrote an unchanged file: it left them as orphans and then found them
  again. Only nodes still linked into the graph count now, and a first pass
  writes the same bytes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:55:46 +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/scripts/blender/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()