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>
566 lines
23 KiB
Python
566 lines
23 KiB
Python
"""
|
|
blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell)
|
|
|
|
Sleeveless tee authored per body via the offset-shell route. Imports
|
|
blender_author_offset_shell.py as the shared library (scene build, join,
|
|
offset, solidify, logo UV2, export), blender_author_denim_pants.py for the
|
|
required boundary-WELD practice, and blender_author_buttondown.py for the
|
|
per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank
|
|
adds beyond the tee base, as reusable parameters:
|
|
|
|
* coverage is torso + torso_upper ONLY — no arm segments. The armhole is a
|
|
real CUT at the shoulder: verts outboard of the strap (|x| > strap_out)
|
|
and above the underarm plane are deleted, leaving a shoulder strap between
|
|
the neck scoop and the armhole.
|
|
* scoop NECKLINE cut — front scoop lower than the back, blended smoothly
|
|
across the +-y transition, both guaranteed below the jagged natural neck
|
|
ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary
|
|
survives except the hem.
|
|
* ring-swallowing thresholds — the natural neck ring and shoulder/arm rings
|
|
are MEASURED per body (open-boundary probe after weld) and the strap /
|
|
scoop / underarm cut planes are clamped so every jagged ring vert falls in
|
|
the deleted zone. Proportional defaults derive from bone landmarks exactly
|
|
like base.derive_thresholds.
|
|
* open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to
|
|
a clean plane (the ring's deepest tooth, so the hem overlaps a pants
|
|
waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the
|
|
boundary loops into fair curves (a plane can't represent a curved scoop).
|
|
* region mask is distance-to-opening based: texels within TRIM_W of the
|
|
neckline edge -> collar band (R), within TRIM_W of an armhole edge ->
|
|
armhole trim (B), everything else -> body (G). The painted albedo shares
|
|
the same distance fields (binding bands + stitch lines + hem stitch), so
|
|
mask and albedo always agree.
|
|
* logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled
|
|
(LOGO_SCALE, aspect preserved) and dropped below the front scoop so the
|
|
decal never crosses the neckline.
|
|
|
|
Region convention (spec): collar band=R (tint_0), body=G (tint_1),
|
|
armhole trim=B (tint_2). A unused. Bright default tints live in the manifest;
|
|
the albedo is a bright neutral so tints carry the colour (style pin: modern,
|
|
texture carries identity, flat toon-friendly).
|
|
|
|
Run (per-body only — offset shells author per body, Q-060):
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_tank_top.py -- \
|
|
client/assets/characters/bodies \
|
|
client/assets/characters/clothing/tank_top \
|
|
[--bodies average_m,child,...] [--offset 0.012] \
|
|
[--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020]
|
|
|
|
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
|
|
<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
|
|
(per-body authoring for offset shells).
|
|
"""
|
|
|
|
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_buttondown as bdn # noqa: E402 (per-pixel bake helpers)
|
|
import blender_author_denim_pants as denim # noqa: E402 (boundary weld)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Garment parameters (average_m metres; scaled per body by bone landmarks)
|
|
# --------------------------------------------------------------------------
|
|
|
|
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"]
|
|
|
|
OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal)
|
|
CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee
|
|
|
|
# Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop
|
|
# drops are fractions of the spine_01->neck_01 span — the same landmark
|
|
# language as base.derive_thresholds.
|
|
STRAP_IN_FRAC = 0.54 # inner strap edge |x|
|
|
STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here)
|
|
FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head
|
|
BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head
|
|
MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this
|
|
RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring
|
|
SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width
|
|
|
|
# Open-rim treatment.
|
|
SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops
|
|
SMOOTH_LAM = 0.5
|
|
HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring
|
|
|
|
# Painted trim (distances measured to the opening edges, post-offset).
|
|
TRIM_W_M = 0.020 # collar / armhole binding band width
|
|
STITCH_W_M = 0.0028 # stitch line half-width
|
|
HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane
|
|
LOGO_SCALE = 0.85 # chest box rescale (aspect preserved)
|
|
LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop
|
|
|
|
MASK_SIZE = 512
|
|
ALBEDO_SIZE = 1024
|
|
|
|
# Bright neutral jersey — the manifest default tints carry the actual colour.
|
|
FABRIC_RGB = (0.70, 0.71, 0.73)
|
|
FABRIC_NOISE = 0.025
|
|
TRIM_MUL = 0.90 # binding bands read slightly denser than the body
|
|
STITCH_MUL = 0.55 # dark stitch lines
|
|
ALBEDO_SEED = 1094 # deterministic, distinct from other garments
|
|
|
|
|
|
def log(msg):
|
|
print(f"[tank-top] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Landmarks + ring probe + cut parameter derivation
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TankLandmarks:
|
|
"""Bone landmarks the proportional parameters scale from."""
|
|
|
|
def __init__(self, armature):
|
|
bones = armature.data.bones
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
neck = bones.get("neck_01")
|
|
spine01 = bones.get("spine_01")
|
|
if not all([ua_l, ua_r, neck, spine01]):
|
|
raise RuntimeError("landmark bones missing — not the 65-bone rig?")
|
|
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
self.neck_z = neck.head_local.z
|
|
self.spine_lo = spine01.head_local.z
|
|
self.span = self.neck_z - self.spine_lo
|
|
self.scale = self.shoulder_x / base._REF_SHOULDER_X
|
|
log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} "
|
|
f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}")
|
|
|
|
|
|
def probe_rings(shell, lm):
|
|
"""Measure the natural open-boundary rings of the welded torso shell.
|
|
|
|
The segment splitter cuts along weight thresholds, so all three natural
|
|
boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut
|
|
thresholds below are clamped so the neck + arm rings fall entirely inside
|
|
the deleted zone; the hem ring is flattened to a plane instead.
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.ensure_lookup_table()
|
|
bverts = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
bverts.update(v.index for v in e.verts)
|
|
hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span
|
|
hem, neck, arm = [], [], []
|
|
for i in bverts:
|
|
co = bm.verts[i].co
|
|
if co.z < hem_test_z:
|
|
hem.append(co.copy())
|
|
elif abs(co.x) < 0.5 * lm.shoulder_x:
|
|
neck.append(co.copy())
|
|
else:
|
|
arm.append(co.copy())
|
|
bm.free()
|
|
if not hem or not neck or not arm:
|
|
raise RuntimeError(
|
|
f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}")
|
|
rings = {
|
|
"hem_min_z": min(c.z for c in hem),
|
|
"hem_max_z": max(c.z for c in hem),
|
|
"hem_test_z": hem_test_z,
|
|
"neck_max_ax": max(abs(c.x) for c in neck),
|
|
"neck_min_z": min(c.z for c in neck),
|
|
"arm_min_ax": min(abs(c.x) for c in arm),
|
|
"arm_min_z": min(c.z for c in arm),
|
|
}
|
|
log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; "
|
|
f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; "
|
|
f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}")
|
|
return rings
|
|
|
|
|
|
def derive_cut(lm, rings):
|
|
"""Cut planes from proportional defaults, clamped to swallow the rings."""
|
|
strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x,
|
|
rings["arm_min_ax"] - RING_MARGIN_M)
|
|
strap_in = max(STRAP_IN_FRAC * lm.shoulder_x,
|
|
rings["neck_max_ax"] + RING_MARGIN_M)
|
|
min_w = MIN_STRAP_W_M * lm.scale
|
|
if strap_out - strap_in < min_w:
|
|
squeezed = strap_out - min_w
|
|
floor = rings["neck_max_ax"] + 0.004
|
|
if squeezed < floor:
|
|
log(f"WARNING: strap squeezed against the neck ring "
|
|
f"(in={squeezed:.3f} floor={floor:.3f}) — using floor")
|
|
squeezed = floor
|
|
strap_in = squeezed
|
|
back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span,
|
|
rings["neck_min_z"] - RING_MARGIN_M)
|
|
front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop)
|
|
armhole_z = rings["arm_min_z"] - RING_MARGIN_M
|
|
params = {
|
|
"strap_in": strap_in,
|
|
"strap_out": strap_out,
|
|
"strap_mid": 0.5 * (strap_in + strap_out),
|
|
"front_scoop": front_scoop,
|
|
"back_scoop": back_scoop,
|
|
"armhole_z": armhole_z,
|
|
"hem_plane": rings["hem_min_z"],
|
|
"hem_test_z": rings["hem_test_z"],
|
|
}
|
|
log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} "
|
|
f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}")
|
|
return params
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: tank cut + hem flatten + boundary smoothing
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _scoop_z(y, params):
|
|
"""Neckline height at this y — front scoop blended into the back scoop."""
|
|
t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5
|
|
t = min(max(t, 0.0), 1.0)
|
|
return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t
|
|
|
|
|
|
def tank_cut(shell, params):
|
|
"""Delete the neck scoop and the armholes.
|
|
|
|
Neck zone: |x| < strap_in AND z above the (front/back blended) scoop.
|
|
Armhole zone: |x| > strap_out AND z above the underarm plane.
|
|
Both thresholds were clamped so the jagged natural neck/arm rings fall
|
|
entirely inside the deleted zone — the only natural boundary that
|
|
survives is the hem ring.
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
doomed = []
|
|
for v in bm.verts:
|
|
x, y, z = v.co.x, v.co.y, v.co.z
|
|
ax = abs(x)
|
|
if ax < params["strap_in"] and z > _scoop_z(y, params):
|
|
doomed.append(v)
|
|
elif ax > params["strap_out"] and z > params["armhole_z"]:
|
|
doomed.append(v)
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain")
|
|
|
|
|
|
def flatten_hem(shell, params):
|
|
"""Pull the hem ring's jagged teeth onto one clean plane (denim practice).
|
|
|
|
The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps
|
|
overlapping a pants waistband instead of retreating to the shallowest
|
|
notch (a tank tucks over the waist; extra length is correct here).
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.ensure_lookup_table()
|
|
hem_idx = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
for v in e.verts:
|
|
if v.co.z < params["hem_test_z"]:
|
|
hem_idx.add(v.index)
|
|
for i in hem_idx:
|
|
bm.verts[i].co.z = params["hem_plane"]
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}")
|
|
|
|
|
|
def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM):
|
|
"""Laplacian-relax the scoop + armhole boundary loops into fair curves.
|
|
|
|
Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane
|
|
flatten (denim) can't express a curved scoop, so each boundary vert is
|
|
repeatedly pulled toward the midpoint of its two loop neighbours. Hem
|
|
verts (already on their plane) are pinned; interior verts never move, so
|
|
weights/UVs are untouched.
|
|
"""
|
|
iters = SMOOTH_ITERS if iters is None else iters
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.ensure_lookup_table()
|
|
nbr = {}
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
a, b = e.verts
|
|
nbr.setdefault(a.index, []).append(b.index)
|
|
nbr.setdefault(b.index, []).append(a.index)
|
|
hem_lim = params["hem_plane"] + 0.001
|
|
movable = [i for i, ns in nbr.items()
|
|
if len(ns) == 2 and bm.verts[i].co.z > hem_lim]
|
|
for _ in range(iters):
|
|
moved = {}
|
|
for i in movable:
|
|
n1, n2 = nbr[i]
|
|
mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5
|
|
moved[i] = bm.verts[i].co.lerp(mid, lam)
|
|
for i, co in moved.items():
|
|
bm.verts[i].co = co
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)")
|
|
|
|
|
|
def collect_trim_edges(shell, params):
|
|
"""Opening-edge vert positions (post-offset) for the distance-based trim.
|
|
|
|
Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the
|
|
hem gets a painted stitch line, not a tinted band (spec: 3 regions).
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.ensure_lookup_table()
|
|
neck, arm = [], []
|
|
seen = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) != 1:
|
|
continue
|
|
for v in e.verts:
|
|
if v.index in seen:
|
|
continue
|
|
seen.add(v.index)
|
|
if v.co.z < params["hem_test_z"]:
|
|
continue
|
|
if abs(v.co.x) < params["strap_mid"]:
|
|
neck.append(v.co[:])
|
|
else:
|
|
arm.append(v.co[:])
|
|
bm.free()
|
|
log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts")
|
|
return (np.array(neck, dtype=np.float32),
|
|
np.array(arm, dtype=np.float32))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-pixel bake: region mask + painted albedo share the distance fields
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _min_dist(pos, pts):
|
|
"""Min euclidean distance from each row of pos (N,3) to the set pts (K,3)."""
|
|
if pts.size == 0:
|
|
return np.full(pos.shape[0], np.inf, dtype=np.float32)
|
|
d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2)
|
|
return np.sqrt(d2.min(axis=1))
|
|
|
|
|
|
def _classify_px(dn, da, trim_w):
|
|
"""One-hot RGBA rows: collar band R / armhole trim B / body G."""
|
|
n = dn.shape[0]
|
|
rgba = np.zeros((n, 4), dtype=np.float32)
|
|
is_r = (dn < trim_w) & (dn <= da)
|
|
is_b = (da < trim_w) & (da < dn)
|
|
rgba[:, 1] = 1.0
|
|
rgba[is_r] = (1.0, 0.0, 0.0, 0.0)
|
|
rgba[is_b] = (0.0, 0.0, 1.0, 0.0)
|
|
return rgba
|
|
|
|
|
|
def _paint_px(pos, rows, dn, da, dims):
|
|
"""Painted albedo: binding bands, band stitch lines, hem stitch (in-place)."""
|
|
mul = np.ones(pos.shape[0], dtype=np.float32)
|
|
d = np.minimum(dn, da)
|
|
mul[d < dims["trim_w"]] = TRIM_MUL
|
|
mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL
|
|
hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"]
|
|
mul[hem_line] = STITCH_MUL
|
|
out = rows * mul[:, None]
|
|
np.clip(out, 0.0, 1.0, out)
|
|
return out
|
|
|
|
|
|
def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path):
|
|
"""One pass over UV0: bake <body>_mask.png + <body>_base_albedo.png."""
|
|
dims = {
|
|
"trim_w": TRIM_W_M * lm.scale,
|
|
"stitch_w": STITCH_W_M * lm.scale,
|
|
"hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale,
|
|
}
|
|
tris = bdn._gather_tris(shell)
|
|
|
|
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
|
mbuf[:, :, 1] = 1.0 # green background = body (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)
|
|
|
|
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
|
cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
|
if cover is not None:
|
|
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
|
dn = _min_dist(pos, neck_pts)
|
|
da = _min_dist(pos, arm_pts)
|
|
mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"])
|
|
cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
|
if cover is not None:
|
|
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
|
dn = _min_dist(pos, neck_pts)
|
|
da = _min_dist(pos, arm_pts)
|
|
abuf[cover[0], cover[1], :] = _paint_px(
|
|
pos, abuf[cover[0], cover[1], :], dn, da, dims)
|
|
|
|
tot = MASK_SIZE * MASK_SIZE
|
|
log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.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))
|
|
|
|
def _save(buf, name, path, alpha):
|
|
arr = buf
|
|
if not alpha:
|
|
arr = np.concatenate(
|
|
[buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2)
|
|
img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha)
|
|
img.pixels.foreach_set(arr.reshape(-1))
|
|
img.update()
|
|
img.filepath_raw = path
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
return img
|
|
|
|
_save(mbuf, "tank_region_mask", mask_path, alpha=True)
|
|
log(f"baked region mask -> {mask_path}")
|
|
albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False)
|
|
log(f"saved painted albedo -> {albedo_path}")
|
|
return albedo_img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Author one body
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_tank(body_dir, out_dir, body, offset):
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
lm = TankLandmarks(armature)
|
|
|
|
denim.weld_boundaries(shell) # required practice: weld seam rings
|
|
rings = probe_rings(shell, lm)
|
|
params = derive_cut(lm, rings)
|
|
|
|
tank_cut(shell, params)
|
|
flatten_hem(shell, params)
|
|
smooth_open_rims(shell, params)
|
|
|
|
base.offset_outward(shell, offset)
|
|
neck_pts, arm_pts = collect_trim_edges(shell, params)
|
|
base.solidify(shell, CLOTH_THICKNESS_M)
|
|
|
|
# Logo UV2: chest box rescaled (aspect preserved) and dropped below the
|
|
# front scoop so the decal sits fully on cloth.
|
|
thr = base.derive_thresholds(armature)
|
|
half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0
|
|
box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE
|
|
top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale
|
|
thr_logo = dict(thr)
|
|
thr_logo["chest_x"] = (-half_w, half_w)
|
|
thr_logo["chest_z"] = (top - box_h, top)
|
|
base.author_logo_uv(shell, thr_logo)
|
|
|
|
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
|
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
|
albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts,
|
|
mask_path, albedo_path)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
|
|
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Entry
|
|
# --------------------------------------------------------------------------
|
|
|
|
def main():
|
|
global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_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] "
|
|
"[--front-scoop-frac F] [--strap-out-frac F] [--trim-w 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 "--front-scoop-frac" in argv:
|
|
FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1])
|
|
if "--strap-out-frac" in argv:
|
|
STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1])
|
|
if "--trim-w" in argv:
|
|
TRIM_W_M = float(argv[argv.index("--trim-w") + 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 mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, "
|
|
f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}")
|
|
|
|
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_tank(body_dir, out_dir, body, offset)
|
|
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}"))
|
|
|
|
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()
|