Files
gwangteo-underpass-block8/src/ModelViewer.jsx
T

222 lines
9.6 KiB
React

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 <canvas ref={canvasRef} className="model-canvas" aria-label="BLOCK8 도면 기반 3D 구조 모델. 끌어 회전하고 휠로 확대할 수 있습니다." tabIndex={0} />
}