在瀏覽器裡讀 PNG、PDF、DXF、DWG,長出 3D 格局
第 05 篇寫的是一份手工整理好的格局,怎麼在 Three.js 裡變成 3D。這一篇處理讀者自己手上的平面圖:一張截圖、一份 PDF,或建築師給的 DXF、DWG。
我們的 3D 格局實驗室接受這四種檔案。解析、長牆、算比例,全部在瀏覽器裡完成,檔案不上傳。
這篇照實寫三件事:每種格式走哪條路、牆線怎麼抓出來、哪些事我們做不到。最後寫踩過的坑,以及一次核對時發現、當天修掉的三個問題。
先講結論
牆:能抓,有條件。圖片和 PDF 只抓水平、垂直的牆。DXF、DWG 靠圖層名稱,加上平行雙線配對。
門窗:不能。四種格式都不會自動辨識門窗,也不能手動放門窗。
比例:DXF、DWG 檔案寫明 mm、cm 或 m 時,照檔案的單位;否則依尺寸判斷。圖片和 PDF 建議你點一道已知長度的牆來定比例。
一、為什麼全部在瀏覽器裡做
平面圖是很私人的檔案。從圖上看得出你家幾房、門開在哪裡。
所以這個工具的做法是:解析在你的瀏覽器裡完成,檔案不送到伺服器。工具頁上也寫著「檔案不上傳」。
這個做法有兩個代價:
解析器要下載到你的裝置。PDF 和 DWG 的解析器只在你選了那種檔案時才下載。DWG 的解析器特別大,後面會講數字。
運算由你的裝置負責。裝置越慢,等得越久。
還有一個前提:3D 場景要建得起來。3D 場景靠 WebGL 畫。WebGL 是瀏覽器用顯示卡畫 3D 的介面。瀏覽器不能用 WebGL 時,上傳的檔案不會被解析,頁面會改顯示一張提示卡。
二、四種格式,四條路
先定義幾個詞:
CAD 檔:建築師用繪圖軟體畫的圖。檔案裡存的是一條條線和文字,不是像素。
DXF、DWG:兩種常見的 CAD 檔格式。DXF 有文字和二進位兩種寫法。
圖層:CAD 檔把不同用途的線分組放,例如牆一層、尺寸標註一層、家具一層。每一組叫一個圖層。
主執行緒:網頁負責畫面和互動的那條執行緒。在這裡跑太久,畫面會卡。
Web Worker:在背景另開的執行緒。放進去的工作不佔用主執行緒。
WASM(WebAssembly):瀏覽器能跑的編譯後程式。用它可以把 C 寫的函式庫搬進網頁。
| 格式 | 在哪裡跑 | 怎麼讀 | 讀進來的東西 | 比例從哪來 |
|---|---|---|---|---|
| PNG/JPG/WebP | 主執行緒 | 長邊縮到 960 px,掃描深色線 | 水平、垂直的牆 | 你點一道牆,輸入長度 |
| 用 pdf.js 轉成圖片(pdf.js 有自己的 worker);之後同 PNG | 只取第 1 頁,算繪成長邊約 2000 px 的圖,再走 PNG 那條路 | 同 PNG | 同 PNG | |
| DXF | 主執行緒,我們自己寫的 TypeScript | 讀文字格式 DXF 的單位欄位和線條 | 直線、輕量多段線、文字 | 檔案單位;沒寫就看尺寸 |
| DWG | Web Worker+WASM(LibreDWG)讀檔;長牆回到主執行緒 | WASM 讀檔,轉成純資料傳回主頁面 | 直線、多段線(含 2D、3D 多段線)、文字 | 同 DXF |
幾個細節:
PDF 一律先變成圖片。PDF 裡的線就算是向量,我們也不抽向量。多頁的 PDF 只讀第 1 頁,畫面會提示「已讀取第 1 頁」。
DXF 只支援文字格式(ASCII)。程式把檔案當成「一行代碼、一行值」來讀,所以二進位 DXF 應該讀不到。這點是從程式推論的,我們沒實測。
DXF 和 DWG 共用同一段長牆程式。兩者讀進來之後,牆線規則完全一樣。DWG 換單位或圖層時,直接重算,不重跑 WASM。
文字變成室名。CAD 檔裡的文字會變成房間名稱,條件是不超過 14 字、不是純數字,最多 16 個。圖片沒有文字辨識,一律標一個「客廳」。
長完牆會自動放幾件家具。圖片放沙發、茶几;CAD 檔放沙發、茶几、床、桌子。
三、牆線怎麼抓出來
圖片和 PDF:掃深色段,再分群

