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>
726 lines
30 KiB
Python
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()
|