@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,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
+ }