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.
- package/LICENSE.ASSETS.md +53 -0
- package/README.md +83 -15
- package/package.json +5 -2
- package/src/human/catalog.ts +106 -2
- package/src/human/editor.ts +164 -37
- package/src/human/runtime.ts +47 -6
- package/src/native/HumanEditor.tsx +29 -10
- package/src/web/HumanEditor.tsx +33 -12
|
@@ -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
|
+
| 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
|
-
|
|
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
|
+

|
|
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.
|
|
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 `
|
|
117
|
-
| `
|
|
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
|
|
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`,
|
|
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:**
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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)
|
|
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.
|
|
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",
|
package/src/human/catalog.ts
CHANGED
|
@@ -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
|
}
|
package/src/human/editor.ts
CHANGED
|
@@ -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 {
|
|
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[];
|
|
32
|
-
/**
|
|
33
|
-
|
|
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 {
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
(
|
|
138
|
-
(c
|
|
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',
|
|
147
|
-
(c) =>
|
|
148
|
-
|
|
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) => ({
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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', '
|
|
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
|
+
}
|
package/src/human/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
120
|
-
{o.
|
|
121
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 },
|
package/src/web/HumanEditor.tsx
CHANGED
|
@@ -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
|
|
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-
|
|
156
|
+
<div className="hf-tiles">
|
|
143
157
|
{ctl.options.map((o) => (
|
|
144
|
-
<button key={o.id ?? 'none'} className={`hf-
|
|
145
|
-
{o.
|
|
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-
|
|
178
|
-
.hf-
|
|
179
|
-
.hf-
|
|
180
|
-
.hf-
|
|
181
|
-
.hf-
|
|
182
|
-
.hf-swatches{display:flex;flex-wrap:wrap;gap:
|
|
183
|
-
.hf-swatch{width:
|
|
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}}
|