@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.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. 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, number>();
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. globe.getHeight (sync, terrain provider exact-zero treated as
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
- * 4. scene.sampleHeightMostDetailed with a bounded timeout — forces tile
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
- export async function resolveTerrainElevation(
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
- ): Promise<number | null> {
69
- const cacheKey = terrainCacheKey(lat, lon);
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(`[TerrainElevation] cached at ${cacheKey}: ${cached.toFixed(2)}m`);
73
- return cached;
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
- // 1. Sync globe.getHeight. The default ellipsoid provider returns 0 for
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 h = viewer.scene.sampleHeight(position);
107
- if (h !== undefined && isPlausibleElevation(h)) {
108
- return accept(h, 'scene.sampleHeight');
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 (h !== undefined) skip(h, 'scene.sampleHeight');
111
- } catch (err) {
112
- console.warn('[TerrainElevation] scene.sampleHeight threw:', err);
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('[TerrainElevation] sampleHeightMostDetailed threw:', err);
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
+ }
@@ -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
- return resolved.entry.ifcDataStore.properties?.getPropertyValue?.(
109
- resolved.expressId,
110
- propertySetName,
111
- propertyName,
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 psets = resolved.entry.ifcDataStore.properties?.getForEntity?.(resolved.expressId);
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
+ }