@ifc-lite/viewer 1.18.0 → 1.19.1

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 (61) hide show
  1. package/.turbo/turbo-build.log +19 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +444 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  7. package/dist/assets/{exporters-B_OBqIyD.js → exporters-xbXqEDlO.js} +2547 -1958
  8. package/dist/assets/{geometry.worker-xHHy-9DV.js → geometry.worker-DQEZB2rB.js} +1 -1
  9. package/dist/assets/ids-2WdONLlu.js +2033 -0
  10. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  11. package/dist/assets/index-BXeEKqJG.css +1 -0
  12. package/dist/assets/{index-BKq-M3Mk.js → index-D8Epw-e7.js} +51781 -32599
  13. package/dist/assets/index-XwKzDuw6.js +22 -0
  14. package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-DKmx1z95.js} +2 -2
  15. package/dist/assets/{sandbox-jez21HtV.js → sandbox-tccwm5Bo.js} +1402 -1329
  16. package/dist/assets/{server-client-ncOQVNso.js → server-client-LoWPK1N2.js} +1 -1
  17. package/dist/assets/three-CDRZThFA.js +4057 -0
  18. package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-BsJGgPMs.js} +1 -1
  19. package/dist/index.html +8 -7
  20. package/dist/samples/building-architecture.ifc +453 -0
  21. package/dist/samples/hello-wall.ifc +1054 -0
  22. package/dist/samples/infra-bridge.ifc +962 -0
  23. package/package.json +13 -7
  24. package/public/samples/building-architecture.ifc +453 -0
  25. package/public/samples/hello-wall.ifc +1054 -0
  26. package/public/samples/infra-bridge.ifc +962 -0
  27. package/src/App.tsx +37 -3
  28. package/src/components/mcp/HeroScene.tsx +876 -0
  29. package/src/components/mcp/McpLanding.tsx +1318 -0
  30. package/src/components/mcp/McpPlayground.tsx +524 -0
  31. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  32. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  33. package/src/components/mcp/README.md +171 -0
  34. package/src/components/mcp/data.ts +659 -0
  35. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  36. package/src/components/mcp/playground-files.ts +107 -0
  37. package/src/components/mcp/playground-uploads.ts +122 -0
  38. package/src/components/mcp/types.ts +65 -0
  39. package/src/components/mcp/use-mcp-page.ts +109 -0
  40. package/src/components/viewer/MainToolbar.tsx +23 -2
  41. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  42. package/src/components/viewer/Viewport.tsx +18 -1
  43. package/src/components/viewer/ViewportContainer.tsx +78 -9
  44. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  45. package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
  46. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  47. package/src/components/viewer/usePointCloudSync.ts +98 -0
  48. package/src/generated/mcp-catalog.json +82 -0
  49. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  50. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  51. package/src/hooks/useIfcFederation.ts +72 -3
  52. package/src/hooks/useIfcLoader.ts +67 -3
  53. package/src/services/file-dialog.ts +4 -2
  54. package/src/store/index.ts +10 -1
  55. package/src/store/slices/pointCloudSlice.ts +102 -0
  56. package/src/store/types.ts +7 -0
  57. package/vite.config.ts +7 -0
  58. package/dist/assets/basketViewActivator-Cm1QEk_R.js +0 -1
  59. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  60. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  61. package/dist/assets/index-COnQRuqY.css +0 -1
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
10
  import { Renderer, type VisualEnhancementOptions } from '@ifc-lite/renderer';
11
- import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
11
+ import type { MeshData, CoordinateInfo, PointCloudAsset } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
14
14
  useSelectionState,
@@ -36,6 +36,8 @@ import { useTouchControls, type TouchState } from './useTouchControls.js';
36
36
  import { useKeyboardControls } from './useKeyboardControls.js';
37
37
  import { useAnimationLoop } from './useAnimationLoop.js';
38
38
  import { useGeometryStreaming } from './useGeometryStreaming.js';
39
+ import { usePointCloudSync } from './usePointCloudSync.js';
40
+ import { usePointCloudLifecycle } from './usePointCloudLifecycle.js';
39
41
  import { useRenderUpdates } from './useRenderUpdates.js';
40
42
 
