探索使用Three.js构建程序化3D厨房设计器的过程
程序化建模和一些智能抽象如何将复杂的3D设计转变为简单直观的网页体验。
使用墙壁设计房间布局
项目从墙壁绘制模块开始。这类似于Figma的钢笔工具,用户可以在无限的2D画布上逐个添加线段,直到形成封闭或开放的多边形。在我们的构建中,每个线段代表从坐标A到坐标B的2D平面墙,而完整的多边形勾勒出房间的周界。
我们首先通过Three.js的内置光线投射器获取用户在地板平面上的初始点击的[X, Z]坐标(Y轴向上),建立点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
|
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;
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
|
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);
scene.add(wall);
walls.push(wall);
}
|
添加地板和天花板网格后,我们可以进一步将墙壁模块转换为房间生成模块。通过逐个添加墙壁,用户能够创建具有墙壁、地板和天花板的完整房间,所有这些都可以在场景中稍后调整。
使用程序化建模生成橱柜
我们的橱柜相关逻辑可以包括台面、底柜和壁柜。
与逐个添加橱柜(例如IKEA的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);
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
|
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 {
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;
}
}
}
window.addEventListener('mousemove', onMouseMoveCabinet);
window.addEventListener('mousedown', onMouseDownCabinet);
|
使用实时橱柜模型自动填充线段
一旦定义了橱柜线段,我们可以程序化地用详细组件填充它们。这涉及将每个段垂直分为三层:底部的底柜、中间的台面和上方的壁柜。对于底柜和壁柜,我们将使用优化函数将段的长度划分为标准宽度(优先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
|
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
|
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
40
41
42
43
44
45
46
47
48
|
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);
this.add(counter);
// 壁柜
offset = 0;
widths.forEach((width) => {
const wallCab = new Cabinet(width, WALL_HEIGHT, WALL_DEPTH, 'models/wall_cabinet.glb', 0x4b0082);
wallCab.position.set(offset + width / 2, WALL_START_Y + WALL_HEIGHT / 2, WALL_DEPTH / 2);
this.add(wallCab);
offset += width;
});
}
}
cabinetSegments.forEach((segment) => segment.populate());
|
进一步改进和优化
此时,我们应该完全具备房间和橱柜创建逻辑的基础元素。为了将这个项目从基本的段绘制应用程序带入实用领域——包括动态橱柜、多个真实材质选项和变化的真实电器网格——我们可以通过几个有针对性的改进来进一步增强用户体验:
我们可以实现一个检测机制来确定橱柜线段是否与墙线段接触。
对于与墙壁平行的橱柜行,我们可以在壁柜和台面表面之间的空间中自动加入后挡板。
对于不靠近墙壁的橱柜段,我们可以移除上部壁柜并将台面额外延伸15英寸,符合厨房岛或半岛的标准做法。
我们可以为电器引入拖放功能,每个都有预定义的宽度,允许用户沿着线段定位它们。这种集成将指示我们的橱柜分割算法从动态橱柜生成中排除这些区域。
此外,我们可以通过启用一个电器与另一个电器的交换、将不同纹理应用到我们的3D模型以及调整默认尺寸(如壁柜深度或台面悬垂)来适应用户特定偏好,从而给用户更多灵活性。
所有这些核心组件使我们能够构建一个全面的交互式应用程序,快速渲染完整的厨房:橱柜、台面和电器,在一个完全交互式、用户驱动的体验中。
这个项目的目的是证明复杂的3D任务可以简化为简单的用户操作。完全有可能将3D工具的高维复杂性——具有看似无限的控制——编码为低维、易于调整的参数。无论