/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore } from 'react'; import { useLevelDisplayEffect } from '@/hooks/useLevelDisplayEffect'; import { Viewport } from './Viewport'; import { initialDragOverlayState, reduceDragOverlay, type DragOverlayEvent, type DragOverlayState, } from './dragOverlayState'; import { ViewportOverlays } from './ViewportOverlays'; import { MergeLayersBanner } from './MergeLayersBanner'; import { LevelDisplayIndicator } from './LevelDisplayIndicator'; import { ToolOverlays } from './ToolOverlays'; import { AnnotationLayer } from './annotations/AnnotationLayer'; import { Section2DPanel } from './Section2DPanel'; import { BasketPresentationDock } from './BasketPresentationDock'; import { BCFOverlay } from './bcf/BCFOverlay'; import { CesiumOverlay } from './CesiumOverlay'; import { CesiumPlacementEditor } from './CesiumPlacementEditor'; import { SunSkyPanel } from './SunSkyPanel'; import { useSolarEnvironment } from '@/hooks/useSolarEnvironment'; import { useSolarSweep } from '@/hooks/useSolarSweep'; import { getViewerStoreApi, useViewerStore } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet'; import type { AggregationRelationships } from '@/utils/aggregation'; import { useIfc } from '@/hooks/useIfc'; import { useWebGPU } from '@/hooks/useWebGPU'; import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files'; import { toast } from '@/components/ui/toast'; import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest'; import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight, PackagePlus } from 'lucide-react'; import { createBlankIfcFile } from '@/utils/createBlankIfc'; import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry'; import { type IfcDataStore, type MapConversion } from '@ifc-lite/parser'; import { getEffectiveGeoreference } from '@/lib/geo/effective-georef'; const ZERO_VEC3 = { x: 0, y: 0, z: 0 }; const DEFAULT_COORDINATE_INFO: CoordinateInfo = { originShift: ZERO_VEC3, originalBounds: { min: ZERO_VEC3, max: ZERO_VEC3 }, shiftedBounds: { min: ZERO_VEC3, max: ZERO_VEC3 }, hasLargeCoordinates: false, }; type Vec3Bounds = { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }; /** True for a real (non-placeholder, non-degenerate) bounds box. */ function isUsableBounds(b: Vec3Bounds | undefined): b is Vec3Bounds { if (!b) return false; return ( b.max.x > b.min.x || b.max.y > b.min.y || b.max.z > b.min.z ); } /** Axis-aligned union of two bounds boxes (either may be undefined). */ function unionBounds(acc: Vec3Bounds | undefined, b: Vec3Bounds | undefined): Vec3Bounds | undefined { if (!isUsableBounds(b)) return acc; if (!acc) return { min: { ...b.min }, max: { ...b.max } }; return { min: { x: Math.min(acc.min.x, b.min.x), y: Math.min(acc.min.y, b.min.y), z: Math.min(acc.min.z, b.min.z) }, max: { x: Math.max(acc.max.x, b.max.x), y: Math.max(acc.max.y, b.max.y), z: Math.max(acc.max.z, b.max.z) }, }; } export function ViewportContainer() { // Drive Stacked / Solo / Exploded level display from the slice. // Mount-once hook — it self-gates on mode + gap + model changes. useLevelDisplayEffect(); const { loadFile, loading, clearAllModels, loadFilesSequentially } = useIfc(); const setActiveTool = useViewerStore((s) => s.setActiveTool); const releaseGeometryMemory = useViewerStore((s) => s.releaseGeometryMemory); const selectedStoreys = useViewerStore((s) => s.selectedStoreys); const typeVisibility = useViewerStore((s) => s.typeVisibility); const typeViewMode = useViewerStore((s) => s.typeViewMode); const setHasTypeGeometry = useViewerStore((s) => s.setHasTypeGeometry); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); const classFilter = useViewerStore((s) => s.classFilter); const resetViewerState = useViewerStore((s) => s.resetViewerState); const bcfOverlayVisible = useViewerStore((s) => s.bcfOverlayVisible); const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled); const solarEnabled = useViewerStore((s) => s.solarEnabled); const cesiumPlacementDraft = useViewerStore((s) => s.cesiumPlacementDraft); const cesiumPlacementDraftModelId = useViewerStore((s) => s.cesiumPlacementDraftModelId); const anchorModelIdOverride = useViewerStore((s) => s.anchorModelIdOverride); const georefMutations = useViewerStore((s) => s.georefMutations); const setCesiumSourceModelId = useViewerStore((s) => s.setCesiumSourceModelId); const setCesiumAvailable = useViewerStore((s) => s.setCesiumAvailable); // Subscribe to mutationVersion so Cesium reacts to georef edits const mutationVersion = useViewerStore((s) => s.mutationVersion); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [showTroubleshooting, setShowTroubleshooting] = useState(false); const [recentFiles, setRecentFiles] = useState([]); const webgpu = useWebGPU(); const viewerStoreApi = getViewerStoreApi(); const viewportStoreState = useSyncExternalStore( viewerStoreApi.subscribe, viewerStoreApi.getState, viewerStoreApi.getState, ); const { geometryResult, ifcDataStore, models, boundedGeometryMode, geometryUpdateTick, geometryContentVersion, } = viewportStoreState; const storeModels = models; const mergedContentVersionRef = useRef(geometryContentVersion); // Check if we have models loaded (for determining add vs replace behavior) const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0); // Multi-model: create mapping from modelId to modelIndex (stable order) const modelIdToIndex = useMemo(() => { const map = new Map(); let index = 0; for (const modelId of storeModels.keys()) { map.set(modelId, index++); } return map; }, [storeModels]); const mergedCacheRef = useRef([]); const mergedLengthsRef = useRef>(new Map()); const mergedVisibilityRef = useRef>(new Map()); // Multi-model: merge geometries from all visible models const mergedGeometryResult = useMemo(() => { if (storeModels.size === 1) { const firstModel = storeModels.values().next().value; if (!firstModel?.visible) { return { meshes: [], totalVertices: 0, totalTriangles: 0, coordinateInfo: DEFAULT_COORDINATE_INFO, } satisfies GeometryResult; } return firstModel.geometryResult ?? geometryResult; } if (storeModels.size > 1) { let totalVertices = 0; let totalTriangles = 0; // The merged coordinateInfo must cover ALL visible models, not just the // first one — the renderer fits the camera to `shiftedBounds`, so a // first-wins box left every model after the first off-screen (it only // showed its 2D grid overlay). Union the bounds across visible models; // keep the first model's frame metadata (originShift / RTC) since // federated models share a coordinate frame. let baseCoordInfo: CoordinateInfo | undefined; let unionedShifted: Vec3Bounds | undefined; let unionedOriginal: Vec3Bounds | undefined; let anyLargeCoords = false; let shouldRebuild = false; if (mergedLengthsRef.current.size !== storeModels.size) { shouldRebuild = true; } // An external content version bump (e.g. realignFederation re-baked // vertices in place) requires a full cache rebuild — length/visibility // triggers above can't detect in-place mutation. Compare against the // last version we honoured; rebuild when it bumps. if (mergedContentVersionRef.current !== geometryContentVersion) { shouldRebuild = true; mergedContentVersionRef.current = geometryContentVersion; } for (const [modelId, model] of storeModels) { const modelGeometry = model.geometryResult; const meshCount = model.visible ? (modelGeometry?.meshes.length ?? 0) : 0; totalVertices += model.visible ? (modelGeometry?.totalVertices ?? 0) : 0; totalTriangles += model.visible ? (modelGeometry?.totalTriangles ?? 0) : 0; if (model.visible && modelGeometry?.coordinateInfo) { const ci = modelGeometry.coordinateInfo; if (!baseCoordInfo) baseCoordInfo = ci; anyLargeCoords = anyLargeCoords || !!ci.hasLargeCoordinates; unionedShifted = unionBounds(unionedShifted, ci.shiftedBounds); unionedOriginal = unionBounds(unionedOriginal, ci.originalBounds); } if ( mergedVisibilityRef.current.get(modelId) !== model.visible || (mergedLengthsRef.current.get(modelId) ?? 0) > meshCount ) { shouldRebuild = true; } } if (shouldRebuild) { const rebuilt: MeshData[] = []; mergedLengthsRef.current = new Map(); mergedVisibilityRef.current = new Map(); for (const [modelId, model] of storeModels) { const modelGeometry = model.geometryResult; mergedVisibilityRef.current.set(modelId, model.visible); const modelIndex = modelIdToIndex.get(modelId) ?? 0; if (!model.visible || !modelGeometry?.meshes) { mergedLengthsRef.current.set(modelId, 0); continue; } for (const mesh of modelGeometry.meshes) { rebuilt.push({ ...mesh, modelIndex }); } mergedLengthsRef.current.set(modelId, modelGeometry.meshes.length); } mergedCacheRef.current = rebuilt; } else { for (const [modelId, model] of storeModels) { const modelGeometry = model.geometryResult; const modelIndex = modelIdToIndex.get(modelId) ?? 0; const previousLength = mergedLengthsRef.current.get(modelId) ?? 0; const nextMeshes = model.visible ? (modelGeometry?.meshes ?? []) : []; for (let i = previousLength; i < nextMeshes.length; i++) { const mesh = nextMeshes[i]; mergedCacheRef.current.push({ ...mesh, modelIndex }); } mergedLengthsRef.current.set(modelId, nextMeshes.length); mergedVisibilityRef.current.set(modelId, model.visible); } } const mergedCoordinateInfo: CoordinateInfo | undefined = baseCoordInfo ? { ...baseCoordInfo, originalBounds: unionedOriginal ?? baseCoordInfo.originalBounds, shiftedBounds: unionedShifted ?? baseCoordInfo.shiftedBounds, hasLargeCoordinates: anyLargeCoords, } : undefined; return { meshes: mergedCacheRef.current, totalVertices, totalTriangles, coordinateInfo: mergedCoordinateInfo ?? DEFAULT_COORDINATE_INFO, } satisfies GeometryResult; } // Legacy mode (no federation): use original geometryResult return geometryResult; }, [storeModels, geometryResult, modelIdToIndex, geometryContentVersion]); /** * Aggregate point clouds across visible models. * * Phase 0: identity-stamping with modelIndex. Returns the same array * reference when nothing has changed so the consumer effect skips work. */ const mergedPointClouds = useMemo(() => { const collected: PointCloudAsset[] = []; if (storeModels.size > 0) { for (const [modelId, model] of storeModels) { if (!model.visible) continue; const assets = model.geometryResult?.pointClouds; if (!assets || assets.length === 0) continue; const modelIndex = modelIdToIndex.get(modelId) ?? 0; for (const asset of assets) { collected.push(asset.modelIndex === modelIndex ? asset : { ...asset, modelIndex }); } } } else if (geometryResult?.pointClouds) { collected.push(...geometryResult.pointClouds); } return collected; }, [storeModels, geometryResult, modelIdToIndex]); // Extract georeferencing info merged with any live mutations (for Cesium overlay). // Reacts to: model load, Cesium toggle, and every georef field edit. // Also computed while the solar study runs without Cesium — the WebGPU sun // needs the site's lat/lon + map rotation to track the studied instant. const georef = useMemo(() => { if (!cesiumEnabled && !solarEnabled) return null; const applyPlacementDraft = ( modelId: string, effective: T, ): T & { baseMapConversion?: T['mapConversion'] } => { const preview = cesiumPlacementDraftModelId === modelId ? cesiumPlacementDraft : null; if (!preview || !effective.mapConversion) { return { ...effective, baseMapConversion: effective.mapConversion, }; } return { ...effective, baseMapConversion: effective.mapConversion, mapConversion: { ...effective.mapConversion, ...preview, }, }; }; // Check federated models, preferring the user-pinned anchor when present. // Matches findReferenceGeorefModel() in useIfcFederation so the Cesium bridge // and the parse-time alignment agree on which model drives the world frame. const orderedModels = (() => { if (!anchorModelIdOverride) return Array.from(storeModels); const entries = Array.from(storeModels); const anchorIdx = entries.findIndex(([id]) => id === anchorModelIdOverride); if (anchorIdx <= 0) return entries; const reordered = [entries[anchorIdx], ...entries.slice(0, anchorIdx), ...entries.slice(anchorIdx + 1)]; return reordered; })(); for (const [modelId, model] of orderedModels) { const ds = model.ifcDataStore; if (!ds) continue; const effective = getEffectiveGeoreference( ds as IfcDataStore, model.geometryResult?.coordinateInfo, georefMutations.get(modelId), ); if ( effective?.projectedCRS?.name && effective.mapConversion && effective.source !== 'siteLocation' ) { const previewed = applyPlacementDraft(modelId, effective); return { ...previewed, sourceModelId: modelId, storeyElevations: ds.spatialHierarchy?.storeyElevations, }; } } // Fallback to legacy single-model if (ifcDataStore) { const effective = getEffectiveGeoreference( ifcDataStore as IfcDataStore, mergedGeometryResult?.coordinateInfo, georefMutations.get('__legacy__'), ); if ( effective?.projectedCRS?.name && effective.mapConversion && effective.source !== 'siteLocation' ) { const previewed = applyPlacementDraft('__legacy__', effective); return { ...previewed, sourceModelId: '__legacy__', storeyElevations: ifcDataStore.spatialHierarchy?.storeyElevations, }; } } return null; }, [ cesiumEnabled, solarEnabled, storeModels, ifcDataStore, georefMutations, mutationVersion, mergedGeometryResult, cesiumPlacementDraft, cesiumPlacementDraftModelId, anchorModelIdOverride, ]); // Feed the solar study's sun position into the WebGPU lighting environment // (viewer-space sun direction + panel readout when Cesium is off). useSolarEnvironment(georef); // Sweep animation runs here so collapsing/closing the panel doesn't stop it. useSolarSweep(); // Determine whether Cesium button should be visible (model has georef or user added it via mutations). // Runs independently of cesiumEnabled so the button appears/disappears reactively. useEffect(() => { function hasGeoref(): boolean { // Check federated models for (const [modelId, model] of storeModels) { const ds = model.ifcDataStore; if (!ds) continue; const effective = getEffectiveGeoreference( ds as IfcDataStore, model.geometryResult?.coordinateInfo, georefMutations.get(modelId), ); if (effective?.projectedCRS?.name && effective.source !== 'siteLocation') return true; } // Fallback to legacy single-model if (ifcDataStore) { const effective = getEffectiveGeoreference( ifcDataStore as IfcDataStore, mergedGeometryResult?.coordinateInfo, georefMutations.get('__legacy__'), ); if (effective?.projectedCRS?.name && effective.source !== 'siteLocation') return true; } return false; } setCesiumAvailable(hasGeoref()); }, [storeModels, ifcDataStore, georefMutations, mutationVersion, setCesiumAvailable, mergedGeometryResult]); // Sync the active Cesium source model ID so terrain actions are scoped correctly useEffect(() => { setCesiumSourceModelId(georef?.sourceModelId ?? null); }, [georef?.sourceModelId, setCesiumSourceModelId]); // Track drag enter/leave depth so the overlay doesn't flicker when the // cursor moves between child elements (each child boundary fires its own // dragenter/dragleave that bubbles to the container). See dragOverlayState.ts. const dragStateRef = useRef(initialDragOverlayState); const applyDragEvent = useCallback((event: DragOverlayEvent) => { dragStateRef.current = reduceDragOverlay(dragStateRef.current, event, webgpu.supported); setIsDragging(dragStateRef.current.dragging); }, [webgpu.supported]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); applyDragEvent('enter'); }, [applyDragEvent]); const handleDragOver = useCallback((e: React.DragEvent) => { // Needed to allow the drop, but does not toggle drag state (avoids flicker) e.preventDefault(); e.stopPropagation(); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); applyDragEvent('leave'); }, [applyDragEvent]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); applyDragEvent('drop'); // Block file loading if WebGPU not supported if (!webgpu.supported) { return; } // Filter to supported files (IFC, IFCX, GLB, point clouds) const allDropped = Array.from(e.dataTransfer.files); const supportedFiles = allDropped.filter( f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') || 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') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') ); if (supportedFiles.length === 0) { // Tell the user *why* — common case is a Recap project / SketchUp // file dropped because they assumed our viewer would understand it. const explained = allDropped.find((f) => describeUnsupportedFormat(f.name)); if (explained) { toast.error(`${explained.name}: ${describeUnsupportedFormat(explained.name)}`); } return; } recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size }))); void cacheFileBlobs(supportedFiles); setRecentFiles(getRecentFiles().slice(0, 3)); if (hasModelsLoaded) { // Models already loaded - add new files sequentially loadFilesSequentially(supportedFiles); } else if (supportedFiles.length === 1) { // Single file, no models loaded - use loadFile loadFile(supportedFiles[0]); } else { // Multiple files, no models loaded - use federation resetViewerState(); clearAllModels(); loadFilesSequentially(supportedFiles); } }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported, hasModelsLoaded]); const handleFileSelect = useCallback((e: React.ChangeEvent) => { // Block file loading if WebGPU not supported if (!webgpu.supported) { return; } const files = e.target.files; if (!files || files.length === 0) return; // Filter to supported files (IFC, IFCX, GLB) const supportedFiles = Array.from(files).filter( f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') || 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') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') ); if (supportedFiles.length === 0) return; recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size }))); void cacheFileBlobs(supportedFiles); setRecentFiles(getRecentFiles().slice(0, 3)); if (supportedFiles.length === 1) { // Single file - use loadFile (simpler single-model path) loadFile(supportedFiles[0]); } else { // Multiple files selected - use federation from the start // Clear everything and start fresh, then load sequentially resetViewerState(); clearAllModels(); loadFilesSequentially(supportedFiles); } // Reset input so same file can be selected again e.target.value = ''; }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported]); const handleStartBlank = useCallback(async () => { if (!webgpu.supported) return; const file = createBlankIfcFile(); // Must await: loadFile() calls resetViewerState() internally which // resets activeTool back to 'select'. Setting addElement before that // races and leaves the user in select mode despite the click. await loadFile(file); setActiveTool('addElement'); }, [webgpu.supported, loadFile, setActiveTool]); // Issue #540 "Merge Multilayer Walls" reload. The setting changes the produced // geometry, so it only takes on a re-load. Re-load the active model IN PLACE // from the File the store ALREADY retains on the model record // (`getActiveModel().sourceFile`, set by upsertModel at load time) — loadFile // re-snapshots `mergeLayers` from the store, so the toggle re-tessellates. // The earlier recent-files-blob-cache source was unreliable (its 150 MB cap // skips real models + a fire-and-forget write races the reload), so it fell to // window.location.reload() which DROPPED the model — the "nothing loads" blank. const handleMergeLayersReload = useCallback(async () => { const st = useViewerStore.getState(); const file = st.getActiveModel()?.sourceFile; console.log( '[merge-reload] start: mergeLayers=', st.mergeLayers, 'activeModel.sourceFile=', file ? `${file.name} (${file.size}B)` : 'NONE', ); st.clearMergeLayersPendingReload(); if (file) { try { console.log('[merge-reload] re-loading active model in place…'); await loadFile(file); const after = useViewerStore.getState(); console.log( '[merge-reload] loadFile resolved: meshes=', after.geometryResult?.meshes?.length ?? 0, 'models=', after.models?.size ?? 0, ); } catch (err) { console.error('[merge-reload] loadFile threw:', err); } } else if (typeof window !== 'undefined') { // No retained File (e.g. blank/new model) — fall back to a full reload // (the toggle is persisted, so the user re-opens the file). console.warn('[merge-reload] no active sourceFile — falling back to window.location.reload()'); window.location.reload(); } }, [loadFile]); const hasGeometry = mergedGeometryResult?.meshes && mergedGeometryResult.meshes.length > 0; // Check if any models are loaded (even if hidden) - used to show empty 3D vs starting UI const hasLoadedModels = storeModels.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0); // Does the rendered geometry carry any type-library geometry? geometryClass // 1 = orphan type, 2 = instanced type; class 0 = placed occurrence. The // Model/Types switch is only meaningful — and "Types" only renders anything — // when class 1/2 meshes exist, so we surface this to gate the toolbar control // (#957 follow-up). Scanned incrementally (O(batch)) and short-circuited once // any type mesh is seen, so the common occurrence-only model costs at most a // single linear pass that stops early. const typeGeoSourceRef = useRef(null); const typeGeoScanLenRef = useRef(0); const sawTypeGeometryRef = useRef(false); const hasTypeGeometry = useMemo(() => { const meshes = mergedGeometryResult?.meshes; if (!meshes || meshes.length === 0) { typeGeoSourceRef.current = meshes ?? null; typeGeoScanLenRef.current = meshes?.length ?? 0; sawTypeGeometryRef.current = false; return false; } // New source array, or it shrank (new file / replace) → rescan from scratch. if (typeGeoSourceRef.current !== meshes || meshes.length < typeGeoScanLenRef.current) { typeGeoSourceRef.current = meshes; typeGeoScanLenRef.current = 0; sawTypeGeometryRef.current = false; } if (!sawTypeGeometryRef.current) { for (let i = typeGeoScanLenRef.current; i < meshes.length; i++) { if ((meshes[i].geometryClass ?? 0) !== 0) { sawTypeGeometryRef.current = true; break; } } } typeGeoScanLenRef.current = meshes.length; return sawTypeGeometryRef.current; // geometryContentVersion bumps per streaming batch — picks up type geometry // that arrives in a later batch even when the meshes array is mutated in place. }, [mergedGeometryResult, geometryContentVersion]); // Persisted view mode may be 'types' from a prior model; fall back to 'model' // when the current geometry has no type library so "Types" never renders an // empty scene (and the now-hidden switch can't be used to recover). const effectiveViewMode = hasTypeGeometry ? typeViewMode : 'model'; // Publish to the store so the toolbar can hide the Model/Types switch when // there is no type geometry to reveal. useEffect(() => { setHasTypeGeometry(hasTypeGeometry); }, [hasTypeGeometry, setHasTypeGeometry]); // PERF: Incremental geometry filtering using refs. // Instead of creating a new 200K+ element array every batch (~200ms), // we push ONLY new meshes into a cached array — O(batch_size) not O(total). // A version counter triggers downstream re-renders via the Viewport prop. const filteredCacheRef = useRef([]); const filteredSourceLenRef = useRef(0); const filteredSourceRef = useRef(null); const filteredTypeVisRef = useRef(typeVisibility); const filteredTypeModeRef = useRef(effectiveViewMode); const filteredVersionRef = useRef(0); const filteredGeometry = useMemo(() => { if (!mergedGeometryResult?.meshes) { filteredCacheRef.current = []; filteredSourceLenRef.current = 0; filteredSourceRef.current = null; filteredVersionRef.current = 0; return null; } const allMeshes = mergedGeometryResult.meshes; const cache = filteredCacheRef.current; // Full rebuild if: type visibility changed, view mode changed, source shrunk // (new file), or empty cache const prevVis = filteredTypeVisRef.current; const typeVisChanged = prevVis.spaces !== typeVisibility.spaces || prevVis.spatialZones !== typeVisibility.spatialZones || prevVis.openings !== typeVisibility.openings || prevVis.virtualElements !== typeVisibility.virtualElements || prevVis.site !== typeVisibility.site || filteredTypeModeRef.current !== effectiveViewMode; const sourceChanged = filteredSourceRef.current !== allMeshes; if (typeVisChanged || sourceChanged || allMeshes.length < filteredSourceLenRef.current) { cache.length = 0; filteredSourceLenRef.current = 0; filteredSourceRef.current = allMeshes; filteredTypeVisRef.current = typeVisibility; filteredTypeModeRef.current = effectiveViewMode; } const needsFilter = !typeVisibility.spaces || !typeVisibility.spatialZones || !typeVisibility.openings || !typeVisibility.virtualElements || !typeVisibility.site; const prevCacheLen = cache.length; // Only process NEW meshes since last run — O(batch_size) not O(total) for (let i = filteredSourceLenRef.current; i < allMeshes.length; i++) { const mesh = allMeshes[i]; const ifcType = mesh.ifcType; // Model/Types view switch (#957 follow-up). geometryClass: 0 = occurrence, // 1 = orphan type (no occurrence — shown in BOTH modes since it's the only // geometry), 2 = instanced type-library shape. In 'model' mode hide class 2 // (else the AC20 duplicate boxes at the MappingOrigin reappear); in 'types' // mode hide occurrences (class 0) so only the type library shows. const geometryClass = mesh.geometryClass ?? 0; // Class 3 = a material-layer slice. It IS rendered in 3D (the renderer // draws it backface-culled so the build-up shows without the thin slabs // z-fighting into a hollow shell), so it's treated like an occurrence // (class 0) for the Model/Types switch. if (effectiveViewMode === 'types') { if (geometryClass === 0 || geometryClass === 3) continue; } else if (geometryClass === 2) { continue; } if (needsFilter) { if (ifcType === 'IfcSpace' && !typeVisibility.spaces) continue; if (ifcType === 'IfcSpatialZone' && !typeVisibility.spatialZones) continue; if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) continue; if (ifcType === 'IfcVirtualElement' && !typeVisibility.virtualElements) continue; if (ifcType === 'IfcSite' && !typeVisibility.site) continue; } // Mesh alpha flows through unchanged. The previous code re-multiplied // IfcSpace / IfcOpeningElement alpha down to <= 0.3 here, which stomped // lens / Pset colour rules even when the user explicitly chose alpha 1.0. // Defaults still come from styling.rs / default-materials.ts; the // renderer promotes overridden entities to the opaque pipeline so the // overlay paint pass finds matching depth. See issue #677. cache.push(mesh); } filteredSourceLenRef.current = allMeshes.length; // Only bump version when cache content actually changed — avoids // unnecessary downstream re-renders when memo runs with same data. if (cache.length !== prevCacheLen || typeVisChanged || sourceChanged) { filteredVersionRef.current++; } // Return the same array reference — downstream change detection uses // geometryVersion (which increments each batch) instead of array identity. return cache; }, [mergedGeometryResult, typeVisibility, effectiveViewMode]); // Version counter that changes every batch — triggers useGeometryStreaming // without requiring a new geometry array reference. const geometryVersion = filteredVersionRef.current; // Compute combined isolation set (storeys + manual isolation) // This is passed to the renderer for batch-level visibility filtering // Now supports multi-model: aggregates elements from all models for selected storeys // IMPORTANT: Returns globalIds (meshes use globalIds after federation registry transformation) const computedIsolatedIds = useMemo(() => { // Compute storey isolation if storeys are selected let storeyIsolation: Set | null = null; if (selectedStoreys.size > 0) { const combinedGlobalIds = new Set(); // Check each federated model's storeys for (const [, model] of storeModels) { const hierarchy = model.ifcDataStore?.spatialHierarchy; if (!hierarchy) continue; // Pass the relationship graph so storey isolation pulls in the parts of // any decomposing assembly (stair flights, railings, …) — they live off // the spatial tree via IfcRelAggregates and would otherwise vanish (#1133). const relationships = model.ifcDataStore?.relationships as AggregationRelationships | undefined; for (const storeyId of selectedStoreys) { const localStoreyId = hierarchy.byStorey.has(storeyId) ? storeyId : storeyId - (model.idOffset ?? 0); const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, localStoreyId, relationships); if (storeyElementIds) { for (const originalExpressId of storeyElementIds) { combinedGlobalIds.add(toGlobalIdFromModels(storeModels, model.id, originalExpressId)); } } } } // Legacy single-model mode (offset = 0) if (ifcDataStore?.spatialHierarchy && storeModels.size === 0) { const hierarchy = ifcDataStore.spatialHierarchy; const relationships = ifcDataStore.relationships as AggregationRelationships | undefined; for (const storeyId of selectedStoreys) { const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId, relationships); if (storeyElementIds) { for (const id of storeyElementIds) { combinedGlobalIds.add(id); } } } } if (combinedGlobalIds.size > 0) { storeyIsolation = combinedGlobalIds; } } // Collect all active filters and intersect them const filters: Set[] = []; if (storeyIsolation !== null) filters.push(storeyIsolation); if (classFilter !== null) filters.push(classFilter.ids); if (isolatedEntities !== null) filters.push(isolatedEntities); if (filters.length === 0) return null; if (filters.length === 1) return filters[0]; // Intersect all active filters — start from smallest for efficiency const sorted = filters.sort((a, b) => a.size - b.size); const intersection = new Set(); for (const id of sorted[0]) { if (sorted.every(s => s.has(id))) { intersection.add(id); } } return intersection; }, [storeModels, ifcDataStore, selectedStoreys, isolatedEntities, classFilter]); // Grid Pattern const GridPattern = () => ( <> {/* Light mode grid - subtle gray */}
{/* Dark mode grid - subtle blue/cyan tint */}
); // Empty state when no file is loaded at all (show starting UI) // But NOT when models are loaded but just hidden - in that case show empty 3D canvas if (!hasLoadedModels && !loading) { return (
{/* Drop overlay */} {isDragging && (

Drop File to Load

)} {/* WebGPU Not Supported Banner — compact on mobile */} {!webgpu.checking && !webgpu.supported && (
{/* Hazard stripes background */}
{/* Icon container with brutalist frame */}

WebGPU Not Available

This viewer requires WebGPU which is not supported by your browser or device. {webgpu.reason && ( {webgpu.reason} )}

Check Browser Support Chrome 113+ / Edge 113+ / Firefox 141+ / Safari 18+
{/* Troubleshooting Section */} {showTroubleshooting && (

Blocklist Override

WebGPU may be disabled due to GPU/driver blocklist. Try these flags:

chrome://flags/#enable-unsafe-webgpu → Enable

chrome://flags/#ignore-gpu-blocklist → Enable

Firefox

WebGPU enabled by default in Firefox 141+. For older versions:

about:configdom.webgpu.enabled → true

Safari

Safari → Settings → Feature Flags → Enable "WebGPU"

Verify Status

Check your GPU status page:

Chrome/Edge: chrome://gpu

Firefox: about:support

Full Troubleshooting Guide
)}
)} {/* Empty state content — mobile-optimized padding and scrollable */}
{/* Main Card */}
{/* Logo Section */}
{/* Back Layer */}
{/* Middle Layer - accent on hover */}
{/* Logo Container */}
IFClite Logo

