3233 lines
130 KiB
JavaScript
3233 lines
130 KiB
JavaScript
/**
|
||
* ============================================================================
|
||
* 프로젝트: 터널 단면 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();
|
||
});
|
||
});
|