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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +459 -0
  3. package/dist/TalkingHead.d.ts +35 -0
  4. package/dist/TalkingHead.d.ts.map +1 -0
  5. package/dist/TalkingHead.js +107 -0
  6. package/dist/TalkingHead.web.d.ts +35 -0
  7. package/dist/TalkingHead.web.d.ts.map +1 -0
  8. package/dist/TalkingHead.web.js +117 -0
  9. package/dist/__tests__/TalkingHead.test.d.ts +2 -0
  10. package/dist/__tests__/TalkingHead.test.d.ts.map +1 -0
  11. package/dist/__tests__/TalkingHead.test.js +23 -0
  12. package/dist/__tests__/sketchfab.test.d.ts +2 -0
  13. package/dist/__tests__/sketchfab.test.d.ts.map +1 -0
  14. package/dist/__tests__/sketchfab.test.js +21 -0
  15. package/dist/appearance/apply.d.ts +7 -0
  16. package/dist/appearance/apply.d.ts.map +1 -0
  17. package/dist/appearance/apply.js +56 -0
  18. package/dist/appearance/index.d.ts +5 -0
  19. package/dist/appearance/index.d.ts.map +1 -0
  20. package/dist/appearance/index.js +3 -0
  21. package/dist/appearance/matchers.d.ts +3 -0
  22. package/dist/appearance/matchers.d.ts.map +1 -0
  23. package/dist/appearance/matchers.js +32 -0
  24. package/dist/appearance/schema.d.ts +9 -0
  25. package/dist/appearance/schema.d.ts.map +1 -0
  26. package/dist/appearance/schema.js +20 -0
  27. package/dist/editor/AvatarCanvas.d.ts +16 -0
  28. package/dist/editor/AvatarCanvas.d.ts.map +1 -0
  29. package/dist/editor/AvatarCanvas.js +85 -0
  30. package/dist/editor/AvatarCanvasErrorBoundary.d.ts +17 -0
  31. package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +1 -0
  32. package/dist/editor/AvatarCanvasErrorBoundary.js +41 -0
  33. package/dist/editor/AvatarModel.d.ts +12 -0
  34. package/dist/editor/AvatarModel.d.ts.map +1 -0
  35. package/dist/editor/AvatarModel.js +31 -0
  36. package/dist/editor/RigidAccessory.d.ts +15 -0
  37. package/dist/editor/RigidAccessory.d.ts.map +1 -0
  38. package/dist/editor/RigidAccessory.js +76 -0
  39. package/dist/editor/SkinnedClothing.d.ts +7 -0
  40. package/dist/editor/SkinnedClothing.d.ts.map +1 -0
  41. package/dist/editor/SkinnedClothing.js +88 -0
  42. package/dist/editor/index.d.ts +6 -0
  43. package/dist/editor/index.d.ts.map +1 -0
  44. package/dist/editor/index.js +4 -0
  45. package/dist/editor/types.d.ts +28 -0
  46. package/dist/editor/types.d.ts.map +1 -0
  47. package/dist/editor/types.js +1 -0
  48. package/dist/html.d.ts +13 -0
  49. package/dist/html.d.ts.map +1 -0
  50. package/dist/html.js +560 -0
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +2 -0
  54. package/dist/index.web.d.ts +4 -0
  55. package/dist/index.web.d.ts.map +1 -0
  56. package/dist/index.web.js +2 -0
  57. package/dist/sketchfab/api.d.ts +12 -0
  58. package/dist/sketchfab/api.d.ts.map +1 -0
  59. package/dist/sketchfab/api.js +52 -0
  60. package/dist/sketchfab/categories.d.ts +5 -0
  61. package/dist/sketchfab/categories.d.ts.map +1 -0
  62. package/dist/sketchfab/categories.js +124 -0
  63. package/dist/sketchfab/index.d.ts +7 -0
  64. package/dist/sketchfab/index.d.ts.map +1 -0
  65. package/dist/sketchfab/index.js +3 -0
  66. package/dist/sketchfab/types.d.ts +51 -0
  67. package/dist/sketchfab/types.d.ts.map +1 -0
  68. package/dist/sketchfab/types.js +1 -0
  69. package/dist/sketchfab/useSketchfabSearch.d.ts +19 -0
  70. package/dist/sketchfab/useSketchfabSearch.d.ts.map +1 -0
  71. package/dist/sketchfab/useSketchfabSearch.js +78 -0
  72. package/dist/voice/convertToWav.d.ts +6 -0
  73. package/dist/voice/convertToWav.d.ts.map +1 -0
  74. package/dist/voice/convertToWav.js +74 -0
  75. package/dist/voice/index.d.ts +6 -0
  76. package/dist/voice/index.d.ts.map +1 -0
  77. package/dist/voice/index.js +3 -0
  78. package/dist/voice/useAudioPlayer.d.ts +11 -0
  79. package/dist/voice/useAudioPlayer.d.ts.map +1 -0
  80. package/dist/voice/useAudioPlayer.js +61 -0
  81. package/dist/voice/useAudioRecording.d.ts +14 -0
  82. package/dist/voice/useAudioRecording.d.ts.map +1 -0
  83. package/dist/voice/useAudioRecording.js +162 -0
  84. package/package.json +120 -0
  85. package/src/TalkingHead.tsx +207 -0
  86. package/src/TalkingHead.web.tsx +210 -0
  87. package/src/__tests__/TalkingHead.test.tsx +32 -0
  88. package/src/__tests__/sketchfab.test.ts +24 -0
  89. package/src/appearance/apply.ts +94 -0
  90. package/src/appearance/index.ts +4 -0
  91. package/src/appearance/matchers.ts +43 -0
  92. package/src/appearance/schema.ts +35 -0
  93. package/src/editor/AvatarCanvas.tsx +167 -0
  94. package/src/editor/AvatarCanvasErrorBoundary.tsx +64 -0
  95. package/src/editor/AvatarModel.tsx +49 -0
  96. package/src/editor/RigidAccessory.tsx +130 -0
  97. package/src/editor/SkinnedClothing.tsx +114 -0
  98. package/src/editor/index.ts +5 -0
  99. package/src/editor/r3f-shim.d.ts +34 -0
  100. package/src/editor/types.ts +30 -0
  101. package/src/html.ts +572 -0
  102. package/src/index.ts +8 -0
  103. package/src/index.web.ts +8 -0
  104. package/src/sketchfab/api.ts +82 -0
  105. package/src/sketchfab/categories.ts +127 -0
  106. package/src/sketchfab/index.ts +6 -0
  107. package/src/sketchfab/types.ts +40 -0
  108. package/src/sketchfab/useSketchfabSearch.ts +110 -0
  109. package/src/voice/convertToWav.ts +87 -0
  110. package/src/voice/index.ts +7 -0
  111. package/src/voice/useAudioPlayer.ts +78 -0
  112. 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
+ }