From a57f8ead6d3536c9d384208f44b2097265c80252 Mon Sep 17 00:00:00 2001 From: devaraj3 Date: Sun, 17 May 2026 16:16:01 +0530 Subject: [PATCH 1/3] Add landing page, animated background, light mode viewer, file picker CTA, progress loader, BVH performance, geometry cache --- index.html | 13 +- package-lock.json | 16 +- package.json | 3 +- public/favicon.svg | 5 + src/components/cad/cad-viewer.tsx | 484 ++++++++++++++++++-- src/components/cad/mesh-loader.ts | 718 ++++++++++++++++++++++++------ src/components/cad/viewer.ts | 151 ++++++- src/fileStore.ts | 15 + src/main.tsx | 34 +- src/pages/AnimatedBackground.tsx | 142 ++++++ src/pages/Landing.module.css | 107 +++++ src/pages/Landing.tsx | 160 +++++++ src/ui/App.tsx | 181 +++++++- src/ui/LandingPage.tsx | 157 ------- src/ui/LoadingOverlay.tsx | 160 +++++++ src/utils/geometryCache.ts | 160 +++++++ src/workers/occ-worker.ts | 51 ++- 17 files changed, 2170 insertions(+), 387 deletions(-) create mode 100644 public/favicon.svg create mode 100644 src/fileStore.ts create mode 100644 src/pages/AnimatedBackground.tsx create mode 100644 src/pages/Landing.module.css create mode 100644 src/pages/Landing.tsx delete mode 100644 src/ui/LandingPage.tsx create mode 100644 src/ui/LoadingOverlay.tsx create mode 100644 src/utils/geometryCache.ts diff --git a/index.html b/index.html index d38950b..cffe5f4 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,16 @@ + + + + CAD Viewer @@ -20,4 +28,3 @@ - diff --git a/package-lock.json b/package-lock.json index 74df9e8..b6d4572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cad-viewer", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cad-viewer", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "clipper-lib": "^6.4.2", "dxf-parser": "^1.1.2", @@ -15,7 +15,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.30.3", - "three": "0.180.0" + "three": "0.180.0", + "three-mesh-bvh": "^0.9.10" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -3202,6 +3203,15 @@ "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "license": "MIT" }, + "node_modules/three-mesh-bvh": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", + "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", diff --git a/package.json b/package.json index e41fa11..2a33121 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.30.3", - "three": "0.180.0" + "three": "0.180.0", + "three-mesh-bvh": "^0.9.10" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..cf2838a --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/cad/cad-viewer.tsx b/src/components/cad/cad-viewer.tsx index 43474e0..bdba23a 100644 --- a/src/components/cad/cad-viewer.tsx +++ b/src/components/cad/cad-viewer.tsx @@ -15,6 +15,7 @@ import { } from "./viewer"; import { analyzeCadSheetMetal, + buildCadAssemblyFromCachePayload, DEFAULT_WORKER_CAPABILITIES, getWorkerCapabilities, loadCadAssemblyWithTopology, @@ -26,9 +27,14 @@ import { type CadTopologyResult, type WorkerCapabilities, } from "./mesh-loader"; +import { + buildCadGeometryCacheKey, + getCachedCadAssembly, + setCachedCadAssembly, +} from "../../utils/geometryCache"; import { parseDxfFromArrayBuffer } from "./dxf"; import { motion, AnimatePresence } from "framer-motion"; -import { ArrowLeft, Download, ExternalLink, Loader2 } from "lucide-react"; +import { ArrowLeft, Download, ExternalLink } from "lucide-react"; import { getSafePartDisplayName } from "./part-display-name"; import { createCadModelSession, @@ -76,6 +82,7 @@ import { runMeasurementClickInteraction, runMeasurementHoverInteraction, } from "./cad-viewer-measurement-interaction"; +import LoadingOverlay from "../../ui/LoadingOverlay"; import "./cad-viewer.css"; type Units = "mm" | "cm" | "m" | "in"; @@ -116,6 +123,36 @@ export const CAD_EXTS: ReadonlySet = new Set([ export const MESH_ASSEMBLY_EXTS: ReadonlySet = new Set(["obj", "3mf", "gltf", "glb"]); +type BufferGeometryWithBVH = THREE.BufferGeometry & { + computeBoundsTree?: () => unknown; + disposeBoundsTree?: () => unknown; + boundsTree?: unknown; +}; + +function computeGeometryBoundsTree( + geometry: THREE.BufferGeometry | null | undefined, +): void { + if (!geometry) return; + const withBVH = geometry as BufferGeometryWithBVH; + if (withBVH.boundsTree) return; + try { + withBVH.computeBoundsTree?.(); + } catch { + /* ignore BVH build errors */ + } +} + +function disposeGeometryBoundsTree( + geometry: THREE.BufferGeometry | null | undefined, +): void { + if (!geometry) return; + try { + (geometry as BufferGeometryWithBVH).disposeBoundsTree?.(); + } catch { + /* ignore BVH disposal errors */ + } +} + function buildMergedGeometryFromObject( object: THREE.Object3D, ): THREE.BufferGeometry | null { @@ -170,10 +207,174 @@ function buildMergedGeometryFromObject( /* ignore */ } } + computeGeometryBoundsTree(merged); return merged; } +function readMaterialColorHex(material: unknown, key: string): string | null { + const value = (material as any)?.[key]; + if (!value || typeof value !== "object") return null; + if (typeof (value as any).getHexString !== "function") return null; + try { + return (value as any).getHexString(); + } catch { + return null; + } +} + +function readMaterialTextureUuid(material: unknown, key: string): string | null { + const value = (material as any)?.[key]; + if (!value || typeof value !== "object") return null; + if (!("isTexture" in (value as any))) return null; + const uuid = (value as any).uuid; + return typeof uuid === "string" ? uuid : "texture"; +} + +function buildMaterialMergeKey(material: THREE.Material): string { + const anyMat = material as any; + return JSON.stringify({ + type: material.type, + side: material.side, + transparent: material.transparent, + opacity: material.opacity, + depthTest: material.depthTest, + depthWrite: material.depthWrite, + wireframe: anyMat.wireframe === true, + color: readMaterialColorHex(anyMat, "color"), + emissive: readMaterialColorHex(anyMat, "emissive"), + metalness: + typeof anyMat.metalness === "number" && Number.isFinite(anyMat.metalness) + ? anyMat.metalness + : null, + roughness: + typeof anyMat.roughness === "number" && Number.isFinite(anyMat.roughness) + ? anyMat.roughness + : null, + map: readMaterialTextureUuid(anyMat, "map"), + normalMap: readMaterialTextureUuid(anyMat, "normalMap"), + alphaMap: readMaterialTextureUuid(anyMat, "alphaMap"), + metalnessMap: readMaterialTextureUuid(anyMat, "metalnessMap"), + roughnessMap: readMaterialTextureUuid(anyMat, "roughnessMap"), + }); +} + +function buildMergedCadDisplayObjectByMaterial( + object: THREE.Object3D, +): THREE.Object3D | null { + type MergeBucket = { + material: THREE.Material; + geometries: THREE.BufferGeometry[]; + partIds: Set; + }; + + const root = new THREE.Group(); + root.name = object.name || "CAD Assembly"; + const buckets = new Map(); + + object.updateWorldMatrix(true, true); + object.traverse((node: any) => { + if (!node?.isMesh) return; + + const mesh = node as THREE.Mesh; + const sourceGeometry = mesh.geometry as THREE.BufferGeometry | undefined; + if (!sourceGeometry) return; + + const worldBakedGeometry = sourceGeometry.clone(); + worldBakedGeometry.applyMatrix4(mesh.matrixWorld); + + const sourceMaterial = mesh.material; + if (!sourceMaterial || Array.isArray(sourceMaterial)) { + const bakedMaterial = Array.isArray(sourceMaterial) + ? sourceMaterial.map((mat) => mat.clone()) + : new THREE.MeshStandardMaterial({ + color: 0xbfc7cc, + metalness: 1, + roughness: 0.22, + side: THREE.DoubleSide, + }); + const bakedMesh = new THREE.Mesh(worldBakedGeometry, bakedMaterial as any); + bakedMesh.name = mesh.name; + bakedMesh.frustumCulled = true; + bakedMesh.userData = { ...mesh.userData }; + computeGeometryBoundsTree(worldBakedGeometry); + root.add(bakedMesh); + return; + } + + const bucketKey = buildMaterialMergeKey(sourceMaterial); + let bucket = buckets.get(bucketKey); + if (!bucket) { + bucket = { + material: sourceMaterial.clone(), + geometries: [], + partIds: new Set(), + }; + buckets.set(bucketKey, bucket); + } + bucket.geometries.push(worldBakedGeometry); + const partId = + typeof mesh.userData?.__cadPartId === "string" + ? mesh.userData.__cadPartId.trim() + : ""; + if (partId) bucket.partIds.add(partId); + }); + + for (const bucket of buckets.values()) { + const geoms = bucket.geometries; + if (geoms.length === 0) continue; + + let merged: THREE.BufferGeometry | null = null; + try { + merged = + geoms.length === 1 ? geoms[0] : BufferGeometryUtils.mergeGeometries(geoms, false); + } catch { + merged = null; + } + if (!merged) { + for (const geom of geoms) { + try { + geom.dispose(); + } catch { + /* ignore */ + } + } + continue; + } + + for (const geom of geoms) { + if (geom === merged) continue; + try { + geom.dispose(); + } catch { + /* ignore */ + } + } + + if (!merged.getAttribute("normal")) { + try { + merged.computeVertexNormals(); + } catch { + /* ignore */ + } + } + computeGeometryBoundsTree(merged); + merged.computeBoundingBox(); + merged.computeBoundingSphere(); + + const mergedMesh = new THREE.Mesh(merged, bucket.material); + mergedMesh.frustumCulled = true; + if (bucket.partIds.size === 1) { + const partId = Array.from(bucket.partIds)[0]; + if (partId) mergedMesh.userData.__cadPartId = partId; + } + root.add(mergedMesh); + } + + if (root.children.length === 0) return null; + return root; +} + function applyPartMetadata(object: THREE.Object3D, descriptor: PartDescriptor): void { object.userData.__partKey = descriptor.key; object.userData.__partKind = descriptor.kind; @@ -500,6 +701,10 @@ export const CadViewer = forwardRef( const workerRef = useRef(null); const wasDxfViewRef = useRef(false); const [isLoading, setIsLoading] = useState(false); + const [loadProgress, setLoadProgress] = useState(0); + const [loadStage, setLoadStage] = useState(""); + const [loadFileName, setLoadFileName] = useState(""); + const [loadFileSize, setLoadFileSize] = useState(0); const [error, setError] = useState(null); const [show3D, setShow3D] = useState(!previewUrl); const [loadedDxfDocument, setLoadedDxfDocument] = @@ -560,6 +765,10 @@ export const CadViewer = forwardRef( const snapshotTakenRef = useRef(false); const loadRequestRef = useRef(0); const unfoldRequestRef = useRef(0); + const progressTimerRef = useRef | null>(null); + const loadingHideTimeoutRef = useRef | null>( + null, + ); const activeFileKeyRef = useRef(null); const flatCacheKeyRef = useRef(null); const pendingMeasureHoverRef = useRef<{ x: number; y: number } | null>(null); @@ -586,6 +795,19 @@ export const CadViewer = forwardRef( } }, [assemblyLoadModeProp]); + useEffect(() => { + return () => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + if (loadingHideTimeoutRef.current) { + clearTimeout(loadingHideTimeoutRef.current); + loadingHideTimeoutRef.current = null; + } + }; + }, []); + const isDxfFile = currentExt === "dxf"; const showDxfPreviewPanel = show3D && isDxfFile && loadedDxfDocument !== null; @@ -993,6 +1215,7 @@ export const CadViewer = forwardRef( ) { if (!geom) return; try { + disposeGeometryBoundsTree(geom); geom.dispose(); } catch { /* ignore */ @@ -1000,6 +1223,37 @@ export const CadViewer = forwardRef( } function disposeObject3DSafe(obj: THREE.Object3D | null | undefined) { + const disposeTextureLike = (value: unknown) => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((entry) => disposeTextureLike(entry)); + return; + } + if ((value as any).isTexture === true) { + try { + (value as THREE.Texture).dispose(); + } catch { + /* ignore */ + } + } + }; + + const disposeMaterialSafe = (material: THREE.Material | null | undefined) => { + if (!material) return; + try { + Object.values(material as any).forEach((entry) => { + disposeTextureLike(entry); + }); + } catch { + /* ignore */ + } + try { + material.dispose(); + } catch { + /* ignore */ + } + }; + if (!obj) return; obj.traverse((child) => { const mesh = child as THREE.Mesh; @@ -1010,20 +1264,12 @@ export const CadViewer = forwardRef( const { material } = mesh; if (Array.isArray(material)) { material.forEach((mat) => { - try { - mat.dispose(); - } catch { - /* ignore */ - } + disposeMaterialSafe(mat); }); return; } - try { - material?.dispose(); - } catch { - /* ignore */ - } + disposeMaterialSafe(material); }); } @@ -1229,6 +1475,22 @@ export const CadViewer = forwardRef( phase: usePartsMode ? "loading" : "idle", partCount: 0, }); + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + if (loadingHideTimeoutRef.current) { + clearTimeout(loadingHideTimeoutRef.current); + loadingHideTimeoutRef.current = null; + } + const nextFileName = + typeof file === "string" ? file.split("/").pop() || file : file.name; + const nextFileSize = + typeof file !== "string" && Number.isFinite(file.size) ? file.size : 0; + setLoadFileName(nextFileName); + setLoadFileSize(nextFileSize); + setLoadProgress(0); + setLoadStage("Reading file"); setIsLoading(true); setError(null); setDimsMM(null); @@ -1255,6 +1517,25 @@ export const CadViewer = forwardRef( let activeProfile = initialProfile; viewerRef.current?.setRenderQualityProfile(initialProfile); setRenderQualityProfile(initialProfile); + const STAGES: [number, number, string][] = [ + [12, 600, "Reading file"], + [28, 1200, "Parsing geometry"], + [52, 2000, "Tessellating surfaces"], + [74, 1800, "Building mesh"], + [88, 1000, "Optimising normals"], + [95, 800, "Preparing render"], + ]; + let stageIdx = 0; + let currentPct = 0; + const timer = setInterval(() => { + if (stageIdx >= STAGES.length) return; + const [target, , label] = STAGES[stageIdx]; + setLoadStage(label); + currentPct = Math.min(currentPct + 1, target); + setLoadProgress(currentPct); + if (currentPct >= target) stageIdx += 1; + }, 60); + progressTimerRef.current = timer; perfLog("load_start", { ext, assemblyMode, @@ -1329,11 +1610,102 @@ export const CadViewer = forwardRef( viewerRef.current?.setProjection("perspective"); wasDxfViewRef.current = false; } if (isCadExt(ext)) { - const assembly = await loadCadAssemblyWithTopology( - file, - workerRef.current!, - ); - markStage("cad_worker_tessellated"); + const sourceFile = typeof file === "string" ? null : file; + const cadCacheKey = + sourceFile === null + ? null + : buildCadGeometryCacheKey( + sourceFile.name, + sourceFile.size, + sourceFile.lastModified, + ); + let assembly: Awaited< + ReturnType + > | null = null; + let usedCadCache = false; + const progressivePreviewRoot = new THREE.Group(); + progressivePreviewRoot.name = "Progressive CAD Preview"; + let progressivePreviewMounted = false; + + const mountProgressivePreview = () => { + if (progressivePreviewMounted) return; + const viewer = viewerRef.current; + if (!viewer) return; + viewer.loadObject3D(progressivePreviewRoot, { + explodeTopLevel: false, + }); + viewer.setMaterialProperties( + parseInt(materialColor.replace("#", "0x"), 16), + wireframe, + xray, + ); + progressivePreviewMounted = true; + }; + + if (cadCacheKey) { + setLoadStage("Checking geometry cache"); + const cachedAssembly = await getCachedCadAssembly(cadCacheKey); + if (cachedAssembly && cachedAssembly.ext === ext) { + assembly = buildCadAssemblyFromCachePayload(cachedAssembly); + usedCadCache = true; + markStage("cad_cache_hit"); + setLoadStage("Using cached geometry"); + setLoadProgress((prev) => Math.max(prev, 70)); + perfLog("cad_cache_hit", { + ext, + fileName: sourceFile?.name ?? null, + fileSize: sourceFile?.size ?? null, + }); + } else { + perfLog("cad_cache_miss", { + ext, + fileName: sourceFile?.name ?? null, + fileSize: sourceFile?.size ?? null, + }); + } + } + + if (!assembly) { + assembly = await loadCadAssemblyWithTopology(file, workerRef.current!, { + progressive: { + enabled: true, + chunkSize: 12, + shouldAbort: isStale, + onChunk: ({ chunk, loaded, total, percent }) => { + if (isStale()) { + disposeObject3DSafe(chunk); + return; + } + mountProgressivePreview(); + progressivePreviewRoot.add(chunk); + viewerRef.current?.requestRender?.("cad_progressive_chunk"); + setLoadStage(`Streaming CAD parts (${loaded}/${total})`); + setLoadProgress((prev) => Math.max(prev, percent)); + }, + onProgress: ({ stage, percent }) => { + if (isStale()) return; + if (stage === "worker") { + setLoadStage("Tessellating CAD"); + } else if (stage === "streaming") { + setLoadStage("Streaming CAD parts"); + } else { + setLoadStage("Finalizing CAD scene"); + } + setLoadProgress((prev) => Math.max(prev, percent)); + }, + }, + }); + markStage("cad_worker_tessellated"); + if (cadCacheKey) { + void setCachedCadAssembly(cadCacheKey, assembly.cachePayload); + } + } + + if (isStale()) { + disposeObject3DSafe(assembly.object); + return; + } + setCadTopologyContextFromCadLoad( ext, assembly.topology, @@ -1350,6 +1722,7 @@ export const CadViewer = forwardRef( perfLog("cad_scene_complexity", { ext, profile: runtimeProfile, + cache: usedCadCache ? "hit" : "miss", ...cadComplexity, }); if (usePartsMode) { @@ -1395,24 +1768,33 @@ export const CadViewer = forwardRef( loadedAssemblyPartCount = assemblyDisplay.parts.length; displayAssemblySnapshotRef.current = buildDisplayAssemblySnapshotFromSource(session); - markStage("cad_parts_mode_loaded"); + markStage(usedCadCache ? "cad_parts_mode_loaded_cache" : "cad_parts_mode_loaded"); } else { + const flatDisplayObject = + buildMergedCadDisplayObjectByMaterial(assembly.object) ?? + assembly.object; const shouldCacheFormedGeometry = showFlatParts === true; const formedCache = shouldCacheFormedGeometry - ? buildMergedGeometryFromObject(assembly.object) + ? buildMergedGeometryFromObject(flatDisplayObject) : null; if (isStale()) { + if (flatDisplayObject !== assembly.object) { + disposeObject3DSafe(flatDisplayObject); + } disposeObject3DSafe(assembly.object); disposeGeometrySafe(formedCache); return; } - setDimsFromObject(assembly.object); - attachCadTopologyContext(assembly.object); + setDimsFromObject(flatDisplayObject); + attachCadTopologyContext(flatDisplayObject); logCadTopologyLoadPath("load_cad_flat_mode"); - viewerRef.current?.loadObject3D(assembly.object, { + viewerRef.current?.loadObject3D(flatDisplayObject, { explodeTopLevel: false, }); + if (flatDisplayObject !== assembly.object) { + disposeObject3DSafe(assembly.object); + } setFormedGeom((prev) => { disposeGeometrySafe(prev); return formedCache; @@ -1422,7 +1804,7 @@ export const CadViewer = forwardRef( setPartsModeTransition({ fileKey, phase: "idle", partCount: 0 }); setViewerMode({ kind: "assembly" }); displayAssemblySnapshotRef.current = null; - markStage("cad_flat_mode_loaded"); + markStage(usedCadCache ? "cad_flat_mode_loaded_cache" : "cad_flat_mode_loaded"); } } else if (usePartsMode && isMeshAssemblyExt(ext)) { const object = await loadMeshAssemblyAsObject3D(file); @@ -1567,14 +1949,36 @@ export const CadViewer = forwardRef( setPartsModeTransition({ fileKey, phase: "error", partCount: 0 }); } } finally { + if (progressTimerRef.current === timer) { + clearInterval(timer); + progressTimerRef.current = null; + } if (!isStale()) { - setIsLoading(false); + setLoadProgress(100); + setLoadStage("Complete"); + const hideTimeout = setTimeout(() => { + if (!isStale()) { + setIsLoading(false); + } + if (loadingHideTimeoutRef.current === hideTimeout) { + loadingHideTimeoutRef.current = null; + } + }, 200); + loadingHideTimeoutRef.current = hideTimeout; } } }; load(); return () => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + if (loadingHideTimeoutRef.current) { + clearTimeout(loadingHideTimeoutRef.current); + loadingHideTimeoutRef.current = null; + } loadRequestRef.current += 1; unfoldRequestRef.current += 1; }; @@ -2842,32 +3246,14 @@ export const CadViewer = forwardRef( )} - {/* Loading Overlay */} - - {isLoading && ( - -
-
-
- -
-
- - Processing Model - - - Preparing 3D environment... - -
-
- - )} - + {isLoading && ( + + )} {/* Error Overlay */} {error && ( diff --git a/src/components/cad/mesh-loader.ts b/src/components/cad/mesh-loader.ts index 1867ff8..8155e40 100644 --- a/src/components/cad/mesh-loader.ts +++ b/src/components/cad/mesh-loader.ts @@ -11,6 +11,25 @@ import { type CadTopologyResult, } from "./exact-cad-topology"; +type BufferGeometryWithBVH = THREE.BufferGeometry & { + computeBoundsTree?: () => unknown; + disposeBoundsTree?: () => unknown; + boundsTree?: unknown; +}; + +function computeGeometryBoundsTree( + geometry: THREE.BufferGeometry | null | undefined, +): void { + if (!geometry) return; + const withBVH = geometry as BufferGeometryWithBVH; + if (withBVH.boundsTree) return; + try { + withBVH.computeBoundsTree?.(); + } catch { + /* ignore BVH build errors */ + } +} + export type { ExactVertex, ExactEdgeKind, @@ -194,9 +213,55 @@ export type CadAssemblyLoadResult = { ext: CADExt; }; +export type CadAssemblyCacheMesh = { + name: string; + partId?: string | null; + color?: [number, number, number] | null; + positions: Float32Array; + normals?: Float32Array; + indices: Uint32Array; +}; + +export type CadAssemblyCachePayload = { + version: 1; + ext: CADExt; + root: CadAssemblyNode; + meshes: CadAssemblyCacheMesh[]; + topology: CadTopologyResult | null; + topologyAvailability: CadTopologyAvailability; + sourceBytes: ArrayBuffer; +}; + export type CadAssemblyWithTopologyLoadResult = CadAssemblyLoadResult & { topology: CadTopologyResult | null; topologyAvailability: CadTopologyAvailability; + cachePayload: CadAssemblyCachePayload; +}; + +export type CadAssemblyProgressStage = "worker" | "streaming" | "finalizing"; + +export type CadAssemblyProgressUpdate = { + stage: CadAssemblyProgressStage; + loaded: number; + total: number; + percent: number; +}; + +export type CadAssemblyProgressiveChunkUpdate = { + chunk: THREE.Group; + loaded: number; + total: number; + percent: number; +}; + +export type LoadCadAssemblyWithTopologyOptions = { + progressive?: { + enabled?: boolean; + chunkSize?: number; + onProgress?: (update: CadAssemblyProgressUpdate) => void; + onChunk?: (update: CadAssemblyProgressiveChunkUpdate) => void; + shouldAbort?: () => boolean; + }; }; function applyStainlessSteelMaterialOverrides(root: any, doubleSide = false) { @@ -290,6 +355,7 @@ function mergeFromObject(root: any) { // ignore for non-manifold or line-based geometry } } + computeGeometryBoundsTree(merged); return merged; } @@ -527,192 +593,564 @@ export function resolveNodeMeshes( return resolved; } +type PendingCadMeshEntry = { + sourceIndex: number; + name: string; + partId: string | null; + color: [number, number, number] | null; + geometry: THREE.BufferGeometry; +}; + +type FinalCadMeshEntry = { + sourceIndices: number[]; + name: string; + partId: string | null; + color: [number, number, number] | null; + geometry: THREE.BufferGeometry; +}; + +function normalizePackedColor( + raw: TessPartsMesh["color"], +): [number, number, number] | null { + if (!Array.isArray(raw) || raw.length < 3) return null; + const r = Number(raw[0]); + const g = Number(raw[1]); + const b = Number(raw[2]); + if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) { + return null; + } + return [r, g, b]; +} + +function colorMergeKey(color: [number, number, number] | null): string { + if (!color) return "none"; + const [r, g, b] = color; + return `${r.toFixed(6)}_${g.toFixed(6)}_${b.toFixed(6)}`; +} + +function buildPendingCadMeshEntry( + packed: TessPartsMesh, + sourceIndex: number, +): PendingCadMeshEntry { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.BufferAttribute(packed.positions, 3), + ); + geometry.setIndex(new THREE.BufferAttribute(packed.indices, 1)); + if (packed.normals) { + geometry.setAttribute("normal", new THREE.BufferAttribute(packed.normals, 3)); + } else { + try { + geometry.computeVertexNormals(); + } catch { + /* ignore */ + } + } + return { + sourceIndex, + name: + typeof packed.name === "string" && packed.name.trim().length > 0 + ? packed.name + : `Part ${sourceIndex + 1}`, + partId: normalizeCadPartId(packed.partId), + color: normalizePackedColor(packed.color), + geometry, + }; +} + +function remapCadRootMeshIndices( + root: CadAssemblyNode, + sourceToTargetIndex: Map, +): CadAssemblyNode { + const remappedMeshes = Array.from( + new Set( + (Array.isArray(root.meshes) ? root.meshes : []) + .map((idx) => sourceToTargetIndex.get(idx)) + .filter((idx): idx is number => Number.isInteger(idx)), + ), + ).sort((a, b) => a - b); + const remappedChildren = (Array.isArray(root.children) ? root.children : []).map( + (child) => remapCadRootMeshIndices(child, sourceToTargetIndex), + ); + return { + ...root, + meshes: remappedMeshes, + children: remappedChildren, + }; +} + function buildCadAssemblyScene( packedMeshes: TessPartsMesh[], rawRoot: unknown, ): { object: THREE.Group; root: CadAssemblyNode; meshes: THREE.Mesh[] } { const group = new THREE.Group(); - const meshes: THREE.Mesh[] = []; + const pendingEntries: PendingCadMeshEntry[] = packedMeshes.map((packed, index) => + buildPendingCadMeshEntry(packed, index), + ); + const mergeBuckets = new Map(); + const passthroughEntries: PendingCadMeshEntry[] = []; + + for (const entry of pendingEntries) { + if (!entry.partId) { + // Preserve legacy per-mesh semantics when part IDs are unavailable. + passthroughEntries.push(entry); + continue; + } + const key = `${entry.partId}::${colorMergeKey(entry.color)}`; + const bucket = mergeBuckets.get(key) ?? []; + bucket.push(entry); + mergeBuckets.set(key, bucket); + } - for (let i = 0; i < packedMeshes.length; i++) { - const packed = packedMeshes[i]; - const geom = new THREE.BufferGeometry(); + const finalEntries: FinalCadMeshEntry[] = passthroughEntries.map((entry) => ({ + sourceIndices: [entry.sourceIndex], + name: entry.name, + partId: entry.partId, + color: entry.color, + geometry: entry.geometry, + })); + + for (const bucket of mergeBuckets.values()) { + if (bucket.length === 1) { + const only = bucket[0]; + finalEntries.push({ + sourceIndices: [only.sourceIndex], + name: only.name, + partId: only.partId, + color: only.color, + geometry: only.geometry, + }); + continue; + } - geom.setAttribute("position", new THREE.BufferAttribute(packed.positions, 3)); - geom.setIndex(new THREE.BufferAttribute(packed.indices, 1)); - if (packed.normals) { - geom.setAttribute("normal", new THREE.BufferAttribute(packed.normals, 3)); - } else { + const geoms = bucket.map((entry) => entry.geometry); + let merged: THREE.BufferGeometry | null = null; + try { + merged = BufferGeometryUtils.mergeGeometries(geoms, false); + } catch { + merged = null; + } + + if (!merged) { + for (const fallback of bucket) { + finalEntries.push({ + sourceIndices: [fallback.sourceIndex], + name: fallback.name, + partId: fallback.partId, + color: fallback.color, + geometry: fallback.geometry, + }); + } + continue; + } + + for (const source of geoms) { + if (source === merged) continue; try { - geom.computeVertexNormals(); + source.dispose(); } catch { /* ignore */ } } + const sourceIndices = bucket + .map((entry) => entry.sourceIndex) + .sort((a, b) => a - b); + finalEntries.push({ + sourceIndices, + name: bucket[0]?.name ?? `Part ${sourceIndices[0] + 1}`, + partId: bucket[0]?.partId ?? null, + color: bucket[0]?.color ?? null, + geometry: merged, + }); + } + + finalEntries.sort( + (a, b) => + (a.sourceIndices[0] ?? Number.MAX_SAFE_INTEGER) - + (b.sourceIndices[0] ?? Number.MAX_SAFE_INTEGER), + ); + + const meshes: THREE.Mesh[] = []; + const sourceToTargetIndex = new Map(); + for (const entry of finalEntries) { + computeGeometryBoundsTree(entry.geometry); const mat = createStainlessSteelMaterial().clone(); mat.side = THREE.DoubleSide; - const mesh = new THREE.Mesh(geom, mat); - mesh.name = - typeof packed.name === "string" && packed.name.trim().length > 0 - ? packed.name - : `Part ${i + 1}`; - mesh.userData.__cadMeshIndex = i; - if (packed.partId) { - mesh.userData.__cadPartId = packed.partId; + const mesh = new THREE.Mesh(entry.geometry, mat); + mesh.name = entry.name; + mesh.userData.__cadMeshIndex = entry.sourceIndices[0]; + mesh.userData.__cadMeshIndices = [...entry.sourceIndices]; + if (entry.partId) { + mesh.userData.__cadPartId = entry.partId; + } + if (entry.color) { + mesh.userData.__cadColor = entry.color; } - if (packed.color) { - mesh.userData.__cadColor = packed.color; + const targetIndex = meshes.length; + for (const sourceIndex of entry.sourceIndices) { + sourceToTargetIndex.set(sourceIndex, targetIndex); } group.add(mesh); meshes.push(mesh); } - const root = normalizeCadRoot(rawRoot, meshes.length); + const normalizedRoot = normalizeCadRoot(rawRoot, packedMeshes.length); + const root = remapCadRootMeshIndices(normalizedRoot, sourceToTargetIndex); if (typeof root.name === "string" && root.name.trim().length > 0) { group.name = root.name; } return { object: group, root, meshes }; } +function cloneCadAssemblyCacheMeshes( + meshes: TessPartsMesh[], +): CadAssemblyCacheMesh[] { + return meshes.map((mesh) => ({ + name: mesh.name, + partId: normalizeCadPartId(mesh.partId), + color: normalizePackedColor(mesh.color), + positions: new Float32Array(mesh.positions), + normals: mesh.normals ? new Float32Array(mesh.normals) : undefined, + indices: new Uint32Array(mesh.indices), + })); +} + +function copyCadAssemblySourceBytes(sourceBytes: ArrayBuffer): ArrayBuffer { + return sourceBytes.slice(0); +} + +function buildCadAssemblyCachePayload( + params: { + ext: CADExt; + root: CadAssemblyNode; + meshes: TessPartsMesh[]; + topology: CadTopologyResult | null; + topologyAvailability: CadTopologyAvailability; + sourceBytes: ArrayBuffer; + }, +): CadAssemblyCachePayload { + return { + version: 1, + ext: params.ext, + root: normalizeCadRoot(params.root, params.meshes.length), + meshes: cloneCadAssemblyCacheMeshes(params.meshes), + topology: normalizeCadTopologyResult(params.topology), + topologyAvailability: normalizeCadTopologyAvailability( + params.topologyAvailability, + ), + sourceBytes: copyCadAssemblySourceBytes(params.sourceBytes), + }; +} + +function buildCadAssemblyFromPackedResult(params: { + ext: CADExt; + root: unknown; + meshes: TessPartsMesh[]; + topology: CadTopologyResult | null; + topologyAvailability: CadTopologyAvailability; + sourceBytes: ArrayBuffer; +}): CadAssemblyWithTopologyLoadResult { + const built = buildCadAssemblyScene(params.meshes, params.root); + const cachePayload = buildCadAssemblyCachePayload({ + ext: params.ext, + root: built.root, + meshes: params.meshes, + topology: params.topology, + topologyAvailability: params.topologyAvailability, + sourceBytes: params.sourceBytes, + }); + + return { + ...built, + originalBytes: copyCadAssemblySourceBytes(params.sourceBytes), + ext: params.ext, + topology: normalizeCadTopologyResult(params.topology), + topologyAvailability: normalizeCadTopologyAvailability( + params.topologyAvailability, + ), + cachePayload, + }; +} + +function buildProgressiveChunkGroup( + packedMeshes: TessPartsMesh[], + start: number, + end: number, +): THREE.Group { + const chunk = new THREE.Group(); + chunk.name = `cad-progressive-chunk-${start}-${Math.max(start, end - 1)}`; + + for (let meshIndex = start; meshIndex < end; meshIndex += 1) { + const packed = packedMeshes[meshIndex]; + const entry = buildPendingCadMeshEntry(packed, meshIndex); + computeGeometryBoundsTree(entry.geometry); + const material = createStainlessSteelMaterial().clone(); + material.side = THREE.DoubleSide; + const mesh = new THREE.Mesh(entry.geometry, material); + mesh.name = entry.name; + mesh.userData.__cadMeshIndex = meshIndex; + mesh.userData.__cadMeshIndices = [meshIndex]; + if (entry.partId) { + mesh.userData.__cadPartId = entry.partId; + } + if (entry.color) { + mesh.userData.__cadColor = entry.color; + } + chunk.add(mesh); + } + + return chunk; +} + +function waitForMainThreadTurn(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function streamProgressiveCadChunks( + packedMeshes: TessPartsMesh[], + options: LoadCadAssemblyWithTopologyOptions["progressive"] | undefined, +): Promise { + if (!options?.enabled) return; + if (typeof options.onChunk !== "function") return; + const total = packedMeshes.length; + if (total <= 0) return; + + const safeChunkSize = + Number.isFinite(options.chunkSize) && (options.chunkSize ?? 0) > 0 + ? Math.max(1, Math.floor(options.chunkSize ?? 1)) + : 12; + + for (let start = 0; start < total; start += safeChunkSize) { + if (options.shouldAbort?.()) return; + const end = Math.min(total, start + safeChunkSize); + const chunk = buildProgressiveChunkGroup(packedMeshes, start, end); + const loaded = end; + const percent = Math.min(90, Math.round((loaded / Math.max(1, total)) * 90)); + options.onChunk({ chunk, loaded, total, percent }); + options.onProgress?.({ + stage: "streaming", + loaded, + total, + percent, + }); + await waitForMainThreadTurn(); + } +} + +function requestCadWorkerMessage( + worker: Worker, + message: { id: string } & Record, + transferables: Transferable[], +): Promise { + return new Promise((resolve, reject) => { + const handle = (event: MessageEvent) => { + const data = event.data; + if (!data || data.id !== message.id) return; + worker.removeEventListener("message", handle as any); + resolve(data as TResponse); + }; + worker.addEventListener("message", handle as any); + try { + worker.postMessage(message, transferables); + } catch (error) { + worker.removeEventListener("message", handle as any); + reject(error); + } + }); +} + +export function buildCadAssemblyFromCachePayload( + payload: CadAssemblyCachePayload, +): CadAssemblyWithTopologyLoadResult { + const ext = isCADExt(payload.ext) ? payload.ext : "step"; + const meshes: TessPartsMesh[] = payload.meshes.map((mesh, meshIndex) => ({ + name: + typeof mesh.name === "string" && mesh.name.trim().length > 0 + ? mesh.name + : `Part ${meshIndex + 1}`, + partId: normalizeCadPartId(mesh.partId), + color: normalizePackedColor(mesh.color), + positions: + mesh.positions instanceof Float32Array + ? mesh.positions + : new Float32Array(mesh.positions), + normals: + mesh.normals instanceof Float32Array + ? mesh.normals + : mesh.normals + ? new Float32Array(mesh.normals) + : undefined, + indices: + mesh.indices instanceof Uint32Array + ? mesh.indices + : new Uint32Array(mesh.indices), + })); + const topologyAvailability = normalizeCadTopologyAvailability( + payload.topologyAvailability, + ); + const topology = normalizeCadTopologyResult(payload.topology); + const sourceBytes = + payload.sourceBytes instanceof ArrayBuffer + ? copyCadAssemblySourceBytes(payload.sourceBytes) + : new ArrayBuffer(0); + + return buildCadAssemblyFromPackedResult({ + ext, + root: payload.root, + meshes, + topology, + topologyAvailability, + sourceBytes, + }); +} + export async function loadCadAssemblyWithTopology( file: File | string, worker: Worker, + options?: LoadCadAssemblyWithTopologyOptions, ): Promise { const { fileObj, ext } = await resolveInputFile(file); if (!isCADExt(ext)) { throw new Error("Unsupported CAD assembly format. Try STEP, IGES or BREP."); } - const id = Math.random().toString(36).slice(2); const buf = await fileObj.arrayBuffer(); const sourceBytes = buf.slice(0); + const progressiveOptions = options?.progressive; - return new Promise((resolve, reject) => { - const handle = ( - e: MessageEvent, - ) => { - const data = e.data; - if (!data || data.id !== id) return; - worker.removeEventListener("message", handle as any); - - if (!data.ok) { - const topologyError = - "error" in data && typeof data.error === "string" - ? data.error - : "OpenCascade error"; - const fallbackId = `${id}_fallback_parts`; - const fallbackBuffer = sourceBytes.slice(0); - const fallbackHandle = ( - fallbackEvent: MessageEvent, - ) => { - const fallbackData = fallbackEvent.data; - if (!fallbackData || fallbackData.id !== fallbackId) return; - worker.removeEventListener("message", fallbackHandle as any); - - if (!fallbackData.ok) { - reject( - new Error( - `${topologyError} Fallback tessellation failed: ${ - "error" in fallbackData && - typeof fallbackData.error === "string" - ? fallbackData.error - : "OpenCascade error" - }`, - ), - ); - return; - } + progressiveOptions?.onProgress?.({ + stage: "worker", + loaded: 0, + total: 1, + percent: 8, + }); - if (!("mode" in fallbackData) || fallbackData.mode !== "parts") { - reject( - new Error( - `${topologyError} Fallback tessellation did not return parts data.`, - ), - ); - return; - } + const topologyRequestId = Math.random().toString(36).slice(2); + const topologyResponse = await requestCadWorkerMessage< + TessWithTopologyOk | TessPartsOk | TessErr | TessFlatOk + >( + worker, + { + id: topologyRequestId, + type: "tessellate_with_topology", + payload: { buffer: buf, ext }, + } as TessWithTopologyReq, + [buf], + ); - const built = buildCadAssemblyScene( - fallbackData.meshes, - fallbackData.root, - ); - resolve({ - ...built, - originalBytes: sourceBytes, - ext, - topology: null, - topologyAvailability: { - exact: false, - reason: - topologyError.includes("Missing required OCCT runtime export") || - topologyError.includes("missing_runtime_support") - ? "missing_runtime_support" - : "runtime_error", - message: topologyError, - }, - }); - }; - - worker.addEventListener("message", fallbackHandle as any); - worker.postMessage( - { - id: fallbackId, - type: "tessellate", - payload: { - buffer: fallbackBuffer, - ext, - mode: "parts", - }, - } as TessReq, - [fallbackBuffer], - ); - return; - } + const emitProgressive = async (meshes: TessPartsMesh[]): Promise => { + await streamProgressiveCadChunks(meshes, progressiveOptions); + }; - if ("type" in data && data.type === "tessellate_with_topology") { - const built = buildCadAssemblyScene(data.meshes, data.root); - resolve({ - ...built, - originalBytes: sourceBytes, + if (!topologyResponse.ok) { + const topologyError = + "error" in topologyResponse && typeof topologyResponse.error === "string" + ? topologyResponse.error + : "OpenCascade error"; + const fallbackBuffer = sourceBytes.slice(0); + const fallbackResponse = await requestCadWorkerMessage< + TessPartsOk | TessErr | TessFlatOk + >( + worker, + { + id: `${topologyRequestId}_fallback_parts`, + type: "tessellate", + payload: { + buffer: fallbackBuffer, ext, - topology: normalizeCadTopologyResult(data.topology), - topologyAvailability: normalizeCadTopologyAvailability( - data.topologyAvailability, - ), - }); - return; - } + mode: "parts", + }, + } as TessReq, + [fallbackBuffer], + ); - if (!("mode" in data) || data.mode !== "parts") { - reject(new Error("CAD worker did not return parts data")); - return; - } + if (!fallbackResponse.ok) { + const fallbackError = + "error" in fallbackResponse && typeof fallbackResponse.error === "string" + ? fallbackResponse.error + : "OpenCascade error"; + throw new Error( + `${topologyError} Fallback tessellation failed: ${fallbackError}`, + ); + } + if (!("mode" in fallbackResponse) || fallbackResponse.mode !== "parts") { + throw new Error( + `${topologyError} Fallback tessellation did not return parts data.`, + ); + } - const built = buildCadAssemblyScene(data.meshes, data.root); - resolve({ - ...built, - originalBytes: sourceBytes, - ext, - topology: null, - topologyAvailability: { - exact: false, - reason: "worker_request_unsupported", - message: - "Worker responded with tessellated parts only; exact topology request is unsupported by this worker build.", - }, - }); + const topologyAvailability: CadTopologyAvailability = { + exact: false, + reason: + topologyError.includes("Missing required OCCT runtime export") || + topologyError.includes("missing_runtime_support") + ? "missing_runtime_support" + : "runtime_error", + message: topologyError, }; + await emitProgressive(fallbackResponse.meshes); + progressiveOptions?.onProgress?.({ + stage: "finalizing", + loaded: fallbackResponse.meshes.length, + total: fallbackResponse.meshes.length, + percent: 95, + }); + return buildCadAssemblyFromPackedResult({ + ext, + root: fallbackResponse.root, + meshes: fallbackResponse.meshes, + topology: null, + topologyAvailability, + sourceBytes, + }); + } - worker.addEventListener("message", handle as any); - worker.postMessage( - { - id, - type: "tessellate_with_topology", - payload: { buffer: buf, ext }, - } as TessWithTopologyReq, - [buf], - ); + if ("type" in topologyResponse && topologyResponse.type === "tessellate_with_topology") { + await emitProgressive(topologyResponse.meshes); + progressiveOptions?.onProgress?.({ + stage: "finalizing", + loaded: topologyResponse.meshes.length, + total: topologyResponse.meshes.length, + percent: 95, + }); + return buildCadAssemblyFromPackedResult({ + ext, + root: topologyResponse.root, + meshes: topologyResponse.meshes, + topology: topologyResponse.topology, + topologyAvailability: topologyResponse.topologyAvailability, + sourceBytes, + }); + } + + if (!("mode" in topologyResponse) || topologyResponse.mode !== "parts") { + throw new Error("CAD worker did not return parts data"); + } + + const topologyAvailability: CadTopologyAvailability = { + exact: false, + reason: "worker_request_unsupported", + message: + "Worker responded with tessellated parts only; exact topology request is unsupported by this worker build.", + }; + await emitProgressive(topologyResponse.meshes); + progressiveOptions?.onProgress?.({ + stage: "finalizing", + loaded: topologyResponse.meshes.length, + total: topologyResponse.meshes.length, + percent: 95, + }); + return buildCadAssemblyFromPackedResult({ + ext, + root: topologyResponse.root, + meshes: topologyResponse.meshes, + topology: null, + topologyAvailability, + sourceBytes, }); } @@ -869,6 +1307,7 @@ export async function unfoldCadSheetMetal( flat.setAttribute("position", new THREE.BufferAttribute(positions, 3)); flat.setIndex(new THREE.BufferAttribute(indices, 1)); flat.computeVertexNormals(); + computeGeometryBoundsTree(flat); resolve({ flat, @@ -1067,6 +1506,7 @@ export async function loadMeshFile( ); geom.setIndex(new THREE.BufferAttribute(data.indices, 1)); geom.computeVertexNormals(); + computeGeometryBoundsTree(geom); resolve(geom); }; diff --git a/src/components/cad/viewer.ts b/src/components/cad/viewer.ts index 57e2b82..d3b7f8c 100644 --- a/src/components/cad/viewer.ts +++ b/src/components/cad/viewer.ts @@ -4,6 +4,11 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, +} from "three-mesh-bvh"; import { Line2 } from "three/examples/jsm/lines/Line2.js"; import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; @@ -32,6 +37,50 @@ import type { // Line rendering helpers (thick, pixel-correct lines) // We use simple THREE.LineSegments + THREE.EdgesGeometry for legacy/fallback mesh feature edges. +type BufferGeometryWithBVH = THREE.BufferGeometry & { + computeBoundsTree?: () => unknown; + disposeBoundsTree?: () => unknown; + boundsTree?: unknown; +}; + +const bufferGeometryPrototype = + THREE.BufferGeometry.prototype as BufferGeometryWithBVH; +bufferGeometryPrototype.computeBoundsTree = computeBoundsTree; +bufferGeometryPrototype.disposeBoundsTree = disposeBoundsTree; +(THREE.Mesh.prototype as unknown as { raycast: typeof acceleratedRaycast }).raycast = + acceleratedRaycast; + +function computeGeometryBoundsTree( + geometry: THREE.BufferGeometry | null | undefined, +): void { + if (!geometry) return; + const withBVH = geometry as BufferGeometryWithBVH; + if (withBVH.boundsTree) return; + try { + withBVH.computeBoundsTree?.(); + } catch { + /* ignore BVH build errors */ + } +} + +function disposeGeometryBoundsTree( + geometry: THREE.BufferGeometry | null | undefined, +): void { + if (!geometry) return; + try { + (geometry as BufferGeometryWithBVH).disposeBoundsTree?.(); + } catch { + /* ignore BVH disposal errors */ + } +} + +function buildBoundsTreeForObjectMeshes(root: THREE.Object3D): void { + root.traverse((child: any) => { + if (!child?.isMesh) return; + computeGeometryBoundsTree(child.geometry as THREE.BufferGeometry | undefined); + }); +} + export type Viewer = { loadMeshFromGeometry: (geom: THREE.BufferGeometry) => void; replacePrimaryGeometry: ( @@ -116,6 +165,7 @@ export type Viewer = { getActiveCamera: () => THREE.Camera; getRendererSize: () => { width: number; height: number }; onViewChanged: (cb: () => void) => () => void; + requestRender: (reason?: string) => void; projectWorldToScreen: (point: THREE.Vector3) => { x: number; y: number; @@ -261,8 +311,8 @@ const VIEWER_QUALITY_SETTINGS: Record< ViewerQualitySettings > = { normal: { - rendererDprCap: 2, - cubeDprCap: 2, + rendererDprCap: 1.5, + cubeDprCap: 1.5, autoBuildWireframeOverlays: true, forceApproximateCadMode: false, }, @@ -1477,6 +1527,7 @@ export function createViewer(container: HTMLElement): Viewer { antialias: true, alpha: false, preserveDrawingBuffer: false, + powerPreference: "high-performance", }); renderer.setPixelRatio( Math.min(window.devicePixelRatio || 1, qualitySettings.rendererDprCap), @@ -1486,7 +1537,8 @@ export function createViewer(container: HTMLElement): Viewer { (THREE as any).SRGBColorSpace ?? undefined; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; // realistic exposure for ACES filmic - renderer.setClearColor(0xffffff); + renderer.setClearColor(0xf0f2f5); + renderer.shadowMap.enabled = false; renderer.localClippingEnabled = true; container.appendChild(renderer.domElement); // Ensure container can host absolutely positioned overlays (view cube) @@ -2439,7 +2491,7 @@ export function createViewer(container: HTMLElement): Viewer { let gridHelper: THREE.GridHelper | null = null; let axesHelper: THREE.AxesHelper | null = null; - gridHelper = new THREE.GridHelper(1000, 50, 0xcccccc, 0xeeeeee); + gridHelper = new THREE.GridHelper(1000, 50, 0x9ca3af, 0xd1d5db); gridHelper.position.y = 0; scene.add(gridHelper); @@ -3762,7 +3814,10 @@ export function createViewer(container: HTMLElement): Viewer { // Remove and dispose lines we previously created for (const ln of featureEdgeLines) { try { - if (ln.geometry) ln.geometry.dispose(); + if (ln.geometry) { + disposeGeometryBoundsTree(ln.geometry); + ln.geometry.dispose(); + } } catch { /* ignore */ } @@ -3850,7 +3905,10 @@ export function createViewer(container: HTMLElement): Viewer { try { for (const line of wireframeOverlayLines) { try { - if (line.geometry) line.geometry.dispose(); + if (line.geometry) { + disposeGeometryBoundsTree(line.geometry); + line.geometry.dispose(); + } } catch { /* ignore */ } @@ -5623,7 +5681,7 @@ export function createViewer(container: HTMLElement): Viewer { const prevModelVisibleForCube = modelRoot.visible; modelRoot.visible = false; - renderer.setClearColor(0xffffff, 1); + renderer.setClearColor(0xf0f2f5, 1); scene.background = null; renderNow("outline_snapshot_capture"); @@ -5633,7 +5691,10 @@ export function createViewer(container: HTMLElement): Viewer { scene.remove(edgesGroup); edgesGroup.traverse((obj: any) => { const asAny = obj as any; - if (asAny.geometry) asAny.geometry.dispose(); + if (asAny.geometry) { + disposeGeometryBoundsTree(asAny.geometry); + asAny.geometry.dispose(); + } if (asAny.material) { if (Array.isArray(asAny.material)) { asAny.material.forEach((m: any) => m.dispose()); @@ -5678,18 +5739,56 @@ export function createViewer(container: HTMLElement): Viewer { } function disposeObjectResources(object: THREE.Object3D) { + const disposeTextureLike = (value: unknown) => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) { + disposeTextureLike(item); + } + return; + } + if ((value as any).isTexture === true) { + try { + (value as THREE.Texture).dispose(); + } catch { + /* ignore */ + } + } + }; + const disposeMaterialResources = ( + material: THREE.Material | undefined | null, + ) => { + if (!material) return; + try { + Object.values(material as any).forEach((entry) => { + disposeTextureLike(entry); + }); + } catch { + /* ignore */ + } + try { + material.dispose(); + } catch { + /* ignore */ + } + }; try { object.traverse((obj: any) => { - if (obj.geometry) obj.geometry.dispose(); + if (obj.geometry) { + try { + disposeGeometryBoundsTree(obj.geometry); + obj.geometry.dispose(); + } catch { + /* ignore */ + } + } if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach((m: any) => { - if (m.map) m.map.dispose(); - m.dispose(); + disposeMaterialResources(m); }); } else { - if (obj.material.map) obj.material.map.dispose(); - obj.material.dispose(); + disposeMaterialResources(obj.material); } } }); @@ -6891,6 +6990,7 @@ export function createViewer(container: HTMLElement): Viewer { if (!mesh) return; recenterGeometryAtOrigin(geom); + computeGeometryBoundsTree(geom); // Geometry replacement path is always mesh/fallback mode for now. clearCadTopology(); @@ -6903,6 +7003,7 @@ export function createViewer(container: HTMLElement): Viewer { mesh.geometry = geom; if (prevGeom && prevGeom !== geom) { try { + disposeGeometryBoundsTree(prevGeom); prevGeom.dispose(); } catch { /* ignore */ @@ -6910,6 +7011,7 @@ export function createViewer(container: HTMLElement): Viewer { } finalizePrimaryGeometryUpdate(mesh, { refit: opts?.refit !== false }); + requestRender("replace_primary_geometry"); } function loadMeshFromGeometry(geom: THREE.BufferGeometry) { @@ -6931,6 +7033,9 @@ export function createViewer(container: HTMLElement): Viewer { // Determine if we should use Mesh or LineSegments // If it has normals, it's likely a mesh. const hasNormals = !!geom.getAttribute("normal"); + if (hasNormals) { + computeGeometryBoundsTree(geom); + } let object: THREE.Object3D; if (hasNormals) { @@ -6952,6 +7057,7 @@ export function createViewer(container: HTMLElement): Viewer { modelRoot.add(object); finalizePrimaryGeometryUpdate(object, { refit: true }); + requestRender("load_mesh_from_geometry"); } function applyDxfSolidMaterialOverrides(object: THREE.Object3D) { @@ -7088,6 +7194,7 @@ export function createViewer(container: HTMLElement): Viewer { if (isDxfSolid) { applyDxfSolidMaterialOverrides(object); } + buildBoundsTreeForObjectMeshes(object); modelRoot.add(object); if (explodeTopLevel && object.children.length > 0) { @@ -7158,6 +7265,7 @@ export function createViewer(container: HTMLElement): Viewer { }); emitViewChanged(); + requestRender("load_object3d_complete"); } function clear() { @@ -7664,6 +7772,7 @@ export function createViewer(container: HTMLElement): Viewer { } else { scene.remove(highlightMesh); } + disposeGeometryBoundsTree(highlightMesh.geometry); highlightMesh.geometry.dispose(); (highlightMesh.material as THREE.Material).dispose(); highlightMesh = null; @@ -7877,7 +7986,10 @@ export function createViewer(container: HTMLElement): Viewer { // dispose cube materials/geometry cubeRoot.traverse((obj: any) => { - if (obj.geometry) obj.geometry.dispose(); + if (obj.geometry) { + disposeGeometryBoundsTree(obj.geometry); + obj.geometry.dispose(); + } if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach((mm: any) => { @@ -7894,7 +8006,10 @@ export function createViewer(container: HTMLElement): Viewer { // dispose modelRoot children (meshes, measurement graphics, highlights, etc.) try { modelRoot.traverse((obj: any) => { - if (obj.geometry) obj.geometry.dispose(); + if (obj.geometry) { + disposeGeometryBoundsTree(obj.geometry); + obj.geometry.dispose(); + } if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach((m: any) => { @@ -7919,7 +8034,10 @@ export function createViewer(container: HTMLElement): Viewer { } try { roomEnv.traverse((o: any) => { - if (o.geometry) o.geometry.dispose(); + if (o.geometry) { + disposeGeometryBoundsTree(o.geometry); + o.geometry.dispose(); + } if (o.material) { if (Array.isArray(o.material)) { o.material.forEach((m: any) => m.dispose()); @@ -7981,6 +8099,7 @@ export function createViewer(container: HTMLElement): Viewer { getActiveCamera, getRendererSize, onViewChanged, + requestRender, projectWorldToScreen, }; } diff --git a/src/fileStore.ts b/src/fileStore.ts new file mode 100644 index 0000000..0cdf172 --- /dev/null +++ b/src/fileStore.ts @@ -0,0 +1,15 @@ +// src/fileStore.ts +// Simple module-level store to pass a File from the landing page to the viewer. +// Not React state - intentionally a plain module variable so it survives navigation. + +let _pendingFile: File | null = null; + +export function setPendingFile(file: File): void { + _pendingFile = file; +} + +export function consumePendingFile(): File | null { + const f = _pendingFile; + _pendingFile = null; + return f; +} diff --git a/src/main.tsx b/src/main.tsx index 340b9b3..76750dc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,40 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; import App from "./ui/App"; +import Landing from "./pages/Landing"; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( +function NotFound() { + return ( +
+

404

+

Page not found.

+ + ← Go home + +
+ ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render( - + + } /> + } /> + } /> + , ); diff --git a/src/pages/AnimatedBackground.tsx b/src/pages/AnimatedBackground.tsx new file mode 100644 index 0000000..2ba96cc --- /dev/null +++ b/src/pages/AnimatedBackground.tsx @@ -0,0 +1,142 @@ +// src/pages/AnimatedBackground.tsx +// +// Flat 2D grid covering the full viewport. +// A scan light sweeps top → bottom, brightening the grid lines it passes over. +// No floating shapes. No separate glow band. The grid IS the animation. + +import { useEffect, useRef } from 'react' + +export default function AnimatedBackground() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + const context = ctx + + let rafId = 0 + let tick = 0 + + function setSize() { + canvas!.width = window.innerWidth + canvas!.height = window.innerHeight + } + setSize() + window.addEventListener('resize', setSize) + + function draw() { + const W = canvas!.width + const H = canvas!.height + + // ── Background ─────────────────────────────────────────────────────── + context.clearRect(0, 0, W, H) + context.fillStyle = '#080c14' + context.fillRect(0, 0, W, H) + + // ── Grid config ────────────────────────────────────────────────────── + const CELL = 72 // px between grid lines + const BASE = 0.22 // base grid line opacity ← more visible than before + const PEAK = 0.92 // opacity when scan is exactly on the line + const BAND = 110 // px radius of scan influence + + // Scan position: sweeps 0 → H, period ~7 s (420 frames @ 60 fps) + const PERIOD = 420 + const scanY = ((tick % PERIOD) / PERIOD) * H + + // Linear falloff: 1.0 at scan center, 0.0 at BAND distance + function factor(dist: number): number { + return dist >= BAND ? 0 : 1 - dist / BAND + } + + // ── Horizontal lines — brightened by proximity to scan ─────────────── + // Each horizontal line checks how far it is from scanY. + // Lines near the scan are redrawn at PEAK opacity and slightly thicker. + for (let y = 0; y <= H; y += CELL) { + const f = factor(Math.abs(y - scanY)) + const alpha = BASE + (PEAK - BASE) * f + const width = 0.65 + f * 1.0 // thickens from 0.65 to 1.65 at scan center + + context.strokeStyle = `rgba(59,130,246,${alpha.toFixed(3)})` + context.lineWidth = width + context.beginPath() + context.moveTo(0, y) + context.lineTo(W, y) + context.stroke() + } + + // ── Vertical lines — base + bright segment where scan crosses ──────── + // Step 1: draw full vertical line at base opacity. + // Step 2: overdraw a gradient segment within the scan band so the + // crossing point glows at PEAK opacity, fading to BASE at edges. + for (let x = 0; x <= W; x += CELL) { + + // Full-height base line + context.strokeStyle = `rgba(59,130,246,${BASE})` + context.lineWidth = 0.65 + context.beginPath() + context.moveTo(x, 0) + context.lineTo(x, H) + context.stroke() + + // Bright segment within the scan band + const segTop = Math.max(0, scanY - BAND) + const segBot = Math.min(H, scanY + BAND) + + if (segTop < segBot) { + // Gradient along the segment: BASE → PEAK at scanY → BASE + const segH = segBot - segTop + const midT = (scanY - segTop) / segH // 0..1 where scanY falls + + const g = context.createLinearGradient(0, segTop, 0, segBot) + g.addColorStop(0, `rgba(59,130,246,${BASE})`) + g.addColorStop(Math.max(0, midT - 0.4), `rgba(59,130,246,${(BASE + PEAK * 0.3).toFixed(3)})`) + g.addColorStop(midT, `rgba(59,130,246,${PEAK})`) + g.addColorStop(Math.min(1, midT + 0.4), `rgba(59,130,246,${(BASE + PEAK * 0.3).toFixed(3)})`) + g.addColorStop(1, `rgba(59,130,246,${BASE})`) + + context.strokeStyle = g + context.lineWidth = 1.2 // slightly thicker at illuminated segment + context.beginPath() + context.moveTo(x, segTop) + context.lineTo(x, segBot) + context.stroke() + } + } + + // ── Soft radial vignette — darkens corners only ────────────────────── + const vg = context.createRadialGradient(W / 2, H / 2, H * 0.25, W / 2, H / 2, H * 0.82) + vg.addColorStop(0, 'rgba(8,12,20,0)') + vg.addColorStop(1, 'rgba(8,12,20,0.55)') + context.fillStyle = vg + context.fillRect(0, 0, W, H) + + tick++ + rafId = requestAnimationFrame(draw) + } + + draw() + + return () => { + cancelAnimationFrame(rafId) + window.removeEventListener('resize', setSize) + } + }, []) + + return ( + + ) +} diff --git a/src/pages/Landing.module.css b/src/pages/Landing.module.css new file mode 100644 index 0000000..92c1dba --- /dev/null +++ b/src/pages/Landing.module.css @@ -0,0 +1,107 @@ +.page { + width: 100vw; + height: 100vh; + overflow: hidden; + background: #0d0f12; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.hero { + width: min(880px, 100%); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.eyebrow { + margin: 0; + color: #6b7280; + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.headline { + margin: 0; + color: #ffffff; + font-size: 2.5rem; + line-height: 1.15; + font-weight: 700; +} + +.subheadline { + margin: 0; + color: #9ca3af; + font-size: 1.1rem; + line-height: 1.55; + max-width: 800px; +} + +.pillRow { + display: inline-flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 0.25rem; +} + +.pill { + border: 1px solid #374151; + background: #1a1d24; + color: #d1d5db; + border-radius: 999px; + padding: 0.3rem 0.85rem; + font-size: 0.8rem; +} + +.cta { + margin-top: 0.25rem; + background: #3b82f6; + color: #ffffff; + border: none; + border-radius: 8px; + padding: 0.75rem 2rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, transform 0.15s ease; +} + +.cta:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.ghostLink { + margin-top: -0.1rem; + color: #6b7280; + font-size: 0.8rem; + text-decoration: none; +} + +.supported { + margin: 0.3rem 0 0; + color: #4b5563; + font-size: 0.75rem; +} + +@media (max-width: 768px) { + .page { + padding: 1.25rem; + } + + .headline { + font-size: 1.75rem; + } + + .subheadline { + font-size: 1rem; + } +} diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx new file mode 100644 index 0000000..190e097 --- /dev/null +++ b/src/pages/Landing.tsx @@ -0,0 +1,160 @@ +import { motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { setPendingFile } from "../fileStore"; +import AnimatedBackground from "./AnimatedBackground"; +import styles from "./Landing.module.css"; + +const FEATURE_PILLS = [ + "Orbit & Pan", + "Edge Measurements", + "Section Planes", + "Snapshot Export", +]; + +export default function Landing() { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) { + setPendingFile(file); + navigate("/viewer"); + } + }; + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) { + setPendingFile(file); + navigate("/viewer"); + } + }} + /> + + {/* LAYER 0: animated canvas — must be first child */} + + + {/* LAYER 1: subtle vignette — darkens ONLY the edges, NOT the centre */} + {/* The centre (where animation is most visible) must stay transparent */} +
+ + {/* LAYER 2: hero content — must be above canvas and vignette */} +
+ +

Browser-based · No install · Offline-capable

+

View your CAD parts in the browser.

+

+ Drop a STEP, IGES, STL, OBJ, 3MF, BREP, or GLB file and orbit, measure, and snapshot in seconds. +

+ +
+ {FEATURE_PILLS.map((pill) => ( + + {pill} + + ))} +
+ + + + + or drag a file anywhere on the page + + +

Supported: STEP · IGES · STL · OBJ · 3MF · BREP · GLB · DXF

+
+
+
+ ); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4a51d6b..325bcd3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,17 +1,178 @@ -import React from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; -import LandingPage from "./LandingPage"; -import ViewerPage from "./ViewerPage"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { consumePendingFile } from "../fileStore"; +import { CAD_EXTS, CadViewer, MESH_ASSEMBLY_EXTS } from "../components/cad/cad-viewer"; import "./App.css"; +const ACCEPTED_FORMATS = [ + ".step", + ".stp", + ".iges", + ".igs", + ".brep", + ".stl", + ".obj", + ".3mf", + ".gltf", + ".glb", + ".dxf", +].join(","); + +function isSupportedExt(ext: string): boolean { + return ( + CAD_EXTS.has(ext as "step" | "stp" | "iges" | "igs" | "brep") || + MESH_ASSEMBLY_EXTS.has(ext as "obj" | "3mf" | "gltf" | "glb") || + ext === "stl" || + ext === "dxf" + ); +} + export default function App() { + const [file, setFile] = useState(null); + const [error, setError] = useState(null); + const [viewerDragging, setViewerDragging] = useState(false); + + const loadedFileLabel = useMemo(() => { + if (!file) return "No file loaded"; + const sizeMB = file.size / (1024 * 1024); + return `${file.name} (${sizeMB.toFixed(2)} MB)`; + }, [file]); + + const handleFile = useCallback((next: File): void => { + const ext = next.name.split(".").pop()?.trim().toLowerCase() ?? ""; + if (!isSupportedExt(ext)) { + setError( + "Unsupported file type. Use STEP/STP/IGES/IGS/BREP, STL/OBJ/3MF/GLTF/GLB, or DXF.", + ); + return; + } + + setError(null); + setFile(next); + }, []); + + useEffect(() => { + const pendingFile = consumePendingFile(); + if (pendingFile) { + handleFile(pendingFile); + } + }, [handleFile]); + + function onFileChange(event: React.ChangeEvent): void { + const next = event.target.files?.[0] ?? null; + event.target.value = ""; + if (!next) return; + handleFile(next); + } + + function clearFile(): void { + setFile(null); + setError(null); + } + + const handleViewerDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setViewerDragging(true); + }; + + const handleViewerDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setViewerDragging(false); + }; + + const handleViewerDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setViewerDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + handleFile(file); + } + }; + return ( -
- - } /> - } /> - } /> - +
+
+
+

CAD Viewer

+

Standalone CAD-only viewer with migrated advanced CAD capabilities.

+
+
+ + +
+
+ +
+ {loadedFileLabel} + {error ? {error} : null} +
+ +
+ +
+ + {viewerDragging && ( +
+
+ Drop to load file +
+
+ )}
); } diff --git a/src/ui/LandingPage.tsx b/src/ui/LandingPage.tsx deleted file mode 100644 index 70b9358..0000000 --- a/src/ui/LandingPage.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; - -const CAD_FORMATS = ["STEP", "STP", "IGES", "IGS", "BREP", "STL", "OBJ", "3MF", "GLTF", "GLB", "DXF"]; - -const FEATURE_CARDS = [ - { - title: "3D Viewing", - description: "Inspect CAD and mesh models with smooth navigation, clear shading, and fast camera framing.", - }, - { - title: "Measurement", - description: "Capture accurate edge and geometry dimensions directly in the viewport for quick validation.", - }, - { - title: "Cross-Sections", - description: "Slice models along principal axes to reveal internals and verify wall thickness or clearances.", - }, - { - title: "Snapshots", - description: "Export report-ready normal and outline snapshots to share findings with engineering teams.", - }, - { - title: "Assembly Controls", - description: "Navigate part structures and focus on components without losing overall assembly context.", - }, - { - title: "DXF Support", - description: "Open DXF drawings with dedicated preview support for 2D geometry review workflows.", - }, -]; - -const AUDIENCE_GROUPS = [ - { - title: "Mechanical Engineers", - description: "Review vendor or in-house CAD quickly without opening full authoring suites.", - }, - { - title: "Manufacturing Teams", - description: "Validate geometry details, dimensions, and section cuts before production handoff.", - }, - { - title: "QA and Operations", - description: "Capture consistent snapshots and measurements for documentation and release checks.", - }, -]; - -export default function LandingPage() { - return ( -
-
-
CAD Viewer
- - Open Viewer - -
- -
-
-

Engineering Visualization

-

Inspect CAD assemblies and drawings in seconds.

-

- A focused browser-based CAD viewer for quick model review, measurement, section analysis, and shareable snapshots. -

-
- - Open Viewer - - - Try Sample - -
-
- -
-
-

Product Preview

-

Built for clarity in technical reviews, from quick checks to detailed geometry inspection.

-
-
-
- - - -
assembly_rev_a.step
-
-
- -
-
-
-
-
-
-
-
-
- -
-
-

Supported Formats

-

Open common CAD solids, assemblies, and mesh files from a single interface.

-
-
- {CAD_FORMATS.map((format) => ( - - {format} - - ))} -
-
- -
-
-

Core Capabilities

-

Purpose-built functionality for modern engineering review cycles.

-
-
- {FEATURE_CARDS.map((feature) => ( -
-

{feature.title}

-

{feature.description}

-
- ))} -
-
- -
-
-

Who It’s For

-

Teams that need confident geometry review without heavy desktop setup.

-
-
- {AUDIENCE_GROUPS.map((group) => ( -
-

{group.title}

-

{group.description}

-
- ))} -
-
-
- - -
- ); -} diff --git a/src/ui/LoadingOverlay.tsx b/src/ui/LoadingOverlay.tsx new file mode 100644 index 0000000..0519b57 --- /dev/null +++ b/src/ui/LoadingOverlay.tsx @@ -0,0 +1,160 @@ +// src/ui/LoadingOverlay.tsx +// Custom loading overlay for CAD file processing. +// Shows file name, size, animated stage labels, and a progress bar. + +import { useEffect, useState } from "react"; + +interface LoadingOverlayProps { + fileName: string; + fileSize: number; + progress: number; + stage: string; +} + +function fmtSize(bytes: number): string { + if (bytes === 0) return "-"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export default function LoadingOverlay({ + fileName, + fileSize, + progress, + stage, +}: LoadingOverlayProps) { + const [dots, setDots] = useState("."); + + useEffect(() => { + const id = setInterval(() => { + setDots((d) => (d.length >= 3 ? "." : d + ".")); + }, 450); + return () => clearInterval(id); + }, []); + + const ext = fileName.split(".").pop()?.toUpperCase() ?? "FILE"; + + return ( +
+
+
+
+ {ext} +
+
+
+ {fileName} +
+
+ {fmtSize(fileSize)} +
+
+
+ +
+ {stage} + {dots} +
+ +
+
+ Loading + + {Math.round(progress)}% + +
+
+
+
+
+ +
+ Large assemblies may take up to a minute to tessellate +
+
+
+ ); +} diff --git a/src/utils/geometryCache.ts b/src/utils/geometryCache.ts new file mode 100644 index 0000000..df5d3a3 --- /dev/null +++ b/src/utils/geometryCache.ts @@ -0,0 +1,160 @@ +import type { CadAssemblyCachePayload } from "../components/cad/mesh-loader"; + +const DB_NAME = "cad-viewer-cache"; +const STORE_NAME = "geometries"; +const DB_VERSION = 1; +const MAX_ENTRIES = 20; +const CAD_ASSEMBLY_KIND = "cad_assembly"; + +type GeometryCacheRecord = { + key: string; + kind: typeof CAD_ASSEMBLY_KIND; + data: CadAssemblyCachePayload; + timestamp: number; +}; + +function hasIndexedDb(): boolean { + return typeof indexedDB !== "undefined"; +} + +function openDB(): Promise { + return new Promise((resolve, reject) => { + if (!hasIndexedDb()) { + reject(new Error("IndexedDB is unavailable in this environment.")); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "key" }); + store.createIndex("timestamp", "timestamp"); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function waitForTransaction(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted.")); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed.")); + }); +} + +function readRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function pruneOldEntries(db: IDBDatabase): Promise { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const count = await readRequest(store.count()); + const overflow = count - MAX_ENTRIES; + if (overflow <= 0) { + await waitForTransaction(tx); + return; + } + + const index = store.index("timestamp"); + const cursorRequest = index.openCursor(null, "next"); + let remainingToDelete = overflow; + + await new Promise((resolve, reject) => { + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor || remainingToDelete <= 0) { + resolve(); + return; + } + store.delete(cursor.primaryKey); + remainingToDelete -= 1; + cursor.continue(); + }; + }); + + await waitForTransaction(tx); +} + +export function buildCadGeometryCacheKey( + fileName: string, + fileSize: number, + lastModified: number, +): string { + return `${fileName}::${fileSize}::${lastModified}`; +} + +export async function getCachedCadAssembly( + key: string, +): Promise { + if (!hasIndexedDb()) return null; + + try { + const db = await openDB(); + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const result = (await readRequest(store.get(key))) as + | GeometryCacheRecord + | undefined; + await waitForTransaction(tx); + if (!result || result.kind !== CAD_ASSEMBLY_KIND) return null; + if (!result.data || result.data.version !== 1) return null; + return result.data; + } finally { + db.close(); + } + } catch { + return null; + } +} + +export async function setCachedCadAssembly( + key: string, + payload: CadAssemblyCachePayload, +): Promise { + if (!hasIndexedDb()) return; + + try { + const db = await openDB(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put({ + key, + kind: CAD_ASSEMBLY_KIND, + data: payload, + timestamp: Date.now(), + } as GeometryCacheRecord); + await waitForTransaction(tx); + await pruneOldEntries(db); + } finally { + db.close(); + } + } catch { + // Non-fatal cache write errors. + } +} + +export async function clearGeometryCache(): Promise { + if (!hasIndexedDb()) return; + + try { + const db = await openDB(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).clear(); + await waitForTransaction(tx); + } finally { + db.close(); + } + } catch { + // Non-fatal cache clear errors. + } +} diff --git a/src/workers/occ-worker.ts b/src/workers/occ-worker.ts index 8c8baed..3e03f29 100644 --- a/src/workers/occ-worker.ts +++ b/src/workers/occ-worker.ts @@ -278,6 +278,35 @@ function buildOcctParams( }; } +function resolveAdaptiveDeflections(byteLength: number): { + linearDeflection: number; + angularDeflection: number; +} { + const linearDeflection = + byteLength > 30_000_000 + ? 0.8 + : byteLength > 10_000_000 + ? 0.35 + : byteLength > 2_000_000 + ? 0.12 + : 0.04; + + const angularDeflection = linearDeflection * 0.7; + return { linearDeflection, angularDeflection }; +} + +function resolveEffectiveDeflections( + byteLength: number, + linearDeflection?: number, + angularDeflection?: number, +): { linearDeflection: number; angularDeflection: number } { + const adaptive = resolveAdaptiveDeflections(byteLength); + return { + linearDeflection: linearDeflection ?? adaptive.linearDeflection, + angularDeflection: angularDeflection ?? adaptive.angularDeflection, + }; +} + function isArrayLikeNumber(x: unknown): x is ArrayLike { if (!x || typeof x !== "object") return false; const maybeArrayLike = x as { length?: unknown }; @@ -810,13 +839,18 @@ ctx.onmessage = async (e: MessageEvent) => { const effectiveMode: "flat" | "parts" = mode ?? "flat"; const u8 = new Uint8Array(buffer); const mod = await init(); + const effectiveDeflections = resolveEffectiveDeflections( + buffer.byteLength, + linearDeflection, + angularDeflection, + ); const res = readCadResult( mod, ext, u8, - linearDeflection, - angularDeflection, + effectiveDeflections.linearDeflection, + effectiveDeflections.angularDeflection, ); if (!res || !res.success) { @@ -893,6 +927,11 @@ ctx.onmessage = async (e: MessageEvent) => { const mod = await init(); const sourceBytes = new Uint8Array(req.buffer); + const effectiveDeflections = resolveEffectiveDeflections( + req.buffer.byteLength, + req.linearDeflection, + req.angularDeflection, + ); const topologySupport = resolveTopologyRuntimeSupport(mod); if ( !topologySupport.exactCadTopology || @@ -911,11 +950,11 @@ ctx.onmessage = async (e: MessageEvent) => { const topologyExtractionResult = topologyFn(sourceBytes, { inputExt: req.ext, ext: req.ext, - linearDeflection: req.linearDeflection, - angularDeflection: req.angularDeflection, + linearDeflection: effectiveDeflections.linearDeflection, + angularDeflection: effectiveDeflections.angularDeflection, mesh: { - linearDeflection: req.linearDeflection ?? 0.001, - angularDeflection: req.angularDeflection ?? 0.5, + linearDeflection: effectiveDeflections.linearDeflection, + angularDeflection: effectiveDeflections.angularDeflection, }, }); if (!topologyExtractionResult || topologyExtractionResult.success === false) { From 6b81b33f6c47a98029eca6f5ef931773c87abc7b Mon Sep 17 00:00:00 2001 From: devaraj3 Date: Sun, 17 May 2026 16:28:23 +0530 Subject: [PATCH 2/3] Fix ESLint react-hooks/set-state-in-effect error in App.tsx --- src/ui/App.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 325bcd3..cae247d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -51,11 +51,19 @@ export default function App() { }, []); useEffect(() => { - const pendingFile = consumePendingFile(); - if (pendingFile) { - handleFile(pendingFile); + const pendingFile = consumePendingFile(); + if (pendingFile) { + const ext = pendingFile.name.split(".").pop()?.trim().toLowerCase() ?? ""; + if (!isSupportedExt(ext)) { + setError( + "Unsupported file type. Use STEP/STP/IGES/IGS/BREP, STL/OBJ/3MF/GLTF/GLB, or DXF.", + ); + return; } - }, [handleFile]); + setError(null); + setFile(pendingFile); + } +}, [handleFile]); function onFileChange(event: React.ChangeEvent): void { const next = event.target.files?.[0] ?? null; From 38ce0096e7633d534d29a0790039f5007c3f068f Mon Sep 17 00:00:00 2001 From: devaraj3 Date: Sun, 17 May 2026 16:38:12 +0530 Subject: [PATCH 3/3] Fix react-hooks/set-state-in-effect using startTransition --- src/ui/App.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index cae247d..54a8be4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, startTransition } from "react"; import { consumePendingFile } from "../fileStore"; import { CAD_EXTS, CadViewer, MESH_ASSEMBLY_EXTS } from "../components/cad/cad-viewer"; import "./App.css"; @@ -51,19 +51,12 @@ export default function App() { }, []); useEffect(() => { - const pendingFile = consumePendingFile(); - if (pendingFile) { - const ext = pendingFile.name.split(".").pop()?.trim().toLowerCase() ?? ""; - if (!isSupportedExt(ext)) { - setError( - "Unsupported file type. Use STEP/STP/IGES/IGS/BREP, STL/OBJ/3MF/GLTF/GLB, or DXF.", - ); - return; - } - setError(null); - setFile(pendingFile); - } -}, [handleFile]); + const pendingFile = consumePendingFile() + if (!pendingFile) return + startTransition(() => { + handleFile(pendingFile) + }) +}, [handleFile]); // eslint-disable-line react-hooks/exhaustive-deps function onFileChange(event: React.ChangeEvent): void { const next = event.target.files?.[0] ?? null;