원본 도면 뷰를 3D 모델 기준면에 투영
This commit is contained in:
1 parent
f8ecd4f5c0
commit
27b3230dba
18 files changed
+489
-15
No files matched your search
+29
-2
@@ -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
@@ -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'
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user