@ifc-lite/viewer 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +79 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +60 -15
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -26,6 +26,8 @@ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
|
|
|
26
26
|
import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
|
|
27
27
|
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
28
28
|
import { createCesiumBridge, type CesiumBridge } from '@/lib/geo/cesium-bridge';
|
|
29
|
+
import { findClampAnchorY } from '@/lib/geo/clamp-anchor';
|
|
30
|
+
import { getEffectiveHorizontalScale } from '@/lib/geo/effective-georef';
|
|
29
31
|
|
|
30
32
|
// Lazy-loaded Cesium module and CSS
|
|
31
33
|
let cesiumPromise: Promise<typeof import('cesium')> | null = null;
|
|
@@ -167,11 +169,16 @@ function buildModelMatrix(
|
|
|
167
169
|
Cesium: typeof import('cesium'),
|
|
168
170
|
bridge: CesiumBridge,
|
|
169
171
|
mapConversion: MapConversion | undefined,
|
|
172
|
+
projectedCRS: ProjectedCRS | undefined,
|
|
170
173
|
coordinateInfo: CoordinateInfo | undefined,
|
|
171
|
-
|
|
172
|
-
terrainH: number | null,
|
|
174
|
+
lengthUnitScale: number,
|
|
173
175
|
) {
|
|
174
|
-
|
|
176
|
+
// GLB vertices are in viewer-space metres (geometry engine converts during
|
|
177
|
+
// extraction). IfcMapConversion.Scale is defined per IFC spec relative to
|
|
178
|
+
// the project length unit, so applying it raw to metre-converted geometry
|
|
179
|
+
// double-scales the model — see issue #595. Use the effective scale.
|
|
180
|
+
const mapUnitScale = projectedCRS?.mapUnitScale ?? lengthUnitScale;
|
|
181
|
+
const hScale = getEffectiveHorizontalScale(mapConversion?.scale, mapUnitScale, lengthUnitScale);
|
|
175
182
|
const absc = mapConversion?.xAxisAbscissa ?? 1.0;
|
|
176
183
|
const ordi = mapConversion?.xAxisOrdinate ?? 0.0;
|
|
177
184
|
const bounds = coordinateInfo?.originalBounds;
|
|
@@ -180,15 +187,11 @@ function buildModelMatrix(
|
|
|
180
187
|
const mvy = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0;
|
|
181
188
|
const mvz = bounds ? (bounds.min.z + bounds.max.z) / 2 : 0;
|
|
182
189
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const bottomOffset = mvy - minY; // already in metres
|
|
187
|
-
placementHeight = terrainH + bottomOffset;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
+
// bridge.modelOrigin.height is the placement altitude — Effect 2 already
|
|
191
|
+
// baked terrain clamping (when applicable) into it before constructing the
|
|
192
|
+
// bridge, so there's no per-frame clamp adjustment to make here.
|
|
190
193
|
const origin = Cesium.Cartesian3.fromDegrees(
|
|
191
|
-
bridge.modelOrigin.longitude, bridge.modelOrigin.latitude,
|
|
194
|
+
bridge.modelOrigin.longitude, bridge.modelOrigin.latitude, bridge.modelOrigin.height,
|
|
192
195
|
);
|
|
193
196
|
const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
|
|
194
197
|
// No lengthUnitScale here — viewer-space GLB vertices are already in metres.
|
|
@@ -212,6 +215,10 @@ export interface CesiumOverlayProps {
|
|
|
212
215
|
geometryResult?: GeometryResult | null;
|
|
213
216
|
/** IFC project length unit → metres (e.g. 0.001 for mm models). Default 1. */
|
|
214
217
|
lengthUnitScale?: number;
|
|
218
|
+
/** IfcBuildingStorey elevations (express id → metres, viewer-Y aligned).
|
|
219
|
+
* Used to clamp the model's ground-floor storey to terrain instead of
|
|
220
|
+
* the lowest geometry vertex (which can be a basement or foundation). */
|
|
221
|
+
storeyElevations?: Map<number, number>;
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
export function CesiumOverlay({
|
|
@@ -220,6 +227,7 @@ export function CesiumOverlay({
|
|
|
220
227
|
coordinateInfo,
|
|
221
228
|
geometryResult,
|
|
222
229
|
lengthUnitScale = 1,
|
|
230
|
+
storeyElevations,
|
|
223
231
|
}: CesiumOverlayProps) {
|
|
224
232
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
225
233
|
const viewerRef = useRef<InstanceType<typeof import('cesium').Viewer> | null>(null);
|
|
@@ -235,25 +243,23 @@ export function CesiumOverlay({
|
|
|
235
243
|
const ionToken = useViewerStore((s) => s.cesiumIonToken);
|
|
236
244
|
const terrainEnabled = useViewerStore((s) => s.cesiumTerrainEnabled);
|
|
237
245
|
const terrainClamp = useViewerStore((s) => s.cesiumTerrainClamp);
|
|
238
|
-
const setCesiumTerrainClamp = useViewerStore((s) => s.setCesiumTerrainClamp);
|
|
239
246
|
const terrainHeight = useViewerStore((s) => s.cesiumTerrainHeight);
|
|
240
247
|
const setCesiumTerrainHeight = useViewerStore((s) => s.setCesiumTerrainHeight);
|
|
241
248
|
const setCesiumTerrainClipY = useViewerStore((s) => s.setCesiumTerrainClipY);
|
|
242
249
|
const setCesiumGlbLoaded = useViewerStore((s) => s.setCesiumGlbLoaded);
|
|
243
250
|
|
|
244
|
-
// Use refs so the camera sync loop always reads the latest values
|
|
245
|
-
const terrainClampRef = useRef(terrainClamp);
|
|
246
|
-
const terrainHeightRef = useRef(terrainHeight);
|
|
247
|
-
terrainClampRef.current = terrainClamp;
|
|
248
|
-
terrainHeightRef.current = terrainHeight;
|
|
249
|
-
|
|
250
|
-
// Track whether we've auto-clamped to terrain (only once, so user can still uncheck)
|
|
251
|
-
const autoClampedRef = useRef(false);
|
|
252
|
-
|
|
253
251
|
// Track the Cesium model (IFC geometry loaded as glTF for correct world positioning)
|
|
254
252
|
const cesiumModelRef = useRef<{ modelMatrix: any; destroy?: () => void } | null>(null);
|
|
255
253
|
const glbCacheRef = useRef<{ meshCount: number; glb: Uint8Array } | null>(null);
|
|
256
254
|
|
|
255
|
+
// Last-known placement altitude (in metres) used to keep the user's WORLD
|
|
256
|
+
// camera position stable across bridge rebuilds. When the user toggles the
|
|
257
|
+
// clamp or edits OrthogonalHeight, the model placement changes and the
|
|
258
|
+
// entire viewer→ECEF frame translates with it; we offset the IFC viewer's
|
|
259
|
+
// camera Y by the inverse so the user perceives the model moving instead
|
|
260
|
+
// of the camera being dragged along.
|
|
261
|
+
const prevPlacementRef = useRef<number | null>(null);
|
|
262
|
+
|
|
257
263
|
// ─── Effect 1: Create/destroy the Cesium viewer (heavy, rare) ───────────
|
|
258
264
|
// Only depends on cesiumEnabled, ionToken, terrainEnabled, dataSource.
|
|
259
265
|
// NOT on mapConversion/projectedCRS — those are handled by Effect 2.
|
|
@@ -296,10 +302,14 @@ export function CesiumOverlay({
|
|
|
296
302
|
|
|
297
303
|
if (cancelled) { viewer.destroy(); return; }
|
|
298
304
|
|
|
299
|
-
// Disable Cesium's user input — the IFC viewer
|
|
300
|
-
//
|
|
305
|
+
// Disable Cesium's user input — the IFC viewer drives the camera,
|
|
306
|
+
// and any input Cesium intercepts (even a stray wheel/touch event
|
|
307
|
+
// past pointer-events:none) interferes with our orbit/zoom and
|
|
308
|
+
// produces "stuck to terrain" symptoms. enableInputs is the
|
|
309
|
+
// master kill-switch; the per-mode flags below are belt-and-braces.
|
|
301
310
|
const scene = viewer.scene;
|
|
302
311
|
const sscc = scene.screenSpaceCameraController;
|
|
312
|
+
sscc.enableInputs = false;
|
|
303
313
|
sscc.enableRotate = false;
|
|
304
314
|
sscc.enableTranslate = false;
|
|
305
315
|
sscc.enableZoom = false;
|
|
@@ -386,122 +396,150 @@ export function CesiumOverlay({
|
|
|
386
396
|
};
|
|
387
397
|
}, [cesiumEnabled, ionToken, terrainEnabled, dataSource]);
|
|
388
398
|
|
|
389
|
-
// ─── Effect 2:
|
|
390
|
-
//
|
|
391
|
-
//
|
|
399
|
+
// ─── Effect 2: Build the coordinate bridge with terrain-aware placement ─
|
|
400
|
+
// Precomputes the model placement (terrain-clamped if applicable) BEFORE
|
|
401
|
+
// building the bridge that the GLB and camera will share. This way the
|
|
402
|
+
// model loads into Cesium at its final altitude — no post-load shifting,
|
|
403
|
+
// no camera/model frame divergence, no compensation gymnastics.
|
|
404
|
+
//
|
|
405
|
+
// Sequence:
|
|
406
|
+
// 1. Build a tentative bridge to recover the model's WGS84 lat/lon.
|
|
407
|
+
// 2. Query terrain at that lat/lon (sync first, async with retry next).
|
|
408
|
+
// 3. Decide whether to clamp (user toggle OR model authored below terrain).
|
|
409
|
+
// 4. Rebuild the bridge with placementHeight baked into its enuToEcef
|
|
410
|
+
// origin so model matrix and camera frame share a single altitude.
|
|
411
|
+
// 5. Push terrain-derived state (height, clip Y, clamp toggle) and
|
|
412
|
+
// install the bridge.
|
|
392
413
|
useEffect(() => {
|
|
393
414
|
if (status !== 'ready' || !mapConversion || !projectedCRS) {
|
|
394
415
|
bridgeRef.current = null;
|
|
416
|
+
prevPlacementRef.current = null;
|
|
395
417
|
return;
|
|
396
418
|
}
|
|
397
419
|
|
|
398
420
|
let cancelled = false;
|
|
399
421
|
|
|
400
422
|
(async () => {
|
|
401
|
-
const
|
|
402
|
-
|
|
423
|
+
const Cesium = cesiumModule;
|
|
424
|
+
const viewer = viewerRef.current;
|
|
425
|
+
if (!Cesium || !viewer) return;
|
|
403
426
|
|
|
404
|
-
|
|
427
|
+
const tentative = await createCesiumBridge(
|
|
428
|
+
mapConversion, projectedCRS, coordinateInfo, lengthUnitScale,
|
|
429
|
+
);
|
|
430
|
+
if (cancelled) return;
|
|
431
|
+
if (!tentative) {
|
|
405
432
|
bridgeRef.current = null;
|
|
406
433
|
return;
|
|
407
434
|
}
|
|
408
435
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
436
|
+
// Query terrain at the model's location. queryTerrainHeight tries
|
|
437
|
+
// Cesium's sync sources first (globe.getHeight, scene.sampleHeight),
|
|
438
|
+
// then Open-Meteo as a fast network fallback that works even with
|
|
439
|
+
// Google Photorealistic 3D Tiles (where there's no Cesium terrain
|
|
440
|
+
// provider for getHeight to read). Cached per-session.
|
|
441
|
+
const t0 = performance.now();
|
|
442
|
+
let terrainH: number | null = null;
|
|
443
|
+
try { terrainH = await tentative.queryTerrainHeight(Cesium, viewer); }
|
|
444
|
+
catch (err) { console.warn('[CesiumOverlay] terrain query failed:', err); }
|
|
445
|
+
if (cancelled) return;
|
|
446
|
+
const terrainMs = performance.now() - t0;
|
|
447
|
+
|
|
448
|
+
const bounds = coordinateInfo?.originalBounds;
|
|
449
|
+
const mvy = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0;
|
|
450
|
+
const minY = bounds?.min.y ?? 0;
|
|
451
|
+
// Clamp anchor: viewer-Y of the storey nearest elevation 0 (typical
|
|
452
|
+
// ground floor), falling back to bounds.min.y. Without this, basements
|
|
453
|
+
// and foundations drag the model deep below terrain.
|
|
454
|
+
const clampAnchorY = findClampAnchorY(bounds, storeyElevations);
|
|
455
|
+
const anchorOffset = mvy - clampAnchorY;
|
|
456
|
+
const ifcOHeight = tentative.modelOrigin.height;
|
|
457
|
+
// Clamp is purely the user's choice. We do NOT auto-clamp on top of
|
|
458
|
+
// the user's setting — that would reactivate the toggle the moment
|
|
459
|
+
// the user disables it (since terrain is almost always above sea-level
|
|
460
|
+
// OrthogonalHeights, the auto condition would re-fire forever and the
|
|
461
|
+
// checkbox becomes un-uncheckable).
|
|
462
|
+
const placementHeight =
|
|
463
|
+
terrainClamp && terrainH !== null
|
|
464
|
+
? terrainH + anchorOffset
|
|
465
|
+
: ifcOHeight;
|
|
466
|
+
|
|
467
|
+
console.debug(
|
|
468
|
+
`[CesiumOverlay] placement decision: terrain=${terrainH?.toFixed(2) ?? 'null'}m`
|
|
469
|
+
+ ` ifcOHeight=${ifcOHeight.toFixed(2)}m anchorY=${clampAnchorY.toFixed(2)}m`
|
|
470
|
+
+ ` (minY=${minY.toFixed(2)}m, ${storeyElevations?.size ?? 0} storeys)`
|
|
471
|
+
+ ` clamp=${terrainClamp} placement=${placementHeight.toFixed(2)}m`
|
|
472
|
+
+ ` (terrain query: ${terrainMs.toFixed(0)}ms)`
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
// Build the final bridge with the placement baked in (or reuse the
|
|
476
|
+
// tentative one when the placement matches its IFC-derived origin).
|
|
477
|
+
let bridge = tentative;
|
|
478
|
+
if (Math.abs(placementHeight - ifcOHeight) > 1e-6) {
|
|
479
|
+
const final = await createCesiumBridge(
|
|
480
|
+
mapConversion, projectedCRS, coordinateInfo, lengthUnitScale,
|
|
481
|
+
placementHeight,
|
|
424
482
|
);
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// can return undefined. Use flyTo which handles terrain automatically,
|
|
430
|
-
// targeting a safe altitude above the model origin.
|
|
431
|
-
const safeHeight = Math.max(modelOrigin.height, 100);
|
|
432
|
-
viewer.camera.flyTo({
|
|
433
|
-
destination: Cesium.Cartesian3.fromDegrees(
|
|
434
|
-
modelOrigin.longitude, modelOrigin.latitude, safeHeight + 500,
|
|
435
|
-
),
|
|
436
|
-
orientation: {
|
|
437
|
-
heading: 0,
|
|
438
|
-
pitch: Cesium.Math.toRadians(-45),
|
|
439
|
-
roll: 0,
|
|
440
|
-
},
|
|
441
|
-
duration: 0, // instant
|
|
442
|
-
});
|
|
443
|
-
} else if (prevBridge) {
|
|
444
|
-
// Georef edit: just re-render, the camera sync loop will pick
|
|
445
|
-
// up the new bridge on the next frame. No dramatic fly animation.
|
|
446
|
-
viewer.scene.requestRender();
|
|
483
|
+
if (cancelled) return;
|
|
484
|
+
if (!final) {
|
|
485
|
+
bridgeRef.current = null;
|
|
486
|
+
return;
|
|
447
487
|
}
|
|
488
|
+
bridge = final;
|
|
448
489
|
}
|
|
449
|
-
})();
|
|
450
|
-
|
|
451
|
-
return () => { cancelled = true; };
|
|
452
|
-
}, [status, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale]);
|
|
453
|
-
|
|
454
|
-
// ─── Effect 2b: Query terrain height when bridge is ready ───────────────
|
|
455
|
-
// Also re-queries when terrainClamp is toggled on (in case first query failed)
|
|
456
|
-
useEffect(() => {
|
|
457
|
-
if (status !== 'ready') return;
|
|
458
|
-
const bridge = bridgeRef.current;
|
|
459
|
-
const viewer = viewerRef.current;
|
|
460
|
-
const Cesium = cesiumModule;
|
|
461
|
-
if (!bridge || !viewer || !Cesium) return;
|
|
462
490
|
|
|
463
|
-
|
|
491
|
+
if (terrainH !== null) {
|
|
492
|
+
setCesiumTerrainHeight(terrainH);
|
|
493
|
+
// terrainClipY stays in viewer-space; it represents the world terrain
|
|
494
|
+
// altitude expressed in the bridge's frame so a clip plane at that Y
|
|
495
|
+
// matches the terrain surface. Use the clamp anchor (ground floor)
|
|
496
|
+
// rather than minY so the clip plane matches the user's ground level
|
|
497
|
+
// rather than the basement floor.
|
|
498
|
+
const terrainClipY = clampAnchorY + (terrainH - ifcOHeight);
|
|
499
|
+
setCesiumTerrainClipY(terrainClipY);
|
|
500
|
+
} else {
|
|
501
|
+
// Failed re-query (offline, API down) — clear stale store fields so
|
|
502
|
+
// the clip plane doesn't drift relative to the new bridge.
|
|
503
|
+
setCesiumTerrainHeight(null);
|
|
504
|
+
setCesiumTerrainClipY(null);
|
|
505
|
+
}
|
|
464
506
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
destination: Cesium.Cartesian3.fromDegrees(
|
|
488
|
-
bridge.modelOrigin.longitude, bridge.modelOrigin.latitude, h + 500,
|
|
489
|
-
),
|
|
490
|
-
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 },
|
|
491
|
-
duration: 0,
|
|
492
|
-
});
|
|
507
|
+
// World-camera stability: when this rebuild changes the placement
|
|
508
|
+
// altitude (clamp toggled, OrthogonalHeight edited), shift the IFC
|
|
509
|
+
// viewer-space camera Y by the inverse delta so the user's WORLD
|
|
510
|
+
// camera ECEF position stays put. Without this, the entire frame
|
|
511
|
+
// translates with the model and edits feel like the camera is
|
|
512
|
+
// moving instead of the model — exactly what the user reported.
|
|
513
|
+
const prevPlacement = prevPlacementRef.current;
|
|
514
|
+
prevPlacementRef.current = placementHeight;
|
|
515
|
+
if (prevPlacement !== null) {
|
|
516
|
+
const dh = placementHeight - prevPlacement;
|
|
517
|
+
// 5 cm threshold — rejects float jitter from cached terrain reads
|
|
518
|
+
// re-flowing through the same effect, while a real placement edit
|
|
519
|
+
// (clamp toggle, OrthogonalHeight change) is always far larger.
|
|
520
|
+
if (Math.abs(dh) > 0.05) {
|
|
521
|
+
const renderer = getGlobalRenderer();
|
|
522
|
+
if (renderer) {
|
|
523
|
+
const cam = renderer.getCamera();
|
|
524
|
+
const pos = cam.getPosition();
|
|
525
|
+
cam.setPosition(pos.x, pos.y - dh, pos.z);
|
|
526
|
+
console.debug(
|
|
527
|
+
`[CesiumOverlay] placement Δh=${dh.toFixed(2)}m → shifted IFC camera Y by ${(-dh).toFixed(2)}m to hold world camera`,
|
|
528
|
+
);
|
|
493
529
|
}
|
|
494
530
|
}
|
|
495
|
-
}
|
|
496
|
-
};
|
|
531
|
+
}
|
|
497
532
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const retryTimer = setTimeout(doQuery, 5000);
|
|
533
|
+
bridgeRef.current = bridge;
|
|
534
|
+
setBridgeVersion((v) => v + 1);
|
|
535
|
+
})();
|
|
502
536
|
|
|
503
|
-
return () => { cancelled = true;
|
|
504
|
-
|
|
537
|
+
return () => { cancelled = true; };
|
|
538
|
+
// terrainEnabled and ionToken intentionally omitted — Effect 1 already
|
|
539
|
+
// owns those (it destroys/recreates the viewer when they change), and
|
|
540
|
+
// listing them here would cause a redundant bridge rebuild while the
|
|
541
|
+
// viewer is being torn down.
|
|
542
|
+
}, [status, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale, terrainClamp, storeyElevations]);
|
|
505
543
|
|
|
506
544
|
// ─── Effect 2c: Load GLB into Cesium (only when geometry changes) ───────
|
|
507
545
|
// This is the heavy operation — only re-runs when geometry actually changes.
|
|
@@ -545,7 +583,7 @@ export function CesiumOverlay({
|
|
|
545
583
|
if (cancelled) return;
|
|
546
584
|
|
|
547
585
|
// Build initial model matrix
|
|
548
|
-
const modelMatrix = buildModelMatrix(Cesium, bridge, mapConversion,
|
|
586
|
+
const modelMatrix = buildModelMatrix(Cesium, bridge, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale);
|
|
549
587
|
|
|
550
588
|
const blob = new Blob([glbBytes as BlobPart], { type: 'model/gltf-binary' });
|
|
551
589
|
const glbUrl = URL.createObjectURL(blob);
|
|
@@ -601,10 +639,15 @@ export function CesiumOverlay({
|
|
|
601
639
|
const Cesium = cesiumModule;
|
|
602
640
|
if (!model || !bridge || !viewer || !Cesium) return;
|
|
603
641
|
|
|
604
|
-
const newMatrix = buildModelMatrix(Cesium, bridge, mapConversion,
|
|
642
|
+
const newMatrix = buildModelMatrix(Cesium, bridge, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale);
|
|
605
643
|
model.modelMatrix = newMatrix;
|
|
606
644
|
viewer.scene.requestRender();
|
|
607
|
-
|
|
645
|
+
// Depend on bridgeVersion so the matrix is rebuilt with the *new* bridge
|
|
646
|
+
// after async createCesiumBridge replaces it. Placement (terrain clamp)
|
|
647
|
+
// is now baked into bridge.modelOrigin.height by Effect 2, so terrain
|
|
648
|
+
// clamp/height changes drive a bridge rebuild instead of a per-frame
|
|
649
|
+
// matrix recomputation here.
|
|
650
|
+
}, [mapConversion, projectedCRS, coordinateInfo, lengthUnitScale, bridgeVersion]);
|
|
608
651
|
|
|
609
652
|
// ─── Effect 3: Camera sync loop ─────────────────────────────────────────
|
|
610
653
|
useEffect(() => {
|
|
@@ -632,7 +675,9 @@ export function CesiumOverlay({
|
|
|
632
675
|
const camUp = camera.getUp();
|
|
633
676
|
const fov = camera.getFOV();
|
|
634
677
|
|
|
635
|
-
//
|
|
678
|
+
// bridge.modelOrigin.height already has the placement baked in (terrain
|
|
679
|
+
// clamp resolved at bridge creation by Effect 2), so the camera frame
|
|
680
|
+
// and the model matrix share the same enuToEcef origin altitude.
|
|
636
681
|
bridge.syncCamera(Cesium, viewer, camPos, camTarget, camUp, fov);
|
|
637
682
|
|
|
638
683
|
rafRef.current = requestAnimationFrame(syncCamera);
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
* BIM ↔ scan deviation heatmap controls.
|
|
7
|
+
*
|
|
8
|
+
* Renders a "Compute Deviation" button when the scene has at least
|
|
9
|
+
* one mesh and one point cloud. Once compute completes, exposes a
|
|
10
|
+
* range slider + diverging-ramp legend; the splat shader's
|
|
11
|
+
* deviation colour mode then visualises signed distance to the
|
|
12
|
+
* nearest mesh surface.
|
|
13
|
+
*
|
|
14
|
+
* Lives inside the `PointCloudPanel`; rendered conditionally on
|
|
15
|
+
* `pointCloudAssetCount > 0`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useState } from 'react';
|
|
19
|
+
import { useViewerStore } from '@/store';
|
|
20
|
+
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
21
|
+
import { cn } from '@/lib/utils';
|
|
22
|
+
|
|
23
|
+
export interface DeviationPanelProps {
|
|
24
|
+
/** Total number of triangles currently in the scene — gates the
|
|
25
|
+
* compute button on the existence of a BIM model. */
|
|
26
|
+
triangleCount: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function DeviationPanel({ triangleCount }: DeviationPanelProps) {
|
|
30
|
+
const halfRange = useViewerStore((s) => s.pointCloudDeviationHalfRange);
|
|
31
|
+
const setHalfRange = useViewerStore((s) => s.setPointCloudDeviationHalfRange);
|
|
32
|
+
const computed = useViewerStore((s) => s.pointCloudDeviationComputed);
|
|
33
|
+
const setComputed = useViewerStore((s) => s.setPointCloudDeviationComputed);
|
|
34
|
+
const colorMode = useViewerStore((s) => s.pointCloudColorMode);
|
|
35
|
+
const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
|
|
36
|
+
|
|
37
|
+
const [running, setRunning] = useState(false);
|
|
38
|
+
const [stats, setStats] = useState<{
|
|
39
|
+
triangles: number;
|
|
40
|
+
points: number;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
} | null>(null);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const handleCompute = useCallback(async () => {
|
|
46
|
+
const renderer = getGlobalRenderer();
|
|
47
|
+
if (!renderer) {
|
|
48
|
+
setError('Renderer not initialised yet.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setError(null);
|
|
52
|
+
setRunning(true);
|
|
53
|
+
const t0 = performance.now();
|
|
54
|
+
try {
|
|
55
|
+
const result = await renderer.computeDeviations({ maxRange: 1.0 });
|
|
56
|
+
const dt = performance.now() - t0;
|
|
57
|
+
if (result.pointsProcessed === 0) {
|
|
58
|
+
setError('No points processed — load a point cloud first.');
|
|
59
|
+
setRunning(false);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (result.bvhTriangles === 0) {
|
|
63
|
+
setError('No mesh geometry in the scene — load an IFC first.');
|
|
64
|
+
setRunning(false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setStats({
|
|
68
|
+
triangles: result.bvhTriangles,
|
|
69
|
+
points: result.pointsProcessed,
|
|
70
|
+
durationMs: dt,
|
|
71
|
+
});
|
|
72
|
+
setComputed(true);
|
|
73
|
+
// Default-pick a sensible half-range from the BVH's bbox if the
|
|
74
|
+
// user hasn't touched the slider yet (initial 5 cm is fine for
|
|
75
|
+
// small models but useless for a city-block scan).
|
|
76
|
+
if (halfRange === 0.05 && result.suggestedHalfRange !== 0.05) {
|
|
77
|
+
setHalfRange(result.suggestedHalfRange);
|
|
78
|
+
}
|
|
79
|
+
// Auto-switch the colour mode to deviation so the user sees
|
|
80
|
+
// the result immediately.
|
|
81
|
+
setColorMode('deviation');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
84
|
+
} finally {
|
|
85
|
+
setRunning(false);
|
|
86
|
+
}
|
|
87
|
+
}, [halfRange, setHalfRange, setColorMode, setComputed]);
|
|
88
|
+
|
|
89
|
+
// Hide the panel entirely when there's no BIM to compare against.
|
|
90
|
+
// Point-cloud-only sessions (just a LAS / IFCx scan) have nothing
|
|
91
|
+
// to deviate from so the button would always fail.
|
|
92
|
+
if (triangleCount === 0) return null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-1 mt-1 pt-1 border-t border-border/40">
|
|
96
|
+
<span className="text-[9px] uppercase text-muted-foreground tracking-wider">
|
|
97
|
+
Deviation (BIM ↔ scan)
|
|
98
|
+
</span>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={handleCompute}
|
|
102
|
+
disabled={running}
|
|
103
|
+
className={cn(
|
|
104
|
+
'text-xs px-2 py-1 rounded transition-colors',
|
|
105
|
+
running
|
|
106
|
+
? 'bg-muted text-muted-foreground'
|
|
107
|
+
: 'bg-teal-600 text-white hover:bg-teal-500 disabled:opacity-50',
|
|
108
|
+
)}
|
|
109
|
+
title={`Build BVH from ${triangleCount.toLocaleString()} triangles, then signed-distance every loaded point against the nearest surface`}
|
|
110
|
+
>
|
|
111
|
+
{running ? 'Computing…' : computed ? 'Recompute' : 'Compute deviation'}
|
|
112
|
+
</button>
|
|
113
|
+
{error && (
|
|
114
|
+
<span className="text-[10px] text-destructive">{error}</span>
|
|
115
|
+
)}
|
|
116
|
+
{stats && (
|
|
117
|
+
<div className="text-[10px] text-muted-foreground">
|
|
118
|
+
{stats.points.toLocaleString()} pts vs.{' '}
|
|
119
|
+
{stats.triangles.toLocaleString()} tris in{' '}
|
|
120
|
+
{Math.round(stats.durationMs)} ms
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{computed && (
|
|
125
|
+
<>
|
|
126
|
+
{/* Range slider: half-width in mm. Range from 1 mm to 1 m
|
|
127
|
+
(logarithmic feel via the millimetre conversion). */}
|
|
128
|
+
<label className="flex items-center gap-2 mt-1">
|
|
129
|
+
<span className="text-[10px] text-muted-foreground w-12 shrink-0">
|
|
130
|
+
±{(halfRange * 1000).toFixed(halfRange < 0.01 ? 1 : 0)}mm
|
|
131
|
+
</span>
|
|
132
|
+
<input
|
|
133
|
+
type="range"
|
|
134
|
+
min={1}
|
|
135
|
+
max={1000}
|
|
136
|
+
step={1}
|
|
137
|
+
value={Math.round(halfRange * 1000)}
|
|
138
|
+
onChange={(e) => setHalfRange(Number(e.target.value) / 1000)}
|
|
139
|
+
className="flex-1 h-1 accent-teal-600 cursor-pointer"
|
|
140
|
+
title="Deviation half-range in millimetres — values past ±this map to the ramp endpoints"
|
|
141
|
+
aria-label="Deviation range half-width"
|
|
142
|
+
/>
|
|
143
|
+
</label>
|
|
144
|
+
|
|
145
|
+
{/* Legend: blue → white → red gradient with labelled endpoints. */}
|
|
146
|
+
<div
|
|
147
|
+
className="h-2 rounded-sm border border-foreground/10 mt-0.5"
|
|
148
|
+
style={{
|
|
149
|
+
background: 'linear-gradient(to right, rgb(26,77,217), rgb(242,242,242), rgb(217,51,26))',
|
|
150
|
+
}}
|
|
151
|
+
aria-label="Deviation ramp from negative (blue) to positive (red)"
|
|
152
|
+
/>
|
|
153
|
+
<div className="flex justify-between text-[9px] text-muted-foreground">
|
|
154
|
+
<span>−{(halfRange * 1000).toFixed(0)}mm (inside)</span>
|
|
155
|
+
<span>0</span>
|
|
156
|
+
<span>+{(halfRange * 1000).toFixed(0)}mm (outside)</span>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{colorMode !== 'deviation' && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => setColorMode('deviation')}
|
|
163
|
+
className="text-[10px] text-teal-600 hover:text-teal-500 underline text-left mt-0.5"
|
|
164
|
+
>
|
|
165
|
+
Switch colour mode to Deviation
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -126,13 +126,39 @@ export function HierarchyPanel() {
|
|
|
126
126
|
groupingMode,
|
|
127
127
|
setGroupingMode,
|
|
128
128
|
unifiedStoreys,
|
|
129
|
-
filteredNodes,
|
|
130
|
-
storeysNodes,
|
|
131
|
-
modelsNodes,
|
|
129
|
+
filteredNodes: rawFilteredNodes,
|
|
130
|
+
storeysNodes: rawStoreysNodes,
|
|
131
|
+
modelsNodes: rawModelsNodes,
|
|
132
132
|
toggleExpand,
|
|
133
133
|
getNodeElements,
|
|
134
134
|
} = useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryResult });
|
|
135
135
|
|
|
136
|
+
// Issue #540: when the user has the merge-layers load setting on,
|
|
137
|
+
// hide `IfcBuildingElementPart` rows from the tree — the Rust layer
|
|
138
|
+
// suppresses their meshes, so leaving the rows visible would lead
|
|
139
|
+
// to dead-clicks. Filter at the consumer (this panel) rather than
|
|
140
|
+
// in `spatialHierarchy.ts` per the agent coordination plan.
|
|
141
|
+
const mergeLayersHidesParts = useViewerStore((s) => s.mergeLayers);
|
|
142
|
+
const PART_TYPE_KEY = 'ifcbuildingelementpart';
|
|
143
|
+
const stripPartNodes = useCallback(
|
|
144
|
+
(nodes: TreeNode[]): TreeNode[] => {
|
|
145
|
+
if (!mergeLayersHidesParts) return nodes;
|
|
146
|
+
return nodes.filter((node) => {
|
|
147
|
+
// Only element rows carry an `ifcType` we can compare. Class
|
|
148
|
+
// grouping ("IfcBuildingElementPart (N)") and ifc-type nodes
|
|
149
|
+
// also expose an `ifcType`; we strip those too because they
|
|
150
|
+
// would expand to empty groups after merge.
|
|
151
|
+
const t = node.ifcType?.toLowerCase();
|
|
152
|
+
if (!t) return true;
|
|
153
|
+
return t !== PART_TYPE_KEY;
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
[mergeLayersHidesParts],
|
|
157
|
+
);
|
|
158
|
+
const filteredNodes = useMemo(() => stripPartNodes(rawFilteredNodes), [stripPartNodes, rawFilteredNodes]);
|
|
159
|
+
const storeysNodes = useMemo(() => stripPartNodes(rawStoreysNodes), [stripPartNodes, rawStoreysNodes]);
|
|
160
|
+
const modelsNodes = useMemo(() => stripPartNodes(rawModelsNodes), [stripPartNodes, rawModelsNodes]);
|
|
161
|
+
|
|
136
162
|
// Refs for both scroll areas
|
|
137
163
|
const storeysRef = useRef<HTMLDivElement>(null);
|
|
138
164
|
const modelsRef = useRef<HTMLDivElement>(null);
|
|
@@ -77,6 +77,11 @@ export function HoverTooltip() {
|
|
|
77
77
|
<div className="text-xs text-muted-foreground">
|
|
78
78
|
#{hoverState.entityId}
|
|
79
79
|
</div>
|
|
80
|
+
{hoverState.worldXYZ && (
|
|
81
|
+
<div className="text-[10px] font-mono text-muted-foreground/80 mt-0.5">
|
|
82
|
+
{hoverState.worldXYZ.x.toFixed(2)}, {hoverState.worldXYZ.y.toFixed(2)}, {hoverState.worldXYZ.z.toFixed(2)}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
80
85
|
</div>
|
|
81
86
|
);
|
|
82
87
|
}
|