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

130 lines
8.6 KiB
React

import { useEffect, useRef } from 'react'
import { model, groups, palette, profileVertices } from './model/geometry'
const initial = () => ({ yaw: -.65, pitch: .56, zoom: 1, pan: [0, 0] })
const distance = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1]
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)))
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
}
export default function Viewer({ hidden, selected, onSelect, view, repeat, section, dimensions, renderMode, onReady }) {
const canvasRef = useRef(null), camera = useRef(initial()), drawRef = useRef(() => {})
const scene = useRef({ hidden, selected, onSelect, repeat, section, dimensions, renderMode })
useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode])
useEffect(() => {
camera.current = { ...initial(), ...({ '평면': { 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 } }[view.name]) }
drawRef.current()
}, [view])
useEffect(() => {
const canvas = canvasRef.current, ctx = canvas.getContext('2d')
const pointers = new Map()
let hits = [], moved = false, last = null, start = null
const project = (p, width, height) => {
const c = camera.current, y = p[1] - 4
const x = p[0] * Math.cos(c.yaw) - p[2] * Math.sin(c.yaw)
const z = p[0] * Math.sin(c.yaw) + p[2] * Math.cos(c.yaw)
const scale = Math.min(width / 35, height / 29) * c.zoom
return [width / 2 + x * scale + c.pan[0], height / 2 - (y * Math.cos(c.pitch) - z * Math.sin(c.pitch)) * scale + c.pan[1], y * Math.sin(c.pitch) + z * Math.cos(c.pitch)]
}
const draw = () => {
const rect = canvas.getBoundingClientRect(), w = rect.width, h = rect.height
if (!w || !h) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const { hidden, selected, repeat, section, dimensions, renderMode } = scene.current
const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, renderMode ? '#e7eeeb' : '#f8faf8'); bg.addColorStop(1, renderMode ? '#c8d5ce' : '#eaf0eb')
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h)
ctx.lineWidth = .6; ctx.strokeStyle = '#aabcb347'
const line = (a, b, color, width = 1) => { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke() }
for (let k = -16; k <= 16; k += 2) {
line(project([k, .5, -16], w, h), project([k, .5, 16], w, h), '#9faea943', .6)
line(project([-16, .5, k], w, h), project([16, .5, k], w, h), '#9faea943', .6)
}
const edges = []
model.parts.forEach(part => {
if (hidden.has(part.id) || (!repeat && part.repeat)) return
const color = palette[groups.indexOf(part.group) % palette.length]
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
const pts = profileVertices(part, s).map(p => project(p, w, h))
for (let i = 0; i < pts.length - 1; i++) edges.push({ a: pts[i], b: pts[i + 1], part, color, depth: (pts[i][2] + pts[i + 1][2]) / 2, s })
}
})
edges.sort((a, b) => a.depth - b.depth)
hits = []
edges.forEach(({ a, b, part, color, s }) => {
const active = selected === part.id
line(a, b, active ? '#cf762f' : color, active ? 3.7 : part.repeat ? .6 : renderMode ? 2.8 : 1.7)
if (renderMode && !part.repeat) { ctx.shadowColor = '#28453d40'; ctx.shadowBlur = 3; line(a, b, active ? '#e89d50' : color, 1); ctx.shadowBlur = 0 }
hits.push({ a, b, id: part.id, s })
})
ctx.font = '12px sans-serif'
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
const z = s ? 10 : -10
const p = project([0, model.sectionTops[s] - 100 + .8, z], w, h)
ctx.fillStyle = s ? '#836442' : '#376f60'; ctx.textAlign = 'center'
ctx.fillText(`${s ? 'B-B' : 'A-A'} · STA ${model.sectionStations[s].toFixed(3)}`, p[0], p[1])
if (dimensions) {
const a = project([-10.05, .1, z], w, h), b = project([10.05, .1, z], w, h)
line(a, b, '#6b8279', .8); ctx.fillStyle = '#557267'
ctx.fillText('표기 폭 20.100m', (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 17)
}
}
ctx.textAlign = 'left'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#71877c'
ctx.fillText('X 횡방향 / Y 표고 / Z 단면 측점 차', 18, h - 20)
ctx.fillText(`확대 ${Math.round(camera.current.zoom * 100)}%`, w - 96, h - 20)
onReady?.()
}
drawRef.current = draw
const local = e => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] }
const down = e => { canvas.focus(); canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, local(e)); start = local(e); last = { p: local(e), distance: 0 }; moved = false; e.preventDefault() }
const move = e => {
if (!pointers.has(e.pointerId)) return
const p = local(e), old = pointers.get(e.pointerId); pointers.set(e.pointerId, p)
if (Math.hypot(p[0] - start[0], p[1] - start[1]) > 4) moved = true
if (pointers.size === 2) {
const [a, b] = [...pointers.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1])
if (last.distance) camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * d / last.distance))
camera.current.pan[0] += (p[0] - old[0]) / 2; camera.current.pan[1] += (p[1] - old[1]) / 2
last.distance = d; moved = true
} else if (e.shiftKey || e.buttons === 2 || e.buttons === 4) {
camera.current.pan[0] += p[0] - old[0]; camera.current.pan[1] += p[1] - old[1]
} else {
camera.current.yaw += (p[0] - old[0]) * .009
camera.current.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.current.pitch + (p[1] - old[1]) * .009))
}
draw()
}
const up = e => {
const p = local(e)
if (!moved && pointers.size === 1) {
const hit = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7)
scene.current.onSelect(hit?.id || null)
}
pointers.delete(e.pointerId); last = { p, distance: 0 }
}
const cancel = e => { pointers.delete(e.pointerId); moved = true }
const wheel = e => { e.preventDefault(); camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * Math.exp(-e.deltaY * .001))); draw() }
const key = e => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', 'Home', 'Escape'].includes(e.key)) return
e.preventDefault()
if (e.key === 'Home') camera.current = initial()
if (e.key === 'Escape') scene.current.onSelect(null)
if (e.key === '+' || e.key === '=') camera.current.zoom = Math.min(8, camera.current.zoom * 1.15)
if (e.key === '-') camera.current.zoom = Math.max(.25, camera.current.zoom / 1.15)
if (e.key === 'ArrowLeft') camera.current.yaw -= .1
if (e.key === 'ArrowRight') camera.current.yaw += .1
if (e.key === 'ArrowUp') camera.current.pitch = Math.min(Math.PI / 2, camera.current.pitch + .1)
if (e.key === 'ArrowDown') camera.current.pitch = Math.max(-Math.PI / 2, camera.current.pitch - .1)
draw()
}
const context = e => e.preventDefault()
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', cancel); canvas.addEventListener('wheel', wheel, { passive: false }); canvas.addEventListener('keydown', key); canvas.addEventListener('contextmenu', context)
const observer = new ResizeObserver(draw); observer.observe(canvas); draw()
return () => { observer.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', cancel); canvas.removeEventListener('wheel', wheel); canvas.removeEventListener('keydown', key); canvas.removeEventListener('contextmenu', context); drawRef.current = () => {} }
}, [onReady])
return <canvas ref={canvasRef} tabIndex={0} role="img" aria-label="광터지하차도 단면 3D 모델. 끌어서 회전, Shift와 끌기로 이동, 휠로 확대. 방향키로 회전, Home으로 전체 보기." />
}