humanforge 0.2.1 → 0.4.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,32 @@ 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)
35
+ npm run build:previews # thumbnails + swatches for every option (needs Chrome)
29
36
  ```
30
37
 
38
+ Leave out the `--extra` packs for the system set only (~14 MB). Every item loads on demand, so
39
+ the size is what you host, not what each user downloads. To add other community items, put
40
+ their ids in `tools/extra-assets.json` (with author, license and source) and their labels in
41
+ `src/human/catalog.ts`. `demo/wardrobe.html` renders a contact sheet of hairstyles or outfits
42
+ for comparing them.
43
+
44
+
45
+ `build:garments` (`tools/build-garments.mts`) adds garments the MakeHuman packs don't have, generated
46
+ in code from the human package itself (no third-party assets): **hijab**, **al-amira**, **abaya**,
47
+ **jalabiya**, a **day dress**, a **maxi dress** and an **evening gown**. They are regular proxies, so
48
+ they follow every body and face slider; they are tintable (`garmentColors`, palette in
49
+ `GARMENT_COLORS`), and headwear hides the hair while worn. Run it again after `build:human`.
50
+ `demo/garments.html` renders a contact sheet (`?variants=1` adds extreme body shapes).
51
+
52
+ ![Generated garments](demo/garments-preview.png)
53
+
31
54
  Host `assets/human/` on any static host or CDN (S3, Cloudflare R2, GitHub Pages…). No server
32
55
  compression is needed: binaries are pre-gzipped and decoded in JS by `fflate`.
33
56
 
@@ -79,7 +102,7 @@ const glb = await exportHumanGLB(avatar); // Mixamo-rigged, ARKit blendshapes
79
102
  ### Avatar editor: one config, web and React Native
80
103
 
81
104
  `<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
105
+ skin, eyes, brows, nose, mouth, ears, hair, headwear, body, outfit, shoes, hat), randomize and save. The
83
106
  same `options` object decides what your users may change on both platforms:
84
107
 
85
108
  ```tsx
@@ -88,7 +111,7 @@ import { HumanEditor, useHumanPackage } from 'humanforge/web';
88
111
  // React Native (Expo)
89
112
  import { HumanEditor, useHumanPackage } from 'humanforge/native';
90
113
 
91
- const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.2.0/assets/human';
114
+ const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.4.0/assets/human';
92
115
  const { pkg } = useHumanPackage(ASSETS);
93
116
 
94
117
  <HumanEditor
@@ -112,9 +135,11 @@ const { pkg } = useHumanPackage(ASSETS);
112
135
  | `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
136
  | `sections` | Which tabs to show, in order. |
114
137
  | `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`). |
138
+ | `items` | Allowed ids per slot: `hair`, `headwear`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
139
+ | `colors` | Palettes for `hair`, `eyebrows` and `garments` (outfit/headwear fabric). |
140
+ | `skinTones` | Skin tone ids to offer, in order (default: all of `HUMAN_SKIN_TONES`, light to deep). |
141
+ | `featureSliders` | Also show the face-feature mix sliders (african / asian / caucasian morphs). Off by default. |
142
+ | `optional` | Slots that offer "None" (`hair`, `hat`, `headwear`, `eyebrows`, `shoes`). |
118
143
  | `detailRange` | Caps the face/body detail sliders (0..1). |
119
144
  | `defaults` / `locked` | Starting values / values that are always enforced. |
120
145
  | `labels` | Rename any section, control or item (for translation). |
@@ -123,7 +148,42 @@ The config is plain JSON; store it as-is. On load, `editor.normalize(saved)` (do
123
148
  `<HumanEditor>`) repairs anything the current options don't allow. For example, a male outfit in
124
149
  a female-only app gets swapped for a women's one. To display the avatar elsewhere, use
125
150
  `<HumanCanvas>` (web) or `<HumanView>` (React Native) with the saved config. To build your own UI,
126
- `createHumanEditor(pkg.manifest, options)` returns the same sections and controls as data.
151
+ `createHumanEditor(pkg, options)` returns the same sections and controls as data.
152
+
153
+ #### Previews and swatches (no text options)
154
+
155
+ Every pickable option ships with a picture or a color, so your UI never has to print item names:
156
+
157
+ | Attribute (`key`) | Shown as | Source |
158
+ | --- | --- | --- |
159
+ | `skin.tone` | round swatch | 11 tones, light to deep; the texture's age/gender variant follows the avatar automatically |
160
+ | `eyes.color` | round iris image + swatch | `previews/eyes-<id>.webp` |
161
+ | `eyes.lashes`, `brows.style` | thumbnail | close-up renders |
162
+ | `hair.style`, `headwear.style`, `hat.style` | thumbnail | head renders (3/4 view) |
163
+ | `outfit.style` | thumbnail | full-body renders |
164
+ | `shoes.style` | thumbnail | shoe close-ups |
165
+ | `hair.color`, `brows.color`, `outfit.color`, `headwear.color` | round swatch | palettes |
166
+
167
+ Thumbnails are 192 px transparent WebP files in `assets/human/previews/`, so they sit on any
168
+ background. `None` options have `id: null` and no preview (the built-in editors draw a ⌀ icon).
169
+
170
+ ```tsx
171
+ import { getHumanCatalog } from 'humanforge/human';
172
+
173
+ const attrs = getHumanCatalog(pkg, { gender: 'female' }); // same options as <HumanEditor>
174
+ const hair = attrs.find((a) => a.key === 'hair.style')!;
175
+ hair.options; // [{ id: 'long01', label: 'Long straight', preview: 'https://…/previews/hair-long01.webp' }, …]
176
+ hair.value(config); // selected id
177
+ config = hair.apply(config, 'bob02'); // new, normalized config
178
+
179
+ <Image source={{ uri: option.preview }} /> // display 'image'
180
+ <View style={{ backgroundColor: option.swatch }} /> // display 'swatch'
181
+ ```
182
+
183
+ `label` is only an accessible name (`aria-label` / `accessibilityLabel`); translate it with the
184
+ `labels` option if you show it. The face-feature mix (`african` / `asian` / `caucasian`) is no longer
185
+ shown by default: picking a skin tone only changes color. The selfie fit still sets the mix, and
186
+ `featureSliders: true` brings the sliders back.
127
187
 
128
188
  ### 4. Selfie to avatar
129
189
 
@@ -210,7 +270,8 @@ avatar picks up the same bias, such as a wider mouth.
210
270
  - **Detail:** `detail: { [id]: -1..1 }`, with the ids listed in `DETAIL_SLIDERS` (head shape, jaw,
211
271
  chin, cheeks, eyes, brows, nose ×9, mouth ×8, ears, neck, shoulders, chest, waist, hips…).
212
272
  - **Style:** `skin`, `skinTint`, `eyes`, `eyebrows`, `eyebrowColor`, `eyelashes`, `hair`,
213
- `hairColor`, and `clothes: string[]`. The available ids are listed in `pkg.manifest`.
273
+ `hairColor`, `clothes: string[]` and `garmentColors: { outfit?, headwear? }` (fabric colors of
274
+ tintable garments). The available ids are listed in `pkg.manifest`.
214
275
 
215
276
  ### How it works
216
277
 
