talking-head-studio 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/LICENSE +21 -0
- package/README.md +459 -0
- package/dist/TalkingHead.d.ts +35 -0
- package/dist/TalkingHead.d.ts.map +1 -0
- package/dist/TalkingHead.js +107 -0
- package/dist/TalkingHead.web.d.ts +35 -0
- package/dist/TalkingHead.web.d.ts.map +1 -0
- package/dist/TalkingHead.web.js +117 -0
- package/dist/__tests__/TalkingHead.test.d.ts +2 -0
- package/dist/__tests__/TalkingHead.test.d.ts.map +1 -0
- package/dist/__tests__/TalkingHead.test.js +23 -0
- package/dist/__tests__/sketchfab.test.d.ts +2 -0
- package/dist/__tests__/sketchfab.test.d.ts.map +1 -0
- package/dist/__tests__/sketchfab.test.js +21 -0
- package/dist/appearance/apply.d.ts +7 -0
- package/dist/appearance/apply.d.ts.map +1 -0
- package/dist/appearance/apply.js +56 -0
- package/dist/appearance/index.d.ts +5 -0
- package/dist/appearance/index.d.ts.map +1 -0
- package/dist/appearance/index.js +3 -0
- package/dist/appearance/matchers.d.ts +3 -0
- package/dist/appearance/matchers.d.ts.map +1 -0
- package/dist/appearance/matchers.js +32 -0
- package/dist/appearance/schema.d.ts +9 -0
- package/dist/appearance/schema.d.ts.map +1 -0
- package/dist/appearance/schema.js +20 -0
- package/dist/editor/AvatarCanvas.d.ts +16 -0
- package/dist/editor/AvatarCanvas.d.ts.map +1 -0
- package/dist/editor/AvatarCanvas.js +85 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts +17 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +1 -0
- package/dist/editor/AvatarCanvasErrorBoundary.js +41 -0
- package/dist/editor/AvatarModel.d.ts +12 -0
- package/dist/editor/AvatarModel.d.ts.map +1 -0
- package/dist/editor/AvatarModel.js +31 -0
- package/dist/editor/RigidAccessory.d.ts +15 -0
- package/dist/editor/RigidAccessory.d.ts.map +1 -0
- package/dist/editor/RigidAccessory.js +76 -0
- package/dist/editor/SkinnedClothing.d.ts +7 -0
- package/dist/editor/SkinnedClothing.d.ts.map +1 -0
- package/dist/editor/SkinnedClothing.js +88 -0
- package/dist/editor/index.d.ts +6 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/types.d.ts +28 -0
- package/dist/editor/types.d.ts.map +1 -0
- package/dist/editor/types.js +1 -0
- package/dist/html.d.ts +13 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +560 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.web.d.ts +4 -0
- package/dist/index.web.d.ts.map +1 -0
- package/dist/index.web.js +2 -0
- package/dist/sketchfab/api.d.ts +12 -0
- package/dist/sketchfab/api.d.ts.map +1 -0
- package/dist/sketchfab/api.js +52 -0
- package/dist/sketchfab/categories.d.ts +5 -0
- package/dist/sketchfab/categories.d.ts.map +1 -0
- package/dist/sketchfab/categories.js +124 -0
- package/dist/sketchfab/index.d.ts +7 -0
- package/dist/sketchfab/index.d.ts.map +1 -0
- package/dist/sketchfab/index.js +3 -0
- package/dist/sketchfab/types.d.ts +51 -0
- package/dist/sketchfab/types.d.ts.map +1 -0
- package/dist/sketchfab/types.js +1 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts +19 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts.map +1 -0
- package/dist/sketchfab/useSketchfabSearch.js +78 -0
- package/dist/voice/convertToWav.d.ts +6 -0
- package/dist/voice/convertToWav.d.ts.map +1 -0
- package/dist/voice/convertToWav.js +74 -0
- package/dist/voice/index.d.ts +6 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/useAudioPlayer.d.ts +11 -0
- package/dist/voice/useAudioPlayer.d.ts.map +1 -0
- package/dist/voice/useAudioPlayer.js +61 -0
- package/dist/voice/useAudioRecording.d.ts +14 -0
- package/dist/voice/useAudioRecording.d.ts.map +1 -0
- package/dist/voice/useAudioRecording.js +162 -0
- package/package.json +120 -0
- package/src/TalkingHead.tsx +207 -0
- package/src/TalkingHead.web.tsx +210 -0
- package/src/__tests__/TalkingHead.test.tsx +32 -0
- package/src/__tests__/sketchfab.test.ts +24 -0
- package/src/appearance/apply.ts +94 -0
- package/src/appearance/index.ts +4 -0
- package/src/appearance/matchers.ts +43 -0
- package/src/appearance/schema.ts +35 -0
- package/src/editor/AvatarCanvas.tsx +167 -0
- package/src/editor/AvatarCanvasErrorBoundary.tsx +64 -0
- package/src/editor/AvatarModel.tsx +49 -0
- package/src/editor/RigidAccessory.tsx +130 -0
- package/src/editor/SkinnedClothing.tsx +114 -0
- package/src/editor/index.ts +5 -0
- package/src/editor/r3f-shim.d.ts +34 -0
- package/src/editor/types.ts +30 -0
- package/src/html.ts +572 -0
- package/src/index.ts +8 -0
- package/src/index.web.ts +8 -0
- package/src/sketchfab/api.ts +82 -0
- package/src/sketchfab/categories.ts +127 -0
- package/src/sketchfab/index.ts +6 -0
- package/src/sketchfab/types.ts +40 -0
- package/src/sketchfab/useSketchfabSearch.ts +110 -0
- package/src/voice/convertToWav.ts +87 -0
- package/src/voice/index.ts +7 -0
- package/src/voice/useAudioPlayer.ts +78 -0
- package/src/voice/useAudioRecording.ts +207 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type AppearanceTarget = 'hairColor' | 'skinColor' | 'eyeColor';
|
|
2
|
+
|
|
3
|
+
function tokenizeMaterialName(name: string): string[] {
|
|
4
|
+
return name
|
|
5
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.split(/[^a-z\d]+/)
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Exact token match: any token equals the candidate exactly
|
|
12
|
+
function hasToken(tokens: string[], candidates: string[]): boolean {
|
|
13
|
+
return candidates.some((candidate) => tokens.includes(candidate));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Prefix match: candidate appears at the START of a token.
|
|
17
|
+
// e.g. "eye" matches token "eyeball", "eyelid", "eyebrow" but NOT "chair" (c-hair).
|
|
18
|
+
// We intentionally avoid suffix/contains to prevent false positives like chair→hair.
|
|
19
|
+
function hasTokenPrefix(tokens: string[], candidates: string[]): boolean {
|
|
20
|
+
return candidates.some((candidate) =>
|
|
21
|
+
tokens.some((token) => token.startsWith(candidate) && token.length > candidate.length),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pickTargetForMaterialName(name: string): AppearanceTarget | null {
|
|
26
|
+
const tokens = tokenizeMaterialName(name);
|
|
27
|
+
|
|
28
|
+
if (hasToken(tokens, ['hair', 'fur']) || hasTokenPrefix(tokens, ['hair', 'fur'])) {
|
|
29
|
+
return 'hairColor';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (hasToken(tokens, ['skin', 'body', 'face']) || hasTokenPrefix(tokens, ['skin', 'body', 'face'])) {
|
|
33
|
+
return 'skinColor';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "eye" prefix catches: eyeball, eyelid, eyebrow, eyelash
|
|
37
|
+
// "eye" suffix catches: lefteye, righteye
|
|
38
|
+
if (hasToken(tokens, ['eye', 'iris']) || hasTokenPrefix(tokens, ['eye', 'iris'])) {
|
|
39
|
+
return 'eyeColor';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type AppearanceVersion = 1;
|
|
2
|
+
|
|
3
|
+
export interface AvatarAppearance {
|
|
4
|
+
version: AppearanceVersion;
|
|
5
|
+
hairColor?: string;
|
|
6
|
+
skinColor?: string;
|
|
7
|
+
eyeColor?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const HEX_COLOR_PATTERN = /^#(?:[\dA-Fa-f]{3}|[\dA-Fa-f]{6})$/;
|
|
11
|
+
|
|
12
|
+
function normalizeHexColor(value: string): string {
|
|
13
|
+
if (!HEX_COLOR_PATTERN.test(value)) {
|
|
14
|
+
throw new Error(`Invalid color format: ${value}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = value.slice(1);
|
|
18
|
+
const expanded =
|
|
19
|
+
raw.length === 3 ? `${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : raw;
|
|
20
|
+
|
|
21
|
+
return `#${expanded.toUpperCase()}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeAppearance(input: AvatarAppearance): AvatarAppearance {
|
|
25
|
+
if (input.version !== 1) {
|
|
26
|
+
throw new Error(`Unsupported appearance version: ${input.version}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
version: input.version,
|
|
31
|
+
hairColor: input.hairColor != null ? normalizeHexColor(input.hairColor) : undefined,
|
|
32
|
+
skinColor: input.skinColor != null ? normalizeHexColor(input.skinColor) : undefined,
|
|
33
|
+
eyeColor: input.eyeColor != null ? normalizeHexColor(input.eyeColor) : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { Environment, OrbitControls } from '@react-three/drei';
|
|
3
|
+
import { Canvas, useThree } from '@react-three/fiber';
|
|
4
|
+
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import type { Vector3 } from 'three';
|
|
6
|
+
import type { AvatarAppearance } from '../appearance';
|
|
7
|
+
import { AvatarCanvasErrorBoundary } from './AvatarCanvasErrorBoundary';
|
|
8
|
+
import { AvatarModel } from './AvatarModel';
|
|
9
|
+
import { RigidAccessory } from './RigidAccessory';
|
|
10
|
+
import { SkinnedClothing } from './SkinnedClothing';
|
|
11
|
+
import type { AssetPlacement, EquippedAsset } from './types';
|
|
12
|
+
|
|
13
|
+
/** Captures the R3F scene object (which contains everything) and passes it up. */
|
|
14
|
+
function SceneRefCapture({ onSceneRef }: { onSceneRef: (scene: any) => void }) {
|
|
15
|
+
const { scene } = useThree();
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
onSceneRef(scene);
|
|
18
|
+
}, [scene, onSceneRef]);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AvatarCanvasProps {
|
|
23
|
+
avatarUrl: string;
|
|
24
|
+
appearance?: AvatarAppearance;
|
|
25
|
+
equipped?: EquippedAsset[];
|
|
26
|
+
placements?: Record<string, AssetPlacement>;
|
|
27
|
+
editingAssetId?: string | null;
|
|
28
|
+
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
onSceneRef?: (scene: any) => void;
|
|
31
|
+
style?: React.CSSProperties;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function AvatarCanvas({
|
|
36
|
+
avatarUrl,
|
|
37
|
+
appearance,
|
|
38
|
+
equipped = [],
|
|
39
|
+
placements = {},
|
|
40
|
+
editingAssetId = null,
|
|
41
|
+
onPlacementChange,
|
|
42
|
+
onSceneRef,
|
|
43
|
+
style,
|
|
44
|
+
className,
|
|
45
|
+
}: AvatarCanvasProps) {
|
|
46
|
+
const [skeleton, setSkeleton] = useState<any>(null);
|
|
47
|
+
const [avatarScene, setAvatarScene] = useState<any>(null);
|
|
48
|
+
const [cameraTarget, setCameraTarget] = useState<[number, number, number]>([0, 1, 0]);
|
|
49
|
+
const [cameraPosition, setCameraPosition] = useState<[number, number, number]>([0, 1.2, 2.5]);
|
|
50
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
51
|
+
|
|
52
|
+
const handleSkeletonReady = useCallback((skel: any) => {
|
|
53
|
+
setSkeleton(skel);
|
|
54
|
+
if (skel.bones.length > 0) {
|
|
55
|
+
const root = skel.bones[0].parent ?? skel.bones[0];
|
|
56
|
+
setAvatarScene(root);
|
|
57
|
+
}
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const handleBoundsReady = useCallback((center: Vector3, height: number) => {
|
|
61
|
+
const dist = Math.max(height * 1.4, 1.5);
|
|
62
|
+
setCameraTarget([center.x, center.y, center.z]);
|
|
63
|
+
setCameraPosition([center.x, center.y + height * 0.1, center.z + dist]);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const equippedItems = equipped.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
const wrapperClass = [
|
|
69
|
+
'w-full h-full overflow-hidden relative bg-[radial-gradient(ellipse_at_center,_rgba(30,20,60,0.6)_0%,_rgba(5,5,15,0.95)_100%)] [background-size:100%_100%]',
|
|
70
|
+
className,
|
|
71
|
+
]
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(' ');
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={wrapperClass} style={style}>
|
|
77
|
+
{/* Subtle grid floor */}
|
|
78
|
+
<div className="absolute inset-0 opacity-[0.04] pointer-events-none"
|
|
79
|
+
style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.3) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.3) 1px,transparent 1px)', backgroundSize: '60px 60px' }} />
|
|
80
|
+
{avatarUrl ? (
|
|
81
|
+
<AvatarCanvasErrorBoundary>
|
|
82
|
+
<Canvas
|
|
83
|
+
ref={canvasRef}
|
|
84
|
+
camera={{ position: cameraPosition, fov: 45 }}
|
|
85
|
+
style={{ width: '100%', height: '100%' }}
|
|
86
|
+
>
|
|
87
|
+
<Suspense fallback={null}>
|
|
88
|
+
{onSceneRef && <SceneRefCapture onSceneRef={onSceneRef} />}
|
|
89
|
+
<ambientLight intensity={0.6} />
|
|
90
|
+
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
|
91
|
+
<Environment preset="studio" />
|
|
92
|
+
<OrbitControls
|
|
93
|
+
target={cameraTarget}
|
|
94
|
+
minDistance={1}
|
|
95
|
+
maxDistance={10}
|
|
96
|
+
enablePan={false}
|
|
97
|
+
makeDefault
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<AvatarModel
|
|
101
|
+
url={avatarUrl}
|
|
102
|
+
appearance={appearance}
|
|
103
|
+
scale={1}
|
|
104
|
+
onSkeletonReady={handleSkeletonReady}
|
|
105
|
+
onBoundsReady={handleBoundsReady}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
{equippedItems.map((asset) => {
|
|
109
|
+
if (!asset) return null;
|
|
110
|
+
const llmPlacement = placements[asset.id];
|
|
111
|
+
const isEditing = editingAssetId === asset.id;
|
|
112
|
+
|
|
113
|
+
// If a placement is provided, always render as rigid attachment
|
|
114
|
+
if (llmPlacement) {
|
|
115
|
+
const bone = llmPlacement.bone ?? asset.attach_bone;
|
|
116
|
+
if (!bone) return null;
|
|
117
|
+
return (
|
|
118
|
+
<RigidAccessory
|
|
119
|
+
key={asset.id}
|
|
120
|
+
assetId={asset.id}
|
|
121
|
+
url={asset.url}
|
|
122
|
+
avatarScene={avatarScene}
|
|
123
|
+
attachBone={bone}
|
|
124
|
+
offsetPosition={llmPlacement.position}
|
|
125
|
+
offsetRotation={llmPlacement.rotation}
|
|
126
|
+
scale={llmPlacement.scale}
|
|
127
|
+
isEditing={isEditing}
|
|
128
|
+
onPlacementChange={onPlacementChange}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (asset.type === 'skinned') {
|
|
134
|
+
return (
|
|
135
|
+
<SkinnedClothing key={asset.id} url={asset.url} avatarSkeleton={skeleton} />
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (asset.type === 'rigid') {
|
|
139
|
+
const bone = asset.attach_bone;
|
|
140
|
+
if (!bone) return null;
|
|
141
|
+
return (
|
|
142
|
+
<RigidAccessory
|
|
143
|
+
key={asset.id}
|
|
144
|
+
assetId={asset.id}
|
|
145
|
+
url={asset.url}
|
|
146
|
+
avatarScene={avatarScene}
|
|
147
|
+
attachBone={bone}
|
|
148
|
+
offsetPosition={asset.offset_position}
|
|
149
|
+
offsetRotation={asset.offset_rotation}
|
|
150
|
+
isEditing={isEditing}
|
|
151
|
+
onPlacementChange={onPlacementChange}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
})}
|
|
157
|
+
</Suspense>
|
|
158
|
+
</Canvas>
|
|
159
|
+
</AvatarCanvasErrorBoundary>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
162
|
+
<p>Select a base avatar to get started</p>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface AvatarCanvasErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface AvatarCanvasErrorBoundaryState {
|
|
8
|
+
hasError: boolean;
|
|
9
|
+
retryKey: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AvatarCanvasErrorBoundary extends Component<
|
|
13
|
+
AvatarCanvasErrorBoundaryProps,
|
|
14
|
+
AvatarCanvasErrorBoundaryState
|
|
15
|
+
> {
|
|
16
|
+
state: AvatarCanvasErrorBoundaryState = {
|
|
17
|
+
hasError: false,
|
|
18
|
+
retryKey: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
22
|
+
console.error('[AvatarCanvas] WebGL error', error, errorInfo.componentStack);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static getDerivedStateFromError(): AvatarCanvasErrorBoundaryState {
|
|
26
|
+
return {
|
|
27
|
+
hasError: true,
|
|
28
|
+
retryKey: 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private handleRetry = () => {
|
|
33
|
+
this.setState((prev) => ({
|
|
34
|
+
hasError: false,
|
|
35
|
+
retryKey: prev.retryKey + 1,
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
render() {
|
|
40
|
+
if (this.state.hasError) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center">
|
|
43
|
+
<p className="text-sm font-medium text-foreground">3D rendering unavailable</p>
|
|
44
|
+
<p className="text-xs text-muted-foreground">
|
|
45
|
+
Your device or browser cannot initialize WebGL.
|
|
46
|
+
</p>
|
|
47
|
+
<button
|
|
48
|
+
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
|
49
|
+
onClick={this.handleRetry}
|
|
50
|
+
type="button"
|
|
51
|
+
>
|
|
52
|
+
Retry
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="h-full w-full" key={this.state.retryKey}>
|
|
60
|
+
{this.props.children}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useGLTF } from '@react-three/drei';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { Box3, Vector3 } from 'three';
|
|
5
|
+
import { applyAppearanceToObject3D } from '../appearance';
|
|
6
|
+
import type { AvatarAppearance } from '../appearance';
|
|
7
|
+
|
|
8
|
+
interface AvatarModelProps {
|
|
9
|
+
url: string;
|
|
10
|
+
appearance?: AvatarAppearance;
|
|
11
|
+
scale?: number;
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
onSkeletonReady?: (skeleton: any) => void;
|
|
14
|
+
onBoundsReady?: (center: typeof Vector3.prototype, height: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AvatarModel({
|
|
18
|
+
url,
|
|
19
|
+
appearance,
|
|
20
|
+
scale = 1,
|
|
21
|
+
onSkeletonReady,
|
|
22
|
+
onBoundsReady,
|
|
23
|
+
}: AvatarModelProps) {
|
|
24
|
+
const { scene } = useGLTF(url);
|
|
25
|
+
const groupRef = useRef(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!scene) return;
|
|
29
|
+
scene.traverse((child: any) => {
|
|
30
|
+
if (child.isSkinnedMesh && child.skeleton) {
|
|
31
|
+
onSkeletonReady?.(child.skeleton);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
if (onBoundsReady) {
|
|
35
|
+
const box = new Box3().setFromObject(scene);
|
|
36
|
+
const center = new Vector3();
|
|
37
|
+
box.getCenter(center);
|
|
38
|
+
const height = box.max.y - box.min.y;
|
|
39
|
+
onBoundsReady(center, height);
|
|
40
|
+
}
|
|
41
|
+
}, [scene, onSkeletonReady, onBoundsReady]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!scene) return;
|
|
45
|
+
applyAppearanceToObject3D(scene, appearance ?? { version: 1 as const });
|
|
46
|
+
}, [scene, appearance]);
|
|
47
|
+
|
|
48
|
+
return <primitive ref={groupRef} object={scene} scale={scale} />;
|
|
49
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useGLTF, PivotControls } from '@react-three/drei';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Box3, Vector3 } from 'three';
|
|
5
|
+
import type { AssetPlacement } from './types';
|
|
6
|
+
|
|
7
|
+
interface RigidAccessoryProps {
|
|
8
|
+
assetId: string;
|
|
9
|
+
url: string;
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
avatarScene: any;
|
|
12
|
+
attachBone: string;
|
|
13
|
+
offsetPosition?: [number, number, number];
|
|
14
|
+
offsetRotation?: [number, number, number];
|
|
15
|
+
scale?: number;
|
|
16
|
+
isEditing?: boolean;
|
|
17
|
+
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RigidAccessory({
|
|
21
|
+
assetId,
|
|
22
|
+
url,
|
|
23
|
+
avatarScene,
|
|
24
|
+
attachBone,
|
|
25
|
+
offsetPosition,
|
|
26
|
+
offsetRotation,
|
|
27
|
+
scale = 1,
|
|
28
|
+
isEditing = false,
|
|
29
|
+
onPlacementChange,
|
|
30
|
+
}: RigidAccessoryProps) {
|
|
31
|
+
const { scene } = useGLTF(url);
|
|
32
|
+
|
|
33
|
+
// Clone uniquely for this instance
|
|
34
|
+
const clone = useMemo(() => scene.clone(true), [scene]);
|
|
35
|
+
const [basePos, setBasePos] = useState<typeof Vector3.prototype | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!avatarScene) return;
|
|
39
|
+
|
|
40
|
+
let targetBone: any = null;
|
|
41
|
+
avatarScene.traverse((child: any) => {
|
|
42
|
+
if (child.isBone && child.name === attachBone) {
|
|
43
|
+
targetBone = child;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!targetBone) return;
|
|
48
|
+
|
|
49
|
+
const box = new Box3().setFromObject(clone);
|
|
50
|
+
const meshMin = box.min;
|
|
51
|
+
const meshCenter = new Vector3();
|
|
52
|
+
box.getCenter(meshCenter);
|
|
53
|
+
|
|
54
|
+
const boneWorldPos = new Vector3();
|
|
55
|
+
targetBone.getWorldPosition(boneWorldPos);
|
|
56
|
+
|
|
57
|
+
setBasePos(new Vector3(
|
|
58
|
+
boneWorldPos.x - meshCenter.x,
|
|
59
|
+
boneWorldPos.y - meshMin.y,
|
|
60
|
+
boneWorldPos.z - meshCenter.z,
|
|
61
|
+
));
|
|
62
|
+
}, [avatarScene, attachBone, clone]);
|
|
63
|
+
|
|
64
|
+
// Apply transforms live
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!basePos) return;
|
|
67
|
+
|
|
68
|
+
clone.position.copy(basePos);
|
|
69
|
+
|
|
70
|
+
if (offsetPosition) {
|
|
71
|
+
clone.position.x += offsetPosition[0];
|
|
72
|
+
clone.position.y += offsetPosition[1];
|
|
73
|
+
clone.position.z += offsetPosition[2];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (offsetRotation) {
|
|
77
|
+
clone.rotation.set(...offsetRotation);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (scale !== undefined) {
|
|
81
|
+
clone.scale.set(scale, scale, scale);
|
|
82
|
+
}
|
|
83
|
+
}, [basePos, offsetPosition, offsetRotation, scale, clone]);
|
|
84
|
+
|
|
85
|
+
const handleDragEnd = () => {
|
|
86
|
+
if (!basePos) return;
|
|
87
|
+
|
|
88
|
+
// The PivotControls autoTransform updates the object directly
|
|
89
|
+
const newOffsetPos: [number, number, number] = [
|
|
90
|
+
clone.position.x - basePos.x,
|
|
91
|
+
clone.position.y - basePos.y,
|
|
92
|
+
clone.position.z - basePos.z,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const newOffsetRot: [number, number, number] = [
|
|
96
|
+
clone.rotation.x,
|
|
97
|
+
clone.rotation.y,
|
|
98
|
+
clone.rotation.z,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const currentScale = clone.scale.x;
|
|
102
|
+
|
|
103
|
+
onPlacementChange?.(assetId, {
|
|
104
|
+
bone: attachBone,
|
|
105
|
+
position: newOffsetPos,
|
|
106
|
+
rotation: newOffsetRot,
|
|
107
|
+
scale: currentScale,
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (!basePos) return null;
|
|
112
|
+
|
|
113
|
+
if (isEditing) {
|
|
114
|
+
return (
|
|
115
|
+
<PivotControls
|
|
116
|
+
anchor={[0, 0, 0]}
|
|
117
|
+
scale={100}
|
|
118
|
+
fixed={true}
|
|
119
|
+
depthTest={false}
|
|
120
|
+
activeAxes={[true, true, true]}
|
|
121
|
+
onDragEnd={handleDragEnd}
|
|
122
|
+
autoTransform={true}
|
|
123
|
+
>
|
|
124
|
+
<primitive object={clone} />
|
|
125
|
+
</PivotControls>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return <primitive object={clone} />;
|
|
130
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useGLTF } from '@react-three/drei';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
6
|
+
|
|
7
|
+
interface SkinnedClothingProps {
|
|
8
|
+
url: string;
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
avatarSkeleton: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rebind a skinned mesh's bones to the avatar skeleton by matching bone names.
|
|
15
|
+
* This handles hair packs / clothing that share the same rig convention but
|
|
16
|
+
* were exported as separate files with their own skeleton.
|
|
17
|
+
*/
|
|
18
|
+
function rebindToAvatarSkeleton(mesh: THREE.SkinnedMesh, avatarSkeleton: THREE.Skeleton) {
|
|
19
|
+
const avatarBonesByName = new Map<string, THREE.Bone>();
|
|
20
|
+
avatarSkeleton.bones.forEach((bone: THREE.Bone) => avatarBonesByName.set(bone.name, bone));
|
|
21
|
+
|
|
22
|
+
// Build a new bone array for this mesh by matching names
|
|
23
|
+
const newBones: THREE.Bone[] = [];
|
|
24
|
+
const oldBones = mesh.skeleton.bones;
|
|
25
|
+
const boneIndexMap = new Map<number, number>(); // old index → new index
|
|
26
|
+
|
|
27
|
+
oldBones.forEach((oldBone: THREE.Bone, oldIdx: number) => {
|
|
28
|
+
const matched = avatarBonesByName.get(oldBone.name);
|
|
29
|
+
if (matched) {
|
|
30
|
+
boneIndexMap.set(oldIdx, newBones.length);
|
|
31
|
+
newBones.push(matched);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (newBones.length === 0) {
|
|
36
|
+
// No bone names matched at all — just bind directly and hope for the best
|
|
37
|
+
mesh.bind(avatarSkeleton);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Remap skinIndex buffer to point at the new bone indices
|
|
42
|
+
const skinIndex = mesh.geometry.attributes.skinIndex;
|
|
43
|
+
const remappedIndex = skinIndex.array.slice();
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < remappedIndex.length; i++) {
|
|
46
|
+
const oldIdx = remappedIndex[i];
|
|
47
|
+
const newIdx = boneIndexMap.get(oldIdx);
|
|
48
|
+
remappedIndex[i] = newIdx !== undefined ? newIdx : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
mesh.geometry.setAttribute(
|
|
52
|
+
'skinIndex',
|
|
53
|
+
new THREE.Uint16BufferAttribute(remappedIndex, skinIndex.itemSize),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Build inverse bind matrices for the matched bones
|
|
57
|
+
const inverseBindMatrices: number[] = [];
|
|
58
|
+
newBones.forEach((bone: THREE.Bone) => {
|
|
59
|
+
const m = new THREE.Matrix4();
|
|
60
|
+
// Use the avatar skeleton's inverse bind matrix if bone index aligns,
|
|
61
|
+
// otherwise compute from world matrix
|
|
62
|
+
const avatarIdx = avatarSkeleton.bones.indexOf(bone);
|
|
63
|
+
if (avatarIdx >= 0 && avatarSkeleton.boneInverses[avatarIdx]) {
|
|
64
|
+
m.copy(avatarSkeleton.boneInverses[avatarIdx]);
|
|
65
|
+
} else {
|
|
66
|
+
m.copy(bone.matrixWorld).invert();
|
|
67
|
+
}
|
|
68
|
+
inverseBindMatrices.push(...m.elements);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const newSkeleton = new THREE.Skeleton(
|
|
72
|
+
newBones,
|
|
73
|
+
newBones.map((_: THREE.Bone, i: number) => {
|
|
74
|
+
const m = new THREE.Matrix4();
|
|
75
|
+
m.fromArray(inverseBindMatrices, i * 16);
|
|
76
|
+
return m;
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
mesh.bind(newSkeleton);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function SkinnedClothing({ url, avatarSkeleton }: SkinnedClothingProps) {
|
|
84
|
+
const { scene } = useGLTF(url);
|
|
85
|
+
const groupRef = useRef<any>(null);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const group = groupRef.current;
|
|
89
|
+
if (!group || !scene || !avatarSkeleton) return;
|
|
90
|
+
|
|
91
|
+
const clone = SkeletonUtils.clone(scene as any);
|
|
92
|
+
|
|
93
|
+
(clone as any).traverse((child: any) => {
|
|
94
|
+
if (child.isSkinnedMesh) {
|
|
95
|
+
rebindToAvatarSkeleton(child as THREE.SkinnedMesh, avatarSkeleton);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
group.add(clone as any);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
group.remove(clone as any);
|
|
103
|
+
(clone as any).traverse((child: any) => {
|
|
104
|
+
if (child.geometry) child.geometry.dispose();
|
|
105
|
+
if (child.material) {
|
|
106
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
107
|
+
materials.forEach((m: any) => m.dispose());
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
}, [scene, avatarSkeleton]);
|
|
112
|
+
|
|
113
|
+
return <group ref={groupRef} />;
|
|
114
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AvatarCanvas } from './AvatarCanvas';
|
|
2
|
+
export { AvatarModel } from './AvatarModel';
|
|
3
|
+
export { RigidAccessory } from './RigidAccessory';
|
|
4
|
+
export { SkinnedClothing } from './SkinnedClothing';
|
|
5
|
+
export type { AvatarEditorProps, AssetPlacement, EquippedAsset } from './types';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSX intrinsic shims for React Three Fiber elements.
|
|
3
|
+
* These are provided at runtime by @react-three/fiber (a peer dependency).
|
|
4
|
+
* This file prevents TS errors when the peer dep types are not installed.
|
|
5
|
+
*/
|
|
6
|
+
import type React from 'react';
|
|
7
|
+
|
|
8
|
+
declare module 'react' {
|
|
9
|
+
namespace JSX {
|
|
10
|
+
interface IntrinsicElements {
|
|
11
|
+
// R3F primitives
|
|
12
|
+
primitive: {
|
|
13
|
+
object: any;
|
|
14
|
+
ref?: React.Ref<any>;
|
|
15
|
+
scale?: any;
|
|
16
|
+
[key: string]: any;
|
|
17
|
+
};
|
|
18
|
+
group: {
|
|
19
|
+
ref?: React.Ref<any>;
|
|
20
|
+
[key: string]: any;
|
|
21
|
+
};
|
|
22
|
+
// Three.js light intrinsics
|
|
23
|
+
ambientLight: {
|
|
24
|
+
intensity?: number;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
};
|
|
27
|
+
directionalLight: {
|
|
28
|
+
position?: [number, number, number];
|
|
29
|
+
intensity?: number;
|
|
30
|
+
[key: string]: any;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AvatarAppearance } from '../appearance';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
|
|
4
|
+
export interface AssetPlacement {
|
|
5
|
+
bone: string;
|
|
6
|
+
position: [number, number, number];
|
|
7
|
+
rotation: [number, number, number];
|
|
8
|
+
scale?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EquippedAsset {
|
|
12
|
+
id: string;
|
|
13
|
+
url: string;
|
|
14
|
+
type: 'rigid' | 'skinned';
|
|
15
|
+
slot?: string;
|
|
16
|
+
attach_bone?: string;
|
|
17
|
+
offset_position?: [number, number, number];
|
|
18
|
+
offset_rotation?: [number, number, number];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AvatarEditorProps {
|
|
22
|
+
avatarUrl: string;
|
|
23
|
+
appearance?: AvatarAppearance;
|
|
24
|
+
equipped?: EquippedAsset[];
|
|
25
|
+
placements?: Record<string, AssetPlacement>;
|
|
26
|
+
editingAssetId?: string | null;
|
|
27
|
+
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
style?: React.CSSProperties;
|
|
30
|
+
}
|