@realsee/vr-cli 0.1.3

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 (72) hide show
  1. package/NOTICE.md +7 -0
  2. package/README.md +216 -0
  3. package/TERMS.txt +54 -0
  4. package/agent-assets/AGENTS.md +113 -0
  5. package/agent-assets/adapters/.cursor/rules/realsee-vr.mdc +7 -0
  6. package/agent-assets/adapters/.github/copilot-instructions.md +1 -0
  7. package/agent-assets/adapters/.kiro/steering/realsee-vr.md +6 -0
  8. package/agent-assets/adapters/CLAUDE.md +3 -0
  9. package/bin/vr-cli.mjs +3 -0
  10. package/dist/cli.mjs +24 -0
  11. package/package.json +38 -0
  12. package/skills/realsee-image-resources/SKILL.md +82 -0
  13. package/skills/realsee-image-resources/references/cors-and-three-textures.md +50 -0
  14. package/skills/realsee-image-resources/references/data-wanxiang.md +57 -0
  15. package/skills/realsee-image-resources/references/five-image-url-transform.md +81 -0
  16. package/skills/realsee-image-resources/references/source-and-domain-policy.md +63 -0
  17. package/skills/realsee-vr-app/SKILL.md +157 -0
  18. package/skills/realsee-vr-app/references/continuous-improvement.md +24 -0
  19. package/skills/realsee-vr-app/references/dnalogel-api-capabilities.md +241 -0
  20. package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +818 -0
  21. package/skills/realsee-vr-app/references/five-api-capabilities.md +119 -0
  22. package/skills/realsee-vr-app/references/five-runtime.md +83 -0
  23. package/skills/realsee-vr-app/references/official-docs.md +61 -0
  24. package/skills/realsee-vr-app/references/plugin-lifecycle.md +65 -0
  25. package/skills/realsee-vr-app/references/ui-and-validation.md +57 -0
  26. package/skills/realsee-vr-app/references/vapor-dom-layer.md +186 -0
  27. package/skills/realsee-vr-app/scripts/verify-project.mjs +1566 -0
  28. package/skills/threejs-r117-five/SKILL.md +71 -0
  29. package/skills/threejs-r117-five/references/five-ownership.md +70 -0
  30. package/skills/threejs-r117-five/references/r117-capabilities.md +82 -0
  31. package/skills/threejs-r117-five/references/r117-version-guards.md +60 -0
  32. package/templates/base/.editorconfig +12 -0
  33. package/templates/base/.env.example +12 -0
  34. package/templates/base/.gitignore.template +6 -0
  35. package/templates/base/.node-version +1 -0
  36. package/templates/base/.npmrc.template +3 -0
  37. package/templates/base/.nvmrc +1 -0
  38. package/templates/base/.prettierignore +12 -0
  39. package/templates/base/README.md +160 -0
  40. package/templates/base/eslint.config.mjs +75 -0
  41. package/templates/base/index.html +13 -0
  42. package/templates/base/package-lock.json +6444 -0
  43. package/templates/base/package.json +55 -0
  44. package/templates/base/prettier.config.mjs +5 -0
  45. package/templates/base/server/app.ts +54 -0
  46. package/templates/base/server/check-config.ts +4 -0
  47. package/templates/base/server/config.ts +54 -0
  48. package/templates/base/server/index.ts +31 -0
  49. package/templates/base/server/realsee-open-api.ts +316 -0
  50. package/templates/base/server/types.ts +64 -0
  51. package/templates/base/server/work-route.ts +80 -0
  52. package/templates/base/server/work-service.ts +54 -0
  53. package/templates/base/shared/model-floorplan.ts +242 -0
  54. package/templates/base/src/main.tsx +11 -0
  55. package/templates/base/src/shared/useElementSize.ts +28 -0
  56. package/templates/base/src/styles.css +91 -0
  57. package/templates/base/src/vr/five/SpatialRuntimeProvider.tsx +234 -0
  58. package/templates/base/src/vr/five/api.ts +10 -0
  59. package/templates/base/src/vr/five/fiveConfig.ts +42 -0
  60. package/templates/base/src/vr/five/initialState.ts +48 -0
  61. package/templates/base/src/vr/plugins/SpatialPluginRegistry.ts +104 -0
  62. package/templates/base/src/vr/plugins/dnalogel.ts +2 -0
  63. package/templates/base/src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts +73 -0
  64. package/templates/base/src/vr/work/WorkSource.ts +247 -0
  65. package/templates/base/tsconfig.json +27 -0
  66. package/templates/base/tsconfig.server.json +25 -0
  67. package/templates/base/vite.config.ts +21 -0
  68. package/templates/viewer/src/app/App.tsx +182 -0
  69. package/templates/viewer/src/features/floorplan/ModelFloorplanFeature.ts +141 -0
  70. package/templates/viewer/src/features/navigation/ModeSwitcher.tsx +111 -0
  71. package/templates/viewer/src/features/tags/PanoTagFeature.ts +110 -0
  72. package/templates/viewer/src/ui/ViewerOverlay.tsx +141 -0
