humanforge 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,145 @@
1
+ // Slider definitions for the realistic human. Shared by the asset build
2
+ // (tools/build-human.ts decides which MakeHuman targets to bake) and the runtime.
3
+ //
4
+ // Detail sliders range -1..1: negative applies `neg` targets, positive applies `pos`.
5
+ // Targets are MakeHuman target paths relative to data/targets (without .target.gz).
6
+
7
+ export interface DetailSlider {
8
+ id: string;
9
+ label: string;
10
+ group: 'head' | 'eyes' | 'nose' | 'mouth' | 'ears' | 'body';
11
+ neg: string[];
12
+ pos: string[];
13
+ }
14
+
15
+ const lr = (dir: string, name: string) => [`${dir}/l-${name}`, `${dir}/r-${name}`];
16
+ const pair = (id: string, label: string, group: DetailSlider['group'], dir: string, base: string, n = 'decr', p = 'incr'): DetailSlider =>
17
+ ({ id, label, group, neg: [`${dir}/${base}-${n}`], pos: [`${dir}/${base}-${p}`] });
18
+ const pairLR = (id: string, label: string, group: DetailSlider['group'], dir: string, base: string, n = 'decr', p = 'incr'): DetailSlider =>
19
+ ({ id, label, group, neg: lr(dir, `${base}-${n}`), pos: lr(dir, `${base}-${p}`) });
20
+ const one = (id: string, label: string, group: DetailSlider['group'], target: string): DetailSlider =>
21
+ ({ id, label, group, neg: [], pos: [target] });
22
+
23
+ export const DETAIL_SLIDERS: DetailSlider[] = [
24
+ // head & face shape
25
+ pair('headWidth', 'Head width', 'head', 'head', 'head-scale-horiz'),
26
+ pair('headHeight', 'Head height', 'head', 'head', 'head-scale-vert'),
27
+ pair('headDepth', 'Head depth', 'head', 'head', 'head-scale-depth'),
28
+ pair('faceFat', 'Face fullness', 'head', 'head', 'head-fat'),
29
+ one('headOval', 'Oval face', 'head', 'head/head-oval'),
30
+ one('headRound', 'Round face', 'head', 'head/head-round'),
31
+ one('headSquare', 'Square face', 'head', 'head/head-square'),
32
+ one('headHeart', 'Heart face', 'head', 'head/head-invertedtriangular'),
33
+ pair('foreheadHeight', 'Forehead height', 'head', 'forehead', 'forehead-scale-vert'),
34
+ pair('foreheadSlope', 'Forehead slope', 'head', 'forehead', 'forehead-trans', 'backward', 'forward'),
35
+ pair('jaw', 'Jaw width', 'head', 'chin', 'chin-bones'),
36
+ pair('chinWidth', 'Chin width', 'head', 'chin', 'chin-width'),
37
+ pair('chinHeight', 'Chin height', 'head', 'chin', 'chin-height'),
38
+ pair('chinProminence', 'Chin prominence', 'head', 'chin', 'chin-prominent'),
39
+ pair('chinCleft', 'Chin cleft', 'head', 'chin', 'chin-cleft'),
40
+ pairLR('cheekBones', 'Cheekbones', 'head', 'cheek', 'cheek-bones'),
41
+ pairLR('cheekVolume', 'Cheek volume', 'head', 'cheek', 'cheek-volume'),
42
+ // eyes & brows
43
+ pairLR('eyeSize', 'Eye size', 'eyes', 'eyes', 'eye-scale'),
44
+ pairLR('eyeOpening', 'Eye opening', 'eyes', 'eyes', 'eye-height2'),
45
+ pairLR('eyeSpacing', 'Eye spacing', 'eyes', 'eyes', 'eye-trans', 'in', 'out'),
46
+ pairLR('eyeHeight', 'Eye height', 'eyes', 'eyes', 'eye-trans', 'down', 'up'),
47
+ pairLR('eyeTilt', 'Eye tilt', 'eyes', 'eyes', 'eye-corner1', 'down', 'up'),
48
+ pairLR('eyeFold', 'Eyelid fold', 'eyes', 'eyes', 'eye-epicanthus', 'in', 'out'),
49
+ pairLR('eyeBags', 'Eye bags', 'eyes', 'eyes', 'eye-bag'),
50
+ pair('browHeight', 'Brow height', 'eyes', 'eyebrows', 'eyebrows-trans', 'down', 'up'),
51
+ pair('browAngle', 'Brow angle', 'eyes', 'eyebrows', 'eyebrows-angle', 'down', 'up'),
52
+ pair('browDepth', 'Brow ridge', 'eyes', 'eyebrows', 'eyebrows-trans', 'backward', 'forward'),
53
+ // nose
54
+ pair('noseWidth', 'Nose width', 'nose', 'nose', 'nose-scale-horiz'),
55
+ pair('noseLength', 'Nose length', 'nose', 'nose', 'nose-scale-vert'),
56
+ pair('noseDepth', 'Nose projection', 'nose', 'nose', 'nose-scale-depth'),
57
+ pair('noseBridge', 'Bridge width', 'nose', 'nose', 'nose-width1'),
58
+ pair('noseHump', 'Nose hump', 'nose', 'nose', 'nose-hump'),
59
+ pair('noseTip', 'Tip up/down', 'nose', 'nose', 'nose-point', 'down', 'up'),
60
+ pair('noseTipWidth', 'Tip width', 'nose', 'nose', 'nose-point-width'),
61
+ pair('nostrils', 'Nostril width', 'nose', 'nose', 'nose-nostrils-width'),
62
+ pair('noseFlare', 'Nostril flare', 'nose', 'nose', 'nose-flaring'),
63
+ // mouth
64
+ pair('mouthWidth', 'Mouth width', 'mouth', 'mouth', 'mouth-scale-horiz'),
65
+ pair('mouthHeight', 'Mouth position', 'mouth', 'mouth', 'mouth-trans', 'down', 'up'),
66
+ pair('upperLip', 'Upper lip', 'mouth', 'mouth', 'mouth-upperlip-volume'),
67
+ pair('lowerLip', 'Lower lip', 'mouth', 'mouth', 'mouth-lowerlip-volume'),
68
+ pair('cupidsBow', "Cupid's bow", 'mouth', 'mouth', 'mouth-cupidsbow'),
69
+ pair('mouthCorners', 'Mouth corners', 'mouth', 'mouth', 'mouth-angles', 'down', 'up'),
70
+ pair('mouthDepth', 'Mouth depth', 'mouth', 'mouth', 'mouth-scale-depth'),
71
+ pair('dimples', 'Dimples', 'mouth', 'mouth', 'mouth-dimples', 'in', 'out'),
72
+ // ears
73
+ pairLR('earSize', 'Ear size', 'ears', 'ears', 'ear-scale'),
74
+ pairLR('earAngle', 'Ear angle', 'ears', 'ears', 'ear-rot', 'backward', 'forward'),
75
+ pairLR('earLobe', 'Ear lobe', 'ears', 'ears', 'ear-lobe'),
76
+ { id: 'earPointed', label: 'Pointed ears', group: 'ears', neg: [], pos: lr('ears', 'ear-shape-pointed') },
77
+ // body detail
78
+ pair('neckWidth', 'Neck width', 'body', 'neck', 'neck-scale-horiz'),
79
+ pair('neckLength', 'Neck length', 'body', 'neck', 'neck-scale-vert'),
80
+ pair('shoulders', 'Shoulders', 'body', 'torso', 'measure-shoulder-dist'),
81
+ pair('chest', 'Chest', 'body', 'torso', 'measure-bust-circ'),
82
+ pair('waist', 'Waist', 'body', 'torso', 'measure-waist-circ'),
83
+ pair('hips', 'Hips', 'body', 'torso', 'measure-hips-circ'),
84
+ pair('vShape', 'V-shape', 'body', 'torso', 'torso-vshape'),
85
+ pair('belly', 'Belly', 'body', 'stomach', 'stomach-pregnant'),
86
+ pair('buttocks', 'Buttocks', 'body', 'buttocks', 'buttocks-volume'),
87
+ ];
88
+
89
+ /** Macro sliders (0..1), mirroring MakeHuman's macro modifiers. */
90
+ export const MACRO_SLIDERS = [
91
+ { id: 'gender', label: 'Gender', min: 'Female', max: 'Male' },
92
+ { id: 'age', label: 'Age', min: '18', max: '90' },
93
+ { id: 'muscle', label: 'Muscle', min: 'Soft', max: 'Muscular' },
94
+ { id: 'weight', label: 'Weight', min: 'Thin', max: 'Heavy' },
95
+ { id: 'height', label: 'Height', min: 'Short', max: 'Tall' },
96
+ { id: 'proportions', label: 'Proportions', min: 'Uncommon', max: 'Ideal' },
97
+ { id: 'cupSize', label: 'Bust', min: 'Small', max: 'Large' },
98
+ { id: 'firmness', label: 'Firmness', min: 'Low', max: 'High' },
99
+ { id: 'african', label: 'African', min: '0', max: '1' },
100
+ { id: 'asian', label: 'Asian', min: '0', max: '1' },
101
+ { id: 'caucasian', label: 'European', min: '0', max: '1' },
102
+ ] as const;
103
+
104
+ export type MacroId = (typeof MACRO_SLIDERS)[number]['id'];
105
+
106
+ /**
107
+ * FACS-style expression units shipped with MPFB, mapped to the closest ARKit
108
+ * blendshape names so ARKit/MediaPipe face-tracking data can drive them.
109
+ */
110
+ export const EXPRESSION_UNITS: Record<string, string> = {
111
+ 'eye-left-closure': 'eyeBlinkLeft',
112
+ 'eye-right-closure': 'eyeBlinkRight',
113
+ 'eye-left-opened-up': 'eyeWideLeft',
114
+ 'eye-right-opened-up': 'eyeWideRight',
115
+ 'eye-left-slit': 'eyeSquintLeft',
116
+ 'eye-right-slit': 'eyeSquintRight',
117
+ 'eyebrows-left-down': 'browDownLeft',
118
+ 'eyebrows-right-down': 'browDownRight',
119
+ 'eyebrows-left-extern-up': 'browOuterUpLeft',
120
+ 'eyebrows-right-extern-up': 'browOuterUpRight',
121
+ 'eyebrows-left-inner-up': 'browInnerUpLeft',
122
+ 'eyebrows-right-inner-up': 'browInnerUpRight',
123
+ 'eyebrows-left-up': 'browUpLeft',
124
+ 'eyebrows-right-up': 'browUpRight',
125
+ 'mouth-open': 'jawOpen',
126
+ 'mouth-corner-puller': 'mouthSmile',
127
+ 'mouth-depression': 'mouthFrown',
128
+ 'mouth-depression-retraction': 'mouthLowerDown',
129
+ 'mouth-elevation': 'mouthUpperUp',
130
+ 'mouth-compression': 'mouthPress',
131
+ 'mouth-pursing': 'mouthPucker',
132
+ 'mouth-protusion': 'mouthFunnel',
133
+ 'mouth-retraction': 'mouthStretch',
134
+ 'mouth-upward-retraction': 'mouthDimple',
135
+ 'mouth-eversion': 'mouthRollOut',
136
+ 'mouth-parling': 'mouthClose',
137
+ 'mouth-part-later': 'mouthLeft',
138
+ 'nose-compression': 'noseCompress',
139
+ 'nose-depression': 'noseDepress',
140
+ 'nose-left-elevation': 'noseSneerLeft',
141
+ 'nose-right-elevation': 'noseSneerRight',
142
+ 'nose-left-dilatation': 'nostrilFlareLeft',
143
+ 'nose-right-dilatation': 'nostrilFlareRight',
144
+ 'neck-platysma': 'neckTense',
145
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core';
2
+ export * from './human';
3
+ export * from './native';
@@ -0,0 +1,161 @@
1
+ // <AvatarCreator>: full-screen editor driven by EDITOR_SCHEMA. No UI deps beyond RN.
2
+ import React, { useRef, useState } from 'react';
3
+ import { PanResponder, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
4
+ import {
5
+ AvatarConfig, Control, DEFAULT_AVATAR, EDITOR_SCHEMA, encodeAvatar, normalizeConfig, prettyLabel, randomAvatar,
6
+ } from '../core';
7
+ import { AvatarView, AvatarViewHandle } from './AvatarView';
8
+
9
+ export interface AvatarCreatorProps {
10
+ initialConfig?: Partial<AvatarConfig> | null;
11
+ /** Called on Save with the config and its compact code (store either). */
12
+ onSave?: (config: AvatarConfig, code: string, view: AvatarViewHandle | null) => void;
13
+ onCancel?: () => void;
14
+ onChange?: (config: AvatarConfig) => void;
15
+ accentColor?: string;
16
+ saveLabel?: string;
17
+ }
18
+
19
+ export function AvatarCreator({
20
+ initialConfig, onSave, onCancel, onChange, accentColor = '#1d1d1f', saveLabel = 'Save',
21
+ }: AvatarCreatorProps) {
22
+ const [config, setConfig] = useState<AvatarConfig>(() => normalizeConfig(initialConfig ?? DEFAULT_AVATAR));
23
+ const [tab, setTab] = useState(0);
24
+ const view = useRef<AvatarViewHandle>(null);
25
+ const section = EDITOR_SCHEMA[tab];
26
+
27
+ const update = (next: AvatarConfig) => {
28
+ setConfig(next);
29
+ onChange?.(next);
30
+ };
31
+ const set = (key: keyof AvatarConfig, value: string | number) => update({ ...config, [key]: value });
32
+
33
+ return (
34
+ <View style={s.root}>
35
+ <AvatarView ref={view} config={config} framing={section.focus === 'face' ? 'face' : 'body'} style={s.preview} />
36
+
37
+ <View style={s.topBar} pointerEvents="box-none">
38
+ {onCancel ? <Btn label="Cancel" onPress={onCancel} /> : <View />}
39
+ <Btn label="🎲" onPress={() => update(randomAvatar())} />
40
+ </View>
41
+
42
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.tabs} contentContainerStyle={s.tabsInner}>
43
+ {EDITOR_SCHEMA.map((sec, i) => (
44
+ <Pressable key={sec.id} onPress={() => setTab(i)} style={[s.tab, i === tab && { borderBottomColor: accentColor }]}>
45
+ <Text style={[s.tabText, i === tab && { color: accentColor, fontWeight: '700' }]}>{sec.label}</Text>
46
+ </Pressable>
47
+ ))}
48
+ </ScrollView>
49
+
50
+ <ScrollView style={s.panel} contentContainerStyle={{ paddingBottom: 16 }}>
51
+ {section.controls.map((ctl) => (
52
+ <View key={ctl.key} style={s.row}>
53
+ <Text style={s.label}>{ctl.label}</Text>
54
+ <ControlInput ctl={ctl} value={config[ctl.key]} onChange={(v) => set(ctl.key, v)} accent={accentColor} />
55
+ </View>
56
+ ))}
57
+ </ScrollView>
58
+
59
+ {onSave ? (
60
+ <Pressable style={[s.save, { backgroundColor: accentColor }]} onPress={() => onSave(config, encodeAvatar(config), view.current)}>
61
+ <Text style={s.saveText}>{saveLabel}</Text>
62
+ </Pressable>
63
+ ) : null}
64
+ </View>
65
+ );
66
+ }
67
+
68
+ function ControlInput({ ctl, value, onChange, accent }: {
69
+ ctl: Control; value: string | number; onChange: (v: string | number) => void; accent: string;
70
+ }) {
71
+ if (ctl.type === 'slider') return <Slider value={value as number} onChange={onChange} accent={accent} />;
72
+ if (ctl.type === 'color') {
73
+ return (
74
+ <View style={s.wrap}>
75
+ {ctl.palette.map((c) => (
76
+ <Pressable key={c} onPress={() => onChange(c)} hitSlop={4}
77
+ style={[s.swatch, { backgroundColor: c }, value === c && { borderColor: accent, borderWidth: 3 }]} />
78
+ ))}
79
+ </View>
80
+ );
81
+ }
82
+ return (
83
+ <View style={s.wrap}>
84
+ {ctl.options.map((o) => (
85
+ <Pressable key={o} onPress={() => onChange(o)} style={[s.chip, value === o && { backgroundColor: accent, borderColor: accent }]}>
86
+ <Text style={[s.chipText, value === o && { color: '#fff' }]}>{prettyLabel(o)}</Text>
87
+ </Pressable>
88
+ ))}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ /** Dependency-free slider (0..1). Commits at most once per frame while dragging. */
94
+ function Slider({ value, onChange, accent }: { value: number; onChange: (v: number) => void; accent: string }) {
95
+ const width = useRef(1);
96
+ const live = useRef({ value, onChange, raf: 0 as number | 0, next: value });
97
+ live.current.value = value;
98
+ live.current.onChange = onChange;
99
+ const emit = (v: number) => {
100
+ live.current.next = Math.min(1, Math.max(0, v));
101
+ if (live.current.raf) return;
102
+ live.current.raf = requestAnimationFrame(() => {
103
+ live.current.raf = 0;
104
+ live.current.onChange(live.current.next);
105
+ });
106
+ };
107
+ const pan = useRef((() => {
108
+ let startX = 0;
109
+ return PanResponder.create({
110
+ onStartShouldSetPanResponder: () => true,
111
+ onMoveShouldSetPanResponder: () => true,
112
+ onPanResponderTerminationRequest: () => false,
113
+ onPanResponderGrant: (e) => {
114
+ startX = e.nativeEvent.locationX;
115
+ emit(startX / width.current);
116
+ },
117
+ onPanResponderMove: (_, g) => emit((startX + g.dx) / width.current),
118
+ });
119
+ })()).current;
120
+ return (
121
+ <View style={s.sliderHit} onLayout={(e) => (width.current = Math.max(1, e.nativeEvent.layout.width))} {...pan.panHandlers}>
122
+ <View style={s.track} pointerEvents="none">
123
+ <View style={[s.fill, { width: `${value * 100}%`, backgroundColor: accent }]} />
124
+ </View>
125
+ <View pointerEvents="none" style={[s.thumb, { left: `${value * 100}%`, borderColor: accent }]} />
126
+ </View>
127
+ );
128
+ }
129
+
130
+ function Btn({ label, onPress }: { label: string; onPress: () => void }) {
131
+ return (
132
+ <Pressable onPress={onPress} style={s.btn}>
133
+ <Text style={s.btnText}>{label}</Text>
134
+ </Pressable>
135
+ );
136
+ }
137
+
138
+ const s = StyleSheet.create({
139
+ root: { flex: 1, backgroundColor: '#fff' },
140
+ preview: { flex: 1.1, backgroundColor: '#eef1f5' },
141
+ topBar: { position: 'absolute', top: 12, left: 12, right: 12, flexDirection: 'row', justifyContent: 'space-between' },
142
+ btn: { backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 18, paddingHorizontal: 14, paddingVertical: 8 },
143
+ btnText: { fontSize: 15, color: '#1d1d1f' },
144
+ tabs: { flexGrow: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ddd' },
145
+ tabsInner: { paddingHorizontal: 8 },
146
+ tab: { paddingHorizontal: 12, paddingVertical: 12, borderBottomWidth: 2, borderBottomColor: 'transparent' },
147
+ tabText: { fontSize: 14, color: '#666' },
148
+ panel: { flex: 1, paddingHorizontal: 16 },
149
+ row: { marginTop: 14 },
150
+ label: { fontSize: 12, color: '#666', marginBottom: 8 },
151
+ wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
152
+ chip: { borderWidth: 1, borderColor: '#d5d8dd', borderRadius: 16, paddingHorizontal: 12, paddingVertical: 6 },
153
+ chipText: { fontSize: 13, color: '#1d1d1f' },
154
+ swatch: { width: 30, height: 30, borderRadius: 15, borderWidth: 1, borderColor: '#ccc' },
155
+ sliderHit: { height: 32, justifyContent: 'center' },
156
+ track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
157
+ fill: { height: 4 },
158
+ thumb: { position: 'absolute', width: 22, height: 22, marginLeft: -11, borderRadius: 11, backgroundColor: '#fff', borderWidth: 2 },
159
+ save: { margin: 16, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
160
+ saveText: { color: '#fff', fontSize: 16, fontWeight: '600' },
161
+ });
@@ -0,0 +1,135 @@
1
+ // <AvatarView>: renders an avatar with expo-gl + three (no expo-three, no R3F).
2
+ // Renders on demand only (on change / drag), so an idle avatar costs zero GPU.
3
+ import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
4
+ import { PanResponder, StyleProp, View, ViewStyle } from 'react-native';
5
+ import { ExpoWebGLRenderingContext, GLView } from 'expo-gl';
6
+ import { WebGLRenderer } from 'three';
7
+ import { AvatarConfig, AvatarStage, Framing, avatarToGLB, encodeAvatar, normalizeConfig, toBase64 } from '../core';
8
+
9
+ export interface AvatarViewProps {
10
+ config: Partial<AvatarConfig>;
11
+ framing?: Framing;
12
+ /** Hex background, or null for transparent. */
13
+ background?: string | null;
14
+ /** Faceted low-poly shading (default true). */
15
+ flatShading?: boolean;
16
+ /** Drag horizontally to spin (default true). */
17
+ rotatable?: boolean;
18
+ /** Slowly spin continuously (renders every frame while on). */
19
+ autoRotate?: boolean;
20
+ /** Initial yaw in radians. */
21
+ yaw?: number;
22
+ style?: StyleProp<ViewStyle>;
23
+ onReady?: () => void;
24
+ }
25
+
26
+ export interface AvatarViewHandle {
27
+ /** Render and save a PNG; resolves to a local file URI. */
28
+ snapshot(): Promise<string | null>;
29
+ /** Binary glTF of the current avatar, base64 encoded (write with expo-file-system). */
30
+ toGLBBase64(): string;
31
+ setYaw(yaw: number): void;
32
+ }
33
+
34
+ export const AvatarView = forwardRef<AvatarViewHandle, AvatarViewProps>(function AvatarView(
35
+ { config, framing = 'body', background = '#eef1f5', flatShading = true, rotatable = true, autoRotate = false, yaw = 0, style, onReady },
36
+ ref,
37
+ ) {
38
+ const stage = useMemo(() => new AvatarStage(background), []);
39
+ const gl = useRef<ExpoWebGLRenderingContext | null>(null);
40
+ const renderer = useRef<WebGLRenderer | null>(null);
41
+ const pending = useRef(false);
42
+ const live = useRef({ autoRotate, config, flatShading });
43
+ live.current = { autoRotate, config, flatShading };
44
+ const key = encodeAvatar(normalizeConfig(config));
45
+
46
+ const requestRender = () => {
47
+ if (pending.current || !gl.current || !renderer.current) return;
48
+ pending.current = true;
49
+ requestAnimationFrame(() => {
50
+ pending.current = false;
51
+ if (!gl.current || !renderer.current) return;
52
+ if (live.current.autoRotate) stage.setYaw(stage.yaw + 0.012);
53
+ renderer.current.render(stage.scene, stage.camera);
54
+ gl.current.endFrameEXP();
55
+ if (live.current.autoRotate) requestRender();
56
+ });
57
+ };
58
+
59
+ const onContextCreate = (ctx: ExpoWebGLRenderingContext) => {
60
+ const w = ctx.drawingBufferWidth, h = ctx.drawingBufferHeight;
61
+ // Minimal canvas shim: three only needs size + no-op listeners when given a context.
62
+ const canvas = {
63
+ width: w, height: h, style: {}, clientWidth: w, clientHeight: h,
64
+ addEventListener: () => {}, removeEventListener: () => {}, getContext: () => ctx,
65
+ };
66
+ const r = new WebGLRenderer({ canvas: canvas as any, context: ctx as any, antialias: true, alpha: background === null });
67
+ r.setPixelRatio(1);
68
+ r.setSize(w, h, false);
69
+ if (background === null) r.setClearColor(0x000000, 0);
70
+ gl.current = ctx;
71
+ renderer.current = r;
72
+ stage.setSize(w, h);
73
+ stage.setFraming(framing);
74
+ stage.setYaw(yaw);
75
+ stage.setAvatar(config, { flatShading });
76
+ requestRender();
77
+ onReady?.();
78
+ };
79
+
80
+ useEffect(() => {
81
+ if (!renderer.current) return;
82
+ stage.setAvatar(live.current.config, { flatShading: live.current.flatShading });
83
+ requestRender();
84
+ }, [key, flatShading]);
85
+
86
+ useEffect(() => {
87
+ stage.setFraming(framing);
88
+ requestRender();
89
+ }, [framing]);
90
+
91
+ useEffect(() => {
92
+ if (autoRotate) requestRender();
93
+ }, [autoRotate]);
94
+
95
+ useEffect(() => () => {
96
+ stage.dispose();
97
+ renderer.current?.dispose();
98
+ gl.current = null;
99
+ renderer.current = null;
100
+ }, []);
101
+
102
+ const pan = useMemo(() => {
103
+ let start = 0;
104
+ return PanResponder.create({
105
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 4 && Math.abs(g.dx) > Math.abs(g.dy),
106
+ onPanResponderGrant: () => { start = stage.yaw; },
107
+ onPanResponderMove: (_, g) => {
108
+ stage.setYaw(start + g.dx * 0.012);
109
+ requestRender();
110
+ },
111
+ onPanResponderTerminationRequest: () => false,
112
+ });
113
+ }, []);
114
+
115
+ useImperativeHandle(ref, () => ({
116
+ async snapshot() {
117
+ if (!gl.current || !renderer.current) return null;
118
+ renderer.current.render(stage.scene, stage.camera);
119
+ const snap = await GLView.takeSnapshotAsync(gl.current, { format: 'png' });
120
+ gl.current.endFrameEXP();
121
+ return typeof snap.uri === 'string' ? snap.uri : null;
122
+ },
123
+ toGLBBase64: () => toBase64(avatarToGLB(live.current.config, { flatShading: live.current.flatShading })),
124
+ setYaw(y: number) {
125
+ stage.setYaw(y);
126
+ requestRender();
127
+ },
128
+ }));
129
+
130
+ return (
131
+ <View style={[{ overflow: 'hidden' }, style]} {...(rotatable ? pan.panHandlers : null)}>
132
+ <GLView style={{ flex: 1 }} onContextCreate={onContextCreate} />
133
+ </View>
134
+ );
135
+ });
@@ -0,0 +1,149 @@
1
+ // <HumanView>: realistic MakeHuman-based avatar for React Native (expo-gl + three).
2
+ // Renders on demand only; a static avatar costs no GPU after the first frame.
3
+ import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
4
+ import { PanResponder, StyleProp, View, ViewStyle } from 'react-native';
5
+ import { ExpoWebGLRenderingContext, GLView } from 'expo-gl';
6
+ import {
7
+ ACESFilmicToneMapping, Color, DirectionalLight, HemisphereLight, PerspectiveCamera, Scene, SRGBColorSpace, Vector3, WebGLRenderer,
8
+ } from 'three';
9
+ import { HumanAvatar, HumanConfig, HumanPackage, loadHumanPackage } from '../human/runtime';
10
+ import { expoLoadOptions } from './humanExpo';
11
+
12
+ /** Load (and disk-cache) the human package once per base URL. */
13
+ const packages = new Map<string, Promise<HumanPackage>>();
14
+ export function useHumanPackage(baseUrl: string) {
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, expoLoadOptions);
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
+ }
30
+
31
+ export interface HumanViewProps {
32
+ pkg: HumanPackage;
33
+ config: Partial<HumanConfig>;
34
+ framing?: 'face' | 'body';
35
+ /** ARKit-style expression weights, e.g. { mouthSmile: 1 } */
36
+ expressions?: Record<string, number>;
37
+ background?: string;
38
+ rotatable?: boolean;
39
+ /** '1k' textures for lists/low-end devices, '2k' for the editor. */
40
+ textureSize?: '1k' | '2k';
41
+ style?: StyleProp<ViewStyle>;
42
+ onReady?: (avatar: HumanAvatar) => void;
43
+ }
44
+
45
+ export interface HumanViewHandle {
46
+ avatar: HumanAvatar | null;
47
+ render(): void;
48
+ snapshot(): Promise<string | null>;
49
+ }
50
+
51
+ export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function HumanView(
52
+ { pkg, config, framing = 'face', expressions, background = '#e9ecf1', rotatable = true, textureSize = '2k', style, onReady }, ref,
53
+ ) {
54
+ const gl = useRef<ExpoWebGLRenderingContext | null>(null);
55
+ const renderer = useRef<WebGLRenderer | null>(null);
56
+ const yaw = useRef(0);
57
+ const pending = useRef(false);
58
+ const stage = useMemo(() => {
59
+ const scene = new Scene();
60
+ scene.background = new Color(background);
61
+ const key = new DirectionalLight('#fff4e8', 2.2);
62
+ key.position.set(1.2, 2.2, 2.5);
63
+ const rim = new DirectionalLight('#dfe8ff', 1.0);
64
+ rim.position.set(-2, 2, -2);
65
+ scene.add(key, rim, new HemisphereLight('#ffffff', '#b9a99a', 1.1));
66
+ const avatar = new HumanAvatar(pkg);
67
+ avatar.textureSize = textureSize;
68
+ scene.add(avatar.group);
69
+ return { scene, camera: new PerspectiveCamera(24, 1, 0.05, 50), avatar };
70
+ }, [pkg]);
71
+
72
+ const draw = () => {
73
+ if (!gl.current || !renderer.current) return;
74
+ const { camera, avatar } = stage;
75
+ const m = avatar.metrics();
76
+ const target = framing === 'face' ? new Vector3(0, m.headY - 0.08, 0) : new Vector3(0, m.height * 0.52, 0);
77
+ const h = framing === 'face' ? 0.42 : m.height * 1.12;
78
+ const dist = h / 2 / Math.tan((camera.fov * Math.PI) / 360) / Math.min(1, camera.aspect * 1.1);
79
+ camera.position.set(Math.sin(yaw.current) * dist, target.y + dist * 0.03, Math.cos(yaw.current) * dist);
80
+ camera.lookAt(target);
81
+ renderer.current.render(stage.scene, camera);
82
+ gl.current.endFrameEXP();
83
+ };
84
+ const requestRender = () => {
85
+ if (pending.current) return;
86
+ pending.current = true;
87
+ requestAnimationFrame(() => { pending.current = false; draw(); });
88
+ };
89
+
90
+ const onContextCreate = (ctx: ExpoWebGLRenderingContext) => {
91
+ const w = ctx.drawingBufferWidth, h = ctx.drawingBufferHeight;
92
+ const canvas = { width: w, height: h, style: {}, clientWidth: w, clientHeight: h, addEventListener: () => {}, removeEventListener: () => {}, getContext: () => ctx };
93
+ const r = new WebGLRenderer({ canvas: canvas as any, context: ctx as any, antialias: true });
94
+ r.setPixelRatio(1);
95
+ r.setSize(w, h, false);
96
+ r.outputColorSpace = SRGBColorSpace;
97
+ r.toneMapping = ACESFilmicToneMapping;
98
+ stage.camera.aspect = w / h;
99
+ stage.camera.updateProjectionMatrix();
100
+ gl.current = ctx;
101
+ renderer.current = r;
102
+ requestRender();
103
+ };
104
+
105
+ const key = JSON.stringify(config);
106
+ useEffect(() => {
107
+ let live = true;
108
+ stage.avatar.setConfig(config).then(() => {
109
+ if (!live) return;
110
+ requestRender();
111
+ onReady?.(stage.avatar);
112
+ });
113
+ return () => { live = false; };
114
+ }, [key, stage]);
115
+
116
+ useEffect(() => {
117
+ for (const [k, v] of Object.entries(expressions ?? {})) stage.avatar.setExpression(k, v);
118
+ requestRender();
119
+ }, [JSON.stringify(expressions), framing]);
120
+
121
+ useEffect(() => () => { stage.avatar.dispose(); renderer.current?.dispose(); gl.current = null; renderer.current = null; }, [stage]);
122
+
123
+ const pan = useMemo(() => {
124
+ let start = 0;
125
+ return PanResponder.create({
126
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 4 && Math.abs(g.dx) > Math.abs(g.dy),
127
+ onPanResponderGrant: () => { start = yaw.current; },
128
+ onPanResponderMove: (_, g) => { yaw.current = start - g.dx * 0.01; requestRender(); },
129
+ onPanResponderTerminationRequest: () => false,
130
+ });
131
+ }, []);
132
+
133
+ useImperativeHandle(ref, () => ({
134
+ avatar: stage.avatar,
135
+ render: requestRender,
136
+ async snapshot() {
137
+ if (!gl.current) return null;
138
+ draw();
139
+ const snap = await GLView.takeSnapshotAsync(gl.current, { format: 'png' });
140
+ return typeof snap.uri === 'string' ? snap.uri : null;
141
+ },
142
+ }));
143
+
144
+ return (
145
+ <View style={[{ overflow: 'hidden' }, style]} {...(rotatable ? pan.panHandlers : null)}>
146
+ <GLView style={{ flex: 1 }} onContextCreate={onContextCreate} />
147
+ </View>
148
+ );
149
+ });
@@ -0,0 +1,58 @@
1
+ // React Native (Expo) adapters for the realistic human: a disk-cached fetcher and an
2
+ // expo-gl texture loader. Pass both to loadHumanPackage (useHumanPackage does it for you).
3
+ import * as FileSystem from 'expo-file-system/legacy';
4
+ import { Texture } from 'three';
5
+ import type { HumanTextureLoader, LoadOptions } from '../human/runtime';
6
+
7
+ const DIR = `${FileSystem.cacheDirectory ?? ''}humanforge/`;
8
+ let dirReady: Promise<void> | null = null;
9
+
10
+ /** Download once into the cache directory; later sessions load offline. */
11
+ async function cached(url: string): Promise<string> {
12
+ dirReady ??= FileSystem.makeDirectoryAsync(DIR, { intermediates: true }).catch(() => {});
13
+ await dirReady;
14
+ const file = DIR + url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9._-]/g, '_');
15
+ const info = await FileSystem.getInfoAsync(file);
16
+ if (!info.exists) {
17
+ const res = await FileSystem.downloadAsync(url, file);
18
+ if (res.status !== 200) {
19
+ await FileSystem.deleteAsync(file, { idempotent: true });
20
+ throw new Error(`humanforge: ${res.status} for ${url}`);
21
+ }
22
+ }
23
+ return file;
24
+ }
25
+
26
+ const B64 = new Uint8Array(128);
27
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('').forEach((c, i) => (B64[c.charCodeAt(0)] = i));
28
+ function fromBase64(s: string): ArrayBuffer {
29
+ const clean = s.replace(/[^A-Za-z0-9+/]/g, '');
30
+ const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
31
+ let o = 0;
32
+ for (let i = 0; i < clean.length; i += 4) {
33
+ const n = (B64[clean.charCodeAt(i)] << 18) | (B64[clean.charCodeAt(i + 1)] << 12) | (B64[clean.charCodeAt(i + 2)] << 6) | B64[clean.charCodeAt(i + 3)];
34
+ out[o++] = n >> 16;
35
+ if (o < out.length) out[o++] = (n >> 8) & 255;
36
+ if (o < out.length) out[o++] = n & 255;
37
+ }
38
+ return out.buffer;
39
+ }
40
+
41
+ export const expoFetch = async (url: string): Promise<ArrayBuffer> =>
42
+ fromBase64(await FileSystem.readAsStringAsync(await cached(url), { encoding: FileSystem.EncodingType.Base64 }));
43
+
44
+ /**
45
+ * expo-gl decodes images natively when texImage2D receives `{ localUri }`, so we hand three
46
+ * a "data texture" whose data is the cached file (same trick expo-three uses).
47
+ */
48
+ export const expoTextureLoader: HumanTextureLoader = async (url, size) => {
49
+ const localUri = await cached(url);
50
+ const tex = new Texture();
51
+ tex.image = { data: { localUri }, width: size, height: size };
52
+ (tex as any).isDataTexture = true;
53
+ tex.generateMipmaps = false;
54
+ tex.needsUpdate = true;
55
+ return tex;
56
+ };
57
+
58
+ export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader };
@@ -0,0 +1,7 @@
1
+ export { AvatarView } from './AvatarView';
2
+ export type { AvatarViewHandle, AvatarViewProps } from './AvatarView';
3
+ export { AvatarCreator } from './AvatarCreator';
4
+ export type { AvatarCreatorProps } from './AvatarCreator';
5
+ export { HumanView, useHumanPackage } from './HumanView';
6
+ export type { HumanViewHandle, HumanViewProps } from './HumanView';
7
+ export { expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';