@ifc-lite/viewer 1.21.0 → 1.22.0
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/.turbo/turbo-build.log +57 -50
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/assets/arrow-fie-E7fe.js +20 -0
- package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
- package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
- package/dist/assets/bcf-Bhx-K17f.js +281 -0
- package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
- package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
- package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
- package/dist/assets/e57-source-CQHxE8n3.js +1 -0
- package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
- package/dist/assets/exporters-KTio0Tdm.js +5723 -0
- package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
- package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
- package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
- package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
- package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
- package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
- package/dist/assets/index-BZC2YaOP.css +1 -0
- package/dist/assets/index-HqAIQkr6.js +22 -0
- package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
- package/dist/assets/las-BW6LIc_j.js +1 -0
- package/dist/assets/las-source-C_IGrgRq.js +1 -0
- package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
- package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
- package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
- package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
- package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
- package/dist/assets/ply-source-C8jjyzxE.js +4 -0
- package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
- package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
- package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
- package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
- package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
- package/dist/assets/zip-BJqVbRkU.js +2 -0
- package/dist/index.html +10 -12
- package/package.json +11 -11
- package/src/components/mcp/PlaygroundChat.tsx +90 -52
- package/src/components/viewer/CesiumOverlay.tsx +150 -91
- package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
- package/src/components/viewer/ChatPanel.tsx +76 -93
- package/src/components/viewer/EntityContextMenu.tsx +68 -10
- package/src/components/viewer/MainToolbar.tsx +33 -3
- package/src/components/viewer/ViewportContainer.tsx +70 -16
- package/src/components/viewer/ViewportOverlays.tsx +2 -98
- package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
- package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
- package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
- package/src/components/viewer/selectionHandlers.ts +7 -1
- package/src/lib/geo/cesium-bridge.ts +86 -50
- package/src/lib/geo/cesium-placement.test.ts +244 -0
- package/src/lib/geo/cesium-placement.ts +231 -0
- package/src/lib/geo/effective-georef.test.ts +74 -1
- package/src/lib/geo/effective-georef.ts +40 -93
- package/src/lib/geo/geo-scale.ts +104 -0
- package/src/lib/geo/reproject.test.ts +130 -0
- package/src/lib/geo/reproject.ts +37 -12
- package/src/lib/geo/terrain-elevation.ts +198 -89
- package/src/lib/lens/adapter.ts +52 -6
- package/src/lib/llm/clipboard-detect.test.ts +150 -0
- package/src/lib/llm/clipboard-detect.ts +90 -0
- package/src/lib/llm/models.ts +28 -0
- package/src/lib/llm/stream-direct.ts +16 -4
- package/src/lib/llm/types.ts +8 -0
- package/src/services/playground-model.ts +55 -0
- package/src/store/index.ts +4 -5
- package/src/store/slices/cesiumSlice.ts +100 -19
- package/src/store.ts +3 -0
- package/dist/assets/arrow-CZ5kQ26f.js +0 -20
- package/dist/assets/bcf-4K724hw0.js +0 -281
- package/dist/assets/cesium-DUOzBlqv.js +0 -17817
- package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
- package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
- package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
- package/dist/assets/index-CSWgTe1s.css +0 -1
- package/dist/assets/index-XwKzDuw6.js +0 -22
- package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
- package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
- package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
- package/dist/assets/zip-DBEtpeu6.js +0 -12
- package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
|
@@ -13,13 +13,34 @@
|
|
|
13
13
|
|
|
14
14
|
import { queryTerrainElevation } from './reproject';
|
|
15
15
|
|
|
16
|
+
export type TerrainElevationSource =
|
|
17
|
+
| 'globe.getHeight'
|
|
18
|
+
| 'scene.sampleHeight'
|
|
19
|
+
| 'scene.sampleHeightMostDetailed'
|
|
20
|
+
| 'open-meteo';
|
|
21
|
+
|
|
22
|
+
export type TerrainHeightReference = 'ellipsoidal' | 'visual-surface' | 'orthometric';
|
|
23
|
+
|
|
24
|
+
export interface TerrainElevationSample {
|
|
25
|
+
height: number;
|
|
26
|
+
source: TerrainElevationSource;
|
|
27
|
+
reference: TerrainHeightReference;
|
|
28
|
+
cacheNamespace: string;
|
|
29
|
+
fromCache: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ResolveTerrainElevationOptions {
|
|
33
|
+
cacheNamespace?: string;
|
|
34
|
+
preferOrthometric?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
// Module-level cache so bridge rebuilds (georef edits, clamp toggles)
|
|
17
38
|
// re-use values within the session instead of re-hitting the network.
|
|
18
|
-
const terrainElevationCache = new Map<string,
|
|
39
|
+
const terrainElevationCache = new Map<string, TerrainElevationSample>();
|
|
19
40
|
|
|
20
|
-
function terrainCacheKey(lat: number, lon: number): string {
|
|
41
|
+
function terrainCacheKey(lat: number, lon: number, cacheNamespace: string): string {
|
|
21
42
|
// 5 decimal places ≈ 1.1m precision — plenty for site-level elevation.
|
|
22
|
-
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
|
43
|
+
return `${cacheNamespace}:${lat.toFixed(5)},${lon.toFixed(5)}`;
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
// Earth's plausible terrestrial elevation range. Mariana Trench ≈ −11 km
|
|
@@ -45,122 +66,210 @@ export function clearTerrainElevationCache(): void {
|
|
|
45
66
|
*
|
|
46
67
|
* Order:
|
|
47
68
|
* 1. Cache (instant — re-bridge after georef edit).
|
|
48
|
-
* 2.
|
|
49
|
-
* "no data" since the default ellipsoid provider returns 0 for every
|
|
50
|
-
* lat/lon).
|
|
51
|
-
* 3. scene.sampleHeight (sync, queries 3D Tiles + terrain — only works
|
|
69
|
+
* 2. scene.sampleHeight (sync, queries 3D Tiles + terrain — only works
|
|
52
70
|
* if tiles for the location are already rendered).
|
|
53
|
-
*
|
|
71
|
+
* 3. scene.sampleHeightMostDetailed with a bounded timeout — forces tile
|
|
54
72
|
* load and returns the height of the actually-rendered surface (what
|
|
55
73
|
* the user SEES in Google Photorealistic 3D Tiles). Tried before
|
|
56
74
|
* Open-Meteo because the visible-tile elevation is what models need
|
|
57
75
|
* to sit on; Open-Meteo's DEM ignores buildings/road surfaces.
|
|
76
|
+
* 4. globe.getHeight (terrain provider fallback — exact-zero treated as
|
|
77
|
+
* "no data" since the default ellipsoid provider returns 0 for every
|
|
78
|
+
* lat/lon).
|
|
58
79
|
* 5. Open-Meteo elevation API — bare-earth fallback when tiles can't be
|
|
59
80
|
* sampled (offline, no 3D tileset, timeout, etc.).
|
|
60
81
|
*/
|
|
61
82
|
const SAMPLE_DETAILED_TIMEOUT_MS = 3500;
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
function getTerrainSourceReference(source: TerrainElevationSource): TerrainHeightReference {
|
|
85
|
+
switch (source) {
|
|
86
|
+
case 'globe.getHeight':
|
|
87
|
+
return 'ellipsoidal';
|
|
88
|
+
case 'scene.sampleHeight':
|
|
89
|
+
case 'scene.sampleHeightMostDetailed':
|
|
90
|
+
return 'visual-surface';
|
|
91
|
+
case 'open-meteo':
|
|
92
|
+
return 'orthometric';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function acceptTerrainElevation(
|
|
97
|
+
cacheKey: string,
|
|
98
|
+
height: number,
|
|
99
|
+
source: TerrainElevationSource,
|
|
100
|
+
cacheNamespace: string,
|
|
101
|
+
ms?: number,
|
|
102
|
+
): TerrainElevationSample {
|
|
103
|
+
const sample: TerrainElevationSample = {
|
|
104
|
+
height,
|
|
105
|
+
source,
|
|
106
|
+
reference: getTerrainSourceReference(source),
|
|
107
|
+
cacheNamespace,
|
|
108
|
+
fromCache: false,
|
|
109
|
+
};
|
|
110
|
+
terrainElevationCache.set(cacheKey, sample);
|
|
111
|
+
const timing = ms !== undefined ? ` (${ms.toFixed(0)}ms)` : '';
|
|
112
|
+
console.debug(
|
|
113
|
+
`[TerrainElevation] via ${source}: ${height.toFixed(2)}m`
|
|
114
|
+
+ ` (${sample.reference}) at ${cacheKey}${timing}`,
|
|
115
|
+
);
|
|
116
|
+
return sample;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getTerrainSourceCandidates(
|
|
120
|
+
preferOrthometric: boolean,
|
|
121
|
+
): Array<{
|
|
122
|
+
source: TerrainElevationSource;
|
|
123
|
+
resolve: (
|
|
124
|
+
Cesium: typeof import('cesium'),
|
|
125
|
+
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
126
|
+
position: InstanceType<typeof import('cesium').Cartographic>,
|
|
127
|
+
lat: number,
|
|
128
|
+
lon: number,
|
|
129
|
+
) => Promise<{ height: number | undefined | null; elapsedMs?: number; skipped?: boolean }>;
|
|
130
|
+
}> {
|
|
131
|
+
const candidates = [
|
|
132
|
+
{
|
|
133
|
+
source: 'scene.sampleHeight' as const,
|
|
134
|
+
resolve: async (
|
|
135
|
+
_Cesium: typeof import('cesium'),
|
|
136
|
+
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
137
|
+
position: InstanceType<typeof import('cesium').Cartographic>,
|
|
138
|
+
) => {
|
|
139
|
+
if (!viewer.scene.sampleHeightSupported) return { height: null, skipped: true };
|
|
140
|
+
return { height: viewer.scene.sampleHeight(position) };
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
source: 'scene.sampleHeightMostDetailed' as const,
|
|
145
|
+
resolve: async (
|
|
146
|
+
_Cesium: typeof import('cesium'),
|
|
147
|
+
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
148
|
+
position: InstanceType<typeof import('cesium').Cartographic>,
|
|
149
|
+
) => {
|
|
150
|
+
if (!viewer.scene.sampleHeightSupported) return { height: null, skipped: true };
|
|
151
|
+
|
|
152
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
try {
|
|
154
|
+
const t0 = performance.now();
|
|
155
|
+
const detailed = viewer.scene.sampleHeightMostDetailed([position]);
|
|
156
|
+
const timeout = new Promise<null>((resolve) => {
|
|
157
|
+
timeoutId = setTimeout(() => resolve(null), SAMPLE_DETAILED_TIMEOUT_MS);
|
|
158
|
+
});
|
|
159
|
+
const winner = await Promise.race([detailed, timeout]);
|
|
160
|
+
const elapsedMs = performance.now() - t0;
|
|
161
|
+
if (winner === null) {
|
|
162
|
+
console.debug(
|
|
163
|
+
`[TerrainElevation] sampleHeightMostDetailed timed out after ${elapsedMs.toFixed(0)}ms`,
|
|
164
|
+
);
|
|
165
|
+
return { height: null, elapsedMs, skipped: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const r0 = winner[0] as { height?: number } | undefined;
|
|
169
|
+
return { height: r0?.height, elapsedMs };
|
|
170
|
+
} finally {
|
|
171
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
source: 'globe.getHeight' as const,
|
|
177
|
+
resolve: async (
|
|
178
|
+
_Cesium: typeof import('cesium'),
|
|
179
|
+
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
180
|
+
position: InstanceType<typeof import('cesium').Cartographic>,
|
|
181
|
+
) => {
|
|
182
|
+
const h = viewer.scene.globe.getHeight(position);
|
|
183
|
+
if (h !== undefined && Math.abs(h) <= 1e-3) {
|
|
184
|
+
return { height: null, skipped: true };
|
|
185
|
+
}
|
|
186
|
+
return { height: h };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
source: 'open-meteo' as const,
|
|
191
|
+
resolve: async (
|
|
192
|
+
_Cesium: typeof import('cesium'),
|
|
193
|
+
_viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
194
|
+
_position: InstanceType<typeof import('cesium').Cartographic>,
|
|
195
|
+
lat: number,
|
|
196
|
+
lon: number,
|
|
197
|
+
) => {
|
|
198
|
+
const t0 = performance.now();
|
|
199
|
+
const elev = await queryTerrainElevation({ lat, lon });
|
|
200
|
+
return { height: elev, elapsedMs: performance.now() - t0 };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
if (!preferOrthometric) return candidates;
|
|
206
|
+
return [
|
|
207
|
+
candidates[3],
|
|
208
|
+
candidates[2],
|
|
209
|
+
candidates[0],
|
|
210
|
+
candidates[1],
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function resolveTerrainElevationDetailed(
|
|
64
215
|
Cesium: typeof import('cesium'),
|
|
65
216
|
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
66
217
|
lat: number,
|
|
67
218
|
lon: number,
|
|
68
|
-
|
|
69
|
-
|
|
219
|
+
options: ResolveTerrainElevationOptions = {},
|
|
220
|
+
): Promise<TerrainElevationSample | null> {
|
|
221
|
+
const cacheNamespace = options.cacheNamespace ?? 'default';
|
|
222
|
+
const preferOrthometric = options.preferOrthometric ?? false;
|
|
223
|
+
const cacheKey = terrainCacheKey(lat, lon, cacheNamespace);
|
|
70
224
|
const cached = terrainElevationCache.get(cacheKey);
|
|
71
225
|
if (cached !== undefined) {
|
|
72
|
-
console.debug(
|
|
73
|
-
|
|
226
|
+
console.debug(
|
|
227
|
+
`[TerrainElevation] cached at ${cacheKey}: ${cached.height.toFixed(2)}m`
|
|
228
|
+
+ ` via ${cached.source} (${cached.reference})`,
|
|
229
|
+
);
|
|
230
|
+
return { ...cached, fromCache: true };
|
|
74
231
|
}
|
|
75
232
|
|
|
76
233
|
const position = Cesium.Cartographic.fromDegrees(lon, lat);
|
|
77
|
-
const accept = (h: number, source: string, ms?: number): number => {
|
|
78
|
-
terrainElevationCache.set(cacheKey, h);
|
|
79
|
-
const t = ms !== undefined ? ` (${ms.toFixed(0)}ms)` : '';
|
|
80
|
-
console.debug(`[TerrainElevation] via ${source}: ${h.toFixed(2)}m at ${cacheKey}${t}`);
|
|
81
|
-
return h;
|
|
82
|
-
};
|
|
83
234
|
const skip = (h: unknown, source: string) => {
|
|
84
235
|
console.debug(`[TerrainElevation] ${source} returned implausible value ${h}; skipping`);
|
|
85
236
|
};
|
|
86
237
|
|
|
87
|
-
|
|
88
|
-
// every lat/lon, so when no real terrain provider is wired we'd lock
|
|
89
|
-
// in 0 and never reach the network fallbacks. Treat exact-zero from
|
|
90
|
-
// this source specifically as "no data" — Open-Meteo can still return
|
|
91
|
-
// a true 0 elsewhere in the chain for legitimate sea-level sites.
|
|
92
|
-
try {
|
|
93
|
-
const h = viewer.scene.globe.getHeight(position);
|
|
94
|
-
if (h !== undefined && isPlausibleElevation(h) && Math.abs(h) > 1e-3) {
|
|
95
|
-
return accept(h, 'globe.getHeight');
|
|
96
|
-
}
|
|
97
|
-
if (h !== undefined && !isPlausibleElevation(h)) skip(h, 'globe.getHeight');
|
|
98
|
-
} catch (err) {
|
|
99
|
-
console.warn('[TerrainElevation] globe.getHeight threw:', err);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 2. Sync scene.sampleHeight — works with 3D Tiles when tiles for this
|
|
103
|
-
// location are already rendered.
|
|
104
|
-
if (viewer.scene.sampleHeightSupported) {
|
|
238
|
+
for (const candidate of getTerrainSourceCandidates(preferOrthometric)) {
|
|
105
239
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
240
|
+
const { height, elapsedMs, skipped } = await candidate.resolve(
|
|
241
|
+
Cesium, viewer, position, lat, lon,
|
|
242
|
+
);
|
|
243
|
+
if (height !== undefined && height !== null && isPlausibleElevation(height)) {
|
|
244
|
+
return acceptTerrainElevation(
|
|
245
|
+
cacheKey,
|
|
246
|
+
height,
|
|
247
|
+
candidate.source,
|
|
248
|
+
cacheNamespace,
|
|
249
|
+
elapsedMs,
|
|
250
|
+
);
|
|
109
251
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 3. Force-load the 3D-Tile tile at this location and sample the
|
|
117
|
-
// rendered surface. This is what Google Photorealistic 3D Tiles
|
|
118
|
-
// show on screen, so the model lands on the SAME surface the user
|
|
119
|
-
// sees — no "below the visible ground" mismatch with Open-Meteo's
|
|
120
|
-
// DEM. Bounded by a timeout so a slow tile fetch doesn't keep the
|
|
121
|
-
// bridge waiting forever; Open-Meteo runs after as a backstop.
|
|
122
|
-
if (viewer.scene.sampleHeightSupported) {
|
|
123
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
-
try {
|
|
125
|
-
const t0 = performance.now();
|
|
126
|
-
const detailed = viewer.scene.sampleHeightMostDetailed([position]);
|
|
127
|
-
const timeout = new Promise<null>((resolve) => {
|
|
128
|
-
timeoutId = setTimeout(() => resolve(null), SAMPLE_DETAILED_TIMEOUT_MS);
|
|
129
|
-
});
|
|
130
|
-
const winner = await Promise.race([detailed, timeout]);
|
|
131
|
-
const ms = performance.now() - t0;
|
|
132
|
-
if (winner !== null) {
|
|
133
|
-
const r0 = winner[0] as { height?: number } | undefined;
|
|
134
|
-
if (r0?.height !== undefined && isPlausibleElevation(r0.height)) {
|
|
135
|
-
return accept(r0.height, 'scene.sampleHeightMostDetailed', ms);
|
|
136
|
-
}
|
|
137
|
-
if (r0?.height !== undefined) skip(r0.height, 'scene.sampleHeightMostDetailed');
|
|
138
|
-
} else {
|
|
139
|
-
console.debug(`[TerrainElevation] sampleHeightMostDetailed timed out after ${ms.toFixed(0)}ms`);
|
|
252
|
+
if (height !== undefined && height !== null) {
|
|
253
|
+
skip(height, candidate.source);
|
|
254
|
+
} else if (!skipped) {
|
|
255
|
+
console.debug(`[TerrainElevation] ${candidate.source} returned no value`);
|
|
140
256
|
}
|
|
141
257
|
} catch (err) {
|
|
142
|
-
console.warn(
|
|
143
|
-
} finally {
|
|
144
|
-
// Cancel the timeout if the detailed sample resolved first, so the
|
|
145
|
-
// timer doesn't dangle and resolve to null after we've moved on.
|
|
146
|
-
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
258
|
+
console.warn(`[TerrainElevation] ${candidate.source} threw:`, err);
|
|
147
259
|
}
|
|
148
260
|
}
|
|
149
261
|
|
|
150
|
-
// 4. Open-Meteo bare-earth elevation. Used as a network fallback when
|
|
151
|
-
// the visible-tile sample didn't resolve in time.
|
|
152
|
-
try {
|
|
153
|
-
const t0 = performance.now();
|
|
154
|
-
const elev = await queryTerrainElevation({ lat, lon });
|
|
155
|
-
const ms = performance.now() - t0;
|
|
156
|
-
if (elev !== null && isPlausibleElevation(elev)) {
|
|
157
|
-
return accept(elev, 'Open-Meteo', ms);
|
|
158
|
-
}
|
|
159
|
-
if (elev !== null) skip(elev, 'Open-Meteo');
|
|
160
|
-
} catch (err) {
|
|
161
|
-
console.warn('[TerrainElevation] Open-Meteo threw:', err);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
262
|
console.warn(`[TerrainElevation] no source returned a plausible value at ${cacheKey}`);
|
|
165
263
|
return null;
|
|
166
264
|
}
|
|
265
|
+
|
|
266
|
+
export async function resolveTerrainElevation(
|
|
267
|
+
Cesium: typeof import('cesium'),
|
|
268
|
+
viewer: InstanceType<typeof import('cesium').Viewer>,
|
|
269
|
+
lat: number,
|
|
270
|
+
lon: number,
|
|
271
|
+
options: ResolveTerrainElevationOptions = {},
|
|
272
|
+
): Promise<number | null> {
|
|
273
|
+
const result = await resolveTerrainElevationDetailed(Cesium, viewer, lat, lon, options);
|
|
274
|
+
return result?.height ?? null;
|
|
275
|
+
}
|
package/src/lib/lens/adapter.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { LensDataProvider, PropertySetInfo, ClassificationInfo } from '@ifc
|
|
|
14
14
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
15
15
|
import {
|
|
16
16
|
extractEntityAttributesOnDemand,
|
|
17
|
+
extractPropertiesOnDemand,
|
|
18
|
+
extractTypePropertiesOnDemand,
|
|
17
19
|
extractQuantitiesOnDemand,
|
|
18
20
|
extractClassificationsOnDemand,
|
|
19
21
|
extractMaterialsOnDemand,
|
|
@@ -105,17 +107,61 @@ export function createLensDataProvider(
|
|
|
105
107
|
): unknown {
|
|
106
108
|
const resolved = resolveGlobalId(globalId, entries);
|
|
107
109
|
if (!resolved) return undefined;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
const store = resolved.entry.ifcDataStore;
|
|
111
|
+
const id = resolved.expressId;
|
|
112
|
+
|
|
113
|
+
// On-demand extraction path: pre-built table is empty for client-parsed
|
|
114
|
+
// stores, so iterate the same psets we expose via getPropertySets.
|
|
115
|
+
if (store.onDemandPropertyMap && store.source?.length > 0) {
|
|
116
|
+
const instancePsets = extractPropertiesOnDemand(store, id);
|
|
117
|
+
for (const pset of instancePsets) {
|
|
118
|
+
if (pset.name !== propertySetName) continue;
|
|
119
|
+
for (const prop of pset.properties) {
|
|
120
|
+
if (prop.name === propertyName) return prop.value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Fall through to type-inherited psets (Pset_*Common is typically
|
|
124
|
+
// attached to IfcSpaceType / IfcWallType, not the instance).
|
|
125
|
+
const typeProps = extractTypePropertiesOnDemand(store, id);
|
|
126
|
+
if (typeProps) {
|
|
127
|
+
for (const pset of typeProps.properties) {
|
|
128
|
+
if (pset.name !== propertySetName) continue;
|
|
129
|
+
for (const prop of pset.properties) {
|
|
130
|
+
if (prop.name === propertyName) return prop.value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return store.properties?.getPropertyValue?.(id, propertySetName, propertyName);
|
|
113
138
|
},
|
|
114
139
|
|
|
115
140
|
getPropertySets(globalId: number): PropertySetInfo[] {
|
|
116
141
|
const resolved = resolveGlobalId(globalId, entries);
|
|
117
142
|
if (!resolved) return [];
|
|
118
|
-
const
|
|
143
|
+
const store = resolved.entry.ifcDataStore;
|
|
144
|
+
const id = resolved.expressId;
|
|
145
|
+
|
|
146
|
+
// Properties are extracted lazily — the pre-built table is empty unless
|
|
147
|
+
// server-parsed. Mirror the quantity path and use the on-demand extractor,
|
|
148
|
+
// which itself falls back to the eager table when no on-demand map exists.
|
|
149
|
+
if (store.onDemandPropertyMap && store.source?.length > 0) {
|
|
150
|
+
const instancePsets = extractPropertiesOnDemand(store, id) as PropertySetInfo[];
|
|
151
|
+
// Merge type-inherited psets (Pset_*Common lives on the type entity
|
|
152
|
+
// for occurrences). Instance psets take precedence on name conflict.
|
|
153
|
+
const typeProps = extractTypePropertiesOnDemand(store, id);
|
|
154
|
+
if (!typeProps || typeProps.properties.length === 0) return instancePsets;
|
|
155
|
+
|
|
156
|
+
const seen = new Set(instancePsets.map((p) => p.name));
|
|
157
|
+
const merged = instancePsets.slice();
|
|
158
|
+
for (const pset of typeProps.properties) {
|
|
159
|
+
if (!seen.has(pset.name)) merged.push(pset as PropertySetInfo);
|
|
160
|
+
}
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const psets = store.properties?.getForEntity?.(id);
|
|
119
165
|
if (!psets) return [];
|
|
120
166
|
return psets as PropertySetInfo[];
|
|
121
167
|
},
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { looksLikeProviderKey, maskKey, readClipboardKey } from './clipboard-detect.js';
|
|
8
|
+
|
|
9
|
+
// ── looksLikeProviderKey ───────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
test('looksLikeProviderKey accepts a realistic Anthropic console key', () => {
|
|
12
|
+
// 14-char prefix + 50 random body chars
|
|
13
|
+
const key = 'sk-ant-api03-' + 'a'.repeat(60);
|
|
14
|
+
assert.equal(looksLikeProviderKey('anthropic', key), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('looksLikeProviderKey rejects too-short Anthropic key', () => {
|
|
18
|
+
const key = 'sk-ant-api03-tooshort';
|
|
19
|
+
assert.equal(looksLikeProviderKey('anthropic', key), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('looksLikeProviderKey rejects Anthropic key with wrong prefix', () => {
|
|
23
|
+
assert.equal(looksLikeProviderKey('anthropic', 'sk-' + 'a'.repeat(80)), false);
|
|
24
|
+
assert.equal(looksLikeProviderKey('anthropic', 'sk-ant-api01-' + 'a'.repeat(80)), false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('looksLikeProviderKey accepts a legacy OpenAI key', () => {
|
|
28
|
+
assert.equal(looksLikeProviderKey('openai', 'sk-' + 'a'.repeat(48)), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('looksLikeProviderKey accepts an OpenAI project key', () => {
|
|
32
|
+
assert.equal(looksLikeProviderKey('openai', 'sk-proj-' + 'a'.repeat(48)), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('looksLikeProviderKey accepts an OpenAI service-account key', () => {
|
|
36
|
+
assert.equal(looksLikeProviderKey('openai', 'sk-svcacct-' + 'a'.repeat(48)), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('looksLikeProviderKey rejects random clipboard contents', () => {
|
|
40
|
+
assert.equal(looksLikeProviderKey('anthropic', 'hello world'), false);
|
|
41
|
+
assert.equal(looksLikeProviderKey('openai', 'https://example.com/?foo=bar'), false);
|
|
42
|
+
assert.equal(looksLikeProviderKey('openai', ''), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('looksLikeProviderKey trims leading/trailing whitespace before matching', () => {
|
|
46
|
+
const key = 'sk-ant-api03-' + 'a'.repeat(60);
|
|
47
|
+
assert.equal(looksLikeProviderKey('anthropic', ` ${key}\n`), true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── maskKey ────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
test('maskKey preserves Anthropic prefix and last 4 chars', () => {
|
|
53
|
+
const key = 'sk-ant-api03-' + 'a'.repeat(56) + 'WXYZ';
|
|
54
|
+
const masked = maskKey(key);
|
|
55
|
+
assert.equal(masked.startsWith('sk-ant-api03-'), true);
|
|
56
|
+
assert.equal(masked.endsWith('WXYZ'), true);
|
|
57
|
+
assert.equal(masked.includes('••••'), true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('maskKey preserves OpenAI project prefix', () => {
|
|
61
|
+
const key = 'sk-proj-' + 'a'.repeat(40) + 'WXYZ';
|
|
62
|
+
const masked = maskKey(key);
|
|
63
|
+
assert.equal(masked.startsWith('sk-proj-'), true);
|
|
64
|
+
assert.equal(masked.endsWith('WXYZ'), true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('maskKey preserves bare sk- prefix for legacy keys', () => {
|
|
68
|
+
const key = 'sk-' + 'a'.repeat(45) + 'WXYZ';
|
|
69
|
+
const masked = maskKey(key);
|
|
70
|
+
assert.equal(masked.startsWith('sk-'), true);
|
|
71
|
+
assert.equal(masked.endsWith('WXYZ'), true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('maskKey falls back to bullets for absurdly short input', () => {
|
|
75
|
+
assert.equal(maskKey('short'), '••••');
|
|
76
|
+
assert.equal(maskKey(''), '••••');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── readClipboardKey ──────────────────────────────────────────────────────
|
|
80
|
+
// We can't easily simulate a real browser Clipboard API in node:test, but we
|
|
81
|
+
// can verify the helper degrades gracefully when navigator/clipboard is absent
|
|
82
|
+
// and returns the matched key when a stub is provided.
|
|
83
|
+
//
|
|
84
|
+
// `globalThis.navigator` is defined as a getter in modern Node, so we use
|
|
85
|
+
// Object.defineProperty with configurable:true to swap it for the duration
|
|
86
|
+
// of each test, then restore the original descriptor.
|
|
87
|
+
|
|
88
|
+
function withNavigator<T>(stub: unknown, fn: () => T): T {
|
|
89
|
+
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
|
90
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
91
|
+
value: stub,
|
|
92
|
+
configurable: true,
|
|
93
|
+
writable: true,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
return fn();
|
|
97
|
+
} finally {
|
|
98
|
+
if (original) {
|
|
99
|
+
Object.defineProperty(globalThis, 'navigator', original);
|
|
100
|
+
} else {
|
|
101
|
+
delete (globalThis as { navigator?: unknown }).navigator;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('readClipboardKey returns null when navigator.clipboard is unavailable', async () => {
|
|
107
|
+
const result = await withNavigator({ clipboard: undefined }, () => readClipboardKey('anthropic'));
|
|
108
|
+
assert.equal(result, null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('readClipboardKey returns matched key when clipboard contents shape-match', async () => {
|
|
112
|
+
const goodKey = 'sk-ant-api03-' + 'b'.repeat(70);
|
|
113
|
+
const result = await withNavigator(
|
|
114
|
+
{ clipboard: { readText: async () => goodKey } },
|
|
115
|
+
() => readClipboardKey('anthropic'),
|
|
116
|
+
);
|
|
117
|
+
assert.equal(result, goodKey);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('readClipboardKey returns null when clipboard contents are unrelated', async () => {
|
|
121
|
+
const result = await withNavigator(
|
|
122
|
+
{ clipboard: { readText: async () => 'just a normal copied string' } },
|
|
123
|
+
() => readClipboardKey('anthropic'),
|
|
124
|
+
);
|
|
125
|
+
assert.equal(result, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('readClipboardKey swallows clipboard read errors and returns null', async () => {
|
|
129
|
+
const result = await withNavigator(
|
|
130
|
+
{
|
|
131
|
+
clipboard: {
|
|
132
|
+
readText: async () => {
|
|
133
|
+
throw new Error('NotAllowedError: permission denied');
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
() => readClipboardKey('openai'),
|
|
138
|
+
);
|
|
139
|
+
assert.equal(result, null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('readClipboardKey wrong-provider clipboard returns null', async () => {
|
|
143
|
+
// Anthropic key on clipboard, but we're checking for an OpenAI key
|
|
144
|
+
const anthropicKey = 'sk-ant-api03-' + 'c'.repeat(70);
|
|
145
|
+
const result = await withNavigator(
|
|
146
|
+
{ clipboard: { readText: async () => anthropicKey } },
|
|
147
|
+
() => readClipboardKey('openai'),
|
|
148
|
+
);
|
|
149
|
+
assert.equal(result, null);
|
|
150
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clipboard sniffing for the BYOK key modal.
|
|
7
|
+
*
|
|
8
|
+
* After the user clicks our "Open Console" button, they create a key on the
|
|
9
|
+
* provider's site, copy it, and return to this tab. If transient activation
|
|
10
|
+
* (or clipboard-read permission) is still valid, we can detect the key and
|
|
11
|
+
* offer one-click insertion instead of forcing a manual paste.
|
|
12
|
+
*
|
|
13
|
+
* Reads are best-effort and silently no-op on:
|
|
14
|
+
* - missing Clipboard API (older Safari, insecure context, sandboxed iframe)
|
|
15
|
+
* - permission denied (Firefox without explicit user grant)
|
|
16
|
+
* - read failure for any other reason
|
|
17
|
+
*
|
|
18
|
+
* We never throw on these — the modal just falls back to its manual paste UX.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type BYOKProvider = 'anthropic' | 'openai';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider-specific shape checks. Tight enough to avoid false positives on
|
|
25
|
+
* random clipboard contents, permissive enough to cover the key formats each
|
|
26
|
+
* provider currently issues.
|
|
27
|
+
*
|
|
28
|
+
* Anthropic console keys: `sk-ant-api03-` + ≥50 chars of [A-Za-z0-9_-]
|
|
29
|
+
* OpenAI keys: `sk-`, `sk-proj-`, `sk-svcacct-`, `sk-admin-` + ≥20 chars
|
|
30
|
+
*
|
|
31
|
+
* The OpenAI pattern uses a negative lookahead for `ant-` so an Anthropic key
|
|
32
|
+
* doesn't accidentally satisfy the OpenAI tab — they both start with `sk-`.
|
|
33
|
+
*/
|
|
34
|
+
const PROVIDER_PATTERNS: Record<BYOKProvider, RegExp> = {
|
|
35
|
+
anthropic: /^sk-ant-api03-[A-Za-z0-9_-]{50,}$/,
|
|
36
|
+
openai: /^sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}$/,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Try to read a candidate provider key from the system clipboard.
|
|
41
|
+
*
|
|
42
|
+
* Returns the trimmed key string if the clipboard contents match the provider
|
|
43
|
+
* pattern. Returns `null` for everything else (no match, permission denied,
|
|
44
|
+
* Clipboard API unavailable, etc.) — callers should treat `null` as "fall back
|
|
45
|
+
* to manual paste".
|
|
46
|
+
*/
|
|
47
|
+
export async function readClipboardKey(provider: BYOKProvider): Promise<string | null> {
|
|
48
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const text = await navigator.clipboard.readText();
|
|
53
|
+
const trimmed = text.trim();
|
|
54
|
+
if (PROVIDER_PATTERNS[provider].test(trimmed)) {
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Permission denied / insecure context / no transient activation — these
|
|
60
|
+
// are expected on Firefox and Safari without an explicit user grant, and
|
|
61
|
+
// the manual-paste fallback still works. Log at debug level for diagnostic
|
|
62
|
+
// but never escalate to the UI.
|
|
63
|
+
console.debug('[byok] clipboard read failed', err);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render-safe key mask. Preserves the provider prefix so the user can confirm
|
|
70
|
+
* the right key is detected, hides the secret middle, shows the last 4 chars
|
|
71
|
+
* for disambiguation.
|
|
72
|
+
*
|
|
73
|
+
* sk-ant-api03-abcdef…XYZA
|
|
74
|
+
* sk-proj-••••XYZA
|
|
75
|
+
* sk-••••XYZA
|
|
76
|
+
*/
|
|
77
|
+
export function maskKey(key: string): string {
|
|
78
|
+
if (key.length < 12) return '••••';
|
|
79
|
+
const dashIdx = key.lastIndexOf('-');
|
|
80
|
+
const prefixEnd = dashIdx > 0 && dashIdx < 14 ? dashIdx + 1 : Math.min(14, key.length - 4);
|
|
81
|
+
return `${key.slice(0, prefixEnd)}••••${key.slice(-4)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Light shape check shared with `readClipboardKey` so callers can validate
|
|
86
|
+
* manual paste contents the same way before saving.
|
|
87
|
+
*/
|
|
88
|
+
export function looksLikeProviderKey(provider: BYOKProvider, value: string): boolean {
|
|
89
|
+
return PROVIDER_PATTERNS[provider].test(value.trim());
|
|
90
|
+
}
|