벽자석 기능 구현 - 좌표계 변환 및 바운딩 박스의 활용

방법이있지·2025년 9월 20일

웹개발

목록 보기
6/19
post-thumbnail

벽 자석 기능

실제 인테리어에서는 가구를 벽에 붙여 배치하는 경우가 많기 때문에, 이를 돕는 벽 자석 기능을 구현했습니다.

파일 구조

벽 자석 기능은 다음 파일들에 구현되어 있습니다:

image image

벽 자석은 가구를 벽의 모서리 300mm(three.js 기준 0.3) 이내로 이동시키면, 자동으로 가구를 벽에서 50mm 떨어진 위치로 보정하는 기능입니다. 위치가 보정된 이후에도 사용자가 추가적으로 미세 조정할 수 있습니다.

이때 자석 효과가 활성화된 벽은 노란색으로 하이라이트되어, 사용자가 쉽게 인식할 수 있습니다.

image

또한 직각으로 만나는 두 벽의 교차점 500mm(three.js 기준 0.5) 이내 범위로 가구를 이동시키면, 두 벽이 만나는 직각 코너에 정확히 맞춰 자동 배치됩니다. 코너 스냅이 활성화되면 벽이 주황색으로 하이라이트됩니다.

image

벽 자석 기능은 화면 좌측 Display 패널의 "벽 자석" 토글 스위치를 통해 활성화 및 비활성화가 가능합니다.

작동 로직

1. 자석 발동 거리 설정

const SNAP_DISTANCE = 0.3; // 300mm 이내에서 자석 효과
const WALL_OFFSET = 0.05; // 벽에서 50mm 떨어진 위치로 보정
const CORNER_SNAP_DISTANCE = 0.5; // 직각 코너의 경우, 500mm 이내

<어따놀래>에서는 실제 1mm를 three.js 0.001 단위로 환산해 사용합니다.

즉 가구가 300mm(한 벽) / 500mm(직각 코너) 이내로 접근하면, 자석 효과가 발동해 가구가 벽에서 50mm 떨어진 위치에 맞춰집니다.

직각 코너에서는 두 벽과 동시에 거리 조건을 만족해야 하는 특성상 일반 벽 자석보다 발동이 어려우므로, 의도적으로 감지 거리를 300mm에서 500mm로 늘려 사용성을 향상시켰습니다.

또한 보정된 벽과의 거리를 0mm가 아닌 50mm로 설정한 이유는, 실제 인테리어에서도 청소 공간 확보 및 벽면 손상 방지를 위해 벽과 가구 사이에 약간의 틈을 두는 점을 반영했습니다.

2. 벽면별 거리 판정 로직

각 벽의 4개 면(앞, 뒤, 좌, 우)에 대해 개별적으로 거리를 계산하고, 자석 거리 이내에 있는지 확인합니다:

// useObjectControls.js:140-149 (벽 앞면 예시)
// Z축 방향 자석 발동 가능 여부 먼저 확인
if (Math.abs(localX) <= wallHalfWidth + rotatedFurnitureWidth) {
  // 벽 앞면과 가구 뒷면의 거리 계산
  const furnitureBackEdge = localZ - rotatedFurnitureDepth;
  const wallFrontEdge = wallHalfDepth;
  const frontDistance = Math.abs(furnitureBackEdge - wallFrontEdge);

  // 거리 조건과 위치 조건을 동시에 만족하는지 확인
  if (furnitureBackEdge > wallFrontEdge && frontDistance < SNAP_DISTANCE) {
    // 자석 발동 후보로 등록
  }
}

이 로직이 벽의 4개 면 모두에 대해 실행되며, 조건을 만족하는 면들이 allCandidates 배열에 수집됩니다.

3. 벽과 가구의 거리 계산을 위한 로컬 좌표계 변환

벽이 회전되어 있는 경우, 벽과 가구의 정확한 거리를 측정하기 까다롭습니다.

예를 들어 45도 회전된 벽의 경우, 가구와의 거리를 X, Z 좌표로 직접 계산하면 복잡한 삼각함수 계산이 필요합니다.

image

이미지 출처

따라서 벽을 기준으로 하는 새로운 좌표계(로컬 좌표계)를 만들어, 마치 벽이 수평인 것처럼 간단하게 계산할 수 있도록 했습니다. 이는 전체 공간(월드 좌표계)에서 벽 중심의 작은 공간(로컬 좌표계)으로 관점을 바꾸는 것입니다.
(월드 좌표계는 전체 공간 기준의 좌표계, 로컬 좌표계는 벽을 기준으로 다시 계산한 좌표계로 이해하시면 됩니다.)

