BLOCK8 중심선과 단면 근거로 3D 모델 생성

This commit is contained in:
김지영 committed 2026-10-20 12:39:40 +09:00
1 parent e7d025075e
commit 9d11b4ed62
13 files changed
+18693 -269

No files matched your search

+132 -65
View File
@@ -1,54 +1,67 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import model from './model/block8.json' with { type: 'json' }
import ModelViewer from './ModelViewer'
const views = {
전체: { transform: 'rotate(-8 450 180) skewY(-2deg)' },
평면: { transform: 'rotate(0 450 180)' },
정면: { transform: 'rotate(-90 450 180)' },
측면: { transform: 'rotate(90 450 180)' },
const categoryLabels = {
major: 'CS-CONC-MAJR 구조선',
minor: 'CS-CONC-MINR 구조선',
insert: '블록 내부 콘크리트 윤곽',
}
const viewNames = ['전체', '평면', '정면', '측면', 'A-A', 'B-B']
function StationModel({ view }) {
return (
<svg className="model-svg" viewBox="0 0 900 360" role="img" aria-labelledby="model-title model-desc">
<title id="model-title">BLOCK8 확인된 종단 기준선</title>
<desc id="model-desc">측점 319.010에서 338.990까지 19.980미터의 기준 구간입니다. 단면 형상은 미확정입니다.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto-start-reverse">
<path d="M 0 0 L 8 4 L 0 8 z" fill="#718b78" />
</marker>
</defs>
<g className="model-grid">
<path d="M90 250 L810 250 M130 280 L850 280 M110 220 L830 220" />
<path d="M180 200 L180 290 M360 200 L360 290 M540 200 L540 290 M720 200 L720 290" />
</g>
<g style={views[view]} className="station-line">
<path className="station-shadow" d="M180 185 L720 185" />
<path className="station-main" d="M180 185 L720 185" />
<path className="station-cap" d="M180 164 L180 207 M720 164 L720 207" />
<circle cx="180" cy="185" r="9" />
<circle cx="720" cy="185" r="9" />
<path className="dimension" d="M190 137 L710 137" markerStart="url(#arrow)" markerEnd="url(#arrow)" />
<path className="dimension-tick" d="M180 145 L180 164 M720 145 L720 164" />
<text x="450" y="122" textAnchor="middle" className="length-label">19.980 m · 측점 차이</text>
<text x="180" y="228" textAnchor="middle" className="endpoint-label">STA. 0K+319.010</text>
<text x="720" y="228" textAnchor="middle" className="endpoint-label">STA. 0K+338.990</text>
</g>
<g className="axis-labels">
<path d="M82 303 L150 303" /><path d="M82 303 L82 263" />
<text x="157" y="307">종단 기준</text><text x="76" y="255">EL 기준 미연결</text>
</g>
</svg>
)
function pointRange(parts, key, coordinate) {
const values = parts.flatMap(part => [...part.a, ...part.b].map(point => point[coordinate]))
return values.length ? [Math.min(...values), Math.max(...values)] : [0, 0]
}
export default function App() {
const [view, setView] = useState('전체')
const [renderMode, setRenderMode] = useState(false)
const [hidden, setHidden] = useState(new Set())
const [hiddenUnits, setHiddenUnits] = useState(new Set())
const [selected, setSelected] = useState(null)
const [tab, setTab] = useState('부재')
const [search, setSearch] = useState('')
const [resetToken, setResetToken] = useState(0)
const allParts = useMemo(() => model.units.flatMap(unit => unit.parts.map(part => ({ ...part, unitId: unit.id, unitName: unit.name }))), [])
const selectedPart = selected ? allParts.find(part => part.id === selected.partId && part.unitId === selected.unitId) : null
const selectedUnit = selected ? model.units.find(unit => unit.id === selected.unitId) : null
const visibleCount = allParts.filter(part => !hidden.has(part.id) && !hiddenUnits.has(part.unitId)).length
const filteredParts = allParts.filter(part => `${part.name} ${part.layer} ${part.unitName}`.toLowerCase().includes(search.toLowerCase()))
const dimensionRange = pointRange(allParts, 'a', 0)
const elevationRange = pointRange(allParts, 'a', 1)
const counts = {
major: allParts.filter(part => part.group === 'major').length,
minor: allParts.filter(part => part.group === 'minor').length,
insert: allParts.filter(part => part.group === 'insert').length,
}
const togglePart = id => setHidden(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const toggleGroup = group => setHidden(current => {
const ids = allParts.filter(part => part.group === group).map(part => part.id)
const hide = ids.some(id => !current.has(id))
const next = new Set(current)
ids.forEach(id => hide ? next.add(id) : next.delete(id))
return next
})
const toggleUnit = id => setHiddenUnits(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
return (
<main className="page-shell">
<header className="topbar">
<a className="brand" href="#top"><span className="brand-mark">B8</span><span>도면 모델 작업실</span></a>
<span className="project-chip">광터지하차도 · BLOCK8</span>
<nav className="file-links" aria-label="작업 자료">
<a href="/analysis/BLOCK8_3D모델_미리보기.svg" target="_blank" rel="noreferrer">모델 이미지 ↗</a>
<a href="/analysis/도면분석.md" target="_blank" rel="noreferrer">분석 파일 ↗</a>
<a href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 ↗</a>
<a href="/drawings/광터지하차도_BLOCK8.dxf" download>원본 DXF ↓</a>
@@ -57,44 +70,98 @@ export default function App() {
<section className="intro" id="top">
<div>
<span className="kicker">도면 근거가 확인된 범위부터 표시합니다</span>
<h1>BLOCK8 <em>모델 보기</em></h1>
<p>현재 도면에서 확인된 종단 구간 기준선입니다. 구조물 단면은 추가 확인이 필요합니다.</p>
<span className="kicker">평면 중심선 · 종단 표고 · 단면 구조선을 연결한 검토 모델</span>
<h1>광터지하차도 <em>BLOCK8</em></h1>
<p>구조선과 블록 내부 콘크리트 선을 추출해 두 측점 단면 사이를 연결했습니다.</p>
</div>
<span className="status-pill"><i /> 단면 윤곽 확인 필요</span>
<span className="status-pill"><i /> 일부 배치 대응 확인 필요</span>
</section>
<section className="summary-grid" aria-label="확인된 기준값">
<article><span>시점</span><strong>0K+319.010</strong><small>도면 명시값</small></article>
<article><span>종점</span><strong>0K+338.990</strong><small>도면 명시값</small></article>
<article><span>구간 길이</span><strong>19.980 <i>m</i></strong><small>측점 차이로 계산</small></article>
<article className="unknown"><span>단면 폭 · 높이</span><strong>확인 필요</strong><small>구조선과 EL 연결 전</small></article>
<section className="metrics" aria-label="주요 모델 기준값">
<article><small>평면 중심선 길이</small><strong>19.980 <i>m</i></strong><p>중심선 원호와 양 끝 구조선 교점 간 계산</p></article>
<article><small>단면 도면 표기 폭</small><strong>20.300 <i>m</i></strong><p>단면 치수 · 외곽 구조선 최대 폭은 20.500m</p></article>
<article><small>벽체 상단 EL</small><strong className="el">107.215 <i>→</i> 107.857</strong><p>STA 319.000 → 339.000</p></article>
<article><small>중심 기준 EL</small><strong className="el">102.094 <i>→</i> 101.517</strong><p>단면 중심 표시와 EL 주석 대조</p></article>
</section>
<section className="model-card" aria-labelledby="model-heading">
<div className="card-heading">
<div><span className="eyebrow">3D 기준 보기 · 형상 검토 중</span><h2 id="model-heading">확인된 종단 구간</h2></div>
<div className="view-controls" aria-label="보기 방향">
{Object.keys(views).map(name => <button key={name} className={view === name ? 'active' : ''} onClick={() => setView(name)}>{name}</button>)}
<div className="notice">
<b>검토 상태</b>
<p>단면 A/B 구조선 114개씩과 내부 블록 윤곽을 반영했습니다. 평면도에 BLOCK8 표기가 있는 중심선이 2개 있어 둘 다 별도 객체로 표시했습니다. 두 배치에 같은 대표 단면을 적용한 것은 확인 필요 항목입니다.</p>
</div>
<section className="workspace">
<div className="viewer-card">
<div className="viewer-head">
<div><span className="eyebrow">도면 구조선 기반 · 열린 표면 모델</span><h2>BLOCK8 3D 구조 보기</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">{viewNames.map(name => <button key={name} className={view === name ? 'active' : ''} onClick={() => setView(name)}>{name}</button>)}</div>
<span>{visibleCount} / {allParts.length} 객체 표시</span>
</div>
<div className={`canvas-wrap ${renderMode ? 'render-mode' : ''}`}>
<ModelViewer units={model.units} hidden={hidden} hiddenUnits={hiddenUnits} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} />
<span className="canvas-tag">{renderMode ? '명암을 더한 도면 검토 시각화 · 실사 렌더링 아님' : '구조선 연결 표면 · 두 배치 중심선 표시'}</span>
</div>
<div className="viewer-foot"><span>끌기: 회전 · 휠: 확대/축소 · Shift+끌기 또는 오른쪽 끌기: 이동</span><button onClick={() => { setView('전체'); setResetToken(value => value + 1); setSelected(null) }}>전체 보기 ↗</button></div>
<div className="selection" aria-live="polite">
{selectedPart && selectedUnit ? <>
<div className="selection-title"><span className="eyebrow">선택한 구조선</span><button onClick={() => setSelected(null)} aria-label="선택 해제">×</button></div>
<h3>{selectedPart.name}</h3>
<div className="selection-grid">
<p>도면 레이어<strong>{selectedPart.layer}</strong></p>
<p>종단 연결 길이<strong>{selectedUnit.length.toFixed(3)} m</strong></p>
<p>단면 EL 범위<strong>{Math.min(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)} ~ {Math.max(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)}</strong></p>
<p>배치 객체<strong>{selectedUnit.name}</strong></p>
</div>
<p className="source-note">원본 HANDLE: {(selectedPart.sourceHandles || [selectedPart.sourceHandle, ...(selectedPart.sourceInsertHandles || [])]).filter(Boolean).join(' / ') || '블록 내부 객체'} · 이 표면은 닫힌 체적이 아닙니다.</p>
<button className="subtle" onClick={() => togglePart(selectedPart.id)}>{hidden.has(selectedPart.id) ? '선택 객체 표시' : '선택 객체 숨기기'}</button>
</> : <><span className="selection-dot" /><div><strong>모델 표면을 선택하면 원본 레이어와 EL 범위를 확인할 수 있습니다.</strong><p>옆 부재 목록에서 이름을 눌러 선택하거나 체크를 해제해 개별 객체를 숨길 수 있습니다.</p></div></>}
</div>
</div>
<div className="model-stage"><StationModel view={view} /><span className="stage-note">선은 측점 범위를 나타냅니다 · 구조물 단면/폭/높이를 표현한 형상이 아닙니다</span></div>
<div className="model-caption"><span><i className="legend-line" /> 도면 명시 측점으로 계산한 기준선</span><span>단면 윤곽 · 배치 · EL 연결: 확인 필요</span></div>
<aside className="side-card">
<div className="tabs">{['부재', '도면·검증'].map(name => <button key={name} className={tab === name ? 'active' : ''} onClick={() => setTab(name)}>{name}</button>)}</div>
{tab === '부재' ? <>
<div className="panel-heading"><h2>모델 객체</h2><span>{visibleCount} 표시</span></div>
<div className="unit-toggles">{model.units.map((unit, index) => <label key={unit.id}><input type="checkbox" checked={!hiddenUnits.has(unit.id)} onChange={() => toggleUnit(unit.id)} />평면 중심선 {index + 1}<small>CAD상 중심 간격 {index ? '35.000' : '—'} m</small></label>)}</div>
<input className="search" aria-label="모델 객체 검색" placeholder="레이어 또는 객체 이름 검색" value={search} onChange={event => setSearch(event.target.value)} />
<div className="group-toggles">
{Object.entries(categoryLabels).map(([group, label]) => {
const groupParts = allParts.filter(part => part.group === group)
const shown = groupParts.filter(part => !hidden.has(part.id)).length
return <div key={group}><span className={`swatch ${group}`} /><span className="group-label">{label}<small>{shown} / {counts[group]}</small></span><button onClick={() => toggleGroup(group)}>{shown ? '숨김' : '표시'}</button></div>
})}
</div>
<div className="part-list">
{filteredParts.map(part => <div className={`part-row ${selected?.partId === part.id ? 'chosen' : ''} ${hidden.has(part.id) ? 'is-hidden' : ''}`} key={part.id}>
<button onClick={() => { setSelected({ unitId: part.unitId, partId: part.id }); setTab('부재') }} title={part.name}>{part.name}<small>{part.unitName.replace('BLOCK8 평면 중심선 ', '배치 ')}</small></button>
<input type="checkbox" aria-label={`${part.name} 표시`} checked={!hidden.has(part.id)} onChange={() => togglePart(part.id)} />
</div>)}
{!filteredParts.length && <p className="empty">검색 결과가 없습니다.</p>}
</div>
<p className="panel-note">단면 구조선은 원본 선 단위로 나누었습니다. 연결 면은 두 단면 사이의 계산 표면입니다.</p>
</> : <>
<h2 className="panel-title">도면 대조 및 추가 확인</h2>
<ul className="checks">
<li><b>확인</b><span>측점 간격 19.980m<small>중심선 원호와 CS-CONC-MAJR 끝선 교점으로 계산</small></span></li>
<li><b>확인</b><span>단면 구조선 114개씩<small>CS-CONC-MAJR 108 + CS-CONC-MINR 6</small></span></li>
<li><b>확인</b><span>블록 내부 콘크리트<small>A$C2017021411375356 내부 18선 + 2원호, 4개 삽입 참조</small></span></li>
<li className="pending"><b>검토</b><span>두 중심선과 대표 단면 대응<small>두 평면 배치는 표시했으며 구조도에서 동일 단면 적용 여부 확인 필요</small></span></li>
<li className="pending"><b>검토</b><span>폭 20.300m / 외곽선 20.500m<small>단면 치수와 외곽 구조선 범위 차이 0.200m</small></span></li>
<li className="pending"><b>검토</b><span>기초 최저선과 EL 주석 차이<small>모델 선 끝이 표기 기초 EL보다 0.100m 낮음</small></span></li>
</ul>
<div className="source-links"><a href="/analysis/도면분석.md" target="_blank" rel="noreferrer">상세 도면 분석 열기 ↗</a><a href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 열기 ↗</a><a href="/drawings/광터지하차도_BLOCK8.dxf" download>원본 DXF 받기 ↓</a></div>
<p className="panel-note">렌더링은 구조 검토용 시각화입니다. 실제 재료·폐합 체적을 나타내지 않습니다.</p>
</>}
</aside>
</section>
<section className="bottom-grid">
<article className="info-card">
<span className="eyebrow">진행 상태</span><h2>분석은 완료, 구조 형상은 미확정</h2>
<p>도면에서 측점과 구간 길이를 읽었습니다. DXF 단위가 지정되지 않았고 BLOCK8의 전체 구조물 윤곽을 확인하지 못해, 임의의 폭·높이를 넣은 구조물 모델은 표시하지 않습니다.</p>
<a className="text-link" href="/analysis/도면분석.md" target="_blank" rel="noreferrer">도면 분석 내용 보기 <span>↗</span></a>
</article>
<article className="info-card log-card">
<span className="eyebrow">작업 기록</span><h2>분석 자료와 변경 이력</h2>
<p>초기화 이후 도면 등록과 분석 기록을 날짜·커밋 단위로 정리했습니다.</p>
<a className="text-link" href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 파일 열기 <span>↗</span></a>
</article>
<article className="info-card"><span className="eyebrow">단면 기준 A-A → B-B</span><h2>EL값과 폭 분할</h2><p>중심 EL은 102.094 → 101.517m, 벽체 상단은 107.215 → 107.857m입니다. 표기 기초 하단은 100.877 → 100.392m입니다. 폭 치수는 0.9 + 8.75 + 1.0 + 8.75 + 0.9 = 20.300m입니다.</p></article>
<article className="info-card"><span className="eyebrow">모델 근거</span><h2>좌표 단위 검산</h2><p>DXF의 단위 설정은 비어 있지만, 구조선 좌표 차 20,300과 도면 치수 20.300m가 맞아 좌표를 mm로 환산했습니다. 현재 범위 EL {elevationRange[0].toFixed(3)}~{elevationRange[1].toFixed(3)}m, 단면 외곽선 폭 {dimensionRange[1] - dimensionRange[0] === 0 ? '—' : '20.500'}m입니다.</p></article>
</section>
<footer>단위 m · 도면 명시값과 계산값을 구분해 표시합니다.</footer>
<footer>출처: BLOCK8 DXF · 3D 면은 두 단면 선형 보간 · 미확정 대응은 검토 패널에 표시</footer>
</main>
)
}
+223
View File
@@ -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} />
}
+141 -91
View File
@@ -1,94 +1,144 @@
:root {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
color: #263a32;
background: #eff2ed;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
: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 { min-width:320px; min-height:100vh; margin:0; }
button,input { font:inherit; }
button { border:0; background:none; color:inherit; cursor:pointer; }
a { color:inherit; text-decoration:none; }
button:focus-visible,a:focus-visible,input:focus-visible { outline:2px solid #bc763b; outline-offset:3px; }
.page-shell { width:min(100% - 64px,1600px); margin:auto; padding-bottom:24px; }
.topbar { min-height:72px; display:flex; align-items:center; gap:18px; border-bottom:1px solid #d9e1d7; }
.brand { display:flex; align-items:center; gap:10px; font-size:14px; font-weight:700; white-space:nowrap; }
.brand-mark { display:grid; width:34px; height:34px; place-items:center; border-radius:10px; background:#446b58; color:#fff; font-size:11px; }
.project-chip { margin-right:auto; color:#7a897e; font-size:11px; }
.file-links { display:flex; gap:7px; }
.file-links a { padding:9px 11px; border:1px solid #d3ddd2; border-radius:7px; background:#fafcf8; color:#536d5b; font-size:10px; white-space:nowrap; }
.file-links a:hover,.source-links a:hover { border-color:#92a994; color:#315c42; }
.intro { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:29px 0 20px; }
.kicker,.eyebrow { display:block; color:#7a8d7e; font-size:10px; font-weight:700; letter-spacing:.08em; }
h1 { margin:8px 0; font-size:clamp(26px,3.3vw,34px); letter-spacing:-.055em; }
h1 em { margin-left:7px; color:#738b79; font-size:.8em; font-style:normal; font-weight:400; }
.intro p { margin:0; color:#7c8d80; font-size:11px; line-height:1.7; }
.status-pill { display:flex; align-items:center; gap:8px; padding:9px 13px; border:1px solid #e0d6bd; border-radius:30px; background:#f8f4e9; color:#9b793c; font-size:10px; white-space:nowrap; }
.status-pill i { width:7px; height:7px; border-radius:50%; background:#b08a45; }
.metrics { display:grid; grid-template-columns:repeat(4,1fr); margin-bottom:13px; padding:17px 0; border:1px solid #dce4d9; border-radius:11px; background:#f9fbf7; }
.metrics article { min-width:0; padding:0 18px; border-right:1px solid #e3e8df; }
.metrics article:last-child { border:0; }
.metrics small { display:block; color:#7d8b7d; font-size:9px; }
.metrics strong { display:block; margin:8px 0 5px; font-family:Georgia,serif; font-size:clamp(19px,2.3vw,26px); font-weight:400; white-space:nowrap; }
.metrics strong.el { font-size:clamp(17px,2vw,22px); }
.metrics strong i { color:#899584; font-family:sans-serif; font-size:11px; font-style:normal; }
.metrics p { margin:0; color:#8b9687; font-size:9px; line-height:1.55; }
.notice { display:flex; align-items:flex-start; gap:11px; margin-bottom:15px; padding:10px 13px; border:1px solid #e6dcc7; border-radius:8px; background:#f5f0e4; }
.notice b { padding-top:2px; color:#967136; font-size:10px; white-space:nowrap; }
.notice p { margin:0; color:#8b7e62; font-size:10px; line-height:1.65; }
.workspace { display:grid; grid-template-columns:minmax(0,1fr) 322px; gap:16px; align-items:start; }
.viewer-card,.side-card,.info-card { min-width:0; overflow:hidden; border:1px solid #dbe4d7; border-radius:11px; background:#fafcf8; }
.viewer-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:17px 19px 13px; }
h2 { margin:5px 0 0; font-size:16px; letter-spacing:-.04em; }
.segmented { display:flex; padding:3px; border-radius:7px; background:#eaf0e7; }
.segmented button { padding:6px 9px; border-radius:5px; color:#7f8d7b; font-size:9px; white-space:nowrap; }
.segmented button.active { background:#fff; color:#496c52; box-shadow:0 1px 4px #233c2912; }
.viewbar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:0 17px 10px; }
.views { display:flex; gap:3px; }
.views button { padding:6px 9px; border:1px solid transparent; border-radius:5px; font-size:9px; }
.views button.active { border-color:#ccdac9; background:#eef3ea; color:#426a50; }
.viewbar>span { color:#8b9687; font-size:9px; white-space:nowrap; }
.canvas-wrap { position:relative; overflow:hidden; border-top:1px solid #dfe8da; border-bottom:1px solid #dfe8da; background:#f4f7f3; }
.model-canvas { display:block; width:100%; height:485px; touch-action:none; cursor:grab; }
.model-canvas:active { cursor:grabbing; }
.model-canvas:focus-visible { outline:2px solid #7d9a83; outline-offset:-2px; }
.canvas-tag { position:absolute; top:12px; left:14px; max-width:calc(100% - 28px); padding:6px 8px; border:1px solid #d5e0d2; border-radius:5px; background:#fcfffbd9; color:#6c846f; font-size:8px; pointer-events:none; }
.render-mode .model-canvas { filter:saturate(.92) contrast(1.04); }
.viewer-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:11px 17px; color:#8b9885; font-size:8px; }
.viewer-foot button { color:#56735b; font-size:9px; white-space:nowrap; }
.selection { min-height:95px; margin:0 18px 17px; padding:14px 0 0; border-top:1px solid #e2e9dd; }
.selection:has(h3) { display:block; }
.selection:not(:has(h3)) { display:flex; align-items:center; gap:11px; }
.selection-dot { width:9px; height:9px; flex:0 0 auto; border-radius:50%; background:#91a78f; }
.selection strong { font-size:10px; }
.selection p { margin:5px 0 0; color:#819084; font-size:9px; line-height:1.6; }
.selection-title { display:flex; justify-content:space-between; align-items:center; }
.selection-title button { color:#8b958d; font-size:20px; }
.selection h3 { margin:7px 0 9px; font-size:13px; }
.selection-grid { display:flex; flex-wrap:wrap; gap:8px 20px; }
.selection-grid p { min-width:100px; margin:0; }
.selection-grid strong { display:block; margin-top:3px; color:#526c58; }
.selection .source-note { font-size:8px; }
.subtle { margin-top:7px; padding:6px 9px; border:1px solid #d8e1d6; border-radius:5px; color:#637a68; font-size:9px; }
.side-card { padding-bottom:13px; }
.tabs { display:flex; gap:17px; padding:14px 17px 11px; border-bottom:1px solid #e2e8df; }
.tabs button { padding:0 0 6px; color:#7e8b81; font-size:10px; }
.tabs button.active { border-bottom:2px solid #547862; color:#46684f; font-weight:700; }
.panel-heading { display:flex; justify-content:space-between; align-items:end; padding:12px 15px 9px; }
.panel-heading h2 { font-size:14px; }
.panel-heading span { color:#829084; font-size:9px; }
.unit-toggles { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:0 14px 10px; }
.unit-toggles label { display:block; padding:8px; border:1px solid #e0e7dd; border-radius:6px; color:#607665; font-size:9px; }
.unit-toggles input { margin:0 5px 0 0; accent-color:#547862; }
.unit-toggles small { display:block; margin:5px 0 0 17px; color:#8a968b; font-size:8px; }
.search { width:calc(100% - 28px); margin:0 14px 8px; padding:8px 9px; border:1px solid #dce4d9; border-radius:6px; background:#fff; color:#37473d; font-size:10px; }
.group-toggles { display:grid; gap:5px; padding:0 14px 9px; }
.group-toggles>div { display:flex; align-items:center; gap:7px; min-width:0; }
.swatch { width:8px; height:8px; flex:0 0 auto; border-radius:2px; background:#78917c; }
.swatch.minor { background:#d0aa69; }
.swatch.insert { background:#9a9074; }
.group-label { flex:1; color:#68776d; font-size:9px; }
.group-label small { display:block; margin-top:2px; color:#97a096; font-size:8px; }
.group-toggles button { padding:4px 6px; border:1px solid #dce4d9; border-radius:4px; color:#68806d; font-size:8px; }
.part-list { max-height:270px; margin:0 7px; overflow:auto; border-top:1px solid #e6ebe4; }
.part-row { display:flex; align-items:center; gap:5px; padding:2px 7px; border-bottom:1px solid #eff2ed; }
.part-row.chosen { background:#edf3eb; }
.part-row.is-hidden button { color:#a1aaa1; }
.part-row>button { min-width:0; flex:1; padding:5px 0; overflow:hidden; text-align:left; text-overflow:ellipsis; white-space:nowrap; color:#53665a; font-size:9px; }
.part-row small { display:block; margin-top:2px; color:#96a095; font-size:8px; }
.part-row input { accent-color:#547862; }
.empty { padding:10px; color:#879387; font-size:9px; }
.panel-note { margin:9px 14px 0; color:#8b9687; font-size:8px; line-height:1.6; }
.panel-title { padding:14px 15px 7px; font-size:14px; }
.checks { display:grid; gap:0; margin:0; padding:0 14px; list-style:none; }
.checks li { display:grid; grid-template-columns:44px 1fr; gap:8px; padding:9px 0; border-bottom:1px solid #edf0eb; }
.checks li>b { width:max-content; height:max-content; padding:3px 5px; border-radius:4px; background:#e9f1e8; color:#608064; font-size:8px; }
.checks li.pending>b { background:#f5efe1; color:#9a7c45; }
.checks li span { color:#5a6d60; font-size:9px; line-height:1.5; }
.checks li small { display:block; margin-top:3px; color:#929d92; font-size:8px; }
.source-links { display:grid; gap:5px; padding:11px 14px 0; }
.source-links a { padding:8px 9px; border:1px solid #dce4d9; border-radius:5px; color:#58745f; font-size:9px; }
.bottom-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }
.info-card { min-height:110px; padding:15px 17px; }
.info-card h2 { margin:5px 0 7px; font-size:13px; }
.info-card p { margin:0; color:#7d8b80; font-size:9px; line-height:1.7; }
footer { padding-top:15px; color:#89958a; font-size:8px; }
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
a { color: inherit; text-decoration: none; }
button { font: inherit; cursor: pointer; }
.page-shell { width: min(100% - 64px, 1380px); margin: 0 auto; padding-bottom: 25px; }
.topbar { min-height: 72px; display: flex; align-items: center; gap: 20px; border-bottom: 1px solid #d8e0d6; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 700; white-space: nowrap; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; background: #446b58; color: #fff; font-size: 12px; }
.project-chip { color: #819087; font-size: 11px; }
.file-links { display: flex; gap: 8px; margin-left: auto; }
.file-links a { padding: 9px 11px; border: 1px solid #d7dfd4; border-radius: 7px; background: #fafcf8; color: #536d5b; font-size: 11px; white-space: nowrap; }
.file-links a:hover,.text-link:hover { border-color: #89a28d; color: #2d6045; }
.intro { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 36px 0 25px; }
.kicker,.eyebrow { display: block; color: #788a7d; font-size: 10px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 8px 0; font-size: clamp(27px, 4vw, 34px); letter-spacing: -.06em; }
h1 em { margin-left: 7px; color: #778b7d; font-size: .8em; font-style: normal; font-weight: 400; }
.intro p,.info-card p { margin: 0; color: #79877c; font-size: 12px; line-height: 1.8; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid #e5dbc2; border-radius: 30px; background: #faf5e9; color: #967641; font-size: 11px; white-space: nowrap; }
.status-pill i { width: 7px; height: 7px; border-radius: 50%; background: #b98f48; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; overflow: hidden; border: 1px solid #dce4d9; border-radius: 11px; background: #fafcf8; }
.summary-grid article { min-height: 112px; padding: 18px 22px; border-right: 1px solid #e3e9e1; }
.summary-grid article:last-child { border: 0; }
.summary-grid span,.summary-grid small { display: block; color: #839084; font-size: 10px; }
.summary-grid strong { display: block; margin: 10px 0 6px; font-family: Georgia,serif; font-size: clamp(19px, 2.5vw, 26px); font-weight: 400; white-space: nowrap; }
.summary-grid strong i { color: #78877c; font-family: sans-serif; font-size: 12px; font-style: normal; }
.summary-grid .unknown strong { color: #a38048; font-family: inherit; font-size: 18px; }
.model-card,.info-card { overflow: hidden; border: 1px solid #dbe4d7; border-radius: 12px; background: #fafcf8; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 19px 22px 12px; }
h2 { margin: 5px 0 0; font-size: 17px; letter-spacing: -.04em; }
.view-controls { display: flex; gap: 4px; padding: 3px; border-radius: 7px; background: #edf1ea; }
.view-controls button { padding: 7px 11px; border: 0; border-radius: 5px; background: transparent; color: #768579; font-size: 10px; }
.view-controls button.active { background: white; color: #426a50; box-shadow: 0 1px 4px #233c2912; }
.model-stage { position: relative; display: grid; min-height: 330px; place-items: center; overflow: hidden; border-top: 1px solid #e0e7dd; border-bottom: 1px solid #e0e7dd; background: linear-gradient(180deg,#f6f9f5,#edf2ec); }
.model-svg { width: min(100%, 940px); height: auto; min-height: 270px; overflow: visible; }
.model-grid path { fill: none; stroke: #dbe4dc; stroke-width: 1; }
.station-line { transform-box: view-box; transform-origin: center; transition: transform .35s ease; }
.station-shadow { fill: none; stroke: #d2ddd2; stroke-width: 15; stroke-linecap: round; }
.station-main { fill: none; stroke: #547d62; stroke-width: 5; stroke-linecap: round; }
.station-cap { fill: none; stroke: #819782; stroke-width: 2; }
.station-line circle { fill: #fafcf8; stroke: #52785d; stroke-width: 4; }
.dimension { fill: none; stroke: #718b78; stroke-width: 1.5; }
.dimension-tick { fill: none; stroke: #a5b5a5; stroke-width: 1; }
.length-label { fill: #49694f; font-size: 16px; font-weight: 700; }
.endpoint-label { fill: #566e5c; font-size: 12px; }
.axis-labels path { stroke: #758e79; stroke-width: 2; }
.axis-labels text { fill: #809083; font-size: 10px; }
.stage-note { position: absolute; right: 15px; bottom: 12px; color: #9a835b; font-size: 9px; text-align: right; }
.model-caption { display: flex; justify-content: space-between; gap: 14px; padding: 13px 20px; color: #839084; font-size: 10px; }
.model-caption>span:first-child { display: inline-flex; align-items: center; gap: 8px; }
.legend-line { width: 19px; height: 3px; border-radius: 2px; background: #547d62; }
.bottom-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 16px; margin-top: 17px; }
.info-card { min-height: 172px; padding: 19px 21px; }
.info-card h2 { margin-bottom: 10px; }
.info-card p { max-width: 720px; }
.text-link { display: inline-flex; align-items: center; gap: 9px; margin-top: 14px; padding: 8px 10px; border: 1px solid #d8e1d6; border-radius: 6px; color: #4b6b52; font-size: 10px; }
.text-link span { font-size: 13px; }
footer { padding-top: 18px; color: #89958a; font-size: 9px; }
@media (max-width: 760px) {
.page-shell { width: min(100% - 32px, 620px); }
.topbar { flex-wrap: wrap; gap: 10px; padding: 13px 0; }
.project-chip { margin-left: auto; }
.file-links { width: 100%; margin: 0; }
.file-links a { flex: 1; text-align: center; }
.intro { align-items: flex-start; flex-direction: column; padding: 27px 0 19px; }
.summary-grid { grid-template-columns: 1fr 1fr; }
.summary-grid article { min-height: 98px; padding: 15px; border-bottom: 1px solid #e3e9e1; }
.summary-grid article:nth-child(2) { border-right: 0; }
.summary-grid article:nth-child(n+3) { border-bottom: 0; }
.card-heading { align-items: flex-start; flex-direction: column; padding: 17px 15px 12px; }
.model-stage { min-height: 260px; }
.model-svg { min-height: 220px; }
.endpoint-label { font-size: 11px; }
.stage-note { max-width: 260px; line-height: 1.5; }
.model-caption { flex-direction: column; padding: 12px 15px; }
.bottom-grid { grid-template-columns: 1fr; gap: 11px; }
.info-card { min-height: 0; padding: 17px; }
@media(max-width:1050px) {
.workspace { grid-template-columns:minmax(0,1fr) 290px; gap:12px; }
.metrics article { padding:0 12px; }
.model-canvas { height:430px; }
}
@media (max-width: 400px) {
.file-links { gap: 5px; }
.file-links a { padding: 8px 5px; font-size: 10px; }
.view-controls button { padding: 7px 9px; }
.model-stage { min-height: 220px; }
.length-label { font-size: 13px; }
@media(max-width:760px) {
.page-shell { width:min(100% - 30px,620px); }
.topbar { flex-wrap:wrap; gap:9px; padding:12px 0; }
.project-chip { margin-left:auto; margin-right:0; }
.file-links { width:100%; }
.file-links a { flex:1; padding:8px 5px; text-align:center; }
.intro { align-items:flex-start; flex-direction:column; padding:24px 0 17px; }
.metrics { grid-template-columns:1fr 1fr; padding:0; }
.metrics article { min-height:91px; padding:14px; border-bottom:1px solid #e3e8df; }
.metrics article:nth-child(2) { border-right:0; }
.metrics article:nth-child(n+3) { border-bottom:0; }
.notice { gap:8px; }
.workspace { grid-template-columns:1fr; }
.model-canvas { height:380px; }
.side-card { order:2; }
.part-list { max-height:330px; }
.bottom-grid { grid-template-columns:1fr; gap:9px; }
}
@media(max-width:440px) {
.viewer-head { align-items:flex-start; flex-direction:column; padding:15px; }
.viewbar { align-items:flex-start; flex-direction:column; padding:0 13px 9px; }
.views { width:100%; flex-wrap:wrap; }
.views button { padding:6px 8px; }
.model-canvas { height:300px; }
.viewer-foot { align-items:flex-start; }
.metrics strong { font-size:20px; }
.metrics strong.el { font-size:16px; }
}
+10701
View File
File diff suppressed because it is too large. Load diff