Compare commits

...
10 Commits
22 changed files with 700 additions and 8928 deletions

No files matched your search

-43
View File
@@ -1,43 +0,0 @@
# 프로젝트 지침: 도면 기반 웹 3D 모델
## 1. 도면 분석
- 평면도, 상부평면도, 종단면도, 단면도 A-A, 단면도 B-B, 측구 상세도를 참고한다.
- 평면도·종단면도·단면도·상세도에서 같은 대상을 어떤 근거로 연결해 읽었는지 설명한다.
- 모델은 평면도상의 선형 중심선과 종단면도, 단면 A-A, 단면 B-B를 기준으로 한다.
- 평면도·종단면도·단면도·상세도의 축척값을 확인한다.
- 평면도 중심 선형의 시점과 단면도 A-A, 종점과 단면도 B-B의 중심 기준점 EL값을 일치시킨다.
- 종단면도의 시종점 벽체 상단 EL값과 단면 A-A, 단면 B-B의 벽체 상단 EL값이 같은지 확인한다.
- 실제 모델 윤곽선인 `CS-CONC-MAJR`, `CS-CONC-MINR` 레이어의 구조물선과 해당 치수가 일치하는지 확인한다.
- 두 레이어의 표기 치수가 부족하면 실제 윤곽선을 측정해 모델링한다.
## 2. 모델링 값 기록
- 대상별 위치, 길이, 폭, 높이, 두께, 간격, 경사 등 모델에 필요한 값을 표로 정리한다.
- 각 값에 근거 도면 번호와 도면 안의 위치를 함께 기록한다.
- 각 값을 다음 상태 중 하나로 표시한다: `도면 명시값`, `계산으로 도출한 값`, `추정값`, `확인 필요`.
- 축척으로 재어 얻은 값은 도면에 적힌 치수와 혼동되지 않도록 별도로 표시한다.
## 3. 오류 및 정보 부족 검토
- 도면 간 치수·위치·높이의 불일치를 확인해 기록한다.
- 누락되었거나 판독하기 어려운 정보를 목록으로 만든다.
- 형상과 배치에 영향을 주는 치수는 임의로 결정하지 않고 사용자 확인을 요청한다.
## 4. 모델 구성 및 생성
- 단면도의 치수선을 제외한 구조물선을 빠짐없이 모델링에 반영한다.
- 부재를 개별 선택·숨김·수정할 수 있도록 객체를 나누고 알아보기 쉬운 이름을 붙인다.
- 도면 개정 때 치수와 배치를 쉽게 바꾸도록 값을 코드와 모델 구조에서 관리하기 쉽게 구성한다.
- 먼저 주요 형상과 전체 배치를 만들고, 이후 세부 형상을 추가한다.
## 5. 웹 3D 뷰어
- 마우스 또는 터치로 회전·확대/축소·이동할 수 있게 한다.
- 전체 보기와 평면·정면·측면 보기를 제공한다.
- 객체를 선택하면 이름과 주요 치수를 보여준다.
- 객체별 표시/숨김 기능을 제공한다.
- 추정값 또는 확인 필요 항목으로 만든 형상은 화면에서 명확히 구분한다.
- 기본 모델 보기와 표면 명암·배경·그림자를 적용한 렌더링 보기를 전환할 수 있게 한다. 렌더링 보기는 도면 검토용 시각화이며 실사 렌더링으로 오해하지 않도록 표시한다.
## 6. 검증 및 결과 보고
- 주요 치수, 기준 높이, 부재 개수와 배치를 원도면과 대조한다.
- 검증 완료 항목과 미검증 항목을 나누어 보고한다.
- 도면과 다르게 표현한 부분, 추정한 부분, 생략한 부분을 기록한다.
- 실행 방법과 다음 수정 때 변경할 항목을 안내한다.
- 변경 후 가능한 실행·검증을 수행하고, 확인하지 못한 부분을 명시한다.
+13 -12
View File
@@ -1,23 +1,24 @@
# 새 3D 모델 프로젝트
# 광터지하차도 BLOCK8 3D 모델
React와 Vite를 사용하는 웹 프로젝트 기본 틀입니다. 작업을 시작할 수 있도록 앱 화면과 프로젝트별 자료를 비워 두었습니다.
React와 Vite로 만든 도면 검토용 웹 3D 모델입니다. 평면 중심선과 종단·A-A/B-B 단면 구조선을 연결해 단일 구조물로 표현합니다. 화면의 면은 검토용 열린 표면이며 제작용 상세 모델이 아닙니다.
## 실행
```bash
npm install
npm run dev
```
## 주요 명령
주요 확인 명령은 `npm run build`와 `npm run lint`입니다.
- `npm run dev`: 개발 서버 실행
- `npm run build`: 배포용 파일 생성
- `npm run preview`: 빌드한 화면 미리보기
- `npm run lint`: 코드 기본 검사
## 주요 경로
## 시작 위치
- `src/App.jsx`, `src/ModelViewer.jsx`: 웹 화면과 3D 뷰어
- `src/model/block8.json`: 모델 데이터
- `public/drawings/광터지하차도_BLOCK8.dxf`: 원본 도면
- `public/analysis/도면분석.md`: 도면 판독 근거, 모델 값 및 미검증 사항
- `public/작업기록.txt`: 작업 이력
- `src/App.jsx`: 웹 화면
- `src/index.css`: 화면 스타일
- `src/main.jsx`: 앱 시작 파일
- `index.html`: 페이지 기본 정보
## 도면 분석 원칙
도면에 적힌 값, 계산값, 축척 측정값, 추정값을 구분해 기록합니다. 도면 간 차이와 확인이 필요한 항목은 분석 문서에 남기며, 근거가 부족한 형상·치수는 임의로 확정하지 않습니다.
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 270 KiB

-77
View File
@@ -1,77 +0,0 @@
# 광터지하차도 BLOCK8 도면 분석 및 모델링 기록
## 사용 도면과 연결 근거
| 도면 | 자료 위치 | 읽은 내용 | 상태 |
|---|---|---|---|
| 평면도 | `public/drawings/광터지하차도_BLOCK8.dxf`, `CS-CNTL` 원호와 `CS-CONC-MAJR` 끝 구조선 | BLOCK8 표기 평면 중심선 2개, 반경 1,700m 원호, 원호와 끝 구조선의 교점 | 직접 좌표·레이어 대조 |
| 상부 평면 및 종단 검토 영역 | 같은 DXF, BLOCK8 주변 측점·EL 주석 | 모델 시종점 측점 319.010/338.990, 길이 19.980m | 측점은 도면값, 길이는 교점 간 계산값 |
| 단면 A-A | 같은 DXF, `STA. 0K+319.000` 주석이 있는 단면 영역 | 단면 구조선 114개, EL 표기와 폭 분할 | 직접 추출 및 대조 |
| 단면 B-B | 같은 DXF, `STA. 0K+339.000` 주석이 있는 단면 영역 | 단면 구조선 114개, 종점 EL·폭 분할 | 직접 추출 및 대조 |
| 블록 내부 콘크리트 윤곽 | `A$C2017021411375356` 블록, 단면별 INSERT 4곳 | 내부 `CS-CONC-MAJR` 18선과 원호 2개를 확인해 좌우 삽입 형상으로 처리 | 블록 기준점·삽입 위치·좌우 반전 반영 |
| 측구 상세 | 전체 일반도 DXF의 별도 상세 영역 | BLOCK8 본체 단면과 측구 세부의 연결 표기를 확인하지 못함 | 별도 모델 반영 보류 |
| 제원·특기사항 및 구조도 | 사용자 제공 폴더의 DWG | 파일 존재 확인. DWG 직접 렌더링/교차검토는 이 실행 환경에서 수행하지 못함 | 후속 검증 필요 |
두 단면은 DXF의 동일 구조 윤곽 영역을 측점 주석(`319.000`, `339.000`)과 연결했다. 각 단면의 `CS-CNTL` 중심 표시 교차점 EL은 각각 102.094m와 101.517m이며, 같은 단면 영역의 EL 주석과 일치한다. 벽체 상단 EL 107.215m/107.857m도 종단 검토 영역에 반복 표기되어 각 단면과 맞는다. 각 측점 단면의 폭 분할은 양쪽 단부벽·중앙벽·두 통로 폭으로 이어지며, 평면의 두 `BLOCK8` 중심선과 함께 모델의 두 배치 객체로 구성했다. 두 평면 배치에 대표 단면을 공통 적용한 관계는 구조도 대조가 끝나지 않아 `확인 필요`로 표시한다.
## 축척 및 좌표 단위 확인
- 평면도 영역의 축척 표기: `S=1:100`.
- 단면 영역의 축척 표기: `S=1:100`.
- 종단 검토 영역의 축척 표기: `S=1:100`.
- 상세 영역에는 `S=1:30`, `S=1:20`, `S=1:3500` 등 다른 표기도 있으므로 각 상세별 표기를 따로 적용해야 한다.
- DXF HEADER는 `$INSUNITS=0`으로 단위를 지정하지 않았다. 다만 단면 구조선의 좌표 차 20,300이 표기 치수 20.300m와 맞고, 중심선 원호를 구조물 끝선에 교차시켜 계산한 길이가 19.980m로 측점 차와 일치한다. 이 교차 검산에 따라 모델 생성에 한해 좌표를 mm로 보고 m로 환산했다. 단위 판정은 계산 근거이며 HEADER 명시값은 아니다.
## 모델링 값과 근거
| 대상/값 | 모델 값 | 근거 위치 | 상태 |
|---|---:|---|---|
| 모델 시점 | STA. 0K+319.010 | 평면도 BLOCK8 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 모델 종점 | STA. 0K+338.990 | 반대쪽 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 중심선 반경 | 1,700m | 평면도 `CS-CNTL` ARC 반경 1,700,000 CAD 단위 | 좌표 단위 검산 후 환산값 |
| 평면 중심선 길이 | 19.980m, 각 배치 | ARC와 `CS-CONC-MAJR` 양 끝선 교점 사이 원호 길이. 교점 각도 약 269.663303°~270.336697° | 계산으로 도출한 값; 측점 차와 일치 |
| 두 평면 중심선 간 CAD 배치 차이 | 35.000m | 두 원호 중심 X 좌표 차 35,000 CAD 단위 | 계산으로 도출; 실제 독립 구조물 관계는 확인 필요 |
| 단면 A-A 측점 | STA. 0K+319.000 | 단면 A 표기 | 도면 명시값 |
| 단면 A-A 벽체 상단 EL | 107.215 | 상단 EL 주석 및 상단 구조선 | 도면 명시값 |
| 단면 A-A 기초 하단 EL | 100.877 | 하단 EL 주석 및 기초 구조선 | 도면 명시값 |
| 단면 A-A 기초 상단 EL | 101.777 | EL100.877 + 구조선상 기초 두께 0.900m | 계산으로 도출한 값 |
| 단면 A-A 중심 기준 EL | 102.094 | `CS-CNTL` 중심 표시 교점과 단면 EL 주석 | 좌표 환산 계산, 표기값과 일치 |
| 단면 B-B 측점 | STA. 0K+339.000 | 단면 B 표기 | 도면 명시값 |
| 단면 B-B 벽체 상단 EL | 107.857 | 상단 EL 주석 및 상단 구조선 | 도면 명시값 |
| 단면 B-B 기초 하단 EL | 100.392 | 하단 EL 주석 및 기초 구조선 | 도면 명시값 |
| 단면 B-B 기초 상단 EL | 101.292 | EL100.392 + 구조선상 기초 두께 0.900m | 계산으로 도출한 값 |
| 단면 B-B 중심 기준 EL | 101.517 | `CS-CNTL` 중심 표시 교점과 단면 EL 주석 | 좌표 환산 계산, 표기값과 일치 |
| 단면 주 통로 폭 분할 | 0.900 + 8.750 + 1.000 + 8.750 + 0.900 = 20.300m | 단면 치수 표기와 `CS-CONC-MAJR/MINR` 좌표 간격 | 도면 명시값 및 좌표 대조 |
| 구조선 최대 외곽 폭 | 20.500m | 단면 구조선 X 좌표 최댓값-최솟값 | 축척·좌표 측정값; 표기 폭과 별도 관리 |
| 구조선 기초 두께 | 0.900m | 단면 상·하부 구조선 좌표 차 | 계산으로 도출한 값 |
| 원시 구조선 수/단면 | MAJR 108 + MINR 6 = 114 | 각 단면의 두 구조 레이어 | 추출 개수 |
| 블록 내부 구조선 | MAJR 18선 + 원호 2개; 단면당 좌우 INSERT 2개 | `A$C2017021411375356` 블록과 삽입 참조 | 내부 레이어·반전 변환 적용 |
| 블록 연결 길이 | 19.980m | 단면 A/B 내부 블록 대응을 따라 선형 보간 | 계산으로 도출 |
모델은 두 단면의 구조선을 대응시켜 각 구조선을 길이 방향 표면으로 연결했다. 평면 원호는 양 끝 구조선과의 교점에 맞춰 길이 19.980m로 잘랐다. 단면 도면은 STA 319.000/339.000에 있고 모델 경계는 각각 0.010m 안쪽의 STA 319.010/338.990이므로, 두 끝에서 0.010m씩 단면 형상을 선형 보간해 경계에 맞췄다.
## 오류 및 확인 필요 사항
1. 단면 외곽 구조선 폭은 20.500m인데 표기된 주 통로/본체 폭은 20.300m다. 좌우 0.100m씩 추가되는 외곽 선의 용도는 구조도 DWG와 확인해야 한다. 모델에는 두 값을 임의로 하나로 합치지 않고 원 구조선을 그대로 보존했다.
2. 구조선 최저 외곽점은 표기된 기초 하단 EL보다 약 0.100m 낮게 계산된다(A: 100.777m, B: 100.292m). 기초 상세 또는 버림층 여부가 확인될 때까지 외곽 구조선 그대로 표시하고 차이를 남긴다.
3. 평면도에서 동일 `(BLOCK8)` 표기가 있는 중심선 2개를 찾았고 중심 간 도면 좌표 차는 35.000m다. 두 중심선이 독립 구조물인지, 도면상 중복/배치 복제인지 구조도와 종평면도로 확인해야 한다. 웹 뷰어에서 서로 독립적으로 숨김 처리할 수 있게 했다.
4. 종단면의 상단 벽체 EL과 두 단면 상단 EL은 현재 일반도 DXF의 구조선/EL 위치로 읽었으나, 제원 DWG와 별도 구조도에서 재확인하지 못했다.
5. 측구 상세도 기호와 본체 구조선의 실제 접합 위치, 부재 두께/재료, 닫힌 체적 경계는 미검증이다.
## 모델 구성과 화면 기능
- 생성 데이터: `src/model/block8.json`. 각 부재에 A/B 단면점, 원본 레이어와 HANDLE을 기록한다.
- 추출 스크립트: `scripts/extract_block8_model.py`. 표준 Python만으로 DXF 단면 선, 원호, INSERT 내부 레이어를 재추출한다.
- 화면: `src/ModelViewer.jsx`와 `src/App.jsx`. 끌어서 회전, 휠 확대/축소, Shift/오른쪽 끌기로 이동, 전체·평면·정면·측면·A-A·B-B 시점을 제공한다.
- 객체는 2개 평면 중심선에 각각 154개씩 구성했다(단면 구조선 114개와 삽입 블록 윤곽 40개). 원본선 단위 선택·숨김과 MAJR/MINR/블록 윤곽 그룹 숨김을 지원한다.
- 화면의 면은 검토용 열린 표면 모델이다. 구조선 사이를 연결했으며 모든 두께/접합을 확정한 닫힌 체적이나 실사 렌더링은 아니다.
- 실행: `npm run dev`. 분석 파일과 작업 로그는 화면 상단 링크에서 연다.
## 검증 결과
- 추출 스크립트 실행 결과: 중심선 2개, 각 길이 19.980m, 단면 구조선 MAJR 108개와 MINR 6개, 내부 블록 객체를 양쪽 단면에서 대응했다.
- `npm run build`: 통과.
- `npm run lint`: 미사용 함수 경고를 제거한 뒤 최종 재실행 예정.
- `npm run lint`: 통과, 경고 없음.
- `git diff --check`: 통과.
- 미검증: 원본 DWG에서 제원·특기사항, 구조 상세, 2개 중심선의 공간상 관계 및 측구 접합부를 CAD/PDF로 교차 대조하지 못했다.
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 270 KiB

