@ifc-lite/viewer 1.19.1 → 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.
Files changed (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -1,5 +1,5 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-D8Epw-e7.js","assets/cesium-DUOzBlqv.js","assets/arrow-CZ5kQ26f.js","assets/cesium-ADbP7waU.css","assets/exporters-xbXqEDlO.js","assets/bcf-4K724hw0.js","assets/zip-DBEtpeu6.js","assets/drawing-2d-DoxKMqbO.js","assets/lens-CSASnhAL.js","assets/server-client-LoWPK1N2.js","assets/ids-2WdONLlu.js","assets/three-CDRZThFA.js","assets/index-BXeEKqJG.css","assets/browser-C5TFR7sH.js"])))=>i.map(i=>d[i]);
2
- import { P as _, Q as qe, E as Ge, g as hn, e as pn, i as vt, n as Nt, _ as Re, __tla as __tla_0 } from "./exporters-xbXqEDlO.js";
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-DVNSvEMh.js","assets/cesium-DUOzBlqv.js","assets/arrow-CZ5kQ26f.js","assets/cesium-ADbP7waU.css","assets/exporters-u0sz2Upj.js","assets/bcf-4K724hw0.js","assets/zip-DBEtpeu6.js","assets/drawing-2d-Bjy8YPrg.js","assets/lens-CSASnhAL.js","assets/server-client-DP8fMPY9.js","assets/ids-B7AXEv7h.js","assets/three-CDRZThFA.js","assets/index-CSWgTe1s.css","assets/browser-C5TFR7sH.js"])))=>i.map(i=>d[i]);
2
+ import { P as _, Q as qe, E as Ge, q as hn, r as pn, s as vt, t as Nt, _ as Re, __tla as __tla_0 } from "./exporters-u0sz2Upj.js";
3
3
  import { g as Q } from "./bcf-4K724hw0.js";
4
4
  import { h as ut, B as fn } from "./lens-CSASnhAL.js";
5
5
  let Wi, Hi, Mi, Jt, gi, _i, Ki, Ji, Vi, zi, An, Qi, Xi, qi, Ui, ji, Gi, vn, Bi, Zi, er, Ts, tr, Yi, bi, Si, nr;
@@ -1218,7 +1218,7 @@ let __tla = Promise.all([
1218
1218
  t.Elevation
1219
1219
  ]
1220
1220
  });