IFClite

IFC toolkit for the open web

{/* Two-track action area: a primary "open file" track and a secondary "drive with LLM" track sit in mirrored slots — same width, same vertical rhythm, each followed by its own caption line. Reads as one balanced composition instead of a primary CTA + a tacked-on link, while keeping the file-open path visually dominant via the filled-on-hover treatment. */} {/* Track 1 — open / drag */}

{webgpu.supported ? 'or drag & drop anywhere' : 'file upload disabled'}

{/* Subtle "or" rule — anchors the symmetry between the two tracks */}
or
{/* Track 2 — two peer pills that both answer "I don't have a file to open": start a fresh project, or hand the wheel to an LLM via MCP. Both share the same dashed-pill silhouette so they read as siblings, with the file-open CTA above staying visually dominant. */}
Drive with any LLM

new untitled project · or LLM via MCP

{recentFiles.length > 0 && (
Recent Files
{recentFiles.map((file) => ( ))}
)}
{/* Feature Grid — hidden on mobile to save viewport space */}
{[ { icon: MousePointer, label: "Select", desc: "Inspect elements", accentClass: 'text-blue-500 dark:text-[#7aa2f7]' }, { icon: Layers, label: "Filter", desc: "Isolate storeys", accentClass: 'text-purple-500 dark:text-[#bb9af7]' }, { icon: Info, label: "Analyze", desc: "View properties", accentClass: 'text-cyan-500 dark:text-[#7dcfff]' } ].map((feature, i) => (

{feature.label}

{feature.desc}

))}
{/* Footer chips — left: discovery link to the marketing site for first-time visitors, right: shortcuts cue for power users. Both desktop-only. */}
SHORTCUTS ?
); } return (
{/* Drop overlay for when a file is already loaded - shows "Add Model" */} {isDragging && (

Add Model to Scene

Drop to federate with {models.size} existing model{models.size !== 1 ? 's' : ''}

)} {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas (web only) */} {cesiumEnabled && georef && ( )} {/* Sun & Sky panel — sky, lighting presets and the sun-path study. Self-anchored below the ViewCube (top-6 right-6 cube) at top-32 right-4 so it never covers navigation; draggable from its header (#1107). */} {cesiumEnabled && georef?.mapConversion && georef.baseMapConversion && ( )} {bcfOverlayVisible && } {/* Issue #540: non-modal "reload to apply" banner anchored to the top of the canvas. Only renders when the user has flipped the merge-layers toggle while a model is in scope. `onReload` re-loads the model in place (full page reload would drop it — no boot auto-restore). */}
); }