프로젝트 틀만 남기고 초기화
This commit is contained in:
1 parent
fac29cb010
commit
aadccee687
32 files changed
+44
-331892
No files matched your search
+13
-60
@@ -1,61 +1,14 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Viewer from './Viewer'
|
||||
import { model, groups, palette, partDimensions, exportModel, exportOBJ } from './model/geometry'
|
||||
|
||||
function download(data, filename, type = 'application/json') {
|
||||
const url = URL.createObjectURL(new Blob([data], { type }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
export default function App() {
|
||||
const [hidden, setHidden] = useState(new Set()), [selected, setSelected] = useState(null)
|
||||
const [view, setView] = useState({ name: '전체', key: 0 }), [repeat, setRepeat] = useState(false)
|
||||
const [section, setSection] = useState('both'), [dimensions, setDimensions] = useState(true), [renderMode, setRenderMode] = useState(false), [surfaces, setSurfaces] = useState(true)
|
||||
const [search, setSearch] = useState(''), [tab, setTab] = useState('부재'), [source, setSource] = useState('단면A')
|
||||
const part = model.parts.find(p => p.id === selected), dims = part && partDimensions(part)
|
||||
const shown = model.parts.filter(p => !hidden.has(p.id) && (repeat || !p.repeat)).length
|
||||
const filtered = useMemo(() => model.parts.filter(p => `${p.name} ${p.layer}`.includes(search)), [search])
|
||||
const toggle = ids => setHidden(current => { const next = new Set(current), hide = ids.some(id => !current.has(id)); ids.forEach(id => hide ? next.add(id) : next.delete(id)); return next })
|
||||
const switchView = name => { setView({ name, key: Date.now() }); if (name === 'A-A') setSection('A'); else if (name === 'B-B') setSection('B'); else setSection('both') }
|
||||
return <main className="shell">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="#top"><span className="brand-icon">G</span>도면 모델 작업실</a>
|
||||
<span className="project-label">구조 검토 <b>/</b> 일반도 13 · 14</span>
|
||||
<a className="drawing-link" href="./drawings/광터지하차도_일반도(BLOCK7).dxf" download>원본 DXF ↓</a>
|
||||
<button className="export" onClick={() => download(exportOBJ(hidden), '광터_BLOCK7_단면윤곽.obj', 'text/plain')}>3D OBJ ↓</button>
|
||||
</header>
|
||||
<section className="intro" id="top">
|
||||
<div><span className="kicker">도면에서 읽은 구조, 근거가 보이는 모델</span><h1>광터지하차도 <em>BLOCK7</em></h1><p>원본 단면을 19.980m로 직선 연결한 3D 검토 모델</p></div>
|
||||
<span className="status-pill"><i />평면 배치 · 단면 연결 확인 필요</span>
|
||||
</section>
|
||||
<section className="metrics" aria-label="도면 기준값">
|
||||
<article><small>종단 구조 경계 간격</small><strong>19.980 <span>m</span></strong><p>STA 299.010 → 318.990 · 계산값</p></article>
|
||||
<article><small>단면 표기 폭</small><strong>20.100 <span>m</span></strong><p>일반도 14 · 도면 명시값</p></article>
|
||||
<article><small>벽체 상단 EL · A / B</small><strong className="el">106.675 <span>/</span> 107.307</strong><p>종단면 · 단면 표기 일치</p></article>
|
||||
<article><small>원본 윤곽 객체</small><strong>154 <span>쌍</span></strong><p>구조선 114 + 좌우 블록 40</p></article>
|
||||
</section>
|
||||
<div className="notice"><span>확인 필요</span><p>허용하신 검토용 직선 연결을 적용했습니다. 모든 연결면은 확인 필요입니다. 원본 단면 STA 299.000 / 319.000을 종단 경계 299.010 / 318.990에 대응시켰으며, 실제 평면 중심선·중심 EL·중간 형상은 미검증입니다.</p></div>
|
||||
<section className="workspace">
|
||||
<div className="viewer-card">
|
||||
<div className="viewer-head"><div><span className="eyebrow">모델 검토</span><h2>단면에서 공간으로</h2></div><div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>렌더링 보기</button></div></div>
|
||||
<div className="viewbar"><div className="views">{['전체', '평면', '정면', '측면', 'A-A', 'B-B'].map(name => <button className={view.name === name ? 'active' : ''} key={name} onClick={() => switchView(name)}>{name}</button>)}</div><label><input type="checkbox" checked={surfaces} onChange={e => setSurfaces(e.target.checked)} />연결면</label><label><input type="checkbox" checked={dimensions} onChange={e => setDimensions(e.target.checked)} />치수</label></div>
|
||||
<div className="canvas-wrap"><Viewer {...{ hidden, selected, view, repeat, section, dimensions, renderMode, surfaces }} onSelect={setSelected} /><div className="canvas-tag">{renderMode ? '렌더링 · 도면 검토용 시각화 · 실사 아님' : '기본 모델 · 모든 연결면 확인 필요'}</div><div className="model-note">황토색 점선: 확인 필요 연결부</div></div>
|
||||
<div className="viewer-foot"><span>끌기 회전 · 휠 확대 · Shift+끌기 이동 · 두 손가락 확대/이동</span><button onClick={() => switchView('전체')}>전체 보기 ↗</button></div>
|
||||
<div className="selection" aria-live="polite">{part ? <><div className="selection-title"><span className="eyebrow">선택한 윤곽</span><button onClick={() => setSelected(null)} aria-label="선택 해제">×</button></div><h3>{part.name}</h3><div className="selection-grid"><p>횡방향 범위<strong>{dims.width.toFixed(3)} m</strong></p><p>연결 길이<strong>19.980 m · 확인 필요</strong></p><p>표고 범위 EL<strong>{dims.elevation.map(v => v.toFixed(3)).join(' ~ ')}</strong></p></div><p className="source">{part.source}<br />원본 선 번호: {part.handles.join(' / ')}</p><p className="source">치수는 원본 좌표로 계산한 값입니다. 선의 범위이며 부재 두께를 확정한 값이 아닙니다. 연결 길이와 방향은 검토용 가정입니다.</p><button className="subtle" onClick={() => toggle([part.id])}>선택한 윤곽 숨기기</button></> : <><span className="selection-dot" /><div><strong>윤곽을 선택해 근거를 확인하세요</strong><p>화면의 선이나 오른쪽 목록을 선택하면 이름·치수·원본 선 번호를 보여줍니다.</p></div></>}</div>
|
||||
</div>
|
||||
<aside className="side-card">
|
||||
<div className="tabs">{['부재', '도면', '검증'].map(t => <button key={t} className={tab === t ? 'active' : ''} onClick={() => setTab(t)}>{t}</button>)}</div>
|
||||
{tab === '부재' && <><div className="panel-heading"><h2>윤곽 목록</h2><span>{shown} / {model.parts.length} 표시</span></div><input className="search" aria-label="윤곽 검색" placeholder="부재 이름 또는 레이어 검색" value={search} onChange={e => setSearch(e.target.value)} /><label className="repeat"><input type="checkbox" checked={repeat} onChange={e => setRepeat(e.target.checked)} />포장 반복선·내부선 표시</label><div className="list-actions"><button onClick={() => setHidden(new Set())}>모두 표시</button><button onClick={() => setHidden(new Set(model.parts.map(p => p.id)))}>모두 숨김</button></div><div className="part-list">{groups.map((g, i) => {
|
||||
const ps = filtered.filter(p => p.group === g)
|
||||
if (!ps.length) return null
|
||||
const allHidden = ps.every(p => hidden.has(p.id))
|
||||
return <details key={g} open={search ? true : undefined}><summary><span className="swatch" style={{ background: palette[i % palette.length] }} /><strong>{g}</strong><small>{ps.length}</small></summary><div className="group-actions"><button onClick={() => toggle(ps.map(p => p.id))}>{allHidden ? '그룹 표시' : '그룹 숨김'}</button></div>{ps.map(p => <div className={`part-row ${selected === p.id ? 'chosen' : ''}`} key={p.id}><button onClick={() => setSelected(p.id)} title={p.name}>{p.name}</button><input aria-label={`${p.name} 표시`} type="checkbox" checked={!hidden.has(p.id)} onChange={() => toggle([p.id])} /></div>)}</details>
|
||||
})}{!filtered.length && <p className="empty">검색 결과가 없습니다.</p>}</div><button className="subtle" onClick={() => download(JSON.stringify(exportModel(hidden), null, 2), '광터_BLOCK7_단면모델.json')}>좌표·근거 JSON ↓</button><p className="panel-note">선 개수는 실제 부재 개수와 다릅니다. 반복선은 기본적으로 숨겨져 있습니다.</p></>}
|
||||
{tab === '도면' && <><h2>원본 좌표 검토</h2><p className="panel-note">주요 LINE·TEXT와 공동구 선을 발췌한 검토 자료입니다. 전체 도면은 원본 DXF를 확인하세요.</p><div className="source-tabs">{['단면A', '단면B', '평면검토', '종단검토', '측구상세'].map(s => <button key={s} className={s === source ? 'active' : ''} onClick={() => setSource(s)}>{s}</button>)}</div><a href={`./analysis/${source}.svg`} target="_blank" rel="noreferrer"><img className="drawing-preview" src={`./analysis/${source}.svg`} alt={`${source} 원본 좌표 발췌`} /><span className="open-drawing">크게 보기 ↗</span></a><div className="source-note"><strong>그림 읽는 법</strong><p>녹색은 두 콘크리트 레이어, 황토색은 공동구 삽입 블록, 회색은 보조선입니다. 도면 기호·일부 블록은 생략되어 있습니다.</p></div></>}
|
||||
{tab === '검증' && <><h2>확인한 것과 남은 것</h2><ul className="checks"><li><b>확인</b><span>A/B 벽체 상단 EL<br /><small>종단면과 단면 각각 일치</small></span></li><li><b>확인</b><span>단면별 구조선 114개<br /><small>MAJR 108 + MINR 6, 원본 번호 보존</small></span></li><li><b>확인</b><span>공동구 실제 직선·원호<br /><small>좌우 반전과 단면별 높이 반영</small></span></li><li className="pending"><b>미확정</b><span>평면 중심선·절단 위치<br /><small>중심 EL·중간 곡률 미검증</small></span></li><li className="pending"><b>미확정</b><span>단면·종단 측점 차이<br /><small>각 끝 10mm 차이, 경계 대응 필요</small></span></li><li className="pending"><b>미확정</b><span>연결 표면·체적·재료<br /><small>실제 중간 형상과 상세 연장 미검증</small></span></li></ul><div className="source-note"><strong>현재 표현 범위</strong><p>두 단면을 19.980m로 직선 연결했습니다. 연결면은 모두 확인 필요이며, 평면 배치와 실제 중간 형상은 미검증입니다. 렌더링은 표면 명암과 그림자를 더한 도면 검토용 시각화입니다. 닫힌 체적 모델이 아닙니다.</p></div></>}
|
||||
</aside>
|
||||
</section>
|
||||
<footer><span>광터지하차도 BLOCK7 · 도면 검토용</span><span>기준 단위 m · 표고 EL · 미확정 형상은 분석 기록에서 관리</span></footer>
|
||||
</main>
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<main className="welcome">
|
||||
<section className="welcome-card" aria-labelledby="welcome-title">
|
||||
<span className="welcome-mark" aria-hidden="true">3D</span>
|
||||
<p className="welcome-kicker">새 프로젝트</p>
|
||||
<h1 id="welcome-title">작업을 시작할 준비가 되었습니다</h1>
|
||||
<p className="welcome-description">
|
||||
이 공간은 비어 있습니다. 도면과 작업 내용을 추가해 새 모델을 만들어 보세요.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { model, groups, palette, profileVertices, partFaces } 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, surfaces, onReady }) {
|
||||
const canvasRef = useRef(null), camera = useRef(initial()), drawRef = useRef(() => {})
|
||||
const scene = useRef({ hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces })
|
||||
useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces])
|
||||
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 = [], faceHits = [], 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, surfaces } = 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 faces = [], edges = []
|
||||
const shade = (hex, amount) => '#' + [1, 3, 5].map(i => Math.round(parseInt(hex.slice(i, i + 2), 16) * amount).toString(16).padStart(2, '0')).join('')
|
||||
const fill = (pts, color) => { ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.closePath(); ctx.fillStyle = color; ctx.fill() }
|
||||
model.parts.forEach(part => {
|
||||
if (hidden.has(part.id) || (!repeat && part.repeat)) return
|
||||
const color = palette[groups.indexOf(part.group) % palette.length]
|
||||
if (surfaces && section === 'both') {
|
||||
for (const points of partFaces(part)) {
|
||||
const pts = points.map(p => project(p, w, h))
|
||||
const u = points[1].map((v, i) => v - points[0][i]), v = points[2].map((v, i) => v - points[0][i])
|
||||
const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
|
||||
const light = Math.abs((n[0] * .35 + n[1] * .8 - n[2] * .45) / (Math.hypot(...n) || 1))
|
||||
faces.push({ type: 'face', pts, points, part, color: selected === part.id ? '#cf9560' : shade(color, renderMode ? .64 + .32 * light : .95), depth: pts.reduce((s, p) => s + p[2], 0) / 3 })
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
})
|
||||
if (renderMode && faces.length) {
|
||||
ctx.globalAlpha = .025
|
||||
for (const face of faces) fill(face.points.map(([x, y, z]) => project([x + (y - .5) * .35, .5, z + (y - .5) * .45], w, h)), '#364e39')
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
hits = []; faceHits = []
|
||||
const primitives = [...faces, ...edges].sort((a, b) => a.depth - b.depth)
|
||||
for (const primitive of primitives) {
|
||||
if (primitive.type === 'face') {
|
||||
fill(primitive.pts, primitive.color)
|
||||
faceHits.push({ pts: primitive.pts, id: primitive.part.id, depth: primitive.depth })
|
||||
} else {
|
||||
const { a, b, part, color, s } = primitive, active = selected === part.id
|
||||
line(a, b, active ? '#cf762f' : renderMode ? '#526e5f' : color, active ? 3.7 : part.repeat ? .6 : 1.3)
|
||||
hits.push({ a, b, id: part.id, s, depth: primitive.depth })
|
||||
}
|
||||
}
|
||||
if (surfaces && section === 'both') {
|
||||
ctx.setLineDash([4, 5])
|
||||
for (const part of model.parts.filter(p => !hidden.has(p.id) && !p.repeat)) {
|
||||
const a = profileVertices(part, 0), b = profileVertices(part, 1)
|
||||
if (part.kind !== 'arc') line(project(a[0], w, h), project(b[0], w, h), '#a47f4b70', .55)
|
||||
}
|
||||
ctx.setLineDash([])
|
||||
} ctx.font = '12px sans-serif'
|
||||
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
|
||||
const z = (s - .5) * model.length
|
||||
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.longitudinalStations[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 inside = pts => { const cross = (a, b) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); const values = pts.map((a, i) => cross(a, pts[(i + 1) % pts.length])); return values.every(v => v >= 0) || values.every(v => v <= 0) }
|
||||
const edge = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7)
|
||||
const face = [...faceHits].reverse().find(h => inside(h.pts))
|
||||
const hit = edge && (!face || edge.depth >= face.depth - .1) ? edge : face
|
||||
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으로 전체 보기." />
|
||||
}
|
||||
|
||||
|
||||
|
||||
+15
-3
@@ -1,4 +1,16 @@
|
||||
:root{font-family:'Malgun Gothic','Apple SD Gothic Neo',sans-serif;color:#263a32;background:#eff2ed;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input{font:inherit}button,a,input{outline-offset:4px}button{cursor:pointer}button{border:0;background:none;color:inherit}a{color:inherit;text-decoration:none}button:hover{color:#2b735c}button:focus-visible,a:focus-visible{outline:2px solid #bc763b}.shell{max-width:1600px;margin:auto;padding:0 36px 24px}.topbar{height:76px;display:flex;align-items:center;gap:18px;border-bottom:1px solid #d9e1d7}.brand{display:flex;gap:10px;align-items:center;font-size:14px;font-weight:700}.brand-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:10px;background:#446b58;color:#fff;font-family:Georgia,serif;font-size:23px}.project-label{margin-left:auto;font-size:11px;color:#7a897e}.project-label b{margin:0 9px;color:#b1b9b1}.drawing-link,.export{padding:10px 13px;border:1px solid #d3ddd2;border-radius:7px;font-size:11px}.export{background:#426851;color:#fff;border-color:#426851}.export:hover{color:white;background:#31513e}.intro{display:flex;justify-content:space-between;align-items:center;padding:35px 0 26px}.kicker,.eyebrow{font-size:10px;letter-spacing:.08em;color:#7a8d7e;display:block;font-weight:700}h1{margin:8px 0;font-size:30px;letter-spacing:-.055em}h1 em{font-style:normal;font-weight:400;margin-left:8px;color:#738b79}.intro p{margin:0;color:#7c8d80;font-size:12px}.status-pill{display:flex;align-items:center;gap:7px;border:1px solid #e0d6bd;background:#f8f4e9;border-radius:30px;padding:9px 13px;font-size:11px;color:#9b793c}.status-pill i{width:6px;height:6px;background:#b08a45;border-radius:50%}.metrics{display:grid;grid-template-columns:repeat(4,1fr);background:#f9fbf7;border:1px solid #dce4d9;border-radius:12px;margin-bottom:18px;padding:20px 0}.metrics article{padding:0 23px;border-right:1px solid #e3e8df}.metrics article:last-child{border:0}.metrics small{display:block;font-size:10px;color:#7d8b7d}.metrics strong{font-family:Georgia,serif;font-size:28px;font-weight:400;display:block;margin:9px 0}.metrics strong.el{font-size:23px;line-height:32px}.metrics strong span{font:12px sans-serif;color:#899584}.metrics p{font-size:9px;margin:0;color:#8b9687}.notice{display:flex;gap:12px;align-items:center;background:#f5f0e4;border:1px solid #e6dcc7;border-radius:8px;padding:11px 15px;margin-bottom:20px}.notice>span{font-size:10px;font-weight:700;white-space:nowrap;color:#967136}.notice p{font-size:11px;line-height:1.75;margin:0;color:#8b7e62}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:20px;align-items:start}.viewer-card,.side-card{background:#fafcf8;border:1px solid #dbe4d7;border-radius:12px;overflow:hidden}.viewer-head{display:flex;align-items:center;justify-content:space-between;padding:20px 22px 16px}h2{font-size:17px;letter-spacing:-.04em;margin:5px 0 0}.segmented{display:flex;padding:3px;background:#eaf0e7;border-radius:7px}.segmented button{font-size:10px;padding:7px 11px;border-radius:5px;color:#7f8d7b}.segmented .active{background:#fff;box-shadow:0 1px 4px #233c2912;color:#496c52}.viewbar{display:flex;justify-content:space-between;align-items:center;padding:0 20px 12px;gap:12px}.views{display:flex;gap:4px}.views button{font-size:10px;padding:7px 10px;border-radius:5px;border:1px solid transparent}.views button.active{border-color:#ccdac9;background:#eef3ea;color:#426a50}.viewbar label{display:flex;align-items:center;font-size:10px;color:#7e8c79;gap:4px}input[type=checkbox]{accent-color:#547862;cursor:pointer}.canvas-wrap{position:relative;border-top:1px solid #dfe8da;border-bottom:1px solid #dfe8da}.canvas-wrap canvas{display:block;width:100%;height:510px;touch-action:none;cursor:grab}.canvas-wrap canvas:active{cursor:grabbing}.canvas-wrap canvas:focus-visible{outline:2px solid #7d9a83;outline-offset:-2px}.canvas-tag{position:absolute;left:18px;top:17px;background:#fcfffbd9;border:1px solid #d5e0d2;border-radius:5px;color:#6c846f;font-size:9px;padding:7px 9px;pointer-events:none}.model-note{position:absolute;right:16px;top:23px;color:#aa824b;font-size:9px;pointer-events:none}.viewer-foot{display:flex;justify-content:space-between;align-items:center;padding:13px 20px;color:#8b9885;font-size:9px}.viewer-foot button{font-size:10px;color:#56735b}.selection{margin:0 20px 20px;border-top:1px solid #e2e9dd;min-height:94px;padding:18px 0 0;display:flex;align-items:center;gap:13px}.selection:has(h3){display:block}.selection strong{font-size:11px;font-weight:500}.selection p{font-size:10px;color:#899581;line-height:1.8;margin:7px 0}.selection-dot{width:10px;height:10px;border-radius:50%;border:2px solid #87a78c}.selection-title{display:flex;align-items:center;justify-content:space-between}.selection-title button{font-size:20px}.selection h3{font-size:15px;margin:5px 0}.selection-grid{display:flex;gap:35px}.selection-grid p strong{display:block;color:#4c6e53}.selLine truncated
|
||||
.viewer-card,.side-card,.metrics article{min-width:0}.canvas-wrap canvas{max-width:100%}.notice p{min-width:0}@media(max-width:480px){.topbar{height:auto;min-height:65px;flex-wrap:wrap;padding:14px 0}.export{margin-left:auto}.viewer-head{gap:8px}.segmented button{font-size:9px}.viewer-foot{gap:10px}.viewer-foot span{min-width:0}.metrics p{line-height:1.7}.notice{align-items:flex-start}}
|
||||
:root {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
color: #263a32;
|
||||
background: #eff2ed;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.viewbar{flex-wrap:wrap}.views{margin-right:auto}.selection-grid{flex-wrap:wrap;gap:12px 25px}@media(max-width:480px){.viewbar{gap:6px}.views{width:100%}.viewbar>label{font-size:9px}}
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; min-height: 100vh; margin: 0; }
|
||||
.welcome { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
|
||||
.welcome-card { width: min(100%, 560px); padding: 48px 32px; text-align: center; background: #fafcf8; border: 1px solid #dbe4d7; border-radius: 16px; }
|
||||
.welcome-mark { display: grid; width: 52px; height: 52px; margin: 0 auto 24px; place-items: center; border-radius: 15px; background: #446b58; color: white; font-size: 17px; font-weight: 700; }
|
||||
.welcome-kicker { margin: 0 0 10px; color: #71877a; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
|
||||
h1 { margin: 0; font-size: clamp(22px, 5vw, 30px); letter-spacing: -.05em; }
|
||||
.welcome-description { margin: 16px 0 0; color: #77857b; font-size: 14px; line-height: 1.8; }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,40 +0,0 @@
|
||||
import data from './block7.json' with { type: 'json' }
|
||||
|
||||
export const model = data
|
||||
export const groups = [...new Set(data.parts.map(p => p.group))]
|
||||
export const palette = ['#b0b5aa', '#a3b3a6', '#a3b3a6', '#acb8b0', '#b2aa91', '#aeb9aa', '#aeb9aa', '#bab6a5', '#adb3a6', '#a2b5a0', '#a2b5a0']
|
||||
export function profileVertices(part, section) {
|
||||
const z = (section - .5) * data.length
|
||||
return part[section === 0 ? 'a' : 'b'].map(([x, el]) => [x, el - 100, z])
|
||||
}
|
||||
export function partFaces(part) {
|
||||
if (part.repeat) return []
|
||||
const a = profileVertices(part, 0), b = profileVertices(part, 1), faces = []
|
||||
for (let i = 0; i < a.length - 1; i++) {
|
||||
faces.push([a[i], a[i + 1], b[i + 1]], [a[i], b[i + 1], b[i]])
|
||||
}
|
||||
return faces
|
||||
}
|
||||
export function partDimensions(part) {
|
||||
const points = [...part.a, ...part.b]
|
||||
const width = Math.max(...points.map(p => p[0])) - Math.min(...points.map(p => p[0]))
|
||||
const elevation = [Math.min(...points.map(p => p[1])), Math.max(...points.map(p => p[1]))]
|
||||
return { width, elevation, length: part.repeat ? null : data.length, sourceStations: data.sectionStations }
|
||||
}
|
||||
export function exportModel(hidden) {
|
||||
return { ...data, parts: data.parts.filter(p => !hidden.has(p.id)), description: '원본 단면을 19.980m 직선 연결한 검토용 표면. 모든 연결부 확인 필요. 닫힌 체적 아님.', coordinateSystem: 'X=횡방향(m), Y=EL(m), Z=종단 경계 기준 거리(m); 실제 평면 좌표 미확정' }
|
||||
}
|
||||
export function exportOBJ(hidden = new Set()) {
|
||||
const rows = ['# 광터지하차도 BLOCK7 검토용 표면 모델', '# 단위 m. X 횡방향 / Y EL / Z 종단 경계 기준 거리.', '# 모든 연결면 확인 필요. 평면 배치·중간 형상 미검증. 닫힌 체적 아님.']
|
||||
let index = 1
|
||||
for (const part of data.parts.filter(p => !hidden.has(p.id))) {
|
||||
rows.push(`# ${part.name} / ${part.source}`, `o ${part.id.replace(/[^a-zA-Z0-9-]/g, '_')}`)
|
||||
const a = profileVertices(part, 0), b = profileVertices(part, 1), n = a.length
|
||||
;[...a, ...b].forEach(([x, y, z]) => rows.push(`v ${x} ${y + 100} ${z + data.length / 2}`))
|
||||
rows.push(`l ${a.map((_, i) => index + i).join(' ')}`, `l ${b.map((_, i) => index + n + i).join(' ')}`)
|
||||
if (!part.repeat) for (let i = 0; i < n - 1; i++) rows.push(`f ${index + i} ${index + i + 1} ${index + n + i + 1}`, `f ${index + i} ${index + n + i + 1} ${index + n + i}`)
|
||||
index += n * 2
|
||||
}
|
||||
return rows.join('\n') + '\n'
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user