Files
settled-reach/client/tools/garment_preview/preview_scene.gd
T
jpmschweitzerandClaude Fable 5 e22ea0fa4a 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>
2026-07-06 22:21:33 +02:00

197 lines
6.6 KiB
GDScript

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 ""