@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.
- package/NOTICE.md +7 -0
- package/README.md +216 -0
- package/TERMS.txt +54 -0
- package/agent-assets/AGENTS.md +113 -0
- package/agent-assets/adapters/.cursor/rules/realsee-vr.mdc +7 -0
- package/agent-assets/adapters/.github/copilot-instructions.md +1 -0
- package/agent-assets/adapters/.kiro/steering/realsee-vr.md +6 -0
- package/agent-assets/adapters/CLAUDE.md +3 -0
- package/bin/vr-cli.mjs +3 -0
- package/dist/cli.mjs +24 -0
- package/package.json +38 -0
- package/skills/realsee-image-resources/SKILL.md +82 -0
- package/skills/realsee-image-resources/references/cors-and-three-textures.md +50 -0
- package/skills/realsee-image-resources/references/data-wanxiang.md +57 -0
- package/skills/realsee-image-resources/references/five-image-url-transform.md +81 -0
- package/skills/realsee-image-resources/references/source-and-domain-policy.md +63 -0
- package/skills/realsee-vr-app/SKILL.md +157 -0
- package/skills/realsee-vr-app/references/continuous-improvement.md +24 -0
- package/skills/realsee-vr-app/references/dnalogel-api-capabilities.md +241 -0
- package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +818 -0
- package/skills/realsee-vr-app/references/five-api-capabilities.md +119 -0
- package/skills/realsee-vr-app/references/five-runtime.md +83 -0
- package/skills/realsee-vr-app/references/official-docs.md +61 -0
- package/skills/realsee-vr-app/references/plugin-lifecycle.md +65 -0
- package/skills/realsee-vr-app/references/ui-and-validation.md +57 -0
- package/skills/realsee-vr-app/references/vapor-dom-layer.md +186 -0
- package/skills/realsee-vr-app/scripts/verify-project.mjs +1566 -0
- package/skills/threejs-r117-five/SKILL.md +71 -0
- package/skills/threejs-r117-five/references/five-ownership.md +70 -0
- package/skills/threejs-r117-five/references/r117-capabilities.md +82 -0
- package/skills/threejs-r117-five/references/r117-version-guards.md +60 -0
- package/templates/base/.editorconfig +12 -0
- package/templates/base/.env.example +12 -0
- package/templates/base/.gitignore.template +6 -0
- package/templates/base/.node-version +1 -0
- package/templates/base/.npmrc.template +3 -0
- package/templates/base/.nvmrc +1 -0
- package/templates/base/.prettierignore +12 -0
- package/templates/base/README.md +160 -0
- package/templates/base/eslint.config.mjs +75 -0
- package/templates/base/index.html +13 -0
- package/templates/base/package-lock.json +6444 -0
- package/templates/base/package.json +55 -0
- package/templates/base/prettier.config.mjs +5 -0
- package/templates/base/server/app.ts +54 -0
- package/templates/base/server/check-config.ts +4 -0
- package/templates/base/server/config.ts +54 -0
- package/templates/base/server/index.ts +31 -0
- package/templates/base/server/realsee-open-api.ts +316 -0
- package/templates/base/server/types.ts +64 -0
- package/templates/base/server/work-route.ts +80 -0
- package/templates/base/server/work-service.ts +54 -0
- package/templates/base/shared/model-floorplan.ts +242 -0
- package/templates/base/src/main.tsx +11 -0
- package/templates/base/src/shared/useElementSize.ts +28 -0
- package/templates/base/src/styles.css +91 -0
- package/templates/base/src/vr/five/SpatialRuntimeProvider.tsx +234 -0
- package/templates/base/src/vr/five/api.ts +10 -0
- package/templates/base/src/vr/five/fiveConfig.ts +42 -0
- package/templates/base/src/vr/five/initialState.ts +48 -0
- package/templates/base/src/vr/plugins/SpatialPluginRegistry.ts +104 -0
- package/templates/base/src/vr/plugins/dnalogel.ts +2 -0
- package/templates/base/src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts +73 -0
- package/templates/base/src/vr/work/WorkSource.ts +247 -0
- package/templates/base/tsconfig.json +27 -0
- package/templates/base/tsconfig.server.json +25 -0
- package/templates/base/vite.config.ts +21 -0
- package/templates/viewer/src/app/App.tsx +182 -0
- package/templates/viewer/src/features/floorplan/ModelFloorplanFeature.ts +141 -0
- package/templates/viewer/src/features/navigation/ModeSwitcher.tsx +111 -0
- package/templates/viewer/src/features/tags/PanoTagFeature.ts +110 -0
- 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'
|