실제 인테리어에서는 가구를 벽에 붙여 배치하는 경우가 많기 때문에, 이를 돕는 벽 자석 기능을 구현했습니다.
벽 자석 기능은 다음 파일들에 구현되어 있습니다:
useObjectControls.js:39-396 - 메인 벽 자석 로직 (findNearestWallSnap 함수)wallSlice.js:11-17 - 벽 자석 기능 상태 관리CameraControlPanel.jsx:164-182 - 벽 자석 ON/OFF 토글 UI벽 자석은 가구를 벽의 모서리 300mm(three.js 기준 0.3) 이내로 이동시키면, 자동으로 가구를 벽에서 50mm 떨어진 위치로 보정하는 기능입니다. 위치가 보정된 이후에도 사용자가 추가적으로 미세 조정할 수 있습니다.
이때 자석 효과가 활성화된 벽은 노란색으로 하이라이트되어, 사용자가 쉽게 인식할 수 있습니다.
또한 직각으로 만나는 두 벽의 교차점 500mm(three.js 기준 0.5) 이내 범위로 가구를 이동시키면, 두 벽이 만나는 직각 코너에 정확히 맞춰 자동 배치됩니다. 코너 스냅이 활성화되면 벽이 주황색으로 하이라이트됩니다.
벽 자석 기능은 화면 좌측 Display 패널의 "벽 자석" 토글 스위치를 통해 활성화 및 비활성화가 가능합니다.
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로 설정한 이유는, 실제 인테리어에서도 청소 공간 확보 및 벽면 손상 방지를 위해 벽과 가구 사이에 약간의 틈을 두는 점을 반영했습니다.
각 벽의 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 배열에 수집됩니다.
벽이 회전되어 있는 경우, 벽과 가구의 정확한 거리를 측정하기 까다롭습니다.
예를 들어 45도 회전된 벽의 경우, 가구와의 거리를 X, Z 좌표로 직접 계산하면 복잡한 삼각함수 계산이 필요합니다.
따라서 벽을 기준으로 하는 새로운 좌표계(로컬 좌표계)를 만들어, 마치 벽이 수평인 것처럼 간단하게 계산할 수 있도록 했습니다. 이는 전체 공간(월드 좌표계)에서 벽 중심의 작은 공간(로컬 좌표계)으로 관점을 바꾸는 것입니다.
(월드 좌표계는 전체 공간 기준의 좌표계, 로컬 좌표계는 벽을 기준으로 다시 계산한 좌표계로 이해하시면 됩니다.)
// 벽을 기준으로 가구의 상대 좌표 계산
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축으로 처리된다는 점에 유의 바랍니다.)
앞서 구한 localX, localZ는 벽과 가구의 중심점 간의 상대적 위치입니다.
하지만 실제 벽 자석에서는 가구의 중심점이 아닌 가구의 가장자리가 벽에 닿는 거리를 계산해야 합니다. 즉, 가구 중심에서 벽까지의 거리가 아니라 가구의 면(edge)에서 벽까지의 최단거리를 측정해야 합니다. 이를 위해 furnitureHalfWidth, furnitureHalfDepth 같은 가구의 실제 크기를 반영한 바운딩 박스 계산이 필수입니다.
이를 위해, 가구를 감싸는 박스인 바운딩 박스를 따로 계산했습니다. (위 사진의 파란색 박스가 bounding box입니다).
가구가 회전된 경우, 월드 좌표계에서 보는 바운딩 박스의 실제 크기가 달라집니다.
예를 들어 원본 크기가 width 2000mm, depth 1000mm인 가구를 Y축 기준으로 90도 회전시키면:
이러한 회전을 고려하지 않으면 가구를 회전해도 벽 자석이 원래 크기로 잘못 계산하여 부정확한 스냅이 발생합니다.
따라서 회전 변환 행렬을 적용한 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)를 계산합니다.
실제 자석 계산은 각 벽의 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",
});
}
계산 과정 요약
furnitureBackEdge와 wallFrontEdge 사이의 실제 거리(frontDistance)frontDistance가 SNAP_DISTANCE(300mm) 이내이고, 가구가 벽의 올바른 쪽에 위치하는지 확인snapLocalZ로 벽에서 WALL_OFFSET(50mm) 떨어진 최종 위치 계산wallCos, wallSin을 사용해 로컬 좌표(snapLocalZ)를 월드 좌표(snapWorldPos)로 변환이 과정이 벽의 모든 면에 대해 반복되어 가능한 모든 자석 발동 후보들이 allCandidates 배열에 수집됩니다.
코너 자석이 활성화되려면 다음 조건들을 만족해야 합니다:
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도)의 오차를 허용합니다.
두 벽이 서로 다른 방향(하나는 가로, 하나는 세로)을 향해야 코너 자석이 가능합니다. 같은 방향을 향하는 평행한 벽들은 코너가 아니므로 제외됩니다.