@linxai/3d-web 0.1.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/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@linxai/3d-web",
3
+ "version": "0.1.0",
4
+ "description": "3D 可视化 Web 组件",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsup",
17
+ "dev": "tsup src/index.tsx --format cjs,esm --dts --watch",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@linxai/3d-shared": "workspace:*"
22
+ },
23
+ "peerDependencies": {
24
+ "@react-three/fiber": ">=8.0.0",
25
+ "react": ">=18.0.0",
26
+ "three": ">=0.160.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^19.0.0",
30
+ "@types/three": "^0.170.0",
31
+ "tsup": "^8.0.0",
32
+ "typescript": "^5.3.0"
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
2
+ import type { AnimationLoaderAdapter } from '@linxai/3d-shared/internal'
3
+
4
+ /**
5
+ * Web 端动画加载适配器
6
+ * 使用 FBXLoader 从 URL 加载动画
7
+ */
8
+ export const WebAnimationLoaderAdapter: AnimationLoaderAdapter = {
9
+ load: async (path: string | number, onProgress?: (progress: number) => void) => {
10
+ // Web 平台:path 必须是 string (URL)
11
+ if (typeof path !== 'string') {
12
+ throw new Error('Web 端动画加载: path 必须是字符串 URL')
13
+ }
14
+
15
+ return new Promise((resolve, reject) => {
16
+ const loader = new FBXLoader()
17
+ loader.load(
18
+ path,
19
+ (fbx) => {
20
+ resolve(fbx)
21
+ },
22
+ (event) => {
23
+ if (event.lengthComputable && onProgress) {
24
+ onProgress(event.loaded / event.total)
25
+ }
26
+ },
27
+ (error) => {
28
+ reject(error)
29
+ }
30
+ )
31
+ })
32
+ },
33
+ }
34
+
@@ -0,0 +1,34 @@
1
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
2
+ import type { ModelLoaderAdapter } from '@linxai/3d-shared/internal'
3
+
4
+ /**
5
+ * Web 端模型加载适配器
6
+ * 使用 GLTFLoader 从 URL 加载模型
7
+ */
8
+ export const WebModelLoaderAdapter: ModelLoaderAdapter = {
9
+ load: async (path: string | number, onProgress?: (progress: number) => void) => {
10
+ // Web 平台:path 必须是 string (URL)
11
+ if (typeof path !== 'string') {
12
+ throw new Error('Web 端模型加载: path 必须是字符串 URL')
13
+ }
14
+
15
+ return new Promise((resolve, reject) => {
16
+ const loader = new GLTFLoader()
17
+ loader.load(
18
+ path,
19
+ (gltf) => {
20
+ resolve(gltf)
21
+ },
22
+ (event) => {
23
+ if (event.lengthComputable && onProgress) {
24
+ onProgress(event.loaded / event.total)
25
+ }
26
+ },
27
+ (error) => {
28
+ reject(error)
29
+ }
30
+ )
31
+ })
32
+ },
33
+ }
34
+
@@ -0,0 +1,91 @@
1
+ import { Canvas } from '@react-three/fiber'
2
+ import { OrbitControls } from '@react-three/drei'
3
+ import type { ReactNode, CSSProperties } from 'react'
4
+ import type { SceneConfig, OrbitControlsConfig, GridHelperConfig } from '@linxai/3d-shared'
5
+ import type { SceneBuilder as ISceneBuilder } from '@linxai/3d-shared/internal'
6
+ import { SceneContent } from '@linxai/3d-shared'
7
+
8
+ /**
9
+ * Web 端场景构建器
10
+ * 装配 Web 平台特定的 Canvas 和 OrbitControls
11
+ */
12
+ export const WebSceneBuilder: ISceneBuilder = {
13
+ /**
14
+ * 构建 Web 平台的 Canvas
15
+ */
16
+ buildCanvas: ({ children, config = {}, style, className }) => {
17
+ const {
18
+ cameraPosition = [0, 0, 5],
19
+ fov = 75,
20
+ } = config
21
+
22
+ return (
23
+ <Canvas
24
+ camera={{ position: cameraPosition, fov }}
25
+ style={style as CSSProperties}
26
+ className={className}
27
+ >
28
+ <SceneContent config={config}>
29
+ {children}
30
+ </SceneContent>
31
+ </Canvas>
32
+ )
33
+ },
34
+
35
+ /**
36
+ * 构建 Web 平台的 OrbitControls
37
+ */
38
+ buildOrbitControls: (config?: OrbitControlsConfig) => {
39
+ const {
40
+ enableDamping = true,
41
+ dampingFactor = 0.05,
42
+ enableRotate = true,
43
+ enableZoom = true,
44
+ enablePan = true,
45
+ minDistance,
46
+ maxDistance,
47
+ minPolarAngle,
48
+ maxPolarAngle,
49
+ target,
50
+ } = config || {}
51
+
52
+ return (
53
+ <OrbitControls
54
+ enableDamping={enableDamping}
55
+ dampingFactor={dampingFactor}
56
+ enableRotate={enableRotate}
57
+ enableZoom={enableZoom}
58
+ enablePan={enablePan}
59
+ minDistance={minDistance}
60
+ maxDistance={maxDistance}
61
+ minPolarAngle={minPolarAngle}
62
+ maxPolarAngle={maxPolarAngle}
63
+ target={target}
64
+ />
65
+ )
66
+ },
67
+
68
+ /**
69
+ * 构建 Web 平台的 GridHelper
70
+ */
71
+ buildGridHelper: (config?: GridHelperConfig) => {
72
+ if (!config || config?.visible === false) {
73
+ return null
74
+ }
75
+
76
+ const {
77
+ size = 10,
78
+ divisions = 10,
79
+ centerLineColor = '#888888',
80
+ gridColor = '#444444',
81
+ } = config || {}
82
+
83
+ return (
84
+ // @ts-ignore - R3F JSX 元素
85
+ <gridHelper
86
+ args={[size, divisions, centerLineColor, gridColor]}
87
+ />
88
+ )
89
+ },
90
+ }
91
+
@@ -0,0 +1,183 @@
1
+ import { Subtitle } from './Subtitle'
2
+
3
+ export interface CharacterOverlayProps {
4
+ /** 是否显示浮层 */
5
+ visible?: boolean
6
+ /** 当前字幕文本 */
7
+ subtitle?: string
8
+ /** 是否显示字幕 */
9
+ showSubtitle?: boolean
10
+ /** 是否显示说话按钮 */
11
+ showSpeakButton?: boolean
12
+ /** 是否正在连接/加载 */
13
+ isLoading?: boolean
14
+ /** 是否已连接 */
15
+ isConnected?: boolean
16
+ /** 开始说话回调 */
17
+ onStartSpeak?: () => void
18
+ /** 停止说话回调 */
19
+ onStopSpeak?: () => void
20
+ /** 按钮点击回调(用于外部控制连接状态) */
21
+ onButtonClick?: () => void
22
+ /** 字幕样式 */
23
+ subtitleStyle?: React.CSSProperties
24
+ /** 容器样式 */
25
+ style?: React.CSSProperties
26
+ }
27
+
28
+ /**
29
+ * 角色浮层组件
30
+ * 包含说话控制按钮和字幕显示
31
+ *
32
+ * 纯 UI 组件,基于 props 驱动,不包含业务逻辑
33
+ */
34
+ export function CharacterOverlay({
35
+ visible = true,
36
+ subtitle = '',
37
+ showSubtitle = true,
38
+ showSpeakButton = true,
39
+ isLoading = false,
40
+ isConnected = false,
41
+ onStartSpeak,
42
+ onStopSpeak,
43
+ onButtonClick,
44
+ subtitleStyle,
45
+ style,
46
+ }: CharacterOverlayProps) {
47
+ if (!visible) return null
48
+
49
+ const handleSpeakClick = () => {
50
+ // 优先使用外部提供的按钮点击回调
51
+ if (onButtonClick) {
52
+ onButtonClick()
53
+ } else {
54
+ // 回退到传统的 start/stop 回调
55
+ if (isConnected) {
56
+ onStopSpeak?.()
57
+ } else {
58
+ onStartSpeak?.()
59
+ }
60
+ }
61
+ }
62
+
63
+ return (
64
+ <div
65
+ style={{
66
+ position: 'absolute',
67
+ top: 0,
68
+ left: 0,
69
+ right: 0,
70
+ bottom: 0,
71
+ pointerEvents: 'none',
72
+ display: 'flex',
73
+ flexDirection: 'column',
74
+ justifyContent: 'flex-end',
75
+ padding: '20px',
76
+ zIndex: 100,
77
+ ...style,
78
+ }}
79
+ >
80
+ {/* 字幕组件 */}
81
+ <Subtitle
82
+ text={subtitle}
83
+ visible={showSubtitle}
84
+ boxStyle={subtitleStyle}
85
+ bottomOffset={0}
86
+ style={{ position: 'relative', bottom: 'auto', marginBottom: '80px' }}
87
+ />
88
+
89
+ {/* 说话按钮 */}
90
+ {showSpeakButton && (
91
+ <div
92
+ style={{
93
+ display: 'flex',
94
+ justifyContent: 'center',
95
+ }}
96
+ >
97
+ <button
98
+ onClick={handleSpeakClick}
99
+ disabled={isLoading}
100
+ style={{
101
+ pointerEvents: 'auto',
102
+ padding: '14px 28px',
103
+ background: isConnected
104
+ ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
105
+ : 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
106
+ color: '#fff',
107
+ border: 'none',
108
+ borderRadius: '50px',
109
+ cursor: isLoading ? 'wait' : 'pointer',
110
+ fontSize: '15px',
111
+ fontWeight: '600',
112
+ display: 'flex',
113
+ alignItems: 'center',
114
+ gap: '8px',
115
+ boxShadow: isConnected || isLoading
116
+ ? '0 4px 20px rgba(239, 68, 68, 0.4)'
117
+ : '0 4px 20px rgba(139, 92, 246, 0.4)',
118
+ transition: 'all 0.3s ease',
119
+ transform: 'scale(1)',
120
+ opacity: isLoading ? 0.7 : 1,
121
+ }}
122
+ onMouseEnter={(e) => {
123
+ if (!isLoading) {
124
+ e.currentTarget.style.transform = 'scale(1.05)'
125
+ e.currentTarget.style.boxShadow = isConnected
126
+ ? '0 6px 25px rgba(239, 68, 68, 0.5)'
127
+ : '0 6px 25px rgba(139, 92, 246, 0.5)'
128
+ }
129
+ }}
130
+ onMouseLeave={(e) => {
131
+ e.currentTarget.style.transform = 'scale(1)'
132
+ e.currentTarget.style.boxShadow = isConnected || isLoading
133
+ ? '0 4px 20px rgba(239, 68, 68, 0.4)'
134
+ : '0 4px 20px rgba(139, 92, 246, 0.4)'
135
+ }}
136
+ onMouseDown={(e) => {
137
+ if (!isLoading) {
138
+ e.currentTarget.style.transform = 'scale(0.95)'
139
+ }
140
+ }}
141
+ onMouseUp={(e) => {
142
+ if (!isLoading) {
143
+ e.currentTarget.style.transform = 'scale(1.05)'
144
+ }
145
+ }}
146
+ >
147
+ <span style={{ fontSize: '18px' }}>
148
+ {isLoading ? '⏳' : isConnected ? '🔴' : '💬'}
149
+ </span>
150
+ <span>
151
+ {isLoading ? '连接中...' : isConnected ? '结束对话' : '开始说话'}
152
+ </span>
153
+ {(isConnected || isLoading) && (
154
+ <span
155
+ style={{
156
+ width: '8px',
157
+ height: '8px',
158
+ borderRadius: '50%',
159
+ background: '#fff',
160
+ animation: 'pulse 1.5s ease-in-out infinite',
161
+ }}
162
+ />
163
+ )}
164
+ </button>
165
+ </div>
166
+ )}
167
+
168
+ {/* 添加动画样式 */}
169
+ <style>{`
170
+ @keyframes pulse {
171
+ 0%, 100% {
172
+ opacity: 1;
173
+ transform: scale(1);
174
+ }
175
+ 50% {
176
+ opacity: 0.5;
177
+ transform: scale(0.8);
178
+ }
179
+ }
180
+ `}</style>
181
+ </div>
182
+ )
183
+ }
@@ -0,0 +1,165 @@
1
+ import { Suspense, useRef, useEffect, useCallback, useState } from 'react'
2
+ import type { CharacterViewProps, Character } from '@linxai/3d-shared'
3
+ import { useCharacterController } from '@linxai/3d-shared/internal'
4
+ import { Scene3D } from './Scene3D'
5
+ import { CharacterOverlay } from './CharacterOverlay'
6
+ import { WebModelLoaderAdapter } from '../adapters/ModelLoaderAdapter'
7
+ import { WebAnimationLoaderAdapter } from '../adapters/AnimationLoaderAdapter'
8
+
9
+ // 重新导出类型
10
+ export type { CharacterViewProps } from '@linxai/3d-shared'
11
+
12
+ /**
13
+ * 3D 角色查看器内容(在 Canvas 内部)
14
+ */
15
+ function CharacterContent({
16
+ model,
17
+ animationStates,
18
+ initialState,
19
+ initialMood,
20
+ enableWelcome,
21
+ onError,
22
+ onReady,
23
+ onStateChange,
24
+ children,
25
+ }: Pick<CharacterViewProps, 'model' | 'animationStates' | 'initialState' | 'initialMood' | 'enableWelcome' | 'onError' | 'onReady' | 'onStateChange' | 'children'>) {
26
+
27
+ const { loadedModel, character, isReady } = useCharacterController({
28
+ model,
29
+ modelAdapter: WebModelLoaderAdapter,
30
+ animationAdapter: WebAnimationLoaderAdapter,
31
+ animationStates,
32
+ initialState,
33
+ initialMood,
34
+ enableWelcome,
35
+ onError,
36
+ onStateChange,
37
+ })
38
+
39
+ // 当 character 准备好时通知外部(只在 character 改变时触发)
40
+ const onReadyRef = useRef(onReady)
41
+ useEffect(() => {
42
+ onReadyRef.current = onReady
43
+ }, [onReady])
44
+
45
+ useEffect(() => {
46
+ if (character) {
47
+ onReadyRef.current?.(character)
48
+ }
49
+ }, [character])
50
+
51
+ // 控制模型可见性
52
+ useEffect(() => {
53
+ if (loadedModel?.scene) {
54
+ loadedModel.scene.visible = isReady
55
+ }
56
+ }, [loadedModel, isReady])
57
+
58
+ if (!loadedModel) return null
59
+
60
+ return (
61
+ <>
62
+ <primitive object={loadedModel.scene} />
63
+ {children}
64
+ </>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * 3D 角色查看器
70
+ */
71
+ export function CharacterView(props: CharacterViewProps) {
72
+ const {
73
+ model,
74
+ animationStates,
75
+ initialState,
76
+ initialMood,
77
+ enableWelcome,
78
+ sceneConfig,
79
+ controlsConfig,
80
+ overlay,
81
+ style,
82
+ className,
83
+ children,
84
+ onError,
85
+ onReady,
86
+ onStateChange,
87
+ } = props
88
+
89
+ // 内部管理字幕状态
90
+ const [subtitle, setSubtitle] = useState('')
91
+ const characterRef = useRef<Character | null>(null)
92
+
93
+ // 字幕控制方法
94
+ const appendSubtitle = useCallback((text: string) => {
95
+ setSubtitle(prev => prev + text)
96
+ }, [])
97
+
98
+ const replaceSubtitle = useCallback((text: string) => {
99
+ setSubtitle(text)
100
+ }, [])
101
+
102
+ const clearSubtitle = useCallback(() => {
103
+ setSubtitle('')
104
+ }, [])
105
+
106
+ const handleReady = useCallback((character: Character) => {
107
+ // 直接在 character 对象上添加字幕控制方法
108
+ character.appendSubtitle = appendSubtitle
109
+ character.setSubtitle = replaceSubtitle
110
+ character.clearSubtitle = clearSubtitle
111
+
112
+ characterRef.current = character
113
+ onReady?.(character)
114
+ }, [onReady, appendSubtitle, replaceSubtitle, clearSubtitle])
115
+
116
+ const handleStartSpeak = useCallback(() => {
117
+ characterRef.current?.startSpeak()
118
+ }, [])
119
+
120
+ const handleStopSpeak = useCallback(() => {
121
+ characterRef.current?.stopSpeak()
122
+ }, [])
123
+
124
+ return (
125
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
126
+ <Scene3D
127
+ config={sceneConfig}
128
+ controlsConfig={controlsConfig}
129
+ style={style}
130
+ className={className}
131
+ showProgress={false}
132
+ >
133
+ <Suspense fallback={null}>
134
+ <CharacterContent
135
+ model={model}
136
+ animationStates={animationStates}
137
+ initialState={initialState}
138
+ initialMood={initialMood}
139
+ enableWelcome={enableWelcome}
140
+ onError={onError}
141
+ onReady={handleReady}
142
+ onStateChange={onStateChange}
143
+ >
144
+ {children}
145
+ </CharacterContent>
146
+ </Suspense>
147
+ </Scene3D>
148
+
149
+ {overlay?.show && (
150
+ <CharacterOverlay
151
+ visible={true}
152
+ subtitle={subtitle}
153
+ showSpeakButton={overlay.showSpeakButton ?? true}
154
+ isLoading={overlay.isLoading}
155
+ isConnected={overlay.isConnected}
156
+ onStartSpeak={handleStartSpeak}
157
+ onStopSpeak={handleStopSpeak}
158
+ onButtonClick={overlay.onButtonClick}
159
+ subtitleStyle={overlay.subtitleStyle}
160
+ style={overlay.style}
161
+ />
162
+ )}
163
+ </div>
164
+ )
165
+ }
@@ -0,0 +1,69 @@
1
+ import { CSSProperties } from 'react'
2
+
3
+ export interface ProgressBarProps {
4
+ /** 进度值 (0-1) */
5
+ progress: number
6
+ /** 容器样式 */
7
+ style?: CSSProperties
8
+ /** 进度条颜色 */
9
+ color?: string
10
+ /** 背景颜色 */
11
+ backgroundColor?: string
12
+ /** 高度 */
13
+ height?: number
14
+ }
15
+
16
+ /**
17
+ * 简单的进度条组件 (Web)
18
+ */
19
+ export function ProgressBar({
20
+ progress,
21
+ style,
22
+ color = '#007AFF',
23
+ backgroundColor = '#E5E5E5',
24
+ height = 8,
25
+ }: ProgressBarProps) {
26
+ const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100)
27
+
28
+ return (
29
+ <div
30
+ style={{
31
+ width: '100%',
32
+ padding: 8,
33
+ backgroundColor: '#2a2a2a',
34
+ borderRadius: 8,
35
+ ...style,
36
+ }}
37
+ >
38
+ <div
39
+ style={{
40
+ width: '100%',
41
+ height,
42
+ backgroundColor,
43
+ borderRadius: 4,
44
+ overflow: 'hidden',
45
+ }}
46
+ >
47
+ <div
48
+ style={{
49
+ width: `${percentage}%`,
50
+ height: '100%',
51
+ backgroundColor: color,
52
+ transition: 'width 0.3s ease',
53
+ }}
54
+ />
55
+ </div>
56
+ <div
57
+ style={{
58
+ marginTop: 8,
59
+ fontSize: 14,
60
+ fontWeight: 'bold',
61
+ color: '#ffffff',
62
+ }}
63
+ >
64
+ {percentage}%
65
+ </div>
66
+ </div>
67
+ )
68
+ }
69
+
@@ -0,0 +1,89 @@
1
+ import type { ReactNode, CSSProperties } from 'react'
2
+ import type { SceneConfig, OrbitControlsConfig } from '@linxai/3d-shared'
3
+ import { LoadingProvider, useLoading } from '@linxai/3d-shared/internal'
4
+ import { ProgressBar } from './ProgressBar'
5
+ import { WebSceneBuilder } from '../builders/SceneBuilder'
6
+
7
+ interface Scene3DProps {
8
+ /** 子组件 */
9
+ children: ReactNode
10
+ /** 场景配置 */
11
+ config?: SceneConfig
12
+ /** OrbitControls 配置 */
13
+ controlsConfig?: OrbitControlsConfig
14
+ /** Canvas 样式 */
15
+ style?: CSSProperties
16
+ /** Canvas className */
17
+ className?: string
18
+ /** 是否显示进度条 */
19
+ showProgress?: boolean
20
+ }
21
+
22
+ /**
23
+ * Web 端 3D 场景容器
24
+ * 使用装配式架构,通过 WebSceneBuilder 装配 Canvas 和 OrbitControls
25
+ * 支持 React Suspense 配合加载进度显示
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <Scene3D
30
+ * config={{ backgroundColor: '#000' }}
31
+ * controlsConfig={{ enableRotate: true }}
32
+ * >
33
+ * <CharacterView model={{ path: "/model.gltf" }} />
34
+ * </Scene3D>
35
+ * ```
36
+ */
37
+ function Scene3DContent({
38
+ children,
39
+ config = {},
40
+ controlsConfig,
41
+ style,
42
+ className,
43
+ showProgress = true,
44
+ }: Scene3DProps) {
45
+ const { progress, isLoading } = useLoading()
46
+ const canvas = WebSceneBuilder.buildCanvas({
47
+ children: (
48
+ <>
49
+ {WebSceneBuilder.buildOrbitControls(controlsConfig)}
50
+ {WebSceneBuilder.buildGridHelper(config.gridHelper)}
51
+ {children}
52
+ </>
53
+ ),
54
+ config,
55
+ style,
56
+ className,
57
+ })
58
+
59
+ const shouldShowProgress = showProgress && isLoading && progress < 1
60
+
61
+ return (
62
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
63
+ {canvas}
64
+ {shouldShowProgress && (
65
+ <div
66
+ style={{
67
+ position: 'absolute',
68
+ top: '50%',
69
+ left: '50%',
70
+ transform: 'translate(-50%, -50%)',
71
+ width: '80%',
72
+ maxWidth: 400,
73
+ zIndex: 1000,
74
+ }}
75
+ >
76
+ <ProgressBar progress={progress} />
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
82
+
83
+ export function Scene3D(props: Scene3DProps) {
84
+ return (
85
+ <LoadingProvider>
86
+ <Scene3DContent {...props} />
87
+ </LoadingProvider>
88
+ )
89
+ }
@@ -0,0 +1,89 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ export interface SubtitleProps {
4
+ /** 字幕文本 */
5
+ text?: string
6
+ /** 是否可见 */
7
+ visible?: boolean
8
+ /** 容器样式 */
9
+ style?: React.CSSProperties
10
+ /** 字幕框样式 */
11
+ boxStyle?: React.CSSProperties
12
+ /** 文本样式 */
13
+ textStyle?: React.CSSProperties
14
+ /** 底部偏移(默认 80px) */
15
+ bottomOffset?: number
16
+ }
17
+
18
+ /**
19
+ * 字幕组件
20
+ * 带淡入淡出动画的字幕显示
21
+ */
22
+ export function Subtitle({
23
+ text = '',
24
+ visible = true,
25
+ style,
26
+ boxStyle,
27
+ textStyle,
28
+ bottomOffset = 80,
29
+ }: SubtitleProps) {
30
+ const [fadeIn, setFadeIn] = useState(false)
31
+
32
+ useEffect(() => {
33
+ if (text) {
34
+ setFadeIn(true)
35
+ } else {
36
+ setFadeIn(false)
37
+ }
38
+ }, [text])
39
+
40
+ if (!visible || !text) return null
41
+
42
+ return (
43
+ <div
44
+ style={{
45
+ position: 'absolute',
46
+ left: 0,
47
+ right: 0,
48
+ bottom: `${bottomOffset}px`,
49
+ display: 'flex',
50
+ justifyContent: 'center',
51
+ padding: '0 20px',
52
+ pointerEvents: 'none',
53
+ zIndex: 100,
54
+ opacity: fadeIn ? 1 : 0,
55
+ transform: fadeIn ? 'translateY(0)' : 'translateY(10px)',
56
+ transition: 'all 0.3s ease-out',
57
+ ...style,
58
+ }}
59
+ >
60
+ <div
61
+ style={{
62
+ background: 'rgba(0, 0, 0, 0.85)',
63
+ backdropFilter: 'blur(10px)',
64
+ padding: '16px 24px',
65
+ borderRadius: '12px',
66
+ maxWidth: '80%',
67
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
68
+ border: '1px solid rgba(255, 255, 255, 0.1)',
69
+ ...boxStyle,
70
+ }}
71
+ >
72
+ <p
73
+ style={{
74
+ margin: 0,
75
+ color: '#fff',
76
+ fontSize: '16px',
77
+ lineHeight: '1.6',
78
+ textAlign: 'center',
79
+ fontWeight: '500',
80
+ ...textStyle,
81
+ }}
82
+ >
83
+ {text}
84
+ </p>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Web 端模型加载 Hook
3
+ * 直接 re-export @react-three/drei 的 useGLTF
4
+ *
5
+ * @example
6
+ * ```tsx
7
+ * const gltf = useModelLoader('/model.glb')
8
+ * return <primitive object={gltf.scene} />
9
+ * ```
10
+ */
11
+ export { useGLTF as useModelLoader } from '@react-three/drei'
12
+
package/src/index.tsx ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @linxai/3d-web
3
+ * 3D 角色可视化 - Web 平台
4
+ */
5
+
6
+ // ============================================================
7
+ // 核心组件(用户使用)
8
+ // ============================================================
9
+ export { CharacterView } from './components/CharacterView'
10
+ export { Scene3D } from './components/Scene3D'
11
+
12
+ // ============================================================
13
+ // 类型定义(从 shared 导出)
14
+ // ============================================================
15
+ export type {
16
+ // Character 相关
17
+ Character,
18
+ CharacterViewProps,
19
+ OverlayConfig,
20
+
21
+ // 配置类型
22
+ ModelLoadConfig,
23
+ AnimationConfig,
24
+ AnimationStatesConfig,
25
+ AnimationStateConfig,
26
+ SceneConfig,
27
+ OrbitControlsConfig,
28
+ LightConfig,
29
+ GridHelperConfig,
30
+ } from '@linxai/3d-shared'
31
+
32
+ // ============================================================
33
+ // UI 组件(可选使用)
34
+ // ============================================================
35
+ export { CharacterOverlay } from './components/CharacterOverlay'
36
+ export type { CharacterOverlayProps } from './components/CharacterOverlay'
37
+ export { Subtitle } from './components/Subtitle'
38
+ export type { SubtitleProps } from './components/Subtitle'
39
+
40
+ // ============================================================
41
+ // 高级 API(可选使用)
42
+ // ============================================================
43
+ export { WebSceneBuilder } from './builders/SceneBuilder'
44
+ export { useModelLoader } from './hooks/useModelLoader'
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM"],
6
+ "jsx": "react-jsx",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "moduleResolution": "bundler",
17
+ "resolveJsonModule": true,
18
+ "allowSyntheticDefaultImports": true
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }
23
+
package/tsup.config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.tsx'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ external: [
8
+ 'react',
9
+ 'react-native',
10
+ 'three',
11
+ '@react-three/fiber',
12
+ '@linxai/3d-shared',
13
+ ],
14
+ // 使用 React 17+ 的新 JSX runtime
15
+ esbuildOptions(options) {
16
+ options.jsx = 'automatic'
17
+ },
18
+ })
19
+