feat(viewer): implement Three.js interactive 3D web viewer and Flask API server
This commit is contained in:
1 parent
42726ff1b4
commit
e5abc6f591
10 files changed
+5664
No files matched your search
@@ -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
@@ -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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 = [];
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Vendored
+6
@@ -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
|
||||
Reference in new issue
Block a user