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>
@@ -841,3 +841,8 @@ Follow-facing refinement (user, live session 2026-07-06): during an RMB path-fol
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'status', 'backlog', 'in_progress', NULL, '2026-07-06 18:55:29', '2026-07-06 18:55:29.038', '2026-07-06 18:55:29.038', NULL, 'da64cb6b48dd4311c5b257cbd70d891b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'status', 'in_progress', 'done', NULL, '2026-07-06 19:18:30', '2026-07-06 19:18:30.658', '2026-07-06 19:18:30.658', NULL, '5bcddf023648706d42815d7aaf08255a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'status', 'backlog', 'in_progress', NULL, '2026-07-06 19:25:35', '2026-07-06 19:25:35.248', '2026-07-06 19:25:35.248', NULL, '8fa2e4eba023cb3ae1577a5f16c9cfd2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'description', 'Found by the T-1089 Synty intake spike (2026-07-06), body-only controls with NO garment attached: thin_f/m, heavy_f/m, child misrender in CharacterVisual (exploded/spider arms, detached head/hair; child 303mm max warp, 87 collapsed edges; f-bodies bust-through). These are the newer high-poly bodies (no seg_hips, different segmentation) — exactly the 5 with no production peasant-clothing variants. Upstream of ANY clothing route; blocks T-1089''s 11-of-11 acceptance criterion and resolves part of Q-060. Evidence: spikes/synty-intake/out/bodies_fit_log.json + out/qa_bodies/ (44 screenshots) + out/bodies_recon.json.', 'Found by the T-1089 Synty intake spike (2026-07-06), body-only controls with NO garment attached: thin_f/m, heavy_f/m, child misrender in CharacterVisual (exploded/spider arms, detached head/hair; child 303mm max warp, 87 collapsed edges; f-bodies bust-through). These are the newer high-poly bodies (no seg_hips, different segmentation) — exactly the 5 with no production peasant-clothing variants. Upstream of ANY clothing route; blocks T-1089''s 11-of-11 acceptance criterion and resolves part of Q-060. Evidence: spikes/synty-intake/out/bodies_fit_log.json + out/qa_bodies/ (44 screenshots) + out/bodies_recon.json.
|
||||
|
||||
RESOLVED (pending review). Root cause: fork bodies (thin/heavy/child) had a mesh<->armature mismatch. blender_segment_body.py apply_scale scaled mesh vertices ONLY, leaving the embedded armature at source scale; since character_visual.gd binds every segment onto ONE shared skeleton (skeleton/armature.glb) by bone name, each segment must be internally consistent with its own embedded armature — the mesh-only scale dropped the head ~0.35m below the Head bone and flung limbs apart on relocation. thin/heavy were additionally stale high-poly artifacts (torso 1419-1704v vs 375v) missing seg_hips entirely. Fix: replaced apply_scale with apply_fork_scale (tooling/blender_segment_body.py) which scales mesh AND armature together and BAKES via Blender transform_apply (edit-bone poking alone shears connected chains — arms/fingers still exploded). Rebuilt all 5 via new tooling/blender_rebuild_forks.py from Source-tier gltf; now 19 segments incl seg_hips, low-poly matching the healthy six. Bare-body QA (idle+walk, front+side, real CharacterVisual): all 5 coherent, head/hair attached, no explosion; healthy 6 untouched. Q-060: all 3 peasant garments Surface-Deform-fit onto all 5 forks (15/15 surface_deform, 0 shrinkwrap fallback) — Surface Deform HOLDS at the extremes on repaired bodies. No manifest change (body ids/paths unchanged).', NULL, '2026-07-06 20:02:30', '2026-07-06 20:02:30.990', '2026-07-06 20:02:30.990', NULL, '7606f8b27d88884fcb7d4a2e3a5663bb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'status', 'in_progress', 'review', NULL, '2026-07-06 20:02:31', '2026-07-06 20:02:31.013', '2026-07-06 20:02:31.013', NULL, '942bea5d7db30ea972e6ba64341369a1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'status', 'review', 'done', NULL, '2026-07-06 20:06:22', '2026-07-06 20:06:22.078', '2026-07-06 20:06:22.078', NULL, '10bec531a986c8b28b68229637a1297f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -719,3 +719,6 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
||||
|
||||
RESOLVED (pending review). Root cause: fork bodies (thin/heavy/child) had a mesh<->armature mismatch. blender_segment_body.py apply_scale scaled mesh vertices ONLY, leaving the embedded armature at source scale; since character_visual.gd binds every segment onto ONE shared skeleton (skeleton/armature.glb) by bone name, each segment must be internally consistent with its own embedded armature — the mesh-only scale dropped the head ~0.35m below the Head bone and flung limbs apart on relocation. thin/heavy were additionally stale high-poly artifacts (torso 1419-1704v vs 375v) missing seg_hips entirely. Fix: replaced apply_scale with apply_fork_scale (tooling/blender_segment_body.py) which scales mesh AND armature together and BAKES via Blender transform_apply (edit-bone poking alone shears connected chains — arms/fingers still exploded). Rebuilt all 5 via new tooling/blender_rebuild_forks.py from Source-tier gltf; now 19 segments incl seg_hips, low-poly matching the healthy six. Bare-body QA (idle+walk, front+side, real CharacterVisual): all 5 coherent, head/hair attached, no explosion; healthy 6 untouched. Q-060: all 3 peasant garments Surface-Deform-fit onto all 5 forks (15/15 surface_deform, 0 shrinkwrap fallback) — Surface Deform HOLDS at the extremes on repaired bodies. No manifest change (body ids/paths unchanged).', 'review', 'high', NULL, 'client', NULL, '2026-07-06 10:38:16.593', '2026-07-06 20:02:31.012', NULL, '5cec3f5395cb96910151ae43d10076dc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKJ25KG3ADM4M5XRXCVR2GQG', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Child/teen bodies composite at adult height — shared-skeleton normalization discards stature', 'Found during T-1090 repair (2026-07-06): the shared-skeleton compositor (character_visual.gd binds every segment to the one Regular-sized armature by bone name) normalizes global scale — only cross-sectional shape survives, so thin reads narrow and heavy reads wide (correct) but child and teen stand at ADULT height. Pre-existing behavior, not a T-1090 regression. If stature matters (it will — children in crowds), options: per-body runtime skeleton, node-level scale on CharacterVisual, or bone-length retarget at load. Interacts with animation retargeting (shorter limbs vs shared UAL clips) and D-164. Sprint-28 verdict warns runtime BONE scaling fails — evaluate NODE scale first.', 'backlog', 'medium', NULL, 'client', NULL, '2026-07-06 20:04:59.392', '2026-07-06 20:04:59.392', NULL, '7ceb0fb9f90b36988d9d2b75765c9585', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'bug', '06FB0TNSRXX29H5X0EB0F0GBW4', '5 of 11 body types misrender bare in CharacterVisual (thin_f/m, heavy_f/m, child)', 'Found by the T-1089 Synty intake spike (2026-07-06), body-only controls with NO garment attached: thin_f/m, heavy_f/m, child misrender in CharacterVisual (exploded/spider arms, detached head/hair; child 303mm max warp, 87 collapsed edges; f-bodies bust-through). These are the newer high-poly bodies (no seg_hips, different segmentation) — exactly the 5 with no production peasant-clothing variants. Upstream of ANY clothing route; blocks T-1089''s 11-of-11 acceptance criterion and resolves part of Q-060. Evidence: spikes/synty-intake/out/bodies_fit_log.json + out/qa_bodies/ (44 screenshots) + out/bodies_recon.json.
|
||||
|
||||
RESOLVED (pending review). Root cause: fork bodies (thin/heavy/child) had a mesh<->armature mismatch. blender_segment_body.py apply_scale scaled mesh vertices ONLY, leaving the embedded armature at source scale; since character_visual.gd binds every segment onto ONE shared skeleton (skeleton/armature.glb) by bone name, each segment must be internally consistent with its own embedded armature — the mesh-only scale dropped the head ~0.35m below the Head bone and flung limbs apart on relocation. thin/heavy were additionally stale high-poly artifacts (torso 1419-1704v vs 375v) missing seg_hips entirely. Fix: replaced apply_scale with apply_fork_scale (tooling/blender_segment_body.py) which scales mesh AND armature together and BAKES via Blender transform_apply (edit-bone poking alone shears connected chains — arms/fingers still exploded). Rebuilt all 5 via new tooling/blender_rebuild_forks.py from Source-tier gltf; now 19 segments incl seg_hips, low-poly matching the healthy six. Bare-body QA (idle+walk, front+side, real CharacterVisual): all 5 coherent, head/hair attached, no explosion; healthy 6 untouched. Q-060: all 3 peasant garments Surface-Deform-fit onto all 5 forks (15/15 surface_deform, 0 shrinkwrap fallback) — Surface Deform HOLDS at the extremes on repaired bodies. No manifest change (body ids/paths unchanged).', 'done', 'high', NULL, 'client', NULL, '2026-07-06 10:38:16.593', '2026-07-06 20:06:22.078', NULL, 'dd9ef4733867c6166098027e49a44569', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 355 KiB |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"hides": ["torso", "arm_upper_l", "arm_upper_r"],
|
||||
"torso_variant": "full",
|
||||
"multi_region": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -54,6 +54,11 @@
|
||||
},
|
||||
"peasant_tunic": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"tshirt_modern": {
|
||||
"slot": "torso",
|
||||
"style": "modern",
|
||||
"logo_capable": true
|
||||
}
|
||||
},
|
||||
"accessories": []
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
shader_type spatial;
|
||||
render_mode unshaded;
|
||||
|
||||
// Multi-region recolorable garment shader (T-1089 gap G3 + G4 logo decal).
|
||||
//
|
||||
// Supersedes toon_masked.gdshader for CLOTHING: that shader routes a single
|
||||
// tint through recolor_mask.r only. This one channel-routes FOUR tints through
|
||||
// an RGBA region mask (R/G/B/A → tint_0..3), channel-BLENDED rather than
|
||||
// indexed-greyscale, so bilinear filtering cross-fades neighbouring regions
|
||||
// instead of smearing through wrong index levels at a boundary (feasibility G3).
|
||||
// A logo decal is then sampled on a dedicated UV2 (TEXCOORD_1) chest channel and
|
||||
// composited AFTER region tinting (brand colours stay faithful) but BEFORE the
|
||||
// toon shadow (the logo darkens with the fabric it sits on).
|
||||
//
|
||||
// Region-mask convention (authored by blender_author_offset_shell.py):
|
||||
// R = region 0 G = region 1 B = region 2 A = region 3
|
||||
// Per-texel channels partition to ~1.0; unmasked (all-zero) texels keep the
|
||||
// original fabric albedo untinted.
|
||||
|
||||
// Flat fabric base albedo (embedded in the garment GLB material).
|
||||
uniform sampler2D albedo_tex : source_color;
|
||||
// RGBA region mask, UV0-aligned. hint_default_black → a missing mask disables
|
||||
// tinting (falls back to the raw albedo) rather than tinting everything.
|
||||
uniform sampler2D region_mask : hint_default_black;
|
||||
// Logo decal, sampled on UV2. hint_default_transparent → an unbound logo
|
||||
// contributes nothing.
|
||||
uniform sampler2D logo_tex : source_color, hint_default_transparent;
|
||||
|
||||
// Per-region tints — fed from CharacterVisualDescriptor.clothing_tints[item][0..3].
|
||||
uniform vec4 tint_0 : source_color = vec4(0.80, 0.80, 0.80, 1.0);
|
||||
uniform vec4 tint_1 : source_color = vec4(0.80, 0.80, 0.80, 1.0);
|
||||
uniform vec4 tint_2 : source_color = vec4(0.80, 0.80, 0.80, 1.0);
|
||||
uniform vec4 tint_3 : source_color = vec4(0.80, 0.80, 0.80, 1.0);
|
||||
|
||||
// 0.0 = no logo (default; keeps every non-logo garment cheap), 1.0 = draw logo.
|
||||
uniform float logo_enabled : hint_range(0.0, 1.0) = 0.0;
|
||||
|
||||
// Toon shadow (same curve as toon_masked.gdshader for a consistent look).
|
||||
uniform float shadow_strength : hint_range(0.0, 1.0) = 0.2;
|
||||
uniform float shadow_threshold : hint_range(0.0, 1.0) = 0.3;
|
||||
|
||||
void fragment() {
|
||||
vec4 original = texture(albedo_tex, UV);
|
||||
vec4 m = texture(region_mask, UV);
|
||||
|
||||
// Luminance-preserving recolor: keep fabric shading detail, replace hue.
|
||||
float luma = dot(original.rgb, vec3(0.299, 0.587, 0.114));
|
||||
vec3 region_tint = m.r * tint_0.rgb
|
||||
+ m.g * tint_1.rgb
|
||||
+ m.b * tint_2.rgb
|
||||
+ m.a * tint_3.rgb;
|
||||
vec3 tinted = region_tint * (luma * 1.5 + 0.2); // scale luma to avoid too-dark
|
||||
// Blend toward the tint by total region weight; unmasked areas stay original.
|
||||
float region_weight = clamp(m.r + m.g + m.b + m.a, 0.0, 1.0);
|
||||
vec3 base = mix(original.rgb, tinted, region_weight);
|
||||
|
||||
// Logo decal on UV2, guarded to the authored [0,1] chest box.
|
||||
vec2 luv = UV2;
|
||||
float in_box = step(0.0, luv.x) * step(luv.x, 1.0)
|
||||
* step(0.0, luv.y) * step(luv.y, 1.0);
|
||||
vec4 logo = texture(logo_tex, luv);
|
||||
float logo_a = logo.a * in_box * logo_enabled;
|
||||
base = mix(base, logo.rgb, logo_a);
|
||||
|
||||
// Gentle toon shadow — subtle darkening on the shadow side.
|
||||
vec3 light_dir = normalize(vec3(0.3, -1.0, 0.5));
|
||||
float ndl = dot(NORMAL, -light_dir);
|
||||
float toon = smoothstep(shadow_threshold - 0.1, shadow_threshold + 0.1, ndl);
|
||||
ALBEDO = base * mix(1.0 - shadow_strength, 1.0, toon);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
## Tests for the T-1089 wardrobe engine: the toon_garment multi-region tint +
|
||||
## logo pipeline and the proof garment (tshirt_modern).
|
||||
##
|
||||
## Covers what is verifiable headless:
|
||||
## 1. The new toon_garment.gdshader loads/compiles.
|
||||
## 2. The region-mask CHANNEL MATH — pure GDScript replication of the shader's
|
||||
## RGBA→4-tint channel blend, asserting channel-blended (not indexed) routing.
|
||||
## 3. The manifest schema extension (style tag + logo_capable flag).
|
||||
## 4. coverage.json shape.
|
||||
## 5. Every fitted body variant loads as a skinned garment mesh.
|
||||
## 6. The logo + region-mask textures load.
|
||||
##
|
||||
## The shader itself runs on the GPU, so its arithmetic is re-implemented here in
|
||||
## GDScript and asserted against the same convention the .gdshader uses:
|
||||
## region_tint = m.r*t0 + m.g*t1 + m.b*t2 + m.a*t3
|
||||
## Ref: T-1089 gaps G3 (multi-region tint) + G4 (logo decal), D-251.
|
||||
class_name TestGarmentEngineT1089
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const SHADER_PATH := "res://assets/characters/shaders/toon_garment.gdshader"
|
||||
const CLOTHING_DIR := "res://assets/characters/clothing/tshirt_modern/"
|
||||
const LOGO_PATH := "res://assets/characters/logos/thrds.png"
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
|
||||
const FITTED_BODIES := [
|
||||
"average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f",
|
||||
]
|
||||
|
||||
|
||||
# --- 1. Shader loads -------------------------------------------------------
|
||||
|
||||
func test_toon_garment_shader_loads() -> void:
|
||||
assert_bool(ResourceLoader.exists(SHADER_PATH)).override_failure_message(
|
||||
"toon_garment.gdshader missing at %s" % SHADER_PATH
|
||||
).is_true()
|
||||
var shader := load(SHADER_PATH) as Shader
|
||||
assert_object(shader).is_not_null()
|
||||
assert_str(shader.code).contains("region_mask")
|
||||
assert_str(shader.code).contains("logo_tex")
|
||||
assert_str(shader.code).contains("UV2")
|
||||
|
||||
|
||||
func test_toon_garment_binds_to_material() -> void:
|
||||
# A ShaderMaterial with all four tints + logo params must accept them.
|
||||
var shader := load(SHADER_PATH) as Shader
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = shader
|
||||
mat.set_shader_parameter("tint_0", Color.RED)
|
||||
mat.set_shader_parameter("tint_1", Color.GREEN)
|
||||
mat.set_shader_parameter("tint_2", Color.BLUE)
|
||||
mat.set_shader_parameter("tint_3", Color.WHITE)
|
||||
mat.set_shader_parameter("logo_enabled", 1.0)
|
||||
assert_object(mat.get_shader_parameter("tint_2")).is_equal(Color.BLUE)
|
||||
|
||||
|
||||
# --- 2. Region-mask channel math (the testable shader logic) ---------------
|
||||
|
||||
## Pure-GDScript replication of the shader's per-texel region tint selection.
|
||||
func _region_tint(mask: Color, t0: Color, t1: Color, t2: Color, t3: Color) -> Color:
|
||||
var r := t0 * mask.r + t1 * mask.g + t2 * mask.b + t3 * mask.a
|
||||
return Color(r.r, r.g, r.b, 1.0)
|
||||
|
||||
|
||||
func test_pure_channel_routes_to_its_tint() -> void:
|
||||
var t0 := Color(0.1, 0.2, 0.3)
|
||||
var t1 := Color(0.4, 0.5, 0.6)
|
||||
var t2 := Color(0.7, 0.8, 0.9)
|
||||
var t3 := Color(0.0, 0.0, 0.0)
|
||||
# Pure collar (R) -> tint_0, body (G) -> tint_1, sleeve (B) -> tint_2
|
||||
assert_bool(_region_tint(Color(1, 0, 0, 0), t0, t1, t2, t3).is_equal_approx(t0)).is_true()
|
||||
assert_bool(_region_tint(Color(0, 1, 0, 0), t0, t1, t2, t3).is_equal_approx(t1)).is_true()
|
||||
assert_bool(_region_tint(Color(0, 0, 1, 0), t0, t1, t2, t3).is_equal_approx(t2)).is_true()
|
||||
|
||||
|
||||
func test_boundary_channel_blends_not_indexes() -> void:
|
||||
# The whole point of G3: a bilinear boundary texel (R=0.5,G=0.5) must produce
|
||||
# a MIDPOINT of the two tints — a crossfade — not snap to one index.
|
||||
var blended := _region_tint(
|
||||
Color(0.5, 0.5, 0.0, 0.0), Color(1, 0, 0), Color(0, 0, 1), Color.BLACK, Color.BLACK
|
||||
)
|
||||
assert_bool(blended.is_equal_approx(Color(0.5, 0.0, 0.5))).override_failure_message(
|
||||
"boundary texel must crossfade tints, got %s" % blended
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_zero_mask_contributes_no_tint() -> void:
|
||||
var out := _region_tint(Color(0, 0, 0, 0), Color.RED, Color.GREEN, Color.BLUE, Color.WHITE)
|
||||
assert_bool(out.is_equal_approx(Color.BLACK)).is_true()
|
||||
|
||||
|
||||
# --- 3. Manifest schema extension ------------------------------------------
|
||||
|
||||
func test_manifest_has_tshirt_with_style_and_logo_flag() -> void:
|
||||
var f := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||
assert_object(f).is_not_null()
|
||||
var data: Variant = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
assert_bool(data is Dictionary).is_true()
|
||||
var clothing: Dictionary = data["clothing"]
|
||||
assert_bool(clothing.has("tshirt_modern")).override_failure_message(
|
||||
"manifest.clothing missing tshirt_modern entry"
|
||||
).is_true()
|
||||
var entry: Dictionary = clothing["tshirt_modern"]
|
||||
assert_str(entry.get("slot", "")).is_equal("torso")
|
||||
assert_str(entry.get("style", "")).is_equal("modern")
|
||||
assert_bool(entry.get("logo_capable", false)).is_true()
|
||||
|
||||
|
||||
# --- 4. coverage.json ------------------------------------------------------
|
||||
|
||||
func test_coverage_json_shape() -> void:
|
||||
var path := CLOTHING_DIR + "coverage.json"
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
assert_object(f).is_not_null()
|
||||
var cov: Variant = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
assert_bool(cov is Dictionary).is_true()
|
||||
assert_bool((cov as Dictionary).has("hides")).is_true()
|
||||
assert_array((cov as Dictionary)["hides"]).contains(["torso"])
|
||||
assert_str((cov as Dictionary).get("torso_variant", "")).is_equal("full")
|
||||
|
||||
|
||||
# --- 5. Fitted variants load as skinned garment meshes ---------------------
|
||||
|
||||
func test_all_fitted_bodies_load_skinned() -> void:
|
||||
for body in FITTED_BODIES:
|
||||
var glb := CLOTHING_DIR + "%s.glb" % body
|
||||
assert_bool(ResourceLoader.exists(glb)).override_failure_message(
|
||||
"fitted variant missing: %s" % glb
|
||||
).is_true()
|
||||
var scene := load(glb) as PackedScene
|
||||
assert_object(scene).override_failure_message(
|
||||
"variant %s failed to load as PackedScene" % body
|
||||
).is_not_null()
|
||||
var inst := scene.instantiate()
|
||||
var skinned := _has_skinned_mesh(inst)
|
||||
inst.queue_free()
|
||||
assert_bool(skinned).override_failure_message(
|
||||
"variant %s has no skinned MeshInstance3D (cannot animate)" % body
|
||||
).is_true()
|
||||
|
||||
|
||||
func _has_skinned_mesh(root: Node) -> bool:
|
||||
var stack: Array[Node] = [root]
|
||||
while not stack.is_empty():
|
||||
var n: Node = stack.pop_back()
|
||||
for c in n.get_children():
|
||||
stack.append(c)
|
||||
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# --- 6. Decal + mask textures load -----------------------------------------
|
||||
|
||||
func test_logo_and_mask_textures_load() -> void:
|
||||
assert_bool(ResourceLoader.exists(LOGO_PATH)).is_true()
|
||||
assert_bool(ResourceLoader.exists(CLOTHING_DIR + "reference_mask.png")).is_true()
|
||||
var logo := load(LOGO_PATH) as Texture2D
|
||||
assert_object(logo).is_not_null()
|
||||
var mask := load(CLOTHING_DIR + "reference_mask.png") as Texture2D
|
||||
assert_object(mask).is_not_null()
|
||||
@@ -0,0 +1,196 @@
|
||||
extends Node3D
|
||||
## Garment preview capture (T-1089 G3/G4 visual QA).
|
||||
##
|
||||
## Renders the fitted t-shirt worn on a body with the NEW toon_garment.gdshader:
|
||||
## RGBA-mask 4-region tints + a logo decal on the UV2 chest channel. This proves
|
||||
## the shader + region mask + logo pipeline end-to-end WITHOUT editing
|
||||
## character_visual.gd (which a parallel agent owns) — the garment meshes are
|
||||
## attached to the CharacterVisual skeleton and the shader is applied here as a
|
||||
## material_override, exactly the surface the lead's _apply_clothing_shader patch
|
||||
## will drive at runtime.
|
||||
##
|
||||
## Config via env vars (all optional):
|
||||
## GARMENT_PREVIEW_ITEM default "tshirt_modern"
|
||||
## GARMENT_PREVIEW_BODY default "average_m"
|
||||
## GARMENT_PREVIEW_OUT default "<repo>/.cache/garment-preview"
|
||||
##
|
||||
## Run: godot4 --path client --rendering-driver opengl3 \
|
||||
## res://tools/garment_preview/preview_scene.tscn
|
||||
|
||||
const CLOTHING_DIR := "res://assets/characters/clothing/"
|
||||
const LOGO_PATH := "res://assets/characters/logos/thrds.png"
|
||||
const SHADER_PATH := "res://assets/characters/shaders/toon_garment.gdshader"
|
||||
const VIEWPORT := Vector2i(768, 1024)
|
||||
|
||||
const BODY_KEY_TO_ENUM := {
|
||||
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
|
||||
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
|
||||
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
|
||||
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
|
||||
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
|
||||
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
|
||||
}
|
||||
|
||||
# Modern casual palette: light collar, teal body, charcoal sleeves.
|
||||
const TINT_COLLAR := Color(0.92, 0.93, 0.95) # R region
|
||||
const TINT_BODY := Color(0.15, 0.42, 0.48) # G region
|
||||
const TINT_SLEEVE := Color(0.20, 0.22, 0.26) # B region
|
||||
|
||||
var _out_dir := ""
|
||||
var _cam: Camera3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var item := _env("GARMENT_PREVIEW_ITEM", "tshirt_modern")
|
||||
var body := _env("GARMENT_PREVIEW_BODY", "average_m")
|
||||
_out_dir = _env("GARMENT_PREVIEW_OUT",
|
||||
"/var/mnt/data/projects/settled-reach/.cache/garment-preview")
|
||||
get_window().size = VIEWPORT
|
||||
_setup_stage()
|
||||
_run.call_deferred(item, body)
|
||||
|
||||
|
||||
func _env(key: String, fallback: String) -> String:
|
||||
var v := OS.get_environment(key)
|
||||
return v if not v.is_empty() else fallback
|
||||
|
||||
|
||||
func _setup_stage() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-38, 28, 0)
|
||||
light.light_energy = 1.3
|
||||
add_child(light)
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.82, 0.82, 0.86)
|
||||
env.ambient_light_energy = 0.9
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.16, 0.17, 0.20)
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
_cam = Camera3D.new()
|
||||
_cam.fov = 45.0
|
||||
add_child(_cam)
|
||||
# Frame the upper body so the chest logo + region tints read clearly.
|
||||
_cam.position = Vector3(0.0, 1.25, 1.7)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0.0, 1.15, 0.0), Vector3.UP)
|
||||
|
||||
|
||||
func _run(item: String, body: String) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
if not BODY_KEY_TO_ENUM.has(body):
|
||||
push_error("preview: unknown body %s" % body)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var cv := CharacterVisual.new()
|
||||
add_child(cv)
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = BODY_KEY_TO_ENUM[body]
|
||||
desc.head_id = "head_001"
|
||||
desc.hair_id = "buzzed"
|
||||
desc.eyebrow_id = "regular"
|
||||
desc.skin_tone = 3
|
||||
cv.load_descriptor(desc)
|
||||
|
||||
var skel := cv.get_skeleton()
|
||||
if skel == null:
|
||||
push_error("preview: no skeleton")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var meshes := _attach_garment(skel, item, body)
|
||||
print("preview: attached %d garment meshes" % meshes.size())
|
||||
var shader := load(SHADER_PATH) as Shader
|
||||
var mask := _try_load(CLOTHING_DIR + "%s/reference_mask.png" % item)
|
||||
var logo_path := _env("GARMENT_PREVIEW_LOGO", LOGO_PATH)
|
||||
var logo := _try_load(logo_path)
|
||||
for mi in meshes:
|
||||
_apply_garment_shader(mi, shader, mask, logo)
|
||||
|
||||
# Freeze on a clean rest-ish pose (idle) so the shirt reads without motion blur.
|
||||
var ap := cv.get_animation_player()
|
||||
if ap != null:
|
||||
var walk := _resolve_clip(ap, "Walk")
|
||||
if not walk.is_empty():
|
||||
cv.play_animation(walk)
|
||||
ap.pause()
|
||||
ap.seek(ap.current_animation_length * 0.25, true)
|
||||
await get_tree().process_frame
|
||||
|
||||
# Front + 3/4 + side + back so we can see the logo wherever the chest lands.
|
||||
for yaw in [0, 45, 180, 315]:
|
||||
cv.rotation_degrees.y = float(yaw)
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var fname := "%s__%s__yaw%03d.png" % [item, body, yaw]
|
||||
get_viewport().get_texture().get_image().save_png(_out_dir.path_join(fname))
|
||||
print("preview: wrote ", fname)
|
||||
|
||||
print("preview: DONE")
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
func _attach_garment(skel: Skeleton3D, item: String, body: String) -> Array:
|
||||
var out: Array = []
|
||||
var glb := CLOTHING_DIR + "%s/%s.glb" % [item, body]
|
||||
if not ResourceLoader.exists(glb):
|
||||
push_error("preview: garment GLB missing %s" % glb)
|
||||
return out
|
||||
var inst := (load(glb) as PackedScene).instantiate()
|
||||
var stack: Array[Node] = [inst]
|
||||
while not stack.is_empty():
|
||||
var n: Node = stack.pop_back()
|
||||
for c in n.get_children():
|
||||
stack.append(c)
|
||||
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
|
||||
var mi := n as MeshInstance3D
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
skel.add_child(mi)
|
||||
out.append(mi)
|
||||
inst.queue_free()
|
||||
return out
|
||||
|
||||
|
||||
func _apply_garment_shader(mi: MeshInstance3D, shader: Shader, mask: Texture2D, logo: Texture2D) -> void:
|
||||
if mi.mesh == null or shader == null:
|
||||
return
|
||||
var albedo := _albedo_of(mi)
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = shader
|
||||
if albedo:
|
||||
mat.set_shader_parameter("albedo_tex", albedo)
|
||||
if mask:
|
||||
mat.set_shader_parameter("region_mask", mask)
|
||||
if logo:
|
||||
mat.set_shader_parameter("logo_tex", logo)
|
||||
mat.set_shader_parameter("logo_enabled", 1.0)
|
||||
mat.set_shader_parameter("tint_0", TINT_COLLAR)
|
||||
mat.set_shader_parameter("tint_1", TINT_BODY)
|
||||
mat.set_shader_parameter("tint_2", TINT_SLEEVE)
|
||||
mat.set_shader_parameter("tint_3", Color.WHITE)
|
||||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||||
mi.material_override = mat
|
||||
|
||||
|
||||
func _albedo_of(mi: MeshInstance3D) -> Texture2D:
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
var m := mi.mesh.surface_get_material(surf)
|
||||
if m is BaseMaterial3D:
|
||||
return (m as BaseMaterial3D).albedo_texture
|
||||
return null
|
||||
|
||||
|
||||
func _try_load(path: String) -> Texture2D:
|
||||
return load(path) as Texture2D if ResourceLoader.exists(path) else null
|
||||
|
||||
|
||||
func _resolve_clip(ap: AnimationPlayer, requested: String) -> String:
|
||||
for lib_name in ap.get_animation_library_list():
|
||||
var lib := ap.get_animation_library(lib_name)
|
||||
for a in lib.get_animation_list():
|
||||
if str(a).to_lower().contains(requested.to_lower()):
|
||||
return str(a)
|
||||
return ""
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c8rtprev0garm1"]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/garment_preview/preview_scene.gd" id="1_prev"]
|
||||
|
||||
[node name="GarmentPreview" type="Node3D"]
|
||||
script = ExtResource("1_prev")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||