照程式的順序:
1. 二值化:把每個像素分成「深」和「淺」兩類。亮度不高於門檻的算深色,透明的像素不算。門檻預設 145,可以用「牆線靈敏度」滑桿在 60~210 之間調。
2. 抽樣掃描:每隔幾列掃水平方向,每隔幾行掃垂直方向。960×720 的圖,間隔是 4 px。
3. 留下夠長的深色段:連續的深色像素要夠長,才算牆的一部分。門檻約等於 0.45 m,最少 12 px。
4. 分群:位置相近、又互相重疊的深色段,併成同一群。
5. 輸出:每一群變成一道純水平或純垂直的牆。牆厚依群的寬度加上抽樣間距換算,限制在 0.15~0.32 m。最多輸出 120 段。
這套做法很陽春。它不會順著線的方向追蹤,沒有更進階的找直線演算法(例如霍夫轉換),也沒有文字辨識和透視校正。
它的後果很直接。一條明顯的斜線(例如 45°)在每一列上只留下很短的深色段,過不了長度門檻,所以會漏掉。只歪幾度的牆不一樣,後面「掃描歪斜」會講。
我們用一張合成圖實測過。條件:960×720 px,在 Node 直接呼叫向量化函式,Apple M3 Pro。結果是 7 段牆,向量化中位數 3.8 ms(10 次):
外框 4 段都抓到。
內牆分成 2 段,中間的門口留成 0.75 m 的開口。
1/4 圓的門弧沒被抓到。
3 條 1 px 的窗線全部漏掉。它們剛好落在 4 px 的抽樣列之間。
45° 的斜牆漏掉。
一塊 60×14 px 的黑色字塊,變成一道 0.75 m 的牆。
DXF 和 DWG:圖層名稱加平行雙線
CAD 圖通常把牆畫成兩條平行線,兩線的間距就是牆厚。我們不讀線寬,牆的判斷靠兩件事。
第一件:圖層名稱。
名稱含 wall、牆、隔間、結構、外牆這類字的,算牆。CAD 的預設圖層「0」也算牆。
名稱含 text、文字、室名、room 這類字的,算文字圖層,預設勾選。
名稱含 dim、標註、hatch、家具、door、window、門、窗這類字的,預設不勾。
牆和文字的規則優先。名稱同時符合兩邊時,預設勾選。
名稱看不出用途的圖層,預設也會勾選。
勾選的圖層加起來沒有任何線時,改用全部的線。
你可以在「CAD 單位與圖層過濾」裡逐一開關圖層。
第二件:幾何配對。
1. 短於 0.12 m 的線先丟掉。
2. 其餘每條線先分類。兩端高低差小於 0.04 m 的算水平線,左右差小於 0.04 m 的算垂直線,其他算斜線。
3. 同方向的兩條線,間距 0.07~0.38 m、重疊至少 0.25 m,就配成一道牆。
4. 有多個候選時,程式偏好重疊較長、或間距較接近 0.15 m 的那條。這兩個條件符合任一個就換,所以結果和掃描順序有關。
5. 配成的牆,牆厚等於兩線間距,限制在 0.09~0.32 m。
6. 配不到對的線,自己變成一道 0.15 m 厚的牆。
7. 斜線不配對,直接變成一道 0.15 m 厚的單線牆。
下面這些東西會消失,程式不讀:
圓弧(ARC)、圓(CIRCLE)。
圖塊參照(INSERT)。圖塊參照就是 CAD 裡重複使用的符號,例如門、馬桶。
多線(MLINE)、平滑曲線(SPLINE)、剖面填色(HATCH)、尺寸標註(DIMENSION)。
DXF 另外不讀舊式的 POLYLINE+VERTEX。
下面這種會留下來,但變形:
多段線裡的弧段,會被拉成直線。
配對是逐條找最佳對象,計算量隨線數平方成長。實測一萬條直線的 DXF(561 KB),在 Node 上長出 5,000 段牆,花 90 ms(Apple M3 Pro,3 次中位數)。
四、做不到的事
門窗
四種格式都不會自動辨識門窗,也不能手動放門窗。
牆線斷開的地方,3D 留成全高開口。3D 裡沒有門楣,也沒有窗台。從檔案讀出的牆一律高 1.98 m。程式另外蓋的那圈外框牆高 2.72 m,朝鏡頭的兩面壓低到 1.18 m。
外牆上的門洞會被封住。3D 會沿著外框再蓋一圈外牆。外框是所有牆的外接矩形,所以外牆上的門洞會被這圈牆擋住。
能放的物件只有 13 種家具。清單裡沒有門窗。3D 頁也不能編輯牆。
各格式的實際行為:
圖片和 PDF:門弧太細、太短,會被長度門檻濾掉。窗線比較細,可能漏掉、併進牆,或變成牆。結果看線多粗、多深,以及抽樣列有沒有剛好掃到。
DXF 和 DWG:門弧(ARC)和門窗圖塊(INSERT)都不讀。圖層名稱含 door、window、門、窗的,預設不勾。這些圖層不勾時,上面的門扇線就不會變成牆。

