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>
597 lines
24 KiB
Python
597 lines
24 KiB
Python
"""
|
|
blender_author_sweater.py (T-1089 wave 2 — sweater_modern, per-body offset shell)
|
|
|
|
Crew-neck pullover authored per body via the offset-shell route. Imports
|
|
blender_author_offset_shell.py as the shared library (segment join, bone-ratio
|
|
thresholds, offset, solidify, logo UV2, GLB export) and
|
|
blender_author_denim_pants.py for the wave-1-proven boundary practices
|
|
(coincident segment-seam WELD, seg_hips crotch probe). What the sweater adds,
|
|
as reusable parameters:
|
|
|
|
* coverage: torso + torso_upper + FULL arms (cuffs at the wrist) + the UPPER
|
|
seg_hips band. Including seg_hips is the hem answer for tops: the natural
|
|
seg_torso bottom boundary is a 9-14 cm jagged tooth ring (probe evidence,
|
|
all bodies) and flattening it up/down either opens skin holes (hips skin
|
|
fails to reach the teeth tops in 2/24 azimuth bins) or invents cloth off
|
|
the body surface. Instead the shell continues into real hip geometry and
|
|
is hem-CUT at a clean plane derived from bone landmarks (spine_01 ->
|
|
crotch fraction), then the cut ring is flattened onto that plane
|
|
(denim-pants ankle practice; pull distance ~ one face, not full teeth).
|
|
Hips stay UNHIDDEN in coverage.json — skin continues under the hem, so no
|
|
hole is geometrically possible (uniform_utility waist-join precedent).
|
|
* crew-neck CUT + rim CIRCULARIZATION — the seg_torso_upper neckline is a
|
|
jagged tooth ring whose VALLEY sits at neck_z + ~0.23*neck_len on every
|
|
body (probe evidence, 6 bodies) — exactly crew height — while its teeth
|
|
reach ~72% up the neck. Raising the rim to the teeth max (the first
|
|
draft) reads as a chin-high mock-neck, not the crew the spec asks. So:
|
|
delete the neck-tube verts ABOVE the valley within the ring's own
|
|
measured hd_max * CUT_R_MUL (per-body self-calibrating radius — the
|
|
neck-trap flare shell probes strictly outside it), then flatten the new
|
|
boundary onto the valley plane: verts within r_med * CREW_RIM_R_MUL of
|
|
the neck axis are circularized to the ring median radius (clean crew
|
|
rim); flare verts beyond only drop to the plane (no radial pinch, which
|
|
would fold cloth on wide-trap bodies). Skin-hole-safe: seg_neck skin
|
|
reaches down exactly to the valley (probe), and runtime never hides
|
|
segments anyway.
|
|
* wrist rim flattening onto the cut plane (denim ankle practice on the arm
|
|
axis) — the vert-threshold cut leaves 1-3 cm teeth; the painted ribbed
|
|
cuff needs a clean edge.
|
|
* cuff PINCH — per-vert offset scale tapering to CUFF_PINCH at the wrist so
|
|
the ribbed cuffs read cinched (knit hugs the wrist).
|
|
* painted knit identity, driven per texel from final 3D positions
|
|
(buttondown per-pixel bake machinery): ribbed collar / cuffs / hem —
|
|
azimuthal rib stripes around the neck axis, arm axis, and torso axis —
|
|
plus border stitch lines. All LUMINANCE detail, so the luma-preserving
|
|
toon_garment recolor keeps it under any tint. Warm muted default tints
|
|
live in the manifest entry, not the albedo.
|
|
* region mask (spec): collar=R (tint_0), body=G (tint_1), cuffs+hem=B
|
|
(tint_2). A unused. Logo-capable OFF per spec, but the UV2 chest channel
|
|
is still authored (costs nothing; buttondown precedent).
|
|
|
|
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
|
|
|
reach blender run blender_author_sweater \
|
|
client/assets/characters/bodies \
|
|
client/assets/characters/clothing/sweater_modern \
|
|
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
|
|
[--rib-period 0.011] [--base-rgb 0.62,0.585,0.545]
|
|
|
|
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
|
<out_dir>/<body>_mask.png (RGBA region mask, 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 math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bmesh
|
|
import bpy
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import blender_author_offset_shell as base # noqa: E402
|
|
import blender_author_denim_pants as denim # noqa: E402 (weld + hips probe)
|
|
import blender_author_buttondown as bd # noqa: E402 (per-pixel bake helpers)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Parameters (the reusable seam — override for cardigan/turtleneck variants)
|
|
# --------------------------------------------------------------------------
|
|
|
|
GARMENT_ID = "sweater_modern"
|
|
|
|
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.014 # knit-weight standoff (tee 12 / hoodie 16)
|
|
THICKNESS_M = 0.005 # knit cloth thickness (tee 4 / hoodie 6)
|
|
WRIST_FRAC = 0.94 # fraction of lowerarm kept — cuffs AT the wrist
|
|
CUFF_PINCH = 0.75 # offset scale at the wrist rim (ribbing cinches)
|
|
CUFF_LEN_FRAC = 0.18 # ribbed cuff length, fraction of lowerarm length
|
|
HEM_DROP_FRAC = 0.30 # hem below the spine_01 waistline, fraction of
|
|
# (waistline - crotch) — hip-length pullover
|
|
HEM_BAND_FRAC = 0.085 # ribbed hem band, fraction of (collar_z - hem_z)
|
|
COLLAR_REACH_FRAC = 1.45 # collar region radial reach, x collar_x_abs
|
|
CUT_R_MUL = 1.05 # crew cut radius, x the neck ring's measured hd_max
|
|
CREW_RIM_R_MUL = 1.6 # circularize rim verts within this x ring r_med
|
|
COLLAR_BAND_FRAC = 0.32 # ribbed collar band height below the crew rim,
|
|
# x neck_len (~2.5 cm on average_m) — anchored to
|
|
# the MEASURED rim, not collar_z_min, so the band
|
|
# reads as a crew rib, not a chest yoke
|
|
|
|
# Knit paint (luminance detail; absolute mm scaled by the body's shoulder
|
|
# ratio so the rib gauge reads identical from child to heavy_m).
|
|
RIB_PERIOD_M = 0.011 # one rib pair (dark+light) around the band
|
|
RIB_DARK = 0.85 # dark rib stripe multiplier
|
|
BAND_MUL = 0.94 # overall band shade vs body knit
|
|
LINE_MUL = 0.70 # border stitch line multiplier
|
|
LINE_HALF_M = 0.0022 # border stitch line half-width
|
|
|
|
# Warm muted base fabric (default tints in the manifest carry the colour;
|
|
# the albedo carries luma detail + the untinted fallback tone).
|
|
FABRIC_RGB = (0.620, 0.585, 0.545)
|
|
FABRIC_NOISE = 0.030
|
|
ALBEDO_SEED = 2093
|
|
|
|
MASK_SIZE = 512
|
|
ALBEDO_SIZE = 1024
|
|
|
|
|
|
def log(msg):
|
|
print(f"[sweater] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Landmarks (all bone-proportional; same philosophy as base.derive_thresholds)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_landmarks(armature, thr, crotch_z):
|
|
bones = armature.data.bones
|
|
neck = bones.get("neck_01")
|
|
la_l = bones.get("lowerarm_l")
|
|
la_r = bones.get("lowerarm_r")
|
|
spine01 = bones.get("spine_01")
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
if not all([neck, la_l, la_r, spine01, ua_l, ua_r]):
|
|
raise RuntimeError("landmark bones missing (neck_01/lowerarm/spine_01)")
|
|
|
|
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
s = shoulder_x / base._REF_SHOULDER_X
|
|
|
|
def along(b, frac):
|
|
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
|
|
|
cut_l = along(la_l, WRIST_FRAC) # left arm +x: delete x > cut_l
|
|
cut_r = along(la_r, WRIST_FRAC) # right arm -x: delete x < cut_r
|
|
lowerarm_len = abs(la_l.tail_local.x - la_l.head_local.x)
|
|
wrist_x_abs = (abs(cut_l) + abs(cut_r)) / 2.0
|
|
|
|
waist_z = spine01.head_local.z
|
|
hem_z = waist_z - HEM_DROP_FRAC * (waist_z - crotch_z)
|
|
|
|
# Arm rib axis (y, z) — midpoint of the lowerarm bone, shared by both
|
|
# sides (the rig mirrors in x only).
|
|
az_y = (la_l.head_local.y + la_l.tail_local.y) / 2.0
|
|
az_z = (la_l.head_local.z + la_l.tail_local.z) / 2.0
|
|
|
|
lm = {
|
|
"style": s,
|
|
"neck_y": neck.head_local.y,
|
|
"neck_len": neck.tail_local.z - neck.head_local.z,
|
|
"cut_l": cut_l,
|
|
"cut_r": cut_r,
|
|
"wrist_x_abs": wrist_x_abs,
|
|
"cuff_x0": wrist_x_abs - CUFF_LEN_FRAC * lowerarm_len,
|
|
"arm_axis_yz": (az_y, az_z),
|
|
"waist_z": waist_z,
|
|
"crotch_z": crotch_z,
|
|
"hem_z": hem_z,
|
|
"collar_reach": COLLAR_REACH_FRAC * thr["collar_x_abs"],
|
|
"rib_period": RIB_PERIOD_M * s,
|
|
"line_half": LINE_HALF_M * s,
|
|
}
|
|
lm["hem_top"] = hem_z + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
|
log(f"landmarks: style {s:.3f} wrist cuts ({cut_l:.3f},{cut_r:.3f}) "
|
|
f"cuff_x0 {lm['cuff_x0']:.3f} hem {hem_z:.3f} (waist {waist_z:.3f}, "
|
|
f"crotch {crotch_z:.3f}) hem_top {lm['hem_top']:.3f} "
|
|
f"collar reach {lm['collar_reach']:.3f}")
|
|
return lm
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: wrist + hem cuts, rim flattening, crew-collar circularization
|
|
# --------------------------------------------------------------------------
|
|
|
|
def wrist_cut(shell, lm):
|
|
"""Trim the sleeve tubes at the wrist plane (hoodie/jacket pattern)."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
doomed = [v for v in bm.verts
|
|
if v.co.x > lm["cut_l"] or v.co.x < lm["cut_r"]]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"wrist cut removed {len(doomed)} verts; "
|
|
f"{len(shell.data.vertices)} remain")
|
|
|
|
|
|
def hem_cut(shell, lm):
|
|
"""Trim the hips continuation below the hem plane (real hip geometry, so
|
|
the surviving cloth conforms to the body — no invented coverage)."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
doomed = [v for v in bm.verts if v.co.z < lm["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={lm['hem_z']:.3f}: removed {len(doomed)} verts")
|
|
|
|
|
|
def crew_cut(shell, thr, lm):
|
|
"""Cut the neck tube down to crew height (probe-driven, per body).
|
|
|
|
The seg_torso_upper neck boundary is a jagged tooth ring: valley at
|
|
~neck_z + 0.23*neck_len (crew height), teeth up to ~72% of the neck.
|
|
Measure the ring (valley z, median + max horizontal distance to the neck
|
|
axis), then delete every vert ABOVE the valley within hd_max * CUT_R_MUL
|
|
of the axis — the tube and its teeth, and provably not the neck-trap
|
|
flare shell (probe: flare hd sits well outside hd_max on all bodies).
|
|
Stores crew_z / crew_r in lm for the flatten pass.
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
boundary = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
boundary.update(v.index for v in e.verts)
|
|
|
|
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
|
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
|
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
|
ring = []
|
|
for i in boundary:
|
|
v = bm.verts[i]
|
|
if v.co.x > mid_l or v.co.x < mid_r or v.co.z <= z_split:
|
|
continue
|
|
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
|
if hd <= lm["collar_reach"] * 1.5:
|
|
ring.append((hd, v.co.z))
|
|
if not ring:
|
|
bm.free()
|
|
raise RuntimeError("no neck boundary ring found for crew cut")
|
|
lm["crew_z"] = min(z for _, z in ring)
|
|
lm["crew_r"] = float(np.median([hd for hd, _ in ring]))
|
|
hd_max = max(hd for hd, _ in ring)
|
|
r_cut = hd_max * CUT_R_MUL
|
|
|
|
doomed = [v for v in bm.verts
|
|
if v.co.z > lm["crew_z"]
|
|
and math.hypot(v.co.x, v.co.y - lm["neck_y"]) <= r_cut]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
lm["collar_band_z0"] = lm["crew_z"] - COLLAR_BAND_FRAC * lm["neck_len"]
|
|
log(f"crew cut: ring valley z={lm['crew_z']:.3f} r_med={lm['crew_r']:.3f} "
|
|
f"hd_max={hd_max:.3f} -> removed {len(doomed)} tube verts "
|
|
f"(r_cut {r_cut:.3f}); collar band z0={lm['collar_band_z0']:.3f}")
|
|
|
|
|
|
def flatten_rims_and_collar(shell, thr, lm):
|
|
"""Clean every open rim (adapted from the denim flatten_open_rims
|
|
practice; only boundary verts move, weights/UVs ride along).
|
|
|
|
wrist rings -> the wrist cut planes (x per side)
|
|
hem ring -> the hem plane (z)
|
|
neck ring -> the crew plane (crew_z from crew_cut): verts within
|
|
crew_r * CREW_RIM_R_MUL of the neck axis circularize to
|
|
the ring median radius (clean crew rim); flare verts
|
|
beyond only drop onto the plane — no radial pinch, which
|
|
would fold cloth on wide-trap bodies.
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
boundary = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
boundary.update(v.index for v in e.verts)
|
|
|
|
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
|
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
|
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
|
|
|
wrist_n = hem_n = rim_n = flare_n = 0
|
|
stray = 0
|
|
for i in boundary:
|
|
v = bm.verts[i]
|
|
if v.co.x > mid_l:
|
|
v.co.x = lm["cut_l"]
|
|
wrist_n += 1
|
|
elif v.co.x < mid_r:
|
|
v.co.x = lm["cut_r"]
|
|
wrist_n += 1
|
|
elif v.co.z <= z_split:
|
|
v.co.z = lm["hem_z"]
|
|
hem_n += 1
|
|
else:
|
|
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
|
if hd <= lm["crew_r"] * CREW_RIM_R_MUL:
|
|
if hd > 1e-6:
|
|
f = lm["crew_r"] / hd
|
|
v.co.x *= f
|
|
v.co.y = lm["neck_y"] + (v.co.y - lm["neck_y"]) * f
|
|
v.co.z = lm["crew_z"]
|
|
rim_n += 1
|
|
elif hd <= lm["collar_reach"] * 1.5:
|
|
v.co.z = lm["crew_z"]
|
|
flare_n += 1
|
|
else:
|
|
stray += 1
|
|
|
|
if rim_n == 0:
|
|
log("WARNING: no crew rim verts found — collar left ragged")
|
|
log(f"flattened rims: wrist {wrist_n} verts -> cut planes, "
|
|
f"hem {hem_n} -> z={lm['hem_z']:.3f}, crew rim {rim_n} -> circle "
|
|
f"r={lm['crew_r']:.3f} z={lm['crew_z']:.3f}, flare {flare_n} -> "
|
|
f"plane, stray {stray}")
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
|
|
|
|
def offset_with_cuff_pinch(shell, offset, lm):
|
|
"""Outward normal offset with the standoff tapering to CUFF_PINCH over
|
|
the ribbed cuff band (knit cuffs hug the wrist)."""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.normal_update()
|
|
span = max(lm["wrist_x_abs"] - lm["cuff_x0"], 1e-6)
|
|
pinched = 0
|
|
for v in bm.verts:
|
|
s = 1.0
|
|
ax = abs(v.co.x)
|
|
if ax > lm["cuff_x0"]:
|
|
t = min((ax - lm["cuff_x0"]) / span, 1.0)
|
|
s = 1.0 - (1.0 - CUFF_PINCH) * t
|
|
pinched += 1
|
|
v.co += v.normal * (offset * s)
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"offset {offset*1000:.0f} mm outward "
|
|
f"({pinched} cuff verts pinched to {CUFF_PINCH:.2f}x)")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-pixel knit paint + region mask (buttondown bake machinery)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _rib_stripes(arc, period):
|
|
"""Bool array: dark rib stripe (half of each rib pair)."""
|
|
return np.mod(arc / period, 1.0) < 0.5
|
|
|
|
|
|
def _classify_px(pos, thr, lm):
|
|
"""Region mask rows: collar=R, body=G, cuffs+hem=B (spec)."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
hd = np.hypot(x, y - lm["neck_y"])
|
|
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
|
band_b = (~collar) & ((ax >= lm["cuff_x0"]) | (z <= lm["hem_top"]))
|
|
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
|
rgba[:, 1] = 1.0 # default: body -> G
|
|
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # crew collar -> R
|
|
rgba[band_b] = (0.0, 0.0, 1.0, 0.0) # cuffs + hem -> B
|
|
return rgba
|
|
|
|
|
|
def _paint_px(pos, rows, thr, lm):
|
|
"""Knit luminance detail: ribbed collar/cuff/hem + border stitch lines."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
hd = np.hypot(x, y - lm["neck_y"])
|
|
period = lm["rib_period"]
|
|
line = lm["line_half"]
|
|
|
|
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
|
cuff = (~collar) & (ax >= lm["cuff_x0"])
|
|
hem = (~collar) & (~cuff) & (z <= lm["hem_top"])
|
|
|
|
mul = np.ones(len(x), dtype=np.float32)
|
|
mul[collar | cuff | hem] = BAND_MUL
|
|
|
|
# Collar ribs: azimuth around the neck axis. atan2's +-pi discontinuity
|
|
# sits where the 2nd arg is negative — (y-cy)*FRONT_Y_SIGN < 0 is the
|
|
# BACK on this rig, so the rib wrap seam hides at centre-back.
|
|
arc = np.arctan2(x, (y - lm["neck_y"]) * base.FRONT_Y_SIGN) * hd
|
|
sel = collar & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Cuff ribs: azimuth around the arm axis (wrap seam under the arm).
|
|
ay, az_ = lm["arm_axis_yz"]
|
|
r_arm = np.hypot(y - ay, z - az_)
|
|
arc = np.arctan2(y - ay, z - az_) * r_arm
|
|
sel = cuff & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Hem ribs: azimuth around the torso axis (wrap seam at centre-back).
|
|
r_t = np.hypot(x, y - lm["hem_cy"])
|
|
arc = np.arctan2(x, (y - lm["hem_cy"]) * base.FRONT_Y_SIGN) * r_t
|
|
sel = hem & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Border stitch lines at each band's inner edge.
|
|
mul[(np.abs(z - lm["collar_band_z0"]) <= line)
|
|
& (hd <= lm["collar_reach"] * 1.1)] = LINE_MUL
|
|
mul[np.abs(ax - lm["cuff_x0"]) <= line] = LINE_MUL
|
|
mul[(np.abs(z - lm["hem_top"]) <= line) & (~cuff)] = LINE_MUL
|
|
|
|
out = rows * mul[:, None]
|
|
np.clip(out, 0.0, 1.0, out)
|
|
return out
|
|
|
|
|
|
def bake_maps(shell, thr, lm, mask_path):
|
|
"""One pass over UV0 triangles: bake <body>_mask.png + painted albedo."""
|
|
tris = bd._gather_tris(shell)
|
|
|
|
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
|
mbuf[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
|
|
|
rng = np.random.default_rng(ALBEDO_SEED)
|
|
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
|
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
|
- 0.5) * 2.0 * FABRIC_NOISE
|
|
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
|
|
|
# Hem torso-axis centre from the final mesh (paint anchor).
|
|
hem_ys = [v.co.y for v in shell.data.vertices if v.co.z <= lm["hem_top"]]
|
|
lm["hem_cy"] = float(np.mean(hem_ys)) if hem_ys else 0.0
|
|
|
|
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
|
cover = bd._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
|
if cover is not None:
|
|
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
|
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr, lm)
|
|
cover = bd._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
|
if cover is not None:
|
|
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
|
abuf[cover[0], cover[1], :] = _paint_px(
|
|
pos, abuf[cover[0], cover[1], :], thr, lm)
|
|
|
|
tot = MASK_SIZE * MASK_SIZE
|
|
log("mask texels: collar={:.1f}% body={:.1f}% cuffs+hem={:.1f}%".format(
|
|
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
|
|
|
mask_img = bpy.data.images.new(f"{GARMENT_ID}_mask", MASK_SIZE, MASK_SIZE,
|
|
alpha=True)
|
|
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
|
mask_img.update()
|
|
mask_img.filepath_raw = mask_path
|
|
mask_img.file_format = 'PNG'
|
|
mask_img.save()
|
|
log(f"baked region mask -> {mask_path}")
|
|
|
|
argba = np.concatenate(
|
|
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
|
axis=2)
|
|
albedo_img = bpy.data.images.new(f"{GARMENT_ID}_albedo", ALBEDO_SIZE,
|
|
ALBEDO_SIZE, alpha=False)
|
|
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
|
albedo_img.update()
|
|
return albedo_img
|
|
|
|
|
|
def save_albedo(albedo_img, out_dir, body):
|
|
"""Sidecar + embed naming (hoodie convention): the glTF exporter names the
|
|
embedded image after the file basename and Godot extracts it as
|
|
<glb>_<imagename>.png, so pointing the image at base_albedo.png makes the
|
|
extraction land exactly on <body>_base_albedo.png."""
|
|
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
|
albedo_img.filepath_raw = sidecar
|
|
albedo_img.file_format = 'PNG'
|
|
albedo_img.save()
|
|
log(f"painted albedo -> {sidecar}")
|
|
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
|
albedo_img.save()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_sweater(body_dir, out_dir, body, offset):
|
|
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
|
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = SEGMENTS
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
thr = base.derive_thresholds(armature)
|
|
lm = derive_landmarks(armature, thr, crotch_z)
|
|
|
|
denim.weld_boundaries(shell)
|
|
wrist_cut(shell, lm)
|
|
hem_cut(shell, lm)
|
|
crew_cut(shell, thr, lm)
|
|
flatten_rims_and_collar(shell, thr, lm)
|
|
offset_with_cuff_pinch(shell, offset, lm)
|
|
base.solidify(shell, THICKNESS_M)
|
|
|
|
base.author_logo_uv(shell, thr) # UV2 before bakes (mask/albedo use UV0)
|
|
albedo_img = bake_maps(shell, thr, lm,
|
|
os.path.join(out_dir, f"{body}_mask.png"))
|
|
save_albedo(albedo_img, out_dir, body)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
|
|
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
|
|
|
|
|
def main():
|
|
global OFFSET_M, HEM_DROP_FRAC, RIB_PERIOD_M, FABRIC_RGB
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
if len(argv) < 2:
|
|
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
|
"[--offset M] [--hem-drop F] [--rib-period M] "
|
|
"[--base-rgb r,g,b]")
|
|
sys.exit(1)
|
|
bodies_root = argv[0]
|
|
out_dir = argv[1]
|
|
offset = OFFSET_M
|
|
if "--offset" in argv:
|
|
offset = float(argv[argv.index("--offset") + 1])
|
|
if "--hem-drop" in argv:
|
|
HEM_DROP_FRAC = float(argv[argv.index("--hem-drop") + 1])
|
|
if "--rib-period" in argv:
|
|
RIB_PERIOD_M = float(argv[argv.index("--rib-period") + 1])
|
|
if "--base-rgb" in argv:
|
|
FABRIC_RGB = tuple(
|
|
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
|
bodies = base.BODY_TYPES
|
|
if "--bodies" in argv:
|
|
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
log(f"per-body sweater: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
|
f"hem-drop {HEM_DROP_FRAC}")
|
|
|
|
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
|
results = []
|
|
for body in bodies:
|
|
body_dir = os.path.join(bodies_root, body)
|
|
log(f"=== {body} ===")
|
|
if not os.path.isdir(body_dir):
|
|
results.append((body, "skipped: body dir missing"))
|
|
continue
|
|
try:
|
|
author_sweater(body_dir, out_dir, body, offset)
|
|
if body == base.REFERENCE_BODY:
|
|
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
|
avg_albedo_stash)
|
|
results.append((body, "ok"))
|
|
except Exception as exc: # noqa: BLE001 — per-body isolation
|
|
log(f"ERROR {body}: {exc}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
# Runtime fallbacks mirror the reference body (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("copied reference_mask.png fallback")
|
|
if os.path.isfile(avg_albedo_stash):
|
|
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
|
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
|
|
|
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()
|