원본 도면 뷰를 3D 모델 기준면에 투영

This commit is contained in:
김지영 committed 2026-10-20 15:14:22 +09:00
1 parent f8ecd4f5c0
commit 27b3230dba
18 files changed
+489 -15

No files matched your search

+29 -2
View File
@@ -8,6 +8,14 @@ const categoryLabels = {
insert: '블록 내부 콘크리트 윤곽',
}
const viewNames = ['전체', '평면', '정면', '측면', 'A-A', 'B-B']
const drawingNames = {
plan: '평면도',
'upper-plan': '상부평면도',
longitudinal: '종단면도',
'section-a': '단면 A-A',
'section-b': '단면 B-B',
}
const drawingForView = { 전체: 'plan', 평면: 'plan', 정면: 'section-a', 측면: 'longitudinal', 'A-A': 'section-a', 'B-B': 'section-b' }
function pointRange(parts, key, coordinate) {
const values = parts.flatMap(part => [...part.a, ...part.b].map(point => point[coordinate]))
@@ -17,6 +25,9 @@ function pointRange(parts, key, coordinate) {
export default function App() {
const [view, setView] = useState('전체')
const [renderMode, setRenderMode] = useState(false)
const [drawingVisible, setDrawingVisible] = useState(false)
const [drawingMode, setDrawingMode] = useState('auto')
const [drawingOpacity, setDrawingOpacity] = useState(0.68)
const [hidden, setHidden] = useState(new Set())
const [selected, setSelected] = useState(null)
const [tab, setTab] = useState('부재')
@@ -35,6 +46,7 @@ export default function App() {
minor: allParts.filter(part => part.group === 'minor').length,
insert: allParts.filter(part => part.group === 'insert').length,
}
const activeDrawing = drawingMode === 'auto' ? drawingForView[view] : drawingMode
const togglePart = id => setHidden(current => {
const next = new Set(current)
@@ -90,16 +102,31 @@ export default function App() {
<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 className="viewer-actions">
<div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>명암 보기</button></div>
<button className={`drawing-toggle ${drawingVisible ? 'active' : ''}`} onClick={() => setDrawingVisible(value => !value)}>{drawingVisible ? '도면 숨김' : '도면 함께 보기'}</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} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} />
<ModelViewer units={model.units} hidden={hidden} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} drawingVisible={drawingVisible} drawing={activeDrawing} drawingOpacity={drawingOpacity} />
<span className="canvas-tag">{renderMode ? '명암을 더한 도면 검토 시각화 · 실사 렌더링 아님' : '평면도·상부평면도 공통 구조물 모델'}</span>
</div>
{drawingVisible && <div className="drawing-controls">
<label>도면 선택
<select value={drawingMode} onChange={event => setDrawingMode(event.target.value)}>
<option value="auto">현재 보기와 연결</option>
{Object.entries(drawingNames).map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</label>
<label className="opacity-control">도면 투명도
<input type="range" min="0.25" max="0.95" step="0.05" value={drawingOpacity} onChange={event => setDrawingOpacity(Number(event.target.value))} />
</label>
<span>A-A/B-B는 해당 끝 측점 단면에 놓이며, 평면·종단 이미지는 CAD 좌표와 측점·EL 주석 기준으로 모델과 함께 투영됩니다. 평면 이미지는 화면 표시용 높이에 놓인 참고면입니다.</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 ? <>
+67 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import drawingViews from './model/drawingViews.json' with { type: 'json' }
const DEFAULT_CAMERA = { yaw: -0.66, pitch: 0.43, zoom: 1, panX: 0, panY: 0 }
const COLORS = { major: '#78917c', minor: '#d0aa69', insert: '#9a9074' }
@@ -18,6 +19,34 @@ function worldPoint(unit, pointA, pointB, pointIndex, t) {
return [cx + lateral * normalX, elevation, cz + lateral * normalZ]
}
function centerlinePoint(unit, t, lateral, elevation) {
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)
return [cx - lateral * Math.cos(angle), elevation, cz - lateral * Math.sin(angle)]
}
function drawingPoint(unit, drawingId, pixelX, pixelY) {
const drawing = drawingViews[drawingId]
const [pixelWidth, pixelHeight] = drawing.pixelSize
const bounds = drawing.cadBounds
const cadX = bounds.xMin + (pixelX / pixelWidth) * (bounds.xMax - bounds.xMin)
const cadY = bounds.yMax - (pixelY / pixelHeight) * (bounds.yMax - bounds.yMin)
const placement = drawing.placement
if (placement.kind === 'plan') {
return [(cadX + placement.xOffset - unit.planOrigin[0]) / 1000, placement.displayEL, (cadY - unit.planOrigin[1]) / 1000]
}
if (placement.kind === 'section') {
const lateral = (cadX - placement.cadXCenter) / 1000
const elevation = placement.baseEL + (cadY - placement.cadYBase) / 1000
return centerlinePoint(unit, placement.stationT, lateral, elevation)
}
const [stationStart, stationEnd] = placement.stationCad
const t = (cadX - stationStart) / (stationEnd - stationStart)
const elevation = cadY / 1000 + placement.elevationOffset
return centerlinePoint(unit, t, placement.lateralOffset, elevation)
}
function hexRgb(hex) {
return [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16))
}
@@ -37,16 +66,28 @@ function inPolygon(point, polygon) {
return inside
}
export default function ModelViewer({ units, hidden, selected, onSelect, view, renderMode, resetToken }) {
export default function ModelViewer({ units, hidden, selected, onSelect, view, renderMode, resetToken, drawingVisible, drawing, drawingOpacity }) {
const canvasRef = useRef(null)
const cameraRef = useRef({ ...DEFAULT_CAMERA })
const sceneRef = useRef({ units, hidden, selected, view, renderMode, onSelect })
const drawingImagesRef = useRef({})
const sceneRef = useRef({ units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity })
const drawRef = useRef(() => {})
useEffect(() => {
sceneRef.current = { units, hidden, selected, view, renderMode, onSelect }
sceneRef.current = { units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity }
drawRef.current()
}, [units, hidden, selected, view, renderMode, onSelect])
}, [units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity])
useEffect(() => {
const images = {}
for (const id of Object.keys(drawingViews)) {
const image = new Image()
image.onload = () => drawRef.current()
image.src = window.BLOCK8_DRAWING_IMAGES?.[id] || `/drawing-images/BLOCK8_${id}.svg`
images[id] = image
}
drawingImagesRef.current = images
}, [])
useEffect(() => {
const canvas = canvasRef.current
@@ -74,7 +115,7 @@ export default function ModelViewer({ units, hidden, selected, onSelect, view, r
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 { units, hidden, selected, renderMode, drawingVisible, drawing, drawingOpacity } = sceneRef.current
const background = ctx.createLinearGradient(0, 0, 0, height)
background.addColorStop(0, renderMode ? '#e8efea' : '#f8faf8')
background.addColorStop(1, renderMode ? '#cbd8cf' : '#e9efea')
@@ -125,6 +166,27 @@ export default function ModelViewer({ units, hidden, selected, onSelect, view, r
hitFaces.push(face)
}
if (drawingVisible && drawing && units.length) {
const image = drawingImagesRef.current[drawing]
if (image?.complete && image.naturalWidth) {
const unit = units[0]
const [imageWidth, imageHeight] = drawingViews[drawing].pixelSize
const p00 = project(drawingPoint(unit, drawing, 0, 0), width, height)
const p10 = project(drawingPoint(unit, drawing, imageWidth, 0), width, height)
const p01 = project(drawingPoint(unit, drawing, 0, imageHeight), width, height)
ctx.save()
ctx.globalAlpha = drawingOpacity
ctx.globalCompositeOperation = 'source-over'
ctx.transform(
(p10[0]-p00[0])/imageWidth, (p10[1]-p00[1])/imageWidth,
(p01[0]-p00[0])/imageHeight, (p01[1]-p00[1])/imageHeight,
p00[0], p00[1],
)
ctx.drawImage(image, 0, 0, imageWidth, imageHeight)
ctx.restore()
}
}
ctx.font = '11px sans-serif'
ctx.textAlign = 'left'
ctx.fillStyle = '#68806d'
+11
View File
@@ -34,6 +34,9 @@ h1 em { margin-left:7px; color:#738b79; font-size:.8em; font-style:normal; font-
.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; }
.viewer-actions { display:flex; align-items:center; gap:8px; }
.drawing-toggle { padding:7px 10px; border:1px solid #d4dfd2; border-radius:6px; color:#5c765f; font-size:9px; white-space:nowrap; }
.drawing-toggle.active { border-color:#8aa48c; background:#e9f0e7; color:#385f42; }
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; }
@@ -44,6 +47,11 @@ h2 { margin:5px 0 0; font-size:16px; letter-spacing:-.04em; }
.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; }
.drawing-controls { display:flex; align-items:center; gap:14px; padding:9px 15px; border-bottom:1px solid #e2e9dd; background:#f7faf5; }
.drawing-controls label { display:flex; align-items:center; gap:7px; color:#647568; font-size:9px; white-space:nowrap; }
.drawing-controls select { max-width:180px; padding:5px 7px; border:1px solid #d6e0d4; border-radius:5px; background:#fff; color:#526657; font-size:9px; }
.drawing-controls input { width:95px; accent-color:#59785e; }
.drawing-controls>span { color:#879287; font-size:8px; line-height:1.5; }
.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; }
@@ -128,6 +136,8 @@ footer { padding-top:15px; color:#89958a; font-size:8px; }
.notice { gap:8px; }
.workspace { grid-template-columns:1fr; }
.model-canvas { height:380px; }
.drawing-controls { flex-wrap:wrap; gap:8px 12px; }
.drawing-controls>span { flex-basis:100%; }
.side-card { order:2; }
.part-list { max-height:330px; }
.bottom-grid { grid-template-columns:1fr; gap:9px; }
@@ -138,6 +148,7 @@ footer { padding-top:15px; color:#89958a; font-size:8px; }
.views { width:100%; flex-wrap:wrap; }
.views button { padding:6px 8px; }
.model-canvas { height:300px; }
.viewer-actions { flex-wrap:wrap; }
.viewer-foot { align-items:flex-start; }
.metrics strong { font-size:20px; }
.metrics strong.el { font-size:16px; }
+100
View File
@@ -0,0 +1,100 @@
{
"plan": {
"title": "평면도",
"pixelSize": [
1400,
1160
],
"cadBounds": {
"xMin": 1195000,
"xMax": 1230000,
"yMin": -90000,
"yMax": -61000
},
"placement": {
"kind": "plan",
"xOffset": 0,
"displayEL": 108.8
}
},
"upper-plan": {
"title": "상부평면도",
"pixelSize": [
1400,
1097
],
"cadBounds": {
"xMin": 1230000,
"xMax": 1267000,
"yMin": -90000,
"yMax": -61000
},
"placement": {
"kind": "plan",
"xOffset": -35000,
"displayEL": 108.8
}
},
"longitudinal": {
"title": "종단면도",
"pixelSize": [
1400,
741
],
"cadBounds": {
"xMin": 1213000,
"xMax": 1247000,
"yMin": -108000,
"yMax": -90000
},
"placement": {
"kind": "longitudinal",
"stationCad": [
1218301.059683612,
1241698.059683612
],
"elevationOffset": 205.2898,
"lateralOffset": 15
}
},
"section-a": {
"title": "단면 A-A · STA. 0K+319.000",
"pixelSize": [
1400,
831
],
"cadBounds": {
"xMin": 1280000,
"xMax": 1312000,
"yMin": -83000,
"yMax": -64000
},
"placement": {
"kind": "section",
"stationT": -0.0005005005,
"cadXCenter": 1296487.109248054,
"cadYBase": -78059.37855593952,
"baseEL": 101.777
}
},
"section-b": {
"title": "단면 B-B · STA. 0K+339.000",
"pixelSize": [
1400,
831
],
"cadBounds": {
"xMin": 1280000,
"xMax": 1312000,
"yMin": -102000,
"yMax": -83000
},
"placement": {
"kind": "section",
"stationT": 1.0005005005,
"cadXCenter": 1296487.109248054,
"cadYBase": -97286.37855593952,
"baseEL": 101.292
}
}
}