feat: [rd] 방화셔터 3D 단품 보기(Isolation) 기능 추가
- 우클릭 컨텍스트 메뉴: 객체 클릭 시 '단품 보기', 빈 공간 시 '전체 보기' - Raycaster 기반 객체 감지 (Group 자식까지 추적) - 단품 보기 시 카메라 자동 포커스 (BoundingBox 기반) - 상태 배지 표시 (클릭으로 전체 보기 복원) - 재빌드 시 단품 상태 유지
This commit is contained in:
@@ -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} 단품 보기 중 ✕`;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user