使用Three.js构建程序化3D厨房设计器的技术探索

本文详细介绍了如何使用Three.js和TypeScript构建程序化3D厨房设计工具,包括墙壁绘制模块、橱柜线段生成、自动填充3D模型等核心技术实现,展示了如何将复杂的3D设计转化为简单直观的Web体验。

探索使用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);
   
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计