Files
wingwall_3D_drawing/static/js/app.js
T

1619 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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' || 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();
});