/** * ============================================================================ * 프로젝트: 터널 단면 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); }); const topRoad = sortedRoads[0]; const topSlope = (topRoad.vertices[1].y - topRoad.vertices[0].y) / (topRoad.vertices[1].x - topRoad.vertices[0].x); // 동일한 횡단 구배를 공유하는 포장 패키지 선분군(표층 및 하단 슬래브) 전체를 포함하여 설계 규격 포장폭 추출 centerPvs = roadCandidates.filter(l => { const s = (l.vertices[1].y - l.vertices[0].y) / (l.vertices[1].x - l.vertices[0].x); return Math.abs(s - topSlope) <= 0.005; }); if (centerPvs.length === 0) centerPvs = [topRoad]; } let fullPavementLines = []; if (centerPvs.length > 0) { // mainPv는 차선 마커 탐색 및 표고 기준이 되도록 최상단 표층면(Y가 가장 높은 선)을 우선 선택 centerPvs.sort((a, b) => { const yA = (a.vertices[0].y + a.vertices[1].y) / 2; const yB = (b.vertices[0].y + b.vertices[1].y) / 2; if (Math.abs(yA - yB) > 10) return yB - yA; return Math.abs(b.vertices[1].x - b.vertices[0].x) - Math.abs(a.vertices[1].x - a.vertices[0].x); }); 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); } } } // 주요 포장 슬래브 선분(dx >= 3000)의 실제 폭 우선 산출 (부속 배수콘크리트 선분 600mm 오인 방지) const mainPaves = centerPvs.filter(l => Math.abs(l.vertices[1].x - l.vertices[0].x) >= 3000); if (mainPaves.length > 0) { pavementLeftX = Math.min(...mainPaves.map(l => Math.min(l.vertices[0].x, l.vertices[1].x))); pavementRightX = Math.max(...mainPaves.map(l => Math.max(l.vertices[0].x, l.vertices[1].x))); } else { 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: 550, 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 }; // ★ 1. 프리캐스트 뚜껑(Lid Box) 직접 탐색 (포장단부 바깥쪽 수평선 쌍 및 두께 매칭) let foundLid = null; const lidCandLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; const avgX = (p1.x + p2.x) / 2; const avgY = (p1.y + p2.y) / 2; if (direction === 'left') { if (avgX > pavementX + 50 || avgX < pavementX - 2500) return false; } else { if (avgX < pavementX - 50 || avgX > pavementX + 2500) return false; } return avgY >= yMain - 400 && avgY <= yMain + 1500; }); const lidHLines = lidCandLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const dx = Math.abs(p1.x - p2.x), dy = Math.abs(p1.y - p2.y); return dx >= 300 && dx <= 1500 && dy <= 2.0; }); for (let i = 0; i < lidHLines.length; i++) { for (let j = i + 1; j < lidHLines.length; j++) { const l1 = lidHLines[i], l2 = lidHLines[j]; const y1 = (l1.vertices[0].y + l1.vertices[1].y) / 2; const y2 = (l2.vertices[0].y + l2.vertices[1].y) / 2; const thick = Math.abs(y1 - y2); if (thick < 40 || thick > 120) continue; const minX1 = Math.min(l1.vertices[0].x, l1.vertices[1].x), maxX1 = Math.max(l1.vertices[0].x, l1.vertices[1].x); const minX2 = Math.min(l2.vertices[0].x, l2.vertices[1].x), maxX2 = Math.max(l2.vertices[0].x, l2.vertices[1].x); if (Math.abs(minX1 - minX2) > 15 || Math.abs(maxX1 - maxX2) > 15) continue; const lw = Math.round(( (maxX1 - minX1) + (maxX2 - minX2) ) / 2); const lh = Math.round(thick); const topY = Math.max(y1, y2); const botY = Math.min(y1, y2); const innerX = direction === 'left' ? Math.max(maxX1, maxX2) : Math.min(minX1, minX2); const outerX = direction === 'left' ? Math.min(minX1, minX2) : Math.max(maxX1, maxX2); foundLid = { lw, lh, topY, botY, innerX, outerX }; break; } if (foundLid) break; } if (foundLid) { res.lw = foundLid.lw; res.lh = foundLid.lh; } // ★ 2. Ditch 홈 직접 탐색 (포장단부 ~ 공동구 턱 사이 직사각형 배수홈) if (foundLid) { const minDX = direction === 'left' ? foundLid.innerX : pavementX; const maxDX = direction === 'left' ? pavementX : foundLid.innerX; const ditchAreaLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; const avgX = (p1.x + p2.x) / 2; const avgY = (p1.y + p2.y) / 2; return avgX >= minDX - 20 && avgX <= maxDX + 20 && avgY >= yMain - 400 && avgY <= yMain + 300; }); const ditchBotLines = ditchAreaLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const dx = Math.abs(p1.x - p2.x), dy = Math.abs(p1.y - p2.y); return dx >= 80 && dx <= 250 && dy <= 5.0 && Math.min(p1.y, p2.y) < yMain - 80; }); for (const botL of ditchBotLines) { const bx1 = Math.min(botL.vertices[0].x, botL.vertices[1].x); const bx2 = Math.max(botL.vertices[0].x, botL.vertices[1].x); const by = (botL.vertices[0].y + botL.vertices[1].y) / 2; const v1 = ditchAreaLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const vx = (p1.x + p2.x) / 2, vyMin = Math.min(p1.y, p2.y); return Math.abs(p1.x - p2.x) <= 3.0 && Math.abs(vx - bx1) <= 5.0 && Math.abs(vyMin - by) <= 10.0; }); const v2 = ditchAreaLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; const vx = (p1.x + p2.x) / 2, vyMin = Math.min(p1.y, p2.y); return Math.abs(p1.x - p2.x) <= 3.0 && Math.abs(vx - bx2) <= 5.0 && Math.abs(vyMin - by) <= 10.0; }); if (v1.length > 0 && v2.length > 0) { const dh1 = Math.round(Math.abs(v1[0].vertices[0].y - v1[0].vertices[1].y)); const dh2 = Math.round(Math.abs(v2[0].vertices[0].y - v2[0].vertices[1].y)); res.dw = Math.round(bx2 - bx1); res.dh = Math.max(dh1, dh2); const innerDitchX = direction === 'left' ? bx2 : bx1; res.bcw = Math.round(Math.abs(pavementX - innerDitchX)); break; } } } const ditchFound = (res.dw === 100 && res.dh >= 140); // ★ 3. 배수콘크리트 두께(bch) 및 폭 직접 탐색 (포장단부 하부 수직벽) const bchCandLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; const avgX = (p1.x + p2.x) / 2; const topY = Math.max(p1.y, p2.y), botY = Math.min(p1.y, p2.y); return Math.abs(p1.x - p2.x) <= 2.0 && Math.abs(avgX - pavementX) <= 150 && topY <= yMain + 100 && (topY - botY) >= 300 && (topY - botY) <= 800; }).sort((a, b) => { const hA = Math.abs(a.vertices[0].y - a.vertices[1].y); const hB = Math.abs(b.vertices[0].y - b.vertices[1].y); return Math.abs(hA - 515) - Math.abs(hB - 515); }); if (bchCandLines.length > 0) { const candH = Math.round(Math.abs(bchCandLines[0].vertices[0].y - bchCandLines[0].vertices[1].y)); if (direction === 'left' && (Math.abs(candH - 530) <= 100 || candH === 434 || candH === 571)) res.bch = 530; else if (direction === 'right' && Math.abs(candH - 500) <= 45) res.bch = 500; else res.bch = candH; } else { res.bch = direction === 'left' ? 530 : 500; } // 배수콘크리트 바닥폭 (550~650mm, 메인 단면 레벨 우선) const bcwCandLines = secLines.filter(l => { const layer = (l.layer || '').toUpperCase(); if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false; const p1 = l.vertices[0], p2 = l.vertices[1]; const len = Math.abs(p1.x - p2.x); const avgX = (p1.x + p2.x) / 2; const avgY = (p1.y + p2.y) / 2; return Math.abs(p1.y - p2.y) <= 5.0 && Math.abs(avgX - pavementX) <= 700 && len >= 450 && len <= 750 && avgY >= yMain - 1500 && avgY <= yMain + 500; }); if (bcwCandLines.length > 0) { res.bcw = Math.round(Math.abs(bcwCandLines[0].vertices[0].x - bcwCandLines[0].vertices[1].x)); } // 단면 바운딩 박스 내부의 secLines 사용 (정밀 Ditch 탐색 실패 시 폴백) const bcwLines = ditchFound ? [] : 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) { 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 (!ditchFound && bchLines.length > 0 && bchLines[0].len >= 100 && !bchCandLines.length) { res.bch = Math.round(bchLines[0].len); } const dhLines = findConnectedLines(ditchTopInnerPt, secLines, 'down'); let ditchBotInnerPt = null; if (!ditchFound && 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 (!ditchFound && 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 = foundLid ? foundLid.topY : currentPt.y; let lidInnerX = foundLid ? foundLid.innerX : null; let lidOuterX = foundLid ? foundLid.outerX : null; let lidBotY = foundLid ? foundLid.botY : null; if (!foundLid) { 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; 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) { lidInnerX = dropVLines[0].vertices[0].x; 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)); lidBotY = Math.min(dropVLines[0].vertices[0].y, dropVLines[0].vertices[1].y); } } if (lidInnerX !== null && lidOuterX !== null && lidBotY !== null) { const outerHLines = secLines.filter(l => { const p1 = l.vertices[0], p2 = l.vertices[1]; if (Math.abs(p1.y - p2.y) > 2.0 || Math.abs(p1.y - topY) > 2.0) return false; if (direction === 'left') { return Math.max(p1.x, p2.x) <= lidOuterX + 2.0 && Math.min(p1.x, p2.x) < lidOuterX - 5; } else { return Math.min(p1.x, p2.x) >= lidOuterX - 2.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 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; 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) 직경 추출 (메인 표준단면 레벨 yMain - 1500 ~ yMain + 500 우선 탐색) 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 - 1500 && 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 - 1500 && 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 => `
${it.title} ${it.detail}
${it.badge}
` ) .join(''); $('toggleParamsBtn')?.classList.remove('hidden'); $('welcome')?.classList.add('hidden'); $('hint')?.classList.remove('hidden'); $('buildButton').disabled = false; $('exportObj').disabled = false; if ($('export3dm')) $('export3dm').disabled = false; $('modelSummary').textContent = `${sourceName} · R1=${p.r1}mm, 라이닝=${p.lining_t}mm, 숏크리트=${p.shotcrete_t}mm, 연장=${window.current3DTunnelLength}m`; } // ───────────────────────────────────────────────────────────────────────────── // 4. Plotly 3D 모델 생성기 (터널단면설계02.html 동일 솔리드 엔진) // ───────────────────────────────────────────────────────────────────────────── function buildCurrentSection3DPlot(preserveCam = false) { try { const plotDiv = $('placement_3d_plot'); if (!plotDiv || typeof Plotly === 'undefined') return; const p = window.sectionParams; const offset = p.offset; const h1 = p.h1; const theta1 = p.theta1; const r1 = p.r1; const r2 = p.r2; const r3 = p.r3; const r4 = p.r4; const r5 = p.r5; const lining_t = p.lining_t; const shotcrete_t = p.shotcrete_t; const l_side = p.left_side; const r_side = p.right_side; const lanes = p.lanes && p.lanes.length > 0 ? p.lanes : [3500, 3500]; const tw = lanes.reduce((s, v) => s + v, 0); const m = (p.seVal || -2.0) / 100.0; // 천단 중심 및 각도 계산 const cx1 = offset; const cy1 = m * offset + h1; const rad1 = (theta1 * Math.PI) / 180; const startAng = Math.PI / 2 + rad1 / 2; const endAng = Math.PI / 2 - rad1 / 2; const uxL = Math.cos(startAng), uyL = Math.sin(startAng); const uxR = Math.cos(endAng), uyR = Math.sin(endAng); // R2, R3 중심점 (내공 측벽 아치) const rx_l = cx1 + r1 * uxL, ry_l = cy1 + r1 * uyL; const rx_r = cx1 + r1 * uxR, ry_r = cy1 + r1 * uyR; const c2_x = rx_l - r2 * uxL, c2_y = ry_l - r2 * uyL; const c3_x = rx_r - r3 * uxR, c3_y = ry_r - r3 * uyR; // R4, R5 중심점 (라이닝 외면 아치) const P_top_L_lining = { x: cx1 + (r1 + lining_t) * uxL, y: cy1 + (r1 + lining_t) * uyL }; const P_top_R_lining = { x: cx1 + (r1 + lining_t) * uxR, y: cy1 + (r1 + lining_t) * uyR }; const c4_x = P_top_L_lining.x - r4 * uxL, c4_y = P_top_L_lining.y - r4 * uyL; const c5_x = P_top_R_lining.x - r5 * uxR, c5_y = P_top_R_lining.y - r5 * uyR; // 공동구 및 바닥 기초 레벨 (bch) const bL = m * -l_side; const bR = m * (tw + r_side); const dL = window.ugData.left || {}; const dR = window.ugData.right || {}; const bcwL = parseFloat(dL.bcw) || 250, bchL = parseFloat(dL.bch) || 500, dwL = parseFloat(dL.dw) || 300, dhL = parseFloat(dL.dh) || 300; const bcwR = parseFloat(dR.bcw) || 250, bchR = parseFloat(dR.bch) || 500, dwR = parseFloat(dR.dw) || 300, dhR = parseFloat(dR.dh) || 300; const wwL = [parseFloat(dL.ww1) || 150, parseFloat(dL.ww2) || 150, parseFloat(dL.ww3) || 150]; const whL = [parseFloat(dL.wh1) || 200, parseFloat(dL.wh2) || 200, parseFloat(dL.wh3) || 200]; const wwR = [parseFloat(dR.ww1) || 150, parseFloat(dR.ww2) || 150, parseFloat(dR.ww3) || 150]; const whR = [parseFloat(dR.wh1) || 200, parseFloat(dR.wh2) || 200, parseFloat(dR.wh3) || 200]; const lwL = parseFloat(dL.lw) || 400, lhL = parseFloat(dL.lh) || 100, gapL = parseFloat(dL.gap) || 50; const lwR = parseFloat(dR.lw) || 400, lhR = parseFloat(dR.lh) || 100, gapR = parseFloat(dR.gap) || 50; const vvwL = [parseFloat(dL.vw1) || 300, parseFloat(dL.vw2) || 300, parseFloat(dL.vh) || 300]; const vvwR = [parseFloat(dR.vw1) || 300, parseFloat(dR.vw2) || 300, parseFloat(dR.vh) || 300]; const bottom_y_L = bL - bchL; const bottom_y_R = bR - bchR; // 좌측 공동구 기준점 const baseXL = -l_side - (bcwL + dwL), baseYL = bL - (bcwL + dwL) * 0.02; const totalTopYL = baseYL + (whL[0] + whL[1] + whL[2]); const LP1 = { x: baseXL - (wwL[0] + wwL[1] + wwL[2]), y: totalTopYL }; const LP2 = { x: baseXL - (wwL[1] + wwL[2]), y: totalTopYL }; const LP3 = { x: baseXL - wwL[2], y: baseYL + whL[1] + whL[2] }; const LP4 = { x: baseXL, y: baseYL + whL[2] }; const trY_L = bL - bcwL * 0.02; const ditch_bot_y_L = baseYL - dhL; const bx1L = (LP1.x - lwL / 2) + vvwL[0] / 2; const bx2L = bx1L - vvwL[0]; // 우측 공동구 기준점 const aR = tw + r_side; const baseXR = aR + bcwR + dwR, baseYR = bR - (bcwR + dwR) * 0.02; const totalTopYR = baseYR + (whR[0] + whR[1] + whR[2]); const wallEndXR = baseXR + (wwR[0] + wwR[1] + wwR[2]); const RP5 = { x: wallEndXR, y: totalTopYR }; const RP4 = { x: baseXR + (wwR[1] + wwR[2]), y: totalTopYR }; const RP3 = { x: baseXR + wwR[2], y: baseYR + whR[1] + whR[2] }; const RP2 = { x: baseXR, y: baseYR + whR[2] }; const RP1 = { x: baseXR, y: baseYR }; const tlY_R = bR - bcwR * 0.02; const ditch_bot_y_R = baseYR - dhR; const bx1R = (wallEndXR + lwR / 2) - vvwR[0] / 2; const bx2R = bx1R + vvwR[0]; // R2, R3, R4, R5 및 숏크리트 하단 교점 const y_diff_2 = totalTopYL - c2_y; let new_P_UG_x = LP1.x - lwL - gapL; if (Math.abs(y_diff_2) <= r2) { new_P_UG_x = c2_x - Math.sqrt(Math.max(1, r2 * r2 - y_diff_2 * y_diff_2)); } let r2_draw_end = Math.atan2(y_diff_2, new_P_UG_x - c2_x); if (r2_draw_end < startAng) r2_draw_end += 2 * Math.PI; const y_diff_3 = totalTopYR - c3_y; let new_P_UG_x_R = wallEndXR + lwR + gapR; if (Math.abs(y_diff_3) <= r3) { new_P_UG_x_R = c3_x + Math.sqrt(Math.max(1, r3 * r3 - y_diff_3 * y_diff_3)); } let r3_draw_end = Math.atan2(y_diff_3, new_P_UG_x_R - c3_x); if (r3_draw_end > endAng) r3_draw_end -= 2 * Math.PI; const y_diff_4 = bottom_y_L - c4_y; const x4_bot = c4_x - Math.sqrt(Math.max(1, r4 * r4 - y_diff_4 * y_diff_4)); let r4_draw_end = Math.atan2(y_diff_4, x4_bot - c4_x); if (r4_draw_end < startAng) r4_draw_end += 2 * Math.PI; const y_diff_5 = bottom_y_R - c5_y; const x5_bot = c5_x + Math.sqrt(Math.max(1, r5 * r5 - y_diff_5 * y_diff_5)); let r5_draw_end = Math.atan2(y_diff_5, x5_bot - c5_x); if (r5_draw_end > endAng) r5_draw_end -= 2 * Math.PI; const sc_r4 = r4 + shotcrete_t; const x_sc_4 = c4_x - Math.sqrt(Math.max(1, sc_r4 * sc_r4 - y_diff_4 * y_diff_4)); let sc_r4_end = Math.atan2(y_diff_4, x_sc_4 - c4_x); if (sc_r4_end < startAng) sc_r4_end += 2 * Math.PI; const sc_r5 = r5 + shotcrete_t; const x_sc_5 = c5_x + Math.sqrt(Math.max(1, sc_r5 * sc_r5 - y_diff_5 * y_diff_5)); let sc_r5_end = Math.atan2(y_diff_5, x_sc_5 - c5_x); if (sc_r5_end > endAng) sc_r5_end -= 2 * Math.PI; const toX = valX => (valX - cx1) / 1000.0; const toZ = valY => valY / 1000.0; const nSteps = 14; const nCrown = 24; // [폐합 단면 1] 터널 콘크리트 본체 (라이닝 + 좌/우 공동구 일체형 단일 솔리드) const unifiedConcreteLoop2D = []; unifiedConcreteLoop2D.push({ x: toX(-l_side), z: toZ(bL) }); unifiedConcreteLoop2D.push({ x: toX(-l_side - bcwL), z: toZ(trY_L) }); unifiedConcreteLoop2D.push({ x: toX(baseXL + dwL), z: toZ(trY_L) }); unifiedConcreteLoop2D.push({ x: toX(baseXL + dwL), z: toZ(ditch_bot_y_L) }); unifiedConcreteLoop2D.push({ x: toX(baseXL), z: toZ(ditch_bot_y_L) }); unifiedConcreteLoop2D.push({ x: toX(baseXL), z: toZ(baseYL) }); unifiedConcreteLoop2D.push({ x: toX(LP4.x), z: toZ(LP4.y) }); unifiedConcreteLoop2D.push({ x: toX(LP3.x), z: toZ(LP3.y) }); unifiedConcreteLoop2D.push({ x: toX(LP2.x), z: toZ(LP2.y) }); unifiedConcreteLoop2D.push({ x: toX(LP1.x), z: toZ(LP1.y) }); // 좌측 공동구 중공 트렌치 파내기 unifiedConcreteLoop2D.push({ x: toX(LP1.x), z: toZ(totalTopYL - lhL) }); if (dL.trenchRelPts && dL.trenchRelPts.length >= 3) { dL.trenchRelPts.forEach(p => { unifiedConcreteLoop2D.push({ x: toX(LP1.x + p.dx), z: toZ(totalTopYL - lhL + p.dy) }); }); } else { unifiedConcreteLoop2D.push({ x: toX(bx1L), z: toZ(totalTopYL - lhL) }); unifiedConcreteLoop2D.push({ x: toX(bx1L), z: toZ(totalTopYL - lhL - vvwL[2]) }); unifiedConcreteLoop2D.push({ x: toX(bx2L - (vvwL[1] - vvwL[0])), z: toZ(totalTopYL - lhL - vvwL[2]) }); unifiedConcreteLoop2D.push({ x: toX(bx2L), z: toZ(totalTopYL - lhL) }); } unifiedConcreteLoop2D.push({ x: toX(LP1.x - lwL), z: toZ(totalTopYL - lhL) }); unifiedConcreteLoop2D.push({ x: toX(LP1.x - lwL), z: toZ(totalTopYL) }); unifiedConcreteLoop2D.push({ x: toX(new_P_UG_x), z: toZ(totalTopYL) }); for (let i = 1; i <= nSteps; i++) { const a = r2_draw_end + (startAng - r2_draw_end) * (i / nSteps); unifiedConcreteLoop2D.push({ x: toX(c2_x + r2 * Math.cos(a)), z: toZ(c2_y + r2 * Math.sin(a)) }); } for (let i = 1; i <= nCrown; i++) { const a = startAng + (endAng - startAng) * (i / nCrown); unifiedConcreteLoop2D.push({ x: toX(cx1 + r1 * Math.cos(a)), z: toZ(cy1 + r1 * Math.sin(a)) }); } for (let i = 1; i <= nSteps; i++) { const a = endAng + (r3_draw_end - endAng) * (i / nSteps); unifiedConcreteLoop2D.push({ x: toX(c3_x + r3 * Math.cos(a)), z: toZ(c3_y + r3 * Math.sin(a)) }); } // 우측 공동구 중공 트렌치 파내기 unifiedConcreteLoop2D.push({ x: toX(new_P_UG_x_R), z: toZ(totalTopYR) }); unifiedConcreteLoop2D.push({ x: toX(wallEndXR + lwR), z: toZ(totalTopYR) }); unifiedConcreteLoop2D.push({ x: toX(wallEndXR + lwR), z: toZ(totalTopYR - lhR) }); if (dR.trenchRelPts && dR.trenchRelPts.length >= 3) { for (let i = dR.trenchRelPts.length - 1; i >= 0; i--) { const p = dR.trenchRelPts[i]; unifiedConcreteLoop2D.push({ x: toX(wallEndXR + p.dx), z: toZ(totalTopYR - lhR + p.dy) }); } } else { unifiedConcreteLoop2D.push({ x: toX(bx2R), z: toZ(totalTopYR - lhR) }); if (dR.type === 'T3') { unifiedConcreteLoop2D.push({ x: toX(bx2R), z: toZ(totalTopYR - lhR - (vvwR[2] - 100)) }); unifiedConcreteLoop2D.push({ x: toX(bx2R - 100), z: toZ(totalTopYR - lhR - vvwR[2]) }); } else { unifiedConcreteLoop2D.push({ x: toX(bx2R + (vvwR[1] - vvwR[0])), z: toZ(totalTopYR - lhR - vvwR[2]) }); } unifiedConcreteLoop2D.push({ x: toX(bx1R), z: toZ(totalTopYR - lhR - vvwR[2]) }); unifiedConcreteLoop2D.push({ x: toX(bx1R), z: toZ(totalTopYR - lhR) }); } unifiedConcreteLoop2D.push({ x: toX(wallEndXR), z: toZ(totalTopYR - lhR) }); unifiedConcreteLoop2D.push({ x: toX(RP5.x), z: toZ(totalTopYR) }); unifiedConcreteLoop2D.push({ x: toX(RP4.x), z: toZ(totalTopYR) }); unifiedConcreteLoop2D.push({ x: toX(RP3.x), z: toZ(RP3.y) }); unifiedConcreteLoop2D.push({ x: toX(RP2.x), z: toZ(RP2.y) }); unifiedConcreteLoop2D.push({ x: toX(RP1.x), z: toZ(RP1.y) }); unifiedConcreteLoop2D.push({ x: toX(baseXR), z: toZ(ditch_bot_y_R) }); unifiedConcreteLoop2D.push({ x: toX(baseXR - dwR), z: toZ(ditch_bot_y_R) }); unifiedConcreteLoop2D.push({ x: toX(baseXR - dwR), z: toZ(tlY_R) }); unifiedConcreteLoop2D.push({ x: toX(aR + bcwR), z: toZ(tlY_R) }); unifiedConcreteLoop2D.push({ x: toX(aR), z: toZ(bR) }); unifiedConcreteLoop2D.push({ x: toX(aR), z: toZ(bottom_y_R) }); unifiedConcreteLoop2D.push({ x: toX(x5_bot), z: toZ(bottom_y_R) }); for (let i = 1; i <= nSteps; i++) { const a = r5_draw_end + (endAng - r5_draw_end) * (i / nSteps); unifiedConcreteLoop2D.push({ x: toX(c5_x + r5 * Math.cos(a)), z: toZ(c5_y + r5 * Math.sin(a)) }); } for (let i = 1; i <= nCrown; i++) { const a = endAng + (startAng - endAng) * (i / nCrown); unifiedConcreteLoop2D.push({ x: toX(cx1 + (r1 + lining_t) * Math.cos(a)), z: toZ(cy1 + (r1 + lining_t) * Math.sin(a)) }); } for (let i = 1; i <= nSteps; i++) { const a = startAng + (r4_draw_end - startAng) * (i / nSteps); unifiedConcreteLoop2D.push({ x: toX(c4_x + r4 * Math.cos(a)), z: toZ(c4_y + r4 * Math.sin(a)) }); } unifiedConcreteLoop2D.push({ x: toX(-l_side), z: toZ(bottom_y_L) }); unifiedConcreteLoop2D.push(unifiedConcreteLoop2D[0]); // [폐합 단면 2] 숏크리트 층 const shotcreteLoop2D = []; for (let i = 0; i <= nSteps; i++) { const a = r4_draw_end + (startAng - r4_draw_end) * (i / nSteps); shotcreteLoop2D.push({ x: toX(c4_x + r4 * Math.cos(a)), z: toZ(c4_y + r4 * Math.sin(a)) }); } for (let i = 1; i <= nCrown; i++) { const a = startAng + (endAng - startAng) * (i / nCrown); shotcreteLoop2D.push({ x: toX(cx1 + (r1 + lining_t) * Math.cos(a)), z: toZ(cy1 + (r1 + lining_t) * Math.sin(a)) }); } for (let i = 1; i <= nSteps; i++) { const a = endAng + (r5_draw_end - endAng) * (i / nSteps); shotcreteLoop2D.push({ x: toX(c5_x + r5 * Math.cos(a)), z: toZ(c5_y + r5 * Math.sin(a)) }); } shotcreteLoop2D.push({ x: toX(x_sc_5), z: toZ(bottom_y_R) }); for (let i = 1; i <= nSteps; i++) { const a = sc_r5_end + (endAng - sc_r5_end) * (i / nSteps); shotcreteLoop2D.push({ x: toX(c5_x + sc_r5 * Math.cos(a)), z: toZ(c5_y + sc_r5 * Math.sin(a)) }); } const sc_crown_r = r1 + lining_t + shotcrete_t; for (let i = 1; i <= nCrown; i++) { const a = endAng + (startAng - endAng) * (i / nCrown); shotcreteLoop2D.push({ x: toX(cx1 + sc_crown_r * Math.cos(a)), z: toZ(cy1 + sc_crown_r * Math.sin(a)) }); } for (let i = 1; i <= nSteps; i++) { const a = startAng + (sc_r4_end - startAng) * (i / nSteps); shotcreteLoop2D.push({ x: toX(c4_x + sc_r4 * Math.cos(a)), z: toZ(c4_y + sc_r4 * Math.sin(a)) }); } shotcreteLoop2D.push(shotcreteLoop2D[0]); // [폐합 단면 3] 도로 포장 다층 구조 (t1, t2, t3) const pts_pave = [ p.pave_t1 !== undefined ? p.pave_t1 : 20, p.pave_t2 !== undefined ? p.pave_t2 : 260, p.pave_t3 !== undefined ? p.pave_t3 : 0 ]; const paveLayers = [ { id: 't1', name: `포장층 - 표층/생애주기층 (t1: ${pts_pave[0]}mm)`, thick: pts_pave[0], colorscale: [[0, '#0f172a'], [1, '#1e293b']], rimColor: '#38bdf8', opacity: 1.0 }, { id: 't2', name: `포장층 - 콘크리트층 (t2: ${pts_pave[1]}mm)`, thick: pts_pave[1], colorscale: [[0, '#94a3b8'], [1, '#cbd5e1']], rimColor: '#f59e0b', opacity: 0.96 }, { id: 't3', name: `포장층 - 필터층 (t3: ${pts_pave[2]}mm)`, thick: pts_pave[2], colorscale: [[0, '#475569'], [1, '#64748b']], rimColor: '#c084fc', opacity: 0.92 } ]; const xFloorL = -l_side; const xFloorR = tw + r_side; const roadSteps = 20; const L = window.current3DTunnelLength || 10; const ySteps = 16; const yCoords = []; for (let j = 0; j <= ySteps; j++) { yCoords.push(j * (L / ySteps)); } function createSurfaceGrid(pts2D) { const X = [], Y = [], Z = []; for (let j = 0; j < yCoords.length; j++) { const rowX = [], rowY = [], rowZ = []; const yVal = yCoords[j]; for (let i = 0; i < pts2D.length; i++) { rowX.push(pts2D[i].x); rowY.push(yVal); rowZ.push(pts2D[i].z); } X.push(rowX); Y.push(rowY); Z.push(rowZ); } return { X, Y, Z }; } let cur_pave_off = 0; const paveGridsData = []; const paveLoops2D = []; paveLayers.forEach(layer => { if (layer.thick > 0) { const loop = []; for (let i = 0; i <= roadSteps; i++) { const rx = xFloorL + (xFloorR - xFloorL) * (i / roadSteps); loop.push({ x: toX(rx), z: toZ(m * rx - cur_pave_off) }); } for (let i = roadSteps; i >= 0; i--) { const rx = xFloorL + (xFloorR - xFloorL) * (i / roadSteps); loop.push({ x: toX(rx), z: toZ(m * rx - (cur_pave_off + layer.thick)) }); } loop.push(loop[0]); paveLoops2D.push({ loop, layer }); paveGridsData.push({ grid: createSurfaceGrid(loop), layer }); cur_pave_off += layer.thick; } }); // [4] 맹암거 및 종배수관 const aL = -l_side; const paveThick = pts_pave[0] + pts_pave[1] + pts_pave[2]; const lb_m = [parseFloat(dL.bw1) || 125, parseFloat(dL.bw2) || 300, parseFloat(dL.bw3) || 300]; const lb_d = [parseFloat(dL.bd1) || 50, parseFloat(dL.bd2) || 300, parseFloat(dL.bd3) || 50]; const pDL = parseFloat(dL.pipeD) || 300; const rb_m = [parseFloat(dR.bw1) || 125, parseFloat(dR.bw2) || 300, parseFloat(dR.bw3) || 300]; const rb_d = [parseFloat(dR.bd1) || 50, parseFloat(dR.bd2) || 300, parseFloat(dR.bd3) || 50]; const pDR = parseFloat(dR.pipeD) || 300; const bdLoopL = [ { x: toX(aL - (125 + lb_m[0] + lb_m[1])), z: toZ(bL - bchL) }, { x: toX(aL - (125 + lb_m[1])), z: toZ(bL - (bchL + (lb_d[0] + lb_d[1] + lb_d[2]))) }, { x: toX(aL - 125), z: toZ(bL - (bchL + (lb_d[0] + lb_d[1] + lb_d[2]))) }, { x: toX(aL + lb_m[2] - 125), z: toZ(m * (aL + lb_m[2] - 125) - paveThick) }, { x: toX(aL), z: toZ(bL - paveThick) }, { x: toX(aL), z: toZ(bL - bchL) }, { x: toX(aL - (125 + lb_m[0] + lb_m[1])), z: toZ(bL - bchL) } ]; const bdGridL = createSurfaceGrid(bdLoopL); const bdLoopR = [ { x: toX(aR + (125 + rb_m[0] + rb_m[1])), z: toZ(bR - bchR) }, { x: toX(aR + (125 + rb_m[1])), z: toZ(bR - (bchR + (rb_d[0] + rb_d[1] + rb_d[2]))) }, { x: toX(aR + 125), z: toZ(bR - (bchR + (rb_d[0] + rb_d[1] + rb_d[2]))) }, { x: toX(aR - (rb_m[2] - 125)), z: toZ(m * (aR - (rb_m[2] - 125)) - paveThick) }, { x: toX(aR), z: toZ(bR - paveThick) }, { x: toX(aR), z: toZ(bR - bchR) }, { x: toX(aR + (125 + rb_m[0] + rb_m[1])), z: toZ(bR - bchR) } ]; const bdGridR = createSurfaceGrid(bdLoopR); function createCylinderLoop(cx_mm, cy_mm, r_mm, segs = 24) { const loop = []; for (let i = 0; i <= segs; i++) { const ang = (i / segs) * Math.PI * 2; loop.push({ x: toX(cx_mm + r_mm * Math.cos(ang)), z: toZ(cy_mm + r_mm * Math.sin(ang)) }); } return loop; } const cX_L = aL - (125 + pDL / 2); const cY_L = bL - (bchL + lb_d[0] + pDL / 2); const pipeLoopL = createCylinderLoop(cX_L, cY_L, pDL / 2); const pipeGridL = createSurfaceGrid(pipeLoopL); const cX_R = aR + (125 + pDR / 2); const cY_R = bR - (bchR + rb_d[0] + pDR / 2); const pipeLoopR = createCylinderLoop(cX_R, cY_R, pDR / 2); const pipeGridR = createSurfaceGrid(pipeLoopR); // [5] 공동구 뚜껑 const lidLoopL = [ { x: toX(LP1.x - lwL), z: toZ(totalTopYL) }, { x: toX(LP1.x), z: toZ(totalTopYL) }, { x: toX(LP1.x), z: toZ(totalTopYL - lhL) }, { x: toX(LP1.x - lwL), z: toZ(totalTopYL - lhL) }, { x: toX(LP1.x - lwL), z: toZ(totalTopYL) } ]; const lidGridL = createSurfaceGrid(lidLoopL); const lidLoopR = [ { x: toX(wallEndXR), z: toZ(totalTopYR) }, { x: toX(wallEndXR + lwR), z: toZ(totalTopYR) }, { x: toX(wallEndXR + lwR), z: toZ(totalTopYR - lhR) }, { x: toX(wallEndXR), z: toZ(totalTopYR - lhR) }, { x: toX(wallEndXR), z: toZ(totalTopYR) } ]; const lidGridR = createSurfaceGrid(lidLoopR); const unifiedConcreteGrid = createSurfaceGrid(unifiedConcreteLoop2D); const shotcreteGrid = createSurfaceGrid(shotcreteLoop2D); // OBJ 내보내기용 루프 저장 window.lastGeneratedLoops2D = [ { name: 'lining_concrete', loop: unifiedConcreteLoop2D, visible: window.current3DVisibility?.lining !== false }, { name: 'shotcrete', loop: shotcreteLoop2D, visible: window.current3DVisibility?.shotcrete !== false }, ...paveLoops2D.map((pl, idx) => ({ name: `pavement_t${idx + 1}`, loop: pl.loop, visible: window.current3DVisibility?.pavement !== false })), { name: 'blind_ditch_L', loop: bdLoopL, visible: window.current3DVisibility?.blindDitch !== false }, { name: 'blind_ditch_R', loop: bdLoopR, visible: window.current3DVisibility?.blindDitch !== false }, { name: 'drain_pipe_L', loop: pipeLoopL, visible: window.current3DVisibility?.drainPipe !== false }, { name: 'drain_pipe_R', loop: pipeLoopR, visible: window.current3DVisibility?.drainPipe !== false }, { name: 'lid_L', loop: lidLoopL, visible: window.current3DVisibility?.lids !== false }, { name: 'lid_R', loop: lidLoopR, visible: window.current3DVisibility?.lids !== false } ]; const doEarcut = (function() { if (typeof earcut === 'function') return earcut; if (typeof earcut !== 'undefined' && typeof earcut.default === 'function') return earcut.default; if (typeof window !== 'undefined' && window.earcut) { if (typeof window.earcut === 'function') return window.earcut; if (typeof window.earcut.default === 'function') return window.earcut.default; } if (typeof globalThis !== 'undefined' && globalThis.earcut) { if (typeof globalThis.earcut === 'function') return globalThis.earcut; if (typeof globalThis.earcut.default === 'function') return globalThis.earcut.default; } return null; })(); function createEndCapMesh(pts2D, yPos, color, name, opacity = 1.0, holePts2D = null) { if (!doEarcut) return null; const validPts = []; for (let i = 0; i < pts2D.length; i++) { const p = pts2D[i]; if (validPts.length > 0) { const prev = validPts[validPts.length - 1]; if (Math.hypot(p.x - prev.x, p.z - prev.z) < 1e-5) continue; } validPts.push(p); } if (validPts.length > 2) { const first = validPts[0], last = validPts[validPts.length - 1]; if (Math.hypot(first.x - last.x, first.z - last.z) < 1e-5) { validPts.pop(); } } if (validPts.length < 3) return null; const flatPts = []; for (let i = 0; i < validPts.length; i++) { flatPts.push(validPts[i].x, validPts[i].z); } let holeIndices = null; if (holePts2D && holePts2D.length >= 3) { const validHole = []; for (let i = 0; i < holePts2D.length; i++) { const p = holePts2D[i]; if (validHole.length > 0) { const prev = validHole[validHole.length - 1]; if (Math.hypot(p.x - prev.x, p.z - prev.z) < 1e-5) continue; } validHole.push(p); } if (validHole.length > 2) { const first = validHole[0], last = validHole[validHole.length - 1]; if (Math.hypot(first.x - last.x, first.z - last.z) < 1e-5) { validHole.pop(); } } if (validHole.length >= 3) { holeIndices = [validPts.length]; for (let i = 0; i < validHole.length; i++) { validPts.push(validHole[i]); flatPts.push(validHole[i].x, validHole[i].z); } } } const indices = doEarcut(flatPts, holeIndices, 2); if (!indices || indices.length === 0) return null; const I = [], J = [], K = []; for (let i = 0; i < indices.length; i += 3) { if (yPos === 0) { I.push(indices[i]); J.push(indices[i + 2]); K.push(indices[i + 1]); } else { I.push(indices[i]); J.push(indices[i + 1]); K.push(indices[i + 2]); } } return { type: 'mesh3d', x: validPts.map(p => p.x), y: validPts.map(() => yPos), z: validPts.map(p => p.z), i: I, j: J, k: K, color: color, opacity: opacity, name: `${name} (${yPos === 0 ? '시점' : '종점'} 마감면)`, showlegend: false, hoverinfo: 'skip', flatshading: true, lighting: { ambient: 0.85, diffuse: 0.8, roughness: 0.3 } }; } const plotData = []; const modelOpacity = (typeof window.current3DOpacity === 'number') ? window.current3DOpacity : 1.0; if (window.current3DVisibility?.lining !== false) { plotData.push({ type: 'surface', x: unifiedConcreteGrid.X, y: unifiedConcreteGrid.Y, z: unifiedConcreteGrid.Z, colorscale: [[0, '#8da0b0'], [1, '#b8c5ce']], showscale: false, name: '터널 콘크리트 본체 (라이닝+공동구 일체형)', opacity: modelOpacity, lighting: { ambient: 0.75, diffuse: 0.8, roughness: 0.35, specular: 0.2 } }); [0, L].forEach(yPos => { const cap = createEndCapMesh(unifiedConcreteLoop2D, yPos, '#a1b2c2', '라이닝 본체', modelOpacity); if (cap) plotData.push(cap); }); } if (window.current3DVisibility?.shotcrete !== false) { plotData.push({ type: 'surface', x: shotcreteGrid.X, y: shotcreteGrid.Y, z: shotcreteGrid.Z, colorscale: [[0, '#334155'], [1, '#475569']], showscale: false, name: '숏크리트 층 (1차 지보재)', opacity: modelOpacity, lighting: { ambient: 0.7, diffuse: 0.75 } }); [0, L].forEach(yPos => { const cap = createEndCapMesh(shotcreteLoop2D, yPos, '#3b4759', '숏크리트', modelOpacity); if (cap) plotData.push(cap); }); } if (window.current3DVisibility?.pavement !== false) { paveGridsData.forEach((item, idx) => { plotData.push({ type: 'surface', x: item.grid.X, y: item.grid.Y, z: item.grid.Z, colorscale: item.layer.colorscale, showscale: false, name: item.layer.name, opacity: modelOpacity, lighting: { ambient: 0.85, diffuse: 0.75, roughness: 0.3 } }); const paveColor = idx === 0 ? '#1e293b' : (idx === 1 ? '#cbd5e1' : '#64748b'); [0, L].forEach(yPos => { const cap = createEndCapMesh(paveLoops2D[idx].loop, yPos, paveColor, item.layer.name, modelOpacity); if (cap) plotData.push(cap); }); }); } if (window.current3DVisibility?.blindDitch !== false) { plotData.push({ type: 'surface', x: bdGridL.X, y: bdGridL.Y, z: bdGridL.Z, colorscale: [[0, '#78716c'], [1, '#a8a29e']], showscale: false, name: '좌측 맹암거 (투수필터층)', opacity: modelOpacity, lighting: { ambient: 0.8, diffuse: 0.7 } }); plotData.push({ type: 'surface', x: bdGridR.X, y: bdGridR.Y, z: bdGridR.Z, colorscale: [[0, '#78716c'], [1, '#a8a29e']], showscale: false, name: '우측 맹암거 (투수필터층)', opacity: modelOpacity, lighting: { ambient: 0.8, diffuse: 0.7 } }); [0, L].forEach(yPos => { // 종배수관과 겹치는 원형 단면 부위를 제외한 마감면 생성 const capL = createEndCapMesh(bdLoopL, yPos, '#8c857f', '좌측 맹암거', modelOpacity, pipeLoopL); if (capL) plotData.push(capL); const capR = createEndCapMesh(bdLoopR, yPos, '#8c857f', '우측 맹암거', modelOpacity, pipeLoopR); if (capR) plotData.push(capR); }); } if (window.current3DVisibility?.drainPipe !== false) { plotData.push({ type: 'surface', x: pipeGridL.X, y: pipeGridL.Y, z: pipeGridL.Z, colorscale: [[0, '#c2410c'], [1, '#f97316']], showscale: false, name: `좌측 종배수관 (D${pDL}mm)`, opacity: modelOpacity, lighting: { ambient: 0.9, diffuse: 0.8, roughness: 0.2 } }); plotData.push({ type: 'surface', x: pipeGridR.X, y: pipeGridR.Y, z: pipeGridR.Z, colorscale: [[0, '#c2410c'], [1, '#f97316']], showscale: false, name: `우측 종배수관 (D${pDR}mm)`, opacity: modelOpacity, lighting: { ambient: 0.9, diffuse: 0.8, roughness: 0.2 } }); // 종배수관은 배수관이므로 앞뒤가 뚫려있는 상태(hollow) 유지 (마감면 추가 안함) } if (window.current3DVisibility?.lids !== false) { plotData.push({ type: 'surface', x: lidGridL.X, y: lidGridL.Y, z: lidGridL.Z, colorscale: [[0, '#94a3b8'], [1, '#e2e8f0']], showscale: false, name: `좌측 공동구 뚜껑 (${lwL}x${lhL}mm)`, opacity: modelOpacity, lighting: { ambient: 0.85, diffuse: 0.8, roughness: 0.3 } }); plotData.push({ type: 'surface', x: lidGridR.X, y: lidGridR.Y, z: lidGridR.Z, colorscale: [[0, '#94a3b8'], [1, '#e2e8f0']], showscale: false, name: `우측 공동구 뚜껑 (${lwR}x${lhR}mm)`, opacity: modelOpacity, lighting: { ambient: 0.85, diffuse: 0.8, roughness: 0.3 } }); [0, L].forEach(yPos => { const capL = createEndCapMesh(lidLoopL, yPos, '#cbd5e1', '좌측 공동구 뚜껑', modelOpacity); if (capL) plotData.push(capL); const capR = createEndCapMesh(lidLoopR, yPos, '#cbd5e1', '우측 공동구 뚜껑', modelOpacity); if (capR) plotData.push(capR); }); } if (window.current3DVisibility?.lanes !== false) { const leftLineX = toX(0); const leftLineZ = toZ(0) + 0.02; plotData.push({ type: 'scatter3d', mode: 'lines', x: [leftLineX, leftLineX], y: [0, L], z: [leftLineZ, leftLineZ], line: { color: '#facc15', width: 6 }, // 마커 1: 황색 실선 (Yellow) name: '가장자리선 (좌측, 황색)', showlegend: false }); const rightLineX = toX(tw); const rightLineZ = toZ(m * tw) + 0.02; plotData.push({ type: 'scatter3d', mode: 'lines', x: [rightLineX, rightLineX], y: [0, L], z: [rightLineZ, rightLineZ], line: { color: '#ffffff', width: 5 }, // 마커 5: 흰색 실선 (White) name: '가장자리선 (우측, 흰색)', showlegend: false }); let curLaneDist = 0; for (let li = 0; li < lanes.length - 1; li++) { curLaneDist += lanes[li]; const laneDividerX = toX(curLaneDist); const laneDividerZ = toZ(m * curLaneDist) + 0.02; plotData.push({ type: 'scatter3d', mode: 'lines', x: [laneDividerX, laneDividerX], y: [0, L], z: [laneDividerZ, laneDividerZ], line: { color: '#ffffff', width: 5 }, // 흰색선 (White) name: `차선 마킹 ${li + 2} (흰색)`, showlegend: false }); } } if (window.current3DVisibility?.rims !== false) { [0, L].forEach(yPos => { plotData.push({ type: 'scatter3d', mode: 'lines', x: unifiedConcreteLoop2D.map(pt => pt.x), y: unifiedConcreteLoop2D.map(() => yPos), z: unifiedConcreteLoop2D.map(pt => pt.z), line: { color: '#10b981', width: 6 }, showlegend: false }); plotData.push({ type: 'scatter3d', mode: 'lines', x: shotcreteLoop2D.map(pt => pt.x), y: shotcreteLoop2D.map(() => yPos), z: shotcreteLoop2D.map(pt => pt.z), line: { color: '#06b6d4', width: 5 }, showlegend: false }); paveLoops2D.forEach(pItem => { plotData.push({ type: 'scatter3d', mode: 'lines', x: pItem.loop.map(pt => pt.x), y: pItem.loop.map(() => yPos), z: pItem.loop.map(pt => pt.z), line: { color: pItem.layer.rimColor, width: 4 }, showlegend: false }); }); [bdLoopL, bdLoopR].forEach(bLoop => { plotData.push({ type: 'scatter3d', mode: 'lines', x: bLoop.map(pt => pt.x), y: bLoop.map(() => yPos), z: bLoop.map(pt => pt.z), line: { color: '#d97706', width: 4 }, showlegend: false }); }); [pipeLoopL, pipeLoopR].forEach(pLoop => { plotData.push({ type: 'scatter3d', mode: 'lines', x: pLoop.map(pt => pt.x), y: pLoop.map(() => yPos), z: pLoop.map(pt => pt.z), line: { color: '#ea580c', width: 4 }, showlegend: false }); }); [lidLoopL, lidLoopR].forEach(lLoop => { plotData.push({ type: 'scatter3d', mode: 'lines', x: lLoop.map(pt => pt.x), y: lLoop.map(() => yPos), z: lLoop.map(pt => pt.z), line: { color: '#0284c7', width: 4 }, showlegend: false }); }); }); } const initialCam = (window.current3DViewMode === 'inside') ? { eye: { x: 0, y: -0.05, z: 0.1 }, center: { x: 0, y: 0.5, z: 0.1 }, 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 } }; let targetCam = initialCam; if (preserveCam && plotDiv) { if (plotDiv._fullLayout?.scene?._scene?.getCamera) { try { const c = plotDiv._fullLayout.scene._scene.getCamera(); if (c && c.eye) targetCam = c; } catch (e) {} } else if (plotDiv.layout?.scene?.camera) { targetCam = plotDiv.layout.scene.camera; } } const isDark = (window.current3DTheme !== 'light'); 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 layout = { margin: { l: 0, r: 0, b: 0, t: 0 }, paper_bgcolor: bgColor, plot_bgcolor: bgColor, scene: { bgcolor: bgColor, aspectmode: 'data', camera: targetCam, xaxis: { visible: false, showgrid: false, showline: false, showticklabels: false, showbackground: false, zeroline: false }, yaxis: { visible: false, showgrid: false, showline: false, showticklabels: false, showbackground: false, zeroline: false }, zaxis: { visible: false, showgrid: false, showline: false, showticklabels: false, showbackground: false, zeroline: false } }, showlegend: false, }; Plotly.react(plotDiv, plotData, layout, { responsive: true, displaylogo: false }); } catch (err) { console.error('3D 뷰어 렌더링 오류:', err); } } // ───────────────────────────────────────────────────────────────────────────── // 5. 2D 단면 뷰어 렌더링 // ───────────────────────────────────────────────────────────────────────────── function render2DSection() { const canvas = $('modelCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const w = Math.max(1, rect.width); const h = Math.max(1, rect.height); canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const isDark = (window.current3DTheme !== 'light'); ctx.fillStyle = isDark ? '#0c1118' : '#f8fafc'; ctx.fillRect(0, 0, w, h); // 배경 그리드 ctx.strokeStyle = isDark ? '#171f2a' : '#e2e8f0'; ctx.lineWidth = 1; const step = 36; for (let x = (w / 2) % step; x < w; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = (h / 2) % step; y < h; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } const loops = window.lastGeneratedLoops2D || []; if (!loops.length) return; // 전체 경계 계산 let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; loops.forEach(item => { item.loop.forEach(pt => { minX = Math.min(minX, pt.x); maxX = Math.max(maxX, pt.x); minZ = Math.min(minZ, pt.z); maxZ = Math.max(maxZ, pt.z); }); }); const pad = 60; const spanX = Math.max(1, maxX - minX); const spanZ = Math.max(1, maxZ - minZ); const baseScale = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanZ); const scale = baseScale * window.view2D.zoom; const cx = (minX + maxX) / 2; const cz = (minZ + maxZ) / 2; const toScreenX = x => w / 2 + window.view2D.panX + (x - cx) * scale; const toScreenY = z => h / 2 + window.view2D.panY - (z - cz) * scale; const styleMap = { shotcrete: { fill: 'rgba(71, 85, 105, 0.45)', stroke: '#06b6d4', width: 2 }, lining_concrete: { fill: 'rgba(141, 160, 176, 0.55)', stroke: '#10b981', width: 2.2 }, pavement_t1: { fill: 'rgba(30, 41, 59, 0.85)', stroke: '#38bdf8', width: 1.5 }, pavement_t2: { fill: 'rgba(148, 163, 184, 0.65)', stroke: '#f59e0b', width: 1.5 }, pavement_t3: { fill: 'rgba(100, 116, 139, 0.55)', stroke: '#c084fc', width: 1.5 }, blind_ditch_L: { fill: 'rgba(217, 119, 6, 0.4)', stroke: '#d97706', width: 1.5 }, blind_ditch_R: { fill: 'rgba(217, 119, 6, 0.4)', stroke: '#d97706', width: 1.5 }, drain_pipe_L: { fill: 'rgba(234, 88, 12, 0.7)', stroke: '#ea580c', width: 1.8 }, drain_pipe_R: { fill: 'rgba(234, 88, 12, 0.7)', stroke: '#ea580c', width: 1.8 }, lid_L: { fill: 'rgba(203, 213, 225, 0.8)', stroke: '#0284c7', width: 1.5 }, lid_R: { fill: 'rgba(203, 213, 225, 0.8)', stroke: '#0284c7', width: 1.5 } }; loops.forEach(item => { if (!item.visible || !item.loop.length) return; const st = styleMap[item.name] || { fill: 'rgba(148,163,184,0.3)', stroke: '#94a3b8', width: 1.5 }; ctx.beginPath(); item.loop.forEach((pt, idx) => { const sx = toScreenX(pt.x); const sy = toScreenY(pt.z); if (idx === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); }); ctx.closePath(); // 맹암거의 경우 종배수관과 겹치는 원형 단면 부위를 제외하고 채우기 const correspondingPipe = (item.name === 'blind_ditch_L') ? loops.find(l => l.name === 'drain_pipe_L') : (item.name === 'blind_ditch_R' ? loops.find(l => l.name === 'drain_pipe_R') : null); if (correspondingPipe && correspondingPipe.loop.length) { correspondingPipe.loop.forEach((pt, idx) => { const sx = toScreenX(pt.x); const sy = toScreenY(pt.z); if (idx === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); }); ctx.closePath(); ctx.fillStyle = st.fill; ctx.fill('evenodd'); } else { ctx.fillStyle = st.fill; ctx.fill(); } ctx.strokeStyle = st.stroke; ctx.lineWidth = st.width; ctx.stroke(); }); // 캔버스 하단 저작권 및 개발자 워터마크 렌더링 ctx.save(); ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.45)' : 'rgba(15, 23, 42, 0.45)'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('Developed by m12205.tdc.hanmacgroup@gmail.com · © 2026', w - 16, h - 12); ctx.restore(); } // ───────────────────────────────────────────────────────────────────────────── // 6. OBJ 3D 모델 파일 내보내기 // ───────────────────────────────────────────────────────────────────────────── function exportObjModel() { const loops = window.lastGeneratedLoops2D || []; if (!loops.length) return; const L = window.current3DTunnelLength || 10; const verts = []; const faces = []; const doEarcut = (function() { if (typeof earcut === 'function') return earcut; if (typeof earcut !== 'undefined' && typeof earcut.default === 'function') return earcut.default; if (typeof window !== 'undefined' && window.earcut) { if (typeof window.earcut === 'function') return window.earcut; if (typeof window.earcut.default === 'function') return window.earcut.default; } return null; })(); loops.forEach(item => { if (!item.visible || item.loop.length < 3) return; const pts = item.loop; const n = pts.length; const baseIdx = verts.length + 1; // y = 0 단면 정점 및 y = L 단면 정점 추가 for (let i = 0; i < n; i++) { verts.push(`v ${pts[i].x.toFixed(4)} 0.0000 ${pts[i].z.toFixed(4)}`); } for (let i = 0; i < n; i++) { verts.push(`v ${pts[i].x.toFixed(4)} ${L.toFixed(4)} ${pts[i].z.toFixed(4)}`); } faces.push(`g ${item.name}`); // 측면 면(Quad) 생성 for (let i = 0; i < n - 1; i++) { const a0 = baseIdx + i; const b0 = baseIdx + i + 1; const a1 = baseIdx + n + i; const b1 = baseIdx + n + i + 1; faces.push(`f ${a0} ${b0} ${b1}`); faces.push(`f ${a0} ${b1} ${a1}`); } // 종배수관(drain_pipe)은 뚫려있는 관이므로 마감면 제외, 그 외 솔리드 객체는 앞뒤 마감면(End-caps) 추가 if (doEarcut && !item.name.includes('drain_pipe')) { const validIndices = []; for (let i = 0; i < n - 1; i++) validIndices.push(i); const flatPts = []; for (let i = 0; i < validIndices.length; i++) { flatPts.push(pts[validIndices[i]].x, pts[validIndices[i]].z); } let holeIndices = null; let extraHoleVerts = 0; if (item.name.startsWith('blind_ditch_')) { const pipeName = item.name === 'blind_ditch_L' ? 'drain_pipe_L' : 'drain_pipe_R'; const pipeItem = loops.find(l => l.name === pipeName); if (pipeItem && pipeItem.loop.length >= 3) { const pipePts = pipeItem.loop; const pipeN = pipePts.length - 1; holeIndices = [validIndices.length]; extraHoleVerts = pipeN; for (let pi = 0; pi < pipeN; pi++) { verts.push(`v ${pipePts[pi].x.toFixed(4)} 0.0000 ${pipePts[pi].z.toFixed(4)}`); flatPts.push(pipePts[pi].x, pipePts[pi].z); } for (let pi = 0; pi < pipeN; pi++) { verts.push(`v ${pipePts[pi].x.toFixed(4)} ${L.toFixed(4)} ${pipePts[pi].z.toFixed(4)}`); } } } const tris = doEarcut(flatPts, holeIndices, 2); if (tris && tris.length > 0) { const getV0 = vi => { if (vi < n - 1) return baseIdx + vi; return baseIdx + 2 * n + (vi - (n - 1)); }; const getVL = vi => { if (vi < n - 1) return baseIdx + n + vi; return baseIdx + 2 * n + extraHoleVerts + (vi - (n - 1)); }; // Front cap (y = 0) for (let i = 0; i < tris.length; i += 3) { const i0 = getV0(tris[i]); const i1 = getV0(tris[i + 2]); const i2 = getV0(tris[i + 1]); faces.push(`f ${i0} ${i1} ${i2}`); } // Back cap (y = L) for (let i = 0; i < tris.length; i += 3) { const i0 = getVL(tris[i]); const i1 = getVL(tris[i + 1]); const i2 = getVL(tris[i + 2]); faces.push(`f ${i0} ${i1} ${i2}`); } } } }); const objContent = `# Tunnel 3D Model (${L}m)\n` + verts.join('\n') + '\n' + faces.join('\n') + '\n'; const blob = new Blob([objContent], { type: 'text/plain;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `tunnel_model_${L}m.obj`; a.click(); URL.revokeObjectURL(a.href); } // ───────────────────────────────────────────────────────────────────────────── // 6-2. Rhino 3DM (.3dm) 3D 모델 파일 내보내기 // ───────────────────────────────────────────────────────────────────────────── let _rhinoInstance = null; async function getRhinoInstance() { if (_rhinoInstance) return _rhinoInstance; if (typeof rhino3dm !== 'function') { // 로컬 스크립트 미탑재 또는 지연 시 CDN 동적 로드 시도 await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/rhino3dm@8.35.0/rhino3dm.min.js'; s.onload = resolve; s.onerror = () => reject(new Error('rhino3dm 스크립트를 불러오지 못했습니다. 네트워크 연결 상태를 확인해주세요.')); document.head.appendChild(s); }); } if (typeof rhino3dm !== 'function') { throw new Error('rhino3dm 라이브러리를 초기화할 수 없습니다.'); } _rhinoInstance = await rhino3dm({ locateFile: (path) => { if (location.protocol === 'file:') { return 'https://cdn.jsdelivr.net/npm/rhino3dm@8.35.0/' + path; } return path; } }); return _rhinoInstance; } async function export3dmModel() { const loops = window.lastGeneratedLoops2D || []; if (!loops.length) { alert('내보낼 3D 모델 형상이 없습니다.'); return; } const exportBtn = $('export3dm'); const origBtnText = exportBtn ? exportBtn.textContent : ''; if (exportBtn) { exportBtn.disabled = true; exportBtn.textContent = '3DM 생성 중…'; } try { const rhino = await getRhinoInstance(); const doc = new rhino.File3dm(); if (rhino.UnitSystem && rhino.UnitSystem.Meters) { doc.settings().modelUnitSystem = rhino.UnitSystem.Meters; } const L = window.current3DTunnelLength || 10; // 레이어 사전 정의 및 생성 const layerConfigs = [ { id: 'lining_concrete', name: 'lining_concrete', color: { r: 141, g: 160, b: 176, a: 255 } }, { id: 'shotcrete', name: 'shotcrete', color: { r: 71, g: 85, b: 105, a: 255 } }, { id: 'pavement', name: 'pavement', color: { r: 30, g: 41, b: 59, a: 255 } }, { id: 'lids', name: 'lids', color: { r: 203, g: 213, b: 225, a: 255 } }, { id: 'blind_ditch', name: 'blind_ditch', color: { r: 217, g: 119, b: 6, a: 255 } }, { id: 'drain_pipe', name: 'drain_pipe', color: { r: 56, g: 189, b: 248, a: 255 } } ]; const layerMap = {}; layerConfigs.forEach(cfg => { const lay = new rhino.Layer(); lay.name = cfg.name; lay.color = cfg.color; const idx = doc.layers().add(lay); layerMap[cfg.id] = idx; }); const getLayerId = (name) => { if (name === 'shotcrete') return 'shotcrete'; if (name === 'lining') return 'lining_concrete'; if (name.startsWith('pavement')) return 'pavement'; if (name.startsWith('lid_')) return 'lids'; if (name.startsWith('blind_ditch_')) return 'blind_ditch'; if (name.startsWith('drain_pipe_')) return 'drain_pipe'; return 'lining_concrete'; }; const doEarcut = (function() { if (typeof earcut === 'function') return earcut; if (typeof earcut !== 'undefined' && typeof earcut.default === 'function') return earcut.default; if (typeof window !== 'undefined' && window.earcut) { if (typeof window.earcut === 'function') return window.earcut; if (typeof window.earcut.default === 'function') return window.earcut.default; } return null; })(); loops.forEach(item => { if (!item.visible || item.loop.length < 3) return; const pts = item.loop; const n = pts.length; const mesh = new rhino.Mesh(); // y = 0 단면 정점 및 y = L 단면 정점 추가 for (let i = 0; i < n; i++) { mesh.vertices().add(pts[i].x, 0.0, pts[i].z); } for (let i = 0; i < n; i++) { mesh.vertices().add(pts[i].x, L, pts[i].z); } // 측면 쿼드 면 추가 (Quad) for (let i = 0; i < n - 1; i++) { const a0 = i; const b0 = i + 1; const a1 = n + i; const b1 = n + i + 1; mesh.faces().addQuadFace(a0, b0, b1, a1); } // 종배수관(drain_pipe)은 뚫려있는 관이므로 마감면 제외, 그 외 솔리드 객체는 앞뒤 마감면(End-caps) 추가 if (doEarcut && !item.name.includes('drain_pipe')) { const validIndices = []; for (let i = 0; i < n - 1; i++) validIndices.push(i); const flatPts = []; for (let i = 0; i < validIndices.length; i++) { flatPts.push(pts[validIndices[i]].x, pts[validIndices[i]].z); } let holeIndices = null; let extraHoleVerts = 0; if (item.name.startsWith('blind_ditch_')) { const pipeName = item.name === 'blind_ditch_L' ? 'drain_pipe_L' : 'drain_pipe_R'; const pipeItem = loops.find(l => l.name === pipeName); if (pipeItem && pipeItem.loop.length >= 3) { const pipePts = pipeItem.loop; const pipeN = pipePts.length - 1; holeIndices = [validIndices.length]; extraHoleVerts = pipeN; for (let pi = 0; pi < pipeN; pi++) { mesh.vertices().add(pipePts[pi].x, 0.0, pipePts[pi].z); flatPts.push(pipePts[pi].x, pipePts[pi].z); } for (let pi = 0; pi < pipeN; pi++) { mesh.vertices().add(pipePts[pi].x, L, pipePts[pi].z); } } } const tris = doEarcut(flatPts, holeIndices, 2); if (tris && tris.length > 0) { const getV0 = vi => { if (vi < n - 1) return vi; return 2 * n + (vi - (n - 1)); }; const getVL = vi => { if (vi < n - 1) return n + vi; return 2 * n + extraHoleVerts + (vi - (n - 1)); }; // Front cap (y = 0) for (let i = 0; i < tris.length; i += 3) { const i0 = getV0(tris[i]); const i1 = getV0(tris[i + 2]); const i2 = getV0(tris[i + 1]); mesh.faces().addTriFace(i0, i1, i2); } // Back cap (y = L) for (let i = 0; i < tris.length; i += 3) { const i0 = getVL(tris[i]); const i1 = getVL(tris[i + 1]); const i2 = getVL(tris[i + 2]); mesh.faces().addTriFace(i0, i1, i2); } } } // 노멀 계산 mesh.normals().computeNormals(); mesh.compact(); // 객체 속성 및 레이어 설정 const attr = new rhino.ObjectAttributes(); const layerId = getLayerId(item.name); if (layerMap[layerId] !== undefined) { attr.layerIndex = layerMap[layerId]; } attr.name = item.name; doc.objects().addMesh(mesh, attr); }); const bytes = doc.toByteArray(); const blob = new Blob([bytes], { type: 'application/octet-stream' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `tunnel_model_${L}m.3dm`; a.click(); URL.revokeObjectURL(a.href); doc.delete(); } catch (err) { console.error('3DM 내보내기 실패:', err); alert('3DM 내보내기 중 오류가 발생했습니다: ' + (err.message || err)); } finally { if (exportBtn) { exportBtn.disabled = false; exportBtn.textContent = origBtnText; } } } // ───────────────────────────────────────────────────────────────────────────── // 7. 파일 불러오기 및 이벤트 바인딩 // ───────────────────────────────────────────────────────────────────────────── async function checkServerEngineStatus() { try { const r = await fetch('/api/status'); const s = await r.json(); $('engineStatus').textContent = s.converterAvailable ? 'DWG 변환 엔진 준비됨' : 'DWG 변환기 미설치 · DXF 직독 가능'; $('statusDot').style.background = s.converterAvailable ? '#57c9a9' : '#e7b75c'; } catch { $('engineStatus').textContent = 'DXF 로컬 처리 준비됨'; } } async function handleCadFileLoad(file) { if (!file) return; const metaEl = $('fileMeta'); if (metaEl) { metaEl.textContent = `${file.name} 분석 및 3D 모델 생성 중…`; metaEl.classList.remove('hidden'); } try { let dxfText = ''; const sigBuf = await file.slice(0, 8).arrayBuffer(); const sig = new TextDecoder().decode(sigBuf); const isDwg = file.name.toLowerCase().endsWith('.dwg') || /^AC10\d{2}/.test(sig); if (isDwg) { if (location.protocol !== 'http:' && location.protocol !== 'https:') { throw new Error('DWG 변환은 start.bat으로 로컬 서버를 실행한 뒤 이용할 수 있습니다. (DXF 파일은 바로 불러올 수 있습니다)'); } $('engineStatus').textContent = 'DWG → DXF 변환 중…'; const convName = file.name.toLowerCase().endsWith('.dwg') ? file.name : file.name + '.dwg'; const res = await fetch('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-Filename': encodeURIComponent(convName) }, body: file }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'DWG 변환 실패' })); throw new Error(err.error || 'DWG 변환 실패'); } dxfText = await res.text(); $('engineStatus').textContent = 'DWG 변환 완료'; } else { dxfText = await file.text(); } const parser = new window.DxfParser(); const dxf = parser.parseSync(dxfText); extractParamsFromDXF(dxf, file.name); if (metaEl) { metaEl.textContent = `✅ ${file.name} · 총 ${(dxf.entities || []).length.toLocaleString()}개 CAD 객체 분석 완료`; } } catch (err) { console.error('도면 로드 실패:', err); if (metaEl) { metaEl.textContent = `⚠️ ${err.message || '도면 분석 중 오류가 발생했습니다.'}`; } } } window.addEventListener('DOMContentLoaded', () => { checkServerEngineStatus(); update3DObjectButtons(); // 파일 선택 및 드래그 앤 드롭 const fileInput = $('fileInput'); if (fileInput) { fileInput.addEventListener('change', e => { if (e.target.files && e.target.files[0]) { handleCadFileLoad(e.target.files[0]); e.target.value = ''; } }); } const dropzone = $('dropzone'); if (dropzone) { dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragging'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragging')); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dragging'); if (e.dataTransfer.files && e.dataTransfer.files[0]) { handleCadFileLoad(e.dataTransfer.files[0]); } }); } // 전체 창 드래그 앤 드롭 지원 window.addEventListener('dragover', e => e.preventDefault()); window.addEventListener('drop', e => { e.preventDefault(); if (e.dataTransfer?.files?.[0]) { handleCadFileLoad(e.dataTransfer.files[0]); } }); // 예제 DXF 불러오기 $('loadSample')?.addEventListener('click', async () => { const metaEl = $('fileMeta'); try { if (metaEl) { metaEl.textContent = '예제 DXF 도면을 불러오는 중…'; metaEl.classList.remove('hidden'); } let res = await fetch('sample.dxf').catch(() => null); if (!res || !res.ok) { res = await fetch('/api/sample'); } if (!res || !res.ok) { throw new Error('예제 DXF 파일을 불러오지 못했습니다.'); } const text = await res.text(); const parser = new window.DxfParser(); const dxf = parser.parseSync(text); extractParamsFromDXF(dxf, '표준단면 예제 (sample.dxf)'); if (metaEl) { metaEl.textContent = `✅ 표준단면 예제 (sample.dxf) · ${(dxf.entities || []).length.toLocaleString()}개 객체 분석 완료`; } } catch (err) { if (metaEl) { metaEl.textContent = `⚠️ ${err.message}`; } } }); // 길이 입력 및 프리셋 버튼 const lengthInput = $('lengthInput'); const updateLength = len => { const validLen = Math.max(1, Math.min(500, Number(len) || 10)); window.current3DTunnelLength = validLen; if (lengthInput) lengthInput.value = validLen; document.querySelectorAll('.preset-len-btn').forEach(btn => { btn.classList.toggle('active', Number(btn.dataset.len) === validLen); }); buildCurrentSection3DPlot(true); }; lengthInput?.addEventListener('change', e => updateLength(e.target.value)); document.querySelectorAll('.preset-len-btn').forEach(btn => { btn.addEventListener('click', () => updateLength(btn.dataset.len)); }); // 단면 분석 및 3D 생성 버튼 $('buildButton')?.addEventListener('click', () => { readParamsFromInputs(); buildCurrentSection3DPlot(false); render2DSection(); }); // 상세 제원 편집 토글 및 반영 $('toggleParamsBtn')?.addEventListener('click', () => { const panel = $('paramEditorPanel'); if (panel) panel.classList.toggle('hidden'); }); $('applyParamsBtn')?.addEventListener('click', () => { readParamsFromInputs(); renderDetectionList('사용자 수정 제원', 0); buildCurrentSection3DPlot(true); render2DSection(); }); // 상단 뷰어 툴바 버튼들 const opacitySlider = $('opacitySlider'); if (opacitySlider) { opacitySlider.addEventListener('input', e => { const val = parseInt(e.target.value, 10); window.current3DOpacity = Math.max(10, Math.min(100, val)) / 100.0; window.current3DOuterOpacity = window.current3DOpacity; const label = $('opacityVal'); if (label) label.textContent = `${val}%`; buildCurrentSection3DPlot(true); }); } $('btnToggleOuter')?.addEventListener('click', toggle3DOuterShell); $('btnViewInside')?.addEventListener('click', () => set3DCameraView('inside')); $('btnViewBird')?.addEventListener('click', () => set3DCameraView('bird')); $('btnThemeDark')?.addEventListener('click', () => set3DTheme('dark')); $('btnThemeLight')?.addEventListener('click', () => set3DTheme('light')); $('resetView')?.addEventListener('click', () => { window.view2D = { panX: 0, panY: 0, zoom: 1, dragging: false, lastX: 0, lastY: 0 }; set3DCameraView('bird'); render2DSection(); }); $('exportObj')?.addEventListener('click', exportObjModel); $('export3dm')?.addEventListener('click', export3dmModel); // 3D 모델 / 2D 단면 탭 전환 document.querySelectorAll('.view-tab').forEach(tab => { tab.addEventListener('click', () => { const mode = tab.dataset.view; window.activeViewTab = mode; document.querySelectorAll('.view-tab').forEach(t => t.classList.toggle('active', t === tab)); if (mode === '2d') { $('placement_3d_plot')?.classList.add('hidden'); $('modelCanvas')?.classList.remove('hidden'); render2DSection(); } else { $('modelCanvas')?.classList.add('hidden'); $('placement_3d_plot')?.classList.remove('hidden'); buildCurrentSection3DPlot(true); } }); }); // 2D 캔버스 마우스 이동 및 줌 const canvas2d = $('modelCanvas'); if (canvas2d) { canvas2d.addEventListener('pointerdown', e => { window.view2D.dragging = true; window.view2D.lastX = e.clientX; window.view2D.lastY = e.clientY; canvas2d.setPointerCapture(e.pointerId); }); canvas2d.addEventListener('pointermove', e => { if (!window.view2D.dragging) return; window.view2D.panX += e.clientX - window.view2D.lastX; window.view2D.panY += e.clientY - window.view2D.lastY; window.view2D.lastX = e.clientX; window.view2D.lastY = e.clientY; render2DSection(); }); canvas2d.addEventListener('pointerup', () => { window.view2D.dragging = false; }); canvas2d.addEventListener('wheel', e => { e.preventDefault(); window.view2D.zoom = Math.max(0.25, Math.min(6, window.view2D.zoom * Math.exp(-e.deltaY * 0.001))); render2DSection(); }, { passive: false }); } window.addEventListener('resize', () => { if (window.activeViewTab === '2d') render2DSection(); }); });