1221
- return this.line(e, "IFCBUILDINGSTOREY", `'${s}',#${this.ownerHistoryId},'${y(i)}',${r},$,$,#${a},$,.ELEMENT.,${o}`), this.storeyIds.push(e), this.storeyElements.set(e, []), this.storeyPlacements.set(e, a), this.entities.push({
1221
+ return this.line(e, "IFCBUILDINGSTOREY", `'${s}',#${this.ownerHistoryId},'${y(i)}',${r},$,#${a},$,$,.ELEMENT.,${o}`), this.storeyIds.push(e), this.storeyElements.set(e, []), this.storeyPlacements.set(e, a), this.entities.push({
1222
1222
  expressId: e,
1223
1223
  type: "IfcBuildingStorey",
1224
1224
  Name: i
@@ -9472,7 +9472,7 @@ Attempted to suspend at:`);
9472
9472
  let t = Je(await n), [e, s, { QuickJSWASMModule: i }] = await Promise.all([
9473
9473
  t.importModuleLoader().then(Je),
9474
9474
  t.importFFI(),
9475
- Re(()=>import("./index-D8Epw-e7.js").then(async (m)=>{
9475
+ Re(()=>import("./index-DVNSvEMh.js").then(async (m)=>{
9476
9476
  await m.__tla;
9477
9477
  return m;
9478
9478
  }).then((a)=>a.m), __vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12])).then(Je)
@@ -1,4 +1,4 @@
1
- import { _ as de, __tla as __tla_0 } from "./exporters-xbXqEDlO.js";
1
+ import { _ as de, __tla as __tla_0 } from "./exporters-u0sz2Upj.js";
2
2
  let We, je;
3
3
  let __tla = Promise.all([
4
4
  (()=>{
@@ -1 +1 @@
1
- import{I as f,a as m}from"./exporters-xbXqEDlO.js";import"./bcf-4K724hw0.js";import"./zip-DBEtpeu6.js";import"./cesium-DUOzBlqv.js";import"./arrow-CZ5kQ26f.js";class b{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}toIfcContent(e){return typeof e=="string"?e:new TextDecoder().decode(e)}async processGeometry(e){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),this.toIfcContent(e)),r=i.collectMeshes(),s=i.getBuildingRotation();performance.now();let o=0,n=0;for(const a of r)o+=a.positions.length/3,n+=a.indices.length/3;return{meshes:r,totalVertices:o,totalTriangles:n,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:s}}}async processGeometryStreaming(e,i){this.initialized||await this.init();const r=performance.now(),s=new m(this.bridge.getApi(),this.toIfcContent(e));let o=0,n=0,c=0;try{for await(const t of s.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;o+=l.length;for(const g of l)n+=g.positions.length/3,c+=g.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:o,total:o,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const d=performance.now()-r,h={totalMeshes:o,totalVertices:n,totalTriangles:c,parseTimeMs:d*.3,geometryTimeMs:d*.7};return i.onComplete?.(h),h}getApi(){return this.bridge.getApi()}}export{b as WasmBridge};
1
+ import{I as f,u as m}from"./exporters-u0sz2Upj.js";import"./bcf-4K724hw0.js";import"./zip-DBEtpeu6.js";import"./cesium-DUOzBlqv.js";import"./arrow-CZ5kQ26f.js";class b{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}toIfcContent(e){return typeof e=="string"?e:new TextDecoder().decode(e)}async processGeometry(e){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),this.toIfcContent(e)),r=i.collectMeshes(),s=i.getBuildingRotation();performance.now();let o=0,n=0;for(const a of r)o+=a.positions.length/3,n+=a.indices.length/3;return{meshes:r,totalVertices:o,totalTriangles:n,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:s}}}async processGeometryStreaming(e,i){this.initialized||await this.init();const r=performance.now(),s=new m(this.bridge.getApi(),this.toIfcContent(e));let o=0,n=0,c=0;try{for await(const t of s.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;o+=l.length;for(const g of l)n+=g.positions.length/3,c+=g.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:o,total:o,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const d=performance.now()-r,h={totalMeshes:o,totalVertices:n,totalTriangles:c,parseTimeMs:d*.3,geometryTimeMs:d*.7};return i.onComplete?.(h),h}getApi(){return this.bridge.getApi()}}export{b as WasmBridge};
@@ -0,0 +1,36 @@
1
+ let a;
2
+ let __tla = (async ()=>{
3
+ function o(r, t) {
4
+ return new Promise((e)=>{
5
+ r.addEventListener("message", function s({ data: n }) {
6
+ n?.type === t && (r.removeEventListener("message", s), e(n));
7
+ });
8
+ });
9
+ }
10
+ o(self, "wasm_bindgen_worker_init").then(async ({ init: r, receiver: t })=>{
11
+ const e = await import("./ifc-lite-DfZHk36-.js");
12
+ await e.default(r), postMessage({
13
+ type: "wasm_bindgen_worker_ready"
14
+ }), e.wbg_rayon_start_worker(t);
15
+ });
16
+ a = async function(r, t, e) {
17
+ if (e.numThreads() === 0) throw new Error("num_threads must be > 0.");
18
+ const s = {
19
+ type: "wasm_bindgen_worker_init",
20
+ init: {
21
+ module_or_path: r,
22
+ memory: t
23
+ },
24
+ receiver: e.receiver()
25
+ };
26
+ await Promise.all(Array.from({
27
+ length: e.numThreads()
28
+ }, async ()=>{
29
+ const n = new Worker(self.location.href, {
30
+ type: "module"
31
+ });
32
+ return n.postMessage(s), await o(n, "wasm_bindgen_worker_ready"), n;
33
+ })), e.build();
34
+ };
35
+ })();
36
+ export { a as s, __tla };
package/dist/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <title>IFClite Viewer</title>
7
7
  <script>
8
8
  // Apply theme before first paint to prevent flash of wrong theme.
@@ -50,20 +50,20 @@
50
50
  <meta name="theme-color" content="#7aa2f7">
51
51
  <meta name="msapplication-TileColor" content="#1a1b26">
52
52
  <meta name="msapplication-TileImage" content="/favicon-192x192-cropped.png">
53
- <script type="module" crossorigin src="/assets/index-D8Epw-e7.js"></script>
53
+ <script type="module" crossorigin src="/assets/index-DVNSvEMh.js"></script>
54
54
  <link rel="modulepreload" crossorigin href="/assets/arrow-CZ5kQ26f.js">
55
55
  <link rel="modulepreload" crossorigin href="/assets/cesium-DUOzBlqv.js">
56
56
  <link rel="modulepreload" crossorigin href="/assets/zip-DBEtpeu6.js">
57
57
  <link rel="modulepreload" crossorigin href="/assets/bcf-4K724hw0.js">
58
- <link rel="modulepreload" crossorigin href="/assets/exporters-xbXqEDlO.js">
58
+ <link rel="modulepreload" crossorigin href="/assets/exporters-u0sz2Upj.js">
59
59
  <link rel="modulepreload" crossorigin href="/assets/lens-CSASnhAL.js">
60
- <link rel="modulepreload" crossorigin href="/assets/sandbox-tccwm5Bo.js">
61
- <link rel="modulepreload" crossorigin href="/assets/drawing-2d-DoxKMqbO.js">
62
- <link rel="modulepreload" crossorigin href="/assets/server-client-LoWPK1N2.js">
63
- <link rel="modulepreload" crossorigin href="/assets/ids-2WdONLlu.js">
60
+ <link rel="modulepreload" crossorigin href="/assets/sandbox-DPD1ROr0.js">
61
+ <link rel="modulepreload" crossorigin href="/assets/drawing-2d-Bjy8YPrg.js">
62
+ <link rel="modulepreload" crossorigin href="/assets/server-client-DP8fMPY9.js">
63
+ <link rel="modulepreload" crossorigin href="/assets/ids-B7AXEv7h.js">
64
64
  <link rel="modulepreload" crossorigin href="/assets/three-CDRZThFA.js">
65
65
  <link rel="stylesheet" crossorigin href="/assets/cesium-ADbP7waU.css">
66
- <link rel="stylesheet" crossorigin href="/assets/index-BXeEKqJG.css">
66
+ <link rel="stylesheet" crossorigin href="/assets/index-CSWgTe1s.css">
67
67
  </head>
68
68
  <body>
69
69
  <div id="root"></div>
package/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <title>IFClite Viewer</title>
7
7
  <script>
8
8
  // Apply theme before first paint to prevent flash of wrong theme.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.19.1",
3
+ "version": "1.21.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -51,25 +51,25 @@
51
51
  "zustand": "^4.4.0",
52
52
  "@ifc-lite/bcf": "^1.15.3",
53
53
  "@ifc-lite/cache": "^1.14.5",
54
- "@ifc-lite/data": "^1.15.2",
55
- "@ifc-lite/drawing-2d": "^1.15.3",
54
+ "@ifc-lite/data": "^1.17.0",
55
+ "@ifc-lite/drawing-2d": "^1.16.0",
56
56
  "@ifc-lite/encoding": "^1.14.6",
57
57
  "@ifc-lite/export": "^1.18.0",
58
- "@ifc-lite/geometry": "^1.17.0",
59
- "@ifc-lite/ids": "^1.14.11",
58
+ "@ifc-lite/geometry": "^1.18.1",
59
+ "@ifc-lite/ids": "^1.15.1",
60
60
  "@ifc-lite/lens": "^1.14.4",
61
- "@ifc-lite/lists": "^1.14.10",
61
+ "@ifc-lite/lists": "^1.14.12",
62
62
  "@ifc-lite/mcp": "^0.2.0",
63
63
  "@ifc-lite/mutations": "^1.15.0",
64
- "@ifc-lite/parser": "^2.3.0",
65
- "@ifc-lite/pointcloud": "^0.2.0",
64
+ "@ifc-lite/parser": "^2.4.0",
65
+ "@ifc-lite/pointcloud": "^0.3.0",
66
66
  "@ifc-lite/query": "^1.14.7",
67
- "@ifc-lite/renderer": "^1.18.0",
67
+ "@ifc-lite/renderer": "^1.20.0",
68
68
  "@ifc-lite/sandbox": "^1.15.0",
69
69
  "@ifc-lite/sdk": "^1.15.0",
70
70
  "@ifc-lite/server-client": "^1.15.3",
71
71
  "@ifc-lite/spatial": "^1.14.5",
72
- "@ifc-lite/wasm": "^1.16.8"
72
+ "@ifc-lite/wasm": "^1.16.10"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@tailwindcss/postcss": "^4.1.18",
@@ -46,6 +46,7 @@ export function BasketPresentationDock() {
46
46
  const basketViews = useViewerStore((s) => s.basketViews);
47
47
  const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
48
48
  const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible);
49
+ const isMobile = useViewerStore((s) => s.isMobile);
49
50
 
50
51
  const showPinboard = useViewerStore((s) => s.showPinboard);
51
52
  const clearIsolation = useViewerStore((s) => s.clearIsolation);
@@ -165,6 +166,8 @@ export function BasketPresentationDock() {
165
166
  setBasketViewTransitionMs(viewId, Math.round(seconds * 1000));
166
167
  }, [setBasketViewTransitionMs]);
167
168
 
169
+ if (isMobile) return null;
170
+
168
171
  if (!basketPresentationVisible) {
169
172
  return (
170
173
  <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 pointer-events-none">
@@ -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
- clamp: boolean,
172
- terrainH: number | null,
174
+ lengthUnitScale: number,
173
175
  ) {
174
- const hScale = mapConversion?.scale ?? 1.0;
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
- let placementHeight = bridge.modelOrigin.height;
184
- if (clamp && terrainH !== null) {
185
- const minY = bounds?.min.y ?? 0;
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, placementHeight,
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 controls the camera.
300
- // Keep collision detection off since we set the camera programmatically.
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: Rebuild coordinate bridge when georef changes (fast) ─────
390
- // This is the live-edit handler. When the user changes EPSG, eastings,
391
- // northings, rotation, etc., we rebuild the bridge and fly to the new spot.
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 bridge = await createCesiumBridge(mapConversion, projectedCRS, coordinateInfo, lengthUnitScale);
402
- if (cancelled) return;
423
+ const Cesium = cesiumModule;
424
+ const viewer = viewerRef.current;
425
+ if (!Cesium || !viewer) return;
403
426
 
404
- if (!bridge) {
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
- const prevBridge = bridgeRef.current;
410
- bridgeRef.current = bridge;
411
- autoClampedRef.current = false; // reset for new bridge
412
- // Bump version so terrain query effect re-runs now that bridge is ready
413
- setBridgeVersion((v) => v + 1);
414
-
415
- // Fly to the new model location (smooth animation)
416
- const viewer = viewerRef.current;
417
- const Cesium = cesiumModule;
418
- if (viewer && Cesium) {
419
- const { modelOrigin } = bridge;
420
-
421
- const isFirstPosition = !prevBridge;
422
- const target = Cesium.Cartesian3.fromDegrees(
423
- modelOrigin.longitude, modelOrigin.latitude, modelOrigin.height,
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 (isFirstPosition) {
427
- // First time: fly to the model location.
428
- // On first load terrain tiles may not be ready, so globe.getHeight
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
- let cancelled = false;
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
- // Query immediately, then retry after a delay if terrain tiles weren't loaded yet
466
- // Compute model center Y in viewer space for terrain clip offset
467
- const bounds = coordinateInfo?.originalBounds;
468
- const modelVY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0;
469
- const modelMinY = bounds ? bounds.min.y : 0;
470
-
471
- const doQuery = () => {
472
- bridge.queryTerrainHeight(Cesium, viewer).then((h) => {
473
- if (!cancelled && h !== null) {
474
- setCesiumTerrainHeight(h);
475
- // Compute terrain clip Y in viewer space (both h and modelOrigin.height are metres,
476
- // bounds are also in metres since the geometry engine converts during extraction)
477
- const terrainClipY = modelMinY + (h - bridge.modelOrigin.height);
478
- setCesiumTerrainClipY(terrainClipY);
479
-
480
- // Auto-enable terrain clamping if the model is significantly below terrain
481
- // (only once — don't override if the user has manually toggled)
482
- if (!autoClampedRef.current && h > bridge.modelOrigin.height + 5) {
483
- autoClampedRef.current = true;
484
- setCesiumTerrainClamp(true);
485
- // Fly camera to the clamped position so the model is visible
486
- viewer.camera.flyTo({
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
- // First attempt
499
- doQuery();
500
- // Retry after 5s in case terrain tiles were still loading
501
- const retryTimer = setTimeout(doQuery, 5000);
533
+ bridgeRef.current = bridge;
534
+ setBridgeVersion((v) => v + 1);
535
+ })();
502
536
 
503
- return () => { cancelled = true; clearTimeout(retryTimer); };
504
- }, [status, terrainEnabled, terrainClamp, bridgeVersion]);
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, coordinateInfo, terrainClampRef.current, terrainHeightRef.current);
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, coordinateInfo, terrainClamp, terrainHeight);
642
+ const newMatrix = buildModelMatrix(Cesium, bridge, mapConversion, projectedCRS, coordinateInfo, lengthUnitScale);
605
643
  model.modelMatrix = newMatrix;
606
644
  viewer.scene.requestRender();
607
- }, [terrainClamp, terrainHeight, mapConversion, coordinateInfo, lengthUnitScale]);
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
- // Sync Cesium camera (no terrain offset model matrix handles clamping)
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);