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 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 { pkg } = useHumanPackage('https://cdn.example.com/human');
66
- if (!pkg) return <Loading />;
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
- <HumanView
69
- pkg={pkg}
70
- framing="face" // or "body"
71
- textureSize="1k" // "2k" for the editor screen
72
- config={{ gender: 0.1, age: 0.2, african: 1, asian: 0, caucasian: 0,
73
- skin: 'young_african_female', hair: 'afro01', hairColor: '#1a120c',
74
- detail: { noseWidth: 0.3, eyeSize: 0.2 },
75
- clothes: ['female_casualsuit01', 'shoes01'] }}
76
- expressions={{ mouthSmile: 0.6 }}
77
- style={{ width: 240, height: 240, borderRadius: 120 }}
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
- Files are cached in the app's cache directory, so later launches work offline. A ref gives you
82
- `avatar` (bones, `setExpression`), `render()` and `snapshot()` (PNG file URI).
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.2.0/assets/human';
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`, `skinTint` and `garments` (outfit/headwear fabric). |
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.manifest, options)` returns the same sections and controls as data.
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.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",
@@ -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
 
@@ -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 { EYE_LABELS, EYE_SWATCH, GARMENT_COLORS, HUMAN_HAIR_COLORS, SKIN_TINTS, clothingInfo, fits, hairInfo, skinInfo } from './catalog';
11
+ import {
12
+ EYE_LABELS, EYE_SWATCH, GARMENT_COLORS, HUMAN_HAIR_COLORS, HUMAN_SKIN_TONES, clothingInfo, fits, hairInfo, hexDistance, multiplyHex, skinBase, skinFor, skinInfo,
13
+ } from './catalog';
12
14
 
13
15
  export type EditorGender = 'female' | 'male' | 'any';
14
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[]; skinTint?: string[]; garments?: 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 { id: string | null; label: string; swatch?: string }
47
- export interface ChoiceControl extends Base { kind: 'choice'; options: ChoiceOption[]; value(c: HumanConfig): string | null; apply(c: HumanConfig, v: string | null): HumanConfig }
52
+ export interface ChoiceOption {
53
+ id: string | null;
54
+ /** Accessible name (the built-in editors don't print it when a preview or swatch exists). */
55
+ label: string;
56
+ /** Hex color to show as a round swatch (skin tones, eye colors). */
57
+ swatch?: string;
58
+ /** Absolute URL of a transparent WebP thumbnail (hair, brows, lashes, clothes, eyes). */
59
+ preview?: string;
60
+ }
61
+ export interface ChoiceControl extends Base {
62
+ kind: 'choice';
63
+ /** 'image': tiles with previews; 'swatch': round color swatches. */
64
+ display: 'image' | 'swatch';
65
+ options: ChoiceOption[];
66
+ value(c: HumanConfig): string | null;
67
+ apply(c: HumanConfig, v: string | null): HumanConfig;
68
+ }
48
69
  export interface ColorControl extends Base { kind: 'color'; colors: string[]; value(c: HumanConfig): string; apply(c: HumanConfig, v: string): HumanConfig }
49
70
  export type EditorControl = SliderControl | ChoiceControl | ColorControl;
50
71
  export interface EditorSection { id: SectionId; label: string; framing: 'face' | 'body'; controls: EditorControl[] }
