探索使用Three.js构建程序化3D厨房设计器的过程
程序化建模和几个智能抽象如何将复杂的3D设计转变为简单直观的Web体验。
使用墙壁设计房间布局
示例:用户使用内置墙壁模块绘制简单房间形状。
要启动我们的项目,我们从墙壁绘制模块开始。在高层次上,这类似于Figma的钢笔工具,用户可以在无限的2D画布上一次添加一个线段,直到形成一个封闭或开放的多边形。在我们的构建中,每个线段代表从坐标A到坐标B的单个墙壁作为2D平面,而完整的多边形勾勒出房间的周界包络。
我们首先捕获用户在无限地板平面上初始点击的[X, Z]坐标(Y向上)。这个2D点通过Three.js内置的光线投射器进行交集检测获得,建立点A。
当用户将光标悬停在地板上的新位置时,我们应用相同的交集逻辑来确定临时点B。在此移动过程中,会出现预览线段,连接固定点A和动态点B以提供视觉反馈。
用户第二次点击确认点B后,我们将线段(由点A和点B定义)附加到线段数组中。前一个点B立即成为新的点A,允许我们继续绘制额外的线段。
以下是使用Three.js的基本2D钢笔绘制工具的简化代码片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
|
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10); // 将摄像机定位在地板上方向下看
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建用于光线投射的无限地板平面
const floorGeometry = new THREE.PlaneGeometry(100, 100);
const floorMaterial = new THREE.MeshBasicMaterial({ color: 0xcccccc, side: THREE.DoubleSide });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2; // 平放在XZ平面上
scene.add(floor);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let points: THREE.Vector3[] = []; // 即墙壁端点
let tempLine: THREE.Line | null = null;
const walls: THREE.Line[] = [];
function getFloorIntersection(event: MouseEvent): THREE.Vector3 | null {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(floor);
if (intersects.length > 0) {
// 四舍五入以简化坐标(可选,用于更清晰的绘制)
const point = intersects[0].point;
point.x = Math.round(point.x);
point.z = Math.round(point.z);
point.y = 0; // 确保在地板平面上
return point;
}
return null;
}
// 更新临时线条预览
function onMouseMove(event: MouseEvent) {
const point = getFloorIntersection(event);
if (point && points.length > 0) {
// 如果存在旧的临时线条,则移除
if (tempLine) {
scene.remove(tempLine);
tempLine = null;
}
// 创建从最后一点到当前悬停的新临时线条
const geometry = new THREE.BufferGeometry().setFromPoints([points[points.length - 1], point]);
const material = new THREE.LineBasicMaterial({ color: 0x0000ff }); // 蓝色表示临时
tempLine = new THREE.Line(geometry, material);
scene.add(tempLine);
}
}
// 添加新点并绘制永久墙壁段
function onMouseDown(event: MouseEvent) {
if (event.button !== 0) return; // 仅左键点击
const point = getFloorIntersection(event);
if (point) {
points.push(point);
if (points.length > 1) {
// 从上一个点到当前点绘制永久墙壁线
const geometry = new THREE.BufferGeometry().setFromPoints([points[points.length - 2], points[points.length - 1]]);
const material = new THREE.LineBasicMaterial({ color: 0xff0000 }); // 红色表示永久
const wall = new THREE.Line(geometry, material);
scene.add(wall);
walls.push(wall);
}
// 点击后移除临时线条
if (tempLine) {
scene.remove(tempLine);
tempLine = null;
}
}
}
// 添加事件监听器
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mousedown', onMouseDown);
// 动画循环
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
|
上面的代码片段是一个非常基本的2D钢笔工具,但这些信息足以生成整个房间实例。作为参考:不仅每个线段代表一堵墙(2D平面),而且累积的点集也可用于自动生成房间的地板网格,以及天花板网格(地板网格的逆)。
为了在3D中查看代表墙壁的平面,可以将每个THREE.Line转换为自定义的Wall类对象,该类包含一条线(用于正交2D"平面图"视图)和一个2D内向平面(用于透视3D"房间"视图)。构建此类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class Wall extends THREE.Group {
constructor(length: number, height: number = 96, thickness: number = 4) {
super();
// 用于顶视图的2D线,沿x轴
const lineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(length, 0, 0),
]);
const lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000 });
const line = new THREE.Line(lineGeometry, lineMaterial);
this.add(line);
// 作为盒子的3D墙,用于厚度
const wallGeometry = new THREE.BoxGeometry(length, height, thickness);
const wallMaterial = new THREE.MeshBasicMaterial({ color: 0xaaaaaa, side: THREE.DoubleSide });
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
wall.position.set(length / 2, height / 2, 0);
this.add(wall);
}
}
|
我们现在可以更新墙壁绘制模块以利用这个新创建的Wall对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 更新我们的变量
let tempWall: Wall | null = null;
const walls: Wall[] = [];
// 在onMouseDown中替换线条创建
if (points.length > 1) {
const start = points[points.length - 2];
const end = points[points.length - 1];
const direction = end.clone().sub(start);
const length = direction.length();
const wall = new Wall(length);
wall.position.copy(start);
wall.rotation.y = Math.atan2(direction.z, direction.x); // 沿方向对齐(假设CCW用于内向)
scene.add(wall);
walls.push(wall);
}
|
添加地板和天花板网格后,我们可以进一步将墙壁模块转换为房间生成模块。回顾我们刚刚创建的内容:通过逐个添加墙壁,我们赋予了用户创建完整房间的能力,包括墙壁、地板和天花板——所有这些都可以稍后在场景中调整。
用户以3D透视摄像机视图拖出墙壁。
使用程序化建模生成橱柜
我们的橱柜相关逻辑可以包括台面、底柜和壁柜。
与像宜家3D厨房构建器那样逐个添加橱柜需要几分钟不同,可以通过单个用户操作一次性添加所有橱柜。这里可以采用的一种方法是允许用户绘制高级橱柜线段,方式与墙壁绘制模块相同。
在这个模块中,每个橱柜段将转换为一排底柜和壁柜,以及在底柜顶部参数化生成的台面网格。随着用户创建段,我们可以使用像Blender这样的网格软件自动用预制的3D橱柜网格填充这个线段。最终,每个橱柜的宽度、深度和高度参数将是固定的,而最后一个橱柜的宽度可以是动态的以填充剩余空间。我们在这里使用橱柜填充件网格——一个普通的木板,根据需要拉伸或压缩其scale-X参数。
创建橱柜线段
用户可以通过沿着墙壁拖动橱柜线段,然后在自由空间中制作半半岛形状。
这里我们将构建一个专用的橱柜模块,具有上述橱柜线段逻辑。这个过程与墙壁绘制机制非常相似,用户可以使用鼠标点击在地板平面上绘制直线来定义起点和终点。与可以用简单细线表示的墙壁不同,橱柜线段需要考虑24英寸的标准深度来表示底柜的占地面积。这些段不需要闭合多边形逻辑,因为它们可以是独立的行或L形,这在大多数厨房布局中很常见。
我们可以通过加入吸附功能进一步改善用户体验,其中橱柜线段的端点如果在某个阈值内(例如4英寸),会自动对齐到附近的墙壁端点或墙壁交点。这确保了橱柜紧密贴合墙壁,无需手动精确调整。为简单起见,我们将在代码中概述吸附逻辑,但专注于核心绘制功能。
我们可以从定义CabinetSegment类开始。像墙壁一样,这应该是它自己的类,因为我们稍后将添加自动填充的3D橱柜模型。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class CabinetSegment extends THREE.Group {
public length: number;
constructor(length: number, height: number = 96, depth: number = 24, color: number = 0xff0000) {
super();
this.length = length;
const geometry = new THREE.BoxGeometry(length, height, depth);
const material = new THREE.MeshBasicMaterial({ color, wireframe: true });
const box = new THREE.Mesh(geometry, material);
box.position.set(length / 2, height / 2, depth / 2); // 移动以使深度跨越0到深度(向内)
this.add(box);
}
}
|
一旦我们有了橱柜段,我们可以以与墙壁线段非常相似的方式使用它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
let cabinetPoints: THREE.Vector3[] = [];
let tempCabinet: CabinetSegment | null = null;
const cabinetSegments: CabinetSegment[] = [];
const CABINET_DEPTH = 24; // 所有单位均为英寸
const CABINET_SEGMENT_HEIGHT = 96; // 即壁柜和底柜 -> 组应延伸到天花板
const SNAPPING_DISTANCE = 4;
function getSnappedPoint(point: THREE.Vector3): THREE.Vector3 {
// 简单吸附:检查现有的墙壁点(来自墙壁模块的wallPoints数组)
for (const wallPoint of wallPoints) {
if (point.distanceTo(wallPoint) < SNAPPING_DISTANCE) return wallPoint;
}
return point;
}
// 更新临时橱柜预览
function onMouseMoveCabinet(event: MouseEvent) {
const point = getFloorIntersection(event);
if (point && cabinetPoints.length > 0) {
const snappedPoint = getSnappedPoint(point);
if (tempCabinet) {
scene.remove(tempCabinet);
tempCabinet = null;
}
const start = cabinetPoints[cabinetPoints.length - 1];
const direction = snappedPoint.clone().sub(start);
const length = direction.length();
if (length > 0) {
tempCabinet = new CabinetSegment(length, CABINET_SEGMENT_HEIGHT, CABINET_DEPTH, 0x0000ff); // 蓝色表示临时
tempCabinet.position.copy(start);
tempCabinet.rotation.y = Math.atan2(direction.z, direction.x);
scene.add(tempCabinet);
}
}
}
// 添加新点并绘制永久橱柜段
function onMouseDownCabinet(event: MouseEvent) {
if (event.button !== 0) return;
const point = getFloorIntersection(event);
if (point) {
const snappedPoint = getSnappedPoint(point);
cabinetPoints.push(snappedPoint);
if (cabinetPoints.length > 1) {
const start = cabinetPoints[cabinetPoints.length - 2];
const end = cabinetPoints[cabinetPoints.length - 1];
const direction = end.clone().sub(start);
const length = direction.length();
if (length > 0) {
const segment = new CabinetSegment(length, CABINET_SEGMENT_HEIGHT, CABINET_DEPTH, 0xff0000); // 红色表示永久
segment.position.copy(start);
segment.rotation.y = Math.atan2(direction.z, direction.x);
scene.add(segment);
cabinetSegments.push(segment);
}
}
if (tempCabinet) {
scene.remove(tempCabinet);
tempCabinet = null;
}
}
}
// 为橱柜模式添加单独的事件监听器(例如,通过UI按钮切换)
window.addEventListener('mousemove', onMouseMoveCabinet);
window.addEventListener('mousedown', onMouseDownCabinet);
|
用实时橱柜模型自动填充线段
这里我们用3D橱柜模型(底柜和壁柜)和台面网格填充2个线段。
一旦定义了橱柜线段,我们可以程序化地用详细组件填充它们。这涉及将每个段垂直分为三层:底部的底柜,中间的台面,以及上方的壁柜。对于底柜和壁柜,我们将使用优化函数将段的长度划分为标准宽度(优先选择30英寸橱柜),任何余数使用上述填充件填充。台面更简单——它们形成单个连续的板,拉伸段的整个长度。
底柜设置为24英寸深和34.5英寸高。台面增加1.5英寸高度,延伸到25.5英寸深(包括1.5英寸悬垂)。壁柜从54英寸高开始(台面上方18英寸),测量12英寸深,30英寸高。生成这些占位符边界框后,我们可以使用加载函数(例如通过GLTFLoader)用从Blender预加载的3D模型替换它们。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 英寸单位常量
const BASE_HEIGHT = 34.5;
const COUNTER_HEIGHT = 1.5;
const WALL_HEIGHT = 30;
const WALL_START_Y = 54;
const BASE_DEPTH = 24;
const COUNTER_DEPTH = 25.5;
const WALL_DEPTH = 12;
const DEFAULT_MODEL_WIDTH = 30;
// 填充件信息
const FILLER_PIECE_FALLBACK_PATH = 'models/filler_piece.glb'
const FILLER_PIECE_WIDTH = 3;
const FILLER_PIECE_HEIGHT = 12;
const FILLER_PIECE_DEPTH = 24;
|
为了处理单个橱柜,我们将创建一个简单的Cabinet类来管理占位符和模型加载。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
class Cabinet extends THREE.Group {
constructor(width: number, height: number, depth: number, modelPath: string, color: number) {
super();
// 占位符盒子
const geometry = new THREE.BoxGeometry(width, height, depth);
const material = new THREE.MeshBasicMaterial({ color });
const placeholder = new THREE.Mesh(geometry, material);
this.add(placeholder);
// 异步加载并替换为模型
// 情况:非标准宽度 -> 使用填充件
if (width < DEFAULT_MODEL_WIDTH) {
loader.load(FILLER_PIECE_FALLBACK_PATH, (gltf) => {
const model = gltf.scene;
model.scale.set(
width / FILLER_PIECE_WIDTH,
height / FILLER_PIECE_HEIGHT,
depth / FILLER_PIECE_DEPTH,
);
this.add(model);
this.remove(placeholder);
});
}
loader.load(modelPath, (gltf) => {
const model = gltf.scene;
model.scale.set(width / DEFAULT_MODEL_WIDTH, 1, 1); // 缩放宽度
this.add(model);
this.remove(placeholder);
});
}
}
|
然后,我们可以向现有的CabinetSegment类添加一个populate方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
function splitIntoCabinets(width: number): number[] {
const cabinets = [];
// 首选宽度
while (width >= DEFAULT_MODEL_WIDTH) {
cabinets.push(DEFAULT_MODEL_WIDTH);
width -= DEFAULT_MODEL_WIDTH;
}
if (width > 0) {
cabinets.push(width); // 自定义空槽
}
return cabinets;
}
class CabinetSegment extends THREE.Group {
// ...(现有构造函数和属性)
populate() {
// 移除占位符线和盒子
while (this.children.length > 0) {
this.remove(this.children[0]);
}
let offset = 0;
const widths = splitIntoCabinets(this.length);
// 底柜
widths.forEach((width) => {
const baseCab = new Cabinet(width, BASE_HEIGHT, BASE_DEPTH, 'models/base_cabinet.glb', 0x8b4513);
baseCab.position.set(offset + width / 2, BASE_HEIGHT / 2, BASE_DEPTH / 2);
this.add(baseCab);
offset += width;
});
// 台面(单板,无模型)
const counterGeometry = new THREE.BoxGeometry(this.length, COUNTER_HEIGHT, COUNTER_DEPTH);
const counterMaterial = new THREE.MeshBasicMaterial({ color: 0xa9a9a9 });
const counter = new THREE.Mesh(counterGeometry, counterMaterial);
counter.position.set(this.length / 2, BASE_HEIGHT + COUNTER_HEIGHT / 2, COUNTER_DEPTH / 2);
|