這只是用圖層名稱排除線條,不是辨識門窗。以下情況,門窗的線仍會變成牆:
圖層名稱不在清單裡。例如 AIA 圖層標準裡的窗圖層 A-GLAZ。
名稱同時符合牆或文字的規則,例如「外牆門窗」。
勾選的圖層加起來沒有任何線,程式改用全部的線。
斜牆、弧牆
| 項目 | 圖片和 PDF | DXF 和 DWG |
|---|---|---|
| 斜牆 | 漏掉 | 保留成 0.15 m 厚的單線牆,不做雙線配對 |
| 弧牆 | 漏掉 | 圓弧、圓丟掉;多段線的弧段拉成直線 |
多層
PDF 只讀第 1 頁。
CAD 檔的所有實體攤在同一個平面。沒有樓層的概念,也不處理配置頁(layout)。
手機拍照的透視變形
未實測。我們手上沒有樣本,程式也沒有透視校正。
依程式推論,輕微變形會和掃描歪斜一樣被「拉直」。變形大時,線可能斷成短段而被濾掉。
掃描歪斜倒是實測過。我們另做一張合成圖(外框加一道有門口的內牆),旋轉 0°~5° 再解析,每個角度都是 6 段牆,總長幾乎不變。但輸出的牆一律強制水平或垂直。用幾何算(沒有實測),10 m 的牆歪 5° 時,兩端各偏約 0.44 m。真實的掃描圖(有雜點、陰影)未實測。
其他限制
外框一律是矩形。L 形的格局會被補成方盒。CAD 檔的外框最小 5 m × 4.4 m。
深色的尺寸線可能變成牆。淺灰色的會被門檻濾掉。
有些 AutoCAD 2000 格式的 DWG 讀不到。下一節會講原因。
五、比例尺與單位
DXF 和 DWG:先讀檔案單位
單位選「auto」時,工具先讀檔案標頭的 $INSUNITS。$INSUNITS 是 CAD 檔記錄繪圖單位的欄位。
值是 mm、cm、m 其中之一,就照它換算。
其他值(例如英寸、未指定),或檔案沒寫,就依尺寸判斷。工具取所有勾選圖層的線的最大跨度(寬和深取較大的),超過 1800 算 mm,超過 45 算 cm,其他算 m。
你可以手動切換 auto、mm、cm、m。手動選的單位優先。
依尺寸判斷會猜錯。一間 1.5 m × 1.2 m 的小空間,用 mm 畫,最大跨度是 1500。1500 沒超過 1800,所以會被判成 cm,變成 15 m × 12 m。檔案的 $INSUNITS 寫的是 mm 時,就不會猜錯。

