├── src ├── vite-env.d.ts ├── components │ ├── worker.tsx │ ├── signal.ts │ ├── Scene.tsx │ ├── screen-culler-helper.ts │ ├── BimModel.ts │ ├── culler-renderer.ts │ ├── IfcTileLoader.ts │ ├── CustomIfcStreamer.ts │ └── geometry-culler-renderer.ts ├── App.css ├── main.tsx ├── Component.tsx ├── index.css ├── App.tsx └── assets │ └── react.svg ├── postcss.config.js ├── tsconfig.node.json ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── tailwind.config.js ├── README.md ├── vite.config.ts └── package.json /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/worker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@react-three/offscreen"; 3 | import Scene from "./Scene"; 4 | 5 | render(); 6 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/icon?family=Material+Icons"); 2 | html, 3 | body, 4 | #root { 5 | margin: 0; 6 | padding: 0; 7 | width: 100%; 8 | height: 100%; 9 | overflow: hidden; 10 | position: relative; 11 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vscode 26 | .env 27 | public/ -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite + React + TS 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Component.tsx: -------------------------------------------------------------------------------- 1 | import React, {lazy} from "react"; 2 | import {Canvas} from "@react-three/offscreen"; 3 | const Scene = lazy(() => import("./components/Scene")); 4 | 5 | const worker = new Worker(new URL("./components/worker.tsx", import.meta.url), { 6 | type: "module", 7 | credentials: "include", 8 | }); 9 | 10 | const Component = () => { 11 | return ( 12 | } 16 | /> 17 | ); 18 | }; 19 | 20 | export default Component; 21 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], 5 | ignorePatterns: ["dist", ".eslintrc.cjs"], 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["react-refresh"], 8 | rules: { 9 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 10 | "@typescript-eslint/no-unused-vars": "off", 11 | "@typescript-eslint/ban-ts-comment": "off", 12 | "@typescript-eslint/no-explicit-any": "off", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/signal.ts: -------------------------------------------------------------------------------- 1 | import {effect, signal} from "@preact/signals-react"; 2 | import * as FRAGS from "@thatopen/fragments"; 3 | 4 | export const fileSignal = signal(null); 5 | export const groupsSignal = signal([]); 6 | 7 | const isBrowser = typeof window !== "undefined"; 8 | const settings = "App_settings"; 9 | const getDefault = () => { 10 | //@ts-ignore 11 | if (!isBrowser) return false; 12 | const setting = window.localStorage.getItem(settings); 13 | if (!setting) { 14 | const loadAsTile = false; 15 | return loadAsTile; 16 | } else { 17 | return JSON.parse(setting).loadAsTile || false; 18 | } 19 | }; 20 | export const loadAsTileSignal = signal(getDefault()); 21 | 22 | effect(() => { 23 | const loadAsTile = loadAsTileSignal.value; 24 | //@ts-ignore 25 | if (isBrowser) 26 | window.localStorage.setItem(settings, JSON.stringify({loadAsTile})); 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": false, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "preserveSymlinks": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react", 19 | "typeRoots": ["./node_modules/@types","vite/client"], 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noImplicitAny": false, 26 | "declaration": true, 27 | "declarationDir": "type", 28 | "baseUrl": ".", 29 | }, 30 | "include": ["src"], 31 | "references": [{ "path": "./tsconfig.node.json" }] 32 | } 33 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | white: "#F2F2F2", 8 | black: "#0D0D0D", 9 | halfBlack: "#2a2a2a", 10 | error: "#FF5252", 11 | warning: "#FB8C00", 12 | success: "#4CAF50", 13 | }, 14 | borderWidth: { 15 | 1: "1px", 16 | }, 17 | fontSize: { 18 | xs: ["12px", { lineHeight: "1rem" }], 19 | sm: ["14px", { lineHeight: "1.25rem" }], 20 | base: ["16px", { lineHeight: "1.5rem" }], 21 | lg: ["18px", { lineHeight: "1.75rem" }], 22 | xl: ["20px", { lineHeight: "1.75rem" }], 23 | "2xl": ["22px", { lineHeight: "2rem" }], 24 | "3xl": ["24px", { lineHeight: "2.25rem" }], 25 | "4xl": ["26px", { lineHeight: "2.5rem" }], 26 | "5xl": ["28px", { lineHeight: "1" }], 27 | "6xl": ["30px", { lineHeight: "1" }], 28 | "7xl": ["32px", { lineHeight: "1" }], 29 | "8xl": ["34px", { lineHeight: "1" }], 30 | "9xl": ["36px", { lineHeight: "1" }], 31 | }, 32 | zIndex: { 33 | 2: "2", 34 | 3: "3", 35 | 1000: "1000", 36 | 2000: "2000", 37 | 3000: "3000", 38 | }, 39 | }, 40 | }, 41 | plugins: [], 42 | }; 43 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @layer base { 5 | h1 { 6 | @apply text-2xl; 7 | @apply mb-5; 8 | } 9 | h2 { 10 | @apply text-xl; 11 | @apply mb-4; 12 | } 13 | h3 { 14 | @apply text-lg; 15 | @apply mb-3; 16 | } 17 | h4 { 18 | @apply text-sm; 19 | @apply mb-2; 20 | } 21 | h5 { 22 | @apply text-xs; 23 | @apply mb-2; 24 | } 25 | p { 26 | @apply mb-2; 27 | } 28 | input { 29 | height: 20px!important; 30 | border-radius: 5px; 31 | } 32 | input:disabled { 33 | opacity: 0.8; 34 | } 35 | } 36 | 37 | * { 38 | margin: 0; 39 | padding: 0; 40 | font-size: 10px; 41 | line-height: 10px; 42 | } 43 | 44 | ::-webkit-scrollbar { 45 | width: 4px; 46 | height: 4px; 47 | } 48 | 49 | 50 | /* Track */ 51 | 52 | ::-webkit-scrollbar-track { 53 | background: gainsboro; 54 | border-radius: 4px; 55 | } 56 | 57 | 58 | /* Handle */ 59 | 60 | ::-webkit-scrollbar-thumb { 61 | background: black; 62 | border-radius: 4px; 63 | } 64 | 65 | 66 | /* Handle on hover */ 67 | 68 | ::-webkit-scrollbar-thumb:hover { 69 | background: #555; 70 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, loadEnv} from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | // https://vitejs.dev/config/ 5 | //@ts-ignore 6 | export default defineConfig(() => { 7 | // Load env file based on `mode` in the current working directory. 8 | // Set the third parameter to '' to load all env regardless of the `VITE_` prefix. 9 | const env = loadEnv("development", process.cwd(), ""); 10 | return { 11 | // vite config 12 | 13 | plugins: [react({fastRefresh: false})], 14 | 15 | worker: { 16 | plugins: [react({fastRefresh: false})], 17 | }, 18 | server: { 19 | port: env.PORT, // set port 20 | }, 21 | esbuild: { 22 | jsxFactory: "React.createElement", 23 | jsxFragment: "React.Fragment", 24 | }, 25 | resolve: { 26 | alias: { 27 | "~": path.resolve(__dirname, "./src"), 28 | "@assets": path.resolve(__dirname, "./src/assets"), 29 | "@BimModel": path.resolve(__dirname, "./src/BimModel"), 30 | "@components": path.resolve(__dirname, "./src/components"), 31 | }, 32 | }, 33 | base: "./", 34 | build: { 35 | outDir: "dist", 36 | }, 37 | test: { 38 | global: true, 39 | includeSource: ["src/**/*.{js,ts}"], 40 | environment: "jsdom", 41 | setupFiles: "./src/test/setup.ts", 42 | CSS: true, 43 | }, 44 | }; 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/Scene.tsx: -------------------------------------------------------------------------------- 1 | import * as FRAGS from "@thatopen/fragments"; 2 | import React, {useEffect, useRef} from "react"; 3 | import {useFrame} from "@react-three/fiber"; 4 | import {ContactShadows, Environment, CameraControls} from "@react-three/drei"; 5 | import {Perf} from "r3f-perf"; 6 | import {fileSignal, groupsSignal} from "./signal"; 7 | import {BimModel} from "./BimModel"; 8 | import {useSignals} from "@preact/signals-react/runtime"; 9 | 10 | const SceneModel = () => { 11 | useSignals(); 12 | const controlRef = useRef(null); 13 | useEffect(() => { 14 | if (!controlRef.current) return; 15 | const bim = new BimModel(controlRef.current); 16 | return () => { 17 | fileSignal.value = null; 18 | bim.dispose(); 19 | }; 20 | }, []); 21 | useFrame((_state, _delta) => {}); 22 | 23 | return ( 24 | <> 25 | {groupsSignal.value.map((group: FRAGS.FragmentsGroup) => ( 26 | 27 | ))} 28 | 29 | 37 | 38 | ); 39 | }; 40 | 41 | export default function App() { 42 | const isDev = import.meta.env.DEV; 43 | return ( 44 | <> 45 | {isDev && } 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offscreencanvas", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --open", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "watch": "rollup -c rollup.config.mjs -w" 12 | }, 13 | "dependencies": { 14 | "@preact/signals-react": "^2.2.0", 15 | "@react-three/drei": "^9.111.5", 16 | "@react-three/fiber": "^8.17.6", 17 | "@react-three/offscreen": "^0.0.8", 18 | "@react-three/postprocessing": "^2.16.2", 19 | "@react-three/xr": "^6.2.3", 20 | "@thatopen/components": "^2.2.9", 21 | "@thatopen/components-front": "^2.2.2", 22 | "@thatopen/fragments": "^2.2.0", 23 | "axios": "^1.7.7", 24 | "r3f-perf": "^7.2.1", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "react-toastify": "^9.1.3", 28 | "three": "0.160.1", 29 | "web-ifc": "0.0.57" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20.8.10", 33 | "@types/react": "^18.2.15", 34 | "@types/react-dom": "^18.2.7", 35 | "@types/three": "0.160.0", 36 | "@typescript-eslint/eslint-plugin": "^6.0.0", 37 | "@typescript-eslint/parser": "^6.0.0", 38 | "@vitejs/plugin-react": "^4.0.3", 39 | "autoprefixer": "^10.4.16", 40 | "eslint": "^8.45.0", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "eslint-plugin-react-refresh": "^0.4.3", 43 | "postcss": "^8.4.31", 44 | "tailwindcss": "^3.3.5", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.4.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/screen-culler-helper.ts: -------------------------------------------------------------------------------- 1 | // Thanks to the advice here https://github.com/zalo/TetSim/commit/9696c2e1cd6354fb9bd40dbd299c58f4de0341dd 2 | 3 | function clientWaitAsync( 4 | gl: WebGL2RenderingContext, 5 | sync: WebGLSync, 6 | flags: any, 7 | intervalMilliseconds: number, 8 | ) { 9 | return new Promise((resolve, reject) => { 10 | function test() { 11 | const res = gl.clientWaitSync(sync, flags, 0); 12 | if (res === gl.WAIT_FAILED) { 13 | reject(); 14 | return; 15 | } 16 | if (res === gl.TIMEOUT_EXPIRED) { 17 | setTimeout(test, intervalMilliseconds); 18 | return; 19 | } 20 | resolve(); 21 | } 22 | 23 | test(); 24 | }); 25 | } 26 | 27 | async function getBufferSubDataAsync( 28 | gl: WebGL2RenderingContext, 29 | target: number, 30 | buffer: WebGLBuffer, 31 | srcByteOffset: number, 32 | dstBuffer: ArrayBufferView, 33 | dstOffset?: number, 34 | length?: number, 35 | ) { 36 | const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!; 37 | gl.flush(); 38 | 39 | await clientWaitAsync(gl, sync, 0, 10); 40 | gl.deleteSync(sync); 41 | gl.bindBuffer(target, buffer); 42 | gl.getBufferSubData(target, srcByteOffset, dstBuffer, dstOffset, length); 43 | gl.bindBuffer(target, null); 44 | } 45 | 46 | export async function readPixelsAsync( 47 | gl: WebGL2RenderingContext, 48 | x: number, 49 | y: number, 50 | w: number, 51 | h: number, 52 | format: any, 53 | type: any, 54 | dest: ArrayBufferView, 55 | ) { 56 | const buf = gl.createBuffer()!; 57 | gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buf); 58 | gl.bufferData(gl.PIXEL_PACK_BUFFER, dest.byteLength, gl.STREAM_READ); 59 | gl.readPixels(x, y, w, h, format, type, 0); 60 | gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); 61 | 62 | await getBufferSubDataAsync(gl, gl.PIXEL_PACK_BUFFER, buf, 0, dest); 63 | 64 | gl.deleteBuffer(buf); 65 | return dest; 66 | } 67 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {ToastContainer} from "react-toastify"; 3 | import Component from "./Component"; 4 | import {fileSignal, loadAsTileSignal} from "./components/signal"; 5 | import {useSignals} from "@preact/signals-react/runtime"; 6 | import "react-toastify/dist/ReactToastify.css"; 7 | import "./App.css"; 8 | 9 | function App() { 10 | useSignals(); 11 | const handleLoad = () => { 12 | const input = document.createElement("input"); 13 | 14 | input.type = "file"; 15 | input.accept = ".ifc"; 16 | input.multiple = false; 17 | 18 | input.onchange = async (e: any) => { 19 | const file = e.target.files[0] as File; 20 | fileSignal.value = file; 21 | }; 22 | input.click(); 23 | input.remove(); 24 | }; 25 | return ( 26 | <> 27 |
28 |
29 | 35 |
36 | (loadAsTileSignal.value = e.target.checked)} 40 | /> 41 |

