├── public ├── favicon.ico ├── web-ifc.wasm ├── ifcjs-logo.png ├── robots.txt ├── web-ifc-mt.wasm └── index.html ├── src ├── components │ ├── building │ │ ├── types.ts │ │ ├── bottom-menu │ │ │ ├── building-bottom-menu.css │ │ │ ├── building-bottom-menu.tsx │ │ │ └── bottombar-tools.tsx │ │ ├── front-menu │ │ │ ├── front-menu-content │ │ │ │ ├── front-menu-context.css │ │ │ │ ├── properties-menu.tsx │ │ │ │ ├── floorplan-menu.tsx │ │ │ │ ├── energy-graphs │ │ │ │ │ ├── wind-graph.tsx │ │ │ │ │ ├── solar-graph.tsx │ │ │ │ │ ├── stacked-bar-graph.tsx │ │ │ │ │ └── monthly-costs-bar-chart.tsx │ │ │ │ ├── building-info-menu.tsx │ │ │ │ ├── model-list-menu.tsx │ │ │ │ ├── energy-menu.tsx │ │ │ │ └── documents-menu.tsx │ │ │ ├── building-front-menu.css │ │ │ └── building-front-menu.tsx │ │ ├── viewport │ │ │ └── building-viewport.tsx │ │ ├── side-menu │ │ │ ├── building-drawer.tsx │ │ │ ├── building-sidebar.tsx │ │ │ └── sidebar-tools.tsx │ │ └── building-viewer.tsx │ ├── map │ │ ├── map-viewer.css │ │ ├── sidebar │ │ │ ├── drawer.tsx │ │ │ ├── sidebar.tsx │ │ │ └── map-tools.tsx │ │ └── map-viewer.tsx │ ├── user │ │ ├── user-styles.css │ │ └── login-form.tsx │ ├── navbar │ │ └── navbar.tsx │ └── utils │ │ └── mui-utils.tsx ├── core │ ├── building │ │ ├── dexie-utils.ts │ │ ├── building-handler.ts │ │ ├── building-database.ts │ │ └── building-scene.ts │ ├── map │ │ ├── map-handler.ts │ │ ├── map-database.ts │ │ └── map-scene.ts │ └── db │ │ ├── energy-data-handler.ts │ │ └── db-handler.ts ├── middleware │ ├── state.ts │ ├── event-handler.ts │ ├── actions.ts │ ├── state-handler.ts │ ├── authenticator.tsx │ ├── context-provider.tsx │ └── core-handler.ts ├── index.tsx ├── App.tsx ├── types.ts └── App.css ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpatacas/bim2twin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/web-ifc.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpatacas/bim2twin/HEAD/public/web-ifc.wasm -------------------------------------------------------------------------------- /public/ifcjs-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpatacas/bim2twin/HEAD/public/ifcjs-logo.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/web-ifc-mt.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpatacas/bim2twin/HEAD/public/web-ifc-mt.wasm -------------------------------------------------------------------------------- /src/components/building/types.ts: -------------------------------------------------------------------------------- 1 | export type FrontMenuMode = 2 | | "BuildingInfo" 3 | | "ModelList" 4 | | "Properties" 5 | | "Floorplans" 6 | | "Energy" 7 | | "Documents"; -------------------------------------------------------------------------------- /src/components/building/bottom-menu/building-bottom-menu.css: -------------------------------------------------------------------------------- 1 | .bottom-menu { 2 | position: absolute; 3 | height: 3rem; 4 | bottom: 1rem; 5 | left: 50%; 6 | transform: translateX(-50%); 7 | padding: 8px; 8 | display: flex; 9 | align-items: center; 10 | } 11 | 12 | .bottom-menu-content { 13 | padding: 0 !important; 14 | height: 100%; 15 | width: 100%; 16 | } -------------------------------------------------------------------------------- /src/core/building/dexie-utils.ts: -------------------------------------------------------------------------------- 1 | import {Dexie} from "dexie"; 2 | 3 | interface IModel { 4 | id: string; 5 | file: Blob; 6 | } 7 | 8 | export class ModelDatabase extends Dexie { 9 | models!: Dexie.Table; 10 | 11 | constructor() { 12 | super("ModelDatabase"); 13 | this.version(2).stores({ 14 | models:"id, file", 15 | }) 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/front-menu-context.css: -------------------------------------------------------------------------------- 1 | .list-item { 2 | margin-top: 1rem; 3 | } 4 | 5 | .margin-left { 6 | margin-left: 15px; 7 | } 8 | .wide-button { 9 | width: 100%; 10 | } 11 | 12 | .value-pair { 13 | display: flex; 14 | } 15 | 16 | .value-pair > *:first-child { 17 | font-weight: bold; 18 | } 19 | 20 | .value-pair > *:nth-child(3) { 21 | margin-left: 1rem; 22 | } -------------------------------------------------------------------------------- /src/middleware/state.ts: -------------------------------------------------------------------------------- 1 | import {User} from "firebase/auth" 2 | import { Building } from './../types'; 3 | import { Floorplan, Property } from "./../types"; 4 | 5 | export interface State { 6 | user: User | null; 7 | building : Building | null; 8 | floorplans: Floorplan[]; 9 | properties: Property[]; 10 | } 11 | 12 | export const initialState: State = { 13 | user: null, 14 | building: null, 15 | floorplans: [], 16 | properties: [], 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env 21 | firebase-conf.js 22 | config.js 23 | /public/bim2twin-logo.PNG 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | -------------------------------------------------------------------------------- /src/middleware/event-handler.ts: -------------------------------------------------------------------------------- 1 | import { ActionType , Action} from "./actions" 2 | 3 | export class Events { 4 | private list: {[type:string]: Function[]} = {} 5 | 6 | on(type: ActionType, callback: Function) { 7 | if (!this.list[type]) { 8 | this.list[type] = []; 9 | } 10 | this.list[type].push(callback); 11 | } 12 | 13 | trigger(action: Action) { 14 | if(!this.list[action.type]) { 15 | return; 16 | } 17 | 18 | for (const event of this.list[action.type]) { 19 | event(action.payload) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/components/map/map-viewer.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | width: 100vw; 4 | height: 100vh; 5 | background: rgba(255,255,255,0.6); 6 | pointer-events: none; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | z-index: 999; 11 | } 12 | 13 | .overlay > Button { 14 | pointer-events: all; 15 | } 16 | 17 | .gis-button-container { 18 | position: absolute; 19 | display: flex; 20 | margin: 1rem; 21 | flex-direction: column; 22 | width: 10rem; 23 | } 24 | 25 | .gis-button-container > * { 26 | 27 | margin: 8px 0 !important; 28 | 29 | } -------------------------------------------------------------------------------- /src/components/building/viewport/building-viewport.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useRef, useEffect} from "react" 2 | import { useAppContext } from "../../../middleware/context-provider"; 3 | 4 | export const BuildingViewport: FC = () => { 5 | 6 | const [{user, building}, dispatch] = useAppContext(); 7 | const containerRef = useRef(null) 8 | 9 | useEffect( () => { 10 | const container = containerRef.current 11 | if (container && user) { 12 | dispatch({type:"START_BUILDING", payload: {container, building}}) 13 | } 14 | }, []) 15 | 16 | return
17 | } -------------------------------------------------------------------------------- /src/components/user/user-styles.css: -------------------------------------------------------------------------------- 1 | .landing-logo { 2 | max-width: 23rem; 3 | margin: 0.5rem auto 2rem auto; 4 | /* animation: logo-spin 8s infinite 0s cubic-bezier(0.38, 0.01, 0, 0.99); */ 5 | display: block; 6 | } 7 | 8 | h1 { 9 | 10 | color: rgb(25, 118, 210) 11 | 12 | } 13 | 14 | @keyframes logo-spin { 15 | 0% { 16 | transform: rotate(0); 17 | } 18 | 25% { 19 | transform: rotate(90deg); 20 | } 21 | 50% { 22 | transform: rotate(180deg); 23 | } 24 | 75% { 25 | transform: rotate(270deg); 26 | } 27 | 100% { 28 | transform: rotate(360deg); 29 | } 30 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/middleware/actions.ts: -------------------------------------------------------------------------------- 1 | export const ActionList = ["LOGIN", 2 | "LOGOUT", 3 | "START_MAP", 4 | "REMOVE_MAP", 5 | "UPDATE_USER", 6 | "ADD_BUILDING", 7 | "OPEN_BUILDING", 8 | "CLOSE_BUILDING", 9 | "UPDATE_BUILDING", 10 | "DELETE_BUILDING", 11 | "UPLOAD_MODEL", 12 | "DELETE_MODEL", 13 | "START_BUILDING", 14 | "CLOSE_BUILDING", 15 | "EXPLODE_MODEL", 16 | "TOGGLE_CLIPPER", 17 | "TOGGLE_DIMENSIONS", 18 | "TOGGLE_FLOORPLAN", 19 | "UPDATE_FLOORPLANS", 20 | "UPDATE_PROPERTIES", 21 | "GET_BUILDINGS", 22 | "CENTER_MAP", 23 | "GET_ENERGY_DATA", 24 | "ADD_ENERGY_DATA", 25 | "UPLOAD_DOCUMENT", 26 | "DELETE_DOCUMENT", 27 | "GET_DOCUMENT" 28 | ] as const; 29 | 30 | export type ActionType = typeof ActionList[number] 31 | 32 | export interface Action { 33 | type: ActionType; 34 | payload?: any; 35 | } 36 | -------------------------------------------------------------------------------- /src/middleware/state-handler.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "./actions"; 2 | import {State} from "./state" 3 | 4 | export const reducer = (state: State, action: Action) => { 5 | if (action.type === "UPDATE_USER") { 6 | return {...state, user: action.payload } 7 | } 8 | if (action.type === "OPEN_BUILDING" || action.type === "UPDATE_BUILDING") { 9 | return {...state, building: action.payload } 10 | } 11 | if (action.type === "CLOSE_BUILDING") { 12 | return {...state, building: null } 13 | } 14 | if (action.type === "UPDATE_FLOORPLANS") { 15 | return { ...state, floorplans: action.payload }; 16 | } 17 | if (action.type === "UPDATE_PROPERTIES") { 18 | return { ...state, properties: action.payload }; 19 | } 20 | return {...state} 21 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/properties-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@mui/material"; 2 | import { FC } from "react"; 3 | import { useAppContext } from "../../../../middleware/context-provider"; 4 | import "./front-menu-context.css"; 5 | 6 | export const PropertiesMenu: FC = () => { 7 | const [state] = useAppContext(); 8 | 9 | return ( 10 |
11 | {Boolean(state.properties.length) ? ( 12 | 13 | ) : ( 14 |

No item selected.

15 | )} 16 | 17 | {state.properties.map((property) => ( 18 |
19 |
20 |
{property.name}
21 |

:

22 |
{property.value}
23 |
24 | 25 |
26 | ))} 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/building/bottom-menu/building-bottom-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Card, IconButton } from "@mui/material"; 2 | import { FC } from "react"; 3 | import { getBottombarTools } from "./bottombar-tools"; 4 | import { useAppContext } from "../../../middleware/context-provider"; 5 | import "./building-bottom-menu.css"; 6 | 7 | const tools = getBottombarTools(); 8 | 9 | export const BuildingBottomMenu: FC = () => { 10 | const [state, dispatch] = useAppContext(); 11 | 12 | //const tools = getBottombarTools(state, dispatch); 13 | 14 | return ( 15 | 16 | {tools.map((tool) => { 17 | return ( 18 | tool.action(dispatch)} 21 | key={tool.name} 22 | > 23 | {tool.icon} 24 | 25 | ); 26 | })} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/middleware/authenticator.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth, onAuthStateChanged } from "firebase/auth" 2 | import { useAppContext } from "./context-provider"; 3 | import {FC, useEffect} from "react" 4 | 5 | let authInitialized = false; 6 | 7 | export const Authenticator: FC = () => { 8 | const auth = getAuth(); 9 | const dispatch = useAppContext()[1]; //same as const [state, dispatch] = useAppContext(); 10 | 11 | const listenToAuthChanges = () => { //called when user authenticates to firebase 12 | onAuthStateChanged(auth, (foundUser) => { 13 | const user = foundUser ? {...foundUser} : null; //if foundUser is there copy it, otherwise null 14 | dispatch({type: "UPDATE_USER", payload: user}) 15 | }) 16 | } 17 | 18 | useEffect(()=> { 19 | if (!authInitialized) { 20 | listenToAuthChanges(); 21 | authInitialized = true; 22 | } 23 | }, []) 24 | 25 | return <>; 26 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 24 | React App 25 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/core/map/map-handler.ts: -------------------------------------------------------------------------------- 1 | import { Events } from "../../middleware/event-handler"; 2 | import { MapScene } from "./map-scene"; 3 | import { User } from "firebase/auth"; 4 | 5 | export const mapHandler = { 6 | 7 | viewer: null as MapScene | null, 8 | 9 | async start(container: HTMLDivElement, user: User, events: Events) { 10 | if(!this.viewer) { 11 | 12 | console.log("Map started!") 13 | this.viewer = new MapScene(container, events) 14 | await this.viewer.getAllBuildings(user) 15 | } 16 | }, 17 | remove() { 18 | if (this.viewer) { 19 | 20 | console.log("Map removed!") 21 | this.viewer.dispose(); 22 | this.viewer = null; 23 | } 24 | }, 25 | 26 | async addBuilding(user: User) { 27 | if (this.viewer) { 28 | await this.viewer.addBuilding(user) 29 | } 30 | }, 31 | centerMap(lat: number, lng: number) { 32 | if (this.viewer) { 33 | this.viewer.centerMap(lat, lng) 34 | } 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/components/navbar/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Outlet } from "react-router-dom"; 3 | import { getAppBar } from "../utils/mui-utils"; 4 | import { Toolbar, IconButton, Typography } from "@mui/material"; 5 | import MenuIcon from "@mui/icons-material/Menu"; 6 | 7 | export const NavBar: FC<{ 8 | open: boolean; 9 | onOpen: () => void; 10 | width: number; 11 | }> = (props) => { 12 | const { open, onOpen, width } = props; 13 | 14 | const Appbar = getAppBar(width); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 27 | 28 | 29 | 30 | BIM2TWIN 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | import {initializeApp} from 'firebase/app'; 6 | 7 | import { firebaseConfig } from './config'; 8 | 9 | import "@fontsource/roboto/300.css" 10 | import "@fontsource/roboto/400.css" 11 | import "@fontsource/roboto/500.css" 12 | import "@fontsource/roboto/700.css" 13 | 14 | // import * as dotenv from "dotenv"; 15 | // console.log(dotenv.config()) 16 | 17 | // const firebaseConfig = { 18 | // apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 19 | // authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 20 | // projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, 21 | // storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, 22 | // messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, 23 | // appId: process.env.REACT_APP_FIREBASE_APP_ID, 24 | // } 25 | 26 | 27 | initializeApp(firebaseConfig) 28 | 29 | const root = ReactDOM.createRoot( 30 | document.getElementById('root') as HTMLElement 31 | ); 32 | root.render( 33 | 34 | 35 | 36 | ); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 João Patacas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/building/front-menu/building-front-menu.css: -------------------------------------------------------------------------------- 1 | .front-menu { 2 | position: absolute; 3 | /* height: 80vh; */ 4 | overflow-y: auto !important; 5 | min-width: 30rem; 6 | z-index: 1; 7 | } 8 | 9 | .front-menu-content { 10 | margin: 1rem; 11 | } 12 | 13 | .front-menu-header { 14 | display: flex; 15 | } 16 | 17 | .front-menu-header > Button { 18 | margin-left: auto; 19 | } 20 | 21 | .bottom-right { position:absolute; bottom:1rem; right:1rem; } 22 | 23 | .plot-container { 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | max-height: 700px; /* Set the maximum height of the container */ 28 | overflow: hidden; /* Hide overflow to prevent double scrollbar */ 29 | } 30 | 31 | .first-plot { 32 | flex: none; /* First plot doesn't grow or shrink */ 33 | } 34 | 35 | .scrollable-plots { 36 | overflow-y: auto; /* Add vertical scrollbar if content overflows */ 37 | max-height: 100%; /* Allow scrollable area to take remaining height */ 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; 2 | import "./App.css"; 3 | import { BuildingViewer } from "./components/building/building-viewer"; 4 | import { MapViewer } from "./components/map/map-viewer"; 5 | import { LoginForm } from "./components/user/login-form"; 6 | import { ContextProvider } from "./middleware/context-provider"; 7 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 8 | 9 | const theme = createTheme({ 10 | palette: { 11 | primary: { 12 | main: '#3975FF' 13 | }, 14 | secondary: { 15 | main: '#352276' 16 | } 17 | } 18 | }); 19 | 20 | function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface GisParameters { 2 | container: HTMLDivElement; 3 | accessToken: string; 4 | zoom: number; 5 | pitch: number; 6 | center: [number, number]; 7 | bearing: number; 8 | buildings: Building[]; 9 | } 10 | 11 | export interface Building { 12 | uid: string; 13 | userID: string; 14 | lat: number; 15 | lng: number; 16 | energy: number; 17 | name: string; 18 | models: Model[]; 19 | documents: Document[]; 20 | } 21 | 22 | export interface Model { 23 | name: string; 24 | id: string; 25 | } 26 | 27 | export interface Document { 28 | name: string; 29 | id: string; 30 | } 31 | 32 | export interface LngLat { 33 | lng: number; 34 | lat: number; 35 | } 36 | 37 | export interface Tool { 38 | name: string; 39 | active: boolean; 40 | icon: any; 41 | action: (...args: any) => void; 42 | } 43 | 44 | export interface Floorplan { 45 | name: string; 46 | id: string; 47 | } 48 | 49 | export interface Property { 50 | name: string; 51 | value: string; 52 | } 53 | 54 | export interface EnergyData { 55 | buildingId: string; 56 | month: string; 57 | electricity: number; 58 | gas: number; 59 | solar: number; 60 | wind: number; 61 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/floorplan-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@mui/material"; 2 | import { FC } from "react"; 3 | import { useAppContext } from "../../../../middleware/context-provider"; 4 | import { Floorplan } from "../../../../types"; 5 | import "./front-menu-context.css"; 6 | 7 | export const FloorplanMenu: FC = () => { 8 | const [state, dispatch] = useAppContext(); 9 | 10 | const onFloorplanSelected = (active: boolean, floorplan?: Floorplan) => { 11 | dispatch({ type: "TOGGLE_FLOORPLAN", payload: { active, floorplan } }); 12 | }; 13 | 14 | return ( 15 |
16 | {state.floorplans.map((plan) => ( 17 |
18 | 24 |
25 | ))} 26 |
27 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: "Roboto", Arial, Helvetica, sans-serif; 6 | --light-grey: #b4c7d7; 7 | } 8 | 9 | .full-screen { 10 | top: 0; 11 | left: 0; 12 | width: 100vw; 13 | height:100vh; 14 | position: absolute; 15 | } 16 | 17 | .thumbnail { 18 | background: rgba(230, 230, 230, 0.7); 19 | padding: 10px; 20 | border-radius: 20%; 21 | border: 3px solid white; 22 | pointer-events: all; 23 | transition: background-color 0.2s ease-in-out; 24 | } 25 | 26 | .thumbnail:hover{ 27 | background-color: rgb(57, 117, 255); 28 | cursor: pointer; 29 | } 30 | 31 | .ifcjs-dimension-label { 32 | background-color: black; 33 | font-family: sans-serif; 34 | color: white; 35 | padding: 8px; 36 | border-radius: 8px; 37 | pointer-events: all; 38 | transition: background-color 200ms ease-in-out; 39 | } 40 | 41 | .ifcjs-dimension-label:hover { 42 | background-color: grey; 43 | } 44 | 45 | .ifcjs-dimension-preview { 46 | background-color: #ffffff; 47 | width: 2rem; 48 | height: 2rem; 49 | opacity: 0.3; 50 | padding: 8px; 51 | border-radius: 100%; 52 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/energy-graphs/wind-graph.tsx: -------------------------------------------------------------------------------- 1 | // WindGraph.tsx 2 | import React from 'react'; 3 | import Plot from 'react-plotly.js'; 4 | import { Data, Layout } from 'plotly.js'; 5 | 6 | interface WindGraphProps { 7 | windGeneration: number[]; 8 | } 9 | 10 | const WindGraph: React.FC = ({ windGeneration }) => { 11 | const months = [ 12 | 'January', 'February', 'March', 'April', 'May', 'June', 13 | 'July', 'August', 'September', 'October', 'November', 'December' 14 | ]; 15 | 16 | const traceWind: Data = { 17 | x: months, 18 | y: windGeneration, 19 | type: 'bar', 20 | name: 'Wind Power Generation', 21 | }; 22 | 23 | const data: Data[] = [traceWind]; 24 | 25 | const layout: Partial = { 26 | barmode: 'stack', 27 | title: 'Wind Power Generation by Month
kWh', 28 | xaxis: { 29 | title: 'Month', 30 | }, 31 | yaxis: { 32 | title: 'Energy Generation
kWh', 33 | }, 34 | }; 35 | 36 | return ( 37 | 41 | ); 42 | }; 43 | 44 | export default WindGraph; 45 | -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/energy-graphs/solar-graph.tsx: -------------------------------------------------------------------------------- 1 | // SolarGraph.tsx 2 | import React from 'react'; 3 | import Plot from 'react-plotly.js'; 4 | import { Data, Layout } from 'plotly.js'; 5 | 6 | interface SolarGraphProps { 7 | solarGeneration: number[]; 8 | } 9 | 10 | const SolarGraph: React.FC = ({ solarGeneration }) => { 11 | const months = [ 12 | 'January', 'February', 'March', 'April', 'May', 'June', 13 | 'July', 'August', 'September', 'October', 'November', 'December' 14 | ]; 15 | 16 | const traceSolar: Data = { 17 | x: months, 18 | y: solarGeneration, 19 | type: 'bar', 20 | name: 'Solar Power Generation', 21 | }; 22 | 23 | const data: Data[] = [traceSolar]; 24 | 25 | const layout: Partial = { 26 | barmode: 'stack', 27 | title: 'Solar Power Generation by Month
kWh', 28 | xaxis: { 29 | title: 'Month', 30 | }, 31 | yaxis: { 32 | title: 'Energy Generation
kWh', 33 | }, 34 | }; 35 | 36 | return ( 37 | 41 | ); 42 | }; 43 | 44 | export default SolarGraph; 45 | -------------------------------------------------------------------------------- /src/middleware/context-provider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FC, 3 | PropsWithChildren, 4 | useReducer, 5 | createContext, 6 | useContext, 7 | } from "react"; 8 | import { reducer } from "./state-handler"; 9 | import { initialState, State } from "./state"; 10 | import { Action, ActionList } from "./actions"; 11 | import { executeCore } from "./core-handler"; 12 | import { Authenticator } from "./authenticator"; 13 | import { Events } from "./event-handler"; 14 | 15 | const appContext = createContext<[State, React.Dispatch]>([ 16 | initialState, 17 | () => {}, 18 | ]); 19 | 20 | export const ContextProvider: FC = ({ children }) => { 21 | const [state, setState] = useReducer(reducer, initialState); 22 | 23 | const dispatch = (value: Action) => { 24 | setState(value); 25 | executeCore(value, events); 26 | }; 27 | 28 | const events = new Events(); 29 | for (const type of ActionList) { 30 | events.on(type, (payload: any) => { 31 | dispatch({ type, payload }); 32 | }); 33 | } 34 | return ( 35 | 36 | 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | export const useAppContext = () => { 43 | return useContext(appContext); 44 | }; 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BIM2TWIN 2 | 3 | A Common Data Environment providing the integration of BIM with other data sources such as GIS, documents, energy data, etc. for the development of urban digital twins. 4 | 5 | Main features: 6 | - Create projects by user based on location on world map 7 | - IFC model storage using Firebase (add and delete models for each building) 8 | - IFC model loading using fragments, including local caching 9 | - IFC model viewer including IFC properties menu, floorplans viewer, clipping planes, measurements and explosion tools 10 | - Document management by building using Firebase storage (add, view and delete documents for each building) 11 | - Basic energy data management by building (using Firebase) 12 | 13 | Technologies used: 14 | - Typescript 15 | - React 16 | - IFC.js - openbim-components 17 | - Mapbox 18 | - Firebase 19 | - Material UI 20 | - Plotly.js 21 | 22 | ## Setup 23 | 24 | In the project directory, you can run: 25 | 26 | ### `npm start` 27 | 28 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 29 | 30 | ## Firebase configuration 31 | 32 | - add a `config.js` file in `/src` with your `firebaseConfig` data and Mapbox API key 33 | 34 | - setup authentication in Firebase using google and setup storage in your Firebase project 35 | 36 | - Configuring CORS using [Google Cloud console](https://stackoverflow.com/a/58613527) 37 | -------------------------------------------------------------------------------- /src/components/building/side-menu/building-drawer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useTheme } from "@mui/material/styles"; 3 | import Divider from "@mui/material/Divider"; 4 | import IconButton from "@mui/material/IconButton"; 5 | import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; 6 | import ChevronRightIcon from "@mui/icons-material/ChevronRight"; 7 | import { BuildingSidebar } from "./building-sidebar"; 8 | import { getDrawer, getDrawerHeader } from "../../utils/mui-utils"; 9 | import { FrontMenuMode } from "../types"; 10 | 11 | export const BuildingDrawer: FC<{ 12 | open: boolean; 13 | width: number; 14 | onToggleMenu: (active?: boolean, mode?: FrontMenuMode) => void; 15 | onClose: () => void; 16 | }> = (props) => { 17 | const theme = useTheme(); 18 | 19 | const { open, width: drawerWidth, onClose, onToggleMenu } = props; 20 | 21 | const Drawer = getDrawer(drawerWidth); 22 | const DrawerHeader = getDrawerHeader(); 23 | 24 | return ( 25 | 26 | 27 | 28 | {theme.direction === "rtl" ? ( 29 | 30 | ) : ( 31 | 32 | )} 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; -------------------------------------------------------------------------------- /src/core/map/map-database.ts: -------------------------------------------------------------------------------- 1 | import {addDoc, collection, getFirestore, onSnapshot, query, where} from "firebase/firestore" 2 | import { User } from 'firebase/auth'; 3 | import { getApp } from 'firebase/app'; 4 | 5 | import { Building } from './../../types'; 6 | export class MapDatabase { 7 | private readonly buildings = "buildings"; 8 | 9 | async add(building: Building) { 10 | const dbInstance = getFirestore (getApp()) 11 | const {lat, lng,energy, userID, name, models, documents} = building; 12 | const result = await addDoc(collection(dbInstance, this.buildings), { 13 | lat, 14 | lng, 15 | energy, 16 | userID, 17 | name, 18 | models, 19 | documents 20 | }) 21 | return result.id; 22 | } 23 | 24 | async getBuildings(user: User) { 25 | const dbInstance = getFirestore(getApp()) 26 | const q = query( 27 | collection(dbInstance, this.buildings), 28 | where("userID", "==", user.uid) 29 | ) 30 | 31 | return new Promise((resolve) => { 32 | const unsubscribe = onSnapshot(q, (snapshot) => { 33 | const result: Building[] = [] 34 | snapshot.docs.forEach((doc) => { 35 | result.push({...(doc.data() as Building), uid: doc.id}) 36 | }) 37 | unsubscribe(); 38 | resolve(result) 39 | }) 40 | }) 41 | } 42 | } -------------------------------------------------------------------------------- /src/components/map/sidebar/drawer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useTheme } from "@mui/material/styles"; 3 | import Divider from "@mui/material/Divider"; 4 | import IconButton from "@mui/material/IconButton"; 5 | import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; 6 | import ChevronRightIcon from "@mui/icons-material/ChevronRight"; 7 | import { Sidebar } from "./sidebar"; 8 | import { getDrawer, getDrawerHeader } from "../../utils/mui-utils"; 9 | import { FrontMenuMode } from "../../building/types"; 10 | 11 | export const Drawer: FC<{ 12 | open: boolean; 13 | width: number; 14 | onToggleMenu: (active?: boolean, mode?: FrontMenuMode) => void; 15 | onClose: () => void; 16 | tools: Array<{ 17 | name: string; 18 | icon: React.ReactNode; 19 | action: Function; 20 | }>; 21 | isCreating: boolean; 22 | }> = (props) => { 23 | const theme = useTheme(); 24 | 25 | const { open, width: drawerWidth, onClose, onToggleMenu, tools } = props; 26 | 27 | const Drawer = getDrawer(drawerWidth); 28 | const DrawerHeader = getDrawerHeader(); 29 | 30 | return ( 31 | 32 | 33 | 34 | {theme.direction === "rtl" ? ( 35 | 36 | ) : ( 37 | 38 | )} 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; -------------------------------------------------------------------------------- /src/components/map/sidebar/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { 3 | List, 4 | ListItem, 5 | ListItemButton, 6 | ListItemIcon, 7 | ListItemText, 8 | } from "@mui/material"; 9 | import { useAppContext } from "../../../middleware/context-provider"; 10 | import { FrontMenuMode } from "../../building/types" //needed for IFC viewer 11 | 12 | export const Sidebar: FC<{ 13 | open: boolean; 14 | onToggleMenu: (active?: boolean, mode?: FrontMenuMode) => void; 15 | tools: Array<{ 16 | name: string; 17 | icon: React.ReactNode; 18 | action: Function; 19 | }>; 20 | }> = (props) => { 21 | const { open, onToggleMenu, tools } = props; 22 | const [state, dispatch] = useAppContext(); 23 | 24 | return ( 25 | 26 | {tools.map((tool) => ( 27 | tool.action({ onToggleMenu, state, dispatch })} 29 | key={tool.name} 30 | disablePadding 31 | sx={{ display: "block" }} 32 | > 33 | 40 | 47 | {tool.icon} 48 | 49 | 50 | 51 | 52 | ))} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/building/side-menu/building-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { 3 | List, 4 | ListItem, 5 | ListItemButton, 6 | ListItemIcon, 7 | ListItemText, 8 | } from "@mui/material"; 9 | import { getSidebarTools } from "./sidebar-tools"; 10 | import { useAppContext } from "../../../middleware/context-provider"; 11 | import { FrontMenuMode } from "../types"; 12 | 13 | //const tools = getSidebarTools(); 14 | 15 | export const BuildingSidebar: FC<{ 16 | open: boolean; 17 | onToggleMenu: (active?: boolean, mode?: FrontMenuMode) => void; 18 | }> = (props) => { 19 | const { open, onToggleMenu } = props; 20 | const [state, dispatch] = useAppContext(); 21 | 22 | const tools = getSidebarTools(state, dispatch, onToggleMenu); 23 | 24 | return ( 25 | 26 | {tools.map((tool) => ( 27 | tool.action({ onToggleMenu, state, dispatch })} 29 | key={tool.name} 30 | disablePadding 31 | sx={{ display: "block" }} 32 | > 33 | 40 | 47 | {tool.icon} 48 | 49 | 50 | 51 | 52 | ))} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/energy-graphs/stacked-bar-graph.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Plot from 'react-plotly.js'; 3 | import { Data, Layout } from 'plotly.js'; 4 | 5 | interface StackedBarGraphProps { 6 | gasData: number[]; 7 | electricityData: number[]; 8 | solarData: number[]; 9 | windData: number[]; 10 | } 11 | 12 | const StackedBarGraph: React.FC = ({ 13 | gasData, 14 | electricityData, 15 | solarData, 16 | windData, 17 | }) => { 18 | const months = [ 19 | 'January', 'February', 'March', 'April', 'May', 'June', 20 | 'July', 'August', 'September', 'October', 'November', 'December' 21 | ]; 22 | 23 | const traceGas: Data = { 24 | x: months, 25 | y: gasData, 26 | type: 'bar', 27 | name: 'Gas', 28 | }; 29 | 30 | const traceElectricity: Data = { 31 | x: months, 32 | y: electricityData, 33 | type: 'bar', 34 | name: 'Electricity', 35 | }; 36 | 37 | const traceSolar: Data = { 38 | x: months, 39 | y: solarData, 40 | type: 'bar', 41 | name: 'Solar', 42 | }; 43 | 44 | const traceWind: Data = { 45 | x: months, 46 | y: windData, 47 | type: 'bar', 48 | name: 'Wind', 49 | }; 50 | 51 | const data: Data[] = [traceGas, traceElectricity, traceSolar, traceWind]; 52 | 53 | const layout: Partial = { 54 | barmode: 'stack', 55 | title: 'Energy Use by Month
kWh', 56 | xaxis: { 57 | title: 'Month', 58 | }, 59 | yaxis: { 60 | title: 'Energy Consumption
kWh', 61 | }, 62 | }; 63 | 64 | return ( 65 | 69 | ); 70 | }; 71 | 72 | export default StackedBarGraph; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.1", 7 | "@emotion/styled": "^11.11.0", 8 | "@fontsource/roboto": "^5.0.4", 9 | "@mui/icons-material": "^5.13.7", 10 | "@mui/material": "^5.13.7", 11 | "@testing-library/jest-dom": "^5.16.5", 12 | "@testing-library/react": "^13.4.0", 13 | "@testing-library/user-event": "^13.5.0", 14 | "@types/jest": "^27.5.2", 15 | "@types/node": "^16.18.38", 16 | "@types/react": "^18.2.14", 17 | "@types/react-dom": "^18.2.6", 18 | "client-zip": "^2.4.4", 19 | "dexie": "^3.2.4", 20 | "dotenv": "^16.3.1", 21 | "firebase": "^9.23.0", 22 | "mapbox-gl": "^2.15.0", 23 | "openbim-components": "^0.0.39", 24 | "plotly.js": "^2.25.1", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "react-plotly.js": "^2.6.0", 28 | "react-router-dom": "^6.14.1", 29 | "react-scripts": "5.0.1", 30 | "three": "^0.135.0", 31 | "typescript": "^4.9.5", 32 | "unzipit": "^1.4.3", 33 | "web-vitals": "^2.1.4" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "build": "react-scripts build", 38 | "test": "react-scripts test", 39 | "eject": "react-scripts eject" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@types/mapbox-gl": "^2.7.11", 61 | "@types/react-plotly.js": "^2.6.0", 62 | "@types/three": "^0.135.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/building/front-menu/building-front-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardContent } from "@mui/material"; 2 | import { FC } from "react"; 3 | import "./building-front-menu.css"; 4 | import CloseIcon from "@mui/icons-material/Close"; 5 | import { BuildingInfoMenu } from "./front-menu-content/building-info-menu"; 6 | import { FrontMenuMode } from "../types"; 7 | import { ModelListMenu } from "./front-menu-content/model-list-menu"; 8 | import { PropertiesMenu } from "./front-menu-content/properties-menu"; 9 | import { FloorplanMenu } from "./front-menu-content/floorplan-menu"; 10 | import { EnergyMenu } from "./front-menu-content/energy-menu"; 11 | import { DocumentsMenu } from "./front-menu-content/documents-menu"; 12 | 13 | 14 | //export type FrontMenuMode = "BuildingInfo"; // if mode == properties, display properties etc... 15 | 16 | export const BuildingFrontMenu: FC<{ 17 | mode: FrontMenuMode; 18 | open: boolean; 19 | onToggleMenu: () => void; 20 | }> = ({ mode, open, onToggleMenu }) => { 21 | if (!open) { 22 | return <>; 23 | } 24 | 25 | const content = new Map(); 26 | content.set("BuildingInfo", ); 27 | content.set("ModelList", ); 28 | content.set("Properties", ); 29 | content.set("Floorplans", ); 30 | content.set("Energy", ); 31 | content.set("Documents", ); 32 | 33 | const titles = { 34 | BuildingInfo: "Building Information", 35 | ModelList: "Model List", 36 | Properties: "Properties", 37 | Floorplans: "Floorplans", 38 | Energy: "Energy", 39 | Documents: "Documents" 40 | }; 41 | 42 | const title = titles[mode]; 43 | 44 | return ( 45 | 46 | 47 |
48 |

{title}

49 | 52 |
53 |
{content.get(mode)}
54 |
55 |
56 | ); 57 | }; -------------------------------------------------------------------------------- /src/components/building/bottom-menu/bottombar-tools.tsx: -------------------------------------------------------------------------------- 1 | import CutIcon from "@mui/icons-material/ContentCut"; 2 | import RulerIcon from "@mui/icons-material/Straighten"; 3 | import ExplodeIcon from "@mui/icons-material/ImportExport"; 4 | import { Tool } from "../../../types"; 5 | 6 | export function getBottombarTools ( 7 | 8 | ): Tool[] { 9 | const tools = [ 10 | { 11 | name: "Clipping planes", 12 | icon: , 13 | active: false, 14 | action: (dispatch: any) => { 15 | const tool = findTool("Clipping planes"); 16 | deactivateAllTools(dispatch, "Clipping planes"); 17 | tool.active = !tool.active; 18 | dispatch({ type: "TOGGLE_CLIPPER", payload: tool.active }); 19 | }, 20 | }, 21 | { 22 | name: "Dimensions", 23 | icon: , 24 | active: false, 25 | action: (dispatch: any) => { 26 | const tool = findTool("Dimensions"); 27 | deactivateAllTools(dispatch, "Dimensions"); 28 | tool.active = !tool.active; 29 | dispatch({ type: "TOGGLE_DIMENSIONS", payload: tool.active }); 30 | }, 31 | }, 32 | { 33 | name: "Explosion", 34 | icon: , 35 | active: false, 36 | action: (dispatch: any) => { 37 | const tool = findTool("Explosion"); 38 | deactivateAllTools(dispatch, "Explosion"); 39 | tool.active = !tool.active; 40 | dispatch({ type: "EXPLODE_MODEL", payload: tool.active }); 41 | }, 42 | }, 43 | ]; 44 | 45 | const findTool = (name: string) => { 46 | const tool = tools.find((tool) => tool.name === name); 47 | if (!tool) throw new Error("Tool not found!"); 48 | return tool; 49 | }; 50 | 51 | const deactivateAllTools = (dispatch: any, name: string) => { 52 | for (const tool of tools) { 53 | if (tool.active && tool.name !== name) { 54 | tool.action(dispatch); 55 | } 56 | } 57 | }; 58 | 59 | 60 | return tools; 61 | } -------------------------------------------------------------------------------- /src/core/db/energy-data-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getFirestore, 3 | setDoc, 4 | doc, 5 | collection, 6 | getDocs, 7 | addDoc, 8 | } from "firebase/firestore"; 9 | import { EnergyData } from "../../types"; 10 | import { getApp } from "firebase/app"; 11 | 12 | export const energyDataHandler = { 13 | addEnergyData: async (energyData: EnergyData) => { 14 | const dbInstance = getFirestore(getApp()); 15 | 16 | await addDoc(collection(dbInstance, "energyData"), energyData); 17 | }, 18 | 19 | updateEnergyData: async (buildingId: string, energyData: EnergyData) => { 20 | const dbInstance = getFirestore(); 21 | const energyDataRef = doc(dbInstance, "energyData", buildingId); 22 | const monthDataRef = collection(energyDataRef, "months"); 23 | await setDoc(doc(monthDataRef, energyData.month), energyData, { 24 | merge: true, 25 | }); 26 | }, 27 | 28 | getEnergyData: async (buildingId: string): Promise => { 29 | const dbInstance = getFirestore(); 30 | const energyDataRef = collection(dbInstance, "energyData"); // Remove buildingId here 31 | const querySnapshot = await getDocs(energyDataRef); 32 | 33 | const energyDataArray: EnergyData[] = []; 34 | 35 | querySnapshot.forEach((doc) => { 36 | const data = doc.data(); 37 | if (data) { 38 | if (buildingId === data.buildingId) { 39 | energyDataArray.push({ 40 | buildingId: data.buildingId, 41 | month: data.month, 42 | electricity: data.electricity, 43 | gas: data.gas, 44 | solar: data.solar, 45 | wind: data.wind, 46 | }); 47 | } 48 | } 49 | }); 50 | 51 | // Sort energy data by month before returning 52 | energyDataArray.sort((a, b) => { 53 | const months = [ 54 | "January", 55 | "February", 56 | "March", 57 | "April", 58 | "May", 59 | "June", 60 | "July", 61 | "August", 62 | "September", 63 | "October", 64 | "November", 65 | "December", 66 | ]; 67 | return months.indexOf(a.month) - months.indexOf(b.month); 68 | }); 69 | 70 | return energyDataArray; 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /src/core/building/building-handler.ts: -------------------------------------------------------------------------------- 1 | import { Floorplan } from "./../../types"; 2 | import { Events } from "./../../middleware/event-handler"; 3 | import { BuildingScene } from "./building-scene"; 4 | import { Building } from "../../types"; 5 | 6 | export const buildingHandler = { 7 | viewer: null as BuildingScene | null, 8 | 9 | start(container: HTMLDivElement, building: Building, events: Events) { 10 | if (!this.viewer) { 11 | this.viewer = new BuildingScene(container, building, events); 12 | } 13 | }, 14 | 15 | remove() { 16 | if (this.viewer) { 17 | console.log("building viewer removed!"); 18 | this.viewer.dispose(); 19 | this.viewer = null; 20 | } 21 | }, 22 | 23 | async convertIfcToFragments(ifc: File) { 24 | if (!this.viewer) { 25 | throw new Error("Building viewer is not active!") 26 | } 27 | return this.viewer.convertIfcToFragments(ifc) 28 | }, 29 | 30 | //this should be renamed for something more generic - using it also to delete documents 31 | async deleteModels(id:string []) { 32 | if (this.viewer) { 33 | await this.viewer.database.deleteModels(id); 34 | } 35 | }, 36 | async refreshModels(building: Building, events: Events) { 37 | if (this.viewer) { 38 | const container = this.viewer.container; 39 | this.viewer.dispose(); 40 | this.viewer = null; 41 | this.viewer = new BuildingScene(container, building, events); 42 | 43 | } 44 | }, 45 | explode(active: boolean) { 46 | if (this.viewer) { 47 | this.viewer.explode(active); 48 | } 49 | }, 50 | toggleClippingPlanes(active: boolean) { 51 | if (this.viewer) { 52 | this.viewer.toggleClippingPlanes(active); 53 | } 54 | }, 55 | 56 | toggleDimensions(active: boolean) { 57 | if (this.viewer) { 58 | this.viewer.toggleDimensions(active); 59 | } 60 | }, 61 | 62 | toggleFloorplan(active: boolean, floorplan?: Floorplan) { 63 | if (this.viewer) { 64 | this.viewer.toggleFloorplan(active, floorplan); 65 | } 66 | }, 67 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/energy-graphs/monthly-costs-bar-chart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Plot from 'react-plotly.js'; 3 | import { Data, Layout } from 'plotly.js'; 4 | 5 | interface MonthlyCostsBarChartProps { 6 | electricityData: number[]; 7 | gasData: number[]; 8 | solarData: number[]; 9 | windData: number[]; 10 | } 11 | 12 | const MonthlyCostsBarChart: React.FC = ({ 13 | electricityData, 14 | gasData, 15 | solarData, 16 | windData, 17 | }) => { 18 | const months = [ 19 | 'January', 'February', 'March', 'April', 'May', 'June', 20 | 'July', 'August', 'September', 'October', 'November', 'December' 21 | ]; 22 | 23 | const conversionRate = 0.40; 24 | 25 | // Calculate electricity and gas costs 26 | const electricityCosts = electricityData.map(usage => usage * conversionRate); 27 | const gasCosts = gasData.map(usage => usage * conversionRate); 28 | 29 | // Calculate solar and wind offsets 30 | const solarOffset = solarData.map(production => production * conversionRate); 31 | const windOffset = windData.map(production => production * conversionRate); 32 | 33 | // Calculate net costs for electricity and gas after offsets 34 | const netElectricityCosts = electricityCosts.map((cost, index) => Math.max(cost - solarOffset[index] - windOffset[index], 0)); 35 | const netGasCosts = gasCosts.map((cost, index) => Math.max(cost - solarOffset[index] - windOffset[index], 0)); 36 | // Calculate total monthly costs by summing net electricity and net gas costs 37 | const netMonthlyCosts = netElectricityCosts.map((cost, index) => cost + netGasCosts[index]); 38 | 39 | // Create data trace for total monthly costs 40 | const traceTotalCosts: Data = { 41 | x: months, 42 | y: netMonthlyCosts, 43 | type: 'bar', 44 | name: 'Total Monthly Costs', 45 | }; 46 | 47 | const data: Data[] = [traceTotalCosts]; 48 | 49 | const layout: Partial = { 50 | title: 'Monthly Energy Costs in GBP', 51 | xaxis: { 52 | title: 'Month', 53 | }, 54 | yaxis: { 55 | title: 'Cost in GBP', 56 | }, 57 | }; 58 | 59 | return ( 60 | 64 | ); 65 | }; 66 | 67 | export default MonthlyCostsBarChart; 68 | -------------------------------------------------------------------------------- /src/components/map/sidebar/map-tools.tsx: -------------------------------------------------------------------------------- 1 | import LogoutIcon from "@mui/icons-material/Logout"; 2 | import AddBuildingIcon from "@mui/icons-material/DomainAdd"; 3 | import BuildingIcon from "@mui/icons-material/Domain"; 4 | 5 | import { Action } from "../../../middleware/actions"; 6 | import { Building, Tool } from "../../../types"; 7 | import { 8 | collection, 9 | getDocs, 10 | getFirestore, 11 | query, 12 | where, 13 | } from "firebase/firestore"; 14 | import { User } from "firebase/auth"; 15 | 16 | async function fetchBuildingsData(userUID: string): Promise { 17 | const dbInstance = getFirestore(); 18 | const q = query( 19 | collection(dbInstance, "buildings"), 20 | where("userID", "==", userUID) 21 | ); 22 | 23 | const snapshot = await getDocs(q); 24 | 25 | const buildings: Building[] = snapshot.docs.map((doc) => ({ 26 | ...(doc.data() as Building), 27 | uid: doc.id, 28 | })); 29 | 30 | return buildings; 31 | } 32 | 33 | // Add an additional parameter newBuilding to the function 34 | export async function getMapTools( 35 | dispatch: React.Dispatch, 36 | isCreating: boolean, 37 | onToggleCreate: () => void, 38 | user: User | null, 39 | newBuilding: Building | null 40 | ): Promise { 41 | const tools: Tool[] = []; 42 | if (user) { 43 | const userUID = user.uid; 44 | const buildings = await fetchBuildingsData(userUID); 45 | 46 | const buildingTools = buildings.map((building) => ({ 47 | name: building.name === "" ? `Building-${Math.random()}` : building.name, 48 | active: newBuilding ? building.uid === newBuilding.uid : false, 49 | icon: , 50 | action: () => { 51 | dispatch({ 52 | type: "CENTER_MAP", 53 | payload: { lat: building.lat, lng: building.lng }, 54 | }); 55 | //} 56 | }, 57 | })); 58 | 59 | tools.push( 60 | { 61 | name: "Create Building", 62 | active: isCreating, 63 | icon: , 64 | action: onToggleCreate, 65 | }, 66 | ...buildingTools, 67 | { 68 | name: "Log out", 69 | active: false, 70 | icon: , 71 | action: () => { 72 | dispatch({ type: "LOGOUT" }); 73 | }, 74 | } 75 | ); 76 | } 77 | 78 | return tools; 79 | } 80 | -------------------------------------------------------------------------------- /src/components/building/building-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; //to define a component 2 | import { Box , CssBaseline} from "@mui/material"; 3 | import { useAppContext } from "../../middleware/context-provider"; 4 | import { Navigate } from "react-router-dom"; 5 | import {BuildingDrawer} from "./side-menu/building-drawer" 6 | import { getDrawerHeader } from "../utils/mui-utils" 7 | import { BuildingFrontMenu } from "./front-menu/building-front-menu"; 8 | import { FrontMenuMode } from "./types"; 9 | import { BuildingViewport } from "./viewport/building-viewport"; 10 | import { BuildingBottomMenu } from "./bottom-menu/building-bottom-menu"; 11 | import { NavBar } from "../navbar/navbar"; 12 | 13 | export const BuildingViewer: FC = () => { 14 | //menus visibility 15 | const [width] = useState(240); //from MUI 16 | const [sideOpen, setSideOpen] = useState(false); 17 | const [frontOpen, setFrontOpen] = useState(false); 18 | const [frontMenu, setFrontMenu] = useState("BuildingInfo") 19 | 20 | // const [state,dispatch] = useAppContext() 21 | const [{ user, building }] = useAppContext(); 22 | 23 | if (!user) { 24 | return ; 25 | } 26 | 27 | if (!building) { 28 | return ; 29 | } 30 | 31 | const toggleDrawer = (active: boolean) => { 32 | setSideOpen(active); 33 | }; 34 | 35 | //for propertiees, floor plans. building metadata 36 | const toggleFrontMenu = (active = !frontOpen, mode?: FrontMenuMode) => { 37 | if (mode) { 38 | setFrontMenu(mode); 39 | } 40 | setFrontOpen(active); 41 | }; 42 | 43 | const DrawerHeader = getDrawerHeader(); 44 | 45 | return ( 46 | 47 | 48 | 49 | toggleDrawer(true)} 53 | /> 54 | 55 | toggleDrawer(false)} 59 | onToggleMenu={toggleFrontMenu} 60 | /> 61 | 62 | 63 | 64 | 65 | 66 | toggleFrontMenu(false)} 68 | open={frontOpen} 69 | mode={frontMenu} 70 | /> 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; //FC type - functional component 78 | -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/building-info-menu.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useAppContext } from "../../../../middleware/context-provider"; 3 | import { Box , TextField, Button} from "@mui/material"; 4 | import "./front-menu-context.css"; 5 | 6 | export const BuildingInfoMenu: FC<{ 7 | onToggleMenu: () => void; 8 | }> = ({ onToggleMenu }) => { 9 | const [state, dispatch] = useAppContext(); 10 | 11 | const { building } = state; 12 | if (!building) { 13 | throw new Error("No building active!"); 14 | } 15 | 16 | const onUpdateBuilding = (event: React.FormEvent) => { 17 | event.preventDefault(); //do not reload page 18 | const data = new FormData(event.currentTarget) 19 | const newBuilding = {...building} as any; 20 | newBuilding.name = data.get("building-name") || building.name; 21 | newBuilding.lng = data.get("building-lng") || building.lng; 22 | newBuilding.lat = data.get("building-lat") || building.lat; 23 | 24 | dispatch({type: "UPDATE_BUILDING", payload: newBuilding}) 25 | onToggleMenu() 26 | } 27 | 28 | return ( 29 | 30 | 31 |
32 | 41 |
42 | 43 |
44 | 52 |
53 | 54 |
55 | 63 |
64 | 65 |
66 | 74 |
75 | 76 |
77 | 80 |
81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/utils/mui-utils.tsx: -------------------------------------------------------------------------------- 1 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; 2 | import { CSSObject, styled, Theme } from "@mui/material/styles"; 3 | import MuiDrawer from "@mui/material/Drawer"; 4 | 5 | interface AppBarProps extends MuiAppBarProps { 6 | open?: boolean; 7 | } 8 | 9 | export function getAppBar(drawerWidth: number) { 10 | return styled(MuiAppBar, { 11 | shouldForwardProp: (prop) => prop !== "open", 12 | })(({ theme, open }) => ({ 13 | zIndex: theme.zIndex.drawer + 1, 14 | transition: theme.transitions.create(["width", "margin"], { 15 | easing: theme.transitions.easing.sharp, 16 | duration: theme.transitions.duration.leavingScreen, 17 | }), 18 | ...(open && { 19 | marginLeft: drawerWidth, 20 | width: `calc(100% - ${drawerWidth}px)`, 21 | transition: theme.transitions.create(["width", "margin"], { 22 | easing: theme.transitions.easing.sharp, 23 | duration: theme.transitions.duration.enteringScreen, 24 | }), 25 | }), 26 | })); 27 | } 28 | 29 | const openedMixin = (theme: Theme, width: number): CSSObject => ({ 30 | width: width, 31 | transition: theme.transitions.create("width", { 32 | easing: theme.transitions.easing.sharp, 33 | duration: theme.transitions.duration.enteringScreen, 34 | }), 35 | overflowX: "hidden", 36 | }); 37 | 38 | const closedMixin = (theme: Theme): CSSObject => ({ 39 | transition: theme.transitions.create("width", { 40 | easing: theme.transitions.easing.sharp, 41 | duration: theme.transitions.duration.leavingScreen, 42 | }), 43 | overflowX: "hidden", 44 | width: `calc(${theme.spacing(7)} + 1px)`, 45 | [theme.breakpoints.up("sm")]: { 46 | width: `calc(${theme.spacing(8)} + 1px)`, 47 | }, 48 | }); 49 | 50 | export function getDrawerHeader() { 51 | return styled("div")(({ theme }) => ({ 52 | display: "flex", 53 | alignItems: "center", 54 | justifyContent: "flex-end", 55 | padding: theme.spacing(0, 1), 56 | // necessary for content to be below app bar 57 | ...theme.mixins.toolbar, 58 | })); 59 | } 60 | 61 | export function getDrawer(width: number) { 62 | return styled(MuiDrawer, { 63 | shouldForwardProp: (prop) => prop !== "open", 64 | })(({ theme, open }) => ({ 65 | width: width, 66 | flexShrink: 0, 67 | whiteSpace: "nowrap", 68 | boxSizing: "border-box", 69 | ...(open && { 70 | ...openedMixin(theme, width), 71 | "& .MuiDrawer-paper": openedMixin(theme, width), 72 | }), 73 | ...(!open && { 74 | ...closedMixin(theme), 75 | "& .MuiDrawer-paper": closedMixin(theme), 76 | }), 77 | })); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/model-list-menu.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useAppContext } from "../../../../middleware/context-provider"; 3 | import "./front-menu-context.css"; 4 | import { IconButton, Button } from "@mui/material"; 5 | import DeleteIcon from '@mui/icons-material/Delete'; 6 | 7 | export const ModelListMenu: FC = () => { 8 | const [{ building, user }, dispatch] = useAppContext(); 9 | 10 | if (!building || !user) { 11 | throw new Error("Error: building not found!"); 12 | } 13 | 14 | const onUploadModel = () => { 15 | const input = document.createElement("input"); 16 | input.type = "file"; 17 | input.style.visibility = "hidden"; 18 | document.body.appendChild(input); 19 | 20 | input.onchange = () => { 21 | console.log(input.files); 22 | 23 | if (input.files && input.files.length) { 24 | const file = input.files[0]; //just get the first file 25 | const newBuilding = { ...building }; //copy of the building object 26 | const { name } = file; 27 | const id = `${name}-${performance.now()}`; //id of the model 28 | const model = {name, id }; 29 | newBuilding.models.push(model); 30 | console.log(newBuilding) 31 | dispatch({ type: "UPDATE_BUILDING", payload: newBuilding }); 32 | dispatch({ 33 | type: "UPLOAD_MODEL", 34 | payload: { 35 | model, 36 | file, 37 | building: newBuilding, 38 | }, 39 | }); 40 | } 41 | 42 | input.remove(); //cleanup 43 | }; 44 | 45 | input.click(); 46 | }; 47 | 48 | const onDeleteModel = (id: string) => { 49 | const newBuilding = {...building} 50 | const model = newBuilding.models.find(model => model.id === id) 51 | if (!model) throw new Error("Model not found!") 52 | newBuilding.models = newBuilding.models.filter((model) => model.id !== id) 53 | dispatch({ 54 | type: "DELETE_MODEL", 55 | payload: { building: newBuilding, model }, 56 | }); 57 | } 58 | 59 | return ( 60 |
61 | {building.models.length ? ( 62 | building.models.map((model) => ( 63 |
64 | onDeleteModel(model.id)}> 65 | 66 | 67 | {model.name} 68 |
69 | )) 70 | ) : ( 71 |

This building has no models!

72 | )} 73 |
74 | 75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/building/side-menu/sidebar-tools.tsx: -------------------------------------------------------------------------------- 1 | 2 | import ListIcon from "@mui/icons-material/ViewList"; 3 | import MapIcon from '@mui/icons-material/Map'; 4 | import DeleteIcon from '@mui/icons-material/Delete'; 5 | import LogoutIcon from '@mui/icons-material/Logout'; 6 | import ModelIcon from '@mui/icons-material/HolidayVillage'; 7 | import FloorplanIcon from "@mui/icons-material/Layers"; 8 | import PropertiesIcon from "@mui/icons-material/Info"; 9 | import EnergyIcon from '@mui/icons-material/ElectricBolt'; 10 | import DocumentIcon from '@mui/icons-material/Article'; 11 | 12 | import { Action } from "../../../middleware/actions"; 13 | import { State } from "../../../middleware/state"; 14 | import { FrontMenuMode } from "../types"; 15 | import { Tool } from "../../../types"; 16 | 17 | 18 | export function getSidebarTools( 19 | state: State, 20 | dispatch: React.Dispatch, 21 | toggleMenu: (active: boolean, mode?: FrontMenuMode) => void 22 | ): Tool[] { 23 | return [ 24 | { 25 | name: "Info", 26 | active: false, 27 | icon: , 28 | action: () => { 29 | toggleMenu(true, "BuildingInfo"); //true, "mode of the menu" 30 | } 31 | }, 32 | { 33 | name: "Model list", 34 | active: false, 35 | icon: , 36 | action: () => { 37 | toggleMenu(true, "ModelList"); //true, "mode of the menu" 38 | } 39 | }, 40 | { 41 | name: "Floorplans", 42 | active: false, 43 | icon: , 44 | action: ({ onToggleMenu }) => { //need to remove onToggleMenu? 45 | onToggleMenu(true, "Floorplans"); 46 | }, 47 | }, 48 | { 49 | name: "Properties", 50 | active: false, 51 | icon: , 52 | action: ({ onToggleMenu }) => { 53 | onToggleMenu(true, "Properties"); 54 | }, 55 | }, 56 | { 57 | name: "Documents", 58 | active: false, 59 | icon: , 60 | action: ({ onToggleMenu }) => { 61 | onToggleMenu(true, "Documents"); 62 | }, 63 | }, 64 | { 65 | name: "Energy", 66 | active: false, 67 | icon: , 68 | action: ({ onToggleMenu }) => { 69 | onToggleMenu(true, "Energy"); 70 | }, 71 | }, 72 | { 73 | name: "Delete building", 74 | active: false, 75 | icon: , 76 | action: () => { 77 | dispatch({type: "DELETE_BUILDING", payload: state.building}) 78 | } 79 | }, 80 | { 81 | name: "Back to map", 82 | active: false, 83 | icon: , 84 | action: () => { 85 | dispatch({type: "CLOSE_BUILDING"}) 86 | } 87 | }, 88 | { 89 | name: "Log out", 90 | active:false, 91 | icon: , 92 | action: () => { 93 | dispatch({type: "LOGOUT"}) 94 | }, 95 | }, 96 | ]; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/energy-menu.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import Plot from "react-plotly.js"; 3 | import StackedBarGraph from "./energy-graphs/stacked-bar-graph"; 4 | import SolarGraph from "./energy-graphs/solar-graph"; 5 | import WindGraph from "./energy-graphs/wind-graph"; 6 | import MonthlyCostsBarChart from "./energy-graphs/monthly-costs-bar-chart"; 7 | import { useAppContext } from "../../../../middleware/context-provider"; 8 | import { databaseHandler } from "../../../../core/db/db-handler"; 9 | import { EnergyData } from "../../../../types"; 10 | 11 | export const EnergyMenu: FC = () => { 12 | const [state] = useAppContext(); 13 | const [energyData, setEnergyData] = useState([]) 14 | 15 | useEffect(() => { 16 | // Fetch energy data for a specific building from the database 17 | const fetchEnergyData = async () => { 18 | if (state.building) { 19 | 20 | const buildingId = state.building.uid; // "clb1tGh504gYrYgPhf0g"; // Replace with your building ID 21 | const energyData = await databaseHandler.getEnergyData(buildingId); 22 | setEnergyData(energyData); // Set fetched energy data in state 23 | } 24 | }; 25 | 26 | fetchEnergyData(); 27 | }, []); 28 | // console.log(energyData) 29 | // console.log(state) 30 | 31 | return ( 32 |
33 |
34 | 35 | kWh/m2/yr", 42 | }, 43 | type: "indicator", 44 | mode: "gauge+number+delta", 45 | delta: { reference: 200 }, //the initial value 46 | gauge: { axis: { range: [140, 390] } }, 47 | }, 48 | ]} 49 | layout={{ 50 | width: 500, 51 | height: 240, 52 | margin: { t: 80, b: 25, l: 25, r: 25 }, 53 | }} 54 | /> 55 |
56 |
57 | {energyData.length !== 0 && (<> 58 | data.gas)} 60 | electricityData={energyData.map(data => data.electricity)} 61 | solarData={energyData.map(data => data.solar)} 62 | windData={energyData.map(data => data.wind)} 63 | /> 64 | 65 | data.solar)}/> 66 | 67 | data.wind)} /> 68 | 69 | data.gas)} 71 | electricityData={energyData.map(data => data.electricity)} 72 | solarData={energyData.map(data => data.solar)} 73 | windData={energyData.map(data => data.wind)} 74 | />)} 75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/core/building/building-database.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseStorage, getStorage } from "firebase/storage"; 2 | import { getApp } from "firebase/app"; 3 | import { getDownloadURL, ref } from "firebase/storage"; 4 | import { Building } from "../../types"; 5 | import { ModelDatabase } from "./dexie-utils"; 6 | 7 | 8 | // CORS problem solution: https://stackoverflow.com/a/58613527 9 | 10 | export class BuildingDatabase { 11 | private db = new ModelDatabase(); 12 | 13 | async getModels (building: Building) { 14 | 15 | await this.db.open() 16 | 17 | const appInstance = getApp(); 18 | const instance = getStorage(appInstance); 19 | 20 | const urls: string[] = []; 21 | 22 | for (const model of building.models) { 23 | const url = await this.getModelURL(instance, model.id) 24 | urls.push(url); 25 | } 26 | 27 | this.db.close() //clear memory 28 | return urls; 29 | } 30 | 31 | async clearCache(building: Building) { 32 | await this.db.open(); 33 | for (const model of building.models) { 34 | localStorage.removeItem(model.id) 35 | } 36 | await this.db.delete() //deletes whole db 37 | this.db = new ModelDatabase(); 38 | this.db.close(); 39 | 40 | } 41 | 42 | async deleteModels (ids:string[]) { 43 | await this.db.open(); 44 | 45 | for (const id of ids) { 46 | if (this.isModelCached(id)) { 47 | localStorage.removeItem(id); 48 | await this.db.models.where("id").equals(id).delete(); 49 | } 50 | 51 | } 52 | this.db.close() 53 | } 54 | 55 | 56 | private async getModelURL(instance: FirebaseStorage, id: string) { 57 | if (this.isModelCached(id)) { 58 | //get model from dexie (user's computer) 59 | return this.getModelFromLocalCache(id); 60 | } else { 61 | //get model from firebase 62 | return this.getModelFromFirebase(instance, id); 63 | } 64 | } 65 | 66 | private async getModelFromFirebase(instance: FirebaseStorage, id: string) { 67 | const fileRef = ref(instance, id); 68 | const url = await getDownloadURL(fileRef); 69 | await this.cacheModel(id, url); 70 | console.log("Got model from firebase then cached it"!); 71 | return url; 72 | } 73 | 74 | private async getModelFromLocalCache (id: string) { 75 | const found = await this.db.models.where("id").equals(id).toArray(); 76 | const file = found[0].file; 77 | console.log("Got model from local cache!") 78 | return URL.createObjectURL(file) 79 | } 80 | 81 | private isModelCached (id: string) { 82 | const stored = localStorage.getItem(id); 83 | return stored !== null; 84 | } 85 | 86 | private async cacheModel (id: string, url: string) { 87 | const time = performance.now().toString(); 88 | localStorage.setItem(id, time); 89 | const rawData = await fetch(url); 90 | const file = await rawData.blob() 91 | await this.db.models.add({ 92 | id, 93 | file, 94 | }) 95 | } 96 | 97 | } -------------------------------------------------------------------------------- /src/components/building/front-menu/front-menu-content/documents-menu.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Button } from "@mui/material"; 2 | import DeleteIcon from '@mui/icons-material/Delete'; 3 | import { FC } from "react"; 4 | import { useAppContext } from "../../../../middleware/context-provider"; 5 | 6 | export const DocumentsMenu: FC = () => { 7 | const [{ building, user }, dispatch] = useAppContext(); 8 | 9 | if (!building || !user) { 10 | throw new Error("Error: building not found!"); 11 | } 12 | 13 | const onUploadDocument = () => { 14 | const input = document.createElement("input"); 15 | input.type = "file"; 16 | input.style.visibility = "hidden"; 17 | document.body.appendChild(input); 18 | 19 | input.onchange = () => { 20 | console.log(input.files); 21 | 22 | if (input.files && input.files.length) { 23 | const file = input.files[0]; //just get the first file 24 | const newBuilding = { ...building }; //copy of the building object 25 | const { name } = file; 26 | const id = `${name}-${performance.now()}`; //id of the document 27 | const document = { name, id }; 28 | newBuilding.documents.push(document); 29 | console.log(newBuilding); 30 | dispatch({ type: "UPDATE_BUILDING", payload: newBuilding }); 31 | dispatch({ 32 | type: "UPLOAD_DOCUMENT", 33 | payload: { 34 | document, 35 | file, 36 | building: newBuilding, 37 | }, 38 | }); 39 | } 40 | 41 | input.remove(); //cleanup 42 | }; 43 | 44 | input.click(); 45 | }; 46 | 47 | const onDeleteDocument = (id: string) => { 48 | const newBuilding = { ...building }; 49 | const document = newBuilding.documents.find( 50 | (document) => document.id === id 51 | ); 52 | if (!document) throw new Error("document not found!"); 53 | newBuilding.documents = newBuilding.documents.filter( 54 | (document) => document.id !== id 55 | ); 56 | dispatch({ 57 | type: "DELETE_DOCUMENT", 58 | payload: { building: newBuilding, document }, 59 | }); 60 | }; 61 | 62 | const onGetDocument = (id: string) => { 63 | const document = building.documents.find( 64 | (document) => document.id === id 65 | ); 66 | if (!document) throw new Error("document not found!"); 67 | 68 | console.log(document.id) 69 | dispatch({ 70 | type: "GET_DOCUMENT", 71 | payload: {document} 72 | }) 73 | 74 | } 75 | 76 | return ( 77 |
78 | {building.documents.length ? ( 79 | building.documents.map((document) => ( 80 |
81 | onDeleteDocument(document.id)}> 82 | 83 | 84 | 85 |
86 | )) 87 | ) : ( 88 |

This building has no documents!

89 | )} 90 |
91 | 92 |
93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/user/login-form.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import { Box, Button, Card, CardContent, TextField, Tab, Tabs } from "@mui/material"; 4 | import { useAppContext } from "../../middleware/context-provider"; 5 | import "./user-styles.css"; 6 | import { NavBar } from "../navbar/navbar"; 7 | 8 | export const LoginForm: FC = () => { 9 | const [state, dispatch] = useAppContext(); 10 | const [activeTab, setActiveTab] = useState(0); 11 | 12 | const onLogin = () => { 13 | dispatch({ type: "LOGIN" }); 14 | }; 15 | 16 | const onSignUp = () => { 17 | // dispatch({ type: "SIGNUP" }); 18 | console.log("Sign up") 19 | }; 20 | 21 | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { 22 | setActiveTab(newValue); 23 | }; 24 | 25 | if (state.user) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | {}} width={100} /> 32 | 33 | 34 | {/*

BIM2TWIN

*/} 35 | ifcjs logo 36 | 37 | 38 | 39 | 40 | 41 | 42 | {activeTab === 0 && ( 43 | <> 44 | 52 | 53 | 61 | 62 | 65 | 66 | 67 | 68 | 71 | 72 | 73 | )} 74 | 75 | {activeTab === 1 && ( 76 | <> 77 | {/* Add sign-up form fields and button */} 78 | 86 | 87 | 95 | 96 | 99 | 100 | 101 | )} 102 |
103 |
104 | 105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/map/map-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useRef, useState } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import { Button } from "@mui/material"; 4 | import { useAppContext } from "../../middleware/context-provider"; 5 | import { NavBar } from "../navbar/navbar"; 6 | import "./map-viewer.css"; 7 | import { Drawer } from "../map/sidebar/drawer"; 8 | import { getMapTools } from "../map/sidebar/map-tools"; 9 | import { Tool } from "../../types"; 10 | 11 | export const MapViewer: FC = () => { 12 | const containerRef = useRef(null); 13 | const [isCreating, setIsCreating] = useState(false); 14 | const [tools, setTools] = useState([]); 15 | 16 | const [state, dispatch] = useAppContext(); 17 | const { user, building } = state; 18 | 19 | const [width] = useState(240); 20 | const [sideOpen, setSideOpen] = useState(false); 21 | 22 | const toggleDrawer = (active: boolean) => { 23 | setSideOpen(active); 24 | }; 25 | 26 | const toggleFrontMenu = () => {}; 27 | 28 | const onToggleCreate = () => { 29 | setIsCreating(!isCreating); 30 | }; 31 | 32 | //need to updae this with the code below, how to fetch the newly created building data? 33 | const onCreate = async () => { 34 | if (isCreating) { 35 | dispatch({ type: "ADD_BUILDING", payload: user }); 36 | setIsCreating(false); 37 | 38 | //to get the new building in the list of buildigns when it is added (id) 39 | if (user ) { 40 | //const userUID = user.uid; 41 | 42 | try { 43 | const newBuilding = null; // Set this to the newly created building, if applicable 44 | const tools = await getMapTools(dispatch, isCreating, onToggleCreate, user, newBuilding); 45 | setTools(tools); 46 | } catch (error) { 47 | console.error("Error fetching buildings data:", error); 48 | setTools([]); 49 | } 50 | } 51 | } 52 | }; 53 | 54 | useEffect(() => { 55 | const fetchTools = async () => { 56 | if (user ) { 57 | //const userUID = user.uid; 58 | 59 | try { 60 | const newBuilding = null; // Set this to the newly created building, if applicable 61 | const tools = await getMapTools(dispatch, isCreating, onToggleCreate, user, newBuilding); 62 | setTools(tools); 63 | } catch (error) { 64 | console.error("Error fetching buildings data:", error); 65 | setTools([]); 66 | } 67 | } 68 | }; 69 | 70 | fetchTools(); 71 | }, [user]); 72 | 73 | useEffect(() => { 74 | const container = containerRef.current; 75 | if (container && user) { 76 | dispatch({ type: "START_MAP", payload: { container, user } }); 77 | } 78 | 79 | // Cleanup function when component is unmounted 80 | return () => { 81 | dispatch({ type: "REMOVE_MAP" }); 82 | }; 83 | }, []); 84 | 85 | if (!user) { 86 | return ; 87 | } 88 | 89 | if (building) { 90 | const url = `/building?id=${building.uid}`; 91 | return ; 92 | } 93 | 94 | return ( 95 | <> 96 | toggleDrawer(true)} /> 97 | toggleDrawer(false)} 101 | onToggleMenu={toggleFrontMenu} 102 | tools={tools} 103 | isCreating={isCreating} 104 | /> 105 | 106 |
107 | {isCreating && ( 108 |
109 |

Right click to create a new Building or

110 | 111 |
112 | )} 113 | 114 | ); 115 | }; 116 | 117 | -------------------------------------------------------------------------------- /src/middleware/core-handler.ts: -------------------------------------------------------------------------------- 1 | import { mapHandler } from "../core/map/map-handler"; 2 | import { databaseHandler } from "../core/db/db-handler"; 3 | import { Action } from "./actions"; 4 | import { Events } from "./event-handler"; 5 | import { buildingHandler } from "../core/building/building-handler"; 6 | import { energyDataHandler } from "../core/db/energy-data-handler"; 7 | 8 | export const executeCore = async (action: Action, events: Events) => { 9 | if (action.type === "LOGIN") { 10 | return databaseHandler.login(); 11 | } 12 | if (action.type === "LOGOUT") { 13 | buildingHandler.remove(); 14 | mapHandler.remove(); 15 | return databaseHandler.logout(); 16 | } 17 | if (action.type === "START_MAP") { 18 | const { container, user } = action.payload; 19 | return mapHandler.start(container, user, events); 20 | } 21 | if (action.type === "REMOVE_MAP" || action.type === "OPEN_BUILDING") { 22 | return mapHandler.remove(); 23 | } 24 | if (action.type === "ADD_BUILDING") { 25 | return mapHandler.addBuilding(action.payload); 26 | } 27 | if (action.type === "DELETE_BUILDING") { 28 | return databaseHandler.deleteBuilding(action.payload, events); 29 | } 30 | if (action.type === "UPDATE_BUILDING") { 31 | return databaseHandler.updateBuilding(action.payload); 32 | } 33 | if (action.type === "UPLOAD_MODEL") { 34 | const { model, file, building } = action.payload; 35 | const zipFile = await buildingHandler.convertIfcToFragments(file); 36 | return databaseHandler.uploadModel(model, zipFile, building, events); 37 | } 38 | if (action.type === "DELETE_MODEL") { 39 | const { model, building } = action.payload; 40 | return databaseHandler.deleteModel(model, building, events); 41 | } 42 | if (action.type === "START_BUILDING") { 43 | const { container, building } = action.payload; 44 | return buildingHandler.start(container, building, events); 45 | } 46 | if (action.type === "CLOSE_BUILDING") { 47 | return buildingHandler.remove(); 48 | } 49 | if (action.type === "EXPLODE_MODEL") { 50 | return buildingHandler.explode(action.payload); 51 | } 52 | if (action.type === "TOGGLE_CLIPPER") { 53 | return buildingHandler.toggleClippingPlanes(action.payload); 54 | } 55 | if (action.type === "TOGGLE_DIMENSIONS") { 56 | return buildingHandler.toggleDimensions(action.payload); 57 | } 58 | if (action.type === "TOGGLE_FLOORPLAN") { 59 | const { active, floorplan } = action.payload; 60 | return buildingHandler.toggleFloorplan(active, floorplan); 61 | } 62 | if (action.type === "GET_BUILDINGS") { 63 | // Assuming you have the current user object from Firebase Auth 64 | const user = action.payload; 65 | if (user) { 66 | return databaseHandler.getBuildings(user); 67 | } 68 | // Return something or handle the case when the user is not authenticated 69 | return null; 70 | } 71 | if (action.type === "CENTER_MAP") { 72 | const { lat, lng } = action.payload; 73 | return mapHandler.centerMap(lat, lng); 74 | } 75 | if (action.type === "GET_ENERGY_DATA") { 76 | const buildingId = action.payload; // Assuming action.payload contains the building ID 77 | const energyData = await energyDataHandler.getEnergyData(buildingId); 78 | return energyData; 79 | } 80 | 81 | if (action.type === "ADD_ENERGY_DATA") { 82 | const energyData = action.payload; // Assuming action.payload contains the necessary data 83 | await energyDataHandler.addEnergyData(energyData); 84 | // Optionally, you can refresh the energy data in the component using the provided events 85 | // Call the relevant event or update the UI as needed 86 | } 87 | if (action.type === "UPLOAD_DOCUMENT") { 88 | const { document, file, building } = action.payload; 89 | return databaseHandler.uploadDocument(document, file, building, events); 90 | } 91 | if (action.type === "DELETE_DOCUMENT") { 92 | const { document, building } = action.payload; 93 | return databaseHandler.deleteDocument(document, building, events); 94 | } 95 | if (action.type === "GET_DOCUMENT") { 96 | const { document } = action.payload; 97 | return databaseHandler.getDocument(document) 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /src/core/db/db-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GoogleAuthProvider, 3 | User, 4 | getAuth, 5 | signInWithPopup, 6 | signOut, 7 | } from "firebase/auth"; 8 | import { Building, EnergyData, Model, Document } from "../../types"; 9 | import { Events } from "../../middleware/event-handler"; 10 | import { getFirestore, deleteDoc, doc, updateDoc ,collection, query, where, onSnapshot } from "firebase/firestore"; 11 | import { getApp } from "firebase/app"; 12 | //import { Action } from "../middleware/actions"; 13 | import { deleteObject, getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage"; 14 | import { buildingHandler } from "../building/building-handler"; 15 | import { energyDataHandler } from "./energy-data-handler"; 16 | 17 | export const databaseHandler = { 18 | login: () => { 19 | const auth = getAuth(); 20 | const provider = new GoogleAuthProvider(); 21 | signInWithPopup(auth, provider); 22 | }, 23 | 24 | logout: () => { 25 | const auth = getAuth(); 26 | signOut(auth); 27 | }, 28 | 29 | deleteBuilding: async (building: Building, events: Events) => { 30 | 31 | const app = getApp() 32 | const dbInstance = getFirestore(); 33 | await deleteDoc(doc(dbInstance, "buildings", building.uid)); 34 | //delete all models assigned to building 35 | const storageInstance = getStorage(app) 36 | const ids: string[] = []; 37 | 38 | for (const model of building.models) { 39 | const fileRef = ref(storageInstance, model.id) 40 | await deleteObject(fileRef) 41 | ids.push(model.id) 42 | } 43 | 44 | for (const document of building.documents) { 45 | const fileRef = ref(storageInstance, document.id) 46 | await deleteObject(fileRef) 47 | ids.push(document.id) 48 | } 49 | 50 | //this should be renamed for something more generic 51 | await buildingHandler.deleteModels(ids) 52 | //await buildingHandler.deleteDocuments(ids) 53 | 54 | events.trigger({ type: "CLOSE_BUILDING" }); 55 | }, 56 | 57 | updateBuilding: async (building: Building) => { 58 | const dbInstance = getFirestore(getApp()); 59 | await updateDoc(doc(dbInstance, "buildings", building.uid), { 60 | ...building, 61 | }); 62 | }, 63 | 64 | uploadModel: async ( 65 | model: Model, 66 | file: File, 67 | building: Building, 68 | events: Events 69 | ) => { 70 | const appInstance = getApp(); 71 | const storageInstance = getStorage(appInstance); 72 | const fileRef = ref(storageInstance, model.id); 73 | await uploadBytes(fileRef, file); 74 | await buildingHandler.refreshModels(building, events); 75 | events.trigger({ type: "UPDATE_BUILDING", payload: building }); 76 | }, 77 | 78 | deleteModel: async (model: Model, building: Building, events: Events) => { 79 | const appInstance = getApp(); 80 | const storageInstance = getStorage(appInstance); 81 | const fileRef = ref(storageInstance, model.id); 82 | await deleteObject(fileRef); 83 | await buildingHandler.deleteModels([model.id]); 84 | await buildingHandler.refreshModels(building, events); 85 | events.trigger({ type: "UPDATE_BUILDING", payload: building }); 86 | }, 87 | 88 | async getBuildings(user: User): Promise { 89 | const dbInstance = getFirestore(getApp()); 90 | const q = query(collection(dbInstance, "buildings"), where("userID", "==", user.uid)); 91 | 92 | return new Promise((resolve) => { 93 | const unsubscribe = onSnapshot(q, (snapshot) => { 94 | const result: Building[] = []; 95 | snapshot.docs.forEach((doc) => { 96 | result.push({ ...(doc.data() as Building), uid: doc.id }); 97 | }); 98 | unsubscribe(); 99 | resolve(result); 100 | }); 101 | }); 102 | }, 103 | 104 | addEnergyData: async (energyData: EnergyData) => { 105 | await energyDataHandler.addEnergyData(energyData); 106 | }, 107 | 108 | updateEnergyData: async (buildingId: string, energyData: EnergyData) => { 109 | await energyDataHandler.updateEnergyData(buildingId, energyData); 110 | }, 111 | 112 | getEnergyData: async (buildingId: string): Promise => { 113 | return await energyDataHandler.getEnergyData(buildingId); 114 | }, 115 | 116 | uploadDocument: async ( 117 | document: Document, 118 | file: File, 119 | building: Building, 120 | events: Events 121 | ) => { 122 | const appInstance = getApp(); 123 | const storageInstance = getStorage(appInstance); 124 | const fileRef = ref(storageInstance, document.id); 125 | await uploadBytes(fileRef, file); 126 | // await buildingHandler.refreshModels(building, events); 127 | events.trigger({ type: "UPDATE_BUILDING", payload: building }); 128 | }, 129 | 130 | deleteDocument: async (document: Document, building: Building, events: Events) => { 131 | const appInstance = getApp(); 132 | const storageInstance = getStorage(appInstance); 133 | const fileRef = ref(storageInstance, document.id); 134 | await deleteObject(fileRef); 135 | // await buildingHandler.deleteModels([model.id]); 136 | // await buildingHandler.refreshModels(building, events); 137 | events.trigger({ type: "UPDATE_BUILDING", payload: building }); 138 | }, 139 | 140 | getDocument : async (document: Document) => { 141 | const appInstance = getApp(); 142 | const storageInstance = getStorage(appInstance); 143 | const fileRef = ref(storageInstance, document.id); 144 | const fileUrl = await getDownloadURL(fileRef); 145 | //console.log(fileUrl); 146 | 147 | window.open(fileUrl, "_blank"); 148 | 149 | } 150 | 151 | }; 152 | -------------------------------------------------------------------------------- /src/core/map/map-scene.ts: -------------------------------------------------------------------------------- 1 | import * as OBC from "openbim-components"; 2 | import * as MAPBOX from "mapbox-gl"; 3 | import * as THREE from "three"; 4 | import { MAPBOX_KEY } from "../../config"; 5 | import { Building, GisParameters , LngLat} from "../../types"; 6 | import { User } from "firebase/auth"; 7 | import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer"; 8 | import { MapDatabase } from "./map-database"; 9 | import { Events } from "../../middleware/event-handler"; 10 | 11 | export class MapScene { 12 | private components = new OBC.Components(); 13 | private readonly style = "mapbox://styles/mapbox/light-v11" 14 | private map: MAPBOX.Map; 15 | private center: LngLat = {lat: 0, lng: 0} 16 | private clickedCoordinates: LngLat = {lat: 0, lng: 0} 17 | private labels: {[id:string]: CSS2DObject} = {} 18 | private database = new MapDatabase(); 19 | private events: Events; 20 | 21 | constructor(container: HTMLDivElement, events: Events) { 22 | this.events = events; 23 | const configuration = this.getConfig(container); 24 | this.map = this.createMap(configuration) 25 | this.initializeComponenents(configuration) 26 | this.setupScene() 27 | } 28 | 29 | dispose() { 30 | this.components.dispose(); 31 | (this.map as any) = null; 32 | (this.components as any) = null; 33 | for (const id in this.labels) { 34 | const label = this.labels[id] 35 | label.removeFromParent() 36 | label.element.remove() 37 | } 38 | this.labels = {} 39 | } 40 | 41 | async getAllBuildings(user:User) { 42 | const buildings = await this.database.getBuildings(user) 43 | if (this.components) { 44 | this.addToScene(buildings) 45 | } 46 | } 47 | 48 | async addBuilding(user: User) { 49 | const {lat, lng} = this.clickedCoordinates; 50 | const userID = user.uid; 51 | const energy = 0; 52 | const building = {userID, lat, lng, energy, uid: "", name: "", models: [], documents: []}; 53 | building.uid = await this.database.add(building) 54 | this.addToScene([building]) 55 | } 56 | 57 | private addToScene(buildings: Building[]) { 58 | for (const building of buildings) { 59 | const {uid, lng, lat} = building; 60 | const htmlElement = this.createHtmlElement(building); 61 | const label = new CSS2DObject(htmlElement) 62 | 63 | const center = MAPBOX.MercatorCoordinate.fromLngLat( 64 | {...this.center}, 65 | 0 66 | ); 67 | 68 | const units = center.meterInMercatorCoordinateUnits(); 69 | 70 | const model = MAPBOX.MercatorCoordinate.fromLngLat({lng, lat}, 0); 71 | model.x /= units; 72 | model.y /= units; 73 | center.x /= units; 74 | center.y /= units; 75 | 76 | label.position.set(model.x - center.x, 0, model.y - center.y) 77 | 78 | this.components.scene.get().add(label) 79 | this.labels[uid] = label; 80 | 81 | } 82 | } 83 | 84 | private createHtmlElement(building: Building) { 85 | const div = document.createElement("div") 86 | div.textContent = "🏢" 87 | div.onclick = () => { 88 | this.events.trigger({type: "OPEN_BUILDING", payload: building}) 89 | } 90 | div.classList.add("thumbnail") 91 | return div; 92 | } 93 | 94 | private setupScene() { 95 | const scene = this.components.scene.get(); 96 | scene.background = null; 97 | const dirLight1 = new THREE.DirectionalLight(0xffffff); //white 98 | dirLight1.position.set(0, -70, 100).normalize() 99 | scene.add(dirLight1) 100 | const dirLight2 = new THREE.DirectionalLight(0xffffff); //white 101 | dirLight2.position.set(0, 70, 100).normalize() 102 | scene.add(dirLight2) 103 | } 104 | 105 | private initializeComponenents (config: GisParameters) { 106 | this.components.scene = new OBC.SimpleScene(this.components); 107 | this.components.camera = new OBC.MapboxCamera(); 108 | this.components.renderer = this.createRenderer(config); 109 | this.components.init(); 110 | } 111 | 112 | private createRenderer(config: GisParameters) { 113 | //const map = this.createMap(config); 114 | const coords = this.getCoordinates(config); 115 | return new OBC.MapboxRenderer(this.components, this.map, coords) 116 | } 117 | 118 | private getCoordinates(config: GisParameters) { 119 | const merc = MAPBOX.MercatorCoordinate; 120 | return merc.fromLngLat(config.center, 0); 121 | } 122 | 123 | private createMap(config: GisParameters) { 124 | const map = new MAPBOX.Map({ 125 | ...config, 126 | style:this.style, 127 | antialias: true 128 | }) 129 | map.on("contextmenu", this.storeMousePosition) //when user right clicks on map store the position 130 | return map; 131 | } 132 | 133 | private storeMousePosition = (event: MAPBOX.MapMouseEvent) => { 134 | this.clickedCoordinates = {...event.lngLat} 135 | } 136 | 137 | private getConfig(container: HTMLDivElement) { 138 | const center = [-0.139203, 51.499702] as [number, number]; 139 | this.center = {lng: center[0], lat: center[1]} 140 | return { 141 | container, 142 | accessToken: MAPBOX_KEY, 143 | zoom: 15, 144 | pitch: 60, 145 | bearing: -40, 146 | center, 147 | buildings: [] 148 | } 149 | } 150 | 151 | centerMap(lat: number, lng: number) { 152 | // Use the Mapbox flyTo method to center the map on the specified location 153 | this.map.flyTo({ 154 | center: [lng, lat], 155 | zoom: 15, // Optional, you can adjust the zoom level as needed 156 | speed: 1.5, // Optional, adjust the speed of the fly animation 157 | }); 158 | } 159 | } -------------------------------------------------------------------------------- /src/core/building/building-scene.ts: -------------------------------------------------------------------------------- 1 | import * as OBC from "openbim-components"; 2 | import { LineMaterial } from "three/examples/jsm/lines/LineMaterial"; 3 | import * as THREE from "three"; 4 | import { downloadZip } from "client-zip"; 5 | import { unzip } from "unzipit"; 6 | 7 | import { Building } from "../../types"; 8 | import { BuildingDatabase } from "./building-database"; 9 | import { Events } from "./../../middleware/event-handler"; 10 | import { Floorplan, Property } from "./../../types"; 11 | 12 | export class BuildingScene { 13 | database = new BuildingDatabase(); 14 | 15 | private floorplans: Floorplan[] = []; 16 | private components: OBC.Components; 17 | private fragments: OBC.Fragments; 18 | 19 | private whiteMaterial = new THREE.MeshBasicMaterial({ color: "white" }); 20 | private properties: { [fragID: string]: any } = {}; 21 | 22 | private sceneEvents: { name: any; action: any }[] = []; 23 | private events: Events; 24 | 25 | get container() { 26 | const domElement = this.components.renderer.get().domElement; 27 | return domElement.parentElement as HTMLDivElement; 28 | } 29 | 30 | constructor(container: HTMLDivElement, building: Building, events: Events) { 31 | this.events = events; 32 | this.components = new OBC.Components(); 33 | 34 | const sceneComponent = new OBC.SimpleScene(this.components); 35 | const scene = sceneComponent.get(); 36 | scene.background = null; 37 | 38 | const directionalLight = new THREE.DirectionalLight(); 39 | directionalLight.position.set(5, 10, 3); 40 | directionalLight.intensity = 0.5; 41 | scene.add(directionalLight); 42 | 43 | const ambientLight = new THREE.AmbientLight(); 44 | ambientLight.intensity = 0.5; 45 | scene.add(ambientLight); 46 | 47 | this.components.scene = sceneComponent; 48 | this.components.renderer = new OBC.SimpleRenderer( 49 | this.components, 50 | container 51 | ); 52 | 53 | const camera = new OBC.OrthoPerspectiveCamera(this.components); 54 | this.components.camera = camera; 55 | this.components.raycaster = new OBC.SimpleRaycaster(this.components); 56 | this.components.init(); 57 | 58 | const dimensions = new OBC.SimpleDimensions(this.components); 59 | this.components.tools.add(dimensions); 60 | 61 | const clipper = new OBC.EdgesClipper(this.components, OBC.EdgesPlane); 62 | this.components.tools.add(clipper); 63 | 64 | const thinLineMaterial = new LineMaterial({ 65 | color: 0x000000, 66 | linewidth: 0.001, 67 | }); 68 | 69 | clipper.styles.create("thin_lines", [], thinLineMaterial); 70 | const floorNav = new OBC.PlanNavigator(clipper, camera); 71 | this.components.tools.add(floorNav); 72 | 73 | const grid = new OBC.SimpleGrid(this.components); 74 | this.components.tools.add(grid); 75 | 76 | this.fragments = new OBC.Fragments(this.components); 77 | //disable culling system - not loading the whole model? 78 | //this.fragments.culler.enabled = false; 79 | this.components.tools.add(this.fragments); 80 | 81 | this.fragments.highlighter.active = true; 82 | const selectMat = new THREE.MeshBasicMaterial({ color: "pink" }); 83 | const preselectMat = new THREE.MeshBasicMaterial({ 84 | color: "pink", 85 | opacity: 0.5, 86 | transparent: true, 87 | }); 88 | 89 | this.fragments.highlighter.add("selection", [selectMat]); 90 | this.fragments.highlighter.add("preselection", [preselectMat]); 91 | 92 | this.loadAllModels(building); 93 | 94 | this.fragments.exploder.groupName = "floor"; 95 | 96 | this.setupEvents(); 97 | } 98 | 99 | dispose() { 100 | this.properties = {}; 101 | this.toggleEvents(false); 102 | this.components.dispose(); 103 | this.whiteMaterial.dispose(); 104 | (this.components as any) = null; 105 | (this.fragments as any) = null; 106 | } 107 | 108 | explode(active: boolean) { 109 | const exploder = this.fragments.exploder; 110 | if (active) { 111 | exploder.explode(); 112 | } else { 113 | exploder.reset(); 114 | } 115 | } 116 | 117 | private setupEvents() { 118 | this.sceneEvents = [ 119 | { name: "mouseup", action: this.updateCulling }, 120 | { name: "wheel", action: this.updateCulling }, 121 | { name: "mousemove", action: this.preselect }, 122 | { name: "click", action: this.select }, 123 | { name: "keydown", action: this.createClippingPlane }, 124 | { name: "keydown", action: this.createDimension }, 125 | { name: "keydown", action: this.deleteClippingPlaneOrDimension }, 126 | ]; 127 | this.toggleEvents(true); 128 | } 129 | 130 | private toggleEvents(active: boolean) { 131 | for (const event of this.sceneEvents) { 132 | if (active) { 133 | window.addEventListener(event.name, event.action); 134 | } else { 135 | window.removeEventListener(event.name, event.action); 136 | } 137 | } 138 | } 139 | 140 | toggleClippingPlanes(active: boolean) { 141 | const clipper = this.getClipper(); 142 | if (clipper) { 143 | clipper.enabled = active; 144 | } 145 | } 146 | 147 | toggleDimensions(active: boolean) { 148 | const dimensions = this.getDimensions(); 149 | if (dimensions) { 150 | dimensions.enabled = active; 151 | } 152 | } 153 | 154 | private createClippingPlane = (event: KeyboardEvent) => { 155 | if (event.code === "KeyP") { 156 | const clipper = this.getClipper(); 157 | if (clipper) { 158 | clipper.create(); 159 | } 160 | } 161 | }; 162 | 163 | private createDimension = (event: KeyboardEvent) => { 164 | if (event.code === "KeyD") { 165 | const dims = this.getDimensions(); 166 | if (dims) { 167 | dims.create(); 168 | } 169 | } 170 | }; 171 | 172 | private toggleGrid(visible: boolean) { 173 | const grid = this.components.tools.get("SimpleGrid") as OBC.SimpleGrid; 174 | const mesh = grid.get(); 175 | mesh.visible = visible; 176 | } 177 | 178 | private getClipper() { 179 | return this.components.tools.get("EdgesClipper") as OBC.EdgesClipper; 180 | } 181 | 182 | private getFloorNav() { 183 | return this.components.tools.get("PlanNavigator") as OBC.PlanNavigator; 184 | } 185 | 186 | private getDimensions() { 187 | return this.components.tools.get( 188 | "SimpleDimensions" 189 | ) as OBC.SimpleDimensions; 190 | } 191 | 192 | private deleteClippingPlaneOrDimension = (event: KeyboardEvent) => { 193 | if (event.code === "Delete") { 194 | const dims = this.getDimensions(); 195 | dims.delete(); 196 | const clipper = this.getClipper(); 197 | clipper.delete(); 198 | } 199 | }; 200 | 201 | private preselect = () => { 202 | this.fragments.highlighter.highlight("preselection"); 203 | }; 204 | 205 | private select = () => { 206 | const result = this.fragments.highlighter.highlight("selection"); 207 | if (result) { 208 | const allProps = this.properties[result.fragment.id]; 209 | const props = allProps[result.id]; 210 | if (props) { 211 | const formatted: Property[] = []; 212 | for (const name in props) { 213 | let value = props[name]; 214 | if (!value) value = "Unknown"; 215 | if (value.value) value = value.value; 216 | if (typeof value === "number") value = value.toString(); 217 | formatted.push({ name, value }); 218 | } 219 | return this.events.trigger({ 220 | type: "UPDATE_PROPERTIES", 221 | payload: formatted, 222 | }); 223 | } 224 | } 225 | this.events.trigger({ type: "UPDATE_PROPERTIES", payload: [] }); 226 | 227 | }; 228 | 229 | private updateCulling = () => { 230 | this.fragments.culler.needsUpdate = true; 231 | }; 232 | 233 | async convertIfcToFragments(ifc: File) { 234 | const fragments = new OBC.Fragments(this.components); 235 | 236 | fragments.ifcLoader.settings.optionalCategories.length = 0; 237 | 238 | fragments.ifcLoader.settings.wasm = { 239 | path: "../../", 240 | absolute: false, 241 | }; 242 | 243 | fragments.ifcLoader.settings.webIfc = { 244 | COORDINATE_TO_ORIGIN: true, 245 | USE_FAST_BOOLS: true, 246 | }; 247 | 248 | const url = URL.createObjectURL(ifc) as any; 249 | const model = await fragments.ifcLoader.load(url); 250 | const file = await this.serializeFragments(model); 251 | 252 | fragments.dispose(); 253 | //(fragments as any) = null; //this is returning an error 254 | 255 | return file as File; 256 | } 257 | 258 | toggleFloorplan(active: boolean, floorplan?: Floorplan) { 259 | const floorNav = this.getFloorNav(); 260 | if (!this.floorplans.length) return; 261 | if (active && floorplan) { 262 | this.toggleGrid(false); 263 | this.toggleEdges(true); 264 | floorNav.goTo(floorplan.id); 265 | 266 | this.fragments.materials.apply(this.whiteMaterial); 267 | } else { 268 | this.toggleGrid(true); 269 | this.toggleEdges(false); 270 | this.fragments.materials.reset(); 271 | floorNav.exitPlanView(); 272 | } 273 | } 274 | 275 | private async serializeFragments(model: OBC.FragmentGroup) { 276 | const files = []; 277 | for (const frag of model.fragments) { 278 | const file = await frag.export(); 279 | files.push(file.geometry, file.data); 280 | } 281 | 282 | files.push(new File([JSON.stringify(model.properties)], "properties.json")); 283 | files.push( 284 | new File( 285 | [JSON.stringify(model.levelRelationships)], 286 | "levels-relationship.json" 287 | ) 288 | ); 289 | files.push(new File([JSON.stringify(model.itemTypes)], "model-types.json")); 290 | files.push(new File([JSON.stringify(model.allTypes)], "all-types.json")); 291 | files.push( 292 | new File( 293 | [JSON.stringify(model.floorsProperties)], 294 | "levels-properties.json" 295 | ) 296 | ); 297 | files.push( 298 | new File( 299 | [JSON.stringify(model.coordinationMatrix)], 300 | "coordination-matrix.json" 301 | ) 302 | ); 303 | files.push( 304 | new File( 305 | [JSON.stringify(model.expressIDFragmentIDMap)], 306 | "express-fragment-map.json" 307 | ) 308 | ); 309 | 310 | return downloadZip(files).blob(); 311 | } 312 | 313 | private toggleEdges(visible: boolean) { 314 | const edges = Object.values(this.fragments.edges.edgesList); 315 | const scene = this.components.scene.get(); 316 | for (const edge of edges) { 317 | if (visible) scene.add(edge); 318 | else edge.removeFromParent(); 319 | } 320 | } 321 | 322 | private async loadAllModels(building: Building) { 323 | const buildingsURLs = await this.database.getModels(building); 324 | for (const url of buildingsURLs) { 325 | const { entries } = await unzip(url); 326 | 327 | const fileNames = Object.keys(entries); 328 | 329 | const properties = await entries["properties.json"].json(); 330 | 331 | //console.log(building) 332 | //this.getIfcTotalAreas(url) 333 | const allTypes = await entries["all-types.json"].json(); 334 | //console.log(allTypes) 335 | const modelTypes = await entries["model-types.json"].json(); 336 | const levelsProperties = await entries["levels-properties.json"].json(); 337 | const levelsRelationship = await entries[ 338 | "levels-relationship.json" 339 | ].json(); 340 | 341 | // Set up floorplans 342 | 343 | const levelOffset = 1.5; 344 | const floorNav = this.getFloorNav(); 345 | 346 | if (this.floorplans.length === 0) { 347 | for (const levelProps of levelsProperties) { 348 | const elevation = levelProps.SceneHeight + levelOffset; 349 | 350 | this.floorplans.push({ 351 | id: levelProps.expressID, 352 | name: levelProps.Name.value, 353 | }); 354 | 355 | // Create floorplan 356 | await floorNav.create({ 357 | id: levelProps.expressID, 358 | ortho: true, 359 | normal: new THREE.Vector3(0, -1, 0), 360 | point: new THREE.Vector3(0, elevation, 0), 361 | }); 362 | } 363 | 364 | this.events.trigger({ 365 | type: "UPDATE_FLOORPLANS", 366 | payload: this.floorplans, 367 | }); 368 | } 369 | 370 | // Load all the fragments within this zip file 371 | 372 | for (let i = 0; i < fileNames.length; i++) { 373 | const name = fileNames[i]; 374 | if (!name.includes(".glb")) continue; 375 | 376 | const geometryName = fileNames[i]; 377 | const geometry = await entries[geometryName].blob(); 378 | const geometryURL = URL.createObjectURL(geometry); 379 | 380 | const dataName = 381 | geometryName.substring(0, geometryName.indexOf(".glb")) + ".json"; 382 | 383 | const data = await entries[dataName].json(); 384 | 385 | const dataBlob = await entries[dataName].blob(); 386 | 387 | const dataURL = URL.createObjectURL(dataBlob); 388 | 389 | const fragment = await this.fragments.load(geometryURL, dataURL); 390 | 391 | this.properties[fragment.id] = properties; 392 | 393 | // Set up edges 394 | 395 | const lines = this.fragments.edges.generate(fragment); 396 | lines.removeFromParent(); 397 | 398 | // Set up clipping edges 399 | 400 | const styles = this.getClipper().styles.get(); 401 | const thinStyle = styles["thin_lines"]; 402 | thinStyle.meshes.push(fragment.mesh); 403 | 404 | // Group items by category and by floor 405 | 406 | const groups = { category: {}, floor: {} } as any; 407 | 408 | const floorNames = {} as any; 409 | for (const levelProps of levelsProperties) { 410 | floorNames[levelProps.expressID] = levelProps.Name.value; 411 | } 412 | 413 | for (const id of data.ids) { 414 | // Get the category of the items 415 | 416 | const categoryExpressID = modelTypes[id]; 417 | const category = allTypes[categoryExpressID]; 418 | if (!groups.category[category]) { 419 | groups.category[category] = []; 420 | } 421 | groups.category[category].push(id); 422 | 423 | // Get the floors of the items 424 | 425 | const floorExpressID = levelsRelationship[id]; 426 | const floor = floorNames[floorExpressID]; 427 | if (!groups["floor"][floor]) { 428 | groups["floor"][floor] = []; 429 | } 430 | groups["floor"][floor].push(id); 431 | } 432 | 433 | this.fragments.groups.add(fragment.id, groups); 434 | } 435 | 436 | this.fragments.culler.needsUpdate = true; 437 | this.fragments.highlighter.update(); 438 | this.fragments.highlighter.active = true; 439 | } 440 | } 441 | 442 | 443 | } 444 | --------------------------------------------------------------------------------