// 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 = `
${d.fileName}
${badgeText}
`; 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.name}
${s.tag}
${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 = `
${z.zone}
${z.sub}
`; 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 = `
${rg.mark}
${rg.role} (${rg.dia})
간격 ${rg.spacing} | 좌 ${lg ? lg.count : 0}본 + 우 ${rg.count}본 = 총 ${totCount}본
`; 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.category} ✔ ${val.status}
${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 ` ${svgBody} `; } 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)}
배치 부위${rg.zone}
배근 간격 / 피복${rg.spacing} (피복 80mm)
종점 좌측 (L=${Number(leftWw.length).toFixed(2)}m)${lg.count} 본 (1본 ${lg.length_m}m)
종점 우측 (L=${Number(rightWw.length).toFixed(2)}m)${rg.count} 본 (1본 ${rg.length_m}m)
좌·우 합계 수량/연장${totCount} 본 / ${totLen} m
합계 중량 (${rg.unit_weight}kg/m)${totWeight} TON

3D 철근 투시 및 표현 제어 실시간 3D 반영

${Math.round(this.viewer3d.concreteOpacity * 100)}%
${this.viewer3d.rebarScale.toFixed(1)}x

철근 재료표 및 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(', ')}

${rowsHtml}
기호 직경 배근 부위 및 역할 절곡 치수 상세 길이 (m) 개수 총길이 (m) 단위무게 (kg/m) 총무게 (TON) 할증 (3%, TON)
`; }; 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(); });