@@ -0,0 +1,54 @@
1
+ import type {
2
+ RealseeSpatialClient,
3
+ RequestOptions,
4
+ WorkEnvelope,
5
+ WorkService,
6
+ } from './types.ts'
7
+
8
+ const firstString = (...values: unknown[]): string | undefined =>
9
+ values.find(
10
+ (value): value is string => typeof value === 'string' && value.length > 0,
11
+ )
12
+
13
+ export interface CreateWorkServiceOptions {
14
+ readonly openApiClient: RealseeSpatialClient
15
+ }
16
+
17
+ export function createWorkService({
18
+ openApiClient,
19
+ }: CreateWorkServiceOptions): WorkService {
20
+ return Object.freeze({
21
+ async getWorkByResourceCode(
22
+ resourceCode: string,
23
+ { signal }: RequestOptions = {},
24
+ ): Promise<WorkEnvelope> {
25
+ const floorplanPromise = openApiClient
26
+ .getModelFloorplan(resourceCode, { signal })
27
+ .catch((error: unknown) => {
28
+ if (!signal?.aborted) {
29
+ console.warn(
30
+ `资源 ${resourceCode} 的平面图 capability 不可用,继续返回基础 Work。`,
31
+ error,
32
+ )
33
+ }
34
+ return undefined
35
+ })
36
+ const work = await openApiClient.getWorkDetail(resourceCode, { signal })
37
+ const modelFloorplan = await floorplanPromise
38
+ const workCode =
39
+ firstString(
40
+ work.resource_code,
41
+ work.workCode,
42
+ work.work_code,
43
+ work.basic_code,
44
+ ) || resourceCode
45
+ const baseURL = firstString(work.baseURL, work.base_url)
46
+ return {
47
+ work,
48
+ workCode,
49
+ ...(baseURL ? { baseURL } : {}),
50
+ ...(modelFloorplan ? { capabilities: { modelFloorplan } } : {}),
51
+ }
52
+ },
53
+ })
54
+ }
@@ -0,0 +1,242 @@
1
+ export interface FloorplanServerPosition {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export interface FloorplanServerImagePosition {
7
+ x: number
8
+ y: number
9
+ }
10
+
11
+ export interface FloorplanServerOutlineItem {
12
+ url: string
13
+ index: number
14
+ checksum?: string
15
+ svg_url?: string
16
+ }
17
+
18
+ export interface FloorplanServerEntrance {
19
+ rad: number
20
+ position: FloorplanServerPosition
21
+ position_in_image: FloorplanServerImagePosition
22
+ north_rad: number
23
+ room_id: string
24
+ floor_index: number
25
+ }
26
+
27
+ export interface FloorplanServerBounding {
28
+ max: FloorplanServerPosition
29
+ min: FloorplanServerPosition
30
+ origin: FloorplanServerPosition
31
+ }
32
+
33
+ export interface FloorplanServerRoomLabelItem {
34
+ position: FloorplanServerPosition
35
+ position_in_image: FloorplanServerImagePosition
36
+ }
37
+
38
+ export interface FloorplanServerRoomItem {
39
+ id: string
40
+ size: number
41
+ type: number
42
+ name: string
43
+ customizedName?: string
44
+ path: FloorplanServerPosition[]
45
+ room_type: string
46
+ floor_type: number
47
+ room_label: FloorplanServerRoomLabelItem
48
+ observer_indexs: number[]
49
+ dimension?: {
50
+ width: number
51
+ height: number
52
+ }
53
+ }
54
+
55
+ export type FloorplanServerRuleLabels = Record<
56
+ 'top' | 'right' | 'bottom' | 'left',
57
+ FloorplanServerPosition[][]
58
+ >
59
+
60
+ export interface FloorplanServerFloorData {
61
+ floor_name: string
62
+ floor_index: number
63
+ rooms: FloorplanServerRoomItem[]
64
+ rules: FloorplanServerRuleLabels
65
+ is_has_wall?: boolean
66
+ }
67
+
68
+ export interface FloorplanServerObserver {
69
+ index: number
70
+ floor_index: number
71
+ position: FloorplanServerPosition
72
+ position_in_image: FloorplanServerImagePosition
73
+ }
74
+
75
+ export interface FloorplanServerDoorItem {
76
+ id: string
77
+ localName: string
78
+ name: string
79
+ position: {
80
+ x: number
81
+ y: number
82
+ z: number
83
+ }
84
+ rad: number
85
+ }
86
+
87
+ export interface FloorplanServerComputedData {
88
+ floor_datas: FloorplanServerFloorData[]
89
+ entrance: FloorplanServerEntrance | null
90
+ bounding: FloorplanServerBounding
91
+ observers: FloorplanServerObserver[]
92
+ doors: FloorplanServerDoorItem[]
93
+ }
94
+
95
+ export interface FloorplanServerData {
96
+ outlines: FloorplanServerOutlineItem[]
97
+ computed_data: FloorplanServerComputedData
98
+ }
99
+
100
+ /** Preferred ModelFloorplanPlugin.load input returned by format_floorplan_url. */
101
+ export interface ModelFloorplanServerData {
102
+ version: number
103
+ data: FloorplanServerData
104
+ }
105
+
106
+ type RawRecord = Record<string, unknown>
107
+
108
+ const isRecord = (value: unknown): value is RawRecord =>
109
+ Boolean(value && typeof value === 'object' && !Array.isArray(value))
110
+ const isFiniteNumber = (value: unknown): value is number =>
111
+ typeof value === 'number' && Number.isFinite(value)
112
+ const isInteger = (value: unknown): value is number => Number.isInteger(value)
113
+ const isString = (value: unknown): value is string => typeof value === 'string'
114
+ const isOptionalString = (value: unknown): value is string | undefined =>
115
+ value === undefined || isString(value)
116
+ const isArrayOf = <Item>(
117
+ value: unknown,
118
+ guard: (item: unknown) => item is Item,
119
+ ): value is Item[] => Array.isArray(value) && value.every(guard)
120
+
121
+ const isPosition = (value: unknown): value is FloorplanServerPosition =>
122
+ isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y)
123
+
124
+ const isOutline = (value: unknown): value is FloorplanServerOutlineItem =>
125
+ isRecord(value) &&
126
+ isString(value.url) &&
127
+ value.url.length > 0 &&
128
+ isInteger(value.index) &&
129
+ isOptionalString(value.checksum) &&
130
+ isOptionalString(value.svg_url)
131
+
132
+ const isEntrance = (value: unknown): value is FloorplanServerEntrance =>
133
+ isRecord(value) &&
134
+ isFiniteNumber(value.rad) &&
135
+ isPosition(value.position) &&
136
+ isPosition(value.position_in_image) &&
137
+ isFiniteNumber(value.north_rad) &&
138
+ isString(value.room_id) &&
139
+ isInteger(value.floor_index)
140
+
141
+ const isBounding = (value: unknown): value is FloorplanServerBounding =>
142
+ isRecord(value) &&
143
+ isPosition(value.max) &&
144
+ isPosition(value.min) &&
145
+ isPosition(value.origin)
146
+
147
+ const isRoomLabel = (value: unknown): value is FloorplanServerRoomLabelItem =>
148
+ isRecord(value) &&
149
+ isPosition(value.position) &&
150
+ isPosition(value.position_in_image)
151
+
152
+ const isRoom = (value: unknown): value is FloorplanServerRoomItem => {
153
+ if (!isRecord(value)) return false
154
+ const dimension = value.dimension
155
+ return (
156
+ isString(value.id) &&
157
+ isFiniteNumber(value.size) &&
158
+ isFiniteNumber(value.type) &&
159
+ isString(value.name) &&
160
+ isOptionalString(value.customizedName) &&
161
+ isArrayOf(value.path, isPosition) &&
162
+ isString(value.room_type) &&
163
+ isFiniteNumber(value.floor_type) &&
164
+ isRoomLabel(value.room_label) &&
165
+ isArrayOf(value.observer_indexs, isInteger) &&
166
+ (dimension === undefined ||
167
+ (isRecord(dimension) &&
168
+ isFiniteNumber(dimension.width) &&
169
+ isFiniteNumber(dimension.height)))
170
+ )
171
+ }
172
+
173
+ const isRuleLines = (value: unknown): value is FloorplanServerPosition[][] =>
174
+ Array.isArray(value) && value.every((line) => isArrayOf(line, isPosition))
175
+
176
+ const isRules = (value: unknown): value is FloorplanServerRuleLabels =>
177
+ isRecord(value) &&
178
+ isRuleLines(value.top) &&
179
+ isRuleLines(value.right) &&
180
+ isRuleLines(value.bottom) &&
181
+ isRuleLines(value.left)
182
+
183
+ const isFloorData = (value: unknown): value is FloorplanServerFloorData =>
184
+ isRecord(value) &&
185
+ isString(value.floor_name) &&
186
+ isInteger(value.floor_index) &&
187
+ isArrayOf(value.rooms, isRoom) &&
188
+ isRules(value.rules) &&
189
+ (value.is_has_wall === undefined || typeof value.is_has_wall === 'boolean')
190
+
191
+ const isObserver = (value: unknown): value is FloorplanServerObserver =>
192
+ isRecord(value) &&
193
+ isInteger(value.index) &&
194
+ isInteger(value.floor_index) &&
195
+ isPosition(value.position) &&
196
+ isPosition(value.position_in_image)
197
+
198
+ const isDoor = (value: unknown): value is FloorplanServerDoorItem => {
199
+ if (!isRecord(value) || !isRecord(value.position)) return false
200
+ return (
201
+ isString(value.id) &&
202
+ isString(value.localName) &&
203
+ isString(value.name) &&
204
+ isFiniteNumber(value.position.x) &&
205
+ isFiniteNumber(value.position.y) &&
206
+ isFiniteNumber(value.position.z) &&
207
+ isFiniteNumber(value.rad)
208
+ )
209
+ }
210
+
211
+ const isComputedData = (value: unknown): value is FloorplanServerComputedData =>
212
+ isRecord(value) &&
213
+ isArrayOf(value.floor_datas, isFloorData) &&
214
+ value.floor_datas.length > 0 &&
215
+ (value.entrance === null || isEntrance(value.entrance)) &&
216
+ isBounding(value.bounding) &&
217
+ isArrayOf(value.observers, isObserver) &&
218
+ isArrayOf(value.doors, isDoor)
219
+
220
+ const isFloorplanServerData = (value: unknown): value is FloorplanServerData =>
221
+ isRecord(value) &&
222
+ isArrayOf(value.outlines, isOutline) &&
223
+ value.outlines.length > 0 &&
224
+ isComputedData(value.computed_data)
225
+
226
+ /**
227
+ * Narrows the browser/server boundary without guessing data from raw Work.
228
+ * Empty or partial floorplan payloads are intentionally treated as unavailable.
229
+ */
230
+ export function asModelFloorplanServerData(
231
+ value: unknown,
232
+ ): ModelFloorplanServerData | undefined {
233
+ if (
234
+ !isRecord(value) ||
235
+ !isInteger(value.version) ||
236
+ value.version < 0 ||
237
+ !isFloorplanServerData(value.data)
238
+ ) {
239
+ return undefined
240
+ }
241
+ return { version: value.version, data: value.data }
242
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import '@realsee/vapor/vapor.css'
4
+ import App from './app/App'
5
+ import './styles.css'
6
+
7
+ ReactDOM.createRoot(document.getElementById('root')!).render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>,
11
+ )
@@ -0,0 +1,28 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ export function useElementSize() {
4
+ const ref = useRef<HTMLDivElement>(null)
5
+ const [size, setSize] = useState({ width: 0, height: 0 })
6
+
7
+ useEffect(() => {
8
+ const element = ref.current
9
+ if (!element) return
10
+ const measure = (width: number, height: number) => {
11
+ const next = { width: Math.round(width), height: Math.round(height) }
12
+ setSize((current) =>
13
+ current.width === next.width && current.height === next.height
14
+ ? current
15
+ : next,
16
+ )
17
+ }
18
+ measure(element.clientWidth, element.clientHeight)
19
+ const observer = new ResizeObserver(([entry]) => {
20
+ if (!entry) return
21
+ measure(entry.contentRect.width, entry.contentRect.height)
22
+ })
23
+ observer.observe(element)
24
+ return () => observer.disconnect()
25
+ }, [])
26
+
27
+ return { ref, size }
28
+ }
@@ -0,0 +1,91 @@
1
+ :root {
2
+ color: #f7f9ff;
3
+ background: #090e1b;
4
+ font-family:
5
+ Inter,
6
+ ui-sans-serif,
7
+ system-ui,
8
+ -apple-system,
9
+ BlinkMacSystemFont,
10
+ 'Segoe UI',
11
+ sans-serif;
12
+ font-synthesis: none;
13
+ }
14
+
15
+ html,
16
+ body,
17
+ #root {
18
+ width: 100%;
19
+ height: 100%;
20
+ margin: 0;
21
+ overflow: hidden;
22
+ }
23
+
24
+ .app {
25
+ width: 100%;
26
+ height: 100%;
27
+ min-width: 0;
28
+ min-height: 0;
29
+ overflow: hidden;
30
+ background: #090e1b;
31
+ }
32
+
33
+ .spatial-stage {
34
+ position: relative;
35
+ width: 100%;
36
+ height: 100%;
37
+ min-width: 0;
38
+ min-height: 0;
39
+ overflow: hidden;
40
+ background: #101728;
41
+ }
42
+
43
+ .five-surface {
44
+ position: absolute;
45
+ inset: 0;
46
+ min-width: 0;
47
+ min-height: 0;
48
+ }
49
+
50
+ .five-surface canvas {
51
+ display: block;
52
+ width: 100%;
53
+ height: 100%;
54
+ }
55
+
56
+ .dom-overlay {
57
+ position: absolute;
58
+ inset: 0;
59
+ z-index: 20;
60
+ pointer-events: none;
61
+ }
62
+
63
+ .viewer-vapor {
64
+ width: 100%;
65
+ height: 100%;
66
+ }
67
+
68
+ .viewer-controls,
69
+ .mode-switcher {
70
+ pointer-events: auto;
71
+ }
72
+
73
+ .mode-switcher-indicator {
74
+ position: absolute;
75
+ bottom: -0.5rem;
76
+ left: 50%;
77
+ width: 3.375rem;
78
+ height: 0.125rem;
79
+ transform: translateX(-50%);
80
+ background-image: linear-gradient(
81
+ to right,
82
+ rgba(255, 255, 255, 0),
83
+ rgba(255, 255, 255, 1) 50%,
84
+ rgba(255, 255, 255, 0)
85
+ );
86
+ }
87
+
88
+ .viewer-status {
89
+ max-width: calc(100vw - 2rem);
90
+ pointer-events: none;
91
+ }
@@ -0,0 +1,234 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react'
9
+ import { Five } from '@realsee/five'
10
+ import { SpatialPluginRegistry } from '../plugins/SpatialPluginRegistry'
11
+ import { createFiveInitArgs } from './fiveConfig'
12
+
13
+ export class SpatialRuntimeLifetime {
14
+ private disposed = false
15
+ private teardowns = new Set<() => void>()
16
+ private pending = new Set<Promise<unknown>>()
17
+ private idleCallbacks = new Set<() => void>()
18
+
19
+ get active() {
20
+ return !this.disposed
21
+ }
22
+
23
+ own(teardown: () => void) {
24
+ let owned = true
25
+ const release = () => {
26
+ if (!owned) return
27
+ owned = false
28
+ this.teardowns.delete(release)
29
+ teardown()
30
+ }
31
+ if (this.disposed) release()
32
+ else this.teardowns.add(release)
33
+ return release
34
+ }
35
+
36
+ track<T>(task: Promise<T>) {
37
+ if (this.disposed) {
38
+ return task.then<T>(
39
+ () => Promise.reject(new Error('空间运行时已停止。')),
40
+ () => Promise.reject(new Error('空间运行时已停止。')),
41
+ )
42
+ }
43
+ const tracked = task.finally(() => {
44
+ this.pending.delete(tracked)
45
+ this.flushIdleCallbacks()
46
+ })
47
+ this.pending.add(tracked)
48
+ return tracked
49
+ }
50
+
51
+ private queue: Promise<void> = Promise.resolve()
52
+
53
+ runExclusive<T>(operation: () => Promise<T>) {
54
+ const task = this.queue.then(async () => {
55
+ if (this.disposed) throw new Error('空间运行时已停止。')
56
+ return operation()
57
+ })
58
+ this.queue = task.then(
59
+ () => undefined,
60
+ () => undefined,
61
+ )
62
+ return this.track(task)
63
+ }
64
+
65
+ disposeFeatures() {
66
+ if (this.disposed) return []
67
+ this.disposed = true
68
+ const errors: unknown[] = []
69
+ for (const teardown of [...this.teardowns].reverse()) {
70
+ try {
71
+ teardown()
72
+ } catch (error) {
73
+ errors.push(error)
74
+ }
75
+ }
76
+ this.flushIdleCallbacks()
77
+ return errors
78
+ }
79
+
80
+ afterPending(callback: () => void) {
81
+ if (this.pending.size === 0) callback()
82
+ else this.idleCallbacks.add(callback)
83
+ }
84
+
85
+ private flushIdleCallbacks() {
86
+ if (this.pending.size !== 0) return
87
+ const callbacks = [...this.idleCallbacks]
88
+ this.idleCallbacks.clear()
89
+ for (const callback of callbacks) {
90
+ try {
91
+ callback()
92
+ } catch (error) {
93
+ console.error('空间运行时延迟清理失败。', error)
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ export type SpatialRuntime = {
100
+ five: Five
101
+ plugins: SpatialPluginRegistry
102
+ lifetime: SpatialRuntimeLifetime
103
+ }
104
+
105
+ const SpatialRuntimeContext = createContext<SpatialRuntime | null>(null)
106
+
107
+ export function SpatialRuntimeProvider({ children }: { children: ReactNode }) {
108
+ const hostRef = useRef<HTMLDivElement>(null)
109
+ const [runtime, setRuntime] = useState<SpatialRuntime | null>(null)
110
+
111
+ useLayoutEffect(() => {
112
+ const host = hostRef.current
113
+ if (!host) return
114
+
115
+ let five: Five | null = null
116
+ let plugins: SpatialPluginRegistry | null = null
117
+ let lifetime: SpatialRuntimeLifetime | null = null
118
+ let observer: ResizeObserver | null = null
119
+ let published = false
120
+ let disposed = false
121
+
122
+ const finalizeRuntime = (
123
+ ownedFive: Five,
124
+ ownedPlugins: SpatialPluginRegistry | null,
125
+ ) => {
126
+ const pluginErrors = ownedPlugins?.disposeAll() ?? []
127
+ if (pluginErrors.length)
128
+ console.error('部分空间插件清理失败。', pluginErrors)
129
+
130
+ const canvas = ownedFive.getElement()
131
+ try {
132
+ ownedFive.dispose()
133
+ } catch (error) {
134
+ console.error('Five 运行时清理失败。', error)
135
+ } finally {
136
+ canvas?.remove()
137
+ }
138
+ }
139
+
140
+ const teardown = () => {
141
+ if (disposed) return
142
+ disposed = true
143
+ observer?.disconnect()
144
+ observer = null
145
+
146
+ const ownedFive = five
147
+ const ownedPlugins = plugins
148
+ const ownedLifetime = lifetime
149
+ five = null
150
+ plugins = null
151
+ lifetime = null
152
+
153
+ if (published && ownedFive) {
154
+ setRuntime((current) => (current?.five === ownedFive ? null : current))
155
+ }
156
+ const featureErrors = ownedLifetime?.disposeFeatures() ?? []
157
+ if (featureErrors.length)
158
+ console.error('部分空间 feature 清理失败。', featureErrors)
159
+ if (!ownedFive) return
160
+
161
+ const finalize = () => finalizeRuntime(ownedFive, ownedPlugins)
162
+ if (ownedLifetime) ownedLifetime.afterPending(finalize)
163
+ else finalize()
164
+ }
165
+
166
+ const applySize = (rawWidth: number, rawHeight: number) => {
167
+ if (disposed) return
168
+ const width = Math.round(rawWidth)
169
+ const height = Math.round(rawHeight)
170
+ if (width <= 0 || height <= 0) return
171
+
172
+ if (five) {
173
+ five.refresh({ width, height }, window.devicePixelRatio)
174
+ return
175
+ }
176
+
177
+ const nextFive = new Five(createFiveInitArgs())
178
+ const nextLifetime = new SpatialRuntimeLifetime()
179
+ five = nextFive
180
+ lifetime = nextLifetime
181
+ try {
182
+ nextFive.appendTo(host, { width, height })
183
+ nextFive.refresh({ width, height }, window.devicePixelRatio)
184
+ const nextPlugins = new SpatialPluginRegistry(nextFive)
185
+ plugins = nextPlugins
186
+ published = true
187
+ setRuntime({
188
+ five: nextFive,
189
+ plugins: nextPlugins,
190
+ lifetime: nextLifetime,
191
+ })
192
+ } catch (error) {
193
+ teardown()
194
+ throw error
195
+ }
196
+ }
197
+
198
+ try {
199
+ observer = new ResizeObserver(([entry]) => {
200
+ if (!entry) return
201
+ try {
202
+ applySize(entry.contentRect.width, entry.contentRect.height)
203
+ } catch (error) {
204
+ console.error('Five 原生 surface 初始化失败。', error)
205
+ }
206
+ })
207
+ observer.observe(host)
208
+ applySize(host.clientWidth, host.clientHeight)
209
+ } catch (error) {
210
+ teardown()
211
+ throw error
212
+ }
213
+
214
+ return teardown
215
+ }, [])
216
+
217
+ return (
218
+ <div className="spatial-stage">
219
+ <div ref={hostRef} className="five-surface" />
220
+ {runtime && (
221
+ <SpatialRuntimeContext.Provider value={runtime}>
222
+ {children}
223
+ </SpatialRuntimeContext.Provider>
224
+ )}
225
+ </div>
226
+ )
227
+ }
228
+
229
+ export function useSpatialRuntime() {
230
+ const runtime = useContext(SpatialRuntimeContext)
231
+ if (!runtime)
232
+ throw new Error('useSpatialRuntime 必须在 SpatialRuntimeProvider 内调用。')
233
+ return runtime
234
+ }
@@ -0,0 +1,10 @@
1
+ // Version-bound public Five API surface. Import only the capability a feature uses.
2
+ // The runtime is constructed directly from @realsee/five; do not use its React subpath.
3
+ export * from '@realsee/five'
4
+ export * as FivePlugins from '@realsee/five/plugins'
5
+ export * as FiveLine from '@realsee/five/line'
6
+ export * as FiveSticker from '@realsee/five/sticker'
7
+ export * as FiveVfx from '@realsee/five/vfx'
8
+ export * as FiveGltfLoader from '@realsee/five/gltf-loader'
9
+ export * as FiveShaderLib from '@realsee/five/shader-lib'
10
+ export * as FiveWorkDownloader from '@realsee/five/work-downloader'