+12 -8
View File
@@ -4,7 +4,7 @@
| 도면 | 자료 위치 | 읽은 내용 | 상태 |
|---|---|---|---|
| 평면도 | `public/drawings/광터지하차도_BLOCK8.dxf`, `CS-CNTL` 원호와 `CS-CONC-MAJR` 끝 구조선 | BLOCK8 표기 평면 중심선 2개, 반경 1,700m 원호, 원호와 끝 구조선의 교점 | 직접 좌표·레이어 대조 |
| 평면도 | `public/drawings/광터지하차도_BLOCK8.dxf`, `CS-CNTL` 원호와 `CS-CONC-MAJR` 끝 구조선 | 평면도와 상부평면도에 같은 형상으로 반복 표기된 중심선. 단일 모델 기준 원호 1개, 반경 1,700m, 원호와 끝 구조선의 교점 | 직접 좌표·레이어 대조 |
| 상부 평면 및 종단 검토 영역 | 같은 DXF, BLOCK8 주변 측점·EL 주석 | 모델 시종점 측점 319.010/338.990, 길이 19.980m | 측점은 도면값, 길이는 교점 간 계산값 |
| 단면 A-A | 같은 DXF, `STA. 0K+319.000` 주석이 있는 단면 영역 | 단면 구조선 114개, EL 표기와 폭 분할 | 직접 추출 및 대조 |
| 단면 B-B | 같은 DXF, `STA. 0K+339.000` 주석이 있는 단면 영역 | 단면 구조선 114개, 종점 EL·폭 분할 | 직접 추출 및 대조 |
@@ -12,7 +12,7 @@
| 측구 상세 | 전체 일반도 DXF의 별도 상세 영역 | BLOCK8 본체 단면과 측구 세부의 연결 표기를 확인하지 못함 | 별도 모델 반영 보류 |
| 제원·특기사항 및 구조도 | 사용자 제공 폴더의 DWG | 파일 존재 확인. DWG 직접 렌더링/교차검토는 이 실행 환경에서 수행하지 못함 | 후속 검증 필요 |
두 단면은 DXF의 동일 구조 윤곽 영역을 측점 주석(`319.000`, `339.000`)과 연결했다. 각 단면의 `CS-CNTL` 중심 표시 교차점 EL은 각각 102.094m와 101.517m이며, 같은 단면 영역의 EL 주석과 일치한다. 벽체 상단 EL 107.215m/107.857m도 종단 검토 영역에 반복 표기되어 각 단면과 맞는다. 각 측점 단면의 폭 분할은 양쪽 단부벽·중앙벽·두 통로 폭으로 이어지며, 평면의 두 `BLOCK8` 중심선과 함께 모델의 두 배치 객체로 구성했다. 두 평면 배치에 대표 단면을 공통 적용한 관계는 구조도 대조가 끝나지 않아 `확인 필요`로 표시한다.
두 단면은 DXF의 동일 구조 윤곽 영역을 측점 주석(`319.000`, `339.000`)과 연결했다. 각 단면의 `CS-CNTL` 중심 표시 교차점 EL은 각각 102.094m와 101.517m이며, 같은 단면 영역의 EL 주석과 일치한다. 벽체 상단 EL 107.215m/107.857m도 종단 검토 영역에 반복 표기되어 각 단면과 맞는다. 각 측점 단면의 폭 분할은 양쪽 단부벽·중앙벽·두 통로 폭으로 이어진다. 평면도와 상부평면도에 보이는 두 중심선 표현은 도면 배치상 서로 다른 위치에 그려진 같은 구조물의 표현으로 해석했다. 따라서 단면 A-A/B-B 및 종단 기준과 일치하는 하나의 중심선을 모델 기준으로 채택했고, 도면상 35m의 배치 간격은 실제 구조물 간격이 아닌 도면 표현 간 거리로 보아 모델에 적용하지 않았다.
## 축척 및 좌표 단위 확인
@@ -29,8 +29,8 @@
| 모델 시점 | STA. 0K+319.010 | 평면도 BLOCK8 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 모델 종점 | STA. 0K+338.990 | 반대쪽 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 중심선 반경 | 1,700m | 평면도 `CS-CNTL` ARC 반경 1,700,000 CAD 단위 | 좌표 단위 검산 후 환산값 |
| 평면 중심선 길이 | 19.980m, 각 배치 | ARC와 `CS-CONC-MAJR` 양 끝선 교점 사이 원호 길이. 교점 각도 약 269.663303°~270.336697° | 계산으로 도출한 값; 측점 차와 일치 |
| 두 평면 중심선 간 CAD 배치 차이 | 35.000m | 두 원호 중심 X 좌표 차 35,000 CAD 단위 | 계산으로 도출; 실제 독립 구조물 관계는 확인 필요 |
| 단일 모델 중심선 길이 | 19.980m | 기준 ARC와 `CS-CONC-MAJR` 양 끝선 교점 사이 원호 길이. 교점 각도 약 269.663303°~270.336697° | 계산으로 도출한 값; 측점 차와 일치 |
| 도면 표현 간 CAD 배치 차이 | 35.000m | 평면도/상부평면도에 배치된 두 중심선 표기의 원호 중심 X 좌표 차 | 도면 배치 좌표 차; 모델 간격으로 사용하지 않음 |
| 단면 A-A 측점 | STA. 0K+319.000 | 단면 A 표기 | 도면 명시값 |
| 단면 A-A 벽체 상단 EL | 107.215 | 상단 EL 주석 및 상단 구조선 | 도면 명시값 |
| 단면 A-A 기초 하단 EL | 100.877 | 하단 EL 주석 및 기초 구조선 | 도면 명시값 |
@@ -54,7 +54,7 @@
1. 단면 외곽 구조선 폭은 20.500m인데 표기된 주 통로/본체 폭은 20.300m다. 좌우 0.100m씩 추가되는 외곽 선의 용도는 구조도 DWG와 확인해야 한다. 모델에는 두 값을 임의로 하나로 합치지 않고 원 구조선을 그대로 보존했다.
2. 구조선 최저 외곽점은 표기된 기초 하단 EL보다 약 0.100m 낮게 계산된다(A: 100.777m, B: 100.292m). 기초 상세 또는 버림층 여부가 확인될 때까지 외곽 구조선 그대로 표시하고 차이를 남긴다.
3. 평면도에서 동일 `(BLOCK8)` 표기가 있는 중심선 2개를 찾았고 중심 간 도면 좌표 차는 35.000m다. 두 중심선이 독립 구조물인지, 도면상 중복/배치 복제인지 구조도와 종평면도로 확인해야 한다. 웹 뷰어에서 서로 독립적으로 숨김 처리할 수 있게 했다.
3. 평면도와 상부평면도에 같은 구조물 중심선이 도면상 35.000m 떨어져 반복 표현되어 있다. 이는 도면 뷰 간 배치 차이로 해석하여 3D에는 한 번만 모델링했다. 중심선 형상과 측점은 종단·A-A/B-B 단면 기준으로 대응시켰다.
4. 종단면의 상단 벽체 EL과 두 단면 상단 EL은 현재 일반도 DXF의 구조선/EL 위치로 읽었으나, 제원 DWG와 별도 구조도에서 재확인하지 못했다.
5. 측구 상세도 기호와 본체 구조선의 실제 접합 위치, 부재 두께/재료, 닫힌 체적 경계는 미검증이다.
@@ -63,15 +63,19 @@
- 생성 데이터: `src/model/block8.json`. 각 부재에 A/B 단면점, 원본 레이어와 HANDLE을 기록한다.
- 추출 스크립트: `scripts/extract_block8_model.py`. 표준 Python만으로 DXF 단면 선, 원호, INSERT 내부 레이어를 재추출한다.
- 화면: `src/ModelViewer.jsx`와 `src/App.jsx`. 끌어서 회전, 휠 확대/축소, Shift/오른쪽 끌기로 이동, 전체·평면·정면·측면·A-A·B-B 시점을 제공한다.
- 객체는 2개 평면 중심선에 각각 154개씩 구성했다(단면 구조선 114개와 삽입 블록 윤곽 40개). 원본선 단위 선택·숨김과 MAJR/MINR/블록 윤곽 그룹 숨김을 지원한다.
- 객체는 단일 중심선 모델에 구조선 114개와 삽입 블록 윤곽 40개, 총 154개 객체를 구성했다(단면 구조선 114개와 삽입 블록 윤곽 40개). 원본선 단위 선택·숨김과 MAJR/MINR/블록 윤곽 그룹 숨김을 지원한다.
- 화면의 면은 검토용 열린 표면 모델이다. 구조선 사이를 연결했으며 모든 두께/접합을 확정한 닫힌 체적이나 실사 렌더링은 아니다.
- `scripts/render_block8_drawing_views.py`는 DXF 도면 영역의 선·문자를 잘라 평면도, 상부평면도, 종단면도, A-A, B-B SVG 이미지 5개와 픽셀-CAD 좌표 연결값을 만든다. 뷰어에서 현재 보기와 도면 종류를 연결하고 투명도를 조절할 수 있다.
- A-A/B-B 이미지는 각 끝 측점의 단면 평면에 놓고, 종단면 이미지는 STA 주석과 EL 주석으로 길이·높이를 변환해 구조물 측면에 놓는다. 평면도는 DXF 좌표를 모델 좌표에 대응시키며, 상부평면도 표기는 도면 간 35m 배치 차이를 이동해 단일 모델 위에 포개고 화면 표시용 EL 108.8m에 둔다.
- 클리핑은 X(좌우 폭), Y(표고), Z(길이/측점 방향) 축 중 하나를 골라 전체 모델 좌표 범위의 0~100% 위치에서 한쪽 표면을 잘라 표시한다. 열린 표면 모델이므로 절단면은 막지 않으며, 도면 이미지 오버레이는 참고면으로 남아 절단되지 않는다.
- 이들은 도면 요소를 3D 화면의 기준 평면에 투영한 검토용 이미지다. 평면의 표시 높이와 종단면의 측면 이격은 보이도록 둔 시각화 위치이며 실제 부재나 설계 고도를 뜻하지 않는다. DXF 글꼴 및 일부 블록 표현은 SVG에서 단순화될 수 있다.
- 실행: `npm run dev`. 분석 파일과 작업 로그는 화면 상단 링크에서 연다.
## 검증 결과
- 추출 스크립트 실행 결과: 중심선 2개, 각 길이 19.980m, 단면 구조선 MAJR 108개와 MINR 6개, 내부 블록 객체를 양쪽 단면에서 대응했다.
- 추출 스크립트 실행 결과: 모델 중심선 1개, 길이 19.980m; 도면에 표현된 대응 중심선 2개 형상은 기준 대조에만 사용, 단면 구조선 MAJR 108개와 MINR 6개, 내부 블록 객체를 양쪽 단면에서 대응했다.
- `npm run build`: 통과.
- `npm run lint`: 미사용 함수 경고를 제거한 뒤 최종 재실행 예정.
- `npm run lint`: 통과, 경고 없음.
- `git diff --check`: 통과.
- 미검증: 원본 DWG에서 제원·특기사항, 구조 상세, 2개 중심선의 공간상 관계 및 측구 접합부를 CAD/PDF로 교차 대조하지 못했다.
- 미검증: 원본 DWG에서 제원·특기사항, 구조 상세, 평면/상부평면도 간 상세 배치 대응의 원본 DWG 교차검토 및 측구 접합부를 CAD/PDF로 교차 대조하지 못했다.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="741" viewBox="0 0 1400 741" role="img" aria-label="종단면도 원본 DXF 영역">
<title>종단면도 · 원본 DXF 벡터 캡처</title>
<g><path d="M351.76,377.60 L351.76,638.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1174.46,351.19 L1174.46,658.55" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M763.11,695.97 L895.54,695.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M763.11,654.79 L763.11,695.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M227.82,638.59 L227.82,377.61" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.99,377.61 L225.76,377.61" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.99,638.59 L225.76,638.59" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.99,601.53 L268.99,377.61" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M310.17,377.61 L266.94,377.61" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.99,638.59 L268.99,601.53" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M310.17,601.53 L266.94,601.53" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M310.17,638.59 L266.94,638.59" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.79,206.07 L351.79,164.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.79,164.89 L211.09,164.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.50,206.07 L1174.50,164.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.50,164.89 L1315.20,164.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.91,206.07 L1199.62,206.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.50,206.07 L1174.91,206.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.91,247.25 L1174.91,204.01" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.79,206.07 L1174.50,206.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.50,247.25 L1174.50,204.01" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.38,206.07 L351.79,206.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.79,247.25 L351.79,204.01" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.38,206.07 L326.67,206.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M351.38,247.25 L351.38,204.01" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,457.75 L1278.23,457.75" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.88,478.34 L1195.46,457.75" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,621.50 L1203.70,633.85" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,621.50 L1187.23,633.85" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1187.23,633.85 L1203.70,633.85" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,621.50 L1195.46,662.68" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,662.68 L1303.06,662.68" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.88,621.50 L1216.05,621.50" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,351.18 L1203.70,338.82" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,351.18 L1187.23,338.82" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1187.23,338.82 L1203.70,338.82" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,351.18 L1195.46,310.00" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1195.46,310.00 L1303.06,310.00" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.88,351.18 L1216.05,351.18" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M330.76,601.53 L322.52,613.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M330.76,601.53 L338.99,613.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M338.99,613.88 L322.52,613.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M330.76,601.53 L330.76,666.18" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M330.76,666.18 L223.16,666.18" fill="none" stroke="#d25245" stroke-width="0.8" stroLine truncated
</svg>

