humanforge 0.3.0 → 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/README.md CHANGED
@@ -32,6 +32,7 @@ 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)
35
36
  ```
36
37
 
37
38
  Leave out the `--extra` packs for the system set only (~14 MB). Every item loads on demand, so
@@ -110,7 +111,7 @@ import { HumanEditor, useHumanPackage } from 'humanforge/web';
110
111
  // React Native (Expo)
111
112
  import { HumanEditor, useHumanPackage } from 'humanforge/native';
112
113
 
113
- const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.2.0/assets/human';
114
+ const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.4.0/assets/human';
114
115
  const { pkg } = useHumanPackage(ASSETS);
115
116
 
116
117
  <HumanEditor
@@ -135,7 +136,9 @@ const { pkg } = useHumanPackage(ASSETS);
135
136
  | `sections` | Which tabs to show, in order. |
136
137
  | `exclude` / `include` | Hide (or whitelist) whole sections or single controls: `'ears'`, `'hair.color'`, `'nose.noseWidth'`, `'body.weight'`… |
137
138
  | `items` | Allowed ids per slot: `hair`, `headwear`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
138
- | `colors` | Palettes for `hair`, `eyebrows`, `skinTint` and `garments` (outfit/headwear fabric). |
139
+ | `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. |
139
142
  | `optional` | Slots that offer "None" (`hair`, `hat`, `headwear`, `eyebrows`, `shoes`). |
140
143
  | `detailRange` | Caps the face/body detail sliders (0..1). |
141
144
  | `defaults` / `locked` | Starting values / values that are always enforced. |
@@ -145,7 +148,42 @@ The config is plain JSON; store it as-is. On load, `editor.normalize(saved)` (do
145
148
  `<HumanEditor>`) repairs anything the current options don't allow. For example, a male outfit in
146
149
  a female-only app gets swapped for a women's one. To display the avatar elsewhere, use
147
150
  `<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.
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.
149
187
 
150
188
  ### 4. Selfie to avatar
151
189
 
@@ -271,6 +309,7 @@ codes stay valid.
271
309
  ```bash
272
310
  npm install
273
311
  npm run build:human -- <mpfb data> <system assets> # see above
312
+ npm run build:previews # option thumbnails + swatches (headless Chrome via puppeteer-core)
274
313
  npm test # both modes: every option builds, codec round-trips, human package decodes, selfie solver
275
314
  npm run typecheck
276
315
  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.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",
@@ -51,7 +51,8 @@
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"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "@gltf-transform/core": ">=4",
@@ -103,6 +104,7 @@
103
104
  "expo-modules-core": "^3.0.30",
104
105
  "gl-matrix": "^3.4.4",
105
106
  "meshoptimizer": "^1.3.0",
107
+ "puppeteer-core": "^24.43.1",
106
108
  "react": "^18.3.1",
107
109
  "react-dom": "^18.3.1",
108
110
  "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
+ }
@@ -63,6 +63,8 @@ export interface ProxyMeta {
63
63
  roughness?: number;
64
64
  /** Texture coordinates are in tiles (repeat wrapping). */
65
65
  tileable?: boolean;
66
+ /** Transparent WebP thumbnail, relative to the package root (tools/build-previews.mts). */
67
+ preview?: string;
66
68
  }
67
69
  export interface HumanManifest {
68
70
  vertexCount: number;
@@ -74,8 +76,11 @@ export interface HumanManifest {
74
76
  };
75
77
  bones: { name: string; parent: number; head: number[]; tail: number[] }[];
76
78
  proxies: ProxyMeta[];
77
- skins: { id: string; tags: string[] }[];
79
+ /** `swatch`: the skin tone as rendered (hex), from tools/build-previews.mts. */
80
+ skins: { id: string; tags: string[]; swatch?: string }[];
78
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 }>;
79
84
  textureSizes: { skin1k: number; skin2k: number; eyes: number };
80
85
  expressions: Record<string, string>;
81
86
  }
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // <HumanEditor pkg={pkg} options={{ gender: 'female', exclude: ['ears'] }} onSave={(config, png) => save(config)} />
5
5
  import React, { useMemo, useRef, useState } from 'react';
