From 79142ab1d9c9268570ac8a9dd22f31e7cd86f5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 9 Mar 2026 09:38:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[rd]=20=EB=B0=A9=ED=99=94=EC=85=94?= =?UTF-8?q?=ED=84=B0=203D=20=EB=8B=A8=ED=92=88=20=EB=B3=B4=EA=B8=B0(Isolat?= =?UTF-8?q?ion)=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 우클릭 컨텍스트 메뉴: 객체 클릭 시 '단품 보기', 빈 공간 시 '전체 보기' - Raycaster 기반 객체 감지 (Group 자식까지 추적) - 단품 보기 시 카메라 자동 포커스 (BoundingBox 기반) - 상태 배지 표시 (클릭으로 전체 보기 복원) - 재빌드 시 단품 상태 유지 --- .../rd/fire-shutter-drawing/index.blade.php | 148 +++++++++++++++++- 1 file changed, 144 insertions(+), 4 deletions(-) diff --git a/resources/views/rd/fire-shutter-drawing/index.blade.php b/resources/views/rd/fire-shutter-drawing/index.blade.php index 9f126863..1386e94e 100644 --- a/resources/views/rd/fire-shutter-drawing/index.blade.php +++ b/resources/views/rd/fire-shutter-drawing/index.blade.php @@ -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); }
@@ -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 = ` '${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 = ' 전체 보기'; + 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);