@ifc-lite/viewer 1.17.4 → 1.18.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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,73 @@
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 { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ import { inferMapUnitScale, mergeMapConversion, mergeProjectedCRS } from './effective-georef.js';
9
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
10
+
11
+ describe('effective georeferencing', () => {
12
+ it('recomputes map unit scale when the edited MapUnit changes', () => {
13
+ const original: ProjectedCRS = {
14
+ id: 1,
15
+ name: 'EPSG:28992',
16
+ mapUnit: 'METRE',
17
+ mapUnitScale: 1,
18
+ };
19
+
20
+ const merged = mergeProjectedCRS(original, { mapUnit: 'US SURVEY FOOT' }, 1);
21
+
22
+ assert.strictEqual(merged?.mapUnit, 'US SURVEY FOOT');
23
+ assert.strictEqual(merged?.mapUnitScale, 0.3048006096);
24
+ });
25
+
26
+ it('preserves the extracted map unit scale when MapUnit was not edited', () => {
27
+ const original: ProjectedCRS = {
28
+ id: 1,
29
+ name: 'EPSG:1234',
30
+ mapUnit: 'CUSTOM',
31
+ mapUnitScale: 2.5,
32
+ };
33
+
34
+ const merged = mergeProjectedCRS(original, { description: 'Edited CRS' }, 1);
35
+
36
+ assert.strictEqual(merged?.description, 'Edited CRS');
37
+ assert.strictEqual(merged?.mapUnitScale, 2.5);
38
+ });
39
+
40
+ it('overlays edited IfcMapConversion fields without dropping original rotation and scale', () => {
41
+ const original: MapConversion = {
42
+ id: 2,
43
+ sourceCRS: 10,
44
+ targetCRS: 11,
45
+ eastings: 100,
46
+ northings: 200,
47
+ orthogonalHeight: 5,
48
+ xAxisAbscissa: 0,
49
+ xAxisOrdinate: 1,
50
+ scale: 0.9999,
51
+ };
52
+
53
+ const merged = mergeMapConversion(original, { eastings: 150, orthogonalHeight: 9 });
54
+
55
+ assert.deepStrictEqual(merged, {
56
+ id: 2,
57
+ sourceCRS: 10,
58
+ targetCRS: 11,
59
+ eastings: 150,
60
+ northings: 200,
61
+ orthogonalHeight: 9,
62
+ xAxisAbscissa: 0,
63
+ xAxisOrdinate: 1,
64
+ scale: 0.9999,
65
+ });
66
+ });
67
+
68
+ it('infers common IFC map unit names', () => {
69
+ assert.strictEqual(inferMapUnitScale('FOOT'), 0.3048);
70
+ assert.strictEqual(inferMapUnitScale('METRE'), 1);
71
+ assert.strictEqual(inferMapUnitScale('MILLIMETRE'), 0.001);
72
+ });
73
+ });
@@ -0,0 +1,111 @@
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 {
6
+ extractGeoreferencingOnDemand,
7
+ extractLengthUnitScale,
8
+ type GeoreferenceInfo,
9
+ type IfcDataStore,
10
+ type MapConversion,
11
+ type ProjectedCRS,
12
+ } from '@ifc-lite/parser';
13
+ import type { CoordinateInfo } from '@ifc-lite/geometry';
14
+
15
+ export interface GeorefMutationDataLike {
16
+ projectedCRS?: Partial<ProjectedCRS>;
17
+ mapConversion?: Partial<MapConversion>;
18
+ }
19
+
20
+ export interface EffectiveGeoreference extends GeoreferenceInfo {
21
+ hasGeoreference: true;
22
+ coordinateInfo?: CoordinateInfo;
23
+ lengthUnitScale: number;
24
+ }
25
+
26
+ export function inferMapUnitScale(mapUnit: string | undefined, fallback?: number): number | undefined {
27
+ if (!mapUnit) return fallback;
28
+ const normalized = mapUnit.toUpperCase();
29
+ if (normalized.includes('US') && (normalized.includes('SURVEY') || normalized.includes('FTUS'))) {
30
+ return 0.3048006096;
31
+ }
32
+ if (normalized.includes('FOOT') || normalized.includes('FEET')) return 0.3048;
33
+ if (normalized.includes('MILLI')) return 0.001;
34
+ if (normalized.includes('CENTI')) return 0.01;
35
+ if (normalized.includes('DECI')) return 0.1;
36
+ if (normalized.includes('KILO')) return 1000;
37
+ if (normalized.includes('METRE') || normalized.includes('METER')) return 1;
38
+ return fallback;
39
+ }
40
+
41
+ export function getIfcLengthUnitScale(dataStore: IfcDataStore | null | undefined): number {
42
+ if (!dataStore?.source?.length || !dataStore.entityIndex) return 1;
43
+ return extractLengthUnitScale(dataStore.source, dataStore.entityIndex);
44
+ }
45
+
46
+ export function mergeProjectedCRS(
47
+ original: ProjectedCRS | undefined,
48
+ mutations: Partial<ProjectedCRS> | undefined,
49
+ lengthUnitScale: number,
50
+ ): ProjectedCRS | undefined {
51
+ if (!original && !mutations) return undefined;
52
+ const mapUnit = mutations?.mapUnit ?? original?.mapUnit;
53
+ const mapUnitScale = mutations?.mapUnit !== undefined
54
+ ? inferMapUnitScale(mapUnit, lengthUnitScale)
55
+ : original?.mapUnitScale ?? inferMapUnitScale(mapUnit, undefined);
56
+ return {
57
+ id: original?.id ?? 0,
58
+ name: (mutations?.name ?? original?.name ?? '') as string,
59
+ description: mutations?.description ?? original?.description,
60
+ geodeticDatum: mutations?.geodeticDatum ?? original?.geodeticDatum,
61
+ verticalDatum: mutations?.verticalDatum ?? original?.verticalDatum,
62
+ mapProjection: mutations?.mapProjection ?? original?.mapProjection,
63
+ mapZone: mutations?.mapZone ?? original?.mapZone,
64
+ mapUnit,
65
+ mapUnitScale,
66
+ };
67
+ }
68
+
69
+ export function mergeMapConversion(
70
+ original: MapConversion | undefined,
71
+ mutations: Partial<MapConversion> | undefined,
72
+ ): MapConversion | undefined {
73
+ if (!original && !mutations) return undefined;
74
+ return {
75
+ id: original?.id ?? 0,
76
+ sourceCRS: original?.sourceCRS ?? 0,
77
+ targetCRS: original?.targetCRS ?? 0,
78
+ eastings: (mutations?.eastings ?? original?.eastings ?? 0) as number,
79
+ northings: (mutations?.northings ?? original?.northings ?? 0) as number,
80
+ orthogonalHeight: (mutations?.orthogonalHeight ?? original?.orthogonalHeight ?? 0) as number,
81
+ xAxisAbscissa: mutations?.xAxisAbscissa ?? original?.xAxisAbscissa,
82
+ xAxisOrdinate: mutations?.xAxisOrdinate ?? original?.xAxisOrdinate,
83
+ scale: mutations?.scale ?? original?.scale,
84
+ };
85
+ }
86
+
87
+ export function getEffectiveGeoreference(
88
+ dataStore: IfcDataStore | null | undefined,
89
+ coordinateInfo?: CoordinateInfo,
90
+ mutations?: GeorefMutationDataLike,
91
+ ): EffectiveGeoreference | null {
92
+ if (!dataStore) return null;
93
+ const original = extractGeoreferencingOnDemand(dataStore);
94
+ const lengthUnitScale = getIfcLengthUnitScale(dataStore);
95
+ const projectedCRS = mergeProjectedCRS(
96
+ original?.projectedCRS,
97
+ mutations?.projectedCRS,
98
+ lengthUnitScale,
99
+ );
100
+ const mapConversion = mergeMapConversion(original?.mapConversion, mutations?.mapConversion);
101
+
102
+ if (!projectedCRS && !mapConversion) return null;
103
+ return {
104
+ hasGeoreference: true,
105
+ projectedCRS,
106
+ mapConversion,
107
+ coordinateInfo,
108
+ lengthUnitScale,
109
+ transformMatrix: original?.transformMatrix,
110
+ };
111
+ }
@@ -36,6 +36,29 @@ function extractEpsgCode(crs: ProjectedCRS): string | null {
36
36
  return match ? match[1] : null;
37
37
  }
38
38
 
39
+ /**
40
+ * Well-known CRS names that IFC authoring tools set without an EPSG: prefix.
41
+ * Maps normalised name → EPSG code.
42
+ */
43
+ const WELL_KNOWN_CRS: Record<string, string> = {
44
+ 'wgs 84': '4326',
45
+ 'wgs84': '4326',
46
+ 'wgs-84': '4326',
47
+ 'nad83': '4269',
48
+ 'nad27': '4267',
49
+ 'etrs89': '4258',
50
+ 'gcs_wgs_1984': '4326', // ArcGIS / Revit export alias
51
+ 'gcs_north_american_1983': '4269',
52
+ };
53
+
54
+ /**
55
+ * Check if a proj4 definition is a geographic (longlat) CRS rather than a projected one.
56
+ * Geographic CRS coordinates are in degrees, not metres.
57
+ */
58
+ function isGeographicProj4(def: string): boolean {
59
+ return /\+proj=longlat\b/.test(def);
60
+ }
61
+
39
62
  /**
40
63
  * Build a proj4 definition string for a UTM zone.
41
64
  */
@@ -101,11 +124,12 @@ async function fetchProj4Def(epsgCode: string): Promise<string | null> {
101
124
  * Resolution order:
102
125
  * 1. Cache hit
103
126
  * 2. Bundled EPSG index (7000+ codes with proj4 strings)
104
- * 3. UTM zone heuristic (from CRS metadata)
105
- * 4. Fetch from epsg.io (network fallback)
127
+ * 3. Well-known CRS name lookup (e.g. "WGS 84" → EPSG:4326)
128
+ * 4. UTM zone heuristic (from CRS metadata — mapZone, name, description, mapProjection)
129
+ * 5. Fetch from epsg.io (network fallback)
106
130
  */
107
131
  export async function resolveProjection(crs: ProjectedCRS): Promise<string | null> {
108
- const code = extractEpsgCode(crs);
132
+ let code = extractEpsgCode(crs);
109
133
 
110
134
  // 1. Check cache
111
135
  if (code && projDefCache.has(code)) {
@@ -126,7 +150,31 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
126
150
  }
127
151
  }
128
152
 
129
- // 3. UTM zone heuristic
153
+ // 3. Well-known CRS name → EPSG code (handles "WGS 84", "NAD83", etc.)
154
+ if (!code) {
155
+ const normalised = crs.name?.trim().toLowerCase() ?? '';
156
+ const wellKnownCode = WELL_KNOWN_CRS[normalised];
157
+ if (wellKnownCode) {
158
+ code = wellKnownCode;
159
+ if (projDefCache.has(code)) {
160
+ return projDefCache.get(code) ?? null;
161
+ }
162
+ try {
163
+ const bundled = await lookupProj4(code);
164
+ if (bundled) {
165
+ const sanitized = sanitizeProj4(bundled);
166
+ projDefCache.set(code, sanitized);
167
+ // For geographic CRS (longlat), check if we can infer a projected CRS
168
+ // from the UTM zone metadata — a projected CRS is much more useful.
169
+ // If we can't, fall through and return the geographic def below.
170
+ }
171
+ } catch {
172
+ // continue
173
+ }
174
+ }
175
+ }
176
+
177
+ // 4. UTM zone heuristic — check mapZone, name, description, AND mapProjection
130
178
  if (crs.mapZone) {
131
179
  const def = utmProj4String(crs.mapZone);
132
180
  if (def) {
@@ -136,7 +184,8 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
136
184
  }
137
185
  const name = crs.name?.toUpperCase() ?? '';
138
186
  const utmMatch = name.match(/UTM\s+ZONE\s+(\d{1,2}[NS])/i)
139
- ?? crs.description?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i);
187
+ ?? crs.description?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i)
188
+ ?? crs.mapProjection?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i);
140
189
  if (utmMatch) {
141
190
  const def = utmProj4String(utmMatch[1]);
142
191
  if (def) {
@@ -145,7 +194,14 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
145
194
  }
146
195
  }
