Three.jsWeb 3DInteriorFloor PlanInteractiveFrontend

把平面圖變成可以走進去的 3D:Three.js 室內格局的完整做法

···閱讀約 13 分鐘

這篇解決的問題:手上只有一張 2D 平面圖,想在瀏覽器裡看成能轉視角、拖家具的 3D。每一步都附程式片段。「怎麼啟動」和「座標單位」兩節,讓你在本機跑起來,並把圖上尺寸換成場景座標。

看房、裝修、租屋,你拿到的都是同一種東西:一張 2D 平面圖。

它是空間最通用的語言,但坦白說,大多數人讀不懂。牆的厚度、房間的比例、從玄關走到主臥的動線——這些資訊都在圖上,可是要在腦中把它立體化,需要的空間想像力並不是人人都有。

所以我做了一個實驗:把一張真實的三房格局,變成瀏覽器裡可以轉視角、切換配置、拖拉家具的 3D 空間。不裝 App、不用帳號,開網頁就能走進去。

做完的心得是:這件事比想像中便宜非常多。不需要室內設計軟體,不需要 Blender,整個場景的來源是一份不到三百行的資料檔。

怎麼啟動:線上直接開,或在本機跑最小範例

最快的方式是直接開 3D 格局實驗室。不用安裝,也不用帳號,桌機和手機瀏覽器都能開。

開啟 3D 格局實驗室

網站原始碼庫沒有公開,所以本機版改提供一個最小範例。它只做兩件事:把平面圖資料變成 3D 地板與牆,以及示範 1 單位=1 公尺。Three.js 已放在包內,不用安裝套件,也不用連網。

下載平面圖 → 3D 最小範例(ZIP)
bash
unzip threejs-floor-plan-minimal.zip
cd threejs-floor-plan-minimal
python3 -m http.server 8000

接著用瀏覽器開 http://localhost:8000。也可以改用 npx serve .,以終端機顯示的網址為準。直接雙擊 index.html 不行,瀏覽器不讓 file:// 載入 ES module。

成功的樣子:畫面上有 L 形地板、外牆和兩道室內牆,面向鏡頭的兩側是矮牆。地上格線每格 1 公尺,藍色方塊邊長 1 公尺,剛好佔一格。滑鼠移到地板上,左上角會顯示資料座標 [x, z]。

格局資料在 layout.js,換成自己的格局就改這裡。範例沒有家具、拖拉、上傳圖片和量尺寸;這些功能請在線上實驗室試。

第一個決定:平面圖的資料長什麼樣

拆到最後,一張平面圖其實只有五種東西:

typescript
export type LayoutPreset = {
  readonly floorOutline: readonly Point2[];   // 外牆輪廓(多邊形頂點)
  readonly walls: readonly WallSegment[];      // 室內牆(線段 + 厚度)
  readonly roomLabels: readonly RoomLabel[];   // 房間名稱與位置
  readonly finishes: readonly RoomFinish[];    // 各房間的地坪材質
  readonly furniture: readonly {               // 家具的初始擺位
    readonly type: string;
    readonly x: number; readonly z: number;
    readonly rotation: number;
  }[];
};

把原始圖紙上的尺寸換算成公尺、原點移到格局中心,一個一個頂點敲進來。這一步最枯燥,但它換到一個重要的性質:資料和渲染徹底分離。同一份型別可以裝任何格局——實驗裡的 A/B 兩種配置切換,就是換一份 preset 而已。

這裡有一個刻意的設計決定值得說:資料檔裡只保留匿名化、正規化之後的幾何。原始圖紙的圖層名稱、標註、座標系統,全部不進版本庫。這是一張真實住宅的格局——幾何本身沒有隱私問題,但「這是哪裡、原圖從哪來」有。資料模型在設計時就把這條線畫清楚,比事後清理容易得多。

座標單位:1 單位=1 公尺,Y 軸朝上

整個場景用公尺當單位,Three.js 的 1 單位就是 1 公尺。外牆高 2.72、室內牆高 1.98、外牆厚 0.2,單位都是公尺。

高度軸是 Y,朝上為正。地板頂面在 y = 0.12,也就是 FLOOR_Y 常數。家具和拖拉用的水平面都放在這個高度。

格局資料只存平面座標 [x, z],原點在格局中心。x 往右為正;z 照平面圖的讀法,往圖面上方為正。

資料交給 Three.js 時,z 會變號:資料的 [x, z] 放到場景的 (x, y, -z),家具的旋轉角也一起變號。所以切到俯視角時,畫面上方就是平面圖上方。

typescript
const FLOOR_Y = 0.12;               // 地板頂面高度(公尺)
const WALL_HEIGHT = 2.72;           // 外牆高
const INTERIOR_WALL_HEIGHT = 1.98;  // 室內牆高
const toWorldPoint = ([x, z]: Point2): Point2 => [x, -z];

