feat(ui): 3단계 배근도 기반 3D 철근배근 UI/UX 및 날개벽별 철근 격리 개선

This commit is contained in:
Antigravity Agent committed 2026-10-22 11:24:27 +09:00
1 parent 573c0bca11
commit c1f67c621c
4 files changed
+388 -269

No files matched your search

+76 -77
View File
@@ -174,16 +174,74 @@
</div>
<div class="step2-content-simple" style="gap:10px;">
<!-- 3.1 적용 배근도/구조도 요약 정보 바 -->
<div id="step3-rebar-summary-card"></div>
<!-- 3.2 3D 구조물 선택 패널 (암거 및 도면상 실제 날개벽 2개소) -->
<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>
<span style="font-size:10px; color:var(--text-muted);">부재 클릭 시 해당 부재 및 철근 집중</span>
<!-- 3.1 3D 구조물 선택 패널 (암거 및 실제 날개벽 2개소) -->
<div class="panel-section" style="margin-bottom:0; padding:10px;">
<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="step3-structures-list" class="step2-structures-scroll" style="gap:7px;"></div>
</div>
<!-- 3.2 3D 철근 투시 및 표현 제어 (좌측 바로 이동 배치) -->
<div class="panel-section" id="step3-ctrl-card" style="margin-bottom:0; padding:10px;">
<div class="card-header" style="padding:0 0 8px 0; background:transparent; border-bottom:1px solid var(--border-color); cursor:pointer;" id="step3-ctrl-header">
<span style="font-weight:700; font-size:12px; display:flex; align-items:center; gap:5px; color:var(--text-primary);">
<span>⚙️</span> 3D 철근 투시 및 표현 제어
</span>
<span id="step3-ctrl-toggle-icon" style="font-size:11px; color:var(--text-muted);">▾</span>
</div>
<div class="card-body" id="step3-ctrl-body" style="padding:8px 0 0 0; display:flex; flex-direction:column; gap:9px;">
<!-- 투시 및 굵기 제어 -->
<div class="ctrl-subgroup">
<div class="ctrl-row">
<span class="ctrl-label">콘크리트 반투명(X-Ray)</span>
<input type="range" id="step3-slider-opacity" min="0.05" max="0.9" step="0.05" value="0.30" class="mini-slider">
<span id="step3-opacity-val" class="ctrl-val">30%</span>
</div>
<div class="ctrl-row">
<span class="ctrl-label">철근 굵기 강조</span>
<input type="range" id="step3-slider-scale" min="1.0" max="3.0" step="0.2" value="1.6" class="mini-slider">
<span id="step3-scale-val" class="ctrl-val">1.6x</span>
</div>
</div>
<!-- 직경별 필터 -->
<div class="ctrl-subgroup">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:2px;">직경별 표시 제어</div>
<div class="dia-pill-group" id="step3-dia-pill-group">
<button class="dia-pill active" data-dia="ALL">전체</button>
<button class="dia-pill active" data-dia="H19" style="--c:#f43f5e;"><span class="dot"></span>H19</button>
<button class="dia-pill active" data-dia="H16" style="--c:#38bdf8;"><span class="dot"></span>H16</button>
<button class="dia-pill active" data-dia="H13" style="--c:#10b981;"><span class="dot"></span>H13</button>
</div>
</div>
<!-- 부위별 필터 -->
<div class="ctrl-subgroup">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:2px;">부위별 표시 제어</div>
<div class="zone-pill-group" id="step3-zone-pill-group">
<button class="zone-pill active" data-zone="우각부">우각부</button>
<button class="zone-pill active" data-zone="벽체">벽체</button>
<button class="zone-pill active" data-zone="저판">저판</button>
<button class="zone-pill active" data-zone="헌치">헌치</button>
</div>
</div>
<!-- 재료표 및 내보내기 -->
<div class="ctrl-subgroup export-subgroup" style="margin-top:2px; border-top:1px solid var(--border-color); padding-top:8px;">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:5px;">철근 재료표 및 내보내기</div>
<button class="btn btn-primary btn-sm" id="btn-step3-open-bom" style="width:100%; justify-content:center; margin-bottom:6px; font-size:11.5px; padding:6px 10px;">
📊 전체 철근 재료표 (BOM) 보기
</button>
<div class="export-btn-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:5px;">
<button class="btn btn-sm" onclick="window.app.exportModel('csv')" style="font-size:10.5px; padding:5px;">📥 재료표 CSV</button>
<button class="btn btn-sm" onclick="window.app.exportModel('obj')" style="font-size:10.5px; padding:5px;">📦 OBJ 솔리드</button>
<button class="btn btn-sm" onclick="window.app.exportModel('stl')" style="font-size:10.5px; padding:5px;">🖨 3D STL</button>
<button class="btn btn-sm" onclick="window.app.exportModel('dxf')" style="font-size:10.5px; padding:5px;">📐 3D DXF</button>
</div>
</div>
</div>
<div id="step3-structures-list" class="step2-structures-scroll" style="gap:6px;"></div>
</div>
<!-- 3.3 하단 2단계 이동 버튼 -->
@@ -211,82 +269,23 @@
<div class="view-pane" id="viewport-3d" style="display:none;">
<div class="viewport-overlay" id="overlay-3d-legend"></div>
<!-- Step 3 Right HUD: Rebar Controls & Floating Detail Card -->
<!-- Step 3 Right HUD: Rebar Detail Floating Card Only (철근 선택 시에만 표출) -->
<div class="step3-right-hud" id="step3-right-hud" style="display:none;">
<!-- 3.2 철근 기호 상세 제원 창 (철근 선택 시에만 표시되는 플로팅 창) -->
<div class="step3-rebar-detail-card" id="step3-rebar-detail-card" style="display:none;">
<div class="card-header">
<div style="display:flex; align-items:center; gap:6px;">
<span id="step3-detail-title" style="font-weight:700; font-size:12px;">철근 상세 제원</span>
<span id="step3-detail-badge" class="badge" style="font-size:10px;">H16</span>
<!-- 2.1 & 2.2 철근 기호 상세 제원 창 (철근 선택 시에만 1.5배 확대 텍스트로 표출) -->
<div class="step3-rebar-detail-card" id="step3-rebar-detail-card" style="display:none; width:340px;">
<div class="card-header" style="padding:10px 14px;">
<div style="display:flex; align-items:center; gap:8px;">
<span id="step3-detail-title" style="font-weight:800; font-size:18px;">철근 상세 제원</span>
<span id="step3-detail-badge" class="badge" style="font-size:13px;">H16</span>
</div>
<button class="btn-close-sm" id="btn-close-rebar-detail" title="닫기">✕</button>
<button class="btn-close-sm" id="btn-close-rebar-detail" title="닫기" style="font-size:14px; padding:3px 8px;">✕</button>
</div>
<div class="card-body" id="step3-rebar-detail-body">
<div class="card-body" id="step3-rebar-detail-body" style="padding:12px 14px;">
<!-- SVG shape, dimensions, count, weight rendered dynamically -->
</div>
</div>
<!-- 3.3 & 3.4 3D 철근 투시·표현 제어 및 재료표·내보내기 우측 패널 -->
<div class="step3-ctrl-card" id="step3-ctrl-card">
<div class="card-header" style="cursor:pointer;" id="step3-ctrl-header">
<span style="font-weight:700; font-size:11.5px; display:flex; align-items:center; gap:5px;">
<span>⚙️</span> 3D 철근 투시 및 표현 제어
</span>
<span id="step3-ctrl-toggle-icon" style="font-size:11px; color:var(--text-muted);">▾</span>
</div>
<div class="card-body" id="step3-ctrl-body">
<!-- 3.3 투시 및 표현 제어 -->
<div class="ctrl-subgroup">
<div class="ctrl-row">
<span class="ctrl-label">콘크리트 반투명(X-Ray)</span>
<input type="range" id="step3-slider-opacity" min="0.05" max="0.9" step="0.05" value="0.30" class="mini-slider">
<span id="step3-opacity-val" class="ctrl-val">30%</span>
</div>
<div class="ctrl-row">
<span class="ctrl-label">철근 굵기 강조</span>
<input type="range" id="step3-slider-scale" min="1.0" max="3.0" step="0.2" value="1.6" class="mini-slider">
<span id="step3-scale-val" class="ctrl-val">1.6x</span>
</div>
</div>
<div class="ctrl-subgroup">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:2px;">직경별 표시 제어</div>
<div class="dia-pill-group" id="step3-dia-pill-group">
<button class="dia-pill active" data-dia="ALL">전체</button>
<button class="dia-pill active" data-dia="H19" style="--c:#f43f5e;"><span class="dot"></span>H19</button>
<button class="dia-pill active" data-dia="H16" style="--c:#38bdf8;"><span class="dot"></span>H16</button>
<button class="dia-pill active" data-dia="H13" style="--c:#10b981;"><span class="dot"></span>H13</button>
</div>
</div>
<div class="ctrl-subgroup">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:2px;">부위별 표시 제어</div>
<div class="zone-pill-group" id="step3-zone-pill-group">
<button class="zone-pill active" data-zone="우각부">우각부</button>
<button class="zone-pill active" data-zone="벽체">벽체</button>
<button class="zone-pill active" data-zone="저판">저판</button>
<button class="zone-pill active" data-zone="헌치">헌치</button>
</div>
</div>
<!-- 3.4 철근 재료표 및 3D 배근 내보내기 -->
<div class="ctrl-subgroup export-subgroup" style="margin-top:4px; border-top:1px solid var(--border-color); padding-top:8px;">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:5px;">철근 재료표 및 내보내기</div>
<button class="btn btn-primary btn-sm" id="btn-step3-open-bom" style="width:100%; justify-content:center; margin-bottom:6px; font-size:11.5px; padding:6px 10px;">
📊 전체 철근 재료표 (BOM) 보기
</button>
<div class="export-btn-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:5px;">
<button class="btn btn-sm" onclick="window.app.exportModel('csv')" style="font-size:10.5px; padding:5px;">📥 재료표 CSV</button>
<button class="btn btn-sm" onclick="window.app.exportModel('obj')" style="font-size:10.5px; padding:5px;">📦 OBJ 솔리드</button>
<button class="btn btn-sm" onclick="window.app.exportModel('stl')" style="font-size:10.5px; padding:5px;">🖨 3D STL</button>
<button class="btn btn-sm" onclick="window.app.exportModel('dxf')" style="font-size:10.5px; padding:5px;">📐 3D DXF</button>
</div>
</div>
</div>
</div>
</div>
<div class="viewport-controls-bottom">
+41 -2
View File
@@ -497,10 +497,10 @@ header {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 5px;
cursor: pointer;
transition: all 0.15s ease;
}
@@ -516,6 +516,45 @@ header {
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5);
}
/* 1.2: 3D 구조물 선택 영역 날개벽 등 텍스트 크기 1.5배 확대 */
.struct-card-step3-title {
font-size: 17.5px !important;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.struct-card-step3-tag {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
}
.struct-card-step3-info {
font-size: 11.5px;
color: var(--text-secondary);
margin-left: 20px;
line-height: 1.4;
}
/* 2.2: 철근 선택 시 표시되는 철근 번호 텍스트 크기 1.5배 확대 */
#step3-detail-title {
font-size: 18px !important;
font-weight: 800 !important;
color: var(--text-primary);
letter-spacing: -0.3px;
}
#step3-detail-badge {
font-size: 13.5px !important;
font-weight: 700 !important;
padding: 3px 8px !important;
}
/* Step 2 Clean Components */
.step2-header-simple {
padding: 12px 16px;
+74 -89
View File
@@ -42,7 +42,7 @@ class WingwallApp {
);
this.viewer3d.onSelectRebar = (mark, wwId) => this.selectRebarMark(mark, wwId);
this.viewer3d.onDeselectRebar = () => this.hideRebarDetailCard();
this.viewer3d.onSelectWingwall = (objId) => this.selectStep3Structure(objId);
this.viewer3d.onSelectWingwall = (objId) => this.selectStep3Structure(objId, false);
this.viewer2d = new CAD2DViewer('cad-canvas', (id) => {
if (this.currentStep === 2) {
@@ -472,7 +472,8 @@ class WingwallApp {
this.viewer3d.setStepMode(3);
this.renderStep3Sidebar();
this.hideRebarDetailCard();
this.selectStep3Structure(this.selectedStep3Structure || 'WW_END_LEFT');
// Requirement 3.2.1: 3단계 진입 시 기본 선택 구조물은 ‘암거본체’로 설정
this.selectStep3Structure('CULVERT', true);
}
this.renderViewportTopBar();
@@ -516,22 +517,9 @@ class WingwallApp {
const rLen = this.modelData?.objects?.ww_right?.dimensions?.length ?? this.params.right.length;
if (this.currentStep === 3) {
const rebCount = this.getRebarDrawings().length;
const totBars = this.modelData?.rebars?.summary?.total_bars ?? 0;
el.innerHTML = `
<div class="overlay-chip" style="border-color:rgba(16,185,129,0.5);">
<strong style="color:#34d399;">[3단계: 배근도 기반 3D 철근 배근]</strong> 적용 배근도: ${rebCount}개 도면 (총 ${totBars}본)
</div>
<div class="overlay-chip">
<span style="color:#f43f5e;">●</span> H19 우각부보강(C1·C2) &nbsp;
<span style="color:#38bdf8;">●</span> H16 주철근(W1·W5·W6·F1·F2·F6) &nbsp;
<span style="color:#10b981;">●</span> H13 배력·전단근 &nbsp;
<span style="color:#facc15;">●</span> 선택 철근
</div>
<div class="overlay-chip" style="font-size:10px; color:#94a3b8;">
좌클릭: 회전 | 우클릭: 이동 | 휠: 줌 | 3D 철근 클릭: 해당 기호 하이라이트 및 상세보기
</div>
`;
// Requirement 3.1.1 & 3.1.2: 3단계에서 불필요한 안내 문구 및 텍스트 완전 삭제
el.innerHTML = '';
return;
} else {
const genDwgs = this.getGeneralDrawings();
const genName = genDwgs.length > 0 ? genDwgs.map(d => d.fileName).join(', ') : '선택된 일반도 없음';
@@ -571,8 +559,9 @@ class WingwallApp {
stepBadgeHtml = `<span class="dwg-type-badge type-general">2단계: 일반도 전용</span> <span>외형 모델 (${gen.length}종)</span>`;
targetDrawings = gen;
} else {
stepBadgeHtml = `<span class="dwg-type-badge type-rebar">3단계: 배근도</span> <span>3D 철근 배근 모델링</span>`;
targetDrawings = []; // 도면 탭 미표시 (지침 4 준수: 배근도 확인 탭 삭제)
// Requirement 3.1.1: '[3단계: 배근도 기반 3D 철근배근]' 문구 삭제
stepBadgeHtml = '';
targetDrawings = [];
}
ind.innerHTML = stepBadgeHtml;
@@ -1113,37 +1102,10 @@ class WingwallApp {
// ==================== STEP 3 RENDERERS (배근도 기반 3D 철근 배근) ====================
renderStep3Sidebar() {
this.renderStep3SummaryCard();
// 1.1: 좌측 바에서 '적용배근도/구조도' 테이블 항목 삭제 (Requirement 1.1)
this.renderStep3StructuresList();
}
renderStep3SummaryCard() {
const card = document.getElementById('step3-rebar-summary-card');
if (!card) return;
const summary = this.modelData?.rebars?.summary;
const totBars = summary?.total_bars ?? 508;
const totTon = summary?.total_weight_ton ?? '1.543';
card.innerHTML = `
<div class="panel-section" style="margin-bottom:0; padding:10px 12px; border-left:3px solid var(--accent-color);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-size:11px; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:5px;">
<span>🔩</span> 적용 배근도/구조도 (4종)
</span>
<span class="badge" style="background:rgba(16,185,129,0.18); color:#34d399; font-size:10px;">배근 완료</span>
</div>
<div style="font-size:10.5px; color:var(--text-secondary); line-height:1.4;">
종점 우측(01~02) 및 종점 좌측(03~04) 구조도 기반 날개벽 내부 3D 철근 배근
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:8px; padding-top:6px; border-top:1px solid rgba(255,255,255,0.06); font-size:10.5px;">
<div><span style="color:var(--text-muted);">총 배근 본수:</span> <strong style="color:var(--text-primary);">${totBars} 본</strong></div>
<div><span style="color:var(--text-muted);">총 철근 중량:</span> <strong style="color:#38bdf8;">${totTon} TON</strong></div>
</div>
</div>
`;
}
renderStep3StructuresList() {
const list = document.getElementById('step3-structures-list');
if (!list || !this.modelData || !this.modelData.objects) return;
@@ -1155,18 +1117,18 @@ class WingwallApp {
const items = [];
// 1. 암거 본체
// 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: `사각 ${culv.dimensions.skew_angle.toFixed(1)}° | 콘크리트 ${culv.volume} m³ | 전체 보기`
info: `전체 보기 (날개벽 선택 시 해당 부재 기본 구도 및 철근 집중)`
});
}
// 2. 종점부 날개벽 (도면에 실제 존재하는 날개벽만 표기)
// 2. 종점부 날개벽 2개소 (도면에 실제 존재하는 부재만 표기)
const wwList = this.modelData.objects.wingwalls || [];
const colorMap = {
'WW_END_LEFT': '#38bdf8',
@@ -1180,13 +1142,14 @@ class WingwallApp {
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: ww.name,
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³`
info: `철근 배근 ${barCount}본 (${weightTon} TON) | 체적 ${ww.volume} m³`
});
}
@@ -1196,30 +1159,35 @@ class WingwallApp {
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">
<div class="struct-card-step2-title" style="font-size:11.5px;">
<span style="color:${s.color};">●</span>
<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-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-step3-tag" style="border:1px solid ${s.color}40; color:${s.color};">${s.tag}</span>
</div>
<div style="font-size:10px; color:var(--text-secondary); margin-left:14px;">
<div class="struct-card-step3-info">
${s.info}
</div>
`;
card.addEventListener('click', () => {
this.selectStep3Structure(s.id);
// Requirement 3.5.2: 좌측 바에서 날개벽/암거 이름을 클릭했을 때만 모델의 기본 구도(시점) 변경
this.selectStep3Structure(s.id, true);
});
list.appendChild(card);
}
}
selectStep3Structure(structId) {
// Requirement 3.4.6 & 3.5.2: 구조물 선택 및 기본 구도 연동
selectStep3Structure(structId, updateCamera = true) {
this.selectedStep3Structure = structId;
this.viewer3d.focusWingwall(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'));
@@ -1230,22 +1198,25 @@ class WingwallApp {
}
}
// Requirement 3.4: 날개벽에 종속된 철근 선택
selectRebarMark(mark, wwId) {
if (!mark) {
const targetWw = wwId || this.selectedStep3Structure;
if (!mark || targetWw === 'CULVERT') {
this.hideRebarDetailCard();
this.viewer3d.clearRebarSelection();
return;
}
this.selectedRebarMark = mark;
if (wwId) this.selectedRebarWingwall = wwId;
this.selectedRebarWingwall = targetWw;
this.viewer3d.selectRebarMark(mark);
this.viewer3d.selectRebarMark(mark, targetWw);
if (this.currentStep === 3) {
this.showRebarDetailCard(mark, wwId);
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');
@@ -1253,46 +1224,60 @@ class WingwallApp {
const bodyEl = document.getElementById('step3-rebar-detail-body');
if (!card || !bodyEl || !this.modelData || !this.modelData.rebars) return;
const leftWw = this.modelData.rebars.wingwalls.WW_END_LEFT;
const rightWw = this.modelData.rebars.wingwalls.WW_END_RIGHT;
const targetWwId = wingwallId || this.selectedStep3Structure || 'WW_END_LEFT';
if (targetWwId === 'CULVERT') {
this.hideRebarDetailCard();
return;
}
const rg = (rightWw?.groups || []).find(g => g.mark === mark) || rightWw?.groups[0];
const lg = (leftWw?.groups || []).find(g => g.mark === (rg?.mark || mark)) || leftWw?.groups[0];
const wwData = this.modelData.rebars.wingwalls[targetWwId];
if (!wwData) return;
if (!rg) return;
const wwName = (targetWwId === 'WW_END_LEFT') ? '종점부 좌측 날개벽' : '종점부 우측 날개벽';
const g = (wwData.groups || []).find(item => item.mark === mark);
if (!g) return;
const col = rg.dia === 'H19' ? '#f43f5e' : (rg.dia === 'H16' ? '#38bdf8' : '#10b981');
const totCount = (rg.count || 0) + (lg ? lg.count : 0);
const totLen = ((rg.total_length_m || 0) + (lg ? lg.total_length_m : 0)).toFixed(3);
const totWeight = ((rg.weight_ton || 0) + (lg ? lg.weight_ton : 0)).toFixed(4);
const col = g.dia === 'H19' ? '#f43f5e' : (g.dia === 'H16' ? '#38bdf8' : '#10b981');
const totWeight = g.weight_ton.toFixed(4);
if (titleEl) titleEl.textContent = `철근 [${rg.mark}] 상세 제원`;
// 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 = `${rg.dia} (SD400)`;
badgeEl.style.background = `${col}22`;
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:11px; color:var(--text-secondary); margin-bottom:4px;">
${rg.desc}
<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:4px 0; padding:6px; background:rgba(0,0,0,0.25); border-radius:4px;">
<div style="font-size:9.5px; color:var(--text-muted); margin-bottom:2px;">
<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(rg)}
${this.buildRebarShapeSvg(g)}
</div>
<div class="meta-grid" style="font-size:10.5px; display:grid; grid-template-columns:1fr 1fr; gap:4px;">
<div class="meta-item"><span class="label">배치 부위</span><span class="value">${rg.zone}</span></div>
<div class="meta-item"><span class="label">간격 / 피복</span><span class="value">${rg.spacing} (피복 80mm)</span></div>
<div class="meta-item"><span class="label">종점 좌측 (${Number(leftWw?.length || 4.35).toFixed(2)}m)</span><span class="value">${lg ? lg.count : 0}본 (${lg ? lg.length_m : 0}m)</span></div>
<div class="meta-item"><span class="label">종점 우측 (${Number(rightWw?.length || 5.4).toFixed(2)}m)</span><span class="value">${rg.count}본 (${rg.length_m}m)</span></div>
<div class="meta-item"><span class="label">총 본수 / 총연장</span><span class="value">${totCount}본 (${totLen}m)</span></div>
<div class="meta-item" style="border-color:${col}66;"><span class="label">총 중량</span><span class="value" style="color:${col}; font-weight:700;">${totWeight} TON</span></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>
`;
+197 -101
View File
@@ -9,6 +9,7 @@ class Viewer3D {
this.sectionMarkers = [];
this.labelSprites = [];
this.rebarLabelSprites = [];
this.wingwallNameSprites = [];
this.displayMode = 'solid'; // 'solid', 'wireframe', 'transparent'
this.stepMode = 2; // 2: Exterior only, 3: Exterior (ghosted) + 3D Rebars
this.selectedObjectId = null;
@@ -113,6 +114,7 @@ class Viewer3D {
}
this.meshes = {};
this.clearSectionMarkers();
this.clearWingwallNameSprites();
const objects = modelData.objects || {};
const botEl = objects.culvert?.dimensions?.bot_el ?? 33.404;
@@ -169,6 +171,9 @@ class Viewer3D {
this.buildRebarMeshes(modelData.rebars);
}
// Build wingwall 3D structure name sprites (Requirement 3.2.2 & 3.3)
this.buildWingwallNameSprites();
this.applyStepVisualState();
this.fitView();
}
@@ -341,6 +346,7 @@ class Viewer3D {
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
}
this.updateWingwallNameSpritesVisibility();
// Concrete appearance
for (const id in this.meshes) {
@@ -387,27 +393,35 @@ class Viewer3D {
}
}
selectRebarMark(mark) {
selectRebarMark(mark, targetWingwallId) {
const wwId = targetWingwallId || this.selectedWingwallFocus;
this.selectedRebarMark = mark;
this.selectedRebarWingwall = wwId;
this.clearRebarLabels();
for (const sp of this.wingwallNameSprites) sp.visible = false;
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const mat = u.mat;
if (u.mark === mark) {
const isTargetWingwall = (u.wingwallId === wwId);
const isTargetBar = isTargetWingwall && (u.mark === mark);
if (isTargetBar) {
// 오직 선택된 날개벽에 속한 철근만 정상 색상 및 하이라이트 활성화
mat.transparent = false;
mat.opacity = 1.0;
mat.color.setHex(0xfacc15); // Bright highlight gold
mat.color.setHex(0xfacc15); // 밝은 골드 하이라이트
mat.emissive.setHex(0xf59e0b);
mat.emissiveIntensity = 0.65;
// Add floating 3D callout sprite at midpoint of first bar in group
// 선택된 날개벽의 해당 철근 위치에 3D 텍스트 레이블 생성
const polys = u.groupData.polylines;
if (polys && polys.length > 0) {
const midPoly = polys[Math.floor(polys.length / 2)];
const pt = midPoly[Math.floor(midPoly.length / 2)];
if (pt) {
const labelText = `[${u.mark}] ${u.dia} (${u.groupData.role})`;
const wwName = (wwId === 'WW_END_LEFT') ? '종점좌' : '종점우';
const labelText = `[${u.mark}] ${u.dia} (${wwName})`;
const sp = this.createTextSprite(labelText, 0xfacc15);
sp.position.set(pt[0], pt[1], pt[2] + 0.55);
this.scene.add(sp);
@@ -417,34 +431,61 @@ class Viewer3D {
} else {
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (this.selectedWingwallFocus && this.selectedWingwallFocus !== 'CULVERT') {
if (u.wingwallId === this.selectedWingwallFocus) {
mat.transparent = false;
mat.opacity = 1.0;
mat.color.setHex(u.baseColor);
} else {
mat.transparent = true;
mat.opacity = 0.12;
mat.color.setHex(0x64748b);
}
} else {
if (isTargetWingwall) {
// 선택된 날개벽 내 다른 철근은 정상 선명도 유지
mat.transparent = false;
mat.opacity = 1.0;
mat.color.setHex(u.baseColor);
} else {
// 다른 날개벽의 철근(동일 철근기호 포함)은 비활성화(흐릿한 0.10 불투명도)
mat.transparent = true;
mat.opacity = 0.10;
mat.color.setHex(0x64748b);
}
}
}
}
focusWingwall(wingwallId) {
// 좌측 바에서 날개벽/암거 이름을 클릭한 경우에만 기본 구도 변경 (Requirement 3.5.2)
focusWingwall(wingwallId, updateCamera = false) {
this.selectedWingwallFocus = wingwallId;
if (wingwallId === 'CULVERT' || !wingwallId) {
this.fitView();
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (updateCamera) {
if (wingwallId === 'CULVERT' || !wingwallId) {
this.fitView();
} else {
const targetMesh = this.meshes[wingwallId];
if (targetMesh && targetMesh.group) {
const box = new THREE.Box3().setFromObject(targetMesh.group);
if (!box.isEmpty()) {
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.5, center.y - maxDim * 1.4, center.z + maxDim * 1.1);
this.camera.lookAt(center);
this.controls.update();
}
}
}
}
this.applyWingwallVisuals(wingwallId);
}
// 카메라 구도를 변경하지 않고 콘크리트 및 철근의 시각적 활성/비활성 상태만 갱신
applyWingwallVisuals(wingwallId) {
const isCulvert = (wingwallId === 'CULVERT' || !wingwallId);
// 1. 콘크리트 솔리드 외형
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (isCulvert) {
mat.transparent = true;
mat.opacity = (id === 'CULVERT') ? Math.min(0.20, this.concreteOpacity * 0.7) : this.concreteOpacity;
mat.color.setHex((id === 'CULVERT') ? 0x94a3b8 : (id === 'WW_END_LEFT' ? 0x38bdf8 : 0xfbbf24));
@@ -452,84 +493,63 @@ class Viewer3D {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
}
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
u.mat.transparent = false;
u.mat.opacity = 1.0;
if (u.mark !== this.selectedRebarMark) {
u.mat.color.setHex(u.baseColor);
}
}
return;
}
// Wingwall focused (WW_END_LEFT or WW_END_RIGHT)
const targetMesh = this.meshes[wingwallId];
if (targetMesh && targetMesh.group) {
const box = new THREE.Box3().setFromObject(targetMesh.group);
if (!box.isEmpty()) {
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.5, center.y - maxDim * 1.4, center.z + maxDim * 1.1);
this.camera.lookAt(center);
this.controls.update();
}
}
// Concrete styling: selected wingwall is normal/active; others are faint/dim (참고용 유지)
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (id === wingwallId) {
mat.transparent = true;
mat.opacity = this.concreteOpacity;
mat.color.setHex(id === 'WW_END_LEFT' ? 0x38bdf8 : 0xfbbf24);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.50;
}
} else {
mat.transparent = true;
mat.opacity = 0.08;
mat.color.setHex(0x475569);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.12;
if (id === wingwallId) {
mat.transparent = true;
mat.opacity = this.concreteOpacity;
mat.color.setHex(id === 'WW_END_LEFT' ? 0x38bdf8 : 0xfbbf24);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.50;
}
} else {
// 비선택 구조물: 옅고 흐릿하게 비활성화 (위치 파악용 유지)
mat.transparent = true;
mat.opacity = 0.08;
mat.color.setHex(0x475569);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.12;
}
}
}
}
// Rebars: selected wingwall's rebars are active & vivid; other wingwall's rebars are deactivated/dim
// 2. 철근군
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
if (u.wingwallId === wingwallId) {
if (isCulvert) {
u.mat.transparent = false;
u.mat.opacity = 1.0;
if (u.mark !== this.selectedRebarMark) {
u.mat.color.setHex(u.baseColor);
}
} else {
u.mat.transparent = true;
u.mat.opacity = 0.12;
u.mat.color.setHex(0x64748b);
if (u.wingwallId === wingwallId) {
u.mat.transparent = false;
u.mat.opacity = 1.0;
if (u.mark !== this.selectedRebarMark) {
u.mat.color.setHex(u.baseColor);
}
} else {
// 다른 날개벽 철근 비활성화
u.mat.transparent = true;
u.mat.opacity = 0.10;
u.mat.color.setHex(0x64748b);
}
}
}
// 3. 날개벽 이름 텍스트 라벨 (Requirement 3.2.2 & 3.3)
this.updateWingwallNameSpritesVisibility();
}
// 화면 클릭이나 선택 해제 시 카메라 시점을 유지한 채 철근 하이라이트만 초기화 (Requirement 3.5.1)
clearRebarSelection() {
this.selectedRebarMark = null;
this.selectedRebarWingwall = null;
this.clearRebarLabels();
if (this.selectedWingwallFocus) {
this.focusWingwall(this.selectedWingwallFocus);
} else {
this.focusWingwall('CULVERT');
}
this.applyWingwallVisuals(this.selectedWingwallFocus || 'CULVERT');
}
clearRebarLabels() {
@@ -618,7 +638,8 @@ class Viewer3D {
texture.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(3.6, 0.75, 1);
sprite.scale.set(3.6, 0.72, 1);
sprite.userData.aspect = 320 / 64; // 5.0
return sprite;
}
@@ -634,6 +655,54 @@ class Viewer3D {
this.labelSprites = [];
}
// Requirement 3.2.2 & 3.3: 암거본체 선택 시 날개벽 구조물 이름만 3D 모델 위에 표시
buildWingwallNameSprites() {
this.clearWingwallNameSprites();
const wwItems = [
{ id: 'WW_END_LEFT', name: '종점부 좌측 날개벽', color: 0x38bdf8 },
{ id: 'WW_END_RIGHT', name: '종점부 우측 날개벽', color: 0xfbbf24 }
];
for (const item of wwItems) {
const meshObj = this.meshes[item.id] || this.meshes[item.id.toLowerCase()];
if (!meshObj || !meshObj.group) continue;
const box = new THREE.Box3().setFromObject(meshObj.group);
if (box.isEmpty()) continue;
const center = new THREE.Vector3();
box.getCenter(center);
const sprite = this.createTextSprite(item.name, item.color);
sprite.position.set(center.x, center.y, box.max.z + 0.65);
sprite.userData.wingwallId = item.id;
sprite.visible = false;
this.scene.add(sprite);
this.wingwallNameSprites.push(sprite);
}
}
clearWingwallNameSprites() {
for (const sp of this.wingwallNameSprites) {
this.scene.remove(sp);
}
this.wingwallNameSprites = [];
}
updateWingwallNameSpritesVisibility() {
if (this.stepMode !== 3) {
for (const sp of this.wingwallNameSprites) sp.visible = false;
return;
}
const isCulvert = (!this.selectedWingwallFocus || this.selectedWingwallFocus === 'CULVERT');
for (const sp of this.wingwallNameSprites) {
if (isCulvert) {
// 암거본체 선택 시 날개벽 구조물의 이름만 표시 (Requirement 3.2.2)
sp.visible = true;
} else {
// 특정 날개벽 선택 시 해당 날개벽 이름만 표시
sp.visible = (sp.userData.wingwallId === this.selectedWingwallFocus);
}
}
}
selectObject(objectId) {
this.selectedObjectId = objectId;
if (this.stepMode === 3) return;
@@ -711,28 +780,43 @@ class Viewer3D {
}
const hits = this.raycaster.intersectObjects(visibleRebarChildren, false);
if (hits.length > 0) {
const hitObj = hits[0].object;
const mark = hitObj.userData.mark || hitObj.parent?.userData?.mark;
if (mark) {
this.selectRebarMark(mark);
if (this.onSelectRebar) this.onSelectRebar(mark, hitObj.userData.wingwallId);
return;
let chosenHit = null;
for (const h of hits) {
const hitWwId = h.object.userData.wingwallId;
if (this.selectedWingwallFocus && this.selectedWingwallFocus !== 'CULVERT') {
if (hitWwId === this.selectedWingwallFocus) {
chosenHit = h;
break;
}
} else {
chosenHit = h;
break;
}
}
if (chosenHit) {
const hitObj = chosenHit.object;
const mark = hitObj.userData.mark || hitObj.parent?.userData?.mark;
const hitWwId = hitObj.userData.wingwallId || hitObj.parent?.userData?.wingwallId;
// Requirement 3.4.1: 선택된 날개벽에 속한 철근만 선택 허용
if (this.selectedWingwallFocus && this.selectedWingwallFocus !== 'CULVERT') {
if (hitWwId !== this.selectedWingwallFocus) {
return;
}
}
if (mark && hitWwId) {
this.selectRebarMark(mark, hitWwId);
if (this.onSelectRebar) this.onSelectRebar(mark, hitWwId);
return;
}
}
}
// Check structure hit in Step 3
const meshes = Object.values(this.meshes).map(m => m.group.userData.mesh);
const intersects = this.raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
const hitMesh = intersects[0].object;
const objId = hitMesh.userData.id;
this.focusWingwall(objId);
if (this.onSelectWingwall) this.onSelectWingwall(objId);
} else {
// Clicked background: clear rebar selection and close rebar detail
this.clearRebarSelection();
if (this.onDeselectRebar) this.onDeselectRebar();
}
// Requirement 3.5.1: 빈 바닥 클릭 시 카메라 구도를 절대 초기화하지 않고 철근 선택만 해제
this.clearRebarSelection();
if (this.onDeselectRebar) this.onDeselectRebar();
return;
}
@@ -761,6 +845,18 @@ class Viewer3D {
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
// Requirement 3.3: 줌 확대/축소 시에도 3D 텍스트 크기 일정 유지 (Screen-space constant size)
const sprites = [...this.rebarLabelSprites, ...this.labelSprites, ...this.wingwallNameSprites];
for (const sp of sprites) {
if (sp && sp.visible) {
const dist = this.camera.position.distanceTo(sp.position);
const baseScale = dist * 0.040;
const aspect = sp.userData.aspect || 5.0;
sp.scale.set(baseScale * aspect, baseScale, 1);
}
}
this.renderer.render(this.scene, this.camera);
}
}