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>
565 lines
23 KiB
Python
565 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):
|
|
|
|
reach blender run blender_author_tank_top \
|
|
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()
|