humanforge 0.1.0 → 0.2.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 +53 -2
- package/package.json +12 -5
- package/src/human/catalog.ts +95 -0
- package/src/human/editor.ts +249 -0
- package/src/human/export.ts +4 -1
- package/src/human/index.ts +2 -0
- package/src/human/runtime.ts +38 -5
- package/src/native/HumanEditor.tsx +186 -0
- package/src/native/index.ts +2 -0
- package/src/web/HumanCanvas.tsx +146 -0
- package/src/web/HumanEditor.tsx +198 -0
- package/src/web/index.ts +29 -0
package/README.md
CHANGED
|
@@ -76,6 +76,55 @@ const glb = await exportHumanGLB(avatar); // Mixamo-rigged, ARKit blendshapes
|
|
|
76
76
|
|
|
77
77
|
`demo/human.html` is a complete web editor (`npm run demo`, then open `/human.html`).
|
|
78
78
|
|
|
79
|
+
### Avatar editor: one config, web and React Native
|
|
80
|
+
|
|
81
|
+
`<HumanEditor>` is a drop-in customization screen with live 3D preview, section tabs (face,
|
|
82
|
+
skin, eyes, brows, nose, mouth, ears, hair, body, outfit, shoes, hat), randomize and save. The
|
|
83
|
+
same `options` object decides what your users may change on both platforms:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
// React web
|
|
87
|
+
import { HumanEditor, useHumanPackage } from 'humanforge/web';
|
|
88
|
+
// React Native (Expo)
|
|
89
|
+
import { HumanEditor, useHumanPackage } from 'humanforge/native';
|
|
90
|
+
|
|
91
|
+
const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.2.0/assets/human';
|
|
92
|
+
const { pkg } = useHumanPackage(ASSETS);
|
|
93
|
+
|
|
94
|
+
<HumanEditor
|
|
95
|
+
pkg={pkg}
|
|
96
|
+
defaultValue={savedAvatar} // the JSON you stored last time (optional)
|
|
97
|
+
options={{
|
|
98
|
+
gender: 'female', // 'female' | 'male' | 'any'
|
|
99
|
+
sections: ['face', 'eyes', 'nose', 'mouth', 'hair', 'outfit'],
|
|
100
|
+
exclude: ['ears', 'nose.noseHump', 'body.cupSize'],
|
|
101
|
+
items: { hair: ['long01', 'ponytail01', 'bob02'] },
|
|
102
|
+
colors: { hair: ['#1a1412', '#3d2618', '#6b4428', '#e6c88a'] },
|
|
103
|
+
detailRange: 0.7, // keep faces natural
|
|
104
|
+
locked: { eyes: 'brownlight' }, // never editable
|
|
105
|
+
}}
|
|
106
|
+
onSave={(config, snapshot) => api.saveAvatar(config)}
|
|
107
|
+
/>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Option | What it does |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `gender` | `'female'` or `'male'` locks the gender, hides the gender slider, and filters hair, clothes and skins to ones that suit it. `'any'` shows everything. |
|
|
113
|
+
| `sections` | Which tabs to show, in order. |
|
|
114
|
+
| `exclude` / `include` | Hide (or whitelist) whole sections or single controls: `'ears'`, `'hair.color'`, `'nose.noseWidth'`, `'body.weight'`… |
|
|
115
|
+
| `items` | Allowed ids per slot: `hair`, `outfit`, `shoes`, `hat`, `skin`, `eyes`, `eyebrows`, `eyelashes`. |
|
|
116
|
+
| `colors` | Palettes for `hair`, `eyebrows` and `skinTint`. |
|
|
117
|
+
| `optional` | Slots that offer "None" (`hair`, `hat`, `eyebrows`, `shoes`). |
|
|
118
|
+
| `detailRange` | Caps the face/body detail sliders (0..1). |
|
|
119
|
+
| `defaults` / `locked` | Starting values / values that are always enforced. |
|
|
120
|
+
| `labels` | Rename any section, control or item (for translation). |
|
|
121
|
+
|
|
122
|
+
The config is plain JSON; store it as-is. On load, `editor.normalize(saved)` (done for you by
|
|
123
|
+
`<HumanEditor>`) repairs anything the current options don't allow. For example, a male outfit in
|
|
124
|
+
a female-only app gets swapped for a women's one. To display the avatar elsewhere, use
|
|
125
|
+
`<HumanCanvas>` (web) or `<HumanView>` (React Native) with the saved config. To build your own UI,
|
|
126
|
+
`createHumanEditor(pkg.manifest, options)` returns the same sections and controls as data.
|
|
127
|
+
|
|
79
128
|
### 4. Selfie to avatar
|
|
80
129
|
|
|
81
130
|
`fitSelfie` turns a front-facing photo into a `HumanConfig` patch. It sets the ethnicity mix, about
|
|
@@ -208,13 +257,15 @@ npm run demo # http://localhost:5173 (stylized) and /human.html (realistic)
|
|
|
208
257
|
|
|
209
258
|
- **Verified:** the asset build, the decoder and smoke tests, and the web runtime (shape sliders,
|
|
210
259
|
skins, hair, clothes, rig posing, expressions, GLB export), visually in Chrome.
|
|
211
|
-
- **Not yet run on a device:** the React Native components (`HumanView`, `AvatarView`,
|
|
260
|
+
- **Not yet run on a device:** the React Native components (`HumanView`, `HumanEditor`, `AvatarView`,
|
|
212
261
|
`AvatarCreator`). They typecheck against RN 0.81, expo-gl 16 and expo-file-system 19, and use
|
|
213
262
|
the standard expo-gl texture path (`{ localUri }`). Test on one iOS and one Android device before
|
|
214
263
|
shipping.
|
|
215
264
|
- **Selfie fit:** verified in Chrome with public-domain portraits (web path). The React Native
|
|
216
265
|
landmark path is documented but not implemented. See "Selfie to avatar" for its limits.
|
|
217
|
-
- **Wardrobe:** hairstyles and clothes are limited to the CC0 MakeHuman system packs
|
|
266
|
+
- **Wardrobe:** hairstyles and clothes are limited to the CC0 MakeHuman system packs (10 hairstyles,
|
|
267
|
+
12 outfits, 6 shoes). They are dated next to commercial avatar SDKs: hair is rendered with soft
|
|
268
|
+
two-pass cards, but the meshes themselves are 2014-era. More CC0
|
|
218
269
|
packs, or your own `.mhclo` assets, can be added through `tools/build-human.ts`. There's no hijab
|
|
219
270
|
or kufi in the realistic set yet.
|
|
220
271
|
- **Hair quality:** hair uses alpha-tested cards, so the edges are crisper with MSAA (`antialias: true`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humanforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"./core": "./src/core/index.ts",
|
|
36
36
|
"./human": "./src/human/index.ts",
|
|
37
37
|
"./human/export": "./src/human/export.ts",
|
|
38
|
-
"./human/selfie": "./src/human/selfie.ts"
|
|
38
|
+
"./human/selfie": "./src/human/selfie.ts",
|
|
39
|
+
"./web": "./src/web/index.ts",
|
|
40
|
+
"./native": "./src/native/index.ts"
|
|
39
41
|
},
|
|
40
42
|
"files": [
|
|
41
43
|
"src",
|
|
@@ -47,19 +49,19 @@
|
|
|
47
49
|
"demo": "vite",
|
|
48
50
|
"build:human": "tsx tools/build-human.ts",
|
|
49
51
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"test": "tsx scripts/smoke.ts && tsx scripts/human-smoke.mts && tsx scripts/fit-test.mts",
|
|
52
|
+
"test": "tsx scripts/smoke.ts && tsx scripts/human-smoke.mts && tsx scripts/fit-test.mts && tsx scripts/editor-test.mts",
|
|
51
53
|
"build:facemap": "tsx tools/build-facemap.mts"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
54
56
|
"@gltf-transform/core": ">=4",
|
|
55
57
|
"@gltf-transform/extensions": ">=4",
|
|
56
58
|
"@gltf-transform/functions": ">=4",
|
|
59
|
+
"@mediapipe/tasks-vision": ">=0.10",
|
|
57
60
|
"expo-file-system": ">=18",
|
|
58
61
|
"expo-gl": ">=13",
|
|
59
62
|
"react": ">=18",
|
|
60
63
|
"react-native": ">=0.72",
|
|
61
|
-
"three": ">=0.160"
|
|
62
|
-
"@mediapipe/tasks-vision": ">=0.10"
|
|
64
|
+
"three": ">=0.160"
|
|
63
65
|
},
|
|
64
66
|
"peerDependenciesMeta": {
|
|
65
67
|
"expo-gl": {
|
|
@@ -82,6 +84,9 @@
|
|
|
82
84
|
},
|
|
83
85
|
"@mediapipe/tasks-vision": {
|
|
84
86
|
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"react-dom": {
|
|
89
|
+
"optional": true
|
|
85
90
|
}
|
|
86
91
|
},
|
|
87
92
|
"devDependencies": {
|
|
@@ -90,6 +95,7 @@
|
|
|
90
95
|
"@gltf-transform/functions": "^4.5.1",
|
|
91
96
|
"@mediapipe/tasks-vision": "^1.1.1",
|
|
92
97
|
"@types/react": "^18.3.12",
|
|
98
|
+
"@types/react-dom": "^18.3.1",
|
|
93
99
|
"@types/three": "^0.186.0",
|
|
94
100
|
"expo-file-system": "^19.0.24",
|
|
95
101
|
"expo-gl": "^16.0.10",
|
|
@@ -97,6 +103,7 @@
|
|
|
97
103
|
"gl-matrix": "^3.4.4",
|
|
98
104
|
"meshoptimizer": "^1.3.0",
|
|
99
105
|
"react": "^18.3.1",
|
|
106
|
+
"react-dom": "^18.3.1",
|
|
100
107
|
"react-native": "^0.81.6",
|
|
101
108
|
"sharp": "^0.35.5",
|
|
102
109
|
"three": "^0.186.1",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Item metadata for the editor: display names, which gender an item suits, and slot.
|
|
2
|
+
// Ids match the asset package manifest (pkg.manifest). Items missing here still work;
|
|
3
|
+
// they get a generated label and are treated as unisex.
|
|
4
|
+
|
|
5
|
+
export type Fit = 'female' | 'male' | 'unisex';
|
|
6
|
+
export type ClothingSlot = 'outfit' | 'shoes' | 'hat';
|
|
7
|
+
|
|
8
|
+
export interface ItemInfo { label: string; fit: Fit }
|
|
9
|
+
|
|
10
|
+
export const HAIR: Record<string, ItemInfo> = {
|
|
11
|
+
long01: { label: 'Long straight', fit: 'female' },
|
|
12
|
+
ponytail01: { label: 'Ponytail', fit: 'female' },
|
|
13
|
+
bob02: { label: 'Bob', fit: 'female' },
|
|
14
|
+
bob01: { label: 'Swept bob', fit: 'female' },
|
|
15
|
+
braid01: { label: 'Braid', fit: 'female' },
|
|
16
|
+
afro01: { label: 'Afro', fit: 'unisex' },
|
|
17
|
+
short01: { label: 'Short textured', fit: 'unisex' },
|
|
18
|
+
short02: { label: 'Short tousled', fit: 'male' },
|
|
19
|
+
short03: { label: 'Side fringe', fit: 'unisex' },
|
|
20
|
+
short04: { label: 'Slicked back', fit: 'male' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const CLOTHES: Record<string, ItemInfo & { slot: ClothingSlot }> = {
|
|
24
|
+
female_casualsuit01: { label: 'Casual tee', fit: 'female', slot: 'outfit' },
|
|
25
|
+
female_casualsuit02: { label: 'Casual layers', fit: 'female', slot: 'outfit' },
|
|
26
|
+
female_elegantsuit01: { label: 'Elegant', fit: 'female', slot: 'outfit' },
|
|
27
|
+
female_sportsuit01: { label: 'Sport', fit: 'female', slot: 'outfit' },
|
|
28
|
+
male_casualsuit01: { label: 'Casual 1', fit: 'male', slot: 'outfit' },
|
|
29
|
+
male_casualsuit02: { label: 'Casual 2', fit: 'male', slot: 'outfit' },
|
|
30
|
+
male_casualsuit03: { label: 'Casual 3', fit: 'male', slot: 'outfit' },
|
|
31
|
+
male_casualsuit04: { label: 'Casual 4', fit: 'male', slot: 'outfit' },
|
|
32
|
+
male_casualsuit05: { label: 'Casual 5', fit: 'male', slot: 'outfit' },
|
|
33
|
+
male_casualsuit06: { label: 'Casual 6', fit: 'male', slot: 'outfit' },
|
|
34
|
+
male_elegantsuit01: { label: 'Suit', fit: 'male', slot: 'outfit' },
|
|
35
|
+
male_worksuit01: { label: 'Workwear', fit: 'male', slot: 'outfit' },
|
|
36
|
+
shoes01: { label: 'Shoes 1', fit: 'unisex', slot: 'shoes' },
|
|
37
|
+
shoes02: { label: 'Shoes 2', fit: 'unisex', slot: 'shoes' },
|
|
38
|
+
shoes03: { label: 'Shoes 3', fit: 'unisex', slot: 'shoes' },
|
|
39
|
+
shoes04: { label: 'Shoes 4', fit: 'unisex', slot: 'shoes' },
|
|
40
|
+
shoes05: { label: 'Shoes 5', fit: 'unisex', slot: 'shoes' },
|
|
41
|
+
shoes06: { label: 'Shoes 6', fit: 'unisex', slot: 'shoes' },
|
|
42
|
+
fedora01: { label: 'Fedora', fit: 'unisex', slot: 'hat' },
|
|
43
|
+
fedora_cocked: { label: 'Fedora (tilted)', fit: 'unisex', slot: 'hat' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const EYE_LABELS: Record<string, string> = {
|
|
47
|
+
brown: 'Dark brown', brownlight: 'Brown', green: 'Green', blue: 'Blue', deepblue: 'Deep blue',
|
|
48
|
+
lightblue: 'Light blue', bluegreen: 'Blue-green', grey: 'Grey', ice: 'Ice',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Swatch colors for eye chips (approximate iris color). */
|
|
52
|
+
export const EYE_SWATCH: Record<string, string> = {
|
|
53
|
+
brown: '#3b1d0e', brownlight: '#6b4a22', green: '#3f6a3a', blue: '#2f5f74', deepblue: '#38376a',
|
|
54
|
+
lightblue: '#6aa3b8', bluegreen: '#5f9a8a', grey: '#86928a', ice: '#9ab8a8',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Natural hair colors, as the tint multiplied over the grey hair textures (they average ~70%
|
|
59
|
+
* brightness, so tints are a bit lighter than the visible result).
|
|
60
|
+
*/
|
|
61
|
+
export const HUMAN_HAIR_COLORS: { id: string; label: string; color: string }[] = [
|
|
62
|
+
{ id: 'black', label: 'Black', color: '#1a1412' },
|
|
63
|
+
{ id: 'darkbrown', label: 'Dark brown', color: '#3d2618' },
|
|
64
|
+
{ id: 'brown', label: 'Brown', color: '#6b4428' },
|
|
65
|
+
{ id: 'lightbrown', label: 'Light brown', color: '#9a6a40' },
|
|
66
|
+
{ id: 'auburn', label: 'Auburn', color: '#8e3b1f' },
|
|
67
|
+
{ id: 'copper', label: 'Copper', color: '#c0602c' },
|
|
68
|
+
{ id: 'darkblonde', label: 'Dark blonde', color: '#b88c55' },
|
|
69
|
+
{ id: 'blonde', label: 'Blonde', color: '#e6c88a' },
|
|
70
|
+
{ id: 'platinum', label: 'Platinum', color: '#f2ead8' },
|
|
71
|
+
{ id: 'grey', label: 'Grey', color: '#a8a39c' },
|
|
72
|
+
{ id: 'white', label: 'White', color: '#f4f2ee' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Skin tint presets (multiplied over the skin texture; white = texture as-is). */
|
|
76
|
+
export const SKIN_TINTS = ['#ffffff', '#fbefe6', '#f5e3d3', '#ffe9dc', '#efe2d6', '#e8d4c4'];
|
|
77
|
+
|
|
78
|
+
const words = (s: string) => s.replace(/_/g, ' ').replace(/(\d+)$/, ' $1').replace(/\s+/g, ' ').trim();
|
|
79
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
80
|
+
|
|
81
|
+
export function hairInfo(id: string): ItemInfo { return HAIR[id] ?? { label: cap(words(id)), fit: 'unisex' }; }
|
|
82
|
+
export function clothingInfo(id: string): ItemInfo & { slot: ClothingSlot } {
|
|
83
|
+
if (CLOTHES[id]) return CLOTHES[id];
|
|
84
|
+
const slot: ClothingSlot = id.startsWith('shoes') ? 'shoes' : /hat|fedora|cap|helmet/.test(id) ? 'hat' : 'outfit';
|
|
85
|
+
const fit: Fit = id.startsWith('female') ? 'female' : id.startsWith('male') ? 'male' : 'unisex';
|
|
86
|
+
return { label: cap(words(id.replace(/^(fe)?male_/, ''))), fit, slot };
|
|
87
|
+
}
|
|
88
|
+
export function skinInfo(id: string, tags: string[]): ItemInfo {
|
|
89
|
+
const fit: Fit = tags.some((t) => t.startsWith('female')) ? 'female' : tags.some((t) => t.startsWith('male')) ? 'male' : 'unisex';
|
|
90
|
+
const age = tags.includes('young') ? 'Young' : tags.includes('middleage') ? 'Middle-aged' : tags.includes('old') ? 'Older' : '';
|
|
91
|
+
const eth = tags.includes('african') ? 'African' : tags.includes('asian') ? 'Asian' : tags.includes('caucasian') ? 'European' : '';
|
|
92
|
+
const variant = /2$/.test(id) ? ' 2' : '';
|
|
93
|
+
return { label: [age, eth].filter(Boolean).join(' ') + variant || cap(words(id)), fit };
|
|
94
|
+
}
|
|
95
|
+
export const fits = (fit: Fit, gender: 'female' | 'male' | 'any') => gender === 'any' || fit === 'unisex' || fit === gender;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Platform-neutral avatar editor model. It turns the asset manifest plus developer options
|
|
2
|
+
// (gender lock, which sections/attributes/items are editable) into a list of sections and
|
|
3
|
+
// controls that the web and React Native <HumanEditor> components render.
|
|
4
|
+
//
|
|
5
|
+
// const editor = createHumanEditor(pkg.manifest, { gender: 'female', exclude: ['ears', 'body.muscle'] });
|
|
6
|
+
// editor.sections -> [{ id: 'face', label: 'Face', controls: [...] }, ...]
|
|
7
|
+
// editor.initial -> a valid HumanConfig that respects the options
|
|
8
|
+
// editor.normalize(savedConfig) -> clamps/repairs any config to the options (use on load)
|
|
9
|
+
import { DEFAULT_HUMAN, HumanConfig, HumanManifest } from './runtime';
|
|
10
|
+
import { DETAIL_SLIDERS, MACRO_SLIDERS } from './sliders';
|
|
11
|
+
import { EYE_LABELS, EYE_SWATCH, HUMAN_HAIR_COLORS, SKIN_TINTS, clothingInfo, fits, hairInfo, skinInfo } from './catalog';
|
|
12
|
+
|
|
13
|
+
export type EditorGender = 'female' | 'male' | 'any';
|
|
14
|
+
export type SectionId = 'body' | 'face' | 'skin' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'ears' | 'hair' | 'outfit' | 'shoes' | 'hat';
|
|
15
|
+
export type ItemSlot = 'hair' | 'outfit' | 'shoes' | 'hat' | 'skin' | 'eyes' | 'eyebrows' | 'eyelashes';
|
|
16
|
+
|
|
17
|
+
export const ALL_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'body', 'outfit', 'shoes', 'hat'];
|
|
18
|
+
|
|
19
|
+
export interface HumanEditorOptions {
|
|
20
|
+
/** Lock the avatar to one gender: hides the gender slider and filters hair, clothes and skins. Default 'any'. */
|
|
21
|
+
gender?: EditorGender;
|
|
22
|
+
/** Sections to show, in order. Default ALL_SECTIONS. */
|
|
23
|
+
sections?: SectionId[];
|
|
24
|
+
/** Section ids or control ids to hide, e.g. ['ears', 'body.muscle', 'nose.noseHump', 'hair.color']. */
|
|
25
|
+
exclude?: string[];
|
|
26
|
+
/** If set, only these control ids are shown (sections without any are dropped). */
|
|
27
|
+
include?: string[];
|
|
28
|
+
/** Allowed item ids per slot (default: everything that fits the gender). */
|
|
29
|
+
items?: Partial<Record<ItemSlot, string[]>>;
|
|
30
|
+
/** Color palettes. */
|
|
31
|
+
colors?: { hair?: string[]; eyebrows?: string[]; skinTint?: string[] };
|
|
32
|
+
/** Slots that may be empty ("None" option). Default: hat and eyebrows. */
|
|
33
|
+
optional?: Partial<Record<'hair' | 'hat' | 'eyebrows' | 'shoes', boolean>>;
|
|
34
|
+
/** Max magnitude of face/body detail sliders (0..1). Lower keeps faces more natural. Default 1. */
|
|
35
|
+
detailRange?: number;
|
|
36
|
+
/** Starting values (merged over the gender's default look). */
|
|
37
|
+
defaults?: Partial<HumanConfig>;
|
|
38
|
+
/** Values always enforced, whatever the user or a saved config says. */
|
|
39
|
+
locked?: Partial<HumanConfig>;
|
|
40
|
+
/** Label overrides by section or control id (e.g. for translation). */
|
|
41
|
+
labels?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Base { id: string; label: string }
|
|
45
|
+
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 }
|
|
48
|
+
export interface ColorControl extends Base { kind: 'color'; colors: string[]; value(c: HumanConfig): string; apply(c: HumanConfig, v: string): HumanConfig }
|
|
49
|
+
export type EditorControl = SliderControl | ChoiceControl | ColorControl;
|
|
50
|
+
export interface EditorSection { id: SectionId; label: string; framing: 'face' | 'body'; controls: EditorControl[] }
|
|
51
|
+
|
|
52
|
+
export interface HumanEditorModel {
|
|
53
|
+
options: HumanEditorOptions;
|
|
54
|
+
sections: EditorSection[];
|
|
55
|
+
/** Starting config that satisfies the options. */
|
|
56
|
+
initial: HumanConfig;
|
|
57
|
+
/** Repair any (saved or partial) config so it satisfies the options. */
|
|
58
|
+
normalize(c?: Partial<HumanConfig> | null): HumanConfig;
|
|
59
|
+
/** A random look within the allowed items and natural slider ranges. */
|
|
60
|
+
randomize(rand?: () => number): HumanConfig;
|
|
61
|
+
/** Allowed item ids per slot after gender filtering. */
|
|
62
|
+
allowed: Record<ItemSlot, string[]>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SECTION_LABELS: Record<SectionId, string> = {
|
|
66
|
+
body: 'Body', face: 'Face', skin: 'Skin', eyes: 'Eyes', brows: 'Brows', nose: 'Nose', mouth: 'Mouth', ears: 'Ears',
|
|
67
|
+
hair: 'Hair', outfit: 'Outfit', shoes: 'Shoes', hat: 'Hat',
|
|
68
|
+
};
|
|
69
|
+
const FACE_SECTIONS: SectionId[] = ['face', 'skin', 'eyes', 'brows', 'nose', 'mouth', 'ears', 'hair', 'hat'];
|
|
70
|
+
const BROW_SLIDERS = ['browHeight', 'browAngle', 'browDepth'];
|
|
71
|
+
|
|
72
|
+
const GENDER_DEFAULTS: Record<'female' | 'male', Partial<HumanConfig>> = {
|
|
73
|
+
female: {
|
|
74
|
+
gender: 0, skin: 'young_caucasian_female', hair: 'long01', hairColor: '#6b4428', eyebrows: 'eyebrow002', eyebrowColor: '#3d2618',
|
|
75
|
+
eyelashes: 'eyelashes02', clothes: ['female_casualsuit02', 'shoes02'],
|
|
76
|
+
},
|
|
77
|
+
male: {
|
|
78
|
+
gender: 1, skin: 'young_caucasian_male', hair: 'short02', hairColor: '#3d2618', eyebrows: 'eyebrow001', eyebrowColor: '#2a1c14',
|
|
79
|
+
eyelashes: 'eyelashes01', clothes: ['male_casualsuit02', 'shoes01'],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const withDetail = (c: HumanConfig, id: string, v: number): HumanConfig => ({ ...c, detail: { ...c.detail, [id]: v } });
|
|
84
|
+
|
|
85
|
+
export function createHumanEditor(manifest: HumanManifest, options: HumanEditorOptions = {}): HumanEditorModel {
|
|
86
|
+
const gender = options.gender ?? 'any';
|
|
87
|
+
const exclude = new Set(options.exclude ?? []);
|
|
88
|
+
const include = options.include ? new Set(options.include) : null;
|
|
89
|
+
const range = Math.min(1, Math.max(0, options.detailRange ?? 1));
|
|
90
|
+
const label = (id: string, fallback: string) => options.labels?.[id] ?? fallback;
|
|
91
|
+
const optional = { hat: true, eyebrows: true, hair: false, shoes: false, ...options.optional };
|
|
92
|
+
|
|
93
|
+
// ---- allowed items
|
|
94
|
+
const restrict = (slot: ItemSlot, ids: string[]) => {
|
|
95
|
+
const only = options.items?.[slot];
|
|
96
|
+
return only ? ids.filter((id) => only.includes(id)) : ids;
|
|
97
|
+
};
|
|
98
|
+
const clothes = manifest.proxies.filter((p) => p.kind === 'clothes').map((p) => p.id);
|
|
99
|
+
const bySlot = (slot: 'outfit' | 'shoes' | 'hat') => clothes.filter((id) => clothingInfo(id).slot === slot && fits(clothingInfo(id).fit, gender));
|
|
100
|
+
const allowed: Record<ItemSlot, string[]> = {
|
|
101
|
+
hair: restrict('hair', manifest.proxies.filter((p) => p.kind === 'hair').map((p) => p.id).filter((id) => fits(hairInfo(id).fit, gender))),
|
|
102
|
+
outfit: restrict('outfit', bySlot('outfit')),
|
|
103
|
+
shoes: restrict('shoes', bySlot('shoes')),
|
|
104
|
+
hat: restrict('hat', bySlot('hat')),
|
|
105
|
+
skin: restrict('skin', manifest.skins.filter((s) => fits(skinInfo(s.id, s.tags).fit, gender)).map((s) => s.id)),
|
|
106
|
+
eyes: restrict('eyes', manifest.eyes),
|
|
107
|
+
eyebrows: restrict('eyebrows', manifest.proxies.filter((p) => p.kind === 'eyebrows').map((p) => p.id)),
|
|
108
|
+
eyelashes: restrict('eyelashes', manifest.proxies.filter((p) => p.kind === 'eyelashes').map((p) => p.id)),
|
|
109
|
+
};
|
|
110
|
+
// order hair by the catalog (best-looking first), then anything extra
|
|
111
|
+
allowed.hair.sort((a, b) => order(a) - order(b));
|
|
112
|
+
|
|
113
|
+
const hairColors = options.colors?.hair ?? HUMAN_HAIR_COLORS.map((h) => h.color);
|
|
114
|
+
const browColors = options.colors?.eyebrows ?? hairColors;
|
|
115
|
+
const tints = options.colors?.skinTint ?? SKIN_TINTS;
|
|
116
|
+
|
|
117
|
+
// ---- controls
|
|
118
|
+
const shown = (section: SectionId, id: string) => !exclude.has(section) && !exclude.has(`${section}.${id}`) && !exclude.has(id) && (!include || include.has(`${section}.${id}`) || include.has(id));
|
|
119
|
+
const macro = (id: (typeof MACRO_SLIDERS)[number]['id'], lbl: string, min = 0, max = 1): SliderControl => ({
|
|
120
|
+
kind: 'slider', id, label: label(id, lbl), min, max, step: 0.01,
|
|
121
|
+
value: (c) => c[id], apply: (c, v) => ({ ...c, [id]: v }),
|
|
122
|
+
});
|
|
123
|
+
const detail = (id: string): SliderControl => {
|
|
124
|
+
const s = DETAIL_SLIDERS.find((d) => d.id === id)!;
|
|
125
|
+
return {
|
|
126
|
+
kind: 'slider', id, label: label(id, s.label), min: s.neg.length ? -range : 0, max: range, step: 0.01,
|
|
127
|
+
value: (c) => c.detail[id] ?? 0, apply: (c, v) => withDetail(c, id, v),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
const choice = (id: string, lbl: string, opts: ChoiceOption[], value: ChoiceControl['value'], apply: ChoiceControl['apply']): ChoiceControl =>
|
|
131
|
+
({ kind: 'choice', id, label: label(id, lbl), options: opts, value, apply });
|
|
132
|
+
const color = (id: string, lbl: string, colors: string[], value: ColorControl['value'], apply: ColorControl['apply']): ColorControl =>
|
|
133
|
+
({ kind: 'color', id, label: label(id, lbl), colors, value, apply });
|
|
134
|
+
const none = (on: boolean): ChoiceOption[] => (on ? [{ id: null, label: label('none', 'None') }] : []);
|
|
135
|
+
const clothingChoice = (slot: 'outfit' | 'shoes' | 'hat', lbl: string, canBeEmpty: boolean) => choice('style', lbl,
|
|
136
|
+
[...none(canBeEmpty), ...allowed[slot].map((id) => ({ id, label: label(id, clothingInfo(id).label) }))],
|
|
137
|
+
(c) => c.clothes.find((id) => clothingInfo(id).slot === slot) ?? null,
|
|
138
|
+
(c, v) => ({ ...c, clothes: [...c.clothes.filter((id) => clothingInfo(id).slot !== slot), ...(v ? [v] : [])] }));
|
|
139
|
+
|
|
140
|
+
const build: Record<SectionId, () => EditorControl[]> = {
|
|
141
|
+
face: () => [
|
|
142
|
+
...(['african', 'asian', 'caucasian'] as const).map((k) => macro(k, { african: 'African features', asian: 'Asian features', caucasian: 'European features' }[k])),
|
|
143
|
+
...DETAIL_SLIDERS.filter((d) => d.group === 'head').map((d) => detail(d.id)),
|
|
144
|
+
],
|
|
145
|
+
skin: () => [
|
|
146
|
+
choice('tone', 'Skin', allowed.skin.map((id) => ({ id, label: label(id, skinInfo(id, manifest.skins.find((s) => s.id === id)?.tags ?? []).label) })),
|
|
147
|
+
(c) => c.skin, (c, v) => ({ ...c, skin: v ?? c.skin })),
|
|
148
|
+
color('tint', 'Tint', tints, (c) => c.skinTint, (c, v) => ({ ...c, skinTint: v })),
|
|
149
|
+
],
|
|
150
|
+
eyes: () => [
|
|
151
|
+
choice('color', 'Eye color', allowed.eyes.map((id) => ({ id, label: label(id, EYE_LABELS[id] ?? id), swatch: EYE_SWATCH[id] })),
|
|
152
|
+
(c) => c.eyes, (c, v) => ({ ...c, eyes: v ?? c.eyes })),
|
|
153
|
+
choice('lashes', 'Lashes', [...none(true), ...allowed.eyelashes.map((id, i) => ({ id, label: `${i + 1}` }))],
|
|
154
|
+
(c) => c.eyelashes, (c, v) => ({ ...c, eyelashes: v })),
|
|
155
|
+
...DETAIL_SLIDERS.filter((d) => d.group === 'eyes' && !BROW_SLIDERS.includes(d.id)).map((d) => detail(d.id)),
|
|
156
|
+
],
|
|
157
|
+
brows: () => [
|
|
158
|
+
choice('style', 'Brows', [...none(optional.eyebrows), ...allowed.eyebrows.map((id, i) => ({ id, label: `${i + 1}` }))],
|
|
159
|
+
(c) => c.eyebrows, (c, v) => ({ ...c, eyebrows: v })),
|
|
160
|
+
color('color', 'Brow color', browColors, (c) => c.eyebrowColor, (c, v) => ({ ...c, eyebrowColor: v })),
|
|
161
|
+
...BROW_SLIDERS.map(detail),
|
|
162
|
+
],
|
|
163
|
+
nose: () => DETAIL_SLIDERS.filter((d) => d.group === 'nose').map((d) => detail(d.id)),
|
|
164
|
+
mouth: () => DETAIL_SLIDERS.filter((d) => d.group === 'mouth').map((d) => detail(d.id)),
|
|
165
|
+
ears: () => DETAIL_SLIDERS.filter((d) => d.group === 'ears').map((d) => detail(d.id)),
|
|
166
|
+
hair: () => [
|
|
167
|
+
choice('style', 'Hairstyle', [...none(optional.hair), ...allowed.hair.map((id) => ({ id, label: label(id, hairInfo(id).label) }))],
|
|
168
|
+
(c) => c.hair, (c, v) => ({ ...c, hair: v })),
|
|
169
|
+
color('color', 'Hair color', hairColors, (c) => c.hairColor, (c, v) => ({ ...c, hairColor: v })),
|
|
170
|
+
],
|
|
171
|
+
body: () => [
|
|
172
|
+
...(gender === 'any' ? [macro('gender', 'Gender')] : []),
|
|
173
|
+
macro('age', 'Age'), macro('weight', 'Weight'), macro('muscle', 'Muscle'), macro('height', 'Height'), macro('proportions', 'Proportions'),
|
|
174
|
+
...(gender !== 'male' ? [macro('cupSize', 'Bust'), macro('firmness', 'Firmness')] : []),
|
|
175
|
+
...DETAIL_SLIDERS.filter((d) => d.group === 'body').map((d) => detail(d.id)),
|
|
176
|
+
],
|
|
177
|
+
outfit: () => [clothingChoice('outfit', 'Outfit', false)],
|
|
178
|
+
shoes: () => [clothingChoice('shoes', 'Shoes', optional.shoes)],
|
|
179
|
+
hat: () => [clothingChoice('hat', 'Hat', optional.hat)],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const sections: EditorSection[] = [];
|
|
183
|
+
for (const id of options.sections ?? ALL_SECTIONS) {
|
|
184
|
+
if (exclude.has(id)) continue;
|
|
185
|
+
const controls = build[id]().filter((ctl) => shown(id, ctl.id)).filter((ctl) => ctl.kind !== 'choice' || ctl.options.length > 1 || ctl.options[0]?.id !== null);
|
|
186
|
+
if ((id === 'outfit' || id === 'shoes' || id === 'hat') && !allowed[id].length) continue;
|
|
187
|
+
if (controls.length) sections.push({ id, label: label(id, SECTION_LABELS[id]), framing: FACE_SECTIONS.includes(id) ? 'face' : 'body', controls });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- normalize: defaults <- input <- locked, then repair anything not allowed
|
|
191
|
+
const genderDefaults = gender === 'any' ? {} : GENDER_DEFAULTS[gender];
|
|
192
|
+
const base: HumanConfig = { ...DEFAULT_HUMAN, ...genderDefaults, ...options.defaults, detail: { ...DEFAULT_HUMAN.detail, ...options.defaults?.detail } } as HumanConfig;
|
|
193
|
+
const pick = (want: string | null | undefined, ids: string[], fallback: string | null | undefined) =>
|
|
194
|
+
want && ids.includes(want) ? want : fallback && ids.includes(fallback) ? fallback : ids[0] ?? null;
|
|
195
|
+
|
|
196
|
+
function normalize(input?: Partial<HumanConfig> | null): HumanConfig {
|
|
197
|
+
const c: HumanConfig = { ...base, ...input, ...options.locked, detail: { ...base.detail, ...input?.detail, ...options.locked?.detail } } as HumanConfig;
|
|
198
|
+
if (gender !== 'any') c.gender = options.locked?.gender ?? (gender === 'female' ? Math.min(c.gender, 0.3) : Math.max(c.gender, 0.7));
|
|
199
|
+
for (const [k, v] of Object.entries(c.detail)) c.detail[k] = Math.max(-range, Math.min(range, v));
|
|
200
|
+
c.skin = pick(c.skin, allowed.skin, closestSkin(c.skin, allowed.skin, manifest) ?? base.skin) ?? c.skin;
|
|
201
|
+
c.eyes = pick(c.eyes, allowed.eyes, base.eyes) ?? c.eyes;
|
|
202
|
+
c.hair = c.hair === null && optional.hair ? null : pick(c.hair, allowed.hair, base.hair);
|
|
203
|
+
c.eyebrows = c.eyebrows === null && optional.eyebrows ? null : pick(c.eyebrows, allowed.eyebrows, base.eyebrows);
|
|
204
|
+
c.eyelashes = c.eyelashes === null ? null : pick(c.eyelashes, allowed.eyelashes, base.eyelashes);
|
|
205
|
+
const wear = (slot: 'outfit' | 'shoes' | 'hat', canBeEmpty: boolean) => {
|
|
206
|
+
const cur = c.clothes.find((id) => clothingInfo(id).slot === slot);
|
|
207
|
+
const fallback = base.clothes.find((id) => clothingInfo(id).slot === slot);
|
|
208
|
+
if (!cur && canBeEmpty) return [];
|
|
209
|
+
const v = pick(cur, allowed[slot], fallback);
|
|
210
|
+
return v ? [v] : [];
|
|
211
|
+
};
|
|
212
|
+
c.clothes = [...wear('outfit', false), ...wear('shoes', optional.shoes), ...wear('hat', true)];
|
|
213
|
+
return c;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function randomize(rand: () => number = Math.random): HumanConfig {
|
|
217
|
+
const any = <T,>(a: T[]) => a[Math.floor(rand() * a.length)];
|
|
218
|
+
const c = normalize();
|
|
219
|
+
const mix = [rand(), rand(), rand()];
|
|
220
|
+
const sum = mix[0] + mix[1] + mix[2];
|
|
221
|
+
const detail: Record<string, number> = {};
|
|
222
|
+
for (const d of DETAIL_SLIDERS) if (d.group !== 'body') detail[d.id] = d.neg.length ? (rand() * 2 - 1) * 0.35 * range : rand() * 0.25 * range;
|
|
223
|
+
return normalize({
|
|
224
|
+
...c, detail,
|
|
225
|
+
african: mix[0] / sum, asian: mix[1] / sum, caucasian: mix[2] / sum,
|
|
226
|
+
age: rand() * 0.45, weight: 0.35 + rand() * 0.3, muscle: 0.35 + rand() * 0.3, height: 0.3 + rand() * 0.4,
|
|
227
|
+
skin: any(allowed.skin) ?? c.skin, eyes: any(allowed.eyes) ?? c.eyes,
|
|
228
|
+
hair: any(allowed.hair) ?? c.hair, hairColor: any(hairColors), eyebrows: any(allowed.eyebrows) ?? c.eyebrows,
|
|
229
|
+
clothes: [any(allowed.outfit), any(allowed.shoes)].filter(Boolean) as string[],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { options, sections, initial: normalize(), normalize, randomize, allowed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const HAIR_ORDER = ['long01', 'ponytail01', 'bob02', 'afro01', 'short02', 'short01', 'short04', 'bob01', 'braid01', 'short03'];
|
|
237
|
+
const order = (id: string) => { const i = HAIR_ORDER.indexOf(id); return i < 0 ? 100 : i; };
|
|
238
|
+
|
|
239
|
+
/** Same age/ethnicity skin among the allowed ones (used when gender filtering removes the current skin). */
|
|
240
|
+
function closestSkin(id: string, allowed: string[], manifest: HumanManifest) {
|
|
241
|
+
const tags = manifest.skins.find((s) => s.id === id)?.tags ?? [];
|
|
242
|
+
let best: string | null = null, score = -1;
|
|
243
|
+
for (const a of allowed) {
|
|
244
|
+
const t = manifest.skins.find((s) => s.id === a)?.tags ?? [];
|
|
245
|
+
const sc = t.filter((x) => tags.includes(x)).length;
|
|
246
|
+
if (sc > score) { score = sc; best = a; }
|
|
247
|
+
}
|
|
248
|
+
return best;
|
|
249
|
+
}
|
package/src/human/export.ts
CHANGED
|
@@ -23,7 +23,10 @@ export async function exportHumanGLB(avatar: HumanAvatar): Promise<Uint8Array> {
|
|
|
23
23
|
const alpha = mat.transparent || mat.alphaTest > 0;
|
|
24
24
|
for (const t of [mat.map, mat.normalMap]) if (t) t.userData.mimeType = alpha ? 'image/png' : 'image/jpeg';
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
// the hair's soft-tip pass is a render-time duplicate; export the mesh once
|
|
27
|
+
const tips: SkinnedMesh[] = [];
|
|
28
|
+
avatar.group.traverse((o) => { if (o.userData.softPass && o.visible) { tips.push(o as SkinnedMesh); o.visible = false; } });
|
|
29
|
+
const raw = (await new GLTFExporter().parseAsync(avatar.group, { binary: true, onlyVisible: true }).finally(() => tips.forEach((t) => (t.visible = true)))) as ArrayBuffer;
|
|
27
30
|
const io = new WebIO().registerExtensions([KHRMeshQuantization]);
|
|
28
31
|
const doc = await io.readBinary(new Uint8Array(raw));
|
|
29
32
|
// quantize first: it rewrites accessors, which would drop the sparse flag set by sparse()
|
package/src/human/index.ts
CHANGED
package/src/human/runtime.ts
CHANGED
|
@@ -236,7 +236,19 @@ function geometryFrom(prim: GlbPrimitive, joints: string, weights: string) {
|
|
|
236
236
|
return g;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Hair cards: the texture's transparent texels are near-white, which shows as a light halo
|
|
241
|
+
* around strands once alpha is blended. Darken texels by their coverage so tips fade naturally.
|
|
242
|
+
*/
|
|
243
|
+
function hairShading(m: MeshStandardMaterial) {
|
|
244
|
+
m.onBeforeCompile = (sh) => {
|
|
245
|
+
sh.fragmentShader = sh.fragmentShader.replace('#include <map_fragment>',
|
|
246
|
+
'#include <map_fragment>\n\tdiffuseColor.rgb *= mix(0.35, 1.0, smoothstep(0.05, 0.85, diffuseColor.a));');
|
|
247
|
+
};
|
|
248
|
+
m.customProgramCacheKey = () => 'humanforge-hair';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; soft?: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array }
|
|
240
252
|
|
|
241
253
|
export class HumanAvatar {
|
|
242
254
|
readonly group = new Group();
|
|
@@ -325,13 +337,14 @@ export class HumanAvatar {
|
|
|
325
337
|
this.removeProxy(entry[0]);
|
|
326
338
|
this.proxies.set(entry[0], entry[1]);
|
|
327
339
|
this.group.add(entry[1].mesh);
|
|
340
|
+
if (entry[1].soft) this.group.add(entry[1].soft);
|
|
328
341
|
}
|
|
329
342
|
this.skinMat.map = skinTex;
|
|
330
343
|
this.skinMat.color.set(c.skinTint);
|
|
331
344
|
this.skinMat.needsUpdate = true;
|
|
332
345
|
for (const [slot, p] of this.proxies) {
|
|
333
346
|
const m = p.mesh.material as MeshStandardMaterial;
|
|
334
|
-
if (slot === 'hair') m.color.set(c.hairColor);
|
|
347
|
+
if (slot === 'hair') { m.color.set(c.hairColor); (p.soft?.material as MeshStandardMaterial | undefined)?.color.set(c.hairColor); }
|
|
335
348
|
if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
|
|
336
349
|
if (slot === 'eyes' && m.map !== eyeTex) { m.map = eyeTex; m.needsUpdate = true; }
|
|
337
350
|
}
|
|
@@ -386,9 +399,25 @@ export class HumanAvatar {
|
|
|
386
399
|
roughness: meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8,
|
|
387
400
|
side: meta.doubleSided ? DoubleSide : FrontSide,
|
|
388
401
|
});
|
|
402
|
+
let soft: SkinnedMesh | undefined;
|
|
389
403
|
if (meta.kind === 'hair') {
|
|
390
|
-
|
|
404
|
+
// Two passes: a solid core (alpha-tested, writes depth, so strands sort correctly) and a
|
|
405
|
+
// blended pass for the soft strand tips the cutout would otherwise chop off.
|
|
406
|
+
mat.alphaTest = 0.5;
|
|
391
407
|
mat.alphaToCoverage = true;
|
|
408
|
+
mat.side = DoubleSide;
|
|
409
|
+
hairShading(mat);
|
|
410
|
+
const tips = mat.clone();
|
|
411
|
+
hairShading(tips);
|
|
412
|
+
tips.alphaTest = 0.04;
|
|
413
|
+
tips.alphaToCoverage = false;
|
|
414
|
+
tips.transparent = true;
|
|
415
|
+
tips.depthWrite = false;
|
|
416
|
+
soft = new SkinnedMesh(geo, tips);
|
|
417
|
+
soft.name = `${fullId}-tips`;
|
|
418
|
+
soft.userData.softPass = true;
|
|
419
|
+
soft.frustumCulled = false;
|
|
420
|
+
soft.renderOrder = 3;
|
|
392
421
|
} else if (meta.transparent) {
|
|
393
422
|
mat.transparent = true;
|
|
394
423
|
mat.depthWrite = false;
|
|
@@ -403,7 +432,7 @@ export class HumanAvatar {
|
|
|
403
432
|
mesh.morphTargetDictionary = this.body.morphTargetDictionary;
|
|
404
433
|
mesh.morphTargetInfluences = [...(this.body.morphTargetInfluences ?? [])];
|
|
405
434
|
}
|
|
406
|
-
return { meta, mesh, refs, refw, offs };
|
|
435
|
+
return { meta, mesh, soft, refs, refw, offs };
|
|
407
436
|
}
|
|
408
437
|
|
|
409
438
|
private removeProxy(slot: string) {
|
|
@@ -412,6 +441,7 @@ export class HumanAvatar {
|
|
|
412
441
|
this.group.remove(p.mesh);
|
|
413
442
|
p.mesh.geometry.dispose();
|
|
414
443
|
(p.mesh.material as Material).dispose();
|
|
444
|
+
if (p.soft) { this.group.remove(p.soft); (p.soft.material as Material).dispose(); }
|
|
415
445
|
this.proxies.delete(slot);
|
|
416
446
|
}
|
|
417
447
|
|
|
@@ -488,7 +518,10 @@ export class HumanAvatar {
|
|
|
488
518
|
});
|
|
489
519
|
this.group.updateMatrixWorld(true);
|
|
490
520
|
this.body.bind(this.skeleton); // recomputes bone inverses for the new rest pose
|
|
491
|
-
for (const p of this.proxies.values())
|
|
521
|
+
for (const p of this.proxies.values()) {
|
|
522
|
+
p.mesh.bind(this.skeleton, this.body.bindMatrix);
|
|
523
|
+
p.soft?.bind(this.skeleton, this.body.bindMatrix);
|
|
524
|
+
}
|
|
492
525
|
}
|
|
493
526
|
|
|
494
527
|
/** Head height and total height (world space at rest), for camera framing. */
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// <HumanEditor> for React Native (Expo): the same developer-controlled editor as the web one,
|
|
2
|
+
// built only from React Native primitives (no slider/UI dependencies).
|
|
3
|
+
//
|
|
4
|
+
// <HumanEditor pkg={pkg} options={{ gender: 'female', exclude: ['ears'] }} onSave={(config, png) => save(config)} />
|
|
5
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
|
|
7
|
+
import type { HumanConfig, HumanPackage } from '../human/runtime';
|
|
8
|
+
import { EditorControl, HumanEditorModel as EditorModel, HumanEditorOptions, createHumanEditor } from '../human/editor';
|
|
9
|
+
import { HumanView, HumanViewHandle } from './HumanView';
|
|
10
|
+
|
|
11
|
+
export interface HumanEditorProps {
|
|
12
|
+
pkg: HumanPackage;
|
|
13
|
+
/** What users may edit (gender lock, sections, items…). Ignored if `editor` is given. */
|
|
14
|
+
options?: HumanEditorOptions;
|
|
15
|
+
editor?: EditorModel;
|
|
16
|
+
/** Controlled value. */
|
|
17
|
+
value?: Partial<HumanConfig>;
|
|
18
|
+
/** Uncontrolled starting value (e.g. the user's saved avatar). */
|
|
19
|
+
defaultValue?: Partial<HumanConfig>;
|
|
20
|
+
onChange?: (config: HumanConfig) => void;
|
|
21
|
+
/** Shows a Save button; gets the config (store as JSON) and a PNG file URI of the avatar. */
|
|
22
|
+
onSave?: (config: HumanConfig, snapshotUri: string | null) => void;
|
|
23
|
+
onCancel?: () => void;
|
|
24
|
+
saveLabel?: string;
|
|
25
|
+
randomize?: boolean;
|
|
26
|
+
accentColor?: string;
|
|
27
|
+
background?: string;
|
|
28
|
+
style?: StyleProp<ViewStyle>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function HumanEditor({
|
|
32
|
+
pkg, options, editor: given, value, defaultValue, onChange, onSave, onCancel, saveLabel = 'Save', randomize = true,
|
|
33
|
+
accentColor = '#1d1d1f', background = '#e9ecf1', style,
|
|
34
|
+
}: HumanEditorProps) {
|
|
35
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
|
|
36
|
+
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
37
|
+
const config = value ? editor.normalize(value) : inner;
|
|
38
|
+
const [tab, setTab] = useState(0);
|
|
39
|
+
const [framing, setFraming] = useState<'face' | 'body' | null>(null);
|
|
40
|
+
const view = useRef<HumanViewHandle>(null);
|
|
41
|
+
const section = editor.sections[Math.min(tab, editor.sections.length - 1)];
|
|
42
|
+
|
|
43
|
+
const update = (next: HumanConfig) => {
|
|
44
|
+
const n = editor.normalize(next);
|
|
45
|
+
if (!value) setInner(n);
|
|
46
|
+
onChange?.(n);
|
|
47
|
+
};
|
|
48
|
+
if (!section) return <View style={style}><Text>No editable sections.</Text></View>;
|
|
49
|
+
const shot = framing ?? section.framing;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View style={[s.root, style]}>
|
|
53
|
+
<View style={s.preview}>
|
|
54
|
+
<HumanView ref={view} pkg={pkg} config={config} framing={shot} background={background} style={StyleSheet.absoluteFill} />
|
|
55
|
+
<View style={s.toolbar} pointerEvents="box-none">
|
|
56
|
+
{onCancel ? <Pill label="Cancel" onPress={onCancel} /> : null}
|
|
57
|
+
<View style={s.seg}>
|
|
58
|
+
{(['face', 'body'] as const).map((f) => (
|
|
59
|
+
<Pressable key={f} onPress={() => setFraming(f)} style={[s.segBtn, shot === f && { backgroundColor: accentColor }]}>
|
|
60
|
+
<Text style={[s.segText, shot === f && { color: '#fff' }]}>{f === 'face' ? 'Face' : 'Body'}</Text>
|
|
61
|
+
</Pressable>
|
|
62
|
+
))}
|
|
63
|
+
</View>
|
|
64
|
+
{randomize ? <Pill label="🎲" onPress={() => update(editor.randomize())} /> : null}
|
|
65
|
+
</View>
|
|
66
|
+
</View>
|
|
67
|
+
|
|
68
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.tabs} contentContainerStyle={s.tabsInner}>
|
|
69
|
+
{editor.sections.map((sec, i) => (
|
|
70
|
+
<Pressable key={sec.id} onPress={() => { setTab(i); setFraming(null); }} style={[s.tab, sec === section && { backgroundColor: accentColor }]}>
|
|
71
|
+
<Text style={[s.tabText, sec === section && { color: '#fff' }]}>{sec.label}</Text>
|
|
72
|
+
</Pressable>
|
|
73
|
+
))}
|
|
74
|
+
</ScrollView>
|
|
75
|
+
|
|
76
|
+
<ScrollView style={s.panel} contentContainerStyle={{ padding: 16, paddingBottom: 24 }} keyboardShouldPersistTaps="handled">
|
|
77
|
+
{section.controls.map((ctl) => <Control key={`${section.id}.${ctl.id}`} ctl={ctl} config={config} onChange={update} accent={accentColor} />)}
|
|
78
|
+
</ScrollView>
|
|
79
|
+
|
|
80
|
+
{onSave ? (
|
|
81
|
+
<Pressable style={[s.save, { backgroundColor: accentColor }]} onPress={async () => onSave(config, (await view.current?.snapshot()) ?? null)}>
|
|
82
|
+
<Text style={s.saveText}>{saveLabel}</Text>
|
|
83
|
+
</Pressable>
|
|
84
|
+
) : null}
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Control({ ctl, config, onChange, accent }: { ctl: EditorControl; config: HumanConfig; onChange: (c: HumanConfig) => void; accent: string }) {
|
|
90
|
+
if (ctl.kind === 'slider') {
|
|
91
|
+
return (
|
|
92
|
+
<View style={s.row}>
|
|
93
|
+
<Text style={s.label}>{ctl.label}</Text>
|
|
94
|
+
<Slider value={ctl.value(config)} min={ctl.min} max={ctl.max} accent={accent} onChange={(v) => onChange(ctl.apply(config, v))} />
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (ctl.kind === 'color') {
|
|
99
|
+
const v = ctl.value(config).toLowerCase();
|
|
100
|
+
return (
|
|
101
|
+
<View style={s.block}>
|
|
102
|
+
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
103
|
+
<View style={s.wrap}>
|
|
104
|
+
{ctl.colors.map((c) => (
|
|
105
|
+
<Pressable key={c} accessibilityLabel={c} onPress={() => onChange(ctl.apply(config, c))}
|
|
106
|
+
style={[s.swatch, { backgroundColor: c }, c.toLowerCase() === v && { borderColor: accent }]} />
|
|
107
|
+
))}
|
|
108
|
+
</View>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const v = ctl.value(config);
|
|
113
|
+
return (
|
|
114
|
+
<View style={s.block}>
|
|
115
|
+
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
116
|
+
<View style={s.wrap}>
|
|
117
|
+
{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>
|
|
122
|
+
</Pressable>
|
|
123
|
+
))}
|
|
124
|
+
</View>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Minimal slider (no native dependency). Updates while dragging; tap to jump. */
|
|
130
|
+
function Slider({ value, min, max, onChange, accent }: { value: number; min: number; max: number; onChange: (v: number) => void; accent: string }) {
|
|
131
|
+
const width = useRef(1);
|
|
132
|
+
const latest = useRef(onChange);
|
|
133
|
+
latest.current = onChange;
|
|
134
|
+
const toValue = (x: number) => Math.round((min + (Math.max(0, Math.min(width.current, x)) / width.current) * (max - min)) * 100) / 100;
|
|
135
|
+
const pan = useMemo(() => PanResponder.create({
|
|
136
|
+
onStartShouldSetPanResponder: () => true,
|
|
137
|
+
onMoveShouldSetPanResponder: () => true,
|
|
138
|
+
onPanResponderTerminationRequest: () => false,
|
|
139
|
+
onPanResponderGrant: (e) => latest.current(toValue(e.nativeEvent.locationX)),
|
|
140
|
+
onPanResponderMove: (e) => latest.current(toValue(e.nativeEvent.locationX)),
|
|
141
|
+
}), [min, max]);
|
|
142
|
+
const t = (value - min) / (max - min || 1);
|
|
143
|
+
return (
|
|
144
|
+
<View style={s.slider} onLayout={(e: LayoutChangeEvent) => (width.current = e.nativeEvent.layout.width)} {...pan.panHandlers}>
|
|
145
|
+
<View style={s.track} pointerEvents="none">
|
|
146
|
+
<View style={[s.fill, { width: `${t * 100}%`, backgroundColor: accent }]} />
|
|
147
|
+
</View>
|
|
148
|
+
<View pointerEvents="none" style={[s.thumb, { left: `${t * 100}%`, borderColor: accent }]} />
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Pill({ label, onPress }: { label: string; onPress: () => void }) {
|
|
154
|
+
return <Pressable onPress={onPress} style={s.pill}><Text style={s.pillText}>{label}</Text></Pressable>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const s = StyleSheet.create({
|
|
158
|
+
root: { flex: 1, backgroundColor: '#fff' },
|
|
159
|
+
preview: { height: '48%' },
|
|
160
|
+
toolbar: { position: 'absolute', top: 12, left: 12, right: 12, flexDirection: 'row', gap: 8, alignItems: 'center' },
|
|
161
|
+
seg: { flexDirection: 'row', backgroundColor: 'rgba(255,255,255,0.92)', borderRadius: 999, padding: 3 },
|
|
162
|
+
segBtn: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 999 },
|
|
163
|
+
segText: { fontSize: 13, color: '#1d1d1f' },
|
|
164
|
+
pill: { backgroundColor: 'rgba(255,255,255,0.92)', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
|
|
165
|
+
pillText: { fontSize: 13, color: '#1d1d1f' },
|
|
166
|
+
tabs: { flexGrow: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e3e5e8' },
|
|
167
|
+
tabsInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 6 },
|
|
168
|
+
tab: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: '#f2f3f5' },
|
|
169
|
+
tabText: { fontSize: 13, fontWeight: '500', color: '#444' },
|
|
170
|
+
panel: { flex: 1 },
|
|
171
|
+
row: { flexDirection: 'row', alignItems: 'center', marginVertical: 8 },
|
|
172
|
+
label: { width: 112, fontSize: 12, color: '#666' },
|
|
173
|
+
block: { marginVertical: 10 },
|
|
174
|
+
blockLabel: { fontSize: 13, fontWeight: '600', color: '#333', marginBottom: 8 },
|
|
175
|
+
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 },
|
|
180
|
+
slider: { flex: 1, height: 32, justifyContent: 'center' },
|
|
181
|
+
track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
|
|
182
|
+
fill: { height: 4 },
|
|
183
|
+
thumb: { position: 'absolute', width: 22, height: 22, marginLeft: -11, borderRadius: 11, backgroundColor: '#fff', borderWidth: 2 },
|
|
184
|
+
save: { margin: 16, marginTop: 0, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
|
|
185
|
+
saveText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
186
|
+
});
|
package/src/native/index.ts
CHANGED
|
@@ -5,3 +5,5 @@ export type { AvatarCreatorProps } from './AvatarCreator';
|
|
|
5
5
|
export { HumanView, useHumanPackage } from './HumanView';
|
|
6
6
|
export type { HumanViewHandle, HumanViewProps } from './HumanView';
|
|
7
7
|
export { expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';
|
|
8
|
+
export { HumanEditor } from './HumanEditor';
|
|
9
|
+
export type { HumanEditorProps } from './HumanEditor';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// <HumanCanvas>: the realistic avatar in a React (web) app. Renders on demand only.
|
|
2
|
+
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ACESFilmicToneMapping, Color, DirectionalLight, HemisphereLight, PMREMGenerator, PerspectiveCamera, Scene, SRGBColorSpace,
|
|
5
|
+
Vector3, WebGLRenderer,
|
|
6
|
+
} from 'three';
|
|
7
|
+
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
|
8
|
+
import { HumanAvatar, HumanConfig, HumanPackage } from '../human/runtime';
|
|
9
|
+
|
|
10
|
+
export interface HumanCanvasProps {
|
|
11
|
+
pkg: HumanPackage;
|
|
12
|
+
config: Partial<HumanConfig>;
|
|
13
|
+
framing?: 'face' | 'body';
|
|
14
|
+
/** ARKit-style expression weights, e.g. { mouthSmile: 0.6 } */
|
|
15
|
+
expressions?: Record<string, number>;
|
|
16
|
+
background?: string;
|
|
17
|
+
/** Drag to turn the avatar. Default true. */
|
|
18
|
+
rotatable?: boolean;
|
|
19
|
+
textureSize?: '1k' | '2k';
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
onReady?: (avatar: HumanAvatar) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HumanCanvasHandle {
|
|
26
|
+
avatar: HumanAvatar | null;
|
|
27
|
+
render(): void;
|
|
28
|
+
/** PNG data URL of the current view. */
|
|
29
|
+
snapshot(): string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Stage { renderer: WebGLRenderer; scene: Scene; camera: PerspectiveCamera; avatar: HumanAvatar; yaw: number; framing: 'face' | 'body'; frame?: number; ready: boolean }
|
|
33
|
+
|
|
34
|
+
export const HumanCanvas = forwardRef<HumanCanvasHandle, HumanCanvasProps>(function HumanCanvas(
|
|
35
|
+
{ pkg, config, framing = 'face', expressions, background = '#e9ecf1', rotatable = true, textureSize = '2k', className, style, onReady }, ref,
|
|
36
|
+
) {
|
|
37
|
+
const host = useRef<HTMLDivElement>(null);
|
|
38
|
+
const stage = useRef<Stage | null>(null);
|
|
39
|
+
const ready = useRef(onReady);
|
|
40
|
+
ready.current = onReady;
|
|
41
|
+
|
|
42
|
+
const draw = () => {
|
|
43
|
+
const s = stage.current;
|
|
44
|
+
// the skeleton is only bound after the first setConfig
|
|
45
|
+
if (!s || !s.ready || s.frame) return;
|
|
46
|
+
s.frame = requestAnimationFrame(() => {
|
|
47
|
+
s.frame = 0;
|
|
48
|
+
const m = s.avatar.metrics();
|
|
49
|
+
const face = s.framing === 'face';
|
|
50
|
+
const target = face ? new Vector3(0, m.headY - 0.08, 0) : new Vector3(0, m.height * 0.52, 0);
|
|
51
|
+
const h = face ? 0.42 : m.height * 1.12;
|
|
52
|
+
const dist = h / 2 / Math.tan((s.camera.fov * Math.PI) / 360) / Math.min(1, s.camera.aspect * 1.1);
|
|
53
|
+
s.camera.position.set(Math.sin(s.yaw) * dist, target.y + dist * 0.03, Math.cos(s.yaw) * dist);
|
|
54
|
+
s.camera.lookAt(target);
|
|
55
|
+
s.renderer.render(s.scene, s.camera);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const el = host.current!;
|
|
61
|
+
const renderer = new WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, alpha: false });
|
|
62
|
+
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
|
|
63
|
+
renderer.outputColorSpace = SRGBColorSpace;
|
|
64
|
+
renderer.toneMapping = ACESFilmicToneMapping;
|
|
65
|
+
Object.assign(renderer.domElement.style, { width: '100%', height: '100%', display: 'block', touchAction: 'none', cursor: rotatable ? 'grab' : 'default' });
|
|
66
|
+
el.appendChild(renderer.domElement);
|
|
67
|
+
const scene = new Scene();
|
|
68
|
+
scene.background = new Color(background);
|
|
69
|
+
const pmrem = new PMREMGenerator(renderer);
|
|
70
|
+
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
71
|
+
scene.environmentIntensity = 0.55;
|
|
72
|
+
const key = new DirectionalLight('#fff4e8', 1.6);
|
|
73
|
+
key.position.set(1.2, 2.2, 2.5);
|
|
74
|
+
const rim = new DirectionalLight('#dfe8ff', 0.9);
|
|
75
|
+
rim.position.set(-2, 2, -2);
|
|
76
|
+
scene.add(key, rim, new HemisphereLight('#ffffff', '#b9a99a', 0.4));
|
|
77
|
+
const avatar = new HumanAvatar(pkg);
|
|
78
|
+
avatar.textureSize = textureSize;
|
|
79
|
+
scene.add(avatar.group);
|
|
80
|
+
const s: Stage = { renderer, scene, camera: new PerspectiveCamera(24, 1, 0.05, 50), avatar, yaw: 0, framing, ready: false };
|
|
81
|
+
stage.current = s;
|
|
82
|
+
|
|
83
|
+
const resize = () => {
|
|
84
|
+
const w = Math.max(1, el.clientWidth), h = Math.max(1, el.clientHeight);
|
|
85
|
+
renderer.setSize(w, h, false);
|
|
86
|
+
s.camera.aspect = w / h;
|
|
87
|
+
s.camera.updateProjectionMatrix();
|
|
88
|
+
draw();
|
|
89
|
+
};
|
|
90
|
+
const ro = new ResizeObserver(resize);
|
|
91
|
+
ro.observe(el);
|
|
92
|
+
resize();
|
|
93
|
+
|
|
94
|
+
let drag: number | null = null;
|
|
95
|
+
const canvas = renderer.domElement;
|
|
96
|
+
canvas.onpointerdown = (e) => { if (!rotatable) return; drag = e.clientX; canvas.setPointerCapture(e.pointerId); canvas.style.cursor = 'grabbing'; };
|
|
97
|
+
canvas.onpointermove = (e) => { if (drag === null) return; s.yaw -= (e.clientX - drag) * 0.01; drag = e.clientX; draw(); };
|
|
98
|
+
canvas.onpointerup = canvas.onpointercancel = () => { drag = null; canvas.style.cursor = rotatable ? 'grab' : 'default'; };
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
ro.disconnect();
|
|
102
|
+
if (s.frame) cancelAnimationFrame(s.frame);
|
|
103
|
+
avatar.dispose();
|
|
104
|
+
pmrem.dispose();
|
|
105
|
+
renderer.dispose();
|
|
106
|
+
canvas.remove();
|
|
107
|
+
stage.current = null;
|
|
108
|
+
};
|
|
109
|
+
}, [pkg]);
|
|
110
|
+
|
|
111
|
+
const key = JSON.stringify(config);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const s = stage.current;
|
|
114
|
+
if (!s) return;
|
|
115
|
+
let live = true;
|
|
116
|
+
s.avatar.setConfig(config).then(() => {
|
|
117
|
+
if (!live) return;
|
|
118
|
+
s.ready = true;
|
|
119
|
+
draw();
|
|
120
|
+
ready.current?.(s.avatar);
|
|
121
|
+
});
|
|
122
|
+
return () => { live = false; };
|
|
123
|
+
}, [key, pkg]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const s = stage.current;
|
|
127
|
+
if (!s) return;
|
|
128
|
+
s.framing = framing;
|
|
129
|
+
(s.scene.background as Color).set(background);
|
|
130
|
+
for (const name of s.avatar.expressionNames) s.avatar.setExpression(name, expressions?.[name] ?? 0);
|
|
131
|
+
draw();
|
|
132
|
+
}, [framing, background, JSON.stringify(expressions)]);
|
|
133
|
+
|
|
134
|
+
useImperativeHandle(ref, () => ({
|
|
135
|
+
get avatar() { return stage.current?.avatar ?? null; },
|
|
136
|
+
render: draw,
|
|
137
|
+
snapshot() {
|
|
138
|
+
const s = stage.current;
|
|
139
|
+
if (!s) return null;
|
|
140
|
+
s.renderer.render(s.scene, s.camera);
|
|
141
|
+
return s.renderer.domElement.toDataURL('image/png');
|
|
142
|
+
},
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return <div ref={host} className={className} style={{ position: 'relative', overflow: 'hidden', ...style }} />;
|
|
146
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// <HumanEditor>: drop-in avatar customization UI for React (web). The developer decides what
|
|
2
|
+
// users may edit through `options` (see HumanEditorOptions in ../human/editor).
|
|
3
|
+
//
|
|
4
|
+
// <HumanEditor pkg={pkg} options={{ gender: 'female', exclude: ['ears'] }} onSave={(config) => api.save(config)} />
|
|
5
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import type { HumanConfig, HumanPackage } from '../human/runtime';
|
|
7
|
+
import { EditorControl, HumanEditorModel as EditorModel, HumanEditorOptions, createHumanEditor } from '../human/editor';
|
|
8
|
+
import { HumanCanvas, HumanCanvasHandle } from './HumanCanvas';
|
|
9
|
+
|
|
10
|
+
export interface HumanEditorProps {
|
|
11
|
+
pkg: HumanPackage;
|
|
12
|
+
/** What users may edit. Ignored if `editor` is given. */
|
|
13
|
+
options?: HumanEditorOptions;
|
|
14
|
+
/** A prebuilt editor model (createHumanEditor), to share it with your own code. */
|
|
15
|
+
editor?: EditorModel;
|
|
16
|
+
/** Controlled value. */
|
|
17
|
+
value?: Partial<HumanConfig>;
|
|
18
|
+
/** Uncontrolled starting value (e.g. the user's saved avatar). */
|
|
19
|
+
defaultValue?: Partial<HumanConfig>;
|
|
20
|
+
onChange?: (config: HumanConfig) => void;
|
|
21
|
+
/** Shows a Save button. Gets the config (store it as JSON) and a PNG snapshot. */
|
|
22
|
+
onSave?: (config: HumanConfig, snapshot: string | null) => void;
|
|
23
|
+
saveLabel?: string;
|
|
24
|
+
/** Show a "From selfie" button (needs @mediapipe/tasks-vision). */
|
|
25
|
+
selfie?: boolean;
|
|
26
|
+
/** Show a randomize button. Default true. */
|
|
27
|
+
randomize?: boolean;
|
|
28
|
+
accentColor?: string;
|
|
29
|
+
background?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: React.CSSProperties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function HumanEditor({
|
|
35
|
+
pkg, options, editor: given, value, defaultValue, onChange, onSave, saveLabel = 'Save', selfie = false, randomize = true,
|
|
36
|
+
accentColor = '#1d1d1f', background = '#e9ecf1', className, style,
|
|
37
|
+
}: HumanEditorProps) {
|
|
38
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg.manifest, options), [given, pkg, JSON.stringify(options)]);
|
|
39
|
+
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
40
|
+
const config = value ? editor.normalize(value) : inner;
|
|
41
|
+
const [tab, setTab] = useState(0);
|
|
42
|
+
const [framing, setFraming] = useState<'face' | 'body' | null>(null);
|
|
43
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
44
|
+
const canvas = useRef<HumanCanvasHandle>(null);
|
|
45
|
+
const file = useRef<HTMLInputElement>(null);
|
|
46
|
+
const section = editor.sections[Math.min(tab, editor.sections.length - 1)];
|
|
47
|
+
|
|
48
|
+
useEffect(() => { if (!value) setInner((c) => editor.normalize(c)); }, [editor]);
|
|
49
|
+
useStyles();
|
|
50
|
+
|
|
51
|
+
const update = (next: HumanConfig) => {
|
|
52
|
+
const n = editor.normalize(next);
|
|
53
|
+
if (!value) setInner(n);
|
|
54
|
+
onChange?.(n);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const fromSelfie = async (f: File) => {
|
|
58
|
+
setBusy('Reading your photo…');
|
|
59
|
+
try {
|
|
60
|
+
const { createSelfieFitter } = await import('../human/selfie');
|
|
61
|
+
const fitter = await (fitterCache ??= createSelfieFitter());
|
|
62
|
+
const g = editor.options.gender;
|
|
63
|
+
const res = await fitter.fit(pkg, f, {
|
|
64
|
+
base: { ...config, detail: {} },
|
|
65
|
+
hints: g === 'female' ? { gender: 0 } : g === 'male' ? { gender: 1 } : { gender: config.gender },
|
|
66
|
+
});
|
|
67
|
+
const c = res.config;
|
|
68
|
+
update({ ...config, african: c.african, asian: c.asian, caucasian: c.caucasian, skin: c.skin, skinTint: c.skinTint, eyes: c.eyes, detail: { ...config.detail, ...c.detail } });
|
|
69
|
+
setBusy(null);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
setBusy(`Couldn't read a face: ${(e as Error).message}`);
|
|
72
|
+
setTimeout(() => setBusy(null), 3000);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (!section) return <div className="hf-editor">No editable sections.</div>;
|
|
77
|
+
const view = framing ?? section.framing;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={`hf-root ${className ?? ''}`} style={{ ['--hf-accent' as any]: accentColor, ...style }}>
|
|
81
|
+
<div className="hf-editor">
|
|
82
|
+
<div className="hf-stage">
|
|
83
|
+
<HumanCanvas ref={canvas} pkg={pkg} config={config} framing={view} background={background} style={{ position: 'absolute', inset: 0 }} />
|
|
84
|
+
<div className="hf-toolbar">
|
|
85
|
+
<div className="hf-seg">
|
|
86
|
+
<button className={view === 'face' ? 'on' : ''} onClick={() => setFraming('face')}>Face</button>
|
|
87
|
+
<button className={view === 'body' ? 'on' : ''} onClick={() => setFraming('body')}>Body</button>
|
|
88
|
+
</div>
|
|
89
|
+
{randomize && <button className="hf-btn" title="Random look" onClick={() => update(editor.randomize())}>🎲</button>}
|
|
90
|
+
{selfie && <button className="hf-btn" onClick={() => file.current?.click()}>📷 Selfie</button>}
|
|
91
|
+
<input ref={file} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) fromSelfie(f); e.target.value = ''; }} />
|
|
92
|
+
</div>
|
|
93
|
+
{busy && <div className="hf-toast">{busy}</div>}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="hf-panel">
|
|
96
|
+
<div className="hf-tabs" role="tablist">
|
|
97
|
+
{editor.sections.map((s, i) => (
|
|
98
|
+
<button key={s.id} role="tab" aria-selected={s === section} className={s === section ? 'on' : ''} onClick={() => { setTab(i); setFraming(null); }}>
|
|
99
|
+
{s.label}
|
|
100
|
+
</button>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
<div className="hf-controls">
|
|
104
|
+
{section.controls.map((ctl) => <Control key={`${section.id}.${ctl.id}`} ctl={ctl} config={config} onChange={update} />)}
|
|
105
|
+
</div>
|
|
106
|
+
{onSave && <button className="hf-save" onClick={() => onSave(config, canvas.current?.snapshot() ?? null)}>{saveLabel}</button>}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
let fitterCache: Promise<any> | undefined;
|
|
113
|
+
|
|
114
|
+
function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanConfig; onChange: (c: HumanConfig) => void }) {
|
|
115
|
+
if (ctl.kind === 'slider') {
|
|
116
|
+
const v = ctl.value(config);
|
|
117
|
+
return (
|
|
118
|
+
<label className="hf-row">
|
|
119
|
+
<span className="hf-label">{ctl.label}</span>
|
|
120
|
+
<input type="range" min={ctl.min} max={ctl.max} step={ctl.step} value={v} onChange={(e) => onChange(ctl.apply(config, Number(e.target.value)))}
|
|
121
|
+
onDoubleClick={() => onChange(ctl.apply(config, ctl.min < 0 ? 0 : (ctl.min + ctl.max) / 2))} />
|
|
122
|
+
</label>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (ctl.kind === 'color') {
|
|
126
|
+
const v = ctl.value(config).toLowerCase();
|
|
127
|
+
return (
|
|
128
|
+
<div className="hf-block">
|
|
129
|
+
<span className="hf-label">{ctl.label}</span>
|
|
130
|
+
<div className="hf-swatches">
|
|
131
|
+
{ctl.colors.map((c) => (
|
|
132
|
+
<button key={c} aria-label={c} className={`hf-swatch ${c.toLowerCase() === v ? 'on' : ''}`} style={{ background: c }} onClick={() => onChange(ctl.apply(config, c))} />
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const v = ctl.value(config);
|
|
139
|
+
return (
|
|
140
|
+
<div className="hf-block">
|
|
141
|
+
<span className="hf-label">{ctl.label}</span>
|
|
142
|
+
<div className="hf-choices">
|
|
143
|
+
{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}
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const CSS = `
|
|
155
|
+
.hf-root{--hf-accent:#1d1d1f;container-type:inline-size;height:100%;min-height:480px}
|
|
156
|
+
.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}
|
|
157
|
+
.hf-stage{position:relative;min-height:260px;min-width:0}
|
|
158
|
+
.hf-toolbar{position:absolute;top:12px;left:12px;right:12px;display:flex;gap:8px;flex-wrap:wrap;pointer-events:none}
|
|
159
|
+
.hf-toolbar>*{pointer-events:auto}
|
|
160
|
+
.hf-seg{display:flex;background:rgba(255,255,255,.9);border-radius:999px;padding:3px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
161
|
+
.hf-seg button,.hf-btn{border:0;background:transparent;border-radius:999px;padding:6px 12px;font:inherit;cursor:pointer;color:inherit}
|
|
162
|
+
.hf-seg button.on{background:var(--hf-accent);color:#fff}
|
|
163
|
+
.hf-btn{background:rgba(255,255,255,.9);box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
164
|
+
.hf-btn:hover{background:#fff}
|
|
165
|
+
.hf-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);background:rgba(29,29,31,.85);color:#fff;padding:8px 14px;border-radius:999px}
|
|
166
|
+
.hf-panel{display:flex;flex-direction:column;border-left:1px solid #eceef1;min-height:0;min-width:0}
|
|
167
|
+
.hf-tabs{display:flex;gap:4px;overflow-x:auto;padding:12px 12px 8px;border-bottom:1px solid #eceef1;scrollbar-width:none}
|
|
168
|
+
.hf-tabs::-webkit-scrollbar{display:none}
|
|
169
|
+
.hf-tabs button{flex:none;border:0;background:#f2f3f5;border-radius:999px;padding:7px 13px;font:inherit;font-weight:500;cursor:pointer;color:#444}
|
|
170
|
+
.hf-tabs button.on{background:var(--hf-accent);color:#fff}
|
|
171
|
+
.hf-controls{flex:1;overflow-y:auto;padding:8px 16px 16px}
|
|
172
|
+
.hf-row{display:grid;grid-template-columns:118px 1fr;align-items:center;gap:10px;margin:9px 0}
|
|
173
|
+
.hf-row input{width:100%;accent-color:var(--hf-accent)}
|
|
174
|
+
.hf-label{font-size:12px;color:#666}
|
|
175
|
+
.hf-block{margin:12px 0}
|
|
176
|
+
.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}
|
|
184
|
+
.hf-swatch.on{box-shadow:0 0 0 2px var(--hf-accent)}
|
|
185
|
+
.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
|
+
@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}}
|
|
187
|
+
`;
|
|
188
|
+
let injected = false;
|
|
189
|
+
function useStyles() {
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (injected || typeof document === 'undefined') return;
|
|
192
|
+
injected = true;
|
|
193
|
+
const el = document.createElement('style');
|
|
194
|
+
el.dataset.humanforge = '';
|
|
195
|
+
el.textContent = CSS;
|
|
196
|
+
document.head.appendChild(el);
|
|
197
|
+
}, []);
|
|
198
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// React (web) components. Import from 'humanforge/web'.
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { HumanPackage, LoadOptions, loadHumanPackage } from '../human/runtime';
|
|
4
|
+
|
|
5
|
+
export { HumanCanvas } from './HumanCanvas';
|
|
6
|
+
export type { HumanCanvasHandle, HumanCanvasProps } from './HumanCanvas';
|
|
7
|
+
export { HumanEditor } from './HumanEditor';
|
|
8
|
+
export type { HumanEditorProps } from './HumanEditor';
|
|
9
|
+
export * from '../human/editor';
|
|
10
|
+
export * from '../human/catalog';
|
|
11
|
+
|
|
12
|
+
const packages = new Map<string, Promise<HumanPackage>>();
|
|
13
|
+
/** Load the human asset package once per URL (shared by every component on the page). */
|
|
14
|
+
export function useHumanPackage(baseUrl: string, opts?: LoadOptions) {
|
|
15
|
+
const [pkg, setPkg] = useState<HumanPackage | null>(null);
|
|
16
|
+
const [error, setError] = useState<Error | null>(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let p = packages.get(baseUrl);
|
|
19
|
+
if (!p) {
|
|
20
|
+
p = loadHumanPackage(baseUrl, opts);
|
|
21
|
+
packages.set(baseUrl, p);
|
|
22
|
+
p.catch(() => packages.delete(baseUrl));
|
|
23
|
+
}
|
|
24
|
+
let live = true;
|
|
25
|
+
p.then((v) => live && setPkg(v), (e) => live && setError(e));
|
|
26
|
+
return () => { live = false; };
|
|
27
|
+
}, [baseUrl]);
|
|
28
|
+
return { pkg, error };
|
|
29
|
+
}
|