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

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

No files matched your search

+1384
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
window.KNOWN_DRAWINGS_MAP = {"27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg": {"id": "27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg", "fileName": "27. 수로암거 일반도(부체도로#02 STA.0+017.46)_2-1공구.dwg", "drawingTitle": "수로암거 일반도(1) (부체도로#02 STA.0+017.46)", "titleBlock": {"mainTitle": "수로암거 일반도(1)", "subTitle": "(부체도로#02 STA.0+017.46)", "projectName": "고속국도 제30호선 대산~당진 고속도로 건설공사", "sectionName": "제2-1공구 : 사성~두산", "scale": "S = 1:100", "dwgCode": "C0040202-186", "dwgNo": "9.243", "targetWingwall": "암거 본체 및 시점부 및 종점부 날개벽 모두 존재"}, "classification": {"autoType": "GENERAL", "autoLabel": "일반도", "matchedKeyword": "일반", "reason": "도면명에 '일반' 키워드가 포함되어 일반도로 자동 분류됨", "needsManualSelection": false, "selectedType": "GENERAL", "selectedLabel": "일반도", "isManualOverride": false}, "structuralParams": {"station": "부체도로#02 STA.0+017.46", "skew_angle": 90.0, "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 228.64, "end_x": 392.78, "center_y": 172.42, "top_dashed_y": 228.935, "bot_dashed_y": 115.91, "clear_span": 11.3, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "BOTH", "configuration_label": "시점부 및 종점부 날개벽 모두 존재", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 좌측(시점부)과 우측(종점부) 모두에 날개벽이 표기되어 있어 양쪽 날개벽을 모두 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [166.44, 198.76, 0.0], "p2": [131.44, 198.76, 0.0], "length": 35.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [204.57, 183.21, 0.0], "p2": [206.31, 193.56, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [235.82, 188.1, 0.0], "p2": [206.23, 193.07, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [148.94, 196.26, 0.0], "p2": [166.26, 166.26, 0.0], "length": 34.64}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [199.53, 178.79, 0.0], "p2": [204.45, 177.97, 0.0], "length": 4.99}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [203.02, 169.47, 0.0], "p2": [204.45, 177.97, 0.0], "length": 8.61}], "texts": ["3.000", "A", "B"], "exists": true, "length": 3.0, "flare_angle": 0.0, "sections": ["A-A", "B-B"], "status_label": "도면 표기됨 (외형 모델링 대상)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선(6개 선), 단면(A-A ↔ B-B), L=3.00m, 0° 표기 검출"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [{"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [204.57, 73.6, 0.0], "p2": [206.31, 63.25, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [235.82, 68.72, 0.0], "p2": [206.23, 63.74, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [218.78, 109.75, 0.0], "p2": [208.42, 108.01, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [218.78, 147.06, 0.0], "p2": [208.42, 148.81, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-LEAL", "color": "#ff4d4f", "p1": [166.26, 166.26, 0.0], "p2": [186.21, 166.26, 0.0], "length": 19.95}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [227.86, 136.26, 0.0], "p2": [198.56, 142.92, 0.0], "length": 30.05}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [232.5, 163.87, 0.0], "p2": [201.54, 160.67, 0.0], "length": 31.12}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [229.18, 144.15, 0.0], "p2": [199.55, 148.84, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [229.14, 143.86, 0.0], "p2": [199.55, 148.84, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [228.52, 140.21, 0.0], "p2": [199.05, 145.88, 0.0], "length": 30.01}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [228.64, 140.91, 0.0], "p2": [199.05, 145.88, 0.0], "length": 30.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [198.56, 142.92, 0.0], "p2": [201.54, 160.67, 0.0], "length": 18.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [232.5, 92.94, 0.0], "p2": [201.54, 96.14, 0.0], "length": 31.12}, {"typLine truncated
window.DEFAULT_DRAWING_DATA = null;
window.DEFAULT_DRAWINGS_LIST = [];
+2
View File
@@ -0,0 +1,2 @@
window.KNOWN_MODELS_MAP = {"STA.13+611.21": {"station": "STA.13+611.21", "generalDrawingName": "13. 수로암거 일반도(STA.13+611.21)(01).dwg", "rebarDrawingNames": ["14. 수로암거 구조도(STA.13+611.21, 종점 우)(01).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 우)(02).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(03).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(04).dwg"], "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 339.77, "end_x": 638.17, "center_y": 447.38, "top_dashed_y": 457.38, "bot_dashed_y": 437.38, "clear_span": 2.0, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "END_ONLY", "configuration_label": "종점부 날개벽만 존재 (2개소 모델링 대상)", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 우측(종점부)에만 날개벽이 표기되어 있으며 암거 좌측(시점부)에는 날개벽이 존재하지 않으므로 종점부 날개벽만 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [337.45, 447.38, 0.0], "p2": [337.51, 447.38, 0.0], "length": 0.07}], "texts": ["(BLOCK2)", "19.980"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [], "texts": ["(BLOCK2)", "19.980", "19.980", "(Block2)"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 하단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "END_LEFT": {"id": "WW_END_LEFT", "name": "종점부 좌측 날개벽", "location": "종점부", "side": "좌측", "position_desc": "암거 우측(종점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [339.77, 447.38, 0.0], "p2": [436.77, 447.38, 0.0], "length": 97.0}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [437.45, 447.38, 0.0], "p2": [437.51, 447.38, 0.0], "length": 0.07}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [439.77, 447.38, 0.0], "p2": [751.56, 447.38, 0.0], "length": 311.79}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.17, 464.38, 0.0], "p2": [388.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [339.77, 464.38, 0.0], "p2": [388.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.17, 464.38, 0.0], "p2": [488.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [388.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [436.77, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [439.77, 464.38, 0.0], "p2": [488.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [488.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [638.17, 464.38, 0.0], "length": 149.8}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 494.38, 0.0], "p2": [438.27, 494.38, 0.0], "length": 100.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [438.27, 494.38, 0.0], "p2": [726.72, 494.38, 0.0], "length": 288.45}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 484.38, 0.0], "p2": [388.17, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 484.38, 0.0], "p2": [388.37, 484.38, 0.0], "length": 0.2}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 474.38, 0.0], "p2": [388.37, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 484.38, 0.0], "p2": [438.27, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML"Line truncated
window.DEFAULT_MODEL_3D = {"station": "STA.13+611.21", "generalDrawingName": "13. 수로암거 일반도(STA.13+611.21)(01).dwg", "rebarDrawingNames": ["14. 수로암거 구조도(STA.13+611.21, 종점 우)(01).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 우)(02).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(03).dwg", "14. 수로암거 구조도(STA.13+611.21, 종점 좌)(04).dwg"], "detection": {"culvert_detected": true, "culvert_bounds": {"start_x": 339.77, "end_x": 638.17, "center_y": 447.38, "top_dashed_y": 457.38, "bot_dashed_y": 437.38, "clear_span": 2.0, "detection_method": "평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별"}, "configuration": "END_ONLY", "configuration_label": "종점부 날개벽만 존재 (2개소 모델링 대상)", "rule_statement": "불러온 일반도 평면도 분석 결과, 암거 우측(종점부)에만 날개벽이 표기되어 있으며 암거 좌측(시점부)에는 날개벽이 존재하지 않으므로 종점부 날개벽만 모델링합니다.", "quadrants": {"START_LEFT": {"id": "WW_START_LEFT", "name": "시점부 좌측 날개벽", "location": "시점부", "side": "좌측", "position_desc": "암거 좌측(시점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [337.45, 447.38, 0.0], "p2": [337.51, 447.38, 0.0], "length": 0.07}], "texts": ["(BLOCK2)", "19.980"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 상단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "START_RIGHT": {"id": "WW_START_RIGHT", "name": "시점부 우측 날개벽", "location": "시점부", "side": "우측", "position_desc": "암거 좌측(시점부) 평면도 하단", "lines": [], "texts": ["(BLOCK2)", "19.980", "19.980", "(Block2)"], "exists": false, "length": 0.0, "flare_angle": 0.0, "sections": [], "status_label": "도면 미표기 (모델링 제외)", "reason": "평면도 암거 좌측(시점부) 평면도 하단에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"}, "END_LEFT": {"id": "WW_END_LEFT", "name": "종점부 좌측 날개벽", "location": "종점부", "side": "좌측", "position_desc": "암거 우측(종점부) 평면도 상단", "lines": [{"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [339.77, 447.38, 0.0], "p2": [436.77, 447.38, 0.0], "length": 97.0}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [437.45, 447.38, 0.0], "p2": [437.51, 447.38, 0.0], "length": 0.07}, {"type": "LINE", "layer": "CS-CNTL", "color": "#ff4d4f", "p1": [439.77, 447.38, 0.0], "p2": [751.56, 447.38, 0.0], "length": 311.79}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.17, 464.38, 0.0], "p2": [388.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [339.77, 464.38, 0.0], "p2": [388.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.17, 464.38, 0.0], "p2": [488.17, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [388.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [388.37, 464.38, 0.0], "p2": [436.77, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [439.77, 464.38, 0.0], "p2": [488.17, 464.38, 0.0], "length": 48.4}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [488.37, 430.38, 0.0], "length": 34.0}, {"type": "LINE", "layer": "CS-CONC-MAJR", "color": "#06b6d4", "p1": [488.37, 464.38, 0.0], "p2": [638.17, 464.38, 0.0], "length": 149.8}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 494.38, 0.0], "p2": [438.27, 494.38, 0.0], "length": 100.0}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [438.27, 494.38, 0.0], "p2": [726.72, 494.38, 0.0], "length": 288.45}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [338.27, 484.38, 0.0], "p2": [388.17, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 484.38, 0.0], "p2": [388.37, 484.38, 0.0], "length": 0.2}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 474.38, 0.0], "p2": [388.37, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.17, 474.38, 0.0], "p2": [388.17, 484.88, 0.0], "length": 10.5}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4f", "p1": [388.37, 484.38, 0.0], "p2": [438.27, 484.38, 0.0], "length": 49.9}, {"type": "LINE", "layer": "CS-DIML", "color": "#ff4d4Line truncated
+1710
View File
File diff suppressed because it is too large. Load diff
+316
View File
@@ -0,0 +1,316 @@
class CAD2DViewer {
constructor(canvasId, onSelectCallback) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.onSelect = onSelectCallback;
this.drawingData = null;
this.activeLayers = new Set();
this.highlightedView = null;
this.needsFit = true;
// View transform: pan & zoom
this.scale = 1.0;
this.offsetX = 0;
this.offsetY = 0;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.initEvents();
}
initEvents() {
this.canvas.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.clientX - this.offsetX;
this.startY = e.clientY - this.offsetY;
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
this.offsetX = e.clientX - this.startX;
this.offsetY = e.clientY - this.startY;
this.render();
});
window.addEventListener('mouseup', () => {
this.isDragging = false;
});
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.max(0.1, Math.min(this.scale * zoomFactor, 30.0));
this.offsetX = mouseX - (mouseX - this.offsetX) * (newScale / this.scale);
this.offsetY = mouseY - (mouseY - this.offsetY) * (newScale / this.scale);
this.scale = newScale;
this.render();
});
this.canvas.addEventListener('click', (e) => {
if (Math.abs(e.clientX - (this.startX + this.offsetX)) > 5) return;
this.handleClick(e);
});
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
if (w === 0 || h === 0) return;
this.canvas.width = w;
this.canvas.height = h;
if (this.needsFit) {
this.fitView();
this.needsFit = false;
} else {
this.render();
}
}
loadDrawing(drawingData) {
if (!drawingData) return;
this.drawingData = drawingData;
this.activeLayers = new Set(Object.keys(drawingData.layers || {}));
this.highlightedView = null;
this.needsFit = true;
const parent = this.canvas.parentElement;
if (parent && parent.clientWidth > 0 && parent.clientHeight > 0) {
this.canvas.width = parent.clientWidth;
this.canvas.height = parent.clientHeight;
this.fitView();
this.needsFit = false;
}
}
setLayerVisibility(layerName, isVisible) {
if (isVisible) {
this.activeLayers.add(layerName);
} else {
this.activeLayers.delete(layerName);
}
this.render();
}
fitView() {
if (!this.drawingData || !this.drawingData.bounds) return;
if (this.canvas.width === 0 || this.canvas.height === 0) {
this.needsFit = true;
return;
}
const b = this.drawingData.bounds;
const dwgW = Math.max(10, b.max_x - b.min_x);
const dwgH = Math.max(10, b.max_y - b.min_y);
const pad = 44;
const scaleX = (this.canvas.width - pad * 2) / dwgW;
const scaleY = (this.canvas.height - pad * 2) / dwgH;
this.scale = Math.max(0.1, Math.min(scaleX, scaleY));
this.offsetX = (this.canvas.width - dwgW * this.scale) / 2 - b.min_x * this.scale;
this.offsetY = (this.canvas.height + dwgH * this.scale) / 2 + b.min_y * this.scale;
this.render();
}
zoomToView(viewId) {
this.highlightedView = viewId;
if (!this.drawingData || !this.drawingData.views || !this.drawingData.views[viewId]) {
this.render();
return;
}
if (this.canvas.width > 0 && this.canvas.height > 0) {
const [bx0, by0, bx1, by1] = this.drawingData.views[viewId].bbox;
const vw = Math.max(10, bx1 - bx0);
const vh = Math.max(10, by1 - by0);
const pad = 60;
const scaleX = (this.canvas.width - pad * 2) / vw;
const scaleY = (this.canvas.height - pad * 2) / vh;
this.scale = Math.max(0.2, Math.min(scaleX, scaleY, 12.0));
this.offsetX = (this.canvas.width - vw * this.scale) / 2 - bx0 * this.scale;
this.offsetY = (this.canvas.height + vh * this.scale) / 2 + by0 * this.scale;
}
this.render();
}
worldToScreen(x, y) {
return {
x: x * this.scale + this.offsetX,
y: -y * this.scale + this.offsetY
};
}
screenToWorld(sx, sy) {
return {
x: (sx - this.offsetX) / this.scale,
y: -(sy - this.offsetY) / this.scale
};
}
highlightView(viewId) {
this.highlightedView = viewId;
this.render();
}
handleClick(e) {
if (!this.drawingData || !this.drawingData.views) return;
const rect = this.canvas.getBoundingClientRect();
const world = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
for (const [vid, view] of Object.entries(this.drawingData.views)) {
const [bx0, by0, bx1, by1] = view.bbox;
if (world.x >= bx0 && world.x <= bx1 && world.y >= by0 && world.y <= by1) {
this.highlightedView = vid;
this.render();
if (view.wingwall && this.onSelect) {
this.onSelect(view.wingwall);
} else if (vid === 'PLAN' && this.onSelect) {
this.onSelect('WW_END_LEFT');
}
return;
}
}
}
render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.drawingData) return;
ctx.lineWidth = Math.max(1.0, 0.75 * this.scale);
for (const ent of this.drawingData.entities || []) {
if (!this.activeLayers.has(ent.layer)) continue;
ctx.strokeStyle = ent.color || '#cccccc';
ctx.fillStyle = ent.color || '#cccccc';
if (ent.type === 'LINE') {
const p1 = this.worldToScreen(ent.p1[0], ent.p1[1]);
const p2 = this.worldToScreen(ent.p2[0], ent.p2[1]);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
} else if (ent.type === 'LWPOLYLINE') {
if (!ent.points || ent.points.length === 0) continue;
ctx.beginPath();
const p0 = this.worldToScreen(ent.points[0][0], ent.points[0][1]);
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < ent.points.length; i++) {
const pt = this.worldToScreen(ent.points[i][0], ent.points[i][1]);
ctx.lineTo(pt.x, pt.y);
}
if (ent.closed) ctx.closePath();
ctx.stroke();
} else if (ent.type === 'TEXT') {
const pos = this.worldToScreen(ent.pos[0], ent.pos[1]);
const fontSize = Math.max(7, ent.height * this.scale * 0.85);
if (fontSize < 3.5) continue;
ctx.font = `${Math.min(fontSize, 26)}px -apple-system, "Malgun Gothic", sans-serif`;
ctx.fillText(ent.text, pos.x, pos.y);
} else if (ent.type === 'ARC' || ent.type === 'CIRCLE') {
const c = this.worldToScreen(ent.center[0], ent.center[1]);
const r = ent.radius * this.scale;
ctx.beginPath();
if (ent.type === 'CIRCLE') {
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
} else {
const sAngle = -THREE.MathUtils.degToRad(ent.end_angle);
const eAngle = -THREE.MathUtils.degToRad(ent.start_angle);
ctx.arc(c.x, c.y, r, sAngle, eAngle);
}
ctx.stroke();
}
}
// Draw View Boundary Boxes & Labels
for (const [vid, view] of Object.entries(this.drawingData.views || {})) {
const [bx0, by0, bx1, by1] = view.bbox;
const pTopLeft = this.worldToScreen(bx0, by1);
const pBotRight = this.worldToScreen(bx1, by0);
const w = pBotRight.x - pTopLeft.x;
const h = pBotRight.y - pTopLeft.y;
const isHigh = (this.highlightedView === vid);
ctx.strokeStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : 'rgba(148, 163, 184, 0.45)');
ctx.lineWidth = isHigh ? 2.5 : 1.0;
ctx.setLineDash(view.is_excluded ? [4, 4] : (isHigh ? [] : [3, 3]));
ctx.strokeRect(pTopLeft.x, pTopLeft.y, w, h);
ctx.setLineDash([]);
if (isHigh) {
ctx.fillStyle = 'rgba(56, 189, 248, 0.06)';
ctx.fillRect(pTopLeft.x, pTopLeft.y, w, h);
}
ctx.fillStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : '#94a3b8');
ctx.font = 'bold 11px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText(view.name, pTopLeft.x + 6, pTopLeft.y + 15);
}
// Draw Plan View Culvert & Wingwall Quadrant Overlay if available
const planView = this.drawingData.views?.PLAN;
const det = this.drawingData.structuralParams?.detection || planView?.detection;
if (planView && det && det.culvert_bounds) {
const cb = det.culvert_bounds;
const midY = cb.center_y || 416.5;
const startX = cb.start_x || 106.91;
const endX = cb.end_x || 162.93;
const pC0 = this.worldToScreen(startX, cb.top_dashed_y || 426.5);
const pC1 = this.worldToScreen(endX, cb.bot_dashed_y || 406.5);
const cW = pC1.x - pC0.x;
const cH = pC1.y - pC0.y;
ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(pC0.x, pC0.y, cW, cH);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
ctx.fillRect(pC0.x, pC0.y, cW, cH);
ctx.fillStyle = '#38bdf8';
ctx.font = 'bold 10px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText('🏢 암거 판별 (내공 2.0m & 점선)', pC0.x + 4, pC0.y - 4);
// Label detected wingwalls
const quads = det.quadrants || {};
if (quads.END_LEFT?.exists) {
const pEL = this.worldToScreen(endX + 5, midY + 28);
ctx.fillStyle = '#38bdf8';
ctx.fillText('🟢 종점부 좌측 날개벽 (L=4.35m, 134°)', pEL.x, pEL.y);
}
if (quads.END_RIGHT?.exists) {
const pER = this.worldToScreen(endX + 5, midY - 32);
ctx.fillStyle = '#fbbf24';
ctx.fillText('🟢 종점부 우측 날개벽 (L=5.40m, 151°)', pER.x, pER.y);
}
if (!quads.START_LEFT?.exists) {
const pSL = this.worldToScreen(startX - 50, midY + 28);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 좌측 (미표기)', pSL.x, pSL.y);
}
if (!quads.START_RIGHT?.exists) {
const pSR = this.worldToScreen(startX - 50, midY - 32);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 우측 (미표기)', pSR.x, pSR.y);
}
}
}
}
+639
View File
@@ -0,0 +1,639 @@
class Viewer3D {
constructor(containerId, onSelectCallback, onSelectRebarCallback) {
this.container = document.getElementById(containerId);
this.onSelect = onSelectCallback;
this.onSelectRebar = onSelectRebarCallback;
this.meshes = {};
this.rebarMeshes = [];
this.rebarGroupRoot = null;
this.sectionMarkers = [];
this.labelSprites = [];
this.rebarLabelSprites = [];
this.displayMode = 'solid'; // 'solid', 'wireframe', 'transparent'
this.stepMode = 2; // 2: Exterior only, 3: Exterior (ghosted) + 3D Rebars
this.selectedObjectId = null;
this.selectedRebarMark = null;
this.concreteOpacity = 0.25;
this.rebarScale = 1.4;
this.rebarFilters = {
wingwall: 'ALL', // 'ALL', 'WW_END_LEFT', 'WW_END_RIGHT'
zones: new Set([
'우각부 보강근 (암거접합부)',
'벽체 전면 철근',
'벽체 배면 철근',
'저판 상면 철근',
'저판 하면 철근',
'헌치 및 전단보강근'
]),
dias: new Set(['H19', 'H16', 'H13']),
marks: new Set() // populated on load
};
this.init();
}
init() {
const width = this.container.clientWidth || 800;
const height = this.container.clientHeight || 600;
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0e1118);
// Camera (Z is up)
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
this.camera.up.set(0, 0, 1);
this.camera.position.set(35, 20, 50);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
// Controls
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.target.set(16, 41, 35);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.70);
this.scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.85);
dirLight1.position.set(30, -20, 60);
dirLight1.castShadow = true;
dirLight1.shadow.mapSize.width = 2048;
dirLight1.shadow.mapSize.height = 2048;
this.scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x93c5fd, 0.40);
dirLight2.position.set(-20, 30, 40);
this.scene.add(dirLight2);
this.rebarGroupRoot = new THREE.Group();
this.scene.add(this.rebarGroupRoot);
// Ground Grid & Axes
this.setupGridAndAxes();
// Raycaster for object & rebar picking
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onPointerDown(e));
window.addEventListener('resize', () => this.onWindowResize());
this.animate();
}
setupGridAndAxes() {
this.gridHelper = new THREE.GridHelper(50, 50, 0x3b82f6, 0x1e293b);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.set(16, 41, 33.0);
this.scene.add(this.gridHelper);
this.axesGroup = new THREE.Group();
this.axesGroup.position.set(5, 30, 33.0);
const axes = new THREE.AxesHelper(5);
this.axesGroup.add(axes);
this.scene.add(this.axesGroup);
}
loadModel(modelData) {
this.modelData = modelData;
// Clear previous concrete meshes
for (const key in this.meshes) {
this.scene.remove(this.meshes[key].group);
}
this.meshes = {};
this.clearSectionMarkers();
const objects = modelData.objects || {};
const botEl = objects.culvert?.dimensions?.bot_el ?? 33.404;
if (this.gridHelper) this.gridHelper.position.set(16, 41, botEl - 0.35);
if (this.axesGroup) this.axesGroup.position.set(5, 30, botEl - 0.35);
const colorMap = {
'CULVERT': { base: 0xa1a1aa, edge: 0x52525b },
'WW_END_LEFT': { base: 0x38bdf8, edge: 0x0284c7 },
'WW_END_RIGHT': { base: 0xfbbf24, edge: 0xd97706 },
'WW_START_LEFT': { base: 0x34d399, edge: 0x059669 },
'WW_START_RIGHT': { base: 0xa78bfa, edge: 0x7c3aed },
'culvert': { base: 0xa1a1aa, edge: 0x52525b },
'ww_left': { base: 0x38bdf8, edge: 0x0284c7 },
'ww_right': { base: 0xfbbf24, edge: 0xd97706 }
};
const renderedIds = new Set();
const candidateList = [];
if (objects.culvert) candidateList.push(objects.culvert);
if (Array.isArray(objects.wingwalls)) {
candidateList.push(...objects.wingwalls);
} else {
for (const k in objects) {
const o = objects[k];
if (o && o.vertices && o.faces && o.id) {
candidateList.push(o);
}
}
}
for (const obj of candidateList) {
if (!obj || !obj.vertices || !obj.faces || renderedIds.has(obj.id)) continue;
renderedIds.add(obj.id);
const col = colorMap[obj.id] || colorMap[obj.name] || { base: 0x94a3b8, edge: 0x475569 };
const meshGroup = this.createMeshGroup(obj, col);
this.scene.add(meshGroup);
this.meshes[obj.id] = {
group: meshGroup,
data: obj,
baseColor: col.base,
edgeColor: col.edge
};
}
// Add Section Cut Markers & EL Labels
this.addSectionMarkers(objects);
this.addAnnotations(objects);
// Load 3D Rebars if present
if (modelData.rebars) {
this.buildRebarMeshes(modelData.rebars);
}
this.applyStepVisualState();
this.fitView();
}
createMeshGroup(obj, col) {
const group = new THREE.Group();
group.userData = { id: obj.id, name: obj.name };
const geom = new THREE.BufferGeometry();
const vertices = new Float32Array(obj.vertices.flat());
const indices = [];
for (const f of obj.faces) {
indices.push(f[0], f[1], f[2]);
}
geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
const isCulvert = (obj.id === 'CULVERT');
const mat = new THREE.MeshStandardMaterial({
color: col.base,
roughness: 0.6,
metalness: 0.1,
side: isCulvert ? THREE.DoubleSide : THREE.FrontSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const mesh = new THREE.Mesh(geom, mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { id: obj.id, parentGroup: group };
group.add(mesh);
const edgesGeom = new THREE.EdgesGeometry(geom, 25);
const edgesMat = new THREE.LineBasicMaterial({ color: col.edge, linewidth: 1.5 });
const edgeLines = new THREE.LineSegments(edgesGeom, edgesMat);
group.add(edgeLines);
group.userData.mesh = mesh;
group.userData.edgeLines = edgeLines;
group.userData.mat = mat;
group.userData.edgesMat = edgesMat;
return group;
}
buildRebarMeshes(rebarData) {
// Clear existing rebar meshes
while (this.rebarGroupRoot.children.length > 0) {
const child = this.rebarGroupRoot.children[0];
this.rebarGroupRoot.remove(child);
}
this.rebarMeshes = [];
this.clearRebarLabels();
const diaColors = {
'H19': 0xf43f5e, // Rose Red
'H16': 0x38bdf8, // Sky Blue
'H13': 0x10b981 // Emerald Green
};
this.rebarFilters.marks.clear();
const unitCylGeom = new THREE.CylinderGeometry(1, 1, 1, 7, 1, false);
const sphereGeom = new THREE.SphereGeometry(1.05, 6, 6);
const yAxis = new THREE.Vector3(0, 1, 0);
for (const [wwId, ww] of Object.entries(rebarData.wingwalls || {})) {
for (const grp of ww.groups || []) {
this.rebarFilters.marks.add(grp.mark);
const baseColor = diaColors[grp.dia] || 0xeab308;
const mat = new THREE.MeshStandardMaterial({
color: baseColor,
roughness: 0.35,
metalness: 0.45
});
const groupContainer = new THREE.Group();
groupContainer.userData = {
isRebarGroup: true,
wingwallId: wwId,
mark: grp.mark,
dia: grp.dia,
zone: grp.zone,
baseColor: baseColor,
mat: mat,
groupData: grp
};
const radiusMeters = (grp.dia_mm / 2000.0) * this.rebarScale;
for (const poly of grp.polylines || []) {
for (let i = 0; i < poly.length - 1; i++) {
const p1 = new THREE.Vector3(poly[i][0], poly[i][1], poly[i][2]);
const p2 = new THREE.Vector3(poly[i + 1][0], poly[i + 1][1], poly[i + 1][2]);
const dir = new THREE.Vector3().subVectors(p2, p1);
const len = dir.length();
if (len < 1e-4) continue;
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
const segMesh = new THREE.Mesh(unitCylGeom, mat);
segMesh.position.copy(mid);
segMesh.quaternion.setFromUnitVectors(yAxis, dir.clone().normalize());
segMesh.scale.set(radiusMeters, len, radiusMeters);
segMesh.userData = {
isRebar: true,
mark: grp.mark,
wingwallId: wwId,
parentRebarGroup: groupContainer
};
groupContainer.add(segMesh);
if (i > 0) {
const joint = new THREE.Mesh(sphereGeom, mat);
joint.position.copy(p1);
joint.scale.setScalar(radiusMeters);
groupContainer.add(joint);
}
}
}
this.rebarGroupRoot.add(groupContainer);
this.rebarMeshes.push(groupContainer);
}
}
this.updateRebarVisibility();
}
setRebarThicknessScale(scaleVal) {
this.rebarScale = scaleVal;
if (this.modelData && this.modelData.rebars) {
this.buildRebarMeshes(this.modelData.rebars);
if (this.selectedRebarMark) {
this.selectRebarMark(this.selectedRebarMark);
}
}
}
setConcreteOpacity(opacityVal) {
this.concreteOpacity = opacityVal;
if (this.stepMode === 3) {
this.applyStepVisualState();
}
}
setStepMode(stepNumber) {
this.stepMode = stepNumber;
this.applyStepVisualState();
}
applyStepVisualState() {
const isStep3 = (this.stepMode === 3);
// Show/hide 3D rebar group root
if (this.rebarGroupRoot) {
this.rebarGroupRoot.visible = isStep3;
}
// Section markers only in Step 2
for (const line of this.sectionMarkers) {
line.visible = !isStep3;
}
for (const sp of this.labelSprites) {
sp.visible = !isStep3;
}
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
}
// Concrete appearance
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (isStep3) {
// In Step 3, wingwalls are semi-transparent ghosted shells so internal rebars shine through
mat.wireframe = false;
mat.transparent = true;
mat.depthWrite = false;
mat.opacity = (id === 'CULVERT') ? Math.min(0.18, this.concreteOpacity * 0.7) : this.concreteOpacity;
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
} else {
mat.depthWrite = true;
if (edgesMat) {
edgesMat.transparent = false;
edgesMat.opacity = 1.0;
}
this.setDisplayMode(this.displayMode);
this.selectObject(this.selectedObjectId);
}
}
if (isStep3) {
this.updateRebarVisibility();
}
}
updateRebarVisibility() {
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const matchWw = (this.rebarFilters.wingwall === 'ALL' || u.wingwallId === this.rebarFilters.wingwall);
const matchZone = this.rebarFilters.zones.has(u.zone);
const matchDia = this.rebarFilters.dias.has(u.dia);
const matchMark = this.rebarFilters.marks.has(u.mark);
grpObj.visible = matchWw && matchZone && matchDia && matchMark;
}
}
selectRebarMark(mark) {
this.selectedRebarMark = mark;
this.clearRebarLabels();
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const mat = u.mat;
if (u.mark === mark) {
mat.color.setHex(0xfacc15); // Bright highlight gold
mat.emissive.setHex(0xf59e0b);
mat.emissiveIntensity = 0.65;
// Add floating 3D callout sprite at midpoint of first bar in group
const polys = u.groupData.polylines;
if (polys && polys.length > 0) {
const midPoly = polys[Math.floor(polys.length / 2)];
const pt = midPoly[Math.floor(midPoly.length / 2)];
if (pt) {
const labelText = `[${u.mark}] ${u.dia} (${u.groupData.role})`;
const sp = this.createTextSprite(labelText, 0xfacc15);
sp.position.set(pt[0], pt[1], pt[2] + 0.55);
this.scene.add(sp);
this.rebarLabelSprites.push(sp);
}
}
} else {
mat.color.setHex(u.baseColor);
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
}
}
}
clearRebarLabels() {
for (const sp of this.rebarLabelSprites) {
this.scene.remove(sp);
}
this.rebarLabelSprites = [];
}
addSectionMarkers(objects) {
this.clearSectionMarkers();
const sections = [
{ name: '단면 A-A', pts: objects.ww_left?.sections_3d?.['A-A'], color: 0xef4444 },
{ name: '단면 B-B', pts: objects.ww_left?.sections_3d?.['B-B'], color: 0xf97316 },
{ name: '단면 C-C', pts: objects.ww_right?.sections_3d?.['C-C'], color: 0x10b981 },
{ name: '단면 D-D', pts: objects.ww_right?.sections_3d?.['D-D'], color: 0x8b5cf6 }
];
for (const s of sections) {
if (!s.pts) continue;
const points = s.pts.map(p => new THREE.Vector3(p[0], p[1], p[2]));
points.push(points[0].clone());
const geom = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: s.color, linewidth: 3 });
const line = new THREE.Line(geom, mat);
this.scene.add(line);
this.sectionMarkers.push(line);
const topPt = points[4];
if (topPt) {
const sprite = this.createTextSprite(s.name, s.color);
sprite.position.set(topPt.x, topPt.y, topPt.z + 0.6);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
}
}
addAnnotations(objects) {
const wwL = objects.ww_left;
const wwR = objects.ww_right;
if (!wwL || !wwR) return;
const ptA = wwL.sections_3d?.['A-A']?.[5] || [15.725, 42.650, wwL.dimensions.top_el_start];
const ptB = wwL.sections_3d?.['B-B']?.[5] || [18.75, 45.77, wwL.dimensions.top_el_end];
const ptD = wwR.sections_3d?.['D-D']?.[5] || [21.01, 38.02, wwR.dimensions.top_el_end];
const stName = this.modelData?.station || '종점부';
const elMarkers = [
{ text: `EL. ${wwL.dimensions.top_el_start.toFixed(3)} (암거 접합부)`, pos: ptA, color: 0x38bdf8 },
{ text: `EL. ${wwL.dimensions.top_el_end.toFixed(3)} (좌 말단 L=${wwL.dimensions.length}m)`, pos: ptB, color: 0x38bdf8 },
{ text: `EL. ${wwR.dimensions.top_el_end.toFixed(3)} (우 말단 L=${wwR.dimensions.length}m)`, pos: ptD, color: 0xfbbf24 },
{ text: `${stName} (종점부)`, pos: [16.0, 41.65, wwL.dimensions.top_el_start + 0.5], color: 0xffffff }
];
for (const m of elMarkers) {
const sprite = this.createTextSprite(m.text, m.color);
sprite.position.set(m.pos[0], m.pos[1], m.pos[2] + 0.4);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
}
createTextSprite(text, colorHex) {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(18, 21, 28, 0.88)';
ctx.roundRect ? ctx.roundRect(4, 4, 312, 56, 8) : ctx.fillRect(4, 4, 312, 56);
ctx.fill();
ctx.strokeStyle = '#' + new THREE.Color(colorHex).getHexString();
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 160, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(3.6, 0.75, 1);
return sprite;
}
clearSectionMarkers() {
for (const line of this.sectionMarkers) {
this.scene.remove(line);
}
this.sectionMarkers = [];
for (const sprite of this.labelSprites) {
this.scene.remove(sprite);
}
this.labelSprites = [];
}
selectObject(objectId) {
this.selectedObjectId = objectId;
if (this.stepMode === 3) return;
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
if (id === objectId) {
mat.emissive = new THREE.Color(0x2563eb);
mat.emissiveIntensity = 0.45;
} else {
mat.emissive = new THREE.Color(0x000000);
mat.emissiveIntensity = 0;
}
}
}
toggleObjectVisibility(objectId, visible) {
if (this.meshes[objectId]) {
this.meshes[objectId].group.visible = visible;
}
}
setDisplayMode(mode) {
this.displayMode = mode;
for (const id in this.meshes) {
const mat = this.meshes[id].group.userData.mat;
if (mode === 'wireframe') {
mat.wireframe = true;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
} else if (mode === 'transparent') {
mat.wireframe = false;
mat.transparent = true;
mat.opacity = 0.35;
mat.depthWrite = false;
} else {
mat.wireframe = false;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
}
}
}
fitView() {
const box = new THREE.Box3();
for (const id in this.meshes) {
box.expandByObject(this.meshes[id].group);
}
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.35, center.y - maxDim * 1.35, center.z + maxDim * 1.05);
this.camera.lookAt(center);
this.controls.update();
}
onPointerDown(event) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
if (this.stepMode === 3) {
const visibleRebarChildren = [];
for (const grp of this.rebarMeshes) {
if (grp.visible) {
visibleRebarChildren.push(...grp.children);
}
}
const hits = this.raycaster.intersectObjects(visibleRebarChildren, false);
if (hits.length > 0) {
const hitObj = hits[0].object;
const mark = hitObj.userData.mark || hitObj.parent?.userData?.mark;
if (mark) {
this.selectRebarMark(mark);
if (this.onSelectRebar) this.onSelectRebar(mark, hitObj.userData.wingwallId);
return;
}
}
}
const meshes = Object.values(this.meshes).map(m => m.group.userData.mesh);
const intersects = this.raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
const hitMesh = intersects[0].object;
const objId = hitMesh.userData.id;
this.selectObject(objId);
if (this.onSelect) this.onSelect(objId);
}
}
onWindowResize() {
if (!this.container) return;
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) return;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2010-2021 Three.js Authors
* SPDX-License-Identifier: MIT
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="128",n=100,i=300,r=301,s=302,a=303,o=304,l=306,c=307,h=1e3,u=1001,d=1002,p=1003,m=1004,f=1005,g=1006,v=1007,y=1008,x=1009,_=1012,w=1014,b=1015,M=1016,S=1020,T=1022,E=1023,A=1026,L=1027,R=33776,C=33777,P=33778,D=33779,I=35840,N=35841,B=35842,z=35843,F=37492,O=37496,H=2300,G=2301,U=2302,k=2400,V=2401,W=2402,j=2500,q=2501,X=3e3,Y=3001,Z=3007,J=3002,Q=3004,K=3005,$=3006,tt=7680,et=35044,nt=35048,it="300 es";class rt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const st=[];for(let t=0;t<256;t++)st[t]=(t<16?"0":"")+t.toString(16);let at=1234567;const ot=Math.PI/180,lt=180/Math.PI;function ct(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(st[255&t]+st[t>>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=tLine truncated