diff --git a/client/assets/characters/clothing/buttondown_modern/average_f.glb b/client/assets/characters/clothing/buttondown_modern/average_f.glb
new file mode 100644
index 000000000..982c216e8
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/average_f_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/average_f_base_albedo.png
new file mode 100644
index 000000000..0348b1f02
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/average_f_mask.png b/client/assets/characters/clothing/buttondown_modern/average_f_mask.png
new file mode 100644
index 000000000..62dbafa02
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/average_m.glb b/client/assets/characters/clothing/buttondown_modern/average_m.glb
new file mode 100644
index 000000000..7d814a545
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/average_m_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/average_m_base_albedo.png
new file mode 100644
index 000000000..d13f4c50e
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/average_m_mask.png b/client/assets/characters/clothing/buttondown_modern/average_m_mask.png
new file mode 100644
index 000000000..3c8486ff2
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/base_albedo.png b/client/assets/characters/clothing/buttondown_modern/base_albedo.png
new file mode 100644
index 000000000..d13f4c50e
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/child.glb b/client/assets/characters/clothing/buttondown_modern/child.glb
new file mode 100644
index 000000000..8d1a40f86
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/child.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/child_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/child_base_albedo.png
new file mode 100644
index 000000000..13700bb97
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/child_mask.png b/client/assets/characters/clothing/buttondown_modern/child_mask.png
new file mode 100644
index 000000000..7d37bdaee
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/coverage.json b/client/assets/characters/clothing/buttondown_modern/coverage.json
new file mode 100644
index 000000000..d72d0c9ac
--- /dev/null
+++ b/client/assets/characters/clothing/buttondown_modern/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["torso", "torso_upper", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_f.glb b/client/assets/characters/clothing/buttondown_modern/heavy_f.glb
new file mode 100644
index 000000000..f6d35491c
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..133f0fe26
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_f_mask.png b/client/assets/characters/clothing/buttondown_modern/heavy_f_mask.png
new file mode 100644
index 000000000..4f7d910ea
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_m.glb b/client/assets/characters/clothing/buttondown_modern/heavy_m.glb
new file mode 100644
index 000000000..0bb032fc4
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..ffa271275
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/heavy_m_mask.png b/client/assets/characters/clothing/buttondown_modern/heavy_m_mask.png
new file mode 100644
index 000000000..48bb96b5d
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_f.glb b/client/assets/characters/clothing/buttondown_modern/muscular_f.glb
new file mode 100644
index 000000000..e305f547a
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/muscular_f_base_albedo.png
new file mode 100644
index 000000000..05e5ab37a
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_f_mask.png b/client/assets/characters/clothing/buttondown_modern/muscular_f_mask.png
new file mode 100644
index 000000000..6c21992aa
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_m.glb b/client/assets/characters/clothing/buttondown_modern/muscular_m.glb
new file mode 100644
index 000000000..0b0d99490
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/muscular_m_base_albedo.png
new file mode 100644
index 000000000..36d29ccdf
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/muscular_m_mask.png b/client/assets/characters/clothing/buttondown_modern/muscular_m_mask.png
new file mode 100644
index 000000000..b9c365e07
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/reference_mask.png b/client/assets/characters/clothing/buttondown_modern/reference_mask.png
new file mode 100644
index 000000000..3c8486ff2
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/reference_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_f.glb b/client/assets/characters/clothing/buttondown_modern/teen_f.glb
new file mode 100644
index 000000000..92ef3be11
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/teen_f_base_albedo.png
new file mode 100644
index 000000000..864fe4514
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_f_mask.png b/client/assets/characters/clothing/buttondown_modern/teen_f_mask.png
new file mode 100644
index 000000000..6dbd75c2b
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_m.glb b/client/assets/characters/clothing/buttondown_modern/teen_m.glb
new file mode 100644
index 000000000..c63af8729
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/teen_m_base_albedo.png
new file mode 100644
index 000000000..170cb92a5
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/teen_m_mask.png b/client/assets/characters/clothing/buttondown_modern/teen_m_mask.png
new file mode 100644
index 000000000..f6142f7af
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_f.glb b/client/assets/characters/clothing/buttondown_modern/thin_f.glb
new file mode 100644
index 000000000..53b16473f
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..1e94c9938
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_f_mask.png b/client/assets/characters/clothing/buttondown_modern/thin_f_mask.png
new file mode 100644
index 000000000..6269f2c82
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_m.glb b/client/assets/characters/clothing/buttondown_modern/thin_m.glb
new file mode 100644
index 000000000..90a113dc1
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/buttondown_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..c28b5342b
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/buttondown_modern/thin_m_mask.png b/client/assets/characters/clothing/buttondown_modern/thin_m_mask.png
new file mode 100644
index 000000000..9e479ec3c
Binary files /dev/null and b/client/assets/characters/clothing/buttondown_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_f.glb b/client/assets/characters/clothing/hoodie_modern/average_f.glb
new file mode 100644
index 000000000..41dab2141
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_f_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/average_f_base_albedo.png
new file mode 100644
index 000000000..618beeb51
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_f_mask.png b/client/assets/characters/clothing/hoodie_modern/average_f_mask.png
new file mode 100644
index 000000000..e9332d0a8
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_m.glb b/client/assets/characters/clothing/hoodie_modern/average_m.glb
new file mode 100644
index 000000000..b0224c37b
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_m_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/average_m_base_albedo.png
new file mode 100644
index 000000000..6d0bf70aa
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/average_m_mask.png b/client/assets/characters/clothing/hoodie_modern/average_m_mask.png
new file mode 100644
index 000000000..b0c3bf6ef
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/base_albedo.png b/client/assets/characters/clothing/hoodie_modern/base_albedo.png
new file mode 100644
index 000000000..6d0bf70aa
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/child.glb b/client/assets/characters/clothing/hoodie_modern/child.glb
new file mode 100644
index 000000000..92cb80a3a
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/child.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/child_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/child_base_albedo.png
new file mode 100644
index 000000000..26d59c03b
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/child_mask.png b/client/assets/characters/clothing/hoodie_modern/child_mask.png
new file mode 100644
index 000000000..92a28b1f4
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/coverage.json b/client/assets/characters/clothing/hoodie_modern/coverage.json
new file mode 100644
index 000000000..9c2d866d2
--- /dev/null
+++ b/client/assets/characters/clothing/hoodie_modern/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["torso", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_f.glb b/client/assets/characters/clothing/hoodie_modern/heavy_f.glb
new file mode 100644
index 000000000..eb538c273
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..03b7e5f6b
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_f_mask.png b/client/assets/characters/clothing/hoodie_modern/heavy_f_mask.png
new file mode 100644
index 000000000..a370a4a97
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_m.glb b/client/assets/characters/clothing/hoodie_modern/heavy_m.glb
new file mode 100644
index 000000000..bf775fb15
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..e776b2516
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/heavy_m_mask.png b/client/assets/characters/clothing/hoodie_modern/heavy_m_mask.png
new file mode 100644
index 000000000..d1da889cc
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_f.glb b/client/assets/characters/clothing/hoodie_modern/muscular_f.glb
new file mode 100644
index 000000000..efc36d77e
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/muscular_f_base_albedo.png
new file mode 100644
index 000000000..71cd037be
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_f_mask.png b/client/assets/characters/clothing/hoodie_modern/muscular_f_mask.png
new file mode 100644
index 000000000..1c88bc0fe
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_m.glb b/client/assets/characters/clothing/hoodie_modern/muscular_m.glb
new file mode 100644
index 000000000..8fa6ec45f
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/muscular_m_base_albedo.png
new file mode 100644
index 000000000..11350c39e
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/muscular_m_mask.png b/client/assets/characters/clothing/hoodie_modern/muscular_m_mask.png
new file mode 100644
index 000000000..b298dd17c
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/reference_mask.png b/client/assets/characters/clothing/hoodie_modern/reference_mask.png
new file mode 100644
index 000000000..b0c3bf6ef
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/reference_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_f.glb b/client/assets/characters/clothing/hoodie_modern/teen_f.glb
new file mode 100644
index 000000000..603136c27
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/teen_f_base_albedo.png
new file mode 100644
index 000000000..a54c8e06f
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_f_mask.png b/client/assets/characters/clothing/hoodie_modern/teen_f_mask.png
new file mode 100644
index 000000000..9793ca1f5
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_m.glb b/client/assets/characters/clothing/hoodie_modern/teen_m.glb
new file mode 100644
index 000000000..23bc44f83
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/teen_m_base_albedo.png
new file mode 100644
index 000000000..e19f7b4d9
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/teen_m_mask.png b/client/assets/characters/clothing/hoodie_modern/teen_m_mask.png
new file mode 100644
index 000000000..ef00693f1
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_f.glb b/client/assets/characters/clothing/hoodie_modern/thin_f.glb
new file mode 100644
index 000000000..7f7e98e8d
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..0334c0ca6
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_f_mask.png b/client/assets/characters/clothing/hoodie_modern/thin_f_mask.png
new file mode 100644
index 000000000..50a693756
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_m.glb b/client/assets/characters/clothing/hoodie_modern/thin_m.glb
new file mode 100644
index 000000000..4abc34103
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/hoodie_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..3ecdfae89
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/hoodie_modern/thin_m_mask.png b/client/assets/characters/clothing/hoodie_modern/thin_m_mask.png
new file mode 100644
index 000000000..8293f6e0d
Binary files /dev/null and b/client/assets/characters/clothing/hoodie_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_f.glb b/client/assets/characters/clothing/jacket_modern/average_f.glb
new file mode 100644
index 000000000..4cbb40416
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_f_base_albedo.png b/client/assets/characters/clothing/jacket_modern/average_f_base_albedo.png
new file mode 100644
index 000000000..7dab99feb
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_f_mask.png b/client/assets/characters/clothing/jacket_modern/average_f_mask.png
new file mode 100644
index 000000000..56d6ea303
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_m.glb b/client/assets/characters/clothing/jacket_modern/average_m.glb
new file mode 100644
index 000000000..69845e862
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_m_base_albedo.png b/client/assets/characters/clothing/jacket_modern/average_m_base_albedo.png
new file mode 100644
index 000000000..b7a872f75
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/average_m_mask.png b/client/assets/characters/clothing/jacket_modern/average_m_mask.png
new file mode 100644
index 000000000..e980b773c
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/base_albedo.png b/client/assets/characters/clothing/jacket_modern/base_albedo.png
new file mode 100644
index 000000000..b7a872f75
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/child.glb b/client/assets/characters/clothing/jacket_modern/child.glb
new file mode 100644
index 000000000..4745e46f2
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/child.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/child_base_albedo.png b/client/assets/characters/clothing/jacket_modern/child_base_albedo.png
new file mode 100644
index 000000000..c30b324ff
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/child_mask.png b/client/assets/characters/clothing/jacket_modern/child_mask.png
new file mode 100644
index 000000000..15f568bd6
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/coverage.json b/client/assets/characters/clothing/jacket_modern/coverage.json
new file mode 100644
index 000000000..9c2d866d2
--- /dev/null
+++ b/client/assets/characters/clothing/jacket_modern/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["torso", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_f.glb b/client/assets/characters/clothing/jacket_modern/heavy_f.glb
new file mode 100644
index 000000000..68efb2486
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/jacket_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..ea3d33e0c
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_f_mask.png b/client/assets/characters/clothing/jacket_modern/heavy_f_mask.png
new file mode 100644
index 000000000..2993abc4b
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_m.glb b/client/assets/characters/clothing/jacket_modern/heavy_m.glb
new file mode 100644
index 000000000..4f067991b
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/jacket_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..28b597ef1
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/heavy_m_mask.png b/client/assets/characters/clothing/jacket_modern/heavy_m_mask.png
new file mode 100644
index 000000000..230ce20f8
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_f.glb b/client/assets/characters/clothing/jacket_modern/muscular_f.glb
new file mode 100644
index 000000000..9efb90c5a
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/jacket_modern/muscular_f_base_albedo.png
new file mode 100644
index 000000000..fb44ee78f
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_f_mask.png b/client/assets/characters/clothing/jacket_modern/muscular_f_mask.png
new file mode 100644
index 000000000..d1af3a256
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_m.glb b/client/assets/characters/clothing/jacket_modern/muscular_m.glb
new file mode 100644
index 000000000..74ef42d25
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/jacket_modern/muscular_m_base_albedo.png
new file mode 100644
index 000000000..e7d389035
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/muscular_m_mask.png b/client/assets/characters/clothing/jacket_modern/muscular_m_mask.png
new file mode 100644
index 000000000..2a0a9bdab
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/reference_mask.png b/client/assets/characters/clothing/jacket_modern/reference_mask.png
new file mode 100644
index 000000000..e980b773c
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/reference_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_f.glb b/client/assets/characters/clothing/jacket_modern/teen_f.glb
new file mode 100644
index 000000000..04c38c82f
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/jacket_modern/teen_f_base_albedo.png
new file mode 100644
index 000000000..9a27fd7b9
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_f_mask.png b/client/assets/characters/clothing/jacket_modern/teen_f_mask.png
new file mode 100644
index 000000000..4f3ffbdae
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_m.glb b/client/assets/characters/clothing/jacket_modern/teen_m.glb
new file mode 100644
index 000000000..9be266703
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/jacket_modern/teen_m_base_albedo.png
new file mode 100644
index 000000000..2ad9179ed
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/teen_m_mask.png b/client/assets/characters/clothing/jacket_modern/teen_m_mask.png
new file mode 100644
index 000000000..2fbcf17e9
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_f.glb b/client/assets/characters/clothing/jacket_modern/thin_f.glb
new file mode 100644
index 000000000..c1366b5d3
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/jacket_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..a9df15393
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_f_mask.png b/client/assets/characters/clothing/jacket_modern/thin_f_mask.png
new file mode 100644
index 000000000..f58ea6513
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_m.glb b/client/assets/characters/clothing/jacket_modern/thin_m.glb
new file mode 100644
index 000000000..37a60eb83
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/jacket_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..0227038f2
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jacket_modern/thin_m_mask.png b/client/assets/characters/clothing/jacket_modern/thin_m_mask.png
new file mode 100644
index 000000000..d5e447e5a
Binary files /dev/null and b/client/assets/characters/clothing/jacket_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_f.glb b/client/assets/characters/clothing/jeans_modern/average_f.glb
new file mode 100644
index 000000000..edc2c2712
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_f_base_albedo.png b/client/assets/characters/clothing/jeans_modern/average_f_base_albedo.png
new file mode 100644
index 000000000..5dd109790
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_f_mask.png b/client/assets/characters/clothing/jeans_modern/average_f_mask.png
new file mode 100644
index 000000000..822557a63
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_m.glb b/client/assets/characters/clothing/jeans_modern/average_m.glb
new file mode 100644
index 000000000..e0a7100bc
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_m_base_albedo.png b/client/assets/characters/clothing/jeans_modern/average_m_base_albedo.png
new file mode 100644
index 000000000..3e79b15b8
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/average_m_mask.png b/client/assets/characters/clothing/jeans_modern/average_m_mask.png
new file mode 100644
index 000000000..aaec0fc5c
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/base_albedo.png b/client/assets/characters/clothing/jeans_modern/base_albedo.png
new file mode 100644
index 000000000..3e79b15b8
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/child.glb b/client/assets/characters/clothing/jeans_modern/child.glb
new file mode 100644
index 000000000..4150c1321
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/child.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/child_base_albedo.png b/client/assets/characters/clothing/jeans_modern/child_base_albedo.png
new file mode 100644
index 000000000..66b6d5a4c
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/child_mask.png b/client/assets/characters/clothing/jeans_modern/child_mask.png
new file mode 100644
index 000000000..d44abfc56
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/coverage.json b/client/assets/characters/clothing/jeans_modern/coverage.json
new file mode 100644
index 000000000..00e827118
--- /dev/null
+++ b/client/assets/characters/clothing/jeans_modern/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["hips", "leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_f.glb b/client/assets/characters/clothing/jeans_modern/heavy_f.glb
new file mode 100644
index 000000000..d25a8bd65
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/jeans_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..36519b93b
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_f_mask.png b/client/assets/characters/clothing/jeans_modern/heavy_f_mask.png
new file mode 100644
index 000000000..d16a0b37e
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_m.glb b/client/assets/characters/clothing/jeans_modern/heavy_m.glb
new file mode 100644
index 000000000..b97099056
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/jeans_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..f1b9e1f68
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/heavy_m_mask.png b/client/assets/characters/clothing/jeans_modern/heavy_m_mask.png
new file mode 100644
index 000000000..56178c000
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_f.glb b/client/assets/characters/clothing/jeans_modern/muscular_f.glb
new file mode 100644
index 000000000..674875be3
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/jeans_modern/muscular_f_base_albedo.png
new file mode 100644
index 000000000..07d6aeb46
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_f_mask.png b/client/assets/characters/clothing/jeans_modern/muscular_f_mask.png
new file mode 100644
index 000000000..67c39da83
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_m.glb b/client/assets/characters/clothing/jeans_modern/muscular_m.glb
new file mode 100644
index 000000000..152a54aae
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/jeans_modern/muscular_m_base_albedo.png
new file mode 100644
index 000000000..17f564e18
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/muscular_m_mask.png b/client/assets/characters/clothing/jeans_modern/muscular_m_mask.png
new file mode 100644
index 000000000..9142b14f7
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/reference_mask.png b/client/assets/characters/clothing/jeans_modern/reference_mask.png
new file mode 100644
index 000000000..aaec0fc5c
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/reference_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_f.glb b/client/assets/characters/clothing/jeans_modern/teen_f.glb
new file mode 100644
index 000000000..493aa2ecf
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/jeans_modern/teen_f_base_albedo.png
new file mode 100644
index 000000000..87566f6d6
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_f_mask.png b/client/assets/characters/clothing/jeans_modern/teen_f_mask.png
new file mode 100644
index 000000000..14f729c61
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_m.glb b/client/assets/characters/clothing/jeans_modern/teen_m.glb
new file mode 100644
index 000000000..3e67cc360
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/jeans_modern/teen_m_base_albedo.png
new file mode 100644
index 000000000..7b5d1c72c
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/teen_m_mask.png b/client/assets/characters/clothing/jeans_modern/teen_m_mask.png
new file mode 100644
index 000000000..0b26e8385
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_f.glb b/client/assets/characters/clothing/jeans_modern/thin_f.glb
new file mode 100644
index 000000000..2cb611ecc
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/jeans_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..9b08389cc
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_f_mask.png b/client/assets/characters/clothing/jeans_modern/thin_f_mask.png
new file mode 100644
index 000000000..c65963304
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_m.glb b/client/assets/characters/clothing/jeans_modern/thin_m.glb
new file mode 100644
index 000000000..06400b64d
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/jeans_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..0bb6b7c05
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/jeans_modern/thin_m_mask.png b/client/assets/characters/clothing/jeans_modern/thin_m_mask.png
new file mode 100644
index 000000000..f6e695c23
Binary files /dev/null and b/client/assets/characters/clothing/jeans_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/average_f.glb b/client/assets/characters/clothing/pants_formal/average_f.glb
new file mode 100644
index 000000000..fb18e1b09
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_f.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/average_f_base_albedo.png b/client/assets/characters/clothing/pants_formal/average_f_base_albedo.png
new file mode 100644
index 000000000..322c9efff
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/average_f_mask.png b/client/assets/characters/clothing/pants_formal/average_f_mask.png
new file mode 100644
index 000000000..431247ec7
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/average_m.glb b/client/assets/characters/clothing/pants_formal/average_m.glb
new file mode 100644
index 000000000..2de13f95b
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_m.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/average_m_base_albedo.png b/client/assets/characters/clothing/pants_formal/average_m_base_albedo.png
new file mode 100644
index 000000000..94dd6ced9
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/average_m_mask.png b/client/assets/characters/clothing/pants_formal/average_m_mask.png
new file mode 100644
index 000000000..0e5f76d01
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/base_albedo.png b/client/assets/characters/clothing/pants_formal/base_albedo.png
new file mode 100644
index 000000000..94dd6ced9
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/child.glb b/client/assets/characters/clothing/pants_formal/child.glb
new file mode 100644
index 000000000..1cce694ec
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/child.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/child_base_albedo.png b/client/assets/characters/clothing/pants_formal/child_base_albedo.png
new file mode 100644
index 000000000..46dd08513
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/child_mask.png b/client/assets/characters/clothing/pants_formal/child_mask.png
new file mode 100644
index 000000000..92733ea72
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/child_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/coverage.json b/client/assets/characters/clothing/pants_formal/coverage.json
new file mode 100644
index 000000000..00e827118
--- /dev/null
+++ b/client/assets/characters/clothing/pants_formal/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["hips", "leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/pants_formal/heavy_f.glb b/client/assets/characters/clothing/pants_formal/heavy_f.glb
new file mode 100644
index 000000000..0759c03e7
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/heavy_f_base_albedo.png b/client/assets/characters/clothing/pants_formal/heavy_f_base_albedo.png
new file mode 100644
index 000000000..8ceccc273
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/heavy_f_mask.png b/client/assets/characters/clothing/pants_formal/heavy_f_mask.png
new file mode 100644
index 000000000..b11f4916e
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/heavy_m.glb b/client/assets/characters/clothing/pants_formal/heavy_m.glb
new file mode 100644
index 000000000..a74b4685d
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/heavy_m_base_albedo.png b/client/assets/characters/clothing/pants_formal/heavy_m_base_albedo.png
new file mode 100644
index 000000000..e29900b16
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/heavy_m_mask.png b/client/assets/characters/clothing/pants_formal/heavy_m_mask.png
new file mode 100644
index 000000000..134eeb945
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_f.glb b/client/assets/characters/clothing/pants_formal/muscular_f.glb
new file mode 100644
index 000000000..58088a2f9
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_f_base_albedo.png b/client/assets/characters/clothing/pants_formal/muscular_f_base_albedo.png
new file mode 100644
index 000000000..a5ff16810
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_f_mask.png b/client/assets/characters/clothing/pants_formal/muscular_f_mask.png
new file mode 100644
index 000000000..0d64c78fa
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_m.glb b/client/assets/characters/clothing/pants_formal/muscular_m.glb
new file mode 100644
index 000000000..e385d3d3d
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_m_base_albedo.png b/client/assets/characters/clothing/pants_formal/muscular_m_base_albedo.png
new file mode 100644
index 000000000..c1c3060ce
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/muscular_m_mask.png b/client/assets/characters/clothing/pants_formal/muscular_m_mask.png
new file mode 100644
index 000000000..dbd7f6a66
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/reference_mask.png b/client/assets/characters/clothing/pants_formal/reference_mask.png
new file mode 100644
index 000000000..0e5f76d01
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/reference_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_f.glb b/client/assets/characters/clothing/pants_formal/teen_f.glb
new file mode 100644
index 000000000..19986b8c9
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_f.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_f_base_albedo.png b/client/assets/characters/clothing/pants_formal/teen_f_base_albedo.png
new file mode 100644
index 000000000..3ef10c32a
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_f_mask.png b/client/assets/characters/clothing/pants_formal/teen_f_mask.png
new file mode 100644
index 000000000..fea3e988f
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_m.glb b/client/assets/characters/clothing/pants_formal/teen_m.glb
new file mode 100644
index 000000000..6b44529a4
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_m.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_m_base_albedo.png b/client/assets/characters/clothing/pants_formal/teen_m_base_albedo.png
new file mode 100644
index 000000000..c30bc98bd
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/teen_m_mask.png b/client/assets/characters/clothing/pants_formal/teen_m_mask.png
new file mode 100644
index 000000000..e56909d13
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_f.glb b/client/assets/characters/clothing/pants_formal/thin_f.glb
new file mode 100644
index 000000000..3b92d821f
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_f.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_f_base_albedo.png b/client/assets/characters/clothing/pants_formal/thin_f_base_albedo.png
new file mode 100644
index 000000000..4e93caebc
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_f_mask.png b/client/assets/characters/clothing/pants_formal/thin_f_mask.png
new file mode 100644
index 000000000..c4fae8b94
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_m.glb b/client/assets/characters/clothing/pants_formal/thin_m.glb
new file mode 100644
index 000000000..90703e186
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_m.glb differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_m_base_albedo.png b/client/assets/characters/clothing/pants_formal/thin_m_base_albedo.png
new file mode 100644
index 000000000..cf117c1ed
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/pants_formal/thin_m_mask.png b/client/assets/characters/clothing/pants_formal/thin_m_mask.png
new file mode 100644
index 000000000..0e5f76d01
Binary files /dev/null and b/client/assets/characters/clothing/pants_formal/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_f.glb b/client/assets/characters/clothing/shorts_modern/average_f.glb
new file mode 100644
index 000000000..b0304e385
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_f_base_albedo.png b/client/assets/characters/clothing/shorts_modern/average_f_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_f_mask.png b/client/assets/characters/clothing/shorts_modern/average_f_mask.png
new file mode 100644
index 000000000..d8aa65c72
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_m.glb b/client/assets/characters/clothing/shorts_modern/average_m.glb
new file mode 100644
index 000000000..5a7a427ce
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_m_base_albedo.png b/client/assets/characters/clothing/shorts_modern/average_m_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/average_m_mask.png b/client/assets/characters/clothing/shorts_modern/average_m_mask.png
new file mode 100644
index 000000000..82e2cdb56
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/base_albedo.png b/client/assets/characters/clothing/shorts_modern/base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/child.glb b/client/assets/characters/clothing/shorts_modern/child.glb
new file mode 100644
index 000000000..9eb35af6b
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/child.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/child_base_albedo.png b/client/assets/characters/clothing/shorts_modern/child_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/child_mask.png b/client/assets/characters/clothing/shorts_modern/child_mask.png
new file mode 100644
index 000000000..70ef68663
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/coverage.json b/client/assets/characters/clothing/shorts_modern/coverage.json
new file mode 100644
index 000000000..aa1db5d24
--- /dev/null
+++ b/client/assets/characters/clothing/shorts_modern/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["hips", "leg_upper_l", "leg_upper_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_f.glb b/client/assets/characters/clothing/shorts_modern/heavy_f.glb
new file mode 100644
index 000000000..6907b1cad
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/shorts_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_f_mask.png b/client/assets/characters/clothing/shorts_modern/heavy_f_mask.png
new file mode 100644
index 000000000..d8aa65c72
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_m.glb b/client/assets/characters/clothing/shorts_modern/heavy_m.glb
new file mode 100644
index 000000000..ed32feb68
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/shorts_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/heavy_m_mask.png b/client/assets/characters/clothing/shorts_modern/heavy_m_mask.png
new file mode 100644
index 000000000..82e2cdb56
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_f.glb b/client/assets/characters/clothing/shorts_modern/muscular_f.glb
new file mode 100644
index 000000000..633aa9591
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_f_base_albedo.png b/client/assets/characters/clothing/shorts_modern/muscular_f_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_f_mask.png b/client/assets/characters/clothing/shorts_modern/muscular_f_mask.png
new file mode 100644
index 000000000..fbac6d67d
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_m.glb b/client/assets/characters/clothing/shorts_modern/muscular_m.glb
new file mode 100644
index 000000000..3a8a812e0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_m_base_albedo.png b/client/assets/characters/clothing/shorts_modern/muscular_m_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/muscular_m_mask.png b/client/assets/characters/clothing/shorts_modern/muscular_m_mask.png
new file mode 100644
index 000000000..40dd39728
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/reference_mask.png b/client/assets/characters/clothing/shorts_modern/reference_mask.png
new file mode 100644
index 000000000..82e2cdb56
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/reference_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_f.glb b/client/assets/characters/clothing/shorts_modern/teen_f.glb
new file mode 100644
index 000000000..c4b5e6ae3
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_f_base_albedo.png b/client/assets/characters/clothing/shorts_modern/teen_f_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_f_mask.png b/client/assets/characters/clothing/shorts_modern/teen_f_mask.png
new file mode 100644
index 000000000..86eb2401a
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_m.glb b/client/assets/characters/clothing/shorts_modern/teen_m.glb
new file mode 100644
index 000000000..da8378289
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_m_base_albedo.png b/client/assets/characters/clothing/shorts_modern/teen_m_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/teen_m_mask.png b/client/assets/characters/clothing/shorts_modern/teen_m_mask.png
new file mode 100644
index 000000000..70ef68663
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_f.glb b/client/assets/characters/clothing/shorts_modern/thin_f.glb
new file mode 100644
index 000000000..a30174576
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/shorts_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_f_mask.png b/client/assets/characters/clothing/shorts_modern/thin_f_mask.png
new file mode 100644
index 000000000..d8aa65c72
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_m.glb b/client/assets/characters/clothing/shorts_modern/thin_m.glb
new file mode 100644
index 000000000..543491e98
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/shorts_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..b40c3cec0
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/shorts_modern/thin_m_mask.png b/client/assets/characters/clothing/shorts_modern/thin_m_mask.png
new file mode 100644
index 000000000..82e2cdb56
Binary files /dev/null and b/client/assets/characters/clothing/shorts_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_f.glb b/client/assets/characters/clothing/suit_jacket_black/average_f.glb
new file mode 100644
index 000000000..518a15cd8
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_f.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_f_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/average_f_base_albedo.png
new file mode 100644
index 000000000..ac40587e7
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_f_mask.png b/client/assets/characters/clothing/suit_jacket_black/average_f_mask.png
new file mode 100644
index 000000000..4f3b0d490
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_m.glb b/client/assets/characters/clothing/suit_jacket_black/average_m.glb
new file mode 100644
index 000000000..7b3e99d7f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_m.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_m_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/average_m_base_albedo.png
new file mode 100644
index 000000000..eda3991f9
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/average_m_mask.png b/client/assets/characters/clothing/suit_jacket_black/average_m_mask.png
new file mode 100644
index 000000000..f4fd5bd98
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/base_albedo.png
new file mode 100644
index 000000000..eda3991f9
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/child.glb b/client/assets/characters/clothing/suit_jacket_black/child.glb
new file mode 100644
index 000000000..1cc7dc08d
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/child.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/child_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/child_base_albedo.png
new file mode 100644
index 000000000..aa8fd1260
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/child_mask.png b/client/assets/characters/clothing/suit_jacket_black/child_mask.png
new file mode 100644
index 000000000..a830ba68b
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/child_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/coverage.json b/client/assets/characters/clothing/suit_jacket_black/coverage.json
new file mode 100644
index 000000000..d72d0c9ac
--- /dev/null
+++ b/client/assets/characters/clothing/suit_jacket_black/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["torso", "torso_upper", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_f.glb b/client/assets/characters/clothing/suit_jacket_black/heavy_f.glb
new file mode 100644
index 000000000..bca2a6b68
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_f_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/heavy_f_base_albedo.png
new file mode 100644
index 000000000..3b32ec227
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_f_mask.png b/client/assets/characters/clothing/suit_jacket_black/heavy_f_mask.png
new file mode 100644
index 000000000..1e6c5463a
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_m.glb b/client/assets/characters/clothing/suit_jacket_black/heavy_m.glb
new file mode 100644
index 000000000..713c60c19
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_m_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/heavy_m_base_albedo.png
new file mode 100644
index 000000000..69784a75d
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/heavy_m_mask.png b/client/assets/characters/clothing/suit_jacket_black/heavy_m_mask.png
new file mode 100644
index 000000000..e98828f4f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_f.glb b/client/assets/characters/clothing/suit_jacket_black/muscular_f.glb
new file mode 100644
index 000000000..e7eadea85
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_f_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/muscular_f_base_albedo.png
new file mode 100644
index 000000000..1ea0d64b5
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_f_mask.png b/client/assets/characters/clothing/suit_jacket_black/muscular_f_mask.png
new file mode 100644
index 000000000..0082079b5
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_m.glb b/client/assets/characters/clothing/suit_jacket_black/muscular_m.glb
new file mode 100644
index 000000000..98437778b
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_m_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/muscular_m_base_albedo.png
new file mode 100644
index 000000000..674ba1f52
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/muscular_m_mask.png b/client/assets/characters/clothing/suit_jacket_black/muscular_m_mask.png
new file mode 100644
index 000000000..a3b312d79
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/reference_mask.png b/client/assets/characters/clothing/suit_jacket_black/reference_mask.png
new file mode 100644
index 000000000..f4fd5bd98
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/reference_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_f.glb b/client/assets/characters/clothing/suit_jacket_black/teen_f.glb
new file mode 100644
index 000000000..291f34b7f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_f.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_f_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/teen_f_base_albedo.png
new file mode 100644
index 000000000..70d84114f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_f_mask.png b/client/assets/characters/clothing/suit_jacket_black/teen_f_mask.png
new file mode 100644
index 000000000..9222403a6
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_m.glb b/client/assets/characters/clothing/suit_jacket_black/teen_m.glb
new file mode 100644
index 000000000..30b7009b6
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_m.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_m_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/teen_m_base_albedo.png
new file mode 100644
index 000000000..561e2d612
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/teen_m_mask.png b/client/assets/characters/clothing/suit_jacket_black/teen_m_mask.png
new file mode 100644
index 000000000..c329274a7
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_f.glb b/client/assets/characters/clothing/suit_jacket_black/thin_f.glb
new file mode 100644
index 000000000..fc24e7139
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_f.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_f_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/thin_f_base_albedo.png
new file mode 100644
index 000000000..4832f635f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_f_mask.png b/client/assets/characters/clothing/suit_jacket_black/thin_f_mask.png
new file mode 100644
index 000000000..e62214dde
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_m.glb b/client/assets/characters/clothing/suit_jacket_black/thin_m.glb
new file mode 100644
index 000000000..2ba81e10b
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_m.glb differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_m_base_albedo.png b/client/assets/characters/clothing/suit_jacket_black/thin_m_base_albedo.png
new file mode 100644
index 000000000..8df54f74f
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/suit_jacket_black/thin_m_mask.png b/client/assets/characters/clothing/suit_jacket_black/thin_m_mask.png
new file mode 100644
index 000000000..65ad2be80
Binary files /dev/null and b/client/assets/characters/clothing/suit_jacket_black/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/average_f.glb b/client/assets/characters/clothing/tshirt_modern/average_f.glb
index 88e1c77e6..aeb9da5ec 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/average_f.glb and b/client/assets/characters/clothing/tshirt_modern/average_f.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/average_f_mask.png b/client/assets/characters/clothing/tshirt_modern/average_f_mask.png
new file mode 100644
index 000000000..161404470
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/average_m.glb b/client/assets/characters/clothing/tshirt_modern/average_m.glb
index 428b37a64..cd627b4c6 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/average_m.glb and b/client/assets/characters/clothing/tshirt_modern/average_m.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/average_m_mask.png b/client/assets/characters/clothing/tshirt_modern/average_m_mask.png
new file mode 100644
index 000000000..b32b44002
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/base_albedo.png b/client/assets/characters/clothing/tshirt_modern/base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/child.glb b/client/assets/characters/clothing/tshirt_modern/child.glb
new file mode 100644
index 000000000..1117960e6
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/child.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/child_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/child_base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/child_mask.png b/client/assets/characters/clothing/tshirt_modern/child_mask.png
new file mode 100644
index 000000000..85f3921d3
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/child_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_f.glb b/client/assets/characters/clothing/tshirt_modern/heavy_f.glb
new file mode 100644
index 000000000..5ee26b131
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_f_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/heavy_f_base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_f_mask.png b/client/assets/characters/clothing/tshirt_modern/heavy_f_mask.png
new file mode 100644
index 000000000..dc0391984
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_m.glb b/client/assets/characters/clothing/tshirt_modern/heavy_m.glb
new file mode 100644
index 000000000..af0ebf76a
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_m_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/heavy_m_base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/heavy_m_mask.png b/client/assets/characters/clothing/tshirt_modern/heavy_m_mask.png
new file mode 100644
index 000000000..6b46c270a
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_f.glb b/client/assets/characters/clothing/tshirt_modern/muscular_f.glb
index aea595c46..4d5f075af 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/muscular_f.glb and b/client/assets/characters/clothing/tshirt_modern/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_f_mask.png b/client/assets/characters/clothing/tshirt_modern/muscular_f_mask.png
new file mode 100644
index 000000000..439416236
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_m.glb b/client/assets/characters/clothing/tshirt_modern/muscular_m.glb
index 37912e2cd..6283f7273 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/muscular_m.glb and b/client/assets/characters/clothing/tshirt_modern/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/muscular_m_mask.png b/client/assets/characters/clothing/tshirt_modern/muscular_m_mask.png
new file mode 100644
index 000000000..aaf492e5f
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/reference_mask.png b/client/assets/characters/clothing/tshirt_modern/reference_mask.png
index 022146ea9..b32b44002 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/reference_mask.png 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
index 8d17fe59f..0bc75938c 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/teen_f.glb and b/client/assets/characters/clothing/tshirt_modern/teen_f.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/teen_f_mask.png b/client/assets/characters/clothing/tshirt_modern/teen_f_mask.png
new file mode 100644
index 000000000..4ed8fbc64
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/teen_m.glb b/client/assets/characters/clothing/tshirt_modern/teen_m.glb
index b69a29e61..580534828 100644
Binary files a/client/assets/characters/clothing/tshirt_modern/teen_m.glb and b/client/assets/characters/clothing/tshirt_modern/teen_m.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/teen_m_mask.png b/client/assets/characters/clothing/tshirt_modern/teen_m_mask.png
new file mode 100644
index 000000000..c508818bb
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_f.glb b/client/assets/characters/clothing/tshirt_modern/thin_f.glb
new file mode 100644
index 000000000..8d3bc4bac
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_f.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_f_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/thin_f_base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_f_mask.png b/client/assets/characters/clothing/tshirt_modern/thin_f_mask.png
new file mode 100644
index 000000000..1e9dd8aca
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_m.glb b/client/assets/characters/clothing/tshirt_modern/thin_m.glb
new file mode 100644
index 000000000..f23038f2c
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_m.glb differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_m_base_albedo.png b/client/assets/characters/clothing/tshirt_modern/thin_m_base_albedo.png
new file mode 100644
index 000000000..ee1fb5f5b
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/tshirt_modern/thin_m_mask.png b/client/assets/characters/clothing/tshirt_modern/thin_m_mask.png
new file mode 100644
index 000000000..a5c8dc0bf
Binary files /dev/null and b/client/assets/characters/clothing/tshirt_modern/thin_m_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_f.glb b/client/assets/characters/clothing/uniform_utility/average_f.glb
new file mode 100644
index 000000000..d2497fc22
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_f.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_f_base_albedo.png b/client/assets/characters/clothing/uniform_utility/average_f_base_albedo.png
new file mode 100644
index 000000000..75f0b8090
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_f_mask.png b/client/assets/characters/clothing/uniform_utility/average_f_mask.png
new file mode 100644
index 000000000..f62a1be1e
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_f_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_m.glb b/client/assets/characters/clothing/uniform_utility/average_m.glb
new file mode 100644
index 000000000..d6e26ae69
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_m.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_m_base_albedo.png b/client/assets/characters/clothing/uniform_utility/average_m_base_albedo.png
new file mode 100644
index 000000000..55efcb31d
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/average_m_mask.png b/client/assets/characters/clothing/uniform_utility/average_m_mask.png
new file mode 100644
index 000000000..f23637d25
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/average_m_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/base_albedo.png b/client/assets/characters/clothing/uniform_utility/base_albedo.png
new file mode 100644
index 000000000..55efcb31d
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/child.glb b/client/assets/characters/clothing/uniform_utility/child.glb
new file mode 100644
index 000000000..8e13b0f4f
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/child.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/child_base_albedo.png b/client/assets/characters/clothing/uniform_utility/child_base_albedo.png
new file mode 100644
index 000000000..26e19d000
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/child_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/child_mask.png b/client/assets/characters/clothing/uniform_utility/child_mask.png
new file mode 100644
index 000000000..9869cb87c
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/child_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/coverage.json b/client/assets/characters/clothing/uniform_utility/coverage.json
new file mode 100644
index 000000000..8c9167667
--- /dev/null
+++ b/client/assets/characters/clothing/uniform_utility/coverage.json
@@ -0,0 +1,5 @@
+{
+ "hides": ["torso", "torso_upper", "hips", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r", "leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r"],
+ "torso_variant": "full",
+ "multi_region": true
+}
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_f.glb b/client/assets/characters/clothing/uniform_utility/heavy_f.glb
new file mode 100644
index 000000000..5da4cf4db
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_f.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_f_base_albedo.png b/client/assets/characters/clothing/uniform_utility/heavy_f_base_albedo.png
new file mode 100644
index 000000000..2429a5c91
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_f_mask.png b/client/assets/characters/clothing/uniform_utility/heavy_f_mask.png
new file mode 100644
index 000000000..e2a17638c
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_f_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_m.glb b/client/assets/characters/clothing/uniform_utility/heavy_m.glb
new file mode 100644
index 000000000..821ee03d9
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_m.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_m_base_albedo.png b/client/assets/characters/clothing/uniform_utility/heavy_m_base_albedo.png
new file mode 100644
index 000000000..ab8068dc8
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/heavy_m_mask.png b/client/assets/characters/clothing/uniform_utility/heavy_m_mask.png
new file mode 100644
index 000000000..db35445bf
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/heavy_m_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_f.glb b/client/assets/characters/clothing/uniform_utility/muscular_f.glb
new file mode 100644
index 000000000..009124127
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_f.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_f_base_albedo.png b/client/assets/characters/clothing/uniform_utility/muscular_f_base_albedo.png
new file mode 100644
index 000000000..4fe9bc184
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_f_mask.png b/client/assets/characters/clothing/uniform_utility/muscular_f_mask.png
new file mode 100644
index 000000000..39a46d814
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_f_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_m.glb b/client/assets/characters/clothing/uniform_utility/muscular_m.glb
new file mode 100644
index 000000000..e8e30f9e4
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_m.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_m_base_albedo.png b/client/assets/characters/clothing/uniform_utility/muscular_m_base_albedo.png
new file mode 100644
index 000000000..5fe12a653
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/muscular_m_mask.png b/client/assets/characters/clothing/uniform_utility/muscular_m_mask.png
new file mode 100644
index 000000000..e17c067d6
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/muscular_m_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/reference_mask.png b/client/assets/characters/clothing/uniform_utility/reference_mask.png
new file mode 100644
index 000000000..f23637d25
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/reference_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_f.glb b/client/assets/characters/clothing/uniform_utility/teen_f.glb
new file mode 100644
index 000000000..5dfee4572
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_f.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_f_base_albedo.png b/client/assets/characters/clothing/uniform_utility/teen_f_base_albedo.png
new file mode 100644
index 000000000..0ed3ab4c7
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_f_mask.png b/client/assets/characters/clothing/uniform_utility/teen_f_mask.png
new file mode 100644
index 000000000..18d9225c3
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_f_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_m.glb b/client/assets/characters/clothing/uniform_utility/teen_m.glb
new file mode 100644
index 000000000..55c561671
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_m.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_m_base_albedo.png b/client/assets/characters/clothing/uniform_utility/teen_m_base_albedo.png
new file mode 100644
index 000000000..0b28c261a
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/teen_m_mask.png b/client/assets/characters/clothing/uniform_utility/teen_m_mask.png
new file mode 100644
index 000000000..b267e9020
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/teen_m_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_f.glb b/client/assets/characters/clothing/uniform_utility/thin_f.glb
new file mode 100644
index 000000000..d22e91151
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_f.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_f_base_albedo.png b/client/assets/characters/clothing/uniform_utility/thin_f_base_albedo.png
new file mode 100644
index 000000000..5dff2e153
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_f_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_f_mask.png b/client/assets/characters/clothing/uniform_utility/thin_f_mask.png
new file mode 100644
index 000000000..9d7b16fcb
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_f_mask.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_m.glb b/client/assets/characters/clothing/uniform_utility/thin_m.glb
new file mode 100644
index 000000000..b110a3a92
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_m.glb differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_m_base_albedo.png b/client/assets/characters/clothing/uniform_utility/thin_m_base_albedo.png
new file mode 100644
index 000000000..70cb2012d
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_m_base_albedo.png differ
diff --git a/client/assets/characters/clothing/uniform_utility/thin_m_mask.png b/client/assets/characters/clothing/uniform_utility/thin_m_mask.png
new file mode 100644
index 000000000..052301d5c
Binary files /dev/null and b/client/assets/characters/clothing/uniform_utility/thin_m_mask.png differ
diff --git a/client/assets/characters/manifest.json b/client/assets/characters/manifest.json
index 84a0a1947..0ce29da1b 100644
--- a/client/assets/characters/manifest.json
+++ b/client/assets/characters/manifest.json
@@ -59,6 +59,84 @@
"slot": "torso",
"style": "modern",
"logo_capable": true
+ },
+ "hoodie_modern": {
+ "slot": "torso",
+ "style": "modern",
+ "logo_capable": true
+ },
+ "buttondown_modern": {
+ "slot": "torso",
+ "style": "modern",
+ "logo_capable": false
+ },
+ "shorts_modern": {
+ "slot": "legs",
+ "style": "modern",
+ "logo_capable": false
+ },
+ "jeans_modern": {
+ "slot": "legs",
+ "style": "modern",
+ "logo_capable": false
+ },
+ "pants_formal": {
+ "slot": "legs",
+ "style": "formal",
+ "logo_capable": false
+ },
+ "jacket_modern": {
+ "slot": "torso",
+ "style": "modern",
+ "logo_capable": true
+ },
+ "suit_jacket_black": {
+ "slot": "torso",
+ "style": "formal",
+ "logo_capable": false,
+ "regions": {
+ "R": "lapels_collar",
+ "G": "jacket_body",
+ "B": "sleeves",
+ "A": "shirt_triangle"
+ },
+ "default_tints": [
+ [
+ 0.13,
+ 0.13,
+ 0.16,
+ 1
+ ],
+ [
+ 0.09,
+ 0.09,
+ 0.11,
+ 1
+ ],
+ [
+ 0.09,
+ 0.09,
+ 0.11,
+ 1
+ ],
+ [
+ 0.93,
+ 0.93,
+ 0.96,
+ 1
+ ]
+ ]
+ },
+ "uniform_utility": {
+ "slot": "full_body",
+ "style": "utility",
+ "logo_capable": true,
+ "default_tints": [
+ "#3b474f",
+ "#6b8280",
+ "#d18c2e",
+ "#d9dee0"
+ ]
}
},
"accessories": []
diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd
index 26cc19dc0..5b120b109 100644
--- a/client/scripts/rendering/character_visual.gd
+++ b/client/scripts/rendering/character_visual.gd
@@ -586,8 +586,12 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void:
if coverage.get("torso_variant") == "upper":
_active_torso_variant = "upper"
- # Load recolor mask: shared across all body-type variants (same UV layout after Surface Deform).
- var mask_path := item_dir + "reference_mask.png"
+ # Load recolor mask: per-body-authored garments (offset-shell route) ship
+ # one mask per body type; SD-fit garments share reference_mask.png
+ # (same UV layout across variants after Surface Deform).
+ var mask_path := item_dir + "%s_mask.png" % desc.body_type_key()
+ if not ResourceLoader.exists(mask_path):
+ mask_path = item_dir + "reference_mask.png"
var mask_tex: Texture2D = null
if ResourceLoader.exists(mask_path):
mask_tex = load(mask_path) as Texture2D
diff --git a/client/tests/test_garment_engine_t1089.gd b/client/tests/test_garment_engine_t1089.gd
index b3ac8ecf5..59c82f5bb 100644
--- a/client/tests/test_garment_engine_t1089.gd
+++ b/client/tests/test_garment_engine_t1089.gd
@@ -22,8 +22,11 @@ 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"
+## All 11 body types — tshirt_modern is authored PER BODY (offset-shell mode,
+## T-1089): each body gets its own
.glb + _mask.png.
const FITTED_BODIES := [
- "average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f",
+ "average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
+ "heavy_m", "heavy_f", "teen_m", "teen_f", "child",
]
@@ -155,8 +158,23 @@ func _has_skinned_mesh(root: Node) -> bool:
func test_logo_and_mask_textures_load() -> void:
assert_bool(ResourceLoader.exists(LOGO_PATH)).is_true()
+ # Runtime fallback mask (SD-fit garments + safety net) must still exist.
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()
+
+
+func test_per_body_masks_load() -> void:
+ # Per-body offset-shell authoring: the compositor prefers
+ # _mask.png over reference_mask.png (character_visual.gd).
+ for body in FITTED_BODIES:
+ var mask_path := CLOTHING_DIR + "%s_mask.png" % body
+ assert_bool(ResourceLoader.exists(mask_path)).override_failure_message(
+ "per-body region mask missing: %s" % mask_path
+ ).is_true()
+ var mask := load(mask_path) as Texture2D
+ assert_object(mask).override_failure_message(
+ "per-body region mask failed to load: %s" % mask_path
+ ).is_not_null()
diff --git a/client/tools/garment_preview/preview_buttondown_modern.gd b/client/tools/garment_preview/preview_buttondown_modern.gd
new file mode 100644
index 000000000..13da5fb01
--- /dev/null
+++ b/client/tools/garment_preview/preview_buttondown_modern.gd
@@ -0,0 +1,187 @@
+extends Node3D
+## Buttondown preview capture (T-1089 buttondown_modern) — per-garment copy of
+## preview_scene.gd (same convention as preview_shorts_modern.gd): oxford
+## palette (white collar/cuffs/placket over light-blue body), body-aware mask
+## loading (_mask.png first, mirroring runtime), no logo (logo_capable
+## OFF), front + 3/4 walk yaws only.
+##
+## Config via env vars (all optional):
+## GARMENT_PREVIEW_ITEM default "buttondown_modern"
+## GARMENT_PREVIEW_BODY default "average_m"
+## GARMENT_PREVIEW_OUT default "/.cache/garment-preview"
+
+const CLOTHING_DIR := "res://assets/characters/clothing/"
+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,
+}
+
+# Winchester oxford: white collar + white cuffs/placket over light-blue body.
+const TINT_COLLAR := Color(0.93, 0.94, 0.96) # R region
+const TINT_BODY := Color(0.55, 0.68, 0.82) # G region
+const TINT_CUFF := Color(0.90, 0.91, 0.94) # B region (cuffs + placket)
+
+var _out_dir := ""
+var _cam: Camera3D = null
+
+
+func _ready() -> void:
+ var item := _env("GARMENT_PREVIEW_ITEM", "buttondown_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 placket/buttons/collar/cuffs 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
+ # Per-body-authored garment: prefer _mask.png (runtime behaviour).
+ var mask := _try_load(CLOTHING_DIR + "%s/%s_mask.png" % [item, body])
+ if mask == null:
+ mask = _try_load(CLOTHING_DIR + "%s/reference_mask.png" % item)
+ for mi in meshes:
+ _apply_garment_shader(mi, shader, mask)
+
+ # Freeze on a walk pose so the shirt reads in motion.
+ 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 (task spec: two preview renders).
+ for yaw in [0, 45]:
+ 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) -> 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)
+ mat.set_shader_parameter("tint_0", TINT_COLLAR)
+ mat.set_shader_parameter("tint_1", TINT_BODY)
+ mat.set_shader_parameter("tint_2", TINT_CUFF)
+ mat.set_shader_parameter("tint_3", Color.WHITE)
+ mat.set_shader_parameter("logo_enabled", 0.0)
+ 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_buttondown_modern.tscn b/client/tools/garment_preview/preview_buttondown_modern.tscn
new file mode 100644
index 000000000..5bc4438fd
--- /dev/null
+++ b/client/tools/garment_preview/preview_buttondown_modern.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://tools/garment_preview/preview_buttondown_modern.gd" id="1_bdprev"]
+
+[node name="ButtondownPreview" type="Node3D"]
+script = ExtResource("1_bdprev")
diff --git a/client/tools/garment_preview/preview_shorts_modern.gd b/client/tools/garment_preview/preview_shorts_modern.gd
new file mode 100644
index 000000000..e57d54762
--- /dev/null
+++ b/client/tools/garment_preview/preview_shorts_modern.gd
@@ -0,0 +1,185 @@
+extends Node3D
+## shorts_modern preview capture (T-1089 basic set) — parameterized copy of
+## preview_scene.gd for a LOWER-BODY 2-region garment: per-body region mask
+## (reference fallback), no logo, camera framed on hips + legs, front + 3/4
+## Walk renders. Temporary tool scene — safe to delete after captures.
+##
+## Config via env vars (all optional):
+## GARMENT_PREVIEW_ITEM default "shorts_modern"
+## GARMENT_PREVIEW_BODY default "average_m"
+## GARMENT_PREVIEW_OUT default "/.cache/garment-preview/shorts_modern"
+
+const CLOTHING_DIR := "res://assets/characters/clothing/"
+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: charcoal waistband, muted olive body. tint_2/3 unused.
+const TINT_WAISTBAND := Color(0.13, 0.14, 0.16) # R region
+const TINT_BODY := Color(0.42, 0.47, 0.38) # G region
+
+var _out_dir := ""
+var _cam: Camera3D = null
+
+
+func _ready() -> void:
+ var item := _env("GARMENT_PREVIEW_ITEM", "shorts_modern")
+ var body := _env("GARMENT_PREVIEW_BODY", "average_m")
+ _out_dir = _env("GARMENT_PREVIEW_OUT",
+ "/var/mnt/data/projects/settled-reach/.cache/garment-preview/shorts_modern")
+ 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 hips + legs so the waistband and hem read clearly.
+ _cam.position = Vector3(0.0, 0.82, 1.7)
+ _cam.look_at_from_position(_cam.position, Vector3(0.0, 0.72, 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
+ # Per-body region mask (offset-shell route); reference fallback mirrors
+ # the runtime compositor.
+ var mask := _try_load(CLOTHING_DIR + "%s/%s_mask.png" % [item, body])
+ if mask == null:
+ mask = _try_load(CLOTHING_DIR + "%s/reference_mask.png" % item)
+ for mi in meshes:
+ _apply_garment_shader(mi, shader, mask)
+
+ # Freeze on a mid-stride Walk pose so hem + waistband read under motion.
+ 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 (task deliverable: two preview renders).
+ for yaw in [0, 45]:
+ 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) -> 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)
+ mat.set_shader_parameter("tint_0", TINT_WAISTBAND)
+ mat.set_shader_parameter("tint_1", TINT_BODY)
+ mat.set_shader_parameter("tint_2", Color.WHITE)
+ 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_shorts_modern.tscn b/client/tools/garment_preview/preview_shorts_modern.tscn
new file mode 100644
index 000000000..8cb35a8cf
--- /dev/null
+++ b/client/tools/garment_preview/preview_shorts_modern.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://tools/garment_preview/preview_shorts_modern.gd" id="1_prevs"]
+
+[node name="GarmentPreviewShorts" type="Node3D"]
+script = ExtResource("1_prevs")
diff --git a/client/tools/garment_preview/preview_uniform_utility.gd b/client/tools/garment_preview/preview_uniform_utility.gd
new file mode 100644
index 000000000..6d415f740
--- /dev/null
+++ b/client/tools/garment_preview/preview_uniform_utility.gd
@@ -0,0 +1,208 @@
+extends Node3D
+## uniform_utility preview capture (T-1089 route (c) colorable-uniform proof).
+##
+## Full-body variant of preview_scene.gd for the utility coverall: whole-figure
+## camera framing (the garment runs collar to ankle), per-body region mask
+## preference (_mask.png, the per-body offset-shell convention) and the
+## default utility palette on the four recolor zones:
+## R trim (collar + cuffs) = dark slate
+## G body fabric = utility teal-grey
+## B belt + patches = safety amber
+## A shoulder marks = pale high-vis
+## The chest patch carries the thrds logo (logo_capable proof).
+##
+## Config via env vars (all optional):
+## GARMENT_PREVIEW_ITEM default "uniform_utility"
+## GARMENT_PREVIEW_BODY default "average_m"
+## GARMENT_PREVIEW_OUT default "/.cache/garment-preview/uniform_utility"
+## GARMENT_PREVIEW_LOGO default thrds
+##
+## Run: godot4 --path client --rendering-driver opengl3 \
+## res://tools/garment_preview/preview_uniform_utility.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,
+ "thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
+ "thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
+ "heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
+ "heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
+ "teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
+ "teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
+ "child": CharacterVisualDescriptor.BodyType.CHILD,
+}
+
+# Default utility palette — four cleanly recolorable zones.
+const TINT_TRIM := Color(0.23, 0.28, 0.31) # R: collar + cuffs, dark slate
+const TINT_BODY := Color(0.42, 0.51, 0.50) # G: main fabric, teal-grey
+const TINT_BELT := Color(0.82, 0.55, 0.18) # B: belt + patches, safety amber
+const TINT_MARKS := Color(0.85, 0.87, 0.88) # A: shoulder marks, pale high-vis
+
+var _out_dir := ""
+var _cam: Camera3D = null
+
+
+func _ready() -> void:
+ var item := _env("GARMENT_PREVIEW_ITEM", "uniform_utility")
+ var body := _env("GARMENT_PREVIEW_BODY", "average_m")
+ _out_dir = _env("GARMENT_PREVIEW_OUT",
+ "/var/mnt/data/projects/settled-reach/.cache/garment-preview/uniform_utility")
+ 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 WHOLE figure — the coverall runs collar to ankle.
+ _cam.position = Vector3(0.0, 1.05, 3.1)
+ _cam.look_at_from_position(_cam.position, Vector3(0.0, 0.92, 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
+ # Per-body mask first (offset-shell per-body convention), then fallback.
+ var mask := _try_load(CLOTHING_DIR + "%s/%s_mask.png" % [item, body])
+ if mask == null:
+ 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 mid-walk so the coverall reads under a real pose.
+ 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 — the two requested preview angles.
+ for yaw in [0, 45]:
+ 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_TRIM)
+ mat.set_shader_parameter("tint_1", TINT_BODY)
+ mat.set_shader_parameter("tint_2", TINT_BELT)
+ mat.set_shader_parameter("tint_3", TINT_MARKS)
+ 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_uniform_utility.tscn b/client/tools/garment_preview/preview_uniform_utility.tscn
new file mode 100644
index 000000000..f68dd3e40
--- /dev/null
+++ b/client/tools/garment_preview/preview_uniform_utility.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://c8rtprevunifrm1"]
+
+[ext_resource type="Script" path="res://tools/garment_preview/preview_uniform_utility.gd" id="1_prevu"]
+
+[node name="UniformUtilityPreview" type="Node3D"]
+script = ExtResource("1_prevu")
diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd
index ff27f3d75..c54ba5373 100644
--- a/client/ui/meta/screens/character_creation/character_creation.gd
+++ b/client/ui/meta/screens/character_creation/character_creation.gd
@@ -77,6 +77,14 @@ const EYEBROW_LABELS := ["Regular", "Female", "Teen", "Thick"]
const CLOTHING_SLOTS := ["torso", "legs", "feet", "hands"]
const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
+# --- T-1089 wardrobe engine: multi-region tints + logo decals ---
+# Region indices match the offset-shell mask channels (R/G/B/A -> tint_0..3):
+# 0 = collar band, 1 = main body, 2 = sleeves, 3 = trim (generic labels).
+const REGION_TINT_LABELS := ["Collar", "Body", "Sleeves", "Trim"]
+const LOGO_DIR := "res://assets/characters/logos/"
+const CLOTHING_DIR := "res://assets/characters/clothing/"
+const DEFAULT_CLOTHING_TINT := Color(0.7, 0.65, 0.6)
+
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
const ACCESSORY_SLOTS := [
"hat",
@@ -236,6 +244,9 @@ var _clothing_accent_swatches: Dictionary = {} # slot -> Button
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
+# --- T-1089 multi-region tint + logo picker state (active clothing dock only) ---
+var _clothing_logo_btns: Dictionary = {} # brand_id ("" = None) -> Button, rebuilt per dock
+
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
# Note: hair highlight is always auto-derived (display-only swatch, no override).
var _eyebrow_tint_auto: bool = true
@@ -260,6 +271,8 @@ var _accessory_dock_container: Control = null
# --- Cached asset scan results (populated once during tab build, reused by randomize) ---
var _cached_hair_ids: Array = []
var _cached_head_ids: Array = []
+var _cached_logo_ids: Array = [] # brand ids scanned from LOGO_DIR (filename = brand id)
+var _coverage_cache: Dictionary = {} # item_id -> coverage.json dict (UI-side, read-only)
# --- Appearance sub-section search text and grid references ---
var _appearance_search: Array[String] = ["", "", "", "", ""] # one per sub-section (Body=0 … Accessories=4)
@@ -296,6 +309,8 @@ var _screenshot_frame_count: int = 0
var _quit_after_screenshot: bool = false
var _screenshot_cardinals: bool = false
var _screenshot_cardinal_idx: int = 0
+var _test_ui_tab: int = -1 # --test-config: top-level tab to open once tabs are built
+var _test_appearance_sub: int = -1 # --test-config: appearance sub-section to open
# resolved in _ready() — @onready path fails when instantiated as child
var _tab_container: TabContainer = null
@@ -346,6 +361,7 @@ func _manifest_array(key: String) -> Array:
func _ready() -> void:
_load_manifest()
+ _cached_logo_ids = _scan_logo_ids()
# Build tab panel programmatically — Godot 4.6 destroys TabContainer children
# defined in .tscn when the scene is instantiated as a child of another scene.
@@ -418,6 +434,14 @@ func _ready() -> void:
_update_cam_angle_label()
_update_start_btn_state()
+ # --test-config UI navigation (applied after tabs exist; -1 = untouched)
+ if _test_ui_tab >= 0 and _tab_container != null:
+ _tab_container.current_tab = clampi(_test_ui_tab, 0, _tab_container.get_tab_count() - 1)
+ if _test_appearance_sub >= 0 and not _appearance_sub_sections.is_empty():
+ _on_appearance_sub_selected(
+ clampi(_test_appearance_sub, 0, _appearance_sub_sections.size() - 1)
+ )
+
# =============================================================================
# Camera management (D-158)
@@ -1321,18 +1345,41 @@ func _on_clothing_slot_selected(slot: String) -> void:
func _rebuild_clothing_color_dock(container: Control) -> void:
for child in container.get_children():
child.queue_free()
-
- var row := HBoxContainer.new()
- row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
- row.add_theme_constant_override("separation", 8)
- container.add_child(row)
+ _clothing_logo_btns.clear()
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
+ var multi_region: bool = not item_id.is_empty() and _is_multi_region(item_id)
+ var logo_capable: bool = not item_id.is_empty() and _is_logo_capable(item_id)
+
+ var vbox := VBoxContainer.new()
+ vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ vbox.add_theme_constant_override("separation", 4)
+ container.add_child(vbox)
+
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 8)
+ vbox.add_child(row)
+
+ if multi_region:
+ _build_region_tint_row(row, item_id)
+ else:
+ _build_legacy_clothing_tint_row(row, item_id)
+
+ if logo_capable:
+ vbox.add_child(_make_section_label("Logo"))
+ vbox.add_child(_build_logo_picker_row())
+
+ # Logo row needs extra dock height (label + button row)
+ container.custom_minimum_size = Vector2(0, 112 if logo_capable else 64)
+
+
+## Legacy single-mask garments: Primary + auto-derived Secondary/Accent swatches.
+func _build_legacy_clothing_tint_row(row: HBoxContainer, item_id: String) -> void:
var tints: Array = (
_descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else []
as Array
)
- var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
+ var primary: Color = tints[0] as Color if tints.size() > 0 else DEFAULT_CLOTHING_TINT
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
@@ -1351,15 +1398,104 @@ func _rebuild_clothing_color_dock(container: Control) -> void:
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
+## Multi-region garments (coverage.json multi_region:true): one swatch per mask
+## region 0-3, feeding descriptor.clothing_tints[item_id] as a 4-Color array.
+func _build_region_tint_row(row: HBoxContainer, item_id: String) -> void:
+ # Legacy swatch refs stay null for this slot — legacy handlers can't fire here.
+ _clothing_primary_swatches[_active_clothing_slot] = null
+ _clothing_secondary_swatches[_active_clothing_slot] = null
+ _clothing_accent_swatches[_active_clothing_slot] = null
+
+ var tints := _region_tints_for(item_id)
+ for i in REGION_TINT_LABELS.size():
+ var swatch := _make_color_swatch(
+ tints[i], REGION_TINT_LABELS[i], _on_clothing_region_tint_changed.bind(i)
+ )
+ row.add_child(swatch)
+
+
+## Resolve the effective 4 region tints for an item, mirroring the compositor's
+## fallback chain (t1<-t0, t2<-t1, t3<-t2) so the dock shows what the shader renders.
+func _region_tints_for(item_id: String) -> Array:
+ var stored: Array = _descriptor.clothing_tints.get(item_id, []) as Array
+ var t0: Color = stored[0] as Color if stored.size() > 0 else DEFAULT_CLOTHING_TINT
+ var t1: Color = stored[1] as Color if stored.size() > 1 else t0
+ var t2: Color = stored[2] as Color if stored.size() > 2 else t1
+ var t3: Color = stored[3] as Color if stored.size() > 3 else t2
+ return [t0, t1, t2, t3]
+
+
+func _on_clothing_region_tint_changed(color: Color, region_idx: int) -> void:
+ var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
+ if item_id.is_empty():
+ return
+ # Materialize the full 4-Color array so untouched regions keep their current look.
+ var tints := _region_tints_for(item_id)
+ tints[region_idx] = color
+ _descriptor.clothing_tints[item_id] = tints
+ _refresh_preview()
+
+
+## Logo picker row: "None" + one button per brand PNG in LOGO_DIR (filename = brand id).
+func _build_logo_picker_row() -> Control:
+ var flow := HFlowContainer.new()
+ flow.add_theme_constant_override("h_separation", 4)
+ flow.add_theme_constant_override("v_separation", 4)
+
+ var none_btn := _make_slot_btn("None")
+ none_btn.pressed.connect(_on_clothing_logo_selected.bind(""))
+ flow.add_child(none_btn)
+ _clothing_logo_btns[""] = none_btn
+
+ for brand_id in _cached_logo_ids:
+ var bid := str(brand_id)
+ var btn := _make_slot_btn(bid.capitalize())
+ btn.pressed.connect(_on_clothing_logo_selected.bind(bid))
+ flow.add_child(btn)
+ _clothing_logo_btns[bid] = btn
+
+ _update_clothing_logo_btns()
+ return flow
+
+
+func _on_clothing_logo_selected(brand_id: String) -> void:
+ var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
+ if item_id.is_empty():
+ return
+ if brand_id.is_empty():
+ _descriptor.clothing_logos.erase(item_id)
+ else:
+ _descriptor.clothing_logos[item_id] = brand_id
+ _update_clothing_logo_btns()
+ _refresh_preview()
+
+
+func _update_clothing_logo_btns() -> void:
+ var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
+ var current: String = str(_descriptor.clothing_logos.get(item_id, ""))
+ for brand_id: String in _clothing_logo_btns:
+ _set_item_selected(_clothing_logo_btns[brand_id], brand_id == current)
+
+
func _on_clothing_item_selected(slot: String, item_id: String) -> void:
var current: String = str(_descriptor.clothing_slots.get(slot, ""))
if current == item_id:
# Toggle off — remove from slot
_descriptor.clothing_slots.erase(slot)
_descriptor.clothing_tints.erase(item_id)
+ _descriptor.clothing_logos.erase(item_id)
else:
_descriptor.clothing_slots[slot] = item_id
- _descriptor.clothing_tints[item_id] = [Color(0.7, 0.65, 0.6)]
+ if _is_multi_region(item_id):
+ # 4-Color array, one per mask region (collar/body/sleeves/trim)
+ _descriptor.clothing_tints[item_id] = [
+ DEFAULT_CLOTHING_TINT,
+ DEFAULT_CLOTHING_TINT,
+ DEFAULT_CLOTHING_TINT,
+ DEFAULT_CLOTHING_TINT,
+ ]
+ else:
+ _descriptor.clothing_tints[item_id] = [DEFAULT_CLOTHING_TINT]
_clothing_secondary_auto[slot] = true
_clothing_accent_auto[slot] = true
@@ -1682,6 +1818,26 @@ func _load_test_config() -> void:
var clothing: Dictionary = cfg["clothing"]
for slot: String in clothing:
_descriptor.clothing_slots[slot] = clothing[slot]
+ if cfg.has("clothing_tints"):
+ # item_id -> array of [r,g,b] / [r,g,b,a] arrays (multi-region: 4 entries)
+ var tint_cfg: Dictionary = cfg["clothing_tints"]
+ for item_id: String in tint_cfg:
+ var colors: Array = []
+ for c in tint_cfg[item_id] as Array:
+ var arr: Array = c
+ colors.append(
+ Color(arr[0], arr[1], arr[2], arr[3] if arr.size() > 3 else 1.0)
+ )
+ _descriptor.clothing_tints[item_id] = colors
+ if cfg.has("clothing_logos"):
+ # item_id -> brand id (resolved to assets/characters/logos/.png)
+ var logo_cfg: Dictionary = cfg["clothing_logos"]
+ for item_id: String in logo_cfg:
+ _descriptor.clothing_logos[item_id] = str(logo_cfg[item_id])
+ if cfg.has("ui_tab"):
+ _test_ui_tab = int(cfg["ui_tab"])
+ if cfg.has("appearance_sub"):
+ _test_appearance_sub = int(cfg["appearance_sub"])
# Auto-quit after screenshot (for CI/automated testing)
if cfg.has("screenshot_delay_frames"):
@@ -2401,6 +2557,62 @@ func _get_clothing_ids_for_slot(slot: String) -> Array:
return result
+## Read (and cache) a clothing item's coverage.json — UI-side, read-only.
+## Mirrors CharacterVisual._read_coverage; kept separate so the dock can resolve
+## region capability before the compositor has loaded the item.
+func _get_item_coverage(item_id: String) -> Dictionary:
+ if item_id.is_empty():
+ return {}
+ if _coverage_cache.has(item_id):
+ return _coverage_cache[item_id]
+ var coverage: Dictionary = {}
+ var path := CLOTHING_DIR + "%s/coverage.json" % item_id
+ if FileAccess.file_exists(path):
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file != null:
+ var parsed: Variant = JSON.parse_string(file.get_as_text())
+ file.close()
+ if parsed is Dictionary:
+ coverage = parsed as Dictionary
+ _coverage_cache[item_id] = coverage
+ return coverage
+
+
+func _is_multi_region(item_id: String) -> bool:
+ return bool(_get_item_coverage(item_id).get("multi_region", false))
+
+
+func _is_logo_capable(item_id: String) -> bool:
+ var clothing_data: Variant = _manifest.get("clothing", {})
+ if not (clothing_data is Dictionary):
+ return false
+ var item_info: Variant = (clothing_data as Dictionary).get(item_id, {})
+ if item_info is Dictionary:
+ return bool((item_info as Dictionary).get("logo_capable", false))
+ return false
+
+
+## Scan LOGO_DIR for brand PNGs (filename = brand id). Handles both editor
+## listings (foo.png) and exported-pack listings (foo.png.import / foo.png.remap).
+func _scan_logo_ids() -> Array:
+ var ids := {}
+ var dir := DirAccess.open(LOGO_DIR)
+ if dir == null:
+ return []
+ dir.list_dir_begin()
+ var fname := dir.get_next()
+ while not fname.is_empty():
+ if not dir.current_is_dir():
+ var base := fname.trim_suffix(".import").trim_suffix(".remap")
+ if base.ends_with(".png"):
+ ids[base.trim_suffix(".png")] = true
+ fname = dir.get_next()
+ dir.list_dir_end()
+ var result: Array = ids.keys()
+ result.sort()
+ return result
+
+
func _get_accessory_ids_for_slot(slot: String) -> Array:
# Accessory items from manifest, filtered by slot name prefix convention.
var all_ids: Array = _manifest_array("accessories")
diff --git a/tooling/garment-fit/blender_author_buttondown.py b/tooling/garment-fit/blender_author_buttondown.py
new file mode 100644
index 000000000..eb5fc9abe
--- /dev/null
+++ b/tooling/garment-fit/blender_author_buttondown.py
@@ -0,0 +1,426 @@
+"""
+blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell)
+
+Button-down shirt authored per body via the offset-shell route. Imports
+blender_author_offset_shell.py as a module and reuses its scene/build/offset/
+solidify/logo-UV/export helpers; this file adds what the button-down needs
+beyond the t-shirt base:
+
+ * long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower),
+ with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC),
+ replacing the base script's upper-arm short-sleeve cut
+ * seam WELD after join — the body segments share exact duplicated boundary
+ rings (probe: 24-38 coincident verts/seam, zero near-misses), so a
+ remove-doubles pass gives continuous normals across segment seams ->
+ crack-free outward offset and no internal solidify rims (UVs live on
+ loops, so UV seams survive the weld; weights are identical by construction)
+ * per-PIXEL region mask bake (barycentric-interpolated body-local positions)
+ instead of the base per-face classification — the placket and cuff
+ boundaries cut through the middle of low-poly faces
+ * PAINTED albedo — texture-carried identity per the style pin: placket band
+ with stitch lines, button dots, collar + cuff seam lines, over the flat
+ toon fabric noise. Baked per body because each body archetype has its own
+ UV atlas (the shell inherits body UVs).
+
+Region convention (spec): collar=R (tint_0), body=G (tint_1),
+cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest
+channel is still authored (costs nothing; enables future brand variants).
+
+Run (per-body only — this garment ships the per-body route):
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_buttondown.py -- \
+ client/assets/characters/bodies \
+ client/assets/characters/clothing/buttondown_modern \
+ [--bodies average_m,child,...] [--offset 0.009]
+
+Writes per body: /.glb + /_mask.png
+Plus: /base_albedo.png (average_m's painted albedo)
+ /reference_mask.png (copy of average_m_mask.png)
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
+(per-body authoring for offset shells).
+"""
+
+import os
+import shutil
+import sys
+
+import bmesh
+import bpy
+import numpy as np
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import blender_author_offset_shell as base # noqa: E402
+
+# --------------------------------------------------------------------------
+# Garment parameters (average_m metres; scaled per body by shoulder ratio)
+# --------------------------------------------------------------------------
+
+COVERED_SEGMENTS = [
+ "seg_torso", "seg_torso_upper",
+ "seg_arm_upper_l", "seg_arm_upper_r",
+ "seg_arm_lower_l", "seg_arm_lower_r",
+]
+
+OFFSET_M = 0.009 # slimmer standoff than the tee (12 mm) — dress shirt
+CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm)
+SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist
+CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B)
+WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm)
+
+MASK_SIZE = 512
+ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024
+
+# Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X);
+# scaled per body by shoulder_x ratio so proportions hold from child to heavy_m.
+PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band)
+STITCH_W_M = 0.0025 # stitch/seam line half-width
+BUTTON_R_M = 0.0095 # button disc radius (chunky toon read)
+SEAM_HALF_M = 0.0022 # collar seam line half-height
+BUTTON_COUNT = 6
+FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this
+
+# Flat toon oxford base tone + painted feature colours (luma carries through
+# the luminance-preserving region tint in toon_garment.gdshader).
+FABRIC_RGB = (0.63, 0.64, 0.66)
+FABRIC_NOISE = 0.02
+PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint
+STITCH_MUL = 0.52 # dark stitch lines
+SEAM_MUL = 0.55 # collar/cuff seam lines
+BUTTON_RGB_OUTER = (0.10, 0.10, 0.12)
+BUTTON_RGB_CORE = (0.24, 0.24, 0.27)
+
+
+def log(msg):
+ print(f"[buttondown] {msg}")
+
+
+# --------------------------------------------------------------------------
+# Threshold derivation (extends base.derive_thresholds with arm + style dims)
+# --------------------------------------------------------------------------
+
+def derive_arm_thresholds(armature):
+ """Wrist cut planes + cuff start from the lowerarm bones (X-axis arms)."""
+ bones = armature.data.bones
+ la_l = bones.get("lowerarm_l")
+ la_r = bones.get("lowerarm_r")
+ if la_l is None or la_r is None:
+ raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut")
+
+ def along(b, frac):
+ return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
+
+ cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l
+ cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r
+ cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC))
+ + abs(along(la_r, CUFF_START_FRAC))) / 2.0
+ log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}")
+ return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs}
+
+
+def derive_style(armature):
+ """Scale the painted-detail dimensions by this body's shoulder ratio."""
+ bones = armature.data.bones
+ ua_l = bones.get("upperarm_l")
+ ua_r = bones.get("upperarm_r")
+ if ua_l is None or ua_r is None:
+ s = 1.0
+ else:
+ shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
+ s = shoulder_x / base._REF_SHOULDER_X
+ log(f"style scale {s:.3f}")
+ return {
+ "placket_half": PLACKET_HALF_M * s,
+ "stitch_w": STITCH_W_M * s,
+ "button_r": BUTTON_R_M * s,
+ "seam_half": SEAM_HALF_M * s,
+ }
+
+
+def derive_buttons(shell, thr):
+ """Button Z positions: evenly spaced down the placket (collar -> hem)."""
+ hem_z = min(v.co.z for v in shell.data.vertices)
+ collar_z = thr["collar_z_min"]
+ span = collar_z - hem_z
+ z_top = collar_z - 0.05 * span
+ z_bot = hem_z + 0.07 * span
+ zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT))
+ log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})")
+ return {"button_zs": zs, "hem_z": hem_z}
+
+
+# --------------------------------------------------------------------------
+# Geometry: seam weld + wrist cut
+# --------------------------------------------------------------------------
+
+def weld_seams(shell):
+ """Merge the duplicated segment-boundary rings into one continuous shell."""
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ before = len(bm.verts)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"seam weld: {before} -> {len(shell.data.vertices)} verts "
+ f"({before - len(shell.data.vertices)} merged)")
+
+
+def wrist_cut(shell, thr):
+ """Delete sleeve verts beyond the wrist plane on each forearm."""
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = [v for v in bm.verts
+ if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]]
+ bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"wrist cut removed {len(to_delete)} verts; "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# Per-pixel bake: region mask + painted albedo in one pass
+# --------------------------------------------------------------------------
+
+def _gather_tris(shell):
+ """Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c)."""
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.faces.ensure_lookup_table()
+ uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas
+ tris = []
+ for face in bm.faces:
+ loops = face.loops[:]
+ uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops]
+ cos = [np.array(lp.vert.co[:]) for lp in loops]
+ for i in range(1, len(loops) - 1):
+ tris.append((uvs[0], uvs[i], uvs[i + 1],
+ cos[0], cos[i], cos[i + 1]))
+ bm.free()
+ return tris
+
+
+def _tri_cover(a, b, c, W, H):
+ """Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2)."""
+ ax, ay = a[0] * (W - 1), a[1] * (H - 1)
+ bx, by = b[0] * (W - 1), b[1] * (H - 1)
+ cx, cy = c[0] * (W - 1), c[1] * (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 None
+ denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
+ if abs(denom) < 1e-9:
+ return None # degenerate (solidify rim) — no UV area to write
+ 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 None
+ return (ys[inside].ravel(), xs[inside].ravel(),
+ w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel())
+
+
+def _interp_pos(cover, co_a, co_b, co_c):
+ _, _, w0, w1, w2 = cover
+ return (w0[:, None] * co_a[None, :]
+ + w1[:, None] * co_b[None, :]
+ + w2[:, None] * co_c[None, :])
+
+
+def _classify_px(pos, thr):
+ """Vectorized region classification -> (N,4) one-hot RGBA rows."""
+ x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
+ ax = np.abs(x)
+ front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
+ cuff = ax >= thr["cuff_x_abs"]
+ collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
+ placket = ((~cuff) & (~collar) & front
+ & (ax <= thr["placket_half"]) & (z < thr["collar_z_min"]))
+ rgba = np.zeros((len(x), 4), dtype=np.float32)
+ rgba[:, 1] = 1.0 # default: body -> G
+ rgba[collar] = (1.0, 0.0, 0.0, 0.0) # collar band -> R
+ rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B
+ return rgba
+
+
+def _paint_px(pos, rows, thr):
+ """Painted albedo: placket band + stitches, seams, button dots (in-place)."""
+ x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
+ ax = np.abs(x)
+ front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
+ below_collar = z < thr["collar_z_min"]
+
+ mul = np.ones(len(x), dtype=np.float32)
+ band = front & below_collar & (ax <= thr["placket_half"])
+ mul[band] = PLACKET_BAND_MUL
+ stitch = front & below_collar & (np.abs(ax - thr["placket_half"])
+ <= thr["stitch_w"])
+ mul[stitch] = STITCH_MUL
+ collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"])
+ & (ax < thr["collar_x_abs"] * 1.8))
+ mul[collar_seam] = SEAM_MUL
+ cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"]
+ mul[cuff_seam] = SEAM_MUL
+ out = rows * mul[:, None]
+
+ r = thr["button_r"]
+ for bz in thr["button_zs"]:
+ d2 = x * x + (z - bz) ** 2
+ disc = front & (d2 <= r * r)
+ out[disc] = BUTTON_RGB_OUTER
+ core = front & (d2 <= (0.45 * r) ** 2)
+ out[core] = BUTTON_RGB_CORE
+ np.clip(out, 0.0, 1.0, out)
+ return out
+
+
+def bake_maps(shell, thr, mask_path):
+ """One pass over the shell: bake _mask.png + return painted albedo."""
+ tris = _gather_tris(shell)
+
+ mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
+ mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
+
+ rng = np.random.default_rng(1090) # deterministic fabric noise
+ fabric = np.array(FABRIC_RGB, dtype=np.float32)
+ noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
+ - 0.5) * 2.0 * FABRIC_NOISE
+ abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
+
+ for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
+ cover = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
+ if cover is not None:
+ pos = _interp_pos(cover, co_a, co_b, co_c)
+ mbuf[cover[0], cover[1], :] = _classify_px(pos, thr)
+ cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
+ if cover is not None:
+ pos = _interp_pos(cover, co_a, co_b, co_c)
+ abuf[cover[0], cover[1], :] = _paint_px(
+ pos, abuf[cover[0], cover[1], :], thr)
+
+ tot = MASK_SIZE * MASK_SIZE
+ log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.1f}%".format(
+ 100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
+ 100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
+ 100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
+
+ mask_img = bpy.data.images.new("garment_region_mask", MASK_SIZE, MASK_SIZE,
+ alpha=True)
+ mask_img.pixels.foreach_set(mbuf.reshape(-1))
+ mask_img.update()
+ mask_img.filepath_raw = mask_path
+ mask_img.file_format = 'PNG'
+ mask_img.save()
+ log(f"baked region mask -> {mask_path}")
+
+ argba = np.concatenate(
+ [abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
+ axis=2)
+ albedo_img = bpy.data.images.new("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE,
+ alpha=False)
+ albedo_img.pixels.foreach_set(argba.reshape(-1))
+ albedo_img.update()
+ return albedo_img
+
+
+# --------------------------------------------------------------------------
+# Author one body
+# --------------------------------------------------------------------------
+
+def author_buttondown(body_dir, out_dir, body, offset):
+ base.clear_scene()
+ base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set
+ shell, armature = base.build_covered_mesh(body_dir)
+
+ thr = base.derive_thresholds(armature) # collar/chest from bone landmarks
+ thr.update(derive_arm_thresholds(armature))
+ thr.update(derive_style(armature))
+
+ weld_seams(shell)
+ wrist_cut(shell, thr)
+ thr.update(derive_buttons(shell, thr))
+
+ base.offset_outward(shell, offset)
+ base.solidify(shell, CLOTH_THICKNESS_M)
+
+ base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0)
+ albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png"))
+ base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
+ base.assign_fabric_material(shell, albedo_img)
+
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+# --------------------------------------------------------------------------
+# Entry
+# --------------------------------------------------------------------------
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- "
+ "[--bodies a,b,c] [--offset M]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ offset = OFFSET_M
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+ log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm")
+
+ avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_buttondown(body_dir, out_dir, body, offset)
+ if body == base.REFERENCE_BODY:
+ shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
+ avg_albedo_stash)
+ results.append((body, "ok"))
+ except Exception as exc: # noqa: BLE001 — per-body isolation
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ # Root sidecars: reference mask + albedo mirror the reference body.
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
+ if os.path.isfile(avg_albedo_stash):
+ shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
+ log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_denim_pants.py b/tooling/garment-fit/blender_author_denim_pants.py
new file mode 100644
index 000000000..b7a5cd8d8
--- /dev/null
+++ b/tooling/garment-fit/blender_author_denim_pants.py
@@ -0,0 +1,659 @@
+"""
+blender_author_denim_pants.py (T-1089, jeans_modern + denim pants family)
+
+Authors full-length DENIM pants as per-body offset shells, reusing
+blender_author_offset_shell.py as a library (scene build, join, offset,
+solidify, GLB export). Sibling companions already cover the plain lower-body
+family (blender_author_offset_shell_legs.py — shorts, 2-region;
+blender_author_lower_shell.py — formal, crease lines); this one adds what
+denim needs and they don't provide, as reusable parameters:
+
+ * TEXEL-level feature painting: every UV0 triangle is rasterized with
+ barycentric-interpolated 3D positions, and an analytic denim feature
+ field is evaluated per texel. ONE field evaluation drives BOTH outputs,
+ so the painted albedo and the region mask always agree:
+ - albedo: painted seam thread lines (outseam/inseam azimuth around the
+ per-z leg axis, centre-front fly, back yoke V), front pocket
+ arcs, back pocket outlines, belt loops, waist button,
+ waist/cuff border stitching — flat toon-friendly, identity
+ carried by the texture (style pin: modern only).
+ - mask: waistband -> R, legs -> G, seams + cuff band -> B
+ (spec: waistband=R, legs=G, seams/cuffs=B). Seam LINES are
+ in the B channel, not just the cuff band, so contrast-thread
+ recoloring works (toon_garment.gdshader channel-blends).
+ * boundary WELD before offsetting — the waist join (hips<->leg_upper) and
+ knee join (leg_upper<->leg_lower) carry duplicated boundary-ring verts
+ per segment; offsetting un-welded rings along diverging normals opens
+ cracks, so coincident verts are merged first (weights identical by
+ origin, so skinning is unaffected).
+ * --hem-frac: fraction of the hip->ankle span covered, measured up from the
+ ankle (1.0 = full length to the ankle = jeans; lower values give cropped
+ variants; cut rims are capped by Solidify(use_rim) like the base sleeves).
+ * a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)): pants are not
+ logo-capable, but toon_garment.gdshader samples UV2 unconditionally, so
+ every multi_region garment ships a well-defined logo channel.
+ * open-rim FLATTENING: the segment splitter cuts along weight thresholds,
+ so the waist and ankle boundary rings are jagged "teeth" (~5-6 cm deep on
+ average_m); boundary verts are pulled onto clean planes (waist ring down
+ to its own valley, ankle rings onto the ankle-joint plane) pre-offset.
+ * --waist-flare: extra feathered radial stand-off at the waistband rim —
+ deep-crouch waist-fold clip mitigation (QA evidence, peasant + jeans).
+
+Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
+boundaries give the waist opening (seg_hips top rim) and the ankle hems
+(seg_leg_lower bottom) for free.
+
+All cut/mask/paint parameters derive PER BODY from that body's own bone
+landmarks (thigh_l/calf_l line) and measured mesh extents (waist rim, ankle
+rim, crotch = seg_hips lowest ring), scaled by the body's garment span and
+hip half-width — the same proportional-ratio philosophy as
+base.derive_thresholds, so the denim details stay consistent across all 11
+bodies. Per-body mode only (offset shells author per body, Q-060).
+
+Usage (jeans_modern reference invocation):
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_denim_pants.py -- \
+ client/assets/characters/bodies \
+ client/assets/characters/clothing/jeans_modern \
+ [--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
+ [--waist-flare 0.007] [--base-rgb 0.24,0.32,0.45] [--plain]
+
+Writes per body: /.glb (skinned, albedo embedded)
+ /_mask.png (RGBA region mask, UV0)
+ /_base_albedo.png
+Plus: /base_albedo.png (average_m's, shared sidecar)
+ /reference_mask.png (average_m's, runtime fallback)
+
+Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
+wardrobe), Q-060 (per-body offset shells).
+"""
+
+import importlib.util
+import os
+import shutil
+import sys
+
+import bmesh
+import bpy
+import numpy as np
+
+# --------------------------------------------------------------------------
+# Import the base offset-shell module (shared machinery)
+# --------------------------------------------------------------------------
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_spec = importlib.util.spec_from_file_location(
+ "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
+base = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(base)
+
+log = base.log
+FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
+
+# --------------------------------------------------------------------------
+# Parameters
+# --------------------------------------------------------------------------
+COVERED_SEGMENTS = [
+ "seg_hips",
+ "seg_leg_upper_l", "seg_leg_upper_r",
+ "seg_leg_lower_l", "seg_leg_lower_r",
+]
+
+HEM_FRAC = 1.0 # 1.0 = full leg to the ankle (jeans)
+WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
+TEX_SIZE = 1024 # albedo + mask resolution (painted seams need >512)
+WAIST_FLARE_M = 0.007 # extra radial stand-off at the waistband rim
+ # (deep-crouch waist-fold mitigation, feathered)
+
+# Vertical proportions — fractions of the garment span (waist_z - hem_z).
+BAND_FRAC = 0.055 # waistband height (R region)
+CUFF_FRAC = 0.032 # cuff band height (B region)
+SEAM_W_FRAC = 0.0095 # painted seam line width
+YOKE_DROP_FRAC = 0.055 # back yoke centre depth below the waistband
+YOKE_RISE_FRAC = 0.030 # yoke V rise toward the sides
+BPOCKET_TOP_FRAC = 0.075 # back pocket top below the waistband
+BPOCKET_HH_FRAC = 0.048 # back pocket half-height
+FPOCKET_RZ_FRAC = 0.14 # front pocket arc vertical radius
+
+# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
+BPOCKET_CX_FRAC = 0.80 # back pocket centre
+BPOCKET_HW_FRAC = 0.52 # back pocket half-width
+FPOCKET_CX_FRAC = 1.30 # front pocket arc centre (near the outseam corner)
+FPOCKET_RX_FRAC = 0.75 # front pocket arc horizontal radius
+LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (front + back pairs)
+LOOP_W_M = 0.016 # belt loop width (m, scaled by hip width)
+
+# Denim style (sRGB floats; saved as-is — matches the proven base pipeline).
+DENIM_RGB = (0.240, 0.320, 0.450) # indigo denim
+THREAD_RGB = (0.800, 0.620, 0.340) # contrast stitching thread
+BUTTON_RGB = (0.850, 0.720, 0.480) # waist button metal
+ALBEDO_NOISE = 0.020 # +/- woven jitter
+CUFF_SHADE = 0.90 # cuff band albedo darkening
+LOOP_SHADE = 0.80 # belt-loop albedo darkening
+PLAIN = False # --plain: skip thread/pocket/button paint
+NOISE_SEED = 2089
+
+# Reference proportions (average_m) the fractions were calibrated against.
+_REF_SPAN = 1.007 # waist rim z (1.093) - ankle z (0.086)
+_REF_HIP_X = 0.0906 # |thigh_l head x|
+
+
+# --------------------------------------------------------------------------
+# Per-body landmarks
+# --------------------------------------------------------------------------
+
+class LegLandmarks:
+ """Cut/mask/paint parameters derived from one body's bones + mesh."""
+
+ def __init__(self, armature, waist_z, ankle_z, crotch_z):
+ bones = armature.data.bones
+ thigh = bones.get("thigh_l")
+ calf = bones.get("calf_l")
+ if thigh is None or calf is None:
+ raise RuntimeError("thigh_l/calf_l missing — not the 65-bone rig?")
+ self.waist_z = waist_z
+ self.ankle_z = ankle_z
+ self.crotch_z = crotch_z
+ self.hip_x = abs(thigh.head_local.x)
+ # Leg axis control points (z-increasing) for azimuth seam placement.
+ # x values are the +x (left) leg; the right leg mirrors via sign.
+ self.leg_z_pts = np.array(
+ [calf.tail_local.z, calf.head_local.z, thigh.head_local.z])
+ self.leg_x_pts = np.array(
+ [abs(calf.tail_local.x), abs(calf.head_local.x),
+ abs(thigh.head_local.x)])
+ self.leg_y_pts = np.array(
+ [calf.tail_local.y, calf.head_local.y, thigh.head_local.y])
+ self.hem_z = ankle_z # finalized after the hem cut
+
+ def finalize(self, hem_z):
+ self.hem_z = hem_z
+ self.span = self.waist_z - self.hem_z
+ self.sh = self.hip_x / _REF_HIP_X
+ self.band_h = BAND_FRAC * self.span
+ self.cuff_h = CUFF_FRAC * self.span
+ self.seam_w = SEAM_W_FRAC * self.span
+ self.band_z = self.waist_z - self.band_h
+ self.cuff_top = self.hem_z + self.cuff_h
+ log(f"landmarks: waist={self.waist_z:.3f} hem={self.hem_z:.3f} "
+ f"crotch={self.crotch_z:.3f} hip_x={self.hip_x:.3f} "
+ f"band_z={self.band_z:.3f} cuff_top={self.cuff_top:.3f} "
+ f"seam_w={self.seam_w * 1000:.1f}mm")
+
+
+def probe_hips_bounds(body_dir):
+ """Import seg_hips alone to measure the crotch (its lowest ring) exactly."""
+ base.clear_scene()
+ objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
+ zs = []
+ for o in objs:
+ if base.is_body_mesh(o):
+ zs.extend(v.co.z for v in o.data.vertices)
+ if not zs:
+ raise RuntimeError("seg_hips.glb yielded no skinned mesh")
+ return min(zs), max(zs)
+
+
+# --------------------------------------------------------------------------
+# Geometry: weld + hem cut
+# --------------------------------------------------------------------------
+
+def weld_boundaries(shell):
+ """Merge coincident segment-boundary verts so the offset can't open cracks."""
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ before = len(bm.verts)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
+ merged = before - len(bm.verts)
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log(f"welded segment boundaries: {merged} verts merged "
+ f"({before} -> {len(me.vertices)})")
+
+
+def flatten_open_rims(shell, ankle_plane):
+ """Pull the open boundary rings onto clean planes (pre-offset).
+
+ The body segment splitter cuts along weight thresholds, not edge loops, so
+ the seg_hips top edge and the seg_leg_lower ankle edges are jagged rings
+ of "teeth" (~5-6 cm on average_m). On the skin this hides under the
+ neighbouring segment; on a garment shell it becomes a ragged silhouette.
+
+ The top ring's verts are pulled DOWN to the ring's own valley (deepest
+ notch) — a clean straight waist edge without inventing coverage. The
+ bottom rings' verts are moved onto `ankle_plane` (the ankle joint for
+ full-length pants, or the hem-cut plane for cropped variants) — a clean
+ straight hem AT the ankle. Only boundary verts move; interior verts are
+ untouched, and weights/UVs ride along, so skinning and painting are
+ unaffected. Returns (waist_plane, ankle_plane).
+ """
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.verts.ensure_lookup_table()
+ bm.edges.ensure_lookup_table()
+ boundary = set()
+ for e in bm.edges:
+ if len(e.link_faces) == 1: # open boundary
+ boundary.update(v.index for v in e.verts)
+ zs = [bm.verts[i].co.z for i in boundary]
+ z_mid = (min(zs) + max(zs)) / 2.0
+ top = [i for i in boundary if bm.verts[i].co.z > z_mid]
+ bottom = [i for i in boundary if bm.verts[i].co.z <= z_mid]
+ waist_plane = min(bm.verts[i].co.z for i in top)
+ teeth_top = max(bm.verts[i].co.z for i in top) - waist_plane
+ teeth_bot_lo = min(bm.verts[i].co.z for i in bottom)
+ teeth_bot_hi = max(bm.verts[i].co.z for i in bottom)
+ for i in top:
+ bm.verts[i].co.z = waist_plane
+ for i in bottom:
+ bm.verts[i].co.z = ankle_plane
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log(f"flattened open rims: waist ring ({len(top)} verts, teeth "
+ f"{teeth_top:.3f} m) -> {waist_plane:.3f}; ankle rings "
+ f"({len(bottom)} verts, z {teeth_bot_lo:.3f}..{teeth_bot_hi:.3f}) "
+ f"-> {ankle_plane:.3f}")
+ return waist_plane, ankle_plane
+
+
+def waist_flare(shell, waist_plane, band_h, flare):
+ """Extra RADIAL stand-off at the waistband rim, feathered over 2x the band
+ height (pre-solidify). QA evidence: in deep crouch the lower-back skin
+ folds over the waist rim (the peasant set's known 'deep-crouch waist gap',
+ worst on small bodies). Flaring the band outward gives the fold room and
+ reads as a natural jeans waistband stand-off."""
+ if flare <= 0.0:
+ return
+ z0 = waist_plane - 2.0 * band_h
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.normal_update()
+ n = 0
+ for v in bm.verts:
+ if v.co.z > z0:
+ t = min((v.co.z - z0) / (2.0 * band_h), 1.0)
+ nx, ny = v.normal.x, v.normal.y
+ mag = (nx * nx + ny * ny) ** 0.5
+ if mag > 1e-6:
+ v.co.x += flare * t * nx / mag
+ v.co.y += flare * t * ny / mag
+ n += 1
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"waist flare: {n} verts, +{flare * 1000:.1f} mm radial at rim "
+ f"(feathered from z={z0:.3f})")
+
+
+def clamp_waist_residue(shell, waist_plane):
+ """Post-solidify safety clamp: any interior tooth verts still above the
+ waist plane (multi-triangle teeth) get squashed onto it."""
+ me = shell.data
+ n = 0
+ for v in me.vertices:
+ if v.co.z > waist_plane:
+ v.co.z = waist_plane
+ n += 1
+ me.update()
+ if n:
+ log(f"clamped {n} residual waist verts -> {waist_plane:.3f}")
+
+
+def hem_cut(shell, lm, hem_frac):
+ """Trim the legs below the hem plane. 1.0 keeps the full ankle length."""
+ if hem_frac >= 0.999:
+ return lm.ankle_z
+ hip_z = float(lm.leg_z_pts[-1])
+ hem_z = lm.ankle_z + (1.0 - hem_frac) * (hip_z - lm.ankle_z)
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ doomed = [v for v in bm.verts if v.co.z < hem_z]
+ bmesh.ops.delete(bm, geom=doomed, context='VERTS')
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
+ return hem_z
+
+
+# --------------------------------------------------------------------------
+# Denim feature field (texel-level; drives albedo AND mask together)
+# --------------------------------------------------------------------------
+
+def _seam_field(px, py, pz, lm):
+ """Evaluate denim features at texel 3D positions (numpy arrays).
+
+ Returns bool arrays (seams, thread, in_cuff, in_band, front):
+ `seams` feeds the mask B channel; `thread` is every painted stitch line.
+ """
+ w2 = lm.seam_w * 0.5
+ front = py * FRONT_Y_SIGN > 0.004
+ backside = py * FRONT_Y_SIGN < -0.004
+ in_band = pz >= lm.band_z
+ in_cuff = pz <= lm.cuff_top
+ mid = (~in_band) & (~in_cuff)
+
+ # Per-z leg axis, mirrored by x sign.
+ side = np.where(px >= 0.0, 1.0, -1.0)
+ cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
+ cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
+ dx = px - cx
+ dy = py - cy
+ r = np.hypot(dx, dy) + 1e-9
+
+ # Outseam: azimuth toward the outer (+/-x) direction; constant metric
+ # width via arc distance. Inseam: inner direction, below the crotch only.
+ arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
+ outseam = mid & (arc_out < w2)
+ arc_in = np.arccos(np.clip(-dx * side / r, -1.0, 1.0)) * r
+ inseam = mid & (arc_in < w2) & (pz < lm.crotch_z - 0.01 * lm.span / _REF_SPAN)
+
+ # Centre-front fly stitch (slightly off-centre, classic J-front).
+ fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
+ & (pz > lm.crotch_z + 0.015 * lm.span / _REF_SPAN)
+
+ # Back yoke: shallow V, higher toward the sides.
+ yoke_z = (lm.band_z - YOKE_DROP_FRAC * lm.span
+ + YOKE_RISE_FRAC * lm.span
+ * np.minimum(np.abs(px) / (1.5 * lm.hip_x), 1.0))
+ yoke = backside & mid & (np.abs(pz - yoke_z) < w2) \
+ & (np.abs(px) < 1.6 * lm.hip_x)
+
+ seams = outseam | inseam | fly | yoke
+
+ # Stitch-only lines (albedo, not mask): waist + cuff border stitching.
+ wstitch = np.abs(pz - lm.band_z) < w2
+ cstitch = np.abs(pz - lm.cuff_top) < w2
+
+ # Back pocket outlines (rectangle rings on the seat).
+ bhw = BPOCKET_HW_FRAC * lm.hip_x
+ bhh = BPOCKET_HH_FRAC * lm.span
+ bcz = lm.band_z - BPOCKET_TOP_FRAC * lm.span - bhh
+ adx = np.abs(np.abs(px) - BPOCKET_CX_FRAC * lm.hip_x)
+ adz = np.abs(pz - bcz)
+ bpocket = backside & (adx < bhw + w2) & (adz < bhh + w2) \
+ & ~((adx < bhw - w2) & (adz < bhh - w2))
+
+ # Front pocket arcs (quarter-ellipse from waistband toward the outseam).
+ fcx = FPOCKET_CX_FRAC * lm.hip_x
+ frx = FPOCKET_RX_FRAC * lm.hip_x
+ frz = FPOCKET_RZ_FRAC * lm.span
+ ex = (np.abs(px) - fcx) / frx
+ ez = (pz - lm.band_z) / frz
+ fdist = (np.sqrt(ex * ex + ez * ez) - 1.0) * (0.5 * (frx + frz))
+ fpocket = front & (np.abs(fdist) < w2) & (pz <= lm.band_z) \
+ & (np.abs(px) <= fcx)
+
+ thread = seams | wstitch | cstitch | bpocket | fpocket
+ return seams, thread, in_cuff, in_band, front
+
+
+def _paint_texels(px, py, pz, noise, lm):
+ """Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
+ n = px.shape[0]
+ seams, thread, in_cuff, in_band, front = _seam_field(px, py, pz, lm)
+
+ # --- albedo ------------------------------------------------------------
+ alb = np.empty((n, 4), dtype=np.float32)
+ for c in range(3):
+ alb[:, c] = DENIM_RGB[c] + noise
+ alb[:, 3] = 1.0
+ alb[in_cuff, :3] *= CUFF_SHADE
+
+ if not PLAIN:
+ # Belt loops: darkened denim bands on the waistband.
+ loop_w = LOOP_W_M * lm.sh
+ loops = np.zeros(n, dtype=bool)
+ for fx in LOOP_X_FRACS:
+ loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
+ loops |= (~front) & (np.abs(px) < loop_w * 0.5) # centre-back loop
+ loops &= in_band
+ alb[loops, :3] *= LOOP_SHADE
+
+ alb[thread, 0] = THREAD_RGB[0]
+ alb[thread, 1] = THREAD_RGB[1]
+ alb[thread, 2] = THREAD_RGB[2]
+
+ # Waist button (front, mid-band).
+ btn = front & (np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h))
+ < 0.009 * lm.sh)
+ alb[btn, 0] = BUTTON_RGB[0]
+ alb[btn, 1] = BUTTON_RGB[1]
+ alb[btn, 2] = BUTTON_RGB[2]
+
+ # --- region mask: waistband R / legs G / seams+cuffs B ------------------
+ mask = np.zeros((n, 4), dtype=np.float32)
+ is_b = (in_cuff | seams) & ~in_band
+ is_g = ~(in_band | is_b)
+ mask[in_band, 0] = 1.0
+ mask[is_b, 2] = 1.0
+ mask[is_g, 1] = 1.0
+ return alb, mask
+
+
+def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
+ """Barycentric texel fill of one UV triangle: interpolate 3D positions,
+ evaluate the denim field, write albedo + mask together."""
+ a, b, c = uvs
+ A, B, C = cos
+ 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]
+ pxg = xs + 0.5
+ pyg = ys + 0.5
+ w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
+ w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
+ w2 = 1.0 - w0 - w1
+ inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
+ if not inside.any():
+ return
+ w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
+ px3 = w0i * A.x + w1i * B.x + w2i * C.x
+ py3 = w0i * A.y + w1i * B.y + w2i * C.y
+ pz3 = w0i * A.z + w1i * B.z + w2i * C.z
+ ysin = ys[inside]
+ xsin = xs[inside]
+ alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
+ alb_buf[ysin, xsin] = alb
+ mask_buf[ysin, xsin] = mask
+
+
+def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
+ """Rasterize all UV0 triangles once, producing the painted albedo and the
+ region mask from one shared feature-field evaluation per texel."""
+ W = H = TEX_SIZE
+ rng = np.random.default_rng(NOISE_SEED)
+ noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
+ * 2.0 * ALBEDO_NOISE)
+
+ alb_buf = np.empty((H, W, 4), dtype=np.float32)
+ for c in range(3):
+ alb_buf[:, :, c] = DENIM_RGB[c] + noise_buf
+ alb_buf[:, :, 3] = 1.0
+ mask_buf = np.zeros((H, W, 4), dtype=np.float32)
+ mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
+
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.faces.ensure_lookup_table()
+ if not len(bm.loops.layers.uv):
+ raise RuntimeError("no UV layer for albedo/mask paint")
+ uv_layer = bm.loops.layers.uv[0]
+
+ tri_count = 0
+ for face in bm.faces:
+ loops = face.loops[:]
+ uvs = [loop[uv_layer].uv.copy() for loop in loops]
+ cos = [loop.vert.co.copy() for loop in loops]
+ for i in range(1, len(uvs) - 1):
+ _raster_tri_paint(
+ alb_buf, mask_buf, noise_buf,
+ (uvs[0], uvs[i], uvs[i + 1]),
+ (cos[0], cos[i], cos[i + 1]),
+ lm, W, H)
+ tri_count += 1
+ bm.free()
+ log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
+
+ def _save(buf, name, path):
+ img = bpy.data.images.new(name, W, H, alpha=True)
+ img.pixels.foreach_set(buf.reshape(-1))
+ img.update()
+ img.filepath_raw = path
+ img.file_format = 'PNG'
+ img.save()
+ return img
+
+ albedo_img = _save(alb_buf, f"denim_albedo_{body}", albedo_path)
+ _save(mask_buf, f"denim_mask_{body}", mask_path)
+ log(f"saved albedo -> {albedo_path}")
+ log(f"saved mask -> {mask_path}")
+ return albedo_img
+
+
+# --------------------------------------------------------------------------
+# Parked logo UV2 (pants are not logo-capable; the shader still samples UV2)
+# --------------------------------------------------------------------------
+
+def author_parked_uv2(shell):
+ me = shell.data
+ while len(me.uv_layers) > 1:
+ me.uv_layers.remove(me.uv_layers[-1])
+ me.uv_layers.new(name="logo_uv")
+ me.uv_layers.active = me.uv_layers[0]
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ uvl = bm.loops.layers.uv.get("logo_uv")
+ for face in bm.faces:
+ for loop in face.loops:
+ loop[uvl].uv = (2.0, 2.0)
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log("logo UV2 authored fully parked (not logo-capable)")
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_denim_shell(body_dir, out_dir, body, offset, hem_frac):
+ crotch_z, _hips_top = probe_hips_bounds(body_dir)
+
+ base.clear_scene()
+ base.COVERED_SEGMENTS = COVERED_SEGMENTS
+ shell, armature = base.build_covered_mesh(body_dir)
+ weld_boundaries(shell)
+
+ zs = [v.co.z for v in shell.data.vertices]
+ waist_z, ankle_z = max(zs), min(zs)
+ lm = LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
+
+ hem_z = hem_cut(shell, lm, hem_frac)
+ # Full-length pants hem AT the ankle joint (calf tail); cropped variants
+ # hem at the cut plane.
+ ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
+ waist_plane, ankle_plane = flatten_open_rims(shell, ankle_plane)
+
+ base.offset_outward(shell, offset)
+ waist_flare(shell, waist_plane,
+ BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M)
+ base.solidify(shell, base.CLOTH_THICKNESS_M)
+ clamp_waist_residue(shell, waist_plane)
+
+ # Landmarks reference the CLEAN rims (band under the flattened waist edge,
+ # cuff above the flattened hem).
+ lm.waist_z = waist_plane
+ lm.finalize(ankle_plane)
+ author_parked_uv2(shell)
+
+ albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
+ mask_path = os.path.join(out_dir, f"{body}_mask.png")
+ albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
+ base.assign_fabric_material(shell, albedo_img)
+
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+def main():
+ global HEM_FRAC, DENIM_RGB, PLAIN, WAIST_FLARE_M
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- [--bodies a,b,c] "
+ "[--offset M] [--hem-frac F] [--waist-flare M] "
+ "[--base-rgb r,g,b] [--plain]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ offset = base.PER_BODY_OFFSET_M
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ if "--hem-frac" in argv:
+ HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
+ if "--waist-flare" in argv:
+ WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
+ if "--base-rgb" in argv:
+ DENIM_RGB = tuple(
+ float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
+ if "--plain" in argv:
+ PLAIN = True
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+ log(f"denim per-body mode: {len(bodies)} bodies, offset "
+ f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, plain={PLAIN}")
+
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_denim_shell(body_dir, out_dir, body, offset, HEM_FRAC)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append((body, f"error: {exc}"))
+
+ ref = base.REFERENCE_BODY
+ ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
+ ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
+ if os.path.isfile(ref_alb):
+ shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
+ log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_hoodie.py b/tooling/garment-fit/blender_author_hoodie.py
new file mode 100644
index 000000000..4db6245ff
--- /dev/null
+++ b/tooling/garment-fit/blender_author_hoodie.py
@@ -0,0 +1,590 @@
+"""
+blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
+
+Hooded-top companion to blender_author_offset_shell.py: reuses the base
+module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
+export machinery and layers the hoodie-specific geometry + texture identity on
+top. Everything spatial is derived from bone landmarks (ratios of neck_01
+length, shoulder |x|, spine span) so the same parameters produce a
+proportionally identical garment on all 11 bodies. The parameter block below
+is the reusable seam for the rest of the hooded/long-sleeve family
+(track_jacket, sweater_modern): import this module and override.
+
+Hoodie deltas over the base t-shirt shell:
+ * covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
+ lowerarm bone instead of the upperarm short-sleeve cut)
+ * HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
+ radially away from the neck axis (back-biased, slight upward lift) before
+ solidify, so the roll gets real thickness and caps into a ring
+ * looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
+ * painted albedo identity (texture-carried, per body because UV0 face
+ classification is per body): kangaroo pocket fill + stitch outline,
+ drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
+ the luma-preserving toon_garment recolor keeps them under any tint
+ * region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
+ back), G = body, B = sleeves + kangaroo pocket
+ * logo-capable chest via the base UV2 channel (unchanged)
+
+PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_hoodie.py -- \
+ client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
+ [--bodies a,b,c] [--offset M]
+
+Writes per body: /.glb, _mask.png, _base_albedo.png
+Plus fallbacks: /base_albedo.png + reference_mask.png (= average_m's).
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
+"""
+
+import importlib.util
+import math
+import os
+import shutil
+import sys
+
+import bpy
+import bmesh
+import numpy as np
+from mathutils import Vector
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def _load_base():
+ spec = importlib.util.spec_from_file_location(
+ "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+base = _load_base()
+
+# --------------------------------------------------------------------------
+# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
+# --------------------------------------------------------------------------
+
+GARMENT_ID = "hoodie_modern"
+
+SEGMENTS = [
+ "seg_torso", "seg_torso_upper",
+ "seg_arm_upper_l", "seg_arm_upper_r",
+ "seg_arm_lower_l", "seg_arm_lower_r",
+]
+
+OFFSET_M = 0.016 # loose standoff (per-body construction guarantees it)
+THICKNESS_M = 0.006 # fleece-weight cloth
+WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
+
+# Hood-down roll: fractions of neck_01 length unless noted.
+ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
+ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
+ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
+ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
+ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
+ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
+ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
+
+# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
+R_Z_DROP_FRONT = 0.15
+R_Z_DROP_BACK = 0.55
+
+# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
+# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
+POCKET_Z0_FRAC = 0.06
+POCKET_Z1_FRAC = 0.88
+POCKET_WB_FRAC = 0.56 # bottom half-width
+POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
+POCKET_FILL_MULT = 0.95 # subtle patch shading
+POCKET_LINE_MULT = 0.70 # stitch outline darkening
+POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
+
+# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
+# z as fractions of neck_len.
+STRING_X_FRAC = 0.30
+STRING_HALF_W_M = 0.005
+STRING_TOP_DROP_FRAC = 0.10
+STRING_LEN_FRAC = 0.75
+STRING_MULT = 0.50
+
+# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
+# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
+# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
+# of the plane, so a nominal-anchored band would mostly land on deleted faces.
+HEM_BAND_FRAC = 0.08
+CUFF_LEN_FRAC = 0.16
+BAND_MULT = 0.85
+BAND_LINE_MULT = 0.72
+BAND_LINE_M = 0.004
+
+# Muted street-tone fabric (luma drives the toon_garment recolor).
+FABRIC_RGB = (0.55, 0.56, 0.58)
+FABRIC_NOISE = 0.035
+ALBEDO_SEED = 1091
+
+
+def log(msg):
+ print(f"[hoodie] {msg}")
+
+
+# --------------------------------------------------------------------------
+# Landmarks beyond the base thresholds
+# --------------------------------------------------------------------------
+
+def derive_landmarks(armature, thr):
+ """Hoodie-specific bone landmarks (all in body-local metres)."""
+ bones = armature.data.bones
+ neck = bones.get("neck_01")
+ la_l = bones.get("lowerarm_l")
+ la_r = bones.get("lowerarm_r")
+ if not all([neck, la_l, la_r]):
+ raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
+ neck_len = neck.tail_local.z - neck.head_local.z
+ lm = {
+ "neck_y": neck.head_local.y,
+ "neck_len": neck_len,
+ "roll_out": ROLL_OUT_FRAC * neck_len,
+ "roll_lift": ROLL_LIFT_FRAC * neck_len,
+ "roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
+ # wrist cut thresholds per side: (sign, x threshold)
+ "wrist_cuts": [],
+ "lowerarm_len": 0.0,
+ }
+ for b, sign in [(la_l, +1), (la_r, -1)]:
+ head_x, tail_x = b.head_local.x, b.tail_local.x
+ thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
+ lm["wrist_cuts"].append((sign, thr_x))
+ lm["lowerarm_len"] = abs(tail_x - head_x)
+ log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
+ f"reach {lm['roll_reach']:.3f} wrist cuts "
+ + " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
+ return lm
+
+
+# --------------------------------------------------------------------------
+# Geometry: wrist cut + hood roll
+# --------------------------------------------------------------------------
+
+def wrist_cut(shell, lm):
+ """Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
+ but on the lowerarm bone so the sleeves stay full length)."""
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = []
+ for v in bm.verts:
+ for sign, thr_x in lm["wrist_cuts"]:
+ if sign > 0 and v.co.x > thr_x:
+ to_delete.append(v)
+ break
+ if sign < 0 and v.co.x < thr_x:
+ 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"wrist cut removed {len(to_delete)} verts; "
+ f"{len(shell.data.vertices)} remain")
+
+
+def hood_roll(shell, thr, lm):
+ """Inflate collar-band verts radially away from the neck axis to read as a
+ rolled-down hood: back-biased bulge + slight rim lift. Runs after the
+ outward offset and before solidify (the roll then gets cloth thickness)."""
+ z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
+ reach = lm["roll_reach"]
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.verts.ensure_lookup_table()
+
+ candidates = []
+ z_rim = z_start
+ for v in bm.verts:
+ if v.co.z < z_start:
+ continue
+ hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
+ if hd > reach or hd < 1e-6:
+ continue
+ candidates.append((v, hd))
+ z_rim = max(z_rim, v.co.z)
+ if z_rim <= z_start or not candidates:
+ log("WARNING: no hood-roll candidates found — roll skipped")
+ bm.free()
+ return
+
+ moved = 0
+ for v, hd in candidates:
+ t = (v.co.z - z_start) / (z_rim - z_start)
+ w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
+ if w <= 0.0:
+ continue
+ dir_h = Vector((v.co.x, v.co.y - lm["neck_y"], 0.0)) / hd
+ backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
+ gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
+ v.co += dir_h * (lm["roll_out"] * w * gain)
+ v.co.z += lm["roll_lift"] * w
+ moved += 1
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
+ f"start z {z_start:.3f})")
+
+
+# --------------------------------------------------------------------------
+# Paint maps: rasterize body-space (x, z) into UV0 texel space
+# --------------------------------------------------------------------------
+
+def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
+ """Barycentric rasterization interpolating body-space x/z per texel."""
+ 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, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
+ px = px_x + 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
+ x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
+ z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
+ sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
+ maps["X"][sl][inside] = x_val[inside]
+ maps["Z"][sl][inside] = z_val[inside]
+ maps["valid"][sl][inside] = True
+ if is_front:
+ maps["front"][sl][inside] = True
+ if is_back:
+ maps["back"][sl][inside] = True
+
+
+def bake_paint_maps(shell, W, H):
+ """Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
+ with front/back facing flags (front = -Y on this rig)."""
+ maps = {
+ "X": np.zeros((H, W), dtype=np.float32),
+ "Z": np.zeros((H, W), dtype=np.float32),
+ "valid": np.zeros((H, W), dtype=bool),
+ "front": np.zeros((H, W), dtype=bool),
+ "back": np.zeros((H, W), dtype=bool),
+ }
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.faces.ensure_lookup_table()
+ bm.normal_update()
+ uv_layer = bm.loops.layers.uv.active
+ if uv_layer is None:
+ raise RuntimeError("no active UV layer for paint-map bake")
+ for face in bm.faces:
+ n_front = face.normal.y * base.FRONT_Y_SIGN
+ c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
+ is_front = n_front > 0.15 and c_front > 0.0
+ is_back = n_front < -0.15 and c_front < 0.0
+ loops = face.loops[:]
+ uvs = [lp[uv_layer].uv.copy() for lp in loops]
+ cos = [lp.vert.co for lp in loops]
+ for i in range(1, len(uvs) - 1):
+ _raster_tri_attr(
+ maps, uvs[0], uvs[i], uvs[i + 1],
+ (cos[0].x, cos[i].x, cos[i + 1].x),
+ (cos[0].z, cos[i].z, cos[i + 1].z),
+ is_front, is_back, W, H)
+ bm.free()
+ overlap = int((maps["front"] & maps["back"]).sum())
+ log(f"paint maps: {int(maps['valid'].sum())} texels "
+ f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
+ f"front/back UV overlap {overlap})")
+ dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
+ if dump:
+ np.savez_compressed(dump, **maps)
+ log(f"debug maps dumped -> {dump}")
+ return maps
+
+
+# --------------------------------------------------------------------------
+# Painted albedo (texture-carried modern identity)
+# --------------------------------------------------------------------------
+
+def derive_paint_params(shell, thr, lm):
+ """Body-space paint geometry, derived from thresholds + final shell mesh."""
+ sleeve_x = thr["sleeve_x_abs"]
+ hem_z = min((v.co.z for v in shell.data.vertices
+ if abs(v.co.x) < sleeve_x), default=1.0)
+ # Actual sleeve reach from the final mesh (jagged post-cut boundary).
+ wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
+ chest_lo = thr["chest_z"][0]
+ hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
+ hem_top = hem_z + hem_band
+ span = chest_lo - hem_top
+ shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
+ p = {
+ "hem_z": hem_z,
+ "hem_band": hem_band,
+ "pocket_z0": hem_top + POCKET_Z0_FRAC * span,
+ "pocket_z1": hem_top + POCKET_Z1_FRAC * span,
+ "pocket_wb": POCKET_WB_FRAC * shoulder_x,
+ "pocket_wt": POCKET_WT_FRAC * shoulder_x,
+ "string_x": STRING_X_FRAC * thr["collar_x_abs"],
+ "string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
+ "string_len": STRING_LEN_FRAC * lm["neck_len"],
+ "cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
+ "wrist_x": wrist_x,
+ "sleeve_x": sleeve_x,
+ }
+ log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
+ f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
+ return p
+
+
+def _erode(m, iters):
+ for _ in range(iters):
+ m = (m
+ & np.roll(m, 1, 0) & np.roll(m, -1, 0)
+ & np.roll(m, 1, 1) & np.roll(m, -1, 1))
+ return m
+
+
+def pocket_mask(maps, p):
+ """Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
+ X, Z = maps["X"], maps["Z"]
+ paintable = maps["front"] & ~maps["back"]
+ z0, z1 = p["pocket_z0"], p["pocket_z1"]
+ if z1 <= z0:
+ return np.zeros_like(paintable)
+ t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
+ half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
+ inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
+ log(f"pocket mask: {int(inside.sum())} texels")
+ return inside
+
+
+def make_painted_albedo(maps, pocket_px, p, out_dir, body):
+ """Flat street-tone fabric + painted luminance detail; returns bpy image."""
+ W = H = base.ALBEDO_SIZE
+ rng = np.random.default_rng(ALBEDO_SEED)
+ fabric = np.array(FABRIC_RGB, dtype=np.float32)
+ noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
+ rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
+
+ X, Z = maps["X"], maps["Z"]
+ valid = maps["valid"]
+ front_only = maps["front"] & ~maps["back"]
+
+ # Ribbed hem band (all around) + top stitch line.
+ hem_top = p["hem_z"] + p["hem_band"]
+ band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
+ rgb[band] *= BAND_MULT
+ line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
+ rgb[line] *= BAND_LINE_MULT
+
+ # Ribbed cuffs (all around) + inner stitch line.
+ cuff_x0 = p["wrist_x"] - p["cuff_len"]
+ cuff = valid & (np.abs(X) >= cuff_x0)
+ rgb[cuff] *= BAND_MULT
+ cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
+ rgb[cline] *= BAND_LINE_MULT
+ log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
+ f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
+ f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
+ zs = Z[valid]
+ xs_v = np.abs(X[valid])
+ log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
+ f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
+ f"Z<=hem_top {int((zs <= hem_top).sum())} "
+ f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
+
+ # Kangaroo pocket: subtle fill + dark stitch outline.
+ rgb[pocket_px] *= POCKET_FILL_MULT
+ outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
+ rgb[outline] *= POCKET_LINE_MULT
+
+ # Drawstrings (front only, hanging from the collar).
+ z_top = p["string_z_top"]
+ z_bot = z_top - p["string_len"]
+ for sx in (+p["string_x"], -p["string_x"]):
+ s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
+ & (Z >= z_bot) & (Z <= z_top)
+ rgb[s] *= STRING_MULT
+
+ rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
+ img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
+ img.pixels.foreach_set(rgba.reshape(-1))
+ img.update()
+ sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
+ img.filepath_raw = sidecar
+ img.file_format = 'PNG'
+ img.save()
+ log(f"painted albedo -> {sidecar}")
+ # The glTF exporter names the embedded image after the file basename, and
+ # the Godot import EXTRACTS it as _.png. Point the image at
+ # the shared base_albedo.png so the extraction lands exactly on the sidecar
+ # name above (_base_albedo.png, same pixels — tshirt convention),
+ # instead of a doubled __base_albedo.png. The shared file is
+ # re-pointed to the reference body's paint at the end of the run.
+ img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
+ img.save()
+ return img
+
+
+# --------------------------------------------------------------------------
+# Region mask: R = hood roll, G = body, B = sleeves + pocket
+# --------------------------------------------------------------------------
+
+def _classify_hoodie(center, thr, lm):
+ x, y, z = center.x, center.y, center.z
+ if abs(x) >= thr["sleeve_x_abs"]:
+ return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
+ hd = math.hypot(x, y - lm["neck_y"])
+ is_front = y * base.FRONT_Y_SIGN > 0.0
+ drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
+ z_min = thr["collar_z_min"] - drop * lm["neck_len"]
+ if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
+ return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
+ return (0.0, 1.0, 0.0, 0.0) # body -> G
+
+
+def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
+ """Per-face region rasterization (like base.bake_region_mask) with the
+ hoodie classifier, then the pocket texels overlaid into B."""
+ W = H = base.MASK_SIZE
+ buf = np.zeros((H, W, 4), dtype=np.float32)
+ buf[:, :, 1] = 1.0 # body-green background (bleed safety)
+
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ 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")
+ counts = {"roll": 0, "body": 0, "sleeve": 0}
+ for face in bm.faces:
+ color = _classify_hoodie(face.calc_center_median(), thr, lm)
+ if color[0] > 0.5:
+ counts["roll"] += 1
+ elif color[2] > 0.5:
+ counts["sleeve"] += 1
+ else:
+ counts["body"] += 1
+ for a, b, c in base._tris_from_face(face, uv_layer):
+ base._raster_tri(buf, a, b, c, color, W, H)
+ bm.free()
+ total = max(sum(counts.values()), 1)
+ log("region faces: " + " ".join(
+ f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
+
+ # Pocket joins the sleeve tint region (B), per the spec.
+ if pocket_px is not None and pocket_px.shape == (H, W):
+ buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
+
+ img = bpy.data.images.new(f"{GARMENT_ID}_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}")
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_hoodie(body_dir, out_dir, body, offset):
+ base.clear_scene()
+ shell, armature = base.build_covered_mesh(body_dir)
+ thr = base.derive_thresholds(armature)
+ lm = derive_landmarks(armature, thr)
+
+ wrist_cut(shell, lm)
+ base.offset_outward(shell, offset)
+ hood_roll(shell, thr, lm)
+ base.solidify(shell, THICKNESS_M)
+
+ maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
+ p = derive_paint_params(shell, thr, lm)
+ pocket_px = pocket_mask(maps, p)
+
+ albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
+ base.assign_fabric_material(shell, albedo_img)
+ base.author_logo_uv(shell, thr)
+
+ # Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
+ # today; rebake the pocket mask if they ever diverge.
+ mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
+ bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
+ thr, lm, mask_pocket)
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- [--bodies a,b,c] [--offset M]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ offset = OFFSET_M
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+ base.COVERED_SEGMENTS = SEGMENTS
+
+ log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_hoodie(body_dir, out_dir, body, offset)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append((body, f"error: {exc}"))
+
+ # Runtime fallbacks mirror the reference body (average_m).
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log("copied reference_mask.png fallback")
+ ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
+ if os.path.isfile(ref_albedo):
+ shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
+ log("copied base_albedo.png fallback")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_jacket_shell.py b/tooling/garment-fit/blender_author_jacket_shell.py
new file mode 100644
index 000000000..4906de9e6
--- /dev/null
+++ b/tooling/garment-fit/blender_author_jacket_shell.py
@@ -0,0 +1,529 @@
+"""
+blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket)
+
+Jacket-family companion to blender_author_offset_shell.py — imports that module
+as a library (segments -> join -> offset -> solidify -> skinned export are
+reused unchanged) and adds what a JACKET needs beyond a t-shirt:
+
+ * full-arm coverage (torso + torso_upper + upper AND lower arms) with a
+ WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut;
+ * a boundary WELD after the segment join, so the elbow/shoulder segment
+ seams offset as one continuous surface (no gap rings on long sleeves);
+ * a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee);
+ * a PAINTED albedo + region mask baked together in ONE per-pixel
+ rasterization pass: each texel's 3D body-local position is interpolated
+ barycentric-ally, so the V-opening / lapels / shirt triangle have crisp
+ edges independent of the low-poly tessellation (the base script's
+ per-FACE classification would read chunky on a chest V).
+
+Region-mask convention for the suit (toon_garment.gdshader tint routing):
+ R = lapels + collar band (tint_0 — subtle satin two-tone)
+ G = jacket body (tint_1)
+ B = sleeves (tint_2)
+ A = shirt triangle in the V (tint_3 — default WHITE so outfits can
+ recolor the visible shirt)
+
+Painted albedo details (luma-carried; the shader recolors hue per region but
+keeps albedo luminance, so detail must live in the 0.53-max luma band or the
+`luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons,
+lapel fold + edge shading, front closure seam + jacket button below the V,
+sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same
+anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01,
+plus lowerarm for the wrist), so every body derives its own proportional
+cut/paint constants — calibrated to the intended metric sizes on average_m.
+
+PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
+guidance) — there is no single-reference mode here.
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_jacket_shell.py -- \
+ \
+ [--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
+
+ Writes per body:
+ /.glb skinned jacket authored on that body
+ /_mask.png RGBA region mask (that body's UV0)
+ Plus:
+ /base_albedo.png copy of average_m's painted albedo
+ /reference_mask.png copy of average_m's mask (runtime fallback)
+
+ The painted per-body albedo is embedded in each GLB (image URI basename
+ "base_albedo", staged under .cache/garment-fit-suit//); on import
+ Godot extracts it to /_base_albedo.png — the same layout the
+ t-shirt reference garment has.
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
+"""
+
+import sys
+import os
+import shutil
+import bpy
+import bmesh
+import numpy as np
+
+# Import the base offset-shell module as a library.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import blender_author_offset_shell as base # noqa: E402
+
+# --------------------------------------------------------------------------
+# Jacket parameters
+# --------------------------------------------------------------------------
+
+JACKET_SEGMENTS = [
+ "seg_torso", "seg_torso_upper",
+ "seg_arm_upper_l", "seg_arm_upper_r",
+ "seg_arm_lower_l", "seg_arm_lower_r",
+]
+
+JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body)
+WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete
+ # cut also drops faces touching deleted verts, so the
+ # visible sleeve ends ~1 low-poly ring earlier — 0.92
+ # lands the hem just above the wrist (matches the
+ # buttondown's calibrated SLEEVE_END_FRAC).
+CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band
+WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld
+
+BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the
+ # 6 mm placket / button dots on the torso island)
+
+# --- V-opening / lapel geometry, as ratios of average_m bone landmarks -----
+# (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205,
+# spine_01 head z 1.072 -> spine span 0.4485)
+V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m
+V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m
+LAPEL_W_M = 0.045 # lapel band width
+LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point
+EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only)
+PLACKET_HALF_M = 0.006 # shirt placket half-width
+CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width
+SHIRT_BTN_R_M = 0.0055 # shirt button radius
+JACKET_BTN_R_M = 0.009 # jacket button radius
+JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point
+SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot)
+
+V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X
+LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X
+EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X
+PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X
+CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X
+SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X
+JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X
+_REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z
+V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN
+LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN
+JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN
+
+# --- Painted albedo luma palette (neutral hue; shader tints supply color) ---
+# Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail
+# survives a white tint on the shirt region.
+LUMA_FABRIC = 0.400 # jacket body + sleeves + back
+LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body)
+LUMA_EDGE = 0.250 # lapel fold/edge shading lines
+LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint)
+LUMA_PLACKET = 0.440 # shirt placket strip
+LUMA_SHIRT_BTN = 0.320 # shirt buttons
+LUMA_CLOSURE = 0.290 # jacket closure seam below the V
+LUMA_JACKET_BTN = 0.180 # jacket button
+LUMA_CUFF = 0.330 # sleeve cuff band
+ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well)
+ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys
+
+log = base.log
+
+
+# --------------------------------------------------------------------------
+# Suit-specific threshold derivation (extends base.derive_thresholds)
+# --------------------------------------------------------------------------
+
+def derive_jacket_thresholds(armature, wrist_frac):
+ thr = base.derive_thresholds(armature)
+ bones = armature.data.bones
+ ua_l = bones.get("upperarm_l")
+ neck = bones.get("neck_01")
+ spine01 = bones.get("spine_01")
+
+ if ua_l and neck and spine01:
+ ua_r = bones.get("upperarm_r")
+ shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
+ spine_lo = spine01.head_local.z
+ span = neck.head_local.z - spine_lo
+ else:
+ log("WARNING: landmark bones missing — legacy average_m jacket constants")
+ shoulder_x = base._REF_SHOULDER_X
+ spine_lo = base._REF_SPINE_LO_Z
+ span = _REF_SPAN
+
+ v_top = thr["collar_z_min"]
+ v_bot = spine_lo + V_BOT_FRAC * span
+ thr.update({
+ "v_top_z": v_top,
+ "v_bot_z": v_bot,
+ "v_half_top": shoulder_x * V_HALF_TOP_FRAC,
+ "lapel_w": shoulder_x * LAPEL_W_FRAC,
+ "lapel_drop": LAPEL_DROP_FRAC * span,
+ "edge_w": shoulder_x * EDGE_W_FRAC,
+ "placket_half": shoulder_x * PLACKET_HALF_FRAC,
+ "closure_half": shoulder_x * CLOSURE_HALF_FRAC,
+ "shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC,
+ "jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC,
+ "jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span,
+ "shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS],
+ })
+
+ # Wrist cut planes + cuff band start, from the lowerarm bones (arms run
+ # along +/-X in rest pose, elbow head -> wrist tail).
+ cut_planes = [] # (sign, wrist_thr_x, cuff_start_x)
+ for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
+ b = bones.get(bone_name)
+ if b is None:
+ log(f"WARNING: bone {bone_name} missing — sleeve uncut on that side")
+ continue
+ head_x = b.head_local.x
+ tail_x = b.tail_local.x
+ wrist_thr = head_x + wrist_frac * (tail_x - head_x)
+ cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x)
+ cut_planes.append((sign, wrist_thr, cuff_start))
+ log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} "
+ f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}")
+ thr["wrist_planes"] = cut_planes
+
+ log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} "
+ f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} "
+ f"btn z {thr['jacket_btn_z']:.3f}")
+ return thr
+
+
+# --------------------------------------------------------------------------
+# Geometry: weld + wrist cut
+# --------------------------------------------------------------------------
+
+def weld_segment_boundaries(shell):
+ """Merge coincident verts along segment seams (elbow/shoulder/chest lines).
+
+ Adjacent body segments duplicate their shared boundary loop; unwelded, the
+ two copies get island-local normals and offset APART, opening a gap ring at
+ every seam. Welding first makes the shell offset as one surface. Merged
+ verts come from the same original body vertex, so weights and loop UVs are
+ identical/preserved.
+ """
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ before = len(bm.verts)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M)
+ bm.to_mesh(shell.data)
+ after = len(shell.data.vertices)
+ bm.free()
+ shell.data.update()
+ log(f"welded segment boundaries: {before} -> {after} verts "
+ f"({before - after} merged)")
+
+
+def wrist_cut(shell, thr):
+ """Delete sleeve verts beyond the wrist plane on each lower arm."""
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = []
+ for v in bm.verts:
+ for sign, wrist_thr, _cuff in thr["wrist_planes"]:
+ if sign > 0 and v.co.x > wrist_thr:
+ to_delete.append(v)
+ break
+ if sign < 0 and v.co.x < wrist_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"wrist cut removed {len(to_delete)} verts; "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# Combined per-pixel albedo paint + region mask bake
+# --------------------------------------------------------------------------
+
+def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr):
+ """Vectorized region + luma classification for one triangle's texels.
+
+ Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions.
+ Region priority: collar > shirt > lapel > body; sleeves are per-face.
+ """
+ n = X.shape[0]
+ mask = np.zeros((n, 4), dtype=np.float32)
+ luma = np.full(n, LUMA_FABRIC, dtype=np.float32)
+
+ front = (Y * base.FRONT_Y_SIGN) > 0.010
+ ax = np.abs(X)
+
+ if face_sleeve:
+ mask[:, 2] = 1.0 # B = sleeves
+ # cuff band on this arm's outer end
+ for sign, wrist_thr, cuff_start in thr["wrist_planes"]:
+ if sign != face_side:
+ continue
+ in_cuff = (X * sign) >= (cuff_start * sign)
+ luma[in_cuff] = LUMA_CUFF
+ return mask, luma
+
+ v_top = thr["v_top_z"]
+ v_bot = thr["v_bot_z"]
+ # V half-width narrows linearly from v_half_top at the collar to 0 at v_bot.
+ t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None)
+ vhalf = thr["v_half_top"] * t
+
+ collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
+ shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf)
+ lapel_out = vhalf + thr["lapel_w"]
+ lapel = (front & ~collar & ~shirt
+ & (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top)
+ & (ax >= vhalf) & (ax < lapel_out))
+
+ mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band
+ mask[:, 3][shirt] = 1.0 # A = shirt triangle
+ body = ~(collar | shirt | lapel)
+ mask[:, 1][body] = 1.0 # G = jacket body
+
+ # ---- painted luma detail ----
+ luma[collar | lapel] = LUMA_SATIN
+ # lapel fold (inner) + edge (outer) shading lines
+ edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"]))
+ luma[edge] = LUMA_EDGE
+ # shirt triangle: base, placket, buttons
+ luma[shirt] = LUMA_SHIRT
+ luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET
+ for bz in thr["shirt_btn_zs"]:
+ btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2)
+ luma[btn] = LUMA_SHIRT_BTN
+ # jacket closure seam + button below the V point
+ closure = body & front & (Z < v_bot) & (ax < thr["closure_half"])
+ luma[closure] = LUMA_CLOSURE
+ jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2)
+ < thr["jacket_btn_r"] ** 2)
+ luma[jbtn] = LUMA_JACKET_BTN
+ return mask, luma
+
+
+def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name):
+ """One rasterization pass -> painted albedo PNG + RGBA region mask PNG.
+
+ Per texel the 3D body-local position is barycentric-interpolated, so paint
+ and regions are classified per PIXEL (crisp V edges on coarse topology).
+ Mask background = body green (bilinear bleed stays tinted); albedo
+ background = fabric luma.
+ """
+ W = H = BAKE_SIZE
+ mask_buf = np.zeros((H, W, 4), dtype=np.float32)
+ mask_buf[:, :, 1] = 1.0
+ albedo_buf = np.full((H, W), LUMA_FABRIC, dtype=np.float32)
+
+ 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 bake")
+
+ counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0}
+ for face in bm.faces:
+ center = face.calc_center_median()
+ face_sleeve = abs(center.x) >= thr["sleeve_x_abs"]
+ face_side = 1 if center.x >= 0.0 else -1
+ loops = face.loops[:]
+ uvs = [lo[uv_layer].uv for lo in loops]
+ cos = [lo.vert.co for lo in loops]
+ for i in range(1, len(uvs) - 1):
+ _raster_tri_pos(
+ mask_buf, albedo_buf,
+ uvs[0], uvs[i], uvs[i + 1],
+ cos[0], cos[i], cos[i + 1],
+ face_sleeve, face_side, thr, W, H, counts)
+ bm.free()
+ log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items()))
+
+ # ---- save mask ----
+ img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True)
+ img.pixels.foreach_set(mask_buf.reshape(-1))
+ img.update()
+ img.filepath_raw = mask_path
+ img.file_format = 'PNG'
+ img.save()
+ log(f"baked region mask -> {mask_path}")
+
+ # ---- albedo: neutral grey luma + blocky woven noise, slightly cool ----
+ rng = np.random.default_rng(1089)
+ coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
+ noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32))
+ lum = np.clip(albedo_buf + noise, 0.0, 1.0)
+ rgba = np.empty((H, W, 4), dtype=np.float32)
+ for c, mul in enumerate(ALBEDO_TINT):
+ rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0)
+ rgba[:, :, 3] = 1.0
+ aimg = bpy.data.images.new(albedo_name, W, H, alpha=False)
+ aimg.pixels.foreach_set(rgba.reshape(-1))
+ aimg.update()
+ aimg.filepath_raw = albedo_path
+ aimg.file_format = 'PNG'
+ aimg.save()
+ log(f"painted albedo -> {albedo_path}")
+ return aimg
+
+
+def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc,
+ face_sleeve, face_side, thr, W, H, counts):
+ """Barycentric fill interpolating 3D position for per-pixel classification."""
+ 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
+ w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
+ X = w0i * pa.x + w1i * pb.x + w2i * pc.x
+ Y = w0i * pa.y + w1i * pb.y + w2i * pc.y
+ Z = w0i * pa.z + w1i * pb.z + w2i * pc.z
+ mask_px, luma_px = _classify_pixels(
+ X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32),
+ face_sleeve, face_side, thr)
+
+ counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum())
+ counts["shirt"] += int((mask_px[:, 3] > 0.5).sum())
+ counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum())
+ counts["body"] += int((mask_px[:, 1] > 0.5).sum())
+
+ m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :]
+ a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1]
+ m_region[inside] = mask_px
+ a_region[inside] = luma_px
+
+
+# --------------------------------------------------------------------------
+# Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent)
+# --------------------------------------------------------------------------
+
+def author_parked_uv2(shell):
+ me = shell.data
+ while len(me.uv_layers) > 1:
+ me.uv_layers.remove(me.uv_layers[-1])
+ me.uv_layers.new(name="logo_uv")
+ me.uv_layers.active = me.uv_layers[0]
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ uvl = bm.loops.layers.uv.get("logo_uv")
+ for face in bm.faces:
+ for loop in face.loops:
+ loop[uvl].uv = (2.0, 2.0)
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log("UV2 authored fully parked (non-logo garment)")
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac):
+ base.clear_scene()
+ base.COVERED_SEGMENTS = JACKET_SEGMENTS
+ shell, armature = base.build_covered_mesh(body_dir)
+ thr = derive_jacket_thresholds(armature, wrist_frac)
+ weld_segment_boundaries(shell)
+ wrist_cut(shell, thr)
+ base.offset_outward(shell, offset)
+ base.solidify(shell, base.CLOTH_THICKNESS_M)
+
+ # Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png"
+ # — the glTF image URI takes the file basename, so Godot's on-import
+ # extraction lands at /_base_albedo.png (tshirt layout)
+ # without a duplicate authored sidecar next to it.
+ body_stage = os.path.join(stage_dir, body)
+ os.makedirs(body_stage, exist_ok=True)
+ albedo_img = bake_albedo_and_mask(
+ shell, thr,
+ os.path.join(body_stage, "base_albedo.png"),
+ os.path.join(out_dir, f"{body}_mask.png"),
+ f"jacket_albedo_{body}")
+ base.assign_fabric_material(shell, albedo_img)
+ author_parked_uv2(shell)
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- "
+ "[--bodies a,b,c] [--offset M] [--wrist-frac F]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ offset = JACKET_OFFSET_M
+ wrist_frac = WRIST_FRAC
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ if "--wrist-frac" in argv:
+ wrist_frac = float(argv[argv.index("--wrist-frac") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+ repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit")
+ log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
+ f"wrist frac {wrist_frac}")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ ref = base.REFERENCE_BODY
+ ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
+ ref_alb = os.path.join(stage_dir, ref, "base_albedo.png")
+ if os.path.isfile(ref_alb):
+ shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
+ log(f"copied staged {ref} albedo -> base_albedo.png")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_lower_shell.py b/tooling/garment-fit/blender_author_lower_shell.py
new file mode 100644
index 000000000..7eeaf94d8
--- /dev/null
+++ b/tooling/garment-fit/blender_author_lower_shell.py
@@ -0,0 +1,524 @@
+"""
+blender_author_lower_shell.py (T-1089, lower-body offset-shell companion)
+
+Author LOWER-BODY offset-shell garments (the pants family) per body, reusing
+blender_author_offset_shell.py as a library (scene build, offset, solidify,
+raster helpers, export). The base script stays the torso/t-shirt reference;
+this companion parameterizes the lower-body family so pants_formal, jeans,
+shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable
+parameters over one-off hacks.
+
+Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r.
+Natural boundaries give the waist opening (top of seg_hips) and ankle hems
+(bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac
+< 1.0 (shorts family).
+
+Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention):
+ R = waistband (top band of the hips, height derived from the pelvis bone)
+ G = legs (everything else — the main fabric region)
+ B = cuffs (optional, --cuff-frac > 0; joggers)
+
+Painted texture identity (baked into the per-body albedo, luma-carried so it
+survives any region tint — the shader replaces hue but keeps luminance):
+ --crease subtle LIGHT front crease line down each leg (formal press line),
+ drawn by intersecting the shell with each leg's hip->knee->ankle
+ axis plane and rasterizing the crossing segments in UV space.
+ --seam subtle DARK horizontal seam line at the waistband boundary.
+
+Per-body mode only (route guidance from Q-060 / 216-capture QA evidence:
+offset-shell garments author per body; weights inherited by construction).
+
+Usage:
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_lower_shell.py -- \
+ \
+ [--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
+ [--waistband-frac 1.0] [--cuff-frac 0.0] \
+ [--fabric R,G,B] [--fabric-noise 0.012] \
+ [--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \
+ [--seed 1093]
+
+Writes per body:
+ /.glb skinned garment authored on that body
+ /_mask.png RGBA region mask (that body's UV0 layout)
+Plus:
+ /base_albedo.png reference body's albedo (sidecar; the
+ painted albedo is embedded per GLB, and
+ the Godot importer extracts it per body
+ as _base_albedo.png)
+ /reference_mask.png copy of average_m_mask.png (runtime fallback)
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe).
+"""
+
+import os
+import shutil
+import sys
+
+import bpy
+import bmesh
+import numpy as np
+
+# Make the sibling base module importable inside Blender.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import blender_author_offset_shell as base # noqa: E402
+
+# --------------------------------------------------------------------------
+# Lower-body garment parameters (CLI-overridable)
+# --------------------------------------------------------------------------
+
+LOWER_SEGMENTS = [
+ "seg_hips",
+ "seg_leg_upper_l", "seg_leg_upper_r",
+ "seg_leg_lower_l", "seg_leg_lower_r",
+]
+
+DEFAULTS = {
+ "offset": 0.010, # slim silhouette standoff (m); per-body authoring
+ # guarantees clearance by construction, and bottoms
+ # sit UNDER tops (t-shirt hem = 12 mm), so 10 mm
+ # keeps the layering order correct at the waist.
+ "leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full)
+ "waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len
+ "cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle
+ "fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast
+ "fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel
+ "crease": True, # painted front crease line per leg
+ "crease_gain": 0.10, # luma lift along the crease (subtle press line)
+ "seam": True, # painted seam at the waistband boundary
+ "seam_gain": 0.05, # luma drop along the seam
+ "seed": 1093, # deterministic albedo noise
+}
+
+CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint
+CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease
+LINE_RADIUS_PX = 0.9 # painted line half-width in texels
+
+
+def log(msg):
+ print(f"[lower-shell] {msg}")
+
+
+# --------------------------------------------------------------------------
+# Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig)
+# --------------------------------------------------------------------------
+
+def derive_lower_thresholds(armature, p):
+ """Derive waistband/cuff z-planes and per-leg axis polylines.
+
+ Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail /
+ calf_* head (knee), calf_* tail (ankle). All in body-local metres so each
+ body derives its OWN proportionally-consistent parameters.
+ """
+ bones = armature.data.bones
+ pelvis = bones.get("pelvis")
+ need = ["thigh_l", "thigh_r", "calf_l", "calf_r"]
+ legs = {}
+ for side in ("l", "r"):
+ thigh = bones.get(f"thigh_{side}")
+ calf = bones.get(f"calf_{side}")
+ if thigh is None or calf is None:
+ raise RuntimeError(f"landmark bones missing for leg _{side} ({need})")
+ # Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up).
+ legs[side] = {
+ "hip": (thigh.head_local.x, thigh.head_local.z),
+ "knee": (thigh.tail_local.x, thigh.tail_local.z),
+ "ankle": (calf.tail_local.x, calf.tail_local.z),
+ }
+ if pelvis is None:
+ raise RuntimeError("pelvis bone missing — cannot derive waistband")
+
+ pelvis_len = pelvis.tail_local.z - pelvis.head_local.z
+ wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len
+
+ hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0
+ ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0
+ knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0
+ leg_len = hip_z - ankle_z
+
+ cut_z = None
+ if p["leg_frac"] < 0.999:
+ cut_z = hip_z - p["leg_frac"] * leg_len
+
+ cuff_z = None
+ if p["cuff_frac"] > 1e-6:
+ cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z)
+
+ thr = {
+ "wb_z": wb_z,
+ "cut_z": cut_z,
+ "cuff_z": cuff_z,
+ "legs": legs,
+ "hip_z": hip_z,
+ "ankle_z": ankle_z,
+ "leg_len": leg_len,
+ }
+ log(f"thresholds: waistband z>={wb_z:.3f} "
+ f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} "
+ f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} "
+ f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}")
+ return thr
+
+
+def _leg_axis_x(leg, z):
+ """Piecewise-linear x of the leg axis at height z (clamped to endpoints)."""
+ hx, hz = leg["hip"]
+ kx, kz = leg["knee"]
+ ax, az = leg["ankle"]
+ if z >= hz:
+ return hx
+ if z <= az:
+ return ax
+ if z >= kz:
+ t = (hz - z) / max(hz - kz, 1e-6)
+ return hx + t * (kx - hx)
+ t = (kz - z) / max(kz - az, 1e-6)
+ return kx + t * (ax - kx)
+
+
+# --------------------------------------------------------------------------
+# Seam weld (pre-offset)
+# --------------------------------------------------------------------------
+
+def weld_seams(shell):
+ """Weld coincident verts before offsetting.
+
+ glTF import splits vertices along UV seams (and the joined segment
+ boundaries duplicate their shared rings), so offsetting each copy along
+ its OWN split normal tears the shell open — on the pants this showed as a
+ skin-coloured slit down the front-centre seam of the hips. Welding fuses
+ the copies so the offset moves one vertex along one averaged normal.
+ Per-loop UVs are untouched (the UV seam itself survives); coincident
+ copies carry identical bone weights by construction, so skinning is
+ unaffected.
+ """
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ before = len(bm.verts)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"welded seams: {before} -> {len(shell.data.vertices)} verts")
+
+
+# --------------------------------------------------------------------------
+# Leg cut (shorts family; skipped at leg_frac 1.0)
+# --------------------------------------------------------------------------
+
+def leg_cut(shell, cut_z):
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ doomed = [v for v in bm.verts if v.co.z < cut_z]
+ bmesh.ops.delete(bm, geom=doomed, context='VERTS')
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# Painted albedo (charcoal base + crease/seam lines, luma-carried identity)
+# --------------------------------------------------------------------------
+
+def _plane_crossings_uv(face, uv_layer, dist_fn):
+ """UV points where the face's edge loop crosses dist_fn(co) == 0."""
+ loops = face.loops[:]
+ n = len(loops)
+ pts = []
+ for i in range(n):
+ la, lb = loops[i], loops[(i + 1) % n]
+ d1 = dist_fn(la.vert.co)
+ d2 = dist_fn(lb.vert.co)
+ if (d1 > 0.0) == (d2 > 0.0):
+ continue
+ denom = d1 - d2
+ if abs(denom) < 1e-9:
+ continue
+ t = d1 / denom
+ uv1 = la[uv_layer].uv
+ uv2 = lb[uv_layer].uv
+ pts.append((uv1[0] + t * (uv2[0] - uv1[0]),
+ uv1[1] + t * (uv2[1] - uv1[1])))
+ return pts
+
+
+def _stamp_uv_line(hits, a, b, W, H):
+ """Mark texels along UV segment a->b into boolean mask `hits`."""
+ ax, ay = a[0] * (W - 1), a[1] * (H - 1)
+ bx, by = b[0] * (W - 1), b[1] * (H - 1)
+ steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1
+ r = LINE_RADIUS_PX
+ for i in range(steps + 1):
+ t = i / steps
+ px = ax + (bx - ax) * t
+ py = ay + (by - ay) * t
+ x0 = max(int(np.floor(px - r)), 0)
+ x1 = min(int(np.ceil(px + r)), W - 1)
+ y0 = max(int(np.floor(py - r)), 0)
+ y1 = min(int(np.ceil(py + r)), H - 1)
+ for yy in range(y0, y1 + 1):
+ for xx in range(x0, x1 + 1):
+ if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25:
+ hits[yy, xx] = True
+
+
+def bake_painted_albedo(shell, thr, p):
+ """Charcoal fabric + painted crease/seam lines, baked in UV0 space.
+
+ The toon_garment shader keeps only albedo LUMINANCE under region tints, so
+ identity painted here (light crease, dark seam) survives any recolor.
+ """
+ W = H = base.ALBEDO_SIZE
+ rng = np.random.default_rng(p["seed"])
+ fabric = np.array(p["fabric"], dtype=np.float32)
+ noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"]
+ rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
+
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.faces.ensure_lookup_table()
+ bm.normal_update()
+ uv_layer = bm.loops.layers.uv.active
+ if uv_layer is None:
+ raise RuntimeError("no active UV layer for albedo bake")
+
+ crease_hits = np.zeros((H, W), dtype=bool)
+ seam_hits = np.zeros((H, W), dtype=bool)
+ crease_faces = 0
+ seam_faces = 0
+
+ crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"]
+ for face in bm.faces:
+ center = face.calc_center_median()
+ # Waistband seam: any face crossing the wb_z plane (full ring).
+ if p["seam"]:
+ pts = _plane_crossings_uv(face, uv_layer,
+ lambda co: co.z - thr["wb_z"])
+ if len(pts) >= 2:
+ _stamp_uv_line(seam_hits, pts[0], pts[1], W, H)
+ seam_faces += 1
+ # Front crease: forward-facing faces crossing the leg-axis plane.
+ if p["crease"]:
+ if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN:
+ continue
+ if center.z > crease_top or center.z < thr["ankle_z"]:
+ continue
+ leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"]
+ pts = _plane_crossings_uv(
+ face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z))
+ if len(pts) >= 2:
+ _stamp_uv_line(crease_hits, pts[0], pts[1], W, H)
+ crease_faces += 1
+ bm.free()
+
+ if p["seam"]:
+ rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0)
+ if p["crease"]:
+ rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0)
+ log(f"painted albedo: crease faces={crease_faces} "
+ f"({int(crease_hits.sum())} px) seam faces={seam_faces} "
+ f"({int(seam_hits.sum())} px)")
+ if p["crease"] and crease_faces == 0:
+ log("WARNING: crease painted on 0 faces — check leg axis / front sign")
+
+ rgba = np.concatenate(
+ [rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
+ img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False)
+ img.pixels.foreach_set(rgba.reshape(-1))
+ img.update()
+ return img
+
+
+# --------------------------------------------------------------------------
+# Region mask (waistband R / legs G / optional cuff B)
+# --------------------------------------------------------------------------
+
+def _classify_lower(center, thr):
+ if center.z >= thr["wb_z"]:
+ return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0)
+ if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]:
+ return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2)
+ return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1)
+
+
+def bake_lower_mask(shell, out_path, thr):
+ W = H = base.MASK_SIZE
+ buf = np.zeros((H, W, 4), dtype=np.float32)
+ buf[:, :, 1] = 1.0 # green background = legs (main region)
+
+ 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")
+
+ counts = {"waistband": 0, "legs": 0, "cuff": 0}
+ for face in bm.faces:
+ color = _classify_lower(face.calc_center_median(), thr)
+ if color[0] > 0.5:
+ counts["waistband"] += 1
+ elif color[2] > 0.5:
+ counts["cuff"] += 1
+ else:
+ counts["legs"] += 1
+ for a, b, c in base._tris_from_face(face, uv_layer):
+ base._raster_tri(buf, a, b, c, color, W, H)
+ bm.free()
+ total = max(sum(counts.values()), 1)
+ log("region faces: " + " ".join(
+ f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
+ if counts["waistband"] == 0:
+ log("WARNING: waistband region empty — check waistband_frac / pelvis")
+
+ img = bpy.data.images.new("garment_lower_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}")
+
+
+# --------------------------------------------------------------------------
+# Parked logo UV2 (pants are not logo-capable; keep the channel guarded)
+# --------------------------------------------------------------------------
+
+def author_parked_logo_uv(shell):
+ me = shell.data
+ 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]
+ data = logo_uv.data
+ parked = [2.0, 2.0] * len(data)
+ logo_uv.data.foreach_set("uv", parked)
+ me.update()
+ log(f"logo UV2 parked outside [0,1] on all {len(data)} loops")
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_lower_shell(body_dir, out_dir, body, p):
+ base.clear_scene()
+ base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer
+ shell, armature = base.build_covered_mesh(body_dir)
+ thr = derive_lower_thresholds(armature, p)
+
+ zs = [v.co.z for v in shell.data.vertices]
+ log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}")
+ if thr["wb_z"] >= max(zs):
+ log("WARNING: waistband plane above shell top — band will be empty")
+
+ weld_seams(shell)
+ if thr["cut_z"] is not None:
+ leg_cut(shell, thr["cut_z"])
+ base.offset_outward(shell, p["offset"])
+ base.solidify(shell, base.CLOTH_THICKNESS_M)
+
+ albedo_img = bake_painted_albedo(shell, thr, p)
+ base.assign_fabric_material(shell, albedo_img)
+ author_parked_logo_uv(shell)
+ bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
+
+ # Save under the SHARED sidecar name for every body: the glTF exporter
+ # names the embedded image after this filepath, and the Godot importer
+ # extracts embedded textures as _.png — so this yields the
+ # per-body _base_albedo.png convention (as tshirt_modern) on import.
+ # The loop authors the reference body LAST so base_albedo.png ends up as
+ # the reference albedo (per-body albedos differ: painted crease follows
+ # each body's own geometry).
+ base.save_albedo_sidecar(
+ albedo_img, os.path.join(out_dir, "base_albedo.png"))
+
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+# --------------------------------------------------------------------------
+# Entry
+# --------------------------------------------------------------------------
+
+def parse_args(argv):
+ p = dict(DEFAULTS)
+ # Reference body last: the shared base_albedo.png sidecar is rewritten per
+ # body, so ordering makes the surviving copy the reference body's.
+ bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY]
+ bodies.append(base.REFERENCE_BODY)
+
+ def _val(flag):
+ return argv[argv.index(flag) + 1]
+
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in _val("--bodies").split(",")]
+ for flag, key, cast in [
+ ("--offset", "offset", float),
+ ("--leg-frac", "leg_frac", float),
+ ("--waistband-frac", "waistband_frac", float),
+ ("--cuff-frac", "cuff_frac", float),
+ ("--fabric-noise", "fabric_noise", float),
+ ("--crease-gain", "crease_gain", float),
+ ("--seam-gain", "seam_gain", float),
+ ("--seed", "seed", int),
+ ]:
+ if flag in argv:
+ p[key] = cast(_val(flag))
+ if "--fabric" in argv:
+ p["fabric"] = tuple(float(s) for s in _val("--fabric").split(","))
+ if "--no-crease" in argv:
+ p["crease"] = False
+ if "--no-seam" in argv:
+ p["seam"] = False
+ return p, bodies
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ p, bodies = parse_args(argv)
+ os.makedirs(out_dir, exist_ok=True)
+
+ log(f"lower-shell per-body: {len(bodies)} bodies, offset "
+ f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, "
+ f"fabric {p['fabric']}")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_lower_shell(body_dir, out_dir, body, p)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_offset_coverall.py b/tooling/garment-fit/blender_author_offset_coverall.py
new file mode 100644
index 000000000..ad00382cc
--- /dev/null
+++ b/tooling/garment-fit/blender_author_offset_coverall.py
@@ -0,0 +1,533 @@
+"""
+blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof)
+
+Full-body COVERALL authored via the offset-shell technique, per body. Companion
+to blender_author_offset_shell.py (imported as a module): reuses its scene
+plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and
+extends it with the coverall-specific geometry and the four-region utility
+mask that this garment exists to prove:
+
+ ONE garment covering torso + torso_upper + arms + hips + legs. The upper and
+ lower shells join at the waist for free: adjacent body segments duplicate a
+ coincident overlap band (probe: median nearest-vertex distance 0.0 in every
+ seam band), so the joined mesh has no gap by construction and the duplicated
+ faces offset identically (same verts, same normals, same atlas UVs) and
+ render invisibly.
+
+Geometry beyond the base script (parameterised, not hard-coded):
+ - WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone
+ (full sleeve ending in a cuff above the hand).
+ - ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in
+ a cuff above the boot line; feet stay free for footwear garments).
+ Both are bone-landmark fractions, so every body derives its own planes.
+
+Region mask (the multi-region showcase, R/G/B/A -> tint_0..3):
+ G = main body fabric
+ R = trim: collar band + arm cuffs + leg cuffs
+ B = belt line (waist band, hides the segment seam) + chest patch (logo box,
+ logo-capable) + right-thigh utility patch
+ A = shoulder marks (top-facing epaulette strap on each shoulder)
+
+Painted albedo details (flat, toon-friendly; identity lives in the texture):
+ per-region flat luminance fills (dark belt webbing, bright patches, mid
+ shoulder marks), a brighter buckle plate at the front-centre of the belt,
+ and dark stitch lines rasterised along every region-boundary edge (patch
+ borders, collar seam, cuff seams, belt edges). The toon_garment shader is
+ luminance-preserving, so these survive any region recolor.
+
+Per-body only (this garment ships per-body per the Q-060 route guidance):
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_offset_coverall.py -- \
+ [--bodies a,b,c] [--offset 0.012]
+
+ Writes per body:
+ /.glb skinned coverall authored on that body
+ /_mask.png RGBA region mask (that body's atlas UVs)
+ /_base_albedo.png painted detail albedo (also in the GLB)
+ Plus:
+ /reference_mask.png copy of average_m_mask.png (runtime fallback)
+ /base_albedo.png copy of average_m's albedo (convention)
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
+(per-body authoring for offset shells).
+"""
+
+import sys
+import os
+import shutil
+
+import bpy
+import bmesh
+import numpy as np
+
+# Make the sibling base module importable when Blender runs this file directly.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import blender_author_offset_shell as base # noqa: E402
+
+# --------------------------------------------------------------------------
+# Coverall parameters (all bone-landmark fractions unless noted)
+# --------------------------------------------------------------------------
+
+COVERED_SEGMENTS = [
+ "seg_torso", "seg_torso_upper",
+ "seg_arm_upper_l", "seg_arm_upper_r",
+ "seg_arm_lower_l", "seg_arm_lower_r",
+ "seg_hips",
+ "seg_leg_upper_l", "seg_leg_upper_r",
+ "seg_leg_lower_l", "seg_leg_lower_r",
+]
+
+WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist)
+ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle)
+CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm
+CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf
+
+# Belt: centred between pelvis head and spine_01 head (the torso|hips seam
+# band sits at ~0.90 of that span on average_m), half-height in ref metres
+# scaled by each body's pelvis->spine_01 span.
+BELT_CENTER_FRAC = 0.90
+BELT_HALF_M_REF = 0.032
+BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate
+
+# Shoulder marks: top-facing band above the upperarm head, outside the collar.
+SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head
+SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x|
+SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward
+
+# Chest patch: the logo box inset by this fraction of its width/height per
+# side, so the amber patch frames the decal instead of touching the box edge.
+CHEST_PATCH_INSET_FRAC = 0.10
+
+# Right-thigh utility patch: box along the thigh bone, front-facing.
+THIGH_PATCH_SIDE = "thigh_r"
+THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone
+THIGH_PATCH_HALF_X_REF = 0.058
+THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN)
+
+# Painted-albedo luminance per region label (flat toon fills).
+ALBEDO_LUMA = {
+ "body": 0.62,
+ "collar": 0.66,
+ "cuff": 0.66,
+ "shoulder": 0.50,
+ "belt": 0.34,
+ "buckle": 0.82,
+ "patch": 0.72,
+}
+STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries
+ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script)
+
+# Label ids index the vectorised classifier's output arrays.
+LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"]
+LABEL_ID = {name: i for i, name in enumerate(LABELS)}
+LABEL_RGBA = {
+ "collar": (1.0, 0.0, 0.0, 0.0), # R trim
+ "cuff": (1.0, 0.0, 0.0, 0.0), # R trim
+ "body": (0.0, 1.0, 0.0, 0.0), # G main fabric
+ "belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches
+ "buckle": (0.0, 0.0, 1.0, 0.0), # B
+ "patch": (0.0, 0.0, 1.0, 0.0), # B
+ "shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks
+}
+LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], dtype=np.float32)
+LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
+
+_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
+
+log = base.log
+
+
+# --------------------------------------------------------------------------
+# Threshold derivation (extends base.derive_thresholds with coverall zones)
+# --------------------------------------------------------------------------
+
+def derive_coverall_thresholds(armature):
+ thr = base.derive_thresholds(armature)
+ bones = armature.data.bones
+
+ def bone(name):
+ b = bones.get(name)
+ if b is None:
+ raise RuntimeError(f"landmark bone {name} missing")
+ return b
+
+ ua = bone("upperarm_l")
+ neck = bone("neck_01")
+ pelvis = bone("pelvis")
+ spine01 = bone("spine_01")
+ shoulder_x = abs(ua.head_local.x)
+ scale = shoulder_x / _REF_SHOULDER_X
+
+ # Wrist cut planes + arm cuff start, per side (arm runs along +/-X).
+ for side, sign in (("l", +1), ("r", -1)):
+ la = bone(f"lowerarm_{side}")
+ hx, tx = la.head_local.x, la.tail_local.x
+ thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx)
+ thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx)
+ # Ankle cut + leg cuff start (leg runs down -Z; both calves share z).
+ calf = bone("calf_l")
+ hz, tz = calf.head_local.z, calf.tail_local.z
+ thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz)
+ thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz)
+
+ # Belt band.
+ pz, sz = pelvis.head_local.z, spine01.head_local.z
+ span = sz - pz
+ thr["belt_z"] = pz + BELT_CENTER_FRAC * span
+ thr["belt_half"] = BELT_HALF_M_REF * scale
+ thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale
+
+ # Shoulder marks.
+ uz = ua.head_local.z
+ thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz)
+ thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC
+
+ # Chest patch = logo box inset a little on every side.
+ cx0, cx1 = thr["chest_x"]
+ cz0, cz1 = thr["chest_z"]
+ dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC
+ dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC
+ thr["patch_x"] = (cx0 + dx, cx1 - dx)
+ thr["patch_z"] = (cz0 + dz, cz1 - dz)
+
+ # Right-thigh patch box.
+ thigh = bone(THIGH_PATCH_SIDE)
+ thz, ttz = thigh.head_local.z, thigh.tail_local.z
+ f0, f1 = THIGH_PATCH_Z_FRACS
+ thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz))
+ tx_ctr = thigh.head_local.x
+ half = THIGH_PATCH_HALF_X_REF * scale
+ thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half)
+
+ log(
+ "coverall thresholds: "
+ f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} "
+ f"ankle z<{thr['ankle_cut_z']:.3f} "
+ f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} "
+ f"shoulder z>={thr['shoulder_z_min']:.3f} "
+ f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) "
+ f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})"
+ )
+ return thr
+
+
+# --------------------------------------------------------------------------
+# Limb cuts (wrists + ankles)
+# --------------------------------------------------------------------------
+
+def limb_cuts(shell, thr):
+ """Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z)."""
+ wl = thr["wrist_cut_x_l"]
+ wr = thr["wrist_cut_x_r"]
+ az = thr["ankle_cut_z"]
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = [
+ v for v in bm.verts
+ if v.co.x > wl or v.co.x < wr or v.co.z < az
+ ]
+ bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"limb cuts removed {len(to_delete)} verts "
+ f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# Region classification (vectorised, per texel)
+#
+# Face-granular classification made the patch/belt/shoulder boundaries follow
+# the triangulation (jagged, torn-looking zones on the first preview). The
+# bake therefore classifies every TEXEL: barycentric interpolation gives each
+# covered texel a body-space position + smoothed vertex normal, and the zone
+# boundaries land exactly where the thresholds say — crisp at mask resolution.
+# --------------------------------------------------------------------------
+
+def classify_texels(pos, nrm, thr):
+ """Classify N texels. pos/nrm are (N,3) body-local arrays.
+
+ Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar,
+ cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body.
+ """
+ x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
+ fy = y * base.FRONT_Y_SIGN
+ fwd = nrm[:, 1] * base.FRONT_Y_SIGN
+ ax = np.abs(x)
+
+ lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
+ remaining = np.ones(x.shape, dtype=bool)
+
+ def take(cond, name):
+ m = cond & remaining
+ lab[m] = LABEL_ID[name]
+ remaining[m] = False
+
+ take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar")
+ take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff")
+ take(z <= thr["cuff_leg_z"], "cuff")
+ take(
+ (z >= thr["shoulder_z_min"])
+ & (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"])
+ & (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN),
+ "shoulder",
+ )
+ belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"]
+ take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle")
+ take(belt, "belt")
+ px0, px1 = thr["patch_x"]
+ pz0, pz1 = thr["patch_z"]
+ take(
+ (fy > base.CHEST_FRONT_Y)
+ & (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1)
+ & (fwd > base.CHEST_NORMAL_Y),
+ "patch",
+ )
+ tx0, tx1 = thr["thigh_patch_x"]
+ tz0, tz1 = thr["thigh_patch_z"]
+ take(
+ (fy > 0.0)
+ & (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1)
+ & (fwd > THIGH_PATCH_NORMAL_Y_MIN),
+ "patch",
+ )
+ return lab
+
+
+# --------------------------------------------------------------------------
+# Combined mask + painted-albedo bake (one classification pass)
+# --------------------------------------------------------------------------
+
+def _tri_texels(a, b, c, W, H):
+ """Texels covered by UV triangle (a,b,c) with barycentric weights.
+
+ Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays
+ instead of writing a flat colour, so the caller can interpolate per-texel
+ attributes (position, normal) across the triangle.
+ """
+ empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
+ 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 empty
+ denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
+ if abs(denom) < 1e-9:
+ return empty
+ 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 empty
+ return (
+ ys[inside], xs[inside],
+ w0[inside].astype(np.float32),
+ w1[inside].astype(np.float32),
+ w2[inside].astype(np.float32),
+ )
+
+
+def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed):
+ """One pass over the faces: bake the RGBA region mask AND the painted
+ detail albedo (flat per-region luminance + stitch lines + woven noise).
+
+ Classification is per TEXEL (interpolated position + smoothed vertex
+ normal), so zone boundaries are crisp at mask resolution instead of
+ following the triangulation.
+ """
+ W = H = base.MASK_SIZE
+ mask = np.zeros((H, W, 4), dtype=np.float32)
+ mask[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
+ albedo = np.zeros((H, W, 4), dtype=np.float32)
+ albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
+ albedo[:, :, 3] = 1.0
+ label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
+ covered = np.zeros((H, W), dtype=bool)
+
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.faces.ensure_lookup_table()
+ bm.normal_update()
+ uv_layer = bm.loops.layers.uv.active
+ if uv_layer is None:
+ raise RuntimeError("no active UV layer for bake")
+
+ for face in bm.faces:
+ loops = face.loops[:]
+ uvs = [loop[uv_layer].uv for loop in loops]
+ pos = [loop.vert.co for loop in loops]
+ nrm = [loop.vert.normal for loop in loops]
+ for i in range(1, len(loops) - 1):
+ tri = (0, i, i + 1)
+ ys, xs, w0, w1, w2 = _tri_texels(uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
+ if ys.size == 0:
+ continue
+ p = np.empty((ys.size, 3), dtype=np.float32)
+ n = np.empty((ys.size, 3), dtype=np.float32)
+ for axis in range(3):
+ p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
+ + w2 * pos[tri[2]][axis])
+ n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
+ + w2 * nrm[tri[2]][axis])
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
+ lab = classify_texels(p, n, thr)
+ label_map[ys, xs] = lab
+ covered[ys, xs] = True
+ mask[ys, xs] = LABEL_RGBA_ARR[lab]
+ albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
+ total = max(int(covered.sum()), 1)
+ tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
+ log("region texels: " + " ".join(
+ f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
+ for i, c in enumerate(tex_counts) if c > 0))
+
+ # Woven-feel noise over the fills, before stitch lines (lines stay crisp).
+ rng = np.random.default_rng(seed)
+ noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
+ albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
+
+ # Stitch lines: texel-space label transitions where BOTH texels belong to
+ # rasterised geometry (skipping UV-island borders against background).
+ # Buckle|belt stays seamless (same physical strap).
+ lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map)
+ edge = np.zeros((H, W), dtype=bool)
+ dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1]
+ edge[:, 1:] |= dh
+ edge[:, :-1] |= dh
+ dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :]
+ edge[1:, :] |= dv
+ edge[:-1, :] |= dv
+ albedo[edge, 0:3] = STITCH_LUMA
+ bm.free()
+ log(f"stitch lines on {int(edge.sum())} boundary texels")
+
+ # Floor the alpha channel at 2/255: Godot's default texture import runs
+ # process/fix_alpha_border, which rewrites the RGB of fully-transparent
+ # texels bordering opaque ones — that would smear the shoulder-mark island
+ # edges into the surrounding body-green weights. With no alpha-0 texels the
+ # pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible.
+ mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
+
+ img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
+ # The mask is channel-packed DATA (R/G/B/A region weights), not imagery
+ # with transparency: without this, Blender's straight-alpha PNG save
+ # zeroes the A channel (verified: shoulder-mark texels came back A=0).
+ img_mask.alpha_mode = 'CHANNEL_PACKED'
+ img_mask.pixels.foreach_set(mask.reshape(-1))
+ img_mask.update()
+ img_mask.filepath_raw = mask_path
+ img_mask.file_format = 'PNG'
+ img_mask.save()
+ log(f"baked region mask -> {mask_path}")
+
+ img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
+ img_albedo.pixels.foreach_set(albedo.reshape(-1))
+ img_albedo.update()
+ return img_albedo
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_coverall(body_dir, out_dir, body, offset, seed):
+ base.clear_scene()
+ base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
+ shell, armature = base.build_covered_mesh(body_dir)
+ thr = derive_coverall_thresholds(armature)
+ limb_cuts(shell, thr)
+ base.offset_outward(shell, offset)
+
+ # Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that
+ # solidify adds duplicates every face with the SAME atlas UVs but a
+ # flipped normal — the normal-gated rules (shoulder marks, patches) would
+ # classify those copies as body and overwrite the very texels the outer
+ # faces just wrote. Pre-solidify there is exactly one face per texel.
+ base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless)
+ albedo_img = bake_mask_and_albedo(
+ shell, thr, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
+
+ base.solidify(shell, base.CLOTH_THICKNESS_M)
+ base.assign_fabric_material(shell, albedo_img)
+
+ # Save the albedo under the SHARED name before export: the glTF exporter
+ # names the embedded texture after the image filepath basename, and the
+ # Godot GLB import extracts it as _.png — with the shared
+ # name that lands on the tshirt-convention _base_albedo.png (a
+ # -specific filepath here would yield __base_albedo.png).
+ base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+ # Keep a deterministic authored per-body sidecar as well.
+ shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
+ os.path.join(out_dir, f"{body}_base_albedo.png"))
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- [--bodies a,b,c] [--offset M]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+ offset = base.PER_BODY_OFFSET_M
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+ log(f"coverall per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
+
+ results = []
+ for i, body in enumerate(bodies):
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_coverall(body_dir, out_dir, body, offset, seed=1089 + i)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append((body, f"error: {exc}"))
+
+ # Runtime fallback: reference_mask.png + base_albedo.png mirror average_m.
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
+ # base_albedo.png currently holds the LAST body's albedo; restore the
+ # reference body's copy so the shared sidecar is deterministic.
+ ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
+ if os.path.isfile(ref_albedo):
+ shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
+ log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> base_albedo.png")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_offset_shell.py b/tooling/garment-fit/blender_author_offset_shell.py
index 1a04291cc..44d4b1bec 100644
--- a/tooling/garment-fit/blender_author_offset_shell.py
+++ b/tooling/garment-fit/blender_author_offset_shell.py
@@ -27,24 +27,51 @@ Pipeline (t-shirt reference on average_m):
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.
+Two modes (T-1089):
+
+SINGLE-REFERENCE (default) — author on one body (average_m); G1
+(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
+derived/hand-authored garments that share one UV layout + one mask.
-Run:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
- /average_m [--offset 0.012] [--sleeve-frac 0.40]
+ /average_m [--offset 0.020] [--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)
+ 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)
+
+PER-BODY (--per-body) — author the shell from EACH body's own segment meshes.
+QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with
+girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring
+per body gives guaranteed standoff + exact weights by construction, and lets
+the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived
+from each body's OWN bone landmarks using the same proportional ratios
+(anchored to reproduce the hand-calibrated average_m constants exactly), so
+regions stay consistent across bodies.
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_offset_shell.py -- \
+ --per-body \
+ [--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
+
+ Writes per body:
+ /.glb skinned garment authored on that body
+ /_mask.png RGBA region mask (that body's UV0 layout)
+ Plus:
+ /base_albedo.png shared flat fabric albedo (deterministic)
+ /reference_mask.png copy of average_m_mask.png (runtime fallback)
+
+ The runtime compositor (character_visual.gd) prefers _mask.png and
+ falls back to reference_mask.png for SD-fit garments.
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
+import shutil
import bpy
import bmesh
import numpy as np
@@ -56,15 +83,25 @@ import numpy as np
# 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)
+OFFSET_M = 0.020 # single-reference 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.
+PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees
+ # clearance by construction, so the ideal applies.
+CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
MASK_SIZE = 512
ALBEDO_SIZE = 512
+# All shipping body types (matches blender_batch_fit_skinned.py / the runtime).
+BODY_TYPES = [
+ "average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
+ "heavy_m", "heavy_f", "teen_m", "teen_f", "child",
+]
+REFERENCE_BODY = "average_m"
+
# 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
@@ -83,11 +120,75 @@ 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)
+# --------------------------------------------------------------------------
+# Per-body threshold derivation (T-1089 per-body shell mode)
+#
+# The absolute constants above were hand-calibrated on average_m. The ratios
+# below re-express every one of them against average_m's bone landmarks
+# (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793,
+# spine_01 head z 1.072) so any body derives the SAME proportional cut/mask
+# parameters from its own armature. On average_m the derivation reproduces
+# the legacy constants exactly; the 11 bodies share one 65-bone rig, so the
+# landmarks exist everywhere.
+# --------------------------------------------------------------------------
+_REF_SHOULDER_X = 0.1919
+_REF_NECK_Z = 1.5205
+_REF_NECK_LEN = 0.0793
+_REF_SPINE_LO_Z = 1.072
+
+SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x|
+COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x|
+COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head
+CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x|
+_REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z
+CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
+CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
+
def log(msg):
print(f"[offset-shell] {msg}")
+def derive_thresholds(armature):
+ """Derive cut/mask thresholds from this body's bone landmarks.
+
+ Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}.
+ Falls back to the legacy average_m constants when landmarks are missing.
+ """
+ bones = armature.data.bones
+ ua_l = bones.get("upperarm_l")
+ ua_r = bones.get("upperarm_r")
+ neck = bones.get("neck_01")
+ spine01 = bones.get("spine_01")
+ if not all([ua_l, ua_r, neck, spine01]):
+ log("WARNING: landmark bones missing — using legacy average_m thresholds")
+ return {
+ "sleeve_x_abs": SLEEVE_X_ABS,
+ "collar_z_min": COLLAR_Z_MIN,
+ "collar_x_abs": COLLAR_X_ABS,
+ "chest_x": CHEST_X,
+ "chest_z": CHEST_Z,
+ }
+ shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
+ neck_z = neck.head_local.z
+ neck_len = neck.tail_local.z - neck.head_local.z
+ spine_lo = spine01.head_local.z
+ spine_span = neck_z - spine_lo
+ thr = {
+ "sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC,
+ "collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len,
+ "collar_x_abs": shoulder_x * COLLAR_X_FRAC,
+ "chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC),
+ "chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span,
+ spine_lo + CHEST_Z_HI_FRAC * spine_span),
+ }
+ log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} "
+ f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
+ f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
+ f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
+ return thr
+
+
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
@@ -290,12 +391,12 @@ def assign_fabric_material(shell, albedo_img):
# Region mask bake (UV0-aligned, per-face rasterization)
# --------------------------------------------------------------------------
-def _classify_region(center):
+def _classify_region(center, thr):
"""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:
+ x, z = center.x, center.z
+ if abs(x) >= thr["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:
+ if z >= thr["collar_z_min"] and abs(x) < thr["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])
@@ -310,7 +411,7 @@ def _tris_from_face(face, uv_layer):
return tris
-def bake_region_mask(shell, out_path):
+def bake_region_mask(shell, out_path, thr):
"""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
@@ -328,12 +429,21 @@ def bake_region_mask(shell, out_path):
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
- yy, xx = np.mgrid[0:H, 0:W]
+ region_counts = {"collar": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
- color = _classify_region(face.calc_center_median())
+ color = _classify_region(face.calc_center_median(), thr)
+ if color[0] > 0.5:
+ region_counts["collar"] += 1
+ elif color[2] > 0.5:
+ region_counts["sleeve"] += 1
+ else:
+ region_counts["body"] += 1
for a, b, c in _tris_from_face(face, uv_layer):
_raster_tri(buf, a, b, c, color, W, H)
bm.free()
+ total = max(sum(region_counts.values()), 1)
+ log("region faces: " + " ".join(
+ f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items()))
# 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.
@@ -378,7 +488,7 @@ def _raster_tri(buf, a, b, c, color, W, H):
# Logo UV2 chest channel
# --------------------------------------------------------------------------
-def author_logo_uv(shell):
+def author_logo_uv(shell, thr):
"""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
@@ -394,8 +504,8 @@ def author_logo_uv(shell):
bm.normal_update()
uvl = bm.loops.layers.uv.get("logo_uv")
- x0, x1 = CHEST_X
- z0, z1 = CHEST_Z
+ x0, x1 = thr["chest_x"]
+ z0, z1 = thr["chest_z"]
placed = 0
for face in bm.faces:
center = face.calc_center_median()
@@ -463,37 +573,93 @@ def save_albedo_sidecar(albedo_img, 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]
+def author_shell(body_dir, out_dir, glb_name, mask_name, offset):
+ """Author one offset-shell garment from `body_dir`'s segments.
- 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)
+ Shared by both modes; thresholds derive from the body's own armature so
+ the same proportional cut/mask parameters apply on every body.
+ """
clear_scene()
-
shell, armature = build_covered_mesh(body_dir)
+ thr = derive_thresholds(armature)
sleeve_cut(shell, armature)
- offset_outward(shell, OFFSET_M)
+ offset_outward(shell, offset)
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"))
+ author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active)
+ bake_region_mask(shell, os.path.join(out_dir, mask_name), thr)
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"))
+ export_reference(shell, armature, os.path.join(out_dir, glb_name))
+
+
+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]\n"
+ " or: -- --per-body "
+ "[--bodies a,b,c] [--offset M] [--sleeve-frac F]")
+ sys.exit(1)
+ in_dir = argv[0]
+ out_dir = argv[1]
+ per_body = "--per-body" in argv
+
+ global SLEEVE_FRAC
+ offset = None
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ if "--sleeve-frac" in argv:
+ SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
+ bodies = BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+
+ if not per_body:
+ # Single-reference mode: is one body's segment dir.
+ offset = OFFSET_M if offset is None else offset
+ body = os.path.basename(os.path.normpath(in_dir))
+ author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset)
+ log("DONE")
+ return
+
+ # Per-body mode: is the bodies root; loop each body's own segments.
+ offset = PER_BODY_OFFSET_M if offset is None else offset
+ log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(in_dir, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png",
+ offset)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ # Runtime fallback + SD-fit reference compatibility: reference_mask.png
+ # mirrors the reference body's mask.
+ ref_mask = os.path.join(out_dir, f"{REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
log("DONE")
diff --git a/tooling/garment-fit/blender_author_offset_shell_legs.py b/tooling/garment-fit/blender_author_offset_shell_legs.py
new file mode 100644
index 000000000..9a0b33fa7
--- /dev/null
+++ b/tooling/garment-fit/blender_author_offset_shell_legs.py
@@ -0,0 +1,373 @@
+"""
+blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
+
+Companion to blender_author_offset_shell.py (imported as a module — scene
+helpers, offset/solidify, albedo, rasterizer and export are reused, not
+copied). Where the base script is calibrated for TORSO garments (sleeve cut,
+collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
+from seg_hips + leg segments with parameterized cuts/regions, so one script
+serves shorts, jeans, joggers, formal pants, swim trunks:
+
+ --coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
+ full: + seg_leg_lower_l/r (jeans/joggers/formal)
+ --hem-frac F fraction of the hem bone's length KEPT below its
+ head (bone-plane cut). Hem bone = thigh for
+ --coverage thigh, calf for full. 0.78 on the thigh
+ = casual shorts hem ~9 cm above the knee.
+ --waistband-frac F waistband band height as a fraction of
+ (waist rim z − thigh head z). The band is
+ classified R in the region mask; everything else
+ is G. (B/A unused — 2-region garment.)
+ --fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
+ toon_garment.gdshader luma-recolor faithful).
+ --seed N albedo noise seed (deterministic output).
+
+Cut/region thresholds derive PER BODY from that body's own landmarks, same
+philosophy as the base script: the hem plane comes from the thigh/calf bone
+(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
+natural top boundary of seg_hips (its own mesh z-max), so proportions match
+across bodies. The waistline itself comes free as a segment boundary — no
+waist join geometry needed.
+
+Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
+body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
+consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
+
+Modes mirror the base script:
+
+PER-BODY (preferred for offset shells, Q-060):
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_offset_shell_legs.py -- \
+ --per-body \
+ [--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
+
+ Writes /.glb + /_mask.png per body, plus
+ shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
+ fallback for the compositor).
+
+SINGLE-REFERENCE:
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_offset_shell_legs.py -- \
+ /average_m [--offset 0.020] [...]
+
+Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
+"""
+
+import sys
+import os
+import shutil
+import importlib.util
+
+import bpy # noqa: F401 (Blender runtime)
+import bmesh
+import numpy as np
+
+# --------------------------------------------------------------------------
+# Import the base authoring module (shared helpers; main() is __main__-guarded)
+# --------------------------------------------------------------------------
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_spec = importlib.util.spec_from_file_location(
+ "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
+base = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(base)
+
+log = base.log
+
+# --------------------------------------------------------------------------
+# Parameters (defaults = shorts_modern)
+# --------------------------------------------------------------------------
+
+COVERAGE_SEGMENTS = {
+ "thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
+ "full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
+ "seg_leg_lower_l", "seg_leg_lower_r"],
+}
+HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
+
+HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
+WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
+FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
+ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
+
+
+# --------------------------------------------------------------------------
+# Per-body threshold derivation
+# --------------------------------------------------------------------------
+
+def derive_leg_thresholds(armature, coverage, hem_frac):
+ """Hem plane from this body's own hem bone; thigh head z for the waistband
+ span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
+ bones = armature.data.bones
+ hem_l, hem_r = HEM_BONES[coverage]
+ zs = []
+ thigh_heads = []
+ for name in (hem_l, hem_r):
+ b = bones.get(name)
+ if b is None:
+ raise RuntimeError(f"hem bone {name} missing on armature")
+ zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
+ for name in ("thigh_l", "thigh_r"):
+ b = bones.get(name)
+ if b is None:
+ raise RuntimeError(f"landmark bone {name} missing on armature")
+ thigh_heads.append(b.head_local.z)
+ thr = {
+ "hem_z": sum(zs) / len(zs),
+ "thigh_head_z": sum(thigh_heads) / len(thigh_heads),
+ }
+ log(f"thresholds: hem z>={thr['hem_z']:.3f} "
+ f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
+ f"thigh head z={thr['thigh_head_z']:.3f}")
+ return thr
+
+
+# --------------------------------------------------------------------------
+# Seam weld — the body segment meshes are assembled from patches whose seam
+# vertices are coincident but DUPLICATED. Each copy's normal averages only its
+# own patch's faces, so the outward offset pulls seams apart (visible slit at
+# the front-centre of seg_hips, V-notches on the waist rim). Welding before
+# the offset merges the copies (weights/UVs are identical on coincident verts)
+# and gives one smooth normal per seam vertex.
+# --------------------------------------------------------------------------
+
+def weld_seams(shell, epsilon=1e-4):
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ before = len(bm.verts)
+ bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ after = len(shell.data.vertices)
+ log(f"seam weld merged {before - after} duplicate verts; {after} remain")
+
+
+# --------------------------------------------------------------------------
+# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
+# --------------------------------------------------------------------------
+
+def hem_cut(shell, hem_z):
+ """Delete every vert below the hem plane. A single global z threshold is
+ safe: the hips segment bottoms out far above any sensible hem."""
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = [v for v in bm.verts if v.co.z < hem_z]
+ bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
+ bm.to_mesh(shell.data)
+ bm.free()
+ shell.data.update()
+ log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# Region mask: waistband (R) / body (G)
+# --------------------------------------------------------------------------
+
+def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
+ """Rasterize UV0 faces: waistband → R, everything else → G.
+
+ MUST run PRE-solidify (open boundaries still present). The waistband is
+ boundary-anchored: the waist rim is the top open boundary loop of seg_hips
+ (the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
+ row), so a face is waistband when it touches a rim-boundary vertex — that
+ keys the full top face row on every tessellation — or when its centre lies
+ inside the proportional band. Solidify afterwards duplicates the UV loops
+ unchanged, so the baked texels serve outer shell, inner shell and rim caps
+ alike."""
+ W = H = base.MASK_SIZE
+ buf = np.zeros((H, W, 4), dtype=np.float32)
+ buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
+
+ me = shell.data
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ bm.faces.ensure_lookup_table()
+ bm.verts.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")
+
+ waist_rim_z = max(v.co.z for v in bm.verts)
+ band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
+ wb_z_min = waist_rim_z - band
+
+ # Waist-rim boundary verts: on an open boundary AND above the thigh head
+ # (the only other boundaries are the leg hems, far below).
+ rim_verts = set()
+ for e in bm.edges:
+ if e.is_boundary:
+ for v in e.verts:
+ if v.co.z >= thr["thigh_head_z"]:
+ rim_verts.add(v.index)
+ log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
+ f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
+
+ counts = {"waistband": 0, "body": 0}
+ for face in bm.faces:
+ in_band = face.calc_center_median().z >= wb_z_min
+ touches_rim = any(v.index in rim_verts for v in face.verts)
+ if in_band or touches_rim:
+ color = (1.0, 0.0, 0.0, 0.0)
+ counts["waistband"] += 1
+ else:
+ color = (0.0, 1.0, 0.0, 0.0)
+ counts["body"] += 1
+ for a, b, c in base._tris_from_face(face, uv_layer):
+ base._raster_tri(buf, a, b, c, color, W, H)
+ bm.free()
+ total = max(sum(counts.values()), 1)
+ log("region faces: " + " ".join(
+ f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
+
+ 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 park_logo_uv(shell):
+ """Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
+ the UV-channel layout identical to torso garments; the shader's in-box
+ guard means nothing ever draws."""
+ me = shell.data
+ while len(me.uv_layers) > 1:
+ me.uv_layers.remove(me.uv_layers[-1])
+ me.uv_layers.new(name="logo_uv")
+ me.uv_layers.active = me.uv_layers[0]
+ bm = bmesh.new()
+ bm.from_mesh(me)
+ uvl = bm.loops.layers.uv.get("logo_uv")
+ for face in bm.faces:
+ for loop in face.loops:
+ loop[uvl].uv = (2.0, 2.0)
+ bm.to_mesh(me)
+ bm.free()
+ me.update()
+ log("logo UV2 parked (no logo region on this garment)")
+
+
+# --------------------------------------------------------------------------
+# Author one body
+# --------------------------------------------------------------------------
+
+def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
+ coverage, hem_frac, waistband_frac, seed):
+ base.clear_scene()
+ base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
+ shell, armature = base.build_covered_mesh(body_dir)
+ weld_seams(shell)
+ thr = derive_leg_thresholds(armature, coverage, hem_frac)
+ hem_cut(shell, thr["hem_z"])
+ base.offset_outward(shell, offset)
+
+ # Region mask + parked logo UV are authored PRE-solidify: the boundary-
+ # anchored waistband needs the open waist rim, and solidify duplicates all
+ # UV loops unchanged so the baked texels stay valid for the final mesh.
+ park_logo_uv(shell)
+ bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
+ waistband_frac)
+
+ base.solidify(shell, base.CLOTH_THICKNESS_M)
+
+ base.FABRIC_RGB = FABRIC_RGB
+ albedo_img = base.make_base_albedo_image(seed=seed)
+ base.assign_fabric_material(shell, albedo_img)
+ base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
+
+ base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
+
+
+# --------------------------------------------------------------------------
+# 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] "
+ "[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
+ "[--fabric R,G,B] [--seed N]\n"
+ " or: -- --per-body [--bodies a,b,c] "
+ "[same flags]")
+ sys.exit(1)
+ in_dir = argv[0]
+ out_dir = argv[1]
+ per_body = "--per-body" in argv
+
+ global FABRIC_RGB
+ offset = None
+ coverage = "thigh"
+ hem_frac = HEM_FRAC
+ waistband_frac = WAISTBAND_FRAC
+ seed = ALBEDO_SEED
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ if "--coverage" in argv:
+ coverage = argv[argv.index("--coverage") + 1]
+ if coverage not in COVERAGE_SEGMENTS:
+ print(f"unknown --coverage {coverage}")
+ sys.exit(1)
+ if "--hem-frac" in argv:
+ hem_frac = float(argv[argv.index("--hem-frac") + 1])
+ if "--waistband-frac" in argv:
+ waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
+ if "--fabric" in argv:
+ FABRIC_RGB = tuple(
+ float(c) for c in argv[argv.index("--fabric") + 1].split(","))
+ if "--seed" in argv:
+ seed = int(argv[argv.index("--seed") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+
+ os.makedirs(out_dir, exist_ok=True)
+
+ if not per_body:
+ offset = base.OFFSET_M if offset is None else offset
+ body = os.path.basename(os.path.normpath(in_dir))
+ author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
+ offset, coverage, hem_frac, waistband_frac, seed)
+ log("DONE")
+ return
+
+ offset = base.PER_BODY_OFFSET_M if offset is None else offset
+ log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
+ f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(in_dir, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_legs_shell(body_dir, out_dir, f"{body}.glb",
+ f"{body}_mask.png", offset, coverage, hem_frac,
+ waistband_frac, seed)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-fit/blender_author_outerwear.py b/tooling/garment-fit/blender_author_outerwear.py
new file mode 100644
index 000000000..442c8f84c
--- /dev/null
+++ b/tooling/garment-fit/blender_author_outerwear.py
@@ -0,0 +1,445 @@
+"""
+blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family)
+
+Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells
+(jacket / track-jacket / parka family) per body. It imports the base module and
+reuses its scene/join/offset/solidify/mask/logo/export machinery; everything
+garment-specific lives in the PARAMS block below, so a new outerwear piece is a
+parameter delta, not a new script.
+
+What it adds over the base t-shirt shell:
+
+ * FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the
+ upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique).
+ * 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through
+ the collar, plus knit cuff bands on the sleeve ends. Region layout per spec:
+ collar=R, body=G, sleeves=B, zip/trim=A.
+ * POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo,
+ each body gets an albedo baked in its own UV0 layout by rasterizing every
+ face and painting per-texel from interpolated body-local position: zip teeth
+ dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges),
+ hem band. The toon_garment shader recolors by LUMA, so the painted detail is
+ authored as luma contrast and survives any tint choice.
+ * LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns
+ the centre line), scaled per body from the same bone-landmark ratios.
+
+Per-body only (this family is the poster child for the Q-060 per-body route):
+
+ tooling/blender --background --python \
+ tooling/garment-fit/blender_author_outerwear.py -- \
+ \
+ [--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
+
+Writes per body: /.glb (painted albedo embedded), _mask.png
+Plus: /base_albedo.png (average_m's painted albedo)
+ /reference_mask.png (copy of average_m's, fallback)
+
+The per-body albedo is embedded in the GLB under the image name "base_albedo"
+(tshirt convention): Godot's importer extracts it as _base_albedo.png.
+average_m is deliberately authored LAST so the shared base_albedo.png sidecar
+ends up holding the reference body's pixels.
+
+Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
+"""
+
+import importlib.util
+import os
+import shutil
+import sys
+
+import bpy
+import bmesh
+import numpy as np
+
+# --------------------------------------------------------------------------
+# Load the base offset-shell module (shared engine machinery).
+# --------------------------------------------------------------------------
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_spec = importlib.util.spec_from_file_location(
+ "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
+base = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(base)
+
+log = base.log
+
+# --------------------------------------------------------------------------
+# PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should
+# only need to touch this block (or override via CLI where exposed).
+# --------------------------------------------------------------------------
+
+# Full-arm coverage: torso + torso_upper + both whole arms.
+SEGMENTS = [
+ "seg_torso", "seg_torso_upper",
+ "seg_arm_upper_l", "seg_arm_upper_r",
+ "seg_arm_lower_l", "seg_arm_lower_r",
+]
+
+OFFSET_M = 0.022 # LARGEST standoff of the tops — worn over a shirt
+ # (tshirt_modern outer surface ~16 mm), must neither
+ # clip the body nor read skin-tight.
+CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee
+
+CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show)
+CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A)
+
+# Region-shape ratios, anchored to the base module's average_m reference
+# landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body.
+ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x|
+COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head
+ # (t-shirt band was 0.3846 — jacket collar is taller,
+ # but 0.80 caught upper-chest faces and rendered as
+ # jagged shoulder "wings"; 0.55 keeps a neat band)
+COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band
+
+# Left-breast logo patch (character-left = +X; centre line belongs to the zip).
+# Fracs of shoulder |x| / spine span, from average_m absolutes (0.035..0.115 m,
+# z 1.30..1.42 m).
+CHEST_X_LO_FRAC = 0.035 / 0.1919
+CHEST_X_HI_FRAC = 0.115 / 0.1919
+CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
+CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
+
+# Painted-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)).
+BASE_LUMA = 0.58
+PLACKET_LUMA = 0.50 # slightly darker front placket band
+SEAM_LUMA = 0.38 # panel seam / stitch lines
+TEETH_LUMA = 0.88 # bright zip teeth dashes
+TEETH_GAP_LUMA = 0.34 # dark tape between dashes
+CUFF_LUMA = 0.48 # knit cuff band
+HEM_LUMA = 0.50 # bottom hem band
+ALBEDO_NOISE = 0.02 # woven jitter
+SEAM_W = 0.005 # seam line half-width, metres
+TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
+TEETH_PERIOD = 0.024 # dash period along Z, metres
+TEETH_DUTY = 0.014 # bright dash length within a period, metres
+HEM_BAND_M = 0.020 # hem band height, metres
+# Slate hue applied on top of luma (only visible if the region mask is missing).
+FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32)
+
+ALBEDO_SIZE = 512
+
+
+# --------------------------------------------------------------------------
+# Thresholds — base derivation + outerwear extras from the same armature
+# --------------------------------------------------------------------------
+
+def outerwear_thresholds(armature):
+ thr = base.derive_thresholds(armature)
+ bones = armature.data.bones
+ neck = bones.get("neck_01")
+ ua_l = bones.get("upperarm_l")
+ ua_r = bones.get("upperarm_r")
+ spine01 = bones.get("spine_01")
+
+ # Shoulder |x| (same landmark the base derivation uses).
+ if ua_l and ua_r:
+ shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
+ else:
+ shoulder_x = 0.1919 # average_m fallback
+
+ # Taller, wider standing collar.
+ if neck:
+ neck_len = neck.tail_local.z - neck.head_local.z
+ thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
+ thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
+
+ # Front zip placket.
+ thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
+
+ # Wrist cut planes + cuff trim band on the lowerarm bones.
+ cut_planes = []
+ cuff_abs = []
+ for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
+ b = bones.get(bone_name)
+ if b is None:
+ log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
+ continue
+ head_x = b.head_local.x
+ tail_x = b.tail_local.x
+ cut_x = head_x + CUFF_KEEP_FRAC * (tail_x - head_x)
+ band_x = head_x + (CUFF_KEEP_FRAC - CUFF_BAND_FRAC) * (tail_x - head_x)
+ cut_planes.append((sign, cut_x))
+ cuff_abs.append(abs(band_x))
+ log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
+ f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
+ thr["wrist_cut_planes"] = cut_planes
+ thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
+
+ # Left-breast logo patch (replaces the base full-chest box).
+ thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
+ if neck and spine01:
+ spine_lo = spine01.head_local.z
+ span = neck.head_local.z - spine_lo
+ thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
+ spine_lo + CHEST_Z_HI_FRAC * span)
+ log(f"outerwear thresholds: zip half {thr['zip_half_x']:.3f} "
+ f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
+ f"cuff |x|>={thr['cuff_x_abs']:.3f} "
+ f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
+ f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
+ return thr
+
+
+# --------------------------------------------------------------------------
+# Wrist cut (bone-plane, same technique as the base sleeve cut)
+# --------------------------------------------------------------------------
+
+def wrist_cut(shell, thr):
+ """Delete forearm verts beyond the wrist plane on each side."""
+ cut_planes = thr.get("wrist_cut_planes", [])
+ if not cut_planes:
+ log("WARNING: no wrist cut planes — sleeves stay full length")
+ return
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ bm.verts.ensure_lookup_table()
+ to_delete = []
+ for v in bm.verts:
+ for sign, thr_x in cut_planes:
+ if sign > 0 and v.co.x > thr_x:
+ to_delete.append(v)
+ break
+ if sign < 0 and v.co.x < thr_x:
+ 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"wrist cut removed {len(to_delete)} verts; "
+ f"{len(shell.data.vertices)} remain")
+
+
+# --------------------------------------------------------------------------
+# 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A)
+# --------------------------------------------------------------------------
+
+def classify_region_outerwear(center, thr):
+ x, y, z = center.x, center.y, center.z
+ ax = abs(x)
+ front = y * base.FRONT_Y_SIGN > 0.0
+ # Zip placket first: runs hem -> THROUGH the collar (zip-through jacket).
+ if front and ax < thr["zip_half_x"]:
+ return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3])
+ if ax >= thr["cuff_x_abs"]:
+ return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3])
+ if ax >= thr["sleeve_x_abs"]:
+ return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2])
+ if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]:
+ return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0])
+ return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
+
+
+# --------------------------------------------------------------------------
+# Position-painted albedo (per-body UV0 bake)
+# --------------------------------------------------------------------------
+
+def _paint_luma(x, y, z, thr, z_min):
+ """Vectorised luma paint from body-local position (arrays in, array out)."""
+ v = np.full(x.shape, BASE_LUMA, dtype=np.float32)
+ ax = np.abs(x)
+ front = (y * base.FRONT_Y_SIGN) > 0.0
+
+ # Hem band along the jacket bottom.
+ v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v)
+ # Knit cuff bands.
+ v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v)
+ # Panel seam lines: shoulder (sleeve boundary), cuff start, collar base.
+ v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v)
+ v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v)
+ collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \
+ & (ax < thr["collar_x_abs"] * 1.2)
+ v = np.where(collar_seam, SEAM_LUMA, v)
+
+ # Front zip placket: darker band + stitch edges + dashed teeth line.
+ zh = thr["zip_half_x"]
+ v = np.where(front & (ax < zh), PLACKET_LUMA, v)
+ v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v)
+ teeth = front & (ax < TEETH_HALF_W)
+ dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY
+ v = np.where(teeth & dash, TEETH_LUMA, v)
+ v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v)
+ return v
+
+
+def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H):
+ """Barycentric fill of a UV triangle, painting per-texel from the
+ barycentrically interpolated body-local position."""
+ ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1)
+ bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1)
+ cx_, cy_ = uv_c.x * (W - 1), uv_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
+ pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x
+ pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y
+ pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z
+ vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min)
+ region = lum[miny:maxy + 1, minx:maxx + 1]
+ region[inside] = vals[inside]
+
+
+def bake_painted_albedo(shell, thr, out_path, seed=1093):
+ """Bake a per-body albedo: luma-painted detail in this body's UV0 layout."""
+ W = H = ALBEDO_SIZE
+ lum = np.full((H, W), BASE_LUMA, dtype=np.float32)
+
+ 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 albedo bake")
+
+ z_min = min(v.co.z for v in bm.verts)
+ for face in bm.faces:
+ loops = face.loops[:]
+ uvs = [loop[uv_layer].uv.copy() for loop in loops]
+ pos = [loop.vert.co.copy() for loop in loops]
+ for i in range(1, len(uvs) - 1):
+ _raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1],
+ pos[0], pos[i], pos[i + 1], thr, z_min, W, H)
+ bm.free()
+
+ rng = np.random.default_rng(seed)
+ lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
+ rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
+ rgba = np.concatenate(
+ [rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32)
+
+ img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False)
+ img.pixels.foreach_set(rgba.reshape(-1))
+ img.update()
+ img.filepath_raw = out_path
+ img.file_format = 'PNG'
+ img.save()
+ log(f"baked painted albedo -> {out_path}")
+ return img
+
+
+# --------------------------------------------------------------------------
+# Per-body authoring
+# --------------------------------------------------------------------------
+
+def author_outerwear(body_dir, out_dir, body, offset):
+ base.clear_scene()
+ shell, armature = base.build_covered_mesh(body_dir)
+ thr = outerwear_thresholds(armature)
+ wrist_cut(shell, thr)
+ base.offset_outward(shell, offset)
+ base.solidify(shell, CLOTH_THICKNESS_M)
+
+ # Shared sidecar name on purpose: the exporter derives the embedded glTF
+ # image name from the filepath basename, and Godot extracts embedded
+ # textures as _.png — so this yields _base_albedo.png
+ # on import, matching the tshirt_modern convention (no doubled names).
+ albedo_path = os.path.join(out_dir, "base_albedo.png")
+ albedo_img = bake_painted_albedo(shell, thr, albedo_path)
+ base.assign_fabric_material(shell, albedo_img)
+
+ base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active)
+ base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
+
+ # Honest region tally (the base bake log can't see the A channel).
+ counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0}
+ bm = bmesh.new()
+ bm.from_mesh(shell.data)
+ for face in bm.faces:
+ c = classify_region_outerwear(face.calc_center_median(), thr)
+ if c[3] > 0.5:
+ counts["zip/trim"] += 1
+ elif c[2] > 0.5:
+ counts["sleeve"] += 1
+ elif c[0] > 0.5:
+ counts["collar"] += 1
+ else:
+ counts["body"] += 1
+ bm.free()
+ total = max(sum(counts.values()), 1)
+ log("outerwear regions: " + " ".join(
+ f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
+
+ base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ if len(argv) < 2:
+ print("Usage: -- "
+ "[--bodies a,b,c] [--offset M] [--cuff-keep F]")
+ sys.exit(1)
+ bodies_root = argv[0]
+ out_dir = argv[1]
+
+ global CUFF_KEEP_FRAC
+ offset = OFFSET_M
+ if "--offset" in argv:
+ offset = float(argv[argv.index("--offset") + 1])
+ if "--cuff-keep" in argv:
+ CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1])
+ bodies = base.BODY_TYPES
+ if "--bodies" in argv:
+ bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
+ # Author the reference body LAST so the shared base_albedo.png sidecar
+ # (rewritten per body before each export) ends as average_m's version.
+ if base.REFERENCE_BODY in bodies:
+ bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY]
+
+ os.makedirs(out_dir, exist_ok=True)
+
+ # Route the base mask bake through the 4-region outerwear classifier and
+ # the covered-segment import through the full-arm list.
+ base._classify_region = classify_region_outerwear
+ base.COVERED_SEGMENTS = SEGMENTS
+
+ log(f"outerwear per-body mode: {len(bodies)} bodies, "
+ f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.2f}")
+ results = []
+ for body in bodies:
+ body_dir = os.path.join(bodies_root, body)
+ log(f"=== {body} ===")
+ if not os.path.isdir(body_dir):
+ results.append((body, "skipped: body dir missing"))
+ continue
+ try:
+ author_outerwear(body_dir, out_dir, body, offset)
+ results.append((body, "ok"))
+ except Exception as exc:
+ log(f"ERROR {body}: {exc}")
+ results.append((body, f"error: {exc}"))
+
+ # Runtime fallback: reference_mask.png mirrors average_m's mask.
+ # (base_albedo.png already holds average_m's albedo — it authored last.)
+ ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
+ if os.path.isfile(ref_mask):
+ shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
+ log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
+
+ log("=" * 50)
+ for body, status in results:
+ log(f" {body:12s} {status}")
+ ok = sum(1 for _, s in results if s == "ok")
+ log(f"OK={ok}/{len(results)}")
+ if ok != len(results):
+ sys.exit(1)
+ log("DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/garment-qa/configs/buttondown_modern.json b/tooling/garment-qa/configs/buttondown_modern.json
new file mode 100644
index 000000000..9ca64f7d0
--- /dev/null
+++ b/tooling/garment-qa/configs/buttondown_modern.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "buttondown_modern", "slot": "torso"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/buttondown_modern"
+}
diff --git a/tooling/garment-qa/configs/hoodie_modern.json b/tooling/garment-qa/configs/hoodie_modern.json
new file mode 100644
index 000000000..addf921e7
--- /dev/null
+++ b/tooling/garment-qa/configs/hoodie_modern.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "hoodie_modern", "slot": "torso"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/hoodie_modern"
+}
diff --git a/tooling/garment-qa/configs/jacket_modern.json b/tooling/garment-qa/configs/jacket_modern.json
new file mode 100644
index 000000000..6ac67fbdc
--- /dev/null
+++ b/tooling/garment-qa/configs/jacket_modern.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "jacket_modern", "slot": "torso"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/jacket_modern"
+}
diff --git a/tooling/garment-qa/configs/jeans_modern.json b/tooling/garment-qa/configs/jeans_modern.json
new file mode 100644
index 000000000..de5dafdec
--- /dev/null
+++ b/tooling/garment-qa/configs/jeans_modern.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "jeans_modern", "slot": "legs"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/jeans_modern"
+}
diff --git a/tooling/garment-qa/configs/pants_formal.json b/tooling/garment-qa/configs/pants_formal.json
new file mode 100644
index 000000000..425bfada9
--- /dev/null
+++ b/tooling/garment-qa/configs/pants_formal.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "pants_formal", "slot": "legs"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/pants_formal"
+}
diff --git a/tooling/garment-qa/configs/shorts_modern.json b/tooling/garment-qa/configs/shorts_modern.json
new file mode 100644
index 000000000..c8afd6b86
--- /dev/null
+++ b/tooling/garment-qa/configs/shorts_modern.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "shorts_modern", "slot": "legs"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/shorts_modern"
+}
diff --git a/tooling/garment-qa/configs/suit_jacket_black.json b/tooling/garment-qa/configs/suit_jacket_black.json
new file mode 100644
index 000000000..7bc30b8bc
--- /dev/null
+++ b/tooling/garment-qa/configs/suit_jacket_black.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "suit_jacket_black", "slot": "torso"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/suit_jacket_black"
+}
diff --git a/tooling/garment-qa/configs/tshirt_modern.json b/tooling/garment-qa/configs/tshirt_modern.json
index 24b52b3e6..a504d8e5c 100644
--- a/tooling/garment-qa/configs/tshirt_modern.json
+++ b/tooling/garment-qa/configs/tshirt_modern.json
@@ -2,7 +2,7 @@
"garments": [
{"item_id": "tshirt_modern", "slot": "torso"}
],
- "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
diff --git a/tooling/garment-qa/configs/uniform_utility.json b/tooling/garment-qa/configs/uniform_utility.json
new file mode 100644
index 000000000..c8ac4f4fd
--- /dev/null
+++ b/tooling/garment-qa/configs/uniform_utility.json
@@ -0,0 +1,15 @@
+{
+ "garments": [
+ {"item_id": "uniform_utility", "slot": "full_body"}
+ ],
+ "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
+ "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/uniform_utility"
+}