// 家具:資料的 z 變號後放上地板
item.position.set(preset.x, FLOOR_Y, -preset.z);
item.rotation.y = -preset.rotation;

平面圖像素怎麼換算:先決定整張圖代表幾公尺寬。每像素公尺數 = 圖寬公尺 ÷ 圖寬像素。實驗室上傳圖片時,先假設整張圖寬 12 公尺,再用「設定比例尺」改正。

設定比例尺有兩種方式。主要方式是點一道已知長度的牆的兩端,工具把「牆長 ÷ 兩點像素距離 × 圖寬像素」當成新的圖寬。不知道牆長時,可以直接填整張圖的真實寬度。

像素轉座標時,原點放在圖片正中央。圖片的 y 往下為正,所以 z 要反過來。x = (像素 x − 圖寬 ÷ 2) × 每像素公尺;z = (圖高 ÷ 2 − 像素 y) × 每像素公尺。結果取到小數點後兩位,也就是 1 公分。

例:一張 1200 × 800 像素的圖,圖寬設 12 公尺,每像素就是 0.01 公尺。圖上 (900, 300) 這一點,換成資料座標是 x = 3.00、z = 1.00。

想自己確認單位,可在實驗室按「量尺寸」,點一道牆的兩端。讀數會同時顯示公分和公尺。

地板:一個多邊形,擠出來

有了輪廓頂點,地板是整個場景最簡單的部分。Three.js 的 Shape 吃一串 2D 頂點,ExtrudeGeometry 把它往下擠出厚度:

typescript
const shape = new THREE.Shape();
// 沿著 floorOutline 頂點 moveTo / lineTo 畫出輪廓...
const geometry = new THREE.ExtrudeGeometry(shape, {
  depth: FLOOR_Y,       // 樓板厚度 0.12 公尺
  bevelEnabled: false,
});
geometry.rotateX(Math.PI / 2);      // Shape 畫在 XY 平面,轉成水平
geometry.translate(0, FLOOR_Y, 0);  // 樓板頂面對齊 y = FLOOR_Y

不是一塊方形地板上面再蓋牆——地板本身就是格局的形狀。凹進去的陽台、突出來的工作陽台,輪廓走到哪,樓板就到哪。這讓整個模型從第一眼就「像那間房子」,而不是像積木。

牆:線段變成箱子,外牆自動生成

牆的做法樸素到有點好笑:每一段牆就是一個 BoxGeometry——長度是線段長、寬度是牆厚、高度是牆高,再依線段角度旋轉到位。

比較有意思的是外牆不用另外畫。外牆就是地板輪廓本身:沿著輪廓的每一條邊自動生一段牆,室內牆才需要在資料裡逐段宣告:

typescript
// 外牆:沿輪廓自動生成
floorOutline.forEach((point, index) => {
  const next = floorOutline[(index + 1) % floorOutline.length];
  this.createWall(point, next, 0.2, outerMaterial, wallHeight);
});
// 室內牆:資料裡逐段宣告(含各自厚度)
walls.forEach((wall) => {
  this.createWall(wall.from, wall.to, wall.thickness ?? 0.14, innerMaterial, interiorHeight);
});

娃娃屋剖面:面向鏡頭的牆,自動變矮

第一版做完有個致命問題:牆全部等高,鏡頭從外面看,只看得到屋頂視角的一圈牆——完全看不進室內。

室內設計軟體的通用解法是「剖面」:把靠近觀察者的牆切掉。我的版本更簡單——判斷每段外牆的中點是否落在「面向鏡頭」的那兩側,是的話牆高直接降到 1.18 公尺(約原高的四成):

typescript
const midpointX = (point[0] + next[0]) / 2;
const midpointZ = (point[1] + next[1]) / 2;
const isCameraFacing = midpointX > maxX - 0.82 || midpointZ < minZ + 0.82;
this.createWall(point, next, 0.2, outerMaterial,
  isCameraFacing ? 1.18 : WALL_HEIGHT);   // 面向鏡頭的牆:矮牆

矮牆而不是無牆,是刻意的:完全切掉會失去「這裡有一道牆」的邊界感,留一截矮牆,空間的圍合關係還在,視線又進得去。娃娃屋就是這樣做的,它有效了一百年。

實例:本站 A 配置,哪幾段外牆變矮

上面的片段和本站現在的程式一致,在 src/pages/InteriorLayoutScene.ts 第 858~866 行。格局資料在 src/pages/interiorLayoutData.ts,A、B 兩個配置都在裡面,全檔 263 行。

規則裡的 maxX、minZ 用的是資料座標。資料 z 最小的一邊是平面圖下緣,放進場景後在 +z 側。預設鏡頭的方向取自 VIEWPOINTS.perspective(第 76 行的 (12.8, 11.2, 13.8))。從格局中心看,鏡頭在右下方偏上。實際距離由 framedViewpoint() 依格局外框算(第 238 行起)。

