feat(viewer): implement Three.js interactive 3D web viewer and Flask API server

This commit is contained in:
Antigravity Agent committed 2026-10-21 19:15:54 +09:00
1 parent 42726ff1b4
commit e5abc6f591
10 files changed
+5664

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
import os
import json
import tempfile
from flask import Flask, request, jsonify, send_file, Response
from flask_cors import CORS
import cad_parser
import model_3d
import exporters
app = Flask(__name__, static_folder='static', static_url_path='/static')
CORS(app, resources={r"/*": {"origins": "*"}})
# In-memory active session state (starts empty until user imports drawings in Step 1)
current_drawings_list = []
current_drawing_data = None
current_model_data = None
@app.route('/')
def index():
return send_file('index.html')
@app.route('/api/reset', methods=['POST'])
def api_reset():
global current_drawings_list, current_drawing_data, current_model_data
current_drawings_list = []
current_drawing_data = None
current_model_data = None
return jsonify({'status': 'success'})
@app.route('/api/default-drawing', methods=['GET'])
def api_default_drawing():
return jsonify({
'status': 'success',
'drawing': current_drawing_data,
'drawings': current_drawings_list,
'model': current_model_data
})
@app.route('/api/project-drawings', methods=['GET'])
def api_project_drawings():
return jsonify({
'status': 'success',
'drawings': current_drawings_list,
'drawing': current_drawing_data,
'model': current_model_data
})
@app.route('/api/classify', methods=['POST'])
def api_classify():
"""
1.2 도면 종류 판별 API:
도면명 키워드 자동 판별 결과 반환 및 수동 선택 업데이트 지원
"""
global current_drawings_list, current_model_data
body = request.get_json() or {}
drawing_name = body.get('drawingName', '')
manual_type = body.get('manualType') # Optional: 'GENERAL' or 'REBAR'
res = cad_parser.classify_drawing_by_name(drawing_name)
selected_type = manual_type if manual_type in ('GENERAL', 'REBAR') else res['auto_type']
label_map = {
'GENERAL': '일반도',
'REBAR': '배근도/구조도',
'UNCLASSIFIED': '미분류 (수동 선택 필요)'
}
classification_obj = {
'autoType': res['auto_type'],
'autoLabel': res['auto_label'],
'matchedKeyword': res['matched_keyword'],
'reason': res['reason'],
'needsManualSelection': res['needs_manual_selection'],
'selectedType': selected_type,
'selectedLabel': label_map.get(selected_type, res['auto_label']),
'isManualOverride': bool(manual_type and manual_type != res['auto_type'])
}
# Update active drawing classification in memory and regenerate 3D model
for d in current_drawings_list:
if d.get('fileName') == drawing_name or d.get('id') == drawing_name:
d['classification'] = classification_obj
if current_drawings_list:
current_model_data = model_3d.generate_full_model(drawings_list=current_drawings_list)
return jsonify({
'status': 'success',
'classification': classification_obj,
'model': current_model_data
})
@app.route('/api/generate-3d', methods=['POST'])
def api_generate_3d():
global current_model_data, current_drawings_list
params = request.get_json() or {}
model_res = model_3d.generate_full_model(params=params, drawings_list=current_drawings_list)
current_model_data = model_res
return jsonify({
'status': 'success',
'model': model_res
})
@app.route('/api/upload', methods=['POST'])
def api_upload():
files = request.files.getlist('files')
if not files and 'file' in request.files:
files = [request.files['file']]
if not files or all(f.filename == '' for f in files):
return jsonify({'status': 'error', 'message': '업로드된 도면 파일이 없습니다.'}), 400
reset_flag = request.form.get('reset', 'false').lower() == 'true'
temp_dir = tempfile.gettempdir()
saved_files = []
# 1. Save all uploaded files first so companion sheets ((01)<->(02), (03)<->(04)) can cross-reference each other
for file in files:
filename = os.path.basename(file.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ('.dwg', '.dxf'):
return jsonify({'status': 'error', 'message': f'지원하지 않는 파일 형식입니다 ({filename}). .dwg, .dxf만 지원합니다.'}), 400
temp_path = os.path.join(temp_dir, filename)
file.save(temp_path)
saved_files.append((filename, temp_path))
# 2. Parse each uploaded DWG file
parsed_drawings = []
for filename, temp_path in saved_files:
try:
drawing_data = cad_parser.parse_dwg(temp_path, custom_filename=filename)
parsed_drawings.append(drawing_data)
except Exception as e:
return jsonify({'status': 'error', 'message': f'도면 분석 중 오류 발생 ({filename}): {str(e)}'}), 500
global current_drawing_data, current_model_data, current_drawings_list
if reset_flag or current_drawings_list is None:
current_drawings_list = []
# If the user uploads a drawing from a DIFFERENT station (e.g. switching from STA.13+611.21 to STA.0+000.00),
# automatically replace drawings from old stations so old sample drawings don't pollute the new model.
new_stations = set()
for pd in parsed_drawings:
st = (pd.get('structuralParams') or {}).get('station') or (pd.get('rebarSpecs') or {}).get('station')
if st:
new_stations.add(st)
if new_stations and current_drawings_list:
filtered_existing = []
for ed in current_drawings_list:
ed_st = (ed.get('structuralParams') or {}).get('station') or (ed.get('rebarSpecs') or {}).get('station')
if not ed_st or ed_st in new_stations:
filtered_existing.append(ed)
current_drawings_list = filtered_existing
existing_by_name = {d['fileName']: i for i, d in enumerate(current_drawings_list)}
for pd in parsed_drawings:
if pd['fileName'] in existing_by_name:
current_drawings_list[existing_by_name[pd['fileName']]] = pd
else:
current_drawings_list.append(pd)
current_drawing_data = parsed_drawings[0]
current_model_data = model_3d.generate_full_model(drawings_list=current_drawings_list)
return jsonify({
'status': 'success',
'drawing': current_drawing_data,
'uploadedDrawings': parsed_drawings,
'drawings': current_drawings_list,
'model': current_model_data
})
@app.route('/api/export/<fmt>', methods=['GET', 'POST'])
def api_export(fmt):
global current_model_data, current_drawings_list
model = current_model_data
if request.is_json:
custom_params = request.get_json()
model = model_3d.generate_full_model(params=custom_params, drawings_list=current_drawings_list)
elif model is None:
model = model_3d.generate_full_model(drawings_list=current_drawings_list)
fmt = fmt.lower()
if fmt == 'obj':
content = exporters.export_obj(model, include_rebars=True)
return Response(content, mimetype='text/plain', headers={'Content-Disposition': 'attachment; filename=wingwall_model.obj'})
elif fmt == 'stl':
content = exporters.export_stl(model)
return Response(content, mimetype='application/sla', headers={'Content-Disposition': 'attachment; filename=wingwall_model.stl'})
elif fmt == 'dxf':
content = exporters.export_dxf_3d(model, include_rebars=True)
return Response(content, mimetype='application/dxf', headers={'Content-Disposition': 'attachment; filename=wingwall_model_3d.dxf'})
elif fmt == 'csv':
content = exporters.export_rebar_bom_csv(model)
return Response(content, mimetype='text/csv; charset=utf-8', headers={'Content-Disposition': 'attachment; filename=wingwall_rebar_bom.csv'})
elif fmt == 'json':
return jsonify(model)
else:
return jsonify({'status': 'error', 'message': 'Unsupported format'}), 400
if __name__ == '__main__':
print("Starting Wingwall 3D & Rebar Modeler Server at http://127.0.0.1:5000")
app.run(host='127.0.0.1', port=5000, debug=False)
+344
View File
@@ -0,0 +1,344 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DWG 도면 기반 암거 및 날개벽 3D 외형·배근 통합 모델러</title>
<link rel="stylesheet" href="static/css/style.css">
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
</head>
<body>
<!-- Top Header -->
<header>
<div class="brand">
<div class="logo-icon">3D</div>
<div>
<h1>암거 및 날개벽 3D 외형·배근 모델러</h1>
<div style="font-size: 11px; color: var(--text-muted);" id="header-subtitle">도면을 불러와주세요 (1단계)</div>
</div>
</div>
<!-- 3-Step Workflow Stepper -->
<div class="workflow-stepper" id="workflow-stepper">
<button class="step-pill active" data-step="1" id="step-btn-1">
<span class="step-num">1</span>
<div class="step-text">
<span class="step-title">1단계: 도면 불러오기</span>
<span class="step-sub" id="step1-sub-badge">도면 확인 및 종류 판별</span>
</div>
</button>
<span class="step-arrow">▸</span>
<button class="step-pill" data-step="2" id="step-btn-2">
<span class="step-num">2</span>
<div class="step-text">
<span class="step-title">2단계: 외형 모델링</span>
<span class="step-sub">일반도 전용 3D 솔리드</span>
</div>
</button>
<span class="step-arrow">▸</span>
<button class="step-pill" data-step="3" id="step-btn-3">
<span class="step-num">3</span>
<div class="step-text">
<span class="step-title">3단계: 철근 배근</span>
<span class="step-sub">배근도 기반 3D 철근</span>
</div>
</button>
</div>
<!-- Center View & Display Mode Controls (Active in Steps 2 & 3) -->
<div style="display:flex; align-items:center; gap:8px;" id="header-mode-controls">
<div class="segmented-control" id="view-mode-group">
<button class="segmented-btn active" data-view-mode="3d" id="btn-view-3d">3D 뷰어</button>
<button class="segmented-btn" data-view-mode="2d" id="btn-view-2d">2D 도면</button>
<button class="segmented-btn" data-view-mode="split" id="btn-view-split">분할</button>
</div>
<div class="segmented-control" id="display-mode-group">
<button class="segmented-btn active" data-display-mode="solid" title="솔리드">솔리드</button>
<button class="segmented-btn" data-display-mode="wireframe" title="와이어프레임">와이어</button>
<button class="segmented-btn" data-display-mode="transparent" title="X-Ray">X-Ray</button>
</div>
</div>
<!-- Header Actions -->
<div class="header-tools">
<input type="file" id="file-upload" accept=".dwg,.dxf,.DWG,.DXF" multiple style="display: none;">
<button class="btn" id="btn-header-file-open" title="도면 파일 불러오기 (.dwg, .dxf)">
📂 도면 열기
</button>
<div class="dropdown">
<button class="btn btn-primary" id="btn-export-dropdown">
💾 내보내기 ▾
</button>
<div class="dropdown-menu" id="export-menu">
<a href="#" class="dropdown-item" data-export-fmt="obj">📦 Wavefront OBJ (.obj)</a>
<a href="#" class="dropdown-item" data-export-fmt="stl">🖨 3D STL (.stl)</a>
<a href="#" class="dropdown-item" data-export-fmt="dxf">📐 AutoCAD 3D DXF (.dxf)</a>
<a href="#" class="dropdown-item" data-export-fmt="csv">📋 철근 재료표 CSV (.csv)</a>
<a href="#" class="dropdown-item" data-export-fmt="json">📊 제원 JSON (.json)</a>
</div>
</div>
</div>
</header>
<!-- Main Workspace Container -->
<div class="app-container step-1-mode" id="app-container">
<!-- ==================== LEFT SIDEBAR / COLUMN ==================== -->
<aside class="sidebar sidebar-left" id="sidebar-left">
<!-- [STEP 1 LEFT COLUMN] 1단계: 도면 불러오기 및 도면 종류 판별 -->
<div class="step-sidebar-pane active" id="left-pane-step-1">
<div class="step1-header-simple">
<h2>1단계: 도면 불러오기 및 종류 판별</h2>
</div>
<div class="step1-content-simple">
<!-- 1.1 도면 파일 불러오기 영역 -->
<div class="panel-section">
<div class="upload-dropzone-simple" id="step1-dropzone">
<div style="font-size:26px; margin-bottom:4px;">📂</div>
<div style="font-size:13px; font-weight:700; color:var(--text-primary);">도면 파일 불러오기</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">클릭 또는 파일을 여기에 드래그 (.dwg, .dxf)</div>
</div>
<div style="margin-top:8px;">
<button class="btn btn-sm btn-primary" id="btn-select-file" type="button" style="width:100%; justify-content:center; padding:7px 12px; font-size:12px;">
📂 도면 파일 선택 (.dwg, .dxf)
</button>
</div>
</div>
<!-- 1.2 불러온 도면 목록 및 종류 판별 -->
<div class="panel-section step1-list-section">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<h3 style="margin:0;">불러온 도면 목록</h3>
<span style="font-size:11px; color:var(--text-muted);" id="step1-total-count">0개</span>
</div>
<!-- List of Drawing Cards -->
<div id="step1-drawings-list" class="step1-drawings-scroll">
<!-- Empty state initially -->
</div>
<!-- Step 1 Bottom Action Area -->
<div id="step1-action-area" style="margin-top:10px;">
<!-- Proceed button rendered when drawings exist -->
</div>
</div>
</div>
</div>
<!-- [STEP 2 LEFT SIDEBAR] 2단계: 일반도 기반 외형 모델링 (심플 UI) -->
<div class="step-sidebar-pane" id="left-pane-step-2">
<div class="step2-header-simple">
<h2>2단계: 일반도 기반 3D 외형 모델링</h2>
</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 구조물 목록 -->
<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>
</div>
<div id="step2-structures-list" class="step2-structures-scroll" style="gap:6px;"></div>
</div>
<!-- 2.4 다음 단계(3단계: 철근 배근) 이동 버튼 -->
<div style="margin-top:auto; padding-top:4px;">
<button class="btn btn-primary" style="width:100%; justify-content:center; padding:9px 12px; font-size:12px; font-weight:700;" id="btn-goto-step3">
3단계: 배근도 기반 철근 배근 진행 ➔
</button>
</div>
</div>
</div>
<!-- [STEP 3 LEFT SIDEBAR] 3단계: 배근도를 이용한 날개벽 내부 철근 배근 -->
<div class="step-sidebar-pane" id="left-pane-step-3">
<div class="sidebar-header-banner step3-banner">
<div style="font-weight:700; font-size:12px;">3단계: 배근도 기반 날개벽 3D 철근 배근</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:2px;" id="step3-source-summary">
배근도/구조도를 이용하여 날개벽 내부에 철근을 배근합니다.
</div>
</div>
<div class="sidebar-tabs">
<button class="tab-btn active" data-tab="tab-rebar-filter">배근도 & 부위필터</button>
<button class="tab-btn" data-tab="tab-rebar-marks" id="tab-rebar-marks-btn">철근 기호</button>
<button class="tab-btn" data-tab="tab-rebar-diag">수량 & 품질검증</button>
</div>
<div class="tab-content active" id="tab-rebar-filter">
<div class="panel-section">
<h3><span>적용된 배근도/구조도</span><span class="badge" style="background:rgba(16,185,129,0.18); color:#34d399;">배근도</span></h3>
<div id="step3-rebar-dwgs-list"></div>
</div>
<div class="panel-section">
<h3>대상 날개벽 필터</h3>
<div class="mini-filter-tabs" id="step3-ww-filter">
<button class="mini-tab active" data-ww="ALL" id="btn-ww-filter-all">전체</button>
<button class="mini-tab" data-ww="WW_END_LEFT" id="btn-ww-filter-left">종점 좌</button>
<button class="mini-tab" data-ww="WW_END_RIGHT" id="btn-ww-filter-right">종점 우</button>
</div>
</div>
<div class="panel-section">
<h3>직경별 표시 제어</h3>
<div id="step3-dia-filters" class="layer-list"></div>
</div>
<div class="panel-section">
<h3>부위별 표시 제어</h3>
<div id="step3-zone-filters" class="layer-list"></div>
</div>
</div>
<div class="tab-content" id="tab-rebar-marks">
<div class="panel-section">
<h3><span>기호별 3D 철근</span><span style="font-size:10px; color:var(--text-muted);">클릭 시 하이라이트</span></h3>
<div id="step3-marks-list"></div>
</div>
</div>
<div class="tab-content" id="tab-rebar-diag">
<div class="panel-section">
<h3>철근 총괄 집계 (SD400 / fy=500MPa)</h3>
<div class="meta-grid" id="rebar-weight-summary"></div>
<button class="btn btn-primary btn-sm" style="width:100%; justify-content:center; margin-top:8px;" onclick="window.app.openBomModal()">
📊 전체 철근 재료표(BOM) 열기
</button>
</div>
<div class="panel-section">
<h3>배근 품질 검증</h3>
<div id="rebar-diag-list"></div>
</div>
</div>
</div>
</aside>
<!-- ==================== CENTER / RIGHT VIEWPORT ==================== -->
<main class="viewport-container" id="viewport-container">
<!-- Top Bar inside Viewport: Active Drawing Switcher Tabs -->
<div class="viewport-top-bar" id="viewport-top-bar">
<div class="viewport-step-indicator" id="viewport-step-indicator"></div>
<div class="viewport-drawing-tabs" id="viewport-drawing-tabs"></div>
</div>
<div class="viewport-panes-wrapper">
<!-- 3D Viewport Pane -->
<div class="view-pane" id="viewport-3d" style="display:none;">
<div class="viewport-overlay" id="overlay-3d-legend"></div>
<div class="viewport-controls-bottom">
<button class="view-hud-btn" id="btn-fit-3d" title="카메라 위치를 모델 전체에 맞춤">⛶ 전체 보기</button>
<button class="view-hud-btn" onclick="window.app.viewer3d.camera.position.set(16, 10, 36); window.app.viewer3d.controls.update();">정면 뷰</button>
<button class="view-hud-btn" onclick="window.app.viewer3d.camera.position.set(16, 41, 65); window.app.viewer3d.controls.update();">평면 뷰</button>
<button class="view-hud-btn" onclick="window.app.viewer3d.camera.position.set(45, 41, 36); window.app.viewer3d.controls.update();">측면 뷰</button>
</div>
</div>
<!-- 2D CAD Viewport Pane -->
<div class="view-pane" id="viewport-2d" style="display:block;">
<canvas id="cad-canvas"></canvas>
<!-- Empty Canvas Overlay for Step 1 when no files loaded -->
<div class="canvas-empty-overlay" id="canvas-empty-overlay" style="display:flex;">
<div style="font-size:36px; margin-bottom:10px; color:#475569;">📐</div>
<div style="font-size:14px; font-weight:700; color:var(--text-secondary);">도면 뷰어</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:4px;">불러온 도면이 없습니다. 좌측에서 도면 파일을 불러와주세요.</div>
</div>
<div class="viewport-overlay" id="overlay-2d-info" style="display:none;">
<div class="overlay-chip" id="overlay-2d-title">2D CAD 도면</div>
</div>
<div class="viewport-controls-bottom" id="viewport-2d-controls" style="display:none;">
<button class="view-hud-btn" id="btn-fit-2d">⛶ 도면 전체보기</button>
</div>
</div>
</div>
</main>
<!-- ==================== RIGHT SIDEBAR (Steps 2 & 3 only) ==================== -->
<aside class="sidebar sidebar-right" id="sidebar-right">
<div class="sidebar-tabs">
<button class="tab-btn active" id="right-sidebar-title" style="cursor:default;">부재 속성 및 제원</button>
</div>
<div class="tab-content active" id="properties-container" style="display:block;"></div>
</aside>
</div>
<!-- ==================== BAR BENDING SCHEDULE (철근 재료표) MODAL ==================== -->
<div id="bom-modal" class="modal-backdrop" style="display:none;">
<div class="modal-card">
<div class="modal-header">
<div>
<h2 style="font-size:14px; font-weight:700;">📊 날개벽 철근 재료표 (Bar Bending Schedule)</h2>
<div style="font-size:11px; color:var(--text-secondary); margin-top:2px;" id="bom-modal-subtitle">
종점부 날개벽 | fck=30MPa, fy=400~500MPa (SD400)
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn btn-sm btn-primary" onclick="window.app.exportModel('csv')">📥 엑셀 CSV 다운로드</button>
<button class="btn btn-sm" onclick="window.app.closeBomModal()">✕ 닫기</button>
</div>
</div>
<div class="modal-body" id="bom-modal-body"></div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" style="display:none; position:fixed; inset:0; background:rgba(14,17,24,0.85); backdrop-filter:blur(6px); z-index:9999; justify-content:center; align-items:center; flex-direction:column; gap:12px;">
<div style="width:36px; height:36px; border:3px solid var(--border-color); border-top-color:var(--accent-color); border-radius:50%; animation:spin 0.8s linear infinite;"></div>
<div style="font-size:13px; font-weight:600; color:var(--text-primary);" id="loading-text">도면 파일 처리 중...</div>
</div>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<!-- Scripts -->
<script src="static/libs/three.min.js"></script>
<script>
if (typeof THREE === 'undefined') {
document.write('<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"><\/script>');
}
</script>
<script src="static/libs/OrbitControls.js"></script>
<script>
if (typeof THREE.OrbitControls === 'undefined') {
document.write('<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"><\/script>');
}
</script>
<!-- Pre-bundled Datasets for Offline Standalone Running -->
<script src="static/data/drawing_data.js"></script>
<script src="static/data/model_3d.js"></script>
<!-- Application Logic -->
<script src="static/js/viewer3d.js"></script>
<script src="static/js/cad2d.js"></script>
<script src="static/js/app.js"></script>
</body>
</html>
+1384
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
window.KNOWN_DRAWINGS_MAP = {"27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg": {"id": "27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg", "fileName": "27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg", "drawingTitle": "수로암거 일반도(1) (부체도로#02 STA.0+017.46)", "titleBlock": {"mainTitle": "수로암거 일반도(1)", "subTitle": "(부체도로#02 STA.0+017.46)", "projectName": "고속국도 제30호선 대산~당진 고속도로 건설공사", "sectionName": "제2-1공구 : 사성~두산", "scale": "S = 1:100", "dwgCode": "C0040202-186", "dwgNo": "9.243", "targetWingwall": "암거 본체 및 시점부 및 종점부 날개벽 모두 존재"}, "classification": {"autoType": "GENERAL", "autoLabel": "일반도", "matchedKeyword": "일반", "reason": "도면명에 '일반' 키워드가 포함되어 일반도로 자동 분류됨", "needsManualSelection": false, "selectedType": "GENERAL", "selectedLabel": "일반도", "isManualOverride": false}, "structuralParams": {"station": "부체도로#02 STA.0+017.46", "skew_angle": 90.0, "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 228.64, "end_x": 392.78, "center_y": 172.42, "top_dashed_y": 228.935, "bot_dashed_y": 115.91, "clear_span": 11.3, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "BOTH", "configuration_label": "시점부 및 종점부 날개벽 모두 존재", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 좌측(시점부)과 우측(종점부) 모두에 날개벽이 표기되어 있어 양쪽 날개벽을 모두 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [166.44, 198.76, 0.0], "p2": [131.44, 198.76, 0.0], "length": 35.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [204.57, 183.21, 0.0], "p2": [206.31, 193.56, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [235.82, 188.1, 0.0], "p2": [206.23, 193.07, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [148.94, 196.26, 0.0], "p2": [166.26, 166.26, 0.0], "length": 34.64}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [199.53, 178.79, 0.0], "p2": [204.45, 177.97, 0.0], "length": 4.99}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [203.02, 169.47, 0.0], "p2": [204.45, 177.97, 0.0], "length": 8.61}], "texts": ["3.000", "A", "B"], "exists": true, "length": 3.0, "flare_angle": 0.0, "sections": ["A-A", "B-B"], "status_label": "도면 표기됨 (외형 모델링 대상)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선(6개 선), 단면(A-A ↔ B-B), L=3.00m, 0° 표기 검출"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [{"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [204.57, 73.6, 0.0], "p2": [206.31, 63.25, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [235.82, 68.72, 0.0], "p2": [206.23, 63.74, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [218.78, 109.75, 0.0], "p2": [208.42, 108.01, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [218.78, 147.06, 0.0], "p2": [208.42, 148.81, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [166.26, 166.26, 0.0], "p2": [186.21, 166.26, 0.0], "length": 19.95}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [227.86, 136.26, 0.0], "p2": [198.56, 142.92, 0.0], "length": 30.05}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [232.5, 163.87, 0.0], "p2": [201.54, 160.67, 0.0], "length": 31.12}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [229.18, 144.15, 0.0], "p2": [199.55, 148.84, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [229.14, 143.86, 0.0], "p2": [199.55, 148.84, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [228.52, 140.21, 0.0], "p2": [199.05, 145.88, 0.0], "length": 30.01}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [228.64, 140.91, 0.0], "p2": [199.05, 145.88, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [198.56, 142.92, 0.0], "p2": [201.54, 160.67, 0.0], "length": 18.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [232.5, 92.94, 0.0], "p2": [201.54, 96.14, 0.0], "length": 31.12}, {"typLine truncated
window.DEFAULT_DRAWING_DATA = null;
window.DEFAULT_DRAWINGS_LIST = [];
+2
View File
@@ -0,0 +1,2 @@
window.KNOWN_MODELS_MAP = {"STA.13+611.21": {"station": "STA.13+611.21", "generalDrawingName": "13. 수로암거 일반도(STA.13+611.21)(01).dwg", "rebarDrawingNames": ["14. 수로암거 구조도(STA.13+611.21, 종점 우)(01).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 우)(02).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(03).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(04).dwg"], "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 339.77, "end_x": 638.17, "center_y": 447.38, "top_dashed_y": 457.38, "bot_dashed_y": 437.38, "clear_span": 2.0, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "END_ONLY", "configuration_label": "종점부 날개벽만 존재 (2개소 모델링 대상)", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 우측(종점부)에만 날개벽이 표기되어 있으며 암거 좌측(시점부)에는 날개벽이 존재하지 않으므로 종점부 날개벽만 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [337.45, 447.38, 0.0], "p2": [337.51, 447.38, 0.0], "length": 0.07}], "texts": ["(BLOCK2)", "19.980"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [], "texts": ["(BLOCK2)", "19.980", "19.980", "(Block2)"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 하단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "END_LEFT": {"id": "WW_END_LEFT", "name": "종점부 좌측 날개벽", "location": "종점부", "side": "좌측", "position_desc": "암거 우측(종점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [339.77, 447.38, 0.0], "p2": [436.77, 447.38, 0.0], "length": 97.0}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [437.45, 447.38, 0.0], "p2": [437.51, 447.38, 0.0], "length": 0.07}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [439.77, 447.38, 0.0], "p2": [751.56, 447.38, 0.0], "length": 311.79}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.17, 464.38, 0.0], "p2": [388.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [339.77, 464.38, 0.0], "p2": [388.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.17, 464.38, 0.0], "p2": [488.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [388.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [436.77, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [439.77, 464.38, 0.0], "p2": [488.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [488.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [638.17, 464.38, 0.0], "length": 149.8}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 494.38, 0.0], "p2": [438.27, 494.38, 0.0], "length": 100.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [438.27, 494.38, 0.0], "p2": [726.72, 494.38, 0.0], "length": 288.45}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 484.38, 0.0], "p2": [388.17, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 484.38, 0.0], "p2": [388.37, 484.38, 0.0], "length": 0.2}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 474.38, 0.0], "p2": [388.37, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 484.38, 0.0], "p2": [438.27, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML"Line truncated
window.DEFAULT_MODEL_3D = {"station": "STA.13+611.21", "generalDrawingName": "13. 수로암거 일반도(STA.13+611.21)(01).dwg", "rebarDrawingNames": ["14. 수로암거 구조도(STA.13+611.21, 종점 우)(01).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 우)(02).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(03).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(04).dwg"], "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 339.77, "end_x": 638.17, "center_y": 447.38, "top_dashed_y": 457.38, "bot_dashed_y": 437.38, "clear_span": 2.0, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "END_ONLY", "configuration_label": "종점부 날개벽만 존재 (2개소 모델링 대상)", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 우측(종점부)에만 날개벽이 표기되어 있으며 암거 좌측(시점부)에는 날개벽이 존재하지 않으므로 종점부 날개벽만 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [337.45, 447.38, 0.0], "p2": [337.51, 447.38, 0.0], "length": 0.07}], "texts": ["(BLOCK2)", "19.980"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [], "texts": ["(BLOCK2)", "19.980", "19.980", "(Block2)"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 하단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "END_LEFT": {"id": "WW_END_LEFT", "name": "종점부 좌측 날개벽", "location": "종점부", "side": "좌측", "position_desc": "암거 우측(종점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [339.77, 447.38, 0.0], "p2": [436.77, 447.38, 0.0], "length": 97.0}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [437.45, 447.38, 0.0], "p2": [437.51, 447.38, 0.0], "length": 0.07}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [439.77, 447.38, 0.0], "p2": [751.56, 447.38, 0.0], "length": 311.79}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.17, 464.38, 0.0], "p2": [388.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [339.77, 464.38, 0.0], "p2": [388.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.17, 464.38, 0.0], "p2": [488.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [388.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [436.77, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [439.77, 464.38, 0.0], "p2": [488.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [488.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [638.17, 464.38, 0.0], "length": 149.8}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 494.38, 0.0], "p2": [438.27, 494.38, 0.0], "length": 100.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [438.27, 494.38, 0.0], "p2": [726.72, 494.38, 0.0], "length": 288.45}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 484.38, 0.0], "p2": [388.17, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 484.38, 0.0], "p2": [388.37, 484.38, 0.0], "length": 0.2}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 474.38, 0.0], "p2": [388.37, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 484.38, 0.0], "p2": [438.27, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4Line truncated
+1710
View File
File diff suppressed because it is too large. Load diff
+316
View File
@@ -0,0 +1,316 @@
class CAD2DViewer {
constructor(canvasId, onSelectCallback) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.onSelect = onSelectCallback;
this.drawingData = null;
this.activeLayers = new Set();
this.highlightedView = null;
this.needsFit = true;
// View transform: pan & zoom
this.scale = 1.0;
this.offsetX = 0;
this.offsetY = 0;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.initEvents();
}
initEvents() {
this.canvas.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.clientX - this.offsetX;
this.startY = e.clientY - this.offsetY;
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
this.offsetX = e.clientX - this.startX;
this.offsetY = e.clientY - this.startY;
this.render();
});
window.addEventListener('mouseup', () => {
this.isDragging = false;
});
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.max(0.1, Math.min(this.scale * zoomFactor, 30.0));
this.offsetX = mouseX - (mouseX - this.offsetX) * (newScale / this.scale);
this.offsetY = mouseY - (mouseY - this.offsetY) * (newScale / this.scale);
this.scale = newScale;
this.render();
});
this.canvas.addEventListener('click', (e) => {
if (Math.abs(e.clientX - (this.startX + this.offsetX)) > 5) return;
this.handleClick(e);
});
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
if (w === 0 || h === 0) return;
this.canvas.width = w;
this.canvas.height = h;
if (this.needsFit) {
this.fitView();
this.needsFit = false;
} else {
this.render();
}
}
loadDrawing(drawingData) {
if (!drawingData) return;
this.drawingData = drawingData;
this.activeLayers = new Set(Object.keys(drawingData.layers || {}));
this.highlightedView = null;
this.needsFit = true;
const parent = this.canvas.parentElement;
if (parent && parent.clientWidth > 0 && parent.clientHeight > 0) {
this.canvas.width = parent.clientWidth;
this.canvas.height = parent.clientHeight;
this.fitView();
this.needsFit = false;
}
}
setLayerVisibility(layerName, isVisible) {
if (isVisible) {
this.activeLayers.add(layerName);
} else {
this.activeLayers.delete(layerName);
}
this.render();
}
fitView() {
if (!this.drawingData || !this.drawingData.bounds) return;
if (this.canvas.width === 0 || this.canvas.height === 0) {
this.needsFit = true;
return;
}
const b = this.drawingData.bounds;
const dwgW = Math.max(10, b.max_x - b.min_x);
const dwgH = Math.max(10, b.max_y - b.min_y);
const pad = 44;
const scaleX = (this.canvas.width - pad * 2) / dwgW;
const scaleY = (this.canvas.height - pad * 2) / dwgH;
this.scale = Math.max(0.1, Math.min(scaleX, scaleY));
this.offsetX = (this.canvas.width - dwgW * this.scale) / 2 - b.min_x * this.scale;
this.offsetY = (this.canvas.height + dwgH * this.scale) / 2 + b.min_y * this.scale;
this.render();
}
zoomToView(viewId) {
this.highlightedView = viewId;
if (!this.drawingData || !this.drawingData.views || !this.drawingData.views[viewId]) {
this.render();
return;
}
if (this.canvas.width > 0 && this.canvas.height > 0) {
const [bx0, by0, bx1, by1] = this.drawingData.views[viewId].bbox;
const vw = Math.max(10, bx1 - bx0);
const vh = Math.max(10, by1 - by0);
const pad = 60;
const scaleX = (this.canvas.width - pad * 2) / vw;
const scaleY = (this.canvas.height - pad * 2) / vh;
this.scale = Math.max(0.2, Math.min(scaleX, scaleY, 12.0));
this.offsetX = (this.canvas.width - vw * this.scale) / 2 - bx0 * this.scale;
this.offsetY = (this.canvas.height + vh * this.scale) / 2 + by0 * this.scale;
}
this.render();
}
worldToScreen(x, y) {
return {
x: x * this.scale + this.offsetX,
y: -y * this.scale + this.offsetY
};
}
screenToWorld(sx, sy) {
return {
x: (sx - this.offsetX) / this.scale,
y: -(sy - this.offsetY) / this.scale
};
}
highlightView(viewId) {
this.highlightedView = viewId;
this.render();
}
handleClick(e) {
if (!this.drawingData || !this.drawingData.views) return;
const rect = this.canvas.getBoundingClientRect();
const world = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
for (const [vid, view] of Object.entries(this.drawingData.views)) {
const [bx0, by0, bx1, by1] = view.bbox;
if (world.x >= bx0 && world.x <= bx1 && world.y >= by0 && world.y <= by1) {
this.highlightedView = vid;
this.render();
if (view.wingwall && this.onSelect) {
this.onSelect(view.wingwall);
} else if (vid === 'PLAN' && this.onSelect) {
this.onSelect('WW_END_LEFT');
}
return;
}
}
}
render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.drawingData) return;
ctx.lineWidth = Math.max(1.0, 0.75 * this.scale);
for (const ent of this.drawingData.entities || []) {
if (!this.activeLayers.has(ent.layer)) continue;
ctx.strokeStyle = ent.color || '#cccccc';
ctx.fillStyle = ent.color || '#cccccc';
if (ent.type === 'LINE') {
const p1 = this.worldToScreen(ent.p1[0], ent.p1[1]);
const p2 = this.worldToScreen(ent.p2[0], ent.p2[1]);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
} else if (ent.type === 'LWPOLYLINE') {
if (!ent.points || ent.points.length === 0) continue;
ctx.beginPath();
const p0 = this.worldToScreen(ent.points[0][0], ent.points[0][1]);
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < ent.points.length; i++) {
const pt = this.worldToScreen(ent.points[i][0], ent.points[i][1]);
ctx.lineTo(pt.x, pt.y);
}
if (ent.closed) ctx.closePath();
ctx.stroke();
} else if (ent.type === 'TEXT') {
const pos = this.worldToScreen(ent.pos[0], ent.pos[1]);
const fontSize = Math.max(7, ent.height * this.scale * 0.85);
if (fontSize < 3.5) continue;
ctx.font = `${Math.min(fontSize, 26)}px -apple-system, "Malgun Gothic", sans-serif`;
ctx.fillText(ent.text, pos.x, pos.y);
} else if (ent.type === 'ARC' || ent.type === 'CIRCLE') {
const c = this.worldToScreen(ent.center[0], ent.center[1]);
const r = ent.radius * this.scale;
ctx.beginPath();
if (ent.type === 'CIRCLE') {
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
} else {
const sAngle = -THREE.MathUtils.degToRad(ent.end_angle);
const eAngle = -THREE.MathUtils.degToRad(ent.start_angle);
ctx.arc(c.x, c.y, r, sAngle, eAngle);
}
ctx.stroke();
}
}
// Draw View Boundary Boxes & Labels
for (const [vid, view] of Object.entries(this.drawingData.views || {})) {
const [bx0, by0, bx1, by1] = view.bbox;
const pTopLeft = this.worldToScreen(bx0, by1);
const pBotRight = this.worldToScreen(bx1, by0);
const w = pBotRight.x - pTopLeft.x;
const h = pBotRight.y - pTopLeft.y;
const isHigh = (this.highlightedView === vid);
ctx.strokeStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : 'rgba(148, 163, 184, 0.45)');
ctx.lineWidth = isHigh ? 2.5 : 1.0;
ctx.setLineDash(view.is_excluded ? [4, 4] : (isHigh ? [] : [3, 3]));
ctx.strokeRect(pTopLeft.x, pTopLeft.y, w, h);
ctx.setLineDash([]);
if (isHigh) {
ctx.fillStyle = 'rgba(56, 189, 248, 0.06)';
ctx.fillRect(pTopLeft.x, pTopLeft.y, w, h);
}
ctx.fillStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : '#94a3b8');
ctx.font = 'bold 11px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText(view.name, pTopLeft.x + 6, pTopLeft.y + 15);
}
// Draw Plan View Culvert & Wingwall Quadrant Overlay if available
const planView = this.drawingData.views?.PLAN;
const det = this.drawingData.structuralParams?.detection || planView?.detection;
if (planView && det && det.culvert_bounds) {
const cb = det.culvert_bounds;
const midY = cb.center_y || 416.5;
const startX = cb.start_x || 106.91;
const endX = cb.end_x || 162.93;
const pC0 = this.worldToScreen(startX, cb.top_dashed_y || 426.5);
const pC1 = this.worldToScreen(endX, cb.bot_dashed_y || 406.5);
const cW = pC1.x - pC0.x;
const cH = pC1.y - pC0.y;
ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(pC0.x, pC0.y, cW, cH);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
ctx.fillRect(pC0.x, pC0.y, cW, cH);
ctx.fillStyle = '#38bdf8';
ctx.font = 'bold 10px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText('🏢 암거 판별 (내공 2.0m & 점선)', pC0.x + 4, pC0.y - 4);
// Label detected wingwalls
const quads = det.quadrants || {};
if (quads.END_LEFT?.exists) {
const pEL = this.worldToScreen(endX + 5, midY + 28);
ctx.fillStyle = '#38bdf8';
ctx.fillText('🟢 종점부 좌측 날개벽 (L=4.35m, 134°)', pEL.x, pEL.y);
}
if (quads.END_RIGHT?.exists) {
const pER = this.worldToScreen(endX + 5, midY - 32);
ctx.fillStyle = '#fbbf24';
ctx.fillText('🟢 종점부 우측 날개벽 (L=5.40m, 151°)', pER.x, pER.y);
}
if (!quads.START_LEFT?.exists) {
const pSL = this.worldToScreen(startX - 50, midY + 28);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 좌측 (미표기)', pSL.x, pSL.y);
}
if (!quads.START_RIGHT?.exists) {
const pSR = this.worldToScreen(startX - 50, midY - 32);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 우측 (미표기)', pSR.x, pSR.y);
}
}
}
}
+639
View File
@@ -0,0 +1,639 @@
class Viewer3D {
constructor(containerId, onSelectCallback, onSelectRebarCallback) {
this.container = document.getElementById(containerId);
this.onSelect = onSelectCallback;
this.onSelectRebar = onSelectRebarCallback;
this.meshes = {};
this.rebarMeshes = [];
this.rebarGroupRoot = null;
this.sectionMarkers = [];
this.labelSprites = [];
this.rebarLabelSprites = [];
this.displayMode = 'solid'; // 'solid', 'wireframe', 'transparent'
this.stepMode = 2; // 2: Exterior only, 3: Exterior (ghosted) + 3D Rebars
this.selectedObjectId = null;
this.selectedRebarMark = null;
this.concreteOpacity = 0.25;
this.rebarScale = 1.4;
this.rebarFilters = {
wingwall: 'ALL', // 'ALL', 'WW_END_LEFT', 'WW_END_RIGHT'
zones: new Set([
'우각부 보강근 (암거접합부)',
'벽체 전면 철근',
'벽체 배면 철근',
'저판 상면 철근',
'저판 하면 철근',
'헌치 및 전단보강근'
]),
dias: new Set(['H19', 'H16', 'H13']),
marks: new Set() // populated on load
};
this.init();
}
init() {
const width = this.container.clientWidth || 800;
const height = this.container.clientHeight || 600;
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0e1118);
// Camera (Z is up)
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
this.camera.up.set(0, 0, 1);
this.camera.position.set(35, 20, 50);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
// Controls
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.target.set(16, 41, 35);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.70);
this.scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.85);
dirLight1.position.set(30, -20, 60);
dirLight1.castShadow = true;
dirLight1.shadow.mapSize.width = 2048;
dirLight1.shadow.mapSize.height = 2048;
this.scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x93c5fd, 0.40);
dirLight2.position.set(-20, 30, 40);
this.scene.add(dirLight2);
this.rebarGroupRoot = new THREE.Group();
this.scene.add(this.rebarGroupRoot);
// Ground Grid & Axes
this.setupGridAndAxes();
// Raycaster for object & rebar picking
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onPointerDown(e));
window.addEventListener('resize', () => this.onWindowResize());
this.animate();
}
setupGridAndAxes() {
this.gridHelper = new THREE.GridHelper(50, 50, 0x3b82f6, 0x1e293b);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.set(16, 41, 33.0);
this.scene.add(this.gridHelper);
this.axesGroup = new THREE.Group();
this.axesGroup.position.set(5, 30, 33.0);
const axes = new THREE.AxesHelper(5);
this.axesGroup.add(axes);
this.scene.add(this.axesGroup);
}
loadModel(modelData) {
this.modelData = modelData;
// Clear previous concrete meshes
for (const key in this.meshes) {
this.scene.remove(this.meshes[key].group);
}
this.meshes = {};
this.clearSectionMarkers();
const objects = modelData.objects || {};
const botEl = objects.culvert?.dimensions?.bot_el ?? 33.404;
if (this.gridHelper) this.gridHelper.position.set(16, 41, botEl - 0.35);
if (this.axesGroup) this.axesGroup.position.set(5, 30, botEl - 0.35);
const colorMap = {
'CULVERT': { base: 0xa1a1aa, edge: 0x52525b },
'WW_END_LEFT': { base: 0x38bdf8, edge: 0x0284c7 },
'WW_END_RIGHT': { base: 0xfbbf24, edge: 0xd97706 },
'WW_START_LEFT': { base: 0x34d399, edge: 0x059669 },
'WW_START_RIGHT': { base: 0xa78bfa, edge: 0x7c3aed },
'culvert': { base: 0xa1a1aa, edge: 0x52525b },
'ww_left': { base: 0x38bdf8, edge: 0x0284c7 },
'ww_right': { base: 0xfbbf24, edge: 0xd97706 }
};
const renderedIds = new Set();
const candidateList = [];
if (objects.culvert) candidateList.push(objects.culvert);
if (Array.isArray(objects.wingwalls)) {
candidateList.push(...objects.wingwalls);
} else {
for (const k in objects) {
const o = objects[k];
if (o && o.vertices && o.faces && o.id) {
candidateList.push(o);
}
}
}
for (const obj of candidateList) {
if (!obj || !obj.vertices || !obj.faces || renderedIds.has(obj.id)) continue;
renderedIds.add(obj.id);
const col = colorMap[obj.id] || colorMap[obj.name] || { base: 0x94a3b8, edge: 0x475569 };
const meshGroup = this.createMeshGroup(obj, col);
this.scene.add(meshGroup);
this.meshes[obj.id] = {
group: meshGroup,
data: obj,
baseColor: col.base,
edgeColor: col.edge
};
}
// Add Section Cut Markers & EL Labels
this.addSectionMarkers(objects);
this.addAnnotations(objects);
// Load 3D Rebars if present
if (modelData.rebars) {
this.buildRebarMeshes(modelData.rebars);
}
this.applyStepVisualState();
this.fitView();
}
createMeshGroup(obj, col) {
const group = new THREE.Group();
group.userData = { id: obj.id, name: obj.name };
const geom = new THREE.BufferGeometry();
const vertices = new Float32Array(obj.vertices.flat());
const indices = [];
for (const f of obj.faces) {
indices.push(f[0], f[1], f[2]);
}
geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
const isCulvert = (obj.id === 'CULVERT');
const mat = new THREE.MeshStandardMaterial({
color: col.base,
roughness: 0.6,
metalness: 0.1,
side: isCulvert ? THREE.DoubleSide : THREE.FrontSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const mesh = new THREE.Mesh(geom, mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { id: obj.id, parentGroup: group };
group.add(mesh);
const edgesGeom = new THREE.EdgesGeometry(geom, 25);
const edgesMat = new THREE.LineBasicMaterial({ color: col.edge, linewidth: 1.5 });
const edgeLines = new THREE.LineSegments(edgesGeom, edgesMat);
group.add(edgeLines);
group.userData.mesh = mesh;
group.userData.edgeLines = edgeLines;
group.userData.mat = mat;
group.userData.edgesMat = edgesMat;
return group;
}
buildRebarMeshes(rebarData) {
// Clear existing rebar meshes
while (this.rebarGroupRoot.children.length > 0) {
const child = this.rebarGroupRoot.children[0];
this.rebarGroupRoot.remove(child);
}
this.rebarMeshes = [];
this.clearRebarLabels();
const diaColors = {
'H19': 0xf43f5e, // Rose Red
'H16': 0x38bdf8, // Sky Blue
'H13': 0x10b981 // Emerald Green
};
this.rebarFilters.marks.clear();
const unitCylGeom = new THREE.CylinderGeometry(1, 1, 1, 7, 1, false);
const sphereGeom = new THREE.SphereGeometry(1.05, 6, 6);
const yAxis = new THREE.Vector3(0, 1, 0);
for (const [wwId, ww] of Object.entries(rebarData.wingwalls || {})) {
for (const grp of ww.groups || []) {
this.rebarFilters.marks.add(grp.mark);
const baseColor = diaColors[grp.dia] || 0xeab308;
const mat = new THREE.MeshStandardMaterial({
color: baseColor,
roughness: 0.35,
metalness: 0.45
});
const groupContainer = new THREE.Group();
groupContainer.userData = {
isRebarGroup: true,
wingwallId: wwId,
mark: grp.mark,
dia: grp.dia,
zone: grp.zone,
baseColor: baseColor,
mat: mat,
groupData: grp
};
const radiusMeters = (grp.dia_mm / 2000.0) * this.rebarScale;
for (const poly of grp.polylines || []) {
for (let i = 0; i < poly.length - 1; i++) {
const p1 = new THREE.Vector3(poly[i][0], poly[i][1], poly[i][2]);
const p2 = new THREE.Vector3(poly[i + 1][0], poly[i + 1][1], poly[i + 1][2]);
const dir = new THREE.Vector3().subVectors(p2, p1);
const len = dir.length();
if (len < 1e-4) continue;
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
const segMesh = new THREE.Mesh(unitCylGeom, mat);
segMesh.position.copy(mid);
segMesh.quaternion.setFromUnitVectors(yAxis, dir.clone().normalize());
segMesh.scale.set(radiusMeters, len, radiusMeters);
segMesh.userData = {
isRebar: true,
mark: grp.mark,
wingwallId: wwId,
parentRebarGroup: groupContainer
};
groupContainer.add(segMesh);
if (i > 0) {
const joint = new THREE.Mesh(sphereGeom, mat);
joint.position.copy(p1);
joint.scale.setScalar(radiusMeters);
groupContainer.add(joint);
}
}
}
this.rebarGroupRoot.add(groupContainer);
this.rebarMeshes.push(groupContainer);
}
}
this.updateRebarVisibility();
}
setRebarThicknessScale(scaleVal) {
this.rebarScale = scaleVal;
if (this.modelData && this.modelData.rebars) {
this.buildRebarMeshes(this.modelData.rebars);
if (this.selectedRebarMark) {
this.selectRebarMark(this.selectedRebarMark);
}
}
}
setConcreteOpacity(opacityVal) {
this.concreteOpacity = opacityVal;
if (this.stepMode === 3) {
this.applyStepVisualState();
}
}
setStepMode(stepNumber) {
this.stepMode = stepNumber;
this.applyStepVisualState();
}
applyStepVisualState() {
const isStep3 = (this.stepMode === 3);
// Show/hide 3D rebar group root
if (this.rebarGroupRoot) {
this.rebarGroupRoot.visible = isStep3;
}
// Section markers only in Step 2
for (const line of this.sectionMarkers) {
line.visible = !isStep3;
}
for (const sp of this.labelSprites) {
sp.visible = !isStep3;
}
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
}
// Concrete appearance
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (isStep3) {
// In Step 3, wingwalls are semi-transparent ghosted shells so internal rebars shine through
mat.wireframe = false;
mat.transparent = true;
mat.depthWrite = false;
mat.opacity = (id === 'CULVERT') ? Math.min(0.18, this.concreteOpacity * 0.7) : this.concreteOpacity;
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
} else {
mat.depthWrite = true;
if (edgesMat) {
edgesMat.transparent = false;
edgesMat.opacity = 1.0;
}
this.setDisplayMode(this.displayMode);
this.selectObject(this.selectedObjectId);
}
}
if (isStep3) {
this.updateRebarVisibility();
}
}
updateRebarVisibility() {
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const matchWw = (this.rebarFilters.wingwall === 'ALL' || u.wingwallId === this.rebarFilters.wingwall);
const matchZone = this.rebarFilters.zones.has(u.zone);
const matchDia = this.rebarFilters.dias.has(u.dia);
const matchMark = this.rebarFilters.marks.has(u.mark);
grpObj.visible = matchWw && matchZone && matchDia && matchMark;
}
}
selectRebarMark(mark) {
this.selectedRebarMark = mark;
this.clearRebarLabels();
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const mat = u.mat;
if (u.mark === mark) {
mat.color.setHex(0xfacc15); // Bright highlight gold
mat.emissive.setHex(0xf59e0b);
mat.emissiveIntensity = 0.65;
// Add floating 3D callout sprite at midpoint of first bar in group
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 sp = this.createTextSprite(labelText, 0xfacc15);
sp.position.set(pt[0], pt[1], pt[2] + 0.55);
this.scene.add(sp);
this.rebarLabelSprites.push(sp);
}
}
} else {
mat.color.setHex(u.baseColor);
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
}
}
}
clearRebarLabels() {
for (const sp of this.rebarLabelSprites) {
this.scene.remove(sp);
}
this.rebarLabelSprites = [];
}
addSectionMarkers(objects) {
this.clearSectionMarkers();
const sections = [
{ name: '단면 A-A', pts: objects.ww_left?.sections_3d?.['A-A'], color: 0xef4444 },
{ name: '단면 B-B', pts: objects.ww_left?.sections_3d?.['B-B'], color: 0xf97316 },
{ name: '단면 C-C', pts: objects.ww_right?.sections_3d?.['C-C'], color: 0x10b981 },
{ name: '단면 D-D', pts: objects.ww_right?.sections_3d?.['D-D'], color: 0x8b5cf6 }
];
for (const s of sections) {
if (!s.pts) continue;
const points = s.pts.map(p => new THREE.Vector3(p[0], p[1], p[2]));
points.push(points[0].clone());
const geom = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: s.color, linewidth: 3 });
const line = new THREE.Line(geom, mat);
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);
}
}
}
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);
}
}
createTextSprite(text, colorHex) {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(18, 21, 28, 0.88)';
ctx.roundRect ? ctx.roundRect(4, 4, 312, 56, 8) : ctx.fillRect(4, 4, 312, 56);
ctx.fill();
ctx.strokeStyle = '#' + new THREE.Color(colorHex).getHexString();
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 160, 32);
const texture = new THREE.CanvasTexture(canvas);
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);
return sprite;
}
clearSectionMarkers() {
for (const line of this.sectionMarkers) {
this.scene.remove(line);
}
this.sectionMarkers = [];
for (const sprite of this.labelSprites) {
this.scene.remove(sprite);
}
this.labelSprites = [];
}
selectObject(objectId) {
this.selectedObjectId = objectId;
if (this.stepMode === 3) return;
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
if (id === objectId) {
mat.emissive = new THREE.Color(0x2563eb);
mat.emissiveIntensity = 0.45;
} else {
mat.emissive = new THREE.Color(0x000000);
mat.emissiveIntensity = 0;
}
}
}
toggleObjectVisibility(objectId, visible) {
if (this.meshes[objectId]) {
this.meshes[objectId].group.visible = visible;
}
}
setDisplayMode(mode) {
this.displayMode = mode;
for (const id in this.meshes) {
const mat = this.meshes[id].group.userData.mat;
if (mode === 'wireframe') {
mat.wireframe = true;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
} else if (mode === 'transparent') {
mat.wireframe = false;
mat.transparent = true;
mat.opacity = 0.35;
mat.depthWrite = false;
} else {
mat.wireframe = false;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
}
}
}
fitView() {
const box = new THREE.Box3();
for (const id in this.meshes) {
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);
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.lookAt(center);
this.controls.update();
}
onPointerDown(event) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
if (this.stepMode === 3) {
const visibleRebarChildren = [];
for (const grp of this.rebarMeshes) {
if (grp.visible) {
visibleRebarChildren.push(...grp.children);
}
}
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;
}
}
}
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.selectObject(objId);
if (this.onSelect) this.onSelect(objId);
}
}
onWindowResize() {
if (!this.container) return;
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) return;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2010-2021 Three.js Authors
* SPDX-License-Identifier: MIT
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="128",n=100,i=300,r=301,s=302,a=303,o=304,l=306,c=307,h=1e3,u=1001,d=1002,p=1003,m=1004,f=1005,g=1006,v=1007,y=1008,x=1009,_=1012,w=1014,b=1015,M=1016,S=1020,T=1022,E=1023,A=1026,L=1027,R=33776,C=33777,P=33778,D=33779,I=35840,N=35841,B=35842,z=35843,F=37492,O=37496,H=2300,G=2301,U=2302,k=2400,V=2401,W=2402,j=2500,q=2501,X=3e3,Y=3001,Z=3007,J=3002,Q=3004,K=3005,$=3006,tt=7680,et=35044,nt=35048,it="300 es";class rt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const st=[];for(let t=0;t<256;t++)st[t]=(t<16?"0":"")+t.toString(16);let at=1234567;const ot=Math.PI/180,lt=180/Math.PI;function ct(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(st[255&t]+st[t>>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=tLine truncated