diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 83b066263..154603702 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index f0b71e781..2525b5b87 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -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); diff --git a/client/assets/characters/clothing/tshirt_modern/average_f.glb b/client/assets/characters/clothing/tshirt_modern/average_f.glb new file mode 100644 index 000000000..88e1c77e6 Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_f.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/average_f_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/average_f_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_f_base_albedo.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/average_m.glb b/client/assets/characters/clothing/tshirt_modern/average_m.glb new file mode 100644 index 000000000..428b37a64 Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_m.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/average_m_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/average_m_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_m_base_albedo.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/coverage.json b/client/assets/characters/clothing/tshirt_modern/coverage.json new file mode 100644 index 000000000..7e202d849 --- /dev/null +++ b/client/assets/characters/clothing/tshirt_modern/coverage.json @@ -0,0 +1,5 @@ +{ + "hides": ["torso", "arm_upper_l", "arm_upper_r"], + "torso_variant": "full", + "multi_region": true +} diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_f.glb b/client/assets/characters/clothing/tshirt_modern/muscular_f.glb new file mode 100644 index 000000000..aea595c46 Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_f.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/muscular_f_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_f_base_albedo.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_m.glb b/client/assets/characters/clothing/tshirt_modern/muscular_m.glb new file mode 100644 index 000000000..37912e2cd Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_m.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/muscular_m_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_m_base_albedo.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/reference_mask.png b/client/assets/characters/clothing/tshirt_modern/reference_mask.png new file mode 100644 index 000000000..022146ea9 Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/reference_mask.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/teen_f.glb b/client/assets/characters/clothing/tshirt_modern/teen_f.glb new file mode 100644 index 000000000..8d17fe59f Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_f.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/teen_f_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_f_base_albedo.png differ diff --git a/client/assets/characters/clothing/tshirt_modern/teen_m.glb b/client/assets/characters/clothing/tshirt_modern/teen_m.glb new file mode 100644 index 000000000..b69a29e61 Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_m.glb differ diff --git a/client/assets/characters/clothing/tshirt_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/teen_m_base_albedo.png new file mode 100644 index 000000000..ee1fb5f5b Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_m_base_albedo.png differ diff --git a/client/assets/characters/logos/thrds.png b/client/assets/characters/logos/thrds.png new file mode 100644 index 000000000..d6f86f06f Binary files /dev/null and b/client/assets/characters/logos/thrds.png differ diff --git a/client/assets/characters/manifest.json b/client/assets/characters/manifest.json index eef2de609..84a0a1947 100644 --- a/client/assets/characters/manifest.json +++ b/client/assets/characters/manifest.json @@ -54,6 +54,11 @@ }, "peasant_tunic": { "slot": "torso" + }, + "tshirt_modern": { + "slot": "torso", + "style": "modern", + "logo_capable": true } }, "accessories": [] diff --git a/client/assets/characters/shaders/toon_garment.gdshader b/client/assets/characters/shaders/toon_garment.gdshader new file mode 100644 index 000000000..ff8a25343 --- /dev/null +++ b/client/assets/characters/shaders/toon_garment.gdshader @@ -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); +} diff --git a/client/tests/test_garment_engine_t1089.gd b/client/tests/test_garment_engine_t1089.gd new file mode 100644 index 000000000..b3ac8ecf5 --- /dev/null +++ b/client/tests/test_garment_engine_t1089.gd @@ -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() diff --git a/client/tools/garment_preview/preview_scene.gd b/client/tools/garment_preview/preview_scene.gd new file mode 100644 index 000000000..164991ff7 --- /dev/null +++ b/client/tools/garment_preview/preview_scene.gd @@ -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 "/.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 "" diff --git a/client/tools/garment_preview/preview_scene.tscn b/client/tools/garment_preview/preview_scene.tscn new file mode 100644 index 000000000..f7b12b28b --- /dev/null +++ b/client/tools/garment_preview/preview_scene.tscn @@ -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") diff --git a/tooling/garment-fit/blender_author_offset_shell.py b/tooling/garment-fit/blender_author_offset_shell.py new file mode 100644 index 000000000..fe04a76c3 --- /dev/null +++ b/tooling/garment-fit/blender_author_offset_shell.py @@ -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 -- \ + /average_m [--offset 0.012] [--sleeve-frac 0.40] + +Writes: + /average_m.glb reference garment (skinned) + /reference_mask.png RGBA region mask (UV0-aligned) + /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: -- /average_m " + "[--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() diff --git a/tooling/garment-fit/blender_batch_fit_skinned.py b/tooling/garment-fit/blender_batch_fit_skinned.py new file mode 100644 index 000000000..f2c5a226c --- /dev/null +++ b/tooling/garment-fit/blender_batch_fit_skinned.py @@ -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 -- \ + \ + [--only average_m,average_f,...] [--solidify 0.0] [--self-check] + +Output: + /.glb one skinned variant per fitted body type + /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(' 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: -- " + "[--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() diff --git a/tooling/garment-fit/make_logo.py b/tooling/garment-fit/make_logo.py new file mode 100644 index 000000000..9fd65a866 --- /dev/null +++ b/tooling/garment-fit/make_logo.py @@ -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 [--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 [--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) diff --git a/tooling/garment-qa/configs/tshirt_modern.json b/tooling/garment-qa/configs/tshirt_modern.json new file mode 100644 index 000000000..24b52b3e6 --- /dev/null +++ b/tooling/garment-qa/configs/tshirt_modern.json @@ -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" +}