147
196
 
148
- // 4. Network fallback fetch from epsg.io
197
+ // If step 3 resolved a geographic CRS (e.g. EPSG:4326) and we couldn't
198
+ // upgrade it to a projected CRS via the UTM heuristic, still return it —
199
+ // reprojectToLatLon will handle the longlat identity case.
200
+ if (code && projDefCache.has(code)) {
201
+ return projDefCache.get(code) ?? null;
202
+ }
203
+
204
+ // 5. Network fallback — fetch from epsg.io
149
205
  if (code) {
150
206
  const raw = await fetchProj4Def(code);
151
207
  const fetched = raw ? sanitizeProj4(raw) : null;
@@ -175,16 +231,19 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
175
231
  function computeProjectedCenter(
176
232
  conversion: MapConversion,
177
233
  coordinateInfo?: CoordinateInfo,
234
+ lengthUnitScale = 1,
178
235
  ): { easting: number; northing: number } {
179
236
  const { ifcX, ifcY } = computeLocalIfcCenter(coordinateInfo);
180
237
 
181
- // Apply MapConversion rotation + scale + offset
238
+ // Geometry coordinates (ifcX, ifcY) are already in metres — the geometry engine
239
+ // converts from the IFC file's native unit during extraction. Only MapConversion
240
+ // values (eastings, northings) are in the file's native unit and need scaling.
182
241
  const scale = conversion.scale ?? 1.0;
183
242
  const abscissa = conversion.xAxisAbscissa ?? 1.0;
184
243
  const ordinate = conversion.xAxisOrdinate ?? 0.0;
185
244
 
186
- const easting = conversion.eastings + scale * (abscissa * ifcX - ordinate * ifcY);
187
- const northing = conversion.northings + scale * (ordinate * ifcX + abscissa * ifcY);
245
+ const easting = conversion.eastings * lengthUnitScale + scale * (abscissa * ifcX - ordinate * ifcY);
246
+ const northing = conversion.northings * lengthUnitScale + scale * (ordinate * ifcX + abscissa * ifcY);
188
247
 
189
248
  return { easting, northing };
190
249
  }
@@ -195,19 +254,34 @@ function computeProjectedCenter(
195
254
  * Uses the model's actual geometry bounds + RTC offset to determine where
196
255
  * the model sits in the projected coordinate system, then reprojects to WGS84.
197
256
  *
198
- * @param conversion IfcMapConversion (offset, rotation, scale)
199
- * @param crs IfcProjectedCRS (EPSG code)
257
+ * @param conversion IfcMapConversion (offset, rotation, scale)
258
+ * @param crs IfcProjectedCRS (EPSG code, mapUnitScale)
200
259
  * @param coordinateInfo Geometry coordinate info with bounds and RTC offset
260
+ * @param lengthUnitScale IFC project length unit → metres (fallback when crs.mapUnitScale is absent)
201
261
  */
202
262
  export async function reprojectToLatLon(
203
263
  conversion: MapConversion,
204
264
  crs: ProjectedCRS,
205
265
  coordinateInfo?: CoordinateInfo,
266
+ lengthUnitScale = 1,
206
267
  ): Promise<LatLon | null> {
207
268
  const projDef = await resolveProjection(crs);
208
269
  if (!projDef) return null;
209
270
 
210
- const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo);
271
+ // Geographic CRS (e.g. EPSG:4326) eastings/northings are already lon/lat.
272
+ // Don't add the model's geometry center (in meters) to degree-based coordinates.
273
+ if (isGeographicProj4(projDef)) {
274
+ const lon = conversion.eastings;
275
+ const lat = conversion.northings;
276
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
277
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
278
+ return { lat, lon };
279
+ }
280
+
281
+ // MapConversion values use the unit from IfcProjectedCRS.MapUnit. If MapUnit
282
+ // is not specified, the IFC spec defaults to the project's length unit.
283
+ const mapScale = crs.mapUnitScale ?? lengthUnitScale;
284
+ const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo, mapScale);
211
285
 
212
286
  try {
213
287
  const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]);
