humanforge 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # Asset licenses
2
+
3
+ All human assets in `assets/human` are CC0 1.0 (public domain dedication). Credit is appreciated but not required.
4
+
5
+ - Base mesh, targets, rig, weights, expression units: [MPFB2](https://github.com/makehumancommunity/mpfb2) (`src/mpfb/data`), CC0.
6
+ - System skins, eyes, brows, lashes, teeth, 10 hairstyles, 12 outfits, shoes, hats: [MakeHuman system assets](https://files2.makehumancommunity.org/asset_packs/makehuman_system_assets/makehuman_system_assets_cc0.zip), CC0.
7
+
8
+ ## Community assets
9
+
10
+ Baked by `tools/build-human.ts --extra <pack>`. The list and provenance live in `tools/extra-assets.json`. Each item's license was checked against the pack's `packs/<pack>.json` and its source page (all marked "Creative Commons Zero", checked 2026-10-18). Only the CC0 variants of the MakeHuman community packs are used; the CC-BY packs (`hair02`, `hair03`, `dress02`, ...) are not.
11
+
12
+ | Pack | Download |
13
+ |---|---|
14
+ | hair01 | https://files2.makehumancommunity.org/asset_packs/hair01/hair01_cc0.zip |
15
+ | dress01 | https://files2.makehumancommunity.org/asset_packs/dress01/dress01_cc0.zip |
16
+ | suits01 | https://files2.makehumancommunity.org/asset_packs/suits01/suits01_cc0.zip |
17
+
18
+ ### Hair
19
+
20
+ | Id | Label | Author | License | Source |
21
+ |---|---|---|---|---|
22
+ | `toigo_blunt_bob` | Blunt bob | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1669 |
23
+ | `toigo_blunt_bob_with_bangs` | Blunt bob with bangs | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1681 |
24
+ | `toigo_curled_under_bob` | Curled bob | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1677 |
25
+ | `toigo_inverted_bob_with_bangs` | Inverted bob with bangs | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1685 |
26
+ | `cortu_straight_bangs` | Straight with bangs | Cortu | CC0-1.0 | http://www.makehumancommunity.org/node/2810 |
27
+ | `rehmanpolanski_hair_bun_brown` | Bun | RehmanPolanski | CC0-1.0 | http://www.makehumancommunity.org/node/2477 |
28
+ | `culturalibre_hair_01` | Long sleek | culturalibre | CC0-1.0 | http://www.makehumancommunity.org/node/2893 |
29
+ | `sonntag78_junglebook_hair` | Shoulder layers | sonntag78 | CC0-1.0 | http://www.makehumancommunity.org/node/174 |
30
+ | `culturalibre_hair_02` | Short side part | culturalibre | CC0-1.0 | http://www.makehumancommunity.org/node/2892 |
31
+
32
+ ### Clothes
33
+
34
+ | Id | Label | Author | License | Source |
35
+ |---|---|---|---|---|
36
+ | `toigo_camisole_dress_with_full_skirt` | Camisole dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1730 |
37
+ | `toigo_halter_dress_knee_length` | Halter dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1853 |
38
+ | `toigo_halter_dress_midi` | Halter midi | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1854 |
39
+ | `toigo_halter_dress_with_fluted_skirt` | Halter gown | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1855 |
40
+ | `toigo_shift_dress` | Shift dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1724 |
41
+ | `toigo_keyhole_neck_dress` | Keyhole dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1613 |
42
+ | `toigo_dress_with_tiered_skirt` | Tiered dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1640 |
43
+ | `toigo_bodice_dress_with_lace_ruffle_skirt` | Lace ruffle dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1706 |
44
+ | `toigo_strapless_ruffle_top_dress` | Strapless dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1705 |
45
+ | `toigo_cut_out_dress` | Cut-out dress | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1704 |
46
+ | `toigo_female_suit` | Skirt suit | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1324 |
47
+ | `toigo_female_double-breasted_suit` | Double-breasted suit | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1742 |
48
+ | `toigo_female_suit_2` | Trouser suit | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1739 |
49
+ | `toigo_male_suit_3` | Suit and tie | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1733 |
50
+ | `toigo_male_double-breasted_suit` | Double-breasted suit | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1740 |
51
+ | `toigo_suit_with_dinner_jacket` | Dinner jacket | MargaretToigo | CC0-1.0 | http://www.makehumancommunity.org/node/1746 |
52
+
53
+ Notes from the asset pages: `culturalibre_hair_01` and `culturalibre_hair_02` are re-uploads of 2015 MakeHuman community hair, and `rehmanpolanski_hair_bun_brown` is adapted from the system `ponytail01`. All of them are published as CC0.
package/README.md CHANGED
@@ -7,7 +7,7 @@ avatar builders such as MetaPerson Creator. It has two modes:
7
7
  | --- | --- | --- |
8
8
  | Look | Photoreal skin, eyes, hair cards, clothes | Flat-colored, procedural |
9
9
  | Base | MakeHuman / MPFB2 human (CC0), 26.7k-triangle body | three.js primitives, 7–11k triangles |
10
- | Customization | 11 macro + 57 detail morph sliders, 20 skins, 9 eye colors, 12 brows, 4 lashes, 10 hairstyles, 18 outfits and shoes | 30+ options |
10
+ | Customization | 11 macro + 57 detail morph sliders, 20 skins, 9 eye colors, 12 brows, 4 lashes, 19 hairstyles, 28 outfits, 6 shoes | 30+ options |
11
11
  | Rig | 52-bone **Mixamo** skeleton with skin weights (clothes and hair follow) | none |
12
12
  | Face animation | 34 FACS blendshapes with **ARKit names** (`jawOpen`, `mouthSmile`, `eyeBlinkLeft`, …) | none |
13
13
  | Download | ~1.4 MB one-time core + ~0.3–0.8 MB per look (gzipped, cached on device) | 0 (code only) |
@@ -25,9 +25,31 @@ git clone --depth 1 --filter=blob:none --sparse https://github.com/makehumancomm
25
25
  cd mpfb2 && git sparse-checkout set src/mpfb/data && cd ..
26
26
  curl -LO https://files2.makehumancommunity.org/asset_packs/makehuman_system_assets/makehuman_system_assets_cc0.zip
27
27
  unzip makehuman_system_assets_cc0.zip -d mh_assets
28
- npm run build:human -- mpfb2/src/mpfb/data mh_assets # -> assets/human (~14 MB, all options)
28
+ # CC0 community packs: extra hairstyles, dresses and suits (the items listed in tools/extra-assets.json)
29
+ for p in hair01 dress01 suits01; do
30
+ curl -LO https://files2.makehumancommunity.org/asset_packs/$p/${p}_cc0.zip && unzip -q ${p}_cc0.zip -d mh_$p
31
+ done
32
+ npm run build:human -- mpfb2/src/mpfb/data mh_assets \
33
+ --extra mh_hair01 --extra mh_dress01 --extra mh_suits01 # -> assets/human (~26 MB, all options)
34
+ npm run build:garments # + modest wear and dresses (below)
29
35
  ```
30
36
 
37
+ Leave out the `--extra` packs for the system set only (~14 MB). Every item loads on demand, so
38
+ the size is what you host, not what each user downloads. To add other community items, put
39
+ their ids in `tools/extra-assets.json` (with author, license and source) and their labels in
40
+ `src/human/catalog.ts`. `demo/wardrobe.html` renders a contact sheet of hairstyles or outfits
41
+ for comparing them.
42
+
43
+
44
+ `build:garments` (`tools/build-garments.mts`) adds garments the MakeHuman packs don't have, generated
45
+ in code from the human package itself (no third-party assets): **hijab**, **al-amira**, **abaya**,
46
+ **jalabiya**, a **day dress**, a **maxi dress** and an **evening gown**. They are regular proxies, so
47
+ they follow every body and face slider; they are tintable (`garmentColors`, palette in
48
+ `GARMENT_COLORS`), and headwear hides the hair while worn. Run it again after `build:human`.
49
+ `demo/garments.html` renders a contact sheet (`?variants=1` adds extreme body shapes).
50
+
51
+ ![Generated garments](demo/garments-preview.png)
52
+
31
53
  Host `assets/human/` on any static host or CDN (S3, Cloudflare R2, GitHub Pages…). No server
32
54
  compression is needed: binaries are pre-gzipped and decoded in JS by `fflate`.
33
55
 
@@ -79,7 +101,7 @@ const glb = await exportHumanGLB(avatar); // Mixamo-rigged, ARKit blendshapes
79
101
  ### Avatar editor: one config, web and React Native
80
102
 
81
103
  `<HumanEditor>` is a drop-in customization screen with live 3D preview, section tabs (face,
82
- skin, eyes, brows, nose, mouth, ears, hair, body, outfit, shoes, hat), randomize and save. The
104
+ skin, eyes, brows, nose, mouth, ears, hair, headwear, body, outfit, shoes, hat), randomize and save. The
83
105
  same `options` object decides what your users may change on both platforms:
84
106
 
85
107
  ```tsx
@@ -112,9 +134,9 @@ const { pkg } = useHumanPackage(ASSETS);
112
134
  | `gender` | `'female'` or `'male'` locks the gender, hides the gender slider, and filters hair, clothes and skins to ones that suit it. `'any'` shows everything. |
113
135
  | `sections` | Which tabs to show, in order. |
114
136
  | `exclude` / `include` | Hide (or whitelist) whole sections or single controls: `'ears'`, `'hair.color'`, `'nose.noseWidth'`, `'body.weight'`… |
115
- | `items` | Allowed ids per slot: `hair`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
116
- | `colors` | Palettes for `hair`, `eyebrows` and `skinTint`. |
117
- | `optional` | Slots that offer "None" (`hair`, `hat`, `eyebrows`, `shoes`). |
137
+ | `items` | Allowed ids per slot: `hair`, `headwear`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
138
+ | `colors` | Palettes for `hair`, `eyebrows`, `skinTint` and `garments` (outfit/headwear fabric). |
139
+ | `optional` | Slots that offer "None" (`hair`, `hat`, `headwear`, `eyebrows`, `shoes`). |
118
140
  | `detailRange` | Caps the face/body detail sliders (0..1). |
119
141
  | `defaults` / `locked` | Starting values / values that are always enforced. |
120
142
  | `labels` | Rename any section, control or item (for translation). |
@@ -210,7 +232,8 @@ avatar picks up the same bias, such as a wider mouth.
210
232
  - **Detail:** `detail: { [id]: -1..1 }`, with the ids listed in `DETAIL_SLIDERS` (head shape, jaw,
211
233
  chin, cheeks, eyes, brows, nose ×9, mouth ×8, ears, neck, shoulders, chest, waist, hips…).
212
234
  - **Style:** `skin`, `skinTint`, `eyes`, `eyebrows`, `eyebrowColor`, `eyelashes`, `hair`,
213
- `hairColor`, and `clothes: string[]`. The available ids are listed in `pkg.manifest`.
235
+ `hairColor`, `clothes: string[]` and `garmentColors: { outfit?, headwear? }` (fabric colors of
236
+ tintable garments). The available ids are listed in `pkg.manifest`.
214
237
 
215
238
  ### How it works
216
239
 
@@ -263,11 +286,15 @@ npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
263
286
  shipping.
264
287
  - **Selfie fit:** verified in Chrome with public-domain portraits (web path). The React Native
265
288
  landmark path is documented but not implemented. See "Selfie to avatar" for its limits.
266
- - **Wardrobe:** hairstyles and clothes are limited to the CC0 MakeHuman system packs (10 hairstyles,
267
- 12 outfits, 6 shoes). They are dated next to commercial avatar SDKs: hair is rendered with soft
268
- two-pass cards, but the meshes themselves are 2014-era. More CC0
269
- packs, or your own `.mhclo` assets, can be added through `tools/build-human.ts`. There's no hijab
270
- or kufi in the realistic set yet.
289
+ - **Wardrobe:** the CC0 MakeHuman system packs (10 hairstyles, 12 outfits, 6 shoes) plus CC0
290
+ community items: 8 women's hairstyles (bobs, bangs, a bun, long and layered cuts), 1 men's style,
291
+ 10 dresses and 3 women's and 3 men's suits (see `LICENSE.ASSETS.md`,
292
+ [before/after](demo/hair-compare.jpg)). They are still behind commercial avatar SDKs: hair is
293
+ rendered with soft two-pass cards on 2014–2021-era community meshes. Separate tops, skirts and
294
+ trousers aren't offered because the editor has a single outfit slot. More CC0 packs, or your
295
+ own `.mhclo` assets, can be added through `tools/build-human.ts --extra`. Modest wear and
296
+ dresses come from `tools/build-garments.mts`: they're smooth, procedurally draped shapes (no
297
+ simulated folds), shaped for the rest pose; there's no kufi or niqab yet.
271
298
  - **Hair quality:** hair uses alpha-tested cards, so the edges are crisper with MSAA (`antialias: true`).
272
299
 
273
300
  ## Licenses
@@ -275,7 +302,9 @@ npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
275
302
  - Code: MIT.
276
303
  - Human assets (base mesh, targets, skins, hair, clothes, rig, weights, expression units): CC0 1.0,
277
304
  from the [MakeHuman](http://www.makehumancommunity.org) project and
278
- [MPFB2](https://github.com/makehumancommunity/mpfb2). Credit is appreciated but not required.
305
+ [MPFB2](https://github.com/makehumancommunity/mpfb2), plus CC0 community hair and clothes listed
306
+ per item (author, source) in [LICENSE.ASSETS.md](LICENSE.ASSETS.md). Credit is appreciated but not required.
307
+ - Generated garments and fabric textures (`tools/build-garments.mts` output): original, CC0 1.0.
279
308
  - Selfie fitting: MediaPipe Face Landmarker and its canonical face mesh are Apache-2.0 (Google).
280
309
  `src/human/faceMap.ts` contains vertex indices and averaged landmark positions derived from them.
281
310
  The model and WASM are downloaded at runtime and aren't bundled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanforge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Open-source avatar SDK for React and React Native: realistic MakeHuman-based humans (CC0) plus a tiny procedural low-poly mode.",
5
5
  "license": "MIT",
6
6
  "author": "abdulrehman043",
@@ -48,6 +48,7 @@
48
48
  "scripts": {
49
49
  "demo": "vite",
50
50
  "build:human": "tsx tools/build-human.ts",
51
+ "build:garments": "tsx tools/build-garments.mts",
51
52
  "typecheck": "tsc --noEmit",
52
53
  "test": "tsx scripts/smoke.ts && tsx scripts/human-smoke.mts && tsx scripts/fit-test.mts && tsx scripts/editor-test.mts",
53
54
  "build:facemap": "tsx tools/build-facemap.mts"
@@ -3,7 +3,7 @@
3
3
  // they get a generated label and are treated as unisex.
4
4
 
5
5
  export type Fit = 'female' | 'male' | 'unisex';
6
- export type ClothingSlot = 'outfit' | 'shoes' | 'hat';
6
+ export type ClothingSlot = 'outfit' | 'shoes' | 'hat' | 'headwear';
7
7
 
8
8
  export interface ItemInfo { label: string; fit: Fit }
9
9
 
@@ -18,6 +18,16 @@ export const HAIR: Record<string, ItemInfo> = {
18
18
  short02: { label: 'Short tousled', fit: 'male' },
19
19
  short03: { label: 'Side fringe', fit: 'unisex' },
20
20
  short04: { label: 'Slicked back', fit: 'male' },
21
+ // MakeHuman community hair (CC0, see LICENSE.ASSETS.md)
22
+ toigo_blunt_bob: { label: 'Blunt bob', fit: 'female' },
23
+ toigo_blunt_bob_with_bangs: { label: 'Blunt bob with bangs', fit: 'female' },
24
+ toigo_curled_under_bob: { label: 'Curled bob', fit: 'female' },
25
+ toigo_inverted_bob_with_bangs: { label: 'Inverted bob with bangs', fit: 'female' },
26
+ cortu_straight_bangs: { label: 'Straight with bangs', fit: 'female' },
27
+ culturalibre_hair_01: { label: 'Long sleek', fit: 'female' },
28
+ sonntag78_junglebook_hair: { label: 'Shoulder layers', fit: 'female' },
29
+ rehmanpolanski_hair_bun_brown: { label: 'Bun', fit: 'female' },
30
+ culturalibre_hair_02: { label: 'Short side part', fit: 'male' },
21
31
  };
22
32
 
23
33
  export const CLOTHES: Record<string, ItemInfo & { slot: ClothingSlot }> = {
@@ -33,6 +43,23 @@ export const CLOTHES: Record<string, ItemInfo & { slot: ClothingSlot }> = {
33
43
  male_casualsuit06: { label: 'Casual 6', fit: 'male', slot: 'outfit' },
34
44
  male_elegantsuit01: { label: 'Suit', fit: 'male', slot: 'outfit' },
35
45
  male_worksuit01: { label: 'Workwear', fit: 'male', slot: 'outfit' },
46
+ // MakeHuman community clothes (CC0, see LICENSE.ASSETS.md)
47
+ toigo_camisole_dress_with_full_skirt: { label: 'Camisole dress', fit: 'female', slot: 'outfit' },
48
+ toigo_halter_dress_knee_length: { label: 'Halter dress', fit: 'female', slot: 'outfit' },
49
+ toigo_halter_dress_midi: { label: 'Halter midi', fit: 'female', slot: 'outfit' },
50
+ toigo_halter_dress_with_fluted_skirt: { label: 'Halter gown', fit: 'female', slot: 'outfit' },
51
+ toigo_shift_dress: { label: 'Shift dress', fit: 'female', slot: 'outfit' },
52
+ toigo_keyhole_neck_dress: { label: 'Keyhole dress', fit: 'female', slot: 'outfit' },
53
+ toigo_dress_with_tiered_skirt: { label: 'Tiered dress', fit: 'female', slot: 'outfit' },
54
+ toigo_bodice_dress_with_lace_ruffle_skirt: { label: 'Lace ruffle dress', fit: 'female', slot: 'outfit' },
55
+ toigo_strapless_ruffle_top_dress: { label: 'Strapless dress', fit: 'female', slot: 'outfit' },
56
+ toigo_cut_out_dress: { label: 'Cut-out dress', fit: 'female', slot: 'outfit' },
57
+ toigo_female_suit: { label: 'Skirt suit', fit: 'female', slot: 'outfit' },
58
+ toigo_female_suit_2: { label: 'Trouser suit', fit: 'female', slot: 'outfit' },
59
+ 'toigo_female_double-breasted_suit': { label: 'Double-breasted suit', fit: 'female', slot: 'outfit' },
60
+ toigo_male_suit_3: { label: 'Suit and tie', fit: 'male', slot: 'outfit' },
61
+ 'toigo_male_double-breasted_suit': { label: 'Double-breasted suit', fit: 'male', slot: 'outfit' },
62
+ toigo_suit_with_dinner_jacket: { label: 'Dinner jacket', fit: 'male', slot: 'outfit' },
36
63
  shoes01: { label: 'Shoes 1', fit: 'unisex', slot: 'shoes' },
37
64
  shoes02: { label: 'Shoes 2', fit: 'unisex', slot: 'shoes' },
38
65
  shoes03: { label: 'Shoes 3', fit: 'unisex', slot: 'shoes' },
@@ -41,8 +68,38 @@ export const CLOTHES: Record<string, ItemInfo & { slot: ClothingSlot }> = {
41
68
  shoes06: { label: 'Shoes 6', fit: 'unisex', slot: 'shoes' },
42
69
  fedora01: { label: 'Fedora', fit: 'unisex', slot: 'hat' },
43
70
  fedora_cocked: { label: 'Fedora (tilted)', fit: 'unisex', slot: 'hat' },
71
+ // Generated by tools/build-garments.ts (original, CC0): tintable, see GARMENT_COLORS.
72
+ abaya01: { label: 'Abaya', fit: 'female', slot: 'outfit' },
73
+ jalabiya01: { label: 'Jalabiya', fit: 'female', slot: 'outfit' },
74
+ dress_knee01: { label: 'Day dress', fit: 'female', slot: 'outfit' },
75
+ dress_maxi01: { label: 'Maxi dress', fit: 'female', slot: 'outfit' },
76
+ gown01: { label: 'Evening gown', fit: 'female', slot: 'outfit' },
77
+ hijab01: { label: 'Hijab', fit: 'female', slot: 'headwear' },
78
+ amira01: { label: 'Al-amira', fit: 'female', slot: 'headwear' },
44
79
  };
45
80
 
81
+ /** Fabric colors for tintable garments (multiplied over a light fabric texture). */
82
+ export const GARMENT_COLORS: { id: string; label: string; color: string }[] = [
83
+ { id: 'black', label: 'Black', color: '#1b1b1f' },
84
+ { id: 'charcoal', label: 'Charcoal', color: '#3a3c42' },
85
+ { id: 'navy', label: 'Navy', color: '#1f3a5f' },
86
+ { id: 'slate', label: 'Slate blue', color: '#3f5e8c' },
87
+ { id: 'teal', label: 'Teal', color: '#1f6f6b' },
88
+ { id: 'emerald', label: 'Emerald', color: '#1e6b45' },
89
+ { id: 'olive', label: 'Olive', color: '#6b6a3a' },
90
+ { id: 'mocha', label: 'Mocha', color: '#6b4f3f' },
91
+ { id: 'sand', label: 'Sand', color: '#cbb89a' },
92
+ { id: 'ivory', label: 'Ivory', color: '#e9e2d6' },
93
+ { id: 'white', label: 'White', color: '#f4f3ef' },
94
+ { id: 'blush', label: 'Blush', color: '#d9a7a0' },
95
+ { id: 'rose', label: 'Dusty rose', color: '#b56f7b' },
96
+ { id: 'terracotta', label: 'Terracotta', color: '#b85c4a' },
97
+ { id: 'burgundy', label: 'Burgundy', color: '#7c2945' },
98
+ { id: 'plum', label: 'Plum', color: '#5b3a5e' },
99
+ { id: 'lavender', label: 'Lavender', color: '#a79bc4' },
100
+ { id: 'sky', label: 'Sky', color: '#8fb3d1' },
101
+ ];
102
+
46
103
  export const EYE_LABELS: Record<string, string> = {
47
104
  brown: 'Dark brown', brownlight: 'Brown', green: 'Green', blue: 'Blue', deepblue: 'Deep blue',
48
105
  lightblue: 'Light blue', bluegreen: 'Blue-green', grey: 'Grey', ice: 'Ice',
@@ -81,7 +138,7 @@ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
81
138
  export function hairInfo(id: string): ItemInfo { return HAIR[id] ?? { label: cap(words(id)), fit: 'unisex' }; }
82
139
  export function clothingInfo(id: string): ItemInfo & { slot: ClothingSlot } {
83
140
  if (CLOTHES[id]) return CLOTHES[id];
84
- const slot: ClothingSlot = id.startsWith('shoes') ? 'shoes' : /hat|fedora|cap|helmet/.test(id) ? 'hat' : 'outfit';
141
+ const slot: ClothingSlot = id.startsWith('shoes') ? 'shoes' : /hijab|amira|shayla|scarf|turban/.test(id) ? 'headwear' : /hat|fedora|cap|helmet/.test(id) ? 'hat' : 'outfit';
85
142
  const fit: Fit = id.startsWith('female') ? 'female' : id.startsWith('male') ? 'male' : 'unisex';
86
143
  return { label: cap(words(id.replace(/^(fe)?male_/, ''))), fit, slot };
87
144
  }
@@ -8,13 +8,13 @@
8
8
  // editor.normalize(savedConfig) -> clamps/repairs any config to the options (use on load)
9
9
  import { DEFAULT_HUMAN, HumanConfig, HumanManifest } from './runtime';
10
10
  import { DETAIL_SLIDERS, MACRO_SLIDERS } from './sliders';
11
- import { EYE_LABELS, EYE_SWATCH, HUMAN_HAIR_COLORS, SKIN_TINTS, clothingInfo, fits, hairInfo, skinInfo } from './catalog';
11
+ import { EYE_LABELS, EYE_SWATCH, GARMENT_COLORS, HUMAN_HAIR_COLORS, SKIN_TINTS, clothingInfo, fits, hairInfo, skinInfo } from './catalog';
12
12
 
13
13
  export type EditorGender = 'female' | 'male' | 'any';
14
- export type SectionId = 'body' | 'face' | 'skin' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'ears' | 'hair' | 'outfit' | 'shoes' | 'hat';
15
- export type ItemSlot = 'hair' | 'outfit' | 'shoes' | 'hat' | 'skin' | 'eyes' | 'eyebrows' | 'eyelashes';
14
+ export type SectionId = 'body' | 'face' | 'skin' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'ears' | 'hair' | 'headwear' | 'outfit' | 'shoes' | 'hat';
15
+ export type ItemSlot = 'hair' | 'headwear' | 'outfit' | 'shoes' | 'hat' | 'skin' | 'eyes' | 'eyebrows' | 'eyelashes';
16
16
 
17
- export const ALL_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'body', 'outfit', 'shoes', 'hat'];
17
+ export const ALL_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'headwear', 'body', 'outfit', 'shoes', 'hat'];
18
18
 
19
19
  export interface HumanEditorOptions {
20
20
  /** Lock the avatar to one gender: hides the gender slider and filters hair, clothes and skins. Default 'any'. */
@@ -28,9 +28,9 @@ export interface HumanEditorOptions {
28
28
  /** Allowed item ids per slot (default: everything that fits the gender). */
29
29
  items?: Partial<Record<ItemSlot, string[]>>;
30
30
  /** Color palettes. */
31
- colors?: { hair?: string[]; eyebrows?: string[]; skinTint?: string[] };
32
- /** Slots that may be empty ("None" option). Default: hat and eyebrows. */
33
- optional?: Partial<Record<'hair' | 'hat' | 'eyebrows' | 'shoes', boolean>>;
31
+ colors?: { hair?: string[]; eyebrows?: string[]; skinTint?: string[]; garments?: string[] };
32
+ /** Slots that may be empty ("None" option). Default: hat, headwear and eyebrows. */
33
+ optional?: Partial<Record<'hair' | 'hat' | 'headwear' | 'eyebrows' | 'shoes', boolean>>;
34
34
  /** Max magnitude of face/body detail sliders (0..1). Lower keeps faces more natural. Default 1. */
35
35
  detailRange?: number;
36
36
  /** Starting values (merged over the gender's default look). */
@@ -64,9 +64,12 @@ export interface HumanEditorModel {
64
64
 
65
65
  const SECTION_LABELS: Record<SectionId, string> = {
66
66
  body: 'Body', face: 'Face', skin: 'Skin', eyes: 'Eyes', brows: 'Brows', nose: 'Nose', mouth: 'Mouth', ears: 'Ears',
67
- hair: 'Hair', outfit: 'Outfit', shoes: 'Shoes', hat: 'Hat',
67
+ hair: 'Hair', headwear: 'Headwear', outfit: 'Outfit', shoes: 'Shoes', hat: 'Hat',
68
68
  };
69
- const FACE_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'hat'];
69
+ const FACE_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'headwear', 'hat'];
70
+ type WearSlot = 'outfit' | 'shoes' | 'hat' | 'headwear';
71
+ /** Slots that can't be worn together: choosing one takes off the other. */
72
+ const CONFLICTS: Partial<Record<WearSlot, WearSlot>> = { hat: 'headwear', headwear: 'hat' };
70
73
  const BROW_SLIDERS = ['browHeight', 'browAngle', 'browDepth'];
71
74
 
72
75
  const GENDER_DEFAULTS: Record<'female' | 'male', Partial<HumanConfig>> = {
@@ -88,7 +91,7 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
88
91
  const include = options.include ? new Set(options.include) : null;
89
92
  const range = Math.min(1, Math.max(0, options.detailRange ?? 1));
90
93
  const label = (id: string, fallback: string) => options.labels?.[id] ?? fallback;
91
- const optional = { hat: true, eyebrows: true, hair: false, shoes: false, ...options.optional };
94
+ const optional = { hat: true, headwear: true, eyebrows: true, hair: false, shoes: false, ...options.optional };
92
95
 
93
96
  // ---- allowed items
94
97
  const restrict = (slot: ItemSlot, ids: string[]) => {
@@ -96,9 +99,10 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
96
99
  return only ? ids.filter((id) => only.includes(id)) : ids;
97
100
  };
98
101
  const clothes = manifest.proxies.filter((p) => p.kind === 'clothes').map((p) => p.id);
99
- const bySlot = (slot: 'outfit' | 'shoes' | 'hat') => clothes.filter((id) => clothingInfo(id).slot === slot && fits(clothingInfo(id).fit, gender));
102
+ const bySlot = (slot: WearSlot) => clothes.filter((id) => clothingInfo(id).slot === slot && fits(clothingInfo(id).fit, gender));
100
103
  const allowed: Record<ItemSlot, string[]> = {
101
104
  hair: restrict('hair', manifest.proxies.filter((p) => p.kind === 'hair').map((p) => p.id).filter((id) => fits(hairInfo(id).fit, gender))),
105
+ headwear: restrict('headwear', bySlot('headwear')),
102
106
  outfit: restrict('outfit', bySlot('outfit')),
103
107
  shoes: restrict('shoes', bySlot('shoes')),
104
108
  hat: restrict('hat', bySlot('hat')),
@@ -113,6 +117,9 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
113
117
  const hairColors = options.colors?.hair ?? HUMAN_HAIR_COLORS.map((h) => h.color);
114
118
  const browColors = options.colors?.eyebrows ?? hairColors;
115
119
  const tints = options.colors?.skinTint ?? SKIN_TINTS;
120
+ const garmentColors = options.colors?.garments ?? GARMENT_COLORS.map((g) => g.color);
121
+ const tintable = (id: string) => !!manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.tintable;
122
+ const defaultColor = (id: string | undefined) => manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.color;
116
123
 
117
124
  // ---- controls
118
125
  const shown = (section: SectionId, id: string) => !exclude.has(section) && !exclude.has(`${section}.${id}`) && !exclude.has(id) && (!include || include.has(`${section}.${id}`) || include.has(id));
@@ -132,10 +139,16 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
132
139
  const color = (id: string, lbl: string, colors: string[], value: ColorControl['value'], apply: ColorControl['apply']): ColorControl =>
133
140
  ({ kind: 'color', id, label: label(id, lbl), colors, value, apply });
134
141
  const none = (on: boolean): ChoiceOption[] => (on ? [{ id: null, label: label('none', 'None') }] : []);
135
- const clothingChoice = (slot: 'outfit' | 'shoes' | 'hat', lbl: string, canBeEmpty: boolean) => choice('style', lbl,
142
+ const worn = (c: HumanConfig, slot: WearSlot) => c.clothes.find((id) => clothingInfo(id).slot === slot);
143
+ const clothingChoice = (slot: WearSlot, lbl: string, canBeEmpty: boolean) => choice('style', lbl,
136
144
  [...none(canBeEmpty), ...allowed[slot].map((id) => ({ id, label: label(id, clothingInfo(id).label) }))],
137
- (c) => c.clothes.find((id) => clothingInfo(id).slot === slot) ?? null,
138
- (c, v) => ({ ...c, clothes: [...c.clothes.filter((id) => clothingInfo(id).slot !== slot), ...(v ? [v] : [])] }));
145
+ (c) => worn(c, slot) ?? null,
146
+ (c, v) => ({ ...c, clothes: [...c.clothes.filter((id) => clothingInfo(id).slot !== slot && !(v && clothingInfo(id).slot === CONFLICTS[slot])), ...(v ? [v] : [])] }));
147
+ /** Fabric color of the tintable garment in a slot (only shown when the slot offers one). */
148
+ const garmentColor = (slot: WearSlot): EditorControl[] => (allowed[slot].some(tintable)
149
+ ? [color('color', 'Color', garmentColors, (c) => c.garmentColors?.[slot] ?? defaultColor(worn(c, slot)) ?? garmentColors[0],
150
+ (c, v) => ({ ...c, garmentColors: { ...c.garmentColors, [slot]: v } }))]
151
+ : []);
139
152
 
140
153
  const build: Record<SectionId, () => EditorControl[]> = {
141
154
  face: () => [
@@ -174,7 +187,8 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
174
187
  ...(gender !== 'male' ? [macro('cupSize', 'Bust'), macro('firmness', 'Firmness')] : []),
175
188
  ...DETAIL_SLIDERS.filter((d) => d.group === 'body').map((d) => detail(d.id)),
176
189
  ],
177
- outfit: () => [clothingChoice('outfit', 'Outfit', false)],
190
+ headwear: () => [clothingChoice('headwear', 'Headwear', optional.headwear), ...garmentColor('headwear')],
191
+ outfit: () => [clothingChoice('outfit', 'Outfit', false), ...garmentColor('outfit')],
178
192
  shoes: () => [clothingChoice('shoes', 'Shoes', optional.shoes)],
179
193
  hat: () => [clothingChoice('hat', 'Hat', optional.hat)],
180
194
  };
@@ -183,7 +197,7 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
183
197
  for (const id of options.sections ?? ALL_SECTIONS) {
184
198
  if (exclude.has(id)) continue;
185
199
  const controls = build[id]().filter((ctl) => shown(id, ctl.id)).filter((ctl) => ctl.kind !== 'choice' || ctl.options.length > 1 || ctl.options[0]?.id !== null);
186
- if ((id === 'outfit' || id === 'shoes' || id === 'hat') && !allowed[id].length) continue;
200
+ if ((id === 'outfit' || id === 'shoes' || id === 'hat' || id === 'headwear') && !allowed[id].length) continue;
187
201
  if (controls.length) sections.push({ id, label: label(id, SECTION_LABELS[id]), framing: FACE_SECTIONS.includes(id) ? 'face' : 'body', controls });
188
202
  }
189
203
 
@@ -202,14 +216,16 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
202
216
  c.hair = c.hair === null && optional.hair ? null : pick(c.hair, allowed.hair, base.hair);
203
217
  c.eyebrows = c.eyebrows === null && optional.eyebrows ? null : pick(c.eyebrows, allowed.eyebrows, base.eyebrows);
204
218
  c.eyelashes = c.eyelashes === null ? null : pick(c.eyelashes, allowed.eyelashes, base.eyelashes);
205
- const wear = (slot: 'outfit' | 'shoes' | 'hat', canBeEmpty: boolean) => {
219
+ const wear = (slot: WearSlot, canBeEmpty: boolean) => {
206
220
  const cur = c.clothes.find((id) => clothingInfo(id).slot === slot);
207
221
  const fallback = base.clothes.find((id) => clothingInfo(id).slot === slot);
208
222
  if (!cur && canBeEmpty) return [];
209
223
  const v = pick(cur, allowed[slot], fallback);
210
224
  return v ? [v] : [];
211
225
  };
212
- c.clothes = [...wear('outfit', false), ...wear('shoes', optional.shoes), ...wear('hat', true)];
226
+ const headwear = wear('headwear', true);
227
+ c.clothes = [...wear('outfit', false), ...wear('shoes', optional.shoes), ...headwear, ...(headwear.length ? [] : wear('hat', true))];
228
+ if (c.garmentColors) c.garmentColors = Object.fromEntries(Object.entries(c.garmentColors).filter(([, v]) => typeof v === 'string'));
213
229
  return c;
214
230
  }
215
231
 
@@ -233,7 +249,9 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
233
249
  return { options, sections, initial: normalize(), normalize, randomize, allowed };
234
250
  }
235
251
 
236
- const HAIR_ORDER = ['long01', 'ponytail01', 'bob02', 'afro01', 'short02', 'short01', 'short04', 'bob01', 'braid01', 'short03'];
252
+ const HAIR_ORDER = ['long01', 'culturalibre_hair_01', 'cortu_straight_bangs', 'sonntag78_junglebook_hair', 'ponytail01', 'rehmanpolanski_hair_bun_brown',
253
+ 'toigo_blunt_bob', 'toigo_blunt_bob_with_bangs', 'toigo_curled_under_bob', 'toigo_inverted_bob_with_bangs', 'bob02', 'afro01',
254
+ 'short02', 'culturalibre_hair_02', 'short01', 'short04', 'bob01', 'braid01', 'short03'];
237
255
  const order = (id: string) => { const i = HAIR_ORDER.indexOf(id); return i < 0 ? 100 : i; };
238
256
 
239
257
  /** Same age/ethnicity skin among the allowed ones (used when gender filtering removes the current skin). */
@@ -6,11 +6,12 @@
6
6
  // (TextureLoader on the web by default; see src/native/humanTextures.ts for React Native).
7
7
  import {
8
8
  Bone, BufferAttribute, BufferGeometry, DoubleSide, FrontSide, Group, Material, Matrix4, MeshStandardMaterial,
9
- Quaternion, SRGBColorSpace, Skeleton, SkinnedMesh, Texture, TextureLoader, Vector3,
9
+ Quaternion, RepeatWrapping, SRGBColorSpace, Skeleton, SkinnedMesh, Texture, TextureLoader, Vector3,
10
10
  } from 'three';
11
11
  import { gunzipSync } from 'fflate';
12
12
  import { DETAIL_SLIDERS } from './sliders';
13
13
  import { GlbPrimitive, readGlb, toFloat } from './glb';
14
+ import { clothingInfo } from './catalog';
14
15
 
15
16
  // ---------------------------------------------------------------- config
16
17
  export interface HumanConfig {
@@ -39,6 +40,8 @@ export interface HumanConfig {
39
40
  hair: string | null;
40
41
  hairColor: string;
41
42
  clothes: string[];
43
+ /** Colors of tintable garments by clothing slot ('outfit', 'headwear'...); unset = the garment's default. */
44
+ garmentColors?: Record<string, string>;
42
45
  }
43
46
 
44
47
  export const DEFAULT_HUMAN: HumanConfig = {
@@ -53,6 +56,13 @@ export interface ProxyMeta {
53
56
  id: string; kind: string; file: string; triangles: number; transparent: boolean; tintable: boolean; zDepth: number;
54
57
  deleteVerts?: [number, number][]; scale: [number, number, number][];
55
58
  texture?: string; normalTexture?: string; textureSize: number; doubleSided: boolean;
59
+ /** Default tint for tintable garments. */
60
+ color?: string;
61
+ /** Headwear that covers the hair: the hair proxy is not shown while it is worn. */
62
+ hidesHair?: boolean;
63
+ roughness?: number;
64
+ /** Texture coordinates are in tiles (repeat wrapping). */
65
+ tileable?: boolean;
56
66
  }
57
67
  export interface HumanManifest {
58
68
  vertexCount: number;
@@ -233,9 +243,25 @@ function geometryFrom(prim: GlbPrimitive, joints: string, weights: string) {
233
243
  g.setAttribute('skinIndex', new BufferAttribute(asU16(prim.attributes[joints]), 4));
234
244
  g.setAttribute('skinWeight', new BufferAttribute(toFloat(prim.attributes[weights]), 4));
235
245
  g.setIndex(new BufferAttribute(prim.index, 1));
246
+ if (prim.attributes.COLOR_0) g.setAttribute('color', new BufferAttribute(toFloat(prim.attributes.COLOR_0), prim.attributes.COLOR_0.itemSize));
236
247
  return g;
237
248
  }
238
249
 
250
+ /**
251
+ * Vertices split only for texture seams share refs/weights/offsets. Pairs (dup, first) so
252
+ * normals can be averaged across the seam after computeVertexNormals.
253
+ */
254
+ function seamPairs(refs: Uint16Array, refw: Float32Array, offs: Float32Array) {
255
+ const first = new Map<string, number>(), pairs: number[] = [];
256
+ for (let i = 0; i < refs.length / 3; i++) {
257
+ let k = '';
258
+ for (let c = 0; c < 3; c++) k += `${refs[i * 3 + c]},${Math.round(refw[i * 3 + c] * 1e4)},${Math.round(offs[i * 3 + c] * 1e5)},`;
259
+ const f = first.get(k);
260
+ if (f === undefined) first.set(k, i); else pairs.push(i, f);
261
+ }
262
+ return Uint32Array.from(pairs);
263
+ }
264
+
239
265
  /**
240
266
  * Hair cards: the texture's transparent texels are near-white, which shows as a light halo
241
267
  * around strands once alpha is blended. Darken texels by their coverage so tips fade naturally.
@@ -248,7 +274,7 @@ function hairShading(m: MeshStandardMaterial) {
248
274
  m.customProgramCacheKey = () => 'humanforge-hair';
249
275
  }
250
276
 
251
- interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; soft?: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array }
277
+ interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; soft?: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array; seams?: Uint32Array }
252
278
 
253
279
  export class HumanAvatar {
254
280
  readonly group = new Group();
@@ -318,7 +344,8 @@ export class HumanAvatar {
318
344
  if (c.eyelashes) wanted.set('eyelashes', `eyelashes-${c.eyelashes}`);
319
345
  wanted.set('teeth', 'teeth-teeth_base');
320
346
  wanted.set('tongue', 'tongue-tongue01');
321
- if (c.hair) wanted.set('hair', `hair-${c.hair}`);
347
+ const meta = (id: string) => this.pkg.manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id);
348
+ if (c.hair && !c.clothes.some((id) => meta(id)?.hidesHair)) wanted.set('hair', `hair-${c.hair}`);
322
349
  c.clothes.forEach((id) => wanted.set(`clothes:${id}`, `clothes-${id}`));
323
350
  const loads = [...wanted.entries()].map(async ([slot, id]) => {
324
351
  const cur = this.proxies.get(slot);
@@ -347,6 +374,7 @@ export class HumanAvatar {
347
374
  if (slot === 'hair') { m.color.set(c.hairColor); (p.soft?.material as MeshStandardMaterial | undefined)?.color.set(c.hairColor); }
348
375
  if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
349
376
  if (slot === 'eyes' && m.map !== eyeTex) { m.map = eyeTex; m.needsUpdate = true; }
377
+ if (p.meta.kind === 'clothes' && p.meta.tintable) m.color.set(c.garmentColors?.[clothingInfo(p.meta.id).slot] ?? p.meta.color ?? '#ffffff');
350
378
  }
351
379
  this.reshape();
352
380
  }
@@ -396,9 +424,11 @@ export class HumanAvatar {
396
424
  const refs = asU16(prim.attributes._REFS), refw = toFloat(prim.attributes._REFW), offs = toFloat(prim.attributes._OFFS);
397
425
  const mat = new MeshStandardMaterial({
398
426
  map, normalMap, metalness: 0,
399
- roughness: meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8,
427
+ roughness: meta.roughness ?? (meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8),
400
428
  side: meta.doubleSided ? DoubleSide : FrontSide,
429
+ vertexColors: !!geo.getAttribute('color'),
401
430
  });
431
+ if (meta.tileable) for (const t of [map, normalMap]) if (t && t.wrapS !== RepeatWrapping) { t.wrapS = t.wrapT = RepeatWrapping; t.needsUpdate = true; }
402
432
  let soft: SkinnedMesh | undefined;
403
433
  if (meta.kind === 'hair') {
404
434
  // Two passes: a solid core (alpha-tested, writes depth, so strands sort correctly) and a
@@ -432,7 +462,8 @@ export class HumanAvatar {
432
462
  mesh.morphTargetDictionary = this.body.morphTargetDictionary;
433
463
  mesh.morphTargetInfluences = [...(this.body.morphTargetInfluences ?? [])];
434
464
  }
435
- return { meta, mesh, soft, refs, refw, offs };
465
+ const seams = meta.kind === 'clothes' ? seamPairs(refs, refw, offs) : undefined;
466
+ return { meta, mesh, soft, refs, refw, offs, seams: seams?.length ? seams : undefined };
436
467
  }
437
468
 
438
469
  private removeProxy(slot: string) {
@@ -497,6 +528,11 @@ export class HumanAvatar {
497
528
  }
498
529
  pp.needsUpdate = true;
499
530
  g.computeVertexNormals();
531
+ if (p.seams) {
532
+ const nn = g.getAttribute('normal') as BufferAttribute, q = p.seams;
533
+ for (let i = 0; i < q.length; i += 2) { tmpA.fromBufferAttribute(nn, q[i]); tmpB.fromBufferAttribute(nn, q[i + 1]).add(tmpA); nn.setXYZ(q[i + 1], tmpB.x, tmpB.y, tmpB.z); }
534
+ for (let i = q.length - 2; i >= 0; i -= 2) { tmpA.fromBufferAttribute(nn, q[i + 1]).normalize(); nn.setXYZ(q[i + 1], tmpA.x, tmpA.y, tmpA.z); nn.setXYZ(q[i], tmpA.x, tmpA.y, tmpA.z); }
535
+ }
500
536
  g.computeBoundingSphere();
501
537
  }
502
538