feat: [rd] 방화셔터 3D 단품 보기(Isolation) 기능 추가

- 우클릭 컨텍스트 메뉴: 객체 클릭 시 '단품 보기', 빈 공간 시 '전체 보기'
- Raycaster 기반 객체 감지 (Group 자식까지 추적)
- 단품 보기 시 카메라 자동 포커스 (BoundingBox 기반)
- 상태 배지 표시 (클릭으로 전체 보기 복원)
- 재빌드 시 단품 상태 유지
This commit is contained in:
김보곤
2026-03-09 09:38:27 +09:00
parent 68a08bdbb8
commit 79142ab1d9

View File

@@ -32,6 +32,12 @@
.fs-calc-row { display: flex; justify-content: space-between; align-items: center; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3); }
.fs-calc-label { color: #64748b; font-size: 0.75rem; }
.fs-calc-value { color: #f8fafc; font-size: 0.875rem; font-weight: 900; }
.fs-ctx-menu { position: fixed; display: none; z-index: 10000; background: rgba(15,23,42,0.95); border: 1px solid #334155; border-radius: 0.5rem; padding: 0.25rem; backdrop-filter: blur(12px); box-shadow: 0 10px 25px rgba(0,0,0,0.5); min-width: 160px; }
.fs-ctx-btn { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem; border: none; background: transparent; color: #e2e8f0; font-size: 0.8125rem; font-weight: 700; cursor: pointer; border-radius: 0.375rem; white-space: nowrap; text-align: left; }
.fs-ctx-btn:hover { background: rgba(59,130,246,0.2); color: #60a5fa; }
.fs-ctx-sep { height: 1px; background: #334155; margin: 0.25rem 0; }
.fs-iso-badge { position: absolute; top: 12px; left: 12px; display: none; padding: 6px 14px; background: rgba(59,130,246,0.9); color: white; font-size: 0.75rem; font-weight: 900; border-radius: 0.5rem; z-index: 100; cursor: pointer; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
.fs-iso-badge:hover { background: rgba(37,99,235,1); }
</style>
<div class="fs-wrap">
@@ -436,6 +442,12 @@
// 3D objects
let scene, camera, renderer, controls, animId;
let meshes = {};
let fs3dIsolated = null; // 현재 단품 보기 중인 키
const meshLabels = {
case: '셔터박스', shaft: '샤프트 ASSY', motor: '모터/체인',
rails: '가이드레일', slats: '슬랫 커튼', slatRoll: '감긴 슬랫',
bottomBar: '하부바', wall: '벽체',
};
// ============================
// TAB SWITCHING
@@ -1008,6 +1020,90 @@ function animate() {
}
});
ro.observe(container);
// === 우클릭 컨텍스트 메뉴 (단품 보기 / 전체 보기) ===
const ctxMenu = document.createElement('div');
ctxMenu.className = 'fs-ctx-menu';
document.body.appendChild(ctxMenu);
// 단품 보기 상태 배지
container.style.position = 'relative';
const isoBadge = document.createElement('div');
isoBadge.className = 'fs-iso-badge';
isoBadge.onclick = () => { fs3dShowAll(); };
container.appendChild(isoBadge);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 클릭된 메시에서 meshes 키 찾기
function findMeshKey(hitObj) {
let target = hitObj;
while (target.parent && target.parent !== scene) target = target.parent;
for (const [key, obj] of Object.entries(meshes)) {
if (obj === target) return key;
}
return null;
}
// 우클릭 이벤트
renderer.domElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
ctxMenu.style.display = 'none';
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const sceneObjects = Object.values(meshes).filter(Boolean);
const intersects = raycaster.intersectObjects(sceneObjects, true);
let hitKey = null;
if (intersects.length > 0) hitKey = findMeshKey(intersects[0].object);
// 메뉴 항목 구성
ctxMenu.innerHTML = '';
let hasItem = false;
if (hitKey) {
const label = meshLabels[hitKey] || hitKey;
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg> '${label}' 단품 보기`;
btn.onclick = () => { fs3dIsolate(hitKey); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (fs3dIsolated) {
if (hasItem) {
const sep = document.createElement('div');
sep.className = 'fs-ctx-sep';
ctxMenu.appendChild(sep);
}
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg> 전체 보기';
btn.onclick = () => { fs3dShowAll(); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (!hasItem) return;
// 화면 밖으로 넘어가지 않도록 위치 조정
ctxMenu.style.display = 'block';
const mw = ctxMenu.offsetWidth, mh = ctxMenu.offsetHeight;
const mx = Math.min(e.clientX, window.innerWidth - mw - 8);
const my = Math.min(e.clientY, window.innerHeight - mh - 8);
ctxMenu.style.left = mx + 'px';
ctxMenu.style.top = my + 'px';
});
// 좌클릭/스크롤 시 메뉴 닫기
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
renderer.domElement.addEventListener('wheel', () => { ctxMenu.style.display = 'none'; });
}
let fs3dCameraInit = false;
@@ -1455,10 +1551,15 @@ function fs3dBuild() {
scene.add(meshes.wall);
// 재빌드 후 토글 상태 동기화 (새로 생성된 mesh에 visibility 적용)
Object.keys(S.td.show).forEach(key => {
if (meshes[key]) meshes[key].visible = S.td.show[key];
});
// 재빌드 후 토글/단품 상태 동기화
if (fs3dIsolated) {
// 단품 보기 모드 유지
Object.entries(meshes).forEach(([k, obj]) => { if (obj) obj.visible = (k === fs3dIsolated); });
} else {
Object.keys(S.td.show).forEach(key => {
if (meshes[key]) meshes[key].visible = S.td.show[key];
});
}
// Camera: 최초 빌드 시에만 위치 설정, 이후 재빌드 시 현재 시점 유지
if (!fs3dCameraInit) {
@@ -1469,6 +1570,45 @@ function fs3dBuild() {
}
}
// 단품 보기 (Isolation)
function fs3dIsolate(key) {
fs3dIsolated = key;
Object.entries(meshes).forEach(([k, obj]) => {
if (obj) obj.visible = (k === key);
});
// 배지 표시
const badge = document.querySelector('.fs-iso-badge');
if (badge) {
const label = meshLabels[key] || key;
badge.innerHTML = `${label} 단품 보기 중 &nbsp;✕`;
badge.style.display = 'block';
}
// 카메라를 객체 중심으로 포커스
const obj = meshes[key];
if (obj && controls) {
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
controls.target.copy(center);
const dist = maxDim * 2.5;
camera.position.set(center.x + dist * 0.6, center.y + dist * 0.4, center.z + dist * 0.8);
controls.update();
}
}
// 전체 보기 (Show All)
function fs3dShowAll() {
fs3dIsolated = null;
Object.entries(meshes).forEach(([k, obj]) => {
if (!obj) return;
obj.visible = S.td.show[k] !== undefined ? S.td.show[k] : true;
});
// 배지 숨기기
const badge = document.querySelector('.fs-iso-badge');
if (badge) badge.style.display = 'none';
}
// 3D Controls
window.fs3dShutterPos = function(v) {
S.td.shutterPos = Number(v);