@@ -256,23 +330,32 @@ export async function reprojectFromLatLon(
256
330
  crs: ProjectedCRS,
257
331
  conversion?: MapConversion,
258
332
  coordinateInfo?: CoordinateInfo,
333
+ lengthUnitScale = 1,
259
334
  ): Promise<{ easting: number; northing: number } | null> {
260
335
  const projDef = await resolveProjection(crs);
261
336
  if (!projDef) return null;
262
337
 
338
+ // Geographic CRS — coordinates are lon/lat in degrees, no projection needed.
339
+ if (isGeographicProj4(projDef)) {
340
+ return { easting: latLon.lon, northing: latLon.lat };
341
+ }
342
+
263
343
  try {
264
344
  const [projE, projN] = proj4('WGS84', projDef, [latLon.lon, latLon.lat]);
265
345
  if (!Number.isFinite(projE) || !Number.isFinite(projN)) return null;
266
346
 
267
- // Subtract the rotated/scaled local geometry offset so that
268
- // the resulting eastings/northings place the model center at this position
347
+ // Convert projected metres back to MapConversion's unit.
348
+ // Geometry offsets (ifcX/Y) are already in metres.
349
+ const mapScale = crs.mapUnitScale ?? lengthUnitScale;
350
+ const invScale = mapScale !== 0 ? 1 / mapScale : 1;
269
351
  const { ifcX, ifcY } = computeLocalIfcCenter(coordinateInfo);
270
352
  const scale = conversion?.scale ?? 1.0;
271
353
  const abscissa = conversion?.xAxisAbscissa ?? 1.0;
272
354
  const ordinate = conversion?.xAxisOrdinate ?? 0.0;
273
355
 
274
- const easting = projE - scale * (abscissa * ifcX - ordinate * ifcY);
275
- const northing = projN - scale * (ordinate * ifcX + abscissa * ifcY);
356
+ // Result is in IFC native units (the reverse of: E_native * LUS + geom_offset = E_metres)
357
+ const easting = (projE - scale * (abscissa * ifcX - ordinate * ifcY)) * invScale;
358
+ const northing = (projN - scale * (ordinate * ifcX + abscissa * ifcY)) * invScale;
276
359
 
277
360
  return { easting, northing };
278
361
  } catch {
@@ -289,12 +372,14 @@ export async function reprojectFromLatLon(
289
372
  * then reprojects to lat/lon. The result is a rotated rectangle matching the
290
373
  * model's XZ extent on the map.
291
374
  *
375
+ * @param lengthUnitScale IFC project length unit → metres (fallback when crs.mapUnitScale is absent)
292
376
  * @returns A single GeoJSON-compatible polygon: closed ring of [lon, lat] pairs
293
377
  */
294
378
  export async function computeFootprintGeoJSON(
295
379
  conversion: MapConversion,
296
380
  crs: ProjectedCRS,
297
381
  coordinateInfo: CoordinateInfo,
382
+ lengthUnitScale = 1,
298
383
  ): Promise<[number, number][] | null> {
299
384
  const projDef = await resolveProjection(crs);
300
385
  if (!projDef) {
@@ -333,9 +418,10 @@ export async function computeFootprintGeoJSON(
333
418
  const ifcX = worldX;
334
419
  const ifcY = -worldZ;
335
420
 
336
- // MapConversion: local IFC projected CRS
337
- const easting = conversion.eastings + scale * (abscissa * ifcX - ordinate * ifcY);
338
- const northing = conversion.northings + scale * (ordinate * ifcX + abscissa * ifcY);
421
+ // Geometry coords (ifcX/Y) are already in metres; only MapConversion needs scaling
422
+ const mapScale = crs.mapUnitScale ?? lengthUnitScale;
423
+ const easting = conversion.eastings * mapScale + scale * (abscissa * ifcX - ordinate * ifcY);
424
+ const northing = conversion.northings * mapScale + scale * (ordinate * ifcX + abscissa * ifcY);
339
425
 
340
426
  // Projected CRS → WGS84
341
427
  try {
@@ -0,0 +1,77 @@
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 { resolveStreamRoute } from './byok-guard.js';
8
+ import { DEFAULT_BYOK_MODEL, BYOK_MODELS } from './models.js';
9
+
10
+ const ANTHROPIC_MODEL = BYOK_MODELS.find((m) => m.source === 'anthropic')!;
11
+ const OPENAI_MODEL = BYOK_MODELS.find((m) => m.source === 'openai')!;
12
+
13
+ test('resolveStreamRoute returns proxy route for free models', () => {
14
+ const route = resolveStreamRoute('openai/gpt-free', { anthropicKey: '', openaiKey: '' });
15
+ assert.equal(route.kind, 'proxy');
16
+ if (route.kind === 'proxy') {
17
+ assert.equal(route.model, 'openai/gpt-free');
18
+ }
19
+ });
20
+
21
+ test('resolveStreamRoute returns proxy route for unknown model ids', () => {
22
+ const route = resolveStreamRoute('made-up-model', { anthropicKey: 'sk-ant-...', openaiKey: '' });
23
+ assert.equal(route.kind, 'proxy');
24
+ });
25
+
26
+ test('resolveStreamRoute returns anthropic route when key present', () => {
27
+ const route = resolveStreamRoute(ANTHROPIC_MODEL.id, {
28
+ anthropicKey: 'sk-ant-abc',
29
+ openaiKey: '',
30
+ });
31
+ assert.equal(route.kind, 'anthropic');
32
+ if (route.kind === 'anthropic') {
33
+ assert.equal(route.apiKey, 'sk-ant-abc');
34
+ assert.equal(route.model, ANTHROPIC_MODEL.id);
35
+ }
36
+ });
37
+
38
+ test('resolveStreamRoute returns missing-key when anthropic model selected without key', () => {
39
+ const route = resolveStreamRoute(ANTHROPIC_MODEL.id, {
40
+ anthropicKey: '',
41
+ openaiKey: 'sk-openai-xyz',
42
+ });
43
+ assert.equal(route.kind, 'missing-key');
44
+ if (route.kind === 'missing-key') {
45
+ assert.equal(route.provider, 'anthropic');
46
+ }
47
+ });
48
+
49
+ test('resolveStreamRoute returns openai route when key present', () => {
50
+ const route = resolveStreamRoute(OPENAI_MODEL.id, {
51
+ anthropicKey: '',
52
+ openaiKey: 'sk-openai-xyz',
53
+ });
54
+ assert.equal(route.kind, 'openai');
55
+ if (route.kind === 'openai') {
56
+ assert.equal(route.apiKey, 'sk-openai-xyz');
57
+ }
58
+ });
59
+
60
+ test('resolveStreamRoute returns missing-key when openai model selected without key', () => {
61
+ const route = resolveStreamRoute(OPENAI_MODEL.id, {
62
+ anthropicKey: 'sk-ant-abc',
63
+ openaiKey: '',
64
+ });
65
+ assert.equal(route.kind, 'missing-key');
66
+ if (route.kind === 'missing-key') {
67
+ assert.equal(route.provider, 'openai');
68
+ }
69
+ });
70
+
71
+ test('resolveStreamRoute treats whitespace-only keys as missing', () => {
72
+ const route = resolveStreamRoute(DEFAULT_BYOK_MODEL.id, {
73
+ anthropicKey: ' ',
74
+ openaiKey: ' ',
75
+ });
76
+ assert.equal(route.kind, 'missing-key');
77
+ });
@@ -0,0 +1,39 @@
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
+ * Pure routing guard for chat streams.
7
+ *
8
+ * Given a model id and the current BYOK key bag, decide whether the send
9
+ * should go through the proxy, direct to a provider, or be blocked because
10
+ * a required key is missing. Pulled out of ChatPanel so we can unit-test
11
+ * the guard without spinning up React — and so the "missing key" check
12
+ * happens BEFORE the user message is appended to the chat history.
13
+ */
14
+
15
+ import { getModelById } from './models.js';
16
+ import type { ApiKeyConfig } from '../../services/api-keys.js';
17
+
18
+ export type StreamRoute =
19
+ | { kind: 'proxy'; model: string }
20
+ | { kind: 'anthropic'; model: string; apiKey: string }
21
+ | { kind: 'openai'; model: string; apiKey: string }
22
+ | { kind: 'missing-key'; provider: 'anthropic' | 'openai' };
23
+
24
+ export function resolveStreamRoute(modelId: string, keys: ApiKeyConfig): StreamRoute {
25
+ const model = getModelById(modelId);
26
+ const source = model?.source ?? 'proxy';
27
+
28
+ if (source === 'anthropic') {
29
+ const apiKey = keys.anthropicKey.trim();
30
+ if (!apiKey) return { kind: 'missing-key', provider: 'anthropic' };
31
+ return { kind: 'anthropic', model: modelId, apiKey };
32
+ }
33
+ if (source === 'openai') {
34
+ const apiKey = keys.openaiKey.trim();
35
+ if (!apiKey) return { kind: 'missing-key', provider: 'openai' };
36
+ return { kind: 'openai', model: modelId, apiKey };
37
+ }
38
+ return { kind: 'proxy', model: modelId };
39
+ }
@@ -47,9 +47,6 @@ test('registry free models match configured env list', async (t) => {
47
47
  }
48
48
 
49
49
  process.env.VITE_LLM_FREE_MODELS = configuredFreeModels.join(',');
50
- process.env.VITE_LLM_PRO_MODELS_LOW = '';
51
- process.env.VITE_LLM_PRO_MODELS_MEDIUM = '';
52
- process.env.VITE_LLM_PRO_MODELS_HIGH = '';
53
50
  process.env.VITE_LLM_IMAGE_MODELS = '';
54
51
  process.env.VITE_LLM_FILE_ATTACHMENT_MODELS = '';
55
52
 
@@ -63,9 +60,6 @@ test('registry free models match configured env list', async (t) => {
63
60
 
64
61
  test('model capabilities follow override env lists', async () => {
65
62
  process.env.VITE_LLM_FREE_MODELS = 'qwen/qwen3-coder,mistralai/devstral-2512';
66
- process.env.VITE_LLM_PRO_MODELS_LOW = '';
67
- process.env.VITE_LLM_PRO_MODELS_MEDIUM = '';
68
- process.env.VITE_LLM_PRO_MODELS_HIGH = '';
69
63
  process.env.VITE_LLM_IMAGE_MODELS = 'mistralai/devstral-2512';
70
64
  process.env.VITE_LLM_FILE_ATTACHMENT_MODELS = 'qwen/qwen3-coder';
71
65