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>
533 lines
22 KiB
Python
533 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):
|
|
|
|
reach blender run blender_author_offset_coverall \
|
|
<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()
|