feat(assets): wardrobe engine + proof t-shirt — batch-fit, offset-shell, 4-region tint, thrds logo (T-1089)

Engine: tooling/garment-fit/blender_batch_fit_skinned.py (G1 — the skinned
Surface-Deform batch the old script couldn't produce; self-check green),
blender_author_offset_shell.py (route c: garment shells from OUR body
segments, weights inherited by construction, bone-plane cuts, procedural
RGBA region mask, UV2 chest channel), make_logo.py. Shader:
toon_garment.gdshader — channel-blended 4-region tint + UV2 logo composited
after tint / before toon shading. Proof: tshirt_modern fitted to the six
healthy bodies, manifest entry with style:modern + logo_capable, thrds
wordmark, 18-assertion test suite, 216-capture chromakey QA.

Key finding (Q-060 evidence): single-reference SD-fit of an offset-shell
degrades on girth-divergent bodies (muscular_m worst) — 24mm standoff
tripled headroom but the mechanism limits. Route guidance recorded on
T-1089: per-body shell authoring for offset-shell garments; SD-fit for
derived/hand-authored ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:21:33 +02:00
co-authored by Claude Fable 5
parent b54b8189d9
commit e22ea0fa4a
26 changed files with 1496 additions and 0 deletions
@@ -0,0 +1,502 @@
"""
blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring)
Derive a garment SHELL from our own body segment meshes — the "create-stuff-
yourself" authoring pipeline that owes nothing to any vendor pack. Because the
shell IS our body topology, bone weights are inherited by construction (no
Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone
vertex groups it had as skin.
Pipeline (t-shirt reference on average_m):
1. Import the body segments the garment covers (torso + torso_upper + upper
arms), keep only the skinned body meshes (Icosphere debris filtered out).
2. Join into one mesh under a single armature; merge vertex groups by name.
3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via
a coordinate threshold derived from the upperarm bone axis (robust; no
boundary-loop classification, which the segment tool warns is fragile).
Neckline + hem come free as the natural segment boundaries.
4. Offset the surface outward along vertex normals (~12 mm standoff from skin).
5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut
rims (sleeve openings) into hems.
6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin:
neutral heather tone, no trim, no fantasy anything.
7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body
-> G, sleeve trim -> B (channel-routed 4-tint shader input, G3).
8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1]
for the logo decal (G4); everything else parks outside the box.
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
base_albedo.png sidecars.
The output reference is authored on average_m only; G1
(blender_batch_fit_skinned.py) fits it to the other body types.
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.012] [--sleeve-frac 0.40]
Writes:
<out_dir>/average_m.glb reference garment (skinned)
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import bpy
import bmesh
import numpy as np
from mathutils import Vector
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
# Segments a t-shirt covers. Order matters only for join-active choice.
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"]
OFFSET_M = 0.020 # outward standoff along vertex normals; larger than the
# 10-14 mm ideal on the reference body buys clearance for
# bigger bodies under Surface-Deform batch-fit (Q-060) —
# the muscular/female torso otherwise pokes through.
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset (total ~24 mm standoff)
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
MASK_SIZE = 512
ALBEDO_SIZE = 512
# Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey.
FABRIC_RGB = (0.60, 0.61, 0.63)
FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel
# Chest logo box in body-local metres (X width, Z height).
# FRONT AXIS: these Quaternius bodies face -Y in Blender (verified empirically —
# a +Y test projection landed on the character's back). So "front" = -Y.
FRONT_Y_SIGN = -1.0
CHEST_X = (-0.12, 0.12)
CHEST_Z = (1.16, 1.44)
CHEST_FRONT_Y = 0.015 # face centre must be on the front side by at least this
CHEST_NORMAL_Y = 0.20 # face normal must point forward by at least this much
# Region-mask classification (body-local, Z up).
COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R)
COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders
SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B)
def log(msg):
print(f"[offset-shell] {msg}")
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def is_body_mesh(obj):
"""A real skinned body segment mesh — not Icosphere debris."""
if obj.type != 'MESH':
return False
if obj.name.startswith("Icosphere"):
return False
if len(obj.vertex_groups) == 0:
return False
if len(obj.data.vertices) < 50:
return False
return True
# --------------------------------------------------------------------------
# Build the joined shell base
# --------------------------------------------------------------------------
def build_covered_mesh(body_dir):
"""Import covered segments, keep skinned meshes, join to one mesh + armature."""
body_meshes = []
armature = None
for seg in COVERED_SEGMENTS:
path = os.path.join(body_dir, f"{seg}.glb")
if not os.path.isfile(path):
log(f"WARNING: missing segment {path} — skipping")
continue
objs = import_glb(path)
for o in objs:
if o.type == 'ARMATURE' and armature is None:
armature = o
elif o.type == 'ARMATURE':
# drop extra armature copies (identical rest pose)
bpy.data.objects.remove(o, do_unlink=True)
elif is_body_mesh(o):
body_meshes.append(o)
else:
# Icosphere / debris
bpy.data.objects.remove(o, do_unlink=True)
if not body_meshes:
raise RuntimeError("no skinned body meshes imported for covered segments")
if armature is None:
raise RuntimeError("no armature found in covered segments")
# Join meshes (vertex groups merge by name across segments).
bpy.ops.object.select_all(action='DESELECT')
for m in body_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = body_meshes[0]
bpy.ops.object.join()
shell = bpy.context.active_object
shell.name = "garment_shell"
# Re-point the armature modifier at the surviving armature; re-parent.
for mod in list(shell.modifiers):
if mod.type == 'ARMATURE':
mod.object = armature
shell.parent = armature
shell.matrix_parent_inverse = armature.matrix_world.inverted()
log(f"joined shell: {len(shell.data.vertices)} verts, "
f"{len(shell.data.polygons)} faces, {len(shell.vertex_groups)} vgroups")
return shell, armature
# --------------------------------------------------------------------------
# Bone-plane sleeve cut
# --------------------------------------------------------------------------
def sleeve_cut(shell, armature):
"""Delete sleeve-tip verts beyond the short-sleeve plane on each upper arm.
The upper arm runs along +/-X (shoulder head -> elbow tail). We keep the
fraction SLEEVE_FRAC of that length from the shoulder and delete the rest.
Torso verts stay (|X| < shoulder head), so a single coordinate threshold is
safe and needs no per-vertex weight test.
"""
bones = armature.data.bones
cut_planes = [] # (axis_sign, threshold_x)
for bone_name, sign in [("upperarm_l", +1), ("upperarm_r", -1)]:
b = bones.get(bone_name)
if b is None:
log(f"WARNING: bone {bone_name} missing — sleeve not cut on that side")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
thr = head_x + SLEEVE_FRAC * (tail_x - head_x)
cut_planes.append((sign, thr))
log(f"sleeve cut {bone_name}: keep |x| up to {thr:.3f} "
f"(shoulder {head_x:.3f} -> elbow {tail_x:.3f})")
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr in cut_planes:
if sign > 0 and v.co.x > thr:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr:
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"sleeve cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Outward offset + solidify
# --------------------------------------------------------------------------
def offset_outward(shell, offset):
"""Push every vertex outward along its (smoothed) normal by `offset` m."""
me = shell.data
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
bm = bmesh.new()
bm.from_mesh(me)
bm.normal_update()
for v in bm.verts:
v.co += v.normal * offset
bm.to_mesh(me)
bm.free()
me.update()
log(f"offset surface outward by {offset*1000:.0f} mm along normals")
def solidify(shell, thickness):
"""Solidify with use_rim to give cloth thickness and cap the cut rims."""
bpy.ops.object.select_all(action='DESELECT')
shell.select_set(True)
bpy.context.view_layer.objects.active = shell
# consistent outward normals first
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
sol = shell.modifiers.new(name="Solidify", type='SOLIDIFY')
sol.thickness = thickness
sol.offset = 1.0 # grow outward only
sol.use_rim = True # cap open boundaries (sleeve/neck/hem)
sol.use_rim_only = False
bpy.ops.object.modifier_apply(modifier=sol.name)
log(f"solidified: {thickness*1000:.0f} mm, use_rim; "
f"{len(shell.data.vertices)} verts")
# --------------------------------------------------------------------------
# Material (flat fabric albedo)
# --------------------------------------------------------------------------
def make_base_albedo_image(seed=1089):
img = bpy.data.images.new("garment_base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False)
rng = np.random.default_rng(seed)
base = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
rgb = np.clip(base[None, :] + noise, 0.0, 1.0)
rgba = np.concatenate([rgb, np.ones((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32)], axis=1)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
return img
def assign_fabric_material(shell, albedo_img):
shell.data.materials.clear()
mat = bpy.data.materials.new("garment_fabric")
mat.use_nodes = True
nt = mat.node_tree
bsdf = nt.nodes.get("Principled BSDF")
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = albedo_img
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if "Roughness" in bsdf.inputs:
bsdf.inputs["Roughness"].default_value = 0.9
shell.data.materials.append(mat)
log("assigned flat fabric material (skin textures dropped)")
# --------------------------------------------------------------------------
# Region mask bake (UV0-aligned, per-face rasterization)
# --------------------------------------------------------------------------
def _classify_region(center):
"""Return an RGBA region colour for a face centre (body-local coords)."""
x, y, z = center.x, center.y, center.z
if abs(x) >= SLEEVE_X_ABS:
return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2])
if z >= COLLAR_Z_MIN and abs(x) < COLLAR_X_ABS:
return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0])
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
def _tris_from_face(face, uv_layer):
"""Fan-triangulate a bmesh face into (uv, uv, uv) tuples in [0,1] space."""
loops = face.loops[:]
uvs = [l[uv_layer].uv.copy() for l in loops]
tris = []
for i in range(1, len(uvs) - 1):
tris.append((uvs[0], uvs[i], uvs[i + 1]))
return tris
def bake_region_mask(shell, out_path):
"""Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2.
Background initialised to main-body green so bilinear bleed at island edges
never lands on an untinted (all-zero) texel.
"""
W = H = MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = main body
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
yy, xx = np.mgrid[0:H, 0:W]
for face in bm.faces:
color = _classify_region(face.calc_center_median())
for a, b, c in _tris_from_face(face, uv_layer):
_raster_tri(buf, a, b, c, color, W, H)
bm.free()
# Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we
# rasterise with row = v*(H-1). Save via Blender to match the texture pipe.
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
def _raster_tri(buf, a, b, c, color, W, H):
"""Barycentric fill of a UV triangle into buf (V=0 at row 0 = bottom)."""
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
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = xs + 0.5
py = ys + 0.5
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
region = buf[miny:maxy + 1, minx:maxx + 1, :]
col = np.array(color, dtype=np.float32)
region[inside] = col
# --------------------------------------------------------------------------
# Logo UV2 chest channel
# --------------------------------------------------------------------------
def author_logo_uv(shell):
"""Create a 2nd UV layer projecting front chest faces into [0,1]; park the
rest outside the box (shader guards uv2 in [0,1])."""
me = shell.data
# Keep exactly two UV layers: primary (albedo/mask) + logo. Remove extras.
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
logo_uv = me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0] # keep albedo layer active for mask bake safety
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.normal_update()
uvl = bm.loops.layers.uv.get("logo_uv")
x0, x1 = CHEST_X
z0, z1 = CHEST_Z
placed = 0
for face in bm.faces:
center = face.calc_center_median()
on_chest = (
center.y * FRONT_Y_SIGN > CHEST_FRONT_Y
and x0 <= center.x <= x1
and z0 <= center.z <= z1
and face.normal.y * FRONT_Y_SIGN > CHEST_NORMAL_Y
)
for loop in face.loops:
if on_chest:
co = loop.vert.co
# Empirically calibrated for the -Y front so the wordmark reads
# upright and left-to-right from the camera (see report: a plain
# projection came out 180deg-rotated on this rig).
u = (co.x - x0) / (x1 - x0)
v = (co.z - z0) / (z1 - z0)
loop[uvl].uv = (min(max(u, 0.0), 1.0), min(max(v, 0.0), 1.0))
else:
loop[uvl].uv = (2.0, 2.0) # parked outside box
if on_chest:
placed += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"logo UV2 authored on {placed} chest faces")
if placed == 0:
log("WARNING: no chest faces matched — check CHEST_* box / front axis")
# --------------------------------------------------------------------------
# Export
# --------------------------------------------------------------------------
def export_reference(shell, armature, out_path):
bpy.ops.object.select_all(action='DESELECT')
shell.select_set(True)
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format='GLB',
use_selection=True,
export_apply=False, # keep Armature modifier for skinning
export_animations=False,
export_skins=True,
export_yup=True,
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
size_kb = os.path.getsize(out_path) // 1024
log(f"exported reference -> {out_path} ({size_kb} KB)")
def save_albedo_sidecar(albedo_img, out_path):
albedo_img.filepath_raw = out_path
albedo_img.file_format = 'PNG'
albedo_img.save()
log(f"saved base albedo -> {out_path}")
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> "
"[--offset M] [--sleeve-frac F]")
sys.exit(1)
body_dir = argv[0]
out_dir = argv[1]
global OFFSET_M, SLEEVE_FRAC
if "--offset" in argv:
OFFSET_M = float(argv[argv.index("--offset") + 1])
if "--sleeve-frac" in argv:
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
os.makedirs(out_dir, exist_ok=True)
clear_scene()
shell, armature = build_covered_mesh(body_dir)
sleeve_cut(shell, armature)
offset_outward(shell, OFFSET_M)
solidify(shell, CLOTH_THICKNESS_M)
albedo_img = make_base_albedo_image()
assign_fabric_material(shell, albedo_img)
author_logo_uv(shell) # do UV2 before mask bake (mask uses UV0/active)
bake_region_mask(shell, os.path.join(out_dir, "reference_mask.png"))
save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
export_reference(shell, armature, os.path.join(out_dir, "average_m.glb"))
log("DONE")
if __name__ == "__main__":
main()
@@ -0,0 +1,445 @@
"""
blender_batch_fit_skinned.py (T-1089 gap G1 — the biggest wardrobe gap)
Fit a reference clothing GLB (authored on average_m) to every body type and
export ANIMATABLE skinned variants. This supersedes
tooling/blender_surface_deform_batch.py, whose output used export_skins=False
(:142) — those variants cannot animate on the shared skeleton, which is how the
runtime loads clothing (character_visual.gd:582-594).
It merges three proven codepaths that had never been combined:
* the 11-body loop + headless temp_override Surface-Deform bind + Shrinkwrap
fallback + pipeline_log.json — from blender_surface_deform_batch.py
* the weight flow — from
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py:160-257
Surface Deform bind -> apply -> Data Transfer VGROUP_WEIGHTS
(POLYINTERP_NEAREST) from the fitted body -> normalize -> retarget the
armature modifier to the body's armature -> export_skins=True
* optional Solidify — from tooling/convert_outfit.py (skipped by
default; offset-shell references are already solidified)
Per body type:
average_m (REFERENCE_BODY): direct copy of the reference GLB (already correct).
others: SD-bind the reference garment to the target body surface, bake the
deformed rest shape, transfer + normalise weights from that body, retarget the
armature, export a skinned GLB.
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_batch_fit_skinned.py -- \
<reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
Output:
<output_dir>/<body_type>.glb one skinned variant per fitted body type
<output_dir>/pipeline_log.json per-variant method (surface_deform/shrinkwrap/
copy) + status, for the review gate
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import json
import glob
import shutil
import bpy
BODY_TYPES = [
"thin_m", "thin_f", "average_m", "average_f", "muscular_m", "muscular_f",
"teen_m", "teen_f", "heavy_m", "heavy_f", "child",
]
REFERENCE_BODY = "average_m"
SHRINKWRAP_OFFSET = 0.002
SD_FALLOFF = 4.0 # generous — clothing sits proud of the body (fit_outfits :212)
def log(msg):
print(f"[batch-fit] {msg}")
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def is_body_mesh(obj):
"""Skinned body-segment mesh — excludes Icosphere debris that rides in GLBs."""
return (
obj.type == 'MESH'
and not obj.name.startswith("Icosphere")
and len(obj.vertex_groups) > 0
and len(obj.data.vertices) >= 50
)
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
# --------------------------------------------------------------------------
# Body surface (join of skinned segments) + one target armature
# --------------------------------------------------------------------------
def build_body(bodies_dir, body_type):
"""Import all seg_*.glb for a body; return (body_surface_mesh, armature).
The surface is a join of the skinned segment meshes (keeps merged vertex
groups so it can serve as both the Surface-Deform target and the weight
source). One armature is kept as the retarget destination; extras dropped.
"""
body_dir = os.path.join(bodies_dir, body_type)
if not os.path.isdir(body_dir):
return None, None
seg_paths = sorted(glob.glob(os.path.join(body_dir, "seg_*.glb")))
meshes = []
armature = None
for p in seg_paths:
for o in import_glb(p):
if o.type == 'ARMATURE' and armature is None:
armature = o
elif o.type == 'ARMATURE':
bpy.data.objects.remove(o, do_unlink=True)
elif is_body_mesh(o):
meshes.append(o)
elif o.type == 'MESH':
bpy.data.objects.remove(o, do_unlink=True)
if not meshes or armature is None:
return None, None
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
surface = bpy.context.active_object
surface.name = f"body_surface_{body_type}"
# Detach from armature so it is pure geometry for SD/weight source.
for mod in list(surface.modifiers):
if mod.type == 'ARMATURE':
surface.modifiers.remove(mod)
return surface, armature
# --------------------------------------------------------------------------
# Reference garment
# --------------------------------------------------------------------------
def import_reference_garment(reference_glb):
"""Import the reference garment; return its single skinned mesh + armature."""
objs = import_glb(reference_glb)
meshes = [o for o in objs if is_body_mesh(o)]
if not meshes:
# offset-shell garments always have vgroups; guard anyway
meshes = [o for o in objs if o.type == 'MESH' and len(o.data.vertices) >= 20]
armature = next((o for o in objs if o.type == 'ARMATURE'), None)
if len(meshes) > 1:
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
return bpy.context.active_object, armature
return (meshes[0] if meshes else None), armature
# --------------------------------------------------------------------------
# Fitting (Surface Deform + Shrinkwrap fallback)
# --------------------------------------------------------------------------
def try_surface_deform(clothing, body_surface):
"""Bind + apply Surface Deform via a headless context override. Returns bool."""
clothing.select_set(True)
set_active(clothing)
mod = clothing.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
mod.target = body_surface
mod.falloff = SD_FALLOFF
mod_name = mod.name
try:
with bpy.context.temp_override(
active_object=clothing, object=clothing, selected_objects=[clothing]
):
bpy.ops.object.surfacedeform_bind(modifier=mod_name)
except Exception as exc:
log(f" SD bind EXCEPTION: {exc}")
clothing.modifiers.remove(mod)
return False
if not mod.is_bound:
log(" SD bind did not complete (is_bound=False)")
clothing.modifiers.remove(mod)
return False
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=mod_name)
return True
def shrinkwrap_fallback(clothing, body_surface):
log(" Shrinkwrap fallback (SD bind failed)")
sw = clothing.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
sw.target = body_surface
sw.wrap_method = 'NEAREST_SURFACEPOINT'
sw.wrap_mode = 'ON_SURFACE'
sw.offset = SHRINKWRAP_OFFSET
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=sw.name)
def transfer_weights(clothing, body_surface):
"""Data Transfer VGROUP_WEIGHTS from the fitted body (fit_outfits :160-186)."""
deselect_all()
set_active(clothing)
clothing.select_set(True)
dt = clothing.modifiers.new("WeightTransfer", 'DATA_TRANSFER')
dt.object = body_surface
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
dt.layers_vgroup_select_dst = 'NAME'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
def normalize_weights(clothing):
deselect_all()
set_active(clothing)
clothing.select_set(True)
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
bpy.ops.object.mode_set(mode='OBJECT')
def retarget_armature(clothing, new_armature):
has_arm = False
for mod in clothing.modifiers:
if mod.type == 'ARMATURE':
mod.object = new_armature
has_arm = True
if not has_arm:
mod = clothing.modifiers.new("Armature", 'ARMATURE')
mod.object = new_armature
clothing.parent = new_armature
clothing.matrix_parent_inverse = new_armature.matrix_world.inverted()
def apply_solidify(clothing, thickness):
if thickness <= 0.0:
return
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
sol = clothing.modifiers.new("Solidify", 'SOLIDIFY')
sol.thickness = thickness
sol.offset = 1.0
sol.use_rim = True
bpy.ops.object.modifier_apply(modifier=sol.name)
def export_variant(clothing, armature, out_path):
deselect_all()
clothing.select_set(True)
armature.select_set(True)
set_active(armature)
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format='GLB',
use_selection=True,
export_apply=False, # keep armature modifier for skinning
export_animations=False,
export_skins=True, # <-- the whole point of G1
export_yup=True,
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
# --------------------------------------------------------------------------
# Per-body processing
# --------------------------------------------------------------------------
def process_body(body_type, reference_glb, bodies_dir, output_dir, solidify_mm):
out_path = os.path.join(output_dir, f"{body_type}.glb")
log(f"=== {body_type} ===")
if body_type == REFERENCE_BODY:
shutil.copy2(reference_glb, out_path)
log(" reference body — direct copy")
return {"body_type": body_type, "status": "ok", "method": "copy"}
clear_scene()
clothing, _ref_arm = import_reference_garment(reference_glb)
if clothing is None:
return {"body_type": body_type, "status": "error", "error": "no garment mesh"}
body_surface, armature = build_body(bodies_dir, body_type)
if body_surface is None:
return {"body_type": body_type, "status": "error", "error": "no body surface"}
if try_surface_deform(clothing, body_surface):
method = "surface_deform"
else:
shrinkwrap_fallback(clothing, body_surface)
method = "shrinkwrap"
transfer_weights(clothing, body_surface)
normalize_weights(clothing)
retarget_armature(clothing, armature)
apply_solidify(clothing, solidify_mm)
# Drop the body surface so only garment + armature export.
bpy.data.objects.remove(body_surface, do_unlink=True)
export_variant(clothing, armature, out_path)
log(f" exported [{method}] -> {os.path.basename(out_path)}")
return {"body_type": body_type, "status": "ok", "method": method}
# --------------------------------------------------------------------------
# Self-check (headless smoke test): refit peasant_tunic to average_f
# --------------------------------------------------------------------------
def self_check(bodies_dir):
"""Prove the SD bind + weight flow + skinned export path runs headless.
Builds average_f's body surface, binds a trivial one-quad plane to it, and
confirms the export produces JOINTS_0/WEIGHTS_0 accessors. Non-fatal probe.
"""
import struct
clear_scene()
bpy.ops.mesh.primitive_plane_add(size=0.3, location=(0, 0.1, 1.2))
plane = bpy.context.active_object
body_surface, armature = build_body(bodies_dir, "average_f")
if body_surface is None:
log("self-check: no average_f body — SKIP")
return
ok = try_surface_deform(plane, body_surface)
log(f"self-check: SD bind {'ok' if ok else 'fell back'}")
if not ok:
shrinkwrap_fallback(plane, body_surface)
transfer_weights(plane, body_surface)
normalize_weights(plane)
retarget_armature(plane, armature)
bpy.data.objects.remove(body_surface, do_unlink=True)
out = os.path.join(bpy.app.tempdir, "selfcheck.glb")
export_variant(plane, armature, out)
with open(out, 'rb') as f:
f.read(12)
clen = struct.unpack('<I', f.read(4))[0]
f.read(4)
j = json.loads(f.read(clen))
attrs = set()
for m in j.get("meshes", []):
for pr in m["primitives"]:
attrs |= set(pr["attributes"].keys())
has_skin = "JOINTS_0" in attrs and "WEIGHTS_0" in attrs
log(f"self-check: exported attrs={sorted(attrs)} skinned={has_skin}")
log(f"self-check: {'PASS' if has_skin else 'FAIL'}")
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--self-check" in argv:
# <bodies_dir> is the first positional in self-check mode
pos = [a for a in argv if not a.startswith("--")]
bodies_dir = pos[0] if pos else "client/assets/characters/bodies"
self_check(bodies_dir)
return
if len(argv) < 3:
print("Usage: -- <reference_glb> <bodies_dir> <output_dir> "
"[--only a,b,c] [--solidify MM]")
sys.exit(1)
reference_glb, bodies_dir, output_dir = argv[0], argv[1], argv[2]
only = None
if "--only" in argv:
only = [s.strip() for s in argv[argv.index("--only") + 1].split(",")]
solidify_mm = 0.0
if "--solidify" in argv:
solidify_mm = float(argv[argv.index("--solidify") + 1])
if not os.path.isfile(reference_glb):
print(f"ERROR: reference not found: {reference_glb}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
types = only if only else BODY_TYPES
log(f"reference={reference_glb}")
log(f"bodies={bodies_dir}")
log(f"output={output_dir}")
log(f"types={types} solidify={solidify_mm*1000:.0f}mm")
results = []
for bt in types:
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt)):
results.append({"body_type": bt, "status": "skipped",
"error": "body dir missing"})
continue
try:
results.append(process_body(bt, reference_glb, bodies_dir,
output_dir, solidify_mm))
except Exception as exc:
log(f" ERROR {bt}: {exc}")
results.append({"body_type": bt, "status": "error", "error": str(exc)})
ok = [r for r in results if r["status"] == "ok"]
sd = len([r for r in ok if r.get("method") == "surface_deform"])
sw = len([r for r in ok if r.get("method") == "shrinkwrap"])
cp = len([r for r in ok if r.get("method") == "copy"])
log("=" * 50)
for r in results:
log(f" {r['body_type']:12s} {r['status'].upper():8s} "
f"{r.get('method', r.get('error', ''))}")
log(f"OK={len(ok)} (surface_deform={sd} shrinkwrap={sw} copy={cp})")
if sw:
log(f"WARNING: {sw} variant(s) used Shrinkwrap — verify extremities at zoom")
with open(os.path.join(output_dir, "pipeline_log.json"), 'w') as f:
json.dump({
"reference": os.path.basename(reference_glb),
"bodies_dir": bodies_dir,
"variants": {r["body_type"]: {
"status": r["status"],
"method": r.get("method"),
"error": r.get("error"),
} for r in results},
}, f, indent=2)
log("wrote pipeline_log.json")
if any(r["status"] == "error" for r in results):
sys.exit(1)
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""
make_logo.py (T-1089 gap G4 — brand-logo supply stub)
Generate a flat 2D wordmark PNG for a clothing brand decal (D-244: flat 2D
artwork on 3D surfaces). White-on-transparent, ~256x256, drawn with its own
crisp edge so the inverted-hull character outline (which never samples the
decal) leaves it untouched. Must stay legible at 16-32 px gameplay zoom (D-044).
This is a PLACEHOLDER supply stub. The production path is /image-gen (Gemini)
per the feasibility §4 pipeline; this script gives the engine a real decal to
render now, and doubles as the deterministic fallback generator.
Run:
python3 tooling/garment-fit/make_logo.py <text> <out_png> [--size 256]
Example (the canonical Braemar fiber co-op, wiki/corporations/thrds.md — always
lowercase):
python3 tooling/garment-fit/make_logo.py thrds \
client/assets/characters/logos/thrds.png
"""
import sys
import os
from PIL import Image, ImageDraw, ImageFont
FONT_CANDIDATES = [
"/usr/share/fonts/fira-code/FiraCode-Bold.ttf",
"/usr/share/fonts/adwaita-mono-fonts/AdwaitaMono-Bold.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
]
def load_font(px):
for path in FONT_CANDIDATES:
if os.path.isfile(path):
return ImageFont.truetype(path, px), os.path.basename(path)
return ImageFont.load_default(), "PIL-default"
def make_logo(text, out_png, size=256):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Grow the font until the wordmark fills ~82% of the width.
target_w = int(size * 0.82)
px = size
font, font_name = load_font(px)
for px in range(size, 8, -2):
font, font_name = load_font(px)
bbox = draw.textbbox((0, 0), text, font=font)
if (bbox[2] - bbox[0]) <= target_w and (bbox[3] - bbox[1]) <= int(size * 0.5):
break
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = (size - tw) / 2 - bbox[0]
y = (size - th) / 2 - bbox[1]
# White wordmark, fully opaque.
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
# A thin underscore bar under the wordmark — reads as a modern corporate mark
# and gives the decal a stable baseline anchor at low zoom.
bar_y = y + bbox[3] + int(size * 0.03)
bar_h = max(2, int(size * 0.02))
draw.rectangle(
[(size * 0.12, bar_y), (size * 0.88, bar_y + bar_h)],
fill=(255, 255, 255, 255),
)
os.makedirs(os.path.dirname(out_png), exist_ok=True)
img.save(out_png)
print(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: make_logo.py <text> <out_png> [--size N]")
sys.exit(1)
text = sys.argv[1]
out = sys.argv[2]
size = 256
if "--size" in sys.argv:
size = int(sys.argv[sys.argv.index("--size") + 1])
make_logo(text, out, size)
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "tshirt_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/tshirt_modern"
}