A 配置的俯視圖。外框 22 段牆中,右側與下緣 11 段畫成橘色,是 1.18 公尺的矮牆,合計 25.17 公尺;其餘 11 段灰色是 2.72 公尺全高,合計 27.81 公尺。兩條虛線是門檻:中點 x 大於 6.27、z 小於 -3.66。室內牆 22 段畫成細灰線。預設鏡頭在右下方
依 A 配置的資料重跑矮牆規則,2026-11-05 整理。KeepOnFirst 自繪示意圖。

我用 A 配置的資料重跑這條規則。外框有 22 個頂點,所以有 22 段外牆。門檻是中點 x 大於 6.27,或中點 z 小於 −3.66。

結果 11 段變成 1.18 公尺,合計 25.17 公尺;另外 11 段維持 2.72 公尺,合計 27.81 公尺。室內牆有 22 段,高 1.98 公尺。

門檻比的是中點,不是整段。B 配置右下角有一段 1.1 公尺的外牆,中點是 x 4.73、z −3.68。兩個門檻都沒過,所以它維持全高。這一點是依程式算的,我沒有截圖確認。

房間名稱:用 Canvas 畫字,不用 3D 文字

「主臥」「客廳」這些標籤需要出現在地板上。Three.js 的 3D 文字(TextGeometry)需要載字型檔、產生幾何,對中文尤其重——一個字型檔就好幾 MB。

便宜的做法:開一個 2D canvas、用瀏覽器原生的 fillText 把中文畫上去、整張 canvas 變成貼圖(CanvasTexture)貼在一個平面上。瀏覽器本來就會畫中文,何必讓 3D 引擎重新學一次。

家具拖拉:一個 Raycaster 和一個看不見的平面

拖家具聽起來像需要物理引擎,實際上一行物理都沒有。核心是 Three.js 的 Raycaster 加一個與地板同高的無形平面:

typescript
private readonly raycaster = new THREE.Raycaster();
private readonly dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -FLOOR_Y);
private readonly dragPoint = new THREE.Vector3();

// pointermove 時:把螢幕座標的射線打到水平面上,交點就是家具的新位置
if (!this.raycaster.ray.intersectPlane(this.dragPlane, this.dragPoint)) return;
item.position.set(this.dragPoint.x, item.position.y, this.dragPoint.z);

按下時用射線選中家具、拖動時射線與水平面的交點就是新位置。家具不會飛起來也不會穿到地下,因為它的移動天生被鎖在那個平面上——約束不是算出來的,是幾何本身給的。

上面的拖拉片段是簡化版。本站程式多兩步(InteriorLayoutScene.ts 第 1318~1322 行)。第一步把位置四捨五入到 0.1 公尺。第二步把位置限制在 x ±6.65、z ±4.0 公尺內。這個範圍是寫死的,不隨格局大小改變。

手機上還有一件事要處理:拖家具和轉視角都是「單指拖動」,兩個手勢會打架。解法是狀態互斥——按到家具就暫停 OrbitControls,放開再還回去。另外工具面板在小螢幕改成可收合、貼齊 safe-area,這部分沒什麼演算法,純粹是「在手機上自己用過一次」之後改的。

光:一盞太陽,一扇假窗

室內場景的光很容易做過頭。這個場景只有三件事:一盞暖色的 DirectionalLight 當太陽(開陰影)、一片冷色的 RectAreaLight 貼在窗的位置當天光、加一點環境光補底。

RectAreaLight 是被低估的工具:它是一個「發光的矩形」,天生就是窗戶的形狀。不用 HDRI、不用烘焙,一盞就讓室內有「白天」的感覺。

本站的實際設定在 InteriorLayoutScene.ts 第 587~606 行。補底的是 HemisphereLight,強度 2.1。RectAreaLight 寬 8、高 3 公尺,放在 (0, 3.8, 5.4),也就是平面圖下緣外側上方。

如果你也想做一個

按這個順序,每一步都有東西可以看:

一、先把平面圖翻成資料:輪廓多邊形、牆段、標籤。這步最無聊,但做完後面全是組裝。

二、Shape + ExtrudeGeometry 做地板,沿輪廓自動生外牆。到這裡就已經「像那間房子」了。

三、加娃娃屋剖面(面向鏡頭的牆降矮),沒有這步就看不進去。

四、標籤用 CanvasTexture,別碰 3D 文字。

五、家具拖拉用 Raycaster + 水平面,別碰物理引擎。

六、如果資料來自真實住宅,匿名化在資料模型層做,不要靠事後記得。

成品在這裡,可以直接走進去:3D 格局實驗室量好房間長寬想換算坪數:坪數計算機用這個工具實際試擺沙發:三人座寬差 57 公分的實測格局怎麼塞進網址分享:deflate-raw 實測與 CRC-32 校驗相關文章:把真實城市變成 3D 場景的管線

參考資料