├── src ├── vite-env.d.ts ├── main.tsx ├── theme │ └── color.ts ├── global.d.ts ├── api │ └── axios.ts ├── state │ ├── carStore.ts │ ├── exportStore.ts │ └── areaStore.ts ├── components │ ├── text │ │ ├── Description.tsx │ │ └── Title.tsx │ ├── flex │ │ ├── Column.tsx │ │ └── Row.tsx │ ├── FullscreenModal.tsx │ ├── button │ │ └── BottomButton.tsx │ ├── modal │ │ └── Modal.tsx │ ├── map │ │ ├── Processing.tsx │ │ └── SelectMap.tsx │ └── nav │ │ └── TopNav.tsx ├── utils │ └── cookie.ts ├── index.css ├── three │ ├── Car.tsx │ └── Space.tsx └── ui │ └── App.tsx ├── .github └── screenshot.png ├── tsconfig.json ├── index.html ├── .gitignore ├── vite.config.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── LICENSE ├── package.json ├── public └── vite.svg └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cartesiancs/map3d/HEAD/.github/screenshot.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import "./index.css"; 3 | import App from "./ui/App.tsx"; 4 | 5 | createRoot(document.getElementById("root")!).render(); 6 | -------------------------------------------------------------------------------- /src/theme/color.ts: -------------------------------------------------------------------------------- 1 | export const BORDER_COLOR = "#ededf290"; 2 | export const SUBTITLE_COLOR = "#5b5d63"; 3 | export const DESC_COLOR = "#8f8f96"; 4 | export const ACTION_ICON_COLOR = "#5b5d63"; 5 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { Object3DNode } from "@react-three/fiber"; 3 | 4 | declare global { 5 | namespace JSX { 6 | interface IntrinsicElements { 7 | "three-line": Object3DNode; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { getCookie } from "../utils/cookie"; 3 | 4 | const instanceFleet = axios.create({ 5 | baseURL: `https://api.fleet.cartesiancs.com/api/`, 6 | timeout: 7000, 7 | headers: { "x-access-token": getCookie("token") }, 8 | }); 9 | 10 | export default instanceFleet; 11 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | map3d 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/state/carStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type CarStore = { 4 | thirdMode: boolean; 5 | 6 | setThirdMode: (thirdMode: boolean) => void; 7 | }; 8 | 9 | export const useCarStore = create((set) => ({ 10 | thirdMode: false, 11 | setThirdMode: (thirdMode) => set(() => ({ thirdMode: thirdMode })), 12 | })); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/components/text/Description.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { DESC_COLOR } from "@/theme/color"; 3 | 4 | export function Description({ children }: { children?: React.ReactNode }) { 5 | return ( 6 |

14 | {children} 15 |

16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/text/Title.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { SUBTITLE_COLOR } from "@/theme/color"; 3 | 4 | export function Title({ children }: { children?: React.ReactNode }) { 5 | return ( 6 |

14 | {children} 15 |

16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | dts({ 9 | insertTypesEntry: true, 10 | }), 11 | react({ 12 | jsxImportSource: "@emotion/react", 13 | babel: { 14 | plugins: ["@emotion/babel-plugin"], 15 | }, 16 | }), 17 | ], 18 | resolve: { 19 | alias: [{ find: "@", replacement: "/src" }], 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/flex/Column.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | 3 | export function Column({ 4 | children, 5 | gap = "0.25rem", 6 | justify = "unset", 7 | height = "auto", 8 | }: { 9 | children?: React.ReactNode; 10 | gap?: string; 11 | justify?: string; 12 | height?: string; 13 | }) { 14 | return ( 15 |
24 | {children} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/state/exportStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type ActionStore = { 4 | action: boolean; 5 | fleetSpaceId: string; 6 | exportType: "glb" | "fleet"; 7 | 8 | setAction: (action: boolean) => void; 9 | setFleet: (fleetSpaceId: string, exportType: "glb" | "fleet") => void; 10 | }; 11 | 12 | export const useActionStore = create((set) => ({ 13 | action: false, 14 | fleetSpaceId: "", 15 | exportType: "glb", 16 | setAction: (action) => set(() => ({ action: action })), 17 | setFleet: (fleetSpaceId, exportType) => 18 | set(() => ({ fleetSpaceId: fleetSpaceId, exportType: exportType })), 19 | })); 20 | -------------------------------------------------------------------------------- /src/components/flex/Row.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { Properties } from "csstype"; 3 | 4 | export function Row({ 5 | children, 6 | gap = "0.25rem", 7 | justify = "unset", 8 | overflow = "visible", 9 | }: { 10 | children?: React.ReactNode; 11 | gap?: string; 12 | justify?: string; 13 | overflow?: Properties["overflow"]; 14 | }) { 15 | return ( 16 |
25 | {children} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/state/areaStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type AreaStore = { 4 | areas: any; 5 | center: { 6 | lat: number; 7 | lng: number; 8 | }[]; 9 | 10 | appendAreas: (areas: []) => void; 11 | setCenter: (center: []) => void; 12 | }; 13 | 14 | export const useAreaStore = create((set) => ({ 15 | areas: [], 16 | center: [ 17 | { 18 | lat: 40.8, 19 | lng: -73.95, 20 | }, 21 | { 22 | lat: 40.83, 23 | lng: -73.88, 24 | }, 25 | ], 26 | appendAreas: (areas) => set(() => ({ areas: [...areas] })), 27 | setCenter: (center) => set(() => ({ center: [...center] })), 28 | })); 29 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | /* Bundler mode */ 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | /* Linting */ 14 | "strict": false, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noFallthroughCasesInSwitch": false, 18 | "noImplicitAny": false, 19 | 20 | "jsxImportSource": "@emotion/react" 21 | }, 22 | "include": ["vite.config.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /src/components/FullscreenModal.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import React from "react"; 3 | 4 | export function FullscreenModal({ 5 | children, 6 | isOpen = false, 7 | }: { 8 | children: React.ReactNode; 9 | isOpen?: boolean; 10 | }) { 11 | return ( 12 |
23 |
30 | {children} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": false, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | /* Linting */ 16 | "strict": false, 17 | "noUnusedLocals": false, 18 | "noUnusedParameters": false, 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitAny": false, 21 | "types": ["@emotion/react/types/css-prop"], 22 | "jsxImportSource": "@emotion/react", 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["src/*"] 26 | } 27 | }, 28 | "include": ["src"] 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | export const getCookies = () => { 2 | try { 3 | const cookies = document.cookie.split(";").reduce((res, c) => { 4 | const [key, val] = c.trim().split("=").map(decodeURIComponent); 5 | try { 6 | return Object.assign(res, { [key]: JSON.parse(val) }); 7 | } catch (e) { 8 | return Object.assign(res, { [key]: val }); 9 | } 10 | }, {}); 11 | 12 | return cookies; 13 | } catch (error) { 14 | return ""; 15 | } 16 | }; 17 | 18 | export const getCookie = (key: string) => { 19 | const cookieList = getCookies(); 20 | if (!cookieList.hasOwnProperty(key)) { 21 | return ""; 22 | } 23 | return cookieList[key]; 24 | }; 25 | 26 | export const setCookie = (key: string, value: string, expDays: number = 6) => { 27 | const date = new Date(); 28 | date.setTime(date.getTime() + expDays * 24 * 60 * 60 * 1000); 29 | const expires = date.toUTCString(); 30 | document.cookie = `${key}=${value}; expires=${expires}; path=/`; 31 | }; 32 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"); 2 | 3 | html, 4 | body, 5 | #root { 6 | height: 100%; 7 | width: 100%; 8 | margin: 0; 9 | font-family: "Pretendard Variable", Pretendard, -apple-system, 10 | BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", 11 | "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", 12 | "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; 13 | -webkit-user-select: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | user-select: none; 17 | -ms-overflow-style: none; 18 | overflow: hidden; 19 | } 20 | 21 | ::-webkit-scrollbar { 22 | display: none; 23 | } 24 | 25 | * { 26 | font-family: "Pretendard Variable", Pretendard, -apple-system, 27 | BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", 28 | "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", 29 | "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 cartesiancs 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "map3d", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@react-three/drei": "^10.0.2", 15 | "@react-three/fiber": "^9.0.4", 16 | "@types/three": "^0.173.0", 17 | "axios": "^1.7.9", 18 | "deventds2": "^0.1.10", 19 | "leaflet": "^1.9.4", 20 | "lucide-react": "^0.475.0", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "react-leaflet": "^5.0.0", 24 | "three": "^0.173.0", 25 | "zustand": "^5.0.3" 26 | }, 27 | "devDependencies": { 28 | "@eslint/js": "^9.19.0", 29 | "@types/leaflet": "^1.9.16", 30 | "@types/react": "^19.0.8", 31 | "@types/react-dom": "^19.0.3", 32 | "@vitejs/plugin-react": "^4.3.4", 33 | "eslint": "^9.19.0", 34 | "eslint-plugin-react-hooks": "^5.0.0", 35 | "eslint-plugin-react-refresh": "^0.4.18", 36 | "globals": "^15.14.0", 37 | "typescript": "~5.7.2", 38 | "typescript-eslint": "^8.22.0", 39 | "vite": "^6.1.0", 40 | "vite-plugin-dts": "^4.5.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

🗺️ map3d

3 |

Generate a real world 3D map

4 |

5 | 6 |

7 | Visit Website · Report Bugs 8 |

9 | 10 | ![img](./.github/screenshot.png) 11 | 12 | ## About The Project 13 | 14 | This is a 3D building mapping service implemented with [React-Three-Fiber](https://github.com/pmndrs/react-three-fiber). It allows exporting as a GLB file, and all features are free to use. Based on this project, various functionalities such as **digital twin**, **drone surveying**, and **GPS markers** can be implemented. 15 | 16 | The map files are based on OpenStreetMap data. 17 | 18 | > [!IMPORTANT] 19 | > 📢 This project cannot guarantee the accuracy of the data. Since it uses OpenStreetMap data, some height values may be missing or incorrectly recorded. To address this issue, an option will be added in the future to allow users to manually correct the data. 20 | 21 | ## Roadmap 22 | 23 | - [x] Create 3D Buildings 24 | - [x] Create Roads 25 | - [x] Export GLB 26 | - [ ] Building Texture 27 | - [ ] Height Customization 28 | - [ ] Material 29 | - [ ] Heightmap 30 | 31 | ## Demo 32 | 33 | https://github.com/user-attachments/assets/1b61c2f8-dcf9-40bb-9804-59f6a74594dc 34 | 35 | ## Contributors 36 | 37 | Hyeong Jun Huh [(GitHub)](https://github.com/DipokalLab) 38 | 39 | ## License 40 | 41 | MIT License 42 | -------------------------------------------------------------------------------- /src/components/button/BottomButton.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { ButtonHTMLAttributes, DetailedHTMLProps } from "react"; 3 | 4 | interface ButtonProps 5 | extends DetailedHTMLProps< 6 | ButtonHTMLAttributes, 7 | HTMLButtonElement 8 | > { 9 | isShow?: boolean; 10 | } 11 | 12 | export function NextButton(props: ButtonProps) { 13 | return ( 14 | 46 | ); 47 | } 48 | 49 | export function PrevButton(props: ButtonProps) { 50 | return ( 51 | 83 | ); 84 | } 85 | 86 | export function Button(props: ButtonProps) { 87 | return ( 88 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/components/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { css, keyframes } from "@emotion/react"; 4 | 5 | type ModalType = { 6 | children?: any; 7 | onClose?: any; 8 | isOpen?: boolean; 9 | isScroll?: boolean; 10 | }; 11 | 12 | const fadeInBackground = keyframes` 13 | 0% { 14 | backdrop-filter: brightness(100%) 15 | 16 | } 17 | 100% { 18 | backdrop-filter: brightness(70%) 19 | } 20 | `; 21 | 22 | const fadeOutBackground = keyframes` 23 | 0% { 24 | backdrop-filter: brightness(70%) 25 | 26 | } 27 | 100% { 28 | backdrop-filter: brightness(100%) 29 | } 30 | `; 31 | 32 | const fadeIn = keyframes` 33 | 0% { 34 | transform: translateY(-10px); 35 | opacity: 40%; 36 | 37 | } 38 | 100% { 39 | transform: translateY(0px); 40 | opacity: 100%; 41 | 42 | } 43 | `; 44 | 45 | const fadeOut = keyframes` 46 | 0% { 47 | transform: translateY(0px); 48 | opacity: 100%; 49 | 50 | } 51 | 100% { 52 | transform: translateY(-10px); 53 | opacity: 0%; 54 | 55 | } 56 | `; 57 | 58 | function Modal({ children, onClose, isOpen, isScroll = false }: ModalType) { 59 | const [open, setOpen] = useState(false); 60 | const [fadeOutAnimation, setFadeOutAnimation] = useState( 61 | `${fadeIn} 0.3s forwards` 62 | ); 63 | const [backgroundAnimation, setBackgroundAnimation] = useState( 64 | `${fadeInBackground} 0.3s forwards` 65 | ); 66 | 67 | const handleClose = (e: any) => { 68 | if (e.target.id != "modal") { 69 | return false; 70 | } 71 | setFadeOutAnimation(`${fadeOut} 0.3s forwards`); 72 | setBackgroundAnimation(`${fadeOutBackground} 0.3s forwards`); 73 | 74 | setTimeout(() => { 75 | onClose(); 76 | setOpen(false); 77 | }, 280); 78 | }; 79 | 80 | useEffect(() => { 81 | if (isOpen) { 82 | setOpen(true); 83 | setFadeOutAnimation(`${fadeIn} 0.3s forwards`); 84 | setBackgroundAnimation(`${fadeInBackground} 0.3s forwards`); 85 | } else { 86 | setFadeOutAnimation(`${fadeOut} 0.3s forwards`); 87 | setBackgroundAnimation(`${fadeOutBackground} 0.3s forwards`); 88 | 89 | setTimeout(() => { 90 | onClose(); 91 | setOpen(false); 92 | }, 280); 93 | } 94 | }, [isOpen]); 95 | 96 | return ( 97 | 142 | ); 143 | } 144 | 145 | export { Modal }; 146 | -------------------------------------------------------------------------------- /src/components/map/Processing.tsx: -------------------------------------------------------------------------------- 1 | import { useAreaStore } from "@/state/areaStore"; 2 | import { css, keyframes } from "@emotion/react"; 3 | import { Loader2 } from "lucide-react"; 4 | import React, { useState } from "react"; 5 | 6 | interface Building { 7 | id: number; 8 | tags: { [key: string]: string | undefined }; 9 | geometry?: { lat: number; lng: number }[]; 10 | } 11 | 12 | const spinAnimation = keyframes` 13 | from { transform: rotate(0deg); } 14 | to { transform: rotate(360deg); } 15 | `; 16 | 17 | export function BuildingHeights({ area }: { area: any }) { 18 | const [buildings, setBuildings] = useState([]); 19 | const [loading, setLoading] = useState(false); 20 | 21 | const appendAreas = useAreaStore((state) => state.appendAreas); 22 | 23 | const requestBuildings = () => { 24 | setLoading(true); 25 | 26 | const south = area[1].lat; 27 | const west = area[1].lng; 28 | const north = area[0].lat; 29 | const east = area[0].lng; 30 | console.log(south, west, north, east); 31 | const query = `[out:json][timeout:25];(way["building"]( ${south},${west},${north},${east} );relation["building"]( ${south},${west},${north},${east} ););out body geom;`; 32 | fetch("https://overpass-api.de/api/interpreter", { 33 | method: "POST", 34 | body: query, 35 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 36 | }) 37 | .then((response) => response.json()) 38 | .then((data) => { 39 | const blds: any = data.elements.map((element) => ({ 40 | id: element.id, 41 | tags: element.tags, 42 | geometry: element.geometry 43 | ? element.geometry.map((pt) => ({ lat: pt.lat, lng: pt.lon })) 44 | : undefined, 45 | })); 46 | setBuildings(blds); 47 | appendAreas(blds); 48 | 49 | console.log("Building Data:", blds); 50 | }) 51 | .catch((error) => { 52 | console.error("Error fetching building data:", error); 53 | }) 54 | .finally(() => { 55 | setLoading(false); 56 | }); 57 | }; 58 | 59 | return ( 60 |
65 | 96 |
    105 | {buildings.map((b) => ( 106 |
  • 107 |
    Building {b.id}
    108 |
    Height: {b.tags.height || "No height info"}
    109 |
    Location/Shape:
    110 | {b.geometry ? ( 111 |
      112 | {b.geometry.map((pt, index) => ( 113 |
    • 114 | ({pt.lat.toFixed(5)}, {pt.lng.toFixed(5)}) 115 |
    • 116 | ))} 117 |
    118 | ) : ( 119 |
    No geometry info
    120 | )} 121 |
    Other Tags: {JSON.stringify(b.tags)}
    122 |
  • 123 | ))} 124 |
125 |
126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /src/components/nav/TopNav.tsx: -------------------------------------------------------------------------------- 1 | import { useCarStore } from "@/state/carStore"; 2 | import { css } from "@emotion/react"; 3 | import { DetailedHTMLProps, ButtonHTMLAttributes, useState } from "react"; 4 | import { Modal } from "../modal/Modal"; 5 | import { Column } from "../flex/Column"; 6 | import { Title } from "../text/Title"; 7 | import { Description } from "../text/Description"; 8 | 9 | const TOP_PANEL_HEIGHT = "3rem"; 10 | const BORDER_COLOR = "#ededf290"; 11 | 12 | const breakpoints = [768]; 13 | const mq = breakpoints.map((bp) => `@media (max-width: ${bp}px)`); 14 | 15 | interface ButtonProps 16 | extends DetailedHTMLProps< 17 | ButtonHTMLAttributes, 18 | HTMLButtonElement 19 | > { 20 | isShow?: boolean; 21 | } 22 | 23 | export function TopNav({ step }: { step: number }) { 24 | const setThirdMode = useCarStore((state) => state.setThirdMode); 25 | const thirdMode = useCarStore((state) => state.thirdMode); 26 | const isMobile = /Mobi|Android/i.test(navigator.userAgent); 27 | 28 | const [openModal, setOpenModal] = useState(false); 29 | 30 | return ( 31 | <> 32 |
50 |
59 | 66 | 🗺️ Map3d 67 | 68 |
69 | 70 |
78 | 79 |
87 | window.open("https://github.com/cartesiancs/map3d")} 90 | > 91 | GitHub 92 | 93 | = 1} onClick={() => setOpenModal(true)}> 94 | Options 95 | 96 | 97 | {!isMobile && ( 98 | <> 99 | {thirdMode ? ( 100 | setThirdMode(false)} 103 | > 104 | Disable Car 105 | 106 | ) : ( 107 | setThirdMode(true)} 110 | > 111 | Car Mode 112 | 113 | )} 114 | 115 | )} 116 |
117 |
118 | 119 | setOpenModal(false)}> 120 | 121 | Options 122 | 123 | 124 | 125 | ); 126 | } 127 | 128 | export function NavButton(props: ButtonProps) { 129 | return ( 130 | 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /src/three/Car.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useCallback } from "react"; 2 | import { useFrame, useThree } from "@react-three/fiber"; 3 | import { OrbitControls } from "@react-three/drei"; 4 | import * as THREE from "three"; 5 | import { useCarStore } from "@/state/carStore"; 6 | 7 | const Car = () => { 8 | const carRef = useRef(null); 9 | const { camera } = useThree(); 10 | const thirdMode = useCarStore((state) => state.thirdMode); 11 | const setThirdMode = useCarStore((state) => state.setThirdMode); 12 | const keys = useRef({ w: false, s: false, a: false, d: false }); 13 | const velocity = useRef(0); 14 | 15 | const handleKeyDown = useCallback((e) => { 16 | switch (e.key.toLowerCase()) { 17 | case "w": 18 | keys.current.w = true; 19 | break; 20 | case "s": 21 | keys.current.s = true; 22 | break; 23 | case "a": 24 | keys.current.a = true; 25 | break; 26 | case "d": 27 | keys.current.d = true; 28 | break; 29 | case "escape": 30 | setThirdMode(false); 31 | if (document.exitPointerLock) { 32 | document.exitPointerLock(); 33 | } 34 | break; 35 | default: 36 | break; 37 | } 38 | }, []); 39 | 40 | const handleKeyUp = useCallback((e) => { 41 | switch (e.key.toLowerCase()) { 42 | case "w": 43 | keys.current.w = false; 44 | break; 45 | case "s": 46 | keys.current.s = false; 47 | break; 48 | case "a": 49 | keys.current.a = false; 50 | break; 51 | case "d": 52 | keys.current.d = false; 53 | break; 54 | default: 55 | break; 56 | } 57 | }, []); 58 | 59 | useEffect(() => { 60 | window.addEventListener("keydown", handleKeyDown); 61 | window.addEventListener("keyup", handleKeyUp); 62 | return () => { 63 | window.removeEventListener("keydown", handleKeyDown); 64 | window.removeEventListener("keyup", handleKeyUp); 65 | }; 66 | }, [handleKeyDown, handleKeyUp]); 67 | 68 | useEffect(() => { 69 | if (thirdMode) { 70 | const handleClick = () => { 71 | if (document.pointerLockElement !== document.body) { 72 | document.body.requestPointerLock(); 73 | } 74 | }; 75 | window.addEventListener("click", handleClick); 76 | return () => window.removeEventListener("click", handleClick); 77 | } 78 | }, [thirdMode]); 79 | 80 | useEffect(() => { 81 | if (thirdMode) { 82 | const onMouseMove = (event) => { 83 | if (document.pointerLockElement === document.body && carRef.current) { 84 | carRef.current.rotation.y -= event.movementX * 0.002; 85 | } 86 | }; 87 | document.addEventListener("mousemove", onMouseMove); 88 | return () => document.removeEventListener("mousemove", onMouseMove); 89 | } 90 | }, [thirdMode]); 91 | 92 | useFrame((state, delta) => { 93 | if (carRef.current) { 94 | const accelerationRate = 0.2; 95 | const maxSpeed = 3.0; 96 | const decelerationRate = 1.0; 97 | if (keys.current.w) { 98 | velocity.current = Math.min( 99 | maxSpeed, 100 | velocity.current + accelerationRate * delta 101 | ); 102 | } else if (keys.current.s) { 103 | velocity.current = Math.max( 104 | -maxSpeed, 105 | velocity.current - accelerationRate * delta 106 | ); 107 | } else { 108 | if (velocity.current > 0) { 109 | velocity.current = Math.max( 110 | 0, 111 | velocity.current - decelerationRate * delta 112 | ); 113 | } else if (velocity.current < 0) { 114 | velocity.current = Math.min( 115 | 0, 116 | velocity.current + decelerationRate * delta 117 | ); 118 | } 119 | } 120 | if (keys.current.a) carRef.current.rotation.y += 0.02; 121 | if (keys.current.d) carRef.current.rotation.y -= 0.02; 122 | const forward = new THREE.Vector3(0, 0, -1); 123 | forward.applyQuaternion(carRef.current.quaternion); 124 | carRef.current.position.addScaledVector(forward, velocity.current); 125 | } 126 | if (thirdMode && carRef.current) { 127 | const carPos = carRef.current.position; 128 | const offset = new THREE.Vector3(0, 1, 2); 129 | offset.applyAxisAngle( 130 | new THREE.Vector3(0, 1, 0), 131 | carRef.current.rotation.y 132 | ); 133 | const desiredPosition = carPos.clone().add(offset); 134 | camera.position.lerp(desiredPosition, 0.1); 135 | camera.lookAt(carPos); 136 | } 137 | }); 138 | 139 | return ( 140 | <> 141 | 142 | 143 | 144 | 145 | {!thirdMode && } 146 | 147 | ); 148 | }; 149 | 150 | export default Car; 151 | -------------------------------------------------------------------------------- /src/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { Space } from "../three/Space"; 3 | import { FullscreenModal } from "../components/FullscreenModal"; 4 | import { Title } from "@/components/text/Title"; 5 | import { Description } from "@/components/text/Description"; 6 | import { Column } from "@/components/flex/Column"; 7 | import { MapComponent } from "@/components/map/SelectMap"; 8 | import { useEffect, useState } from "react"; 9 | import { 10 | Button, 11 | NextButton, 12 | PrevButton, 13 | } from "@/components/button/BottomButton"; 14 | import { BuildingHeights } from "@/components/map/Processing"; 15 | import { ChevronLeft, ChevronRight, Download } from "lucide-react"; 16 | import { useAreaStore } from "@/state/areaStore"; 17 | import { useActionStore } from "@/state/exportStore"; 18 | import { Modal } from "@/components/modal/Modal"; 19 | import { TopNav } from "@/components/nav/TopNav"; 20 | import { getCookie } from "@/utils/cookie"; 21 | import { Row } from "@/components/flex/Row"; 22 | import instanceFleet from "@/api/axios"; 23 | 24 | const IconSize = css({ 25 | width: "14px", 26 | height: "14px", 27 | }); 28 | 29 | function App() { 30 | const [isNextButtonDisabled, setIsNextButtonDisabled] = useState(true); 31 | const [areaData, setAreaData] = useState([]); 32 | const [steps, setSteps] = useState(["front", "processing"]); 33 | const [step, setStep] = useState(0); 34 | const [isWarnModal, setIsWarnModal] = useState(false); 35 | const [isExportModal, setIsExportModal] = useState(false); 36 | const [isFleetLogin, setIsFleetLogin] = useState(false); 37 | const [isFleetModal, setIsFleetModal] = useState(false); 38 | const [spaceList, setSpaceList] = useState([]); 39 | 40 | const setCenter = useAreaStore((state) => state.setCenter); 41 | const setAction = useActionStore((state) => state.setAction); 42 | const setFleet = useActionStore((state) => state.setFleet); 43 | 44 | const checkIsBig = () => { 45 | const a = areaData[0].lat - areaData[1].lat; 46 | const b = areaData[0].lng - areaData[1].lng; 47 | 48 | console.log(a + b); 49 | 50 | if (a + b > 0.1) { 51 | return true; 52 | } else { 53 | return false; 54 | } 55 | }; 56 | 57 | const exportFile = () => { 58 | setAction(true); 59 | }; 60 | 61 | const exportFleet = () => { 62 | setAction(true); 63 | }; 64 | 65 | const getFleetSpaces = async () => { 66 | const getSpace: any = await instanceFleet.get("space"); 67 | 68 | setSpaceList([ 69 | ...getSpace.data.spaces.map((item) => { 70 | return { 71 | ...item, 72 | key: item.id, 73 | }; 74 | }), 75 | ]); 76 | }; 77 | 78 | const putGlbOnFleetSpace = (spaceId) => { 79 | setFleet(spaceId, "fleet"); 80 | setTimeout(() => { 81 | exportFleet(); 82 | }, 100); 83 | }; 84 | 85 | const loadFleetSpace = () => { 86 | getFleetSpaces(); 87 | setIsFleetModal(true); 88 | }; 89 | 90 | const checkFleetLogin = () => { 91 | try { 92 | const isCookie = getCookie("token"); 93 | if (isCookie) { 94 | setIsFleetLogin(true); 95 | } 96 | } catch (error) {} 97 | }; 98 | 99 | const handleDone = (data) => { 100 | setAreaData(data); 101 | setCenter(data); 102 | console.log(data, "AAEE"); 103 | setIsNextButtonDisabled(false); 104 | }; 105 | 106 | const handleRemove = () => { 107 | setAreaData([]); 108 | setIsNextButtonDisabled(true); 109 | }; 110 | 111 | const handleClickNextStep = () => { 112 | if (step == 0 && checkIsBig()) { 113 | setIsWarnModal(true); 114 | return false; 115 | } 116 | setStep(step + 1); 117 | }; 118 | 119 | const handleClickPrevStep = () => { 120 | setStep(step - 1); 121 | }; 122 | 123 | const handleClickExport = () => { 124 | setIsExportModal(true); 125 | }; 126 | 127 | useEffect(() => { 128 | checkFleetLogin(); 129 | }, []); 130 | 131 | return ( 132 |
133 | 134 | 135 | 136 | 137 | 138 | Generate 3d map 139 | 140 | Tools to create 3D maps based on maps and export them in GLB 141 | format 142 | 143 | 144 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | Processing 155 | 156 | Click the button below to get the building information. 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | Prev Step 166 | 167 | 168 | 173 | Next Step 174 | 175 | 176 | 177 | Export GLB 178 | 179 | 180 | setIsWarnModal(false)}> 181 | 182 | The area is too big 183 | Do you want to proceed? 184 | 194 | 195 | 196 | 197 | setIsExportModal(false)}> 198 | 199 | Export 200 | 201 | 202 | 205 | 206 | {isFleetLogin ? ( 207 | 210 | ) : ( 211 | 217 | )} 218 | 219 | 220 | 221 | 222 | setIsFleetModal(false)}> 223 | 224 | Select Fleet Space 225 | {spaceList.map((item, index) => ( 226 | 229 | ))} 230 | 231 | 232 | 233 | 234 |
235 | ); 236 | } 237 | 238 | export default App; 239 | -------------------------------------------------------------------------------- /src/components/map/SelectMap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { 3 | MapContainer, 4 | Rectangle, 5 | TileLayer, 6 | useMapEvents, 7 | } from "react-leaflet"; 8 | import L, { LatLng, LatLngBounds } from "leaflet"; 9 | import "leaflet/dist/leaflet.css"; 10 | import { css } from "@emotion/react"; 11 | import { CircleMinus, MousePointerClick } from "lucide-react"; 12 | 13 | const IconSize = css({ 14 | width: "14px", 15 | height: "14px", 16 | }); 17 | 18 | function RectangleSelector({ 19 | isDrag = true, 20 | 21 | bounds, 22 | drawBounds, 23 | 24 | onChange, 25 | onDrawChange, 26 | }: { 27 | isDrag: boolean; 28 | 29 | bounds: LatLngBounds | null; 30 | drawBounds: LatLngBounds | null; 31 | 32 | onChange: (bounds: LatLngBounds) => void; 33 | onDrawChange: (bounds: LatLngBounds) => void; 34 | }) { 35 | const [firstPoint, setFirstPoint] = useState(null); 36 | 37 | const lastLatlngRef = useRef(null); 38 | 39 | const adjustLng = (latlng: LatLng): LatLng => { 40 | const adjustedLng = ((((latlng.lng + 180) % 360) + 360) % 360) - 180; 41 | return new L.LatLng(latlng.lat, adjustedLng); 42 | }; 43 | 44 | const map = useMapEvents({ 45 | mousedown(e) { 46 | if (!isDrag) { 47 | setFirstPoint(e.latlng); 48 | } 49 | }, 50 | mousemove(e) { 51 | if (firstPoint) { 52 | lastLatlngRef.current = adjustLng(e.latlng); 53 | onDrawChange(new L.LatLngBounds(firstPoint, e.latlng)); 54 | onChange( 55 | new L.LatLngBounds(adjustLng(firstPoint), adjustLng(e.latlng)) 56 | ); 57 | } 58 | }, 59 | mouseup(e) { 60 | if (firstPoint) { 61 | onDrawChange(new L.LatLngBounds(firstPoint, e.latlng)); 62 | onChange( 63 | new L.LatLngBounds(adjustLng(firstPoint), adjustLng(e.latlng)) 64 | ); 65 | setFirstPoint(null); 66 | } 67 | }, 68 | }); 69 | 70 | useEffect(() => { 71 | const container = map.getContainer(); 72 | const handleTouchStart = (e: TouchEvent) => { 73 | if (!isDrag && e.touches.length > 0) { 74 | const touch = e.touches[0]; 75 | const latlng = map.mouseEventToLatLng(touch as any); 76 | setFirstPoint(latlng); 77 | } 78 | }; 79 | 80 | const handleTouchMove = (e: TouchEvent) => { 81 | if (firstPoint && e.touches.length > 0) { 82 | const touch = e.touches[0]; 83 | const latlng = map.mouseEventToLatLng(touch as any); 84 | lastLatlngRef.current = latlng; 85 | 86 | onDrawChange(new L.LatLngBounds(firstPoint, latlng)); 87 | onChange(new L.LatLngBounds(adjustLng(firstPoint), adjustLng(latlng))); 88 | } 89 | }; 90 | 91 | const handleTouchEnd = (e: TouchEvent) => { 92 | if (firstPoint) { 93 | const latlng = lastLatlngRef.current || firstPoint; 94 | 95 | onDrawChange(new L.LatLngBounds(firstPoint, latlng)); 96 | onChange(new L.LatLngBounds(adjustLng(firstPoint), adjustLng(latlng))); 97 | setFirstPoint(null); 98 | } 99 | }; 100 | 101 | container.addEventListener("touchstart", handleTouchStart); 102 | container.addEventListener("touchmove", handleTouchMove); 103 | container.addEventListener("touchend", handleTouchEnd); 104 | 105 | return () => { 106 | container.removeEventListener("touchstart", handleTouchStart); 107 | container.removeEventListener("touchmove", handleTouchMove); 108 | container.removeEventListener("touchend", handleTouchEnd); 109 | }; 110 | }, [map, isDrag, firstPoint, onChange]); 111 | 112 | useEffect(() => { 113 | if (map) { 114 | isDrag ? map.dragging.enable() : map.dragging.disable(); 115 | } 116 | }, [isDrag, map]); 117 | 118 | return drawBounds ? ( 119 | 120 | ) : null; 121 | } 122 | 123 | export function MapComponent({ 124 | onDone, 125 | onRemove, 126 | }: { 127 | onDone: (e) => void; 128 | onRemove: () => void; 129 | }) { 130 | const [isDrag, setIsDrag] = useState(true); 131 | const [bounds, setBounds] = useState(null); 132 | const [drawBounds, setDrawBounds] = useState(null); 133 | 134 | const handleClickSwitchDrag = () => { 135 | setIsDrag(!isDrag); 136 | }; 137 | 138 | const handleClickRemoveBox = () => { 139 | onRemove(); 140 | setBounds(null); 141 | setDrawBounds(null); 142 | setIsDrag(true); 143 | }; 144 | 145 | const handleChangeDone = (e) => { 146 | setBounds(e); 147 | onDone([e._northEast, e._southWest]); 148 | }; 149 | 150 | const handleChangeDraw = (e) => { 151 | setDrawBounds(e); 152 | onDone([e._northEast, e._southWest]); 153 | }; 154 | 155 | return ( 156 |
161 |
172 | 195 | 196 | 221 |
222 | 223 | 231 | 235 | 242 | 243 |
244 | ); 245 | } 246 | 247 | function SelectBox() { 248 | return ( 249 | <> 250 | 251 | Select Box 252 | 253 | ); 254 | } 255 | -------------------------------------------------------------------------------- /src/three/Space.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Canvas, extend, ReactThreeFiber, useThree } from "@react-three/fiber"; 3 | import { useAreaStore } from "@/state/areaStore"; 4 | import { Html, Sky, Environment, Line } from "@react-three/drei"; 5 | import * as THREE from "three"; 6 | import { useActionStore } from "@/state/exportStore"; 7 | import { GLTFExporter } from "three/examples/jsm/Addons.js"; 8 | import Car from "./Car"; 9 | import instanceFleet from "@/api/axios"; 10 | 11 | const scale = 51000; 12 | 13 | function Building({ 14 | shape, 15 | extrudeSettings, 16 | tags, 17 | }: { 18 | shape: THREE.Shape; 19 | extrudeSettings: any; 20 | tags: any; 21 | }) { 22 | const [hovered, setHovered] = useState(false); 23 | const [clicked, setClicked] = useState(false); 24 | const [hoverPos, setHoverPos] = useState(null); 25 | const [showTranslations, setShowTranslations] = useState(false); 26 | const [showAdditionalInfo, setShowAdditionalInfo] = useState(false); 27 | return ( 28 | { 30 | setHovered(true); 31 | e.stopPropagation(); 32 | }} 33 | onPointerOut={(e) => { 34 | setHovered(false); 35 | e.stopPropagation(); 36 | }} 37 | onPointerMove={(e) => { 38 | setHoverPos(e.point.clone()); 39 | e.stopPropagation(); 40 | }} 41 | onClick={(e) => { 42 | setClicked(!clicked); 43 | e.stopPropagation(); 44 | }} 45 | rotation={[-Math.PI / 2, 0, 0]} 46 | > 47 | 48 | 49 | {(hovered || clicked) && hoverPos && ( 50 | 51 |
68 |
77 | {tags.name || "Building Information"} 78 |
79 | {["building", "height", "building:levels", "amenity", "denomination"].map( 80 | (key) => 81 | tags[key] && 82 | (key !== "building" || tags[key] !== "yes") && ( 83 |
91 | 92 | {key === "building" 93 | ? "Type" 94 | : key === "height" 95 | ? "Height" 96 | : key === "building:levels" 97 | ? "Levels" 98 | : key === "amenity" 99 | ? "Facility" 100 | : key === "denomination" 101 | ? "Denomination" 102 | : key.replace(/_/g, " ")} 103 | : 104 | 105 | 106 | {key === "height" ? `${tags[key]} m` : tags[key]} 107 | 108 |
109 | ) 110 | )} 111 | {[ 112 | "addr:street", 113 | "addr:housenumber", 114 | "addr:district", 115 | "addr:city", 116 | "addr:postcode", 117 | ].some((key) => tags[key]) && ( 118 |
125 |
126 | Address 127 |
128 |
129 | {[ 130 | [tags["addr:street"], tags["addr:housenumber"]].filter(Boolean).join(" "), 131 | tags["addr:district"], 132 | tags["addr:city"], 133 | tags["addr:postcode"], 134 | ] 135 | .filter(Boolean) 136 | .join(", ")} 137 |
138 |
139 | )} 140 | {Object.entries(tags).filter( 141 | ([key]) => 142 | ![ 143 | "building", 144 | "name", 145 | "height", 146 | "building:levels", 147 | "source", 148 | "amenity", 149 | "denomination", 150 | ].includes(key) && 151 | !key.startsWith("addr:") && 152 | !key.startsWith("name:") && 153 | !key.startsWith("alt_name:") 154 | ).length > 0 && ( 155 |
162 |
setShowAdditionalInfo(!showAdditionalInfo)} 173 | > 174 | Additional Information 175 | {showAdditionalInfo ? "▲" : "▼"} 176 |
177 | {showAdditionalInfo && ( 178 |
179 | {Object.entries(tags) 180 | .filter( 181 | ([key]) => 182 | ![ 183 | "building", 184 | "name", 185 | "height", 186 | "building:levels", 187 | "source", 188 | "amenity", 189 | "denomination", 190 | ].includes(key) && 191 | !key.startsWith("addr:") && 192 | !key.startsWith("name:") && 193 | !key.startsWith("alt_name:") 194 | ) 195 | .map(([key, value]) => { 196 | if ( 197 | key === "description" || 198 | (typeof value === "string" && value.length > 80) 199 | ) { 200 | return ( 201 |
202 |
205 | {key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ")} 206 |
207 |
221 | {String(value)} 222 |
223 |
224 | ); 225 | } 226 | return ( 227 |
235 | 242 | {key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ")}: 243 | 244 | 251 | {String(value)} 252 | 253 |
254 | ); 255 | })} 256 |
257 | )} 258 |
259 | )} 260 | {Object.entries(tags).filter(([key]) => key.startsWith("name:")).length > 0 && ( 261 |
269 |
setShowTranslations(!showTranslations)} 280 | > 281 | Name Translations 282 | {showTranslations ? "▲" : "▼"} 283 |
284 | {showTranslations && ( 285 |
286 | {Object.entries(tags) 287 | .filter(([key]) => key.startsWith("name:")) 288 | .map(([key, value]) => ( 289 |
297 | 298 | {key.replace("name:", "").toUpperCase()}: 299 | 300 | {String(value)} 301 |
302 | ))} 303 |
304 | )} 305 |
306 | )} 307 |
308 | 309 | )} 310 |
311 | ); 312 | } 313 | 314 | function Roads({ area }: { area: any }) { 315 | const [roads, setRoads] = useState([]); 316 | if (!area || area.length < 2) return null; 317 | const refLat = (area[1].lat + area[0].lat) / 2; 318 | const refLng = (area[1].lng + area[0].lng) / 2; 319 | 320 | function project(lat: number, lng: number) { 321 | const x = (lng - refLng) * scale * Math.cos((refLat * Math.PI) / 180); 322 | const y = (lat - refLat) * scale; 323 | return new THREE.Vector2(x, y); 324 | } 325 | 326 | useEffect(() => { 327 | const south = area[1].lat; 328 | const west = area[1].lng; 329 | const north = area[0].lat; 330 | const east = area[0].lng; 331 | const query = `[out:json][timeout:25];(way["highway"](${south},${west},${north},${east}););out body geom;`; 332 | fetch("https://overpass-api.de/api/interpreter", { 333 | method: "POST", 334 | body: query, 335 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 336 | }) 337 | .then((response) => response.json()) 338 | .then((data) => { 339 | setRoads(data.elements); 340 | }) 341 | .catch((err) => console.error(err)); 342 | }, [area]); 343 | 344 | return ( 345 | <> 346 | {roads.map((road, index) => { 347 | if (!road.geometry || road.geometry.length < 2) return null; 348 | 349 | const points = road.geometry.map((pt: any) => { 350 | const v = project(pt.lat, pt.lon); 351 | return new THREE.Vector3(v.x, 0.1, -v.y); 352 | }); 353 | 354 | const lineGeometry: any = new THREE.BufferGeometry().setFromPoints(points); 355 | 356 | return ; 357 | })} 358 | 359 | ); 360 | } 361 | 362 | export function Export() { 363 | const { scene } = useThree(); 364 | const action = useActionStore((state) => state.action); 365 | const fleetSpaceId = useActionStore((state) => state.fleetSpaceId); 366 | 367 | const exportType = useActionStore((state) => state.exportType); 368 | 369 | const setAction = useActionStore((state) => state.setAction); 370 | 371 | useEffect(() => { 372 | if (action === true) { 373 | setAction(false); 374 | exportGLB(); 375 | } 376 | }, [action, setAction, scene]); 377 | 378 | const uploadFleet = async (blob) => { 379 | const formData = new FormData(); 380 | 381 | formData.append("object", blob, "box3d.glb"); 382 | formData.append("title", "New Object"); 383 | formData.append("description", ""); 384 | formData.append("spaceId", fleetSpaceId); 385 | 386 | await instanceFleet.post("space/file/mesh", formData, { 387 | headers: { 388 | "Content-Type": "multipart/form-data", 389 | }, 390 | }); 391 | }; 392 | 393 | const exportGLB = () => { 394 | const sceneClone = scene.clone(true); 395 | sceneClone.traverse((child) => { 396 | if (child.userData && child.userData.skipExport === true) child.parent?.remove(child); 397 | if ((child as any).isHtml === true) child.parent?.remove(child); 398 | }); 399 | const exporter = new GLTFExporter(); 400 | const options = { binary: true, embedImages: true }; 401 | exporter.parse( 402 | sceneClone, 403 | (result) => { 404 | if (result instanceof ArrayBuffer) { 405 | const blob = new Blob([result], { type: "model/gltf-binary" }); 406 | 407 | if (exportType == "glb") { 408 | const link = document.createElement("a"); 409 | link.style.display = "none"; 410 | document.body.appendChild(link); 411 | link.href = URL.createObjectURL(blob); 412 | link.download = "scene.glb"; 413 | link.click(); 414 | document.body.removeChild(link); 415 | } 416 | 417 | if (exportType == "fleet") { 418 | uploadFleet(blob); 419 | } 420 | } else { 421 | console.error("GLB export failed: unexpected result", result); 422 | } 423 | }, 424 | (error) => { 425 | console.error("An error occurred during export", error); 426 | }, 427 | options 428 | ); 429 | }; 430 | return null; 431 | } 432 | 433 | export function Space() { 434 | const areas = useAreaStore((state) => state.areas); 435 | const [realCenter, setRealCenter] = useState(); 436 | const center = useAreaStore((state) => state.center); 437 | const refLat = (center[1].lat + center[0].lat) / 2; 438 | const refLng = (center[1].lng + center[0].lng) / 2; 439 | 440 | function project(lat: number, lng: number) { 441 | const x = (lng - refLng) * scale * Math.cos((refLat * Math.PI) / 180); 442 | const y = (lat - refLat) * scale; 443 | return new THREE.Vector2(x, y); 444 | } 445 | 446 | const areaData = () => { 447 | const result: Array<{ 448 | shape: THREE.Shape; 449 | extrudeSettings: any; 450 | tags: any; 451 | }> = []; 452 | areas.forEach((bld: any) => { 453 | if (!bld.geometry || bld.geometry.length < 3) return; 454 | const shapePoints = bld.geometry.map((pt: any) => project(pt.lat, pt.lng)); 455 | if (!shapePoints[0].equals(shapePoints[shapePoints.length - 1])) 456 | shapePoints.push(shapePoints[0]); 457 | const shape = new THREE.Shape(shapePoints); 458 | let heightValue = parseFloat(bld.tags.height || ""); 459 | const heightLevels = parseFloat(bld.tags["building:levels"] || ""); 460 | if (isNaN(heightValue)) heightValue = 10; 461 | if (!isNaN(heightLevels)) heightValue = heightLevels * 2.2; 462 | const extrudeSettings = { 463 | steps: 1, 464 | depth: heightValue, 465 | bevelEnabled: false, 466 | }; 467 | result.push({ shape, extrudeSettings, tags: bld.tags }); 468 | }); 469 | return result; 470 | }; 471 | 472 | useEffect(() => { 473 | setRealCenter(center); 474 | }, [areas]); 475 | 476 | const buildingsData = areaData(); 477 | 478 | return ( 479 | 480 | 481 | 482 | {buildingsData.map((item, index) => ( 483 | 489 | ))} 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | ); 499 | } 500 | --------------------------------------------------------------------------------