map3d 完整教學 — 從 OSM bounding box 到可下載 GLB 的 R3F 工作流
本文件是
cartesiancs/map3d的深度技術教學,配對的 gh-save 報告請見2026-05-25-github-cartesiancs-map3d.md。建議閱讀順序:先讀本檔 §1–§2 建立心智模型,再依需求跳讀 §3(架構)/ §4(工作流細節)/ §6(資安)。
1. 專案定位
1.1 一句話
map3d 是「Leaflet 拉框 + OSM Overpass + R3F 即時 extrude + GLTFExporter 一鍵匯出」的純前端工具,把 GIS 工作流壓縮到 ~1,400 行 TypeScript。
1.2 設計品味(為何值得讀)
| 面向 | 評價 | 原因 |
|---|---|---|
| 依賴選擇 | 🟢 好品味 | react-leaflet 處理 2D、@react-three/fiber (R3F) 處理 3D,職責清晰、不混用 |
| 資料流 | 🟢 好品味 | bbox → Overpass query → element[] → ExtrudeGeometry,單向、無 round-trip |
| state 結構 | 🟡 可接受 | 3 個 Zustand store(area / car / export)分得乾淨,但 action 命名 setAction(true) 過於通用 |
| error handling | 🔴 待改進 | catch (error) {} 空塊出現多處,第三方 API 失敗會默默吞掉 |
| TypeScript 嚴謹度 | 🔴 待改進 | : any 散落、onDone: (e) => void 缺型別 |
| 可讀性 | 🟢 好品味 | 單檔不超 510 行(最大檔 Space.tsx 508 行),章節分明 |
1.3 與同類工具的關係
1┌─────────────────────────────────────────────────────────┐
2│ 3D Map / Digital Twin 工具光譜 │
3├─────────────────────────────────────────────────────────┤
4│ CesiumJS / deck.gl │ 重量級、付費 tile、GIS 嚴謹 │
5│ Mapbox GL 3D Tiles │ 商用 API、需 access token │
6│ ★ map3d │ 輕量、純 OSM、可 fork、MIT │
7│ Three.js 純造 │ DIY、需手寫 OSM parser │
8│ OSMBuildings.js │ 老牌、API 較陳舊、無 R3F 整合 │
9└─────────────────────────────────────────────────────────┘
map3d 的甜蜜點:「想要學 R3F + 真實資料整合、又不想付費 API」 的開發者。
2. 安裝指南
2.1 系統需求
- Node.js ≥ 20.x(vite 6 + React 19 要求)
- npm(package-lock.json 已 commit,推薦用 npm 避免鎖檔不一致)
- 瀏覽器:Chrome 110+ / Firefox 110+(需支援 WebGL 2 + ES2022)
2.2 安裝 5 步驟
1# 1. clone
2git clone https://github.com/cartesiancs/map3d.git
3cd map3d
4
5# 2. install
6npm install
7
8# 3. dev server (vite, http://localhost:5173)
9npm run dev
10
11# 4. build production
12npm run build
13
14# 5. preview
15npm run preview
2.3 安裝流程圖
flowchart TD
A[git clone] --> B[cd map3d]
B --> C[npm install]
C --> D{選擇模式}
D -->|開發| E[npm run dev]
D -->|產品| F[npm run build]
E --> G[localhost:5173]
F --> H[dist.tar 部署到 CDN]
G --> I[Step 0: 拉框]
H --> J[線上 demo
map.fleet.im]
2.4 常見安裝問題
| 症狀 | 原因 | 解法 |
|---|---|---|
npm install 卡在 three 編譯 | Node < 18 | 升級到 Node 20 LTS |
| WebGL context lost | 顯卡記憶體不足 | 縮小 bbox 或關其他 GPU 應用 |
| Leaflet 樣式破版 | 忘記 import css | 已內建 import "leaflet/dist/leaflet.css" |
| 跨域取 Overpass fail | 地區 ISP 擋 overpass-api.de | 改用 mirror(lz4.overpass-api.de / z.overpass-api.de) |
3. 核心架構解析
3.1 元件樹(component tree)
flowchart TB
Main[main.tsx] --> App[App.tsx
狀態機 step=0/1/2]
App --> TopNav[TopNav]
App --> FMOdal[FullscreenModal]
App --> Modal[Modal]
App --> MapSelect[SelectMap.tsx
react-leaflet]
App --> Process[Processing.tsx
建築清單]
App --> Space[Space.tsx
R3F Canvas]
Space --> Building[Building component
extrudeGeometry]
Space --> Roads[Roads component
Line]
Space --> Car[Car.tsx
WASD 漫遊]
Space --> Export[Export component
GLTFExporter]
MapSelect --> RectSel[RectangleSelector]
App --> Stores{{Zustand stores}}
Stores --> areaStore
Stores --> carStore
Stores --> exportStore
3.2 三層責任分配
| 層 | 元件 | 責任 | 行數 |
|---|---|---|---|
| UI 流程 | App.tsx | 狀態機 (step) + 跨步驟 fetch Overpass | 305 |
| 2D 地圖 | SelectMap.tsx | Leaflet MapContainer + RectangleSelector (滑鼠/觸控) | 254 |
| 3D 渲染 | Space.tsx | R3F Canvas + Building/Roads/Export 子元件 | 508 |
| 互動 | Car.tsx | OrbitControls / PointerLock + WASD 物理 | 150 |
| 狀態 | state/*.ts | Zustand store(area/car/export) | 59 |
3.3 資料流(end-to-end)
flowchart LR
U[使用者拉框] -->|onChange
LatLngBounds| AS[areaStore
setCenter]
AS --> APP[App handleClickNextStep]
APP -->|POST query| OV[Overpass API
way&building / highway]
OV -->|elements.json| APP
APP -->|appendAreas| AS2[areaStore
areas]
AS2 --> SP[Space.tsx
areaData()]
SP --> BUILD[Building 元件
ExtrudeGeometry]
SP --> ROAD[Roads 元件
fetch highway
並渲染 Line]
BUILD --> CV[R3F Canvas]
ROAD --> CV
CV -->|userData.exportToGLB| EX[Export 元件
GLTFExporter]
EX -->|blob| DL[scene.glb 下載]
3.4 投影函式(latlng → 平面座標)
map3d 用一個極簡的「等距圓柱投影 (equirectangular)」把經緯度轉成 R3F 場景座標:
1const scale = 51000; // 經驗值,使距離視覺合理
2
3function project(lat: number, lng: number) {
4 const x = (lng - refLng) * scale * Math.cos((refLat * Math.PI) / 180);
5 const y = (lat - refLat) * scale;
6 return new THREE.Vector2(x, y);
7}
refLat / refLng 取 bbox 中心,所以「選的區域中心永遠在 scene 原點」。Math.cos(refLat) 修正經度隨緯度收斂的問題(赤道 1° lng ≈ 111km,極區趨近 0)。
限制:scale = 51000 是 hard-coded,跨大區塊(> 0.1°)誤差會累積、建築物變形。README 內建「太大警告 modal」就是這原因。
3.5 高度估計(building.tags → extrude depth)
1let heightValue = parseFloat(bld.tags.height || "");
2const heightLevels = parseFloat(bld.tags["building:levels"] || "");
3if (isNaN(heightValue)) heightValue = 10; // 預設 10m
4if (!isNaN(heightLevels)) heightValue = heightLevels * 2.2; // 每層 2.2m
優先序:tags.height > tags.building:levels × 2.2 > fallback 10m(issue #5 / #12 的「都是立方體」就是這 fallback 觸發)。
4. Helper 程式與關鍵檔細解
4.1 App.tsx — 三步驟狀態機
1const [steps, setSteps] = useState(["front", "processing"]); // 預設 2 步
2const [step, setStep] = useState(0);
注意:實際 UI 是「拉框 → Processing → 3D Canvas → Export」4 步,但
steps陣列只列 2 個(front/processing)。第 3 步「3D Canvas」是當step==2時<FullscreenModal>全部isOpen=false、<Space>顯露的 implicit 狀態。這是專案最 tricky 的設計選擇,初讀容易誤解。
關鍵函式:
| 函式 | 用途 |
|---|---|
handleDone(data) | Leaflet 拉框完成後呼叫,把 [NE, SW] 寫入 areaStore.center |
requestBuildings() | POST Overpass,回傳 element[],filter geometry 後 appendAreas |
checkIsBig() | (lat_a - lat_b) + (lng_a - lng_b) > 0.1 → 太大警告 |
checkFleetLogin() | 讀 cookie token,決定是否顯示 Fleet 整合 UI |
4.2 Space.tsx — R3F 場景 (508 行最大檔)
3 個子 component:
Building(行 13–313):mesh + extrudeGeometry + 互動 popup(HTML 元素疊在 3D mesh 上方)。Roads(行 315–368):useEffect 內 fetch Overpassway["highway"],把每條路畫成<Line>。Export(行 370–440):scene.traverse 過濾userData.exportToGLB === true的 mesh → GLTFExporter → blob → 下載或 upload。
設計巧思:Building / Roads / Car 三類 mesh 都帶
userData.exportToGLB = true,Export 時只挑這些。其他輔助 mesh(Sky / Environment / Light)不會被匯出。這比「全 scene 匯出」更乾淨。
4.3 Car.tsx — 第三人稱漫遊
- WASD 鍵盤控制(用
keys.current.w/s/a/dref 避免 re-render) - Pointer Lock 滑鼠視角(FPS 標準作法)
- 加速度 / 減速度 / 最大速度寫死(
accelerationRate=0.2 / maxSpeed=3.0 / decelerationRate=1.0) useFrame內每 frame 更新position+ 攝影機 lerp(0.1 平滑跟隨)
4.4 state/*.ts — Zustand 範本
1// areaStore.ts (28 行)
2export const useAreaStore = create((set) => ({
3 center: [{lat:0,lng:0},{lat:0,lng:0}],
4 areas: [],
5 setCenter: (data) => set(() => ({ center: data })),
6 appendAreas: (data) => set((state) => ({ areas: [...state.areas, ...data] })),
7}));
Zustand 用法極乾淨,沒有 immer / persist / devtools middleware,對初學者是好教材。
4.5 api/axios.ts — Fleet 整合(10 行)
1const instanceFleet = axios.create({
2 baseURL: `https://api.fleet.cartesiancs.com/api/`,
3 timeout: 7000,
4 headers: { "x-access-token": getCookie("token") },
5});
重要:這是 maintainer 自家服務
fleet.cartesiancs.com,fork 後可移除 / 自行替換。當前版本App.tsx行 273–284 的 Fleet 登入 UI 已被註解掉(commit935067f“remove request button”),表示作者正在「降低自家服務的耦合」。
5. 應用場景
5.1 直接使用(不改 code)
- 教學 demo:給學生看 R3F + OSM + GLTFExporter 完整鏈
- 快速建模:城市區塊原型,匯出 GLB → Blender 後製
- Drone 模擬:把區塊匯出當 Unity / Unreal 飛行訓練環境
5.2 二次開發場景
| 場景 | 改什麼 | 預估工時 |
|---|---|---|
| 加入屋頂類型(roof:shape) | Space.tsx Building component 內判斷 tags.roof:shape 切換 geometry | 4–8h |
| 改用 Mapbox 3D tiles 取代 extrude | 換 <Building> 為 Mapbox SDK,保留 export 邏輯 | 16h |
| 加入高度紋理(texture) | meshStandardMaterial 改為 meshPhysicalMaterial + textureLoader | 4h |
| 大區塊分塊 fetch | App.tsx requestBuildings 改為 grid 切割 | 8h |
| 移除 Fleet 整合(純 self-host) | 刪 api/axios.ts + App.tsx 行 22–84 + 273–284 | 1h |
| 改投影為 Web Mercator | Space.tsx project() 重寫 | 4h(含驗證) |
5.3 整合進其他工作流
- bioinformatics + 城市流行病:把人口稠密區塊 export 成 GLB → 餵 Unreal Engine 視覺化 SARS / 流感擴散
- 建築 AI 訓練資料:批次 fetch + export 100 個城市 → image rendering → diffusion model 訓練
- AR / WebXR:把 GLB 載進
<model-viewer>或<a-scene>顯示城市縮影
6. 資安掃描報告
6.1 評級
🟢 整體低風險(適合學習 / fork / 自架)
map3d 是純前端 SPA、無後端、無 secret。掃描全 codebase 後沒發現任何高風險 pattern(eval / exec / dangerouslySetInnerHTML / 硬編 secret 都不存在)。
6.2 詳細掃描結果
| 項目 | 狀態 | 細節 |
|---|---|---|
eval / Function() / exec | 🟢 | 全 codebase 0 次出現 |
dangerouslySetInnerHTML | 🟢 | 0 次出現;HTML overlay 用 <Html> (drei) 安全注入 |
| 硬編 secret / API key | 🟢 | 0 次出現;只有公開 OSM / Fleet API URL |
shell / subprocess | 🟢 | N/A(純前端) |
| 第三方 CDN 無 SRI hash | 🟡 | src/index.css:1 從 jsdelivr 拉 pretendard 字型 → 建議加 integrity= |
document.cookie 直接操作 | 🟡 | utils/cookie.ts 讀寫 cookie,無 HttpOnly / SameSite 屬性(仍由瀏覽器設) |
React key={index} | 🟡 | Space.tsx:493 等多處用 index 當 key,動態增減時可能 re-mount 異常 |
catch (error) {} 空 catch | 🟡 | App.tsx:105 等多處吞錯誤,debug 困難 |
as any / 隱式 any | 🟡 | App.tsx:75 getSpace: any、多處 callback 缺型別 |
| Overpass API 無 rate-limit 保護 | 🟡 | 高頻拉框時可能被 ban;建議加 debounce |
| GLB 上傳 (Fleet) 缺 CSRF | 🟡 | 但 Fleet UI 當前已註解掉,預設不會觸發 |
6.3 部署建議
- 靜態託管即可(GitHub Pages / Cloudflare Pages / Vercel),無需後端伺服器
- Content Security Policy (CSP):
1default-src 'self'; 2img-src 'self' https://*.tile.openstreetmap.org data:; 3connect-src 'self' https://overpass-api.de https://api.fleet.cartesiancs.com; 4style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; 5font-src https://cdn.jsdelivr.net; - 若拿掉 Fleet:上面 CSP 可移除
api.fleet.cartesiancs.com
6.4 隱私
- 使用者選的 bbox 會送到
overpass-api.de(公開服務、無 PII) - 本地 cookie
token只在登入 Fleet 時建立,未登入 = 完全離線 - 無 analytics / tracking
7. FAQ
Q1:為什麼下載的 GLB 是一個立方體? A:OSM 該區域的 building 沒有 geometry 或 height tags,map3d fallback 成預設立方體(10m × footprint)。換到歐美城市試(如 New York / Berlin)會明顯改善。
Q2:可以離線使用嗎?
A:不行 — Overpass API 是 live query。離線需要:(1) 自架 Overpass instance(Docker wiktorn/overpass-api),(2) 改 fetch URL 指向 localhost。
Q3:可以選圓形區域、多邊形區域嗎?
A:當前 UI 只支援矩形 bbox。要改成 polygon 需重寫 SelectMap.tsx 的 RectangleSelector 元件、改 Overpass query 為 (poly:"...") 語法。
Q4:地球曲率影響大嗎? A:在 < 1km × 1km 範圍可忽略;> 10km × 10km 建築物會「歪斜」。map3d 內建警告 0.1° ≈ 11km。
Q5:能商用嗎? A:MIT License — 可以。需保留版權聲明。OSM 資料受 ODbL(Open Database License),若重新發佈資料需附 attribution;只匯出 GLB 自用無此限制。
Q6:為什麼 react-leaflet + R3F 兩個 canvas 不衝突?
A:Leaflet 用 DOM + SVG/Canvas2D,R3F 用 WebGL;兩者是不同的 rendering context。同畫面可並存。
8. 進階技巧
8.1 加速大 bbox(用 Overpass API 的 bbox-around)
1// 原寫法:way["building"](south,west,north,east)
2// 加速:先 query node[building=*],再 expand to way
3const query = `[out:json][timeout:60];
4 way["building"](${south},${west},${north},${east});
5 out body geom 5000;`; // 限制最多 5000 棟,避免 timeout
8.2 加 building footprint cache
1// Zustand persist middleware
2import { persist } from "zustand/middleware";
3
4export const useAreaStore = create(persist(
5 (set) => ({ /* ... */ }),
6 { name: "map3d-area-cache" }
7));
8.3 改用 meshPhysicalMaterial 加玻璃感
1<meshPhysicalMaterial
2 color="#9da0a3"
3 metalness={0.3}
4 roughness={0.4}
5 clearcoat={0.5}
6 transmission={0.1} // 透光感
7/>
8.4 加陰影(shadow)
1<Canvas shadows camera={{ fov: 90 }}>
2 <directionalLight position={[10, 20, 5]} intensity={1} castShadow />
3 <mesh castShadow receiveShadow>{/* ... */}</mesh>
4</Canvas>
9. 整合進其他工作流
9.1 配合 docling(layer 8)
把 OSM building list 匯出成 markdown 表格 → docling 解析 → 餵給 paper-qa-lite 做「城市 X 的建築統計」問答。
9.2 配合 graphify(layer 4)
對 map3d 跑 graphify init . 可產出元件呼叫圖;對 fork 後做架構分析很有用。
9.3 配合 paper-tutorial(layer 15)
若閱讀「3D city modeling」相關 paper,可以把 paper 提到的 building footprint 演算法用 map3d 跑 visual demo,做成教學 HTML。
10. 重點摘要 Checklist
- 核心命題:純前端 + OSM + R3F + GLTFExporter,1,400 行做完 city → 3D → GLB
- 3 個進入點:
App.tsx(流程)/Space.tsx(3D)/SelectMap.tsx(2D) - 資料源:OSM Overpass(公開、無 key、有 rate limit)
- 狀態管理:Zustand 三個 store(area / car / export),無 middleware
- 匯出機制:
userData.exportToGLB = trueflag + GLTFExporter - 限制 1:bbox < 0.1°(~11km)
- 限制 2:缺 height tag 的 building → fallback 10m 立方體
- 限制 3:必須線上(Overpass live)
- 資安:🟢 低;建議加 CSP + SRI + Overpass debounce
- 授權:MIT;可商用、可 fork、可改造
11. 進一步閱讀
| 主題 | 資源 |
|---|---|
| R3F 入門 | https://docs.pmnd.rs/react-three-fiber/getting-started/introduction |
| drei 元件庫 | https://github.com/pmndrs/drei |
| Three.js GLTFExporter | https://threejs.org/docs/#examples/en/exporters/GLTFExporter |
| OSM Overpass QL | https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL |
| Overpass turbo(線上 IDE) | https://overpass-turbo.eu/ |
| OSM building tag | https://wiki.openstreetmap.org/wiki/Key:building |
| Leaflet 官方教學 | https://leafletjs.com/examples.html |
| Zustand 範例 | https://github.com/pmndrs/zustand |
| glTF 規格 | https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html |
| map3d 線上 demo | https://map.fleet.im/ |
文件版本:2026-05-25,自動生成 by gh-tutorial-qd (Layer 12)。配對 gh-save 報告:2026-05-25-github-cartesiancs-map3d.md。
Comments