Load As BIM-Tiles

42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 | 61 | 62 | ); 63 | } 64 | 65 | export default App; 66 | -------------------------------------------------------------------------------- /src/components/BimModel.ts: -------------------------------------------------------------------------------- 1 | import * as OBC from "@thatopen/components"; 2 | import {CameraControls} from "@react-three/drei"; 3 | import {IfcTileLoader} from "./IfcTileLoader"; 4 | import {effect} from "@preact/signals-react"; 5 | import {fileSignal, groupsSignal, loadAsTileSignal} from "./signal"; 6 | import {CustomIfcStreamer} from "./CustomIfcStreamer"; 7 | export class BimModel implements OBC.Disposable { 8 | readonly onDisposed: OBC.Event = new OBC.Event(); 9 | private components!: OBC.Components; 10 | get domElement() { 11 | //@ts-ignore 12 | return this.controls._domElement; 13 | } 14 | get camera() { 15 | return this.controls.camera; 16 | } 17 | private loadAsTile = false; 18 | 19 | /** 20 | * 21 | */ 22 | constructor(private controls: CameraControls) { 23 | this.init(); 24 | effect(() => { 25 | (async () => { 26 | if (!fileSignal.value) return; 27 | if (!this.components) return; 28 | if (this.loadAsTile) { 29 | const ifcTileLoader = this.components.get(IfcTileLoader); 30 | await ifcTileLoader.streamIfc(fileSignal.value); 31 | } else { 32 | const buffer = new Uint8Array(await fileSignal.value.arrayBuffer()); 33 | const loader = this.components.get(OBC.IfcLoader); 34 | const group = await loader.load(buffer, true); 35 | if (!group) return; 36 | groupsSignal.value = [...groupsSignal.value, group]; 37 | } 38 | })(); 39 | }); 40 | effect(() => { 41 | this.loadAsTile = loadAsTileSignal.value; 42 | }); 43 | } 44 | async dispose() { 45 | this.components.dispose(); 46 | (this.components as any) = null; 47 | (this.controls as any) = null; 48 | this.onDisposed.trigger(); 49 | this.onDisposed.reset(); 50 | } 51 | private init() { 52 | this.components = new OBC.Components(); 53 | 54 | const ifcTileLoader = this.components.get(IfcTileLoader); 55 | ifcTileLoader.enabled = true; 56 | 57 | const customIfcStreamer = this.components.get(CustomIfcStreamer); 58 | customIfcStreamer.controls = this.controls; 59 | customIfcStreamer.culler.threshold = 50; 60 | customIfcStreamer.culler.maxHiddenTime = 3000; 61 | customIfcStreamer.culler.maxLostTime = 30000; 62 | customIfcStreamer.culler.setupEvent = false; 63 | customIfcStreamer.culler.setupEvent = true; 64 | 65 | const loader = this.components.get(OBC.IfcLoader); 66 | loader.setup(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/culler-renderer.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import * as OBC from "@thatopen/components"; 3 | import {CameraControls} from "@react-three/drei"; 4 | import {readPixelsAsync} from "./screen-culler-helper"; 5 | 6 | /** 7 | * Settings to configure the CullerRenderer. 8 | */ 9 | export interface CullerRendererSettings { 10 | /** 11 | * Interval in milliseconds at which the visibility check should be performed. 12 | * Default value is 1000. 13 | */ 14 | updateInterval?: number; 15 | 16 | /** 17 | * Width of the render target used for visibility checks. 18 | * Default value is 512. 19 | */ 20 | width?: number; 21 | 22 | /** 23 | * Height of the render target used for visibility checks. 24 | * Default value is 512. 25 | */ 26 | height?: number; 27 | 28 | /** 29 | * Whether the visibility check should be performed automatically. 30 | * Default value is true. 31 | */ 32 | autoUpdate?: boolean; 33 | } 34 | 35 | /** 36 | * A base renderer to determine visibility on screen. 37 | */ 38 | export class CullerRenderer { 39 | /** {@link Disposable.onDisposed} */ 40 | readonly onDisposed = new OBC.Event(); 41 | 42 | /** 43 | * Fires after making the visibility check to the meshes. It lists the 44 | * meshes that are currently visible, and the ones that were visible 45 | * just before but not anymore. 46 | */ 47 | readonly onViewUpdated: OBC.Event | OBC.AsyncEvent = 48 | new OBC.AsyncEvent(); 49 | 50 | /** 51 | * Whether this renderer is active or not. If not, it won't render anything. 52 | */ 53 | enabled = true; 54 | 55 | /** 56 | * Needs to check whether there are objects that need to be hidden or shown. 57 | * You can bind this to the camera movement, to a certain interval, etc. 58 | */ 59 | needsUpdate = false; 60 | 61 | /** 62 | * Render the internal scene used to determine the object visibility. Used 63 | * for debugging purposes. 64 | */ 65 | renderDebugFrame = false; 66 | 67 | /** The THREE.js renderer used to make the visibility test. */ 68 | readonly renderer: THREE.WebGLRenderer; 69 | 70 | protected autoUpdate = true; 71 | 72 | protected updateInterval = 1000; 73 | 74 | protected readonly worker: Worker; 75 | 76 | protected readonly scene = new THREE.Scene(); 77 | 78 | private _width = 512; 79 | 80 | private _height = 512; 81 | 82 | private _availableColor = 1; 83 | 84 | private readonly renderTarget: THREE.WebGLRenderTarget; 85 | 86 | private readonly bufferSize: number; 87 | 88 | private readonly _buffer: Uint8Array; 89 | 90 | // Prevents worker being fired multiple times 91 | protected _isWorkerBusy = false; 92 | 93 | constructor( 94 | public components: OBC.Components, 95 | public controls: CameraControls, 96 | settings?: CullerRendererSettings 97 | ) { 98 | this.applySettings(settings); 99 | 100 | this.renderer = new THREE.WebGLRenderer(); 101 | 102 | this.renderTarget = new THREE.WebGLRenderTarget(this._width, this._height); 103 | this.bufferSize = this._width * this._height * 4; 104 | this._buffer = new Uint8Array(this.bufferSize); 105 | 106 | this.renderer.clippingPlanes = []; 107 | 108 | const code = ` 109 | addEventListener("message", (event) => { 110 | const { buffer } = event.data; 111 | const colors = new Map(); 112 | for (let i = 0; i < buffer.length; i += 4) { 113 | const r = buffer[i]; 114 | const g = buffer[i + 1]; 115 | const b = buffer[i + 2]; 116 | const code = "" + r + "-" + g + "-" + b; 117 | if(colors.has(code)) { 118 | colors.set(code, colors.get(code) + 1); 119 | } else { 120 | colors.set(code, 1); 121 | } 122 | } 123 | postMessage({ colors }); 124 | }); 125 | `; 126 | 127 | const blob = new Blob([code], {type: "application/javascript"}); 128 | this.worker = new Worker(URL.createObjectURL(blob)); 129 | } 130 | 131 | /** {@link Disposable.dispose} */ 132 | dispose() { 133 | this.enabled = false; 134 | for (const child of this.scene.children) { 135 | child.removeFromParent(); 136 | } 137 | this.onViewUpdated.reset(); 138 | this.worker.terminate(); 139 | this.renderer.forceContextLoss(); 140 | this.renderer.dispose(); 141 | this.renderTarget.dispose(); 142 | (this._buffer as any) = null; 143 | this.onDisposed.reset(); 144 | } 145 | 146 | /** 147 | * The function that the culler uses to reprocess the scene. Generally it's 148 | * better to call needsUpdate, but you can also call this to force it. 149 | * @param force if true, it will refresh the scene even if needsUpdate is 150 | * not true. 151 | */ 152 | updateVisibility = async (force?: boolean) => { 153 | if (!this.enabled) return; 154 | if (!this.needsUpdate && !force) return; 155 | 156 | if (this._isWorkerBusy) return; 157 | this._isWorkerBusy = true; 158 | 159 | const camera = this.controls.camera; 160 | camera.updateMatrix(); 161 | 162 | this.renderer.setSize(this._width, this._height); 163 | this.renderer.setRenderTarget(this.renderTarget); 164 | this.renderer.render(this.scene, camera); 165 | 166 | const context = this.renderer.getContext() as WebGL2RenderingContext; 167 | 168 | try { 169 | await readPixelsAsync( 170 | context, 171 | 0, 172 | 0, 173 | this._width, 174 | this._height, 175 | context.RGBA, 176 | context.UNSIGNED_BYTE, 177 | this._buffer 178 | ); 179 | } catch (e) { 180 | // Pixels couldn't be read, possibly because culler was disposed 181 | this.needsUpdate = false; 182 | this._isWorkerBusy = false; 183 | this.renderer.setRenderTarget(null); 184 | return; 185 | } 186 | 187 | this.renderer.setRenderTarget(null); 188 | 189 | if (this.renderDebugFrame) { 190 | this.renderer.render(this.scene, camera); 191 | } 192 | 193 | this.worker.postMessage({ 194 | buffer: this._buffer, 195 | }); 196 | 197 | this.needsUpdate = false; 198 | }; 199 | 200 | protected getAvailableColor() { 201 | // src: https://stackoverflow.com/a/67579485 202 | 203 | let bigOne = BigInt(this._availableColor.toString()); 204 | const colorArray: number[] = []; 205 | do { 206 | colorArray.unshift(Number(bigOne % 256n)); 207 | bigOne /= 256n; 208 | } while (bigOne); 209 | 210 | while (colorArray.length !== 3) { 211 | colorArray.unshift(0); 212 | } 213 | 214 | const [r, g, b] = colorArray; 215 | const code = `${r}-${g}-${b}`; 216 | 217 | return {r, g, b, code}; 218 | } 219 | 220 | protected increaseColor() { 221 | if (this._availableColor === 256 * 256 * 256) { 222 | console.warn("Color can't be increased over 256 x 256 x 256!"); 223 | return; 224 | } 225 | this._availableColor++; 226 | } 227 | 228 | protected decreaseColor() { 229 | if (this._availableColor === 1) { 230 | console.warn("Color can't be decreased under 0!"); 231 | return; 232 | } 233 | this._availableColor--; 234 | } 235 | 236 | private applySettings(settings?: CullerRendererSettings) { 237 | if (settings) { 238 | if (settings.updateInterval !== undefined) { 239 | this.updateInterval = settings.updateInterval; 240 | } 241 | if (settings.height !== undefined) { 242 | this._height = settings.height; 243 | } 244 | if (settings.width !== undefined) { 245 | this._width = settings.width; 246 | } 247 | if (settings.autoUpdate !== undefined) { 248 | this.autoUpdate = settings.autoUpdate; 249 | } 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/components/IfcTileLoader.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import * as OBC from "@thatopen/components"; 3 | import * as OBF from "@thatopen/components-front"; 4 | import * as FRAG from "@thatopen/fragments"; 5 | import * as WEBIFC from "web-ifc"; 6 | import {CustomIfcStreamer} from "./CustomIfcStreamer"; 7 | import {groupsSignal} from "./signal"; 8 | interface StreamedProperties { 9 | types: { 10 | [typeID: number]: number[]; 11 | }; 12 | 13 | ids: { 14 | [id: number]: number; 15 | }; 16 | 17 | indexesFile: string; 18 | } 19 | export class IfcTileLoader extends OBC.Component implements OBC.Disposable { 20 | /** 21 | * A unique identifier for the component. 22 | * This UUID is used to register the component within the Components system. 23 | */ 24 | static readonly uuid = "b07943e1-a81f-455c-a459-516baf395d6f" as const; 25 | 26 | enabled = false; 27 | 28 | /** {@link Disposable.onDisposed} */ 29 | readonly onDisposed = new OBC.Event(); 30 | 31 | private webIfc: WEBIFC.LoaderSettings = { 32 | COORDINATE_TO_ORIGIN: true, 33 | //@ts-ignore 34 | OPTIMIZE_PROFILES: true, 35 | } as const; 36 | 37 | private wasm = { 38 | path: "https://unpkg.com/web-ifc@0.0.57/", 39 | absolute: true, 40 | logLevel: WEBIFC.LogLevel.LOG_LEVEL_OFF as WEBIFC.LogLevel | undefined, 41 | } as const; 42 | 43 | private excludedCategories = new Set([ 44 | WEBIFC.IFCSPACE, 45 | WEBIFC.IFCREINFORCINGBAR, 46 | WEBIFC.IFCOPENINGELEMENT, 47 | ]); 48 | // S3 storage ${host}/${bucket_name}/${modelId} 49 | artifactModelData: { 50 | [uuid: string]: { 51 | modelServer: {modelId: string; name: string}; 52 | settings: { 53 | assets: OBC.StreamedAsset[]; 54 | geometries: OBC.StreamedGeometries; 55 | }; 56 | groupBuffer: Uint8Array; 57 | propertyStorageFiles: {name: string; bits: Blob}[]; 58 | propertyServerData: { 59 | name: string; 60 | modelId: string; 61 | data: {[id: number]: any}; 62 | }[]; 63 | streamedGeometryFiles: {[fileName: string]: Uint8Array}; 64 | }; 65 | } = {}; 66 | 67 | readonly onUpdateServerModels: OBC.AsyncEvent = new OBC.AsyncEvent(); 68 | /** 69 | * 70 | * @param components 71 | */ 72 | constructor(components: OBC.Components) { 73 | super(components); 74 | this.components.add(IfcTileLoader.uuid, this); 75 | } 76 | /** {@link Disposable.dispose} */ 77 | dispose() { 78 | this.artifactModelData = {}; 79 | this.onDisposed.trigger(); 80 | this.onDisposed.reset(); 81 | } 82 | 83 | async streamIfc(file: File) { 84 | const buffer = new Uint8Array(await file?.arrayBuffer()); 85 | const modelId = THREE.MathUtils.generateUUID(); 86 | const name = file.name; 87 | /* ========== IfcPropertyTiler ========== */ 88 | const ifcPropertiesTiler = this.components.get(OBC.IfcPropertiesTiler); 89 | ifcPropertiesTiler.settings.wasm = this.wasm; 90 | ifcPropertiesTiler.settings.autoSetWasm = false; 91 | ifcPropertiesTiler.settings.webIfc = this.webIfc; 92 | ifcPropertiesTiler.settings.excludedCategories = this.excludedCategories; 93 | ifcPropertiesTiler.settings.propertiesSize = 500; 94 | ifcPropertiesTiler.onIndicesStreamed.reset(); 95 | ifcPropertiesTiler.onPropertiesStreamed.reset(); 96 | ifcPropertiesTiler.onProgress.reset(); 97 | 98 | // storage in S3 because it's large size 99 | const jsonFile: StreamedProperties = { 100 | types: {}, 101 | ids: {}, 102 | indexesFile: `properties`, 103 | }; 104 | // storage in S3 because it's large size 105 | const propertyStorageFiles: {name: string; bits: Blob}[] = []; 106 | // post request to server to storage in mongdb 107 | const propertyServerData: { 108 | name: string; 109 | modelId: string; 110 | data: {[id: number]: any}; 111 | }[] = []; 112 | 113 | let counter = 0; 114 | // storage in S3 because it's large size 115 | let propertyJson: FRAG.IfcProperties; 116 | // storage in S3 because it's large size 117 | let assets: OBC.StreamedAsset[] = []; 118 | // storage in S3 because it's large size 119 | let geometries: OBC.StreamedGeometries; 120 | // storage in S3 because it's large size 121 | let groupBuffer: Uint8Array; 122 | 123 | let geometryFilesCount = 0; 124 | // storage in S3 because it's large size 125 | const streamedGeometryFiles: {[fileName: string]: Uint8Array} = {}; 126 | 127 | const modelServer = {modelId, name}; 128 | 129 | const onSuccess = async () => { 130 | const customIfcStreamer = this.components.get(CustomIfcStreamer); 131 | if (!customIfcStreamer) return; 132 | customIfcStreamer.fromServer = false; 133 | if ( 134 | propertyStorageFiles.length === 0 || 135 | propertyServerData.length === 0 || 136 | assets.length === 0 || 137 | geometries === undefined || 138 | groupBuffer === undefined || 139 | !propertyJson 140 | ) 141 | return; 142 | const settings = {assets, geometries} as OBF.StreamLoaderSettings; 143 | const group = await customIfcStreamer.loadFromLocal( 144 | settings, 145 | groupBuffer, 146 | true, 147 | propertyJson 148 | ); 149 | groupsSignal.value = [...groupsSignal.value, group]; 150 | const uuid = group.uuid; 151 | if (!this.artifactModelData[uuid]) { 152 | this.artifactModelData[uuid] = { 153 | modelServer, 154 | settings, 155 | groupBuffer, 156 | propertyStorageFiles, 157 | propertyServerData, 158 | streamedGeometryFiles, 159 | }; 160 | } 161 | }; 162 | 163 | ifcPropertiesTiler.onPropertiesStreamed.add( 164 | async (props: {type: number; data: {[id: number]: any}}) => { 165 | const {type, data} = props; 166 | if (!jsonFile.types[type]) jsonFile.types[type] = []; 167 | jsonFile.types[type].push(counter); 168 | if (!propertyJson) propertyJson = {}; 169 | for (const id in data) { 170 | jsonFile.ids[id] = counter; 171 | if (!propertyJson[id]) propertyJson[id] = data[id]; 172 | } 173 | 174 | const name = `properties-${counter}`; 175 | 176 | propertyServerData.push({data, name, modelId}); 177 | counter++; 178 | } 179 | ); 180 | ifcPropertiesTiler.onIndicesStreamed.add( 181 | async (props: Map>) => { 182 | const bits = new Blob([JSON.stringify(jsonFile)]); 183 | propertyStorageFiles.push({ 184 | name: `properties.json`, 185 | bits, 186 | }); 187 | const relations = this.components.get(OBC.IfcRelationsIndexer); 188 | const serializedRels = relations.serializeRelations(props); 189 | propertyStorageFiles.push({ 190 | name: `properties-indexes.json`, 191 | bits: new Blob([serializedRels]), 192 | }); 193 | } 194 | ); 195 | ifcPropertiesTiler.onProgress.add(async (progress: number) => { 196 | if (progress !== 1) return; 197 | await onSuccess(); 198 | }); 199 | await ifcPropertiesTiler.streamFromBuffer(buffer); 200 | /* ========== IfcGeometryTiler ========== */ 201 | const ifcGeometryTiler = this.components.get(OBC.IfcGeometryTiler); 202 | ifcGeometryTiler.settings.wasm = this.wasm; 203 | ifcGeometryTiler.settings.autoSetWasm = false; 204 | ifcGeometryTiler.settings.webIfc = this.webIfc; 205 | ifcGeometryTiler.settings.excludedCategories = this.excludedCategories; 206 | ifcGeometryTiler.settings.minGeometrySize = 10; 207 | ifcGeometryTiler.settings.minAssetsSize = 1000; 208 | ifcGeometryTiler.onAssetStreamed.reset(); 209 | ifcGeometryTiler.onGeometryStreamed.reset(); 210 | ifcGeometryTiler.onIfcLoaded.reset(); 211 | ifcGeometryTiler.onProgress.reset(); 212 | 213 | const streamGeometry = async ( 214 | data: OBC.StreamedGeometries, 215 | buffer: Uint8Array 216 | ) => { 217 | const geometryFile = `geometries-${geometryFilesCount}.frag`; 218 | if (geometries === undefined) geometries = {}; 219 | for (const id in data) { 220 | if (!geometries[id]) geometries[id] = {...data[id], geometryFile}; 221 | } 222 | if (!streamedGeometryFiles[geometryFile]) 223 | streamedGeometryFiles[geometryFile] = buffer; 224 | geometryFilesCount++; 225 | }; 226 | 227 | ifcGeometryTiler.onAssetStreamed.add( 228 | async (assetItems: OBC.StreamedAsset[]) => { 229 | assets = [...assets, ...assetItems]; 230 | } 231 | ); 232 | 233 | ifcGeometryTiler.onGeometryStreamed.add( 234 | async ({ 235 | data, 236 | buffer, 237 | }: { 238 | data: OBC.StreamedGeometries; 239 | buffer: Uint8Array; 240 | }) => { 241 | await streamGeometry(data, buffer); 242 | } 243 | ); 244 | 245 | ifcGeometryTiler.onIfcLoaded.add(async (group: Uint8Array) => { 246 | groupBuffer = group; 247 | await onSuccess(); 248 | }); 249 | ifcGeometryTiler.onProgress.add(async (progress: number) => { 250 | if (progress !== 1) return; 251 | await onSuccess(); 252 | }); 253 | await ifcGeometryTiler.streamFromBuffer(buffer); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/components/CustomIfcStreamer.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import * as FRAG from "@thatopen/fragments"; 3 | import * as OBC from "@thatopen/components"; 4 | import * as OBF from "@thatopen/components-front"; 5 | import {CameraControls} from "@react-three/drei"; 6 | import {IfcTileLoader} from "./IfcTileLoader"; 7 | import axios from "axios"; 8 | import {GeometryCullerRenderer} from "./geometry-culler-renderer"; 9 | export interface StreamPropertiesSettings { 10 | /** 11 | * Map of identifiers to numbers. 12 | */ 13 | ids: {[id: number]: number}; 14 | 15 | /** 16 | * Map of types to arrays of numbers. 17 | */ 18 | types: {[type: number]: number[]}; 19 | 20 | indexesFile: string; 21 | /** 22 | * Identifier of the indexes file. 23 | */ 24 | relationsMap: OBC.RelationsMap; 25 | } 26 | 27 | /** 28 | * The IfcStreamer component is responsible for managing and streaming tiled IFC data. It provides methods for loading, removing, and managing IFC models, as well as handling visibility and caching. 📕 [Tutorial](https://docs.thatopen.com/Tutorials/Components/Front/IfcStreamer). 📘 [API](https://docs.thatopen.com/api/@thatopen/components-front/classes/IfcStreamer). 29 | */ 30 | export class CustomIfcStreamer extends OBC.Component implements OBC.Disposable { 31 | /** 32 | * A unique identifier for the component. 33 | * This UUID is used to register the component within the Components system. 34 | */ 35 | static readonly uuid = "98186ed2-96ff-4721-860a-2b845d7bb133" as const; 36 | 37 | /** {@link OBC.Component.enabled} */ 38 | enabled = true; 39 | 40 | /** 41 | * Event triggered when fragments are deleted. 42 | */ 43 | readonly onFragmentsDeleted = new OBC.Event(); 44 | 45 | /** 46 | * Event triggered when fragments are loaded. 47 | */ 48 | readonly onFragmentsLoaded = new OBC.Event(); 49 | 50 | /** {@link OBC.Disposable.onDisposed} */ 51 | readonly onDisposed = new OBC.Event(); 52 | 53 | /** 54 | * The data of the streamed models. It defines the geometries, their instances, its bounding box (OBB) and the assets to which they belong. 55 | */ 56 | models: { 57 | [modelID: string]: { 58 | assets: OBC.StreamedAsset[]; 59 | geometries: OBC.StreamedGeometries; 60 | }; 61 | } = {}; 62 | 63 | /** 64 | * Importer of binary IFC data previously converted to fragment tiles. 65 | */ 66 | serializer = new FRAG.StreamSerializer(); 67 | 68 | /** 69 | * Maximum time in milliseconds for a geometry to stay in the RAM cache. 70 | */ 71 | maxRamTime = 5000; 72 | 73 | private _culler: GeometryCullerRenderer | null = null; 74 | 75 | private _ramCache = new Map(); 76 | 77 | private _isDisposing = false; 78 | 79 | private _geometryInstances: { 80 | [modelID: string]: OBF.StreamedInstances; 81 | } = {}; 82 | 83 | private _loadedFragments: { 84 | [modelID: string]: {[geometryID: number]: FRAG.Fragment[]}; 85 | } = {}; 86 | 87 | private fragIDData = new Map< 88 | string, 89 | [FRAG.FragmentsGroup, number, Set] 90 | >(); 91 | 92 | private _baseMaterial = new THREE.MeshLambertMaterial(); 93 | 94 | private _baseMaterialT = new THREE.MeshLambertMaterial({ 95 | transparent: true, 96 | opacity: 0.5, 97 | }); 98 | 99 | /** 100 | * Sets the world in which the fragments will be displayed. 101 | * @param world - The new world to be set. 102 | */ 103 | set controls(controls: CameraControls) { 104 | this._culler?.dispose(); 105 | 106 | this._culler = new GeometryCullerRenderer(this.components, controls); 107 | this._culler.onViewUpdated.add( 108 | async ({toLoad, toRemove, toShow, toHide}) => { 109 | await this.loadFoundGeometries(toLoad); 110 | await this.unloadLostGeometries(toRemove); 111 | this.setMeshVisibility(toShow, true); 112 | this.setMeshVisibility(toHide, false); 113 | } 114 | ); 115 | } 116 | 117 | /** 118 | * The culler used for managing and rendering the fragments. 119 | * It is automatically created when the world is set. 120 | */ 121 | get culler() { 122 | if (!this._culler) { 123 | throw new Error("You must set a world before using the streamer!"); 124 | } 125 | return this._culler; 126 | } 127 | fromServer = false; 128 | 129 | constructor(components: OBC.Components) { 130 | super(components); 131 | this.components.add(CustomIfcStreamer.uuid, this); 132 | } 133 | 134 | /** {@link OBC.Disposable.dispose} */ 135 | dispose() { 136 | this._isDisposing = true; 137 | this.onFragmentsLoaded.reset(); 138 | this.onFragmentsDeleted.reset(); 139 | this.fromServer = false; 140 | this._ramCache.clear(); 141 | 142 | this.models = {}; 143 | this._geometryInstances = {}; 144 | // Disposed by fragment manager 145 | this._loadedFragments = {}; 146 | this.fragIDData.clear(); 147 | 148 | this._baseMaterial.dispose(); 149 | this._baseMaterialT.dispose(); 150 | 151 | this._culler?.dispose(); 152 | 153 | this.onDisposed.trigger(CustomIfcStreamer.uuid); 154 | this.onDisposed.reset(); 155 | this._isDisposing = false; 156 | } 157 | 158 | /** 159 | * Loads a new fragment group into the scene using streaming. 160 | * 161 | * @param settings - The settings for the new fragment group. 162 | * @param coordinate - Whether to federate this model with the rest. 163 | * @param properties - Optional properties for the new fragment group. 164 | * @returns The newly loaded fragment group. 165 | */ 166 | async loadFromLocal( 167 | settings: OBF.StreamLoaderSettings, 168 | groupBuffer: Uint8Array, 169 | coordinate: boolean, 170 | properties?: FRAG.IfcProperties 171 | ) { 172 | const {assets, geometries} = settings; 173 | const fragments = this.components.get(OBC.FragmentsManager); 174 | const group = fragments.load(groupBuffer, {coordinate, properties}); 175 | const {opaque, transparent} = group.geometryIDs; 176 | for (const [geometryID, key] of opaque) { 177 | const fragID = group.keyFragments.get(key); 178 | if (fragID === undefined) { 179 | throw new Error("Malformed fragments group!"); 180 | } 181 | this.fragIDData.set(fragID, [group, geometryID, new Set()]); 182 | } 183 | for (const [geometryID, key] of transparent) { 184 | const fragID = group.keyFragments.get(key); 185 | if (fragID === undefined) { 186 | throw new Error("Malformed fragments group!"); 187 | } 188 | this.fragIDData.set(fragID, [group, Math.abs(geometryID), new Set()]); 189 | } 190 | 191 | this.culler.add(group.uuid, assets, geometries); 192 | this.models[group.uuid] = {assets, geometries}; 193 | const instances: OBF.StreamedInstances = new Map(); 194 | 195 | for (const asset of assets) { 196 | const id = asset.id; 197 | for (const {transformation, geometryID, color} of asset.geometries) { 198 | if (!instances.has(geometryID)) { 199 | instances.set(geometryID, []); 200 | } 201 | const current = instances.get(geometryID); 202 | if (!current) { 203 | throw new Error("Malformed instances"); 204 | } 205 | current.push({id, transformation, color}); 206 | } 207 | } 208 | 209 | this._geometryInstances[group.uuid] = instances; 210 | 211 | this.culler.updateTransformations(group.uuid); 212 | this.culler.needsUpdate = true; 213 | 214 | return group; 215 | } 216 | 217 | /** 218 | * Removes a fragment group from the scene. 219 | * 220 | * @param modelID - The unique identifier of the fragment group to remove. 221 | */ 222 | remove(modelID: string) { 223 | this._isDisposing = true; 224 | 225 | const fragments = this.components.get(OBC.FragmentsManager); 226 | const group = fragments.groups.get(modelID); 227 | if (group === undefined) { 228 | console.log("Group to delete not found."); 229 | return; 230 | } 231 | 232 | delete this.models[modelID]; 233 | delete this._geometryInstances[modelID]; 234 | delete this._loadedFragments[modelID]; 235 | 236 | const ids = group.keyFragments.values(); 237 | for (const id of ids) { 238 | this.fragIDData.delete(id); 239 | } 240 | 241 | this.culler.remove(modelID); 242 | 243 | this._isDisposing = false; 244 | } 245 | 246 | /** 247 | * Sets the visibility of items in fragments based on the provided filter. 248 | * 249 | * @param visible - The visibility state to set. 250 | * @param filter - A map of fragment IDs to arrays of item IDs. 251 | * Only items with IDs present in the arrays will be visible. 252 | */ 253 | setVisibility(visible: boolean, filter: FRAG.FragmentIdMap) { 254 | const modelGeomsAssets = new Map>>(); 255 | for (const fragID in filter) { 256 | const found = this.fragIDData.get(fragID); 257 | if (found === undefined) { 258 | throw new Error("Geometry not found!"); 259 | } 260 | const [group, geometryID, hiddenItems] = found; 261 | const modelID = group.uuid; 262 | if (!modelGeomsAssets.has(modelID)) { 263 | modelGeomsAssets.set(modelID, new Map()); 264 | } 265 | const geometriesAsset = modelGeomsAssets.get(modelID)!; 266 | const assets = filter[fragID]; 267 | 268 | // Store the visible filter so that it's applied if this fragment 269 | // is loaded later 270 | for (const itemID of assets) { 271 | if (visible) { 272 | hiddenItems.delete(itemID); 273 | } else { 274 | hiddenItems.add(itemID); 275 | } 276 | } 277 | 278 | if (!geometriesAsset.get(geometryID)) { 279 | geometriesAsset.set(geometryID, new Set()); 280 | } 281 | 282 | const assetGroup = geometriesAsset.get(geometryID)!; 283 | for (const asset of assets) { 284 | assetGroup.add(asset); 285 | } 286 | } 287 | for (const [modelID, geometriesAssets] of modelGeomsAssets) { 288 | // Set visibility of stream culler 289 | this.culler.setVisibility(visible, modelID, geometriesAssets); 290 | // set visibility of loaded fragments 291 | for (const [geometryID] of geometriesAssets) { 292 | const allFrags = this._loadedFragments[modelID]; 293 | if (!allFrags) continue; 294 | const frags = allFrags[geometryID]; 295 | if (!frags) continue; 296 | for (const frag of frags) { 297 | const ids = filter[frag.id]; 298 | if (!ids) continue; 299 | frag.setVisibility(visible, ids); 300 | } 301 | } 302 | } 303 | 304 | this.culler.needsUpdate = true; 305 | } 306 | 307 | private async getGeometryFile( 308 | geometryFile: string, 309 | modelID: string, 310 | serverUrl?: string 311 | ) { 312 | if (!this.fromServer) { 313 | const artifactModelData = 314 | this.components.get(IfcTileLoader).artifactModelData; 315 | if (!artifactModelData || !artifactModelData[modelID]) return null; 316 | const {streamedGeometryFiles} = artifactModelData[modelID]; 317 | return streamedGeometryFiles[geometryFile]; 318 | } else { 319 | if (!serverUrl) return null; 320 | try { 321 | const res = await axios({ 322 | url: `${serverUrl}/${geometryFile}`, 323 | method: "GET", 324 | responseType: "arraybuffer", 325 | }); 326 | return new Uint8Array(res.data); 327 | } catch (error) { 328 | return null; 329 | } 330 | } 331 | } 332 | 333 | private async loadFoundGeometries(seen: { 334 | [modelID: string]: Map>; 335 | }) { 336 | for (const modelID in seen) { 337 | if (this._isDisposing) return; 338 | 339 | const fragments = this.components.get(OBC.FragmentsManager); 340 | const group = fragments.groups.get(modelID); 341 | if (!group) { 342 | // throw new Error("Fragment group not found!"); 343 | // Might happen when disposing 344 | return; 345 | } 346 | const {serverUrl} = group.userData; 347 | const {geometries} = this.models[modelID]; 348 | 349 | const files = new Map(); 350 | 351 | const allIDs = new Set(); 352 | 353 | for (const [priority, ids] of seen[modelID]) { 354 | for (const id of ids) { 355 | allIDs.add(id); 356 | const geometry = geometries[id]; 357 | if (!geometry) { 358 | throw new Error("Geometry not found"); 359 | } 360 | if (geometry.geometryFile) { 361 | const file = geometry.geometryFile; 362 | const value = files.get(file) || 0; 363 | files.set(file, value + priority); 364 | } 365 | } 366 | } 367 | 368 | const sortedFiles = Array.from(files).sort((a, b) => b[1] - a[1]); 369 | 370 | for (const [file] of sortedFiles) { 371 | // If this file is still in the ram, get it 372 | if (!this._ramCache.has(file)) { 373 | const bytes = await this.getGeometryFile(file, modelID, serverUrl); 374 | if (bytes) { 375 | const data = this.serializer.import(bytes); 376 | this._ramCache.set(file, data); 377 | } 378 | } 379 | 380 | const result = this._ramCache.get(file); 381 | if (!result) { 382 | continue; 383 | } 384 | 385 | const loaded: FRAG.Fragment[] = []; 386 | if (result) { 387 | for (const [geometryID, {position, index, normal}] of result) { 388 | if (this._isDisposing) return; 389 | 390 | if (!allIDs.has(geometryID)) continue; 391 | 392 | if ( 393 | !this._geometryInstances[modelID] || 394 | !this._geometryInstances[modelID].has(geometryID) 395 | ) { 396 | continue; 397 | } 398 | 399 | const geoms = this._geometryInstances[modelID]; 400 | const instances = geoms.get(geometryID); 401 | 402 | if (!instances) { 403 | throw new Error("Instances not found!"); 404 | } 405 | 406 | const geom = new THREE.BufferGeometry(); 407 | 408 | const posAttr = new THREE.BufferAttribute(position, 3); 409 | const norAttr = new THREE.BufferAttribute(normal, 3); 410 | 411 | geom.setAttribute("position", posAttr); 412 | geom.setAttribute("normal", norAttr); 413 | 414 | geom.setIndex(Array.from(index)); 415 | 416 | // Separating opaque and transparent items is neccesary for Three.js 417 | 418 | const transp: OBF.StreamedInstance[] = []; 419 | const opaque: OBF.StreamedInstance[] = []; 420 | for (const instance of instances) { 421 | if (instance.color[3] === 1) { 422 | opaque.push(instance); 423 | } else { 424 | transp.push(instance); 425 | } 426 | } 427 | 428 | this.newFragment(group, geometryID, geom, transp, true, loaded); 429 | this.newFragment(group, geometryID, geom, opaque, false, loaded); 430 | } 431 | } 432 | 433 | if (loaded.length && !this._isDisposing) { 434 | this.onFragmentsLoaded.trigger(loaded); 435 | } 436 | } 437 | 438 | // this._storageCache.close(); 439 | } 440 | } 441 | 442 | private async unloadLostGeometries(_unseen: {[p: string]: Set}) { 443 | if (this._isDisposing) return; 444 | 445 | // const deletedFragments: FRAG.Fragment[] = []; 446 | // const fragments = this.components.get(OBC.FragmentsManager); 447 | // for (const modelID in unseen) { 448 | // const group = fragments.groups.get(modelID); 449 | // if (!group) { 450 | // throw new Error("Fragment group not found!"); 451 | // } 452 | 453 | // if (!this._loadedFragments[modelID]) continue; 454 | // const loadedFrags = this._loadedFragments[modelID]; 455 | // const geometries = unseen[modelID]; 456 | 457 | // for (const geometryID of geometries) { 458 | // this.culler.removeFragment(group.uuid, geometryID); 459 | 460 | // if (!loadedFrags[geometryID]) continue; 461 | // const frags = loadedFrags[geometryID]; 462 | // for (const frag of frags) { 463 | // group.items.splice(group.items.indexOf(frag), 1); 464 | // deletedFragments.push(frag); 465 | // } 466 | // delete loadedFrags[geometryID]; 467 | // } 468 | // } 469 | 470 | // if (deletedFragments.length) { 471 | // this.onFragmentsDeleted.trigger(deletedFragments); 472 | // } 473 | 474 | // for (const frag of deletedFragments) { 475 | // fragments.list.delete(frag.id); 476 | // this.world.meshes.delete(frag.mesh); 477 | // frag.mesh.material = [] as THREE.Material[]; 478 | // frag.dispose(true); 479 | // } 480 | } 481 | 482 | private setMeshVisibility( 483 | filter: {[modelID: string]: Set}, 484 | visible: boolean 485 | ) { 486 | for (const modelID in filter) { 487 | for (const geometryID of filter[modelID]) { 488 | const geometries = this._loadedFragments[modelID]; 489 | if (!geometries) continue; 490 | const frags = geometries[geometryID]; 491 | if (!frags) continue; 492 | for (const frag of frags) { 493 | frag.mesh.visible = visible; 494 | } 495 | } 496 | } 497 | } 498 | 499 | private newFragment( 500 | group: FRAG.FragmentsGroup, 501 | geometryID: number, 502 | geometry: THREE.BufferGeometry, 503 | instances: OBF.StreamedInstance[], 504 | transparent: boolean, 505 | result: FRAG.Fragment[] 506 | ) { 507 | if (instances.length === 0) return; 508 | if (this._isDisposing) return; 509 | 510 | const uuids = group.geometryIDs; 511 | const uuidMap = transparent ? uuids.transparent : uuids.opaque; 512 | const factor = transparent ? -1 : 1; 513 | const tranpsGeomID = geometryID * factor; 514 | const key = uuidMap.get(tranpsGeomID); 515 | 516 | if (key === undefined) { 517 | // throw new Error("Malformed fragment!"); 518 | return; 519 | } 520 | const fragID = group.keyFragments.get(key); 521 | if (fragID === undefined) { 522 | // throw new Error("Malformed fragment!"); 523 | return; 524 | } 525 | 526 | const fragments = this.components.get(OBC.FragmentsManager); 527 | const fragmentAlreadyExists = fragments.list.has(fragID); 528 | if (fragmentAlreadyExists) { 529 | return; 530 | } 531 | 532 | const material = transparent ? this._baseMaterialT : this._baseMaterial; 533 | const fragment = new FRAG.Fragment(geometry, material, instances.length); 534 | 535 | fragment.id = fragID; 536 | fragment.mesh.uuid = fragID; 537 | 538 | fragment.group = group; 539 | group.add(fragment.mesh); 540 | group.items.push(fragment); 541 | 542 | fragments.list.set(fragment.id, fragment); 543 | 544 | if (!this._loadedFragments[group.uuid]) { 545 | this._loadedFragments[group.uuid] = {}; 546 | } 547 | const geoms = this._loadedFragments[group.uuid]; 548 | if (!geoms[geometryID]) { 549 | geoms[geometryID] = []; 550 | } 551 | 552 | geoms[geometryID].push(fragment); 553 | 554 | const itemsMap = new Map(); 555 | for (let i = 0; i < instances.length; i++) { 556 | const transform = new THREE.Matrix4(); 557 | const col = new THREE.Color(); 558 | const {id, transformation, color} = instances[i]; 559 | transform.fromArray(transformation); 560 | const [r, g, b] = color; 561 | col.setRGB(r, g, b, "srgb"); 562 | if (itemsMap.has(id)) { 563 | const item = itemsMap.get(id)!; 564 | if (!item) continue; 565 | item.transforms.push(transform); 566 | if (item.colors) { 567 | item.colors.push(col); 568 | } 569 | } else { 570 | itemsMap.set(id, {id, colors: [col], transforms: [transform]}); 571 | } 572 | } 573 | 574 | const items = Array.from(itemsMap.values()); 575 | fragment.add(items); 576 | 577 | const data = this.fragIDData.get(fragment.id); 578 | if (!data) { 579 | throw new Error("Fragment data not found!"); 580 | } 581 | 582 | const hiddenItems = data[2]; 583 | if (hiddenItems.size) { 584 | fragment.setVisibility(false, hiddenItems); 585 | } 586 | 587 | this.culler.addFragment(group.uuid, geometryID, fragment); 588 | 589 | result.push(fragment); 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /src/components/geometry-culler-renderer.ts: -------------------------------------------------------------------------------- 1 | import * as FRAGS from "@thatopen/fragments"; 2 | import * as THREE from "three"; 3 | import * as OBC from "@thatopen/components"; 4 | import {CameraControls} from "@react-three/drei"; 5 | 6 | import {CullerRenderer} from "./culler-renderer"; 7 | 8 | type CullerBoundingBox = { 9 | modelIndex: number; 10 | geometryID: number; 11 | assetIDs: Set; 12 | exists: boolean; 13 | time: number; 14 | hidden: boolean; 15 | fragment?: FRAGS.Fragment; 16 | }; 17 | 18 | /** 19 | * A renderer to determine a geometry visibility on screen 20 | */ 21 | export class GeometryCullerRenderer extends CullerRenderer { 22 | /* Pixels in screen a geometry must occupy to be considered "seen". */ 23 | threshold = 50; 24 | 25 | bboxThreshold = 200; 26 | 27 | maxLostTime = 30000; 28 | maxHiddenTime = 5000; 29 | 30 | boxes = new Map(); 31 | 32 | private _staticGeometries: { 33 | culled: {[modelID: string]: Set}; 34 | unculled: {[modelID: string]: Set}; 35 | } = {culled: {}, unculled: {}}; 36 | 37 | private readonly _geometry: THREE.BufferGeometry; 38 | 39 | private _material = new THREE.MeshBasicMaterial({ 40 | transparent: true, 41 | side: 2, 42 | opacity: 1, 43 | }); 44 | 45 | readonly onViewUpdated = new OBC.AsyncEvent<{ 46 | toLoad: {[modelID: string]: Map>}; 47 | toRemove: {[modelID: string]: Set}; 48 | toHide: {[modelID: string]: Set}; 49 | toShow: {[modelID: string]: Set}; 50 | }>(); 51 | 52 | private _modelIDIndex = new Map(); 53 | private _indexModelID = new Map(); 54 | private _nextModelID = 0; 55 | 56 | private _geometries = new Map(); 57 | private _geometriesGroups = new Map(); 58 | private _geometriesInMemory = new Set(); 59 | private _intervalID: number | null = null; 60 | 61 | private codes = new Map>(); 62 | 63 | set setupEvent(enabled: boolean) { 64 | if (!this.controls) return; 65 | if (enabled) { 66 | this.controls.addEventListener("rest", this.updateCulling); 67 | this.controls.addEventListener("controlstart", this.updateCulling); 68 | this.controls.addEventListener("controlend", this.updateCulling); 69 | this.controls.addEventListener("wake", this.updateCulling); 70 | } else { 71 | this.controls.removeEventListener("rest", this.updateCulling); 72 | this.controls.removeEventListener("controlstart", this.updateCulling); 73 | this.controls.removeEventListener("controlend", this.updateCulling); 74 | this.controls.removeEventListener("wake", this.updateCulling); 75 | } 76 | } 77 | private updateCulling = async () => { 78 | this.needsUpdate = true; 79 | }; 80 | constructor( 81 | components: OBC.Components, 82 | controls: CameraControls, 83 | settings?: OBC.CullerRendererSettings 84 | ) { 85 | super(components, controls, settings); 86 | 87 | this.updateInterval = 500; 88 | 89 | this._geometry = new THREE.BoxGeometry(1, 1, 1); 90 | this._geometry.groups = []; 91 | this._geometry.deleteAttribute("uv"); 92 | const position = this._geometry.attributes.position.array as Float32Array; 93 | for (let i = 0; i < position.length; i++) { 94 | position[i] += 0.5; 95 | } 96 | this._geometry.attributes.position.needsUpdate = true; 97 | 98 | this.worker.addEventListener("message", this.handleWorkerMessage); 99 | if (this.autoUpdate) { 100 | this._intervalID = window.setInterval( 101 | this.updateVisibility, 102 | this.updateInterval 103 | ); 104 | } 105 | } 106 | 107 | dispose() { 108 | super.dispose(); 109 | this.onViewUpdated.reset(); 110 | this.setupEvent = false; 111 | if (this._intervalID !== null) { 112 | window.clearInterval(this._intervalID); 113 | this._intervalID = null; 114 | } 115 | 116 | for (const [_id, group] of this._geometriesGroups) { 117 | group.removeFromParent(); 118 | const children = [...group.children]; 119 | for (const child of children) { 120 | child.removeFromParent(); 121 | } 122 | } 123 | this._geometriesGroups.clear(); 124 | 125 | for (const [_id, frag] of this.boxes) { 126 | frag.dispose(true); 127 | } 128 | this.boxes.clear(); 129 | 130 | for (const [_id, box] of this._geometries) { 131 | if (box.fragment) { 132 | box.fragment.dispose(true); 133 | box.fragment = undefined; 134 | } 135 | } 136 | this._geometries.clear(); 137 | 138 | this._staticGeometries = {culled: {}, unculled: {}}; 139 | 140 | this._geometry.dispose(); 141 | this._material.dispose(); 142 | this._modelIDIndex.clear(); 143 | this._indexModelID.clear(); 144 | this.codes.clear(); 145 | } 146 | 147 | add( 148 | modelID: string, 149 | assets: OBC.StreamedAsset[], 150 | geometries: OBC.StreamedGeometries 151 | ): void { 152 | const modelIndex = this.createModelIndex(modelID); 153 | 154 | const colorEnabled = THREE.ColorManagement.enabled; 155 | THREE.ColorManagement.enabled = false; 156 | 157 | type NextColor = {r: number; g: number; b: number; code: string}; 158 | const visitedGeometries = new Map(); 159 | 160 | const tempMatrix = new THREE.Matrix4(); 161 | 162 | const bboxes = new FRAGS.Fragment(this._geometry, this._material, 10); 163 | this.boxes.set(modelIndex, bboxes); 164 | this.scene.add(bboxes.mesh); 165 | 166 | const fragmentsGroup = new THREE.Group(); 167 | this.scene.add(fragmentsGroup); 168 | this._geometriesGroups.set(modelIndex, fragmentsGroup); 169 | 170 | const items = new Map< 171 | number, 172 | FRAGS.Item & {geometryColors: THREE.Color[]} 173 | >(); 174 | 175 | for (const asset of assets) { 176 | // if (asset.id !== 9056429) continue; 177 | for (const geometryData of asset.geometries) { 178 | const {geometryID, transformation, color} = geometryData; 179 | 180 | const geometryColor = new THREE.Color(); 181 | geometryColor.setRGB(color[0], color[1], color[2], "srgb"); 182 | 183 | const instanceID = this.getInstanceID(asset.id, geometryID); 184 | 185 | const geometry = geometries[geometryID]; 186 | if (!geometry) { 187 | console.log(`Geometry not found: ${geometryID}`); 188 | continue; 189 | } 190 | 191 | const {boundingBox} = geometry; 192 | 193 | // Get bounding box color 194 | 195 | let nextColor: NextColor; 196 | if (visitedGeometries.has(geometryID)) { 197 | nextColor = visitedGeometries.get(geometryID) as NextColor; 198 | } else { 199 | nextColor = this.getAvailableColor(); 200 | this.increaseColor(); 201 | visitedGeometries.set(geometryID, nextColor); 202 | } 203 | const {r, g, b, code} = nextColor; 204 | const threeColor = new THREE.Color(); 205 | threeColor.setRGB(r / 255, g / 255, b / 255, "srgb"); 206 | 207 | // Save color code by model and geometry 208 | 209 | if (!this.codes.has(modelIndex)) { 210 | this.codes.set(modelIndex, new Map()); 211 | } 212 | const map = this.codes.get(modelIndex) as Map; 213 | map.set(geometryID, code); 214 | 215 | // Get bounding box transform 216 | 217 | const instanceMatrix = new THREE.Matrix4(); 218 | const boundingBoxArray = Object.values(boundingBox); 219 | instanceMatrix.fromArray(transformation); 220 | tempMatrix.fromArray(boundingBoxArray); 221 | instanceMatrix.multiply(tempMatrix); 222 | 223 | if (items.has(instanceID)) { 224 | // This geometry exists multiple times in this asset 225 | const item = items.get(instanceID); 226 | if (item === undefined || !item.colors) { 227 | throw new Error("Malformed item!"); 228 | } 229 | item.colors.push(threeColor); 230 | item.geometryColors.push(geometryColor); 231 | item.transforms.push(instanceMatrix); 232 | } else { 233 | // This geometry exists only once in this asset (for now) 234 | items.set(instanceID, { 235 | id: instanceID, 236 | colors: [threeColor], 237 | geometryColors: [geometryColor], 238 | transforms: [instanceMatrix], 239 | }); 240 | } 241 | 242 | if (!this._geometries.has(code)) { 243 | const assetIDs = new Set([asset.id]); 244 | this._geometries.set(code, { 245 | modelIndex, 246 | geometryID, 247 | assetIDs, 248 | exists: false, 249 | hidden: false, 250 | time: 0, 251 | }); 252 | } else { 253 | const box = this._geometries.get(code) as CullerBoundingBox; 254 | box.assetIDs.add(asset.id); 255 | } 256 | } 257 | } 258 | 259 | const itemsArray = Array.from(items.values()); 260 | bboxes.add(itemsArray); 261 | 262 | THREE.ColorManagement.enabled = colorEnabled; 263 | 264 | // const { geometry, material, count, instanceMatrix, instanceColor } = [ 265 | // ...this.boxes.values(), 266 | // ][0].mesh; 267 | // const mesh = new THREE.InstancedMesh(geometry, material, count); 268 | // mesh.instanceMatrix = instanceMatrix; 269 | // mesh.instanceColor = instanceColor; 270 | // this.components.scene.get().add(mesh); 271 | } 272 | 273 | remove(modelID: string) { 274 | const index = this._modelIDIndex.get(modelID); 275 | if (index === undefined) { 276 | throw new Error("Model doesn't exist!"); 277 | } 278 | 279 | const group = this._geometriesGroups.get(index) as THREE.Group; 280 | group.removeFromParent(); 281 | const children = [...group.children]; 282 | for (const child of children) { 283 | child.removeFromParent(); 284 | } 285 | this._geometriesGroups.delete(index); 286 | 287 | const box = this.boxes.get(index) as FRAGS.Fragment; 288 | box.dispose(false); 289 | this.boxes.delete(index); 290 | 291 | const codes = this.codes.get(index) as Map; 292 | this.codes.delete(index); 293 | for (const [_id, code] of codes) { 294 | const geometry = this._geometries.get(code); 295 | if (geometry && geometry.fragment) { 296 | geometry.fragment.dispose(false); 297 | geometry.fragment = undefined; 298 | } 299 | this._geometries.delete(code); 300 | } 301 | 302 | this._modelIDIndex.delete(modelID); 303 | this._indexModelID.delete(index); 304 | this._geometriesInMemory.clear(); 305 | } 306 | 307 | addFragment(modelID: string, geometryID: number, frag: FRAGS.Fragment) { 308 | const colorEnabled = THREE.ColorManagement.enabled; 309 | THREE.ColorManagement.enabled = false; 310 | 311 | const modelIndex = this._modelIDIndex.get(modelID) as number; 312 | 313 | // Hide bounding box 314 | 315 | const map = this.codes.get(modelIndex) as Map; 316 | const code = map.get(geometryID) as string; 317 | const geometry = this._geometries.get(code) as CullerBoundingBox; 318 | this.setGeometryVisibility(geometry, false, false); 319 | 320 | // Substitute it by fragment with same color 321 | 322 | if (!geometry.fragment) { 323 | geometry.fragment = new FRAGS.Fragment( 324 | frag.mesh.geometry, 325 | this._material, 326 | frag.capacity 327 | ); 328 | 329 | const group = this._geometriesGroups.get(modelIndex); 330 | if (!group) { 331 | throw new Error("Group not found!"); 332 | } 333 | 334 | group.add(geometry.fragment.mesh); 335 | } 336 | 337 | const [r, g, b] = code.split("-").map((value) => parseInt(value, 10)); 338 | 339 | const items: FRAGS.Item[] = []; 340 | for (const itemID of frag.ids) { 341 | const item = frag.get(itemID); 342 | if (!item.colors) { 343 | throw new Error("Malformed fragments!"); 344 | } 345 | for (const color of item.colors) { 346 | color.setRGB(r / 255, g / 255, b / 255, "srgb"); 347 | } 348 | items.push(item); 349 | } 350 | 351 | geometry.fragment.add(items); 352 | 353 | THREE.ColorManagement.enabled = colorEnabled; 354 | 355 | this.needsUpdate = true; 356 | } 357 | 358 | removeFragment(modelID: string, geometryID: number) { 359 | const modelIndex = this._modelIDIndex.get(modelID) as number; 360 | 361 | const map = this.codes.get(modelIndex) as Map; 362 | const code = map.get(geometryID) as string; 363 | const geometry = this._geometries.get(code) as CullerBoundingBox; 364 | if (!geometry.hidden) { 365 | this.setGeometryVisibility(geometry, true, false); 366 | } 367 | 368 | if (geometry.fragment) { 369 | const {fragment} = geometry; 370 | fragment.dispose(false); 371 | geometry.fragment = undefined; 372 | } 373 | } 374 | 375 | // TODO: Is this neccesary anymore? 376 | setModelTransformation(modelID: string, transform: THREE.Matrix4) { 377 | const modelIndex = this._modelIDIndex.get(modelID); 378 | if (modelIndex === undefined) { 379 | throw new Error("Model not found!"); 380 | } 381 | const bbox = this.boxes.get(modelIndex); 382 | if (bbox) { 383 | bbox.mesh.position.set(0, 0, 0); 384 | bbox.mesh.rotation.set(0, 0, 0); 385 | bbox.mesh.scale.set(1, 1, 1); 386 | bbox.mesh.applyMatrix4(transform); 387 | } 388 | const group = this._geometriesGroups.get(modelIndex); 389 | if (group) { 390 | group.position.set(0, 0, 0); 391 | group.rotation.set(0, 0, 0); 392 | group.scale.set(1, 1, 1); 393 | group.applyMatrix4(transform); 394 | } 395 | } 396 | 397 | setVisibility( 398 | visible: boolean, 399 | modelID: string, 400 | geometryIDsAssetIDs: Map> 401 | ) { 402 | const modelIndex = this._modelIDIndex.get(modelID); 403 | if (modelIndex === undefined) { 404 | return; 405 | } 406 | for (const [geometryID, assets] of geometryIDsAssetIDs) { 407 | const map = this.codes.get(modelIndex); 408 | if (map === undefined) { 409 | throw new Error("Map not found!"); 410 | } 411 | const code = map.get(geometryID) as string; 412 | const geometry = this._geometries.get(code); 413 | if (geometry === undefined) { 414 | throw new Error("Geometry not found!"); 415 | } 416 | geometry.hidden = !visible; 417 | this.setGeometryVisibility(geometry, visible, true, assets); 418 | } 419 | } 420 | 421 | updateTransformations(modelID: string) { 422 | const key = this._modelIDIndex.get(modelID); 423 | if (key === undefined) return; 424 | const fragments = this.components.get(OBC.FragmentsManager); 425 | const originalModel = fragments.groups.get(modelID); 426 | if (originalModel) { 427 | originalModel.updateWorldMatrix(true, false); 428 | originalModel.updateMatrix(); 429 | const bboxes = this.boxes.get(key); 430 | if (bboxes) { 431 | bboxes.mesh.position.set(0, 0, 0); 432 | bboxes.mesh.rotation.set(0, 0, 0); 433 | bboxes.mesh.scale.set(1, 1, 1); 434 | bboxes.mesh.updateMatrix(); 435 | bboxes.mesh.applyMatrix4(originalModel.matrixWorld); 436 | bboxes.mesh.updateMatrix(); 437 | } 438 | 439 | const group = this._geometriesGroups.get(key); 440 | if (group) { 441 | group.position.set(0, 0, 0); 442 | group.rotation.set(0, 0, 0); 443 | group.scale.set(1, 1, 1); 444 | group.updateMatrix(); 445 | group.applyMatrix4(originalModel.matrixWorld); 446 | group.updateMatrix(); 447 | } 448 | } 449 | } 450 | 451 | async addStaticGeometries( 452 | geometries: {[modelID: string]: Set}, 453 | culled = true 454 | ) { 455 | const event = { 456 | data: { 457 | colors: new Map(), 458 | }, 459 | }; 460 | const dummyPixelValue = this.threshold + 1000; 461 | 462 | for (const modelID in geometries) { 463 | const modelKey = this._modelIDIndex.get(modelID); 464 | if (modelKey === undefined) { 465 | continue; 466 | } 467 | const map = this.codes.get(modelKey); 468 | if (!map) { 469 | continue; 470 | } 471 | 472 | const geometryIDs = geometries[modelID]; 473 | 474 | for (const geometryID of geometryIDs) { 475 | const colorCode = map.get(geometryID); 476 | if (!colorCode) { 477 | continue; 478 | } 479 | 480 | const geometry = this._geometries.get(colorCode); 481 | if (!geometry) { 482 | continue; 483 | } 484 | 485 | geometry.exists = true; 486 | if (!culled) { 487 | // Static unculled geometries are always visible 488 | geometry.hidden = false; 489 | geometry.time = performance.now(); 490 | event.data.colors.set(colorCode, dummyPixelValue); 491 | } 492 | 493 | this._geometriesInMemory.add(colorCode); 494 | 495 | const statics = culled 496 | ? this._staticGeometries.culled 497 | : this._staticGeometries.unculled; 498 | 499 | if (!statics[modelID]) { 500 | statics[modelID] = new Set(); 501 | } 502 | 503 | statics[modelID].add(geometryID); 504 | } 505 | } 506 | 507 | if (!culled) { 508 | // If unculled, we'll make these geometries visible by forcing its discovery 509 | await this.handleWorkerMessage(event as any); 510 | } 511 | } 512 | 513 | removeStaticGeometries( 514 | geometries: {[modelID: string]: Set}, 515 | culled?: boolean 516 | ) { 517 | const options: ("culled" | "unculled")[] = []; 518 | if (culled === undefined) { 519 | options.push("culled", "unculled"); 520 | } else if (culled === true) { 521 | options.push("culled"); 522 | } else { 523 | options.push("unculled"); 524 | } 525 | 526 | for (const modelID in geometries) { 527 | const geometryIDs = geometries[modelID]; 528 | for (const option of options) { 529 | const set = this._staticGeometries[option][modelID]; 530 | if (set) { 531 | for (const geometryID of geometryIDs) { 532 | set.delete(geometryID); 533 | } 534 | } 535 | } 536 | } 537 | } 538 | 539 | private setGeometryVisibility( 540 | geometry: CullerBoundingBox, 541 | visible: boolean, 542 | includeFragments: boolean, 543 | assets?: Iterable 544 | ) { 545 | const {modelIndex, geometryID, assetIDs} = geometry; 546 | const bbox = this.boxes.get(modelIndex); 547 | if (bbox === undefined) { 548 | throw new Error("Model not found!"); 549 | } 550 | const items = assets || assetIDs; 551 | 552 | if (includeFragments && geometry.fragment) { 553 | geometry.fragment.setVisibility(visible, items); 554 | } else { 555 | const instancesID = new Set(); 556 | for (const id of items) { 557 | const instanceID = this.getInstanceID(id, geometryID); 558 | instancesID.add(instanceID); 559 | } 560 | bbox.setVisibility(visible, instancesID); 561 | } 562 | } 563 | 564 | private handleWorkerMessage = async (event: MessageEvent) => { 565 | const colors = event.data.colors as Map; 566 | 567 | const toLoad: {[modelID: string]: Map>} = {}; 568 | 569 | const toRemove: {[modelID: string]: Set} = {}; 570 | const toHide: {[modelID: string]: Set} = {}; 571 | const toShow: {[modelID: string]: Set} = {}; 572 | 573 | const now = performance.now(); 574 | let viewWasUpdated = false; 575 | 576 | // We can only lose geometries that were previously found 577 | const lostGeometries = new Set(this._geometriesInMemory); 578 | 579 | for (const [color, number] of colors) { 580 | const geometry = this._geometries.get(color); 581 | if (!geometry) { 582 | continue; 583 | } 584 | 585 | const isGeometryBigEnough = number > this.threshold; 586 | if (!isGeometryBigEnough) { 587 | continue; 588 | } 589 | 590 | // The geometry is big enough to be considered seen, so remove it 591 | // from the geometries to be considered lost 592 | lostGeometries.delete(color); 593 | 594 | const {exists} = geometry; 595 | const modelID = this._indexModelID.get(geometry.modelIndex) as string; 596 | 597 | if (exists) { 598 | // Geometry was present in memory, and still is, so show it 599 | geometry.time = now; 600 | if (!toShow[modelID]) { 601 | toShow[modelID] = new Set(); 602 | } 603 | toShow[modelID].add(geometry.geometryID); 604 | this._geometriesInMemory.add(color); 605 | viewWasUpdated = true; 606 | } else { 607 | // New geometry found that is not in memory 608 | if (!toLoad[modelID]) { 609 | toLoad[modelID] = new Map(); 610 | } 611 | geometry.time = now; 612 | geometry.exists = true; 613 | 614 | if (!toLoad[modelID].has(number)) { 615 | toLoad[modelID].set(number, new Set()); 616 | } 617 | const set = toLoad[modelID].get(number) as Set; 618 | set.add(geometry.geometryID); 619 | this._geometriesInMemory.add(color); 620 | viewWasUpdated = true; 621 | } 622 | } 623 | 624 | // Handle geometries that were lost 625 | for (const color of lostGeometries) { 626 | const geometry = this._geometries.get(color); 627 | if (geometry) { 628 | this.handleLostGeometries(now, color, geometry, toRemove, toHide); 629 | viewWasUpdated = true; 630 | } 631 | } 632 | 633 | if (viewWasUpdated) { 634 | await this.onViewUpdated.trigger({toLoad, toRemove, toHide, toShow}); 635 | } 636 | 637 | this._isWorkerBusy = false; 638 | }; 639 | 640 | private handleLostGeometries( 641 | now: number, 642 | color: string, 643 | geometry: CullerBoundingBox, 644 | toRemove: { 645 | [p: string]: Set; 646 | }, 647 | toHide: {[p: string]: Set} 648 | ) { 649 | const modelID = this._indexModelID.get(geometry.modelIndex) as string; 650 | const lostTime = now - geometry.time; 651 | 652 | const {culled, unculled} = this._staticGeometries; 653 | 654 | if (lostTime > this.maxLostTime) { 655 | // This geometry was lost too long - delete it 656 | 657 | // If it's any kind of static geometry, skip it 658 | if ( 659 | culled[modelID]?.has(geometry.geometryID) || 660 | unculled[modelID]?.has(geometry.geometryID) 661 | ) { 662 | return; 663 | } 664 | 665 | if (!toRemove[modelID]) { 666 | toRemove[modelID] = new Set(); 667 | } 668 | geometry.exists = false; 669 | toRemove[modelID].add(geometry.geometryID); 670 | this._geometriesInMemory.delete(color); 671 | } else if (lostTime > this.maxHiddenTime) { 672 | // If it's an unculled static geometry, skip it 673 | if (unculled[modelID]?.has(geometry.geometryID)) { 674 | return; 675 | } 676 | 677 | // This geometry was lost for a while - hide it 678 | if (!toHide[modelID]) { 679 | toHide[modelID] = new Set(); 680 | } 681 | toHide[modelID].add(geometry.geometryID); 682 | } 683 | } 684 | 685 | private createModelIndex(modelID: string) { 686 | if (this._modelIDIndex.has(modelID)) { 687 | throw new Error("Can't load the same model twice!"); 688 | } 689 | const count = this._nextModelID; 690 | this._nextModelID++; 691 | this._modelIDIndex.set(modelID, count); 692 | this._indexModelID.set(count, modelID); 693 | return count; 694 | } 695 | 696 | private getInstanceID(assetID: number, geometryID: number) { 697 | // src: https://stackoverflow.com/questions/14879691/get-number-of-digits-with-javascript 698 | const size = (Math.log(geometryID) * Math.LOG10E + 1) | 0; 699 | const factor = 10 ** size; 700 | return assetID + geometryID / factor; 701 | } 702 | } 703 | --------------------------------------------------------------------------------