@ifc-lite/viewer 1.17.4 → 1.17.6
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 +16 -16
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +117 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
- package/dist/assets/index-_bfZsDCC.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
- package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +7 -7
- package/src/App.tsx +16 -2
- package/src/components/viewer/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +195 -91
- package/src/components/viewer/MainToolbar.tsx +4 -3
- package/src/components/viewer/PropertiesPanel.tsx +16 -2
- package/src/components/viewer/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ViewerLayout.tsx +1 -0
- package/src/components/viewer/Viewport.tsx +14 -2
- package/src/components/viewer/ViewportContainer.tsx +49 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +1 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +484 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -0
- package/src/lib/llm/types.ts +14 -2
- package/src/main.tsx +1 -10
- package/src/services/api-keys.ts +73 -0
- package/src/store/constants.ts +20 -2
- package/src/store/index.ts +12 -5
- package/src/store/slices/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- package/src/store/slices/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -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
|
+
}
|
package/src/lib/geo/reproject.ts
CHANGED
|
@@ -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.
|
|
105
|
-
* 4.
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
199
|
-
* @param crs
|
|
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
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
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
|
-
|
|
275
|
-
const
|
|
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
|
-
//
|
|
337
|
-
const
|
|
338
|
-
const
|
|
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
|
|