@@ -248,6 +309,7 @@ codes stay valid.
248
309
  ```bash
249
310
  npm install
250
311
  npm run build:human -- <mpfb data> <system assets> # see above
312
+ npm run build:previews # option thumbnails + swatches (headless Chrome via puppeteer-core)
251
313
  npm test # both modes: every option builds, codec round-trips, human package decodes, selfie solver
252
314
  npm run typecheck
253
315
  npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
@@ -263,11 +325,15 @@ npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
263
325
  shipping.
264
326
  - **Selfie fit:** verified in Chrome with public-domain portraits (web path). The React Native
265
327
  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.
328
+ - **Wardrobe:** the CC0 MakeHuman system packs (10 hairstyles, 12 outfits, 6 shoes) plus CC0
329
+ community items: 8 women's hairstyles (bobs, bangs, a bun, long and layered cuts), 1 men's style,
330
+ 10 dresses and 3 women's and 3 men's suits (see `LICENSE.ASSETS.md`,
331
+ [before/after](demo/hair-compare.jpg)). They are still behind commercial avatar SDKs: hair is
332
+ rendered with soft two-pass cards on 2014–2021-era community meshes. Separate tops, skirts and
333
+ trousers aren't offered because the editor has a single outfit slot. More CC0 packs, or your
334
+ own `.mhclo` assets, can be added through `tools/build-human.ts --extra`. Modest wear and
335
+ dresses come from `tools/build-garments.mts`: they're smooth, procedurally draped shapes (no
336
+ simulated folds), shaped for the rest pose; there's no kufi or niqab yet.
271
337
  - **Hair quality:** hair uses alpha-tested cards, so the edges are crisper with MSAA (`antialias: true`).
272
338
 
273
339
  ## Licenses
@@ -275,7 +341,9 @@ npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
275
341
  - Code: MIT.
276
342
  - Human assets (base mesh, targets, skins, hair, clothes, rig, weights, expression units): CC0 1.0,
277
343
  from the [MakeHuman](http://www.makehumancommunity.org) project and
278
- [MPFB2](https://github.com/makehumancommunity/mpfb2). Credit is appreciated but not required.
344
+ [MPFB2](https://github.com/makehumancommunity/mpfb2), plus CC0 community hair and clothes listed
345
+ per item (author, source) in [LICENSE.ASSETS.md](LICENSE.ASSETS.md). Credit is appreciated but not required.
346
+ - Generated garments and fabric textures (`tools/build-garments.mts` output): original, CC0 1.0.
279
347
  - Selfie fitting: MediaPipe Face Landmarker and its canonical face mesh are Apache-2.0 (Google).
280
348
  `src/human/faceMap.ts` contains vertex indices and averaged landmark positions derived from them.
281
349
  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.1",
3
+ "version": "0.4.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,9 +48,11 @@
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
- "build:facemap": "tsx tools/build-facemap.mts"
54
+ "build:facemap": "tsx tools/build-facemap.mts",
55
+ "build:previews": "tsx tools/build-previews.mts"
54
56
  },
55
57
  "peerDependencies": {
56
58
  "@gltf-transform/core": ">=4",
@@ -102,6 +104,7 @@
102
104
  "expo-modules-core": "^3.0.30",
103
105
  "gl-matrix": "^3.4.4",
104
106
  "meshoptimizer": "^1.3.0",
107
+ "puppeteer-core": "^24.43.1",
105
108
  "react": "^18.3.1",
106
109
  "react-dom": "^18.3.1",
107
110
  "react-native": "^0.81.6",
@@ -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',
@@ -75,13 +132,60 @@ export const HUMAN_HAIR_COLORS: { id: string; label: string; color: string }[] =
75
132
  /** Skin tint presets (multiplied over the skin texture; white = texture as-is). */
76
133
  export const SKIN_TINTS = ['#ffffff', '#fbefe6', '#f5e3d3', '#ffe9dc', '#efe2d6', '#e8d4c4'];
77
134
 
135
+ /**
136
+ * Skin tone palette shown as swatches. Each tone is a base skin texture family plus a tint
137
+ * (multiplied over it). The texture's age and gender variant is picked automatically from the
138
+ * avatar's age and gender (see skinFor), so users only ever choose a color.
139
+ */
140
+ export interface SkinToneDef { id: string; base: 'caucasian' | 'caucasian2' | 'asian' | 'african'; tint: string }
141
+ export const HUMAN_SKIN_TONES: SkinToneDef[] = [
142
+ { id: 'porcelain', base: 'caucasian2', tint: '#ffffff' },
143
+ { id: 'fair', base: 'caucasian', tint: '#ffffff' },
144
+ { id: 'light', base: 'caucasian', tint: '#f4e2cf' },
145
+ { id: 'beige', base: 'asian', tint: '#ffffff' },
146
+ { id: 'warm', base: 'asian', tint: '#f2dcc2' },
147
+ { id: 'olive', base: 'asian', tint: '#e0c4a2' },
148
+ { id: 'tan', base: 'caucasian', tint: '#d4ad86' },
149
+ { id: 'caramel', base: 'asian', tint: '#c99f78' },
150
+ { id: 'chestnut', base: 'asian', tint: '#ab7f5c' },
151
+ { id: 'mocha', base: 'asian', tint: '#8d6649' },
152
+ { id: 'brown', base: 'african', tint: '#ffffff' },
153
+ { id: 'deep', base: 'african', tint: '#d2bcaa' },
154
+ { id: 'ebony', base: 'african', tint: '#a99383' },
155
+ ];
156
+
157
+ /** Skin texture family of a skin id, e.g. young_caucasian_female2 -> caucasian2. */
158
+ export function skinBase(id: string): SkinToneDef['base'] {
159
+ // check caucasian first: 'caucasian' contains 'asian'
160
+ const eth = id.includes('caucasian') ? 'caucasian' : id.includes('african') ? 'african' : id.includes('asian') ? 'asian' : 'caucasian';
161
+ return (eth === 'caucasian' && /2$/.test(id) ? 'caucasian2' : eth) as SkinToneDef['base'];
162
+ }
163
+
164
+ /** The skin texture for a tone family that best matches an age (0..1) and gender (0..1). */
165
+ export function skinFor(skins: string[], base: string, age: number, gender: number): string | null {
166
+ const eth = base.replace(/2$/, ''), variant = base.endsWith('2') ? '2' : '';
167
+ const g = gender < 0.5 ? 'female' : 'male';
168
+ const ages = age < 0.24 ? ['young', 'middleage', 'old'] : age < 0.52 ? ['middleage', 'young', 'old'] : ['old', 'middleage', 'young'];
169
+ for (const a of ages) for (const v of [variant, '']) {
170
+ const id = `${a}_${eth}_${g}${v}`;
171
+ if (skins.includes(id)) return id;
172
+ }
173
+ return skins.find((id) => skinBase(id) === base) ?? skins.find((id) => skinBase(id).replace(/2$/, '') === eth) ?? null;
174
+ }
175
+
176
+ const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
177
+ const hex = (c: number[]) => '#' + c.map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0')).join('');
178
+ /** Multiply two hex colors (how a tint combines with a skin texture). */
179
+ export const multiplyHex = (a: string, b: string) => { const x = rgb(a), y = rgb(b); return hex(x.map((v, i) => v * y[i])); };
180
+ export const hexDistance = (a: string, b: string) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
181
+
78
182
  const words = (s: string) => s.replace(/_/g, ' ').replace(/(\d+)$/, ' $1').replace(/\s+/g, ' ').trim();
79
183
  const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
80
184
 
81
185
  export function hairInfo(id: string): ItemInfo { return HAIR[id] ?? { label: cap(words(id)), fit: 'unisex' }; }
82
186
  export function clothingInfo(id: string): ItemInfo & { slot: ClothingSlot } {
83
187
  if (CLOTHES[id]) return CLOTHES[id];
84
- const slot: ClothingSlot = id.startsWith('shoes') ? 'shoes' : /hat|fedora|cap|helmet/.test(id) ? 'hat' : 'outfit';
188
+ const slot: ClothingSlot = id.startsWith('shoes') ? 'shoes' : /hijab|amira|shayla|scarf|turban/.test(id) ? 'headwear' : /hat|fedora|cap|helmet/.test(id) ? 'hat' : 'outfit';
85
189
  const fit: Fit = id.startsWith('female') ? 'female' : id.startsWith('male') ? 'male' : 'unisex';
86
190
  return { label: cap(words(id.replace(/^(fe)?male_/, ''))), fit, slot };
87
191
  }
@@ -6,15 +6,17 @@
6
6
  // editor.sections -> [{ id: 'face', label: 'Face', controls: [...] }, ...]
7
7
  // editor.initial -> a valid HumanConfig that respects the options
8
8
  // editor.normalize(savedConfig) -> clamps/repairs any config to the options (use on load)
9
- import { DEFAULT_HUMAN, HumanConfig, HumanManifest } from './runtime';
9
+ import { DEFAULT_HUMAN, HumanConfig, HumanManifest, HumanPackage } 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 {
12
+ EYE_LABELS, EYE_SWATCH, GARMENT_COLORS, HUMAN_HAIR_COLORS, HUMAN_SKIN_TONES, clothingInfo, fits, hairInfo, hexDistance, multiplyHex, skinBase, skinFor, skinInfo,
13
+ } from './catalog';
12
14
 
13
15
  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';
16
+ export type SectionId = 'body' | 'face' | 'skin' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'ears' | 'hair' | 'headwear' | 'outfit' | 'shoes' | 'hat';
17
+ export type ItemSlot = 'hair' | 'headwear' | 'outfit' | 'shoes' | 'hat' | 'skin' | 'eyes' | 'eyebrows' | 'eyelashes';
16
18
 
17
- export const ALL_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'body', 'outfit', 'shoes', 'hat'];
19
+ export const ALL_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'headwear', 'body', 'outfit', 'shoes', 'hat'];
18
20
 
19
21
  export interface HumanEditorOptions {
20
22
  /** Lock the avatar to one gender: hides the gender slider and filters hair, clothes and skins. Default 'any'. */
@@ -28,9 +30,13 @@ export interface HumanEditorOptions {
28
30
  /** Allowed item ids per slot (default: everything that fits the gender). */
29
31
  items?: Partial<Record<ItemSlot, string[]>>;
30
32
  /** 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>>;
33
+ colors?: { hair?: string[]; eyebrows?: string[]; garments?: string[] };
34
+ /** Skin tone ids to offer (see HUMAN_SKIN_TONES), in order. Default: all, light to deep. */
35
+ skinTones?: string[];
36
+ /** Also show the face-feature mix sliders (african / asian / caucasian morphs). Default false. */
37
+ featureSliders?: boolean;
38
+ /** Slots that may be empty ("None" option). Default: hat, headwear and eyebrows. */
39
+ optional?: Partial<Record<'hair' | 'hat' | 'headwear' | 'eyebrows' | 'shoes', boolean>>;
34
40
  /** Max magnitude of face/body detail sliders (0..1). Lower keeps faces more natural. Default 1. */
35
41
  detailRange?: number;
36
42
  /** Starting values (merged over the gender's default look). */
@@ -43,8 +49,23 @@ export interface HumanEditorOptions {
43
49
 
44
50
  interface Base { id: string; label: string }
45
51
  export interface SliderControl extends Base { kind: 'slider'; min: number; max: number; step: number; value(c: HumanConfig): number; apply(c: HumanConfig, v: number): HumanConfig }
46
- export interface ChoiceOption { id: string | null; label: string; swatch?: string }
47
- export interface ChoiceControl extends Base { kind: 'choice'; options: ChoiceOption[]; value(c: HumanConfig): string | null; apply(c: HumanConfig, v: string | null): HumanConfig }
52
+ export interface ChoiceOption {
53
+ id: string | null;
54
+ /** Accessible name (the built-in editors don't print it when a preview or swatch exists). */
55
+ label: string;
56
+ /** Hex color to show as a round swatch (skin tones, eye colors). */
57
+ swatch?: string;
58
+ /** Absolute URL of a transparent WebP thumbnail (hair, brows, lashes, clothes, eyes). */
59
+ preview?: string;
60
+ }
61
+ export interface ChoiceControl extends Base {
62
+ kind: 'choice';
63
+ /** 'image': tiles with previews; 'swatch': round color swatches. */
64
+ display: 'image' | 'swatch';
65
+ options: ChoiceOption[];
66
+ value(c: HumanConfig): string | null;
67
+ apply(c: HumanConfig, v: string | null): HumanConfig;
68
+ }
48
69
  export interface ColorControl extends Base { kind: 'color'; colors: string[]; value(c: HumanConfig): string; apply(c: HumanConfig, v: string): HumanConfig }
49
70
  export type EditorControl = SliderControl | ChoiceControl | ColorControl;
50
71
  export interface EditorSection { id: SectionId; label: string; framing: 'face' | 'body'; controls: EditorControl[] }
@@ -64,9 +85,12 @@ export interface HumanEditorModel {
64
85
 
65
86
  const SECTION_LABELS: Record<SectionId, string> = {
66
87
  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',
88
+ hair: 'Hair', headwear: 'Headwear', outfit: 'Outfit', shoes: 'Shoes', hat: 'Hat',
68
89
  };
69
- const FACE_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'hat'];
90
+ const FACE_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'headwear', 'hat'];
91
+ type WearSlot = 'outfit' | 'shoes' | 'hat' | 'headwear';
92
+ /** Slots that can't be worn together: choosing one takes off the other. */
93
+ const CONFLICTS: Partial<Record<WearSlot, WearSlot>> = { hat: 'headwear', headwear: 'hat' };
70
94
  const BROW_SLIDERS = ['browHeight', 'browAngle', 'browDepth'];
71
95
 
72
96
  const GENDER_DEFAULTS: Record<'female' | 'male', Partial<HumanConfig>> = {
@@ -82,13 +106,21 @@ const GENDER_DEFAULTS: Record<'female' | 'male', Partial<HumanConfig>> = {
82
106
 
83
107
  const withDetail = (c: HumanConfig, id: string, v: number): HumanConfig => ({ ...c, detail: { ...c.detail, [id]: v } });
84
108
 
85
- export function createHumanEditor(manifest: HumanManifest, options: HumanEditorOptions = {}): HumanEditorModel {
109
+ /**
110
+ * Build the editor model. Pass the loaded package (preferred: option previews get absolute URLs)
111
+ * or just its manifest.
112
+ */
113
+ export function createHumanEditor(source: HumanPackage | HumanManifest, options: HumanEditorOptions = {}): HumanEditorModel {
114
+ const manifest: HumanManifest = 'manifest' in source ? source.manifest : source;
115
+ const baseUrl = 'manifest' in source ? source.baseUrl.replace(/\/$/, '') + '/' : '';
116
+ const url = (rel?: string) => (rel ? baseUrl + rel : undefined);
117
+ const proxyPreview = (kind: string, id: string) => url(manifest.proxies.find((p) => p.kind === kind && p.id === id)?.preview);
86
118
  const gender = options.gender ?? 'any';
87
119
  const exclude = new Set(options.exclude ?? []);
88
120
  const include = options.include ? new Set(options.include) : null;
89
121
  const range = Math.min(1, Math.max(0, options.detailRange ?? 1));
90
122
  const label = (id: string, fallback: string) => options.labels?.[id] ?? fallback;
91
- const optional = { hat: true, eyebrows: true, hair: false, shoes: false, ...options.optional };
123
+ const optional = { hat: true, headwear: true, eyebrows: true, hair: false, shoes: false, ...options.optional };
92
124
 
93
125
  // ---- allowed items
94
126
  const restrict = (slot: ItemSlot, ids: string[]) => {
@@ -96,9 +128,10 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
96
128
  return only ? ids.filter((id) => only.includes(id)) : ids;
97
129
  };
98
130
  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));
131
+ const bySlot = (slot: WearSlot) => clothes.filter((id) => clothingInfo(id).slot === slot && fits(clothingInfo(id).fit, gender));
100
132
  const allowed: Record<ItemSlot, string[]> = {
101
133
  hair: restrict('hair', manifest.proxies.filter((p) => p.kind === 'hair').map((p) => p.id).filter((id) => fits(hairInfo(id).fit, gender))),
134
+ headwear: restrict('headwear', bySlot('headwear')),
102
135
  outfit: restrict('outfit', bySlot('outfit')),
103
136
  shoes: restrict('shoes', bySlot('shoes')),
104
137
  hat: restrict('hat', bySlot('hat')),
@@ -112,7 +145,28 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
112
145
 
113
146
  const hairColors = options.colors?.hair ?? HUMAN_HAIR_COLORS.map((h) => h.color);
114
147
  const browColors = options.colors?.eyebrows ?? hairColors;
115
- const tints = options.colors?.skinTint ?? SKIN_TINTS;
148
+ // skin tones: base texture family x tint, previewed as the color it renders
149
+ const skinIds = allowed.skin;
150
+ const baseColor = (base: string) => {
151
+ const cols = manifest.skins.filter((s) => skinBase(s.id) === base && s.swatch && skinIds.includes(s.id)).map((s) => s.swatch!);
152
+ if (!cols.length) return null;
153
+ const avg = [1, 3, 5].map((i) => cols.reduce((a, c) => a + parseInt(c.slice(i, i + 2), 16), 0) / cols.length);
154
+ return '#' + avg.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
155
+ };
156
+ const lum = (h: string) => 0.299 * parseInt(h.slice(1, 3), 16) + 0.587 * parseInt(h.slice(3, 5), 16) + 0.114 * parseInt(h.slice(5, 7), 16);
157
+ const tones = HUMAN_SKIN_TONES
158
+ .filter((t) => (!options.skinTones || options.skinTones.includes(t.id)) && skinIds.some((id) => skinBase(id) === t.base))
159
+ .map((t) => ({ ...t, swatch: multiplyHex(baseColor(t.base) ?? '#c9a58a', t.tint) }));
160
+ if (!options.skinTones) tones.sort((a, b) => lum(b.swatch) - lum(a.swatch));
161
+ const toneOf = (c: HumanConfig) => {
162
+ const base = skinBase(c.skin);
163
+ const same = tones.filter((t) => t.base === base);
164
+ const pool = same.length ? same : tones;
165
+ return pool.reduce((best, t) => (hexDistance(t.tint, c.skinTint) < hexDistance(best.tint, c.skinTint) ? t : best), pool[0]);
166
+ };
167
+ const garmentColors = options.colors?.garments ?? GARMENT_COLORS.map((g) => g.color);
168
+ const tintable = (id: string) => !!manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.tintable;
169
+ const defaultColor = (id: string | undefined) => manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.color;
116
170
 
117
171
  // ---- controls
118
172
  const shown = (section: SectionId, id: string) => !exclude.has(section) && !exclude.has(`${section}.${id}`) && !exclude.has(id) && (!include || include.has(`${section}.${id}`) || include.has(id));
@@ -127,35 +181,46 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
127
181
  value: (c) => c.detail[id] ?? 0, apply: (c, v) => withDetail(c, id, v),
128
182
  };
129
183
  };
130
- const choice = (id: string, lbl: string, opts: ChoiceOption[], value: ChoiceControl['value'], apply: ChoiceControl['apply']): ChoiceControl =>
131
- ({ kind: 'choice', id, label: label(id, lbl), options: opts, value, apply });
184
+ const choice = (id: string, lbl: string, opts: ChoiceOption[], value: ChoiceControl['value'], apply: ChoiceControl['apply'], display: ChoiceControl['display'] = 'image'): ChoiceControl =>
185
+ ({ kind: 'choice', id, label: label(id, lbl), display, options: opts, value, apply });
132
186
  const color = (id: string, lbl: string, colors: string[], value: ColorControl['value'], apply: ColorControl['apply']): ColorControl =>
133
187
  ({ kind: 'color', id, label: label(id, lbl), colors, value, apply });
134
188
  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,
136
- [...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] : [])] }));
189
+ const worn = (c: HumanConfig, slot: WearSlot) => c.clothes.find((id) => clothingInfo(id).slot === slot);
190
+ const clothingChoice = (slot: WearSlot, lbl: string, canBeEmpty: boolean) => choice('style', lbl,
191
+ [...none(canBeEmpty), ...allowed[slot].map((id) => ({ id, label: label(id, clothingInfo(id).label), preview: proxyPreview('clothes', id) }))],
192
+ (c) => worn(c, slot) ?? null,
193
+ (c, v) => ({ ...c, clothes: [...c.clothes.filter((id) => clothingInfo(id).slot !== slot && !(v && clothingInfo(id).slot === CONFLICTS[slot])), ...(v ? [v] : [])] }));
194
+ /** Fabric color of the tintable garment in a slot (only shown when the slot offers one). */
195
+ const garmentColor = (slot: WearSlot): EditorControl[] => (allowed[slot].some(tintable)
196
+ ? [color('color', 'Color', garmentColors, (c) => c.garmentColors?.[slot] ?? defaultColor(worn(c, slot)) ?? garmentColors[0],
197
+ (c, v) => ({ ...c, garmentColors: { ...c.garmentColors, [slot]: v } }))]
198
+ : []);
139
199
 
140
200
  const build: Record<SectionId, () => EditorControl[]> = {
141
201
  face: () => [
142
- ...(['african', 'asian', 'caucasian'] as const).map((k) => macro(k, { african: 'African features', asian: 'Asian features', caucasian: 'European features' }[k])),
202
+ ...(options.featureSliders ? (['african', 'asian', 'caucasian'] as const).map((k) => macro(k, { african: 'African features', asian: 'Asian features', caucasian: 'European features' }[k])) : []),
143
203
  ...DETAIL_SLIDERS.filter((d) => d.group === 'head').map((d) => detail(d.id)),
144
204
  ],
145
205
  skin: () => [
146
- choice('tone', 'Skin', allowed.skin.map((id) => ({ id, label: label(id, skinInfo(id, manifest.skins.find((s) => s.id === id)?.tags ?? []).label) })),
147
- (c) => c.skin, (c, v) => ({ ...c, skin: v ?? c.skin })),
148
- color('tint', 'Tint', tints, (c) => c.skinTint, (c, v) => ({ ...c, skinTint: v })),
206
+ choice('tone', 'Skin tone', tones.map((t) => ({ id: t.id, label: label(t.id, t.id.charAt(0).toUpperCase() + t.id.slice(1)), swatch: t.swatch })),
207
+ (c) => toneOf(c)?.id ?? null,
208
+ (c, v) => {
209
+ const t = tones.find((x) => x.id === v);
210
+ return t ? { ...c, skin: skinFor(skinIds, t.base, c.age, c.gender) ?? c.skin, skinTint: t.tint } : c;
211
+ }, 'swatch'),
149
212
  ],
150
213
  eyes: () => [
151
- choice('color', 'Eye color', allowed.eyes.map((id) => ({ id, label: label(id, EYE_LABELS[id] ?? id), swatch: EYE_SWATCH[id] })),
152
- (c) => c.eyes, (c, v) => ({ ...c, eyes: v ?? c.eyes })),
153
- choice('lashes', 'Lashes', [...none(true), ...allowed.eyelashes.map((id, i) => ({ id, label: `${i + 1}` }))],
214
+ choice('color', 'Eye color', allowed.eyes.map((id) => ({
215
+ id, label: label(id, EYE_LABELS[id] ?? id),
216
+ swatch: manifest.eyeOptions?.[id]?.swatch ?? EYE_SWATCH[id], preview: url(manifest.eyeOptions?.[id]?.preview),
217
+ })), (c) => c.eyes, (c, v) => ({ ...c, eyes: v ?? c.eyes }), 'swatch'),
218
+ choice('lashes', 'Lashes', [...none(true), ...allowed.eyelashes.map((id, i) => ({ id, label: label(id, `Lashes ${i + 1}`), preview: proxyPreview('eyelashes', id) }))],
154
219
  (c) => c.eyelashes, (c, v) => ({ ...c, eyelashes: v })),
155
220
  ...DETAIL_SLIDERS.filter((d) => d.group === 'eyes' && !BROW_SLIDERS.includes(d.id)).map((d) => detail(d.id)),
156
221
  ],
157
222
  brows: () => [
158
- choice('style', 'Brows', [...none(optional.eyebrows), ...allowed.eyebrows.map((id, i) => ({ id, label: `${i + 1}` }))],
223
+ choice('style', 'Brows', [...none(optional.eyebrows), ...allowed.eyebrows.map((id, i) => ({ id, label: label(id, `Brows ${i + 1}`), preview: proxyPreview('eyebrows', id) }))],
159
224
  (c) => c.eyebrows, (c, v) => ({ ...c, eyebrows: v })),
160
225
  color('color', 'Brow color', browColors, (c) => c.eyebrowColor, (c, v) => ({ ...c, eyebrowColor: v })),
161
226
  ...BROW_SLIDERS.map(detail),
@@ -164,7 +229,7 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
164
229
  mouth: () => DETAIL_SLIDERS.filter((d) => d.group === 'mouth').map((d) => detail(d.id)),
165
230
  ears: () => DETAIL_SLIDERS.filter((d) => d.group === 'ears').map((d) => detail(d.id)),
166
231
  hair: () => [
167
- choice('style', 'Hairstyle', [...none(optional.hair), ...allowed.hair.map((id) => ({ id, label: label(id, hairInfo(id).label) }))],
232
+ choice('style', 'Hairstyle', [...none(optional.hair), ...allowed.hair.map((id) => ({ id, label: label(id, hairInfo(id).label), preview: proxyPreview('hair', id) }))],
168
233
  (c) => c.hair, (c, v) => ({ ...c, hair: v })),
169
234
  color('color', 'Hair color', hairColors, (c) => c.hairColor, (c, v) => ({ ...c, hairColor: v })),
170
235
  ],
@@ -174,7 +239,8 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
174
239
  ...(gender !== 'male' ? [macro('cupSize', 'Bust'), macro('firmness', 'Firmness')] : []),
175
240
  ...DETAIL_SLIDERS.filter((d) => d.group === 'body').map((d) => detail(d.id)),
176
241
  ],
177
- outfit: () => [clothingChoice('outfit', 'Outfit', false)],
242
+ headwear: () => [clothingChoice('headwear', 'Headwear', optional.headwear), ...garmentColor('headwear')],
243
+ outfit: () => [clothingChoice('outfit', 'Outfit', false), ...garmentColor('outfit')],
178
244
  shoes: () => [clothingChoice('shoes', 'Shoes', optional.shoes)],
179
245
  hat: () => [clothingChoice('hat', 'Hat', optional.hat)],
180
246
  };
@@ -183,7 +249,7 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
183
249
  for (const id of options.sections ?? ALL_SECTIONS) {
184
250
  if (exclude.has(id)) continue;
185
251
  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;
252
+ if ((id === 'outfit' || id === 'shoes' || id === 'hat' || id === 'headwear') && !allowed[id].length) continue;
187
253
  if (controls.length) sections.push({ id, label: label(id, SECTION_LABELS[id]), framing: FACE_SECTIONS.includes(id) ? 'face' : 'body', controls });
188
254
  }
189
255
 
@@ -197,19 +263,22 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
197
263
  const c: HumanConfig = { ...base, ...input, ...options.locked, detail: { ...base.detail, ...input?.detail, ...options.locked?.detail } } as HumanConfig;
198
264
  if (gender !== 'any') c.gender = options.locked?.gender ?? (gender === 'female' ? Math.min(c.gender, 0.3) : Math.max(c.gender, 0.7));
199
265
  for (const [k, v] of Object.entries(c.detail)) c.detail[k] = Math.max(-range, Math.min(range, v));
200
- c.skin = pick(c.skin, allowed.skin, closestSkin(c.skin, allowed.skin, manifest) ?? base.skin) ?? c.skin;
266
+ // keep the chosen tone family, but follow the avatar's age and gender with the texture variant
267
+ c.skin = skinFor(allowed.skin, skinBase(c.skin), c.age, c.gender) ?? pick(c.skin, allowed.skin, closestSkin(c.skin, allowed.skin, manifest) ?? base.skin) ?? c.skin;
201
268
  c.eyes = pick(c.eyes, allowed.eyes, base.eyes) ?? c.eyes;
202
269
  c.hair = c.hair === null && optional.hair ? null : pick(c.hair, allowed.hair, base.hair);
203
270
  c.eyebrows = c.eyebrows === null && optional.eyebrows ? null : pick(c.eyebrows, allowed.eyebrows, base.eyebrows);
204
271
  c.eyelashes = c.eyelashes === null ? null : pick(c.eyelashes, allowed.eyelashes, base.eyelashes);
205
- const wear = (slot: 'outfit' | 'shoes' | 'hat', canBeEmpty: boolean) => {
272
+ const wear = (slot: WearSlot, canBeEmpty: boolean) => {
206
273
  const cur = c.clothes.find((id) => clothingInfo(id).slot === slot);
207
274
  const fallback = base.clothes.find((id) => clothingInfo(id).slot === slot);
208
275
  if (!cur && canBeEmpty) return [];
209
276
  const v = pick(cur, allowed[slot], fallback);
210
277
  return v ? [v] : [];
211
278
  };
212
- c.clothes = [...wear('outfit', false), ...wear('shoes', optional.shoes), ...wear('hat', true)];
279
+ const headwear = wear('headwear', true);
280
+ c.clothes = [...wear('outfit', false), ...wear('shoes', optional.shoes), ...headwear, ...(headwear.length ? [] : wear('hat', true))];
281
+ if (c.garmentColors) c.garmentColors = Object.fromEntries(Object.entries(c.garmentColors).filter(([, v]) => typeof v === 'string'));
213
282
  return c;
214
283
  }
215
284
 
@@ -233,7 +302,9 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
233
302
  return { options, sections, initial: normalize(), normalize, randomize, allowed };
234
303
  }
235
304
 
236
- const HAIR_ORDER = ['long01', 'ponytail01', 'bob02', 'afro01', 'short02', 'short01', 'short04', 'bob01', 'braid01', 'short03'];
305
+ const HAIR_ORDER = ['long01', 'culturalibre_hair_01', 'cortu_straight_bangs', 'sonntag78_junglebook_hair', 'ponytail01', 'rehmanpolanski_hair_bun_brown',
306
+ 'toigo_blunt_bob', 'toigo_blunt_bob_with_bangs', 'toigo_curled_under_bob', 'toigo_inverted_bob_with_bangs', 'bob02', 'afro01',
307
+ 'short02', 'culturalibre_hair_02', 'short01', 'short04', 'bob01', 'braid01', 'short03'];
237
308
  const order = (id: string) => { const i = HAIR_ORDER.indexOf(id); return i < 0 ? 100 : i; };
238
309
 
239
310
  /** Same age/ethnicity skin among the allowed ones (used when gender filtering removes the current skin). */
@@ -247,3 +318,59 @@ function closestSkin(id: string, allowed: string[], manifest: HumanManifest) {
247
318
  }
248
319
  return best;
249
320
  }
321
+
322
+ // ---------------------------------------------------------------- catalog for custom UIs
323
+ export interface CatalogOption {
324
+ /** Value to pass to `apply` (null = "none"). */
325
+ id: string | null;
326
+ /** Accessible name; not meant as visible text. */
327
+ label: string;
328
+ /** Hex color for round swatches (skin tones, eye colors, hair/brow/fabric colors). */
329
+ swatch?: string;
330
+ /** Absolute URL of a transparent WebP thumbnail (hair, brows, lashes, outfits, shoes, hats, headwear, eyes). */
331
+ preview?: string;
332
+ }
333
+ export interface CatalogAttribute {
334
+ /** e.g. 'hair.style', 'skin.tone', 'eyes.color', 'outfit.color' */
335
+ key: string;
336
+ section: SectionId;
337
+ label: string;
338
+ /** How to show the options: 'image' -> thumbnails, 'swatch' -> round color swatches. */
339
+ display: 'image' | 'swatch';
340
+ options: CatalogOption[];
341
+ /** The selected option id for a config. */
342
+ value(c: HumanConfig): string | null;
343
+ /** A new config with this option selected (already normalized). */
344
+ apply(c: HumanConfig, id: string | null): HumanConfig;
345
+ }
346
+
347
+ /**
348
+ * Every pickable (non-slider) attribute with its preview images or color swatches, for apps that
349
+ * build their own avatar UI. Respects the same options as the editor (gender lock, items, …).
350
+ *
351
+ * const attrs = getHumanCatalog(pkg, { gender: 'female' });
352
+ * attrs.find((a) => a.key === 'hair.style')!.options // [{ id: 'long01', preview: 'https://…/previews/hair-long01.webp' }, …]
353
+ * config = attr.apply(config, option.id);
354
+ */
355
+ export function getHumanCatalog(source: HumanPackage | HumanManifest | HumanEditorModel, options?: HumanEditorOptions): CatalogAttribute[] {
356
+ const editor = 'sections' in source ? source : createHumanEditor(source, options);
357
+ const out: CatalogAttribute[] = [];
358
+ for (const s of editor.sections) {
359
+ for (const ctl of s.controls) {
360
+ if (ctl.kind === 'slider') continue;
361
+ const key = `${s.id}.${ctl.id}`;
362
+ if (ctl.kind === 'choice') {
363
+ out.push({
364
+ key, section: s.id, label: ctl.label, display: ctl.display, options: ctl.options,
365
+ value: ctl.value, apply: (c, id) => editor.normalize(ctl.apply(c, id)),
366
+ });
367
+ } else {
368
+ out.push({
369
+ key, section: s.id, label: ctl.label, display: 'swatch', options: ctl.colors.map((c) => ({ id: c, label: c, swatch: c })),
370
+ value: (c) => ctl.value(c), apply: (c, id) => (id ? editor.normalize(ctl.apply(c, id)) : c),
371
+ });
372
+ }
373
+ }
374
+ }
375
+ return out;
376
+ }
@@ -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,15 @@ 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;
66
+ /** Transparent WebP thumbnail, relative to the package root (tools/build-previews.mts). */
67
+ preview?: string;
56
68
  }
57
69
  export interface HumanManifest {
58
70
  vertexCount: number;
@@ -64,8 +76,11 @@ export interface HumanManifest {
64
76
  };
65
77
  bones: { name: string; parent: number; head: number[]; tail: number[] }[];
66
78
  proxies: ProxyMeta[];
67
- skins: { id: string; tags: string[] }[];
79
+ /** `swatch`: the skin tone as rendered (hex), from tools/build-previews.mts. */
80
+ skins: { id: string; tags: string[]; swatch?: string }[];
68
81
  eyes: string[];
82
+ /** Iris color and round iris thumbnail per eye color (tools/build-previews.mts). */
83
+ eyeOptions?: Record<string, { swatch: string; preview: string }>;
69
84
  textureSizes: { skin1k: number; skin2k: number; eyes: number };
70
85
  expressions: Record<string, string>;
71
86
  }
@@ -233,9 +248,25 @@ function geometryFrom(prim: GlbPrimitive, joints: string, weights: string) {
233
248
  g.setAttribute('skinIndex', new BufferAttribute(asU16(prim.attributes[joints]), 4));
234
249
  g.setAttribute('skinWeight', new BufferAttribute(toFloat(prim.attributes[weights]), 4));
235
250
  g.setIndex(new BufferAttribute(prim.index, 1));
251
+ if (prim.attributes.COLOR_0) g.setAttribute('color', new BufferAttribute(toFloat(prim.attributes.COLOR_0), prim.attributes.COLOR_0.itemSize));
236
252
  return g;
237
253
  }
238
254
 
255
+ /**
256
+ * Vertices split only for texture seams share refs/weights/offsets. Pairs (dup, first) so
257
+ * normals can be averaged across the seam after computeVertexNormals.
258
+ */
259
+ function seamPairs(refs: Uint16Array, refw: Float32Array, offs: Float32Array) {
260
+ const first = new Map<string, number>(), pairs: number[] = [];
261
+ for (let i = 0; i < refs.length / 3; i++) {
262
+ let k = '';
263
+ 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)},`;
264
+ const f = first.get(k);
265
+ if (f === undefined) first.set(k, i); else pairs.push(i, f);
266
+ }
267
+ return Uint32Array.from(pairs);
268
+ }
269
+
239
270
  /**
240
271
  * Hair cards: the texture's transparent texels are near-white, which shows as a light halo
241
272
  * around strands once alpha is blended. Darken texels by their coverage so tips fade naturally.
@@ -248,7 +279,7 @@ function hairShading(m: MeshStandardMaterial) {
248
279
  m.customProgramCacheKey = () => 'humanforge-hair';
249
280
  }
250
281
 
251
- interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; soft?: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array }
282
+ interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; soft?: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array; seams?: Uint32Array }
252
283
 
253
284
  export class HumanAvatar {
254
285
  readonly group = new Group();
@@ -318,7 +349,8 @@ export class HumanAvatar {
318
349
  if (c.eyelashes) wanted.set('eyelashes', `eyelashes-${c.eyelashes}`);
319
350
  wanted.set('teeth', 'teeth-teeth_base');
320
351
  wanted.set('tongue', 'tongue-tongue01');
321
- if (c.hair) wanted.set('hair', `hair-${c.hair}`);
352
+ const meta = (id: string) => this.pkg.manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id);
353
+ if (c.hair && !c.clothes.some((id) => meta(id)?.hidesHair)) wanted.set('hair', `hair-${c.hair}`);
322
354
  c.clothes.forEach((id) => wanted.set(`clothes:${id}`, `clothes-${id}`));
323
355
  const loads = [...wanted.entries()].map(async ([slot, id]) => {
324
356
  const cur = this.proxies.get(slot);
@@ -347,6 +379,7 @@ export class HumanAvatar {
347
379
  if (slot === 'hair') { m.color.set(c.hairColor); (p.soft?.material as MeshStandardMaterial | undefined)?.color.set(c.hairColor); }
348
380
  if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
349
381
  if (slot === 'eyes' && m.map !== eyeTex) { m.map = eyeTex; m.needsUpdate = true; }
382
+ if (p.meta.kind === 'clothes' && p.meta.tintable) m.color.set(c.garmentColors?.[clothingInfo(p.meta.id).slot] ?? p.meta.color ?? '#ffffff');
350
383
  }
351
384
  this.reshape();
352
385
  }
@@ -396,9 +429,11 @@ export class HumanAvatar {
396
429
  const refs = asU16(prim.attributes._REFS), refw = toFloat(prim.attributes._REFW), offs = toFloat(prim.attributes._OFFS);
397
430
  const mat = new MeshStandardMaterial({
398
431
  map, normalMap, metalness: 0,
399
- roughness: meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8,
432
+ roughness: meta.roughness ?? (meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8),
400
433
  side: meta.doubleSided ? DoubleSide : FrontSide,
434
+ vertexColors: !!geo.getAttribute('color'),
401
435
  });
436
+ if (meta.tileable) for (const t of [map, normalMap]) if (t && t.wrapS !== RepeatWrapping) { t.wrapS = t.wrapT = RepeatWrapping; t.needsUpdate = true; }
402
437
  let soft: SkinnedMesh | undefined;
403
438
  if (meta.kind === 'hair') {
404
439
  // Two passes: a solid core (alpha-tested, writes depth, so strands sort correctly) and a
@@ -432,7 +467,8 @@ export class HumanAvatar {
432
467
  mesh.morphTargetDictionary = this.body.morphTargetDictionary;
433
468
  mesh.morphTargetInfluences = [...(this.body.morphTargetInfluences ?? [])];
434
469
  }
435
- return { meta, mesh, soft, refs, refw, offs };
470
+ const seams = meta.kind === 'clothes' ? seamPairs(refs, refw, offs) : undefined;
471
+ return { meta, mesh, soft, refs, refw, offs, seams: seams?.length ? seams : undefined };
436
472
  }
437
473
 
438
474
  private removeProxy(slot: string) {
@@ -497,6 +533,11 @@ export class HumanAvatar {
497
533
  }
498
534
  pp.needsUpdate = true;
499
535
  g.computeVertexNormals();
536
+ if (p.seams) {
537
+ const nn = g.getAttribute('normal') as BufferAttribute, q = p.seams;
538
+ 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); }
539
+ 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); }
540
+ }
500
541
  g.computeBoundingSphere();
501
542
  }
502
543
 
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // <HumanEditor pkg={pkg} options={{ gender: 'female', exclude: ['ears'] }} onSave={(config, png) => save(config)} />
5
5
  import React, { useMemo, useRef, useState } from 'react';
6
- import { LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
6
+ import { Image, LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
7
7
  import type { HumanConfig, HumanPackage } from '../human/runtime';
8
8
  import { EditorControl, HumanEditorModel as EditorModel, HumanEditorOptions, createHumanEditor } from '../human/editor';
9
9
  import { HumanView, HumanViewHandle } from './HumanView';
@@ -32,7 +32,7 @@ export function HumanEditor({
32
32
  pkg, options, editor: given, value, defaultValue, onChange, onSave, onCancel, saveLabel = 'Save', randomize = true,
33
33
  accentColor = '#1d1d1f', background = '#e9ecf1', style,
34
34
  }: HumanEditorProps) {
35
- const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
35
+ const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
36
36
  const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
37
37
  const config = value ? editor.normalize(value) : inner;
38
38
  const [tab, setTab] = useState(0);
@@ -110,15 +110,31 @@ function Control({ ctl, config, onChange, accent }: { ctl: EditorControl; config
110
110
  );
111
111
  }
112
112
  const v = ctl.value(config);
113
+ if (ctl.display === 'swatch') {
114
+ return (
115
+ <View style={s.block}>
116
+ <Text style={s.blockLabel}>{ctl.label}</Text>
117
+ <View style={s.wrap}>
118
+ {ctl.options.map((o) => (
119
+ <Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
120
+ style={[s.swatch, { backgroundColor: o.swatch ?? '#eee' }, o.id === v && { borderColor: accent }]}>
121
+ {o.preview ? <Image source={{ uri: o.preview }} style={s.swatchImg} /> : null}
122
+ </Pressable>
123
+ ))}
124
+ </View>
125
+ </View>
126
+ );
127
+ }
113
128
  return (
114
129
  <View style={s.block}>
115
130
  <Text style={s.blockLabel}>{ctl.label}</Text>
116
131
  <View style={s.wrap}>
117
132
  {ctl.options.map((o) => (
118
- <Pressable key={o.id ?? 'none'} onPress={() => onChange(ctl.apply(config, o.id))}
119
- style={[s.choice, o.id === v && { borderColor: accent, borderWidth: 2 }]}>
120
- {o.swatch ? <View style={[s.dot, { backgroundColor: o.swatch }]} /> : null}
121
- <Text style={[s.choiceText, o.id === v && { fontWeight: '700' }]}>{o.label}</Text>
133
+ <Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
134
+ style={[s.tile, o.id === v && { borderColor: accent, borderWidth: 2, backgroundColor: '#fff' }]}>
135
+ {o.id === null ? <View style={s.none}><View style={s.noneBar} /></View>
136
+ : o.preview ? <Image source={{ uri: o.preview }} style={s.tileImg} resizeMode="contain" />
137
+ : <Text style={s.choiceText}>{o.label}</Text>}
122
138
  </Pressable>
123
139
  ))}
124
140
  </View>
@@ -173,10 +189,13 @@ const s = StyleSheet.create({
173
189
  block: { marginVertical: 10 },
174
190
  blockLabel: { fontSize: 13, fontWeight: '600', color: '#333', marginBottom: 8 },
175
191
  wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
176
- choice: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderColor: '#e1e3e7', borderRadius: 10, paddingHorizontal: 12, paddingVertical: 9, minWidth: 64, justifyContent: 'center' },
177
- choiceText: { fontSize: 12, color: '#1d1d1f' },
178
- dot: { width: 12, height: 12, borderRadius: 6 },
179
- swatch: { width: 32, height: 32, borderRadius: 16, borderWidth: 3, borderColor: '#fff', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
192
+ tile: { width: 76, height: 76, borderRadius: 12, borderWidth: 1, borderColor: '#e1e3e7', backgroundColor: '#f4f5f7', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', padding: 3 },
193
+ tileImg: { width: '100%', height: '100%' },
194
+ none: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: '#9aa0a8', alignItems: 'center', justifyContent: 'center' },
195
+ noneBar: { width: 30, height: 2, backgroundColor: '#9aa0a8', transform: [{ rotate: '-45deg' }] },
196
+ choiceText: { fontSize: 11, color: '#555', textAlign: 'center' },
197
+ swatch: { width: 36, height: 36, borderRadius: 18, borderWidth: 3, borderColor: '#fff', overflow: 'hidden', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
198
+ swatchImg: { width: '100%', height: '100%' },
180
199
  slider: { flex: 1, height: 32, justifyContent: 'center' },
181
200
  track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
182
201
  fill: { height: 4 },
@@ -35,7 +35,7 @@ export function HumanEditor({
35
35
  pkg, options, editor: given, value, defaultValue, onChange, onSave, saveLabel = 'Save', selfie = false, randomize = true,
36
36
  accentColor = '#1d1d1f', background = '#e9ecf1', className, style,
37
37
  }: HumanEditorProps) {
38
- const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
38
+ const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
39
39
  const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
40
40
  const config = value ? editor.normalize(value) : inner;
41
41
  const [tab, setTab] = useState(0);
@@ -136,14 +136,27 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
136
136
  );
137
137
  }
138
138
  const v = ctl.value(config);
139
+ if (ctl.display === 'swatch') {
140
+ return (
141
+ <div className="hf-block">
142
+ <span className="hf-label">{ctl.label}</span>
143
+ <div className="hf-swatches">
144
+ {ctl.options.map((o) => (
145
+ <button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-swatch ${o.id === v ? 'on' : ''}`}
146
+ style={{ background: o.preview ? `center/cover url("${o.preview}") ${o.swatch ?? ''}` : o.swatch ?? '#eee' }}
147
+ onClick={() => onChange(ctl.apply(config, o.id))} />
148
+ ))}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
139
153
  return (
140
154
  <div className="hf-block">
141
155
  <span className="hf-label">{ctl.label}</span>
142
- <div className="hf-choices">
156
+ <div className="hf-tiles">
143
157
  {ctl.options.map((o) => (
144
- <button key={o.id ?? 'none'} className={`hf-choice ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
145
- {o.swatch && <i style={{ background: o.swatch }} />}
146
- {o.label}
158
+ <button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-tile ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
159
+ {o.id === null ? <NoneIcon /> : o.preview ? <img src={o.preview} alt="" loading="lazy" draggable={false} /> : <span>{o.label}</span>}
147
160
  </button>
148
161
  ))}
149
162
  </div>
@@ -151,6 +164,14 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
151
164
  );
152
165
  }
153
166
 
167
+ function NoneIcon() {
168
+ return (
169
+ <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" fill="none" stroke="#9aa0a8" strokeWidth="1.8">
170
+ <circle cx="12" cy="12" r="9" /><path d="M5.6 18.4 18.4 5.6" />
171
+ </svg>
172
+ );
173
+ }
174
+
154
175
  const CSS = `
155
176
  .hf-root{--hf-accent:#1d1d1f;container-type:inline-size;height:100%;min-height:480px}
156
177
  .hf-editor{display:grid;grid-template-columns:minmax(0,1fr) 360px;height:100%;min-height:480px;font:13px/1.4 system-ui,-apple-system,sans-serif;color:#1d1d1f;background:#fff;border-radius:16px;overflow:hidden}
@@ -174,13 +195,13 @@ const CSS = `
174
195
  .hf-label{font-size:12px;color:#666}
175
196
  .hf-block{margin:12px 0}
176
197
  .hf-block>.hf-label{display:block;margin-bottom:8px;font-weight:600;color:#333}
177
- .hf-choices{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:6px}
178
- .hf-choice{display:flex;align-items:center;gap:6px;justify-content:center;border:1px solid #e1e3e7;background:#fff;border-radius:10px;padding:9px 8px;font:inherit;font-size:12px;cursor:pointer;text-align:center}
179
- .hf-choice:hover{border-color:#c5c8ce}
180
- .hf-choice.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 1px var(--hf-accent);font-weight:600}
181
- .hf-choice i{width:12px;height:12px;border-radius:50%;flex:none}
182
- .hf-swatches{display:flex;flex-wrap:wrap;gap:8px}
183
- .hf-swatch{width:28px;height:28px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
198
+ .hf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:8px}
199
+ .hf-tile{aspect-ratio:1;display:grid;place-items:center;border:1px solid #e1e3e7;background:#f4f5f7;border-radius:12px;padding:4px;cursor:pointer;overflow:hidden;font:inherit;font-size:11px;color:#555}
200
+ .hf-tile:hover{border-color:#c5c8ce}
201
+ .hf-tile.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 2px var(--hf-accent);background:#fff}
202
+ .hf-tile img{width:100%;height:100%;object-fit:contain;pointer-events:none}
203
+ .hf-swatches{display:flex;flex-wrap:wrap;gap:10px}
204
+ .hf-swatch{width:34px;height:34px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
184
205
  .hf-swatch.on{box-shadow:0 0 0 2px var(--hf-accent)}
185
206
  .hf-save{margin:12px 16px 16px;border:0;border-radius:12px;padding:12px;background:var(--hf-accent);color:#fff;font:inherit;font-weight:600;cursor:pointer}
186
207
  @container (max-width:720px){.hf-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(260px,52%) minmax(0,1fr)}.hf-panel{border-left:0;border-top:1px solid #eceef1}}