├── 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 | 
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 |
116 |
139 | {children}
140 |
141 |
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 |
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 |
498 | );
499 | }
500 |
--------------------------------------------------------------------------------