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

726 lines
30 KiB
Python

"""
blender_author_parka.py (T-1089 wave 2 — parka_thrds, hip-length insulated parka)
Cold-weather parka authored as per-body offset shells — the FIRST CANON-BRANDED
garment (thrds, the Braemar fiber cooperative, wiki/star-systems/GJ-475).
Companion to blender_author_offset_shell.py (imported as a library): reuses the
base module's segment join / bone-ratio thresholds / offset / solidify /
logo-UV2 / export machinery and layers the parka geometry + texture identity on
top, combining the proven practices of the wave-1 family:
* HIP-LENGTH — covers torso + torso_upper + FULL arms + the TOP of the hips
zone: seg_hips joins the shell, the coincident torso|hips overlap band is
WELDED before offsetting (denim practice — un-welded rings offset along
diverging normals and open cracks), and the hem is cut at a bone-derived
plane between the pelvis head and spine_01 head (no skirt panel below).
The vert-threshold hem cut leaves jagged teeth, so the open hem rim is
FLATTENED onto its own valley plane (denim flatten practice) for a clean
straight hem, which Solidify(use_rim) then caps.
* HOOD-DOWN ROLL — the hoodie's rolled-collar inflation with bulkier parka
parameters (radial bulge away from the neck axis, back-biased, rim lift).
* CHUNKY INSULATED READ — painted, not modelled: horizontal quilt channel
lines (constant-z on the body, constant-|x| around the sleeves) with a
soft per-channel "puff" luminance gradient; big front patch pockets with
flaps; a storm-flap front placket with bright zip-teeth dashes; hem
drawcord band; sleeve cuff bands. All identity is carried as LUMINANCE so
the luma-preserving toon_garment recolor keeps it under any tint.
* REGIONS (spec): R = hood roll + collar, G = body, B = sleeves,
A = pocket / quilt trim (quilt lines, pockets, storm flap, cuff bands).
Albedo and mask come from ONE shared per-texel feature-field evaluation
(denim practice) so they always agree. The mask ships CHANNEL_PACKED with
an alpha floor (coverall lesson: Godot's fix_alpha_border pass + Blender's
straight-alpha PNG save both destroy a plain A channel).
* LOGO-CAPABLE — left-breast chest patch (the storm flap owns the centre
line), same bone-ratio box as jacket_modern; painted as a brighter patch
rectangle kept in the G region so the decal reads on the body tint.
Per-body only (Q-060: offset shells author per body, never SD-fit):
reach blender run blender_author_parka \
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
Writes per body: <out_dir>/<body>.glb (skinned, painted albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, that body's UV0)
<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.
"""
import importlib.util
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
from mathutils import Vector
# --------------------------------------------------------------------------
# Load the base offset-shell module (shared engine machinery).
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
base = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(base)
def log(msg):
print(f"[parka] {msg}")
# --------------------------------------------------------------------------
# PARAMS — parka_thrds. A parka-family variant should only touch this block.
# --------------------------------------------------------------------------
GARMENT_ID = "parka_thrds"
SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hips",
]
OFFSET_M = 0.026 # chunkiest standoff of the tops family — an insulated
# parka worn over layers (jacket_modern is 0.022)
THICKNESS_M = 0.008 # insulated cloth (jacket 5 mm, hoodie 6 mm)
WELD_DIST = 5e-4 # coincident segment-seam weld tolerance (denim)
# Hem: fraction of the pelvis->spine_01 span ABOVE the pelvis head. 0.15 lands
# just below the hip joint (thigh head) on every body — hip-length, no skirt.
HEM_PELVIS_FRAC = 0.15
HEM_ALLOW_FRAC = 0.50 # hem-ring search band above the cut plane (same span)
# Sleeves: wrist cut + cuff band (jacket/hoodie practice — cuff anchored to the
# ACTUAL post-cut mesh reach, the jagged cut stops 1-3 cm short of the plane).
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept
CUFF_LEN_FRAC = 0.14 # cuff band length as fraction of the lowerarm
# Collar / hood-down roll (fractions of neck_01 length unless noted).
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
COLLAR_X_MULT = 1.20 # parka collar wider than the tee band
ROLL_OUT_FRAC = 0.50 # radial bulge magnitude (hoodie 0.40 — bulkier)
ROLL_LIFT_FRAC = 0.16 # upward lift at the rim
ROLL_Z_START_FRAC = 0.50 # roll influence starts this far below collar_z_min
ROLL_REACH_COLLAR_X = 1.80 # candidate radius around neck axis (x tee collar_x)
ROLL_FRONT_GAIN = 0.60 # bulge scale at the front...
ROLL_BACK_GAIN = 1.25 # ...and the back (hood mass hangs behind)
ROLL_EXPONENT = 1.80 # falloff sharpness toward the rim
R_Z_DROP_FRONT = 0.20 # R region drop below collar_z_min (x neck_len)
R_Z_DROP_BACK = 0.60 # asymmetric — the roll drapes lower on the back
# Storm flap + painted zip (metric refs scale by shoulder |x| / 0.1919).
FLAP_HALF_FRAC = 0.026 / 0.1919 # storm-flap half-width, frac of shoulder |x|
TEETH_HALF_M_REF = 0.0065 # zip teeth strip half-width
TEETH_PERIOD_M_REF = 0.026 # dash period along Z
TEETH_DUTY_M_REF = 0.015 # bright dash length within a period
# Big front patch pockets (x fracs of shoulder |x|, z fracs of garment span).
POCKET_CX_FRAC = 0.44
POCKET_HW_FRAC = 0.25
POCKET_Z0_FRAC = 0.06 # clear of the hem drawcord band (HEM_BAND_FRAC 0.045)
POCKET_Z1_FRAC = 0.255
POCKET_FLAP_FRAC = 0.30 # top fraction of the pocket = flap
# Quilt channels (fracs of the garment span = collar_z_min - hem).
QUILT_PERIOD_FRAC = 0.13
QUILT_HALFW_FRAC = 0.0070 # painted line half-width
HEM_BAND_FRAC = 0.045 # drawcord hem band height
PUFF_AMP = 0.045 # per-channel puff luminance amplitude
# Left-breast logo patch (character-left = +X; the flap owns the centre line).
# Same average_m absolutes as jacket_modern (0.035..0.115 m, z 1.30..1.42 m).
CHEST_X_LO_FRAC = 0.035 / 0.1919
CHEST_X_HI_FRAC = 0.115 / 0.1919
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
# Painted luma palette (toon_garment recolors by luma: tint * (luma*1.5+0.2)).
BASE_LUMA = 0.55
QUILT_LUMA = 0.40 # quilt channel lines
ROLL_LUMA = 0.62 # hood roll reads lighter/lofted
FLAP_LUMA = 0.50 # storm flap band
POCKET_LUMA = 0.60 # pocket patch fill
POCKET_FLAP_LUMA = 0.47 # pocket flap
PATCH_LUMA = 0.68 # chest logo patch backing
CUFF_LUMA = 0.46 # sleeve cuff bands
HEM_LUMA = 0.48 # hem drawcord band
STITCH_LUMA = 0.33 # stitch / outline lines
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.30 # dark zip tape between dashes
ALBEDO_NOISE = 0.02 # woven jitter
FRONT_EPS_M = 0.004 # front-facing gate on |y| (denim practice)
# Deep blue-grey cold-weather hue (visible when the region mask is absent;
# with the mask bound, identity is carried by luma alone).
FABRIC_HUE = np.array([0.62, 0.70, 0.88], dtype=np.float32)
TEX_SIZE = 1024 # albedo + mask (painted quilt lines need > 512)
NOISE_SEED = 2093
_REF_SHOULDER_X = 0.1919 # average_m anchor (same as base/jacket)
# Region labels -> mask channels (spec: roll+collar=R, body=G, sleeves=B, trim=A).
LBL_G, LBL_R, LBL_B, LBL_A = 0, 1, 2, 3
LABEL_NAMES = ["body", "roll", "sleeve", "trim"]
LABEL_RGBA_ARR = np.array([
(0.0, 1.0, 0.0, 0.0), # G body
(1.0, 0.0, 0.0, 0.0), # R hood roll + collar
(0.0, 0.0, 1.0, 0.0), # B sleeves
(0.0, 0.0, 0.0, 1.0), # A pocket / quilt trim
], dtype=np.float32)
# --------------------------------------------------------------------------
# Thresholds — base derivation + parka extras from the same armature
# --------------------------------------------------------------------------
def parka_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_l = bone("upperarm_l")
ua_r = bone("upperarm_r")
neck = bone("neck_01")
pelvis = bone("pelvis")
spine01 = bone("spine_01")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
scale = shoulder_x / _REF_SHOULDER_X
# Collar band (taller + wider than the tee) and hood-roll geometry.
neck_len = neck.tail_local.z - neck.head_local.z
tee_collar_x = thr["collar_x_abs"] # before the parka widening
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
thr["collar_x_abs"] = tee_collar_x * COLLAR_X_MULT
thr["neck_y"] = neck.head_local.y
thr["neck_len"] = neck_len
thr["roll_out"] = ROLL_OUT_FRAC * neck_len
thr["roll_lift"] = ROLL_LIFT_FRAC * neck_len
thr["roll_reach"] = ROLL_REACH_COLLAR_X * tee_collar_x
# Hip-length hem plane + ring-search allowance (bone-derived, per body).
pz, sz = pelvis.head_local.z, spine01.head_local.z
thr["hem_z"] = pz + HEM_PELVIS_FRAC * (sz - pz)
thr["hem_allow"] = HEM_ALLOW_FRAC * (sz - pz)
# Wrist cut planes on the lowerarm bones (jacket practice).
cut_planes = []
lowerarm_len = 0.0
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
b = bone(bone_name)
head_x, tail_x = b.head_local.x, b.tail_local.x
cut_planes.append((sign, head_x + CUFF_KEEP_FRAC * (tail_x - head_x)))
lowerarm_len = abs(tail_x - head_x)
thr["wrist_cut_planes"] = cut_planes
thr["cuff_len"] = CUFF_LEN_FRAC * lowerarm_len
# Storm flap + zip teeth.
thr["flap_half"] = shoulder_x * FLAP_HALF_FRAC
thr["teeth_half"] = TEETH_HALF_M_REF * scale
thr["teeth_period"] = TEETH_PERIOD_M_REF * scale
thr["teeth_duty"] = TEETH_DUTY_M_REF * scale
# Pockets (x now; z after the span is known in finalize_thresholds).
thr["pocket_cx"] = POCKET_CX_FRAC * shoulder_x
thr["pocket_hw"] = POCKET_HW_FRAC * shoulder_x
# Left-breast logo patch (replaces the base full-chest box).
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
spine_lo + CHEST_Z_HI_FRAC * span)
log(f"thresholds: hem z={thr['hem_z']:.3f} collar z>={thr['collar_z_min']:.3f} "
f"|x|<{thr['collar_x_abs']:.3f} roll out={thr['roll_out']*1000:.0f}mm "
f"reach={thr['roll_reach']:.3f} flap half={thr['flap_half']:.3f} "
f"pocket cx={thr['pocket_cx']:.3f} hw={thr['pocket_hw']:.3f} "
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
return thr
def finalize_thresholds(shell, thr):
"""Mesh-measured extras after cuts/flatten/offset/roll: the garment span
(drives quilt/pocket/hem-band proportions) and the cuff anchor from the
sleeves' ACTUAL post-cut reach (hoodie practice)."""
span = thr["collar_z_min"] - thr["hem_z"]
thr["span"] = span
thr["quilt_period"] = QUILT_PERIOD_FRAC * span
thr["quilt_halfw"] = QUILT_HALFW_FRAC * span
thr["hem_band_h"] = HEM_BAND_FRAC * span
thr["pocket_z0"] = thr["hem_z"] + POCKET_Z0_FRAC * span
thr["pocket_z1"] = thr["hem_z"] + POCKET_Z1_FRAC * span
thr["pocket_flap_h"] = POCKET_FLAP_FRAC * (thr["pocket_z1"] - thr["pocket_z0"])
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=0.0)
thr["cuff_x0"] = wrist_x - thr["cuff_len"]
log(f"finalized: span={span:.3f} quilt period={thr['quilt_period']*1000:.0f}mm "
f"pocket z=({thr['pocket_z0']:.3f},{thr['pocket_z1']:.3f}) "
f"cuff |x|>={thr['cuff_x0']:.3f} (reach {wrist_x:.3f})")
# --------------------------------------------------------------------------
# Geometry: weld, cuts, hem flatten, hood roll
# --------------------------------------------------------------------------
def weld_boundaries(shell):
"""Merge coincident segment-boundary verts (denim practice) so the offset
can't open cracks at the torso|hips / torso|arm seams. Weights are
identical by origin, so skinning is unaffected."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
merged = before - len(bm.verts)
bm.to_mesh(me)
bm.free()
me.update()
log(f"welded segment boundaries: {merged} verts merged "
f"({before} -> {len(me.vertices)})")
def wrist_cut(shell, thr):
"""Delete forearm verts beyond the wrist plane on each side (jacket)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr_x in thr["wrist_cut_planes"]:
if sign > 0 and v.co.x > thr_x:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr_x:
to_delete.append(v)
break
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"wrist cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
def hem_cut(shell, thr):
"""Trim everything below the hip-length hem plane (removes the seg_hips
lower boundary, crotch and leg openings entirely)."""
hem_z = thr["hem_z"]
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z < hem_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
def flatten_hem_rim(shell, thr):
"""Pull the open hem-ring verts onto the ring's own valley plane (denim
flatten practice) — the vert-threshold cut leaves jagged teeth; a clean
straight hem is what Solidify(use_rim) then caps. Only boundary verts in
the hem band move; the neck and wrist rims sit far above `hem_allow`."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
limit = thr["hem_z"] + thr["hem_allow"]
ring = set()
for e in bm.edges:
if len(e.link_faces) == 1: # open boundary
for v in e.verts:
if v.co.z < limit:
ring.add(v.index)
if not ring:
log("WARNING: no hem-ring verts found — hem left jagged")
bm.free()
return
zs = [bm.verts[i].co.z for i in ring]
valley = min(zs)
teeth = max(zs) - valley
for i in ring:
bm.verts[i].co.z = valley
bm.to_mesh(me)
bm.free()
me.update()
thr["hem_z"] = valley # paint anchors reference the clean rim
log(f"flattened hem rim: {len(ring)} verts, teeth {teeth:.3f} m "
f"-> plane z={valley:.3f}")
def hood_roll(shell, thr):
"""Inflate collar-band verts radially away from the neck axis to read as a
rolled-down hood (hoodie practice, parka-bulk parameters). Runs after the
outward offset and before solidify so the roll gets cloth thickness."""
import math
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * thr["neck_len"]
reach = thr["roll_reach"]
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
candidates = []
z_rim = z_start
for v in bm.verts:
if v.co.z < z_start:
continue
hd = math.hypot(v.co.x, v.co.y - thr["neck_y"])
if hd > reach or hd < 1e-6:
continue
candidates.append((v, hd))
z_rim = max(z_rim, v.co.z)
if z_rim <= z_start or not candidates:
log("WARNING: no hood-roll candidates found — roll skipped")
bm.free()
return
moved = 0
for v, hd in candidates:
t = (v.co.z - z_start) / (z_rim - z_start)
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
if w <= 0.0:
continue
dir_h = Vector((v.co.x, v.co.y - thr["neck_y"], 0.0)) / hd
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
v.co += dir_h * (thr["roll_out"] * w * gain)
v.co.z += thr["roll_lift"] * w
moved += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
f"start z {z_start:.3f})")
# --------------------------------------------------------------------------
# Feature field — ONE evaluation drives albedo luma AND region label together
# --------------------------------------------------------------------------
def parka_field(px, py, pz, thr):
"""Evaluate the parka feature field at texel 3D positions (numpy arrays).
Returns (label uint8 array indexing LABEL_RGBA_ARR, luma float32 array).
"""
n = px.shape[0]
label = np.full(n, LBL_G, dtype=np.uint8)
luma = np.full(n, BASE_LUMA, dtype=np.float32)
ax = np.abs(px)
front = py * base.FRONT_Y_SIGN > FRONT_EPS_M
ew = thr["quilt_halfw"] # shared edge/stitch line half-width
on_sleeve = ax >= thr["sleeve_x_abs"]
label[on_sleeve] = LBL_B
# --- zones -------------------------------------------------------------
hem = thr["hem_z"]
hem_top = hem + thr["hem_band_h"]
hem_band = pz <= hem_top
cuff = ax >= thr["cuff_x0"]
flap = front & (ax <= thr["flap_half"])
pocket_dx = np.abs(ax - thr["pocket_cx"])
in_pocket_z = (pz >= thr["pocket_z0"]) & (pz <= thr["pocket_z1"])
pocket = front & in_pocket_z & (pocket_dx <= thr["pocket_hw"])
m = ew * 3.0 # quilt keep-out margin around painted features
pocket_pad = front & (pz >= thr["pocket_z0"] - m) & (pz <= thr["pocket_z1"] + m) \
& (pocket_dx <= thr["pocket_hw"] + m)
x0, x1 = thr["chest_x"]
z0, z1 = thr["chest_z"]
patch = front & (px >= x0) & (px <= x1) & (pz >= z0) & (pz <= z1)
patch_pad = front & (px >= x0 - m) & (px <= x1 + m) \
& (pz >= z0 - m) & (pz <= z1 + m)
hd = np.hypot(px, py - thr["neck_y"])
roll_zmin = thr["collar_z_min"] \
- np.where(front, R_Z_DROP_FRONT, R_Z_DROP_BACK) * thr["neck_len"]
# Sleeve texels never join the roll (hoodie precedence: sleeve check first)
# — without the gate the deltoid caps classify as R on every body.
roll = (pz >= roll_zmin) & ~on_sleeve \
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.01)
# --- quilt channels (constant-z on the body, constant-|x| on sleeves) ---
coord = np.where(on_sleeve, ax - thr["sleeve_x_abs"], pz - hem)
period = thr["quilt_period"]
tph = np.mod(coord, period) / period
edge_d = np.minimum(tph, 1.0 - tph) * period
quilt_ok = ~(hem_band | cuff | flap | pocket_pad | patch_pad | roll)
qline = quilt_ok & (edge_d <= ew)
puff = 4.0 * tph * (1.0 - tph)
# --- luma (paint order = precedence, later writes win) ------------------
luma += np.where(quilt_ok, PUFF_AMP * (puff - 0.5) * 2.0, 0.0)
luma[qline] = QUILT_LUMA
luma[hem_band] = HEM_LUMA
hemline = (np.abs(pz - hem_top) <= ew) & ~on_sleeve
luma[hemline] = STITCH_LUMA
luma[cuff] = CUFF_LUMA
cuffline = np.abs(ax - thr["cuff_x0"]) <= ew
luma[cuffline] = STITCH_LUMA
# Big patch pockets: fill, flap, stitch outline.
luma[pocket] = POCKET_LUMA
flap_z = thr["pocket_z1"] - thr["pocket_flap_h"]
luma[pocket & (pz >= flap_z)] = POCKET_FLAP_LUMA
p_outline = pocket & (
(pocket_dx >= thr["pocket_hw"] - ew)
| (pz <= thr["pocket_z0"] + ew)
| (pz >= thr["pocket_z1"] - ew)
| (np.abs(pz - flap_z) <= ew)
)
luma[p_outline] = STITCH_LUMA
# Chest logo patch backing (stays in G so the decal reads on body tint).
luma[patch] = PATCH_LUMA
patch_edge = patch & (
(px <= x0 + ew) | (px >= x1 - ew) | (pz <= z0 + ew) | (pz >= z1 - ew)
)
luma[patch_edge] = STITCH_LUMA
# Storm flap + zip teeth (teeth stop under the hood roll).
luma[flap] = FLAP_LUMA
flap_edge = front & (np.abs(ax - thr["flap_half"]) <= ew * 0.8)
luma[flap_edge] = STITCH_LUMA
teeth = front & (ax <= thr["teeth_half"]) & ~roll
dash = np.mod(pz, thr["teeth_period"]) < thr["teeth_duty"]
luma[teeth & dash] = TEETH_LUMA
luma[teeth & ~dash] = TEETH_GAP_LUMA
# Hood roll last: lofted read + a collar seam line right under it.
rollseam = (np.abs(pz - roll_zmin) <= ew) & ~on_sleeve \
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.02)
luma[rollseam & ~roll] = STITCH_LUMA
luma[roll] = ROLL_LUMA
# --- region labels (same masks; precedence: trim, then roll wins) -------
label[qline] = LBL_A
label[cuff] = LBL_A
label[pocket] = LBL_A
label[flap] = LBL_A
label[roll] = LBL_R
return label, luma
# --------------------------------------------------------------------------
# Combined albedo + mask bake (per-texel, pre-solidify — coverall practice)
# --------------------------------------------------------------------------
def _tri_texels(a, b, c, W, H):
"""Texels covered by UV triangle (a,b,c) with barycentric weights
(coverall rasterizer: returns ys, xs, w0, w1, w2)."""
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]
pxg = xs + 0.5
pyg = ys + 0.5
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - 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_albedo_and_mask(shell, thr, out_dir, body, seed):
"""One pass over the UV0 faces: albedo and mask from a single shared
feature-field evaluation per texel (denim practice). Baked PRE-solidify so
there is exactly one face per texel (coverall practice)."""
W = H = TEX_SIZE
rng = np.random.default_rng(seed)
noise_buf = (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
alb = np.empty((H, W, 4), dtype=np.float32)
alb[:, :, 0:3] = np.clip(
(BASE_LUMA + noise_buf)[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
alb[:, :, 3] = 1.0
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
label_map = np.full((H, W), LBL_G, 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()
if not len(bm.loops.layers.uv):
raise RuntimeError("no UV layer for albedo/mask bake")
uv_layer = bm.loops.layers.uv[0]
tri_count = 0
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]
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
px3 = w0 * pos[tri[0]].x + w1 * pos[tri[1]].x + w2 * pos[tri[2]].x
py3 = w0 * pos[tri[0]].y + w1 * pos[tri[1]].y + w2 * pos[tri[2]].y
pz3 = w0 * pos[tri[0]].z + w1 * pos[tri[1]].z + w2 * pos[tri[2]].z
lab, lum = parka_field(px3, py3, pz3, thr)
lum = np.clip(lum + noise_buf[ys, xs], 0.0, 1.0)
alb[ys, xs, 0:3] = np.clip(
lum[:, None] * FABRIC_HUE[None, :], 0.0, 1.0)
mask[ys, xs] = LABEL_RGBA_ARR[lab]
label_map[ys, xs] = lab
covered[ys, xs] = True
tri_count += 1
bm.free()
total = max(int(covered.sum()), 1)
counts = np.bincount(label_map[covered], minlength=len(LABEL_NAMES))
log(f"painted {tri_count} UV triangles; region texels: " + " ".join(
f"{LABEL_NAMES[i]}={int(c)} ({100.0 * c / total:.1f}%)"
for i, c in enumerate(counts)))
# Alpha floor (coverall lesson): Godot's fix_alpha_border import pass
# rewrites RGB of fully-transparent texels — floor A at 2/255 everywhere.
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
mask_path = os.path.join(out_dir, f"{body}_mask.png")
img_mask = bpy.data.images.new(f"{GARMENT_ID}_mask_{body}", W, H, alpha=True)
# Channel-packed DATA, not imagery: a straight-alpha PNG save would zero
# the A (trim) channel (coverall lesson, verified there).
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_alb = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
img_alb.pixels.foreach_set(alb.reshape(-1))
img_alb.update()
return img_alb
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_parka(body_dir, out_dir, body, offset, seed):
base.clear_scene()
base.COVERED_SEGMENTS = SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
thr = parka_thresholds(armature)
wrist_cut(shell, thr)
hem_cut(shell, thr)
flatten_hem_rim(shell, thr)
base.offset_outward(shell, offset)
hood_roll(shell, thr)
finalize_thresholds(shell, thr)
# UV2 + bake BEFORE solidify (coverall practice: one face per texel).
base.author_logo_uv(shell, thr)
albedo_img = bake_albedo_and_mask(shell, thr, out_dir, body, seed)
base.solidify(shell, THICKNESS_M)
base.assign_fabric_material(shell, albedo_img)
# Shared sidecar name before export: the glTF exporter names the embedded
# image after the filepath basename and Godot extracts it as
# <glb>_<image>.png — this lands on the tshirt-convention
# <body>_base_albedo.png (coverall/jacket practice).
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"))
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
os.path.join(out_dir, f"{body}_base_albedo.png"))
def main():
global HEM_PELVIS_FRAC
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] [--hem-frac F]")
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 "--hem-frac" in argv:
HEM_PELVIS_FRAC = float(argv[argv.index("--hem-frac") + 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"parka per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"hem-frac {HEM_PELVIS_FRAC}")
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_parka(body_dir, out_dir, body, offset, seed=NOISE_SEED + 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}"))
# Deterministic shared sidecars mirror the reference body (average_m).
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()