@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,42 @@
|
|
|
1
|
+
import type { FiveInitArgs } from '@realsee/five'
|
|
2
|
+
import { BackgroundPlugin, PanoAnimePlugin } from '@realsee/five/plugins'
|
|
3
|
+
|
|
4
|
+
function supportsWebGL2() {
|
|
5
|
+
const match = navigator.userAgent.match(/OS (\d+)_/i)
|
|
6
|
+
const iosVersion = match ? Number(match[1]) : 0
|
|
7
|
+
return !iosVersion || iosVersion >= 16
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createFiveInitArgs(): FiveInitArgs {
|
|
11
|
+
return {
|
|
12
|
+
panorama: {
|
|
13
|
+
maxLatitude: Math.PI / 4,
|
|
14
|
+
minLatitude: -Math.PI / 4,
|
|
15
|
+
minFov: 40,
|
|
16
|
+
maxFov: 120,
|
|
17
|
+
adjustPanoCircleMeshPositionBasedOnModel: true,
|
|
18
|
+
},
|
|
19
|
+
floorplan: {
|
|
20
|
+
minLatitude: 0,
|
|
21
|
+
maxLatitude: Math.PI / 2,
|
|
22
|
+
defaultFov: 80,
|
|
23
|
+
},
|
|
24
|
+
imageOptions: {
|
|
25
|
+
size: 512,
|
|
26
|
+
format: 'avif',
|
|
27
|
+
},
|
|
28
|
+
textureOptions: {
|
|
29
|
+
quality: 95,
|
|
30
|
+
format: 'avif',
|
|
31
|
+
autoResize: true,
|
|
32
|
+
},
|
|
33
|
+
plugins: [
|
|
34
|
+
[BackgroundPlugin, 'BackgroundPlugin', {}],
|
|
35
|
+
[PanoAnimePlugin, 'PanoAnimePlugin', {}],
|
|
36
|
+
],
|
|
37
|
+
backgroundAlpha: 0,
|
|
38
|
+
onlyRenderIfNeeds: true,
|
|
39
|
+
poweredByRealsee: false,
|
|
40
|
+
webgl2: supportsWebGL2(),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Five, type State } from '@realsee/five'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import type { RawRecord } from '../work/WorkSource'
|
|
4
|
+
|
|
5
|
+
const asRecord = (value: unknown): RawRecord =>
|
|
6
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
? (value as RawRecord)
|
|
8
|
+
: {}
|
|
9
|
+
|
|
10
|
+
const finiteNumber = (...values: unknown[]) => {
|
|
11
|
+
for (const value of values)
|
|
12
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function initialStateFrom(
|
|
17
|
+
work: RawRecord,
|
|
18
|
+
override?: Partial<State>,
|
|
19
|
+
): Partial<State> {
|
|
20
|
+
const initial = asRecord(work.initial)
|
|
21
|
+
const offset = asRecord(initial.offset)
|
|
22
|
+
const state: Partial<State> = {
|
|
23
|
+
mode:
|
|
24
|
+
typeof initial.mode === 'string'
|
|
25
|
+
? (initial.mode as State['mode'])
|
|
26
|
+
: Five.Mode.Panorama,
|
|
27
|
+
}
|
|
28
|
+
const panoIndex = finiteNumber(
|
|
29
|
+
initial.panoIndex,
|
|
30
|
+
initial.pano_index,
|
|
31
|
+
initial.pano,
|
|
32
|
+
)
|
|
33
|
+
const longitude = finiteNumber(initial.longitude)
|
|
34
|
+
const latitude = finiteNumber(initial.latitude)
|
|
35
|
+
const fov = finiteNumber(initial.fov)
|
|
36
|
+
const distance = finiteNumber(initial.distance)
|
|
37
|
+
if (panoIndex !== undefined) state.panoIndex = panoIndex
|
|
38
|
+
if (longitude !== undefined) state.longitude = longitude
|
|
39
|
+
if (latitude !== undefined) state.latitude = latitude
|
|
40
|
+
if (fov !== undefined) state.fov = fov
|
|
41
|
+
if (distance !== undefined) state.distance = distance
|
|
42
|
+
const x = finiteNumber(offset.x)
|
|
43
|
+
const y = finiteNumber(offset.y)
|
|
44
|
+
const z = finiteNumber(offset.z)
|
|
45
|
+
if (x !== undefined && y !== undefined && z !== undefined)
|
|
46
|
+
state.offset = new THREE.Vector3(x, y, z)
|
|
47
|
+
return { ...state, ...override }
|
|
48
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Five } from '@realsee/five'
|
|
2
|
+
|
|
3
|
+
export type SpatialPluginFactory<Parameters extends unknown[], Plugin> = (
|
|
4
|
+
five: Five,
|
|
5
|
+
...parameters: Parameters
|
|
6
|
+
) => Plugin
|
|
7
|
+
|
|
8
|
+
type DisposablePlugin = {
|
|
9
|
+
dispose?: () => unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const disposePlugin = (plugin: unknown) => {
|
|
13
|
+
if (!plugin || typeof plugin !== 'object') return
|
|
14
|
+
const dispose = (plugin as DisposablePlugin).dispose
|
|
15
|
+
if (typeof dispose === 'function') dispose.call(plugin)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns dynamically created spatial plugins for one Five instance.
|
|
20
|
+
*
|
|
21
|
+
* dnalogel presets and application-defined Five plugins do not share one
|
|
22
|
+
* universal lifecycle interface. Feature adapters remain responsible for
|
|
23
|
+
* plugin-specific hooks, media and load/enable/disable methods; the registry
|
|
24
|
+
* guarantees conflict-safe ownership and best-effort drain.
|
|
25
|
+
*/
|
|
26
|
+
export class SpatialPluginRegistry {
|
|
27
|
+
private readonly owned = new Map<string, unknown>()
|
|
28
|
+
|
|
29
|
+
constructor(readonly five: Five) {}
|
|
30
|
+
|
|
31
|
+
create<Parameters extends unknown[], Plugin>(
|
|
32
|
+
name: string,
|
|
33
|
+
factory: SpatialPluginFactory<Parameters, Plugin>,
|
|
34
|
+
...parameters: Parameters
|
|
35
|
+
) {
|
|
36
|
+
const plugin = factory(this.five, ...parameters)
|
|
37
|
+
try {
|
|
38
|
+
return this.register(name, plugin)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
try {
|
|
41
|
+
disposePlugin(plugin)
|
|
42
|
+
} catch (disposeError) {
|
|
43
|
+
console.error(
|
|
44
|
+
`注册空间插件 ${name} 失败后清理候选实例失败。`,
|
|
45
|
+
disposeError,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
register<Plugin>(name: string, plugin: Plugin) {
|
|
53
|
+
const current = this.owned.get(name)
|
|
54
|
+
const registered: unknown = this.five.plugins[name]
|
|
55
|
+
const hasRegisteredPlugin = Object.prototype.hasOwnProperty.call(
|
|
56
|
+
this.five.plugins,
|
|
57
|
+
name,
|
|
58
|
+
)
|
|
59
|
+
if (
|
|
60
|
+
hasRegisteredPlugin &&
|
|
61
|
+
registered !== current &&
|
|
62
|
+
registered !== plugin
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`空间插件名称冲突:${name} 已由当前 registry 之外的实例占用。`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (current && current !== plugin) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`空间插件名称冲突:${name} 已由当前 registry 中的活动实例占用。`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
this.five.plugins[name] = plugin
|
|
75
|
+
this.owned.set(name, plugin)
|
|
76
|
+
return plugin
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get<Plugin>(name: string) {
|
|
80
|
+
return this.five.plugins[name] as Plugin | undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
unregister(name: string, expected?: unknown) {
|
|
84
|
+
const plugin = this.owned.get(name)
|
|
85
|
+
if (!plugin || (expected !== undefined && plugin !== expected)) return
|
|
86
|
+
this.owned.delete(name)
|
|
87
|
+
if (this.five.plugins[name] === plugin) delete this.five.plugins[name]
|
|
88
|
+
try {
|
|
89
|
+
disposePlugin(plugin)
|
|
90
|
+
return undefined
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
disposeAll() {
|
|
97
|
+
const errors: unknown[] = []
|
|
98
|
+
for (const [name, plugin] of [...this.owned.entries()].reverse()) {
|
|
99
|
+
const error = this.unregister(name, plugin)
|
|
100
|
+
if (error) errors.push(error)
|
|
101
|
+
}
|
|
102
|
+
return errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Five } from '@realsee/five'
|
|
2
|
+
import { BoxBufferGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
|
|
3
|
+
import type { SpatialPluginRegistry } from '../SpatialPluginRegistry'
|
|
4
|
+
|
|
5
|
+
// Opt-in reference for Agent-generated Five/Three features. The default App does not import or register it.
|
|
6
|
+
export const FEATURE_OWNED_MESH_PLUGIN_NAME = 'example.featureOwnedMesh'
|
|
7
|
+
|
|
8
|
+
export interface FeatureOwnedMeshPluginOptions {
|
|
9
|
+
size?: number
|
|
10
|
+
color?: string | number
|
|
11
|
+
position?: readonly [number, number, number]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FeatureOwnedMeshPlugin {
|
|
15
|
+
readonly root: Group
|
|
16
|
+
readonly mesh: Mesh<BoxBufferGeometry, MeshBasicMaterial>
|
|
17
|
+
setVisible(visible: boolean): void
|
|
18
|
+
dispose(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createFeatureOwnedMeshPlugin(
|
|
22
|
+
five: Five,
|
|
23
|
+
options: FeatureOwnedMeshPluginOptions = {},
|
|
24
|
+
): FeatureOwnedMeshPlugin {
|
|
25
|
+
const size = options.size ?? 0.2
|
|
26
|
+
if (!Number.isFinite(size) || size <= 0)
|
|
27
|
+
throw new Error('自定义 Mesh 插件的 size 必须是正数。')
|
|
28
|
+
|
|
29
|
+
const root = new Group()
|
|
30
|
+
root.name = FEATURE_OWNED_MESH_PLUGIN_NAME
|
|
31
|
+
const geometry = new BoxBufferGeometry(size, size, size)
|
|
32
|
+
const material = new MeshBasicMaterial({ color: options.color ?? 0x4f8cff })
|
|
33
|
+
const mesh = new Mesh(geometry, material)
|
|
34
|
+
root.add(mesh)
|
|
35
|
+
|
|
36
|
+
const [x, y, z] = options.position ?? [0, 0, 0]
|
|
37
|
+
root.position.set(x, y, z)
|
|
38
|
+
five.scene.add(root)
|
|
39
|
+
five.needsRender = true
|
|
40
|
+
|
|
41
|
+
let disposed = false
|
|
42
|
+
return {
|
|
43
|
+
root,
|
|
44
|
+
mesh,
|
|
45
|
+
setVisible(visible) {
|
|
46
|
+
if (disposed) return
|
|
47
|
+
root.visible = visible
|
|
48
|
+
five.needsRender = true
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
if (disposed) return
|
|
52
|
+
disposed = true
|
|
53
|
+
root.parent?.remove(root)
|
|
54
|
+
geometry.dispose()
|
|
55
|
+
material.dispose()
|
|
56
|
+
five.needsRender = true
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function registerFeatureOwnedMeshPlugin(
|
|
62
|
+
plugins: SpatialPluginRegistry,
|
|
63
|
+
options: FeatureOwnedMeshPluginOptions = {},
|
|
64
|
+
) {
|
|
65
|
+
const plugin = createFeatureOwnedMeshPlugin(plugins.five, options)
|
|
66
|
+
try {
|
|
67
|
+
return plugins.register(FEATURE_OWNED_MESH_PLUGIN_NAME, plugin)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Handles both external name collisions and registry-owned replacement rejection.
|
|
70
|
+
plugin.dispose()
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { State } from '@realsee/five'
|
|
2
|
+
import type { Tags } from '@realsee/dnalogel/libs/PanoTagPlugin'
|
|
3
|
+
import {
|
|
4
|
+
asModelFloorplanServerData,
|
|
5
|
+
type ModelFloorplanServerData,
|
|
6
|
+
} from '../../../shared/model-floorplan'
|
|
7
|
+
|
|
8
|
+
export type RawRecord = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
export type WorkPresetData = {
|
|
11
|
+
panoTagList?: Tags['tagList']
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type WorkCapabilities = {
|
|
15
|
+
modelFloorplan?: ModelFloorplanServerData
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type WorkPayload = {
|
|
19
|
+
work: RawRecord
|
|
20
|
+
workCode: string
|
|
21
|
+
displayName: string
|
|
22
|
+
baseURL?: string
|
|
23
|
+
initialState?: Partial<State>
|
|
24
|
+
presetData?: WorkPresetData
|
|
25
|
+
capabilities?: WorkCapabilities
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RESOURCE_CODE_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
29
|
+
|
|
30
|
+
const isRecord = (value: unknown): value is RawRecord =>
|
|
31
|
+
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
32
|
+
|
|
33
|
+
const asRecord = (value: unknown): RawRecord => (isRecord(value) ? value : {})
|
|
34
|
+
|
|
35
|
+
const firstString = (...values: unknown[]) =>
|
|
36
|
+
values.find(
|
|
37
|
+
(value): value is string => typeof value === 'string' && value.length > 0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const displayNameFrom = (value: unknown): string | undefined => {
|
|
41
|
+
if (typeof value !== 'string') return undefined
|
|
42
|
+
const displayName = value.trim()
|
|
43
|
+
return displayName || undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PANO_TAG_CONTENT_TYPES = new Set([
|
|
47
|
+
'Text',
|
|
48
|
+
'Audio',
|
|
49
|
+
'ImageText',
|
|
50
|
+
'Image',
|
|
51
|
+
'Video',
|
|
52
|
+
'Panorama',
|
|
53
|
+
'Link',
|
|
54
|
+
'VRLink',
|
|
55
|
+
'Sticker',
|
|
56
|
+
'PanoLink',
|
|
57
|
+
'Marketing',
|
|
58
|
+
'MediaPlane',
|
|
59
|
+
'MediaModel',
|
|
60
|
+
'Model',
|
|
61
|
+
'Custom',
|
|
62
|
+
'Unknown',
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
66
|
+
typeof value === 'string' && value.length > 0
|
|
67
|
+
|
|
68
|
+
const isNumberArray = (value: unknown, minimumLength = 1): value is number[] =>
|
|
69
|
+
Array.isArray(value) &&
|
|
70
|
+
value.length >= minimumLength &&
|
|
71
|
+
value.every((item) => typeof item === 'number' && Number.isFinite(item))
|
|
72
|
+
|
|
73
|
+
const isPosition = (value: unknown): value is number[] =>
|
|
74
|
+
isNumberArray(value, 3)
|
|
75
|
+
|
|
76
|
+
const isPositionList = (value: unknown, length: number, exact = false) =>
|
|
77
|
+
Array.isArray(value) &&
|
|
78
|
+
(exact ? value.length === length : value.length >= length) &&
|
|
79
|
+
value.every(isPosition)
|
|
80
|
+
|
|
81
|
+
const isMediaDataList = (value: unknown) =>
|
|
82
|
+
Array.isArray(value) &&
|
|
83
|
+
value.every((item) => {
|
|
84
|
+
if (!isRecord(item) || !isNonEmptyString(item.url)) return false
|
|
85
|
+
return item.type === 'Image' || item.type === 'Video'
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const hasCanonicalPanoTagData = (contentType: string, data: RawRecord) => {
|
|
89
|
+
switch (contentType) {
|
|
90
|
+
case 'Audio':
|
|
91
|
+
return isNonEmptyString(data.audioUrl)
|
|
92
|
+
case 'ImageText':
|
|
93
|
+
case 'Image':
|
|
94
|
+
case 'Video':
|
|
95
|
+
case 'Panorama':
|
|
96
|
+
return isMediaDataList(data.mediaData)
|
|
97
|
+
case 'Marketing':
|
|
98
|
+
return isNonEmptyString(data.title)
|
|
99
|
+
case 'MediaPlane':
|
|
100
|
+
return isMediaDataList(data.mediaData)
|
|
101
|
+
case 'MediaModel':
|
|
102
|
+
return (
|
|
103
|
+
isNonEmptyString(data.modelUrl) &&
|
|
104
|
+
isMediaDataList(data.mediaData) &&
|
|
105
|
+
isPositionList(data.mediaPosition, 4, true)
|
|
106
|
+
)
|
|
107
|
+
case 'Model':
|
|
108
|
+
return isNonEmptyString(data.modelUrl)
|
|
109
|
+
default:
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hasCanonicalPanoTagAnchor = (tag: RawRecord) => {
|
|
115
|
+
const stickType = tag.stickType ?? '2DPoint'
|
|
116
|
+
switch (stickType) {
|
|
117
|
+
case '2DPoint':
|
|
118
|
+
return isPosition(tag.position)
|
|
119
|
+
case '3DPoint':
|
|
120
|
+
return isPosition(tag.position) && isPosition(tag.normal)
|
|
121
|
+
case 'Plane':
|
|
122
|
+
return isPositionList(tag.position, 4, true)
|
|
123
|
+
case 'Model':
|
|
124
|
+
return isPosition(tag.position) && isNumberArray(tag.matrix, 16)
|
|
125
|
+
case '3DBox': {
|
|
126
|
+
if (!isRecord(tag.position)) return false
|
|
127
|
+
return (
|
|
128
|
+
isPosition(tag.position.start) &&
|
|
129
|
+
isPosition(tag.position.end) &&
|
|
130
|
+
isPosition(tag.position.rotation)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
case 'Polygon':
|
|
134
|
+
return isPositionList(tag.position, 3)
|
|
135
|
+
case 'Mask':
|
|
136
|
+
return (
|
|
137
|
+
isPosition(tag.position) &&
|
|
138
|
+
isNonEmptyString(tag.mask) &&
|
|
139
|
+
(typeof tag.color === 'string' ||
|
|
140
|
+
typeof tag.color === 'number' ||
|
|
141
|
+
isNumberArray(tag.color, 3))
|
|
142
|
+
)
|
|
143
|
+
default:
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hasExplicitPanoTagShape = (
|
|
149
|
+
value: unknown,
|
|
150
|
+
): value is Tags['tagList'][number] => {
|
|
151
|
+
if (
|
|
152
|
+
!isRecord(value) ||
|
|
153
|
+
!isNonEmptyString(value.contentType) ||
|
|
154
|
+
!isRecord(value.data)
|
|
155
|
+
)
|
|
156
|
+
return false
|
|
157
|
+
return (
|
|
158
|
+
PANO_TAG_CONTENT_TYPES.has(value.contentType) &&
|
|
159
|
+
hasCanonicalPanoTagData(value.contentType, value.data) &&
|
|
160
|
+
hasCanonicalPanoTagAnchor(value)
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const asExplicitPanoTagList = (value: unknown): Tags['tagList'] | undefined => {
|
|
165
|
+
if (value === undefined) return undefined
|
|
166
|
+
if (!Array.isArray(value))
|
|
167
|
+
throw new Error('presetData.panoTagList 必须是数组。')
|
|
168
|
+
return value.map((item, index) => {
|
|
169
|
+
if (!hasExplicitPanoTagShape(item)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`presetData.panoTagList[${index}] 不符合 canonical content/data/stickType anchor 契约。`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return item
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function resourceCodeFromPathname(pathname: string) {
|
|
179
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
180
|
+
if (segments.length !== 1) throw new Error('访问路径必须为 /{resourceCode}。')
|
|
181
|
+
const [encodedResourceCode = ''] = segments
|
|
182
|
+
|
|
183
|
+
let resourceCode: string
|
|
184
|
+
try {
|
|
185
|
+
resourceCode = decodeURIComponent(encodedResourceCode)
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error('resourceCode 不是有效的 URL 路径参数。')
|
|
188
|
+
}
|
|
189
|
+
if (!RESOURCE_CODE_PATTERN.test(resourceCode))
|
|
190
|
+
throw new Error('resourceCode 格式无效。')
|
|
191
|
+
return resourceCode
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface WorkSource {
|
|
195
|
+
load(signal?: AbortSignal): Promise<WorkPayload>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class RouteWorkSource implements WorkSource {
|
|
199
|
+
async load(signal?: AbortSignal): Promise<WorkPayload> {
|
|
200
|
+
const resourceCode = resourceCodeFromPathname(window.location.pathname)
|
|
201
|
+
// The generated Fastify BFF implements this contract; another language may replace it without changing the browser.
|
|
202
|
+
const response = await fetch(
|
|
203
|
+
`/api/work/${encodeURIComponent(resourceCode)}`,
|
|
204
|
+
{
|
|
205
|
+
signal,
|
|
206
|
+
headers: { Accept: 'application/json' },
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
if (!response.ok) throw new Error(`Work 请求失败:HTTP ${response.status}`)
|
|
210
|
+
|
|
211
|
+
const raw = (await response.json()) as unknown
|
|
212
|
+
const envelope = asRecord(raw)
|
|
213
|
+
const nestedWork = asRecord(envelope.work)
|
|
214
|
+
const work = Object.keys(nestedWork).length ? nestedWork : envelope
|
|
215
|
+
if (!Object.keys(work).length) throw new Error('Work 响应不是有效对象。')
|
|
216
|
+
|
|
217
|
+
const workCode =
|
|
218
|
+
firstString(
|
|
219
|
+
envelope.workCode,
|
|
220
|
+
envelope.work_code,
|
|
221
|
+
work.workCode,
|
|
222
|
+
work.work_code,
|
|
223
|
+
work.code,
|
|
224
|
+
) ?? resourceCode
|
|
225
|
+
const presetData = asRecord(envelope.presetData)
|
|
226
|
+
const panoTagList = asExplicitPanoTagList(presetData.panoTagList)
|
|
227
|
+
const modelFloorplan = asModelFloorplanServerData(
|
|
228
|
+
asRecord(envelope.capabilities).modelFloorplan,
|
|
229
|
+
)
|
|
230
|
+
return {
|
|
231
|
+
work,
|
|
232
|
+
workCode,
|
|
233
|
+
displayName: displayNameFrom(work.name) ?? workCode,
|
|
234
|
+
baseURL: firstString(
|
|
235
|
+
envelope.baseURL,
|
|
236
|
+
envelope.base_url,
|
|
237
|
+
work.baseURL,
|
|
238
|
+
work.base_url,
|
|
239
|
+
),
|
|
240
|
+
initialState: asRecord(envelope.initialState),
|
|
241
|
+
...(panoTagList !== undefined ? { presetData: { panoTagList } } : {}),
|
|
242
|
+
...(modelFloorplan ? { capabilities: { modelFloorplan } } : {}),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const workSource: WorkSource = new RouteWorkSource()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "Bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"noImplicitReturns": true,
|
|
20
|
+
"noImplicitOverride": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"noUncheckedIndexedAccess": true,
|
|
23
|
+
"jsx": "react-jsx",
|
|
24
|
+
"types": ["vite/client"]
|
|
25
|
+
},
|
|
26
|
+
"include": ["src", "shared", "vite.config.ts"]
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"noUnusedLocals": true,
|
|
11
|
+
"noUnusedParameters": true,
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noImplicitOverride": true,
|
|
14
|
+
"noFallthroughCasesInSwitch": true,
|
|
15
|
+
"noUncheckedIndexedAccess": true,
|
|
16
|
+
"exactOptionalPropertyTypes": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"verbatimModuleSyntax": true,
|
|
19
|
+
"forceConsistentCasingInFileNames": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"allowImportingTsExtensions": true,
|
|
22
|
+
"erasableSyntaxOnly": true
|
|
23
|
+
},
|
|
24
|
+
"include": ["server", "shared"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
server: {
|
|
7
|
+
host: '127.0.0.1',
|
|
8
|
+
port: 8087,
|
|
9
|
+
strictPort: true,
|
|
10
|
+
proxy: {
|
|
11
|
+
'/api': {
|
|
12
|
+
target: 'http://127.0.0.1:8088',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
preview: {
|
|
17
|
+
host: '127.0.0.1',
|
|
18
|
+
port: 8087,
|
|
19
|
+
strictPort: true,
|
|
20
|
+
},
|
|
21
|
+
})
|