Files

3233 lines
130 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ============================================================================
* 프로젝트: 터널 단면 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: 150, ww2: 30, ww3: 120,
wh1: 450, wh2: 175, wh3: 75,
bcw: 550, bch: 530, 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: 150, ww2: 80, ww3: 120,
wh1: 750, wh2: 175, wh3: 75,
bcw: 550, 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: 150, ww2: direction === 'right' ? 80 : 30, ww3: 120,
wh1: direction === 'right' ? 750 : 450, wh2: 175, wh3: 75,
bw1: 125, bw2: 300, bw3: 300,
bd1: 50, bd2: 300, bd3: 50,
pipeD: 300
};
const startPt = { x: pavementX, y: yMain };
let ditchTopOuterPt = null;
// ★ 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));
const outerV = direction === 'left' ? v1[0] : v2[0];
ditchTopOuterPt = {
x: (outerV.vertices[0].x + outerV.vertices[1].x) / 2,
y: Math.max(outerV.vertices[0].y, outerV.vertices[1].y)
};
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 };
}
if (!ditchTopOuterPt) {
const dhOuterLines = findConnectedLines(ditchBotOuterPt, secLines, 'up');
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 lineTop = getConnectedLinesAt(currentPt).find(l => {
const p = getOtherPt(l, currentPt);
const dx = Math.abs(p.x - currentPt.x);
const dy = Math.abs(p.y - currentPt.y);
return dy <= 3.0 && dx >= 50 && dx <= 300 && (direction === 'left' ? p.x < currentPt.x : p.x > currentPt.x);
});
if (lineTop) {
const p4 = getOtherPt(lineTop, currentPt);
res.ww1 = Math.round(Math.abs(p4.x - currentPt.x));
}
}
}
}
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);
const dL = window.ugData.left || {};
const dR = window.ugData.right || {};
setIf('p_ug_wh_l', (dL.wh1 || 450) + (dL.wh2 || 175) + (dL.wh3 || 75));
setIf('p_ug_wh_r', (dR.wh1 || 750) + (dR.wh2 || 175) + (dR.wh3 || 75));
}
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);
const whTotL = getNum('p_ug_wh_l', 700);
const whTotR = getNum('p_ug_wh_r', 1000);
if (window.ugData.left) {
window.ugData.left.wh3 = 75;
window.ugData.left.wh2 = 175;
window.ugData.left.wh1 = Math.max(100, Math.round(whTotL - 250));
}
if (window.ugData.right) {
window.ugData.right.wh3 = 75;
window.ugData.right.wh2 = 175;
window.ugData.right.wh1 = Math.max(100, Math.round(whTotR - 250));
}
}
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.wh1 + dL.wh2 + dL.wh3}mm(뚜껑 ${dL.lw}×${dL.lh}) · 우 벽체 ${dR.wh1 + dR.wh2 + dR.wh3}mm(뚜껑 ${dR.lw}×${dR.lh}) · 내공 ${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 => `
<div class="detection-item">
<span class="detection-swatch" style="background:${it.color}"></span>
<div>
<strong>${it.title}</strong>
<small>${it.detail}</small>
</div>
<span class="confidence">${it.badge}</span>
</div>`
)
.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();
});
});