feat(ui): 2단계 일반도 기반 3D 외형 모델링 UI 개선 및 80% 뷰포트 스케일링

This commit is contained in:
Antigravity Agent committed 2026-10-22 14:48:57 +09:00
1 parent c1f67c621c
commit fe6b09f9b2
4 files changed
+63 -133

No files matched your search

+4 -16
View File
@@ -137,25 +137,13 @@
</div>
<div class="step2-content-simple" style="gap:10px;">
<!-- 2.1 적용 일반도 정보 & 체적 한눈에 보기 요약 바 -->
<div id="step2-general-dwg-card"></div>
<!-- 2.2 평면도 암거·날개벽 판별 현황 (심플 미니 매트릭스) -->
<div class="panel-section" style="margin-bottom:0; padding:10px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<h3 style="margin:0; font-size:12px;">평면도 부재 판별</h3>
<span class="badge" id="step2-detection-badge" style="background:rgba(16,185,129,0.18); color:#34d399;">판별 완료</span>
</div>
<div id="step2-detection-card"></div>
</div>
<!-- 2.3 생성된 3D 구조물 목록 -->
<!-- 2.1 3D 구조물 선택 패널 (암거 및 실제 날개벽 2개소 - Requirement 1.1) -->
<div class="panel-section" style="flex:1; display:flex; flex-direction:column; min-height:0; margin-bottom:0; padding:10px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<h3 style="margin:0; font-size:12px;">3D 구조물 목록</h3>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<h3 style="margin:0; font-size:13px; font-weight:700;">3D 구조물 선택</h3>
<span style="font-size:10px; color:var(--text-muted);">부재 클릭 시 강조</span>
</div>
<div id="step2-structures-list" class="step2-structures-scroll" style="gap:6px;"></div>
<div id="step2-structures-list" class="step2-structures-scroll" style="gap:7px;"></div>
</div>
<!-- 2.4 다음 단계(3단계: 철근 배근) 이동 버튼 -->
+5 -2
View File
@@ -617,12 +617,15 @@ header {
gap: 8px;
}
/* 1.2: 2단계 3D 구조물 선택 영역 날개벽 등 텍스트 크기 1.5배 확대 */
.struct-card-step2-title {
font-size: 12px;
font-size: 17.5px !important;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.struct-card-step2-tag {
+20 -72
View File
@@ -453,9 +453,10 @@ class WingwallApp {
await this.recalculateModel();
this.viewer3d.setStepMode(2);
this.viewer3d.fitView();
this.viewer3d.fitView(0.80);
this.renderStep2Sidebar();
this.selectObject(this.selectedObject || 'WW_END_LEFT');
// 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');
@@ -513,29 +514,12 @@ class WingwallApp {
const el = document.getElementById('overlay-3d-legend');
if (!el) return;
const lLen = this.modelData?.objects?.ww_left?.dimensions?.length ?? this.params.left.length;
const rLen = this.modelData?.objects?.ww_right?.dimensions?.length ?? this.params.right.length;
if (this.currentStep === 3) {
// Requirement 3.1.1 & 3.1.2: 3단계에서 불필요한 안내 문구 및 텍스트 완전 삭제
if (this.currentStep === 2 || this.currentStep === 3) {
// Requirement 2.1 & 3.1: 2단계 및 3단계에서 불필요한 안내 문구 및 텍스트 완전 삭제
el.innerHTML = '';
return;
} else {
const genDwgs = this.getGeneralDrawings();
const genName = genDwgs.length > 0 ? genDwgs.map(d => d.fileName).join(', ') : '선택된 일반도 없음';
el.innerHTML = `
<div class="overlay-chip" style="border-color:rgba(56,189,248,0.5);">
<strong style="color:#38bdf8;">[2단계: 일반도 전용 외형 모델]</strong> ${genName}
</div>
<div class="overlay-chip">
<span style="color:#38bdf8;">■</span> 좌측 날개벽(${Number(lLen).toFixed(2)}m) &nbsp;
<span style="color:#fbbf24;">■</span> 우측 날개벽(${Number(rLen).toFixed(2)}m) &nbsp;
<span style="color:#9ca3af;">■</span> 암거 본체 (배근도 제외)
</div>
<div class="overlay-chip" style="font-size:10px; color:#94a3b8;">
좌클릭: 회전 | 우클릭: 이동 | 휠: 줌 | 부재 클릭: 외형 제원 확인 및 3D 강조
</div>
`;
el.innerHTML = '';
}
}
@@ -555,9 +539,9 @@ class WingwallApp {
targetDrawings = this.drawings;
}
} else if (this.currentStep === 2) {
const gen = this.getGeneralDrawings();
stepBadgeHtml = `<span class="dwg-type-badge type-general">2단계: 일반도 전용</span> <span>외형 모델 (${gen.length}종)</span>`;
targetDrawings = gen;
// Requirement 2.1.1: 메인창 상단에 표시되는 불러온 도면명과 ‘[2단계: 일반도 전용 외형 모델]’ 문구 삭제
stepBadgeHtml = '';
targetDrawings = [];
} else {
// Requirement 3.1.1: '[3단계: 배근도 기반 3D 철근배근]' 문구 삭제
stepBadgeHtml = '';
@@ -566,7 +550,7 @@ class WingwallApp {
ind.innerHTML = stepBadgeHtml;
tabsContainer.innerHTML = '';
tabsContainer.style.display = (this.currentStep === 3) ? 'none' : 'flex';
tabsContainer.style.display = (this.currentStep === 1) ? 'flex' : 'none';
for (const d of targetDrawings) {
const btn = document.createElement('button');
@@ -829,48 +813,10 @@ class WingwallApp {
// ==================== STEP 2 RENDERERS (일반도 전용 3D 외형 모델링) ====================
renderStep2Sidebar() {
const genDwgs = this.getGeneralDrawings();
// 1. 적용 일반도 정보 & 체적 요약 카드
const activeGen = genDwgs.find(d => d.id === this.activeDrawingId) || genDwgs[0] || this.drawingData;
const genCard = document.getElementById('step2-general-dwg-card');
if (genCard) {
if (!activeGen) {
genCard.innerHTML = `
<div style="padding:10px; background:rgba(239,68,68,0.1); border:1px solid #ef4444; border-radius:6px; font-size:11px; color:#fca5a5; text-align:center;">
⚠ 불러온 일반도가 없습니다.
<button class="btn btn-sm" style="margin-top:6px; width:100%; justify-content:center;" onclick="window.app.setStep(1)">1단계에서 도면 불러오기</button>
</div>
`;
} else {
const st = this.modelData?.station || this.params?.station || activeGen.titleBlock?.subTitle || '수로암거';
const totVol = this.modelData?.summary?.total_volume || 0;
const wwCount = (this.modelData?.objects?.wingwalls || []).length;
genCard.innerHTML = `
<div style="background:var(--bg-primary); border:1px solid var(--border-color); border-radius:6px; padding:8px 10px; font-size:11px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-weight:700; color:#38bdf8; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:180px;" title="${activeGen.fileName}">
📐 ${activeGen.fileName}
</span>
<span class="badge" style="background:rgba(56,189,248,0.15); color:#38bdf8; font-size:10px;">일반도</span>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; color:var(--text-secondary); font-size:10.5px;">
<span>${st} (날개벽 <strong>${wwCount}개소</strong>)</span>
<span>총 체적: <strong style="color:#38bdf8; font-size:11.5px;">${totVol} m³</strong></span>
</div>
</div>
`;
}
}
// 2. 평면도 암거 및 날개벽 위치 판별 현황 카드 (심플)
this.renderStep2DetectionCard(activeGen);
// 3. 외형 모델링 구조물 목록 (심플)
// Requirement 1.1: 좌측 바에서 '적용 일반도' 및 '평면도 부재 판별' 테이블 항목 삭제
this.renderStep2StructuresList();
// 4. Step 3 이동 버튼 이벤트 연결
// Step 3 이동 버튼 이벤트 연결
document.getElementById('btn-goto-step3')?.addEventListener('click', () => {
this.setStep(3);
});
@@ -1008,9 +954,10 @@ class WingwallApp {
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: ww.name,
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³`
@@ -1029,19 +976,20 @@ class WingwallApp {
const card = document.createElement('div');
card.className = `struct-card-step2 ${isSel ? 'selected' : ''}`;
card.id = `step2-struct-${s.id}`;
card.style.padding = '7px 9px';
card.style.gap = '3px';
card.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" style="font-size:11.5px;">
<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:9.5px; padding:1px 5px;">${s.tag}</span>
<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:10px; color:var(--text-secondary); margin-left:18px;">
<div style="font-size:11px; color:var(--text-secondary); margin-left:20px; line-height:1.4;">
${s.info}
</div>
`;
+34 -43
View File
@@ -336,12 +336,12 @@ class Viewer3D {
this.rebarGroupRoot.visible = isStep3;
}
// Section markers only in Step 2
// Section markers and labels hidden to keep 3D model clean (Requirement 2.2.2 & 2.2.3)
for (const line of this.sectionMarkers) {
line.visible = !isStep3;
line.visible = false;
}
for (const sp of this.labelSprites) {
sp.visible = !isStep3;
sp.visible = false;
}
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
@@ -575,44 +575,17 @@ class Viewer3D {
points.push(points[0].clone());
const geom = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: s.color, linewidth: 3 });
const mat = new THREE.LineBasicMaterial({ color: s.color, linewidth: 2 });
const line = new THREE.Line(geom, mat);
line.visible = false;
this.scene.add(line);
this.sectionMarkers.push(line);
const topPt = points[4];
if (topPt) {
const sprite = this.createTextSprite(s.name, s.color);
sprite.position.set(topPt.x, topPt.y, topPt.z + 0.6);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
// Requirement 2.2.3: 단면 0-0 텍스트 표기 삭제 (스프라이트 미생성)
}
}
addAnnotations(objects) {
const wwL = objects.ww_left;
const wwR = objects.ww_right;
if (!wwL || !wwR) return;
const ptA = wwL.sections_3d?.['A-A']?.[5] || [15.725, 42.650, wwL.dimensions.top_el_start];
const ptB = wwL.sections_3d?.['B-B']?.[5] || [18.75, 45.77, wwL.dimensions.top_el_end];
const ptD = wwR.sections_3d?.['D-D']?.[5] || [21.01, 38.02, wwR.dimensions.top_el_end];
const stName = this.modelData?.station || '종점부';
const elMarkers = [
{ text: `EL. ${wwL.dimensions.top_el_start.toFixed(3)} (암거 접합부)`, pos: ptA, color: 0x38bdf8 },
{ text: `EL. ${wwL.dimensions.top_el_end.toFixed(3)} (좌 말단 L=${wwL.dimensions.length}m)`, pos: ptB, color: 0x38bdf8 },
{ text: `EL. ${wwR.dimensions.top_el_end.toFixed(3)} (우 말단 L=${wwR.dimensions.length}m)`, pos: ptD, color: 0xfbbf24 },
{ text: `${stName} (종점부)`, pos: [16.0, 41.65, wwL.dimensions.top_el_start + 0.5], color: 0xffffff }
];
for (const m of elMarkers) {
const sprite = this.createTextSprite(m.text, m.color);
sprite.position.set(m.pos[0], m.pos[1], m.pos[2] + 0.4);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
// Requirement 2.2.2: 암거본체 선택 시 날개벽 구조물 이름만 표출 (불필요한 EL/스테이션 텍스트 미생성)
}
createTextSprite(text, colorHex) {
@@ -687,18 +660,19 @@ class Viewer3D {
}
updateWingwallNameSpritesVisibility() {
if (this.stepMode !== 3) {
if (this.stepMode !== 2 && this.stepMode !== 3) {
for (const sp of this.wingwallNameSprites) sp.visible = false;
return;
}
const isCulvert = (!this.selectedWingwallFocus || this.selectedWingwallFocus === 'CULVERT');
const currentFocus = (this.stepMode === 3) ? this.selectedWingwallFocus : this.selectedObjectId;
const isCulvert = (!currentFocus || currentFocus === 'CULVERT');
for (const sp of this.wingwallNameSprites) {
if (isCulvert) {
// 암거본체 선택 시 날개벽 구조물의 이름만 표시 (Requirement 3.2.2)
// 암거본체 선택 시 날개벽 구조물의 이름만 표시 (Requirement 2.2.2 & 3.2.2)
sp.visible = true;
} else {
// 특정 날개벽 선택 시 해당 날개벽 이름만 표시
sp.visible = (sp.userData.wingwallId === this.selectedWingwallFocus);
sp.visible = (sp.userData.wingwallId === currentFocus);
}
}
}
@@ -717,6 +691,7 @@ class Viewer3D {
mat.emissiveIntensity = 0;
}
}
this.updateWingwallNameSpritesVisibility();
}
toggleObjectVisibility(objectId, visible) {
@@ -748,18 +723,34 @@ class Viewer3D {
}
}
fitView() {
// Requirement 2.4: 3D 모델이 메인 화면 표시 영역의 약 80%를 차지하도록 초기 표시 크기(카메라 거리) 조정
fitView(coverage = 0.80) {
const box = new THREE.Box3();
for (const id in this.meshes) {
box.expandByObject(this.meshes[id].group);
if (this.meshes[id] && this.meshes[id].group) {
box.expandByObject(this.meshes[id].group);
}
}
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const sphere = box.getBoundingSphere(new THREE.Sphere());
const radius = Math.max(sphere.radius, 1.0);
const fov = this.camera.fov * (Math.PI / 180);
const aspect = this.camera.aspect || ((this.container && this.container.clientHeight) ? (this.container.clientWidth / this.container.clientHeight) : 1.6);
const halfFovV = fov / 2;
const halfFovH = Math.atan(Math.tan(halfFovV) * aspect);
const limitingHalfFov = Math.min(halfFovV, halfFovH);
// 거리 산출: 바운딩 구가 뷰포트의 제한 치수 대비 약 80%를 차지하도록 배치
const targetDist = (radius / coverage) / Math.sin(limitingHalfFov);
const dir = new THREE.Vector3(1.0, -1.0, 0.78).normalize();
const camPos = center.clone().add(dir.multiplyScalar(targetDist));
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.35, center.y - maxDim * 1.35, center.z + maxDim * 1.05);
this.camera.position.copy(camPos);
this.camera.lookAt(center);
this.controls.update();
}