@thatopen/services 0.3.15 → 0.3.16

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.
@@ -1,199 +1,199 @@
1
- import * as THREE from "three";
2
- import * as OBC from "@thatopen/components";
3
- import * as OBF from "@thatopen/components-front";
4
- import * as FRAGS from "@thatopen/fragments";
5
- // Inlines the fragments worker so it runs inside the platform's sandboxed iframe.
6
- import "@thatopen/fragments/inline";
7
- import * as BUI from "@thatopen/ui";
8
- import * as MARKERJS from "@markerjs/markerjs3";
9
- import { PlatformClient, UIManager } from "@thatopen/services";
10
- import { setAppContext } from "./app";
11
-
12
- // ─── A2 migration — PHASES 1+2: boot on UIManager + re-dock panels ───────────
13
- // Juan consolidated the old AppManager (layout) + ViewportsManager (viewport)
14
- // built-ins into the single UIManager built-in, which ships `top-app` (shell +
15
- // layout) and `top-viewer` (deferred-PEN viewport). This boots the bim-viewer on
16
- // that model.
17
- // Phase 1 — top-app shell hosting one top-viewer + auto-load. [done]
18
- // Phase 2 — re-dock the side panels (tree/properties/files/data/objects/
19
- // settings) into top-app's layouts + sidebar. [this file]
20
- // Phase 3 — the viewer-overlay tools (fps/HUD/gizmo/bottom toolbar/measure +
21
- // clip handles) that mount over the canvas. [next]
22
- // The pre-A2 rich main is preserved at `main.rich.ts.bak`.
23
- async function main() {
24
- const client = PlatformClient.fromPlatformContext();
25
-
26
- // Brand accent (purple) — drives layout-selector active state, dividers, etc.
27
- document.documentElement.style.setProperty("--bim-ui_accent-base", "#6528d7");
28
-
29
- // The dev `thatopen serve` wrapper HTML doesn't zero the UA body margin (8px),
30
- // which insets the whole app inside the platform iframe. Kill it here so it's
31
- // fixed in both dev and production regardless of the host page.
32
- document.body.style.margin = "0";
33
-
34
- // UIManager must be in the setup call: it registers the platform web
35
- // components (top-app, top-viewer, top-viewer-tools, …) before the DOM renders.
36
- const { components } = await client.setup<OBC.Components>(
37
- { OBC, OBF, BUI, THREE, FRAGS, MARKERJS },
38
- { uuid: UIManager.uuid },
39
- );
40
- components.get(UIManager).init();
41
-
42
- // One STABLE top-viewer node, returned by reference so re-rendering top-app
43
- // (when we add the panels below) reuses it instead of disposing/recreating
44
- // its world. No <top-viewer-tools>: the bim-viewer mounts its own tabbed
45
- // visibility/inspect toolbar (see below), so the platform default would just
46
- // duplicate it.
47
- const viewerEl = document.createElement("top-viewer");
48
- // Frame the viewport to match the side panels (BUI bim-panel host = 1px border
49
- // + 0.75rem radius). top-viewer's host is already overflow:hidden, so the
50
- // radius clips the canvas corners; border-box keeps the 1px inside the grid
51
- // area. Done here (not in top-viewer) so the CDE's top-file-viewer, which draws
52
- // its own frame, never double-borders.
53
- viewerEl.style.border = "1px solid var(--bim-ui_bg-contrast-20)";
54
- viewerEl.style.borderRadius = "0.75rem";
55
- viewerEl.style.boxSizing = "border-box";
56
-
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
- const app = document.createElement("top-app") as any;
59
-
60
- app.setup = (waitUntil: (p: Promise<void>, label?: string) => void) => {
61
- waitUntil(
62
- (async () => {
63
- const fragments = components.get(OBC.FragmentsManager);
64
- const workerUrl = await FRAGS.FragmentsModels.getWorker();
65
- fragments.init(await FRAGS.toClassicWorker(workerUrl), {
66
- classicWorker: true,
67
- });
68
- })(),
69
- "Fragments Core",
70
- );
71
- return { components, client };
72
- };
73
-
74
- // Mount minimally first (viewer only) so top-viewer creates the world; the
75
- // panels + their tools are built once that world exists (below).
76
- app.elements = { viewer: () => BUI.html`${viewerEl}` };
77
- app.layouts = {
78
- Main: {
79
- label: "Main",
80
- icon: "solar:3d-square-bold",
81
- template: `"viewer" 1fr / 1fr`,
82
- },
83
- };
84
- app.layout = "Main";
85
-
86
- const container = document.getElementById("that-open-app") ?? document.body;
87
- container.appendChild(app);
88
-
89
- // Wait for top-viewer's world to exist before building world-dependent panels.
90
- // The viewer tool suite + dynamic-anchor pivot dot are now baked into
91
- // <top-viewer> (setupViewerTools), so the app no longer wires them.
92
- await firstWorld(components.get(OBC.Worlds));
93
-
94
- // Platform client + project data for the AppManager-shim consumers
95
- // (CloudRunner, data-table-panel, app-info-section).
96
- const projectId: string | undefined = client?.context?.projectId;
97
- let projectData;
98
- try {
99
- if (projectId) projectData = await client.getProjectData(projectId);
100
- } catch {
101
- /* dev/no-project → consumers degrade gracefully */
102
- }
103
- setAppContext(client, projectData);
104
-
105
- // Pluggable loaders for <top-models-list>. The built-in ships the lightweight
106
- // defaults (.frag load, IFC→fragments convert); heavy/app-specific loaders are
107
- // registered here so they stay OUT of the built-in's bundle. The reality-capture
108
- // .3tz viewer pulls Spark + 3d-tiles-renderer, so it's lazy-imported app-side
109
- // and plugged in via the loader registry. Alignment persists through the panel's
110
- // app-data via the loader context (getAlignment/setAlignment).
111
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
- let rcViewer: any = null;
113
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
- const modelLoaders: Record<string, any> = {
115
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
- "3tz": async (fileId: string, ctx: any) => {
117
- if (!rcViewer) {
118
- const { realityCaptureViewer } = await import(
119
- "./setups/reality-capture-viewer"
120
- );
121
- rcViewer = realityCaptureViewer(ctx.components, client);
122
- }
123
- const saved = ctx.getAlignment(fileId) as number[] | undefined;
124
- await rcViewer.loadIntoWorld(fileId, {
125
- keepPostproduction: true,
126
- transform: saved ? new THREE.Matrix4().fromArray(saved) : undefined,
127
- onTransformChange: (m: THREE.Matrix4) =>
128
- ctx.setAlignment(fileId, m.toArray()),
129
- });
130
- },
131
- };
132
-
133
- // Re-dock: the stable viewer + the panels, under the bim-viewer's named layouts
134
- // with the activity-bar sidebar (Explorer · Assets · Data · Settings). All panels
135
- // are now built-ins that self-wire from top-app's componentsContext/clientContext
136
- // — no `components` plumbing in the app.
137
- app.elements = {
138
- viewer: () => BUI.html`${viewerEl}`,
139
- tree: () => BUI.html`<top-model-tree></top-model-tree>`,
140
- properties: () => BUI.html`<top-properties-panel></top-properties-panel>`,
141
- files: () =>
142
- BUI.html`<top-models-list .loaders=${modelLoaders}></top-models-list>`,
143
- dataTable: () => BUI.html`<top-data-table-panel></top-data-table-panel>`,
144
- objects: () => BUI.html`<top-objects-panel></top-objects-panel>`,
145
- settings: () => BUI.html`<top-settings-panel></top-settings-panel>`,
146
- };
147
- // No `label` → the sidebar renders icon-only activity-bar buttons (matching
148
- // the pre-A2 look), background only on the active one.
149
- app.layouts = {
150
- Explorer: {
151
- icon: "mdi:file-tree",
152
- template: `"tree viewer" 1fr "properties viewer" 1fr / 22rem 1fr`,
153
- },
154
- Assets: {
155
- icon: "mdi:folder-multiple-outline",
156
- template: `"files viewer" 1fr "objects viewer" 1fr / 22rem 1fr`,
157
- },
158
- Data: {
159
- icon: "mdi:table",
160
- template: `"dataTable viewer" 1fr / 22rem 1fr`,
161
- },
162
- Settings: {
163
- icon: "mdi:cog",
164
- template: `"settings viewer" 1fr / 22rem 1fr`,
165
- },
166
- };
167
- app.layout = "Explorer";
168
- app.sidebar = true;
169
-
170
- // ── Viewer toolbar — now a built-in slotted INTO <top-viewer> (it consumes the
171
- // world + components contexts top-viewer provides). The rich bottom toolbar +
172
- // active-tool HUD + clip/measure/walkthrough tools all live in
173
- // <top-viewer-toolbar>; the side panels share the SAME tool singletons via
174
- // components.get (OBC.Component-wrapped).
175
- viewerEl.appendChild(document.createElement("top-viewer-toolbar"));
176
-
177
- // Navigation gizmo is now baked into <top-viewer> (setupViewerTools), so the
178
- // app no longer mounts it. (Cascade: the rest of the overlay tools follow.)
179
-
180
- // No auto-load: the viewer opens empty. Users add models from the Assets
181
- // (files) panel — top-models-list loads the .frag they pick into the world.
182
- }
183
-
184
- /** Resolves with the first world once it exists (top-viewer creates it async). */
185
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
- function firstWorld(worlds: any): Promise<any> {
187
- const existing = [...worlds.list.values()][0];
188
- if (existing) return Promise.resolve(existing);
189
- return new Promise((resolve) => {
190
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
- const handler = ({ value }: any) => {
192
- worlds.list.onItemSet.remove(handler);
193
- resolve(value);
194
- };
195
- worlds.list.onItemSet.add(handler);
196
- });
197
- }
198
-
199
- main().catch(console.error);
1
+ import * as THREE from "three";
2
+ import * as OBC from "@thatopen/components";
3
+ import * as OBF from "@thatopen/components-front";
4
+ import * as FRAGS from "@thatopen/fragments";
5
+ // Inlines the fragments worker so it runs inside the platform's sandboxed iframe.
6
+ import "@thatopen/fragments/inline";
7
+ import * as BUI from "@thatopen/ui";
8
+ import * as MARKERJS from "@markerjs/markerjs3";
9
+ import { PlatformClient, UIManager } from "@thatopen/services";
10
+ import { setAppContext } from "./app";
11
+
12
+ // ─── A2 migration — PHASES 1+2: boot on UIManager + re-dock panels ───────────
13
+ // Juan consolidated the old AppManager (layout) + ViewportsManager (viewport)
14
+ // built-ins into the single UIManager built-in, which ships `top-app` (shell +
15
+ // layout) and `top-viewer` (deferred-PEN viewport). This boots the bim-viewer on
16
+ // that model.
17
+ // Phase 1 — top-app shell hosting one top-viewer + auto-load. [done]
18
+ // Phase 2 — re-dock the side panels (tree/properties/files/data/objects/
19
+ // settings) into top-app's layouts + sidebar. [this file]
20
+ // Phase 3 — the viewer-overlay tools (fps/HUD/gizmo/bottom toolbar/measure +
21
+ // clip handles) that mount over the canvas. [next]
22
+ // The pre-A2 rich main is preserved at `main.rich.ts.bak`.
23
+ async function main() {
24
+ const client = PlatformClient.fromPlatformContext();
25
+
26
+ // Brand accent (purple) — drives layout-selector active state, dividers, etc.
27
+ document.documentElement.style.setProperty("--bim-ui_accent-base", "#6528d7");
28
+
29
+ // The dev `thatopen serve` wrapper HTML doesn't zero the UA body margin (8px),
30
+ // which insets the whole app inside the platform iframe. Kill it here so it's
31
+ // fixed in both dev and production regardless of the host page.
32
+ document.body.style.margin = "0";
33
+
34
+ // UIManager must be in the setup call: it registers the platform web
35
+ // components (top-app, top-viewer, top-viewer-tools, …) before the DOM renders.
36
+ const { components } = await client.setup<OBC.Components>(
37
+ { OBC, OBF, BUI, THREE, FRAGS, MARKERJS },
38
+ { uuid: UIManager.uuid },
39
+ );
40
+ components.get(UIManager).init();
41
+
42
+ // One STABLE top-viewer node, returned by reference so re-rendering top-app
43
+ // (when we add the panels below) reuses it instead of disposing/recreating
44
+ // its world. No <top-viewer-tools>: the bim-viewer mounts its own tabbed
45
+ // visibility/inspect toolbar (see below), so the platform default would just
46
+ // duplicate it.
47
+ const viewerEl = document.createElement("top-viewer");
48
+ // Frame the viewport to match the side panels (BUI bim-panel host = 1px border
49
+ // + 0.75rem radius). top-viewer's host is already overflow:hidden, so the
50
+ // radius clips the canvas corners; border-box keeps the 1px inside the grid
51
+ // area. Done here (not in top-viewer) so the CDE's top-file-viewer, which draws
52
+ // its own frame, never double-borders.
53
+ viewerEl.style.border = "1px solid var(--bim-ui_bg-contrast-20)";
54
+ viewerEl.style.borderRadius = "0.75rem";
55
+ viewerEl.style.boxSizing = "border-box";
56
+
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ const app = document.createElement("top-app") as any;
59
+
60
+ app.setup = (waitUntil: (p: Promise<void>, label?: string) => void) => {
61
+ waitUntil(
62
+ (async () => {
63
+ const fragments = components.get(OBC.FragmentsManager);
64
+ const workerUrl = await FRAGS.FragmentsModels.getWorker();
65
+ fragments.init(await FRAGS.toClassicWorker(workerUrl), {
66
+ classicWorker: true,
67
+ });
68
+ })(),
69
+ "Fragments Core",
70
+ );
71
+ return { components, client };
72
+ };
73
+
74
+ // Mount minimally first (viewer only) so top-viewer creates the world; the
75
+ // panels + their tools are built once that world exists (below).
76
+ app.elements = { viewer: () => BUI.html`${viewerEl}` };
77
+ app.layouts = {
78
+ Main: {
79
+ label: "Main",
80
+ icon: "solar:3d-square-bold",
81
+ template: `"viewer" 1fr / 1fr`,
82
+ },
83
+ };
84
+ app.layout = "Main";
85
+
86
+ const container = document.getElementById("that-open-app") ?? document.body;
87
+ container.appendChild(app);
88
+
89
+ // Wait for top-viewer's world to exist before building world-dependent panels.
90
+ // The viewer tool suite + dynamic-anchor pivot dot are now baked into
91
+ // <top-viewer> (setupViewerTools), so the app no longer wires them.
92
+ await firstWorld(components.get(OBC.Worlds));
93
+
94
+ // Platform client + project data for the AppManager-shim consumers
95
+ // (CloudRunner, data-table-panel, app-info-section).
96
+ const projectId: string | undefined = client?.context?.projectId;
97
+ let projectData;
98
+ try {
99
+ if (projectId) projectData = await client.getProjectData(projectId);
100
+ } catch {
101
+ /* dev/no-project → consumers degrade gracefully */
102
+ }
103
+ setAppContext(client, projectData);
104
+
105
+ // Pluggable loaders for <top-models-list>. The built-in ships the lightweight
106
+ // defaults (.frag load, IFC→fragments convert); heavy/app-specific loaders are
107
+ // registered here so they stay OUT of the built-in's bundle. The reality-capture
108
+ // .3tz viewer pulls Spark + 3d-tiles-renderer, so it's lazy-imported app-side
109
+ // and plugged in via the loader registry. Alignment persists through the panel's
110
+ // app-data via the loader context (getAlignment/setAlignment).
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ let rcViewer: any = null;
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ const modelLoaders: Record<string, any> = {
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
+ "3tz": async (fileId: string, ctx: any) => {
117
+ if (!rcViewer) {
118
+ const { realityCaptureViewer } = await import(
119
+ "./setups/reality-capture-viewer"
120
+ );
121
+ rcViewer = realityCaptureViewer(ctx.components, client);
122
+ }
123
+ const saved = ctx.getAlignment(fileId) as number[] | undefined;
124
+ await rcViewer.loadIntoWorld(fileId, {
125
+ keepPostproduction: true,
126
+ transform: saved ? new THREE.Matrix4().fromArray(saved) : undefined,
127
+ onTransformChange: (m: THREE.Matrix4) =>
128
+ ctx.setAlignment(fileId, m.toArray()),
129
+ });
130
+ },
131
+ };
132
+
133
+ // Re-dock: the stable viewer + the panels, under the bim-viewer's named layouts
134
+ // with the activity-bar sidebar (Explorer · Assets · Data · Settings). All panels
135
+ // are now built-ins that self-wire from top-app's componentsContext/clientContext
136
+ // — no `components` plumbing in the app.
137
+ app.elements = {
138
+ viewer: () => BUI.html`${viewerEl}`,
139
+ tree: () => BUI.html`<top-model-tree></top-model-tree>`,
140
+ properties: () => BUI.html`<top-properties-panel></top-properties-panel>`,
141
+ files: () =>
142
+ BUI.html`<top-models-list .loaders=${modelLoaders}></top-models-list>`,
143
+ dataTable: () => BUI.html`<top-data-table-panel></top-data-table-panel>`,
144
+ objects: () => BUI.html`<top-objects-panel></top-objects-panel>`,
145
+ settings: () => BUI.html`<top-settings-panel></top-settings-panel>`,
146
+ };
147
+ // No `label` → the sidebar renders icon-only activity-bar buttons (matching
148
+ // the pre-A2 look), background only on the active one.
149
+ app.layouts = {
150
+ Explorer: {
151
+ icon: "mdi:file-tree",
152
+ template: `"tree viewer" 1fr "properties viewer" 1fr / 22rem 1fr`,
153
+ },
154
+ Assets: {
155
+ icon: "mdi:folder-multiple-outline",
156
+ template: `"files viewer" 1fr "objects viewer" 1fr / 22rem 1fr`,
157
+ },
158
+ Data: {
159
+ icon: "mdi:table",
160
+ template: `"dataTable viewer" 1fr / 22rem 1fr`,
161
+ },
162
+ Settings: {
163
+ icon: "mdi:cog",
164
+ template: `"settings viewer" 1fr / 22rem 1fr`,
165
+ },
166
+ };
167
+ app.layout = "Explorer";
168
+ app.sidebar = true;
169
+
170
+ // ── Viewer toolbar — now a built-in slotted INTO <top-viewer> (it consumes the
171
+ // world + components contexts top-viewer provides). The rich bottom toolbar +
172
+ // active-tool HUD + clip/measure/walkthrough tools all live in
173
+ // <top-viewer-toolbar>; the side panels share the SAME tool singletons via
174
+ // components.get (OBC.Component-wrapped).
175
+ viewerEl.appendChild(document.createElement("top-viewer-toolbar"));
176
+
177
+ // Navigation gizmo is now baked into <top-viewer> (setupViewerTools), so the
178
+ // app no longer mounts it. (Cascade: the rest of the overlay tools follow.)
179
+
180
+ // No auto-load: the viewer opens empty. Users add models from the Assets
181
+ // (files) panel — top-models-list loads the .frag they pick into the world.
182
+ }
183
+
184
+ /** Resolves with the first world once it exists (top-viewer creates it async). */
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
+ function firstWorld(worlds: any): Promise<any> {
187
+ const existing = [...worlds.list.values()][0];
188
+ if (existing) return Promise.resolve(existing);
189
+ return new Promise((resolve) => {
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ const handler = ({ value }: any) => {
192
+ worlds.list.onItemSet.remove(handler);
193
+ resolve(value);
194
+ };
195
+ worlds.list.onItemSet.add(handler);
196
+ });
197
+ }
198
+
199
+ main().catch(console.error);
@@ -1,88 +1,88 @@
1
- // 3DTilesRendererJS plugin: serve a STANDARD loose 3D Tiles dataset whose tiles
2
- // are stored as platform HIDDEN files, streamed one-file-per-tile.
3
- //
4
- // Seam: the renderer's `fetchData(url, options)` hook (intercepts BOTH the root
5
- // tileset and every tile content fetch). Point the TilesRenderer at
6
- // `<baseUrl>/tileset.json`; relative content URIs then resolve to
7
- // `<baseUrl>/<relUri>` (e.g. "mem://t/tiles/n12_d3.spz"), which we strip back to
8
- // the dataset-relative path (`relUri`) and look up in the `hiddenFiles` map to
9
- // find the platform hidden-file id to download.
10
- //
11
- // SHARED CONTRACT (matches the converters): the main visible file is a standard
12
- // `tileset.json`; each tile `content.uri` is a relative path under "tiles/".
13
- // tileset.json carries a top-level `hiddenFiles: { "<content.uri>": "<id>" }`
14
- // map. We fetch a tile blob with `client.downloadHiddenFile(id)` -> Response.
15
- //
16
- // Per-tile streaming = one `downloadHiddenFile` per visible tile; the controller's
17
- // Q1 LRU/count-budget governor bounds how many are resident/loading at once.
18
-
19
- // Structural type for the platform client: download a normal file by id (the
20
- // tileset main file) and a hidden file by id (each tile). PlatformClient /
21
- // EngineServicesClient satisfy this.
22
- export interface HiddenTilesClient {
23
- downloadFile(fileId: string, params?: any): Promise<Response>;
24
- downloadHiddenFile(hiddenId: string): Promise<Response>;
25
- }
26
-
27
- export interface HiddenTilesOptions {
28
- baseUrl: string; // synthetic base the TilesRenderer is pointed at, e.g. "mem://t"
29
- tilesetBytes: Uint8Array; // the already-downloaded tileset.json bytes
30
- hiddenFiles: Record<string, string>; // content.uri -> hidden file id
31
- client: HiddenTilesClient;
32
- }
33
-
34
- export class HiddenTilesPlugin {
35
- name = "HIDDEN_TILES_PLUGIN";
36
- private baseUrl: string;
37
- private tilesetBytes: Uint8Array;
38
- private hiddenFiles: Record<string, string>;
39
- private client: HiddenTilesClient;
40
-
41
- constructor({ baseUrl, tilesetBytes, hiddenFiles, client }: HiddenTilesOptions) {
42
- this.baseUrl = baseUrl.replace(/\/$/, "");
43
- this.tilesetBytes = tilesetBytes;
44
- this.hiddenFiles = hiddenFiles;
45
- this.client = client;
46
- }
47
-
48
- // dataset-relative path for URLs under our synthetic base, else null.
49
- private rel(url: string): string | null {
50
- const prefix = this.baseUrl + "/";
51
- return url.startsWith(prefix) ? url.slice(prefix.length) : null;
52
- }
53
-
54
- // The renderer fetch hook. Return a Response, or null to let the default fetch
55
- // run (URLs that aren't under our base).
56
- async fetchData(url: string, _options: any): Promise<Response | null> {
57
- const rel = this.rel(url);
58
- if (rel === null) return null;
59
-
60
- // Root tileset request: serve the bytes we already downloaded.
61
- if (rel === "tileset.json") {
62
- // copy out into a standalone ArrayBuffer for the Response
63
- const b = this.tilesetBytes;
64
- const ab = (b.byteOffset === 0 && b.byteLength === b.buffer.byteLength
65
- ? b.buffer
66
- : b.slice().buffer) as ArrayBuffer;
67
- return new Response(ab, {
68
- status: 200,
69
- headers: { "Content-Type": "application/json" },
70
- });
71
- }
72
-
73
- // Content (tile) request: strip query/fragment, look up the hidden id.
74
- const relUri = decodeURIComponent(rel.split("?")[0].split("#")[0]);
75
- const hiddenId = this.hiddenFiles[relUri];
76
- if (!hiddenId) {
77
- // not a known tile -> 404 so the renderer treats it as a failed fetch.
78
- return new Response(null, { status: 404, statusText: "Not Found" });
79
- }
80
-
81
- const res = await this.client.downloadHiddenFile(hiddenId);
82
- const buf = await res.arrayBuffer();
83
- return new Response(buf, {
84
- status: 200,
85
- headers: { "Content-Type": "application/octet-stream" },
86
- });
87
- }
88
- }
1
+ // 3DTilesRendererJS plugin: serve a STANDARD loose 3D Tiles dataset whose tiles
2
+ // are stored as platform HIDDEN files, streamed one-file-per-tile.
3
+ //
4
+ // Seam: the renderer's `fetchData(url, options)` hook (intercepts BOTH the root
5
+ // tileset and every tile content fetch). Point the TilesRenderer at
6
+ // `<baseUrl>/tileset.json`; relative content URIs then resolve to
7
+ // `<baseUrl>/<relUri>` (e.g. "mem://t/tiles/n12_d3.spz"), which we strip back to
8
+ // the dataset-relative path (`relUri`) and look up in the `hiddenFiles` map to
9
+ // find the platform hidden-file id to download.
10
+ //
11
+ // SHARED CONTRACT (matches the converters): the main visible file is a standard
12
+ // `tileset.json`; each tile `content.uri` is a relative path under "tiles/".
13
+ // tileset.json carries a top-level `hiddenFiles: { "<content.uri>": "<id>" }`
14
+ // map. We fetch a tile blob with `client.downloadHiddenFile(id)` -> Response.
15
+ //
16
+ // Per-tile streaming = one `downloadHiddenFile` per visible tile; the controller's
17
+ // Q1 LRU/count-budget governor bounds how many are resident/loading at once.
18
+
19
+ // Structural type for the platform client: download a normal file by id (the
20
+ // tileset main file) and a hidden file by id (each tile). PlatformClient /
21
+ // EngineServicesClient satisfy this.
22
+ export interface HiddenTilesClient {
23
+ downloadFile(fileId: string, params?: any): Promise<Response>;
24
+ downloadHiddenFile(hiddenId: string): Promise<Response>;
25
+ }
26
+
27
+ export interface HiddenTilesOptions {
28
+ baseUrl: string; // synthetic base the TilesRenderer is pointed at, e.g. "mem://t"
29
+ tilesetBytes: Uint8Array; // the already-downloaded tileset.json bytes
30
+ hiddenFiles: Record<string, string>; // content.uri -> hidden file id
31
+ client: HiddenTilesClient;
32
+ }
33
+
34
+ export class HiddenTilesPlugin {
35
+ name = "HIDDEN_TILES_PLUGIN";
36
+ private baseUrl: string;
37
+ private tilesetBytes: Uint8Array;
38
+ private hiddenFiles: Record<string, string>;
39
+ private client: HiddenTilesClient;
40
+
41
+ constructor({ baseUrl, tilesetBytes, hiddenFiles, client }: HiddenTilesOptions) {
42
+ this.baseUrl = baseUrl.replace(/\/$/, "");
43
+ this.tilesetBytes = tilesetBytes;
44
+ this.hiddenFiles = hiddenFiles;
45
+ this.client = client;
46
+ }
47
+
48
+ // dataset-relative path for URLs under our synthetic base, else null.
49
+ private rel(url: string): string | null {
50
+ const prefix = this.baseUrl + "/";
51
+ return url.startsWith(prefix) ? url.slice(prefix.length) : null;
52
+ }
53
+
54
+ // The renderer fetch hook. Return a Response, or null to let the default fetch
55
+ // run (URLs that aren't under our base).
56
+ async fetchData(url: string, _options: any): Promise<Response | null> {
57
+ const rel = this.rel(url);
58
+ if (rel === null) return null;
59
+
60
+ // Root tileset request: serve the bytes we already downloaded.
61
+ if (rel === "tileset.json") {
62
+ // copy out into a standalone ArrayBuffer for the Response
63
+ const b = this.tilesetBytes;
64
+ const ab = (b.byteOffset === 0 && b.byteLength === b.buffer.byteLength
65
+ ? b.buffer
66
+ : b.slice().buffer) as ArrayBuffer;
67
+ return new Response(ab, {
68
+ status: 200,
69
+ headers: { "Content-Type": "application/json" },
70
+ });
71
+ }
72
+
73
+ // Content (tile) request: strip query/fragment, look up the hidden id.
74
+ const relUri = decodeURIComponent(rel.split("?")[0].split("#")[0]);
75
+ const hiddenId = this.hiddenFiles[relUri];
76
+ if (!hiddenId) {
77
+ // not a known tile -> 404 so the renderer treats it as a failed fetch.
78
+ return new Response(null, { status: 404, statusText: "Not Found" });
79
+ }
80
+
81
+ const res = await this.client.downloadHiddenFile(hiddenId);
82
+ const buf = await res.arrayBuffer();
83
+ return new Response(buf, {
84
+ status: 200,
85
+ headers: { "Content-Type": "application/octet-stream" },
86
+ });
87
+ }
88
+ }