After

Width:  |  Height:  |  Size: 48 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1160" viewBox="0 0 1400 1160" role="img" aria-label="평면도 원본 DXF 영역">
<title>평면도 · 원본 DXF 벡터 캡처</title>
<g><path d="M1158.55,302.96 L1163.33,1114.95" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M364.13,302.96 L359.35,1114.95" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M461.08,769.47 L460.69,857.91" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M460.69,857.91 L460.65,865.91" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M461.61,649.47 L462.00,561.03" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M462.00,561.03 L462.03,553.03" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1199.16,338.72 L1241.16,338.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1198.95,302.72 L1240.95,302.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1239.16,338.49 L1238.95,302.49" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1201.22,688.72 L1243.22,688.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1199.16,338.72 L1241.16,338.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1241.22,688.48 L1239.16,338.49" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1201.46,728.72 L1243.45,728.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1201.22,688.72 L1243.22,688.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1241.45,728.48 L1241.22,688.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1203.51,1078.71 L1245.51,1078.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1201.46,728.72 L1243.45,728.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1243.51,1078.48 L1241.45,728.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1203.73,1114.71 L1245.73,1114.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1203.51,1078.71 L1245.51,1078.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1243.73,1114.48 L1243.51,1078.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M323.52,338.72 L281.52,338.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M323.73,302.72 L281.73,302.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M283.52,338.49 L283.73,302.49" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M321.46,688.72 L279.46,688.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M323.52,338.72 L281.52,338.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M281.46,688.48 L283.52,338.49" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M321.23,728.72 L279.23,728.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M321.46,688.72 L279.46,688.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M281.23,728.48 L281.46,688.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M319.17,1078.71 L277.17,1078.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M321.23,728.72 L279.23,728.47" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M279.17,1078.48 L281.23,728.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M318.96,1114.71 L276.96,1114.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M319.17,1078.71 L277.17,1078.46" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M278.96,1114.48 L279.17,1078.48" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1158.24,182.96 L1158.01,142.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1158.01,142.96 L1294.69,142.16" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M364.44,182.96 L364.67,142.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M364.67,142.96 L227.99,142.16" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M364.20,222.96 L364.45,180.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1158.48,222.96 L1158.23,180.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M364.37,262.96 L364.61,220.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M363.97,262.96 L364.21,220.96" fill="none" stroke="#d25245" stroke-wLine truncated
</svg>

After

Width:  |  Height:  |  Size: 65 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="831" viewBox="0 0 1400 831" role="img" aria-label="단면 A-A · STA. 0K+319.000 원본 DXF 영역">
<title>단면 A-A · STA. 0K+319.000 · 원본 DXF 벡터 캡처</title>
<g><path d="M699.44,600.98 L743.19,600.98" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M721.31,622.85 L721.31,579.10" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M277.25,654.22 L316.62,654.22" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M316.62,654.22 L699.44,654.22" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M699.44,654.22 L743.19,654.22" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,654.22 L1126.00,654.22" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1126.00,654.22 L1165.37,654.22" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M360.37,609.51 L362.56,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M362.56,609.51 L362.56,623.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M362.56,623.60 L380.06,623.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M380.06,623.60 L380.06,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M380.06,609.51 L382.25,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,609.51 L382.25,607.32" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,607.32 L382.25,610.82" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,610.82 L393.19,610.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,610.60 L677.56,604.91" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M677.56,604.91 L699.44,604.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M699.44,604.48 L699.44,614.85" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,607.32 L1060.37,610.82" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,607.32 L1060.37,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,609.51 L1062.56,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1062.56,609.51 L1062.56,623.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1062.56,623.60 L1080.06,623.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1080.06,623.60 L1080.06,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1080.06,609.51 L1082.25,609.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,614.85 L743.19,604.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,604.48 L765.06,604.91" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M765.06,604.91 L1049.44,610.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1049.44,610.60 L1060.37,610.82" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,610.38 L393.19,610.16" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,609.95 L393.19,609.73" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,609.51 L393.19,609.29" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,609.07 L393.19,608.85" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,608.63 L393.19,608.41" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,608.20 L393.19,607.98" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,607.76 L393.19,607.54" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,607.32 L393.19,607.10" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,607.32 L382.25,610.82" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,610.60 L393.19,607.10" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,610.16 L677.56,604.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,609.73 L677.56,604.04" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,609.29 L677.56,603.60" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,608.85 L677.56,603.16" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,608.41 L677.56,602.73" fill="none" stroke="#438b9e" stroke-Line truncated
</svg>

