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 +42 -3
- package/package.json +4 -2
- package/src/human/catalog.ts +47 -0
- package/src/human/editor.ts +129 -20
- package/src/human/runtime.ts +6 -1
- package/src/native/HumanEditor.tsx +29 -10
- package/src/web/HumanEditor.tsx +33 -12
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.
|
|
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
|
|
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
|
|
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
|
+
"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",
|
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
|
+
}
|
package/src/human/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
35
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
|
|
36
36
|
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
37
37
|
const config = value ? editor.normalize(value) : inner;
|
|
38
38
|
const [tab, setTab] = useState(0);
|
|
@@ -110,15 +110,31 @@ function Control({ ctl, config, onChange, accent }: { ctl: EditorControl; config
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
const v = ctl.value(config);
|
|
113
|
+
if (ctl.display === 'swatch') {
|
|
114
|
+
return (
|
|
115
|
+
<View style={s.block}>
|
|
116
|
+
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
117
|
+
<View style={s.wrap}>
|
|
118
|
+
{ctl.options.map((o) => (
|
|
119
|
+
<Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
120
|
+
style={[s.swatch, { backgroundColor: o.swatch ?? '#eee' }, o.id === v && { borderColor: accent }]}>
|
|
121
|
+
{o.preview ? <Image source={{ uri: o.preview }} style={s.swatchImg} /> : null}
|
|
122
|
+
</Pressable>
|
|
123
|
+
))}
|
|
124
|
+
</View>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
113
128
|
return (
|
|
114
129
|
<View style={s.block}>
|
|
115
130
|
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
116
131
|
<View style={s.wrap}>
|
|
117
132
|
{ctl.options.map((o) => (
|
|
118
|
-
<Pressable key={o.id ?? 'none'} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
119
|
-
style={[s.
|
|
120
|
-
{o.
|
|
121
|
-
|
|
133
|
+
<Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
134
|
+
style={[s.tile, o.id === v && { borderColor: accent, borderWidth: 2, backgroundColor: '#fff' }]}>
|
|
135
|
+
{o.id === null ? <View style={s.none}><View style={s.noneBar} /></View>
|
|
136
|
+
: o.preview ? <Image source={{ uri: o.preview }} style={s.tileImg} resizeMode="contain" />
|
|
137
|
+
: <Text style={s.choiceText}>{o.label}</Text>}
|
|
122
138
|
</Pressable>
|
|
123
139
|
))}
|
|
124
140
|
</View>
|
|
@@ -173,10 +189,13 @@ const s = StyleSheet.create({
|
|
|
173
189
|
block: { marginVertical: 10 },
|
|
174
190
|
blockLabel: { fontSize: 13, fontWeight: '600', color: '#333', marginBottom: 8 },
|
|
175
191
|
wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
tile: { width: 76, height: 76, borderRadius: 12, borderWidth: 1, borderColor: '#e1e3e7', backgroundColor: '#f4f5f7', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', padding: 3 },
|
|
193
|
+
tileImg: { width: '100%', height: '100%' },
|
|
194
|
+
none: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: '#9aa0a8', alignItems: 'center', justifyContent: 'center' },
|
|
195
|
+
noneBar: { width: 30, height: 2, backgroundColor: '#9aa0a8', transform: [{ rotate: '-45deg' }] },
|
|
196
|
+
choiceText: { fontSize: 11, color: '#555', textAlign: 'center' },
|
|
197
|
+
swatch: { width: 36, height: 36, borderRadius: 18, borderWidth: 3, borderColor: '#fff', overflow: 'hidden', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
|
|
198
|
+
swatchImg: { width: '100%', height: '100%' },
|
|
180
199
|
slider: { flex: 1, height: 32, justifyContent: 'center' },
|
|
181
200
|
track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
|
|
182
201
|
fill: { height: 4 },
|
package/src/web/HumanEditor.tsx
CHANGED
|
@@ -35,7 +35,7 @@ export function HumanEditor({
|
|
|
35
35
|
pkg, options, editor: given, value, defaultValue, onChange, onSave, saveLabel = 'Save', selfie = false, randomize = true,
|
|
36
36
|
accentColor = '#1d1d1f', background = '#e9ecf1', className, style,
|
|
37
37
|
}: HumanEditorProps) {
|
|
38
|
-
const editor = useMemo(() => given ?? createHumanEditor(pkg
|
|
38
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
|
|
39
39
|
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
40
40
|
const config = value ? editor.normalize(value) : inner;
|
|
41
41
|
const [tab, setTab] = useState(0);
|
|
@@ -136,14 +136,27 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
|
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
const v = ctl.value(config);
|
|
139
|
+
if (ctl.display === 'swatch') {
|
|
140
|
+
return (
|
|
141
|
+
<div className="hf-block">
|
|
142
|
+
<span className="hf-label">{ctl.label}</span>
|
|
143
|
+
<div className="hf-swatches">
|
|
144
|
+
{ctl.options.map((o) => (
|
|
145
|
+
<button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-swatch ${o.id === v ? 'on' : ''}`}
|
|
146
|
+
style={{ background: o.preview ? `center/cover url("${o.preview}") ${o.swatch ?? ''}` : o.swatch ?? '#eee' }}
|
|
147
|
+
onClick={() => onChange(ctl.apply(config, o.id))} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
139
153
|
return (
|
|
140
154
|
<div className="hf-block">
|
|
141
155
|
<span className="hf-label">{ctl.label}</span>
|
|
142
|
-
<div className="hf-
|
|
156
|
+
<div className="hf-tiles">
|
|
143
157
|
{ctl.options.map((o) => (
|
|
144
|
-
<button key={o.id ?? 'none'} className={`hf-
|
|
145
|
-
{o.
|
|
146
|
-
{o.label}
|
|
158
|
+
<button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-tile ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
|
|
159
|
+
{o.id === null ? <NoneIcon /> : o.preview ? <img src={o.preview} alt="" loading="lazy" draggable={false} /> : <span>{o.label}</span>}
|
|
147
160
|
</button>
|
|
148
161
|
))}
|
|
149
162
|
</div>
|
|
@@ -151,6 +164,14 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
|
|
|
151
164
|
);
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
function NoneIcon() {
|
|
168
|
+
return (
|
|
169
|
+
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" fill="none" stroke="#9aa0a8" strokeWidth="1.8">
|
|
170
|
+
<circle cx="12" cy="12" r="9" /><path d="M5.6 18.4 18.4 5.6" />
|
|
171
|
+
</svg>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
154
175
|
const CSS = `
|
|
155
176
|
.hf-root{--hf-accent:#1d1d1f;container-type:inline-size;height:100%;min-height:480px}
|
|
156
177
|
.hf-editor{display:grid;grid-template-columns:minmax(0,1fr) 360px;height:100%;min-height:480px;font:13px/1.4 system-ui,-apple-system,sans-serif;color:#1d1d1f;background:#fff;border-radius:16px;overflow:hidden}
|
|
@@ -174,13 +195,13 @@ const CSS = `
|
|
|
174
195
|
.hf-label{font-size:12px;color:#666}
|
|
175
196
|
.hf-block{margin:12px 0}
|
|
176
197
|
.hf-block>.hf-label{display:block;margin-bottom:8px;font-weight:600;color:#333}
|
|
177
|
-
.hf-
|
|
178
|
-
.hf-
|
|
179
|
-
.hf-
|
|
180
|
-
.hf-
|
|
181
|
-
.hf-
|
|
182
|
-
.hf-swatches{display:flex;flex-wrap:wrap;gap:
|
|
183
|
-
.hf-swatch{width:
|
|
198
|
+
.hf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:8px}
|
|
199
|
+
.hf-tile{aspect-ratio:1;display:grid;place-items:center;border:1px solid #e1e3e7;background:#f4f5f7;border-radius:12px;padding:4px;cursor:pointer;overflow:hidden;font:inherit;font-size:11px;color:#555}
|
|
200
|
+
.hf-tile:hover{border-color:#c5c8ce}
|
|
201
|
+
.hf-tile.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 2px var(--hf-accent);background:#fff}
|
|
202
|
+
.hf-tile img{width:100%;height:100%;object-fit:contain;pointer-events:none}
|
|
203
|
+
.hf-swatches{display:flex;flex-wrap:wrap;gap:10px}
|
|
204
|
+
.hf-swatch{width:34px;height:34px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
|
|
184
205
|
.hf-swatch.on{box-shadow:0 0 0 2px var(--hf-accent)}
|
|
185
206
|
.hf-save{margin:12px 16px 16px;border:0;border-radius:12px;padding:12px;background:var(--hf-accent);color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
|
186
207
|
@container (max-width:720px){.hf-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(260px,52%) minmax(0,1fr)}.hf-panel{border-left:0;border-top:1px solid #eceef1}}
|