35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
727 lines
30 KiB
Python
727 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):
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_parka.py -- \
|
|
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()
|