@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,182 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { parseWork, type Five } from '@realsee/five'
|
|
3
|
+
import {
|
|
4
|
+
createModelFloorplanFeature,
|
|
5
|
+
type ModelFloorplanFeature,
|
|
6
|
+
} from '../features/floorplan/ModelFloorplanFeature'
|
|
7
|
+
import { ModeSwitcher } from '../features/navigation/ModeSwitcher'
|
|
8
|
+
import { createPanoTagFeature } from '../features/tags/PanoTagFeature'
|
|
9
|
+
import { ViewerOverlay } from '../ui/ViewerOverlay'
|
|
10
|
+
import { initialStateFrom } from '../vr/five/initialState'
|
|
11
|
+
import {
|
|
12
|
+
SpatialRuntimeProvider,
|
|
13
|
+
useSpatialRuntime,
|
|
14
|
+
type SpatialRuntimeLifetime,
|
|
15
|
+
} from '../vr/five/SpatialRuntimeProvider'
|
|
16
|
+
import { workSource } from '../vr/work/WorkSource'
|
|
17
|
+
|
|
18
|
+
function waitForActiveModel(five: Five, lifetime: SpatialRuntimeLifetime) {
|
|
19
|
+
if (five.model.loaded) return Promise.resolve()
|
|
20
|
+
|
|
21
|
+
return new Promise<void>((resolve, reject) => {
|
|
22
|
+
let settled = false
|
|
23
|
+
let release = () => {}
|
|
24
|
+
let stopLoaded = () => {}
|
|
25
|
+
let stopError = () => {}
|
|
26
|
+
|
|
27
|
+
const settle = (error?: Error) => {
|
|
28
|
+
if (settled) return
|
|
29
|
+
settled = true
|
|
30
|
+
release()
|
|
31
|
+
if (error) reject(error)
|
|
32
|
+
else resolve()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stopLoaded = five.on('models.load', () => {
|
|
36
|
+
if (five.model.loaded) settle()
|
|
37
|
+
})
|
|
38
|
+
stopError = five.on('model.error', () =>
|
|
39
|
+
settle(new Error('Five 模型加载失败。')),
|
|
40
|
+
)
|
|
41
|
+
release = lifetime.own(() => {
|
|
42
|
+
stopLoaded()
|
|
43
|
+
stopError()
|
|
44
|
+
if (!settled) {
|
|
45
|
+
settled = true
|
|
46
|
+
reject(new Error('空间运行时已停止。'))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
if (five.model.loaded) settle()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ViewerScene() {
|
|
54
|
+
const { five, plugins, lifetime } = useSpatialRuntime()
|
|
55
|
+
const [ready, setReady] = useState(false)
|
|
56
|
+
const [workName, setWorkName] = useState<string | null>(null)
|
|
57
|
+
const [status, setStatus] = useState('正在加载 VR Work…')
|
|
58
|
+
const [error, setError] = useState<string | null>(null)
|
|
59
|
+
const [selectedTagId, setSelectedTagId] = useState<string | null>(null)
|
|
60
|
+
const [floorplanFeature, setFloorplanFeature] =
|
|
61
|
+
useState<ModelFloorplanFeature | null>(null)
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const request = new AbortController()
|
|
65
|
+
let active = true
|
|
66
|
+
let tags: ReturnType<typeof createPanoTagFeature> | null = null
|
|
67
|
+
let floorplan: ModelFloorplanFeature | null = null
|
|
68
|
+
const teardown = lifetime.own(() => {
|
|
69
|
+
active = false
|
|
70
|
+
request.abort()
|
|
71
|
+
tags?.dispose()
|
|
72
|
+
tags = null
|
|
73
|
+
floorplan?.dispose()
|
|
74
|
+
floorplan = null
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const load = async () => {
|
|
78
|
+
try {
|
|
79
|
+
setReady(false)
|
|
80
|
+
setWorkName(null)
|
|
81
|
+
setError(null)
|
|
82
|
+
setSelectedTagId(null)
|
|
83
|
+
setFloorplanFeature(null)
|
|
84
|
+
const payload = await workSource.load(request.signal)
|
|
85
|
+
if (!active) return
|
|
86
|
+
setWorkName(payload.displayName)
|
|
87
|
+
|
|
88
|
+
await lifetime.runExclusive(async () => {
|
|
89
|
+
if (!active) return
|
|
90
|
+
const work = parseWork(payload.work, {
|
|
91
|
+
workCode: payload.workCode,
|
|
92
|
+
...(payload.baseURL ? { baseURL: payload.baseURL } : {}),
|
|
93
|
+
})
|
|
94
|
+
await five.load(
|
|
95
|
+
[work],
|
|
96
|
+
initialStateFrom(payload.work, payload.initialState),
|
|
97
|
+
{ duration: 0 },
|
|
98
|
+
)
|
|
99
|
+
if (!active) return
|
|
100
|
+
await waitForActiveModel(five, lifetime)
|
|
101
|
+
if (!active) return
|
|
102
|
+
|
|
103
|
+
const modelFloorplan = payload.capabilities?.modelFloorplan
|
|
104
|
+
if (modelFloorplan) {
|
|
105
|
+
const currentFloorplan = createModelFloorplanFeature(five, plugins)
|
|
106
|
+
floorplan = currentFloorplan
|
|
107
|
+
try {
|
|
108
|
+
await currentFloorplan.load(modelFloorplan)
|
|
109
|
+
} catch (floorplanError) {
|
|
110
|
+
currentFloorplan.dispose()
|
|
111
|
+
if (floorplan === currentFloorplan) floorplan = null
|
|
112
|
+
console.warn(
|
|
113
|
+
'平面图 capability 初始化失败,继续使用基础 Viewer。',
|
|
114
|
+
floorplanError,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const panoTagList = payload.presetData?.panoTagList
|
|
120
|
+
if (panoTagList?.length) {
|
|
121
|
+
const currentTags = createPanoTagFeature(five, plugins, (id) => {
|
|
122
|
+
if (!active) return
|
|
123
|
+
setSelectedTagId(id)
|
|
124
|
+
setStatus(`已选择空间标签 ${id}`)
|
|
125
|
+
})
|
|
126
|
+
tags = currentTags
|
|
127
|
+
try {
|
|
128
|
+
await currentTags.load(panoTagList)
|
|
129
|
+
} catch (tagError) {
|
|
130
|
+
currentTags.dispose()
|
|
131
|
+
if (tags === currentTags) tags = null
|
|
132
|
+
throw tagError
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!active) return
|
|
136
|
+
setFloorplanFeature(floorplan)
|
|
137
|
+
setReady(true)
|
|
138
|
+
setStatus(`空间已就绪 · ${payload.workCode}`)
|
|
139
|
+
})
|
|
140
|
+
} catch (loadError) {
|
|
141
|
+
floorplan?.dispose()
|
|
142
|
+
floorplan = null
|
|
143
|
+
if (!active) return
|
|
144
|
+
setFloorplanFeature(null)
|
|
145
|
+
setError(
|
|
146
|
+
loadError instanceof Error ? loadError.message : '空间加载失败',
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (active) void load()
|
|
152
|
+
return teardown
|
|
153
|
+
}, [five, plugins, lifetime])
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<ViewerOverlay
|
|
157
|
+
controls={
|
|
158
|
+
ready ? (
|
|
159
|
+
<ModeSwitcher
|
|
160
|
+
five={five}
|
|
161
|
+
lifetime={lifetime}
|
|
162
|
+
floorplan={floorplanFeature}
|
|
163
|
+
/>
|
|
164
|
+
) : null
|
|
165
|
+
}
|
|
166
|
+
error={error}
|
|
167
|
+
selectedTagId={selectedTagId}
|
|
168
|
+
status={status}
|
|
169
|
+
title={workName}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function App() {
|
|
175
|
+
return (
|
|
176
|
+
<main className="app">
|
|
177
|
+
<SpatialRuntimeProvider>
|
|
178
|
+
<ViewerScene />
|
|
179
|
+
</SpatialRuntimeProvider>
|
|
180
|
+
</main>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Five } from '@realsee/five'
|
|
2
|
+
import ModelFloorplanPlugin, {
|
|
3
|
+
type ModelFloorplanPluginReturnType,
|
|
4
|
+
} from '@realsee/dnalogel/libs/floorplan/ModelFloorplanPlugin'
|
|
5
|
+
import type { ModelFloorplanServerData } from '../../../shared/model-floorplan'
|
|
6
|
+
import type { SpatialPluginRegistry } from '../../vr/plugins/SpatialPluginRegistry'
|
|
7
|
+
const PLUGIN_NAME = 'dnalogel.modelFloorplan'
|
|
8
|
+
type VisibilityListener = (visible: boolean) => void
|
|
9
|
+
export function createModelFloorplanFeature(
|
|
10
|
+
five: Five,
|
|
11
|
+
plugins: SpatialPluginRegistry,
|
|
12
|
+
) {
|
|
13
|
+
const modelSurface = five.getElement()
|
|
14
|
+
const wrapper = modelSurface?.parentElement
|
|
15
|
+
if (!modelSurface || !wrapper) {
|
|
16
|
+
throw new Error('ModelFloorplanPlugin 找不到 Five DOM 宿主。')
|
|
17
|
+
}
|
|
18
|
+
const restoreModelSurface = () => {
|
|
19
|
+
modelSurface.style.opacity = '1'
|
|
20
|
+
modelSurface.style.transition = 'none'
|
|
21
|
+
for (const element of Array.from(wrapper.children)) {
|
|
22
|
+
if (element instanceof HTMLElement && element.dataset.infive === '1') {
|
|
23
|
+
element.style.opacity = '1'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const candidate = ModelFloorplanPlugin(five, { autoShowEnable: false })
|
|
28
|
+
let plugin: ModelFloorplanPluginReturnType
|
|
29
|
+
try {
|
|
30
|
+
plugin = plugins.register(PLUGIN_NAME, candidate)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
try {
|
|
33
|
+
candidate.dispose()
|
|
34
|
+
} catch (disposeError) {
|
|
35
|
+
console.error(
|
|
36
|
+
'ModelFloorplanPlugin 注册失败后清理候选实例失败。',
|
|
37
|
+
disposeError,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
plugin.appendTo(wrapper)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const disposeError = plugins.unregister(PLUGIN_NAME, plugin)
|
|
46
|
+
if (disposeError) {
|
|
47
|
+
console.error(
|
|
48
|
+
'ModelFloorplanPlugin 挂载失败后清理实例失败。',
|
|
49
|
+
disposeError,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
const visibilityListeners = new Set<VisibilityListener>()
|
|
55
|
+
const emitVisibility = () => {
|
|
56
|
+
for (const listener of visibilityListeners) listener(plugin.state.visible)
|
|
57
|
+
}
|
|
58
|
+
const stopShow = plugin.hooks.on('show', emitVisibility)
|
|
59
|
+
const stopHide = plugin.hooks.on('hide', emitVisibility)
|
|
60
|
+
const stopStateChange = plugin.hooks.on(
|
|
61
|
+
'stateChange',
|
|
62
|
+
({ state, prevState }) => {
|
|
63
|
+
if (state.visible === prevState?.visible) return
|
|
64
|
+
if (!state.visible) restoreModelSurface()
|
|
65
|
+
emitVisibility()
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
let loading: Promise<void> | null = null
|
|
69
|
+
let disposeRequested = false
|
|
70
|
+
let finalized = false
|
|
71
|
+
const finalize = () => {
|
|
72
|
+
if (finalized) return
|
|
73
|
+
finalized = true
|
|
74
|
+
restoreModelSurface()
|
|
75
|
+
visibilityListeners.clear()
|
|
76
|
+
const disposeError = plugins.unregister(PLUGIN_NAME, plugin)
|
|
77
|
+
if (disposeError)
|
|
78
|
+
console.error('ModelFloorplanPlugin 实例清理失败。', disposeError)
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
plugin,
|
|
82
|
+
get visible() {
|
|
83
|
+
return plugin.state.visible
|
|
84
|
+
},
|
|
85
|
+
subscribeVisibility(listener: VisibilityListener) {
|
|
86
|
+
visibilityListeners.add(listener)
|
|
87
|
+
listener(plugin.state.visible)
|
|
88
|
+
return () => visibilityListeners.delete(listener)
|
|
89
|
+
},
|
|
90
|
+
async load(serverData: ModelFloorplanServerData) {
|
|
91
|
+
if (disposeRequested)
|
|
92
|
+
throw new Error('ModelFloorplanPlugin feature 已停止。')
|
|
93
|
+
if (loading)
|
|
94
|
+
throw new Error('ModelFloorplanPlugin feature 不支持并发加载。')
|
|
95
|
+
const task = plugin.load(serverData)
|
|
96
|
+
loading = task
|
|
97
|
+
try {
|
|
98
|
+
await task
|
|
99
|
+
} finally {
|
|
100
|
+
if (loading === task) loading = null
|
|
101
|
+
if (disposeRequested) finalize()
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
show() {
|
|
105
|
+
if (disposeRequested)
|
|
106
|
+
return Promise.reject(
|
|
107
|
+
new Error('ModelFloorplanPlugin feature 已停止。'),
|
|
108
|
+
)
|
|
109
|
+
return plugin
|
|
110
|
+
.show({ userAction: true, modelOpacity: 0 })
|
|
111
|
+
.catch((error) => {
|
|
112
|
+
restoreModelSurface()
|
|
113
|
+
throw error
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
hide() {
|
|
117
|
+
if (disposeRequested)
|
|
118
|
+
return Promise.reject(
|
|
119
|
+
new Error('ModelFloorplanPlugin feature 已停止。'),
|
|
120
|
+
)
|
|
121
|
+
return plugin.hide({ userAction: true }).finally(restoreModelSurface)
|
|
122
|
+
},
|
|
123
|
+
dispose() {
|
|
124
|
+
if (disposeRequested) return
|
|
125
|
+
disposeRequested = true
|
|
126
|
+
if (plugin.state.visible) {
|
|
127
|
+
void plugin.hide({ userAction: false }).catch((error) => {
|
|
128
|
+
console.error('ModelFloorplanPlugin 停止时隐藏平面图失败。', error)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
restoreModelSurface()
|
|
132
|
+
stopShow()
|
|
133
|
+
stopHide()
|
|
134
|
+
stopStateChange()
|
|
135
|
+
if (!loading) finalize()
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export type ModelFloorplanFeature = ReturnType<
|
|
140
|
+
typeof createModelFloorplanFeature
|
|
141
|
+
>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Five } from '@realsee/five'
|
|
3
|
+
import { Button, Font, HStack, VStack, useDimension } from '@realsee/vapor'
|
|
4
|
+
import type { ModelFloorplanFeature } from '../floorplan/ModelFloorplanFeature'
|
|
5
|
+
import type { SpatialRuntimeLifetime } from '../../vr/five/SpatialRuntimeProvider'
|
|
6
|
+
|
|
7
|
+
type ViewerTab = 'tour' | 'model' | 'floorplan'
|
|
8
|
+
|
|
9
|
+
const tabFromFiveMode = (mode: string): ViewerTab =>
|
|
10
|
+
mode === Five.Mode.Panorama ? 'tour' : 'model'
|
|
11
|
+
|
|
12
|
+
export function ModeSwitcher({
|
|
13
|
+
five,
|
|
14
|
+
lifetime,
|
|
15
|
+
floorplan,
|
|
16
|
+
}: {
|
|
17
|
+
five: Five
|
|
18
|
+
lifetime: SpatialRuntimeLifetime
|
|
19
|
+
floorplan?: ModelFloorplanFeature | null
|
|
20
|
+
}) {
|
|
21
|
+
const { match, orientation } = useDimension()
|
|
22
|
+
const [tab, setTab] = useState<ViewerTab>(() =>
|
|
23
|
+
floorplan?.visible ? 'floorplan' : tabFromFiveMode(five.currentMode),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const syncMode = ({ mode }: { mode: string }) => {
|
|
28
|
+
if (!floorplan?.visible) setTab(tabFromFiveMode(mode))
|
|
29
|
+
}
|
|
30
|
+
const releaseMode = lifetime.own(five.on('mode.change', syncMode))
|
|
31
|
+
const stopVisibility = floorplan?.subscribeVisibility((visible) => {
|
|
32
|
+
setTab(visible ? 'floorplan' : tabFromFiveMode(five.currentMode))
|
|
33
|
+
})
|
|
34
|
+
return () => {
|
|
35
|
+
stopVisibility?.()
|
|
36
|
+
releaseMode()
|
|
37
|
+
}
|
|
38
|
+
}, [five, floorplan, lifetime])
|
|
39
|
+
|
|
40
|
+
const changeTab = async (nextTab: ViewerTab) => {
|
|
41
|
+
try {
|
|
42
|
+
await lifetime.runExclusive(async () => {
|
|
43
|
+
if (nextTab === 'floorplan') {
|
|
44
|
+
if (!floorplan) return
|
|
45
|
+
await floorplan.show()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (floorplan?.visible) await floorplan.hide()
|
|
50
|
+
if (nextTab === 'model') {
|
|
51
|
+
await five.changeMode(
|
|
52
|
+
Five.Mode.Mapview,
|
|
53
|
+
{ longitude: Math.PI / 4, latitude: Math.PI / 5 },
|
|
54
|
+
{ duration: 300 },
|
|
55
|
+
true,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
await five.changeMode(Five.Mode.Panorama)
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
setTab(
|
|
63
|
+
floorplan?.visible ? 'floorplan' : tabFromFiveMode(five.currentMode),
|
|
64
|
+
)
|
|
65
|
+
if (lifetime.active) console.error(`切换到 ${nextTab} 视图失败。`, error)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const options: Array<{ value: ViewerTab; text: string }> = [
|
|
70
|
+
{ value: 'tour', text: '漫游' },
|
|
71
|
+
{ value: 'model', text: '三维模型' },
|
|
72
|
+
...(floorplan ? [{ value: 'floorplan' as const, text: '平面图' }] : []),
|
|
73
|
+
]
|
|
74
|
+
const compact = match('sm') || (match('md') && orientation === 'portrait')
|
|
75
|
+
const controlWidth = compact
|
|
76
|
+
? `min(calc(100vw - 2rem), ${options.length === 3 ? '20rem' : '15rem'})`
|
|
77
|
+
: options.length === 3
|
|
78
|
+
? '26.625rem'
|
|
79
|
+
: '20rem'
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="mode-switcher" role="group" aria-label="空间浏览模式">
|
|
83
|
+
<Font size={compact ? 'lg' : 'xl'} opacity="80">
|
|
84
|
+
<HStack
|
|
85
|
+
width={controlWidth}
|
|
86
|
+
height={compact ? '3rem' : '5rem'}
|
|
87
|
+
align={{ horizontal: 'space-around' }}
|
|
88
|
+
>
|
|
89
|
+
{options.map((option) => {
|
|
90
|
+
const selected = option.value === tab
|
|
91
|
+
return (
|
|
92
|
+
<VStack key={option.value}>
|
|
93
|
+
<Button
|
|
94
|
+
appearance="shadow"
|
|
95
|
+
weight={selected ? 'bold' : 'regular'}
|
|
96
|
+
opacity={selected ? '100' : '80'}
|
|
97
|
+
onClick={() => void changeTab(option.value)}
|
|
98
|
+
>
|
|
99
|
+
{option.text}
|
|
100
|
+
</Button>
|
|
101
|
+
{selected ? (
|
|
102
|
+
<div className="mode-switcher-indicator" aria-hidden="true" />
|
|
103
|
+
) : null}
|
|
104
|
+
</VStack>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</HStack>
|
|
108
|
+
</Font>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Five } from '@realsee/five'
|
|
2
|
+
import {
|
|
3
|
+
PanoTagPlugin,
|
|
4
|
+
type PanoTagPluginExportInterface,
|
|
5
|
+
type TagClickParams,
|
|
6
|
+
type Tags,
|
|
7
|
+
} from '@realsee/dnalogel/libs/PanoTagPlugin'
|
|
8
|
+
import type { SpatialPluginRegistry } from '../../vr/plugins/SpatialPluginRegistry'
|
|
9
|
+
|
|
10
|
+
const PLUGIN_NAME = 'dnalogel.panoTag'
|
|
11
|
+
|
|
12
|
+
export type TagPlugin = PanoTagPluginExportInterface
|
|
13
|
+
|
|
14
|
+
export function createPanoTagFeature(
|
|
15
|
+
five: Five,
|
|
16
|
+
plugins: SpatialPluginRegistry,
|
|
17
|
+
onSelect?: (id: string) => void,
|
|
18
|
+
) {
|
|
19
|
+
const candidate = PanoTagPlugin(five, {
|
|
20
|
+
containerZIndex: 2,
|
|
21
|
+
config: {
|
|
22
|
+
globalConfig: {
|
|
23
|
+
initialState: { unfolded: false },
|
|
24
|
+
unfoldedConfig: { autoUnfold: { enable: false } },
|
|
25
|
+
},
|
|
26
|
+
contentTypeConfig: {
|
|
27
|
+
Image: { unfoldedConfig: { keep: 'folded' } },
|
|
28
|
+
ImageText: { unfoldedConfig: { keep: 'folded' } },
|
|
29
|
+
Video: { unfoldedConfig: { keep: 'folded' } },
|
|
30
|
+
Audio: { unfoldedConfig: { keep: 'folded' } },
|
|
31
|
+
Panorama: { unfoldedConfig: { keep: 'folded' } },
|
|
32
|
+
MediaPlane: { unfoldedConfig: { keep: 'folded' } },
|
|
33
|
+
MediaModel: { unfoldedConfig: { keep: 'folded' } },
|
|
34
|
+
Model: { unfoldedConfig: { keep: 'folded' } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
let plugin: TagPlugin
|
|
39
|
+
try {
|
|
40
|
+
plugin = plugins.register(PLUGIN_NAME, candidate)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
try {
|
|
43
|
+
candidate.dispose()
|
|
44
|
+
} catch (disposeError) {
|
|
45
|
+
console.error('PanoTag preset 注册失败后清理候选实例失败。', disposeError)
|
|
46
|
+
}
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
const unsubscribe = plugin.hooks.on('click', ({ tag }: TagClickParams) =>
|
|
50
|
+
onSelect?.(String(tag.id)),
|
|
51
|
+
)
|
|
52
|
+
let loading: Promise<void> | null = null
|
|
53
|
+
let disposeRequested = false
|
|
54
|
+
let finalized = false
|
|
55
|
+
|
|
56
|
+
const finalize = () => {
|
|
57
|
+
if (finalized) return
|
|
58
|
+
finalized = true
|
|
59
|
+
const disposeError = plugins.unregister(PLUGIN_NAME, plugin)
|
|
60
|
+
if (disposeError)
|
|
61
|
+
console.error('PanoTag preset 实例清理失败。', disposeError)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
plugin,
|
|
66
|
+
async load(panoTagList: Tags['tagList']) {
|
|
67
|
+
if (disposeRequested) throw new Error('PanoTag preset feature 已停止。')
|
|
68
|
+
if (loading) throw new Error('PanoTag preset feature 不支持并发加载。')
|
|
69
|
+
const task = plugin.load({ tagList: panoTagList })
|
|
70
|
+
loading = task
|
|
71
|
+
try {
|
|
72
|
+
await task
|
|
73
|
+
} finally {
|
|
74
|
+
if (loading === task) loading = null
|
|
75
|
+
if (disposeRequested) finalize()
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
dispose() {
|
|
79
|
+
if (disposeRequested) return
|
|
80
|
+
disposeRequested = true
|
|
81
|
+
unsubscribe()
|
|
82
|
+
if (!loading) finalize()
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getTag(plugin: TagPlugin, id: string) {
|
|
88
|
+
const numericId = Number(id)
|
|
89
|
+
return (
|
|
90
|
+
plugin.getTagById(id) ??
|
|
91
|
+
(Number.isNaN(numericId) ? undefined : plugin.getTagById(numericId))
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function locateTagInCurrentMode(
|
|
96
|
+
five: Five,
|
|
97
|
+
plugin: TagPlugin,
|
|
98
|
+
id: string,
|
|
99
|
+
) {
|
|
100
|
+
const tag = getTag(plugin, id)
|
|
101
|
+
if (!tag) throw new Error(`空间标签 ${id} 当前无法定位。`)
|
|
102
|
+
const targetMode =
|
|
103
|
+
five.currentMode === Five.Mode.Mapview
|
|
104
|
+
? Five.Mode.Mapview
|
|
105
|
+
: Five.Mode.Panorama
|
|
106
|
+
await tag.find({ targetMode })
|
|
107
|
+
plugin.changeUnfoldedById(tag.id, false)
|
|
108
|
+
await tag.blink({ loop: 4, autoplay: true })
|
|
109
|
+
return targetMode
|
|
110
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Layer,
|
|
4
|
+
Text,
|
|
5
|
+
TitleBar,
|
|
6
|
+
Vapor,
|
|
7
|
+
VStack,
|
|
8
|
+
ZStack,
|
|
9
|
+
useDimension,
|
|
10
|
+
useSaveAreaInsets,
|
|
11
|
+
} from '@realsee/vapor'
|
|
12
|
+
|
|
13
|
+
export type ViewerOverlayProps = {
|
|
14
|
+
controls?: ReactNode
|
|
15
|
+
title?: string | null
|
|
16
|
+
status: string
|
|
17
|
+
error?: string | null
|
|
18
|
+
selectedTagId?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ViewerChromeProps = Omit<ViewerOverlayProps, 'title'> & {
|
|
22
|
+
title?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cssSafeInset = (
|
|
26
|
+
side: 'top' | 'right' | 'bottom' | 'left',
|
|
27
|
+
vaporInset: number,
|
|
28
|
+
spacing = '0px',
|
|
29
|
+
) => `calc(env(safe-area-inset-${side}, 0px) + ${vaporInset}px + ${spacing})`
|
|
30
|
+
|
|
31
|
+
function ViewerChrome({
|
|
32
|
+
controls,
|
|
33
|
+
title,
|
|
34
|
+
status,
|
|
35
|
+
error,
|
|
36
|
+
selectedTagId,
|
|
37
|
+
}: ViewerChromeProps) {
|
|
38
|
+
const saveAreaInsets = useSaveAreaInsets()
|
|
39
|
+
const { match, orientation } = useDimension()
|
|
40
|
+
const stackControls =
|
|
41
|
+
match('sm') || (match('md') && orientation === 'portrait')
|
|
42
|
+
const topSpacing = match('sm')
|
|
43
|
+
? '0.75rem'
|
|
44
|
+
: match('md') && orientation === 'landscape'
|
|
45
|
+
? '0.75rem'
|
|
46
|
+
: match('md') || match('lg')
|
|
47
|
+
? '1.25rem'
|
|
48
|
+
: '1.875rem'
|
|
49
|
+
const sideSpacing = match('sm')
|
|
50
|
+
? '1rem'
|
|
51
|
+
: match('md') && orientation === 'landscape'
|
|
52
|
+
? '2rem'
|
|
53
|
+
: match('md') || match('lg')
|
|
54
|
+
? '1.5rem'
|
|
55
|
+
: '2rem'
|
|
56
|
+
const titleMaxWidth = stackControls
|
|
57
|
+
? `calc(100vw - ${sideSpacing} - ${sideSpacing})`
|
|
58
|
+
: 'min(28rem, calc(50vw - 15rem))'
|
|
59
|
+
const statusText = `${error ?? status}${selectedTagId ? ` · tag ${selectedTagId}` : ''}`
|
|
60
|
+
const horizontalPadding = {
|
|
61
|
+
left: cssSafeInset('left', saveAreaInsets.left, sideSpacing),
|
|
62
|
+
right: cssSafeInset('right', saveAreaInsets.right, sideSpacing),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<ZStack width="100%" height="100%" align={{ vertical: 'start' }}>
|
|
67
|
+
<Layer vignetting />
|
|
68
|
+
<VStack
|
|
69
|
+
width="100%"
|
|
70
|
+
margin={{ top: cssSafeInset('top', saveAreaInsets.top) }}
|
|
71
|
+
padding={{
|
|
72
|
+
top: topSpacing,
|
|
73
|
+
...horizontalPadding,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{title ? (
|
|
77
|
+
<TitleBar labelMaxWidth={titleMaxWidth}>{title}</TitleBar>
|
|
78
|
+
) : null}
|
|
79
|
+
</VStack>
|
|
80
|
+
{controls ? (
|
|
81
|
+
<VStack
|
|
82
|
+
className="viewer-controls"
|
|
83
|
+
width="100%"
|
|
84
|
+
margin={{ top: cssSafeInset('top', saveAreaInsets.top) }}
|
|
85
|
+
padding={{
|
|
86
|
+
top: stackControls ? '3.5rem' : '0.25rem',
|
|
87
|
+
...horizontalPadding,
|
|
88
|
+
}}
|
|
89
|
+
align={{ horizontal: 'center' }}
|
|
90
|
+
>
|
|
91
|
+
{controls}
|
|
92
|
+
</VStack>
|
|
93
|
+
) : null}
|
|
94
|
+
<ZStack
|
|
95
|
+
width="100%"
|
|
96
|
+
height="100%"
|
|
97
|
+
align={{ horizontal: 'start', vertical: 'end' }}
|
|
98
|
+
padding={{
|
|
99
|
+
right: cssSafeInset('right', saveAreaInsets.right, '1rem'),
|
|
100
|
+
bottom: cssSafeInset('bottom', saveAreaInsets.bottom, '1rem'),
|
|
101
|
+
left: cssSafeInset('left', saveAreaInsets.left, '1rem'),
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<div
|
|
105
|
+
className="viewer-status"
|
|
106
|
+
role={error ? 'alert' : 'status'}
|
|
107
|
+
aria-live={error ? 'assertive' : 'polite'}
|
|
108
|
+
aria-atomic="true"
|
|
109
|
+
title={statusText}
|
|
110
|
+
>
|
|
111
|
+
<Text
|
|
112
|
+
appearance="solid"
|
|
113
|
+
size="sm"
|
|
114
|
+
opacity={error ? '100' : '80'}
|
|
115
|
+
wrap={1}
|
|
116
|
+
maxWidth="min(32rem, calc(100vw - 2rem))"
|
|
117
|
+
{...(error ? { color: '#ffd6d6' } : {})}
|
|
118
|
+
>
|
|
119
|
+
{statusText}
|
|
120
|
+
</Text>
|
|
121
|
+
</div>
|
|
122
|
+
</ZStack>
|
|
123
|
+
</ZStack>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** DOM-only UI mounted above the native Five canvas; it owns no spatial state or rendering. */
|
|
128
|
+
export function ViewerOverlay(props: ViewerOverlayProps) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="dom-overlay">
|
|
131
|
+
<Vapor
|
|
132
|
+
className="viewer-vapor"
|
|
133
|
+
width="100%"
|
|
134
|
+
height="100%"
|
|
135
|
+
theme="arc_beauty"
|
|
136
|
+
>
|
|
137
|
+
<ViewerChrome {...props} />
|
|
138
|
+
</Vapor>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|