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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:21:33 +02:00
co-authored by Claude Fable 5
parent b54b8189d9
commit e22ea0fa4a
26 changed files with 1496 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

@@ -0,0 +1,5 @@
{
"hides": ["torso", "arm_upper_l", "arm_upper_r"],
"torso_variant": "full",
"multi_region": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+5
View File
@@ -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);
}
+162
View File
@@ -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")