feat(ui): 2단계 일반도 기반 3D 외형 모델링 UI 개선 및 80% 뷰포트 스케일링
This commit is contained in:
1 parent
c1f67c621c
commit
fe6b09f9b2
4 files changed
+63
-133
No files matched your search
+4
-16
@@ -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단계: 철근 배근) 이동 버튼 -->
|
||||
|
||||
@@ -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
@@ -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)
|
||||
<span style="color:#fbbf24;">■</span> 우측 날개벽(${Number(rLen).toFixed(2)}m)
|
||||
<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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user