@ifc-lite/viewer 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
@@ -3,9 +3,8 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * CesiumOverlay — renders a CesiumJS globe behind the WebGPU canvas,
7
- * providing real-world 3D context (terrain, buildings, imagery) for
8
- * georeferenced IFC models.
6
+ * CesiumOverlay — renders Google Photorealistic 3D Tiles behind the WebGPU
7
+ * canvas, providing real-world 3D context for georeferenced IFC models.
9
8
  *
10
9
  * Architecture:
11
10
  * - A separate <div> behind the WebGPU <canvas> (z-index layering)
@@ -26,8 +25,11 @@ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
26
25
  import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
27
26
  import { getGlobalRenderer } from '@/hooks/useBCF';
28
27
  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';
28
+ import {
29
+ computeCesiumPlacement,
30
+ shouldPreferOrthometricTerrain,
31
+ } from '@/lib/geo/cesium-placement';
32
+ import { getEffectiveHorizontalScale } from '@/lib/geo/geo-scale';
31
33
 
32
34
  // Lazy-loaded Cesium module and CSS
33
35
  let cesiumPromise: Promise<typeof import('cesium')> | null = null;
@@ -210,6 +212,7 @@ function buildModelMatrix(
210
212
 
211
213
  export interface CesiumOverlayProps {
212
214
  mapConversion?: MapConversion;
215
+ cameraMapConversion?: MapConversion;
213
216
  projectedCRS?: ProjectedCRS;
214
217
  coordinateInfo?: CoordinateInfo;
215
218
  geometryResult?: GeometryResult | null;
@@ -223,6 +226,7 @@ export interface CesiumOverlayProps {
223
226
 
224
227
  export function CesiumOverlay({
225
228
  mapConversion,
229
+ cameraMapConversion,
226
230
  projectedCRS,
227
231
  coordinateInfo,
228
232
  geometryResult,
@@ -232,6 +236,7 @@ export function CesiumOverlay({
232
236
  const containerRef = useRef<HTMLDivElement>(null);
233
237
  const viewerRef = useRef<InstanceType<typeof import('cesium').Viewer> | null>(null);
234
238
  const bridgeRef = useRef<CesiumBridge | null>(null);
239
+ const cameraBridgeRef = useRef<CesiumBridge | null>(null);
235
240
  const rafRef = useRef<number | null>(null);
236
241
  const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
237
242
  const [error, setError] = useState<string | null>(null);
@@ -242,9 +247,9 @@ export function CesiumOverlay({
242
247
  const dataSource = useViewerStore((s) => s.cesiumDataSource);
243
248
  const ionToken = useViewerStore((s) => s.cesiumIonToken);
244
249
  const terrainEnabled = useViewerStore((s) => s.cesiumTerrainEnabled);
245
- const terrainClamp = useViewerStore((s) => s.cesiumTerrainClamp);
246
- const terrainHeight = useViewerStore((s) => s.cesiumTerrainHeight);
250
+ const terrainClipY = useViewerStore((s) => s.cesiumTerrainClipY);
247
251
  const setCesiumTerrainHeight = useViewerStore((s) => s.setCesiumTerrainHeight);
252
+ const setCesiumTerrainSource = useViewerStore((s) => s.setCesiumTerrainSource);
248
253
  const setCesiumTerrainClipY = useViewerStore((s) => s.setCesiumTerrainClipY);
249
254
  const setCesiumGlbLoaded = useViewerStore((s) => s.setCesiumGlbLoaded);
250
255
 
@@ -341,18 +346,7 @@ export function CesiumOverlay({
341
346
  scene.globe.showGroundAtmosphere = false;
342
347
  scene.backgroundColor = Cesium.Color.TRANSPARENT;
343
348
  scene.globe.baseColor = Cesium.Color.TRANSPARENT;
344
-
345
- // Add imagery
346
- try {
347
- const imageryProvider = await Cesium.IonImageryProvider.fromAssetId(2);
348
- viewer.imageryLayers.addImageryProvider(imageryProvider);
349
- } catch {
350
- viewer.imageryLayers.addImageryProvider(
351
- new Cesium.OpenStreetMapImageryProvider({
352
- url: 'https://a.tile.openstreetmap.org/',
353
- })
354
- );
355
- }
349
+ scene.globe.show = false;
356
350
 
357
351
  // Add terrain
358
352
  if (terrainEnabled && ionToken) {
@@ -397,7 +391,8 @@ export function CesiumOverlay({
397
391
  }, [cesiumEnabled, ionToken, terrainEnabled, dataSource]);
398
392
 
399
393
  // ─── Effect 2: Build the coordinate bridge with terrain-aware placement ─
400
- // Precomputes the model placement (terrain-clamped if applicable) BEFORE
394
+ // Precomputes the model placement (floored to the visible surface when
395
+ // necessary) BEFORE
401
396
  // building the bridge that the GLB and camera will share. This way the
402
397
  // model loads into Cesium at its final altitude — no post-load shifting,
403
398
  // no camera/model frame divergence, no compensation gymnastics.
@@ -405,14 +400,15 @@ export function CesiumOverlay({
405
400
  // Sequence:
406
401
  // 1. Build a tentative bridge to recover the model's WGS84 lat/lon.
407
402
  // 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).
403
+ // 3. Auto-floor the model if its authored base sits below the visible surface.
409
404
  // 4. Rebuild the bridge with placementHeight baked into its enuToEcef
410
405
  // origin so model matrix and camera frame share a single altitude.
411
- // 5. Push terrain-derived state (height, clip Y, clamp toggle) and
406
+ // 5. Push terrain-derived state (height, clip Y) and
412
407
  // install the bridge.
413
408
  useEffect(() => {
414
409
  if (status !== 'ready' || !mapConversion || !projectedCRS) {
415
410
  bridgeRef.current = null;
411
+ cameraBridgeRef.current = null;
416
412
  prevPlacementRef.current = null;
417
413
  return;
418
414
  }
@@ -424,12 +420,15 @@ export function CesiumOverlay({
424
420
  const viewer = viewerRef.current;
425
421
  if (!Cesium || !viewer) return;
426
422
 
427
- const tentative = await createCesiumBridge(
428
- mapConversion, projectedCRS, coordinateInfo, lengthUnitScale,
423
+ const cameraConversion = cameraMapConversion ?? mapConversion;
424
+ const usesSeparateCameraBridge = cameraConversion !== mapConversion;
425
+ const cameraTentative = await createCesiumBridge(
426
+ cameraConversion, projectedCRS, coordinateInfo, lengthUnitScale,
429
427
  );
430
428
  if (cancelled) return;
431
- if (!tentative) {
429
+ if (!cameraTentative) {
432
430
  bridgeRef.current = null;
431
+ cameraBridgeRef.current = null;
433
432
  return;
434
433
  }
435
434
 
@@ -439,46 +438,81 @@ export function CesiumOverlay({
439
438
  // Google Photorealistic 3D Tiles (where there's no Cesium terrain
440
439
  // provider for getHeight to read). Cached per-session.
441
440
  const t0 = performance.now();
442
- let terrainH: number | null = null;
443
- try { terrainH = await tentative.queryTerrainHeight(Cesium, viewer); }
441
+ const preferOrthometricTerrain = shouldPreferOrthometricTerrain(projectedCRS);
442
+ let terrainSample = null;
443
+ try {
444
+ terrainSample = await cameraTentative.queryTerrainHeight(Cesium, viewer, {
445
+ cacheNamespace: [
446
+ terrainEnabled ? 'terrain' : 'ellipsoid',
447
+ dataSource,
448
+ preferOrthometricTerrain ? 'orthometric' : 'visual-surface',
449
+ ].join(':'),
450
+ preferOrthometric: preferOrthometricTerrain,
451
+ });
452
+ }
444
453
  catch (err) { console.warn('[CesiumOverlay] terrain query failed:', err); }
445
454
  if (cancelled) return;
446
455
  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;
456
+ const terrainH = terrainSample?.height ?? null;
457
+ const modelTentative = usesSeparateCameraBridge
458
+ ? await createCesiumBridge(mapConversion, projectedCRS, coordinateInfo, lengthUnitScale)
459
+ : cameraTentative;
460
+ if (cancelled) return;
461
+ if (!modelTentative) {
462
+ bridgeRef.current = null;
463
+ return;
464
+ }
465
+ let placement = computeCesiumPlacement({
466
+ coordinateInfo,
467
+ projectedCRS,
468
+ ifcOriginHeight: modelTentative.modelOrigin.height,
469
+ terrainHeight: terrainH,
470
+ storeyElevations,
471
+ });
472
+ const cameraPlacement = usesSeparateCameraBridge
473
+ ? computeCesiumPlacement({
474
+ coordinateInfo,
475
+ projectedCRS,
476
+ ifcOriginHeight: cameraTentative.modelOrigin.height,
477
+ terrainHeight: terrainH,
478
+ storeyElevations,
479
+ })
480
+ : placement;
481
+ if (usesSeparateCameraBridge) {
482
+ const mapScale = projectedCRS.mapUnitScale ?? lengthUnitScale;
483
+ const deltaHeightMeters = (
484
+ mapConversion.orthogonalHeight - cameraConversion.orthogonalHeight
485
+ ) * mapScale;
486
+ const floorPlacementHeight = terrainH !== null
487
+ ? terrainH + cameraPlacement.anchorOffset
488
+ : Number.NEGATIVE_INFINITY;
489
+ placement = {
490
+ ...placement,
491
+ placementHeight: Math.max(
492
+ cameraPlacement.placementHeight + deltaHeightMeters,
493
+ floorPlacementHeight,
494
+ ),
495
+ };
496
+ }
466
497
 
467
498
  console.debug(
468
499
  `[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`
500
+ + ` source=${terrainSample?.source ?? 'none'}`
501
+ + ` ref=${terrainSample?.reference ?? 'none'}`
502
+ + ` ifcOHeight=${placement.ifcOriginHeight.toFixed(2)}m`
503
+ + ` anchorY=${placement.clampAnchorY.toFixed(2)}m`
504
+ + ` (minY=${placement.minY.toFixed(2)}m, ${storeyElevations?.size ?? 0} storeys)`
505
+ + ` placement=${placement.placementHeight.toFixed(2)}m`
472
506
  + ` (terrain query: ${terrainMs.toFixed(0)}ms)`
473
507
  );
474
508
 
475
509
  // Build the final bridge with the placement baked in (or reuse the
476
510
  // tentative one when the placement matches its IFC-derived origin).
477
- let bridge = tentative;
478
- if (Math.abs(placementHeight - ifcOHeight) > 1e-6) {
511
+ let bridge = modelTentative;
512
+ if (Math.abs(placement.placementHeight - placement.ifcOriginHeight) > 1e-6) {
479
513
  const final = await createCesiumBridge(
480
514
  mapConversion, projectedCRS, coordinateInfo, lengthUnitScale,
481
- placementHeight,
515
+ placement.placementHeight,
482
516
  );
483
517
  if (cancelled) return;
484
518
  if (!final) {
@@ -488,35 +522,38 @@ export function CesiumOverlay({
488
522
  bridge = final;
489
523
  }
490
524
 
491
- if (terrainH !== null) {
525
+ if (terrainSample) {
492
526
  setCesiumTerrainHeight(terrainH);
527
+ setCesiumTerrainSource(
528
+ `${terrainSample.source}${terrainSample.reference === 'orthometric' ? ' (orthometric)' : ''}`,
529
+ );
493
530
  // 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);
531
+ // altitude expressed in the camera bridge's committed frame. Draft
532
+ // placement edits must not move this floor, or the camera will drift.
533
+ setCesiumTerrainClipY(cameraPlacement.terrainClipY);
500
534
  } else {
501
535
  // Failed re-query (offline, API down) — clear stale store fields so
502
536
  // the clip plane doesn't drift relative to the new bridge.
503
537
  setCesiumTerrainHeight(null);
538
+ setCesiumTerrainSource(null);
504
539
  setCesiumTerrainClipY(null);
505
540
  }
506
541
 
507
542
  // World-camera stability: when this rebuild changes the placement
508
- // altitude (clamp toggled, OrthogonalHeight edited), shift the IFC
543
+ // altitude (surface floor or OrthogonalHeight edited), shift the IFC
509
544
  // viewer-space camera Y by the inverse delta so the user's WORLD
510
545
  // camera ECEF position stays put. Without this, the entire frame
511
546
  // translates with the model and edits feel like the camera is
512
547
  // moving instead of the model — exactly what the user reported.
513
548
  const prevPlacement = prevPlacementRef.current;
514
- prevPlacementRef.current = placementHeight;
515
- if (prevPlacement !== null) {
516
- const dh = placementHeight - prevPlacement;
549
+ if (!usesSeparateCameraBridge) {
550
+ prevPlacementRef.current = placement.placementHeight;
551
+ }
552
+ if (!usesSeparateCameraBridge && prevPlacement !== null) {
553
+ const dh = placement.placementHeight - prevPlacement;
517
554
  // 5 cm threshold — rejects float jitter from cached terrain reads
518
555
  // re-flowing through the same effect, while a real placement edit
519
- // (clamp toggle, OrthogonalHeight change) is always far larger.
556
+ // is always far larger.
520
557
  if (Math.abs(dh) > 0.05) {
521
558
  const renderer = getGlobalRenderer();
522
559
  if (renderer) {
@@ -530,7 +567,18 @@ export function CesiumOverlay({
530
567
  }
531
568
  }
532
569
 
570
+ let cameraBridge = usesSeparateCameraBridge ? cameraTentative : bridge;
571
+ if (usesSeparateCameraBridge && Math.abs(cameraPlacement.placementHeight - cameraPlacement.ifcOriginHeight) > 1e-6) {
572
+ const finalCamera = await createCesiumBridge(
573
+ cameraConversion, projectedCRS, coordinateInfo, lengthUnitScale,
574
+ cameraPlacement.placementHeight,
575
+ );
576
+ if (cancelled) return;
577
+ cameraBridge = finalCamera ?? cameraTentative;
578
+ }
579
+
533
580
  bridgeRef.current = bridge;
581
+ cameraBridgeRef.current = cameraBridge;
534
582
  setBridgeVersion((v) => v + 1);
535
583
  })();
536
584
 
@@ -539,7 +587,20 @@ export function CesiumOverlay({
539
587
  // owns those (it destroys/recreates the viewer when they change), and
540
588
  // listing them here would cause a redundant bridge rebuild while the
541
589
  // viewer is being torn down.
542
- }, [status, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale, terrainClamp, storeyElevations]);
590
+ }, [
591
+ status,
592
+ mapConversion,
593
+ cameraMapConversion,
594
+ projectedCRS,
595
+ coordinateInfo,
596
+ lengthUnitScale,
597
+ terrainEnabled,
598
+ dataSource,
599
+ storeyElevations,
600
+ setCesiumTerrainHeight,
601
+ setCesiumTerrainSource,
602
+ setCesiumTerrainClipY,
603
+ ]);
543
604
 
544
605
  // ─── Effect 2c: Load GLB into Cesium (only when geometry changes) ───────
545
606
  // This is the heavy operation — only re-runs when geometry actually changes.
@@ -630,7 +691,7 @@ export function CesiumOverlay({
630
691
  }, [status, bridgeVersion, geometryResult]);
631
692
 
632
693
  // ─── Effect 2d: Update model matrix (instant, no reload) ────────────────
633
- // When terrain clamp, terrain height, or georef changes, just update the
694
+ // When terrain placement or georef changes, just update the
634
695
  // existing model's matrix — no GLB re-export, no flicker.
635
696
  useEffect(() => {
636
697
  const model = cesiumModelRef.current;
@@ -643,10 +704,8 @@ export function CesiumOverlay({
643
704
  model.modelMatrix = newMatrix;
644
705
  viewer.scene.requestRender();
645
706
  // 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.
707
+ // after async createCesiumBridge replaces it. Placement is baked into
708
+ // bridge.modelOrigin.height by Effect 2.
650
709
  }, [mapConversion, projectedCRS, coordinateInfo, lengthUnitScale, bridgeVersion]);
651
710
 
652
711
  // ─── Effect 3: Camera sync loop ─────────────────────────────────────────
@@ -662,23 +721,32 @@ export function CesiumOverlay({
662
721
  if (cancelled) return;
663
722
 
664
723
  const bridge = bridgeRef.current;
724
+ const cameraBridge = cameraBridgeRef.current ?? bridge;
665
725
  const renderer = getGlobalRenderer();
666
726
  const Cesium = cesiumModule;
667
- if (!viewer || !bridge || !renderer || !Cesium) {
727
+ if (!viewer || !bridge || !cameraBridge || !renderer || !Cesium) {
668
728
  rafRef.current = requestAnimationFrame(syncCamera);
669
729
  return;
670
730
  }
671
731
 
672
732
  const camera = renderer.getCamera();
673
- const camPos = camera.getPosition();
674
- const camTarget = camera.getTarget();
733
+ let camPos = camera.getPosition();
734
+ let camTarget = camera.getTarget();
675
735
  const camUp = camera.getUp();
676
736
  const fov = camera.getFOV();
677
737
 
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.
681
- bridge.syncCamera(Cesium, viewer, camPos, camTarget, camUp, fov);
738
+ if (terrainClipY !== null) {
739
+ const minCameraY = terrainClipY + 0.05;
740
+ if (camPos.y < minCameraY) {
741
+ const dy = minCameraY - camPos.y;
742
+ camPos = { ...camPos, y: minCameraY };
743
+ camTarget = { ...camTarget, y: camTarget.y + dy };
744
+ }
745
+ }
746
+
747
+ // bridge.modelOrigin.height already has the placement baked in, so the
748
+ // camera frame and the model matrix share the same enuToEcef origin altitude.
749
+ cameraBridge.syncCamera(Cesium, viewer, camPos, camTarget, camUp, fov);
682
750
 
683
751
  rafRef.current = requestAnimationFrame(syncCamera);
684
752
  }
@@ -692,7 +760,7 @@ export function CesiumOverlay({
692
760
  rafRef.current = null;
693
761
  }
694
762
  };
695
- }, [status]);
763
+ }, [status, terrainClipY]);
696
764
 
697
765
  if (!cesiumEnabled || !mapConversion || !projectedCRS) {
698
766
  return null;
@@ -721,7 +789,7 @@ export function CesiumOverlay({
721
789
  }
722
790
 
723
791
  /**
724
- * Add the selected 3D data source layer to the Cesium viewer.
792
+ * Add the Google Photorealistic 3D Tiles layer to the Cesium viewer.
725
793
  */
726
794
  async function addDataSourceLayer(
727
795
  Cesium: typeof import('cesium'),
@@ -731,16 +799,11 @@ async function addDataSourceLayer(
731
799
  ) {
732
800
  try {
733
801
  switch (dataSource) {
734
- case 'osm-buildings': {
735
- if (!ionToken) return;
736
- const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(96188);
737
- viewer.scene.primitives.add(tileset);
738
- break;
739
- }
740
- case 'google-photorealistic': {
802
+ case 'google-photorealistic':
803
+ default: {
741
804
  try {
742
805
  const tileset = await Cesium.createGooglePhotorealistic3DTileset();
743
- viewer.scene.primitives.add(tileset);
806
+ viewer.scene.primitives.add(tileset);
744
807
  } catch {
745
808
  if (ionToken) {
746
809
  const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(2275207);
@@ -749,10 +812,6 @@ async function addDataSourceLayer(
749
812
  }
750
813
  break;
751
814
  }
752
- case 'bing-aerial':
753
- default:
754
- // No 3D tileset for Bing — imagery is added separately via imageryLayers
755
- break;
756
815
  }
757
816
  } catch (err) {
758
817
  console.warn('[CesiumOverlay] Failed to add data source:', dataSource, err);