// 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.viewer3d.onSelectRebar = (mark, wwId) => this.selectRebarMark(mark, wwId); this.viewer3d.onDeselectRebar = () => this.hideRebarDetailCard(); this.viewer3d.onSelectWingwall = (objId) => this.selectStep3Structure(objId, false); 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 2 Next button -> Step 3 document.getElementById('btn-goto-step3')?.addEventListener('click', () => this.setStep(3)); // Step 3 Back button -> Step 2 document.getElementById('btn-backto-step2')?.addEventListener('click', () => this.setStep(2)); // Step 3 HUD Controls const sliderOp = document.getElementById('step3-slider-opacity'); if (sliderOp) { sliderOp.addEventListener('input', (e) => { const val = parseFloat(e.target.value); this.viewer3d.setConcreteOpacity(val); const valEl = document.getElementById('step3-opacity-val'); if (valEl) valEl.textContent = `${Math.round(val * 100)}%`; }); } const sliderScale = document.getElementById('step3-slider-scale'); if (sliderScale) { sliderScale.addEventListener('input', (e) => { const val = parseFloat(e.target.value); this.viewer3d.setRebarThicknessScale(val); const valEl = document.getElementById('step3-scale-val'); if (valEl) valEl.textContent = `${val.toFixed(1)}x`; }); } // Step 3 Dia Filter Pills document.querySelectorAll('#step3-dia-pill-group .dia-pill').forEach(btn => { btn.addEventListener('click', () => { const dia = btn.dataset.dia; if (dia === 'ALL') { const allActive = btn.classList.contains('active'); document.querySelectorAll('#step3-dia-pill-group .dia-pill').forEach(b => { b.classList.toggle('active', !allActive); }); if (!allActive) { this.viewer3d.rebarFilters.dias = new Set(['H19', 'H16', 'H13']); } else { this.viewer3d.rebarFilters.dias.clear(); } } else { btn.classList.toggle('active'); if (btn.classList.contains('active')) { this.viewer3d.rebarFilters.dias.add(dia); } else { this.viewer3d.rebarFilters.dias.delete(dia); } const allBtn = document.querySelector('#step3-dia-pill-group .dia-pill[data-dia="ALL"]'); if (allBtn) { allBtn.classList.toggle('active', this.viewer3d.rebarFilters.dias.size === 3); } } this.viewer3d.updateRebarVisibility(); }); }); // Step 3 Zone Filter Pills document.querySelectorAll('#step3-zone-pill-group .zone-pill').forEach(btn => { btn.addEventListener('click', () => { const zoneKeyword = btn.dataset.zone; btn.classList.toggle('active'); const isActive = btn.classList.contains('active'); const allZones = [ '우각부 보강근 (암거접합부)', '벽체 전면 철근', '벽체 배면 철근', '저판 상면 철근', '저판 하면 철근', '헌치 및 전단보강근' ]; for (const z of allZones) { if (z.includes(zoneKeyword)) { if (isActive) this.viewer3d.rebarFilters.zones.add(z); else this.viewer3d.rebarFilters.zones.delete(z); } } this.viewer3d.updateRebarVisibility(); }); }); // Step 3 Open BOM Button document.getElementById('btn-step3-open-bom')?.addEventListener('click', () => this.openBomModal()); // Step 3 Collapse/Expand HUD Card const ctrlHeader = document.getElementById('step3-ctrl-header'); const ctrlBody = document.getElementById('step3-ctrl-body'); const ctrlIcon = document.getElementById('step3-ctrl-toggle-icon'); if (ctrlHeader && ctrlBody) { ctrlHeader.addEventListener('click', () => { const isHidden = (ctrlBody.style.display === 'none'); ctrlBody.style.display = isHidden ? 'flex' : 'none'; if (ctrlIcon) ctrlIcon.textContent = isHidden ? '▾' : '▸'; }); } // Step 3 Close Rebar Detail Card Button document.getElementById('btn-close-rebar-detail')?.addEventListener('click', () => { this.hideRebarDetailCard(); this.viewer3d.clearRebarSelection(); }); // 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, 3 are all 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); appContainer.classList.toggle('step-3-mode', stepNum === 3); } // Toggle header mode controls (Step 2 & 3 exclusively show 3D, no 2D toggle in header) const headerModes = document.getElementById('header-mode-controls'); if (headerModes) { headerModes.style.display = (stepNum === 1) ? 'flex' : 'none'; } // Toggle Step 3 Right HUD const hud = document.getElementById('step3-right-hud'); if (hud) { hud.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(0.80); this.renderStep2Sidebar(); // Requirement 2.2.1: 2단계 진입 시 기본 선택 구조물은 ‘암거본체’로 설정 this.selectObject('CULVERT'); } else if (stepNum === 3) { // Step 3 is exclusively 3D rebar model view (세로 2단: 좌측 구조물 선택 + 우측 3D 뷰어 & HUD) this.setViewMode('3d'); 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); this.renderStep3Sidebar(); this.hideRebarDetailCard(); // Requirement 3.2.1: 3단계 진입 시 기본 선택 구조물은 ‘암거본체’로 설정 this.selectStep3Structure('CULVERT', true); } 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; if (this.currentStep === 2 || this.currentStep === 3) { // Requirement 2.1 & 3.1: 2단계 및 3단계에서 불필요한 안내 문구 및 텍스트 완전 삭제 el.innerHTML = ''; return; } else { el.innerHTML = ''; } } 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) { // Requirement 2.1.1: 메인창 상단에 표시되는 불러온 도면명과 ‘[2단계: 일반도 전용 외형 모델]’ 문구 삭제 stepBadgeHtml = ''; targetDrawings = []; } else { // Requirement 3.1.1: '[3단계: 배근도 기반 3D 철근배근]' 문구 삭제 stepBadgeHtml = ''; targetDrawings = []; } ind.innerHTML = stepBadgeHtml; tabsContainer.innerHTML = ''; tabsContainer.style.display = (this.currentStep === 1) ? 'flex' : 'none'; 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.renderStep3Sidebar(); } } // 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() { // Requirement 1.1: 좌측 바에서 '적용 일반도' 및 '평면도 부재 판별' 테이블 항목 삭제 this.renderStep2StructuresList(); // 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'; const cleanName = (ww.id === 'WW_END_LEFT') ? '종점부 좌측 날개벽' : ((ww.id === 'WW_END_RIGHT') ? '종점부 우측 날개벽' : ww.name); items.push({ id: ww.id, name: cleanName, 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 = '10px 12px'; card.style.gap = '5px'; // 1.2: 1.5배 확대 폰트 크기 적용 (.struct-card-step2-title: 17.5px) 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() { // 1.1: 좌측 바에서 '적용배근도/구조도' 테이블 항목 삭제 (Requirement 1.1) this.renderStep3StructuresList(); } renderStep3StructuresList() { const list = document.getElementById('step3-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 || 'STA.13+611.21'; const reb = this.modelData.rebars; const items = []; // 1. 암거 본체 (초기 기본 선택 구조물 - Requirement 3.2.1) if (culv) { items.push({ id: 'CULVERT', name: `암거 본체 (${station})`, tag: `내공 ${culv.dimensions.clear_span.toFixed(1)}×${culv.dimensions.clear_height.toFixed(1)}m`, color: '#94a3b8', info: `전체 보기 (날개벽 선택 시 해당 부재 기본 구도 및 철근 집중)` }); } // 2. 종점부 날개벽 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'; const wwReb = reb?.wingwalls?.[ww.id]; const barCount = wwReb ? wwReb.groups.reduce((acc, g) => acc + g.count, 0) : 0; const weightTon = wwReb ? wwReb.groups.reduce((acc, g) => acc + g.weight_ton, 0).toFixed(3) : '0'; const cleanName = (ww.id === 'WW_END_LEFT') ? '종점부 좌측 날개벽' : ((ww.id === 'WW_END_RIGHT') ? '종점부 우측 날개벽' : ww.name); items.push({ id: ww.id, name: cleanName, tag: `L=${ww.dimensions.length.toFixed(2)}m (${ww.dimensions.flare_angle.toFixed(0)}°)`, color: c, info: `철근 배근 ${barCount}본 (${weightTon} TON) | 체적 ${ww.volume} m³` }); } for (const s of items) { const isSel = (this.selectedStep3Structure === s.id); const card = document.createElement('div'); card.className = `struct-card-step3 ${isSel ? 'selected' : ''}`; card.id = `step3-struct-${s.id}`; // 1.2: 텍스트 크기 1.5배 확대 클래스 (struct-card-step3-title: 17.5px) card.innerHTML = `
● ${s.name}
${s.tag}
${s.info}
`; card.addEventListener('click', () => { // Requirement 3.5.2: 좌측 바에서 날개벽/암거 이름을 클릭했을 때만 모델의 기본 구도(시점) 변경 this.selectStep3Structure(s.id, true); }); list.appendChild(card); } } // Requirement 3.4.6 & 3.5.2: 구조물 선택 및 기본 구도 연동 selectStep3Structure(structId, updateCamera = true) { this.selectedStep3Structure = structId; this.hideRebarDetailCard(); this.viewer3d.clearRebarSelection(); this.viewer3d.focusWingwall(structId, updateCamera); // Update active style on cards document.querySelectorAll('.struct-card-step3').forEach(el => el.classList.remove('selected')); const targetCard = document.getElementById(`step3-struct-${structId}`); if (targetCard) { targetCard.classList.add('selected'); targetCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } // Requirement 3.4: 날개벽에 종속된 철근 선택 selectRebarMark(mark, wwId) { const targetWw = wwId || this.selectedStep3Structure; if (!mark || targetWw === 'CULVERT') { this.hideRebarDetailCard(); this.viewer3d.clearRebarSelection(); return; } this.selectedRebarMark = mark; this.selectedRebarWingwall = targetWw; this.viewer3d.selectRebarMark(mark, targetWw); if (this.currentStep === 3) { this.showRebarDetailCard(mark, targetWw); } } // Requirement 2.1 & 2.2 & 3.4: 선택된 날개벽에 속한 철근만 1.5배 확대 텍스트로 상세 표출 showRebarDetailCard(mark, wingwallId) { const card = document.getElementById('step3-rebar-detail-card'); const titleEl = document.getElementById('step3-detail-title'); const badgeEl = document.getElementById('step3-detail-badge'); const bodyEl = document.getElementById('step3-rebar-detail-body'); if (!card || !bodyEl || !this.modelData || !this.modelData.rebars) return; const targetWwId = wingwallId || this.selectedStep3Structure || 'WW_END_LEFT'; if (targetWwId === 'CULVERT') { this.hideRebarDetailCard(); return; } const wwData = this.modelData.rebars.wingwalls[targetWwId]; if (!wwData) return; const wwName = (targetWwId === 'WW_END_LEFT') ? '종점부 좌측 날개벽' : '종점부 우측 날개벽'; const g = (wwData.groups || []).find(item => item.mark === mark); if (!g) return; const col = g.dia === 'H19' ? '#f43f5e' : (g.dia === 'H16' ? '#38bdf8' : '#10b981'); const totWeight = g.weight_ton.toFixed(4); // 2.2: 철근 번호 텍스트 크기 1.5배 확대 표출 (18px) if (titleEl) { titleEl.innerHTML = ` 철근 [${g.mark}] ${wwName} (L=${Number(wwData.length).toFixed(2)}m) `; } if (badgeEl) { badgeEl.textContent = `${g.dia} (SD400)`; badgeEl.style.fontSize = '12px'; badgeEl.style.background = `${col}25`; badgeEl.style.color = col; badgeEl.style.border = `1px solid ${col}66`; } bodyEl.innerHTML = `
${g.desc}
📐 철근 절곡 형상도 (Bar Bending Detail)
${this.buildRebarShapeSvg(g)}
소속 날개벽${wwName}
배치 부위${g.zone}
배근 간격${g.spacing} (피복 80mm)
1본 길이${g.length_m.toFixed(3)} m
해당부재 본수${g.count} 본
해당부재 총연장${g.total_length_m.toFixed(3)} m
철근 중량 (${g.unit_weight}kg/m) ${totWeight} TON
`; card.style.display = 'flex'; } hideRebarDetailCard() { const card = document.getElementById('step3-rebar-detail-card'); if (card) card.style.display = 'none'; } 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') { // 세로 2단 레이아웃 규약: 우측 플로팅 상세 제원 창으로 표출 this.showRebarDetailCard(mark); } // ==================== 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(); });