허용된 19.980m 직선 연결과 검토용 표면 명암 및 그림자 적용
This commit is contained in:
1 parent
02eb05bda4
commit
d01ef8b0e8
7 files changed
+2075
-45
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -67,7 +67,7 @@ for side,x,sx in [('좌측',1119736.218066468,1),('우측',1137236.218066513,-1)
|
||||
profiles=[]
|
||||
for s,y in zip(sections,[-77447.89255584464,-95669.3155558447]):profiles.append([norm([x+sx*p[0],y+p[1]],s) for p in pts])
|
||||
parts.append({'id':f'duct-{side}-{j}','name':f'{side} 공동구·상부 상세 · {j+1}','group':f'{side} 공동구·상부 상세','layer':layer(e),'handles':[get(e,5)],'a':profiles[0],'b':profiles[1],'repeat':False,'kind':e['type'].lower(),'source':'일반도 14 / '+ductName+' / 원본 삽입 블록'})
|
||||
model={'source':'광터지하차도_일반도(BLOCK7).dxf','units':'m','length':19.98,'sectionStations':[299,319],'longitudinalStations':[299.01,318.99],'sectionTops':[106.675,107.307],'sectionBottoms':[101.636,101.069],'centerEL':[None,None],'alignment':None,'status':'확인 필요','parts':parts}
|
||||
model={'source':'광터지하차도_일반도(BLOCK7).dxf','units':'m','length':19.98,'sectionStations':[299,319],'longitudinalStations':[299.01,318.99],'sectionTops':[106.675,107.307],'sectionBottoms':[101.636,101.069],'centerEL':[None,None],'alignment':None,'status':'확인 필요','connection':{'mode':'straight','status':'확인 필요','authorization':'2026-10-20 사용자 검토용 직선 연결 허용','description':'종단 경계 299.010~318.990에 원본 단면 299.000/319.000을 대응시킨 검토용 연결. 실제 평면 위치와 중간 형상은 미검증.'},'parts':parts}
|
||||
modelDir=ROOT/'src/model';modelDir.mkdir(exist_ok=True)
|
||||
(modelDir/'block7.json').write_text(json.dumps(model,ensure_ascii=False,indent=2),encoding='utf8')
|
||||
import html
|
||||
@@ -93,3 +93,4 @@ for title,x0,y0,x1,y1 in views:
|
||||
svg=f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x0} {-y1} {x1-x0} {y1-y0}"><rect x="{x0}" y="{-y1}" width="{x1-x0}" height="{y1-y0}" fill="#fff"/>'+''.join(content)+'</svg>'
|
||||
(previewDir/(title+'.svg')).write_text(svg,encoding='utf8')
|
||||
print('모델 추출:',len(parts),'객체. 단면 원본 선 114쌍, 공동구 20쌍 × 좌우.')
|
||||
|
||||
+9
-8
@@ -9,7 +9,7 @@ function download(data, filename, type = 'application/json') {
|
||||
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)
|
||||
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
|
||||
@@ -24,7 +24,7 @@ export default function App() {
|
||||
<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>원본 단면 윤곽을 보존한 3D 검토 모델</p></div>
|
||||
<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="도면 기준값">
|
||||
@@ -33,14 +33,14 @@ export default function App() {
|
||||
<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과 종단 경계의 10mm 차이, 평면 중심선 및 중심 EL을 확인해야 전체 연결 형상을 확정할 수 있습니다.</p></div>
|
||||
<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={dimensions} onChange={e => setDimensions(e.target.checked)} />치수</label></div>
|
||||
<div className="canvas-wrap"><Viewer {...{ hidden, selected, view, repeat, section, dimensions, renderMode }} onSelect={setSelected} /><div className="canvas-tag">{renderMode ? '윤곽 명암 · 도면 검토용 시각화' : '원본 단면 윤곽 · 단위 m'}</div><div className="model-note">평면 위치 미확정 · Z축은 단면 측점 차</div></div>
|
||||
<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>표고 범위 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 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>
|
||||
@@ -51,10 +51,11 @@ export default function App() {
|
||||
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>두 단면의 구조선과 공동구 윤곽을 공간에 배치했습니다. 표면·그림자를 적용한 렌더링은 연결 형상 확인 후 추가할 항목입니다. 현재 윤곽 명암은 실사 렌더링이 아닙니다.</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>
|
||||
}
|
||||
|
||||
|
||||
+51
-20
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { model, groups, palette, profileVertices } from './model/geometry'
|
||||
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) => {
|
||||
@@ -7,10 +7,10 @@ const distance = (p, a, b) => {
|
||||
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)))
|
||||
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
|
||||
}
|
||||
export default function Viewer({ hidden, selected, onSelect, view, repeat, section, dimensions, renderMode, onReady }) {
|
||||
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 })
|
||||
useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode])
|
||||
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()
|
||||
@@ -18,7 +18,7 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current, ctx = canvas.getContext('2d')
|
||||
const pointers = new Map()
|
||||
let hits = [], moved = false, last = null, start = null
|
||||
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)
|
||||
@@ -32,7 +32,7 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const { hidden, selected, repeat, section, dimensions, renderMode } = scene.current
|
||||
const { 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'
|
||||
@@ -41,29 +41,56 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
line(project([k, .5, -16], w, h), project([k, .5, 16], w, h), '#9faea943', .6)
|
||||
line(project([-16, .5, k], w, h), project([16, .5, k], w, h), '#9faea943', .6)
|
||||
}
|
||||
const edges = []
|
||||
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 })
|
||||
}
|
||||
})
|
||||
edges.sort((a, b) => a.depth - b.depth)
|
||||
hits = []
|
||||
edges.forEach(({ a, b, part, color, s }) => {
|
||||
const active = selected === part.id
|
||||
line(a, b, active ? '#cf762f' : color, active ? 3.7 : part.repeat ? .6 : renderMode ? 2.8 : 1.7)
|
||||
if (renderMode && !part.repeat) { ctx.shadowColor = '#28453d40'; ctx.shadowBlur = 3; line(a, b, active ? '#e89d50' : color, 1); ctx.shadowBlur = 0 }
|
||||
hits.push({ a, b, id: part.id, s })
|
||||
})
|
||||
ctx.font = '12px sans-serif'
|
||||
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 ? 10 : -10
|
||||
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.sectionStations[s].toFixed(3)}`, p[0], p[1])
|
||||
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'
|
||||
@@ -71,7 +98,7 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#71877c'
|
||||
ctx.fillText('X 횡방향 / Y 표고 / Z 단면 측점 차', 18, h - 20)
|
||||
ctx.fillText('X 횡방향 / Y 표고 / Z 종단 경계 기준 거리', 18, h - 20)
|
||||
ctx.fillText(`확대 ${Math.round(camera.current.zoom * 100)}%`, w - 96, h - 20)
|
||||
onReady?.()
|
||||
}
|
||||
@@ -98,7 +125,10 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
const up = e => {
|
||||
const p = local(e)
|
||||
if (!moved && pointers.size === 1) {
|
||||
const hit = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7)
|
||||
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 }
|
||||
@@ -127,3 +157,4 @@ export default function Viewer({ hidden, selected, onSelect, view, repeat, secti
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
: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}}
|
||||
|
||||
.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}}
|
||||
@@ -24,6 +24,12 @@
|
||||
],
|
||||
"alignment": null,
|
||||
"status": "확인 필요",
|
||||
"connection": {
|
||||
"mode": "straight",
|
||||
"status": "확인 필요",
|
||||
"authorization": "2026-10-20 사용자 검토용 직선 연결 허용",
|
||||
"description": "종단 경계 299.010~318.990에 원본 단면 299.000/319.000을 대응시킨 검토용 연결. 실제 평면 위치와 중간 형상은 미검증."
|
||||
},
|
||||
"parts": [
|
||||
{
|
||||
"id": "line-21420",
|
||||
|
||||
+22
-16
@@ -1,34 +1,40 @@
|
||||
import data from './block7.json'
|
||||
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 = ['#778c83', '#839b91', '#839b91', '#acb8b0', '#9b927e', '#79968d', '#79968d', '#aab5ad', '#7f9588', '#b1a082', '#b1a082']
|
||||
export const palette = ['#b0b5aa', '#a3b3a6', '#a3b3a6', '#acb8b0', '#b2aa91', '#aeb9aa', '#aeb9aa', '#bab6a5', '#adb3a6', '#a2b5a0', '#a2b5a0']
|
||||
export function profileVertices(part, section) {
|
||||
const z = data.sectionStations[section] - data.sectionStations[0] - 10
|
||||
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: null, sourceStations: data.sectionStations }
|
||||
return { width, elevation, length: part.repeat ? null : data.length, sourceStations: data.sectionStations }
|
||||
}
|
||||
export function exportModel(hidden) {
|
||||
const visibleParts = data.parts.filter(p => !hidden.has(p.id))
|
||||
return { ...data, parts: visibleParts, description: '단면 윤곽 3D 배치. 평면 위치·단면간 연결 미확정. 부재 체적 아님.', coordinateSystem: 'X=단면 횡방향(m), Y=EL(m), Z=단면 STA 차(m); 실제 평면 좌표 미확정' }
|
||||
}
|
||||
|
||||
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 단면 STA 차. 평면 배치와 연결 표면 미확정.']
|
||||
const rows = ['# 광터지하차도 BLOCK7 검토용 표면 모델', '# 단위 m. X 횡방향 / Y EL / Z 종단 경계 기준 거리.', '# 모든 연결면 확인 필요. 평면 배치·중간 형상 미검증. 닫힌 체적 아님.']
|
||||
let index = 1
|
||||
for (const part of data.parts.filter(p => !hidden.has(p.id))) {
|
||||
for (const section of [0, 1]) {
|
||||
rows.push(`# ${part.name} / ${part.source}`, `o ${part.id.replace(/[^a-zA-Z0-9-]/g, '_')}-${section ? 'B' : 'A'}`)
|
||||
const points = profileVertices(part, section)
|
||||
points.forEach(([x, y, z]) => rows.push(`v ${x} ${y + 100} ${z + 10}`))
|
||||
rows.push(`l ${points.map((_, i) => index + i).join(' ')}`)
|
||||
index += points.length
|
||||
}
|
||||
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