6
- import { LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
6
+ import { Image, LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
7
7
  import type { HumanConfig, HumanPackage } from '../human/runtime';
8
8
  import { EditorControl, HumanEditorModel as EditorModel, HumanEditorOptions, createHumanEditor } from '../human/editor';
9
9
  import { HumanView, HumanViewHandle } from './HumanView';
@@ -32,7 +32,7 @@ export function HumanEditor({
32
32
  pkg, options, editor: given, value, defaultValue, onChange, onSave, onCancel, saveLabel = 'Save', randomize = true,
33
33
  accentColor = '#1d1d1f', background = '#e9ecf1', style,
34
34
  }: HumanEditorProps) {
35
- const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
35
+ const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
36
36
  const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
37
37
  const config = value ? editor.normalize(value) : inner;
38
38
  const [tab, setTab] = useState(0);
@@ -110,15 +110,31 @@ function Control({ ctl, config, onChange, accent }: { ctl: EditorControl; config
110
110
  );
111
111
  }
112
112
  const v = ctl.value(config);
113
+ if (ctl.display === 'swatch') {
114
+ return (
115
+ <View style={s.block}>
116
+ <Text style={s.blockLabel}>{ctl.label}</Text>
117
+ <View style={s.wrap}>
118
+ {ctl.options.map((o) => (
119
+ <Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
120
+ style={[s.swatch, { backgroundColor: o.swatch ?? '#eee' }, o.id === v && { borderColor: accent }]}>
121
+ {o.preview ? <Image source={{ uri: o.preview }} style={s.swatchImg} /> : null}
122
+ </Pressable>
123
+ ))}
124
+ </View>
125
+ </View>
126
+ );
127
+ }
113
128
  return (
114
129
  <View style={s.block}>
115
130
  <Text style={s.blockLabel}>{ctl.label}</Text>
116
131
  <View style={s.wrap}>
117
132
  {ctl.options.map((o) => (
118
- <Pressable key={o.id ?? 'none'} onPress={() => onChange(ctl.apply(config, o.id))}
119
- style={[s.choice, o.id === v && { borderColor: accent, borderWidth: 2 }]}>
120
- {o.swatch ? <View style={[s.dot, { backgroundColor: o.swatch }]} /> : null}
121
- <Text style={[s.choiceText, o.id === v && { fontWeight: '700' }]}>{o.label}</Text>
133
+ <Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
134
+ style={[s.tile, o.id === v && { borderColor: accent, borderWidth: 2, backgroundColor: '#fff' }]}>
135
+ {o.id === null ? <View style={s.none}><View style={s.noneBar} /></View>
136
+ : o.preview ? <Image source={{ uri: o.preview }} style={s.tileImg} resizeMode="contain" />
137
+ : <Text style={s.choiceText}>{o.label}</Text>}
122
138
  </Pressable>
123
139
  ))}
124
140
  </View>
@@ -173,10 +189,13 @@ const s = StyleSheet.create({
173
189
  block: { marginVertical: 10 },
174
190
  blockLabel: { fontSize: 13, fontWeight: '600', color: '#333', marginBottom: 8 },
175
191
  wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
176
- choice: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderColor: '#e1e3e7', borderRadius: 10, paddingHorizontal: 12, paddingVertical: 9, minWidth: 64, justifyContent: 'center' },
177
- choiceText: { fontSize: 12, color: '#1d1d1f' },
178
- dot: { width: 12, height: 12, borderRadius: 6 },
179
- swatch: { width: 32, height: 32, borderRadius: 16, borderWidth: 3, borderColor: '#fff', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
192
+ tile: { width: 76, height: 76, borderRadius: 12, borderWidth: 1, borderColor: '#e1e3e7', backgroundColor: '#f4f5f7', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', padding: 3 },
193
+ tileImg: { width: '100%', height: '100%' },
194
+ none: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: '#9aa0a8', alignItems: 'center', justifyContent: 'center' },
195
+ noneBar: { width: 30, height: 2, backgroundColor: '#9aa0a8', transform: [{ rotate: '-45deg' }] },
196
+ choiceText: { fontSize: 11, color: '#555', textAlign: 'center' },
197
+ swatch: { width: 36, height: 36, borderRadius: 18, borderWidth: 3, borderColor: '#fff', overflow: 'hidden', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
198
+ swatchImg: { width: '100%', height: '100%' },
180
199
  slider: { flex: 1, height: 32, justifyContent: 'center' },
181
200
  track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
182
201
  fill: { height: 4 },
@@ -35,7 +35,7 @@ export function HumanEditor({
35
35
  pkg, options, editor: given, value, defaultValue, onChange, onSave, saveLabel = 'Save', selfie = false, randomize = true,
36
36
  accentColor = '#1d1d1f', background = '#e9ecf1', className, style,
37
37
  }: HumanEditorProps) {
38
- const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
38
+ const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
39
39
  const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
40
40
  const config = value ? editor.normalize(value) : inner;
41
41
  const [tab, setTab] = useState(0);
@@ -136,14 +136,27 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
136
136
  );
137
137
  }
138
138
  const v = ctl.value(config);
139
+ if (ctl.display === 'swatch') {
140
+ return (
141
+ <div className="hf-block">
142
+ <span className="hf-label">{ctl.label}</span>
143
+ <div className="hf-swatches">
144
+ {ctl.options.map((o) => (
145
+ <button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-swatch ${o.id === v ? 'on' : ''}`}
146
+ style={{ background: o.preview ? `center/cover url("${o.preview}") ${o.swatch ?? ''}` : o.swatch ?? '#eee' }}
147
+ onClick={() => onChange(ctl.apply(config, o.id))} />
148
+ ))}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
139
153
  return (
140
154
  <div className="hf-block">
141
155
  <span className="hf-label">{ctl.label}</span>
142
- <div className="hf-choices">
156
+ <div className="hf-tiles">
143
157
  {ctl.options.map((o) => (
144
- <button key={o.id ?? 'none'} className={`hf-choice ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
145
- {o.swatch && <i style={{ background: o.swatch }} />}
146
- {o.label}
158
+ <button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-tile ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
159
+ {o.id === null ? <NoneIcon /> : o.preview ? <img src={o.preview} alt="" loading="lazy" draggable={false} /> : <span>{o.label}</span>}
147
160
  </button>
148
161
  ))}
149
162
  </div>
@@ -151,6 +164,14 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
151
164
  );
152
165
  }
153
166
 
167
+ function NoneIcon() {
168
+ return (
169
+ <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" fill="none" stroke="#9aa0a8" strokeWidth="1.8">
170
+ <circle cx="12" cy="12" r="9" /><path d="M5.6 18.4 18.4 5.6" />
171
+ </svg>
172
+ );
173
+ }
174
+
154
175
  const CSS = `
155
176
  .hf-root{--hf-accent:#1d1d1f;container-type:inline-size;height:100%;min-height:480px}
156
177
  .hf-editor{display:grid;grid-template-columns:minmax(0,1fr) 360px;height:100%;min-height:480px;font:13px/1.4 system-ui,-apple-system,sans-serif;color:#1d1d1f;background:#fff;border-radius:16px;overflow:hidden}
@@ -174,13 +195,13 @@ const CSS = `
174
195
  .hf-label{font-size:12px;color:#666}
175
196
  .hf-block{margin:12px 0}
176
197
  .hf-block>.hf-label{display:block;margin-bottom:8px;font-weight:600;color:#333}
177
- .hf-choices{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:6px}
178
- .hf-choice{display:flex;align-items:center;gap:6px;justify-content:center;border:1px solid #e1e3e7;background:#fff;border-radius:10px;padding:9px 8px;font:inherit;font-size:12px;cursor:pointer;text-align:center}
179
- .hf-choice:hover{border-color:#c5c8ce}
180
- .hf-choice.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 1px var(--hf-accent);font-weight:600}
181
- .hf-choice i{width:12px;height:12px;border-radius:50%;flex:none}
182
- .hf-swatches{display:flex;flex-wrap:wrap;gap:8px}
183
- .hf-swatch{width:28px;height:28px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
198
+ .hf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:8px}
199
+ .hf-tile{aspect-ratio:1;display:grid;place-items:center;border:1px solid #e1e3e7;background:#f4f5f7;border-radius:12px;padding:4px;cursor:pointer;overflow:hidden;font:inherit;font-size:11px;color:#555}
200
+ .hf-tile:hover{border-color:#c5c8ce}
201
+ .hf-tile.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 2px var(--hf-accent);background:#fff}
202
+ .hf-tile img{width:100%;height:100%;object-fit:contain;pointer-events:none}
203
+ .hf-swatches{display:flex;flex-wrap:wrap;gap:10px}
204
+ .hf-swatch{width:34px;height:34px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
184
205
  .hf-swatch.on{box-shadow:0 0 0 2px var(--hf-accent)}
185
206
  .hf-save{margin:12px 16px 16px;border:0;border-radius:12px;padding:12px;background:var(--hf-accent);color:#fff;font:inherit;font-weight:600;cursor:pointer}
186
207
  @container (max-width:720px){.hf-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(260px,52%) minmax(0,1fr)}.hf-panel{border-left:0;border-top:1px solid #eceef1}}