@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,818 @@
|
|
|
1
|
+
# dnalogel OpenAPI-to-SDK implementation recipes
|
|
2
|
+
|
|
3
|
+
External projects do not have the dnalogel or Five source repositories. This reference is self-contained: turn the user's product prompt into a server-side OpenAPI adapter and a browser-side Five/dnalogel feature without asking the developer to clone an SDK repository or locate a repository example.
|
|
4
|
+
|
|
5
|
+
The generated manifest declares minimums `@realsee/dnalogel>=3.81.2` and `@realsee/five>=6.8.9`; its initial lockfile resolves the reviewed baseline versions. Confirm imports and calls against the target project's actual lockfile, installed `package.json`, `libs/index.d.ts`, and selected plugin declaration. The installed declaration wins when a project intentionally upgrades. Do not use TypeScript `paths` to bypass the package boundary.
|
|
6
|
+
|
|
7
|
+
## Prompt-to-capability routing
|
|
8
|
+
|
|
9
|
+
| User prompt or intent | Authoritative source | Browser implementation | Required readiness |
|
|
10
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
11
|
+
| 热点、标签、图文点、视频点、音频点、VR 跳转、空间锚点 | `GET /open/v3/hot/list` | parsed shared DTO -> sized media variants -> exact PanoTag `Tags` -> `PanoTagPlugin` | Five Work loaded; model readiness only for model-dependent content/anchors |
|
|
12
|
+
| 导览点、讲解点、预设视角、房间漫游点 | `GET /open/v3/observerpicture/search` | validated records -> Five `Partial<State>` snapshots plus sized preview images | Work loaded and pano index present in `five.observers` |
|
|
13
|
+
| 模型框选、模型区域、模型标注 | `GET /open/v3/annotation/search` | validated `{ list }` -> `ModelMakerPlugin` | model-capable Work; cancellable model readiness gate |
|
|
14
|
+
| 路线、引导线、自动漫游、点位巡游、模型曲线移动 | `GET /open/v3/route/search` | `GuideLinePlugin` rendering plus shape-selected `CruisePlugin` or `MovePlugin` playback | branch-specific; see route recipe |
|
|
15
|
+
| 平面图、户型图 | `GET /open/v3/floorplan/plugins`, then `data.format_floorplan_url` | validated wrapper -> `ModelFloorplanPlugin` | Work/model and mounted plugin container |
|
|
16
|
+
| 点击模型选点、坐标拾取、可见性判断 | no default persistence API | Five-lifetime `PanoCursorRaycasterPlugin`; runtime-only unless an editor/API contract is supplied | model loaded |
|
|
17
|
+
| 虚拟屏、电视视频、空间 DOM、全景视频 | data contract selected by behavior | `ModelTVVideoPlugin`, PanoTag `MediaPlane`/`MediaModel`, `CSS3DRenderPlugin`, or `PanoVideoPlugin` | plugin-specific; media must be Work/API/user authorized |
|
|
18
|
+
|
|
19
|
+
Do not route by a vague noun alone. Classify content, renderer, anchor, playback behavior, readiness, and persistence owner before selecting a capability. A screenshot or a field named `tags` is not an API contract.
|
|
20
|
+
|
|
21
|
+
## Mandatory implementation sequence
|
|
22
|
+
|
|
23
|
+
For every OpenAPI-backed prompt, perform these steps in order:
|
|
24
|
+
|
|
25
|
+
1. **Resolve intent:** select one row in the routing table and state why it matches.
|
|
26
|
+
2. **Define a browser-safe contract:** add a narrow JSON-safe capability DTO under `shared/`; do not expose the upstream envelope or SDK runtime objects.
|
|
27
|
+
3. **Fetch server-side:** add the authenticated OpenAPI call in `server/realsee-open-api.ts`, reusing token cache, timeout, retry, and `code === 0` checks.
|
|
28
|
+
4. **Parse and adapt:** treat upstream JSON as `unknown`; use a runtime parser with indexed field diagnostics; return only the shared discriminated DTO.
|
|
29
|
+
5. **Expose through the BFF:** extend the service/envelope or add a same-origin `/api/...` route. Keep AK/SK and access tokens server-only.
|
|
30
|
+
6. **Derive sized image variants:** every image URL that reaches a plugin or the DOM goes through the reviewed transform with a bounded size. Passing the upstream original is a defect, not a default.
|
|
31
|
+
7. **Convert at the browser boundary:** exhaustively convert the DTO to the exact installed SDK type. Do not use a final `as Tags[...]` escape hatch.
|
|
32
|
+
8. **Implement one focused feature:** create the plugin from the active native Five instance, serialize loads, and retain each hook/helper owner.
|
|
33
|
+
9. **Clean up by ownership:** abort and invalidate first. An unregistered candidate is disposed directly; a registered instance is disposed by identity-safe `plugins.unregister(name, expected)` exactly once after pending work settles.
|
|
34
|
+
10. **Isolate failure:** an API/schema/plugin failure disables that feature only; the base Viewer remains usable.
|
|
35
|
+
11. **Validate:** run typecheck, lint, format check, build, verifier, the pinned declaration contract fixture, and a real browser scenario.
|
|
36
|
+
|
|
37
|
+
## BFF extension pattern
|
|
38
|
+
|
|
39
|
+
The generated `RealseeOpenApiClient` already owns access-token exchange, timeout, one authentication retry, and success-envelope checks. Add feature methods inside that class so they reuse `#requestAuthenticated`; never create a browser token flow.
|
|
40
|
+
|
|
41
|
+
Use small unknown narrowers in the server adapter:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
45
|
+
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
46
|
+
|
|
47
|
+
const requireRecord = (value: unknown, label: string) => {
|
|
48
|
+
if (!isRecord(value)) throw new RealseeOpenApiError(`${label} 必须是对象。`);
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const requireArray = (value: unknown, label: string): unknown[] => {
|
|
53
|
+
if (!Array.isArray(value)) {
|
|
54
|
+
throw new RealseeOpenApiError(`${label} 必须是数组。`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The authenticated call pattern is:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const query = new URLSearchParams({ resource_code: resourceCode });
|
|
64
|
+
const payload = await this.#requestAuthenticated(
|
|
65
|
+
`/open/v3/hot/list?${query.toString()}`,
|
|
66
|
+
"获取空间热点",
|
|
67
|
+
signal,
|
|
68
|
+
);
|
|
69
|
+
const data = requireRecord(payload.data, "热点 data");
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Never return `payload` directly. Validate required fields, remove unreviewed metadata, normalize nullable optional fields, and return only the browser capability. Fetch optional capabilities independently; log a server-side diagnostic and omit only the failed capability rather than failing the base Work request.
|
|
73
|
+
|
|
74
|
+
## Recipe: tags, hotspots, and anchors
|
|
75
|
+
|
|
76
|
+
### Shared discriminated DTO
|
|
77
|
+
|
|
78
|
+
Do not put `RawRecord[]` in `WorkCapabilities`. Use a shared discriminated DTO that is serializable and narrower than PanoTag's runtime type. This starter contract covers common OpenAPI hotspot prompts and every anchor shape; add a content variant only together with its parser and converter arm.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// shared/pano-tag-capability.ts
|
|
82
|
+
export type Vec3Dto = readonly [number, number, number];
|
|
83
|
+
export type MediaItemDto =
|
|
84
|
+
| {
|
|
85
|
+
readonly type: "Image";
|
|
86
|
+
readonly url: string;
|
|
87
|
+
readonly thumbnail?: string;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly type: "Video";
|
|
91
|
+
readonly url: string;
|
|
92
|
+
readonly videoCoverUrl?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type PanoTagAnchorDto =
|
|
96
|
+
| { readonly stickType: "2DPoint"; readonly position: Vec3Dto }
|
|
97
|
+
| {
|
|
98
|
+
readonly stickType: "3DPoint";
|
|
99
|
+
readonly position: Vec3Dto;
|
|
100
|
+
readonly normal: Vec3Dto;
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
readonly stickType: "Plane";
|
|
104
|
+
readonly position: readonly [Vec3Dto, Vec3Dto, Vec3Dto, Vec3Dto];
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
readonly stickType: "Model";
|
|
108
|
+
readonly position: Vec3Dto;
|
|
109
|
+
readonly matrix: readonly number[];
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
readonly stickType: "3DBox";
|
|
113
|
+
readonly position: {
|
|
114
|
+
readonly start: Vec3Dto;
|
|
115
|
+
readonly end: Vec3Dto;
|
|
116
|
+
readonly rotation: Vec3Dto;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
readonly stickType: "Polygon";
|
|
121
|
+
readonly position: readonly [Vec3Dto, Vec3Dto, Vec3Dto, ...Vec3Dto[]];
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly stickType: "Mask";
|
|
125
|
+
readonly position: Vec3Dto;
|
|
126
|
+
readonly mask: string;
|
|
127
|
+
readonly color: string | number | readonly [number, number, number];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type PanoTagContentDto =
|
|
131
|
+
| {
|
|
132
|
+
readonly contentType: "Text";
|
|
133
|
+
readonly data: { readonly title?: string; readonly description?: string };
|
|
134
|
+
}
|
|
135
|
+
| {
|
|
136
|
+
readonly contentType: "Audio";
|
|
137
|
+
readonly data: { readonly title?: string; readonly audioUrl: string };
|
|
138
|
+
}
|
|
139
|
+
| {
|
|
140
|
+
readonly contentType: "ImageText" | "Image" | "Video" | "Panorama";
|
|
141
|
+
readonly data: {
|
|
142
|
+
readonly title?: string;
|
|
143
|
+
readonly mediaData: readonly MediaItemDto[];
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
| {
|
|
147
|
+
readonly contentType: "Link" | "VRLink" | "Sticker" | "PanoLink";
|
|
148
|
+
readonly data: { readonly title?: string };
|
|
149
|
+
}
|
|
150
|
+
| {
|
|
151
|
+
readonly contentType: "Model";
|
|
152
|
+
readonly data: { readonly modelUrl: string };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type PanoTagDto = {
|
|
156
|
+
readonly id?: string | number;
|
|
157
|
+
readonly enabled?: boolean;
|
|
158
|
+
} & PanoTagAnchorDto &
|
|
159
|
+
PanoTagContentDto;
|
|
160
|
+
|
|
161
|
+
export interface PanoTagCapabilityDto {
|
|
162
|
+
readonly version?: string;
|
|
163
|
+
readonly tagList: readonly PanoTagDto[];
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The starter intentionally omits `globalConfig` and `contentTypeConfig`: the installed SDK config allows callbacks, DOM values, and Three objects that are not a safe BFF contract. Add an explicit JSON-safe whitelist DTO only when the product needs a specific config field. Canonical 3.81.2 content types not shown above (`Marketing`, `MediaPlane`, `MediaModel`, `Custom`, and `Unknown`) must be rejected until a product-specific serializable schema, parser arm, and converter arm are supplied. Never accept arbitrary `Custom`/`Unknown` objects by default.
|
|
168
|
+
|
|
169
|
+
### Runtime parser
|
|
170
|
+
|
|
171
|
+
Parse `GET /open/v3/hot/list` as `unknown`. The parser must switch on both `stickType` and `contentType`; validate finite coordinates, exact plane length, polygon minimum length, model matrix convention, 3D normal, media URLs, and required content fields. Keep the indexed path in every error.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const requireString = (value: unknown, path: string) => {
|
|
175
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
176
|
+
throw new RealseeOpenApiError(`${path} 必须是非空字符串。`);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const optionalString = (
|
|
182
|
+
record: Record<string, unknown>,
|
|
183
|
+
key: string,
|
|
184
|
+
path: string,
|
|
185
|
+
) => {
|
|
186
|
+
const value = record[key];
|
|
187
|
+
return value === undefined
|
|
188
|
+
? undefined
|
|
189
|
+
: requireString(value, `${path}.${key}`);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const requireFinite = (value: unknown, path: string) => {
|
|
193
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
194
|
+
throw new RealseeOpenApiError(`${path} 必须是有限数字。`);
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const parseVec3 = (value: unknown, path: string): Vec3Dto => {
|
|
200
|
+
if (!Array.isArray(value) || value.length !== 3) {
|
|
201
|
+
throw new RealseeOpenApiError(`${path} 必须是三个坐标。`);
|
|
202
|
+
}
|
|
203
|
+
return [
|
|
204
|
+
requireFinite(value[0], `${path}[0]`),
|
|
205
|
+
requireFinite(value[1], `${path}[1]`),
|
|
206
|
+
requireFinite(value[2], `${path}[2]`),
|
|
207
|
+
];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const parseColor = (
|
|
211
|
+
value: unknown,
|
|
212
|
+
path: string,
|
|
213
|
+
): string | number | readonly [number, number, number] => {
|
|
214
|
+
if (typeof value === "string") return requireString(value, path);
|
|
215
|
+
if (typeof value === "number") return requireFinite(value, path);
|
|
216
|
+
return parseVec3(value, path);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const parseMediaItem = (value: unknown, path: string): MediaItemDto => {
|
|
220
|
+
const media = requireRecord(value, path);
|
|
221
|
+
switch (media.type) {
|
|
222
|
+
case "Image": {
|
|
223
|
+
const thumbnail = optionalString(media, "thumbnail", path);
|
|
224
|
+
return {
|
|
225
|
+
type: "Image",
|
|
226
|
+
url: requireString(media.url, `${path}.url`),
|
|
227
|
+
...(thumbnail ? { thumbnail } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
case "Video": {
|
|
231
|
+
const videoCoverUrl = optionalString(media, "videoCoverUrl", path);
|
|
232
|
+
return {
|
|
233
|
+
type: "Video",
|
|
234
|
+
url: requireString(media.url, `${path}.url`),
|
|
235
|
+
...(videoCoverUrl ? { videoCoverUrl } : {}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
throw new RealseeOpenApiError(`${path}.type 必须是 Image 或 Video。`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const parseAnchor = (
|
|
244
|
+
record: Record<string, unknown>,
|
|
245
|
+
path: string,
|
|
246
|
+
): PanoTagAnchorDto => {
|
|
247
|
+
switch (record.stickType) {
|
|
248
|
+
case "2DPoint":
|
|
249
|
+
return {
|
|
250
|
+
stickType: "2DPoint",
|
|
251
|
+
position: parseVec3(record.position, `${path}.position`),
|
|
252
|
+
};
|
|
253
|
+
case "3DPoint":
|
|
254
|
+
return {
|
|
255
|
+
stickType: "3DPoint",
|
|
256
|
+
position: parseVec3(record.position, `${path}.position`),
|
|
257
|
+
normal: parseVec3(record.normal, `${path}.normal`),
|
|
258
|
+
};
|
|
259
|
+
case "Plane": {
|
|
260
|
+
const points = requireArray(record.position, `${path}.position`);
|
|
261
|
+
if (points.length !== 4) {
|
|
262
|
+
throw new RealseeOpenApiError(`${path}.position 必须恰好有四个点。`);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
stickType: "Plane",
|
|
266
|
+
position: [
|
|
267
|
+
parseVec3(points[0], `${path}.position[0]`),
|
|
268
|
+
parseVec3(points[1], `${path}.position[1]`),
|
|
269
|
+
parseVec3(points[2], `${path}.position[2]`),
|
|
270
|
+
parseVec3(points[3], `${path}.position[3]`),
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "Model": {
|
|
275
|
+
const matrix = requireArray(record.matrix, `${path}.matrix`).map(
|
|
276
|
+
(item, index) => requireFinite(item, `${path}.matrix[${index}]`),
|
|
277
|
+
);
|
|
278
|
+
if (matrix.length !== 16) {
|
|
279
|
+
throw new RealseeOpenApiError(`${path}.matrix 必须是 4x4 矩阵。`);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
stickType: "Model",
|
|
283
|
+
position: parseVec3(record.position, `${path}.position`),
|
|
284
|
+
matrix,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case "3DBox": {
|
|
288
|
+
const position = requireRecord(record.position, `${path}.position`);
|
|
289
|
+
return {
|
|
290
|
+
stickType: "3DBox",
|
|
291
|
+
position: {
|
|
292
|
+
start: parseVec3(position.start, `${path}.position.start`),
|
|
293
|
+
end: parseVec3(position.end, `${path}.position.end`),
|
|
294
|
+
rotation: parseVec3(position.rotation, `${path}.position.rotation`),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
case "Polygon": {
|
|
299
|
+
const points = requireArray(record.position, `${path}.position`);
|
|
300
|
+
if (points.length < 3) {
|
|
301
|
+
throw new RealseeOpenApiError(`${path}.position 至少需要三个点。`);
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
stickType: "Polygon",
|
|
305
|
+
position: [
|
|
306
|
+
parseVec3(points[0], `${path}.position[0]`),
|
|
307
|
+
parseVec3(points[1], `${path}.position[1]`),
|
|
308
|
+
parseVec3(points[2], `${path}.position[2]`),
|
|
309
|
+
...points
|
|
310
|
+
.slice(3)
|
|
311
|
+
.map((point, index) =>
|
|
312
|
+
parseVec3(point, `${path}.position[${index + 3}]`),
|
|
313
|
+
),
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
case "Mask":
|
|
318
|
+
return {
|
|
319
|
+
stickType: "Mask",
|
|
320
|
+
position: parseVec3(record.position, `${path}.position`),
|
|
321
|
+
mask: requireString(record.mask, `${path}.mask`),
|
|
322
|
+
color: parseColor(record.color, `${path}.color`),
|
|
323
|
+
};
|
|
324
|
+
default:
|
|
325
|
+
throw new RealseeOpenApiError(
|
|
326
|
+
`${path}.stickType 不受当前 capability 支持。`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const parseContent = (
|
|
332
|
+
record: Record<string, unknown>,
|
|
333
|
+
path: string,
|
|
334
|
+
): PanoTagContentDto => {
|
|
335
|
+
const data = requireRecord(record.data, `${path}.data`);
|
|
336
|
+
const title = optionalString(data, "title", `${path}.data`);
|
|
337
|
+
switch (record.contentType) {
|
|
338
|
+
case "Text": {
|
|
339
|
+
const description = optionalString(data, "description", `${path}.data`);
|
|
340
|
+
return {
|
|
341
|
+
contentType: "Text",
|
|
342
|
+
data: {
|
|
343
|
+
...(title ? { title } : {}),
|
|
344
|
+
...(description ? { description } : {}),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
case "Audio":
|
|
349
|
+
return {
|
|
350
|
+
contentType: "Audio",
|
|
351
|
+
data: {
|
|
352
|
+
...(title ? { title } : {}),
|
|
353
|
+
audioUrl: requireString(data.audioUrl, `${path}.data.audioUrl`),
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
case "ImageText":
|
|
357
|
+
case "Image":
|
|
358
|
+
case "Video":
|
|
359
|
+
case "Panorama": {
|
|
360
|
+
const mediaData = requireArray(data.mediaData, `${path}.data.mediaData`);
|
|
361
|
+
if (mediaData.length === 0) {
|
|
362
|
+
throw new RealseeOpenApiError(`${path}.data.mediaData 不得为空。`);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
contentType: record.contentType,
|
|
366
|
+
data: {
|
|
367
|
+
...(title ? { title } : {}),
|
|
368
|
+
mediaData: mediaData.map((item, index) =>
|
|
369
|
+
parseMediaItem(item, `${path}.data.mediaData[${index}]`),
|
|
370
|
+
),
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
case "Link":
|
|
375
|
+
case "VRLink":
|
|
376
|
+
case "Sticker":
|
|
377
|
+
case "PanoLink":
|
|
378
|
+
return {
|
|
379
|
+
contentType: record.contentType,
|
|
380
|
+
data: { ...(title ? { title } : {}) },
|
|
381
|
+
};
|
|
382
|
+
case "Model":
|
|
383
|
+
return {
|
|
384
|
+
contentType: "Model",
|
|
385
|
+
data: {
|
|
386
|
+
modelUrl: requireString(data.modelUrl, `${path}.data.modelUrl`),
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
default:
|
|
390
|
+
throw new RealseeOpenApiError(
|
|
391
|
+
`${path}.contentType 不受当前 capability 支持。`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const parsePanoTag = (value: unknown, index: number): PanoTagDto => {
|
|
397
|
+
const path = `热点 tagList[${index}]`;
|
|
398
|
+
const record = requireRecord(value, path);
|
|
399
|
+
const id = record.id;
|
|
400
|
+
if (id !== undefined && typeof id !== "string" && typeof id !== "number") {
|
|
401
|
+
throw new RealseeOpenApiError(`${path}.id 必须是字符串或数字。`);
|
|
402
|
+
}
|
|
403
|
+
if (record.enabled !== undefined && typeof record.enabled !== "boolean") {
|
|
404
|
+
throw new RealseeOpenApiError(`${path}.enabled 必须是布尔值。`);
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
...(id !== undefined ? { id } : {}),
|
|
408
|
+
...(record.enabled !== undefined ? { enabled: record.enabled } : {}),
|
|
409
|
+
...parseAnchor(record, path),
|
|
410
|
+
...parseContent(record, path),
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The parser above implements every discriminator in the starter DTO and rejects all others rather than passing them through. Return `{ version?, tagList: data.tagList.map(parsePanoTag) }` only after the complete array parses. Validate optional `version` as a non-empty string when present.
|
|
416
|
+
|
|
417
|
+
### Exhaustive browser conversion and feature ownership
|
|
418
|
+
|
|
419
|
+
Convert readonly DTO arrays into mutable SDK arrays and declare the converter's return type as `Tags`; do not cast at the final boundary:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
import {
|
|
423
|
+
PanoTagPlugin,
|
|
424
|
+
type PanoTagPluginExportInterface,
|
|
425
|
+
type Tags,
|
|
426
|
+
} from "@realsee/dnalogel/libs/PanoTagPlugin";
|
|
427
|
+
|
|
428
|
+
const cloneVec3 = (value: Vec3Dto): [number, number, number] => [
|
|
429
|
+
value[0],
|
|
430
|
+
value[1],
|
|
431
|
+
value[2],
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
const toSdkAnchor = (tag: PanoTagAnchorDto) => {
|
|
435
|
+
switch (tag.stickType) {
|
|
436
|
+
case "2DPoint":
|
|
437
|
+
return {
|
|
438
|
+
stickType: "2DPoint" as const,
|
|
439
|
+
position: cloneVec3(tag.position),
|
|
440
|
+
};
|
|
441
|
+
case "3DPoint":
|
|
442
|
+
return {
|
|
443
|
+
stickType: "3DPoint" as const,
|
|
444
|
+
position: cloneVec3(tag.position),
|
|
445
|
+
normal: cloneVec3(tag.normal),
|
|
446
|
+
};
|
|
447
|
+
case "Plane":
|
|
448
|
+
return {
|
|
449
|
+
stickType: "Plane" as const,
|
|
450
|
+
position: [
|
|
451
|
+
cloneVec3(tag.position[0]),
|
|
452
|
+
cloneVec3(tag.position[1]),
|
|
453
|
+
cloneVec3(tag.position[2]),
|
|
454
|
+
cloneVec3(tag.position[3]),
|
|
455
|
+
] as [
|
|
456
|
+
[number, number, number],
|
|
457
|
+
[number, number, number],
|
|
458
|
+
[number, number, number],
|
|
459
|
+
[number, number, number],
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
case "Model":
|
|
463
|
+
return {
|
|
464
|
+
stickType: "Model" as const,
|
|
465
|
+
position: cloneVec3(tag.position),
|
|
466
|
+
matrix: [...tag.matrix],
|
|
467
|
+
};
|
|
468
|
+
case "3DBox":
|
|
469
|
+
return {
|
|
470
|
+
stickType: "3DBox" as const,
|
|
471
|
+
position: {
|
|
472
|
+
start: cloneVec3(tag.position.start),
|
|
473
|
+
end: cloneVec3(tag.position.end),
|
|
474
|
+
rotation: cloneVec3(tag.position.rotation),
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
case "Polygon":
|
|
478
|
+
return {
|
|
479
|
+
stickType: "Polygon" as const,
|
|
480
|
+
position: tag.position.map(cloneVec3),
|
|
481
|
+
};
|
|
482
|
+
case "Mask":
|
|
483
|
+
return {
|
|
484
|
+
stickType: "Mask" as const,
|
|
485
|
+
position: cloneVec3(tag.position),
|
|
486
|
+
mask: tag.mask,
|
|
487
|
+
color:
|
|
488
|
+
typeof tag.color === "string" || typeof tag.color === "number"
|
|
489
|
+
? tag.color
|
|
490
|
+
: cloneVec3(tag.color),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const toSdkMedia = (media: MediaItemDto) =>
|
|
496
|
+
media.type === "Image"
|
|
497
|
+
? {
|
|
498
|
+
type: "Image" as const,
|
|
499
|
+
url: media.url,
|
|
500
|
+
...(media.thumbnail ? { thumbnail: media.thumbnail } : {}),
|
|
501
|
+
}
|
|
502
|
+
: {
|
|
503
|
+
type: "Video" as const,
|
|
504
|
+
url: media.url,
|
|
505
|
+
...(media.videoCoverUrl ? { videoCoverUrl: media.videoCoverUrl } : {}),
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const toSdkContent = (tag: PanoTagContentDto) => {
|
|
509
|
+
switch (tag.contentType) {
|
|
510
|
+
case "Text":
|
|
511
|
+
return {
|
|
512
|
+
...(tag.data.title ? { title: tag.data.title } : {}),
|
|
513
|
+
...(tag.data.description ? { description: tag.data.description } : {}),
|
|
514
|
+
};
|
|
515
|
+
case "Audio":
|
|
516
|
+
return {
|
|
517
|
+
...(tag.data.title ? { title: tag.data.title } : {}),
|
|
518
|
+
audioUrl: tag.data.audioUrl,
|
|
519
|
+
};
|
|
520
|
+
case "ImageText":
|
|
521
|
+
case "Image":
|
|
522
|
+
case "Video":
|
|
523
|
+
case "Panorama":
|
|
524
|
+
return {
|
|
525
|
+
...(tag.data.title ? { title: tag.data.title } : {}),
|
|
526
|
+
mediaData: tag.data.mediaData.map(toSdkMedia),
|
|
527
|
+
};
|
|
528
|
+
case "Link":
|
|
529
|
+
case "VRLink":
|
|
530
|
+
case "Sticker":
|
|
531
|
+
case "PanoLink":
|
|
532
|
+
return { ...(tag.data.title ? { title: tag.data.title } : {}) };
|
|
533
|
+
case "Model":
|
|
534
|
+
return { modelUrl: tag.data.modelUrl };
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const toSdkTag = (tag: PanoTagDto): Tags["tagList"][number] => ({
|
|
539
|
+
...(tag.id !== undefined ? { id: tag.id } : {}),
|
|
540
|
+
...(tag.enabled !== undefined ? { enabled: tag.enabled } : {}),
|
|
541
|
+
...toSdkAnchor(tag),
|
|
542
|
+
contentType: tag.contentType,
|
|
543
|
+
data: toSdkContent(tag),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const toSdkTags = (capability: PanoTagCapabilityDto): Tags => ({
|
|
547
|
+
...(capability.version ? { version: capability.version } : {}),
|
|
548
|
+
tagList: capability.tagList.map(toSdkTag),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const candidate = PanoTagPlugin(five, { containerZIndex: 2 });
|
|
552
|
+
let plugin: PanoTagPluginExportInterface;
|
|
553
|
+
try {
|
|
554
|
+
plugin = plugins.register("dnalogel.panoTag", candidate);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
candidate.dispose(); // candidate was never registered
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const loading = plugin.load(toSdkTags(capability));
|
|
561
|
+
await loading;
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Serialize loads and retain hook unsubscribe functions. On cleanup, invalidate/abort and remove hooks immediately; after the current `load` settles, call `plugins.unregister("dnalogel.panoTag", plugin)` once. `SpatialPluginRegistry.unregister` removes by identity and invokes the registered instance's `dispose`; do not call `plugin.dispose()` afterward. Ordinary point/plane tags may load after `five.load`; wait for model readiness only for model-dependent content or anchors.
|
|
565
|
+
|
|
566
|
+
## Recipe: sized image variants for feature media
|
|
567
|
+
|
|
568
|
+
OpenAPI records carry full-resolution originals. Loading those directly into tags, tour previews, cards or DOM thumbnails is the most common performance defect in a generated project, and it is not fixed by CSS sizing: the browser still downloads the original. Every feature above that carries an image must derive a variant. Read `.agents/skills/realsee-image-resources/SKILL.md` for provenance, host policy and CORS; this section is only the wiring into a feature.
|
|
569
|
+
|
|
570
|
+
Five 6.8.9 exports exactly `defaultImageURLTransform`, `ImageURLOptions`, `ImageURLMappings`, `ImageOptions` and `TextureOptions` from the package root. The size helpers (`replaceImageSize`, `getImageSizeFromURL`) live under `five/utils/imageURL` and are **not** root exports, so do not import them from `@realsee/five`. Five's own `ai_guides/features/image-options.md` documents the transform and the `cut` option.
|
|
571
|
+
|
|
572
|
+
`ImageURLOptions` requires `key` and optionally takes `size`, `quality` (0–100), `format`, `cut`, `sharpen` (unsupported overseas), `forceUseExifOrientation` and `mappings`. Wrap it once in an application-owned adapter that validates the host before and after transforming, then call that adapter from features:
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
// src/vr/five/imageURL.ts
|
|
576
|
+
import { defaultImageURLTransform, type ImageURLOptions } from "@realsee/five";
|
|
577
|
+
|
|
578
|
+
export function imageURLTransform(source: string, options: ImageURLOptions) {
|
|
579
|
+
assertApprovedRealseeImageURL(source);
|
|
580
|
+
const transformed = defaultImageURLTransform(source, {
|
|
581
|
+
...options,
|
|
582
|
+
mappings: { ...REALSEE_IMAGE_URL_MAPPINGS, ...options.mappings },
|
|
583
|
+
});
|
|
584
|
+
assertApprovedRealseeImageURL(transformed);
|
|
585
|
+
return transformed;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export const featureThumbnailURL = (source: string, size: 256 | 512 | 1024) =>
|
|
589
|
+
imageURLTransform(source, {
|
|
590
|
+
key: "default",
|
|
591
|
+
size,
|
|
592
|
+
quality: 80,
|
|
593
|
+
format: "webp",
|
|
594
|
+
});
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Apply it at the DTO-to-SDK conversion boundary, where the display context is known:
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
const toSdkMedia = (media: MediaItemDto) =>
|
|
601
|
+
media.type === "Image"
|
|
602
|
+
? {
|
|
603
|
+
type: "Image" as const,
|
|
604
|
+
// Popover artwork does not need the original.
|
|
605
|
+
url: featureThumbnailURL(media.url, 1024),
|
|
606
|
+
// A list thumbnail needs far less; fall back to the same source when absent.
|
|
607
|
+
thumbnail: featureThumbnailURL(media.thumbnail ?? media.url, 256),
|
|
608
|
+
}
|
|
609
|
+
: {
|
|
610
|
+
type: "Video" as const,
|
|
611
|
+
url: media.url, // video streams are not image-transform targets
|
|
612
|
+
...(media.videoCoverUrl
|
|
613
|
+
? { videoCoverUrl: featureThumbnailURL(media.videoCoverUrl, 512) }
|
|
614
|
+
: {}),
|
|
615
|
+
};
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
Per-capability targets:
|
|
619
|
+
|
|
620
|
+
- **PanoTag:** `Image`/`ImageText`/`Panorama` `mediaData[].url` and `thumbnail`, `Video` `videoCoverUrl`, `Marketing` `headerPictureUrl`, and any icon artwork in `style.point.url`.
|
|
621
|
+
- **Tour points:** the preview image of each observer-picture record, sized for its DOM list, not the panorama itself.
|
|
622
|
+
- **Cards, drawers and status chrome:** every DOM `Image`/`<img>` source in the Vapor layer.
|
|
623
|
+
|
|
624
|
+
Choose the size from the rendered box and device pixel ratio, prefer powers of two, and keep one bounded set of sizes rather than per-call numbers.
|
|
625
|
+
|
|
626
|
+
Do not transform these:
|
|
627
|
+
|
|
628
|
+
- pano, tile, texture and model resources consumed by Five itself: configure `FiveInitArgs.imageOptions`/`textureOptions` so Five applies its own pipeline, and never rewrite those URLs inside a feature;
|
|
629
|
+
- video and audio stream URLs;
|
|
630
|
+
- URLs on a host that is not an approved Realsee image host — reject instead of rewriting the hostname.
|
|
631
|
+
|
|
632
|
+
A transform changes a URL only. It does not grant CORS access, authorize a source, or prove the derived object exists; validate the final URL in a browser and keep an explicit fallback if a derived variant fails.
|
|
633
|
+
|
|
634
|
+
## Recipe: tour points as Five State
|
|
635
|
+
|
|
636
|
+
`GET /open/v3/observerpicture/search` is a Five State feature, not a dnalogel route controller. Validate `data.list`; for every item:
|
|
637
|
+
|
|
638
|
+
- read pose values from nested `data.pano_index`, `data.longitude`, `data.latitude`, and `data.fov`;
|
|
639
|
+
- if top-level `pano_index` exists, require it to agree;
|
|
640
|
+
- require finite values and confirm the pano index belongs to `five.observers`;
|
|
641
|
+
- set `mode: "Panorama"` and the active `workCode` explicitly;
|
|
642
|
+
- treat `offset: null` as absent; reconstruct a Three `Vector3` only from three finite components.
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
const state: Partial<State> = {
|
|
646
|
+
workCode,
|
|
647
|
+
mode: "Panorama",
|
|
648
|
+
panoIndex,
|
|
649
|
+
longitude,
|
|
650
|
+
latitude,
|
|
651
|
+
fov,
|
|
652
|
+
};
|
|
653
|
+
five.setState(state);
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`five.setState` returns `void`; do not await it. Use `state.synced` or `five.ready()` only when the product needs a completion gate. Invalidate the Work/generation token before aborting so a late response cannot apply State to another Work.
|
|
657
|
+
|
|
658
|
+
## Recipe: model annotations
|
|
659
|
+
|
|
660
|
+
`GET /open/v3/annotation/search` succeeds only with `code === 0` and array `data.list`. Validate each record's `object_data` discriminator:
|
|
661
|
+
|
|
662
|
+
- `box`: finite start/end points;
|
|
663
|
+
- `triangles`: finite triangle points;
|
|
664
|
+
- `prism`: finite polygon points and finite height.
|
|
665
|
+
|
|
666
|
+
Preserve only reviewed identity/label metadata and assign a stable non-zero id. If an upstream list is non-empty but no valid records remain, return a schema error rather than silently loading an empty plugin. Pass only:
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
await modelMaker.load({ list: validatedItems });
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Do not start this feature for a panorama-only Work. Guard model readiness with an AbortSignal or generation token. For a registration failure dispose only the unregistered candidate. For a registered instance, disable/clear and remove hooks as declared, wait for pending load, then call identity-safe `plugins.unregister(name, modelMaker)` once; unregister performs disposal.
|
|
673
|
+
|
|
674
|
+
## Recipe: routes, rendering, and playback
|
|
675
|
+
|
|
676
|
+
`GET /open/v3/route/search` records may contain `pano_group`, `path`, panorama/model style configs, and floor visibility. Rendering and playback use separate SDK inputs.
|
|
677
|
+
|
|
678
|
+
### Choose one playback semantic
|
|
679
|
+
|
|
680
|
+
- **Pano-index route:** require at least two integer indexes and confirm every index exists in `five.observers`. Load `CruisePlugin` with exactly one branch: `{ panoIndexList }` or `{ keyframes }`.
|
|
681
|
+
- **Model-space route:** require a supported path with at least two finite points. Load `MovePlugin` with `{ path }`.
|
|
682
|
+
- **Rendering:** separately adapt selected geometry/style to `GuideLinePlugin.load({ lines })` or a validated pano route to `load({ routes })`.
|
|
683
|
+
|
|
684
|
+
If a record contains both `pano_group` and `path`, choose product semantics explicitly; never merge both into one controller input.
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
await cruise.load({
|
|
688
|
+
panoIndexList,
|
|
689
|
+
moveType: "justMove",
|
|
690
|
+
moveToFirstPanoEffect: "montage",
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
await move.load({ path: points, useGuildLine: false });
|
|
694
|
+
await guideLine.load({ lines: validatedLines });
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Do not pass raw OpenAPI records, GuideLine `PathItem` arrays, or style objects to Cruise/Move. Do not invent `RoutePlugin`, `PanoRove`, `stop()`, or `cruise.clear()`. GuideLine waits for model readiness; Cruise needs Work/observers; Move enters Model and awaits Five readiness when playback starts.
|
|
698
|
+
|
|
699
|
+
Cleanup order:
|
|
700
|
+
|
|
701
|
+
1. invalidate the active Work/generation and abort the request;
|
|
702
|
+
2. pause the owned Cruise/Move controller with the declared API;
|
|
703
|
+
3. remove playback hooks and call GuideLine `clear()` if declared;
|
|
704
|
+
4. wait for each pending `load` to settle;
|
|
705
|
+
5. call identity-safe `plugins.unregister(name, expected)` once per registered controller.
|
|
706
|
+
|
|
707
|
+
Do not explicitly dispose a controller after successful unregister. Only a candidate that failed before registration is directly disposed.
|
|
708
|
+
|
|
709
|
+
## Recipe: model floorplan
|
|
710
|
+
|
|
711
|
+
The generated project already implements this recipe. Extend it rather than creating a second floor source of truth.
|
|
712
|
+
|
|
713
|
+
1. Call `GET /open/v3/floorplan/plugins?resource_code=...` server-side.
|
|
714
|
+
2. Read `data.format_floorplan_url`; do not substitute `format_model_url`.
|
|
715
|
+
3. Fetch that external HTTPS URL without gateway `Authorization`, redirects, or unbounded response size/time.
|
|
716
|
+
4. Validate the complete `{ version, data: FloorplanServerData }` wrapper.
|
|
717
|
+
5. Return optional `capabilities.modelFloorplan` so failure does not block the base Work.
|
|
718
|
+
6. Create `ModelFloorplanPlugin` with `autoShowEnable: false`, mount it with `appendTo(wrapper)`, then load.
|
|
719
|
+
7. Add `平面图` only after successful validation/load. `漫游` maps to Panorama and `三维模型` to Mapview; Topview is not “地图”.
|
|
720
|
+
8. Own model opacity while visible and restore it on hide, failure, and disposal. Let Five/plugin floor synchronization remain authoritative.
|
|
721
|
+
|
|
722
|
+
## Recipe: runtime model picking without persistence
|
|
723
|
+
|
|
724
|
+
For prompts such as “点击墙面选一个点” or “判断空间点是否可见”, use `PanoCursorRaycasterPlugin`. No OpenAPI call is required unless persistence is separately requested.
|
|
725
|
+
|
|
726
|
+
The pinned 3.81.2 public declaration is version-specific:
|
|
727
|
+
|
|
728
|
+
- import from the package root, not `@realsee/dnalogel/PanoCursorRaycasterPlugin`;
|
|
729
|
+
- call `canSeePoint(point, distanceError?)` and `pointAxesHelper(point, length?, fixDistance?)` with positional arguments;
|
|
730
|
+
- call `destroy()`, not the undeclared runtime `dispose` alias.
|
|
731
|
+
|
|
732
|
+
3.81.2 also installs an internal anonymous `modelLoaded` listener that `destroy()` cannot remove. Therefore create one cursor owner before `five.load`, keep it for the Five instance lifetime, remove only business listeners/helpers when a feature turns off, and call `destroy()` exactly once immediately before `five.dispose()`. Do not load another Work/model between those two calls. An SDK version may support shorter feature ownership only when its declarations and implementation both remove every internal listener.
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
import type { Five } from "@realsee/five";
|
|
736
|
+
import { PanoCursorRaycasterPlugin } from "@realsee/dnalogel";
|
|
737
|
+
import type { Vector3 } from "three";
|
|
738
|
+
|
|
739
|
+
export const createPanoCursorRuntime = (five: Five) => {
|
|
740
|
+
// Construct before the first five.load so 3.81.2 observes modelLoaded.
|
|
741
|
+
const cursor = PanoCursorRaycasterPlugin(five, {});
|
|
742
|
+
let helper: ReturnType<typeof cursor.pointAxesHelper> | undefined;
|
|
743
|
+
let select: ((point: Vector3) => void) | undefined;
|
|
744
|
+
let disposed = false;
|
|
745
|
+
|
|
746
|
+
const onTap = (): false | void => {
|
|
747
|
+
if (!select) return;
|
|
748
|
+
const hit = cursor.intersection();
|
|
749
|
+
if (!hit) return;
|
|
750
|
+
|
|
751
|
+
const point = hit.point.clone();
|
|
752
|
+
if (!cursor.canSeePoint(point.clone(), 0.05)) return;
|
|
753
|
+
|
|
754
|
+
helper?.clear();
|
|
755
|
+
helper = cursor.pointAxesHelper(point, 1.5, 0.2);
|
|
756
|
+
select(point.clone());
|
|
757
|
+
return false; // only a successful selection owns the tap
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const onModelLoaded = () => {
|
|
761
|
+
if (disposed) return;
|
|
762
|
+
five.off("wantsTapGesture", onTap);
|
|
763
|
+
five.on("wantsTapGesture", onTap);
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
five.on("modelLoaded", onModelLoaded);
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
install(onSelect: (point: Vector3) => void) {
|
|
770
|
+
select = onSelect;
|
|
771
|
+
return () => {
|
|
772
|
+
if (select === onSelect) select = undefined;
|
|
773
|
+
helper?.clear();
|
|
774
|
+
helper = undefined;
|
|
775
|
+
};
|
|
776
|
+
},
|
|
777
|
+
disposeBeforeFive() {
|
|
778
|
+
if (disposed) return;
|
|
779
|
+
disposed = true;
|
|
780
|
+
select = undefined;
|
|
781
|
+
five.off("modelLoaded", onModelLoaded);
|
|
782
|
+
five.off("wantsTapGesture", onTap);
|
|
783
|
+
helper?.clear();
|
|
784
|
+
helper = undefined;
|
|
785
|
+
cursor.destroy();
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
All business listeners above have stable identities and are removed. Misses, pre-readiness calls, invisible points, and inactive features return `void`, preserving Five's default tap navigation; only a successful selection returns `false`. Clone visibility inputs because 3.81.2 may mutate the supplied vector while constructing its ray.
|
|
792
|
+
|
|
793
|
+
Keep the result runtime-only unless an authoritative editor/API contract and explicit user intent define where it may be persisted.
|
|
794
|
+
|
|
795
|
+
## Prompt handling examples
|
|
796
|
+
|
|
797
|
+
- “给这个 VR 加几个视频热点”:use `/open/v3/hot/list`, parse a `Video` DTO, and convert to PanoTag; do not invent media URLs or browser credentials.
|
|
798
|
+
- “按后台路线自动漫游”:use `/open/v3/route/search`; inspect whether the selected route is a pano group or model path, then choose Cruise or Move and keep GuideLine separate.
|
|
799
|
+
- “展示讲解点并点击切换”:use `/open/v3/observerpicture/search`; adapt to Five State snapshots, not PanoTag or Cruise.
|
|
800
|
+
- “显示模型标注”:use `/open/v3/annotation/search` and ModelMaker after geometry/model-readiness validation.
|
|
801
|
+
- “加户型图”:extend the existing floorplan capability path; never add an unvalidated browser fetch.
|
|
802
|
+
- “点墙面放一个临时标记”:use the Five-lifetime PanoCursor owner plus a feature-owned helper; persistence requires a separate explicit source contract.
|
|
803
|
+
|
|
804
|
+
## Completion checklist
|
|
805
|
+
|
|
806
|
+
Before claiming completion, verify:
|
|
807
|
+
|
|
808
|
+
- the prompt maps to an explicit capability and authoritative data source;
|
|
809
|
+
- browser code contains no AK/SK, access token, or authenticated OpenAPI call;
|
|
810
|
+
- upstream `unknown` data passes a shared runtime parser before reaching the browser;
|
|
811
|
+
- every feature image reaching a plugin or the DOM is a derived sized variant on an approved host, not the upstream original;
|
|
812
|
+
- DTO-to-SDK conversion is exhaustive and contains no final unsafe cast;
|
|
813
|
+
- imports and controller methods compile against the pinned installed npm declarations;
|
|
814
|
+
- Work/model readiness matches the selected plugin;
|
|
815
|
+
- requests, stale continuations, hooks, playback/media, registrations, helper meshes, and controllers are cleaned up by one owner;
|
|
816
|
+
- registered instances are disposed only through identity-safe unregister, while failed unregistered candidates are disposed directly;
|
|
817
|
+
- one feature failure leaves the base Viewer usable;
|
|
818
|
+
- generated-project checks, the 3.81.2 contract fixture, and a real browser scenario pass.
|