humanforge 0.3.0 → 0.5.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/README.md +117 -18
- package/package.json +5 -2
- package/src/human/catalog.ts +47 -0
- package/src/human/editor.ts +129 -20
- package/src/human/runtime.ts +177 -25
- package/src/index.ts +2 -0
- package/src/native/HumanEditor.tsx +29 -10
- package/src/native/HumanView.tsx +129 -25
- package/src/native/humanExpo.ts +39 -17
- package/src/native/index.ts +3 -3
- package/src/web/HumanCanvas.tsx +3 -2
- package/src/web/HumanEditor.tsx +33 -12
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ done
|
|
|
32
32
|
npm run build:human -- mpfb2/src/mpfb/data mh_assets \
|
|
33
33
|
--extra mh_hair01 --extra mh_dress01 --extra mh_suits01 # -> assets/human (~26 MB, all options)
|
|
34
34
|
npm run build:garments # + modest wear and dresses (below)
|
|
35
|
+
npm run build:previews # thumbnails + swatches for every option (needs Chrome)
|
|
36
|
+
npm run build:mobile -- --skin-2k # JPEG/PNG texture pack for Expo (no WebP in expo-gl)
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
Leave out the `--extra` packs for the system set only (~14 MB). Every item loads on demand, so
|
|
@@ -62,24 +64,83 @@ npx expo install expo-gl expo-file-system three fflate
|
|
|
62
64
|
```tsx
|
|
63
65
|
import { HumanView, useHumanPackage } from 'humanforge';
|
|
64
66
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
+
const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.5.0/assets/human';
|
|
68
|
+
|
|
69
|
+
function Avatar({ config }) {
|
|
70
|
+
// ready = the first textured frame can be painted (manifest, body, and this look's
|
|
71
|
+
// hair/clothes/skin/eyes are on disk), not just "manifest loaded"
|
|
72
|
+
const { pkg, ready, progress } = useHumanPackage(ASSETS, { config, textureSize: '1k' });
|
|
73
|
+
if (!pkg || !ready) return <Loading progress={progress} />;
|
|
74
|
+
return (
|
|
75
|
+
<HumanView
|
|
76
|
+
pkg={pkg}
|
|
77
|
+
config={config}
|
|
78
|
+
framing="face" // or "body"
|
|
79
|
+
textureSize="1k" // see the table below
|
|
80
|
+
expressions={{ mouthSmile: 0.6 }}
|
|
81
|
+
onReady={() => SplashScreen.hideAsync()} // fires once, after the first textured frame
|
|
82
|
+
style={{ width: 240, height: 240, borderRadius: 120 }}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
67
87
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
A ref gives you `avatar` (bones, `setExpression`), `render()` and `snapshot()` (PNG file URI).
|
|
89
|
+
|
|
90
|
+
#### Textures on Expo: the mobile pack
|
|
91
|
+
|
|
92
|
+
expo-gl uploads images through stb_image, which **can't decode WebP**: a WebP texture leaves the
|
|
93
|
+
mesh black with only the lights showing. The asset package therefore ships a **mobile texture
|
|
94
|
+
pack** with the same textures as baseline JPEG (opaque) and paletted PNG (hair, lashes, lace),
|
|
95
|
+
in three tiers. `useHumanPackage`, `preloadHumanPackage` and `expoLoadOptions` select it
|
|
96
|
+
automatically (`textureFormat: 'mobile'`); the web keeps using WebP. Nothing is converted on the
|
|
97
|
+
device, and nothing large goes into your app or the npm package: files download from the CDN
|
|
98
|
+
on demand and stay in the app's cache directory (offline on later launches).
|
|
99
|
+
|
|
100
|
+
| `textureSize` | Max edge | Use for | First-look download* |
|
|
101
|
+
| --- | --- | --- | --- |
|
|
102
|
+
| `'512'` | 512 px | lists, chat bubbles, avatars under ~120 pt | ~0.8 MB |
|
|
103
|
+
| `'1k'` (default) | 1024 px | profile, bust/face framing; at phone sizes this matches web 2k | ~1.2 MB |
|
|
104
|
+
| `'2k'` | 2048 px (skins) | full-screen face close-ups, tablets | ~1.3 MB |
|
|
105
|
+
|
|
106
|
+
\* meshes + textures of a default look (hair, outfit, shoes, skin, eyes, brows, lashes); the
|
|
107
|
+
shared body and shape data add ~1.45 MB once. Brows, lashes and eye textures are small at every
|
|
108
|
+
tier; textures are never upscaled.
|
|
109
|
+
|
|
110
|
+
On the jsDelivr setup the pack lives on its own git tag (`mobile-v0.5.0`), because jsDelivr serves
|
|
111
|
+
GitHub versions only up to 50 MB; `manifest.mobileTextures.baseUrl` points there, so you only pass
|
|
112
|
+
the main URL. Self-hosting: run `npm run build:mobile` and upload `assets/human/mobile/` next to
|
|
113
|
+
`manifest.json` (or pass `mobileBaseUrl`).
|
|
114
|
+
|
|
115
|
+
#### Preload during the splash screen
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
import { preloadHumanPackage } from 'humanforge';
|
|
119
|
+
|
|
120
|
+
// resolves when the files are on disk, so the first <HumanView> paints textured at once
|
|
121
|
+
await preloadHumanPackage(ASSETS, { config: savedAvatar, textureSize: '1k', onProgress: (done, total) => {} });
|
|
79
122
|
```
|
|
80
123
|
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
It shares its cache with `useHumanPackage` (same URL = same package, no second download).
|
|
125
|
+
|
|
126
|
+
#### Performance knobs
|
|
127
|
+
|
|
128
|
+
| `HumanView` prop | Default | Notes |
|
|
129
|
+
| --- | --- | --- |
|
|
130
|
+
| `textureSize` | `'1k'` | tiers above |
|
|
131
|
+
| `pixelRatio` | device (max 3) | `2` on 3x phones renders ~55% fewer pixels and still looks sharp |
|
|
132
|
+
| `antialias` | `true` | MSAA (expo-gl `msaaSamples` on iOS) |
|
|
133
|
+
| `maxTextureAnisotropy` | `2` | `1` turns it off |
|
|
134
|
+
| `normalMaps` | `true` | `false` skips clothing normal maps (lighter, slightly flatter fabric) |
|
|
135
|
+
|
|
136
|
+
Slider, color and expression changes reuse the textures already on the GPU: only a new item
|
|
137
|
+
(hair, outfit, skin tone) or a new tier loads files. `onReady` fires once per avatar, after the
|
|
138
|
+
first frame with all textures; it does not fire again on later edits.
|
|
139
|
+
|
|
140
|
+
Recommended: editor screen `textureSize="1k"` (or `'2k'` on tablets), lists and thumbnails
|
|
141
|
+
`'512'` with `pixelRatio={2}`. For option pickers, use the static preview images and swatches
|
|
142
|
+
(`getHumanCatalog`, below) rather than live 3D views: they are small WebP files shown with
|
|
143
|
+
`<Image>`, which uses the platform decoder (WebP is fine there).
|
|
83
144
|
|
|
84
145
|
### 3. React / web
|
|
85
146
|
|
|
@@ -110,7 +171,7 @@ import { HumanEditor, useHumanPackage } from 'humanforge/web';
|
|
|
110
171
|
// React Native (Expo)
|
|
111
172
|
import { HumanEditor, useHumanPackage } from 'humanforge/native';
|
|
112
173
|
|
|
113
|
-
const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.
|
|
174
|
+
const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.5.0/assets/human';
|
|
114
175
|
const { pkg } = useHumanPackage(ASSETS);
|
|
115
176
|
|
|
116
177
|
<HumanEditor
|
|
@@ -135,7 +196,9 @@ const { pkg } = useHumanPackage(ASSETS);
|
|
|
135
196
|
| `sections` | Which tabs to show, in order. |
|
|
136
197
|
| `exclude` / `include` | Hide (or whitelist) whole sections or single controls: `'ears'`, `'hair.color'`, `'nose.noseWidth'`, `'body.weight'`… |
|
|
137
198
|
| `items` | Allowed ids per slot: `hair`, `headwear`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
|
|
138
|
-
| `colors` | Palettes for `hair`, `eyebrows
|
|
199
|
+
| `colors` | Palettes for `hair`, `eyebrows` and `garments` (outfit/headwear fabric). |
|
|
200
|
+
| `skinTones` | Skin tone ids to offer, in order (default: all of `HUMAN_SKIN_TONES`, light to deep). |
|
|
201
|
+
| `featureSliders` | Also show the face-feature mix sliders (african / asian / caucasian morphs). Off by default. |
|
|
139
202
|
| `optional` | Slots that offer "None" (`hair`, `hat`, `headwear`, `eyebrows`, `shoes`). |
|
|
140
203
|
| `detailRange` | Caps the face/body detail sliders (0..1). |
|
|
141
204
|
| `defaults` / `locked` | Starting values / values that are always enforced. |
|
|
@@ -145,7 +208,42 @@ The config is plain JSON; store it as-is. On load, `editor.normalize(saved)` (do
|
|
|
145
208
|
`<HumanEditor>`) repairs anything the current options don't allow. For example, a male outfit in
|
|
146
209
|
a female-only app gets swapped for a women's one. To display the avatar elsewhere, use
|
|
147
210
|
`<HumanCanvas>` (web) or `<HumanView>` (React Native) with the saved config. To build your own UI,
|
|
148
|
-
`createHumanEditor(pkg
|
|
211
|
+
`createHumanEditor(pkg, options)` returns the same sections and controls as data.
|
|
212
|
+
|
|
213
|
+
#### Previews and swatches (no text options)
|
|
214
|
+
|
|
215
|
+
Every pickable option ships with a picture or a color, so your UI never has to print item names:
|
|
216
|
+
|
|
217
|
+
| Attribute (`key`) | Shown as | Source |
|
|
218
|
+
| --- | --- | --- |
|
|
219
|
+
| `skin.tone` | round swatch | 11 tones, light to deep; the texture's age/gender variant follows the avatar automatically |
|
|
220
|
+
| `eyes.color` | round iris image + swatch | `previews/eyes-<id>.webp` |
|
|
221
|
+
| `eyes.lashes`, `brows.style` | thumbnail | close-up renders |
|
|
222
|
+
| `hair.style`, `headwear.style`, `hat.style` | thumbnail | head renders (3/4 view) |
|
|
223
|
+
| `outfit.style` | thumbnail | full-body renders |
|
|
224
|
+
| `shoes.style` | thumbnail | shoe close-ups |
|
|
225
|
+
| `hair.color`, `brows.color`, `outfit.color`, `headwear.color` | round swatch | palettes |
|
|
226
|
+
|
|
227
|
+
Thumbnails are 192 px transparent WebP files in `assets/human/previews/`, so they sit on any
|
|
228
|
+
background. `None` options have `id: null` and no preview (the built-in editors draw a ⌀ icon).
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { getHumanCatalog } from 'humanforge/human';
|
|
232
|
+
|
|
233
|
+
const attrs = getHumanCatalog(pkg, { gender: 'female' }); // same options as <HumanEditor>
|
|
234
|
+
const hair = attrs.find((a) => a.key === 'hair.style')!;
|
|
235
|
+
hair.options; // [{ id: 'long01', label: 'Long straight', preview: 'https://…/previews/hair-long01.webp' }, …]
|
|
236
|
+
hair.value(config); // selected id
|
|
237
|
+
config = hair.apply(config, 'bob02'); // new, normalized config
|
|
238
|
+
|
|
239
|
+
<Image source={{ uri: option.preview }} /> // display 'image'
|
|
240
|
+
<View style={{ backgroundColor: option.swatch }} /> // display 'swatch'
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`label` is only an accessible name (`aria-label` / `accessibilityLabel`); translate it with the
|
|
244
|
+
`labels` option if you show it. The face-feature mix (`african` / `asian` / `caucasian`) is no longer
|
|
245
|
+
shown by default: picking a skin tone only changes color. The selfie fit still sets the mix, and
|
|
246
|
+
`featureSliders: true` brings the sliders back.
|
|
149
247
|
|
|
150
248
|
### 4. Selfie to avatar
|
|
151
249
|
|
|
@@ -271,6 +369,7 @@ codes stay valid.
|
|
|
271
369
|
```bash
|
|
272
370
|
npm install
|
|
273
371
|
npm run build:human -- <mpfb data> <system assets> # see above
|
|
372
|
+
npm run build:previews # option thumbnails + swatches (headless Chrome via puppeteer-core)
|
|
274
373
|
npm test # both modes: every option builds, codec round-trips, human package decodes, selfie solver
|
|
275
374
|
npm run typecheck
|
|
276
375
|
npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humanforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
"build:garments": "tsx tools/build-garments.mts",
|
|
52
52
|
"typecheck": "tsc --noEmit",
|
|
53
53
|
"test": "tsx scripts/smoke.ts && tsx scripts/human-smoke.mts && tsx scripts/fit-test.mts && tsx scripts/editor-test.mts",
|
|
54
|
-
"build:facemap": "tsx tools/build-facemap.mts"
|
|
54
|
+
"build:facemap": "tsx tools/build-facemap.mts",
|
|
55
|
+
"build:previews": "tsx tools/build-previews.mts",
|
|
56
|
+
"build:mobile": "tsx tools/build-mobile-textures.mts"
|
|
55
57
|
},
|
|
56
58
|
"peerDependencies": {
|
|
57
59
|
"@gltf-transform/core": ">=4",
|
|
@@ -103,6 +105,7 @@
|
|
|
103
105
|
"expo-modules-core": "^3.0.30",
|
|
104
106
|
"gl-matrix": "^3.4.4",
|
|
105
107
|
"meshoptimizer": "^1.3.0",
|
|
108
|
+
"puppeteer-core": "^24.43.1",
|
|
106
109
|
"react": "^18.3.1",
|
|
107
110
|
"react-dom": "^18.3.1",
|
|
108
111
|
"react-native": "^0.81.6",
|
package/src/human/catalog.ts
CHANGED
|
@@ -132,6 +132,53 @@ export const HUMAN_HAIR_COLORS: { id: string; label: string; color: string }[] =
|
|
|
132
132
|
/** Skin tint presets (multiplied over the skin texture; white = texture as-is). */
|
|
133
133
|
export const SKIN_TINTS = ['#ffffff', '#fbefe6', '#f5e3d3', '#ffe9dc', '#efe2d6', '#e8d4c4'];
|
|
134
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
|
+
|
|
135
182
|
const words = (s: string) => s.replace(/_/g, ' ').replace(/(\d+)$/, ' $1').replace(/\s+/g, ' ').trim();
|
|
136
183
|
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
137
184
|
|
package/src/human/editor.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
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
16
|
export type SectionId = 'body' | 'face' | 'skin' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'ears' | 'hair' | 'headwear' | 'outfit' | 'shoes' | 'hat';
|
|
@@ -28,7 +30,11 @@ 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[];
|
|
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;
|
|
32
38
|
/** Slots that may be empty ("None" option). Default: hat, headwear and eyebrows. */
|
|
33
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. */
|
|
@@ -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[] }
|
|
@@ -85,7 +106,15 @@ const GENDER_DEFAULTS: Record<'female' | 'male', Partial<HumanConfig>> = {
|
|
|
85
106
|
|
|
86
107
|
const withDetail = (c: HumanConfig, id: string, v: number): HumanConfig => ({ ...c, detail: { ...c.detail, [id]: v } });
|
|
87
108
|
|
|
88
|
-
|
|
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);
|
|
89
118
|
const gender = options.gender ?? 'any';
|
|
90
119
|
const exclude = new Set(options.exclude ?? []);
|
|
91
120
|
const include = options.include ? new Set(options.include) : null;
|
|
@@ -116,7 +145,25 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
|
|
|
116
145
|
|
|
117
146
|
const hairColors = options.colors?.hair ?? HUMAN_HAIR_COLORS.map((h) => h.color);
|
|
118
147
|
const browColors = options.colors?.eyebrows ?? hairColors;
|
|
119
|
-
|
|
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
|
+
};
|
|
120
167
|
const garmentColors = options.colors?.garments ?? GARMENT_COLORS.map((g) => g.color);
|
|
121
168
|
const tintable = (id: string) => !!manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.tintable;
|
|
122
169
|
const defaultColor = (id: string | undefined) => manifest.proxies.find((p) => p.kind === 'clothes' && p.id === id)?.color;
|
|
@@ -134,14 +181,14 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
|
|
|
134
181
|
value: (c) => c.detail[id] ?? 0, apply: (c, v) => withDetail(c, id, v),
|
|
135
182
|
};
|
|
136
183
|
};
|
|
137
|
-
const choice = (id: string, lbl: string, opts: ChoiceOption[], value: ChoiceControl['value'], apply: ChoiceControl['apply']): ChoiceControl =>
|
|
138
|
-
({ 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 });
|
|
139
186
|
const color = (id: string, lbl: string, colors: string[], value: ColorControl['value'], apply: ColorControl['apply']): ColorControl =>
|
|
140
187
|
({ kind: 'color', id, label: label(id, lbl), colors, value, apply });
|
|
141
188
|
const none = (on: boolean): ChoiceOption[] => (on ? [{ id: null, label: label('none', 'None') }] : []);
|
|
142
189
|
const worn = (c: HumanConfig, slot: WearSlot) => c.clothes.find((id) => clothingInfo(id).slot === slot);
|
|
143
190
|
const clothingChoice = (slot: WearSlot, lbl: string, canBeEmpty: boolean) => choice('style', lbl,
|
|
144
|
-
[...none(canBeEmpty), ...allowed[slot].map((id) => ({ id, label: label(id, clothingInfo(id).label) }))],
|
|
191
|
+
[...none(canBeEmpty), ...allowed[slot].map((id) => ({ id, label: label(id, clothingInfo(id).label), preview: proxyPreview('clothes', id) }))],
|
|
145
192
|
(c) => worn(c, slot) ?? null,
|
|
146
193
|
(c, v) => ({ ...c, clothes: [...c.clothes.filter((id) => clothingInfo(id).slot !== slot && !(v && clothingInfo(id).slot === CONFLICTS[slot])), ...(v ? [v] : [])] }));
|
|
147
194
|
/** Fabric color of the tintable garment in a slot (only shown when the slot offers one). */
|
|
@@ -152,23 +199,28 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
|
|
|
152
199
|
|
|
153
200
|
const build: Record<SectionId, () => EditorControl[]> = {
|
|
154
201
|
face: () => [
|
|
155
|
-
...(['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])) : []),
|
|
156
203
|
...DETAIL_SLIDERS.filter((d) => d.group === 'head').map((d) => detail(d.id)),
|
|
157
204
|
],
|
|
158
205
|
skin: () => [
|
|
159
|
-
choice('tone', 'Skin',
|
|
160
|
-
(c) =>
|
|
161
|
-
|
|
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'),
|
|
162
212
|
],
|
|
163
213
|
eyes: () => [
|
|
164
|
-
choice('color', 'Eye color', allowed.eyes.map((id) => ({
|
|
165
|
-
|
|
166
|
-
|
|
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) }))],
|
|
167
219
|
(c) => c.eyelashes, (c, v) => ({ ...c, eyelashes: v })),
|
|
168
220
|
...DETAIL_SLIDERS.filter((d) => d.group === 'eyes' && !BROW_SLIDERS.includes(d.id)).map((d) => detail(d.id)),
|
|
169
221
|
],
|
|
170
222
|
brows: () => [
|
|
171
|
-
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) }))],
|
|
172
224
|
(c) => c.eyebrows, (c, v) => ({ ...c, eyebrows: v })),
|
|
173
225
|
color('color', 'Brow color', browColors, (c) => c.eyebrowColor, (c, v) => ({ ...c, eyebrowColor: v })),
|
|
174
226
|
...BROW_SLIDERS.map(detail),
|
|
@@ -177,7 +229,7 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
|
|
|
177
229
|
mouth: () => DETAIL_SLIDERS.filter((d) => d.group === 'mouth').map((d) => detail(d.id)),
|
|
178
230
|
ears: () => DETAIL_SLIDERS.filter((d) => d.group === 'ears').map((d) => detail(d.id)),
|
|
179
231
|
hair: () => [
|
|
180
|
-
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) }))],
|
|
181
233
|
(c) => c.hair, (c, v) => ({ ...c, hair: v })),
|
|
182
234
|
color('color', 'Hair color', hairColors, (c) => c.hairColor, (c, v) => ({ ...c, hairColor: v })),
|
|
183
235
|
],
|
|
@@ -211,7 +263,8 @@ export function createHumanEditor(manifest: HumanManifest, options: HumanEditorO
|
|
|
211
263
|
const c: HumanConfig = { ...base, ...input, ...options.locked, detail: { ...base.detail, ...input?.detail, ...options.locked?.detail } } as HumanConfig;
|
|
212
264
|
if (gender !== 'any') c.gender = options.locked?.gender ?? (gender === 'female' ? Math.min(c.gender, 0.3) : Math.max(c.gender, 0.7));
|
|
213
265
|
for (const [k, v] of Object.entries(c.detail)) c.detail[k] = Math.max(-range, Math.min(range, v));
|
|
214
|
-
|
|
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;
|
|
215
268
|
c.eyes = pick(c.eyes, allowed.eyes, base.eyes) ?? c.eyes;
|
|
216
269
|
c.hair = c.hair === null && optional.hair ? null : pick(c.hair, allowed.hair, base.hair);
|
|
217
270
|
c.eyebrows = c.eyebrows === null && optional.eyebrows ? null : pick(c.eyebrows, allowed.eyebrows, base.eyebrows);
|
|
@@ -265,3 +318,59 @@ function closestSkin(id: string, allowed: string[], manifest: HumanManifest) {
|
|
|
265
318
|
}
|
|
266
319
|
return best;
|
|
267
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
|
+
}
|