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
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