@linxai/3d-shared 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,41 @@
1
+ {
2
+ "name": "@linxai/3d-shared",
3
+ "version": "0.1.0",
4
+ "description": "3D 可视化共享组件 - Web & React Native 通用",
5
+ "main": "./dist/index.js",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "module": "./dist/index.mjs",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.mjs",
15
+ "require": "./dist/index.js"
16
+ },
17
+ "./internal": {
18
+ "types": "./dist/internal.d.ts",
19
+ "import": "./dist/internal.mjs",
20
+ "require": "./dist/internal.js"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "peerDependencies": {
29
+ "@react-three/fiber": ">=8.0.0",
30
+ "react": ">=18.0.0",
31
+ "react-native": "*",
32
+ "three": ">=0.160.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.2.7",
36
+ "@types/three": "^0.166.0",
37
+ "tsup": "^8.5.1",
38
+ "typescript": "^5.9.3"
39
+ }
40
+ }
41
+
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { SceneConfig, OrbitControlsConfig, GridHelperConfig } from '../types'
3
+
4
+ /**
5
+ * 场景构建器接口
6
+ * Web 和 Native 分别实现此接口来装配场景
7
+ */
8
+ export interface SceneBuilder {
9
+ /** 构建 Canvas 组件 */
10
+ buildCanvas: (props: {
11
+ children: ReactNode
12
+ config?: SceneConfig
13
+ style?: any
14
+ className?: string
15
+ }) => ReactNode
16
+
17
+ /** 构建 OrbitControls 组件 */
18
+ buildOrbitControls: (config?: OrbitControlsConfig) => ReactNode | null
19
+
20
+ /** 构建 GridHelper 组件 */
21
+ buildGridHelper: (config?: GridHelperConfig) => ReactNode | null
22
+ }
23
+
24
+ /**
25
+ * 使用构建器创建场景的 Hook
26
+ * 返回构建好的场景组件
27
+ */
28
+ export function useSceneBuilder(builder: SceneBuilder) {
29
+ return {
30
+ buildCanvas: builder.buildCanvas,
31
+ buildOrbitControls: builder.buildOrbitControls,
32
+ }
33
+ }
34
+
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { SceneConfig } from '../types'
3
+
4
+ interface SceneContentProps {
5
+ /** 子组件 */
6
+ children: ReactNode
7
+ /** 场景配置 */
8
+ config?: SceneConfig
9
+ }
10
+
11
+ /**
12
+ * 通用的场景内容组件
13
+ * 包含背景色、光源等通用元素
14
+ * 这个组件在 Web 和 Native 中完全复用
15
+ *
16
+ * 注意:此组件返回 JSX 片段,需要在 R3F Canvas 内部使用
17
+ */
18
+ export function SceneContent({ children, config = {} }: SceneContentProps) {
19
+ const {
20
+ backgroundColor = '#000',
21
+ ambientLightIntensity = 0.5, // 向后兼容
22
+ lights,
23
+ } = config
24
+
25
+ // 光源配置(支持新配置和旧配置)
26
+ const ambientIntensity = lights?.ambientIntensity ?? ambientLightIntensity
27
+ const pointLight = lights?.pointLight
28
+ const directionalLight = lights?.directionalLight
29
+
30
+ return (
31
+ <>
32
+ {/* @ts-ignore - R3F JSX 元素 */}
33
+ <color attach="background" args={[backgroundColor]} />
34
+
35
+ {/* 环境光 */}
36
+ {ambientIntensity > 0 && (
37
+ /* @ts-ignore - R3F JSX 元素 */
38
+ <ambientLight intensity={ambientIntensity} />
39
+ )}
40
+
41
+ {/* 点光源 */}
42
+ {pointLight && (
43
+ /* @ts-ignore - R3F JSX 元素 */
44
+ <pointLight
45
+ position={pointLight.position || [10, 10, 10]}
46
+ intensity={pointLight.intensity ?? 1}
47
+ />
48
+ )}
49
+
50
+ {/* 方向光 */}
51
+ {directionalLight && (
52
+ /* @ts-ignore - R3F JSX 元素 */
53
+ <directionalLight
54
+ position={directionalLight.position || [-10, -10, -5]}
55
+ intensity={directionalLight.intensity ?? 0.5}
56
+ />
57
+ )}
58
+
59
+ {children}
60
+ </>
61
+ )
62
+ }
63
+
@@ -0,0 +1,46 @@
1
+ import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
2
+
3
+ interface LoadingContextValue {
4
+ progress: number
5
+ isLoading: boolean
6
+ setProgress: (progress: number) => void
7
+ setLoading: (loading: boolean) => void
8
+ }
9
+
10
+ const LoadingContext = createContext<LoadingContextValue | null>(null)
11
+
12
+ /**
13
+ * 简单的加载状态管理 Context (Web)
14
+ */
15
+ export function LoadingProvider({ children }: { children: ReactNode }) {
16
+ const [progress, setProgressState] = useState(0)
17
+ const [isLoading, setLoadingState] = useState(false)
18
+
19
+ const setProgress = useCallback((value: number) => {
20
+ setProgressState(Math.max(0, Math.min(1, value)))
21
+ }, [])
22
+
23
+ const setLoading = useCallback((loading: boolean) => {
24
+ setLoadingState(loading)
25
+ }, [])
26
+
27
+ return (
28
+ <LoadingContext.Provider
29
+ value={{ progress, isLoading, setProgress, setLoading }}
30
+ >
31
+ {children}
32
+ </LoadingContext.Provider>
33
+ )
34
+ }
35
+
36
+ /**
37
+ * 使用加载状态的 Hook
38
+ */
39
+ export function useLoading() {
40
+ const context = useContext(LoadingContext)
41
+ if (!context) {
42
+ throw new Error('useLoading must be used within LoadingProvider')
43
+ }
44
+ return context
45
+ }
46
+
@@ -0,0 +1,145 @@
1
+ import { useRef, useCallback, useState, useEffect } from 'react'
2
+ import { AnimationMixer, AnimationAction, LoopOnce, LoopRepeat } from 'three'
3
+ import { useFrame } from '@react-three/fiber'
4
+
5
+ export interface AnimationControlOptions {
6
+ /** 加载的模型 */
7
+ model: any
8
+ /** 动画 Map */
9
+ animationsMap: Map<string, any> | null
10
+ }
11
+
12
+ export interface PlayOptions {
13
+ /** 是否循环播放 */
14
+ loop?: boolean
15
+ /** 淡入时间(秒) */
16
+ fadeIn?: number
17
+ /** 淡出时间(秒) */
18
+ fadeOut?: number
19
+ /** 播放速度 */
20
+ speed?: number
21
+ }
22
+
23
+ /**
24
+ * 动画控制 Hook
25
+ * 管理 AnimationMixer、动画播放、切换和过渡
26
+ */
27
+ export function useAnimationControl(options: AnimationControlOptions) {
28
+ const { model, animationsMap } = options
29
+
30
+ const [mixer, setMixer] = useState<AnimationMixer | null>(null)
31
+ const currentActionRef = useRef<AnimationAction | null>(null)
32
+ const currentAnimationIdRef = useRef<string | null>(null)
33
+
34
+ // 初始化 AnimationMixer
35
+ useEffect(() => {
36
+ if (model?.scene) {
37
+ const newMixer = new AnimationMixer(model.scene)
38
+ setMixer(newMixer)
39
+
40
+ return () => {
41
+ setMixer(null)
42
+ }
43
+ }
44
+ }, [model])
45
+
46
+
47
+ /**
48
+ * 播放动画
49
+ */
50
+ const play = useCallback((animationId: string, options: PlayOptions = {}) => {
51
+ if (!mixer || !animationsMap) return
52
+
53
+ const clip = animationsMap.get(animationId)
54
+ if (!clip) {
55
+ console.warn(`[AnimationControl] Animation clip not found: ${animationId}`)
56
+ return
57
+ }
58
+
59
+ const { loop = true, fadeIn = 0.3, speed = 1 } = options
60
+ const blendTime = fadeIn
61
+
62
+ const newAction = mixer.clipAction(clip)
63
+ newAction.loop = loop ? LoopRepeat : LoopOnce
64
+ newAction.clampWhenFinished = !loop
65
+
66
+ // 如果是同一个动画,不做任何操作
67
+ if (currentActionRef.current === newAction) {
68
+ return
69
+ }
70
+
71
+ // 如果有当前动画,使用 crossFadeTo 实现平滑过渡
72
+ if (currentActionRef.current && currentActionRef.current.isRunning()) {
73
+ // 准备新动画
74
+ newAction.reset().setEffectiveWeight(1).setEffectiveTimeScale(speed).play()
75
+ // 从当前动画平滑过渡到新动画
76
+ currentActionRef.current.crossFadeTo(newAction, blendTime, false)
77
+ } else {
78
+ // 首次播放,直接启动
79
+ newAction.reset().fadeIn(blendTime).play()
80
+ }
81
+
82
+ currentActionRef.current = newAction
83
+ currentAnimationIdRef.current = animationId
84
+ }, [mixer, animationsMap])
85
+
86
+ /**
87
+ * 停止动画
88
+ */
89
+ const stop = useCallback((fadeOut = 0.3) => {
90
+ if (currentActionRef.current) {
91
+ currentActionRef.current.fadeOut(fadeOut)
92
+ currentActionRef.current = null
93
+ currentAnimationIdRef.current = null
94
+ }
95
+ }, [])
96
+
97
+ /**
98
+ * 暂停动画
99
+ */
100
+ const pause = useCallback(() => {
101
+ if (currentActionRef.current?.isRunning()) {
102
+ currentActionRef.current.paused = true
103
+ }
104
+ }, [])
105
+
106
+ /**
107
+ * 恢复播放
108
+ */
109
+ const resume = useCallback(() => {
110
+ if (currentActionRef.current?.paused) {
111
+ currentActionRef.current.paused = false
112
+ }
113
+ }, [])
114
+
115
+ /**
116
+ * 获取当前播放的动画 ID
117
+ */
118
+ const getCurrentAnimation = useCallback(() => currentAnimationIdRef.current, [])
119
+
120
+ /**
121
+ * 是否正在播放
122
+ */
123
+ const isPlaying = useCallback(() => {
124
+ return currentActionRef.current ? currentActionRef.current.isRunning() : false
125
+ }, [])
126
+
127
+ /**
128
+ * 更新 AnimationMixer
129
+ */
130
+ useFrame((_, delta) => {
131
+ mixer?.update(delta)
132
+ })
133
+
134
+ return {
135
+ mixer,
136
+ currentAction: currentActionRef.current,
137
+ play,
138
+ stop,
139
+ pause,
140
+ resume,
141
+ getCurrentAnimation,
142
+ isPlaying,
143
+ }
144
+ }
145
+
@@ -0,0 +1,149 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import type { AnimationConfig } from '../types'
3
+
4
+ /**
5
+ * 动画加载状态
6
+ */
7
+ export interface AnimationLoadState {
8
+ /** 是否已加载 */
9
+ loaded: boolean
10
+ /** 加载进度 (0-1) */
11
+ progress?: number
12
+ /** 错误信息 */
13
+ error?: Error | null
14
+ /** 加载的动画剪辑 */
15
+ clips?: any[] // Three.js AnimationClip[]
16
+ }
17
+
18
+ /**
19
+ * 动画加载器 Hook 返回值
20
+ */
21
+ export interface AnimationLoaderResult {
22
+ /** 动画剪辑列表 */
23
+ clips: any[] | null
24
+ /** 加载状态 */
25
+ state: AnimationLoadState
26
+ /** 根据 ID 获取动画剪辑 */
27
+ getClip: (id: string) => any | null
28
+ /** 动画 Map (id -> clip) */
29
+ animationsMap: Map<string, any>
30
+ }
31
+
32
+ /**
33
+ * 平台特定的动画加载适配器
34
+ * 平台包提供具体的加载实现
35
+ */
36
+ export interface AnimationLoaderAdapter {
37
+ /**
38
+ * 加载单个动画文件
39
+ * @param path 动画文件路径
40
+ * @param onProgress 进度回调
41
+ * @returns Promise<动画对象(包含 animations 数组)>
42
+ */
43
+ load: (
44
+ path: string | number,
45
+ onProgress?: (progress: number) => void
46
+ ) => Promise<{ animations: any[] }>
47
+ }
48
+
49
+ /**
50
+ * 使用动画加载器的 Hook
51
+ * 在 shared 中实现核心逻辑,使用平台适配器加载
52
+ * 加载后使用配置的 ID 重命名动画剪辑
53
+ */
54
+ export function useAnimationLoader(
55
+ animations: AnimationConfig[] | undefined,
56
+ adapter: AnimationLoaderAdapter
57
+ ): AnimationLoaderResult {
58
+ const [state, setState] = useState<AnimationLoadState>({
59
+ loaded: false,
60
+ progress: 0,
61
+ })
62
+ const clipsMapRef = useRef<Map<string, any>>(new Map())
63
+
64
+ useEffect(() => {
65
+ if (!animations || animations.length === 0) {
66
+ setState({ loaded: true, progress: 1, clips: [] })
67
+ clipsMapRef.current.clear()
68
+ return
69
+ }
70
+
71
+ let mounted = true
72
+ let loadedCount = 0
73
+ const totalCount = animations.length
74
+ clipsMapRef.current.clear()
75
+
76
+ const loadAll = async () => {
77
+ try {
78
+ const loadPromises = animations.map(async (config, index) => {
79
+ try {
80
+ const fbxObject = await adapter.load(config.path, (progress) => {
81
+ if (mounted) {
82
+ const overallProgress = (index + progress) / totalCount
83
+ setState((prev) => ({
84
+ ...prev,
85
+ progress: overallProgress,
86
+ }))
87
+ }
88
+ })
89
+
90
+ if (mounted && fbxObject.animations && fbxObject.animations.length > 0) {
91
+ const clip = fbxObject.animations[0] // 取第一个动画剪辑
92
+ // 使用配置的 ID 重命名
93
+ clip.name = config.id
94
+ clipsMapRef.current.set(config.id, clip)
95
+ }
96
+
97
+ loadedCount++
98
+ if (mounted) {
99
+ setState((prev) => ({
100
+ ...prev,
101
+ progress: loadedCount / totalCount,
102
+ }))
103
+ }
104
+ } catch (error) {
105
+ console.error(`加载动画失败 (${config.id}):`, error)
106
+ if (mounted) {
107
+ setState((prev) => ({
108
+ ...prev,
109
+ error: error as Error,
110
+ }))
111
+ }
112
+ }
113
+ })
114
+
115
+ await Promise.all(loadPromises)
116
+
117
+ if (mounted) {
118
+ setState({
119
+ loaded: true,
120
+ progress: 1,
121
+ error: null,
122
+ clips: Array.from(clipsMapRef.current.values()),
123
+ })
124
+ }
125
+ } catch (error) {
126
+ if (mounted) {
127
+ setState({
128
+ loaded: false,
129
+ progress: 0,
130
+ error: error as Error,
131
+ })
132
+ }
133
+ }
134
+ }
135
+
136
+ loadAll()
137
+
138
+ return () => {
139
+ mounted = false
140
+ }
141
+ }, [animations, adapter])
142
+
143
+ return {
144
+ clips: state.clips || null,
145
+ state,
146
+ getClip: (id: string) => clipsMapRef.current.get(id) || null,
147
+ animationsMap: clipsMapRef.current,
148
+ }
149
+ }
@@ -0,0 +1,229 @@
1
+ import { useRef, useCallback, useState, useEffect } from 'react'
2
+ import type { AnimationStatesConfig } from '../types'
3
+ import type { PlayOptions } from './useAnimationControl'
4
+
5
+ export interface AnimationStateMachineOptions {
6
+ /** 动画状态机配置 */
7
+ animationStates?: AnimationStatesConfig
8
+ /** 初始状态 */
9
+ initialState?: string
10
+ /** 初始情绪 */
11
+ initialMood?: string
12
+ /** 是否启用 welcome 状态 */
13
+ enableWelcome?: boolean
14
+ /** 播放动画函数 */
15
+ playAnimation: (animationId: string, options?: PlayOptions) => void
16
+ /** AnimationMixer */
17
+ mixer: any
18
+ /** 应用情绪表情函数 */
19
+ applyEmotion?: (emotion: string) => void
20
+ /** 状态变化回调 */
21
+ onStateChange?: (state: string, mood: string) => void
22
+ }
23
+
24
+ /**
25
+ * 动画状态机 Hook
26
+ * 管理状态、情绪切换和动画调度
27
+ */
28
+ export function useAnimationStateMachine(options: AnimationStateMachineOptions) {
29
+ const {
30
+ animationStates,
31
+ initialState = 'idle',
32
+ initialMood = 'neutral',
33
+ enableWelcome = false,
34
+ playAnimation,
35
+ mixer,
36
+ applyEmotion,
37
+ onStateChange,
38
+ } = options
39
+
40
+ // 状态初始化:如果启用 welcome 则先用 welcome,否则用 initialState
41
+ const getInitialState = () => {
42
+ if (enableWelcome && animationStates?.welcome) {
43
+ return 'welcome'
44
+ }
45
+ return initialState
46
+ }
47
+
48
+ const [currentState, setCurrentStateInternal] = useState<string>(getInitialState())
49
+ const [currentMood, setCurrentMoodInternal] = useState<string>(initialMood)
50
+
51
+ const nextAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
52
+ const currentAnimationListRef = useRef<string[]>([])
53
+ const currentAnimationIndexRef = useRef<number>(0)
54
+ const targetStateAfterWelcomeRef = useRef<string>(initialState)
55
+ const initializedRef = useRef<boolean>(false)
56
+
57
+ // 清理定时器
58
+ useEffect(() => {
59
+ return () => {
60
+ if (nextAnimationTimeoutRef.current) {
61
+ clearTimeout(nextAnimationTimeoutRef.current)
62
+ }
63
+ }
64
+ }, [])
65
+
66
+ /**
67
+ * 播放状态机中的下一个动画
68
+ * State 决定动画,Mood 决定表情
69
+ */
70
+ const playNextAnimation = useCallback((state: string, mood: string, isWelcome = false) => {
71
+ if (!animationStates || !animationStates[state]) return
72
+
73
+ const stateConfig = animationStates[state]
74
+ const animations = stateConfig.animations
75
+
76
+ if (!animations || animations.length === 0) {
77
+ console.warn(`[StateMachine] No animations found for state: ${state}`)
78
+ return
79
+ }
80
+
81
+ // 应用情绪表情(morphTargets)
82
+ if (applyEmotion) {
83
+ applyEmotion(mood)
84
+ }
85
+
86
+ const config = stateConfig.config || {}
87
+ const { random = false, loop = true, fadeIn = 0.3, fadeOut = 0.3, duration } = config
88
+
89
+ let animationId: string
90
+
91
+ if (random) {
92
+ // 随机选择
93
+ const randomIndex = Math.floor(Math.random() * animations.length)
94
+ animationId = animations[randomIndex]
95
+ } else {
96
+ // 顺序播放
97
+ if (currentAnimationListRef.current !== animations) {
98
+ // 动画列表变了,重置索引
99
+ currentAnimationListRef.current = animations
100
+ currentAnimationIndexRef.current = 0
101
+ }
102
+
103
+ animationId = animations[currentAnimationIndexRef.current]
104
+ currentAnimationIndexRef.current = (currentAnimationIndexRef.current + 1) % animations.length
105
+ }
106
+
107
+ // 播放动画
108
+ playAnimation(animationId, { loop, fadeIn, fadeOut })
109
+
110
+ // 如果是 welcome 状态,监听动画结束事件
111
+ if (isWelcome && mixer) {
112
+ const handleWelcomeFinished = (e: any) => {
113
+ console.log('[StateMachine] Welcome animation finished, transition to:', targetStateAfterWelcomeRef.current)
114
+ setState(targetStateAfterWelcomeRef.current, mood)
115
+ mixer.removeEventListener('finished', handleWelcomeFinished)
116
+ }
117
+
118
+ mixer.addEventListener('finished', handleWelcomeFinished)
119
+ } else {
120
+ // 如果设置了 duration,自动播放下一个
121
+ if (duration && duration > 0) {
122
+ if (nextAnimationTimeoutRef.current) {
123
+ clearTimeout(nextAnimationTimeoutRef.current)
124
+ }
125
+
126
+ nextAnimationTimeoutRef.current = setTimeout(() => {
127
+ playNextAnimation(state, mood, false)
128
+ }, duration) as any
129
+ }
130
+ }
131
+ }, [animationStates, playAnimation, mixer])
132
+
133
+ /**
134
+ * 设置状态(和可选的情绪)
135
+ */
136
+ const setState = useCallback((state: string, mood?: string) => {
137
+ if (!animationStates || !animationStates[state]) {
138
+ console.warn(`[StateMachine] State not found: ${state}`)
139
+ return
140
+ }
141
+
142
+ const newMood = mood || currentMood
143
+ setCurrentStateInternal(state)
144
+ setCurrentMoodInternal(newMood)
145
+
146
+ // 清理之前的定时器
147
+ if (nextAnimationTimeoutRef.current) {
148
+ clearTimeout(nextAnimationTimeoutRef.current)
149
+ nextAnimationTimeoutRef.current = null
150
+ }
151
+
152
+ // 应用情绪表情
153
+ if (applyEmotion) {
154
+ applyEmotion(newMood)
155
+ }
156
+
157
+ // 播放对应的动画(welcome 状态特殊处理)
158
+ const isWelcome = state === 'welcome'
159
+ playNextAnimation(state, newMood, isWelcome)
160
+
161
+ // 触发状态变化回调
162
+ if (onStateChange) {
163
+ onStateChange(state, newMood)
164
+ }
165
+ }, [animationStates, currentState, currentMood, playNextAnimation, applyEmotion, onStateChange])
166
+
167
+ /**
168
+ * 设置情绪(保持当前状态)
169
+ */
170
+ const setMood = useCallback((mood: string) => {
171
+ // mood 现在只影响表情,不影响动画选择
172
+ setCurrentMoodInternal(mood)
173
+
174
+ // 应用情绪表情(morphTargets)
175
+ if (applyEmotion) {
176
+ applyEmotion(mood)
177
+ }
178
+
179
+ // 触发状态变化回调
180
+ if (onStateChange) {
181
+ onStateChange(currentState, mood)
182
+ }
183
+ }, [currentState, applyEmotion, onStateChange])
184
+
185
+ /**
186
+ * 获取当前状态和情绪
187
+ */
188
+ const getCurrentState = useCallback(() => {
189
+ return { state: currentState, mood: currentMood }
190
+ }, [currentState, currentMood])
191
+
192
+ /**
193
+ * 初始化状态机(播放初始动画)
194
+ */
195
+ const initialize = useCallback(() => {
196
+ // 只初始化一次
197
+ if (initializedRef.current || !animationStates) return
198
+
199
+ initializedRef.current = true
200
+
201
+ // 应用初始情绪表情
202
+ if (applyEmotion) {
203
+ applyEmotion(initialMood)
204
+ }
205
+
206
+ // currentState 已在 useState 时设置,这里只需要播放动画
207
+ if (currentState === 'welcome') {
208
+ targetStateAfterWelcomeRef.current = initialState
209
+ playNextAnimation('welcome', initialMood, true)
210
+ } else {
211
+ playNextAnimation(currentState, initialMood, false)
212
+ }
213
+
214
+ // 触发状态变化回调
215
+ if (onStateChange) {
216
+ onStateChange(currentState, initialMood)
217
+ }
218
+ }, [animationStates, currentState, initialMood, initialState, playNextAnimation, applyEmotion, onStateChange])
219
+
220
+ return {
221
+ currentState,
222
+ currentMood,
223
+ setState,
224
+ setMood,
225
+ getCurrentState,
226
+ initialize,
227
+ }
228
+ }
229
+