圖片和 PDF:點一道你知道長度的牆
圖片沒有單位。牆長出來後,工具會自動打開比例尺對話框:
1. 點一道已知長度的牆的一端。
2. 再點另一端。兩個端點都能拖,拖的時候手指上方會出現 3 倍放大鏡。
3. 輸入長度。單位可以選 cm 或 m,也可以直接打「3.6m」「360公分」,全形數字也接受。
4. 按「套用比例尺」。工具用新比例重新抓一次牆線。
換算方式:先用「真實長度 ÷ 兩點之間的像素數」得到每個像素代表幾公尺,再乘上圖片的像素寬,得到整張圖的真實寬度。
舉例:你點的牆真實長 3.6 m,在圖上佔 300 px。圖片寬 960 px,整張圖就是 3.6 ÷ 300 × 960 ≈ 11.5 m 寬。
你輸入的長度要在 30 cm~100 m 之間。兩點距離小於 10 px 時,不給比例。
你也可以改填整張平面圖的真實寬度。選「稍後再說」的話,沿用預設的「整張圖寬 12.0 m」。
六、踩過的坑
LibreDWG:DWG 的解析器
我們用 LibreDWG 讀 DWG。LibreDWG 是 GNU 用 C 寫的 DWG 函式庫。這裡用的是 mlightcad 編成 WASM 的 @mlightcad/libredwg-web 0.8.0,現在透過同一位作者的 @mlightcad/libredwg-converter 3.15.11 附的 Worker 載入。
1. 第一版在執行時從 CDN 載入,沒有列進專案的套件清單,也沒測。當時手上沒有樣本,DWG 讀取完全沒驗過。我們後來整段拿掉。
2. 改成自架、鎖定版本。解析器跟網站一起部署,不走 CDN。
3. 0.8.0 讀不了 AutoCAD 2000 格式的範例檔。錯誤是 memory access out of bounds,發生在 LibreDWG 的 C 解碼器裡,我們的轉換程式還沒開始跑。我們沒有改套件。頁面的常見問題照實寫「有些 AutoCAD 2000 格式的 DWG 可能讀不到」。
4. WASM 崩潰後不能再用,初始記憶體又有 1 GB。所以每次上傳都開一個新的 Worker,解析完就關掉。
5. 套件沒有開放 wasm 檔的路徑。第一版我們在打包設定裡加別名指過去。改用上游的 Worker 原檔之後(見下一節),這個別名就拿掉了。
6. npm 套件沒附授權全文。我們另外放一份 GPL-3.0 全文在網站上。
解析器多大?兩個數字都要看。
檔案大小:10.8 MB。這是 .wasm 檔本身的大小。
實際傳輸:約 1.8 MB。正式站用 brotli 壓縮傳送。
伺服器回應裡的快取設定是一年、不可變(immutable)。
我們在正式站量過「從選檔到畫面顯示解析結果」的時間。條件:2026-10-18,Playwright 無頭(headless)Chromium,每輪都是冷快取。樣本是 LibreDWG 官方測試檔(149 KB),它不是建築平面圖。
| 情境 | 第 1 次選檔(含下載解析器) | 同頁第 2 次 |
|---|---|---|
| 桌機,不限速(3 次) | 中位數約 0.7 秒 | 約 0.15 秒 |
| 桌機,限速 20 Mbps、延遲 40 ms(3 次) | 中位數約 1.3 秒 | 約 0.15~0.19 秒 |
| 模擬手機,4G 1.6 Mbps、延遲 150 ms、CPU 降速 4 倍(1 次) | 約 9.9 秒 | 約 0.24 秒 |
第 2 次變快,我們推斷是解析器從瀏覽器快取讀出來。但我們沒有直接讀到「從快取讀取」的標記,所以這點是推論。Safari、Firefox 未實測。
GPL-3.0:我們怎麼隔離
LibreDWG 用 GPL-3.0 授權。我們的隔離做法是:
Worker 和 .wasm 直接用上游作者發布的原檔。我們部署 @mlightcad/libredwg-converter 3.15.11 附的 libredwg-parser-worker.js 和 libredwg-web.wasm,一個位元組都不改。部署後的檔案和套件原檔的 SHA-256 雜湊值相同,任何人都可以下載比對。
我們的程式不在 Worker 裡。主頁面只用 postMessage 送出檔案內容、收回解析結果,不 import 這個套件的任何 JavaScript。訊息的型別是我們自己寫的。
主程式的打包檔裡沒有 LibreDWG 的程式碼,只有 Worker 的網址字串。
只在上傳 DWG 時才下載。只開頁面,或上傳 DXF、PNG,都不會下載這兩個檔。
頁面上另有一行「DWG 讀取使用 LibreDWG(GPL-3.0)」,連到授權說明。說明裡寫了版本、來源、commit,以及我們沒有修改套件。
第一版不是這樣。當時是我們自己寫一個 Worker,在裡面呼叫 LibreDWG。寫這篇時,我們重新讀了自由軟體基金會的 GPL 常見問答,改成現在的做法。上游作者自己的產品也用同一種架構,把 GPL 解析器和主程式分開。改完後,6 個 DWG 樣本的解析結果,和改之前逐位元相同。
這段只描述技術做法。
WebGL 失敗時,整頁變白
舊版在瀏覽器不能用 WebGL 時,整個頁面會消失:沒有標題,沒有文字。原因是建立 3D 場景時拋出錯誤,整個 React 畫面被卸載。
修法:
1. 用 try/catch 包住建立場景的那一步,失敗時記下狀態。
2. 標題區顯示提示卡,連到不需要 3D 的工具。
3. 依賴 3D 的按鈕一次停用。
4. 上傳的檔案不解析,也不下載 DWG 解析器。
這裡還有一個小坑。提示卡第一版放在 3D 畫布那一層,被首屏的對照圖蓋住,點不到。原因是那一層用了 position: fixed,在 CSS 裡自成一個獨立的上下疊放順序,提示卡怎麼調都蓋不過對照圖。我們把提示卡移到標題區,跟著一般內容排版,才解決。
還沒處理的:WebGL 在使用中途失效。
首屏影片和 LCP
LCP(Largest Contentful Paint)是 Google 的載入速度指標,量首屏最大的元素多久畫出來。
工具頁首屏有一段示範影片。第一版在頁面載入後自動播放。正式站量到 LCP 變慢,我們先撤回。在本機,我們重現不出變慢。變慢的原因到現在還沒證實。
第二版改成點擊才播放:
預覽圖用 <img>,不用 <video poster>。用 <video poster> 的話,LCP 元素會被算成影片元素。
點擊屬於使用者輸入。使用者輸入之後,瀏覽器不再記錄 LCP。所以點擊後才放上去的 <video>,不會變成 LCP。
第二版上線後,我們發現一個 bug:滑鼠點不到,鍵盤可以。外層容器設了 pointer-events: none,影片框繼承了這個設定,滑鼠點擊就穿過影片框,落到底下的 3D 畫布。修法是讓影片框自己設回 pointer-events: auto。
驗收腳本為什麼沒抓到?因為它只用鍵盤按 Enter 觸發播放。鍵盤操作不受 pointer-events 影響,所以檢查通過。我們補上真正的滑鼠點擊檢查。把那行 CSS 還原,新檢查就會失敗。
量測方法也有坑。我們量這個頁面的 LCP 時,無頭瀏覽器要開 GPU(在 macOS 上用 Metal)。改用軟體算繪(SwiftShader)時,LCP 晚了好幾秒,那一輪數字只好作廢。
其他小坑
工具自己匯出的 DXF,讀回來牆會變多。匯出時每道牆寫成中心線、兩條牆面線和兩端封口。讀回來時,中心線和封口線各自被當成牆。這在 3D 畫面上的影響有多大,我們還沒確認。
Playwright 的請求攔截會停用 HTTP 快取。我們第一輪因此把「第二次上傳」量錯,整輪作廢。
PDF 先算繪成約 2000 px,再縮到 960 px。有一部分運算是白花的。這是從程式推論的,影響多大沒量。
pdf.js 沒設定字型資料的路徑。沒內嵌字型的文字可能畫不出來。牆線通常不受影響。
七、核對時發現、當天修掉的三件事
寫這篇之前,我們把頁面上的說明一句一句拿去對程式,再實際上傳測試。對出三個問題,當天修掉。
1. DXF 沒讀檔案單位
頁面寫「DWG、DXF 會用檔案裡的單位」,但程式只有 DWG 會讀。DXF 一律靠尺寸猜,所以第五節那間 1.5 m × 1.2 m 的小空間,會變成 15 m × 12 m。
修法:DXF 也讀標頭的 $INSUNITS,規則和 DWG 一樣。修正後,同一個檔案判成 mm,尺寸是 1.5 m × 1.2 m。
2. 底圖和牆對不上
上傳圖片後,原圖會半透明地貼在地板上當底圖。修正前,原圖的黑色外牆縮在 3D 外牆裡面。

