// Application Main Controller (3-Step Workflow: 1단계 도면 불러오기·판별 -> 2단계 일반도 외형 모델링 -> 3단계 배근도 철근 배근)
class WingwallApp {
constructor() {
this.currentStep = 1; // 1: 도면 불러오기 및 판별, 2: 일반도 외형 모델링, 3: 배근도 철근 배근
this.drawings = [];
this.activeDrawingId = null;
this.step1Filter = 'ALL'; // 'ALL', 'GENERAL', 'REBAR', 'UNCLASSIFIED'
this.drawingData = null;
this.modelData = null;
this.selectedObject = 'WW_END_LEFT';
this.selectedRebarMark = 'W1';
this.selectedRebarWingwall = 'WW_END_RIGHT';
this.viewMode = '2d'; // Step 1 starts in '2d', Step 2 & 3 use '3d' / 'split'
// Step 2 Parametric state (dynamically populated from imported GENERAL drawing)
this.params = {
station: '',
generalDrawingName: '',
rebarDrawingNames: [],
culvert: {},
rebarTables: {},
left: null,
right: null
};
this.init();
}
getApiUrl(path) {
if (window.location.protocol === 'file:') {
return `http://127.0.0.1:5000${path}`;
}
return path;
}
async init() {
this.viewer3d = new Viewer3D(
'viewport-3d',
(id) => this.selectObject(id),
(mark, wwId) => this.selectRebarMark(mark, wwId)
);
this.viewer2d = new CAD2DViewer('cad-canvas', (id) => {
if (this.currentStep === 2) {
this.selectObject(id);
}
});
this.bindEvents();
// Default state: No files loaded initially
this.drawings = [];
this.drawingData = null;
this.activeDrawingId = null;
this.modelData = null;
// Reset backend session state if server is running
fetch(this.getApiUrl('/api/reset'), { method: 'POST' }).catch(() => {});
this.renderAll();
this.setStep(1);
}
showLoading(show, text = '도면 파일 처리 중...') {
const overlay = document.getElementById('loading-overlay');
const txt = document.getElementById('loading-text');
if (txt && text) txt.textContent = text;
if (overlay) overlay.style.display = show ? 'flex' : 'none';
}
// 1.2 도면명에 포함된 키워드를 기준으로 도면 종류 자동 판별
classifyDrawingByName(drawingName) {
const nameStr = (drawingName || '').trim();
if (nameStr.includes('일반')) {
return {
autoType: 'GENERAL',
autoLabel: '일반도',
matchedKeyword: '일반',
reason: "도면명에 '일반' 키워드가 포함되어 일반도로 자동 분류됨",
needsManualSelection: false
};
} else if (nameStr.includes('배근') || nameStr.includes('구조')) {
const kws = [];
if (nameStr.includes('배근')) kws.push('배근');
if (nameStr.includes('구조')) kws.push('구조');
const kwStr = kws.join('/');
return {
autoType: 'REBAR',
autoLabel: '배근도/구조도',
matchedKeyword: kwStr,
reason: `도면명에 '${kwStr}' 키워드가 포함되어 배근도/구조도로 자동 분류됨`,
needsManualSelection: false
};
} else {
return {
autoType: 'UNCLASSIFIED',
autoLabel: '미분류 (수동 선택 필요)',
matchedKeyword: null,
reason: "도면명에서 '일반' 또는 '배근/구조' 키워드를 판별할 수 없음 — 직접 선택 필요",
needsManualSelection: true
};
}
}
// Syncs this.params and offline 3D model from the currently loaded drawings
syncParamsFromLoadedDrawings() {
const genDwgs = this.getGeneralDrawings();
const rebDwgs = this.getRebarDrawings();
let station = '';
if (genDwgs.length > 0) {
const gd = genDwgs.find(d => d.id === this.activeDrawingId) || genDwgs[0];
this.params.generalDrawingName = gd.fileName;
const sp = gd.structuralParams;
if (sp) {
station = sp.station || '';
this.params.station = station;
this.params.culvert = JSON.parse(JSON.stringify(sp.culvert || {}));
if (sp.left) {
this.params.left = JSON.parse(JSON.stringify(sp.left));
}
if (sp.right) {
this.params.right = JSON.parse(JSON.stringify(sp.right));
}
}
} else {
this.params.generalDrawingName = '';
this.params.station = '';
this.params.culvert = {};
this.params.left = null;
this.params.right = null;
}
this.params.rebarDrawingNames = rebDwgs.map(d => d.fileName);
this.params.rebarTables = {};
for (const rd of rebDwgs) {
const rs = rd.rebarSpecs;
if (rs) {
if (!station && rs.station) {
station = rs.station;
this.params.station = station;
}
if (rs.tables) {
for (const [sideKey, tb] of Object.entries(rs.tables)) {
this.params.rebarTables[sideKey] = JSON.parse(JSON.stringify(tb));
}
}
}
}
// Update top header subtitle dynamically
const subEl = document.getElementById('header-subtitle');
if (subEl) {
if (this.drawings.length === 0) {
subEl.textContent = '도면을 불러와주세요 (1단계)';
} else {
const stLabel = station || this.drawings[0].drawingTitle || this.drawings[0].fileName;
subEl.textContent = `${stLabel} | 일반도 ${genDwgs.length}건 · 배근도 ${rebDwgs.length}건`;
}
}
}
bindEvents() {
// 3-Step Workflow Stepper Buttons
document.querySelectorAll('.step-pill').forEach(btn => {
btn.addEventListener('click', () => {
const step = parseInt(btn.dataset.step, 10);
this.setStep(step);
});
});
// View Mode Toggle
document.querySelectorAll('[data-view-mode]').forEach(btn => {
btn.addEventListener('click', () => {
this.setViewMode(btn.dataset.viewMode);
});
});
// Display Mode Toggle
document.querySelectorAll('[data-display-mode]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-display-mode]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.viewer3d.setDisplayMode(btn.dataset.displayMode);
});
});
// Sidebar Tabs (Scoped per step pane)
document.querySelectorAll('.sidebar-tabs .tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tabGroup = btn.parentElement;
const pane = tabGroup.parentElement;
tabGroup.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
pane.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
const target = pane.querySelector(`#${btn.dataset.tab}`);
if (target) target.classList.add('active');
});
});
// Header File Open Button
document.getElementById('btn-header-file-open')?.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById('file-upload')?.click();
});
// Step 1 File Select Button
document.getElementById('btn-select-file')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
document.getElementById('file-upload')?.click();
});
// Step 1 Drag and Drop Zone
const dropzone = document.getElementById('step1-dropzone');
if (dropzone) {
dropzone.addEventListener('click', (e) => {
if (e.target.closest('#btn-select-file')) return;
document.getElementById('file-upload')?.click();
});
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
this.uploadFiles(Array.from(e.dataTransfer.files));
}
});
}
// Step 3 Wingwall Filter Tabs
document.querySelectorAll('#step3-ww-filter .mini-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#step3-ww-filter .mini-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.viewer3d.rebarFilters.wingwall = btn.dataset.ww;
this.viewer3d.updateRebarVisibility();
});
});
// Fit View Buttons
document.getElementById('btn-fit-3d')?.addEventListener('click', () => this.viewer3d.fitView());
document.getElementById('btn-fit-2d')?.addEventListener('click', () => this.viewer2d.fitView());
// BOM Modal Button
document.getElementById('btn-open-bom-modal')?.addEventListener('click', () => this.openBomModal());
// Export Dropdown
const exportBtn = document.getElementById('btn-export-dropdown');
const exportMenu = document.getElementById('export-menu');
if (exportBtn && exportMenu) {
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.toggle('show');
});
window.addEventListener('click', () => exportMenu.classList.remove('show'));
}
// Export Actions
document.querySelectorAll('[data-export-fmt]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
this.exportModel(item.dataset.exportFmt);
});
});
// File Upload (Supports multiple DWG/DXF files)
const fileInput = document.getElementById('file-upload');
if (fileInput) {
fileInput.addEventListener('change', async (e) => {
const fileList = Array.from(e.target.files || []);
if (fileList.length > 0) {
await this.uploadFiles(fileList);
}
try {
e.target.value = '';
} catch (_) {}
});
}
}
getGeneralDrawings() {
return this.drawings.filter(d => d.classification.selectedType === 'GENERAL');
}
getRebarDrawings() {
return this.drawings.filter(d => d.classification.selectedType === 'REBAR');
}
getUnclassifiedDrawings() {
return this.drawings.filter(d => d.classification.selectedType === 'UNCLASSIFIED');
}
// Switch between Step 1, Step 2, Step 3
async setStep(stepNum) {
if (stepNum > 1 && this.drawings.length === 0) {
alert('1단계에서 도면을 먼저 불러와주세요.');
return;
}
if (stepNum === 2 && this.getGeneralDrawings().length === 0) {
alert('일반도로 분류된 도면이 없습니다. 1단계에서 도면을 일반도로 분류해주세요.');
return;
}
this.currentStep = stepNum;
// Toggle 2-split mode on app container (Steps 1 & 2 are 2-split vertical UI)
const appContainer = document.getElementById('app-container');
if (appContainer) {
appContainer.classList.toggle('step-1-mode', stepNum === 1);
appContainer.classList.toggle('step-2-mode', stepNum === 2);
}
// Toggle header mode controls (Step 1 & Step 2 hide 2D/3D switcher so Step 2 main screen exclusively shows the 3D exterior model)
const headerModes = document.getElementById('header-mode-controls');
if (headerModes) {
headerModes.style.display = (stepNum === 3) ? 'flex' : 'none';
}
// Update top stepper buttons
document.querySelectorAll('.step-pill').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.step, 10) === stepNum);
});
// Toggle Left Sidebar Pane
document.querySelectorAll('.step-sidebar-pane').forEach(pane => pane.classList.remove('active'));
document.getElementById(`left-pane-step-${stepNum}`)?.classList.add('active');
const rightTitle = document.getElementById('right-sidebar-title');
if (stepNum === 1) {
this.setViewMode('2d');
this.renderStep1LeftSidebar();
} else if (stepNum === 2) {
// Step 2 is exclusively 3D exterior solid model view (세로 2분할 메인 화면)
this.setViewMode('3d');
const genDwgs = this.getGeneralDrawings();
if (genDwgs.length > 0) {
const curIsGeneral = genDwgs.some(d => d.id === this.activeDrawingId);
if (!curIsGeneral) {
this.selectActiveDrawing(genDwgs[0].id, false);
}
}
this.syncParamsFromLoadedDrawings();
await this.recalculateModel();
this.viewer3d.setStepMode(2);
this.viewer3d.fitView();
this.renderStep2Sidebar();
this.selectObject(this.selectedObject || 'WW_END_LEFT');
} else if (stepNum === 3) {
if (rightTitle) rightTitle.textContent = '3단계: 철근 상세 제원 및 배근 제어 (배근도)';
const rebDwgs = this.getRebarDrawings();
if (rebDwgs.length > 0) {
const curIsRebar = rebDwgs.some(d => d.id === this.activeDrawingId);
if (!curIsRebar) {
this.selectActiveDrawing(rebDwgs[0].id, false);
}
}
if (!this.modelData) {
await this.recalculateModel();
}
this.viewer3d.setStepMode(3);
if (this.viewMode === '2d') {
this.setViewMode('3d');
} else {
this.setViewMode(this.viewMode);
}
this.viewer3d.fitView();
this.renderStep3Sidebar();
this.selectRebarMark(this.selectedRebarMark || 'W1', this.selectedRebarWingwall);
}
this.renderViewportTopBar();
this.update3DOverlayLegend();
this.updateCanvasEmptyState();
}
setViewMode(mode) {
this.viewMode = mode;
document.querySelectorAll('[data-view-mode]').forEach(b => {
b.classList.toggle('active', b.dataset.viewMode === mode);
});
const vp3d = document.getElementById('viewport-3d');
const vp2d = document.getElementById('viewport-2d');
if (mode === '3d') {
vp3d.style.display = 'block';
vp2d.style.display = 'none';
vp3d.classList.remove('split-active');
this.viewer3d.onWindowResize();
} else if (mode === '2d') {
vp3d.style.display = 'none';
vp2d.style.display = 'block';
vp3d.classList.remove('split-active');
this.viewer2d.resizeCanvas();
} else if (mode === 'split') {
vp3d.style.display = 'block';
vp2d.style.display = 'block';
vp3d.classList.add('split-active');
this.viewer3d.onWindowResize();
this.viewer2d.resizeCanvas();
}
}
update3DOverlayLegend() {
const el = document.getElementById('overlay-3d-legend');
if (!el) return;
const lLen = this.modelData?.objects?.ww_left?.dimensions?.length ?? this.params.left.length;
const rLen = this.modelData?.objects?.ww_right?.dimensions?.length ?? this.params.right.length;
if (this.currentStep === 3) {
const rebCount = this.getRebarDrawings().length;
const totBars = this.modelData?.rebars?.summary?.total_bars ?? 0;
el.innerHTML = `
[3단계: 배근도 기반 3D 철근 배근] 적용 배근도: ${rebCount}개 도면 (총 ${totBars}본)
● H19 우각부보강(C1·C2)
● H16 주철근(W1·W5·W6·F1·F2·F6)
● H13 배력·전단근
● 선택 철근
좌클릭: 회전 | 우클릭: 이동 | 휠: 줌 | 3D 철근 클릭: 해당 기호 하이라이트 및 상세보기
`;
} else {
const genDwgs = this.getGeneralDrawings();
const genName = genDwgs.length > 0 ? genDwgs.map(d => d.fileName).join(', ') : '선택된 일반도 없음';
el.innerHTML = `
[2단계: 일반도 전용 외형 모델] ${genName}
■ 좌측 날개벽(${Number(lLen).toFixed(2)}m)
■ 우측 날개벽(${Number(rLen).toFixed(2)}m)
■ 암거 본체 (배근도 제외)
좌클릭: 회전 | 우클릭: 이동 | 휠: 줌 | 부재 클릭: 외형 제원 확인 및 3D 강조
`;
}
}
renderViewportTopBar() {
const ind = document.getElementById('viewport-step-indicator');
const tabsContainer = document.getElementById('viewport-drawing-tabs');
if (!ind || !tabsContainer) return;
let stepBadgeHtml = '';
let targetDrawings = [];
if (this.currentStep === 1) {
if (this.drawings.length === 0) {
stepBadgeHtml = `1단계 도면 없음`;
} else {
stepBadgeHtml = `1단계 불러온 도면 (${this.drawings.length}종)`;
targetDrawings = this.drawings;
}
} else if (this.currentStep === 2) {
const gen = this.getGeneralDrawings();
stepBadgeHtml = `2단계: 일반도 전용 외형 모델 (${gen.length}종)`;
targetDrawings = gen;
} else {
const reb = this.getRebarDrawings();
stepBadgeHtml = `3단계: 배근도 전용 철근 배근 (${reb.length}종)`;
targetDrawings = reb;
}
ind.innerHTML = stepBadgeHtml;
tabsContainer.innerHTML = '';
for (const d of targetDrawings) {
const btn = document.createElement('button');
const isAct = (d.id === this.activeDrawingId);
btn.className = `vp-dwg-tab ${isAct ? 'active' : ''}`;
const cType = d.classification ? d.classification.selectedType : 'GENERAL';
const dotColor = cType === 'GENERAL' ? '#38bdf8' : (cType === 'REBAR' ? '#10b981' : '#f59e0b');
const shortName = d.fileName.replace(/\.dwg$/i, '');
btn.innerHTML = `● ${shortName}`;
btn.title = `${d.fileName} (${d.classification ? d.classification.selectedLabel : ''})`;
btn.addEventListener('click', () => {
this.selectActiveDrawing(d.id, true);
});
tabsContainer.appendChild(btn);
}
// Update 2D overlay chip
const ovTitle = document.getElementById('overlay-2d-title');
if (ovTitle) {
if (this.drawingData && this.drawingData.classification) {
const c = this.drawingData.classification;
ovTitle.innerHTML = `2D CAD: ${this.drawingData.fileName} ${c.selectedLabel}`;
} else {
ovTitle.innerHTML = `2D CAD 도면`;
}
}
}
updateCanvasEmptyState() {
const overlay = document.getElementById('canvas-empty-overlay');
const controls = document.getElementById('viewport-2d-controls');
const ovInfo = document.getElementById('overlay-2d-info');
const hasDrawing = !!this.drawingData;
if (overlay) {
overlay.style.display = hasDrawing ? 'none' : 'flex';
}
if (controls) {
controls.style.display = hasDrawing ? 'flex' : 'none';
}
if (ovInfo) {
ovInfo.style.display = hasDrawing ? 'block' : 'none';
}
}
selectActiveDrawing(dwgId, switchTo2DInStep1 = false) {
const found = this.drawings.find(d => d.id === dwgId);
if (!found) return;
this.activeDrawingId = found.id;
this.drawingData = found;
this.viewer2d.loadDrawing(found);
if (switchTo2DInStep1 && this.currentStep === 1 && this.viewMode === '3d') {
this.setViewMode('2d');
}
this.renderViewportTopBar();
this.updateCanvasEmptyState();
if (this.currentStep === 1) {
this.renderStep1DrawingsList();
} else if (this.currentStep === 2) {
this.renderStep2Sidebar();
} else if (this.currentStep === 3) {
this.renderStep3RebarDrawingsList();
}
}
// 1.2 도면 종류 판별 및 수동 변경
async setDrawingClassification(dwgId, newType) {
const dwg = this.drawings.find(d => d.id === dwgId);
if (!dwg) return;
const c = dwg.classification;
c.selectedType = newType;
c.selectedLabel = newType === 'GENERAL' ? '일반도' : '배근도/구조도';
c.isManualOverride = (newType !== c.autoType);
this.syncParamsFromLoadedDrawings(true);
await this.recalculateModel();
this.updateStepHeaderCounts();
this.renderStep1LeftSidebar();
this.renderViewportTopBar();
this.update3DOverlayLegend();
}
async removeDrawing(dwgId) {
this.drawings = this.drawings.filter(d => d.id !== dwgId);
if (this.activeDrawingId === dwgId) {
if (this.drawings.length > 0) {
this.selectActiveDrawing(this.drawings[0].id, false);
} else {
this.activeDrawingId = null;
this.drawingData = null;
this.modelData = null;
if (this.viewer2d) {
this.viewer2d.drawingData = null;
this.viewer2d.render();
}
}
}
this.syncParamsFromLoadedDrawings(true);
if (this.drawings.length > 0) {
await this.recalculateModel();
}
this.renderAll();
this.updateCanvasEmptyState();
}
updateStepHeaderCounts() {
const genCount = this.getGeneralDrawings().length;
const rebCount = this.getRebarDrawings().length;
const uncCount = this.getUnclassifiedDrawings().length;
const subBadge = document.getElementById('step1-sub-badge');
if (subBadge) {
if (this.drawings.length === 0) {
subBadge.textContent = '도면 확인 및 종류 판별';
} else {
subBadge.textContent = uncCount > 0
? `일반도 ${genCount} / 배근도 ${rebCount} / 미분류 ${uncCount}`
: `일반도 ${genCount} / 배근도 ${rebCount}`;
}
}
}
renderAll() {
this.updateStepHeaderCounts();
this.renderStep1LeftSidebar();
this.renderStep2Sidebar();
this.renderStep3Sidebar();
this.renderViewportTopBar();
this.update3DOverlayLegend();
this.updateCanvasEmptyState();
}
// ==================== STEP 1 RENDERERS (세로 2분할 단순 UI) ====================
renderStep1LeftSidebar() {
const totalEl = document.getElementById('step1-total-count');
if (totalEl) totalEl.textContent = `${this.drawings.length}개`;
this.renderStep1DrawingsList();
}
renderStep1DrawingsList() {
const listEl = document.getElementById('step1-drawings-list');
const actionArea = document.getElementById('step1-action-area');
if (!listEl) return;
listEl.innerHTML = '';
if (this.drawings.length === 0) {
listEl.innerHTML = `
📁
불러온 도면이 없습니다.
상단의 도면 파일 선택 또는 파일을 드래그하여 추가하세요.
`;
if (actionArea) actionArea.innerHTML = '';
return;
}
for (const d of this.drawings) {
const c = d.classification;
const isSelected = (d.id === this.activeDrawingId);
const isUnc = (c.selectedType === 'UNCLASSIFIED');
const isGen = (c.selectedType === 'GENERAL');
const isReb = (c.selectedType === 'REBAR');
const badgeClass = isGen ? 'type-general' : (isReb ? 'type-rebar' : 'type-unclassified');
const badgeText = isGen ? '일반도' : (isReb ? '배근도/구조도' : '미분류');
const card = document.createElement('div');
card.className = `dwg-card-simple ${isSelected ? 'selected' : ''} ${isUnc ? 'unclassified' : ''}`;
card.innerHTML = `
`;
card.addEventListener('click', (e) => {
if (e.target.closest('[data-set-type]') || e.target.closest('[data-del-id]')) return;
this.selectActiveDrawing(d.id, true);
});
card.querySelectorAll('[data-set-type]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.setDrawingClassification(d.id, btn.dataset.setType);
});
});
card.querySelector('[data-del-id]')?.addEventListener('click', (e) => {
e.stopPropagation();
this.removeDrawing(d.id);
});
listEl.appendChild(card);
}
// Step 1 Bottom Action Area (Transition to Step 2)
if (actionArea) {
const genCount = this.getGeneralDrawings().length;
if (genCount > 0) {
actionArea.innerHTML = `
`;
document.getElementById('btn-goto-step2')?.addEventListener('click', () => {
this.setStep(2);
});
} else {
actionArea.innerHTML = `
⚠ 2단계 외형 모델링을 진행하려면 최소 1개 이상의 도면을 '일반도'로 지정해주세요.
`;
}
}
}
populateLayerCheckboxes(containerId, dwgObj) {
const list = document.getElementById(containerId);
if (!list || !dwgObj || !dwgObj.layers) return;
list.innerHTML = '';
const colorMap = { 1: '#ef4444', 2: '#eab308', 3: '#22c55e', 4: '#06b6d4', 5: '#3b82f6', 6: '#d946ef', 7: '#f8fafc' };
for (const [lname, info] of Object.entries(dwgObj.layers)) {
const item = document.createElement('div');
item.className = 'layer-item';
const color = colorMap[info.color] || '#94a3b8';
const checked = this.viewer2d.activeLayers.has(lname);
item.innerHTML = `
${lname}
`;
item.querySelector('input').addEventListener('change', (e) => {
this.viewer2d.setLayerVisibility(lname, e.target.checked);
});
list.appendChild(item);
}
}
// ==================== STEP 2 RENDERERS (일반도 전용 3D 외형 모델링) ====================
renderStep2Sidebar() {
const genDwgs = this.getGeneralDrawings();
// 1. 적용 일반도 정보 & 체적 요약 카드
const activeGen = genDwgs.find(d => d.id === this.activeDrawingId) || genDwgs[0] || this.drawingData;
const genCard = document.getElementById('step2-general-dwg-card');
if (genCard) {
if (!activeGen) {
genCard.innerHTML = `
⚠ 불러온 일반도가 없습니다.
`;
} else {
const st = this.modelData?.station || this.params?.station || activeGen.titleBlock?.subTitle || '수로암거';
const totVol = this.modelData?.summary?.total_volume || 0;
const wwCount = (this.modelData?.objects?.wingwalls || []).length;
genCard.innerHTML = `
📐 ${activeGen.fileName}
일반도
${st} (날개벽 ${wwCount}개소)
총 체적: ${totVol} m³
`;
}
}
// 2. 평면도 암거 및 날개벽 위치 판별 현황 카드 (심플)
this.renderStep2DetectionCard(activeGen);
// 3. 외형 모델링 구조물 목록 (심플)
this.renderStep2StructuresList();
// 4. Step 3 이동 버튼 이벤트 연결
document.getElementById('btn-goto-step3')?.addEventListener('click', () => {
this.setStep(3);
});
}
// 2단계 핵심 기능: 암거 및 날개벽 위치 판별 결과 카드 (간결화)
renderStep2DetectionCard(activeGen) {
const card = document.getElementById('step2-detection-card');
if (!card) return;
const det = this.modelData?.detection || activeGen?.structuralParams?.detection || {};
const quads = det.quadrants || {};
const renderPill = (qKey, label, posLabel) => {
const q = quads[qKey] || {};
const exists = !!q.exists;
const border = exists ? 'rgba(56,189,248,0.4)' : 'rgba(255,255,255,0.06)';
const bg = exists ? 'rgba(56,189,248,0.08)' : 'rgba(255,255,255,0.02)';
const color = exists ? '#38bdf8' : 'var(--text-muted)';
const spec = exists ? `L=${q.length || 0}m` : '미표기';
return `
${label} (${posLabel})
${exists ? `● ${spec}` : '○ 제외'}
`;
};
card.innerHTML = `
${renderPill('START_LEFT', '시점 좌측', '상')}
${renderPill('END_LEFT', '종점 좌측', '상')}
${renderPill('START_RIGHT', '시점 우측', '하')}
${renderPill('END_RIGHT', '종점 우측', '하')}
`;
}
// 날개벽 구성 시뮬레이션 적용 핸들러
async applySimulationConfig(mode) {
this.simulatedConfig = mode;
const override = { culvert: {}, left: {}, right: {}, start_left: {}, start_right: {}, detection: {} };
if (mode === 'REAL') {
// Restore actual detection from current drawings
this.syncParamsFromLoadedDrawings(true);
} else if (mode === 'BOTH') {
override.detection = {
configuration: 'BOTH',
configuration_label: '시점부 및 종점부 날개벽 모두 존재 (4개소 시뮬레이션)',
rule_statement: '시점부 좌·우 및 종점부 좌·우 총 4개 날개벽을 모두 모델링합니다.',
quadrants: {
START_LEFT: { exists: true, length: 4.35, flare_angle: 134.0 },
START_RIGHT: { exists: true, length: 5.40, flare_angle: 151.0 },
END_LEFT: { exists: true, length: 4.35, flare_angle: 134.0 },
END_RIGHT: { exists: true, length: 5.40, flare_angle: 151.0 }
}
};
override.start_left = { length: 4.35, flare_angle: 134.0, exists: true };
override.start_right = { length: 5.40, flare_angle: 151.0, exists: true };
override.left = { length: 4.35, flare_angle: 134.0, exists: true };
override.right = { length: 5.40, flare_angle: 151.0, exists: true };
} else if (mode === 'START_ONLY') {
override.detection = {
configuration: 'START_ONLY',
configuration_label: '시점부 날개벽만 존재 (2개소 시뮬레이션)',
rule_statement: '암거 좌측(시점부)에만 날개벽이 표기되어 시점부 날개벽만 모델링합니다.',
quadrants: {
START_LEFT: { exists: true, length: 4.35, flare_angle: 134.0 },
START_RIGHT: { exists: true, length: 5.40, flare_angle: 151.0 },
END_LEFT: { exists: false },
END_RIGHT: { exists: false }
}
};
override.start_left = { length: 4.35, flare_angle: 134.0, exists: true };
override.start_right = { length: 5.40, flare_angle: 151.0, exists: true };
override.left = { exists: false };
override.right = { exists: false };
} else if (mode === 'NONE') {
override.detection = {
configuration: 'NONE',
configuration_label: '날개벽 미존재 (암거 본체만 시뮬레이션)',
rule_statement: '날개벽이 전혀 표기되어 있지 않아 날개벽을 생성하지 않고 암거 본체만 모델링합니다.',
quadrants: {
START_LEFT: { exists: false },
START_RIGHT: { exists: false },
END_LEFT: { exists: false },
END_RIGHT: { exists: false }
}
};
override.start_left = { exists: false };
override.start_right = { exists: false };
override.left = { exists: false };
override.right = { exists: false };
}
await this.recalculateModel(override);
this.viewer3d.fitView();
this.renderStep2Sidebar();
this.update3DOverlayLegend();
}
renderStep2StructuresList() {
const list = document.getElementById('step2-structures-list');
if (!list || !this.modelData || !this.modelData.objects) return;
list.innerHTML = '';
const culv = this.modelData.objects.culvert;
const station = this.modelData.station || this.params.station || '수로암거';
const items = [];
// 1. 암거 본체 (항상 포함)
if (culv) {
items.push({
id: 'CULVERT',
name: `암거 본체 (${station})`,
tag: `내공 ${culv.dimensions.clear_span.toFixed(1)}×${culv.dimensions.clear_height.toFixed(1)}m`,
color: '#a1a1aa',
info: `폭 ${culv.dimensions.outer_width.toFixed(2)}m × 높이 ${culv.dimensions.height.toFixed(2)}m · 사각 ${culv.dimensions.skew_angle.toFixed(1)}° · ${culv.volume} m³`
});
}
// 2. 도면에 실제로 생성된 날개벽 목록만 동적으로 추가
const wwList = this.modelData.objects.wingwalls || [];
const colorMap = {
'WW_END_LEFT': '#38bdf8',
'WW_END_RIGHT': '#fbbf24',
'WW_START_LEFT': '#34d399',
'WW_START_RIGHT': '#a78bfa'
};
for (const ww of wwList) {
const c = colorMap[ww.id] || '#38bdf8';
items.push({
id: ww.id,
name: ww.name,
tag: `L=${ww.dimensions.length.toFixed(2)}m (${ww.dimensions.flare_angle.toFixed(0)}°)`,
color: c,
info: `H=${(ww.dimensions.total_height_start || 3.3).toFixed(2)}~${(ww.dimensions.total_height_end || 1.5).toFixed(2)}m · B=${(ww.dimensions.footing_width_start || 2.2).toFixed(2)}~${(ww.dimensions.footing_width_end || 1.5).toFixed(2)}m · ${ww.volume} m³`
});
}
if (wwList.length === 0) {
const emptyNote = document.createElement('div');
emptyNote.style.cssText = 'padding:8px; background:rgba(255,255,255,0.02); border:1px dashed var(--border-color); border-radius:4px; font-size:11px; color:var(--text-muted); text-align:center;';
emptyNote.innerHTML = '도면에 날개벽이 표기되어 있지 않아 암거 본체만 생성되었습니다.';
list.appendChild(emptyNote);
}
for (const s of items) {
const isSel = (this.selectedObject === s.id);
const card = document.createElement('div');
card.className = `struct-card-step2 ${isSel ? 'selected' : ''}`;
card.id = `step2-struct-${s.id}`;
card.style.padding = '7px 9px';
card.style.gap = '3px';
card.innerHTML = `
${s.info}
`;
card.querySelector('input').addEventListener('change', (e) => {
e.stopPropagation();
this.viewer3d.toggleObjectVisibility(s.id, e.target.checked);
});
card.addEventListener('click', () => {
this.selectObject(s.id);
});
list.appendChild(card);
}
}
renderStep2Diagnostics() {
if (!this.modelData) return;
const summary = this.modelData.summary || {};
const volCard = document.getElementById('step2-volume-summary');
const wwCount = summary.wingwall_count ?? (this.modelData.objects?.wingwalls?.length ?? 2);
const wwVol = summary.wingwalls_volume ?? ((summary.ww_left_volume || 0) + (summary.ww_right_volume || 0));
if (volCard) {
volCard.innerHTML = `
암거 본체 체적${summary.culvert_volume || 0} m³
날개벽 합계 (${wwCount}개소)${wwVol.toFixed(2)} m³
총 콘크리트 체적 (암거+날개벽)${summary.total_volume || 0} m³
`;
}
const badge = document.getElementById('step2-validation-badge');
if (badge) {
const cfgLbl = summary.configuration_label || '종점부 날개벽만 존재 (2개소)';
badge.innerHTML = `
✓ 위치 판별 일치: ${cfgLbl} (평면도 중앙 사다리꼴·상하 점선 검출)
✓ 임의생성 배제: 도면에 없는 날개벽 생성 차단 및 도면 표기 날개벽 누락 없음
✓ 3D 솔리드 폐합: 완전 폐합 2-manifold closed mesh (Non-manifold / Boundary: 0)
`;
}
}
selectObject(objectId) {
this.selectedObject = objectId;
this.viewer3d.selectObject(objectId);
// Update Step 2 cards selection
document.querySelectorAll('.struct-card-step2').forEach(el => el.classList.remove('selected'));
const targetCard = document.getElementById(`step2-struct-${objectId}`);
if (targetCard) {
targetCard.classList.add('selected');
targetCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ==================== STEP 3 RENDERERS (배근도 기반 3D 철근 배근) ====================
renderStep3Sidebar() {
if (this.modelData && this.modelData.rebars) {
const summary = this.modelData.rebars.summary;
const rightGroups = this.modelData.rebars.wingwalls.WW_END_RIGHT?.groups || [];
const marksBtn = document.getElementById('tab-rebar-marks-btn');
if (marksBtn) {
marksBtn.textContent = `철근 기호(${rightGroups.length}종)`;
}
const btnAll = document.getElementById('btn-ww-filter-all');
const btnLeft = document.getElementById('btn-ww-filter-left');
const btnRight = document.getElementById('btn-ww-filter-right');
if (btnAll) btnAll.textContent = `전체 (${summary.total_bars}본)`;
if (btnLeft) btnLeft.textContent = `종점 좌 (${summary.left_bars}본)`;
if (btnRight) btnRight.textContent = `종점 우 (${summary.right_bars}본)`;
}
this.renderStep3RebarDrawingsList();
this.renderStep3Filters();
this.renderStep3MarksList();
this.renderStep3Diagnostics();
}
renderStep3RebarDrawingsList() {
const rebDwgs = this.getRebarDrawings();
const totBars = this.modelData?.rebars?.summary?.total_bars ?? 0;
const banner = document.getElementById('step3-source-summary');
if (banner) {
banner.innerHTML = `1단계에서 분류된 배근도/구조도 ${rebDwgs.length}건을 이용하여 날개벽 내부에 3D 철근(총 ${totBars}본)을 배근합니다.`;
}
const container = document.getElementById('step3-rebar-dwgs-list');
if (!container) return;
container.innerHTML = '';
if (rebDwgs.length === 0) {
container.innerHTML = `
⚠ '배근도/구조도'로 분류된 도면이 없습니다. (일반도 제원 기준으로 기본 배근이 표시됩니다)
`;
return;
}
for (const d of rebDwgs) {
const isAct = (d.id === this.activeDrawingId);
const item = document.createElement('div');
item.className = `struct-item ${isAct ? 'selected' : ''}`;
item.innerHTML = `
🔩
${d.fileName}
${d.drawingTitle} (${d.titleBlock?.targetWingwall || ''})
2D 도면보기
`;
item.addEventListener('click', () => {
this.selectActiveDrawing(d.id, false);
if (this.viewMode === '3d') {
this.setViewMode('split');
}
});
container.appendChild(item);
}
}
renderStep3Filters() {
const diaEl = document.getElementById('step3-dia-filters');
if (diaEl) {
const dias = [
{ dia: 'H19', label: 'H19 (D19) - 암거접합부 우각부 보강근 (C1, C2)', color: '#f43f5e' },
{ dia: 'H16', label: 'H16 (D16) - 벽체·저판 주철근 (W1, W5, W6, F1, F2, F6)', color: '#38bdf8' },
{ dia: 'H13', label: 'H13 (D13) - 수평·종방향 배력근 및 전단보강근 (W2~W4, W7~W9, F3~F9, S1~S3)', color: '#10b981' }
];
diaEl.innerHTML = '';
for (const item of dias) {
const row = document.createElement('div');
row.className = 'layer-item';
const checked = this.viewer3d.rebarFilters.dias.has(item.dia);
row.innerHTML = `
${item.label}
`;
row.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) this.viewer3d.rebarFilters.dias.add(item.dia);
else this.viewer3d.rebarFilters.dias.delete(item.dia);
this.viewer3d.updateRebarVisibility();
});
diaEl.appendChild(row);
}
}
const zoneEl = document.getElementById('step3-zone-filters');
if (zoneEl) {
const zones = [
{ zone: '우각부 보강근 (암거접합부)', sub: 'C1, C2 (H19 @250)' },
{ zone: '벽체 전면 철근', sub: 'W1(주철근 H16), W2~W4(배력근 H13)' },
{ zone: '벽체 배면 철근', sub: 'W5·W6(주철근 H16 1·2Cycle), W7~W9(배력근 H13)' },
{ zone: '저판 상면 철근', sub: 'F1·F2(주철근 H16), F3~F5(배력근 H13)' },
{ zone: '저판 하면 철근', sub: 'F6(주철근 H16), F7~F9(배력근 H13)' },
{ zone: '헌치 및 전단보강근', sub: 'S1~S3 전단보강근 및 헌치보강근' }
];
zoneEl.innerHTML = '';
for (const z of zones) {
const row = document.createElement('div');
row.className = 'layer-item';
const checked = this.viewer3d.rebarFilters.zones.has(z.zone);
row.innerHTML = `
`;
row.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) this.viewer3d.rebarFilters.zones.add(z.zone);
else this.viewer3d.rebarFilters.zones.delete(z.zone);
this.viewer3d.updateRebarVisibility();
});
zoneEl.appendChild(row);
}
}
}
renderStep3MarksList() {
const listEl = document.getElementById('step3-marks-list');
if (!listEl || !this.modelData || !this.modelData.rebars) return;
listEl.innerHTML = '';
const rightGroups = this.modelData.rebars.wingwalls.WW_END_RIGHT?.groups || [];
const leftGroups = this.modelData.rebars.wingwalls.WW_END_LEFT?.groups || [];
const leftByMark = {};
for (const lg of leftGroups) leftByMark[lg.mark] = lg;
const diaColorMap = { 'H19': '#f43f5e', 'H16': '#38bdf8', 'H13': '#10b981' };
for (const rg of rightGroups) {
const lg = leftByMark[rg.mark];
const totCount = rg.count + (lg ? lg.count : 0);
const isSel = (this.selectedRebarMark === rg.mark);
const isVis = this.viewer3d.rebarFilters.marks.has(rg.mark);
const col = diaColorMap[rg.dia] || '#eab308';
const card = document.createElement('div');
card.className = `struct-item ${isSel ? 'selected' : ''}`;
card.id = `rebar-mark-card-${rg.mark}`;
card.innerHTML = `
`;
card.querySelector('input').addEventListener('change', (e) => {
e.stopPropagation();
if (e.target.checked) this.viewer3d.rebarFilters.marks.add(rg.mark);
else this.viewer3d.rebarFilters.marks.delete(rg.mark);
this.viewer3d.updateRebarVisibility();
});
card.addEventListener('click', () => {
this.selectRebarMark(rg.mark, this.selectedRebarWingwall);
});
listEl.appendChild(card);
}
}
renderStep3Diagnostics() {
if (!this.modelData || !this.modelData.rebars) return;
const summary = this.modelData.rebars.summary;
const weightEl = document.getElementById('rebar-weight-summary');
if (weightEl) {
weightEl.innerHTML = `
종점 좌측 날개벽 철근${summary.left_bars} 본 (${summary.left_weight_ton} TON)
종점 우측 날개벽 철근${summary.right_bars} 본 (${summary.right_weight_ton} TON)
총 철근 연장 / 본수${summary.total_length_m} m (${summary.total_bars}본)
총 철근 중량 (할증3%)${summary.total_weight_ton} T (${summary.total_surcharge_ton} T)
`;
}
const diagList = document.getElementById('rebar-diag-list');
if (diagList) {
diagList.innerHTML = '';
for (const val of summary.validations || []) {
const card = document.createElement('div');
card.className = 'diag-item';
card.innerHTML = `
${val.detail}
`;
diagList.appendChild(card);
}
}
}
selectRebarMark(mark, wwId) {
this.selectedRebarMark = mark;
if (wwId) this.selectedRebarWingwall = wwId;
this.viewer3d.selectRebarMark(mark);
document.querySelectorAll('#step3-marks-list .struct-item').forEach(el => el.classList.remove('selected'));
const card = document.getElementById(`rebar-mark-card-${mark}`);
if (card) card.classList.add('selected');
if (this.currentStep === 3) {
this.renderStep3RightPanel(mark);
}
}
buildRebarShapeSvg(grp) {
const p = grp.shape_params || {};
const st = grp.shape_type;
const col = grp.dia === 'H19' ? '#f43f5e' : (grp.dia === 'H16' ? '#38bdf8' : '#10b981');
let svgBody = '';
if (st === 'CORNER_BENT') {
svgBody = `
a = ${p.a}mm (암거정착)
b = ${p.b}mm (날개벽)
x1 = ${p.x1}
`;
} else if (st === 'U_HOOK') {
svgBody = `
a = ${p.a} m (저판 폭 가변)
b=${p.b}
c=${p.c}
`;
} else if (st === 'U_HOOK_UP') {
svgBody = `
a = ${p.a} m (저판 하면 폭 가변)
b=${p.b}
c=${p.c}
`;
} else if (st === 'L_BENT') {
svgBody = `
a = ${p.a} m (2Cycle 보강)
b=${p.b}
`;
} else if (st === 'STEM_VERTICAL_FRONT' || st === 'STEM_VERTICAL_BACK') {
svgBody = `
기초정착 ${p.c || p.a}mm
수직부 ${p.b && p.b.includes('~') ? p.b : p.a} m
`;
} else if (st === 'HAUNCH_BENT') {
svgBody = `
a=${p.a}
b=${p.b}
c=${p.c}
`;
} else if (st === 'STIRRUP') {
svgBody = `
a = ${p.a} mm
후크 b = ${p.b} mm
`;
} else {
svgBody = `
직선 배력근 a = ${p.a} m
`;
}
return `
`;
}
renderStep3RightPanel(mark = 'W1') {
const pContainer = document.getElementById('properties-container');
if (!pContainer || !this.modelData || !this.modelData.rebars) return;
const leftWw = this.modelData.rebars.wingwalls.WW_END_LEFT;
const rightWw = this.modelData.rebars.wingwalls.WW_END_RIGHT;
const rg = (rightWw?.groups || []).find(g => g.mark === mark) || rightWw.groups[0];
const lg = (leftWw?.groups || []).find(g => g.mark === rg.mark) || leftWw.groups[0];
const col = rg.dia === 'H19' ? '#f43f5e' : (rg.dia === 'H16' ? '#38bdf8' : '#10b981');
const totCount = rg.count + lg.count;
const totLen = (rg.total_length_m + lg.total_length_m).toFixed(3);
const totWeight = (rg.weight_ton + lg.weight_ton).toFixed(4);
pContainer.innerHTML = `
철근 기호 [${rg.mark}] 상세 제원
${rg.dia} (SD400)
${rg.desc}
📐 철근 절곡 상세 형상도 (Bar Bending Detail)
${this.buildRebarShapeSvg(rg)}
철근 재료표 및 3D 배근 내보내기
`;
document.getElementById('slider-conc-op')?.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
document.getElementById('val-conc-op').textContent = Math.round(v * 100) + '%';
this.viewer3d.setConcreteOpacity(v);
});
document.getElementById('slider-reb-scale')?.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
document.getElementById('val-reb-scale').textContent = v.toFixed(1) + 'x';
this.viewer3d.setRebarThicknessScale(v);
});
}
// ==================== BOM MODAL (철근 재료표) ====================
openBomModal() {
const modal = document.getElementById('bom-modal');
const body = document.getElementById('bom-modal-body');
if (!modal || !body || !this.modelData || !this.modelData.rebars) return;
const modalSub = document.getElementById('bom-modal-subtitle');
if (modalSub) {
const st = this.modelData.station || this.params.station || '';
modalSub.textContent = `${st} 종점부 날개벽 | fck=30MPa, fy=400~500MPa (SD400)`;
}
const rebars = this.modelData.rebars;
const renderWwTable = (ww) => {
const groups = ww.groups || [];
const subs = ww.subtotals_by_dia || {};
let rowsHtml = '';
const renderDiaSection = (diaKey) => {
const dGroups = groups.filter(g => g.dia === diaKey);
for (const g of dGroups) {
const shapeStr = Object.entries(g.shape_params || {}).map(([k, v]) => `${k}=${v}`).join(', ');
rowsHtml += `
| ${g.mark} |
${g.dia} |
${g.role} (${g.zone}) |
${shapeStr} |
${g.length_m.toFixed(3)} |
${g.count} |
${g.total_length_m.toFixed(3)} |
- |
- |
- |
`;
}
const sub = subs[diaKey];
if (sub) {
rowsHtml += `
| ${diaKey} 소 계 |
${sub.total_length_m.toFixed(3)} |
${sub.unit_weight.toFixed(3)} |
${sub.weight_ton.toFixed(3)} |
${sub.surcharge_ton.toFixed(3)} (3%) |
`;
}
};
renderDiaSection('H19');
renderDiaSection('H16');
renderDiaSection('H13');
const s = ww.summary;
rowsHtml += `
| ${ww.wingwall_name} 총 계 |
${s.total_bars} 본 |
${s.total_length_m.toFixed(3)} m |
- |
${s.total_weight_ton.toFixed(3)} TON |
${s.total_surcharge_ton.toFixed(3)} TON |
`;
return `
■ ${ww.wingwall_name} (연장 L=${ww.length}m, 각도 ${ww.flare_angle}°) — 출처: ${ww.source_drawings.join(', ')}
| 기호 |
직경 |
배근 부위 및 역할 |
절곡 치수 상세 |
길이 (m) |
개수 |
총길이 (m) |
단위무게 (kg/m) |
총무게 (TON) |
할증 (3%, TON) |
${rowsHtml}
`;
};
body.innerHTML = `
${renderWwTable(rebars.wingwalls.WW_END_RIGHT)}
${renderWwTable(rebars.wingwalls.WW_END_LEFT)}
`;
modal.style.display = 'flex';
}
closeBomModal() {
const modal = document.getElementById('bom-modal');
if (modal) modal.style.display = 'none';
}
// ==================== API INTERACTIONS ====================
async recalculateModel(overrideParams = null) {
this.syncParamsFromLoadedDrawings();
const payload = JSON.parse(JSON.stringify(this.params || {}));
if (overrideParams) {
if (overrideParams.detection) payload.detection = Object.assign({}, payload.detection || {}, overrideParams.detection);
if (overrideParams.left) payload.left = Object.assign({}, payload.left || {}, overrideParams.left);
if (overrideParams.right) payload.right = Object.assign({}, payload.right || {}, overrideParams.right);
if (overrideParams.start_left) payload.start_left = Object.assign({}, payload.start_left || {}, overrideParams.start_left);
if (overrideParams.start_right) payload.start_right = Object.assign({}, payload.start_right || {}, overrideParams.start_right);
if (overrideParams.culvert) payload.culvert = Object.assign({}, payload.culvert || {}, overrideParams.culvert);
}
try {
const res = await fetch(this.getApiUrl('/api/generate-3d'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
this.modelData = data.model;
this.viewer3d.loadModel(this.modelData);
if (this.currentStep === 2) this.renderStep2Sidebar();
if (this.currentStep === 3) this.renderStep3Sidebar();
this.update3DOverlayLegend();
if (this.selectedObject) this.viewer3d.selectObject(this.selectedObject);
return;
}
} catch (e) {
console.warn('API recalculate failed, using bundled model fallback:', e);
}
// Offline fallback using pre-bundled station models
const st = this.params.station;
let fallbackModel = null;
if (window.KNOWN_MODELS_MAP && st) {
if (window.KNOWN_MODELS_MAP[st]) {
fallbackModel = window.KNOWN_MODELS_MAP[st];
} else {
for (const [k, m] of Object.entries(window.KNOWN_MODELS_MAP)) {
if (k.includes(st) || st.includes(k) || (st.includes('0+000') && k.includes('0+000')) || (st.includes('13+611') && k.includes('13+611')) || (st.includes('0+017') && k.includes('0+017'))) {
fallbackModel = m;
break;
}
}
}
}
if (fallbackModel) {
this.modelData = JSON.parse(JSON.stringify(fallbackModel));
this.viewer3d.loadModel(this.modelData);
if (this.currentStep === 2) this.renderStep2Sidebar();
if (this.currentStep === 3) this.renderStep3Sidebar();
this.update3DOverlayLegend();
if (this.selectedObject) this.viewer3d.selectObject(this.selectedObject);
} else {
console.warn('1단계에서 불러온 도면과 일치하는 3D 모델 데이터가 없습니다. 기존 샘플 모델을 적용하지 않습니다.');
}
}
async exportModel(format) {
try {
const res = await fetch(this.getApiUrl(`/api/export/${format}`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.params)
});
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const extMap = { dxf: 'dxf', stl: 'stl', obj: 'obj', csv: 'csv', json: 'json' };
a.download = `wingwall_model.${extMap[format] || 'dat'}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}
} catch (e) {
alert('내보내기 중 오류가 발생했습니다: ' + e);
}
}
mergeUploadedDrawings(uploadedList) {
if (!uploadedList || uploadedList.length === 0) return;
// Identify station of newly uploaded drawings; if different from existing drawings, clear old station's drawings
const newStations = new Set();
for (const ud of uploadedList) {
const st = ud.structuralParams?.station || ud.rebarSpecs?.station;
if (st) newStations.add(st);
}
if (newStations.size > 0 && this.drawings.length > 0) {
this.drawings = this.drawings.filter(ed => {
const edSt = ed.structuralParams?.station || ed.rebarSpecs?.station;
return !edSt || newStations.has(edSt);
});
}
for (const ud of uploadedList) {
if (!ud.id) ud.id = ud.fileName;
if (!ud.classification) {
const c = this.classifyDrawingByName(ud.fileName);
ud.classification = {
...c,
selectedType: c.autoType,
selectedLabel: c.autoLabel,
isManualOverride: false
};
}
const idx = this.drawings.findIndex(d => d.fileName === ud.fileName);
if (idx >= 0) this.drawings[idx] = ud;
else this.drawings.push(ud);
}
this.activeDrawingId = uploadedList[0].id;
this.drawingData = uploadedList[0];
this.viewer2d.loadDrawing(this.drawingData);
this.syncParamsFromLoadedDrawings(true);
}
async uploadFiles(fileList) {
const filesArray = Array.from(fileList || []);
if (filesArray.length === 0) return;
this.showLoading(true, '도면 파일 처리 중...');
const formData = new FormData();
for (const f of filesArray) {
formData.append('files', f);
}
try {
const res = await fetch(this.getApiUrl('/api/upload'), {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.status === 'success') {
const uploaded = data.uploadedDrawings || (data.drawing ? [data.drawing] : []);
this.mergeUploadedDrawings(uploaded);
if (data.model) {
this.modelData = data.model;
this.viewer3d.loadModel(this.modelData);
} else {
await this.recalculateModel();
}
this.setStep(1);
this.renderAll();
} else {
alert('도면 분석 오류: ' + data.message);
}
} catch (e) {
console.warn('Backend upload unavailable, using client known drawings map:', e);
const uploadedOffline = [];
for (const f of filesArray) {
let known = null;
if (window.KNOWN_DRAWINGS_MAP) {
if (window.KNOWN_DRAWINGS_MAP[f.name]) {
known = window.KNOWN_DRAWINGS_MAP[f.name];
} else {
// Flexible name matching (normalize whitespace, brackets, NFC/NFD)
const cleanName = f.name.replace(/\s+/g, '').normalize('NFC');
for (const [k, v] of Object.entries(window.KNOWN_DRAWINGS_MAP)) {
const cleanK = k.replace(/\s+/g, '').normalize('NFC');
if (cleanK === cleanName || cleanK.includes(cleanName) || cleanName.includes(cleanK)) {
known = v;
break;
}
}
}
}
if (known) {
const clone = JSON.parse(JSON.stringify(known));
clone.id = clone.fileName;
uploadedOffline.push(clone);
} else {
alert(`서버(http://127.0.0.1:5000)에 연결되어 있지 않거나 신규 도면(${f.name})이 등록되어 있지 않습니다.\n\n▶ 터미널이나 run_server.bat을 실행하여 파이썬 웹 서버(python app.py)를 켜두시면 모든 신규 DWG 파일을 실시간으로 자동 분석할 수 있습니다.`);
}
}
if (uploadedOffline.length > 0) {
this.mergeUploadedDrawings(uploadedOffline);
await this.recalculateModel();
this.setStep(1);
this.renderAll();
}
} finally {
this.showLoading(false);
}
}
}
window.addEventListener('DOMContentLoaded', () => {
window.app = new WingwallApp();
});