// 벽을 기준으로 가구의 상대 좌표 계산
const relativePos = new THREE.Vector3(
  position.x - wallPos.x,
  0,
  position.z - wallPos.z
);

먼저 가구의 월드 좌표에서 벽의 월드 좌표를 빼서, 가구가 벽으로부터 얼마나 떨어져 있는지를 나타내는 상대 벡터(relativePos)를 구합니다.

하지만 이 relativePos는 여전히 월드 좌표계 기준이므로 벽의 회전이 반영되지 않은 상태입니다.

// 벽 회전에 따라 좌표 변환
const wallCos = Math.cos(wallRotation);
const wallSin = Math.sin(wallRotation);

const localX = relativePos.x * wallCos + relativePos.z * wallSin;
const localZ = -relativePos.x * wallSin + relativePos.z * wallCos;

따라서 벽의 회전 각도(wallRotation)을 고려하여, 가구가 실제로 벽 기준으로 어느 쪽에 있는지를 다시 계산합니다.

이때 앞서 구한 relativePos에 삼각함수를 사용한 회전 변환을 적용합니다.

localX는 벽을 기준으로 한 좌우 거리로, 양수인 경우 벽의 오른쪽, 음수인 경우 벽의 왼쪽에 가구가 있음을 의미합니다.

localZ는 벽을 기준으로 한 앞뒤 거리로, 양수인 경우 벽의 앞쪽, 음수인 경우 벽의 뒤쪽에 가구가 있음을 의미합니다.

(three.js에서는 관례와 다르게 높이를 나타내는 축이 z축이 아닌 y축으로 처리된다는 점에 유의 바랍니다.)

4. 회전된 가구의 바운딩 박스 계산

image

앞서 구한 localX, localZ는 벽과 가구의 중심점 간의 상대적 위치입니다.

하지만 실제 벽 자석에서는 가구의 중심점이 아닌 가구의 가장자리가 벽에 닿는 거리를 계산해야 합니다. 즉, 가구 중심에서 벽까지의 거리가 아니라 가구의 면(edge)에서 벽까지의 최단거리를 측정해야 합니다. 이를 위해 furnitureHalfWidth, furnitureHalfDepth 같은 가구의 실제 크기를 반영한 바운딩 박스 계산이 필수입니다.

이를 위해, 가구를 감싸는 박스인 바운딩 박스를 따로 계산했습니다. (위 사진의 파란색 박스가 bounding box입니다).

image

이미지 출처

가구가 회전된 경우, 월드 좌표계에서 보는 바운딩 박스의 실제 크기가 달라집니다.

예를 들어 원본 크기가 width 2000mm, depth 1000mm인 가구를 Y축 기준으로 90도 회전시키면:

  • 회전 전: X축 방향 2000mm, Z축 방향 1000mm
  • 회전 후: X축 방향 1000mm, Z축 방향 2000mm

이러한 회전을 고려하지 않으면 가구를 회전해도 벽 자석이 원래 크기로 잘못 계산하여 부정확한 스냅이 발생합니다.

따라서 회전 변환 행렬을 적용한 Oriented Bounding Box(OBB)를 계산하여 실제 공간에서의 정확한 바운딩 박스 크기(rotatedFurnitureWidth, rotatedFurnitureDepth)를 구해야 합니다.

// 가구의 회전을 벽의 로컬 좌표계에서 계산
const relativeRotation = furnitureRotationY - wallRotation;
const furnitureCos = Math.cos(relativeRotation);
const furnitureSin = Math.sin(relativeRotation);

// 회전된 가구의 실제 바운딩 박스 크기
const rotatedFurnitureWidth =
  Math.abs(furnitureHalfWidth * furnitureCos) +
  Math.abs(furnitureHalfDepth * furnitureSin);
const rotatedFurnitureDepth =
  Math.abs(furnitureHalfWidth * furnitureSin) +
  Math.abs(furnitureHalfDepth * furnitureCos);

벽을 기준으로 가구가 얼마나 회전했는지(relativeRotation)를 구한 다음, 삼각함수(furnitureCos, furnitureSin)를 이용해 회전된 가구가 실제로 차지하는 폭(rotatedFurnitureWidth)과 깊이(rotatedFurnitureDepth)를 계산합니다.

5. 벽면별 자석 위치 계산

