/** * ============================================================================ * 프로젝트: 터널 단면 3D 파라메트릭 모델러 (Tunnel Modeler 3D) * 개발자: m12205.tdc.hanmacgroup@gmail.com * 저작권: Copyright (c) 2026 m12205.tdc.hanmacgroup@gmail.com. All rights reserved. * 설명: DWG/DXF 표준단면도 기반 지능형 형상 인식 및 3D 솔리드 모델 자동 생성 엔진 * ============================================================================ */ window.current3DTunnelLength = 10; window.current3DOpacity = 1.0; window.current3DOuterOpacity = 1.0; window.current3DViewMode = 'bird'; window.current3DTheme = 'dark'; window.current3DVisibility = { shotcrete: true, lining: true, pavement: true, lids: true, blindDitch: true, drainPipe: true, lanes: true, rims: true }; // 추출된 단면 설계 제원 상태 window.sectionParams = { offset: 3500, h1: 500, theta1: 120, r1: 5500, r2: 7500, r3: 7500, r4: 9200, r5: 9200, lining_t: 300, shotcrete_t: 150, seVal: -2.0, lanes: [3500, 3500], left_sh: 1000, right_sh: 1000, left_side: 1000, right_side: 1000, h_val: 4800, pave_t1: 20, pave_t2: 260, pave_t3: 150 }; window.ugData = { left: { type: 'T1', vw1: 300, vw2: 300, vh: 300, lw: 400, lh: 60, gap: 50, ww1: 144, ww2: 36, ww3: 120, wh1: 360, wh2: 175, wh3: 125, bcw: 650, bch: 500, dw: 100, dh: 150, bw1: 125, bw2: 300, bw3: 300, bd1: 50, bd2: 300, bd3: 50, pipeD: 300 }, right: { type: 'T1', vw1: 300, vw2: 300, vh: 300, lw: 400, lh: 60, gap: 50, ww1: 144, ww2: 36, ww3: 120, wh1: 360, wh2: 175, wh3: 125, bcw: 650, bch: 500, dw: 100, dh: 150, bw1: 125, bw2: 300, bw3: 300, bd1: 50, bd2: 300, bd3: 50, pipeD: 300 } }; window.rawDxfEntities = []; window.lastGeneratedMeshesForExport = []; window.activeViewTab = '3d'; window.view2D = { panX: 0, panY: 0, zoom: 1, dragging: false, lastX: 0, lastY: 0 }; const $ = (id) => document.getElementById(id); // ───────────────────────────────────────────────────────────────────────────── // 1. 전구(💡) 표시 레이어 컨트롤러 함수 // ───────────────────────────────────────────────────────────────────────────── function update3DObjectButtons() { const keys = ['shotcrete', 'lining', 'pavement', 'lids', 'blindDitch', 'drainPipe', 'lanes', 'rims']; let allOn = true; let anyOn = false; const isLight = (window.current3DTheme === 'light'); keys.forEach(k => { const isOn = window.current3DVisibility && window.current3DVisibility[k] !== false; if (isOn) anyOn = true; else allOn = false; const item = $('item_layer_' + k); const bulb = $('bulb_icon_' + k); const badge = $('badge_status_' + k); const label = $('label_layer_' + k); if (bulb && badge) { if (isOn) { bulb.style.filter = 'drop-shadow(0 0 5px #fbbf24)'; bulb.style.opacity = '1.0'; badge.innerText = 'ON'; badge.style.background = '#10b981'; badge.style.color = '#ffffff'; if (label) { label.style.color = isLight ? '#1f2937' : '#ffffff'; label.style.fontWeight = 'bold'; } if (item) { item.style.background = isLight ? 'rgba(0, 0, 0, 0.05)' : 'rgba(255, 255, 255, 0.08)'; } } else { bulb.style.filter = 'grayscale(100%) brightness(40%)'; bulb.style.opacity = '0.32'; badge.innerText = 'OFF'; badge.style.background = '#4b5563'; badge.style.color = '#9ca3af'; if (label) { label.style.color = '#9ca3af'; label.style.fontWeight = 'normal'; } if (item) { item.style.background = 'transparent'; } } } }); const bulbAll = $('bulb_icon_all'); const badgeAll = $('badge_status_all'); const labelAll = $('label_layer_all'); if (labelAll) { labelAll.style.color = isLight ? '#111827' : '#ffffff'; } if (bulbAll && badgeAll) { if (allOn) { bulbAll.style.filter = 'drop-shadow(0 0 7px #f59e0b)'; bulbAll.style.opacity = '1.0'; badgeAll.innerText = 'ON'; badgeAll.style.background = '#10b981'; badgeAll.style.color = '#ffffff'; } else if (!anyOn) { bulbAll.style.filter = 'grayscale(100%) brightness(35%)'; bulbAll.style.opacity = '0.32'; badgeAll.innerText = 'OFF'; badgeAll.style.background = '#4b5563'; badgeAll.style.color = '#9ca3af'; } else { bulbAll.style.filter = 'drop-shadow(0 0 3px #f59e0b) brightness(85%)'; bulbAll.style.opacity = '0.75'; badgeAll.innerText = 'PART'; badgeAll.style.background = '#d97706'; badgeAll.style.color = '#ffffff'; } } } function toggle3DObject(key) { if (!window.current3DVisibility || window.current3DVisibility[key] === undefined) return; window.current3DVisibility[key] = !window.current3DVisibility[key]; update3DObjectButtons(); buildCurrentSection3DPlot(true); if (window.activeViewTab === '2d') render2DSection(); } function setAll3DObjects(val) { if (!window.current3DVisibility) return; Object.keys(window.current3DVisibility).forEach(k => { window.current3DVisibility[k] = !!val; }); update3DObjectButtons(); buildCurrentSection3DPlot(true); if (window.activeViewTab === '2d') render2DSection(); } function toggleAll3DObjects() { if (!window.current3DVisibility) return; const keys = ['shotcrete', 'lining', 'pavement', 'lids', 'blindDitch', 'drainPipe', 'lanes', 'rims']; const allOn = keys.every(k => window.current3DVisibility[k] !== false); setAll3DObjects(!allOn); } function toggle3DLayerPanelMinimize() { const body = $('placement_3d_layer_items_wrapper'); const btn = $('btn_toggle_layer_panel'); if (!body) return; const isHidden = (body.style.display === 'none'); body.style.display = isHidden ? 'flex' : 'none'; if (btn) btn.innerText = isHidden ? '−' : '+'; } // ───────────────────────────────────────────────────────────────────────────── // 2. 테마(다크/라이트), 시점, 길이 제어 // ───────────────────────────────────────────────────────────────────────────── function set3DTheme(theme) { window.current3DTheme = theme; const isDark = (theme === 'dark'); const darkBtn = $('btnThemeDark'); const lightBtn = $('btnThemeLight'); if (darkBtn && lightBtn) { darkBtn.classList.toggle('active', isDark); lightBtn.classList.toggle('active', !isDark); } const bgColor = isDark ? '#111827' : '#ffffff'; const legendFontColor = isDark ? '#e5e7eb' : '#1f2937'; const legendBgColor = isDark ? 'rgba(17, 24, 39, 0.75)' : 'rgba(255, 255, 255, 0.85)'; const viewport = $('viewport'); if (viewport) { viewport.style.backgroundColor = bgColor; } const layerPanel = $('placement_3d_layer_panel'); if (layerPanel) { if (isDark) { layerPanel.style.background = 'rgba(15, 23, 42, 0.88)'; layerPanel.style.borderColor = 'rgba(255, 255, 255, 0.18)'; layerPanel.style.color = '#f3f4f6'; layerPanel.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; } else { layerPanel.style.background = 'rgba(255, 255, 255, 0.94)'; layerPanel.style.borderColor = 'rgba(0, 0, 0, 0.18)'; layerPanel.style.color = '#1f2937'; layerPanel.style.boxShadow = '0 8px 24px rgba(0,0,0,0.15)'; } } const sliderBox = $('opacitySliderBox'); if (sliderBox) { if (isDark) { sliderBox.style.background = '#171e28'; sliderBox.style.borderColor = '#2b3542'; sliderBox.style.color = '#cbd5e1'; } else { sliderBox.style.background = '#f8fafc'; sliderBox.style.borderColor = '#cbd5e1'; sliderBox.style.color = '#1f2937'; } } update3DObjectButtons(); const plotDiv = $('placement_3d_plot'); if (plotDiv && typeof Plotly !== 'undefined' && plotDiv.data) { try { Plotly.relayout(plotDiv, { 'paper_bgcolor': bgColor, 'plot_bgcolor': bgColor, 'scene.bgcolor': bgColor, 'legend.font.color': legendFontColor, 'legend.bgcolor': legendBgColor }); } catch (e) { buildCurrentSection3DPlot(true); } } if (window.activeViewTab === '2d') render2DSection(); } function toggle3DOuterShell() { if (window.current3DOpacity >= 0.85) { window.current3DOpacity = 0.4; } else if (window.current3DOpacity > 0.15) { window.current3DOpacity = 0.1; } else { window.current3DOpacity = 1.0; } window.current3DOuterOpacity = window.current3DOpacity; const pct = Math.round(window.current3DOpacity * 100); const slider = $('opacitySlider'); const valEl = $('opacityVal'); if (slider) slider.value = pct; if (valEl) valEl.textContent = `${pct}%`; update3DObjectButtons(); buildCurrentSection3DPlot(true); } function set3DCameraView(mode) { window.current3DViewMode = mode; const plotEl = $('placement_3d_plot'); if (!plotEl || !plotEl.layout || !plotEl.layout.scene) return; const newCam = (mode === 'inside') ? { eye: { x: 0, y: -0.05, z: 0.05 }, center: { x: 0, y: 0.5, z: 0.05 }, up: { x: 0, y: 0, z: 1 } } : { eye: { x: 1.15, y: -1.35, z: 0.85 }, center: { x: 0, y: 0, z: 0.1 }, up: { x: 0, y: 0, z: 1 } }; Plotly.relayout(plotEl, { 'scene.camera': newCam }); } // ───────────────────────────────────────────────────────────────────────────── // 3. DXF 도면 자동 제원 추출 엔진 (cad_import.js 기반 + 임의 좌표계 호환) // ───────────────────────────────────────────────────────────────────────────── function extractParamsFromDXF(dxf, sourceName = '도면') { if (!dxf || !dxf.entities) { throw new Error('유효한 DXF 엔티티가 없습니다.'); } // ★ 1. 도곽(Border Frame) 자동 감지 및 도곽 내부 격리 (비교안/다른 단면 섞임 원천 차단) function detectFrameBBox() { const frameInserts = dxf.entities.filter( e => e && e.type === 'INSERT' && /도곽|A[0-3]|KECH|도로공사|BORDER|TITLE|FRAME/i.test(e.name || '') ); if (frameInserts.length === 0) return null; frameInserts.sort((a, b) => (b.xScale || 1) - (a.xScale || 1)); const ins = frameInserts[0]; const p = ins.position || { x: 0, y: 0 }; const scale = ins.xScale || 50; let w = 841 * scale, h = 594 * scale; const blk = dxf.blocks ? dxf.blocks[ins.name] : null; if (blk && blk.entities) { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; blk.entities.forEach(e => { const pts = e.vertices || e.points || (e.center ? [e.center] : (e.position ? [e.position] : [])); pts.forEach(pt => { minX = Math.min(minX, pt.x); maxX = Math.max(maxX, pt.x); minY = Math.min(minY, pt.y); maxY = Math.max(maxY, pt.y); }); }); if (Number.isFinite(minX) && Number.isFinite(maxX) && Number.isFinite(minY) && Number.isFinite(maxY)) { return { minX: p.x + minX * scale - 200, maxX: p.x + maxX * scale + 200, minY: p.y + minY * scale - 200, maxY: p.y + maxY * scale + 200 }; } } return { minX: p.x - 200, maxX: p.x + w + 200, minY: p.y - 200, maxY: p.y + h + 200 }; } const frameBBox = detectFrameBBox(); const targetEntities = frameBBox ? dxf.entities.filter(e => { const p = e.center || e.position || (e.vertices && e.vertices[0]) || (e.points && e.points[0]); if (!p) return false; return p.x >= frameBBox.minX && p.x <= frameBBox.maxX && p.y >= frameBBox.minY && p.y <= frameBBox.maxY; }) : dxf.entities; // ★ 2. bulge(원호) 세그먼트를 ARC로 변환하는 기하학 엔진 function bulgeToArc(p1, p2, bulge, layer) { const theta = 4 * Math.atan(bulge); const dx = p2.x - p1.x; const dy = p2.y - p1.y; const chord = Math.hypot(dx, dy); const radius = Math.abs(chord / (2 * Math.sin(theta / 2))); const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; const dCenter = (chord / 2) / Math.tan(theta / 2); const perpX = -dy / chord; const perpY = dx / chord; const sign = bulge > 0 ? 1 : -1; const cx = midX + sign * dCenter * perpX; const cy = midY + sign * dCenter * perpY; let startAngle = Math.atan2(p1.y - cy, p1.x - cx); let endAngle = Math.atan2(p2.y - cy, p2.x - cx); if (bulge < 0) { const tmp = startAngle; startAngle = endAngle; endAngle = tmp; } if (startAngle < 0) startAngle += 2 * Math.PI; if (endAngle < 0) endAngle += 2 * Math.PI; return { type: 'ARC', layer: layer, center: { x: cx, y: cy, z: 0 }, radius: radius, startAngle: startAngle, endAngle: endAngle }; } const allLines = []; targetEntities.forEach(e => { if (e && e.type === 'LINE' && e.vertices && e.vertices.length >= 2) { allLines.push(e); } else if (e && (e.type === 'LWPOLYLINE' || e.type === 'POLYLINE') && e.vertices && e.vertices.length >= 2) { for (let i = 0; i < e.vertices.length - 1; i++) { allLines.push({ type: 'LINE', layer: e.layer, vertices: [e.vertices[i], e.vertices[i + 1]] }); } } }); const allArcs = []; targetEntities.forEach(e => { if (e && e.type === 'ARC' && e.radius > 500 && e.center) { allArcs.push(e); } else if (e && (e.type === 'LWPOLYLINE' || e.type === 'POLYLINE') && e.vertices) { for (let i = 0; i < e.vertices.length - 1; i++) { const v1 = e.vertices[i], v2 = e.vertices[i + 1]; if (v1.bulge && Math.abs(v1.bulge) > 1e-4) { allArcs.push(bulgeToArc(v1, v2, v1.bulge, e.layer)); } } } }); if (allArcs.length === 0) { throw new Error('도면에서 터널 아치(ARC) 객체를 찾지 못했습니다.'); } // 1) 터널 천단 아치 그룹(동심원 아치 패밀리) 및 표준단면 중심 X 결정 // 도면 내 '표준단면' 타이틀 텍스트 위치 탐색 const titleTexts = []; if (targetEntities) { targetEntities.forEach(e => { if (e && (e.type === 'TEXT' || e.type === 'MTEXT') && e.text) { const str = e.text.trim(); if (str.includes('표준단면') && str.length <= 15) { titleTexts.push(e.startPoint || e.position || e.insertionPoint || e); } } }); } // 상부를 향하는 천단 대반경 아치 탐색 (각도 약 15°~65° 시작, 115°~165° 종료, R >= 4500) const candidateCrowns = allArcs.filter(a => { let saDeg = (a.startAngle * 180) / Math.PI; let eaDeg = (a.endAngle * 180) / Math.PI; if (saDeg < 0) saDeg += 360; if (eaDeg < 0) eaDeg += 360; const isUpper = (saDeg >= 15 && saDeg <= 65 && eaDeg >= 115 && eaDeg <= 165); return isUpper && a.radius >= 4500; }); let mainCrown = null; if (candidateCrowns.length > 0) { // 1) 여러 단면(상행/하행 등)이 동시에 그려진 경우 독립 단면 후보 그룹화 const distinctSections = []; candidateCrowns.forEach(cArc => { if (!distinctSections.some(sec => Math.abs(sec.arc.center.x - cArc.center.x) < 3000)) { distinctSections.push({ arc: cArc, center: cArc.center }); } }); if (distinctSections.length > 1) { // 각 단면 영역별 '도로' 중심선과 '터널' 중심선 위치 관계 조사 // ★ 사용자 핵심 규칙: 도로중심이 터널중심보다 왼쪽에 있는 단면(X_road < X_tunnel) 우선 선택! distinctSections.forEach(sec => { const cX = sec.center.x, cY = sec.center.y; let roadX = null, tunnelX = null; if (targetEntities) { targetEntities.forEach(e => { if (e && (e.type === 'TEXT' || e.type === 'MTEXT') && e.text) { const str = (e.text || '').replace(/\\[A-Za-z0-9_]+;/g, '').replace(/[{}\\]/g, '').trim(); const p = e.startPoint || e.position; if (p && Math.abs(p.x - cX) < 10000 && p.y >= cY - 4000) { if (str.includes('도로') && (str.includes('L') || str.includes('중심선'))) roadX = p.x; if (str.includes('터널') && (str.includes('L') || str.includes('중심선'))) tunnelX = p.x; } } }); } sec.roadX = roadX; sec.tunnelX = tunnelX; sec.isRoadLeftOfTunnel = (roadX !== null && tunnelX !== null) ? (roadX < tunnelX) : null; }); const roadLeftSec = distinctSections.find(sec => sec.isRoadLeftOfTunnel === true); if (roadLeftSec) { mainCrown = roadLeftSec.arc; } } if (!mainCrown) { if (titleTexts.length > 0) { // 타이틀 텍스트의 X와 가장 가까운 천단 아치 선택 candidateCrowns.sort((a, b) => { const distA = Math.min(...titleTexts.map(t => Math.abs(a.center.x - (t.x || 0)))); const distB = Math.min(...titleTexts.map(t => Math.abs(b.center.x - (t.x || 0)))); return distA - distB; }); } else { // 가장 빈도 높은 중심 X 또는 표준 도로폭 대반경 선택 candidateCrowns.sort((a, b) => b.radius - a.radius); } mainCrown = candidateCrowns[0]; } } let targetCenterX = mainCrown ? mainCrown.center.x : null; let targetCenterY = mainCrown ? mainCrown.center.y : null; if (targetCenterX === null) { // 수직선 탐색 fallback const rightSideVertLines = allLines.filter( l => Math.abs(l.vertices[0].x - l.vertices[1].x) < 1 && l.vertices[0].x > 10 ); if (rightSideVertLines.length > 0) { rightSideVertLines.sort((a, b) => Math.abs(b.vertices[0].y - b.vertices[1].y) - Math.abs(a.vertices[0].y - a.vertices[1].y)); targetCenterX = rightSideVertLines[0].vertices[0].x; } } if (targetCenterX === null) { throw new Error('터널 표준단면의 중심 X 좌표를 찾을 수 없습니다.'); } // ★ 핵심 교정: 애초에 X 좌표가 다른 객체/도면 프레임이 섞이지 않도록 단면 Bounding Box 격리! // 또한 하부 추가 작도 단면(종배수관 Ø400 등)이 섞이지 않도록 Y 범위도 본선 단면 구조물 영역으로 한정! const bboxX1 = targetCenterX - 11000; const bboxX2 = targetCenterX + 11000; const bboxY1 = targetCenterY !== null ? (targetCenterY - Math.max(4500, mainCrown.radius * 0.7)) : -Infinity; const bboxY2 = targetCenterY !== null ? (targetCenterY + mainCrown.radius + 3500) : Infinity; const secLines = allLines.filter( l => l.vertices[0].x >= bboxX1 && l.vertices[0].x <= bboxX2 && l.vertices[1].x >= bboxX1 && l.vertices[1].x <= bboxX2 && (targetCenterY === null || (l.vertices[0].y >= bboxY1 && l.vertices[1].y >= bboxY1 && l.vertices[0].y <= bboxY2 && l.vertices[1].y <= bboxY2)) ); const secArcs = allArcs.filter(a => a.center.x >= bboxX1 && a.center.x <= bboxX2 && (targetCenterY === null || (a.center.y >= bboxY1 - 2000 && a.center.y <= bboxY2)) ); // 천단 동심원 패밀리 추출 (동일 중심 X, Y 기준) let crownFamily = secArcs.filter( a => Math.abs(a.center.x - targetCenterX) < 150 && (targetCenterY === null || Math.abs(a.center.y - targetCenterY) < 150) && a.radius >= 4500 ); crownFamily.sort((a, b) => a.radius - b.radius); if (crownFamily.length === 0) { crownFamily = [mainCrown]; } const innerCrown = crownFamily[0]; // 라이닝 외면: 내면보다 150mm 이상 큰 첫 번째 호 const outerCrown = crownFamily.find(a => a.radius >= innerCrown.radius + 150) || null; // 숏크리트 외면: 라이닝 외면보다 50mm 이상 큰 호 const shotcreteCrown = outerCrown ? crownFamily.find(a => a.radius >= outerCrown.radius + 50) : null; const r1 = Math.round(innerCrown.radius); const eValAbs = innerCrown.center.x; const h1Abs = innerCrown.center.y; let lining_t = outerCrown ? Math.round(outerCrown.radius - innerCrown.radius) : 300; let shotcrete_t = (outerCrown && shotcreteCrown) ? Math.round(shotcreteCrown.radius - outerCrown.radius) : 150; // 천단 중심각 theta1 추출 let spanDeg = ((innerCrown.endAngle - innerCrown.startAngle) * 180) / Math.PI; if (spanDeg <= 0) spanDeg += 360; const theta1 = (spanDeg >= 60 && spanDeg <= 165) ? Math.round(spanDeg) : 120; function toDeg(rad) { return ((rad * 180) / Math.PI + 360) % 360; } function getPts(a) { return [ { x: a.center.x + a.radius * Math.cos(a.startAngle), y: a.center.y + a.radius * Math.sin(a.startAngle) }, { x: a.center.x + a.radius * Math.cos(a.endAngle), y: a.center.y + a.radius * Math.sin(a.endAngle) } ]; } function dist(p1, p2) { return Math.hypot(p1.x - p2.x, p1.y - p2.y); } const saCrown = toDeg(innerCrown.startAngle); const eaCrown = toDeg(innerCrown.endAngle); // 치수표(테이블) 자동 파싱 함수 (도면 치수와 CAD 엔티티 크로스체크용) function parseDimensionTable(slopePercent) { const texts = dxf.entities.filter(e => e && (e.type === 'TEXT' || e.type === 'MTEXT') && e.text); const title = texts.find(t => t.text.replace(/\s+/g, '').includes('치수표')); if (!title) return null; const tPos = title.startPoint || title.position || title.insertionPoint || {}; const nearby = texts.filter(t => { const p = t.startPoint || t.position || t.insertionPoint || {}; return Math.abs(p.x - tPos.x) < 15000 && p.y >= tPos.y - 12000 && p.y <= tPos.y + 2000; }); const targetSlopeStr = String(Math.round(slopePercent)); const slopeRows = nearby.filter(t => { const str = t.text.trim(); return str === targetSlopeStr || str === `${targetSlopeStr}%` || str === `${targetSlopeStr}.0`; }); if (slopeRows.length === 0) return null; const r1Header = nearby.find(t => t.text.trim() === 'R1'); const r2Header = nearby.find(t => t.text.trim() === 'R2'); const r3Header = nearby.find(t => t.text.trim() === 'R3'); const r2pHeader = nearby.find(t => t.text.trim() === "R2'"); const r3pHeader = nearby.find(t => t.text.trim() === "R3'"); if (!r1Header) return null; const targetSlopeRow = slopeRows.find(sr => { const p = sr.startPoint || sr.position || sr.insertionPoint || {}; const r1p = r1Header.startPoint || r1Header.position || r1Header.insertionPoint || {}; return Math.abs(p.y - r1p.y) < 6000 && p.y < r1p.y; }); if (!targetSlopeRow) return null; const rowY = (targetSlopeRow.startPoint || targetSlopeRow.position || targetSlopeRow.insertionPoint).y; const rowCells = nearby.filter(t => { const p = t.startPoint || t.position || t.insertionPoint || {}; return Math.abs(p.y - rowY) < 100; }); function getVal(header) { if (!header) return null; const hp = header.startPoint || header.position || header.insertionPoint || {}; const match = rowCells.find(c => { const cp = c.startPoint || c.position || c.insertionPoint || {}; return Math.abs(cp.x - hp.x) < 300; }); if (!match) return null; const val = parseFloat(match.text.trim()); return isNaN(val) ? null : Math.round(val * 1000); } return { r1: getVal(r1Header), r2: getVal(r2Header), r4: getVal(r3Header), // 도면 치수표의 R3 = 좌측 라이닝 외면 r3: getVal(r2pHeader), // 도면 치수표의 R2' = 우측 라이닝 내면 r5: getVal(r3pHeader) // 도면 치수표의 R3' = 우측 라이닝 외면 }; } // 임시 편경사(-2%) 기준으로 치수표 우선 검색 let crossTable = parseDimensionTable(-2); // ★ 접선 연속 각도 기반 벽부 호 탐색 + 방수막(10mm 오프셋 호) 자동 배제 + 치수표 크로스체크 // - 좌측 벽부 호(R2, R4, 좌측 숏크리트): 천단 끝각도(eaCrown, 약 140°)에서 접선으로 시작 // - 우측 벽부 호(R3, R5, 우측 숏크리트): 천단 시작각도(saCrown, 약 40°)에서 접선으로 종료 function findWallArcByAngle(targetPt, side, expectedRadius = null, excludeArcs = []) { let cands = secArcs.filter(a => { if (excludeArcs.includes(a)) return false; const pts = getPts(a); if (dist(pts[0], targetPt) >= 25 && dist(pts[1], targetPt) >= 25) return false; const sa = toDeg(a.startAngle), ea = toDeg(a.endAngle); if (side === 'left') { return Math.abs(sa - eaCrown) <= 2.0 || Math.abs(ea - eaCrown) <= 2.0; } else { return Math.abs(ea - saCrown) <= 2.0 || Math.abs(sa - saCrown) <= 2.0; } }); if (cands.length === 0) return null; // ★ 방수막(두께 5~15mm, 통상 10mm) 배제: 동일 시작/끝점을 공유하는 호 쌍이 있는 경우 // 콘크리트 외면보다 10mm 큰 바깥쪽 호는 방수막이므로 배제하고 안쪽 호(콘크리트 외면)를 선택 if (cands.length >= 2) { cands.sort((a, b) => a.radius - b.radius); // 만약 두 호의 반경 차이가 5mm ~ 18mm 사이라면, 더 큰 호는 방수막! const filtered = []; for (let i = 0; i < cands.length; i++) { const isMembrane = (i > 0 && (cands[i].radius - cands[i - 1].radius) >= 5 && (cands[i].radius - cands[i - 1].radius) <= 18); if (!isMembrane) { filtered.push(cands[i]); } } if (filtered.length > 0) cands = filtered; } // 치수표(테이블) 크로스체크가 가능한 경우 테이블 치수와 가장 일치하는 호 우선 if (expectedRadius !== null) { cands.sort((a, b) => Math.abs(a.radius - expectedRadius) - Math.abs(b.radius - expectedRadius)); return cands[0]; } cands.sort((a, b) => { const ptsA = getPts(a), ptsB = getPts(b); const minDA = Math.min(dist(ptsA[0], targetPt), dist(ptsA[1], targetPt)); const minDB = Math.min(dist(ptsB[0], targetPt), dist(ptsB[1], targetPt)); return minDA - minDB; }); return cands[0]; } // R1 양 끝점과 연결된 R2, R3 (접선 각도 조건으로 탐색) const r1Pts = getPts(innerCrown); const r1LeftPt = r1Pts[0].x < r1Pts[1].x ? r1Pts[0] : r1Pts[1]; const r1RightPt = r1Pts[0].x < r1Pts[1].x ? r1Pts[1] : r1Pts[0]; let r2 = null, r3 = null, r4 = null, r5 = null; const usedArcs = [innerCrown]; const r2Arc = findWallArcByAngle(r1LeftPt, 'left', crossTable ? crossTable.r2 : null, usedArcs); if (r2Arc) { r2 = Math.round(r2Arc.radius); usedArcs.push(r2Arc); } const r3Arc = findWallArcByAngle(r1RightPt, 'right', crossTable ? crossTable.r3 : null, usedArcs); if (r3Arc) { r3 = Math.round(r3Arc.radius); usedArcs.push(r3Arc); } if (outerCrown) { usedArcs.push(outerCrown); const r1pPts = getPts(outerCrown); const r1pLeftPt = r1pPts[0].x < r1pPts[1].x ? r1pPts[0] : r1pPts[1]; const r1pRightPt = r1pPts[0].x < r1pPts[1].x ? r1pPts[1] : r1pPts[0]; const r4Arc = findWallArcByAngle(r1pLeftPt, 'left', crossTable ? crossTable.r4 : null, usedArcs); if (r4Arc) { r4 = Math.round(r4Arc.radius); usedArcs.push(r4Arc); } const r5Arc = findWallArcByAngle(r1pRightPt, 'right', crossTable ? crossTable.r5 : null, usedArcs); if (r5Arc) { r5 = Math.round(r5Arc.radius); usedArcs.push(r5Arc); } } // 숏크리트 외면 호 탐색을 통한 두께 검증 if (shotcreteCrown && r4) { usedArcs.push(shotcreteCrown); const scPts = getPts(shotcreteCrown); const scLeftPt = scPts[0].x < scPts[1].x ? scPts[0] : scPts[1]; const scLeftArc = findWallArcByAngle(scLeftPt, 'left', usedArcs); if (scLeftArc) { shotcrete_t = Math.round(scLeftArc.radius - r4); } } // 2) 도로 포장면(Pavement) 및 다층 두께 탐색 (secLines 내에서 탐색!) let m3 = -0.02; let pavementLeftX = null; let pavementRightX = null; let mainPv = null; let pave_t1_val = 20, pave_t2_val = 260, pave_t3_val = 150; const roadCandidates = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('EXCV') || layer.includes('굴착') || layer.includes('CNTL') || layer.includes('HIDL') || layer.includes('DIM') || layer.includes('LEAL') || layer.includes('TEXT')) { return false; } const p1 = l.vertices[0], p2 = l.vertices[1]; const dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); if (dx < 4000 || dy > dx * 0.1) return false; const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; const crownTopY = h1Abs + r1; // 터널 도로 포장면의 높이는 천단 최상단(crownTopY)으로부터 5.0m ~ 11.5m 아래에 위치! return Math.abs(midX - targetCenterX) < 4500 && midY >= crownTopY - 11500 && midY <= crownTopY - 5000; }); const verticalWalls = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const dx = Math.abs(l.vertices[0].x - l.vertices[1].x); const dy = Math.abs(l.vertices[0].y - l.vertices[1].y); return dx <= dy * 0.1 && dy <= 1200 && dy >= 40; }); const TOL = 15; const isTouching = (p, l) => Math.hypot(p.x - l.vertices[0].x, p.y - l.vertices[0].y) <= TOL || Math.hypot(p.x - l.vertices[1].x, p.y - l.vertices[1].y) <= TOL; // (0,0) 근처를 지나는 포장선 우선, 없으면 배수구 수직벽과 맞닿아 있는 상단 표층선 선택 let centerPvs = roadCandidates.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const crossProduct = p1.x * p2.y - p2.x * p1.y; if (Math.abs(crossProduct) > 120) return false; const minX = Math.min(p1.x, p2.x) - 5, maxX = Math.max(p1.x, p2.x) + 5; const minY = Math.min(p1.y, p2.y) - 5, maxY = Math.max(p1.y, p2.y) + 5; return (0 >= minX && 0 <= maxX && 0 >= minY && 0 <= maxY); }); if (centerPvs.length === 0 && roadCandidates.length > 0) { // 1) 수직벽(배수구 턱)과 접촉하는 선분을 최우선 순위로, 그 중 Y가 가장 높은 선(표층) 선택 const sortedRoads = [...roadCandidates].sort((a, b) => { const touchA = verticalWalls.filter(w => isTouching(a.vertices[0], w) || isTouching(a.vertices[1], w)).length; const touchB = verticalWalls.filter(w => isTouching(b.vertices[0], w) || isTouching(b.vertices[1], w)).length; if (touchA !== touchB) return touchB - touchA; return ((b.vertices[0].y + b.vertices[1].y) / 2) - ((a.vertices[0].y + a.vertices[1].y) / 2); }); centerPvs = [sortedRoads[0]]; } let fullPavementLines = []; if (centerPvs.length > 0) { mainPv = centerPvs[0]; const centerSlopes = centerPvs.map(l => (l.vertices[1].y - l.vertices[0].y) / (l.vertices[1].x - l.vertices[0].x)); if (Number.isFinite(centerSlopes[0]) && Math.abs(centerSlopes[0]) <= 0.08) { m3 = Math.round(centerSlopes[0] * 1000) / 1000; } const collinear = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT') || layer.includes('TABL')) return false; const dx = l.vertices[1].x - l.vertices[0].x; if (Math.abs(dx) < 10) return false; const lSlope = (l.vertices[1].y - l.vertices[0].y) / dx; for (let i = 0; i < centerPvs.length; i++) { const cPv = centerPvs[i]; const cSlope = centerSlopes[i]; if (Math.abs(lSlope - cSlope) < 0.005) { const cYInt = cPv.vertices[0].y - cSlope * cPv.vertices[0].x; const lYInt = l.vertices[0].y - lSlope * l.vertices[0].x; if (Math.abs(cYInt - lYInt) < 5) return true; } } return false; }); const leftWalls = []; const rightWalls = []; for (const l of collinear) { for (const w of verticalWalls) { if (isTouching(l.vertices[0], w)) { const x = l.vertices[0].x; if (x < eValAbs) leftWalls.push(x); else rightWalls.push(x); } if (isTouching(l.vertices[1], w)) { const x = l.vertices[1].x; if (x < eValAbs) leftWalls.push(x); else rightWalls.push(x); } } } if (leftWalls.length > 0) pavementLeftX = Math.max(...leftWalls); if (rightWalls.length > 0) pavementRightX = Math.min(...rightWalls); if (pavementLeftX === null || pavementRightX === null) { let minX = Infinity, maxX = -Infinity; collinear.forEach(l => { minX = Math.min(minX, l.vertices[0].x, l.vertices[1].x); maxX = Math.max(maxX, l.vertices[0].x, l.vertices[1].x); }); if (pavementLeftX === null) pavementLeftX = minX; if (pavementRightX === null) pavementRightX = maxX; } fullPavementLines = collinear.filter(l => { const cx = (l.vertices[0].x + l.vertices[1].x) / 2; return cx >= pavementLeftX - 100 && cx <= pavementRightX + 100; }); // 포장층 하단 평행선들로 t1, t2, t3 추출 const parallelLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT') || layer.includes('TABL')) return false; const cx = (l.vertices[0].x + l.vertices[1].x) / 2; if (cx < pavementLeftX - 100 || cx > pavementRightX + 100) return false; const dx = l.vertices[1].x - l.vertices[0].x; if (Math.abs(dx) < 1500) return false; const lSlope = (l.vertices[1].y - l.vertices[0].y) / dx; return centerSlopes.some(cs => Math.abs(lSlope - cs) <= 0.008); }); const diffs = parallelLines .filter(l => !fullPavementLines.includes(l)) .map(l => { const cx = (l.vertices[0].x + l.vertices[1].x) / 2; const cy = (l.vertices[0].y + l.vertices[1].y) / 2; const cSlope = centerSlopes[0]; const targetY = mainPv.vertices[0].y + cSlope * (cx - mainPv.vertices[0].x); return targetY - cy; }) .filter(d => d > 15 && d < 1000) .sort((a, b) => a - b); // 중복 거리 병합 const uniqueDiffs = []; diffs.forEach(d => { if (uniqueDiffs.length === 0 || Math.abs(uniqueDiffs[uniqueDiffs.length - 1] - d) > 15) { uniqueDiffs.push(d); } }); if (uniqueDiffs.length >= 3) { pave_t1_val = Math.round(uniqueDiffs[0] / 10) * 10; pave_t2_val = Math.round((uniqueDiffs[1] - uniqueDiffs[0]) / 10) * 10; pave_t3_val = Math.round((uniqueDiffs[2] - uniqueDiffs[1]) / 10) * 10; } else if (uniqueDiffs.length === 2) { // 도면에 2개 층만 작도된 경우 (예: 콘크리트 슬래브 t1=280mm + 필터층 t2=150mm) pave_t1_val = Math.round(uniqueDiffs[0] / 10) * 10; pave_t2_val = Math.round((uniqueDiffs[1] - uniqueDiffs[0]) / 10) * 10; pave_t3_val = 0; } else if (uniqueDiffs.length === 1) { pave_t1_val = Math.round(uniqueDiffs[0] / 10) * 10; pave_t2_val = 0; pave_t3_val = 0; } } // 3) 공동구/배수구(ugData) 추출 function isSamePt(p1, p2) { return Math.abs(p1.x - p2.x) < 1.0 && Math.abs(p1.y - p2.y) < 1.0; } function getOtherPt(l, targetPt) { const p1 = l.vertices[0], p2 = l.vertices[1]; return isSamePt(p1, targetPt) ? p2 : p1; } function findConnectedLines(targetPt, lines, direction) { return lines .filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const isP1 = isSamePt(p1, targetPt); const isP2 = isSamePt(p2, targetPt); if (!isP1 && !isP2) return false; const otherPt = isP1 ? p2 : p1; const dx = Math.abs(otherPt.x - targetPt.x); const dy = Math.abs(otherPt.y - targetPt.y); if (direction === 'left' || direction === 'right') { if (dy > dx * 0.5) return false; if (direction === 'left' && otherPt.x > targetPt.x - 2) return false; if (direction === 'right' && otherPt.x < targetPt.x + 2) return false; } else if (direction === 'up' || direction === 'down') { if (dx > dy * 0.5) return false; if (direction === 'down' && otherPt.y > targetPt.y - 2) return false; if (direction === 'up' && otherPt.y < targetPt.y + 2) return false; } return true; }) .map(l => ({ l, len: Math.hypot(l.vertices[1].x - l.vertices[0].x, l.vertices[1].y - l.vertices[0].y) })) .sort((a, b) => b.len - a.len); } function extractUg(pavementX, yMain, direction) { const res = { type: 'T1', bcw: 650, bch: 500, dw: 100, dh: 150, lw: 400, lh: 60, gap: 50, vw1: 300, vw2: 300, vh: 300, ww1: 144, ww2: 36, ww3: 120, wh1: 360, wh2: 175, wh3: 125, bw1: 125, bw2: 300, bw3: 300, bd1: 50, bd2: 300, bd3: 50, pipeD: 300 }; const startPt = { x: pavementX, y: yMain }; // 단면 바운딩 박스 내부의 secLines 사용 const bcwLines = findConnectedLines(startPt, secLines, direction); let ditchTopInnerPt = null; if (bcwLines.length > 0) { res.bcw = Math.round(bcwLines[0].len); ditchTopInnerPt = getOtherPt(bcwLines[0].l, startPt); } else { ditchTopInnerPt = { x: startPt.x + (direction === 'left' ? -res.bcw : res.bcw), y: startPt.y }; } let bchLines = findConnectedLines(startPt, secLines, 'down'); if (bchLines.length === 0) { // 포장단부 점과 미세한 캐드 오차(1~15mm)로 단절된 경우 근접 하향 수직선 동적 탐색 const nearVLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('HIDL') || layer.includes('DASH') || layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; if (Math.abs(p1.x - p2.x) > 2) return false; const topY = Math.max(p1.y, p2.y), botY = Math.min(p1.y, p2.y); const avgX = (p1.x + p2.x) / 2; return Math.abs(avgX - startPt.x) <= 20 && Math.abs(topY - startPt.y) <= 20 && (topY - botY) >= 100; }).sort((a, b) => { const lenA = Math.abs(a.vertices[0].y - a.vertices[1].y); const lenB = Math.abs(b.vertices[0].y - b.vertices[1].y); return lenB - lenA; }); if (nearVLines.length > 0) { bchLines = [{ l: nearVLines[0], len: Math.abs(nearVLines[0].vertices[0].y - nearVLines[0].vertices[1].y) }]; } } if (bchLines.length > 0 && bchLines[0].len >= 100) { res.bch = Math.round(bchLines[0].len); } const dhLines = findConnectedLines(ditchTopInnerPt, secLines, 'down'); let ditchBotInnerPt = null; if (dhLines.length > 0) { res.dh = Math.round(dhLines[0].len); ditchBotInnerPt = getOtherPt(dhLines[0].l, ditchTopInnerPt); } else { ditchBotInnerPt = { x: ditchTopInnerPt.x, y: ditchTopInnerPt.y - res.dh }; } const dwLines = findConnectedLines(ditchBotInnerPt, secLines, direction); let ditchBotOuterPt = null; if (dwLines.length > 0) { res.dw = Math.round(dwLines[0].len); ditchBotOuterPt = getOtherPt(dwLines[0].l, ditchBotInnerPt); } else { ditchBotOuterPt = { x: ditchBotInnerPt.x + (direction === 'left' ? -res.dw : res.dw), y: ditchBotInnerPt.y }; } const dhOuterLines = findConnectedLines(ditchBotOuterPt, secLines, 'up'); let ditchTopOuterPt = null; if (dhOuterLines.length > 0) { ditchTopOuterPt = getOtherPt(dhOuterLines[0].l, ditchBotOuterPt); } else { ditchTopOuterPt = { x: ditchBotOuterPt.x, y: ditchBotOuterPt.y + res.dh }; } let currentPt = ditchTopOuterPt; function getConnectedLinesAt(pt) { return secLines.filter(l => isSamePt(l.vertices[0], pt) || isSamePt(l.vertices[1], pt)); } const lineH3 = getConnectedLinesAt(currentPt).find(l => { const p = getOtherPt(l, currentPt); return p.y > currentPt.y && Math.abs(p.x - currentPt.x) <= 1.0; }); if (lineH3) { const p1 = getOtherPt(lineH3, currentPt); res.wh3 = Math.round(p1.y - currentPt.y); currentPt = p1; const lineH2 = getConnectedLinesAt(currentPt).find(l => { const p = getOtherPt(l, currentPt); if (p.y <= currentPt.y) return false; return direction === 'left' ? p.x < currentPt.x : p.x > currentPt.x; }); if (lineH2) { const p2 = getOtherPt(lineH2, currentPt); res.wh2 = Math.round(p2.y - currentPt.y); res.ww3 = Math.round(Math.abs(p2.x - currentPt.x)); currentPt = p2; const lineH1 = getConnectedLinesAt(currentPt).find(l => { const p = getOtherPt(l, currentPt); if (p.y <= currentPt.y) return false; return direction === 'left' ? p.x < currentPt.x : p.x > currentPt.x; }); if (lineH1) { const p3 = getOtherPt(lineH1, currentPt); res.wh1 = Math.round(p3.y - currentPt.y); res.ww2 = Math.round(Math.abs(p3.x - currentPt.x)); currentPt = p3; } } } const topY = currentPt.y; const dropVLines = secLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; if (Math.abs(p1.x - p2.x) > 1.0) return false; const maxY = Math.max(p1.y, p2.y); if (Math.abs(maxY - topY) > 1.0) return false; if (direction === 'left' && p1.x > currentPt.x - 0.5) return false; if (direction === 'right' && p1.x < currentPt.x + 0.5) return false; // 뚜껑은 포장 단부 currentPt에서 1200mm 이내의 X 좌표에 위치해야 함 if (Math.abs(p1.x - currentPt.x) > 1200) return false; return true; }); dropVLines.sort((a, b) => direction === 'left' ? b.vertices[0].x - a.vertices[0].x : a.vertices[0].x - b.vertices[0].x ); if (dropVLines.length >= 2) { const lidInnerX = dropVLines[0].vertices[0].x; const lidOuterX = dropVLines[1].vertices[0].x; res.ww1 = Math.round(Math.abs(currentPt.x - lidInnerX)); res.lw = Math.round(Math.abs(lidInnerX - lidOuterX)); res.lh = Math.round(Math.abs(dropVLines[0].vertices[0].y - dropVLines[0].vertices[1].y)); const outerHLines = secLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; if (Math.abs(p1.y - p2.y) > 1.0 || Math.abs(p1.y - topY) > 1.0) return false; if (direction === 'left') { return Math.max(p1.x, p2.x) <= lidOuterX + 1.0 && Math.min(p1.x, p2.x) < lidOuterX - 5; } else { return Math.min(p1.x, p2.x) >= lidOuterX - 1.0 && Math.max(p1.x, p2.x) > lidOuterX + 5; } }); if (outerHLines.length > 0) { res.gap = Math.round(Math.abs(outerHLines[0].vertices[0].x - outerHLines[0].vertices[1].x)); } const lidBotY = Math.min(dropVLines[0].vertices[0].y, dropVLines[0].vertices[1].y); const minX = Math.min(lidInnerX, lidOuterX) - 5; const maxX = Math.max(lidInnerX, lidOuterX) + 5; // 실제 트렌치 벽체/바닥 다각형 선분 추적 (X 좌표 및 깊이 구속: 2000mm까지 대형 공동구 수용) function traceTrenchPoly() { const candLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('HIDL') || layer.includes('DASH') || layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; const minY = Math.min(p1.y, p2.y), maxY = Math.max(p1.y, p2.y); const avgX = (p1.x + p2.x) / 2; // 공동구는 200*300, 300*300 고정이 아니며, 대형 복합 공동구(깊이 600~1500mm 이상)도 도면 그대로 수용 if (lidBotY - minY > 2000) return false; return avgX >= minX && avgX <= maxX && maxY <= lidBotY + 2 && minY < lidBotY - 10; }); const downLines = candLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const topPt = p1.y > p2.y ? p1 : p2; return Math.abs(topPt.y - lidBotY) <= 2; }); downLines.sort((a, b) => { const aTop = a.vertices[0].y > a.vertices[1].y ? a.vertices[0] : a.vertices[1]; const bTop = b.vertices[0].y > b.vertices[1].y ? b.vertices[0] : b.vertices[1]; return Math.abs(aTop.x - lidInnerX) - Math.abs(bTop.x - lidInnerX); }); if (downLines.length === 0) return null; const innerDownLine = downLines[0]; const p1 = innerDownLine.vertices[0], p2 = innerDownLine.vertices[1]; const innerTop = p1.y > p2.y ? p1 : p2; const innerBot = p1.y > p2.y ? p2 : p1; let curr = innerBot; const pts = [innerTop, innerBot]; const used = new Set([innerDownLine]); for (let i = 0; i < 8; i++) { const nexts = candLines.filter(l => { if (used.has(l)) return false; const d1 = Math.hypot(l.vertices[0].x - curr.x, l.vertices[0].y - curr.y); const d2 = Math.hypot(l.vertices[1].x - curr.x, l.vertices[1].y - curr.y); if (d1 >= 10 && d2 >= 10) return false; const other = d1 < 10 ? l.vertices[1] : l.vertices[0]; if (other.x < minX - 10 || other.x > maxX + 10 || other.y > lidBotY + 5) return false; if (pts.length === 2) { if (direction === 'left' && other.x > curr.x - 10) return false; if (direction === 'right' && other.x < curr.x + 10) return false; } return true; }).map(l => { const len = Math.hypot(l.vertices[1].x - l.vertices[0].x, l.vertices[1].y - l.vertices[0].y); return { l, len }; }).sort((a, b) => b.len - a.len); if (nexts.length === 0) break; const best = nexts[0].l; used.add(best); const d1 = Math.hypot(best.vertices[0].x - curr.x, best.vertices[0].y - curr.y); const other = d1 < 10 ? best.vertices[1] : best.vertices[0]; curr = other; pts.push(curr); if (Math.abs(curr.y - lidBotY) <= 2) break; } return pts; } const tPts = traceTrenchPoly(); if (tPts && tPts.length >= 3) { res.trenchRelPts = tPts.map(p => ({ dx: Math.round(p.x - lidInnerX), dy: Math.round(p.y - lidBotY) })); const innerWallDx = Math.abs(tPts[1].x - tPts[0].x); const outerWallDx = Math.abs(tPts[tPts.length - 1].x - tPts[tPts.length - 2].x); res.vw1 = Math.round(Math.abs(tPts[0].x - tPts[tPts.length - 1].x)); const minTrenchY = Math.min(...tPts.map(p => p.y)); const floorPts = tPts.filter(p => Math.abs(p.y - minTrenchY) <= 5); if (floorPts.length >= 2) { res.vw2 = Math.round(Math.abs(floorPts[0].x - floorPts[floorPts.length - 1].x)); } else { res.vw2 = res.vw1; } res.vh = Math.round(Math.abs(lidBotY - minTrenchY)); if (tPts.length > 4) { res.type = 'T3'; } else if (outerWallDx > 25 || innerWallDx > 25) { res.type = 'T2'; } else { res.type = 'T1'; } } else { const bodyLines = secLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const minY = Math.min(p1.y, p2.y); const maxY = Math.max(p1.y, p2.y); if (Math.abs(maxY - lidBotY) > 2.0) return false; const topX = p1.y > p2.y ? p1.x : p2.x; if (topX < minX || topX > maxX) return false; return minY < lidBotY - 10 && (lidBotY - minY) <= 2000; }); if (bodyLines.length >= 2) { const getTopBot = l => l.vertices[0].y > l.vertices[1].y ? { top: l.vertices[0], bot: l.vertices[1] } : { top: l.vertices[1], bot: l.vertices[0] }; const l1 = getTopBot(bodyLines[0]); const l2 = getTopBot(bodyLines[1]); res.vw1 = Math.round(Math.abs(l1.top.x - l2.top.x)); res.vw2 = Math.round(Math.abs(l1.bot.x - l2.bot.x)); res.vh = Math.round(Math.abs(l1.top.y - l1.bot.y)); if (Math.abs(res.vw1 - res.vw2) > 30) res.type = 'T2'; } } } return res; } // 종배수관(유공관) 원형(CIRCLE) 직경 추출 (포장단부 맹암거 하부 대구경 유공관 우선 탐색) let pipeDL = 300, pipeDR = 300; if (pavementLeftX !== null && pavementRightX !== null && mainPv) { const cSlope = (mainPv.vertices[1].y - mainPv.vertices[0].y) / (mainPv.vertices[1].x - mainPv.vertices[0].x); const yMainL = mainPv.vertices[0].y + cSlope * (pavementLeftX - mainPv.vertices[0].x); const yMainR = mainPv.vertices[0].y + cSlope * (pavementRightX - mainPv.vertices[0].x); const leftDrains = dxf.entities.filter(e => { if (!e || e.type !== 'CIRCLE' || !e.center) return false; return Math.abs(e.center.x - pavementLeftX) <= 2000 && e.center.y < yMainL + 500 && e.radius >= 100 && e.radius <= 300; }).sort((a, b) => b.radius - a.radius); const rightDrains = dxf.entities.filter(e => { if (!e || e.type !== 'CIRCLE' || !e.center) return false; return Math.abs(e.center.x - pavementRightX) <= 2000 && e.center.y < yMainR + 500 && e.radius >= 100 && e.radius <= 300; }).sort((a, b) => b.radius - a.radius); const fallbackCircles = dxf.entities.filter( e => e && e.type === 'CIRCLE' && e.center && e.radius >= 80 && e.radius <= 300 && Math.abs(e.center.x - eValAbs) < r1 ).sort((a, b) => b.radius - a.radius); const defaultPipeD = fallbackCircles.length > 0 ? Math.round(fallbackCircles[0].radius * 2) : 300; pipeDL = leftDrains.length > 0 ? Math.round(leftDrains[0].radius * 2) : defaultPipeD; pipeDR = rightDrains.length > 0 ? Math.round(rightDrains[0].radius * 2) : defaultPipeD; const parsedL = extractUg(pavementLeftX, yMainL, 'left'); const parsedR = extractUg(pavementRightX, yMainR, 'right'); parsedL.pipeD = pipeDL; parsedR.pipeD = pipeDR; window.ugData.left = parsedL; window.ugData.right = parsedR; } // 4) 차선 마커 및 도로 폭원 추출 let laneWidths = [3500, 3500]; let left_side = 1000; let right_side = 1000; let roadOriginX = 0; let roadOriginY = 0; const solidCandidates = dxf.entities.filter( e => e && (e.type === 'SOLID' || e.type === 'LWPOLYLINE' || e.type === 'POLYLINE' || e.type === 'HATCH') ); const markers = []; solidCandidates.forEach(s => { const layer = (s.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return; const pts = s.points || s.vertices || []; if (!pts.length) return; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; pts.forEach(p => { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); }); const width = maxX - minX, height = maxY - minY; if (width > 500 || height > 200) return; const bcX = (minX + maxX) / 2, bcY = minY; // 차선 마커는 반드시 포장면 X구간 내에 존재해야 함 if (pavementLeftX !== null && pavementRightX !== null) { if (bcX < pavementLeftX - 30 || bcX > pavementRightX + 30) return; } else if (Math.abs(bcX - eValAbs) > 10000) { return; } let targetPavementY = m3 * bcX; if (mainPv) { const v0 = mainPv.vertices[0], v1 = mainPv.vertices[1]; const slope = (v1.y - v0.y) / (v1.x - v0.x); targetPavementY = v0.y + slope * (bcX - v0.x); } if (Math.abs(bcY - targetPavementY) <= 12) { markers.push({ x: bcX, y: bcY }); } }); if (markers.length >= 2) { markers.sort((a, b) => a.x - b.x); const uniqueMarkers = []; markers.forEach(mk => { if (uniqueMarkers.length === 0 || Math.abs(uniqueMarkers[uniqueMarkers.length - 1].x - mk.x) > 50) { uniqueMarkers.push(mk); } }); let bestChain = []; const findChain = startIndex => { const queue = [[uniqueMarkers[startIndex]]]; let longest = []; while (queue.length > 0) { const current = queue.shift(); const last = current[current.length - 1]; let expanded = false; for (let j = 0; j < uniqueMarkers.length; j++) { const next = uniqueMarkers[j]; if (next.x > last.x) { const d = next.x - last.x; // 차로폭 규격: 최소 2800mm ~ 최대 4200mm 또는 차선 분리 마킹 간격(300mm ~ 1000mm) if ((d >= 2800 && d <= 4200) || (d >= 300 && d <= 1000)) { queue.push([...current, next]); expanded = true; } } } if (!expanded && current.length > longest.length) longest = current; } return longest; }; for (let i = 0; i < uniqueMarkers.length; i++) { const ch = findChain(i); if (ch.length > bestChain.length) bestChain = ch; } if (bestChain.length >= 2) { laneWidths = []; for (let i = 0; i < bestChain.length - 1; i++) { laneWidths.push(Math.round(bestChain[i + 1].x - bestChain[i].x)); } roadOriginX = bestChain[0].x; roadOriginY = bestChain[0].y; if (pavementLeftX !== null && pavementRightX !== null) { const theta = Math.atan(m3); const cos = Math.cos(theta); // 경사면(기울기 m3)을 따르는 실제 투영 길이 계산 (도면 절대좌표계 Y 오프셋 왜곡 방지) const distL = Math.abs(bestChain[0].x - pavementLeftX) / cos; const distR = Math.abs(pavementRightX - bestChain[bestChain.length - 1].x) / cos; left_side = Math.round(distL); right_side = Math.round(distR); } } } else if (pavementLeftX !== null && pavementRightX !== null) { // 차선 마커가 없는 도면인 경우 포장 전체 폭에서 차로 및 측대 산출 const totalPaveW = Math.round(pavementRightX - pavementLeftX); const totalLaneW = laneWidths.reduce((s, v) => s + v, 0); const remainSide = Math.max(500, totalPaveW - totalLaneW); left_side = Math.round(remainSide / 2); right_side = remainSide - left_side; roadOriginX = pavementLeftX + left_side; if (mainPv) { const v0 = mainPv.vertices[0], v1 = mainPv.vertices[1]; const slope = (v1.y - v0.y) / (v1.x - v0.x); roadOriginY = v0.y + slope * (roadOriginX - v0.x); } } // 도로 좌측 차선 기준 상대 좌표 (offset, h1) 계산 const offset = Math.round(eValAbs - roadOriginX); const pavementYAtCenter = roadOriginY + m3 * offset; const h1 = Math.round(h1Abs - pavementYAtCenter); // 만약 R2~R5 중 일부가 도면 선분 분리로 미검출된 경우 기하학 공식으로 보완 const tw = laneWidths.reduce((s, v) => s + v, 0); const cx1 = offset; const cy1 = m3 * offset + h1; const rad1 = (theta1 * Math.PI) / 180; const startAng = Math.PI / 2 + rad1 / 2; const endAng = Math.PI / 2 - rad1 / 2; const rx_l = cx1 + r1 * Math.cos(startAng), ry_l = cy1 + r1 * Math.sin(startAng); const rx_r = cx1 + r1 * Math.cos(endAng), ry_r = cy1 + r1 * Math.sin(endAng); if (!r2) { const dL = window.ugData.left; const bL = m3 * -left_side; const baseYL = bL - (dL.bcw + dL.dw) * 0.02; const totalTopYL = baseYL + (dL.wh1 + dL.wh2 + dL.wh3); const LP1_x = -left_side - (dL.bcw + dL.dw) - (dL.ww1 + dL.ww2 + dL.ww3); const P_UG_x = LP1_x - dL.lw - dL.gap, P_UG_y = totalTopYL; const dx = rx_l - P_UG_x, dy = ry_l - P_UG_y; const ux = Math.cos(startAng), uy = Math.sin(startAng); r2 = Math.round(Math.max(1000, (dx * dx + dy * dy) / (2 * (dx * ux + dy * uy)))); } if (!r3) { const dR = window.ugData.right; const bR = m3 * (tw + right_side); const baseXR = tw + right_side + dR.bcw + dR.dw; const baseYR = bR - (dR.bcw + dR.dw) * 0.02; const wallEndXR = baseXR + (dR.ww1 + dR.ww2 + dR.ww3); const totalTopYR = baseYR + (dR.wh1 + dR.wh2 + dR.wh3); const P_UG_x_R = wallEndXR + dR.lw + dR.gap, P_UG_y_R = totalTopYR; const dx_R = rx_r - P_UG_x_R, dy_R = ry_r - P_UG_y_R; const ux_R = Math.cos(endAng), uy_R = Math.sin(endAng); r3 = Math.round(Math.max(1000, Math.abs((dx_R * dx_R + dy_R * dy_R) / (2 * (dx_R * ux_R + dy_R * uy_R))))); } if (!r4) r4 = Math.round(r2 + lining_t * 2.5); if (!r5) r5 = Math.round(r3 + lining_t * 2.5); // 전역 상태 업데이트 window.sectionParams = { offset, h1, theta1, r1, r2, r3, r4, r5, lining_t, shotcrete_t, seVal: Math.round(m3 * 100 * 10) / 10, lanes: laneWidths, left_sh: left_side, right_sh: right_side, left_side, right_side, h_val: 4800, pave_t1: pave_t1_val, pave_t2: pave_t2_val, pave_t3: pave_t3_val, pavementLeftX, pavementRightX }; syncParamsToInputs(); renderDetectionList(sourceName, dxf.entities.length); buildCurrentSection3DPlot(false); render2DSection(); } function syncParamsToInputs() { const p = window.sectionParams; const setIf = (id, v) => { const el = $(id); if (el) el.value = v; }; setIf('p_r1', p.r1); setIf('p_theta1', p.theta1); setIf('p_r2', p.r2); setIf('p_r3', p.r3); setIf('p_r4', p.r4); setIf('p_r5', p.r5); setIf('p_h1', p.h1); setIf('p_offset', p.offset); setIf('p_lining_t', p.lining_t); setIf('p_shotcrete_t', p.shotcrete_t); setIf('p_se', p.seVal); setIf('p_pave_t1', p.pave_t1); setIf('p_pave_t2', p.pave_t2); setIf('p_pave_t3', p.pave_t3); } function readParamsFromInputs() { const p = window.sectionParams; const getNum = (id, fb) => { const v = parseFloat($(id)?.value); return Number.isFinite(v) ? v : fb; }; p.r1 = getNum('p_r1', p.r1); p.theta1 = getNum('p_theta1', p.theta1); p.r2 = getNum('p_r2', p.r2); p.r3 = getNum('p_r3', p.r3); p.r4 = getNum('p_r4', p.r4); p.r5 = getNum('p_r5', p.r5); p.h1 = getNum('p_h1', p.h1); p.offset = getNum('p_offset', p.offset); p.lining_t = getNum('p_lining_t', p.lining_t); p.shotcrete_t = getNum('p_shotcrete_t', p.shotcrete_t); p.seVal = getNum('p_se', p.seVal); p.pave_t1 = getNum('p_pave_t1', p.pave_t1); p.pave_t2 = getNum('p_pave_t2', p.pave_t2); p.pave_t3 = getNum('p_pave_t3', p.pave_t3); } function renderDetectionList(sourceName, entityCount) { const p = window.sectionParams; const dL = window.ugData.left; const dR = window.ugData.right; const host = $('detectionList'); if (!host) return; const items = [ { color: '#475569', title: '숏크리트 1차 지보재', detail: `두께 ${p.shotcrete_t}mm · 천단 반경 R=${p.r1 + p.lining_t + p.shotcrete_t}mm 폐합 아치`, badge: '100%' }, { color: '#8da0b0', title: '라이닝·공동구 일체형 본체', detail: `R1=${p.r1} · R2=${p.r2} · R3=${p.r3} · 두께 ${p.lining_t}mm (경계면 없는 단일 솔리드)`, badge: '100%' }, { color: '#cbd5e1', title: '공동구 및 프리캐스트 뚜껑', detail: `좌 ${dL.type || 'T2'} (${dL.lw}×${dL.lh}mm, 내공 ${dL.vw1}×${dL.vh}mm) · 우 ${dR.type || 'T2'} (${dR.lw}×${dR.lh}mm, 내공 ${dR.vw1}×${dR.vh}mm)`, badge: '100%' }, { color: '#38bdf8', title: p.pave_t3 > 0 ? '도로포장 다층 구조 (3개층)' : '도로포장 구조 (2개층)', detail: p.pave_t3 > 0 ? `표층 t1=${p.pave_t1}mm · 콘크리트층 t2=${p.pave_t2}mm · 필터층 t3=${p.pave_t3}mm` : `콘크리트 슬래브 t1=${p.pave_t1}mm · 린콘크리트/필터층 t2=${p.pave_t2}mm`, badge: '98%' }, { color: '#d97706', title: '맹암거 및 유공 종배수관', detail: `좌·우 투수필터층 맹암거 · 종배수관 Ø${dL.pipeD}mm 원형 파이프`, badge: '95%' }, { color: '#10b981', title: '차선 및 도로 제원', detail: `${p.lanes.length}차로 (${p.lanes.join('+')}mm) · 측대 좌 ${p.left_side}/우 ${p.right_side}mm · 편경사 ${p.seVal}%`, badge: '95%' } ]; host.innerHTML = items .map( it => `