1625 lines
64 KiB
JavaScript
1625 lines
64 KiB
JavaScript
// 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 = `<span class="badge" style="background:rgba(100,116,139,0.2); color:#94a3b8; border-color:#475569;">1단계</span> <span style="color:var(--text-muted);">도면 없음</span>`;
|
||
} else {
|
||
stepBadgeHtml = `<span class="dwg-type-badge type-general">1단계</span> <span>불러온 도면 (${this.drawings.length}종)</span>`;
|
||
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 = `<span style="color:${dotColor};">●</span> ${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: <strong style="color:#f8fafc;">${this.drawingData.fileName}</strong> <span class="dwg-type-badge ${c.selectedType === 'GENERAL' ? 'type-general' : (c.selectedType === 'REBAR' ? 'type-rebar' : 'type-unclassified')}" style="margin-left:6px;">${c.selectedLabel}</span>`;
|
||
} 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 = `
|
||
<div class="empty-state-card">
|
||
<div style="font-size:24px; margin-bottom:6px; opacity:0.6;">📁</div>
|
||
<div>불러온 도면이 없습니다.</div>
|
||
<div style="font-size:10.5px; margin-top:3px; color:var(--text-muted);">
|
||
상단의 <strong>도면 파일 선택</strong> 또는 파일을 드래그하여 추가하세요.
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="dwg-card-simple-row1">
|
||
<div class="dwg-card-simple-title" title="${d.fileName}">${d.fileName}</div>
|
||
<button class="dwg-card-btn-del" title="도면 제거" data-del-id="${d.id}">✕</button>
|
||
</div>
|
||
<div class="dwg-card-simple-row2">
|
||
<span class="dwg-card-simple-badge ${badgeClass}">${badgeText}</span>
|
||
<div class="dwg-card-type-toggle">
|
||
<button class="type-toggle-btn ${isGen ? 'active-gen' : ''}" data-set-type="GENERAL">일반도</button>
|
||
<button class="type-toggle-btn ${isReb ? 'active-reb' : ''}" data-set-type="REBAR">배근도/구조도</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<button class="btn btn-primary" style="width:100%; justify-content:center; padding:9px 12px; font-size:12px; font-weight:700;" id="btn-goto-step2">
|
||
2단계: 일반도 외형 모델링 시작 ➔
|
||
</button>
|
||
`;
|
||
document.getElementById('btn-goto-step2')?.addEventListener('click', () => {
|
||
this.setStep(2);
|
||
});
|
||
} else {
|
||
actionArea.innerHTML = `
|
||
<div style="font-size:11px; color:#f59e0b; text-align:center; padding:6px; background:rgba(245,158,11,0.1); border-radius:6px; border:1px solid rgba(245,158,11,0.25);">
|
||
⚠ 2단계 외형 모델링을 진행하려면 최소 1개 이상의 도면을 <strong>'일반도'</strong>로 지정해주세요.
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<input type="checkbox" ${checked ? 'checked' : ''} style="cursor:pointer">
|
||
<span class="layer-color-dot" style="background:${color}"></span>
|
||
<span class="layer-name" title="${lname}">${lname}</span>
|
||
`;
|
||
|
||
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 `
|
||
<div style="display:flex; justify-content:space-between; align-items:center; padding:4px 7px; background:${bg}; border:1px solid ${border}; border-radius:4px; font-size:10.5px;">
|
||
<span style="color:${color}; font-weight:${exists ? '600' : '400'};">${label} (${posLabel})</span>
|
||
<span style="color:${color}; font-size:10px;">${exists ? `● ${spec}` : '○ 제외'}</span>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
card.innerHTML = `
|
||
<div style="display:flex; flex-direction:column; gap:5px;">
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:4px;">
|
||
${renderPill('START_LEFT', '시점 좌측', '상')}
|
||
${renderPill('END_LEFT', '종점 좌측', '상')}
|
||
${renderPill('START_RIGHT', '시점 우측', '하')}
|
||
${renderPill('END_RIGHT', '종점 우측', '하')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 날개벽 구성 시뮬레이션 적용 핸들러
|
||
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 = `
|
||
<div class="struct-card-step2-row1">
|
||
<div class="struct-card-step2-title">
|
||
<input type="checkbox" checked id="vis-chk-${s.id}" title="3D 표시/숨김" style="cursor:pointer; margin:0;">
|
||
<span style="color:${s.color};">■</span>
|
||
<span>${s.name}</span>
|
||
</div>
|
||
<span class="struct-card-step2-tag" style="border:1px solid ${s.color}40; color:${s.color}; font-size:10px; padding:2px 6px;">${s.tag}</span>
|
||
</div>
|
||
<div style="font-size:11px; color:var(--text-secondary); margin-left:20px; line-height:1.4;">
|
||
${s.info}
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="meta-item"><span class="label">암거 본체 체적</span><span class="value">${summary.culvert_volume || 0} m³</span></div>
|
||
<div class="meta-item"><span class="label">날개벽 합계 (${wwCount}개소)</span><span class="value">${wwVol.toFixed(2)} m³</span></div>
|
||
<div class="meta-item" style="border-color:#38bdf8; grid-column:span 2;"><span class="label">총 콘크리트 체적 (암거+날개벽)</span><span class="value" style="color:#38bdf8; font-size:14px;">${summary.total_volume || 0} m³</span></div>
|
||
`;
|
||
}
|
||
|
||
const badge = document.getElementById('step2-validation-badge');
|
||
if (badge) {
|
||
const cfgLbl = summary.configuration_label || '종점부 날개벽만 존재 (2개소)';
|
||
badge.innerHTML = `
|
||
<div style="padding:8px 10px; background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.25); border-radius:6px; font-size:11px; color:#34d399; display:flex; flex-direction:column; gap:4px;">
|
||
<div>✓ <strong>위치 판별 일치:</strong> ${cfgLbl} (평면도 중앙 사다리꼴·상하 점선 검출)</div>
|
||
<div>✓ <strong>임의생성 배제:</strong> 도면에 없는 날개벽 생성 차단 및 도면 표기 날개벽 누락 없음</div>
|
||
<div>✓ <strong>3D 솔리드 폐합:</strong> 완전 폐합 2-manifold closed mesh (Non-manifold / Boundary: 0)</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<div class="struct-card-step2-row1" style="align-items:center;">
|
||
<div class="struct-card-step3-title">
|
||
<span style="color:${s.color}; font-size:16px;">●</span>
|
||
<span>${s.name}</span>
|
||
</div>
|
||
<span class="struct-card-step3-tag" style="border:1px solid ${s.color}40; color:${s.color};">${s.tag}</span>
|
||
</div>
|
||
<div class="struct-card-step3-info">
|
||
${s.info}
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<span style="font-size:18px; font-weight:800; color:#facc15;">철근 [${g.mark}]</span>
|
||
<span style="font-size:12px; font-weight:600; color:var(--text-secondary); margin-left:6px;">${wwName} (L=${Number(wwData.length).toFixed(2)}m)</span>
|
||
`;
|
||
}
|
||
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 = `
|
||
<div style="font-size:11.5px; color:var(--text-secondary); margin-bottom:6px; line-height:1.4;">
|
||
${g.desc}
|
||
</div>
|
||
|
||
<div class="rebar-shape-box" style="margin:6px 0; padding:8px; background:rgba(0,0,0,0.3); border-radius:6px;">
|
||
<div style="font-size:10px; color:var(--text-muted); margin-bottom:4px;">
|
||
📐 철근 절곡 형상도 (Bar Bending Detail)
|
||
</div>
|
||
${this.buildRebarShapeSvg(g)}
|
||
</div>
|
||
|
||
<div class="meta-grid" style="font-size:11px; display:grid; grid-template-columns:1fr 1fr; gap:5px; margin-top:6px;">
|
||
<div class="meta-item"><span class="label">소속 날개벽</span><span class="value" style="color:#38bdf8; font-weight:700;">${wwName}</span></div>
|
||
<div class="meta-item"><span class="label">배치 부위</span><span class="value">${g.zone}</span></div>
|
||
<div class="meta-item"><span class="label">배근 간격</span><span class="value">${g.spacing} (피복 80mm)</span></div>
|
||
<div class="meta-item"><span class="label">1본 길이</span><span class="value">${g.length_m.toFixed(3)} m</span></div>
|
||
<div class="meta-item"><span class="label">해당부재 본수</span><span class="value" style="font-weight:700;">${g.count} 본</span></div>
|
||
<div class="meta-item"><span class="label">해당부재 총연장</span><span class="value">${g.total_length_m.toFixed(3)} m</span></div>
|
||
<div class="meta-item" style="border-color:${col}66; grid-column:span 2;">
|
||
<span class="label">철근 중량 (${g.unit_weight}kg/m)</span>
|
||
<span class="value" style="color:${col}; font-weight:800; font-size:13px;">${totWeight} TON</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<polyline points="45,95 140,95 215,40" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="90" y="112" fill="#e2e8f0" font-size="11" text-anchor="middle">a = ${p.a}mm (암거정착)</text>
|
||
<text x="195" y="82" fill="#e2e8f0" font-size="11" text-anchor="middle">b = ${p.b}mm (날개벽)</text>
|
||
<text x="138" y="78" fill="#facc15" font-size="11" font-weight="bold">x1 = ${p.x1}</text>
|
||
`;
|
||
} else if (st === 'U_HOOK') {
|
||
svgBody = `
|
||
<polyline points="45,95 45,45 235,45 235,95" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="140" y="34" fill="#e2e8f0" font-size="11" text-anchor="middle">a = ${p.a} m (저판 폭 가변)</text>
|
||
<text x="28" y="75" fill="#94a3b8" font-size="10" text-anchor="middle">b=${p.b}</text>
|
||
<text x="252" y="75" fill="#94a3b8" font-size="10" text-anchor="middle">c=${p.c}</text>
|
||
`;
|
||
} else if (st === 'U_HOOK_UP') {
|
||
svgBody = `
|
||
<polyline points="45,45 45,90 235,90 235,45" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="140" y="108" fill="#e2e8f0" font-size="11" text-anchor="middle">a = ${p.a} m (저판 하면 폭 가변)</text>
|
||
<text x="28" y="68" fill="#94a3b8" font-size="10" text-anchor="middle">b=${p.b}</text>
|
||
<text x="252" y="68" fill="#94a3b8" font-size="10" text-anchor="middle">c=${p.c}</text>
|
||
`;
|
||
} else if (st === 'L_BENT') {
|
||
svgBody = `
|
||
<polyline points="50,45 230,45 230,95" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="140" y="34" fill="#e2e8f0" font-size="11" text-anchor="middle">a = ${p.a} m (2Cycle 보강)</text>
|
||
<text x="250" y="75" fill="#94a3b8" font-size="10" text-anchor="middle">b=${p.b}</text>
|
||
`;
|
||
} else if (st === 'STEM_VERTICAL_FRONT') {
|
||
svgBody = `
|
||
<polyline points="85,105 155,105 155,20" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="120" y="120" fill="#94a3b8" font-size="10" text-anchor="middle">저판하면 ${p.b || '200'}mm</text>
|
||
<text x="208" y="65" fill="#e2e8f0" font-size="11" text-anchor="middle">벽체부 ${p.a} m</text>
|
||
`;
|
||
} else if (st === 'STEM_VERTICAL_BACK') {
|
||
svgBody = `
|
||
<polyline points="85,105 155,105 162,20" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="120" y="120" fill="#94a3b8" font-size="10" text-anchor="middle">기초정착 ${p.c || p.a}mm</text>
|
||
<text x="210" y="65" fill="#e2e8f0" font-size="11" text-anchor="middle">수직부 ${p.b && p.b.includes('~') ? p.b : p.a} m</text>
|
||
`;
|
||
} else if (st === 'HAUNCH_BENT') {
|
||
svgBody = `
|
||
<polyline points="65,100 135,100 195,35 225,35" fill="none" stroke="${col}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="100" y="115" fill="#94a3b8" font-size="10" text-anchor="middle">a=${p.a}</text>
|
||
<text x="185" y="78" fill="#e2e8f0" font-size="11" text-anchor="middle">b=${p.b}</text>
|
||
<text x="215" y="25" fill="#94a3b8" font-size="10" text-anchor="middle">c=${p.c}</text>
|
||
`;
|
||
} else if (st === 'STIRRUP') {
|
||
svgBody = `
|
||
<polyline points="85,35 65,35 65,90 215,90 215,35 195,35" fill="none" stroke="${col}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<text x="140" y="108" fill="#e2e8f0" font-size="11" text-anchor="middle">a = ${p.a} mm</text>
|
||
<text x="140" y="55" fill="#94a3b8" font-size="10" text-anchor="middle">후크 b = ${p.b} mm</text>
|
||
`;
|
||
} else {
|
||
svgBody = `
|
||
<line x1="35" y1="68" x2="245" y2="68" stroke="${col}" stroke-width="5" stroke-linecap="round"/>
|
||
<text x="140" y="52" fill="#e2e8f0" font-size="11" text-anchor="middle">직선 배력근 a = ${p.a} m</text>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<svg width="280" height="128" viewBox="0 0 280 128" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="2" y="2" width="276" height="124" rx="6" fill="#0d1017" stroke="#222736"/>
|
||
${svgBody}
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
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 += `
|
||
<tr>
|
||
<td style="font-weight:700;">${g.mark}</td>
|
||
<td>${g.dia}</td>
|
||
<td style="text-align:left;">${g.role} (${g.zone})</td>
|
||
<td style="text-align:left; font-size:10.5px; color:#94a3b8;">${shapeStr}</td>
|
||
<td>${g.length_m.toFixed(3)}</td>
|
||
<td>${g.count}</td>
|
||
<td>${g.total_length_m.toFixed(3)}</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
const sub = subs[diaKey];
|
||
if (sub) {
|
||
rowsHtml += `
|
||
<tr class="subtotal-row">
|
||
<td colspan="6">${diaKey} 소 계</td>
|
||
<td>${sub.total_length_m.toFixed(3)}</td>
|
||
<td>${sub.unit_weight.toFixed(3)}</td>
|
||
<td>${sub.weight_ton.toFixed(3)}</td>
|
||
<td>${sub.surcharge_ton.toFixed(3)} (3%)</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
};
|
||
|
||
renderDiaSection('H19');
|
||
renderDiaSection('H16');
|
||
renderDiaSection('H13');
|
||
|
||
const s = ww.summary;
|
||
rowsHtml += `
|
||
<tr class="total-row">
|
||
<td colspan="5">${ww.wingwall_name} 총 계</td>
|
||
<td>${s.total_bars} 본</td>
|
||
<td>${s.total_length_m.toFixed(3)} m</td>
|
||
<td>-</td>
|
||
<td>${s.total_weight_ton.toFixed(3)} TON</td>
|
||
<td>${s.total_surcharge_ton.toFixed(3)} TON</td>
|
||
</tr>
|
||
`;
|
||
|
||
return `
|
||
<div style="margin-bottom:18px;">
|
||
<h3 style="font-size:13px; margin-bottom:6px; color:#38bdf8;">
|
||
■ ${ww.wingwall_name} (연장 L=${ww.length}m, 각도 ${ww.flare_angle}°) — 출처: ${ww.source_drawings.join(', ')}
|
||
</h3>
|
||
<table class="bom-table">
|
||
<thead>
|
||
<tr>
|
||
<th>기호</th>
|
||
<th>직경</th>
|
||
<th>배근 부위 및 역할</th>
|
||
<th>절곡 치수 상세</th>
|
||
<th>길이 (m)</th>
|
||
<th>개수</th>
|
||
<th>총길이 (m)</th>
|
||
<th>단위무게 (kg/m)</th>
|
||
<th>총무게 (TON)</th>
|
||
<th>할증 (3%, TON)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rowsHtml}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
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();
|
||
});
|