@@ -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
- export function createHumanEditor(manifest: HumanManifest, options: HumanEditorOptions = {}): HumanEditorModel {
109
+ /**
110
+ * Build the editor model. Pass the loaded package (preferred: option previews get absolute URLs)
111
+ * or just its manifest.
112
+ */
113
+ export function createHumanEditor(source: HumanPackage | HumanManifest, options: HumanEditorOptions = {}): HumanEditorModel {
114
+ const manifest: HumanManifest = 'manifest' in source ? source.manifest : source;
115
+ const baseUrl = 'manifest' in source ? source.baseUrl.replace(/\/$/, '') + '/' : '';
116
+ const url = (rel?: string) => (rel ? baseUrl + rel : undefined);
117
+ const proxyPreview = (kind: string, id: string) => url(manifest.proxies.find((p) => p.kind === kind && p.id === id)?.preview);
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
- const tints = options.colors?.skinTint ?? SKIN_TINTS;
148
+ // skin tones: base texture family x tint, previewed as the color it renders
149
+ const skinIds = allowed.skin;
150
+ const baseColor = (base: string) => {
151
+ const cols = manifest.skins.filter((s) => skinBase(s.id) === base && s.swatch && skinIds.includes(s.id)).map((s) => s.swatch!);
152
+ if (!cols.length) return null;
153
+ const avg = [1, 3, 5].map((i) => cols.reduce((a, c) => a + parseInt(c.slice(i, i + 2), 16), 0) / cols.length);
154
+ return '#' + avg.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
155
+ };
156
+ const lum = (h: string) => 0.299 * parseInt(h.slice(1, 3), 16) + 0.587 * parseInt(h.slice(3, 5), 16) + 0.114 * parseInt(h.slice(5, 7), 16);
157
+ const tones = HUMAN_SKIN_TONES
158
+ .filter((t) => (!options.skinTones || options.skinTones.includes(t.id)) && skinIds.some((id) => skinBase(id) === t.base))
159
+ .map((t) => ({ ...t, swatch: multiplyHex(baseColor(t.base) ?? '#c9a58a', t.tint) }));
160
+ if (!options.skinTones) tones.sort((a, b) => lum(b.swatch) - lum(a.swatch));
161
+ const toneOf = (c: HumanConfig) => {
162
+ const base = skinBase(c.skin);
163
+ const same = tones.filter((t) => t.base === base);
164
+ const pool = same.length ? same : tones;
165
+ return pool.reduce((best, t) => (hexDistance(t.tint, c.skinTint) < hexDistance(best.tint, c.skinTint) ? t : best), pool[0]);
166
+ };
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', allowed.skin.map((id) => ({ id, label: label(id, skinInfo(id, manifest.skins.find((s) => s.id === id)?.tags ?? []).label) })),
160
- (c) => c.skin, (c, v) => ({ ...c, skin: v ?? c.skin })),
161
- color('tint', 'Tint', tints, (c) => c.skinTint, (c, v) => ({ ...c, skinTint: v })),
206
+ choice('tone', 'Skin tone', tones.map((t) => ({ id: t.id, label: label(t.id, t.id.charAt(0).toUpperCase() + t.id.slice(1)), swatch: t.swatch })),
207
+ (c) => toneOf(c)?.id ?? null,
208
+ (c, v) => {
209
+ const t = tones.find((x) => x.id === v);
210
+ return t ? { ...c, skin: skinFor(skinIds, t.base, c.age, c.gender) ?? c.skin, skinTint: t.tint } : c;
211
+ }, 'swatch'),
162
212
  ],
163
213
  eyes: () => [
164
- choice('color', 'Eye color', allowed.eyes.map((id) => ({ id, label: label(id, EYE_LABELS[id] ?? id), swatch: EYE_SWATCH[id] })),
165
- (c) => c.eyes, (c, v) => ({ ...c, eyes: v ?? c.eyes })),
166
- choice('lashes', 'Lashes', [...none(true), ...allowed.eyelashes.map((id, i) => ({ id, label: `${i + 1}` }))],
214
+ choice('color', 'Eye color', allowed.eyes.map((id) => ({
215
+ id, label: label(id, EYE_LABELS[id] ?? id),
216
+ swatch: manifest.eyeOptions?.[id]?.swatch ?? EYE_SWATCH[id], preview: url(manifest.eyeOptions?.[id]?.preview),
217
+ })), (c) => c.eyes, (c, v) => ({ ...c, eyes: v ?? c.eyes }), 'swatch'),
218
+ choice('lashes', 'Lashes', [...none(true), ...allowed.eyelashes.map((id, i) => ({ id, label: label(id, `Lashes ${i + 1}`), preview: proxyPreview('eyelashes', id) }))],
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: `${i + 1}` }))],
223
+ choice('style', 'Brows', [...none(optional.eyebrows), ...allowed.eyebrows.map((id, i) => ({ id, label: label(id, `Brows ${i + 1}`), preview: proxyPreview('eyebrows', id) }))],
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
- c.skin = pick(c.skin, allowed.skin, closestSkin(c.skin, allowed.skin, manifest) ?? base.skin) ?? c.skin;
266
+ // keep the chosen tone family, but follow the avatar's age and gender with the texture variant
267
+ c.skin = skinFor(allowed.skin, skinBase(c.skin), c.age, c.gender) ?? pick(c.skin, allowed.skin, closestSkin(c.skin, allowed.skin, manifest) ?? base.skin) ?? c.skin;
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
+ }