41
43
  interface ViewportProps {
@@ -43,6 +45,8 @@ interface ViewportProps {
43
45
  /** Monotonic counter that increments when geometry changes — used to trigger
44
46
  * streaming effects even when the geometry array reference is stable. */
45
47
  geometryVersion?: number;
48
+ /** Point cloud assets aggregated across visible federated models. */
49
+ pointClouds?: ReadonlyArray<PointCloudAsset> | null;
46
50
  coordinateInfo?: CoordinateInfo;
47
51
  computedIsolatedIds?: Set<number> | null;
48
52
  modelIdToIndex?: Map<string, number>;
@@ -56,6 +60,7 @@ interface ViewportProps {
56
60
  export function Viewport({
57
61
  geometry,
58
62
  geometryVersion,
63
+ pointClouds,
59
64
  coordinateInfo,
60
65
  computedIsolatedIds,
61
66
  modelIdToIndex,
@@ -856,6 +861,18 @@ export function Viewport({
856
861
  onGeometryReleased,
857
862
  });
858
863
 
864
+ usePointCloudSync({
865
+ rendererRef,
866
+ isInitialized,
867
+ pointClouds,
868
+ hasMeshes: (geometry?.length ?? 0) > 0,
869
+ });
870
+
871
+ usePointCloudLifecycle({
872
+ rendererRef,
873
+ isInitialized,
874
+ });
875
+
859
876
  useRenderUpdates({
860
877
  rendererRef,
861
878
  isInitialized,
@@ -20,8 +20,10 @@ import { openIfcFileDialog } from '@/services/file-dialog';
20
20
  import { logToDesktopTerminal } from '@/services/desktop-logger';
21
21
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
22
22
  import { isTauri } from '@/lib/platform';
23
- import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
24
- import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
23
+ import { toast } from '@/components/ui/toast';
24
+ import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
25
+ import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight } from 'lucide-react';
26
+ import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
25
27
  import { type IfcDataStore } from '@ifc-lite/parser';
26
28
  import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
27
29
 
@@ -174,6 +176,30 @@ export function ViewportContainer() {
174
176
  return geometryResult;
175
177
  }, [storeModels, geometryResult, modelIdToIndex]);
176
178
 
179
+ /**
180
+ * Aggregate point clouds across visible models.
181
+ *
182
+ * Phase 0: identity-stamping with modelIndex. Returns the same array
183
+ * reference when nothing has changed so the consumer effect skips work.
184
+ */
185
+ const mergedPointClouds = useMemo(() => {
186
+ const collected: PointCloudAsset[] = [];
187
+ if (storeModels.size > 0) {
188
+ for (const [modelId, model] of storeModels) {
189
+ if (!model.visible) continue;
190
+ const assets = model.geometryResult?.pointClouds;
191
+ if (!assets || assets.length === 0) continue;
192
+ const modelIndex = modelIdToIndex.get(modelId) ?? 0;
193
+ for (const asset of assets) {
194
+ collected.push(asset.modelIndex === modelIndex ? asset : { ...asset, modelIndex });
195
+ }
196
+ }
197
+ } else if (geometryResult?.pointClouds) {
198
+ collected.push(...geometryResult.pointClouds);
199
+ }
200
+ return collected;
201
+ }, [storeModels, geometryResult, modelIdToIndex]);
202
+
177
203
  // Extract georeferencing info merged with any live mutations (for Cesium overlay).
178
204
  // Reacts to: model load, Cesium toggle, and every georef field edit.
179
205
  const georef = useMemo(() => {
@@ -281,12 +307,22 @@ export function ViewportContainer() {
281
307
  return;
282
308
  }
283
309
 
284
- // Filter to supported files (IFC, IFCX, GLB)
285
- const supportedFiles = Array.from(e.dataTransfer.files).filter(
310
+ // Filter to supported files (IFC, IFCX, GLB, point clouds)
311
+ const allDropped = Array.from(e.dataTransfer.files);
312
+ const supportedFiles = allDropped.filter(
286
313
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
314
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
287
315
  );
288
316
 
289
- if (supportedFiles.length === 0) return;
317
+ if (supportedFiles.length === 0) {
318
+ // Tell the user *why* — common case is a Recap project / SketchUp
319
+ // file dropped because they assumed our viewer would understand it.
320
+ const explained = allDropped.find((f) => describeUnsupportedFormat(f.name));
321
+ if (explained) {
322
+ toast.error(`${explained.name}: ${describeUnsupportedFormat(explained.name)}`);
323
+ }
324
+ return;
325
+ }
290
326
 
291
327
  recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
292
328
  void cacheFileBlobs(supportedFiles);
@@ -318,6 +354,7 @@ export function ViewportContainer() {
318
354
  // Filter to supported files (IFC, IFCX, GLB)
319
355
  const supportedFiles = Array.from(files).filter(
320
356
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
357
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
321
358
  );
322
359
 
323
360
  if (supportedFiles.length === 0) return;
@@ -529,7 +566,7 @@ export function ViewportContainer() {
529
566
  <input
530
567
  ref={fileInputRef}
531
568
  type="file"
532
- accept=".ifc,.ifcx,.glb"
569
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
533
570
  multiple
534
571
  onChange={handleFileSelect}
535
572
  className="hidden"
@@ -698,10 +735,18 @@ export function ViewportContainer() {
698
735
  IFClite
699
736
  </h2>
700
737
  <p className="text-zinc-500 dark:text-[#565f89] font-mono text-sm text-center mb-8 border-b border-zinc-200 dark:border-[#3b4261] pb-4 w-full">
701
- High-performance web viewer demo
738
+ IFC toolkit for the open web
702
739
  </p>
703
740
 
704
- {/* Action */}
741
+ {/*
742
+ Two-track action area: a primary "open file" track and a
743
+ secondary "drive with LLM" track sit in mirrored slots — same
744
+ width, same vertical rhythm, each followed by its own caption
745
+ line. Reads as one balanced composition instead of a primary
746
+ CTA + a tacked-on link, while keeping the file-open path
747
+ visually dominant via the filled-on-hover treatment.
748
+ */}
749
+ {/* Track 1 — open / drag */}
705
750
  <button
706
751
  onClick={async () => {
707
752
  if (!webgpu.supported) {
@@ -737,10 +782,33 @@ export function ViewportContainer() {
737
782
  <span>{webgpu.checking ? 'Checking WebGPU...' : webgpu.supported ? 'Open .ifc file' : 'WebGPU Required'}</span>
738
783
  </button>
739
784
 
740
- <p className="mt-3 text-xs font-mono text-zinc-400 dark:text-[#565f89]">
785
+ <p className="mt-2.5 text-[11px] font-mono text-center text-zinc-400 dark:text-[#565f89]">
741
786
  {webgpu.supported ? 'or drag & drop anywhere' : 'file upload disabled'}
742
787
  </p>
743
788
 
789
+ {/* Subtle "or" rule — anchors the symmetry between the two tracks */}
790
+ <div className="mt-5 mb-5 w-full flex items-center gap-3 text-[10px] font-mono uppercase tracking-[0.22em] text-zinc-400 dark:text-[#565f89]">
791
+ <span className="h-px flex-1 bg-zinc-200 dark:bg-[#3b4261]" />
792
+ <span>or</span>
793
+ <span className="h-px flex-1 bg-zinc-200 dark:bg-[#3b4261]" />
794
+ </div>
795
+
796
+ {/* Track 2 — agent / MCP. Compact inline pill, self-centred so
797
+ it reads as a meta-link sibling to the primary file-open
798
+ CTA, not a competing full-width button. */}
799
+ <a
800
+ href="/mcp"
801
+ className="group inline-flex self-center items-center gap-1.5 px-3 py-1.5 font-mono text-[11px] border border-dashed border-zinc-300 dark:border-[#3b4261] text-zinc-500 dark:text-[#7a82a5] hover:border-primary hover:text-primary transition-all cursor-pointer"
802
+ >
803
+ <Sparkles className="h-3 w-3 transition-transform group-hover:-translate-y-0.5" />
804
+ <span>Drive with any LLM</span>
805
+ <ArrowUpRight className="h-2.5 w-2.5 opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
806
+ </a>
807
+
808
+ <p className="mt-1.5 text-[10px] font-mono text-center text-zinc-400 dark:text-[#565f89]">
809
+ via MCP · install or try the playground
810
+ </p>
811
+
744
812
  {recentFiles.length > 0 && (
745
813
  <div className="mt-6 w-full border-t border-zinc-200 dark:border-[#3b4261] pt-4">
746
814
  <div className="mb-3 flex items-center gap-2 text-xs font-mono uppercase tracking-[0.2em] text-zinc-400 dark:text-[#565f89]">
@@ -845,6 +913,7 @@ export function ViewportContainer() {
845
913
  <Viewport
846
914
  geometry={filteredGeometry}
847
915
  geometryVersion={geometryVersion}
916
+ pointClouds={mergedPointClouds}
848
917
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
849
918
  computedIsolatedIds={computedIsolatedIds}
850
919
  modelIdToIndex={modelIdToIndex}
@@ -22,10 +22,11 @@ import { goHomeFromStore } from '@/store/homeView';
22
22
  import { useIfc } from '@/hooks/useIfc';
23
23
  import { cn } from '@/lib/utils';
24
24
  import { isTauri } from '@/lib/platform';
25
-
26
- const isDesktop = isTauri();
27
25
  import { ViewCube, type ViewCubeRef } from './ViewCube';
28
26
  import { AxisHelper, type AxisHelperRef } from './AxisHelper';
27
+ import { PointCloudPanel } from './PointCloudPanel';
28
+
29
+ const isDesktop = isTauri();
29
30
 
30
31
  export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) {
31
32
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
@@ -149,6 +150,7 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
149
150
 
150
151
  return (
151
152
  <>
153
+ <PointCloudPanelMount />
152
154
  {/* Bottom-right: Cesium settings overlay OR Navigation controls (Cesium is web-only) */}
153
155
  {cesiumEnabled && !isDesktop ? (
154
156
  <CesiumSettingsOverlay
@@ -314,3 +316,12 @@ function CesiumSettingsOverlay({
314
316
  </div>
315
317
  );
316
318
  }
319
+
320
+ /**
321
+ * Tiny indirection so the panel can subscribe to its own slice without
322
+ * pulling extra state into the parent overlay component.
323
+ */
324
+ function PointCloudPanelMount() {
325
+ const count = useViewerStore((s) => s.pointCloudAssetCount);
326
+ return <PointCloudPanel assetCount={count} />;
327
+ }
@@ -51,14 +51,54 @@ export function AddElementOverlay() {
51
51
  // hover points each frame. The tick state is just a number that
52
52
  // forces a re-render; the projection itself is read fresh from the
53
53
  // store callback.
54
+ //
55
+ // Two perf gates:
56
+ // 1. Skip the loop entirely when there's nothing to project.
57
+ // pendingPoints / hoverPoint / autoSpacePreview already trigger
58
+ // React re-renders via the store, so the only reason we'd need
59
+ // a per-frame tick is to track the camera while content exists.
60
+ // 2. Only re-render when the camera actually moved since last tick.
61
+ // A held tool with a static camera does ~0 work.
62
+ const getViewpoint = useViewerStore((s) => s.cameraCallbacks.getViewpoint);
63
+ const hasOverlayContent =
64
+ pendingPoints.length > 0 ||
65
+ hoverPoint !== null ||
66
+ (autoSpacePreview != null && autoSpacePreview.outlines.length > 0);
54
67
  const [frameTick, setFrameTick] = useState(0);
55
68
  const rafRef = useRef<number | null>(null);
69
+ const lastViewpointRef = useRef<{
70
+ px: number; py: number; pz: number;
71
+ tx: number; ty: number; tz: number;
72
+ fov: number;
73
+ } | null>(null);
56
74
  useEffect(() => {
57
75
  if (activeTool !== 'addElement') return;
76
+ if (!hasOverlayContent) return;
58
77
  let mounted = true;
59
78
  const loop = () => {
60
79
  if (!mounted) return;
61
- setFrameTick((t) => (t + 1) & 0xffff);
80
+ const vp = getViewpoint?.();
81
+ if (vp) {
82
+ const last = lastViewpointRef.current;
83
+ const moved =
84
+ !last ||
85
+ last.px !== vp.position.x || last.py !== vp.position.y || last.pz !== vp.position.z ||
86
+ last.tx !== vp.target.x || last.ty !== vp.target.y || last.tz !== vp.target.z ||
87
+ last.fov !== vp.fov;
88
+ if (moved) {
89
+ lastViewpointRef.current = {
90
+ px: vp.position.x, py: vp.position.y, pz: vp.position.z,
91
+ tx: vp.target.x, ty: vp.target.y, tz: vp.target.z,
92
+ fov: vp.fov,
93
+ };
94
+ setFrameTick((t) => (t + 1) & 0xffff);
95
+ }
96
+ } else {
97
+ // Fallback for environments without getViewpoint — preserves the
98
+ // pre-fix behaviour of an unconditional tick so the projection
99
+ // can't get stuck stale.
100
+ setFrameTick((t) => (t + 1) & 0xffff);
101
+ }
62
102
  rafRef.current = requestAnimationFrame(loop);
63
103
  };
64
104
  rafRef.current = requestAnimationFrame(loop);
@@ -66,8 +106,9 @@ export function AddElementOverlay() {
66
106
  mounted = false;
67
107
  if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
68
108
  rafRef.current = null;
109
+ lastViewpointRef.current = null;
69
110
  };
70
- }, [activeTool]);
111
+ }, [activeTool, hasOverlayContent, getViewpoint]);
71
112
 
72
113
  const projection = useMemo(
73
114
  () => makeProjection(projectToScreen),
@@ -0,0 +1,64 @@
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
+ * Tear down streamed point cloud GPU resources when a model is removed.
7
+ *
8
+ * Streamed assets (LAS/LAZ) live in a separate ownership bucket on the
9
+ * renderer (see `PointCloudRenderer`'s `'streamed'` owner tag), so they
10
+ * survive `setPointClouds` calls. That isolation cuts both ways: nothing
11
+ * else clears them, so when a model is removed we have to do it here or
12
+ * the GPU buffers leak for the rest of the session.
13
+ *
14
+ * The hook tracks the previous set of `(modelId → handleId)` pairs and,
15
+ * on every store change, frees the handles for models that disappeared.
16
+ * Pure cleanup — no state mutation.
17
+ */
18
+
19
+ import { useEffect, useRef, type MutableRefObject } from 'react';
20
+ import type { Renderer } from '@ifc-lite/renderer';
21
+ import { useViewerStore } from '@/store';
22
+
23
+ export interface UsePointCloudLifecycleParams {
24
+ rendererRef: MutableRefObject<Renderer | null>;
25
+ isInitialized: boolean;
26
+ }
27
+
28
+ export function usePointCloudLifecycle(params: UsePointCloudLifecycleParams): void {
29
+ const { rendererRef, isInitialized } = params;
30
+ const models = useViewerStore((s) => s.models);
31
+ const decCount = useViewerStore((s) => s.incrementPointCloudAssetCount);
32
+ const previousRef = useRef<Map<string, number>>(new Map());
33
+
34
+ useEffect(() => {
35
+ if (!isInitialized) return;
36
+ const renderer = rendererRef.current;
37
+ if (!renderer) return;
38
+
39
+ const current = new Map<string, number>();
40
+ for (const [modelId, model] of models) {
41
+ if (typeof model.pointCloudHandleId === 'number') {
42
+ current.set(modelId, model.pointCloudHandleId);
43
+ }
44
+ }
45
+
46
+ // Dispose handles whose model disappeared OR whose model still
47
+ // exists but was rebound to a new handle (e.g. the user reloaded
48
+ // the same file and got a fresh streaming session). Without the
49
+ // rebind branch the old GPU buffers stay allocated for the rest
50
+ // of the session.
51
+ for (const [modelId, handleId] of previousRef.current) {
52
+ const nextHandle = current.get(modelId);
53
+ if (nextHandle !== handleId) {
54
+ renderer.removePointCloudAsset({ id: handleId });
55
+ decCount(-1);
56
+ }
57
+ }
58
+
59
+ previousRef.current = current;
60
+ renderer.requestRender();
61
+ }, [models, isInitialized, rendererRef, decCount]);
62
+ }
63
+
64
+ export default usePointCloudLifecycle;
@@ -0,0 +1,98 @@
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
+ * Sync IFCx-derived point cloud assets to the renderer.
7
+ *
8
+ * On every change of the `pointClouds` array we replace the renderer's
9
+ * asset list and request a fresh frame. When the active scene has no
10
+ * triangle meshes (the buildingSMART point-cloud-only samples), we
11
+ * additionally trigger a one-shot camera fit — the geometry streaming
12
+ * hook bails out early in that case and would otherwise leave points
13
+ * stranded outside the camera frustum.
14
+ */
15
+
16
+ import { useEffect, useRef, type MutableRefObject } from 'react';
17
+ import type { PointColorMode, PointSizeMode, Renderer } from '@ifc-lite/renderer';
18
+ import type { PointCloudAsset } from '@ifc-lite/geometry';
19
+ import { useViewerStore } from '@/store';
20
+
21
+ export interface UsePointCloudSyncParams {
22
+ rendererRef: MutableRefObject<Renderer | null>;
23
+ isInitialized: boolean;
24
+ pointClouds: ReadonlyArray<PointCloudAsset> | null | undefined;
25
+ /** True when the scene has triangle meshes — the geometry streaming
26
+ * hook owns fit-to-view in that case and we shouldn't fight it. */
27
+ hasMeshes: boolean;
28
+ }
29
+
30
+ export function usePointCloudSync(params: UsePointCloudSyncParams): void {
31
+ const { rendererRef, isInitialized, pointClouds, hasMeshes } = params;
32
+ const colorMode = useViewerStore((s) => s.pointCloudColorMode) as PointColorMode;
33
+ const fixedColor = useViewerStore((s) => s.pointCloudFixedColor);
34
+ const sizeMode = useViewerStore((s) => s.pointCloudSizeMode) as PointSizeMode;
35
+ const pointSize = useViewerStore((s) => s.pointCloudPointSize);
36
+ const worldRadius = useViewerStore((s) => s.pointCloudWorldRadius);
37
+ const roundShape = useViewerStore((s) => s.pointCloudRoundShape);
38
+ const edlEnabled = useViewerStore((s) => s.pointCloudEdlEnabled);
39
+ const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
40
+ const setAssetCount = useViewerStore((s) => s.setPointCloudAssetCount);
41
+ const fittedRef = useRef(false);
42
+
43
+ // Reset the one-shot fit flag whenever the asset list identity changes.
44
+ useEffect(() => {
45
+ fittedRef.current = false;
46
+ }, [pointClouds]);
47
+
48
+ // Replace IFCx-owned assets when the merged list changes
49
+ useEffect(() => {
50
+ const renderer = rendererRef.current;
51
+ if (!renderer || !isInitialized) return;
52
+
53
+ const assets = pointClouds ?? [];
54
+ renderer.setPointClouds(assets);
55
+ const count = renderer.getPointCloudAssetCount();
56
+ setAssetCount(count);
57
+
58
+ // Camera fit for points-only scenes — useGeometryStreaming skips its
59
+ // own fit branch when meshes is empty, so points stay off-screen
60
+ // unless we step in. Run once per fresh asset list.
61
+ if (count > 0 && !hasMeshes && !fittedRef.current) {
62
+ const bounds = renderer.getModelBounds();
63
+ if (bounds && Number.isFinite(bounds.min.x) && Number.isFinite(bounds.max.x)) {
64
+ renderer.getCamera().fitToBounds(bounds.min, bounds.max);
65
+ fittedRef.current = true;
66
+ }
67
+ }
68
+
69
+ renderer.requestRender();
70
+ }, [pointClouds, isInitialized, rendererRef, setAssetCount, hasMeshes]);
71
+
72
+ // Push color + sizing + shape preferences to the renderer whenever the
73
+ // user changes them. The slice already clamps numeric ranges so the
74
+ // shader only ever sees sane values.
75
+ useEffect(() => {
76
+ const renderer = rendererRef.current;
77
+ if (!renderer || !isInitialized) return;
78
+ renderer.setPointCloudOptions({
79
+ colorMode,
80
+ fixedColor,
81
+ sizeMode,
82
+ pointSize,
83
+ worldRadius,
84
+ roundShape,
85
+ });
86
+ renderer.requestRender();
87
+ }, [colorMode, fixedColor, sizeMode, pointSize, worldRadius, roundShape, isInitialized, rendererRef]);
88
+
89
+ // Push EDL toggle + strength to the renderer.
90
+ useEffect(() => {
91
+ const renderer = rendererRef.current;
92
+ if (!renderer || !isInitialized) return;
93
+ renderer.setEdlOptions({ enabled: edlEnabled, strength: edlStrength });
94
+ renderer.requestRender();
95
+ }, [edlEnabled, edlStrength, isInitialized, rendererRef]);
96
+ }
97
+
98
+ export default usePointCloudSync;
@@ -0,0 +1,82 @@
1
+ {
2
+ "generatedAt": "2026-05-03T09:30:00Z",
3
+ "version": "0.1.0",
4
+ "tools": [
5
+ { "name": "model_info", "category": "Discovery", "scope": "read", "description": "Schema, entity counts, units, georeferencing — the at-a-glance summary of a loaded IFC.", "inputSchema": { "type": "object", "properties": { "model_id": { "type": "string" } } } },
6
+ { "name": "model_list", "category": "Discovery", "scope": "read", "description": "List every model loaded in the current MCP session.", "inputSchema": { "type": "object" } },
7
+ { "name": "model_load", "category": "Discovery", "scope": "mutate", "description": "Load an additional .ifc from disk into the federated session.", "inputSchema": { "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string" }, "model_id": { "type": "string" } } } },
8
+ { "name": "model_unload", "category": "Discovery", "scope": "mutate", "description": "Drop a model from the registry; frees memory.", "inputSchema": { "type": "object", "required": ["model_id"], "properties": { "model_id": { "type": "string" } } } },
9
+ { "name": "schema_describe", "category": "Discovery", "scope": "read", "description": "Attributes, parent class, and inheritance for any IfcType — useful before mutating.", "inputSchema": { "type": "object", "required": ["type"], "properties": { "type": { "type": "string" }, "include_inherited": { "type": "boolean" } } } },
10
+
11
+ { "name": "query_entities", "category": "Query", "scope": "read", "description": "Type + property filters with pagination. Returns expressId, GlobalId, name, type for each match.", "inputSchema": { "type": "object", "properties": { "type": { "type": "string" }, "limit": { "type": "integer" } } } },
12
+ { "name": "count_entities", "category": "Query", "scope": "read", "description": "Group counts by type, storey, or material — histogram form, not the full set.", "inputSchema": { "type": "object", "properties": { "group_by": { "type": "string", "enum": ["type", "storey", "material"] } } } },
13
+ { "name": "get_entity", "category": "Query", "scope": "read", "description": "Full attributes + property sets for one entity by GlobalId or expressId.", "inputSchema": { "type": "object", "properties": { "global_id": { "type": "string" }, "express_id": { "type": "integer" } } } },
14
+ { "name": "get_entities_bulk", "category": "Query", "scope": "read", "description": "Batched get_entity for up to 200 ids at once.", "inputSchema": { "type": "object", "required": ["global_ids"], "properties": { "global_ids": { "type": "array", "items": { "type": "string" } } } } },
15
+ { "name": "spatial_hierarchy", "category": "Query", "scope": "read", "description": "Project → site → building → storey → space tree, with element counts at each node.", "inputSchema": { "type": "object" } },
16
+ { "name": "containment_chain", "category": "Query", "scope": "read", "description": "Walk up the spatial chain for one entity (storey + parent + grandparent…).", "inputSchema": { "type": "object", "required": ["global_id"] } },
17
+ { "name": "relationships", "category": "Query", "scope": "read", "description": "Voids, fills, groups, connections — every IfcRel touching this entity.", "inputSchema": { "type": "object", "required": ["global_id"] } },
18
+ { "name": "properties_unique", "category": "Query", "scope": "read", "description": "Unique values + counts for one property across a type set (perfect for filter UIs).", "inputSchema": { "type": "object", "required": ["type", "pset", "property"] } },
19
+ { "name": "materials_list", "category": "Query", "scope": "read", "description": "Distinct materials across the model with usage counts.", "inputSchema": { "type": "object" } },
20
+ { "name": "classifications_list","category": "Query", "scope": "read", "description": "Distinct classification references (system + identification) and how often each is used.", "inputSchema": { "type": "object" } },
21
+ { "name": "georeferencing", "category": "Query", "scope": "read", "description": "MapConversion, projected CRS, project north, true north — the geo handshake.", "inputSchema": { "type": "object" } },
22
+ { "name": "units", "category": "Query", "scope": "read", "description": "Length unit scale + the unit assignments declared in the file.", "inputSchema": { "type": "object" } },
23
+
24
+ { "name": "geometry_bbox", "category": "Geometry", "scope": "read", "description": "Per-entity axis-aligned bounding box (read from quantity sets when available).", "inputSchema": { "type": "object", "required": ["global_id"] } },
25
+ { "name": "geometry_volume", "category": "Geometry", "scope": "read", "description": "Net/gross volume in m³ for a single entity or a type aggregate.", "inputSchema": { "type": "object", "required": ["global_id"] } },
26
+ { "name": "geometry_area", "category": "Geometry", "scope": "read", "description": "Surface area for an entity (front/side/footprint depending on what the IFC carries).", "inputSchema": { "type": "object", "required": ["global_id"] } },
27
+
28
+ { "name": "model_audit", "category": "Validation", "scope": "read", "description": "Out-of-the-box health score + a list of issues (missing GlobalIds, broken refs, orphan entities).", "inputSchema": { "type": "object" } },
29
+ { "name": "ids_validate", "category": "Validation", "scope": "read", "description": "Run a buildingSMART IDS spec against the loaded model. Per-spec pass/fail with offending entities.", "inputSchema": { "type": "object", "required": ["ids_path"], "properties": { "ids_path": { "type": "string" } } } },
30
+ { "name": "ids_explain", "category": "Validation", "scope": "read", "description": "Parse + summarize an IDS file in plain language — what each spec asks for, in what order.", "inputSchema": { "type": "object", "required": ["ids_path"] } },
31
+
32
+ { "name": "entity_set_property", "category": "Mutation", "scope": "mutate", "description": "Queue a Pset.property write on one entity. Persist later via export_ifc / model_save.", "inputSchema": { "type": "object", "required": ["pset", "name"] } },
33
+ { "name": "entity_delete_property", "category": "Mutation","scope": "mutate", "description": "Queue a property removal from a Pset. Reversible via mutation_undo.", "inputSchema": { "type": "object", "required": ["pset", "name"] } },
34
+ { "name": "entity_set_attribute","category": "Mutation", "scope": "mutate", "description": "Set Name, Description, ObjectType, or Tag on an entity.", "inputSchema": { "type": "object", "required": ["attribute", "value"] } },
35
+ { "name": "entity_create", "category": "Mutation", "scope": "mutate", "description": "Create a new IFC entity with raw STEP attributes and get back its expressId.", "inputSchema": { "type": "object", "required": ["type"] } },
36
+ { "name": "entity_delete", "category": "Mutation", "scope": "mutate", "description": "Delete an entity by expressId/GlobalId. Caller is responsible for cascading rels.", "inputSchema": { "type": "object" } },
37
+ { "name": "mutation_batch", "category": "Mutation", "scope": "mutate", "description": "Apply N mutation ops in order, returning per-step results.", "inputSchema": { "type": "object", "required": ["operations"] } },
38
+ { "name": "mutation_diff", "category": "Mutation", "scope": "read", "description": "Inspect every queued mutation vs the original parsed state.", "inputSchema": { "type": "object" } },
39
+ { "name": "mutation_undo", "category": "Mutation", "scope": "mutate", "description": "Pop the last N pending mutations off the queue.", "inputSchema": { "type": "object", "properties": { "n": { "type": "integer" } } } },
40
+ { "name": "model_save", "category": "Mutation", "scope": "mutate", "description": "Write the current model (with pending mutations) back to .ifc.", "inputSchema": { "type": "object", "required": ["file_path"] } },
41
+
42
+ { "name": "bcf_topic_list", "category": "BCF", "scope": "read", "description": "List BCF topics in this session, optionally filtered by status.", "inputSchema": { "type": "object" } },
43
+ { "name": "bcf_topic_create", "category": "BCF", "scope": "mutate", "description": "Create a topic with title/description/priority and get the GUID for follow-ups.", "inputSchema": { "type": "object", "required": ["title"] } },
44
+ { "name": "bcf_topic_update", "category": "BCF", "scope": "mutate", "description": "Update topic fields or append a comment.", "inputSchema": { "type": "object", "required": ["guid"] } },
45
+ { "name": "bcf_topic_close", "category": "BCF", "scope": "mutate", "description": "Mark a topic resolved (status=Closed).", "inputSchema": { "type": "object", "required": ["guid"] } },
46
+ { "name": "bcf_viewpoint_create","category": "BCF", "scope": "mutate", "description": "Attach a selection-based viewpoint (or full viewer state) to a topic.", "inputSchema": { "type": "object", "required": ["guid"] } },
47
+ { "name": "bcf_export", "category": "BCF", "scope": "export", "description": "Export the in-memory BCF project as a .bcfzip file.", "inputSchema": { "type": "object", "required": ["file_path"] } },
48
+
49
+ { "name": "bsdd_search", "category": "bSDD", "scope": "read", "description": "Full-text search the buildingSMART Data Dictionary for classes by keyword.", "inputSchema": { "type": "object", "required": ["query"] } },
50
+ { "name": "bsdd_class", "category": "bSDD", "scope": "read", "description": "Full bSDD class info for an IFC entity name (definition, parent, properties).", "inputSchema": { "type": "object", "required": ["ifc_type"] } },
51
+ { "name": "bsdd_property_sets", "category": "bSDD", "scope": "read", "description": "Pset_* groups for an IFC type (Pset_WallCommon for IfcWall, etc.).", "inputSchema": { "type": "object", "required": ["ifc_type"] } },
52
+ { "name": "bsdd_match", "category": "bSDD", "scope": "read", "description": "Suggest matching bSDD classes for an entity in the loaded model.", "inputSchema": { "type": "object" } },
53
+
54
+ { "name": "model_diff", "category": "Diff", "scope": "read", "description": "Compare two loaded models. Reports added/removed entities by GlobalId and per-type count deltas.", "inputSchema": { "type": "object", "required": ["a", "b"] } },
55
+ { "name": "quantity_diff", "category": "Diff", "scope": "read", "description": "Per-entity-type quantity comparison between two models, optionally grouped by storey.", "inputSchema": { "type": "object", "required": ["a", "b"] } },
56
+
57
+ { "name": "export_ifc", "category": "Export", "scope": "export", "description": "Write the model (with pending mutations) to .ifc/.ifczip on disk.", "inputSchema": { "type": "object", "required": ["file_path"] } },
58
+ { "name": "export_csv", "category": "Export", "scope": "export", "description": "Tabular property/quantity export. Columns may be Pset_X.Property paths.", "inputSchema": { "type": "object" } },
59
+ { "name": "export_json", "category": "Export", "scope": "export", "description": "Structured JSON dump of attributes/properties/quantities for a type set.", "inputSchema": { "type": "object" } },
60
+ { "name": "export_glb", "category": "Export", "scope": "export", "description": "(v0.2) Geometry export to glTF binary — needs the WASM mesh pipeline.", "inputSchema": { "type": "object" } },
61
+ { "name": "export_ifcx", "category": "Export", "scope": "export", "description": "(v0.2) Export to the new IFCx interchange format.", "inputSchema": { "type": "object" } },
62
+ { "name": "export_pdf_report", "category": "Export", "scope": "export", "description": "(v0.5) Branded PDF audit report with charts.", "inputSchema": { "type": "object" } },
63
+
64
+ { "name": "viewer_ask", "category": "Viewer", "scope": "read", "description": "Suggest wording the agent can use to ask the user for permission to open the viewer.", "inputSchema": { "type": "object" } },
65
+ { "name": "viewer_open", "category": "Viewer", "scope": "read", "description": "Boot the in-process WebGL viewer and return its URL for the user to open.", "inputSchema": { "type": "object" } },
66
+ { "name": "viewer_close", "category": "Viewer", "scope": "read", "description": "Stop the viewer + clear its selection state.", "inputSchema": { "type": "object" } },
67
+ { "name": "viewer_status", "category": "Viewer", "scope": "read", "description": "Report whether the viewer is open, on what port, and the current selection.", "inputSchema": { "type": "object" } },
68
+ { "name": "viewer_colorize", "category": "Viewer", "scope": "read", "description": "Paint a set of entities with a color (hex / rgb / named).", "inputSchema": { "type": "object", "required": ["color"] } },
69
+ { "name": "viewer_isolate", "category": "Viewer", "scope": "read", "description": "Hide everything except the picked set.", "inputSchema": { "type": "object" } },
70
+ { "name": "viewer_hide", "category": "Viewer", "scope": "read", "description": "Hide the picked set; everything else stays.", "inputSchema": { "type": "object" } },
71
+ { "name": "viewer_show", "category": "Viewer", "scope": "read", "description": "Show the picked set (un-hide).", "inputSchema": { "type": "object" } },
72
+ { "name": "viewer_reset", "category": "Viewer", "scope": "read", "description": "Reset visibility + colors to the model defaults.", "inputSchema": { "type": "object" } },
73
+ { "name": "viewer_fly_to", "category": "Viewer", "scope": "read", "description": "Frame the camera on a set of entities or a bbox.", "inputSchema": { "type": "object" } },
74
+ { "name": "viewer_set_section", "category": "Viewer", "scope": "read", "description": "Apply an axis-aligned section plane.", "inputSchema": { "type": "object", "required": ["axis", "position"] } },
75
+ { "name": "viewer_clear_section","category": "Viewer", "scope": "read", "description": "Remove the active section plane.", "inputSchema": { "type": "object" } },
76
+ { "name": "viewer_color_by_storey","category": "Viewer", "scope": "read", "description": "Apply a per-storey overlay (built-in viewer preset).", "inputSchema": { "type": "object" } },
77
+ { "name": "viewer_color_by_property","category": "Viewer", "scope": "read", "description": "Color a type set by property value, returns a legend the agent can describe.", "inputSchema": { "type": "object", "required": ["type", "pset", "property"] } },
78
+ { "name": "viewer_get_selection","category": "Viewer", "scope": "read", "description": "Return the current selection — type, expressId, GlobalId, name, attributes, materials.", "inputSchema": { "type": "object" } },
79
+ { "name": "viewer_describe_selection","category": "Viewer","scope": "read", "description": "Kitchen-sink: every section (attributes, properties, quantities, classifications, materials) for the picked entity.", "inputSchema": { "type": "object" } },
80
+ { "name": "viewer_wait_for_selection","category": "Viewer","scope": "read", "description": "Block until the user picks something in the viewer (or timeout).", "inputSchema": { "type": "object" } }
81
+ ]
82
+ }