├── 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 |
--------------------------------------------------------------------------------