After

Width:  |  Height:  |  Size: 57 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="831" viewBox="0 0 1400 831" role="img" aria-label="단면 B-B · STA. 0K+339.000 원본 DXF 영역">
<title>단면 B-B · STA. 0K+339.000 · 원본 DXF 벡터 캡처</title>
<g><path d="M699.44,614.95 L743.19,614.95" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M721.31,636.82 L721.31,593.07" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M277.25,664.15 L316.62,664.15" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M316.62,664.15 L699.44,664.15" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M699.44,664.15 L743.19,664.15" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,664.15 L1126.00,664.15" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1126.00,664.15 L1165.37,664.15" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M360.37,623.48 L362.56,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M362.56,623.48 L362.56,633.53" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M362.56,633.53 L380.06,633.53" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M380.06,633.53 L380.06,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M380.06,623.48 L382.25,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,623.48 L382.25,621.29" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,621.29 L382.25,624.79" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,624.79 L393.19,624.57" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,624.57 L677.56,618.89" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M677.56,618.89 L699.44,618.45" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M699.44,618.45 L699.44,624.78" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,621.29 L1060.37,624.79" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,621.29 L1060.37,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1060.37,623.48 L1062.56,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1062.56,623.48 L1062.56,633.53" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1062.56,633.53 L1080.06,633.53" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1080.06,633.53 L1080.06,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1080.06,623.48 L1082.25,623.48" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,624.78 L743.19,618.45" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M743.19,618.45 L765.06,618.89" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M765.06,618.89 L1049.44,624.57" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M1049.44,624.57 L1060.37,624.79" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,624.35 L393.19,624.14" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,623.92 L393.19,623.70" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,623.48 L393.19,623.26" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,623.04 L393.19,622.82" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,622.60 L393.19,622.39" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,622.17 L393.19,621.95" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,621.73 L393.19,621.51" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,621.29 L393.19,621.07" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M382.25,621.29 L382.25,624.79" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,624.57 L393.19,621.07" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,624.14 L677.56,618.45" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,623.70 L677.56,618.01" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,623.26 L677.56,617.57" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,622.82 L677.56,617.14" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M393.19,622.39 L677.56,616.70" fill="none" stroke="#438b9e" stroke-Line truncated
</svg>

After

Width:  |  Height:  |  Size: 57 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1097" viewBox="0 0 1400 1097" role="img" aria-label="상부평면도 원본 DXF 영역">
<title>상부평면도 · 원본 DXF 벡터 캡처</title>
<g><path d="M1095.93,286.29 L1100.44,1054.38" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M344.45,286.29 L339.93,1054.38" fill="none" stroke="#438b9e" stroke-width="1.25" stroke-linejoin="round"/><path d="M436.15,727.58 L435.78,811.24" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M435.78,811.24 L435.75,818.80" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M436.65,614.06 L437.02,530.41" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M437.02,530.41 L437.06,522.84" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1138.57,1039.02 L1178.30,1038.79" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1138.46,1020.11 L1178.19,1019.87" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1176.41,1038.80 L1176.30,1019.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1138.46,1020.11 L1178.19,1019.87" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1136.51,689.03 L1176.24,688.80" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1176.30,1019.88 L1174.35,688.81" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1136.51,689.03 L1176.24,688.80" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1136.29,651.19 L1176.02,650.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.35,688.81 L1174.13,650.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1136.29,651.19 L1176.02,650.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1134.34,320.12 L1174.07,319.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1174.13,650.97 L1172.18,319.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1134.34,320.12 L1174.07,319.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1134.23,301.20 L1173.96,300.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1172.18,319.89 L1172.07,300.98" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1176.41,1038.80 L1216.14,1038.57" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1172.07,300.98 L1211.80,300.74" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1214.24,1038.58 L1209.90,300.75" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M306.03,320.12 L266.30,319.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M306.14,301.20 L266.41,300.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.20,319.89 L268.31,300.98" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M304.08,651.19 L264.36,650.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M306.03,320.12 L266.30,319.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M266.25,650.97 L268.20,319.89" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M303.86,689.03 L264.13,688.80" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M304.08,651.19 L264.36,650.96" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M266.03,688.81 L266.25,650.97" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M301.91,1020.11 L262.19,1019.87" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M303.86,689.03 L264.13,688.80" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M264.08,1019.88 L266.03,688.81" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M301.80,1039.02 L262.07,1038.79" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M301.91,1020.11 L262.19,1019.87" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M263.97,1038.80 L264.08,1019.88" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M263.97,1038.80 L224.24,1038.57" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M268.31,300.98 L228.58,300.74" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M226.13,1038.58 L230.47,300.75" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1095.64,172.77 L1095.41,134.94" fill="none" stroke="#d25245" stroke-width="0.8" stroke-linejoin="round"/><path d="M1095.41,134.94 L1224.70,134.18" fill="none" stroke="#d25245Line truncated
</svg>

After

Width:  |  Height:  |  Size: 66 KiB

