import { useEffect, useRef } from 'react' const DEFAULT_CAMERA = { yaw: -0.66, pitch: 0.43, zoom: 1, panX: 0, panY: 0 } const COLORS = { major: '#78917c', minor: '#d0aa69', insert: '#9a9074' } const clamp = (value, min, max) => Math.max(min, Math.min(max, value)) function worldPoint(unit, pointA, pointB, pointIndex, t) { const angle = (unit.startAngle + (unit.endAngle - unit.startAngle) * t) * Math.PI / 180 const cx = unit.center[0] + unit.radius * Math.cos(angle) const cz = unit.center[1] + unit.radius * Math.sin(angle) const tangentX = -Math.sin(angle) const tangentZ = Math.cos(angle) const normalX = -tangentZ const normalZ = tangentX const stationT = (0.01 + t * 19.98) / 20 const lateral = pointA[pointIndex][0] + (pointB[pointIndex][0] - pointA[pointIndex][0]) * stationT const elevation = pointA[pointIndex][1] + (pointB[pointIndex][1] - pointA[pointIndex][1]) * stationT return [cx + lateral * normalX, elevation, cz + lateral * normalZ] } function hexRgb(hex) { return [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16)) } function colorShade(hex, amount, alpha) { const [r, g, b] = hexRgb(hex).map(value => Math.round(value * amount)) return `rgba(${r}, ${g}, ${b}, ${alpha})` } function inPolygon(point, polygon) { let inside = false for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const [xi, yi] = polygon[i], [xj, yj] = polygon[j] const intersect = ((yi > point[1]) !== (yj > point[1])) && (point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi || 1) + xi) if (intersect) inside = !inside } return inside } export default function ModelViewer({ units, hidden, selected, onSelect, view, renderMode, resetToken }) { const canvasRef = useRef(null) const cameraRef = useRef({ ...DEFAULT_CAMERA }) const sceneRef = useRef({ units, hidden, selected, view, renderMode, onSelect }) const drawRef = useRef(() => {}) useEffect(() => { sceneRef.current = { units, hidden, selected, view, renderMode, onSelect } drawRef.current() }, [units, hidden, selected, view, renderMode, onSelect]) useEffect(() => { const canvas = canvasRef.current const ctx = canvas.getContext('2d') let hitFaces = [] let pointerStart = null let lastPoint = null let dragged = false const project = (point, width, height) => { const camera = cameraRef.current const y = point[1] - 104 const x = point[0] * Math.cos(camera.yaw) - point[2] * Math.sin(camera.yaw) const z = point[0] * Math.sin(camera.yaw) + point[2] * Math.cos(camera.yaw) const scale = Math.min(width / 66, height / 24) * camera.zoom return [width / 2 + x * scale + camera.panX, height / 2 - (y * Math.cos(camera.pitch) - z * Math.sin(camera.pitch)) * scale + camera.panY, y * Math.sin(camera.pitch) + z * Math.cos(camera.pitch)] } const draw = () => { const rect = canvas.getBoundingClientRect() const width = rect.width, height = rect.height if (!width || !height) return const dpr = Math.min(window.devicePixelRatio || 1, 2) if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr) } ctx.setTransform(dpr, 0, 0, dpr, 0, 0) const { units, hidden, selected, renderMode } = sceneRef.current const background = ctx.createLinearGradient(0, 0, 0, height) background.addColorStop(0, renderMode ? '#e8efea' : '#f8faf8') background.addColorStop(1, renderMode ? '#cbd8cf' : '#e9efea') ctx.fillStyle = background; ctx.fillRect(0, 0, width, height) const drawLine = (a, b, color, lineWidth = 1) => { const pa = project(a, width, height), pb = project(b, width, height) ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]) ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke() } for (let x = -40; x <= 40; x += 5) drawLine([x, 100.2, -22], [x, 100.2, 22], '#aab9ad38', .6) for (let z = -20; z <= 20; z += 5) drawLine([-40, 100.2, z], [40, 100.2, z], '#aab9ad38', .6) const faces = [] const longitudinalSteps = 8 for (const unit of units) { for (const part of unit.parts) { if (hidden.has(part.id) || part.a.length !== part.b.length) continue const color = COLORS[part.group] || COLORS.major const active = selected?.unitId === unit.id && selected?.partId === part.id for (let step = 0; step < longitudinalSteps; step++) { const t0 = step / longitudinalSteps, t1 = (step + 1) / longitudinalSteps for (let edge = 0; edge < part.a.length - 1; edge++) { const points3d = [ worldPoint(unit, part.a, part.b, edge, t0), worldPoint(unit, part.a, part.b, edge + 1, t0), worldPoint(unit, part.a, part.b, edge + 1, t1), worldPoint(unit, part.a, part.b, edge, t1), ] const projected = points3d.map(point => project(point, width, height)) const depth = projected.reduce((sum, point) => sum + point[2], 0) / projected.length faces.push({ projected, part, unit, color, depth, active, shade: .82 + .18 * Math.abs(Math.sin(edge * .73 + step * .11)) }) } } } } faces.sort((a, b) => a.depth - b.depth) hitFaces = [] for (const face of faces) { ctx.beginPath() face.projected.forEach((point, index) => index ? ctx.lineTo(point[0], point[1]) : ctx.moveTo(point[0], point[1])) ctx.closePath() ctx.fillStyle = colorShade(face.active ? '#d27c42' : face.color, renderMode ? face.shade : 1, face.active ? .92 : face.part.group === 'insert' ? .58 : .72) ctx.fill() ctx.strokeStyle = face.active ? '#9b4f20' : renderMode ? '#51675a99' : '#566b5b8c' ctx.lineWidth = face.active ? 1.8 : .65 ctx.stroke() hitFaces.push(face) } ctx.font = '11px sans-serif' ctx.textAlign = 'left' ctx.fillStyle = '#68806d' units.forEach(unit => { const start = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 0), width, height) const end = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 1), width, height) ctx.fillText('BLOCK8 중심선 · STA 319.010', start[0] - 5, start[1] - 16) ctx.fillText(`STA 338.990`, end[0] - 8, end[1] - 14) }) ctx.fillStyle = '#6f8275' ctx.fillText('X·Z 평면: 도면 중심선 · Y: 표고 EL', 16, height - 18) ctx.textAlign = 'right' ctx.fillText(`확대 ${Math.round(cameraRef.current.zoom * 100)}%`, width - 16, height - 18) } drawRef.current = draw const local = event => { const rect = canvas.getBoundingClientRect() return [event.clientX - rect.left, event.clientY - rect.top] } const down = event => { canvas.setPointerCapture(event.pointerId) pointerStart = local(event); lastPoint = pointerStart; dragged = false event.preventDefault() } const move = event => { if (pointerStart === null) return const point = local(event) const dx = point[0] - lastPoint[0], dy = point[1] - lastPoint[1] if (Math.hypot(point[0] - pointerStart[0], point[1] - pointerStart[1]) > 4) dragged = true const camera = cameraRef.current if (event.shiftKey || event.buttons === 2 || event.buttons === 4) { camera.panX += dx; camera.panY += dy } else { camera.yaw += dx * .008 camera.pitch = clamp(camera.pitch + dy * .006, -.4, 1.45) } lastPoint = point; drawRef.current() } const up = event => { if (pointerStart === null) return if (!dragged) { const point = local(event) for (let index = hitFaces.length - 1; index >= 0; index--) { const face = hitFaces[index] if (inPolygon(point, face.projected)) { sceneRef.current.onSelect({ unitId: face.unit.id, partId: face.part.id }) break } } } pointerStart = null; lastPoint = null } const wheel = event => { event.preventDefault() cameraRef.current.zoom = clamp(cameraRef.current.zoom * (event.deltaY < 0 ? 1.1 : .9), .3, 5) drawRef.current() } const context = event => event.preventDefault() const resize = new ResizeObserver(() => drawRef.current()) resize.observe(canvas) canvas.addEventListener('pointerdown', down) canvas.addEventListener('pointermove', move) canvas.addEventListener('pointerup', up) canvas.addEventListener('pointercancel', up) canvas.addEventListener('wheel', wheel, { passive: false }) canvas.addEventListener('contextmenu', context) draw() return () => { resize.disconnect() canvas.removeEventListener('pointerdown', down) canvas.removeEventListener('pointermove', move) canvas.removeEventListener('pointerup', up) canvas.removeEventListener('pointercancel', up) canvas.removeEventListener('wheel', wheel) canvas.removeEventListener('contextmenu', context) } }, []) useEffect(() => { const viewCameras = { 전체: { yaw: -.66, pitch: .43 }, 평면: { yaw: 0, pitch: Math.PI / 2 }, 정면: { yaw: 0, pitch: 0 }, 측면: { yaw: Math.PI / 2, pitch: 0 }, 'A-A': { yaw: 0, pitch: 0 }, 'B-B': { yaw: Math.PI, pitch: 0 }, } cameraRef.current = { ...DEFAULT_CAMERA, ...viewCameras[view] } drawRef.current() }, [view, resetToken]) return }