BLOCK8 중심선과 단면 근거로 3D 모델 생성
This commit is contained in:
1 parent
e7d025075e
commit
9d11b4ed62
13 files changed
+18693
-269
No files matched your search
@@ -0,0 +1,223 @@
|
||||
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, hiddenUnits, selected, onSelect, view, renderMode, resetToken }) {
|
||||
const canvasRef = useRef(null)
|
||||
const cameraRef = useRef({ ...DEFAULT_CAMERA })
|
||||
const sceneRef = useRef({ units, hidden, hiddenUnits, selected, view, renderMode, onSelect })
|
||||
const drawRef = useRef(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
sceneRef.current = { units, hidden, hiddenUnits, selected, view, renderMode, onSelect }
|
||||
drawRef.current()
|
||||
}, [units, hidden, hiddenUnits, 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, hiddenUnits, 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) {
|
||||
if (hiddenUnits.has(unit.id)) continue
|
||||
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, index) => {
|
||||
if (hiddenUnits.has(unit.id)) return
|
||||
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(`중심선 ${index + 1} · 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} />
|
||||
}
|
||||
Reference in new issue
Block a user