+18 -1
View File
@@ -28,6 +28,23 @@
- 단면 A/B 구조선 114개씩( MAJR 108, MINR 6 ) 추출
- A$C2017021411375356 블록 내부 콘크리트 레이어 18선과 원호 2개를 좌우 단면에 반영
- 중심 기준 EL 102.094 → 101.517, 벽체 상단 EL 107.215 → 107.857, 폭 분할 20.300m 대조
- 두 평면 중심선에 각각 3D 연결 표면 모델 생성, 부재 선택/숨김 및 보기 방향 제공
- 평면도와 상부평면도를 같은 구조물로 해석해 단일 3D 연결 표면 모델 생성, 부재 선택/숨김 및 보기 방향 제공
- 구조도와 대조가 필요한 배치 관계·외곽 폭 차이·기초 최저선 차이는 검토 항목으로 유지
- 모델 미리보기 SVG와 갱신된 분석 문서 추가
6. 평면도/상부평면도 표현 중복 정리
- 두 도면의 중심선 표기를 서로 다른 구조물로 보던 해석을 수정
- 단일 중심선 기준 3D 모델 1개로 재생성 (154개 객체)
- 35.000m는 도면 뷰 배치 좌표 차이로 기록하고 모델 간격에서 제외
- 분석 문서, 웹 뷰어, 모델 미리보기 갱신
7. 도면 뷰 이미지와 3D 모델 동시 보기
- 원본 DXF에서 평면도·상부평면도·종단면도·단면 A-A/B-B 영역을 SVG 참고 이미지로 추출
- 뷰어 현재 방향과 도면 이미지를 연결하고 도면 선택/표시/투명도 조절 추가
- CAD 픽셀좌표를 모델 좌표로 변환해 A-A/B-B 끝 단면, 종단 측면, 평면 기준면으로 투영
- 평면도·상부평면도는 35m 도면 배치 차이를 보정해 단일 구조물에 중첩
- 단독 HTML과 전달용 소스 묶음에 추출 이미지를 포함
8. 모델 클리핑 기능 추가
- X(좌우), Y(높이/EL), Z(길이/측점) 축과 절단 위치·남길 방향을 선택하는 조작부 추가
- 표면 다각형을 절단 평면에서 보간해 내부가 보이도록 처리
- 열린 표면 모델이므로 절단면 덮개는 만들지 않고, 도면 오버레이는 참고면으로 유지
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Vite 빌드 결과를 독립 실행 가능한 BLOCK8 HTML로 묶는다."""
from html.parser import HTMLParser
import base64
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
OUTPUT = ROOT / "public/BLOCK8_모델웹페이지.html"
class AssetParser(HTMLParser):
def __init__(self):
super().__init__()
self.script = None
self.stylesheet = None
def handle_starttag(self, tag, attrs):
values = dict(attrs)
if tag == "script" and values.get("src", "").startswith("/assets/"):
self.script = values["src"].split("/assets/", 1)[1]
if tag == "link" and values.get("rel") == "stylesheet" and values.get("href", "").startswith("/assets/"):
self.stylesheet = values["href"].split("/assets/", 1)[1]
def main():
index = (DIST / "index.html").read_text(encoding="utf-8")
parser = AssetParser()
parser.feed(index)
if not parser.script or not parser.stylesheet:
raise RuntimeError("빌드된 HTML에서 JavaScript/CSS 파일을 찾지 못했습니다. npm run build를 먼저 실행하세요.")
js = (DIST / "assets" / parser.script).read_text(encoding="utf-8")
css = (DIST / "assets" / parser.stylesheet).read_text(encoding="utf-8")
image_map = {}
for name in ("plan", "upper-plan", "longitudinal", "section-a", "section-b"):
image = ROOT / "public/drawing-images" / f"BLOCK8_{name}.svg"
image_map[name] = "data:image/svg+xml;base64," + base64.b64encode(image.read_bytes()).decode("ascii")
# public 폴더 안의 자료 링크가 file:// 환경에서도 열리도록 상대 경로로 바꾼다.
for path in ("analysis/", "drawings/", "BLOCK8_", "작업기록.txt"):
js = js.replace(f"/{path}", f"./{path}")
title = re.search(r"<title>(.*?)</title>", index, re.DOTALL)
page_title = title.group(1) if title else "광터지하차도 BLOCK8 3D 모델"
html = f'''<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#eff2ed">
<meta name="description" content="도면의 중심선과 구조 단면을 연결한 광터지하차도 BLOCK8 3D 검토 모델입니다.">
<title>{page_title}</title>
<style>{css}</style>
</head>
<body>
<div id="root"></div>
<script>window.BLOCK8_DRAWING_IMAGES={json.dumps(image_map, ensure_ascii=False)};</script>
<script>{js}</script>
</body>
</html>
'''
OUTPUT.write_text(html, encoding="utf-8")
print(f"{OUTPUT.relative_to(ROOT)} 생성 완료 ({OUTPUT.stat().st_size:,} bytes)")
if __name__ == "__main__":
main()
+4 -2
View File
@@ -212,7 +212,7 @@ def alignment_units(entities):
length = radius_m * math.radians(theta1 - theta0)
units.append({
"id": f"alignment-{index + 1}",
"name": f"BLOCK8 평면 중심선 {index + 1}",
"name": "BLOCK8 기준 중심선",
"center": [(acx - center_x_origin) / 1000, (float(first(props(arc), 20)) - center_y_origin) / 1000],
"planOrigin": [center_x_origin, center_y_origin],
"radius": radius_m,
@@ -222,7 +222,9 @@ def alignment_units(entities):
"centerlineHandle": first(props(arc), 5, ""),
"boundaryHandles": boundary_handles,
})
return units
# 평면도와 상부평면도에 반복된 중심선은 같은 구조물의 도면 표현이다.
# 첫 번째 중심선을 모델 배치 기준으로 사용하고 두 번째는 검산에만 쓴다.
return units[:1]
def add_block_parts(parts, entities, blocks, center_x, sections):
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""프로젝트 소스와 검토 자료를 전달용 ZIP으로 묶는다."""
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "public/BLOCK8_소스코드_전달자료.zip"
ARCHIVE_ROOT = "광터지하차도_BLOCK8_전달자료"
EXCLUDED = {"BLOCK8_소스코드_전달자료.zip"}
GUIDE = """# 광터지하차도 BLOCK8 전달 자료 안내
이 압축 파일에는 웹 3D 모델의 전체 작성 코드와 결과물, 근거 자료가 들어 있습니다.
## 실행 방법
1. Node.js를 설치합니다.
2. 이 폴더에서 `npm install`을 실행합니다.
3. `npm run dev`를 실행하고 터미널에 나온 주소를 브라우저에서 엽니다.
## 주요 폴더
- `src/`: React 화면, 뷰어, 모델 JSON
- `scripts/`: DXF 모델 추출, SVG 미리보기 생성, 단독 HTML 생성, 전달 ZIP 생성 코드
- `public/`: 원본 DXF, 분석·작업·커밋 기록, 단독 HTML 결과물
- `docs/`: 상세 분석과 모델 미리보기
- `public/drawing-images/`: 도면 SVG 오버레이와 픽셀-CAD 연결 정보
## 포함하지 않은 항목
`node_modules/`, `dist/`, `.git/`은 포함하지 않았습니다. 의존성은 `npm install`로 설치하고, 빌드 결과는 `npm run build`로 다시 만들 수 있습니다. Git 커밋 목록은 `public/BLOCK8_커밋기록.md`에서 확인하세요.
현재 압축본에는 DXF에서 생성한 평면·상부평면·종단·A-A/B-B 오버레이 이미지가 포함되어 있습니다. 3D 기준면 배치는 참고 시각화이며, 도면 요소의 최종 치수 검증은 원본 CAD에서 수행하세요.
"""
def main():
files = [ROOT / "AGENTS.md", ROOT / "README.md", ROOT / "index.html", ROOT / "package.json", ROOT / "package-lock.json", ROOT / "vite.config.js"]
for folder in ("src", "scripts", "docs", "public"):
files.extend(path for path in (ROOT / folder).rglob("*") if path.is_file() and path.name not in EXCLUDED and "__pycache__" not in path.parts and path.suffix != ".pyc")
files = sorted(set(files))
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(OUTPUT, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
archive.writestr(f"{ARCHIVE_ROOT}/전달자료_안내.md", GUIDE)
for path in files:
archive.write(path, f"{ARCHIVE_ROOT}/{path.relative_to(ROOT).as_posix()}")
print(f"{OUTPUT.relative_to(ROOT)} 생성 완료: {len(files) + 1}개 파일, {OUTPUT.stat().st_size:,} bytes")
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""BLOCK8 DXF에서 평면/종단/단면 영역을 벡터 SVG 참조 이미지로 추출한다."""
from collections import defaultdict
from html import escape
import json
import math
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.dont_write_bytecode = True
sys.path.insert(0, str(ROOT / "scripts"))
import extract_block8_model as dxf
OUTPUT = ROOT / "public/drawing-images"
WIDTH = 1400
VIEWS = {
"plan": {"title": "평면도", "bounds": (1_195_000, 1_230_000, -90_000, -61_000)},
"upper-plan": {"title": "상부평면도", "bounds": (1_230_000, 1_267_000, -90_000, -61_000)},
"longitudinal": {"title": "종단면도", "bounds": (1_213_000, 1_247_000, -108_000, -90_000)},
"section-a": {"title": "단면 A-A · STA. 0K+319.000", "bounds": (1_280_000, 1_312_000, -83_000, -64_000)},
"section-b": {"title": "단면 B-B · STA. 0K+339.000", "bounds": (1_280_000, 1_312_000, -102_000, -83_000)},
}
ACI = {
1: "#d25245", 2: "#b58b32", 3: "#43845c", 4: "#438b9e",
5: "#5273a6", 6: "#995d96", 7: "#26332b", 8: "#777f78", 9: "#a1aaa2",
}
FALLBACK = {
"CS-CONC-MAJR": "#26332b", "CS-CONC-MINR": "#3d817e",
"CS-CNTL": "#c05045", "CS-DIML": "#ad584c", "CS-DIMT": "#43845c",
"CS-NOTE": "#43845c", "CS-TEXT": "#43845c", "CZ-TEX1": "#995d96",
"CZ-TEX2": "#43845c", "CS-SYMB": "#43845c", "CS-CUTL": "#5273a6",
}
def compose(a, b):
return (
a[0]*b[0] + a[2]*b[1], a[1]*b[0] + a[3]*b[1],
a[0]*b[2] + a[2]*b[3], a[1]*b[2] + a[3]*b[3],
a[0]*b[4] + a[2]*b[5] + a[4], a[1]*b[4] + a[3]*b[5] + a[5],
)
def transform(matrix, point):
x, y = point
return matrix[0]*x + matrix[2]*y + matrix[4], matrix[1]*x + matrix[3]*y + matrix[5]
def make_insert_matrix(entity, block):
p = dxf.props(entity)
ix, iy = float(dxf.first(p, 10, "0")), float(dxf.first(p, 20, "0"))
sx, sy = float(dxf.first(p, 41, "1")), float(dxf.first(p, 42, "1"))
angle = math.radians(float(dxf.first(p, 50, "0")))
bx, by = block["base"][:2]
co, si = math.cos(angle), math.sin(angle)
a, c = co*sx, -si*sy
b, e = si*sx, co*sy
return a, b, c, e, ix-a*bx-c*by, iy-b*bx-e*by
def entity_layer(entity, inherited=None):
name = dxf.layer(entity)
return inherited if name == "0" and inherited else name
def parse_primitives(entity, blocks, matrix, inherited=None, depth=0, visited=()):
if depth > 5:
return []
kind = entity["type"]
layer = entity_layer(entity, inherited)
p = dxf.props(entity)
if kind == "INSERT":
name = dxf.first(p, 2, "")
block = blocks.get(name)
if not block or name in visited:
return []
next_matrix = compose(matrix, make_insert_matrix(entity, block))
result = []
for child in block["entities"]:
result.extend(parse_primitives(child, blocks, next_matrix, layer, depth+1, visited+(name,)))
return result
if kind in ("LINE", "LWPOLYLINE", "ARC", "CIRCLE", "SOLID", "TRACE"):
points = []
if kind == "LINE":
points = [(float(dxf.first(p, 10)), float(dxf.first(p, 20))), (float(dxf.first(p, 11)), float(dxf.first(p, 21)))]
elif kind == "LWPOLYLINE":
raw_x, raw_y = [float(v) for v in p.get(10, [])], [float(v) for v in p.get(20, [])]
points = list(zip(raw_x, raw_y))
if int(dxf.first(p, 70, "0")) & 1 and points:
points.append(points[0])
elif kind == "ARC":
points = dxf.arc_points(entity, 48)
elif kind == "CIRCLE":
cx, cy, r = float(dxf.first(p, 10)), float(dxf.first(p, 20)), float(dxf.first(p, 40))
points = [(cx+r*math.cos(2*math.pi*i/64), cy+r*math.sin(2*math.pi*i/64)) for i in range(65)]
else:
points = [(float(dxf.first(p, x)), float(dxf.first(p, y))) for x, y in ((10,20),(11,21),(12,22),(13,23)) if x in p and y in p]
if points:
points.append(points[0])
return [("path", [transform(matrix, point) for point in points], layer)] if len(points) > 1 else []
if kind in ("TEXT", "MTEXT", "ATTRIB", "ATTDEF"):
x, y = float(dxf.first(p, 10, "0")), float(dxf.first(p, 20, "0"))
pos = transform(matrix, (x, y))
text = "".join(p.get(1, []) + p.get(3, []))
text = text.replace("\\P", " ").replace("%%d", "°").replace("%%p", "±").replace("%%c", "Ø")
text = re.sub(r"\\[A-Za-z][^;]*;", "", text)
size = float(dxf.first(p, 40, "250"))
# Text heights are in drawing units; image uses the same uniform drawing scale.
angle = float(dxf.first(p, 50, "0"))
align = int(dxf.first(p, 72, "0"))
anchor = {0: "start", 1: "middle", 2: "end"}.get(align, "start")
return [("text", [pos, text, size, angle, anchor], layer)] if text.strip() else []
return []
def get_colors(pairs):
begin, end = dxf.parse_sections(pairs, "TABLES")
colors = {}
for record in dxf.records(pairs, begin+2, end):
if record["type"] != "LAYER":
continue
p = dxf.props(record)
name = dxf.first(p, 2, "")
try:
colors[name] = ACI.get(abs(int(dxf.first(p, 62, "7"))), FALLBACK.get(name, "#374239"))
except ValueError:
colors[name] = FALLBACK.get(name, "#374239")
return colors
def render_view(name, spec, entities, blocks, colors):
x0, x1, y0, y1 = spec["bounds"]
HEIGHT = round(WIDTH * (y1-y0) / (x1-x0))
scale = WIDTH / (x1-x0)
def screen(point):
x, y = point
return (x-x0)*scale, HEIGHT-(y-y0)*scale
primitives = []
for entity in entities:
primitives.extend(parse_primitives(entity, blocks, (1,0,0,1,0,0)))
elements = []
for kind, data, layer in primitives:
color = colors.get(layer, FALLBACK.get(layer, "#667267"))
if kind == "path":
px = [point[0] for point in data]
py = [point[1] for point in data]
if max(px) < x0 or min(px) > x1 or max(py) < y0 or min(py) > y1:
continue
# Canvas crop clips geometry outside the requested drawing region.
points = [screen(point) for point in data]
path = "M" + " L".join(f"{x:.2f},{y:.2f}" for x,y in points)
width = 1.25 if layer == "CS-CONC-MAJR" else 0.8
elements.append(f'<path d="{path}" fill="none" stroke="{color}" stroke-width="{width}" stroke-linejoin="round"/>')
else:
(x,y), text, height, angle, anchor = data
if not (x0-1000 <= x <= x1+1000 and y0-1000 <= y <= y1+1000):
continue
sx, sy = screen((x,y))
size = max(4, min(26, height*scale))
escaped = escape(text, quote=False)
# Negative angle compensates for the SVG screen's downward-positive y axis.
elements.append(f'<text x="{sx:.2f}" y="{sy:.2f}" font-size="{size:.2f}" text-anchor="{anchor}" fill="{color}" transform="rotate({-angle:.2f} {sx:.2f} {sy:.2f})">{escaped}</text>')
svg = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-label="{escape(spec["title"])} 원본 DXF 영역">',
f'<title>{escape(spec["title"])} · 원본 DXF 벡터 캡처</title>',
f'<g>{"".join(elements)}</g>',
'</svg>',
]
path = OUTPUT / f"BLOCK8_{name}.svg"
path.write_text("\n".join(svg), encoding="utf-8")
print(f"{path.relative_to(ROOT)}: {len(elements)}개 도면 요소")
return {
"title": spec["title"],
"pixelSize": [WIDTH, HEIGHT],
"cadBounds": {
"xMin": x0,
"xMax": x1,
"yMin": y0,
"yMax": y1,
},
"placement": None,
}
def main():
pairs = dxf.read_pairs(dxf.SOURCE)
entities = dxf.parse_modelspace(pairs)
blocks = dxf.parse_blocks(pairs)
colors = get_colors(pairs)
OUTPUT.mkdir(parents=True, exist_ok=True)
metadata = {name: render_view(name, spec, entities, blocks, colors) for name, spec in VIEWS.items()}
metadata["plan"]["placement"] = {"kind": "plan", "xOffset": 0, "displayEL": 108.8}
metadata["upper-plan"]["placement"] = {"kind": "plan", "xOffset": -35_000, "displayEL": 108.8}
metadata["longitudinal"]["placement"] = {
"kind": "longitudinal",
"stationCad": [1_218_301.059683612, 1_241_698.059683612],
"elevationOffset": 205.2898,
"lateralOffset": 15,
}
metadata["section-a"]["placement"] = {
"kind": "section", "stationT": -0.0005005005,
"cadXCenter": 1_296_487.109248054,
"cadYBase": -78_059.37855593952, "baseEL": 101.777,
}
metadata["section-b"]["placement"] = {
"kind": "section", "stationT": 1.0005005005,
"cadXCenter": 1_296_487.109248054,
"cadYBase": -97_286.37855593952, "baseEL": 101.292,
}
model_metadata = ROOT / "src/model/drawingViews.json"
model_metadata.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{model_metadata.relative_to(ROOT)}: 도면/모델 배치 기준 기록")
if __name__ == "__main__":
main()
+3 -3
View File
@@ -56,7 +56,7 @@ def main():
svg = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 660" role="img" aria-labelledby="title desc">',
'<title id="title">광터지하차도 BLOCK8 도면 기반 3D 구조 모델</title>',
'<desc id="desc">두 평면 중심선을 따라 시점과 종점 단면 구조선을 연결한 열린 표면 검토 모델. 일부 배치 대응은 확인 필요.</desc>',
'<desc id="desc">평면도와 상부평면도의 동일 구조물 중심선을 기준으로 시점과 종점 단면 구조선을 연결한 단일 열린 표면 검토 모델.</desc>',
'<defs><linearGradient id="bg" x2="0" y2="1"><stop stop-color="#f7faf6"/><stop offset="1" stop-color="#e8eee8"/></linearGradient></defs>',
'<rect width="1200" height="660" rx="22" fill="url(#bg)"/>',
'<text x="62" y="61" fill="#728579" font-family="sans-serif" font-size="14" font-weight="700" letter-spacing="2">광터지하차도 · 도면 기반 검토 모델</text>',
@@ -78,12 +78,12 @@ def main():
left = point_at(unit, [[0, 104]], [[0, 104]], 0, 0)
right = point_at(unit, [[0, 104]], [[0, 104]], 0, 1)
p0, p1 = project(left), project(right)
svg.append(f'<g font-family="sans-serif" font-size="12" fill="#55715c"><text x="{p0[0]:.1f}" y="{p0[1]-13:.1f}">중심선 {idx+1} · STA 319.010</text><text x="{p1[0]:.1f}" y="{p1[1]-12:.1f}">STA 338.990</text></g>')
svg.append(f'<g font-family="sans-serif" font-size="12" fill="#55715c"><text x="{p0[0]:.1f}" y="{p0[1]-13:.1f}">BLOCK8 중심선 · STA 319.010</text><text x="{p1[0]:.1f}" y="{p1[1]-12:.1f}">STA 338.990</text></g>')
svg.extend([
'<g font-family="sans-serif" font-size="12"><rect x="62" y="570" width="1076" height="54" rx="9" fill="#f8f3e8" stroke="#e5dbc4"/>',
'<text x="82" y="593" fill="#8c774e">길이 19.980m · 평면 중심선 교점으로 계산 · 시점/종점 단면 구조선 114개씩 + 블록 내부 윤곽</text>',
'<text x="82" y="612" fill="#8c774e">열린 표면 모델이며 닫힌 체적은 아닙니다. 두 중심선에 같은 단면을 적용한 대응은 검토가 필요합니다.</text></g>',
'<text x="82" y="612" fill="#8c774e">단일 구조물 모델입니다. 열린 표면이며 닫힌 체적이 아닙니다. 상부평면도 중심선은 같은 구조물 표현으로 해석했습니다.</text></g>',
'</svg>',
])
OUTPUT.write_text('\n'.join(svg) + '\n', encoding="utf-8")
+60 -19
View File
@@ -8,6 +8,14 @@ const categoryLabels = {
insert: '블록 내부 콘크리트 윤곽',
}
const viewNames = ['전체', '평면', '정면', '측면', 'A-A', 'B-B']
const drawingNames = {
plan: '평면도',
'upper-plan': '상부평면도',
longitudinal: '종단면도',
'section-a': '단면 A-A',
'section-b': '단면 B-B',
}
const drawingForView = { 전체: 'plan', 평면: 'plan', 정면: 'section-a', 측면: 'longitudinal', 'A-A': 'section-a', 'B-B': 'section-b' }
function pointRange(parts, key, coordinate) {
const values = parts.flatMap(part => [...part.a, ...part.b].map(point => point[coordinate]))
@@ -17,8 +25,14 @@ function pointRange(parts, key, coordinate) {
export default function App() {
const [view, setView] = useState('전체')
const [renderMode, setRenderMode] = useState(false)
const [drawingVisible, setDrawingVisible] = useState(false)
const [drawingMode, setDrawingMode] = useState('auto')
const [drawingOpacity, setDrawingOpacity] = useState(0.68)
const [clipping, setClipping] = useState(false)
const [clipAxis, setClipAxis] = useState('x')
const [clipPosition, setClipPosition] = useState(50)
const [clipKeep, setClipKeep] = useState('positive')
const [hidden, setHidden] = useState(new Set())
const [hiddenUnits, setHiddenUnits] = useState(new Set())
const [selected, setSelected] = useState(null)
const [tab, setTab] = useState('부재')
const [search, setSearch] = useState('')
@@ -27,7 +41,7 @@ export default function App() {
const allParts = useMemo(() => model.units.flatMap(unit => unit.parts.map(part => ({ ...part, unitId: unit.id, unitName: unit.name }))), [])
const selectedPart = selected ? allParts.find(part => part.id === selected.partId && part.unitId === selected.unitId) : null
const selectedUnit = selected ? model.units.find(unit => unit.id === selected.unitId) : null
const visibleCount = allParts.filter(part => !hidden.has(part.id) && !hiddenUnits.has(part.unitId)).length
const visibleCount = allParts.filter(part => !hidden.has(part.id)).length
const filteredParts = allParts.filter(part => `${part.name} ${part.layer} ${part.unitName}`.toLowerCase().includes(search.toLowerCase()))
const dimensionRange = pointRange(allParts, 'a', 0)
const elevationRange = pointRange(allParts, 'a', 1)
@@ -36,6 +50,7 @@ export default function App() {
minor: allParts.filter(part => part.group === 'minor').length,
insert: allParts.filter(part => part.group === 'insert').length,
}
const activeDrawing = drawingMode === 'auto' ? drawingForView[view] : drawingMode
const togglePart = id => setHidden(current => {
const next = new Set(current)
@@ -49,12 +64,6 @@ export default function App() {
ids.forEach(id => hide ? next.add(id) : next.delete(id))
return next
})
const toggleUnit = id => setHiddenUnits(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
return (
<main className="page-shell">
<header className="topbar">
@@ -63,6 +72,8 @@ export default function App() {
<nav className="file-links" aria-label="작업 자료">
<a href="/analysis/BLOCK8_3D모델_미리보기.svg" target="_blank" rel="noreferrer">모델 이미지 ↗</a>
<a href="/analysis/도면분석.md" target="_blank" rel="noreferrer">분석 파일 ↗</a>
<a href="/BLOCK8_모델웹페이지.html" target="_blank" rel="noreferrer">단독 HTML ↗</a>
<a href="/BLOCK8_소스코드_전달자료.zip" download>전체 소스 ZIP ↓</a>
<a href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 ↗</a>
<a href="/drawings/광터지하차도_BLOCK8.dxf" download>원본 DXF ↓</a>
</nav>
@@ -72,9 +83,9 @@ export default function App() {
<div>
<span className="kicker">평면 중심선 · 종단 표고 · 단면 구조선을 연결한 검토 모델</span>
<h1>광터지하차도 <em>BLOCK8</em></h1>
<p>구조선과 블록 내부 콘크리트 선을 추출해 두 측점 단면 사이를 연결했습니다.</p>
<p>평면도·상부평면도는 같은 구조물의 표현으로 해석해 한 개 모델로 구성했습니다.</p>
</div>
<span className="status-pill"><i /> 일부 배치 대응 확인 필요</span>
<span className="status-pill"><i /> 단일 구조물 · 도면 대조 검토</span>
</section>
<section className="metrics" aria-label="주요 모델 기준값">
@@ -86,23 +97,54 @@ export default function App() {
<div className="notice">
<b>검토 상태</b>
<p>단면 A/B 구조선 114개씩과 내부 블록 윤곽을 반영했습니다. 평면도에 BLOCK8 표기가 있는 중심선이 2개 있어 둘 다 별도 객체로 표시했습니다. 두 배치에 같은 대표 단면을 적용한 것은 확인 필요 항목입니다.</p>
<p>단면 A-A/B-B 구조선과 내부 블록 윤곽을 한 구조물에 반영했습니다. 평면도와 상부평면도에 반복된 표현은 별도 구조물로 중복 생성하지 않았습니다.</p>
</div>
<section className="workspace">
<div className="viewer-card">
<div className="viewer-head">
<div><span className="eyebrow">도면 구조선 기반 · 열린 표면 모델</span><h2>BLOCK8 3D 구조 보기</h2></div>
<div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>명암 보기</button></div>
<div className="viewer-actions">
<div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>명암 보기</button></div>
<button className={`drawing-toggle ${drawingVisible ? 'active' : ''}`} onClick={() => setDrawingVisible(value => !value)}>{drawingVisible ? '도면 숨김' : '도면 함께 보기'}</button>
<button className={`drawing-toggle ${clipping ? 'active' : ''}`} onClick={() => setClipping(value => !value)}>{clipping ? '클리핑 해제' : '클리핑 켜기'}</button>
</div>
</div>
<div className="viewbar">
<div className="views">{viewNames.map(name => <button key={name} className={view === name ? 'active' : ''} onClick={() => setView(name)}>{name}</button>)}</div>
<span>{visibleCount} / {allParts.length} 객체 표시</span>
</div>
<div className={`canvas-wrap ${renderMode ? 'render-mode' : ''}`}>
<ModelViewer units={model.units} hidden={hidden} hiddenUnits={hiddenUnits} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} />
<span className="canvas-tag">{renderMode ? '명암을 더한 도면 검토 시각화 · 실사 렌더링 아님' : '구조선 연결 표면 · 두 배치 중심선 표시'}</span>
<ModelViewer units={model.units} hidden={hidden} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} drawingVisible={drawingVisible} drawing={activeDrawing} drawingOpacity={drawingOpacity} clipping={clipping} clipAxis={clipAxis} clipPosition={clipPosition} clipKeep={clipKeep} />
<span className="canvas-tag">{renderMode ? '명암을 더한 도면 검토 시각화 · 실사 렌더링 아님' : '평면도·상부평면도 공통 구조물 모델'}</span>
</div>
{drawingVisible && <div className="drawing-controls">
<label>도면 선택
<select value={drawingMode} onChange={event => setDrawingMode(event.target.value)}>
<option value="auto">현재 보기와 연결</option>
{Object.entries(drawingNames).map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</label>
<label className="opacity-control">도면 투명도
<input type="range" min="0.25" max="0.95" step="0.05" value={drawingOpacity} onChange={event => setDrawingOpacity(Number(event.target.value))} />
</label>
<span>A-A/B-B는 해당 끝 측점 단면에 놓이며, 평면·종단 이미지는 CAD 좌표와 측점·EL 주석 기준으로 모델과 함께 투영됩니다. 평면 이미지는 화면 표시용 높이에 놓인 참고면입니다.</span>
</div>}
{clipping && <div className="clip-controls">
<label>절단 축
<select value={clipAxis} onChange={event => setClipAxis(event.target.value)}>
<option value="x">X · 좌우 폭</option><option value="y">Y · 높이(EL)</option><option value="z">Z · 길이/측점 방향</option>
</select>
</label>
<label className="clip-range">절단 위치 <input type="range" min="0" max="100" value={clipPosition} onChange={event => setClipPosition(Number(event.target.value))} /><b>{clipPosition}%</b></label>
<label>남길 쪽
<select value={clipKeep} onChange={event => setClipKeep(event.target.value)}>
<option value="positive">축의 + 방향</option><option value="negative">축의 − 방향</option>
</select>
</label>
<button className="clip-reset" onClick={() => { setClipPosition(50); setClipKeep('positive') }}>가운데로 초기화</button>
<span>표면을 잘라 내부를 볼 수 있습니다. 절단면 덮개는 생성하지 않으며, 도면 이미지는 참고용으로 클리핑되지 않습니다.</span>
</div>}
<div className="viewer-foot"><span>끌기: 회전 · 휠: 확대/축소 · Shift+끌기 또는 오른쪽 끌기: 이동</span><button onClick={() => { setView('전체'); setResetToken(value => value + 1); setSelected(null) }}>전체 보기 ↗</button></div>
<div className="selection" aria-live="polite">
{selectedPart && selectedUnit ? <>
@@ -112,7 +154,7 @@ export default function App() {
<p>도면 레이어<strong>{selectedPart.layer}</strong></p>
<p>종단 연결 길이<strong>{selectedUnit.length.toFixed(3)} m</strong></p>
<p>단면 EL 범위<strong>{Math.min(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)} ~ {Math.max(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)}</strong></p>
<p>배치 객체<strong>{selectedUnit.name}</strong></p>
<p>모델 대상<strong>{selectedUnit.name}</strong></p>
</div>
<p className="source-note">원본 HANDLE: {(selectedPart.sourceHandles || [selectedPart.sourceHandle, ...(selectedPart.sourceInsertHandles || [])]).filter(Boolean).join(' / ') || '블록 내부 객체'} · 이 표면은 닫힌 체적이 아닙니다.</p>
<button className="subtle" onClick={() => togglePart(selectedPart.id)}>{hidden.has(selectedPart.id) ? '선택 객체 표시' : '선택 객체 숨기기'}</button>
@@ -124,7 +166,6 @@ export default function App() {
<div className="tabs">{['부재', '도면·검증'].map(name => <button key={name} className={tab === name ? 'active' : ''} onClick={() => setTab(name)}>{name}</button>)}</div>
{tab === '부재' ? <>
<div className="panel-heading"><h2>모델 객체</h2><span>{visibleCount} 표시</span></div>
<div className="unit-toggles">{model.units.map((unit, index) => <label key={unit.id}><input type="checkbox" checked={!hiddenUnits.has(unit.id)} onChange={() => toggleUnit(unit.id)} />평면 중심선 {index + 1}<small>CAD상 중심 간격 {index ? '35.000' : '—'} m</small></label>)}</div>
<input className="search" aria-label="모델 객체 검색" placeholder="레이어 또는 객체 이름 검색" value={search} onChange={event => setSearch(event.target.value)} />
<div className="group-toggles">
{Object.entries(categoryLabels).map(([group, label]) => {
@@ -135,7 +176,7 @@ export default function App() {
</div>
<div className="part-list">
{filteredParts.map(part => <div className={`part-row ${selected?.partId === part.id ? 'chosen' : ''} ${hidden.has(part.id) ? 'is-hidden' : ''}`} key={part.id}>
<button onClick={() => { setSelected({ unitId: part.unitId, partId: part.id }); setTab('부재') }} title={part.name}>{part.name}<small>{part.unitName.replace('BLOCK8 평면 중심선 ', '배치 ')}</small></button>
<button onClick={() => { setSelected({ unitId: part.unitId, partId: part.id }); setTab('부재') }} title={part.name}>{part.name}<small>단일 구조물</small></button>
<input type="checkbox" aria-label={`${part.name} 표시`} checked={!hidden.has(part.id)} onChange={() => togglePart(part.id)} />
</div>)}
{!filteredParts.length && <p className="empty">검색 결과가 없습니다.</p>}
@@ -147,7 +188,7 @@ export default function App() {
<li><b>확인</b><span>측점 간격 19.980m<small>중심선 원호와 CS-CONC-MAJR 끝선 교점으로 계산</small></span></li>
<li><b>확인</b><span>단면 구조선 114개씩<small>CS-CONC-MAJR 108 + CS-CONC-MINR 6</small></span></li>
<li><b>확인</b><span>블록 내부 콘크리트<small>A$C2017021411375356 내부 18선 + 2원호, 4개 삽입 참조</small></span></li>
<li className="pending"><b>검토</b><span>두 중심선과 대표 단면 대응<small>두 평면 배치는 표시했으며 구조도에서 동일 단면 적용 여부 확인 필요</small></span></li>
<li><b>확인</b><span>도면 표현 연결<small>평면도·상부평면도는 같은 구조물을 나타내며 모델은 한 개로 구성</small></span></li>
<li className="pending"><b>검토</b><span>폭 20.300m / 외곽선 20.500m<small>단면 치수와 외곽 구조선 범위 차이 0.200m</small></span></li>
<li className="pending"><b>검토</b><span>기초 최저선과 EL 주석 차이<small>모델 선 끝이 표기 기초 EL보다 0.100m 낮음</small></span></li>
</ul>
@@ -161,7 +202,7 @@ export default function App() {
<article className="info-card"><span className="eyebrow">단면 기준 A-A → B-B</span><h2>EL값과 폭 분할</h2><p>중심 EL은 102.094 → 101.517m, 벽체 상단은 107.215 → 107.857m입니다. 표기 기초 하단은 100.877 → 100.392m입니다. 폭 치수는 0.9 + 8.75 + 1.0 + 8.75 + 0.9 = 20.300m입니다.</p></article>
<article className="info-card"><span className="eyebrow">모델 근거</span><h2>좌표 단위 검산</h2><p>DXF의 단위 설정은 비어 있지만, 구조선 좌표 차 20,300과 도면 치수 20.300m가 맞아 좌표를 mm로 환산했습니다. 현재 범위 EL {elevationRange[0].toFixed(3)}~{elevationRange[1].toFixed(3)}m, 단면 외곽선 폭 {dimensionRange[1] - dimensionRange[0] === 0 ? '—' : '20.500'}m입니다.</p></article>
</section>
<footer>출처: BLOCK8 DXF · 3D 면은 두 단면 선형 보간 · 미확정 대응은 검토 패널에 표시</footer>
<footer>출처: BLOCK8 DXF · 3D 면은 두 단면 선형 보간 · 평면도와 상부평면도는 단일 구조물 기준</footer>
</main>
)
}
+101 -13
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import drawingViews from './model/drawingViews.json' with { type: 'json' }
const DEFAULT_CAMERA = { yaw: -0.66, pitch: 0.43, zoom: 1, panX: 0, panY: 0 }
const COLORS = { major: '#78917c', minor: '#d0aa69', insert: '#9a9074' }
@@ -18,6 +19,34 @@ function worldPoint(unit, pointA, pointB, pointIndex, t) {
return [cx + lateral * normalX, elevation, cz + lateral * normalZ]
}
function centerlinePoint(unit, t, lateral, elevation) {
const angle = (unit.startAngle + (unit.endAngle - unit.startAngle) * t) * Math.PI / 180
const cx = unit.center[0] + unit.radius * Math.cos(angle)
const cz = unit.center[1] + unit.radius * Math.sin(angle)
return [cx - lateral * Math.cos(angle), elevation, cz - lateral * Math.sin(angle)]
}
function drawingPoint(unit, drawingId, pixelX, pixelY) {
const drawing = drawingViews[drawingId]
const [pixelWidth, pixelHeight] = drawing.pixelSize
const bounds = drawing.cadBounds
const cadX = bounds.xMin + (pixelX / pixelWidth) * (bounds.xMax - bounds.xMin)
const cadY = bounds.yMax - (pixelY / pixelHeight) * (bounds.yMax - bounds.yMin)
const placement = drawing.placement
if (placement.kind === 'plan') {
return [(cadX + placement.xOffset - unit.planOrigin[0]) / 1000, placement.displayEL, (cadY - unit.planOrigin[1]) / 1000]
}
if (placement.kind === 'section') {
const lateral = (cadX - placement.cadXCenter) / 1000
const elevation = placement.baseEL + (cadY - placement.cadYBase) / 1000
return centerlinePoint(unit, placement.stationT, lateral, elevation)
}
const [stationStart, stationEnd] = placement.stationCad
const t = (cadX - stationStart) / (stationEnd - stationStart)
const elevation = cadY / 1000 + placement.elevationOffset
return centerlinePoint(unit, t, placement.lateralOffset, elevation)
}
function hexRgb(hex) {
return [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16))
}
@@ -37,16 +66,44 @@ function inPolygon(point, polygon) {
return inside
}
export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSelect, view, renderMode, resetToken }) {
function clipPolygon(points, axis, threshold, keepPositive) {
const inside = point => keepPositive ? point[axis] >= threshold : point[axis] <= threshold
const output = []
for (let index = 0; index < points.length; index++) {
const current = points[index]
const previous = points[(index + points.length - 1) % points.length]
const currentInside = inside(current), previousInside = inside(previous)
if (currentInside !== previousInside) {
const ratio = (threshold - previous[axis]) / (current[axis] - previous[axis])
output.push(previous.map((value, coordinate) => value + (current[coordinate] - value) * ratio))
}
if (currentInside) output.push(current)
}
return output
}
export default function ModelViewer({ units, hidden, selected, onSelect, view, renderMode, resetToken, drawingVisible, drawing, drawingOpacity, clipping, clipAxis, clipPosition, clipKeep }) {
const canvasRef = useRef(null)
const cameraRef = useRef({ ...DEFAULT_CAMERA })
const sceneRef = useRef({ units, hidden, hiddenUnits, selected, view, renderMode, onSelect })
const drawingImagesRef = useRef({})
const sceneRef = useRef({ units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity, clipping, clipAxis, clipPosition, clipKeep })
const drawRef = useRef(() => {})
useEffect(() => {
sceneRef.current = { units, hidden, hiddenUnits, selected, view, renderMode, onSelect }
sceneRef.current = { units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity, clipping, clipAxis, clipPosition, clipKeep }
drawRef.current()
}, [units, hidden, hiddenUnits, selected, view, renderMode, onSelect])
}, [units, hidden, selected, view, renderMode, onSelect, drawingVisible, drawing, drawingOpacity, clipping, clipAxis, clipPosition, clipKeep])
useEffect(() => {
const images = {}
for (const id of Object.keys(drawingViews)) {
const image = new Image()
image.onload = () => drawRef.current()
image.src = window.BLOCK8_DRAWING_IMAGES?.[id] || `/drawing-images/BLOCK8_${id}.svg`
images[id] = image
}
drawingImagesRef.current = images
}, [])
useEffect(() => {
const canvas = canvasRef.current
@@ -74,7 +131,7 @@ export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSe
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr)
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const { units, hidden, hiddenUnits, selected, renderMode } = sceneRef.current
const { units, hidden, selected, renderMode, drawingVisible, drawing, drawingOpacity, clipping, clipAxis, clipPosition, clipKeep } = sceneRef.current
const background = ctx.createLinearGradient(0, 0, 0, height)
background.addColorStop(0, renderMode ? '#e8efea' : '#f8faf8')
background.addColorStop(1, renderMode ? '#cbd8cf' : '#e9efea')
@@ -88,10 +145,10 @@ export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSe
for (let x = -40; x <= 40; x += 5) drawLine([x, 100.2, -22], [x, 100.2, 22], '#aab9ad38', .6)
for (let z = -20; z <= 20; z += 5) drawLine([-40, 100.2, z], [40, 100.2, z], '#aab9ad38', .6)
const faces = []
const sourceFaces = []
const bounds = [[Infinity, -Infinity], [Infinity, -Infinity], [Infinity, -Infinity]]
const longitudinalSteps = 8
for (const unit of units) {
if (hiddenUnits.has(unit.id)) continue
for (const part of unit.parts) {
if (hidden.has(part.id) || part.a.length !== part.b.length) continue
const color = COLORS[part.group] || COLORS.major
@@ -105,13 +162,24 @@ export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSe
worldPoint(unit, part.a, part.b, edge + 1, t1),
worldPoint(unit, part.a, part.b, edge, t1),
]
const projected = points3d.map(point => project(point, width, height))
const depth = projected.reduce((sum, point) => sum + point[2], 0) / projected.length
faces.push({ projected, part, unit, color, depth, active, shade: .82 + .18 * Math.abs(Math.sin(edge * .73 + step * .11)) })
points3d.forEach(point => point.forEach((value, axis) => {
bounds[axis][0] = Math.min(bounds[axis][0], value)
bounds[axis][1] = Math.max(bounds[axis][1], value)
}))
sourceFaces.push({ points3d, part, unit, color, active, shade: .82 + .18 * Math.abs(Math.sin(edge * .73 + step * .11)) })
}
}
}
}
const axisIndex = { x: 0, y: 1, z: 2 }[clipAxis]
const threshold = bounds[axisIndex][0] + (bounds[axisIndex][1] - bounds[axisIndex][0]) * clipPosition / 100
const faces = sourceFaces.flatMap(face => {
const points3d = clipping ? clipPolygon(face.points3d, axisIndex, threshold, clipKeep === 'positive') : face.points3d
if (points3d.length < 3) return []
const projected = points3d.map(point => project(point, width, height))
const depth = projected.reduce((sum, point) => sum + point[2], 0) / projected.length
return [{ ...face, projected, depth }]
})
faces.sort((a, b) => a.depth - b.depth)
hitFaces = []
for (const face of faces) {
@@ -126,14 +194,34 @@ export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSe
hitFaces.push(face)
}
if (drawingVisible && drawing && units.length) {
const image = drawingImagesRef.current[drawing]
if (image?.complete && image.naturalWidth) {
const unit = units[0]
const [imageWidth, imageHeight] = drawingViews[drawing].pixelSize
const p00 = project(drawingPoint(unit, drawing, 0, 0), width, height)
const p10 = project(drawingPoint(unit, drawing, imageWidth, 0), width, height)
const p01 = project(drawingPoint(unit, drawing, 0, imageHeight), width, height)
ctx.save()
ctx.globalAlpha = drawingOpacity
ctx.globalCompositeOperation = 'source-over'
ctx.transform(
(p10[0]-p00[0])/imageWidth, (p10[1]-p00[1])/imageWidth,
(p01[0]-p00[0])/imageHeight, (p01[1]-p00[1])/imageHeight,
p00[0], p00[1],
)
ctx.drawImage(image, 0, 0, imageWidth, imageHeight)
ctx.restore()
}
}
ctx.font = '11px sans-serif'
ctx.textAlign = 'left'
ctx.fillStyle = '#68806d'
units.forEach((unit, index) => {
if (hiddenUnits.has(unit.id)) return
units.forEach(unit => {
const start = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 0), width, height)
const end = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 1), width, height)
ctx.fillText(`중심선 ${index + 1} · STA 319.010`, start[0] - 5, start[1] - 16)
ctx.fillText('BLOCK8 중심선 · STA 319.010', start[0] - 5, start[1] - 16)
ctx.fillText(`STA 338.990`, end[0] - 8, end[1] - 14)
})
ctx.fillStyle = '#6f8275'
+18
View File
@@ -34,6 +34,9 @@ h1 em { margin-left:7px; color:#738b79; font-size:.8em; font-style:normal; font-
.workspace { display:grid; grid-template-columns:minmax(0,1fr) 322px; gap:16px; align-items:start; }
.viewer-card,.side-card,.info-card { min-width:0; overflow:hidden; border:1px solid #dbe4d7; border-radius:11px; background:#fafcf8; }
.viewer-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:17px 19px 13px; }
.viewer-actions { display:flex; align-items:center; gap:8px; }
.drawing-toggle { padding:7px 10px; border:1px solid #d4dfd2; border-radius:6px; color:#5c765f; font-size:9px; white-space:nowrap; }
.drawing-toggle.active { border-color:#8aa48c; background:#e9f0e7; color:#385f42; }
h2 { margin:5px 0 0; font-size:16px; letter-spacing:-.04em; }
.segmented { display:flex; padding:3px; border-radius:7px; background:#eaf0e7; }
.segmented button { padding:6px 9px; border-radius:5px; color:#7f8d7b; font-size:9px; white-space:nowrap; }
@@ -44,6 +47,18 @@ h2 { margin:5px 0 0; font-size:16px; letter-spacing:-.04em; }
.views button.active { border-color:#ccdac9; background:#eef3ea; color:#426a50; }
.viewbar>span { color:#8b9687; font-size:9px; white-space:nowrap; }
.canvas-wrap { position:relative; overflow:hidden; border-top:1px solid #dfe8da; border-bottom:1px solid #dfe8da; background:#f4f7f3; }
.drawing-controls { display:flex; align-items:center; gap:14px; padding:9px 15px; border-bottom:1px solid #e2e9dd; background:#f7faf5; }
.drawing-controls label { display:flex; align-items:center; gap:7px; color:#647568; font-size:9px; white-space:nowrap; }
.drawing-controls select { max-width:180px; padding:5px 7px; border:1px solid #d6e0d4; border-radius:5px; background:#fff; color:#526657; font-size:9px; }
.drawing-controls input { width:95px; accent-color:#59785e; }
.drawing-controls>span { color:#879287; font-size:8px; line-height:1.5; }
.clip-controls { display:flex; align-items:center; flex-wrap:wrap; gap:9px 14px; padding:10px 15px; border-bottom:1px solid #e2e9dd; background:#f4f8f1; }
.clip-controls label { display:flex; align-items:center; gap:7px; color:#526b58; font-size:9px; white-space:nowrap; }
.clip-controls select { max-width:180px; padding:5px 7px; border:1px solid #d2dfd0; border-radius:5px; background:#fff; color:#526657; font-size:9px; }
.clip-controls input { width:105px; accent-color:#59785e; }
.clip-controls b { min-width:28px; color:#44694d; font-size:9px; }
.clip-controls .clip-reset { padding:5px 8px; border:1px solid #d4dfd2; border-radius:5px; color:#58715d; font-size:9px; }
.clip-controls>span { flex-basis:100%; color:#879287; font-size:8px; line-height:1.5; }
.model-canvas { display:block; width:100%; height:485px; touch-action:none; cursor:grab; }
.model-canvas:active { cursor:grabbing; }
.model-canvas:focus-visible { outline:2px solid #7d9a83; outline-offset:-2px; }
@@ -128,6 +143,8 @@ footer { padding-top:15px; color:#89958a; font-size:8px; }
.notice { gap:8px; }
.workspace { grid-template-columns:1fr; }
.model-canvas { height:380px; }
.drawing-controls { flex-wrap:wrap; gap:8px 12px; }
.drawing-controls>span { flex-basis:100%; }
.side-card { order:2; }
.part-list { max-height:330px; }
.bottom-grid { grid-template-columns:1fr; gap:9px; }
@@ -138,6 +155,7 @@ footer { padding-top:15px; color:#89958a; font-size:8px; }
.views { width:100%; flex-wrap:wrap; }
.views button { padding:6px 8px; }
.model-canvas { height:300px; }
.viewer-actions { flex-wrap:wrap; }
.viewer-foot { align-items:flex-start; }
.metrics strong { font-size:20px; }
.metrics strong.el { font-size:16px; }
+1 -5318
View File
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
{
"plan": {
"title": "평면도",
"pixelSize": [
1400,
1160
],
"cadBounds": {
"xMin": 1195000,
"xMax": 1230000,
"yMin": -90000,
"yMax": -61000
},
"placement": {
"kind": "plan",
"xOffset": 0,
"displayEL": 108.8
}
},
"upper-plan": {
"title": "상부평면도",
"pixelSize": [
1400,
1097
],
"cadBounds": {
"xMin": 1230000,
"xMax": 1267000,
"yMin": -90000,
"yMax": -61000
},
"placement": {
"kind": "plan",
"xOffset": -35000,
"displayEL": 108.8
}
},
"longitudinal": {
"title": "종단면도",
"pixelSize": [
1400,
741
],
"cadBounds": {
"xMin": 1213000,
"xMax": 1247000,
"yMin": -108000,
"yMax": -90000
},
"placement": {
"kind": "longitudinal",
"stationCad": [
1218301.059683612,
1241698.059683612
],
"elevationOffset": 205.2898,
"lateralOffset": 15
}
},
"section-a": {
"title": "단면 A-A · STA. 0K+319.000",
"pixelSize": [
1400,
831
],
"cadBounds": {
"xMin": 1280000,
"xMax": 1312000,
"yMin": -83000,
"yMax": -64000
},
"placement": {
"kind": "section",
"stationT": -0.0005005005,
"cadXCenter": 1296487.109248054,
"cadYBase": -78059.37855593952,
"baseEL": 101.777
}
},
"section-b": {
"title": "단면 B-B · STA. 0K+339.000",
"pixelSize": [
1400,
831
],
"cadBounds": {
"xMin": 1280000,
"xMax": 1312000,
"yMin": -102000,
"yMax": -83000
},
"placement": {
"kind": "section",
"stationT": 1.0005005005,
"cadXCenter": 1296487.109248054,
"cadYBase": -97286.37855593952,
"baseEL": 101.292
}
}
}