실제 자석 계산은 각 벽의 4개 면(앞, 뒤, 좌, 우)에 대해 개별적으로 수행됩니다. 아래는 벽 앞면과의 자석 발동 여부를 계산한 코드입니다:

// 벽 앞면 자석 발동 계산 예시
const furnitureBackEdge = localZ - rotatedFurnitureDepth; // 가구 뒷면 위치
const wallFrontEdge = wallHalfDepth; // 벽 앞면 위치
const frontDistance = Math.abs(furnitureBackEdge - wallFrontEdge); // 거리 계산

// 거리 조건과 위치 조건 확인
if (furnitureBackEdge > wallFrontEdge && frontDistance < SNAP_DISTANCE) {
  // 자석 발동 위치 계산: 벽 앞면에서 WALL_OFFSET만큼 떨어진 곳
  const snapLocalZ = wallFrontEdge + WALL_OFFSET + rotatedFurnitureDepth;

  // 로컬 좌표를 월드 좌표로 변환
  const snapWorldPos = {
    x: wallPos.x + (localX * wallCos - snapLocalZ * wallSin),
    y: position.y,
    z: wallPos.z + (localX * wallSin + snapLocalZ * wallCos),
  };

  // 자석 발동 후보로 등록
  allCandidates.push({
    distance: frontDistance,
    snapPosition: snapWorldPos,
    wall: wall,
    face: "front",
  });
}

계산 과정 요약

  1. 가구와 벽면의 거리 계산: furnitureBackEdgewallFrontEdge 사이의 실제 거리(frontDistance)
  2. 조건 확인: frontDistanceSNAP_DISTANCE(300mm) 이내이고, 가구가 벽의 올바른 쪽에 위치하는지 확인
  3. 자석 발동 위치 계산: snapLocalZ로 벽에서 WALL_OFFSET(50mm) 떨어진 최종 위치 계산
  4. 좌표 변환: wallCos, wallSin을 사용해 로컬 좌표(snapLocalZ)를 월드 좌표(snapWorldPos)로 변환

이 과정이 벽의 모든 면에 대해 반복되어 가능한 모든 자석 발동 후보들이 allCandidates 배열에 수집됩니다.

6. 직각 벽 감지 및 코너 자석 활성화

코너 자석이 활성화되려면 다음 조건들을 만족해야 합니다:

1. 두 개 이상의 가까운 벽 감지

// useObjectControls.js:250-252
const nearCandidates = allCandidates.filter(
  (candidate) => candidate.distance < CORNER_SNAP_DISTANCE // 0.5 (500mm) 이내
);

allCandidates 배열에서 CORNER_SNAP_DISTANCE(500mm) 이내의 벽들을 nearCandidates로 필터링합니다.

2. 직각 관계 확인

// useObjectControls.js:284-288
const angleDiff = Math.abs(wall1.rotation[1] - wall2.rotation[1]);
const normalizedDiff = angleDiff % (2 * Math.PI);
const isRightAngle =
  Math.abs(normalizedDiff - Math.PI / 2) < 0.1 ||
  Math.abs(normalizedDiff - (3 * Math.PI) / 2) < 0.1;

wall1.rotation[1]wall2.rotation[1]의 차이(angleDiff)를 계산하여 두 벽이 90도 각도로 만나는지 확인합니다. normalizedDiff로 정규화하고, isRightAngle로 0.1라디안 (약 5.7도)의 오차를 허용합니다.

두 벽이 서로 다른 방향(하나는 가로, 하나는 세로)을 향해야 코너 자석이 가능합니다. 같은 방향을 향하는 평행한 벽들은 코너가 아니므로 제외됩니다.

추가 설명

벽이 많거나 가구가 여러 개일 때 성능이 저하될 가능성은 없을까요?

  • 벽 자석 발동 여부 계산은 매 프레임마다 실행되지 않고, 가구를 드래그할 때만 실행됩니다.
  • 가구를 움직이지 않을 때는 계산이 발생하지 않아, 성능에 영향은 없습니다.
  • 한 번에 드래그되는 가구 1개에 대해서만 벽 자석 계산이 실행됩니다.

조건을 만족하는 벽이 많을 때, 어떤 우선순위로 결정되나요?

  • 코너 자석과 일반 벽 자석을 모두 만족하는 경우, 코너 자석이 최우선으로 적용됩니다.
  • 일반 자석의 경우, 가장 가까운 벽을 기준으로 작동합니다.
profile
뭔가 만드는 걸 좋아하는 개발자 지망생입니다. 프로야구단 LG 트윈스를 응원하고 있습니다.

0개의 댓글