原因:程式把整張圖(含白邊)拉伸到「偵測到的牆」的外接矩形上。原圖有白邊時,底圖就被壓小了。
修法:解析時以圖片中心為原點,所以整張圖的範圍就是「像素數 × 每像素公尺數」。底圖照這個範圍貼。底圖和牆用同一個比例,所以換比例尺時一起縮放。

這個 bug 只影響底圖顯示。牆的位置、比例尺、量尺寸都不受影響。
還沒修的:從「PNG 轉 CAD (DXF) 獨立工作台」送到 3D 頁的格局,底圖仍是舊的拉伸做法。
3. 門窗圖層被當成牆
修正前,門窗圖層預設會被勾選。門扇線長成一道牆,擋在門洞前面。
修法:把 door、window、門、窗加進預設不勾的清單。修正後,同一張測試圖從 8 段牆變成 5 段,下牆留出 0.9 m 的開口。你仍然可以手動勾回門窗圖層。
再說一次:這是排除圖層,不是辨識門窗。
怎麼驗的
新增 8 項單元測試,當時(2026-10-18)全部 94 項通過。其中 3 項在舊程式上會失敗。
在本機用無頭 Chromium 上傳 PNG、PDF、DXF、DWG,加上 2 個新的 DXF 樣本,頁面錯誤都是 0。
底圖對齊是看截圖確認的:開「透視牆體」讓牆半透明,原圖黑線和 3D 牆底的位置重疊。
八、下一步(考慮中,不承諾)
上傳的格局不再蓋外框牆,讓外牆上的門洞留下來。
把 A-GLAZ 這類常見的窗圖層名稱加進預設不勾的清單。
讓「PNG 轉 CAD (DXF) 獨立工作台」送來的格局,底圖也照整張圖的範圍貼。
處理 WebGL 在使用中途失效的情況。
這些都還在評估,沒有時程。
開啟 3D 格局實驗室,上傳你的平面圖上一篇:把平面圖變成可以走進去的 3D