프로젝트 틀만 남기고 초기화

This commit is contained in:
김지영 committed 2026-10-20 12:05:57 +09:00
1 parent fac29cb010
commit aadccee687
32 files changed
+44 -331892

No files matched your search

-51
View File
@@ -1,51 +0,0 @@
# 프로젝트 지침: 도면 기반 웹 3D 모델
## 기본 원칙
- 사용자와 주고받는 설명 및 웹 화면 문구는 한국어로 작성하고, 개발 초보자도 이해하기 쉬운 용어를 쓴다.
- 작업 전 기존 폴더 구조와 코드를 확인하고 필요한 부분만 수정한다. 큰 구조 변경이 필요하면 이유와 범위를 먼저 설명한다.
- 비밀번호, API 키 등 민감한 정보를 코드나 Git 기록에 포함하지 않는다.
- 도면 첨부 후에는 분석, 주요 형상 모델링, 세부 형상, 뷰어 기능, 검증 및 문서화처럼 검토 가능한 작업 단위로 나누어 진행한다. 각 단위가 완료되고 변경 내용을 확인한 뒤 해당 변경만 Git 커밋으로 저장한다.
- 커밋 메시지는 변경 내용을 쉽게 알 수 있도록 한국어로 작성한다. 사용자가 요청한 각 작업 단위별 커밋을 만들되, 원격 저장소로 푸시하거나 공개하지 않는다.
- 도면 파일이 없거나 판독이 불가능하면 그 사실을 알리고, 도면 근거가 필요한 형상·치수를 임의로 확정하지 않는다. 정보가 부족해도 진행 가능한 부분은 먼저 진행하고 확인 항목과 나눈다.
## 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레이어를 가진 구조물선관 그에 해당하는 치수가 일치하는지 확인한다.
- CS-CONC-MAJR,CS-CONC-MINR 이 두레이어의 표기 치수가 부족한 경우 실제 윤곽선을 측정하여 모델링한다.
## 2. 모델링 값 기록
- 대상별 위치, 길이, 폭, 높이, 두께, 간격, 경사 등 모델에 필요한 값을 표로 정리한다.
- 각 값에 근거 도면 번호와 도면 안의 위치를 함께 기록한다.
- 각 값을 다음 상태 중 하나로 표시한다: `도면 명시값`, `계산으로 도출한 값`, `추정값`, `확인 필요`.
- 축척으로 재어 얻은 값은 도면에 적힌 치수와 혼동되지 않도록 별도로 표시한다.
## 3. 오류 및 정보 부족 검토
- 도면 간 치수·위치·높이의 불일치를 확인해 기록한다.
- 누락되었거나 판독하기 어려운 정보를 목록으로 만든다.
- 형상과 배치에 영향을 주는 치수는 임의로 결정하지 않고 사용자 확인을 요청한다.
## 4. 모델 구성 및 생성
- 단면도의 치수선을 제외한 구조물선을 누락없이 모델링에 반영한다.
- 부재를 개별 선택·숨김·수정할 수 있도록 객체를 나누고 알아보기 쉬운 이름을 붙인다.
- 도면 개정 때 치수와 배치를 쉽게 바꾸도록 값을 코드와 모델 구조에서 관리하기 쉽게 구성한다.
- 먼저 주요 형상과 전체 배치를 만들고, 이후 세부 형상을 추가한다.
## 5. 웹 3D 뷰어
- 마우스 또는 터치로 회전·확대/축소·이동할 수 있게 한다.
- 전체 보기와 평면·정면·측면 보기를 제공한다.
- 객체를 선택하면 이름과 주요 치수를 보여준다.
- 객체별 표시/숨김 기능을 제공한다.
- 추정값 또는 확인 필요 항목으로 만든 형상은 화면에서 명확히 구분한다.
- 기본 모델 보기와 표면 명암·배경·그림자를 적용한 렌더링 보기를 전환할 수 있게 한다. 렌더링 보기는 도면 검토용 시각화이며 실사 렌더링으로 오해하지 않도록 표시한다.
## 6. 검증 및 결과 보고
- 주요 치수, 기준 높이, 부재 개수와 배치를 원도면과 대조한다.
- 검증 완료 항목과 미검증 항목을 나누어 보고한다.
- 도면과 다르게 표현한 부분, 추정한 부분, 생략한 부분을 기록한다.
- 실행 방법과 다음 수정 때 변경할 항목을 안내한다.
- 변경 후 가능한 실행·검증을 수행하고, 확인하지 못한 부분을 명시한다.
+13 -44
View File
@@ -1,54 +1,23 @@
# 광터지하차도 BLOCK7 도면 기반 3D 모델
# 새 3D 모델 프로젝트
원본 DXF의 단면 구조선과 공동구 실제 윤곽을 추출해, 사용자가 허용한 19.980m 직선 연결로 만든 검토용 웹 3D 표면 모델입니다. 평면 중심선·실제 중간 형상은 미확정이며 모든 연결면에 `확인 필요`를 표시합니다.
React와 Vite를 사용하는 웹 프로젝트 기본 틀입니다. 작업을 시작할 수 있도록 앱 화면과 프로젝트별 자료를 비워 두었습니다.
## 실행
프로젝트 폴더의 WSL 터미널에서 실행합니다. Node 24 환경으로 확인했습니다.
```bash
npm ci
npm run dev -- --host 127.0.0.1
npm run dev
```
터미널에 나오는 주소를 브라우저에서 엽니다. 기본 포트가 사용 중이면 다음 포트를 선택합니다. `npm run build`는 dist 폴더를 만들고, `npm run preview -- --host 127.0.0.1`로 빌드 결과를 볼 수 있습니다.
## 주요 명령
## 사용
- `npm run dev`: 개발 서버 실행
- `npm run build`: 배포용 파일 생성
- `npm run preview`: 빌드한 화면 미리보기
- `npm run lint`: 코드 기본 검사
- 끌기: 회전 / 휠: 확대 / Shift+끌기 또는 오른쪽 끌기: 이동
- 터치: 한 손가락 회전 / 두 손가락 확대·이동
- 방향키: 회전 / +와 -: 확대·축소 / Home: 전체 보기 / Esc: 선택 해제
- 전체·평면·정면·측면·A-A·B-B 시점, 연결면 표시 전환
- 선 또는 면 선택: 이름·좌표 기반 치수·원본 선 번호 표시
- 목록: 개별/그룹 숨김, 검색, 포장 반복선 표시
- 기본/렌더링 보기: 표면 명암·배경·그림자 전환. 실사 렌더링이 아닙니다.
- 3D OBJ 다운로드: 숨기지 않은 객체의 단면선·연결면 내보내기
- 좌표·근거 JSON 다운로드: 원본 좌표와 HANDLE, 가정 상태 보존
## 시작 위치
미리 생성된 OBJ: `public/models/BLOCK7_검토용모델.obj`. 표면과 선이 포함된 열린 모델이며 닫힌 체적이 아닙니다. 단위는 m, OBJ 축은 X=횡방향 / Y=EL / Z=종단 경계 기준 거리 0~19.980입니다. 실제 평면 좌표는 적용하지 않았습니다. 포장 반복선은 웹 기본 보기에서만 숨기며, OBJ에는 별도 선 객체로 보존합니다.
## 분석과 검증
```bash
python3 scripts/analyze_dxf.py
python3 scripts/verify_model.py
node scripts/verify_geometry.mjs
npm run lint
npm run build
```
Python은 표준 라이브러리만 사용합니다. 추출 중간 자료는 `docs/extracted/`에 만들고 Git에서는 제외합니다. `verify_geometry.mjs`는 검증 후 미리 생성된 OBJ도 갱신합니다.
개발 서버에서 `/scripts/browser-check.html`을 열면 브라우저 동작 검증 결과를, `/scripts/mobile-check.html`에서 좁은 화면의 가로 넘침 검사를 확인할 수 있습니다. 이 검증 페이지는 배포용 dist에 포함되지 않습니다.
- 도면 근거·치수·불일치: `docs/도면분석.md`
- 검증 결과·미검증 항목: `docs/검증보고.md`
- 화면 캡처: `docs/screenshots/`
## 다음 수정 위치
원본 개정 시 DXF를 교체하고 분석 스크립트의 단면 범위·EL 기준·공동구 INSERT 좌표를 새 도면에 맞춰 수정합니다. `scripts/analyze_dxf.py`의 group 함수는 현 도면의 원본 선 순서에 대응하므로 개정 시 부재 분류를 다시 대조해야 합니다. 자동 추출만으로 변경 검증을 대신하지 않습니다.
`src/model/block7.json`은 추출된 값, `geometry.js`는 연결·OBJ 생성, `Viewer.jsx`는 화면 조작과 명암, `App.jsx`는 검토 패널입니다. 부재별 윤곽 좌표와 이름은 JSON에서 분리되어 있습니다. JSON을 직접 수정하면 다음 원본 재추출 때 덮어쓰므로, 확정 변경은 추출 스크립트의 근거값에도 반영하세요.
원격 저장소로 푸시하거나 공개하지 않았습니다.
- `src/App.jsx`: 웹 화면
- `src/index.css`: 화면 스타일
- `src/main.jsx`: 앱 시작 파일
- `index.html`: 페이지 기본 정보
-125
View File
@@ -1,125 +0,0 @@
{
"status": "PASS",
"checks": [
{
"name": "3D 캔버스 생성",
"pass": true
},
{
"name": "원본 154 객체 표시",
"pass": true
},
{
"name": "버튼 존재: 평면",
"pass": true
},
{
"name": "버튼 존재: 정면",
"pass": true
},
{
"name": "버튼 존재: 측면",
"pass": true
},
{
"name": "버튼 존재: A-A",
"pass": true
},
{
"name": "버튼 존재: B-B",
"pass": true
},
{
"name": "버튼 존재: 전체",
"pass": true
},
{
"name": "버튼 존재: 모두 숨김",
"pass": true
},
{
"name": "전체 숨김 반영",
"pass": true
},
{
"name": "버튼 존재: 모두 표시",
"pass": true
},
{
"name": "반복선 제외한 전체 표시",
"pass": true
},
{
"name": "선택하면 원본 근거 표시",
"pass": true
},
{
"name": "버튼 존재: 선택한 윤곽 숨기기",
"pass": true
},
{
"name": "개별 숨김 반영",
"pass": true
},
{
"name": "버튼 존재: 3D OBJ ↓",
"pass": true
},
{
"name": "OBJ 내보내기 153개 객체",
"pass": true
},
{
"name": "OBJ 유효한 좌표",
"pass": true
},
{
"name": "OBJ 연결 삼각면 466개",
"pass": true
},
{
"name": "키보드 회전 반영",
"pass": true
},
{
"name": "휠 확대 반영",
"pass": true
},
{
"name": "윤곽 검색 빈 결과 표시",
"pass": true
},
{
"name": "버튼 존재: 도면",
"pass": true
},
{
"name": "도면 발췌 표시",
"pass": true
},
{
"name": "버튼 존재: 측구상세",
"pass": true
},
{
"name": "상세도 전환",
"pass": true
},
{
"name": "버튼 존재: 검증",
"pass": true
},
{
"name": "미검증 항목 구분",
"pass": true
},
{
"name": "버튼 존재: 렌더링 보기",
"pass": true
},
{
"name": "시각화 구분",
"pass": true
}
]
}
-6
View File
@@ -1,6 +0,0 @@
{
"viewport": 375,
"content": 375,
"body": 375,
"pass": true
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

-43
View File
@@ -1,43 +0,0 @@
# BLOCK7 모델 검증 보고 (2026-10-20)
## 결과와 적용 범위
원본 단면 구조선 114쌍과 좌우 공동구·상부 상세 40쌍을 각각 선택 가능한 154개 윤곽 객체로 만들었다. 사용자 허용에 따라 두 단면을 종단 경계 간격 19.980m로 직선 연결했다. 포장 반복선 60개는 원본 선으로 보존하고, 나머지 94개 객체의 연결 삼각면은 468개다. 실물 부재 개수나 확정 체적을 뜻하지 않는다.
모든 연결면은 `확인 필요`다. 실제 평면 중심선, 중간 곡률·단면, 중심 기준 EL은 적용하지 않았다. 원본 단면 STA 299.000/319.000은 보존하고 배치만 종단 구조 경계 299.010/318.990에 대응시켰다.
## 수행한 검사
| 검사 | 결과 | 근거 |
|---|---|---|
| 원본 두 단면 레이어 수 | A/B 각각 MAJR 108+MINR 6=114 | scripts/verify_model.py |
| 원본 선 번호와 모든 끝점 | 원본과 일치, 좌표 반올림 오차 0.000051mm 이하 | docs/좌표검증결과.txt |
| 상단 EL | 106.675 / 107.307, 종단·단면 표기 일치 | docs/도면분석.md 및 좌표 검산 |
| 경계 간격 | 318.990-299.010=19.980m | 두 단면 원본 좌표 보존 후 배치 검산 |
| 공동구·상부 상세 | 좌우 각각 LINE 18+ARC 2, 우측 반전 및 삽입 Y 반영 | DXF 블록과 추출 데이터 |
| 연결·OBJ | 468 삼각면 / 154 객체, 유효한 꼭짓점 참조, 숨김 반영 | scripts/verify_geometry.mjs |
| 웹 브라우저 동작 | Chrome 검사 30개 통과 (버튼 존재 확인 포함) | docs/browser-check-result.json |
| 시점·선택·숨김·검색 | 시점 6종, 전체·개별 숨김, 목록 선택 정보, 빈 검색 결과 확인 | 위 브라우저 검사 |
| 회전·확대 | 키보드 회전 및 휠 확대 후 실제 캔버스 변화 확인 | 위 브라우저 검사 |
| OBJ 다운로드 | 1개 숨긴 상태에서 153 객체/466면 내보내기 확인 | 위 브라우저 검사 |
| 도면·검증·렌더링 패널 | 도면 전환 및 미확정 항목/시각화 안내 확인 | 위 브라우저 검사 |
| 좁은 화면 | 390px 프레임의 내용 영역 375px에서 가로 넘침 없음 | docs/mobile-check-result.json |
| 빌드·정적 검사 | Vite 빌드 성공, oxlint 경고 0/오류 0 | 최종 명령 실행 |
| 화면 확인 | 기본·렌더링 화면 캡처와 배치 확인 | docs/screenshots/desktop.png, render.png, mobile.png |
## 미검증·생략·표현 차이
- 실제 평면 중심선·좌표·곡률 및 A/B 절단 위치 대응, 도로 중심 기준 EL은 미검증이다. 임의 방향각을 원본 중심선으로 사용하지 않았다.
- 원본 단면 자체 측점과 종단 경계가 양 끝 각각 10mm 차이 난다. 사용자가 허용한 연결 검토를 위해 대응시킨 것이며 원인 확인은 남았다.
- 단면 사이 모든 점은 직선 보간이다. 공동구·측구·상부 가는 상세도 같은 방식으로 연장한 확인 필요 형상이다.
- 포장 반복선·하부 외측 보조선의 재료·해칭 여부, 중복된 선·면과 체적 접합은 완전히 분류·검증하지 않았다. 선 기반 열린 표면이며 끝단을 임의로 막지 않는다.
- ARC는 반원당 36개 선분으로 표시한다. 원본 반지름 약 30mm에서 최대 현 오차는 약 0.029mm다.
- 측구 상세는 원본 발췌를 제공하지만 단면 측구와의 깊이·시종점 적용 대응은 미확정이다. 상세 치수로 단면 윤곽을 임의 교체하지 않았다.
- 원본 검토 SVG는 LINE·TEXT·공동구 LINE 발췌다. 전체 DXF의 모든 블록·해칭·기호·폴리라인·원호를 렌더링하지 않는다.
- 지반·되메우기·철근·재료·정착·이음 상세의 구간 적용은 생략했다. 근거 없는 별도 금속 난간은 추가하지 않았다.
- 렌더링은 CPU 기반 삼각면 깊이 정렬·단순 명암·투영 그림자이다. 고급 광학 재질·실사 표현·정밀 단면 절단 기능은 제공하지 않는다.
- 실제 터치 기기의 두 손가락 동작, 모든 브라우저·3D 프로그램의 OBJ 호환성, 마우스 드래그·면 선택의 수동 전수 검사는 수행하지 못했다. 목록 선택·키보드 회전·휠 확대·내보내기는 자동 검증했다.
## 다음에 확인할 자료
평면 중심선의 엔티티 또는 측점별 중심 좌표, A-A/B-B 절단 위치와 중심 EL, 측점 10mm 차이의 경계 기준, 중간 단면·측구 연장 범위를 확인하면 검토용 직선 연결을 실제 배치로 갱신할 수 있다. 수정 위치와 실행 방법은 README.md에 기록했다.
-67
View File
@@ -1,67 +0,0 @@
# BLOCK7 원본 재분석 및 모델 적용 근거
원본: `public/drawings/광터지하차도_일반도(BLOCK7).dxf`. 분석 재현: `python3 scripts/analyze_dxf.py`. 이전 문서의 가정과 사용자 지시는 이번 모델의 확정 근거로 사용하지 않는다.
## 원본과 도면 연결
- AC1027 DXF이며 본문 문자는 UTF-8로 정상 판독된다. 헤더의 ANSI_949와 달리 CP949로 읽으면 한글이 깨진다. `$INSUNITS=0`은 단위 미지정이다.
- 일반도 13: 평면·상부평면·종단 S=1:100. 일반도 14: A-A/B-B S=1:100, 측구 S=1:30, 시공이음 등 S=1:20. 축척은 표현 축척이며 모델 좌표에 100을 다시 곱하지 않는다.
- 14번 단면 폭 치수열 800+8.750+1.000+8.750+800과 구조선 X 차 20100을 대조하면 CAD 1000단위=1m가 된다. 상·하단 EL 차와 Y 차도 같은 환산에 일치한다.
- A-A/B-B는 제목·상단 EL·하단 EL·동일 X 치수열로 대응시켰다. 단면 측점은 299.000/319.000, 종단면 구조 경계 측점은 299.010/318.990이다. 각각 10mm 어긋나며 신축이음 등 경계 기준의 차이인지 확정되지 않았다.
- 평면의 BLOCK7 표기 주변 CS-CONC-MAJR 선 21835/21836은 각각 20.100m이다. 단면 폭도 20.100m여서 이전 문서의 종방향 중심선 판단은 근거가 부족하다. 선형중심 문자는 (1069233.4121,-81231.4642), (1034233.4121,-81231.4642)에 있으나 대응 엔티티와 절단선은 미확정이다. 이 후보 방향을 모델 중심선으로 적용하지 않는다.
- 상부평면과 평면을 포함한 원본 검토 SVG를 제공한다. SVG는 LINE·TEXT·공동구 LINE 발췌이며 전체 CAD 렌더러가 아니다. 다른 블록·해칭·폴리라인·기호는 원본 DXF에서 확인해야 한다.
## 적용값 표
| 대상 | 값 | 상태 | 도면 번호·도면 내 위치와 근거 |
|---|---|---|---|
| 구조 경계 측점 | 299.010 / 318.990m | 도면 명시값 | 13 종단, 문자 (1050295.169,-97151.037)/(1073692.169,-97151.037) |
| 경계 사이 거리 | 19.980m | 계산으로 도출한 값 | 위 두 측점 차; 단면 연결에 사용할 경우 배치 확인 필요 |
| 단면 자체 측점 | 299.000 / 319.000m | 도면 명시값 | 14 A-A/B-B 중앙 STA 문자; 종단 경계와 10mm 차이 |
| 단면 원점 X | 1128486.218066491 | 계산으로 도출한 값 | 14 폭 치수 20.100의 양끝 중앙 |
| A-A 상단/하단 | EL 106.675 / 101.636m | 도면 명시값 | 14 A-A 좌측 EL 문자 |
| B-B 상단/하단 | EL 107.307 / 101.069m | 도면 명시값 | 14 B-B 좌측 EL 문자 |
| 상단 CAD Y | A -74271.37855593952 / B -91211.37855593952 | 계산으로 도출한 값 | 14 두 벽체 상단 수평선과 EL 표기의 대응 |
| 벽체 전체 높이 | A 5.039m / B 6.238m | 계산으로 도출한 값 | 각 상·하단 차; 바닥판을 포함한 높이 |
| 종단 상면 표기 | A 102.436 / B 101.869m | 도면 명시값 | 13 종단 좌우 EL 문자 |
| 상면-하단 차 | 0.800m | 계산으로 도출한 값 | 위 종단 EL과 14 하단 EL 차 |
| 폭 치수열 | 0.800+8.750+1.000+8.750+0.800=20.100m | 도면 명시값 | 14 단면 아래 치수열 |
| 가장 바깥 보조선 폭 | 20.300m | 계산으로 도출한 값 | 14 좌표 측정 X=중심±10150; 표기 폭 20.100과 기준이 다름 |
| 횡단경사 | 양쪽 바깥으로 -2% | 도면 명시값・계산으로 도출한 값 | 14 S=-2.000% 및 포장선 X ±0.5→±7.75에서 Y 145mm 하강 / 7250mm |
| 단면 중심 기준 EL | 미확정 | 확인 필요 | 벽체 상단 EL을 도로 중심 EL로 대체하지 않음 |
| 공동구·상부 상세 | 폭 경계 1.000m, 전체 높이 경계 2.100m | 계산으로 도출한 값 | 14 블록 A$C2017021411375356, 로컬 X -500~500/Y -900~1200. 좌표 측정이며 명시 치수 아님 |
| 공동구 좌우 위치 | 중심에서 ±8.750m | 계산으로 도출한 값 | 14 네 INSERT의 X 좌표, 우측은 X반전 |
| 공동구 기준 Y | A -77447.89255584464 / B -95669.3155558447 | 계산으로 도출한 값 | 14 INSERT 원점. 선·반원 전체를 원형대로 변환 |
| 측구 단면 내부 폭 | 0.400m | 계산으로 도출한 값 | 14 단면 CS-CONC-MAJR X ±7.8~±8.2 좌표 측정 |
| 측구 상세 표기 | 50 / 200 등 | 도면 명시값 | 14 측구 상세, S=1:30。단면 내 깊이와 같다고 단정하지 않음 |
| 평면 중심선·단면 방향 | 미확정 | 확인 필요 | 13 평면·상부평면의 절단 위치와 엔티티 대응 미완료 |
| 중간 단면·연장 | 미확정 | 확인 필요 | 두 단면 사이 선형 보간은 별도 가정, 실제 중간 형상 검증 안 됨 |
## 구조선 대조
각 단면의 X 1118000~1139000, 상단 Y부터 아래 7500·위 100 범위에 양 끝이 들어오는 LINE 중 두 레이어를 추출했다. 이 범위는 주요 단면과 하부 100mm 보조 윤곽을 포함한다.
- A-A/B-B 각각 MAJR 108개 + MINR 6개 = 114개. 기존 90+6 기록에는 하부·벽체 등의 선이 빠져 있었다.
- 각 대응 선의 레이어와 X 끝점이 일치한다. 원본 HANDLE을 모델에 저장해 다시 찾을 수 있다.
- 포장 반복선·겹친 선도 삭제하지 않는다. 반복선을 모두 실제 콘크리트 표면으로 판단하지 않으며 기본 보기에서는 끌 수 있는 별도 그룹이다.
- 좌우 공동구·상부 상세는 블록당 LINE 18개+ARC 2개. 우측 X반전, 각 단면 INSERT Y를 적용한다. 반원은 36분할 표시하므로 현 높이 오차는 약 0.03mm 이하이다. 임의 100mm 벽두께를 만들지 않는다.
- 원본 선 기반 객체 154개(단면 LINE 114쌍, 블록 엔티티 20쌍×좌우). 부재 체적이 아니라 대응 윤곽 객체의 수이다.
## 확인 항목과 모델 제한
1. 평면 선형중심에 해당하는 엔티티·A/B 절단 위치·중심 EL이 미확정이다. 확인되지 않은 위치·곡률을 도면 확정값으로 만들지 않는다.
2. 단면 자체 측점 20.000m 간격과 종단 구조 경계 19.980m 차이의 원인을 확인해야 한다.
3. 종단 벽체 상단 EL 106.675/107.307은 단면 상단 표기와 각각 일치한다. 도로 중심 EL과의 일치는 검증하지 못했다.
4. 포장 반복선, 하부·외측 보조선의 재료와 체적 경계 해석은 미검증이다. 닫힌 체적·수량 산출용 모델로 해석하지 않는다.
5. 측구 상세 S=1:30은 별도 검토 자료로 제공한다. 단면 측구와의 깊이·시종점 적용은 미확정이며 상세 치수로 단면을 임의 교체하지 않는다.
6. 철근, 지반, 되메우기, 이음 상세의 구간 적용, 재료·정착 상세는 모델링하지 않았다. 공동구 블록의 가는 상부 윤곽은 존재하는 그대로 보존하며 근거 없는 금속 난간을 추가하지 않는다.
## 사용자 확인 후 적용한 직선 연결 (2026-10-20)
사용자가 “검토용 직선 연결 허용”을 선택했다. 원본 단면의 횡방향·EL 좌표는 유지하고, A-A/B-B를 종단 구조 경계 299.010/318.990에 대응시켜 19.980m 직선 연결했다. 원본 단면의 STA 299.000/319.000 표기는 데이터에 별도 보존한다. 각 끝 10mm 차이를 실제 형상 검증 완료로 해석하지 않는다.
- 모델 X는 단면 횡방향, Y는 EL, Z는 종단 경계 기준 거리이다. 실제 평면 중심선·방향·곡률은 적용하지 않았다.
- 모든 연결면 상태는 `확인 필요`다. 화면 안내와 황토색 점선으로 표시한다. 기본/렌더링 보기 모두 같은 가정을 사용한다.
- 154개 대응 윤곽 객체 중 포장 반복선 60개는 단면 선으로만 보존한다. 나머지 94개 윤곽 객체로 468개 연결 삼각면을 만든다. 원호 분할 때문에 삼각면 수가 늘어난다. 이 수는 실물 부재 수가 아니다.
- 양 끝 단면을 임의 캡으로 채우지 않은 열린 표면 모델이다. 원본 중복선·내부선이 남아 있을 수 있으며 체적·수량 산출에 사용하지 않는다.
- 기본 보기는 연결면과 원본 단면 윤곽을 함께 표시한다. 렌더링은 단순 표면 명암·배경·투영 그림자를 적용한 도면 검토용 시각화이다.
-6
View File
@@ -1,6 +0,0 @@
A: 114개 선 HANDLE·레이어·양 끝점 원본 대조 통과 (좌표 반올림 오차 0.000051mm 이하)
B: 114개 선 HANDLE·레이어·양 끝점 원본 대조 통과 (좌표 반올림 오차 0.000051mm 이하)
154개 ID 중복 없음 / 모든 좌표 유한수 / 대응 꼭짓점 수 일치
원호 4개, 좌우 삽입 블록 40쌍 반영
벽체 상단 EL 및 종단 측점 차 19.980m 검산 통과
원본 단면 STA 간격 20.000m 보존; 사용자 허용에 따라 배치 간격만 19.980m로 대응 (verify_geometry.mjs에서 별도 검증)
-241
View File
@@ -1,241 +0,0 @@
# 광터지하차도 일반도 (BLOCK7) 도면 분석
## 받은 파일과 도면 구성
| 파일/도면 | 확인된 역할 | 확인 근거 |
|---|---|---|
| `광터지하차도_일반도(BLOCK7).dxf` | CAD 원본. 한 파일 안에 일반도 13번·14번과 상세 도면이 함께 있음 | 도곽 문자 `광터지하차도 일반도(13)`, `(14)`, 상세 제목들 |
| 같은 DXF 안의 13번 시트 | 평면도, 상부 평면도, 종단면도 등 전체 배치와 BLOCK7 접속부 확인 | 도면 제목, 축척 표기 `1:100`, 위치도 `1:3500` |
| 같은 DXF 안의 14번 시트 | 단면 A-A/B-B, 측구·슬래브·신축이음 등 구조 상세 | 도면 제목, 축척 표기 `1:100`, `1:30`, `1:20` |
| `광터지하차도_일반도(BLOCK7).gbk` | DXF 형식의 백업 파일로 확인됨. 본 모델은 DXF를 기준으로 함 | 파일 헤더가 DXF `AC1027` |
시트 제목의 `13`, `14`는 도면 페이지 번호이고, `BLOCK7`은 모델 대상 구간 이름으로 읽었다. 사용자의 지시에 따라 평면도와 상부평면도는 형상 근거에서 제외하고, 종단면의 측점과 단면 A-A/B-B의 표고·폭 치수만 연결한다.
## 좌표·단위·기준
- DXF 좌표는 동쪽 약 1,128,000, 북쪽 약 -80,000 부근에 놓여 있다. 이는 도면 내부 CAD 좌표이며, 이 값만으로 측지 좌표계나 절대 기준점을 특정할 수 없다.
- DXF `$INSUNITS` 값은 `0`(미지정)이다. 도곽과 치수 표기에는 m 단위 값(예: `STA. 0K+299.000`, `19.500`, `EL. 106.675`)과 mm로 보이는 상세 치수가 함께 있다. 모델의 기준 단위는 m로 정리하되, 단위 확인이 필요한 값은 따로 표시한다.
- 도면에 측점(STA.)과 EL. 표고가 있어 길이 방향은 측점 기준, 높이는 표고 기준으로 읽는다. 도면 원점, 좌표계 명칭, 표고 기준면은 제공 정보만으로 확인되지 않았다.
- 전체 엔티티 범위는 X 약 -116,729~1,693,286, Y 약 -292,813~-55,495로 지나치게 크다. 일부 기준선/외부 참조 또는 도면 편집 요소가 포함됐을 가능성이 있어 이 범위를 모델 크기로 쓰지 않는다.
## 도면 기호와 실제 형상 구분
- DXF의 레이어 이름상 `CS-DIMT`, `CS-DIML`은 치수 문자·치수선, `CS-TEXT`, `CS-NOTE`는 제목·주기, `CS-CONC-MAJR/MINR`는 콘크리트 윤곽/세부선, `CS-SYMB`는 기호, `CX-BORD-*`는 도곽·표제란 계열이다.
- 치수 숫자, 측점, 표고, 경사 표기(`S=-2.000%`)는 주석 정보로 취급하고 3D 부재로 만들지 않는다. 콘크리트 단면의 닫힌 외곽과 해칭 경계를 형상 후보로 취급한다.
- DXF 내부에는 선 1,078개, TEXT 2,473개, MTEXT 10개, 삽입 블록 273개와 속성 문자 168개 등이 섞여 있다. 모든 엔티티를 모델 형상으로 변환하지 않는다.
## 모델링 값 초안
아래 값은 DXF 문자에서 확인한 항목이며, 실제 단면 위치와 치수선 연결은 3D 형상 확정 전에 화면 또는 CAD 뷰어에서 대조해야 한다.
| 대상/값 | 값 | 구분 | 근거 위치 | 메모 |
|---|---:|---|---|---|
| BLOCK7 전후 측점 | STA. 0K+299.010 ~ 0K+318.990 | 도면 명시값 | 13번 종단면도, 신축이음 양 끝 | A-A/B-B 단면 위치로 연결 |
| A-A~B-B 길이 | 19.980 m | 계산으로 도출한 값 | 종단면도 측점 차 `318.990 - 299.010` | 이전 초안의 `20.118 m`를 사용하지 않음 |
| 접속부/U형 구간 길이 | L=19.882 m | 도면 명시값 | 종단면/구간 표기 | 인접 구간 측점 및 경계와 대조 필요 |
| 단면 안쪽 폭 치수열 | 8.750 + 1.000 + 8.750 m | 도면 명시값 | 14번 단면 A-A/B-B, 폭 치수선 | 합계 18.500 m |
| 횡단 경사 | S=-2.000% | 도면 명시값 | 시트 13·14, 평면/단면 주기 | 좌우 어느 쪽으로 낮아지는지 도면 방향 확인 필요 |
| A-A 벽체 상단/하단 | EL. 106.675 / 101.636 | 도면 명시값 | 14번 단면 A-A 표고 표기 | 높이 차 5.039 m |
| A-A 바닥판 상면 | EL. 102.436 | 도면 명시값 | 13번 종단면도, STA. 0K+299.010 | 벽체 하단과 차이 0.800 m |
| B-B 벽체 상단/하단 | EL. 107.307 / 101.069 | 도면 명시값 | 14번 단면 B-B 및 13번 종단면도 표고 표기 | 높이 차 6.238 m |
| B-B 바닥판 상면 | EL. 101.869 | 도면 명시값 | 13번 종단면도, STA. 0K+318.990 | 벽체 하단과 차이 0.800 m |
| 벽체 높이 변화 | A-A 5.039 m → B-B 6.238 m | 계산으로 도출한 값 | A-A/B-B의 상·하단 EL 차 | 두 단면 사이를 종단 방향으로 보간 |
| 외벽 폭 및 벽체 두께 | 바닥 쪽 폭 20.100 m, 안쪽 폭 18.500 m, 바닥판 0.800 m | 도면 명시값/계산값 | 14번 단면 폭 치수열 `800 + 8.750 + 1.000 + 8.750 + 800` | A-A/B-B 단면에서 치수 연결을 화면으로 추가 대조해야 함 |
| 단면의 전체 폭·내부 폭 | 20.100 m / 18.500 m로 초안 구성 | 확인 필요 | 14번 단면의 폭 치수열 | 폭 치수의 외곽/내곽 기준점을 원도면과 더 대조해야 함 |
| 공동구 외곽(양쪽) | 폭 약 1.000 m × 높이 약 2.070 m | 도면 축척 측정 | 14번 단면의 좌우 삽입 윤곽 `A$C2017021411375356`; 단면의 CAD 단위(mm) 기준 | 치수선은 없음. 선 윤곽의 바운딩 크기이며 내부 두께는 약 0.100m로 추정 |
| 공동구 위치·EL | 단면 중심에서 좌우 각 8.750 m. A-A 하단/상단 EL 102.598/104.668, B-B 101.949/104.019 | 도면 축척 측정 | A-A/B-B 위치의 블록 삽입점, 블록 바운딩, 벽체 상단 EL을 좌표로 연결 | 벽체 안쪽 하단. A-A와 B-B 사이 표고는 선형 연결 |
| 측구 외곽 폭/깊이 | 약 0.500 m × 0.230 m | 도면 축척 측정 | 14번 `측구 상세`, 축척 `S=1:30` | 상세의 CAD 선 길이를 축척 환산 |
| 측구 내부 폭/깊이 | 약 0.400 m × 0.200 m | 도면 명시값/축척 측정 | `측구 상세` 치수 `50`, `200` 및 윤곽 측정 | 측벽 약 0.050m, 바닥 약 0.030m; 바닥판 상면보다 0.050m 낮춰 표현 |
도면 축척으로 측정한 값은 아직 추출하지 않았다. 위 값은 문자로 읽은 치수이며, CAD 좌표 거리를 실제 치수로 환산한 값과는 별도로 대조해야 한다.
## 불일치 및 추가 확인 항목
- `L=20.118m`, `20.098`, `20.100`은 종단면도 측점 차에서 구한 `19.980m`와 다르다. 사용자 지시에 따라 길이 모델에는 측점 차를 사용하고, 다른 치수는 끝점 기준을 확인하기 전 구간 길이로 쓰지 않는다.
- `L=19.882m`와 `19.862`도 함께 나타나므로 기준점 차이 또는 치수 불일치 여부를 확인해야 한다.
- `$INSUNITS=0`이므로 CAD 좌표 단위는 파일 헤더만으로 확정할 수 없다. 치수열 및 알려진 구간 길이와 비교해 스케일을 확인해야 한다.
- 기준 좌표계/원점, EL 표고의 기준면, 단면 A-A/B-B가 평면상 어느 위치인지, 0.5/0.8/0.1m 치수가 가리키는 부재를 확인해야 한다.
- 공동구에는 상세 치수가 없어서 DXF 블록의 외곽 선을 측정했다. 외곽 크기는 약 1.000m×2.070m이며, 블록 내부 선은 단순 직사각형과 다르므로 모델은 바운딩 크기에 맞춘 개략 공동구로 표현하고 벽 두께 0.100m는 추정으로 표시한다. EL은 각 단면의 벽체 상단 표고와 블록 삽입 좌표의 차이로 구했다.
- 측구 상세의 `50`, `200`은 표기 치수로 사용하고, 폭 0.500m·바닥 0.030m·전체 깊이 0.230m는 `S=1:30` 윤곽으로 측정했다. 모델에서는 양쪽 벽체 하단에 놓았다.
- 측구 상세에는 구간 길이 방향의 시작·끝이 따로 표시되지 않아, 19.980m 전체에 이어지는 것으로 모델링했다. 길이 방향 연장은 추정값이다.
- 도면 텍스트는 일부 CAD 인코딩·문자 스타일에 의존한다. 판독이 불명확한 주기는 원본 CAD에서 확인할 필요가 있다.
## 모델링 범위 초안
- 포함: 종단면에 표시된 19.980m 구간, 단면 A-A와 B-B의 열린 단면 바닥판 및 양쪽 벽체, 벽체 하단 양쪽의 공동구와 측구, 단면별 벽체 상·하단 EL.
- 제외: 평면도와 상부평면도에서 가져오는 형상, 덮개/상부판, 지반·되메우기, 철근 전체, 치수선·문자·도곽, 상세 적용이 불명확한 부속물.
- 표현 수준: A-A/B-B를 각 측점 위치에 배치하고, 바닥 EL과 벽체 상단 EL을 종단 방향으로 이어서 주요 구조 형상을 만든다. 부재는 각각 선택·숨김 가능하게 분리한다.
- 검증 제한: 단면별 표고와 측점은 도면 문자에서 확인했다. 단면 폭의 외곽/내곽 기준, 벽체 기울기, 중간 측점의 종단 형상은 CAD 화면 대조가 추가로 필요하다.
## 모델 기준 수정 기록 (사용자 지시에 따른 재구성)
| 모델 항목 | 적용 값 | 상태/근거 |
|---|---:|---|
| 길이 방향 | 19.980 m | 종단면도의 STA. 0K+299.010(A-A)에서 0K+318.990(B-B)까지 |
| A-A 단면 벽체 | 바닥 EL. 101.636, 상단 EL. 106.675 | 단면 A-A의 표고값 사용. 높이 5.039m |
| B-B 단면 벽체 | 바닥 EL. 101.069, 상단 EL. 107.307 | 단면 B-B의 표고값 사용. 높이 6.238m |
| 바닥판 상면·두께 | A-A: EL. 102.436, B-B: EL. 101.869, 두께 0.800m | 종단면의 바닥 상면과 단면 하단 EL 차로 구성 |
| 폭 방향 | 전체 20.100m, 유효 폭 18.500m | 당시 초안의 기준. 이후 아래 「평면도 선형에 맞춘 단면 재배치」에서 평면 배치 방향도 추가 반영 |
| 공동구(좌/우) | 외곽 약 1.000m × 2.070m, 벽 두께 약 0.100m | A-A/B-B의 연두색 윤곽을 측정. 외곽은 축척 측정, 내부 두께는 추정 상태로 화면에 표시 |
| 측구(좌/우) | 외곽 약 0.500m × 0.230m, 내부 폭 0.400m·깊이 0.200m | 측구 상세 S=1:30의 50/200 치수와 도면 선 길이에서 추출 |
| 벽체 연결 | A-A에서 B-B까지 각 EL을 직선 보간 | 종단면 측점 거리를 기준으로 길이 방향 변화 반영 |
브라우저 모델은 부재별 표시/숨김, 선택, 회전·확대/축소·이동, 전체·평면·정면·측면 보기를 제공한다. 형상은 HTML Canvas에 투영하는 개략 모델이며, 원본 DXF를 자동 3D 변환하지 않았다. 위와 같은 종단면·단면 연결을 사용했지만, 최종 벽체 단면 윤곽과 폭 치수 위치를 CAD 화면에서 추가 대조해야 한다. 지반·되메우기·철근·도곽은 모델에서 제외했다.
## 사용자 지시에 따른 단면 레이어 재구성
기존 치수 기반의 단순 벽체·바닥판과 축척 추정 공동구·측구 객체를 제거했다. 현재 웹 모델은 평면도·상부평면도를 형상 근거로 사용하지 않고, 14번 시트의 단면 A-A/B-B에서 `CS-CONC-MAJR`, `CS-CONC-MINR` 레이어 선을 읽어 13번 종단면도의 측점 및 EL 기준으로 연결한다.
| 항목 | 적용 내용 | 상태와 근거 |
|---|---|---|
| 단면 연결 위치 | A-A `0K+299.010`에서 B-B `0K+318.990`, 길이 `19.980 m` | 도면 명시 측점, 차이 계산 |
| 횡방향 기준 | CAD X 중심 `1,128,486.2181`, 폭 방향 좌표로 환산 | 단면 바깥 폭 경계 `1,118,436.2181~1,138,536.2181`의 중심 계산 |
| A-A 표고 변환 | CAD Y `-74,271`을 EL `106.675`에 맞춤, CAD 1,000 단위 = 1 m | 단면 A-A 상단 EL 및 DXF 좌표 축척 대조 |
| B-B 표고 변환 | CAD Y `-91,211`을 EL `107.307`에 맞춤, CAD 1,000 단위 = 1 m | 단면 B-B 상단 EL 및 DXF 좌표 축척 대조 |
| 주요 콘크리트 선 | A-A 90개, B-B 90개를 동일 횡방향 끝점 순서로 연결 | `CS-CONC-MAJR`; 반복되는 긴 내부 해칭 대각선 18개씩 제외 |
| 세부 콘크리트 선 | A-A 6개, B-B 6개를 연결 | `CS-CONC-MINR`; DXF 선 좌표 직접 사용 |
| 길이 방향 중간 형상 | A-A와 B-B 사이를 직선 보간 | 두 단면 사이 중간 단면이 없어 계산상 연결이며 도면 명시 형상은 아님 |
좌표 데이터는 `src/model/sectionLayerGeometry.js`, 연결 로직과 표시 대상은 `src/model/block7.js`에 있다. 각 선 쌍은 STA 방향의 4점 표면으로 연결되어, 두 레이어를 웹 화면에서 별도로 선택하거나 숨길 수 있다. 이 방식은 CAD 선을 직접 반영한 **표면 연결 모델**이다. 단면 레이어의 모든 콘크리트 경계를 닫힌 체적으로 자동 복원한 것은 아니므로, 표면이 실제 구조체의 두께와 맞는지 CAD에서 최종 확인이 필요하다.
공동구·측구는 해당 두 콘크리트 레이어에서 사용자 지정 윤곽으로 분리 확인되지 않아, 기존에 축척·두께를 추정해 만든 별도 상자 형상은 이번 모델에서 제거했다. 실제로 `CS-CONC-MAJR/MINR`에 포함되는 세부 윤곽은 레이어 원형 선으로 남겨 연결했다. 레이어 외의 형상·두께·길이 연장은 새로 추정하지 않았다.
### 현재 검증 상태
- 확인: A-A/B-B의 두 레이어 선 개수가 각각 90쌍과 6쌍으로 맞고, 대응 선의 좌우 끝점(횡방향 좌표) 순서가 같습니다.
- 확인: 상단 CAD Y 좌표를 각 단면 상단 EL에 맞추면 바닥 경계가 A-A EL 101.636, B-B EL 101.069와 일치합니다.
- 미검증: 긴 내부 해칭 대각선 제거 후 남은 모든 `CS-CONC-MAJR` 선이 실제 구조 경계인지, 각 세부선의 콘크리트 체적 소속인지 CAD 화면 대조가 필요합니다.
- 미검증: 단면 A-A/B-B 사이 실제 종단 형상이 직선 보간과 일치하는지, 중간 측점 자료가 없어 확인할 수 없습니다.
- 미검증: 화면에 연결된 표면 모델은 체적 모델이 아니며, 시공용 구조 형상이나 철근·마감 상세 검증에 사용할 수 없습니다.
## 공동구 블록 추가 반영
사용자 지정 객체 `A$C2017021411375356`를 DXF에서 확인했다. 이 블록은 단면 A-A/B-B의 좌우에 각각 삽입되어 있고, 삽입 엔티티 레이어는 `CS-DIML`이다. 블록 정의 안에는 `CS-CONC-MAJR`의 LINE 18개와 ARC 2개가 있다. 이번 모델에는 블록 안의 선·호를 사용했으며, 호는 각 12개 직선 조각으로 근사했다.
| 값 | 적용 | 근거/상태 |
|---|---|---|
| 블록 크기 | 약 폭 1.000 m × 높이 2.070 m | 블록 내부 로컬 좌표 범위 X=-500~500, Y=-900~1170. DXF 블록 좌표에서 계산 |
| 좌우 위치 | 단면 중심에서 좌·우 약 8.750 m | CS-DIML INSERT 좌표, 오른쪽 블록의 X 반전(-1) 반영 |
| A-A 하단/상단 | EL. 102.598 / 104.668 | A-A 삽입 좌표와 단면 상단 EL로 계산 |
| B-B 하단/상단 | EL. 101.949 / 104.019 | B-B 삽입 좌표와 단면 상단 EL로 계산 |
| 길이 방향 연결 | STA. 0K+299.010~0K+318.990, 19.980 m | 해당 단면 사이 직선 보간. 중간 상세는 확인 필요 |
좌·우 공동구를 별도 객체로 추가해 선택·숨김할 수 있도록 했다. 색상은 콘크리트 레이어와 구분했고, 겹쳐 보이지 않도록 뷰어에서 공동구 윤곽을 위에 그린다. 이는 공동구를 확인하기 위한 화면 표시 순서이며, 겹치는 다른 구조체와의 실제 앞뒤·접합 관계를 검증한 것은 아니다. 공동구도 닫힌 체적이 아니라 DXF 윤곽을 종단 방향으로 이은 표면 모델이다.
## 도로 포장면 추가
단면 A-A/B-B의 `CS-CONC-MAJR` 해칭 상단 경계선을 포장면의 좌우 단면선으로 읽어, 각 측점에서 횡방향 표면 패치를 만들고 종단 방향으로 연결했다. 좌우 포장면은 중앙부에서 바깥쪽으로 낮아진다. CAD 좌표로 확인한 좌측 외측→내측은 `145 / 7,250 = +2.000%`, 우측 내측→외측은 `-145 / 7,250 = -2.000%`여서 도면의 `S=-2.000%`와 맞는다.
| 항목 | 적용값 | 근거/상태 |
|---|---:|---|
| 횡단 경사 | 중앙부에서 좌우 바깥쪽으로 각각 -2.000% | 도면 표기 `S=-2.000%`; 실제 단면선 좌표 차이로 검산 |
| 좌측/우측 포장 폭 | 각 약 7.250 m | A-A/B-B 해칭 상단 경계 좌표 측정 |
| A-A 외측/내측 EL | 102.598 / 102.743 | A-A 상단 EL 기준 CAD Y 좌표 변환 |
| B-B 외측/내측 EL | 101.949 / 102.094 | B-B 상단 EL 기준 CAD Y 좌표 변환 |
| 종단 연결 | 19.980 m, A-A에서 B-B까지 | 종단면 측점 차, 두 단면 사이 직선 보간 |
| 두께 | 모델 표면에 두께를 부여하지 않음 | 이번 요청은 포장면 생성이며 포장 두께 치수는 확인되지 않음 |
포장면은 `도로 포장면 (횡단경사 -2%)` 객체로 분리해 선택·숨김할 수 있다. 모델 표면은 CAD 단면선에 따른 시각화이며, 포장층 두께와 재료 구성을 표현하지 않는다. A-A/B-B 사이 실제 종단 포장고 자료는 별도 확인되지 않아 두 단면 사이만 선형으로 연결했다.
## 공동구 내측 하부벽체 형상 수정
공동구 바로 안쪽의 낮은 벽체를 좌·우 기초벽 객체로 분리했다. 해당 벽체는 A-A/B-B 단면의 CAD 선 끝점을 연결한 표면으로 구성했다. 각 벽체는 개별 선택·숨김이 가능하다. 금속 난간의 형상은 아래 이미지 참고 추가 작업에서 별도 객체로 얹었다.
| 항목 | 적용값 | 근거/상태 |
|---|---:|---|
| 단면 폭 | 약 0.400 m | 14번 단면 A-A/B-B, `CS-CONC-MAJR`의 x=±7.8~±8.2 윤곽 좌표 차이에서 계산 |
| A-A 높이 | 약 0.312 m, EL. 102.236~102.548 | 14번 단면 A-A CAD 좌표와 상단 EL 기준으로 계산 |
| B-B 높이 | 약 0.230 m, EL. 101.669~101.899 | 14번 단면 B-B CAD 좌표와 상단 EL 기준으로 계산 |
| 길이 | 19.980 m | 13번 종단면의 STA. 0K+299.010~0K+318.990 측점 차 |
| 상세 두께·마감 | 미표현 | 기초벽 상세와 재료 치수가 없어 단면 윤곽 기반 표면으로 표시; 확인 필요 |
두 단면의 세로 윤곽은 도면 선 좌표에서 읽었지만, 기초벽 상단은 끝점 사이에 연결했다. 기초벽 두께, 길이 방향 중간 형상과 공동구 접합부는 미검증이다.
## 참고 이미지 형식의 금속 난간 추가
사용자가 제공한 참고 이미지처럼 낮은 콘크리트 기초벽 위에 금속 세로 지주와 둥근 가로 봉 1개를 배치했다. 좌·우 금속 난간은 기초벽과 별도 객체로 만들어 각각 선택·숨김할 수 있다. 난간 중심은 단면의 공동구 안쪽 위치(중심에서 좌·우 약 8.000m)에 두고, A-A/B-B 사이에서 기초벽 상단선을 따라 연결했다.
| 항목 | 모델 값 | 상태/근거 |
|---|---:|---|
| 가로 난간봉 | 지름 약 0.070m, 지주 상단 높이에 설치 | 사용자 표시 단면에서 지주 끝의 원형 난간봉 위치를 반영; 지름은 이미지 비례 추정 |
| 세로 지주 | 약 0.060m × 0.060m, 기초벽에서 높이 1.150m | 제공 이미지 비례로 추정 |
| 지주 배치 | 한쪽당 11개, 간격 약 2.000m | 19.980m 구간에 균등 배치한 추정값 |
| 난간 위치 | 단면 중심에서 좌·우 약 8.150m | 사용자 표시 위치를 공동구 안쪽 가장자리로 해석; 도면 좌표 기반 배치 |
| 난간 연장 | STA. 0K+299.010~0K+318.990, 19.980m | 종단면 측점 차 |
앞서 난간 위치를 공동구 중심에서 8.000m, 봉 높이를 기초벽 위 0.750m로 잘못 잡았다. 사용자가 지정한 단면 이미지를 기준으로 위치를 공동구 안쪽 가장자리인 좌·우 8.150m로 옮기고, 가로 봉을 지주 상단에 맞췄다. 참고 이미지에 축척과 치수 표기가 없어 봉 지름, 지주 크기·간격·높이는 시각 검토용 추정값이다. 도면에 금속 난간 상세가 추가되면 `communalHandrailPart`의 지주 간격, 단면 크기, 기초벽 위 높이와 봉 지름을 수정해야 한다. 재질·도장색·기초 정착 방식은 반영하지 않았다.
## 수정 지침에 따른 원본 재분석 및 모델 기준 갱신 (2026-10-19)
이번 갱신은 저장소의 수정된 `AGENTS.md` 1~6항을 기준으로 DXF 원본과 모델 생성 코드를 다시 대조한 결과다. 앞 절의 변경 이력은 당시 작업 기록이고, 현재 적용 기준은 이 절과 현재 `src/model/` 데이터다.
### 도면 구성·축척·대상 연결
| 도면 | 도면 내 표기/축척 | 재분석한 역할과 연결 근거 |
|---|---|---|
| 일반도 13번 평면도 | `S=1:100` | BLOCK7 구간의 평면상 중심선·위치 확인 대상. 현재 모델은 중심선 전체 형상을 아직 입력하지 않았으므로 단면 위치 배치 근거로 완결 검증하지 못함 |
| 일반도 13번 상부평면도 | `S=1:100` | 상부 배치·부재 위치의 교차 확인 자료. 평면 중심선 및 구조 외곽과 대조해야 함 |
| 일반도 13번 종단면도 | `S=1:100` | 측점 `0K+299.010`, `0K+318.990`과 시·종점 표고를 단면 A-A/B-B에 연결하는 길이·높이 기준 |
| 일반도 14번 단면 A-A, B-B | 각각 `S=1:100` | 단면별 구조 윤곽 및 EL. 종단면의 같은 측점과 대응시킴 |
| 일반도 14번 측구 상세 | `S=1:30` | 측구 형상·표기 치수 확인 자료. 상세 치수 `50`, `200`은 원도면 표기(mm)이며 상세 축척 측정값과 구분 |
| 일반도 14번 기타 상세 | `S=1:20` 등 | 해당 상세 부재의 형상 검토 자료. 적용 위치가 BLOCK7인지 확인되지 않으면 모델에 임의 추가하지 않음 |
시점 연결은 종단면 측점 `0K+299.010`과 단면 A-A, 종점 연결은 `0K+318.990`과 단면 B-B로 설정했다. 중심 기준 EL은 A-A `106.675`, B-B `107.307`을 단면 상단 기준에 맞췄다. 종단면의 시·종점 벽체 상단 EL과 단면 값이 각각 같은지 DXF 표기를 대조했으며, 현재 변환 데이터는 상단 기준 CAD Y `-74,271`, `-91,211`에 맞춘 상태다. 원도면에서 표시 위치·부재별 상단 기준을 CAD 화면으로 재확인하는 것은 남은 검증 항목이다.
### 기준값·상태·근거
| 대상/항목 | 적용값 | 상태 | 도면 번호와 위치 / 판정 |
|---|---:|---|---|
| A-A 측점 | STA. 0K+299.010 | 도면 명시값 | 13번 종단면도 시작부. 모델 단면 A-A와 연결 |
| B-B 측점 | STA. 0K+318.990 | 도면 명시값 | 13번 종단면도 끝부. 모델 단면 B-B와 연결 |
| 모델 구간 길이 | 19.980 m | 계산으로 도출한 값 | 위 두 측점 차. 코드 `block7.length`에 적용 |
| 인접 구간 표기 | L=19.882 m 등 | 확인 필요 | 13번 종단면/접속부 주변의 별도 구간 표기. A-A~B-B 측점 차와 같지 않아 모델 길이로 대체하지 않음 |
| A-A 벽체 상단/하단 EL | 106.675 / 101.636 | 도면 명시값 | 14번 단면 A-A 표기; 높이 차 5.039m는 계산값 |
| B-B 벽체 상단/하단 EL | 107.307 / 101.069 | 도면 명시값 | 14번 단면 B-B 표기; 높이 차 6.238m는 계산값 |
| A-A/B-B 간 폭 방향 외곽 | 약 20.100 m | 도면 치수/좌표 대조 필요 | 14번 단면 폭 치수열과 `CS-CONC-MAJR` 바깥 윤곽 측정치를 서로 대조해야 함. 현재 코드 `width` 20.1m |
| `CS-CONC-MAJR` 선 | A-A/B-B 각 90개 | 도면 좌표 측정 | 14번 A-A/B-B의 해당 레이어. 내부 해칭 추정선도 레이어 원본선이므로 모델에서 버리지 않도록 수정 |
| `CS-CONC-MINR` 선 | A-A/B-B 각 6개 | 도면 좌표 측정 | 14번 A-A/B-B의 해당 레이어. 같은 순번 단면선을 연결 |
| 포장 횡단경사 | S=-2.000% | 도면 명시값 + 좌표 검산 | 14번 단면 및 포장선 좌표. 중앙부에서 양 외측으로 낮아지는지 좌우 방향을 단면 방향과 함께 확인 |
| 공동구 블록 외곽 | DXF 블록 로컬 범위에 따른 약 1.000×2.100 m | 도면 좌표 측정 | 14번 A-A/B-B의 `A$C2017021411375356`. 기존 문서의 2.070m는 로컬 Y 최대값 1,200과 최소값 -900의 차(2,100mm)와 불일치하므로 수정 필요 |
| 공동구 삽입 위치 | 중심에서 좌우 약 8.750 m | 계산으로 도출한 값 | 14번 A-A/B-B의 블록 INSERT X 좌표 및 단면 중심 좌표 |
| 측구 `50`, `200` | 도면상 mm 표기 | 도면 명시값 | 14번 측구 상세. 폭·깊이 방향 연결은 상세 윤곽 좌표와 함께 대조 필요 |
| 금속 난간 | 지주·봉 크기 및 간격은 기존 코드 추정값 | 추정값 | 도면 치수·구조 레이어에서 근거 확인되지 않음. 참고 이미지 기반 시각 형상으로 구분하고 도면 확정값처럼 사용하지 않음 |
CAD 헤더의 `$INSUNITS=0`은 단위 미지정이다. 따라서 이 문서에서 m 환산은 측점·EL·표기 치수와 DXF 좌표 차이를 대조해 적용한 모델 변환이며, 파일 헤더에 기록된 단위라고 해석하지 않는다. 시트 축척을 이용한 측정값은 `도면 좌표 측정`으로 별도 표시하고 명시 치수와 혼용하지 않는다.
후속 좌표 확인에서 일반도 14번 A-A/B-B 폭 치수 `20.100`을 확인했고, 두 단면의 기준 중심 X는 각각 `1128486.2181`과 같은 폭 중앙 좌표로 모델링 데이터에 사용한다. 단면 좌표 외곽 폭은 `20.300m`(±10.150m)까지 그려지는 선도 있어, 도면의 20.100m 치수 기준선이 구조물 외측면 전체 폭과 일치하는지는 서로 다른 윤곽(벽체/돌출부)의 기준을 나눠 추가 대조해야 한다. 따라서 모델 UI의 `전체 폭 20.100m`는 도면 치수값이며 가장 바깥 좌표 경계값이라고 단정하지 않는다.
### 도면 간 점검 및 미확정 사항
- BLOCK7 표기와 폭 치수열 부근의 평면 `CS-CONC-MAJR` 선을 배치 기준으로 모델 방향에 반영했다. 이 선이 실제 측량 중심선인지, 별도 절단 표식과 일치하는지, 두 끝의 단면 중심 좌표와 횡방향 기준까지 완전히 대조했는지는 추가 확인이 필요하다. 해당 엔티티는 직선이어서 중간 곡률은 재현하지 않는다.
- 종단면의 측점 차 19.980m와 별도 구간 표기 19.882m 및 주변 19.862/20.098/20.100 표기의 기준점이 다를 가능성이 있다. 도면 내 치수 보조선의 양 끝점을 확인해야 하며, 현재 두 단면 위치 사이 연결에는 측점 차만 사용한다.
- 단면 레이어 선 개수와 끝점 순서가 A-A/B-B에서 일치하는 것은 데이터 구조상 확인했다. 그러나 레이어의 모든 선이 구조 경계인지, 해칭선·치수 인접선인지 화면에서 분류하는 검토는 완료되지 않았다. 지침에 따라 원본 레이어 선은 삭제하지 않고 반영하되, 실제 체적 경계 해석은 미검증으로 남긴다.
- 공동구 블록의 로컬 좌표 범위는 X -500~500, Y -900~1200이므로 외곽 바운딩 높이는 2.100m다. 곡선/외곽 윤곽과 블록 내 부재의 포함 범위가 단순 바운딩과 다를 수 있어 2.100m는 외곽 경계 사각형 치수로만 사용한다.
- 단면 도면의 치수선·문자·기호를 모델 구조물로 변환하지 않는다. 구조선으로 보이는 선의 소속이 불명확한 곳은 코드 모델에 임의 체적을 만들지 않고 확인 필요로 남긴다.
- 포장면은 단면 A-A/B-B 사이를 직선 보간한다. 중간 측점 포장 EL과 평면 중심선 곡률은 미확정이다.
- 금속 난간은 원도면에서 치수·재료 상세를 확인하지 못했으므로 해당 형상을 도면 명시값으로 간주하지 않는다. 필요 시 별도 확인 후 도면 반영 범위를 결정한다.
### 모델 재생성 변경·검증 기록
이번 분석을 기준으로 `src/model/block7.js`의 `CS-CONC-MAJR` 생성에서 제외하던 17개 선을 복원했다. 이전에는 내부 해칭으로 판단해 제외했지만, 이는 새 지침의 “구조물선을 누락 없이 반영” 및 레이어 좌표 부족 시 윤곽 측정 지침과 맞지 않았다. 현재 연결 표면은 `CS-CONC-MAJR` 90개와 `CS-CONC-MINR` 6개를 단면별로 전부 생성한다.
| 확인 항목 | 결과 | 상태 |
|---|---|---|
| 측점 길이 | 318.990−299.010=19.980m | 확인 |
| A-A/B-B 단면 레이어 개수 | MAJR 90/90, MINR 6/6 | 좌표 데이터에서 확인 |
| 단면 상단 EL 기준 | A-A 106.675, B-B 107.307 | 코드 변환 기준 확인 |
| MAJR 선 제외 | 기존 17개 제외 목록 제거 | 코드 수정 완료 |
| 폭 치수 | 도면 표기 20.100m | A-A/B-B 단면 치수 문자 확인, 외곽 좌표선의 기준선은 별도 대조 필요 |
| 공동구 높이 안내 | 약 2.100m | 블록 로컬 Y 범위 -900~1200을 코드 객체 정보에 반영 |
| 평면 선형 배치 | 선택한 BLOCK7 평면 선형의 방향을 모델에 적용 | 완료. 엔티티가 직선이라 중간 곡률은 확인되지 않음 |
| 표면 모델의 체적·접합 검증 | 닫힌 체적이 아닌 선 연결 표면 | 미검증, 시공용 사용 불가 |
브라우저 모델은 도면선 연결 표면을 검토하기 위한 시각화다. 길이·폭·표고와 평면 배치 기준은 `src/model/block7.js`, 단면 윤곽은 `src/model/sectionLayerGeometry.js`, 포장 경계는 `src/model/pavementGeometry.js`, 공동구 원형은 `src/model/communalDuctGeometry.js`에서 바꾼다. 선형 엔티티가 추가되면 같은 파일의 `alignment` 시작·끝 좌표와 경로 변환을 갱신한다.
## 평면도 선형에 맞춘 단면 재배치
사용자 요청에 따라 두 단면으로 만든 부재 전체의 평면 방향을 BLOCK7 표기와 폭 치수열 부근의 `CS-CONC-MAJR` 종방향 선에 맞췄다. 이 선을 실제 중심선으로 확정한 것은 아니며, 단면 배치를 위한 평면 기준선으로 사용했다. A-A측 끝점은 `(1055963.44842508, -71293.23557967)`, B-B측 끝점은 `(1056081.68303708, -91392.88783067)`이다. 두 점 차이는 `(118.234612, -20099.652251)` CAD 좌표, 길이 20.100m, 방향각 약 -89.663°다.
코드에서는 단면 생성 시의 길이축·횡축 좌표를 이 기준선의 접선·수직 방향으로 회전해 주요 콘크리트, 세부 콘크리트, 포장면, 공동구, 난간에 동일하게 적용한다. 길이 방향 모델 범위는 종단면 측점 차인 19.980m이며, 횡방향 단면과 표고는 A-A/B-B 값을 유지한다. 기준선 길이 20.100m와 측점 차 19.980m의 차이 0.120m는 코드에서 축척 보정하지 않아 양 끝에 각각 0.060m 차이가 남는다. 모델 길이는 측점값을 따른다.
이 평면도 데이터 구간에는 위 기준선 외에 A-A~B-B 사이의 중간 곡선점이나 원호가 확인되지 않았다. 따라서 현재 모델은 평면곡선을 재현한 것이 아니라, 도면에서 확인 가능한 직선 기준선의 방향에 맞춘 결과다. 실제 곡률이 별도 선형 도면·좌표표에 있다면 그 자료가 있어야 곡선으로 갱신할 수 있다. 앱 좌표는 원본의 큰 CAD 동쪽/북쪽 수치를 중심점 기준 상대 미터로 바꿔 표시한다.
+3 -2
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#edf1ed" />
<title>광터지하차도 새 모델</title>
<meta name="theme-color" content="#eff2ed" />
<meta name="description" content="새 3D 모델 프로젝트를 시작하는 작업 공간입니다." />
<title>새 3D 모델 프로젝트</title>
</head>
<body>
<div id="root"></div>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1113000 69500 29000 14800"><rect x="1113000" y="69500" width="29000" height="14800" fill="#fff"/><line x1="1127986.218066491" y1="78202.89240393949" x2="1128986.218066491" y2="78202.89240393949" stroke="#8d969e" stroke-width="15"><title>16483 / CS-CNTL</title></line><line x1="1128486.218066491" y1="78702.89240393949" x2="1128486.218066491" y2="77702.89240393949" stroke="#8d969e" stroke-width="15"><title>16484 / CS-CNTL</title></line><line x1="1118436.218066491" y1="79310.37855593952" x2="1119236.218066491" y2="79310.37855593952" stroke="#326c5d" stroke-width="15"><title>21420 / CS-CONC-MAJR</title></line><line x1="1119236.218066491" y1="79310.37855593952" x2="1127986.218066491" y2="79310.37855593952" stroke="#326c5d" stroke-width="15"><title>21421 / CS-CONC-MAJR</title></line><line x1="1127986.218066491" y1="79310.37855593952" x2="1128986.218066491" y2="79310.37855593952" stroke="#326c5d" stroke-width="15"><title>21422 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="79310.37855593952" x2="1137736.218066491" y2="79310.37855593952" stroke="#326c5d" stroke-width="15"><title>21423 / CS-CONC-MAJR</title></line><line x1="1137736.218066491" y1="79310.37855593952" x2="1138536.218066491" y2="79310.37855593952" stroke="#326c5d" stroke-width="15"><title>21424 / CS-CONC-MAJR</title></line><line x1="1120236.218066491" y1="78397.89255593948" x2="1120286.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21425 / CS-CONC-MAJR</title></line><line x1="1120286.218066491" y1="78397.89255593948" x2="1120286.218066491" y2="78710.37855593952" stroke="#326c5d" stroke-width="15"><title>21426 / CS-CONC-MAJR</title></line><line x1="1120286.218066491" y1="78710.37855593952" x2="1120686.218066491" y2="78710.37855593952" stroke="#326c5d" stroke-width="15"><title>21427 / CS-CONC-MAJR</title></line><line x1="1120686.218066491" y1="78710.37855593952" x2="1120686.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21428 / CS-CONC-MAJR</title></line><line x1="1120686.218066491" y1="78397.89255593948" x2="1120736.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21429 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="78397.89255593948" x2="1120736.218066491" y2="78347.89255593948" stroke="#326c5d" stroke-width="15"><title>21430 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="78347.89255593948" x2="1120736.218066491" y2="78427.89255593948" stroke="#326c5d" stroke-width="15"><title>21431 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="78427.89255593948" x2="1120986.218066491" y2="78422.89255593948" stroke="#326c5d" stroke-width="15"><title>21432 / CS-CONC-MAJR</title></line><line x1="1120986.218066491" y1="78422.89255593948" x2="1127486.218066491" y2="78292.89255593948" stroke="#326c5d" stroke-width="15"><title>21433 / CS-CONC-MAJR</title></line><line x1="1127486.218066491" y1="78292.89255593948" x2="1127986.218066491" y2="78282.89255593948" stroke="#326c5d" stroke-width="15"><title>21434 / CS-CONC-MAJR</title></line><line x1="1127986.218066491" y1="78282.89255593948" x2="1127986.218066491" y2="78510.37855593952" stroke="#326c5d" stroke-width="15"><title>21435 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="78347.89255593948" x2="1136236.218066491" y2="78427.89255593948" stroke="#326c5d" stroke-width="15"><title>21436 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="78347.89255593948" x2="1136236.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21437 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="78397.89255593948" x2="1136286.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21438 / CS-CONC-MAJR</title></line><line x1="1136286.218066491" y1="78397.89255593948" x2="1136286.218066491" y2="78710.37855593952" stroke="#326c5d" stroke-width="15"><title>21439 / CS-CONC-MAJR</title></line><line x1="1136286.218066491" y1="78710.37855593952" x2="1136686.218066491" y2="78710.37855593952" stroke="#326c5d" stroke-width="15"><title>21440 / CS-CONC-MAJR</title></line><line x1="1136686.218066491" y1="78710.37855593952" x2="1136686.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21441 / CS-CONC-MAJR</title></line><line x1="1136686.218066491" y1="78397.89255593948" x2="1136736.218066491" y2="78397.89255593948" stroke="#326c5d" stroke-width="15"><title>21442 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="78510.37855593952" x2="1128986.218066491" y2="78282.89255593948" stroke="#326c5d" stroke-width="15"><title>21443 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="78282.89255593948" x2="1129486.218066491" y2="78292.89255593948" stroke="#326c5d" stroke-width="15"><title>21444 / CS-CONC-MAJR</title></line><line x1="1129486.218066491" y1="78292.89255593948" x2="1135986.218066491" y2="78422.89255593948" stroke="#326c5d" stroke-width="15"><titlLine truncated

Before

Width:  |  Height:  |  Size: 53 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1113000 85500 29000 16800"><rect x="1113000" y="85500" width="29000" height="16800" fill="#fff"/><line x1="1127986.218066491" y1="96424.31539693956" x2="1128986.218066491" y2="96424.31539693956" stroke="#8d969e" stroke-width="15"><title>16485 / CS-CNTL</title></line><line x1="1128486.218066491" y1="96924.31539693956" x2="1128486.218066491" y2="95924.31539693956" stroke="#8d969e" stroke-width="15"><title>16486 / CS-CNTL</title></line><line x1="1118436.218066491" y1="97449.37855593952" x2="1119236.218066491" y2="97449.37855593952" stroke="#326c5d" stroke-width="15"><title>21624 / CS-CONC-MAJR</title></line><line x1="1119236.218066491" y1="97449.37855593952" x2="1127986.218066491" y2="97449.37855593952" stroke="#326c5d" stroke-width="15"><title>21625 / CS-CONC-MAJR</title></line><line x1="1127986.218066491" y1="97449.37855593952" x2="1128986.218066491" y2="97449.37855593952" stroke="#326c5d" stroke-width="15"><title>21626 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="97449.37855593952" x2="1137736.218066491" y2="97449.37855593952" stroke="#326c5d" stroke-width="15"><title>21627 / CS-CONC-MAJR</title></line><line x1="1137736.218066491" y1="97449.37855593952" x2="1138536.218066491" y2="97449.37855593952" stroke="#326c5d" stroke-width="15"><title>21628 / CS-CONC-MAJR</title></line><line x1="1120236.218066491" y1="96619.31555593957" x2="1120286.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21629 / CS-CONC-MAJR</title></line><line x1="1120286.218066491" y1="96619.31555593957" x2="1120286.218066491" y2="96849.37855593952" stroke="#326c5d" stroke-width="15"><title>21630 / CS-CONC-MAJR</title></line><line x1="1120286.218066491" y1="96849.37855593952" x2="1120686.218066491" y2="96849.37855593952" stroke="#326c5d" stroke-width="15"><title>21631 / CS-CONC-MAJR</title></line><line x1="1120686.218066491" y1="96849.37855593952" x2="1120686.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21632 / CS-CONC-MAJR</title></line><line x1="1120686.218066491" y1="96619.31555593957" x2="1120736.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21633 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="96619.31555593957" x2="1120736.218066491" y2="96569.31555593957" stroke="#326c5d" stroke-width="15"><title>21634 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="96569.31555593957" x2="1120736.218066491" y2="96649.31555593957" stroke="#326c5d" stroke-width="15"><title>21635 / CS-CONC-MAJR</title></line><line x1="1120736.218066491" y1="96649.31555593957" x2="1120986.218066491" y2="96644.31555593957" stroke="#326c5d" stroke-width="15"><title>21636 / CS-CONC-MAJR</title></line><line x1="1120986.218066491" y1="96644.31555593957" x2="1127486.218066491" y2="96514.31555593957" stroke="#326c5d" stroke-width="15"><title>21637 / CS-CONC-MAJR</title></line><line x1="1127486.218066491" y1="96514.31555593957" x2="1127986.218066491" y2="96504.31555593957" stroke="#326c5d" stroke-width="15"><title>21638 / CS-CONC-MAJR</title></line><line x1="1127986.218066491" y1="96504.31555593957" x2="1127986.218066491" y2="96649.37855593952" stroke="#326c5d" stroke-width="15"><title>21639 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="96569.31555593957" x2="1136236.218066491" y2="96649.31555593957" stroke="#326c5d" stroke-width="15"><title>21640 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="96569.31555593957" x2="1136236.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21641 / CS-CONC-MAJR</title></line><line x1="1136236.218066491" y1="96619.31555593957" x2="1136286.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21642 / CS-CONC-MAJR</title></line><line x1="1136286.218066491" y1="96619.31555593957" x2="1136286.218066491" y2="96849.37855593952" stroke="#326c5d" stroke-width="15"><title>21643 / CS-CONC-MAJR</title></line><line x1="1136286.218066491" y1="96849.37855593952" x2="1136686.218066491" y2="96849.37855593952" stroke="#326c5d" stroke-width="15"><title>21644 / CS-CONC-MAJR</title></line><line x1="1136686.218066491" y1="96849.37855593952" x2="1136686.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21645 / CS-CONC-MAJR</title></line><line x1="1136686.218066491" y1="96619.31555593957" x2="1136736.218066491" y2="96619.31555593957" stroke="#326c5d" stroke-width="15"><title>21646 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="96649.37855593952" x2="1128986.218066491" y2="96504.31555593957" stroke="#326c5d" stroke-width="15"><title>21647 / CS-CONC-MAJR</title></line><line x1="1128986.218066491" y1="96504.31555593957" x2="1129486.218066491" y2="96514.31555593957" stroke="#326c5d" stroke-width="15"><title>21648 / CS-CONC-MAJR</title></line><line x1="1129486.218066491" y1="96514.31555593957" x2="1135986.218066491" y2="96644.31555593957" stroke="#326c5d" stroke-width="15"><titlLine truncated

Before

Width:  |  Height:  |  Size: 53 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1040000 95000 45000 18000"><rect x="1040000" y="95000" width="45000" height="18000" fill="#fff"/><line x1="1069527.623228672" y1="101620.1265643799" x2="1073517.623228672" y2="101503.6778453799" stroke="#326c5d" stroke-width="15"><title>21828 / CS-CONC-MAJR</title></line><line x1="1069527.623228672" y1="107618.3265643799" x2="1073517.623228672" y2="107741.0783453799" stroke="#326c5d" stroke-width="15"><title>21829 / CS-CONC-MAJR</title></line><line x1="1053537.623228672" y1="102134.6376983799" x2="1053537.623228672" y2="107174.2371983799" stroke="#326c5d" stroke-width="15"><title>21830 / CS-CONC-MAJR</title></line><line x1="1073517.623228672" y1="101503.6780093799" x2="1073517.623228672" y2="107741.0785093799" stroke="#326c5d" stroke-width="15"><title>21831 / CS-CONC-MAJR</title></line><line x1="1069527.623228672" y1="107618.0631093799" x2="1073527.623228672" y2="107741.0375093799" stroke="#326c5d" stroke-width="15"><title>21832 / CS-CONC-MAJR</title></line><line x1="1069527.623228672" y1="107718.0631093799" x2="1073527.623228672" y2="107841.0375093799" stroke="#326c5d" stroke-width="15"><title>21833 / CS-CONC-MAJR</title></line><line x1="1063527.623228672" y1="108522.0983143799" x2="1066743.623228672" y2="108522.0983143799" stroke="#8d969e" stroke-width="15"><title>53087 / CS-DIML</title></line><line x1="1063527.623228672" y1="107522.0983143799" x2="1063527.623228672" y2="108522.0983143799" stroke="#8d969e" stroke-width="15"><title>53088 / CS-DIML</title></line><line x1="1050527.623228672" y1="107174.0375093799" x2="1050527.623228672" y2="102135.0375093799" stroke="#8d969e" stroke-width="15"><title>53089 / CS-DIML</title></line><line x1="1051527.623228672" y1="102135.0375093799" x2="1050477.623228672" y2="102135.0375093799" stroke="#8d969e" stroke-width="15"><title>53090 / CS-DIML</title></line><line x1="1051527.623228672" y1="107174.0375093799" x2="1050477.623228672" y2="107174.0375093799" stroke="#8d969e" stroke-width="15"><title>53091 / CS-DIML</title></line><line x1="1051527.623228672" y1="106374.0375093799" x2="1051527.623228672" y2="102135.0375093799" stroke="#8d969e" stroke-width="15"><title>53092 / CS-DIML</title></line><line x1="1052527.623228672" y1="102135.0375093799" x2="1051477.623228672" y2="102135.0375093799" stroke="#8d969e" stroke-width="15"><title>53093 / CS-DIML</title></line><line x1="1051527.623228672" y1="107174.0375093799" x2="1051527.623228672" y2="106374.0375093799" stroke="#8d969e" stroke-width="15"><title>53094 / CS-DIML</title></line><line x1="1052527.623228672" y1="106374.0375093799" x2="1051477.623228672" y2="106374.0375093799" stroke="#8d969e" stroke-width="15"><title>53095 / CS-DIML</title></line><line x1="1052527.623228672" y1="107174.0375093799" x2="1051477.623228672" y2="107174.0375093799" stroke="#8d969e" stroke-width="15"><title>53096 / CS-DIML</title></line><line x1="1053537.623228672" y1="97801.03711951047" x2="1053537.623228672" y2="96801.03711951047" stroke="#8d969e" stroke-width="15"><title>53097 / CS-DIML</title></line><line x1="1053537.623228672" y1="96801.03711951047" x2="1050120.623228672" y2="96801.03711951047" stroke="#8d969e" stroke-width="15"><title>53098 / CS-DIML</title></line><line x1="1073517.623228672" y1="97801.03711951047" x2="1073517.623228672" y2="96801.03711951047" stroke="#8d969e" stroke-width="15"><title>53099 / CS-DIML</title></line><line x1="1073517.623228672" y1="96801.03711951047" x2="1076934.623228672" y2="96801.03711951047" stroke="#8d969e" stroke-width="15"><title>53100 / CS-DIML</title></line><line x1="1073527.623228672" y1="97801.03711951047" x2="1074127.623228672" y2="97801.03711951047" stroke="#8d969e" stroke-width="15"><title>53101 / CS-DIML</title></line><line x1="1073517.623228672" y1="97801.03711951047" x2="1073527.623228672" y2="97801.03711951047" stroke="#8d969e" stroke-width="15"><title>53102 / CS-DIML</title></line><line x1="1073527.623228672" y1="98801.03711951047" x2="1073527.623228672" y2="97751.03711951047" stroke="#8d969e" stroke-width="15"><title>53103 / CS-DIML</title></line><line x1="1053537.623228672" y1="97801.03711951047" x2="1073517.623228672" y2="97801.03711951047" stroke="#8d969e" stroke-width="15"><title>53104 / CS-DIML</title></line><line x1="1073517.623228672" y1="98801.03711951047" x2="1073517.623228672" y2="97751.03711951047" stroke="#8d969e" stroke-width="15"><title>53105 / CS-DIML</title></line><line x1="1053527.623228672" y1="97801.03711951047" x2="1053537.623228672" y2="97801.03711951047" stroke="#8d969e" stroke-width="15"><title>53106 / CS-DIML</title></line><line x1="1053537.623228672" y1="98801.03711951047" x2="1053537.623228672" y2="97751.03711951047" stroke="#8d969e" stroke-width="15"><title>53107 / CS-DIML</title></line><line x1="1053527.623228672" y1="97801.03711951047" x2="1052927.623228672" y2="97801.03711951047" stroke="#8d969e" stroke-width="15"><title>53108 / CS-DIML</title></line><line x1="1053527.623228672" y1="98801.03711951047" x2="1053527Line truncated

Before

Width:  |  Height:  |  Size: 12 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1165000 77000 20000 8000"><rect x="1165000" y="77000" width="20000" height="8000" fill="#fff"/><line x1="1170283.393169491" y1="77512.37855593946" x2="1171033.393169491" y2="77512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFB8 / CS-DIML</title></line><line x1="1171033.393169491" y1="77512.37855593946" x2="1171283.393169491" y2="77512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFBA / CS-DIML</title></line><line x1="1171283.393169491" y1="77512.37855593946" x2="1171783.393169491" y2="77512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFBD / CS-DIML</title></line><line x1="1171783.393169491" y1="77512.37855593946" x2="1172033.393169491" y2="77512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFBF / CS-DIML</title></line><line x1="1172033.393169491" y1="77512.37855593946" x2="1172933.393169491" y2="77512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFC0 / CS-DIML</title></line><line x1="1171033.393169491" y1="77512.37855593946" x2="1171033.393169491" y2="78562.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFC1 / CS-DIML</title></line><line x1="1172033.393169491" y1="77512.37855593946" x2="1172033.393169491" y2="78562.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFC2 / CS-DIML</title></line><line x1="1171033.393169491" y1="78512.37855593946" x2="1172033.393169491" y2="78512.37855593946" stroke="#8d969e" stroke-width="15"><title>15CFC3 / CS-DIML</title></line><text x="1170383.393169491" y="77462.37855593946" font-size="200" fill="#28334b">50</text><text x="1171226.602224084" y="77462.37855593946" font-size="200" fill="#28334b">100</text><text x="1172278.865872553" y="77462.37855593946" font-size="200" fill="#28334b">50</text><text x="1171226.602224084" y="78462.37855593946" font-size="200" fill="#28334b">200</text><text x="1170506.971082648" y="84508.21131017663" font-size="200" fill="#28334b">측구 상세</text></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1020000 64000 85000 32000"><rect x="1020000" y="64000" width="85000" height="32000" fill="#fff"/><line x1="1090963.44842508" y1="71293.23557867091" x2="1091081.68303708" y2="91392.8878296709" stroke="#326c5d" stroke-width="15"><title>21834 / CS-CONC-MAJR</title></line><line x1="1071101.79803108" y1="71293.23557867091" x2="1070983.56341908" y2="91392.8878296709" stroke="#326c5d" stroke-width="15"><title>21835 / CS-CONC-MAJR</title></line><line x1="1055963.44842508" y1="71293.23557967138" x2="1056081.68303708" y2="91392.88783067137" stroke="#326c5d" stroke-width="15"><title>21836 / CS-CONC-MAJR</title></line><line x1="1036101.79803208" y1="71293.23557967138" x2="1035983.56341908" y2="91392.88782967137" stroke="#326c5d" stroke-width="15"><title>21837 / CS-CONC-MAJR</title></line><line x1="1073526.02993208" y1="82855.85590167092" x2="1073516.27555208" y2="85066.83438467093" stroke="#8d969e" stroke-width="15"><title>53136 / CS-DIML</title></line><line x1="1073516.27555208" y1="85066.83438467093" x2="1073515.39320208" y2="85266.8324386709" stroke="#8d969e" stroke-width="15"><title>53137 / CS-DIML</title></line><line x1="1073539.26518308" y1="79855.88509667093" x2="1073549.01956308" y2="77644.90661367092" stroke="#8d969e" stroke-width="15"><title>53138 / CS-DIML</title></line><line x1="1073549.01956308" y1="77644.90661367092" x2="1073549.90191308" y2="77444.9085606709" stroke="#8d969e" stroke-width="15"><title>53139 / CS-DIML</title></line><line x1="1092089.90086708" y1="91086.95190667092" x2="1093139.88270108" y2="91080.77547167095" stroke="#8d969e" stroke-width="15"><title>53140 / CS-DIML</title></line><line x1="1092086.95970808" y1="90586.9605576709" x2="1093136.94154208" y2="90580.78412267091" stroke="#8d969e" stroke-width="15"><title>53141 / CS-DIML</title></line><line x1="1093089.88356608" y1="91081.0695876709" x2="1093086.94240708" y2="90581.07823767092" stroke="#8d969e" stroke-width="15"><title>53142 / CS-DIML</title></line><line x1="1092086.95970808" y1="90586.9605576709" x2="1093136.94154208" y2="90580.78412267091" stroke="#8d969e" stroke-width="15"><title>53143 / CS-DIML</title></line><line x1="1092035.48941608" y1="81837.11194067092" x2="1093085.47125008" y2="81830.93550567089" stroke="#8d969e" stroke-width="15"><title>53144 / CS-DIML</title></line><line x1="1093086.94240708" y1="90581.07823767092" x2="1093035.47211508" y2="81831.2296216709" stroke="#8d969e" stroke-width="15"><title>53145 / CS-DIML</title></line><line x1="1092035.48941608" y1="81837.11194067092" x2="1093085.47125008" y2="81830.93550567089" stroke="#8d969e" stroke-width="15"><title>53146 / CS-DIML</title></line><line x1="1092029.60709708" y1="80837.12924167093" x2="1093079.58893108" y2="80830.9528066709" stroke="#8d969e" stroke-width="15"><title>53147 / CS-DIML</title></line><line x1="1093035.47211508" y1="81831.2296216709" x2="1093029.58979608" y2="80831.2469226709" stroke="#8d969e" stroke-width="15"><title>53148 / CS-DIML</title></line><line x1="1092029.60709708" y1="80837.12924167093" x2="1093079.58893108" y2="80830.9528066709" stroke="#8d969e" stroke-width="15"><title>53149 / CS-DIML</title></line><line x1="1091978.13680608" y1="72087.2806256709" x2="1093028.11864008" y2="72081.10419067093" stroke="#8d969e" stroke-width="15"><title>53150 / CS-DIML</title></line><line x1="1093029.58979608" y1="80831.2469226709" x2="1092978.11950508" y2="72081.39830667093" stroke="#8d969e" stroke-width="15"><title>53151 / CS-DIML</title></line><line x1="1091978.13680608" y1="72087.2806256709" x2="1093028.11864008" y2="72081.10419067093" stroke="#8d969e" stroke-width="15"><title>53152 / CS-DIML</title></line><line x1="1091975.19564708" y1="71587.28927567093" x2="1093025.17748008" y2="71581.1128406709" stroke="#8d969e" stroke-width="15"><title>53153 / CS-DIML</title></line><line x1="1092978.11950508" y1="72081.39830667093" x2="1092975.17834508" y2="71581.40695667091" stroke="#8d969e" stroke-width="15"><title>53154 / CS-DIML</title></line><line x1="1093089.88356608" y1="91081.0695876709" x2="1094139.86540008" y2="91074.89315267092" stroke="#8d969e" stroke-width="15"><title>53155 / CS-DIML</title></line><line x1="1092975.17834508" y1="71581.40695667091" x2="1094025.16017908" y2="71575.23052167094" stroke="#8d969e" stroke-width="15"><title>53156 / CS-DIML</title></line><line x1="1094089.86626508" y1="91075.18726867092" x2="1093975.16104408" y2="71575.52463767094" stroke="#8d969e" stroke-width="15"><title>53157 / CS-DIML</title></line><line x1="1070087.10965008" y1="72087.2806256709" x2="1069037.12781608" y2="72081.10419067093" stroke="#8d969e" stroke-width="15"><title>53158 / CS-DIML</title></line><line x1="1070090.05080908" y1="71587.28927567093" x2="1069040.06897508" y2="71581.1128406709" stroke="#8d969e" stroke-width="15"><title>53159 / CS-DIML</title></line><line x1="1069087.12695108" y1="72081.39830667093" x2="1069090.06811008" y2="71581.40695667091" stroke="#8d969e" stroke-width="15"><title>53160 / CSLine truncated

Before

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-96
View File
@@ -1,96 +0,0 @@
import json,pathlib,collections,math
ROOT=pathlib.Path(__file__).resolve().parents[1]
raw=(ROOT/'public/drawings/광터지하차도_일반도(BLOCK7).dxf').read_text(encoding='utf8',errors='replace').splitlines()
pairs=[(int(raw[i].strip()),raw[i+1].strip()) for i in range(0,len(raw)-1,2)]
entities=[]; current=None; section=''; block=None; blocks={}
for code,value in pairs:
if code==0:
if current:
if section=='ENTITIES': entities.append(current)
elif section=='BLOCKS' and block and current['type'] not in ('BLOCK','ENDBLK'): blocks.setdefault(block,[]).append(current)
current={'type':value,'data':[]}
if value=='ENDBLK': block=None
elif current:
current['data'].append((code,value))
if current['type']=='SECTION' and code==2: section=value
if current['type']=='BLOCK' and code==2: block=value
def get(e,c,d=None): return next((v for k,v in e['data'] if k==c),d)
def point(e,c=10): return [float(get(e,c,0)),float(get(e,c+10,0))]
def layer(e): return get(e,8,'')
out=ROOT/'docs/extracted';out.mkdir(exist_ok=True)
texts=[{'text':get(e,1,''),'p':point(e),'layer':layer(e)} for e in entities if e['type'] in ('TEXT','MTEXT')]
lines=[{'a':point(e),'b':point(e,11),'layer':layer(e),'handle':get(e,5)} for e in entities if e['type']=='LINE']
for name,data in [('texts',texts),('lines',lines),('blocks',blocks),('inserts',[{'name':get(e,2),'p':point(e),'sx':get(e,41,'1'),'sy':get(e,42,'1'),'rotation':get(e,50,'0'),'layer':layer(e)} for e in entities if e['type']=='INSERT'])]:
(out/(name+'.json')).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf8')
print('Entities',collections.Counter(e['type'] for e in entities))
for t in texts:
if any(s in t['text'] for s in ('BLOCK7','106.675','107.307','101.636','101.069','102.436','101.869','299.010','318.990','선형','측구','20.100')): print(t)
for name,top in [('A',-74271),('B',-91211)]:
selected=[l for l in lines if l['layer'] in ('CS-CONC-MAJR','CS-CONC-MINR') and all(1118000<p[0]<1139000 and top-7500<p[1]<top+100 for p in (l['a'],l['b']))]
print(name,len(selected),collections.Counter(l['layer'] for l in selected))
(out/f'section-{name}.json').write_text(json.dumps(selected,ensure_ascii=False,indent=2),encoding='utf8')
# 도면 근거를 보존하는 모델 데이터와 원도면 검토 SVG 생성
sections=[]
for name,top,el,sta in [('A',-74271.37855593952,106.675,299.000),('B',-91211.37855593952,107.307,319.000)]:
selected=[l for l in lines if l['layer'] in ('CS-CONC-MAJR','CS-CONC-MINR') and all(1118000<p[0]<1139000 and top-7500<p[1]<top+100 for p in (l['a'],l['b']))]
sections.append({'name':name,'topCad':top,'topEL':el,'station':sta,'lines':selected})
def group(i):
if i<5 or i==100: return '底版'
if 5<=i<=15: return '좌측 측구·포장 경계'
if 16<=i<=26: return '우측 측구·포장 경계'
if 27<=i<=86: return '포장 반복선·내부선'
if 87<=i<=89: return '중앙 분리부'
if 90<=i<=94: return '좌측 벽체'
if 95<=i<=99: return '우측 벽체'
if 101<=i<=107: return '하부·외측 보조 윤곽'
return '바닥판 상면·세부선'
def norm(p,s): return [round((p[0]-1128486.218066491)/1000,7),round(s['topEL']+(p[1]-s['topCad'])/1000,7)]
parts=[]
for i,(a,b) in enumerate(zip(sections[0]['lines'],sections[1]['lines'])):
assert a['layer']==b['layer']
assert all(abs(a[k][0]-b[k][0])<0.01 for k in ['a','b'])
parts.append({'id':'line-'+a['handle'],'name':group(i).replace('底版','바닥판 하단')+' · '+str(i+1),'group':group(i).replace('底版','바닥판 하단'),'layer':a['layer'],'handles':[a['handle'],b['handle']],'a':[norm(a['a'],sections[0]),norm(a['b'],sections[0])],'b':[norm(b['a'],sections[1]),norm(b['b'],sections[1])],'repeat':27<=i<=86,'kind':'line','source':'일반도 14 / 단면 A-A·B-B / '+a['layer']})
# 삽입 블록의 반전과 실제 LINE/ARC 윤곽을 보존한다. 원호는 5도 간격으로 표시한다.
ductName='A$C2017021411375356'
for side,x,sx in [('좌측',1119736.218066468,1),('우측',1137236.218066513,-1)]:
for j,e in enumerate(blocks[ductName]):
if e['type']=='LINE': pts=[point(e),point(e,11)]
elif e['type']=='ARC':
center=point(e); r=float(get(e,40)); start=float(get(e,50)); end=float(get(e,51))
if end<=start:end+=360
pts=[[center[0]+r*math.cos(math.radians(t)),center[1]+r*math.sin(math.radians(t))] for t in [start+(end-start)*k/36 for k in range(37)]]
else: continue
profiles=[]
for s,y in zip(sections,[-77447.89255584464,-95669.3155558447]):profiles.append([norm([x+sx*p[0],y+p[1]],s) for p in pts])
parts.append({'id':f'duct-{side}-{j}','name':f'{side} 공동구·상부 상세 · {j+1}','group':f'{side} 공동구·상부 상세','layer':layer(e),'handles':[get(e,5)],'a':profiles[0],'b':profiles[1],'repeat':False,'kind':e['type'].lower(),'source':'일반도 14 / '+ductName+' / 원본 삽입 블록'})
model={'source':'광터지하차도_일반도(BLOCK7).dxf','units':'m','length':19.98,'sectionStations':[299,319],'longitudinalStations':[299.01,318.99],'sectionTops':[106.675,107.307],'sectionBottoms':[101.636,101.069],'centerEL':[None,None],'alignment':None,'status':'확인 필요','connection':{'mode':'straight','status':'확인 필요','authorization':'2026-10-20 사용자 검토용 직선 연결 허용','description':'종단 경계 299.010~318.990에 원본 단면 299.000/319.000을 대응시킨 검토용 연결. 실제 평면 위치와 중간 형상은 미검증.'},'parts':parts}
modelDir=ROOT/'src/model';modelDir.mkdir(exist_ok=True)
(modelDir/'block7.json').write_text(json.dumps(model,ensure_ascii=False,indent=2),encoding='utf8')
import html
previewDir=ROOT/'public/analysis';previewDir.mkdir(exist_ok=True)
views=[('단면A',1113000,-84300,1142000,-69500),('단면B',1113000,-102300,1142000,-85500),('평면검토',1020000,-96000,1105000,-64000),('종단검토',1040000,-113000,1085000,-95000),('측구상세',1165000,-85000,1185000,-77000)]
for title,x0,y0,x1,y1 in views:
content=[]
for l in lines:
if all(x0<p[0]<x1 and y0<p[1]<y1 for p in (l['a'],l['b'])):
color='#326c5d' if l['layer'] in ('CS-CONC-MAJR','CS-CONC-MINR') else '#8d969e'
content.append(f'<line x1="{l["a"][0]}" y1="{-l["a"][1]}" x2="{l["b"][0]}" y2="{-l["b"][1]}" stroke="{color}" stroke-width="15"><title>{l["handle"]} / {l["layer"]}</title></line>')
for t in texts:
x,y=t['p']
if x0<x<x1 and y0<y<y1:content.append(f'<text x="{x}" y="{-y}" font-size="200" fill="#28334b">{html.escape(t["text"])}</text>')
# 공동구 삽입 블록의 실제 선 포함
for e in entities:
if e['type']=='INSERT' and get(e,2)==ductName:
x,y=point(e);sx=float(get(e,41,1));sy=float(get(e,42,1))
if x0<x<x1 and y0<y<y1:
for q in blocks[ductName]:
if q['type']=='LINE':
a,b=point(q),point(q,11);content.append(f'<line x1="{x+sx*a[0]}" y1="{-y-sy*a[1]}" x2="{x+sx*b[0]}" y2="{-y-sy*b[1]}" stroke="#a97635" stroke-width="15"/>')
svg=f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x0} {-y1} {x1-x0} {y1-y0}"><rect x="{x0}" y="{-y1}" width="{x1-x0}" height="{y1-y0}" fill="#fff"/>'+''.join(content)+'</svg>'
(previewDir/(title+'.svg')).write_text(svg,encoding='utf8')
print('모델 추출:',len(parts),'객체. 단면 원본 선 114쌍, 공동구 20쌍 × 좌우.')
-34
View File
@@ -1,34 +0,0 @@
<!doctype html><meta charset="utf-8"><title>뷰어 자동 검증</title><pre id="result">검증 중</pre><iframe src="/" style="width:1280px;height:1000px;border:0"></iframe><script type="module">
const iframe = document.querySelector('iframe'), result = document.querySelector('#result');
const pause = () => new Promise(resolve => setTimeout(resolve, 120));
const checks = [];
const check = (condition, name) => { checks.push({ name, pass: !!condition }); if (!condition) throw Error(name); };
try {
for (let i = 0; i < 80 && !iframe.contentDocument?.querySelector('canvas'); i++) await pause();
const doc = iframe.contentDocument;
const click = async text => { const button = [...doc.querySelectorAll('button')].find(e => e.textContent === text); check(button, `버튼 존재: ${text}`); button.click(); await pause(); };
const canvas = doc.querySelector('canvas'); check(canvas?.width > 0, '3D 캔버스 생성');
check(doc.body.textContent.includes('154'), '원본 154 객체 표시');
for (const name of ['평면','정면','측면','A-A','B-B','전체']) await click(name);
await click('모두 숨김'); check(doc.querySelector('.panel-heading').textContent.includes('0 / 154'), '전체 숨김 반영');
await click('모두 표시'); check(doc.querySelector('.panel-heading').textContent.includes('94 / 154'), '반복선 제외한 전체 표시');
const details = doc.querySelector('details'); details.open = true;
const row = doc.querySelector('.part-row button'); row.click(); await pause();
check(doc.querySelector('.selection').textContent.includes('원본 선 번호'), '선택하면 원본 근거 표시');
await click('선택한 윤곽 숨기기'); check(doc.querySelector('.panel-heading').textContent.includes('93 / 154'), '개별 숨김 반영');
let captured; iframe.contentWindow.URL.createObjectURL = blob => { captured = blob; return 'blob:viewer-test'; }; doc.addEventListener('click', e => { if (e.target.closest('a[download]')) e.preventDefault(); }, true);
await click('3D OBJ ↓'); const obj = await captured.text(); check(obj.split('\n').filter(s => s.startsWith('o ')).length === 153, 'OBJ 내보내기 153개 객체'); check(!obj.includes('NaN'), 'OBJ 유효한 좌표'); check(obj.split('\n').filter(s => s.startsWith('f ')).length === 466, 'OBJ 연결 삼각면 466개');
const before = canvas.toDataURL(); canvas.dispatchEvent(new iframe.contentWindow.KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); await pause(); check(canvas.toDataURL() !== before, '키보드 회전 반영');
const beforeZoom = canvas.toDataURL(); canvas.dispatchEvent(new iframe.contentWindow.WheelEvent('wheel', { deltaY: -200, bubbles: true, cancelable: true })); await pause(); check(canvas.toDataURL() !== beforeZoom, '휠 확대 반영');
const input = doc.querySelector('.search'), setter = Object.getOwnPropertyDescriptor(iframe.contentWindow.HTMLInputElement.prototype, 'value').set;
setter.call(input, '없는부재'); input.dispatchEvent(new iframe.contentWindow.Event('input', { bubbles: true })); await pause();
check(doc.querySelector('.empty'), '윤곽 검색 빈 결과 표시');
await click('도면'); check(doc.querySelector('.drawing-preview'), '도면 발췌 표시');
await click('측구상세'); await pause(); check(doc.querySelector('.drawing-preview').getAttribute('src').includes('측구상세'), '상세도 전환');
await click('검증'); check(doc.querySelectorAll('.checks .pending').length === 3, '미검증 항목 구분');
await click('렌더링 보기'); check(doc.querySelector('.canvas-tag').textContent.includes('도면 검토용'), '시각화 구분');
result.textContent = JSON.stringify({ status: 'PASS', checks }, null, 2);
} catch(e) { result.textContent = JSON.stringify({ status:'FAIL', error:e.message, checks }, null, 2); }
</script>
-7
View File
@@ -1,7 +0,0 @@
<!doctype html><meta charset="utf-8"><pre id="result">검증 중</pre><iframe src="/" style="width:390px;height:1450px;border:0"></iframe><script type="module">
const iframe=document.querySelector('iframe');
await new Promise(resolve=>iframe.addEventListener('load',resolve));
await new Promise(resolve=>setTimeout(resolve,1000));
const doc=iframe.contentDocument;
document.querySelector('#result').textContent=JSON.stringify({viewport:doc.documentElement.clientWidth,content:doc.documentElement.scrollWidth,body:doc.body.scrollWidth,pass:doc.documentElement.scrollWidth===doc.documentElement.clientWidth});
</script>
-4
View File
@@ -1,4 +0,0 @@
<!doctype html><meta charset="utf-8"><title>검토용 렌더링 미리보기</title><style>body{margin:0}iframe{border:0;width:100vw;height:100vh;display:block}</style><iframe src="/"></iframe><script type="module">
const iframe=document.querySelector('iframe');
for(let i=0;i<100;i++) { await new Promise(r=>setTimeout(r,100)); const button=[...(iframe.contentDocument?.querySelectorAll('button')||[])].find(b=>b.textContent==='렌더링 보기'); if(button){button.click();break;} }
</script>
-9
View File
@@ -1,9 +0,0 @@
import pathlib,json,html,re
root=pathlib.Path(__file__).resolve().parents[1]
for name in ['browser','mobile']:
file=root/f'docs/{name}-check-output.html'
source=file.read_text(encoding='utf-8-sig')
text=html.unescape(re.search(r'<pre id="result">(.*?)</pre>',source,re.S).group(1))
result=json.loads(text)
(root/f'docs/{name}-check-result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf8')
print(name,result.get('status',result.get('pass')),len(result.get('checks',[])))
-27
View File
@@ -1,27 +0,0 @@
import assert from 'node:assert/strict'
import { writeFileSync, mkdirSync } from 'node:fs'
import { model, exportOBJ, partFaces, profileVertices } from '../src/model/geometry.js'
const faces = model.parts.flatMap(partFaces)
assert.equal(faces.length, 468)
assert.equal(model.connection.status, '확인 필요')
for (const part of model.parts) {
const a = profileVertices(part, 0), b = profileVertices(part, 1)
assert.equal(a.length,b.length)
for(let i=0;i<a.length;i++) {
assert.ok(Math.abs(b[i][2]-a[i][2]-19.98)<1e-10)
assert.equal(a[i][0],part.a[i][0]); assert.equal(b[i][0],part.b[i][0])
assert.ok(Math.abs(a[i][1]+100-part.a[i][1])<1e-10)
assert.ok(Math.abs(b[i][1]+100-part.b[i][1])<1e-10)
}
}
const obj=exportOBJ()
const rows=obj.split('\n'), vertices=rows.filter(r=>r.startsWith('v '))
assert.equal(rows.filter(r=>r.startsWith('o ')).length,154)
assert.equal(rows.filter(r=>r.startsWith('f ')).length,468)
for(const row of rows.filter(r=>r.startsWith('f ')||r.startsWith('l '))) {
for(const n of row.split(' ').slice(1)) assert.ok(Number(n)>=1&&Number(n)<=vertices.length)
}
assert.equal(exportOBJ(new Set(model.parts.map(p=>p.id))).split('\n').filter(r=>r.startsWith('v ')).length,0)
mkdirSync('public/models',{recursive:true})
writeFileSync('public/models/BLOCK7_검토용모델.obj',obj)
console.log('직선 연결 19.980m, 468개 삼각면, 154개 객체, OBJ 참조·숨김 반영 검증 통과')
-30
View File
@@ -1,30 +0,0 @@
import json,math,pathlib
root=pathlib.Path(__file__).resolve().parents[1]
model=json.loads((root/'src/model/block7.json').read_text(encoding='utf8'))
parts=model['parts']; assert len(parts)==154
assert len({p['id'] for p in parts})==154
results=[]
for s,top,el in [('A',-74271.37855593952,106.675),('B',-91211.37855593952,107.307)]:
raw=json.loads((root/f'docs/extracted/section-{s}.json').read_text(encoding='utf8'))
assert len(raw)==114
assert sum(l['layer']=='CS-CONC-MAJR' for l in raw)==108
assert sum(l['layer']=='CS-CONC-MINR' for l in raw)==6
for original,part in zip(raw,parts[:114]):
assert original['handle']==part['handles'][0 if s=='A' else 1]
for key,point in zip(['a','b'],part[s.lower()]):
expected=[(original[key][0]-1128486.218066491)/1000,el+(original[key][1]-top)/1000]
assert all(abs(a-b)<0.000000051 for a,b in zip(point,expected))
results.append(f'{s}: 114개 선 HANDLE·레이어·양 끝점 원본 대조 통과 (좌표 반올림 오차 0.000051mm 이하)')
for p in parts:
assert len(p['a'])==len(p['b'])
assert all(math.isfinite(v) for s in ['a','b'] for point in p[s] for v in point)
assert sum(p['kind']=='arc' for p in parts)==4
assert abs(model['longitudinalStations'][1]-model['longitudinalStations'][0]-model['length'])<1e-10
assert model['sectionStations'][1]-model['sectionStations'][0]==20
for i,el in enumerate(model['sectionTops']):
assert abs(max(pt[1] for p in parts[:114] for pt in p['a' if i==0 else 'b'])-el)<1e-7
assert abs((parts[1]['a'][1][1]-parts[1]['a'][0][1]))<1e-7
results+=['154개 ID 중복 없음 / 모든 좌표 유한수 / 대응 꼭짓점 수 일치','원호 4개, 좌우 삽입 블록 40쌍 반영','벽체 상단 EL 및 종단 측점 차 19.980m 검산 통과','원본 단면 STA 간격 20.000m 보존; 사용자 허용에 따라 배치 간격만 19.980m로 대응 (verify_geometry.mjs에서 별도 검증)']
print('\n'.join(results))
(root/'docs/좌표검증결과.txt').write_text('\n'.join(results)+'\n',encoding='utf8')
+13 -60
View File
@@ -1,61 +1,14 @@
import { useMemo, useState } from 'react'
import Viewer from './Viewer'
import { model, groups, palette, partDimensions, exportModel, exportOBJ } from './model/geometry'
function download(data, filename, type = 'application/json') {
const url = URL.createObjectURL(new Blob([data], { type }))
const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export default function App() {
const [hidden, setHidden] = useState(new Set()), [selected, setSelected] = useState(null)
const [view, setView] = useState({ name: '전체', key: 0 }), [repeat, setRepeat] = useState(false)
const [section, setSection] = useState('both'), [dimensions, setDimensions] = useState(true), [renderMode, setRenderMode] = useState(false), [surfaces, setSurfaces] = useState(true)
const [search, setSearch] = useState(''), [tab, setTab] = useState('부재'), [source, setSource] = useState('단면A')
const part = model.parts.find(p => p.id === selected), dims = part && partDimensions(part)
const shown = model.parts.filter(p => !hidden.has(p.id) && (repeat || !p.repeat)).length
const filtered = useMemo(() => model.parts.filter(p => `${p.name} ${p.layer}`.includes(search)), [search])
const toggle = ids => setHidden(current => { const next = new Set(current), hide = ids.some(id => !current.has(id)); ids.forEach(id => hide ? next.add(id) : next.delete(id)); return next })
const switchView = name => { setView({ name, key: Date.now() }); if (name === 'A-A') setSection('A'); else if (name === 'B-B') setSection('B'); else setSection('both') }
return <main className="shell">
<header className="topbar">
<a className="brand" href="#top"><span className="brand-icon">G</span>도면 모델 작업실</a>
<span className="project-label">구조 검토 <b>/</b> 일반도 13 · 14</span>
<a className="drawing-link" href="./drawings/광터지하차도_일반도(BLOCK7).dxf" download>원본 DXF ↓</a>
<button className="export" onClick={() => download(exportOBJ(hidden), '광터_BLOCK7_단면윤곽.obj', 'text/plain')}>3D OBJ ↓</button>
</header>
<section className="intro" id="top">
<div><span className="kicker">도면에서 읽은 구조, 근거가 보이는 모델</span><h1>광터지하차도 <em>BLOCK7</em></h1><p>원본 단면을 19.980m로 직선 연결한 3D 검토 모델</p></div>
<span className="status-pill"><i />평면 배치 · 단면 연결 확인 필요</span>
</section>
<section className="metrics" aria-label="도면 기준값">
<article><small>종단 구조 경계 간격</small><strong>19.980 <span>m</span></strong><p>STA 299.010 → 318.990 · 계산값</p></article>
<article><small>단면 표기 폭</small><strong>20.100 <span>m</span></strong><p>일반도 14 · 도면 명시값</p></article>
<article><small>벽체 상단 EL · A / B</small><strong className="el">106.675 <span>/</span> 107.307</strong><p>종단면 · 단면 표기 일치</p></article>
<article><small>원본 윤곽 객체</small><strong>154 <span>쌍</span></strong><p>구조선 114 + 좌우 블록 40</p></article>
</section>
<div className="notice"><span>확인 필요</span><p>허용하신 검토용 직선 연결을 적용했습니다. 모든 연결면은 확인 필요입니다. 원본 단면 STA 299.000 / 319.000을 종단 경계 299.010 / 318.990에 대응시켰으며, 실제 평면 중심선·중심 EL·중간 형상은 미검증입니다.</p></div>
<section className="workspace">
<div className="viewer-card">
<div className="viewer-head"><div><span className="eyebrow">모델 검토</span><h2>단면에서 공간으로</h2></div><div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>렌더링 보기</button></div></div>
<div className="viewbar"><div className="views">{['전체', '평면', '정면', '측면', 'A-A', 'B-B'].map(name => <button className={view.name === name ? 'active' : ''} key={name} onClick={() => switchView(name)}>{name}</button>)}</div><label><input type="checkbox" checked={surfaces} onChange={e => setSurfaces(e.target.checked)} />연결면</label><label><input type="checkbox" checked={dimensions} onChange={e => setDimensions(e.target.checked)} />치수</label></div>
<div className="canvas-wrap"><Viewer {...{ hidden, selected, view, repeat, section, dimensions, renderMode, surfaces }} onSelect={setSelected} /><div className="canvas-tag">{renderMode ? '렌더링 · 도면 검토용 시각화 · 실사 아님' : '기본 모델 · 모든 연결면 확인 필요'}</div><div className="model-note">황토색 점선: 확인 필요 연결부</div></div>
<div className="viewer-foot"><span>끌기 회전 · 휠 확대 · Shift+끌기 이동 · 두 손가락 확대/이동</span><button onClick={() => switchView('전체')}>전체 보기 ↗</button></div>
<div className="selection" aria-live="polite">{part ? <><div className="selection-title"><span className="eyebrow">선택한 윤곽</span><button onClick={() => setSelected(null)} aria-label="선택 해제">×</button></div><h3>{part.name}</h3><div className="selection-grid"><p>횡방향 범위<strong>{dims.width.toFixed(3)} m</strong></p><p>연결 길이<strong>19.980 m · 확인 필요</strong></p><p>표고 범위 EL<strong>{dims.elevation.map(v => v.toFixed(3)).join(' ~ ')}</strong></p></div><p className="source">{part.source}<br />원본 선 번호: {part.handles.join(' / ')}</p><p className="source">치수는 원본 좌표로 계산한 값입니다. 선의 범위이며 부재 두께를 확정한 값이 아닙니다. 연결 길이와 방향은 검토용 가정입니다.</p><button className="subtle" onClick={() => toggle([part.id])}>선택한 윤곽 숨기기</button></> : <><span className="selection-dot" /><div><strong>윤곽을 선택해 근거를 확인하세요</strong><p>화면의 선이나 오른쪽 목록을 선택하면 이름·치수·원본 선 번호를 보여줍니다.</p></div></>}</div>
</div>
<aside className="side-card">
<div className="tabs">{['부재', '도면', '검증'].map(t => <button key={t} className={tab === t ? 'active' : ''} onClick={() => setTab(t)}>{t}</button>)}</div>
{tab === '부재' && <><div className="panel-heading"><h2>윤곽 목록</h2><span>{shown} / {model.parts.length} 표시</span></div><input className="search" aria-label="윤곽 검색" placeholder="부재 이름 또는 레이어 검색" value={search} onChange={e => setSearch(e.target.value)} /><label className="repeat"><input type="checkbox" checked={repeat} onChange={e => setRepeat(e.target.checked)} />포장 반복선·내부선 표시</label><div className="list-actions"><button onClick={() => setHidden(new Set())}>모두 표시</button><button onClick={() => setHidden(new Set(model.parts.map(p => p.id)))}>모두 숨김</button></div><div className="part-list">{groups.map((g, i) => {
const ps = filtered.filter(p => p.group === g)
if (!ps.length) return null
const allHidden = ps.every(p => hidden.has(p.id))
return <details key={g} open={search ? true : undefined}><summary><span className="swatch" style={{ background: palette[i % palette.length] }} /><strong>{g}</strong><small>{ps.length}</small></summary><div className="group-actions"><button onClick={() => toggle(ps.map(p => p.id))}>{allHidden ? '그룹 표시' : '그룹 숨김'}</button></div>{ps.map(p => <div className={`part-row ${selected === p.id ? 'chosen' : ''}`} key={p.id}><button onClick={() => setSelected(p.id)} title={p.name}>{p.name}</button><input aria-label={`${p.name} 표시`} type="checkbox" checked={!hidden.has(p.id)} onChange={() => toggle([p.id])} /></div>)}</details>
})}{!filtered.length && <p className="empty">검색 결과가 없습니다.</p>}</div><button className="subtle" onClick={() => download(JSON.stringify(exportModel(hidden), null, 2), '광터_BLOCK7_단면모델.json')}>좌표·근거 JSON ↓</button><p className="panel-note">선 개수는 실제 부재 개수와 다릅니다. 반복선은 기본적으로 숨겨져 있습니다.</p></>}
{tab === '도면' && <><h2>원본 좌표 검토</h2><p className="panel-note">주요 LINE·TEXT와 공동구 선을 발췌한 검토 자료입니다. 전체 도면은 원본 DXF를 확인하세요.</p><div className="source-tabs">{['단면A', '단면B', '평면검토', '종단검토', '측구상세'].map(s => <button key={s} className={s === source ? 'active' : ''} onClick={() => setSource(s)}>{s}</button>)}</div><a href={`./analysis/${source}.svg`} target="_blank" rel="noreferrer"><img className="drawing-preview" src={`./analysis/${source}.svg`} alt={`${source} 원본 좌표 발췌`} /><span className="open-drawing">크게 보기 ↗</span></a><div className="source-note"><strong>그림 읽는 법</strong><p>녹색은 두 콘크리트 레이어, 황토색은 공동구 삽입 블록, 회색은 보조선입니다. 도면 기호·일부 블록은 생략되어 있습니다.</p></div></>}
{tab === '검증' && <><h2>확인한 것과 남은 것</h2><ul className="checks"><li><b>확인</b><span>A/B 벽체 상단 EL<br /><small>종단면과 단면 각각 일치</small></span></li><li><b>확인</b><span>단면별 구조선 114개<br /><small>MAJR 108 + MINR 6, 원본 번호 보존</small></span></li><li><b>확인</b><span>공동구 실제 직선·원호<br /><small>좌우 반전과 단면별 높이 반영</small></span></li><li className="pending"><b>미확정</b><span>평면 중심선·절단 위치<br /><small>중심 EL·중간 곡률 미검증</small></span></li><li className="pending"><b>미확정</b><span>단면·종단 측점 차이<br /><small>각 끝 10mm 차이, 경계 대응 필요</small></span></li><li className="pending"><b>미확정</b><span>연결 표면·체적·재료<br /><small>실제 중간 형상과 상세 연장 미검증</small></span></li></ul><div className="source-note"><strong>현재 표현 범위</strong><p>두 단면을 19.980m로 직선 연결했습니다. 연결면은 모두 확인 필요이며, 평면 배치와 실제 중간 형상은 미검증입니다. 렌더링은 표면 명암과 그림자를 더한 도면 검토용 시각화입니다. 닫힌 체적 모델이 아닙니다.</p></div></>}
</aside>
</section>
<footer><span>광터지하차도 BLOCK7 · 도면 검토용</span><span>기준 단위 m · 표고 EL · 미확정 형상은 분석 기록에서 관리</span></footer>
</main>
}
return (
<main className="welcome">
<section className="welcome-card" aria-labelledby="welcome-title">
<span className="welcome-mark" aria-hidden="true">3D</span>
<p className="welcome-kicker">새 프로젝트</p>
<h1 id="welcome-title">작업을 시작할 준비가 되었습니다</h1>
<p className="welcome-description">
이 공간은 비어 있습니다. 도면과 작업 내용을 추가해 새 모델을 만들어 보세요.
</p>
</section>
</main>
)
}
-160
View File
@@ -1,160 +0,0 @@
import { useEffect, useRef } from 'react'
import { model, groups, palette, profileVertices, partFaces } from './model/geometry'
const initial = () => ({ yaw: -.65, pitch: .56, zoom: 1, pan: [0, 0] })
const distance = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1]
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)))
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
}
export default function Viewer({ hidden, selected, onSelect, view, repeat, section, dimensions, renderMode, surfaces, onReady }) {
const canvasRef = useRef(null), camera = useRef(initial()), drawRef = useRef(() => {})
const scene = useRef({ hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces })
useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode, surfaces])
useEffect(() => {
camera.current = { ...initial(), ...({ '평면': { yaw: 0, pitch: Math.PI / 2 }, '정면': { yaw: 0, pitch: 0 }, '측면': { yaw: Math.PI / 2, pitch: 0 }, 'A-A': { yaw: 0, pitch: 0 }, 'B-B': { yaw: Math.PI, pitch: 0 } }[view.name]) }
drawRef.current()
}, [view])
useEffect(() => {
const canvas = canvasRef.current, ctx = canvas.getContext('2d')
const pointers = new Map()
let hits = [], faceHits = [], moved = false, last = null, start = null
const project = (p, width, height) => {
const c = camera.current, y = p[1] - 4
const x = p[0] * Math.cos(c.yaw) - p[2] * Math.sin(c.yaw)
const z = p[0] * Math.sin(c.yaw) + p[2] * Math.cos(c.yaw)
const scale = Math.min(width / 35, height / 29) * c.zoom
return [width / 2 + x * scale + c.pan[0], height / 2 - (y * Math.cos(c.pitch) - z * Math.sin(c.pitch)) * scale + c.pan[1], y * Math.sin(c.pitch) + z * Math.cos(c.pitch)]
}
const draw = () => {
const rect = canvas.getBoundingClientRect(), w = rect.width, h = rect.height
if (!w || !h) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const { hidden, selected, repeat, section, dimensions, renderMode, surfaces } = scene.current
const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, renderMode ? '#e7eeeb' : '#f8faf8'); bg.addColorStop(1, renderMode ? '#c8d5ce' : '#eaf0eb')
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h)
ctx.lineWidth = .6; ctx.strokeStyle = '#aabcb347'
const line = (a, b, color, width = 1) => { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke() }
for (let k = -16; k <= 16; k += 2) {
line(project([k, .5, -16], w, h), project([k, .5, 16], w, h), '#9faea943', .6)
line(project([-16, .5, k], w, h), project([16, .5, k], w, h), '#9faea943', .6)
}
const faces = [], edges = []
const shade = (hex, amount) => '#' + [1, 3, 5].map(i => Math.round(parseInt(hex.slice(i, i + 2), 16) * amount).toString(16).padStart(2, '0')).join('')
const fill = (pts, color) => { ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.closePath(); ctx.fillStyle = color; ctx.fill() }
model.parts.forEach(part => {
if (hidden.has(part.id) || (!repeat && part.repeat)) return
const color = palette[groups.indexOf(part.group) % palette.length]
if (surfaces && section === 'both') {
for (const points of partFaces(part)) {
const pts = points.map(p => project(p, w, h))
const u = points[1].map((v, i) => v - points[0][i]), v = points[2].map((v, i) => v - points[0][i])
const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
const light = Math.abs((n[0] * .35 + n[1] * .8 - n[2] * .45) / (Math.hypot(...n) || 1))
faces.push({ type: 'face', pts, points, part, color: selected === part.id ? '#cf9560' : shade(color, renderMode ? .64 + .32 * light : .95), depth: pts.reduce((s, p) => s + p[2], 0) / 3 })
}
}
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
const pts = profileVertices(part, s).map(p => project(p, w, h))
for (let i = 0; i < pts.length - 1; i++) edges.push({ a: pts[i], b: pts[i + 1], part, color, depth: (pts[i][2] + pts[i + 1][2]) / 2, s })
}
})
if (renderMode && faces.length) {
ctx.globalAlpha = .025
for (const face of faces) fill(face.points.map(([x, y, z]) => project([x + (y - .5) * .35, .5, z + (y - .5) * .45], w, h)), '#364e39')
ctx.globalAlpha = 1
}
hits = []; faceHits = []
const primitives = [...faces, ...edges].sort((a, b) => a.depth - b.depth)
for (const primitive of primitives) {
if (primitive.type === 'face') {
fill(primitive.pts, primitive.color)
faceHits.push({ pts: primitive.pts, id: primitive.part.id, depth: primitive.depth })
} else {
const { a, b, part, color, s } = primitive, active = selected === part.id
line(a, b, active ? '#cf762f' : renderMode ? '#526e5f' : color, active ? 3.7 : part.repeat ? .6 : 1.3)
hits.push({ a, b, id: part.id, s, depth: primitive.depth })
}
}
if (surfaces && section === 'both') {
ctx.setLineDash([4, 5])
for (const part of model.parts.filter(p => !hidden.has(p.id) && !p.repeat)) {
const a = profileVertices(part, 0), b = profileVertices(part, 1)
if (part.kind !== 'arc') line(project(a[0], w, h), project(b[0], w, h), '#a47f4b70', .55)
}
ctx.setLineDash([])
} ctx.font = '12px sans-serif'
for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) {
const z = (s - .5) * model.length
const p = project([0, model.sectionTops[s] - 100 + .8, z], w, h)
ctx.fillStyle = s ? '#836442' : '#376f60'; ctx.textAlign = 'center'
ctx.fillText(`${s ? 'B-B' : 'A-A'} · STA ${model.longitudinalStations[s].toFixed(3)}`, p[0], p[1])
if (dimensions) {
const a = project([-10.05, .1, z], w, h), b = project([10.05, .1, z], w, h)
line(a, b, '#6b8279', .8); ctx.fillStyle = '#557267'
ctx.fillText('표기 폭 20.100m', (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 17)
}
}
ctx.textAlign = 'left'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#71877c'
ctx.fillText('X 횡방향 / Y 표고 / Z 종단 경계 기준 거리', 18, h - 20)
ctx.fillText(`확대 ${Math.round(camera.current.zoom * 100)}%`, w - 96, h - 20)
onReady?.()
}
drawRef.current = draw
const local = e => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] }
const down = e => { canvas.focus(); canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, local(e)); start = local(e); last = { p: local(e), distance: 0 }; moved = false; e.preventDefault() }
const move = e => {
if (!pointers.has(e.pointerId)) return
const p = local(e), old = pointers.get(e.pointerId); pointers.set(e.pointerId, p)
if (Math.hypot(p[0] - start[0], p[1] - start[1]) > 4) moved = true
if (pointers.size === 2) {
const [a, b] = [...pointers.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1])
if (last.distance) camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * d / last.distance))
camera.current.pan[0] += (p[0] - old[0]) / 2; camera.current.pan[1] += (p[1] - old[1]) / 2
last.distance = d; moved = true
} else if (e.shiftKey || e.buttons === 2 || e.buttons === 4) {
camera.current.pan[0] += p[0] - old[0]; camera.current.pan[1] += p[1] - old[1]
} else {
camera.current.yaw += (p[0] - old[0]) * .009
camera.current.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.current.pitch + (p[1] - old[1]) * .009))
}
draw()
}
const up = e => {
const p = local(e)
if (!moved && pointers.size === 1) {
const inside = pts => { const cross = (a, b) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); const values = pts.map((a, i) => cross(a, pts[(i + 1) % pts.length])); return values.every(v => v >= 0) || values.every(v => v <= 0) }
const edge = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7)
const face = [...faceHits].reverse().find(h => inside(h.pts))
const hit = edge && (!face || edge.depth >= face.depth - .1) ? edge : face
scene.current.onSelect(hit?.id || null)
}
pointers.delete(e.pointerId); last = { p, distance: 0 }
}
const cancel = e => { pointers.delete(e.pointerId); moved = true }
const wheel = e => { e.preventDefault(); camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * Math.exp(-e.deltaY * .001))); draw() }
const key = e => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', 'Home', 'Escape'].includes(e.key)) return
e.preventDefault()
if (e.key === 'Home') camera.current = initial()
if (e.key === 'Escape') scene.current.onSelect(null)
if (e.key === '+' || e.key === '=') camera.current.zoom = Math.min(8, camera.current.zoom * 1.15)
if (e.key === '-') camera.current.zoom = Math.max(.25, camera.current.zoom / 1.15)
if (e.key === 'ArrowLeft') camera.current.yaw -= .1
if (e.key === 'ArrowRight') camera.current.yaw += .1
if (e.key === 'ArrowUp') camera.current.pitch = Math.min(Math.PI / 2, camera.current.pitch + .1)
if (e.key === 'ArrowDown') camera.current.pitch = Math.max(-Math.PI / 2, camera.current.pitch - .1)
draw()
}
const context = e => e.preventDefault()
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', cancel); canvas.addEventListener('wheel', wheel, { passive: false }); canvas.addEventListener('keydown', key); canvas.addEventListener('contextmenu', context)
const observer = new ResizeObserver(draw); observer.observe(canvas); draw()
return () => { observer.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', cancel); canvas.removeEventListener('wheel', wheel); canvas.removeEventListener('keydown', key); canvas.removeEventListener('contextmenu', context); drawRef.current = () => {} }
}, [onReady])
return <canvas ref={canvasRef} tabIndex={0} role="img" aria-label="광터지하차도 단면 3D 모델. 끌어서 회전, Shift와 끌기로 이동, 휠로 확대. 방향키로 회전, Home으로 전체 보기." />
}
+15 -3
View File
@@ -1,4 +1,16 @@
:root{font-family:'Malgun Gothic','Apple SD Gothic Neo',sans-serif;color:#263a32;background:#eff2ed;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input{font:inherit}button,a,input{outline-offset:4px}button{cursor:pointer}button{border:0;background:none;color:inherit}a{color:inherit;text-decoration:none}button:hover{color:#2b735c}button:focus-visible,a:focus-visible{outline:2px solid #bc763b}.shell{max-width:1600px;margin:auto;padding:0 36px 24px}.topbar{height:76px;display:flex;align-items:center;gap:18px;border-bottom:1px solid #d9e1d7}.brand{display:flex;gap:10px;align-items:center;font-size:14px;font-weight:700}.brand-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:10px;background:#446b58;color:#fff;font-family:Georgia,serif;font-size:23px}.project-label{margin-left:auto;font-size:11px;color:#7a897e}.project-label b{margin:0 9px;color:#b1b9b1}.drawing-link,.export{padding:10px 13px;border:1px solid #d3ddd2;border-radius:7px;font-size:11px}.export{background:#426851;color:#fff;border-color:#426851}.export:hover{color:white;background:#31513e}.intro{display:flex;justify-content:space-between;align-items:center;padding:35px 0 26px}.kicker,.eyebrow{font-size:10px;letter-spacing:.08em;color:#7a8d7e;display:block;font-weight:700}h1{margin:8px 0;font-size:30px;letter-spacing:-.055em}h1 em{font-style:normal;font-weight:400;margin-left:8px;color:#738b79}.intro p{margin:0;color:#7c8d80;font-size:12px}.status-pill{display:flex;align-items:center;gap:7px;border:1px solid #e0d6bd;background:#f8f4e9;border-radius:30px;padding:9px 13px;font-size:11px;color:#9b793c}.status-pill i{width:6px;height:6px;background:#b08a45;border-radius:50%}.metrics{display:grid;grid-template-columns:repeat(4,1fr);background:#f9fbf7;border:1px solid #dce4d9;border-radius:12px;margin-bottom:18px;padding:20px 0}.metrics article{padding:0 23px;border-right:1px solid #e3e8df}.metrics article:last-child{border:0}.metrics small{display:block;font-size:10px;color:#7d8b7d}.metrics strong{font-family:Georgia,serif;font-size:28px;font-weight:400;display:block;margin:9px 0}.metrics strong.el{font-size:23px;line-height:32px}.metrics strong span{font:12px sans-serif;color:#899584}.metrics p{font-size:9px;margin:0;color:#8b9687}.notice{display:flex;gap:12px;align-items:center;background:#f5f0e4;border:1px solid #e6dcc7;border-radius:8px;padding:11px 15px;margin-bottom:20px}.notice>span{font-size:10px;font-weight:700;white-space:nowrap;color:#967136}.notice p{font-size:11px;line-height:1.75;margin:0;color:#8b7e62}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:20px;align-items:start}.viewer-card,.side-card{background:#fafcf8;border:1px solid #dbe4d7;border-radius:12px;overflow:hidden}.viewer-head{display:flex;align-items:center;justify-content:space-between;padding:20px 22px 16px}h2{font-size:17px;letter-spacing:-.04em;margin:5px 0 0}.segmented{display:flex;padding:3px;background:#eaf0e7;border-radius:7px}.segmented button{font-size:10px;padding:7px 11px;border-radius:5px;color:#7f8d7b}.segmented .active{background:#fff;box-shadow:0 1px 4px #233c2912;color:#496c52}.viewbar{display:flex;justify-content:space-between;align-items:center;padding:0 20px 12px;gap:12px}.views{display:flex;gap:4px}.views button{font-size:10px;padding:7px 10px;border-radius:5px;border:1px solid transparent}.views button.active{border-color:#ccdac9;background:#eef3ea;color:#426a50}.viewbar label{display:flex;align-items:center;font-size:10px;color:#7e8c79;gap:4px}input[type=checkbox]{accent-color:#547862;cursor:pointer}.canvas-wrap{position:relative;border-top:1px solid #dfe8da;border-bottom:1px solid #dfe8da}.canvas-wrap canvas{display:block;width:100%;height:510px;touch-action:none;cursor:grab}.canvas-wrap canvas:active{cursor:grabbing}.canvas-wrap canvas:focus-visible{outline:2px solid #7d9a83;outline-offset:-2px}.canvas-tag{position:absolute;left:18px;top:17px;background:#fcfffbd9;border:1px solid #d5e0d2;border-radius:5px;color:#6c846f;font-size:9px;padding:7px 9px;pointer-events:none}.model-note{position:absolute;right:16px;top:23px;color:#aa824b;font-size:9px;pointer-events:none}.viewer-foot{display:flex;justify-content:space-between;align-items:center;padding:13px 20px;color:#8b9885;font-size:9px}.viewer-foot button{font-size:10px;color:#56735b}.selection{margin:0 20px 20px;border-top:1px solid #e2e9dd;min-height:94px;padding:18px 0 0;display:flex;align-items:center;gap:13px}.selection:has(h3){display:block}.selection strong{font-size:11px;font-weight:500}.selection p{font-size:10px;color:#899581;line-height:1.8;margin:7px 0}.selection-dot{width:10px;height:10px;border-radius:50%;border:2px solid #87a78c}.selection-title{display:flex;align-items:center;justify-content:space-between}.selection-title button{font-size:20px}.selection h3{font-size:15px;margin:5px 0}.selection-grid{display:flex;gap:35px}.selection-grid p strong{display:block;color:#4c6e53}.selLine truncated
.viewer-card,.side-card,.metrics article{min-width:0}.canvas-wrap canvas{max-width:100%}.notice p{min-width:0}@media(max-width:480px){.topbar{height:auto;min-height:65px;flex-wrap:wrap;padding:14px 0}.export{margin-left:auto}.viewer-head{gap:8px}.segmented button{font-size:9px}.viewer-foot{gap:10px}.viewer-foot span{min-width:0}.metrics p{line-height:1.7}.notice{align-items:flex-start}}
:root {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
color: #263a32;
background: #eff2ed;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
.viewbar{flex-wrap:wrap}.views{margin-right:auto}.selection-grid{flex-wrap:wrap;gap:12px 25px}@media(max-width:480px){.viewbar{gap:6px}.views{width:100%}.viewbar>label{font-size:9px}}
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
.welcome { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
.welcome-card { width: min(100%, 560px); padding: 48px 32px; text-align: center; background: #fafcf8; border: 1px solid #dbe4d7; border-radius: 16px; }
.welcome-mark { display: grid; width: 52px; height: 52px; margin: 0 auto 24px; place-items: center; border-radius: 15px; background: #446b58; color: white; font-size: 17px; font-weight: 700; }
.welcome-kicker { margin: 0 0 10px; color: #71877a; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 0; font-size: clamp(22px, 5vw, 30px); letter-spacing: -.05em; }
.welcome-description { margin: 16px 0 0; color: #77857b; font-size: 14px; line-height: 1.8; }
File diff suppressed because it is too large. Load diff
-40
View File
@@ -1,40 +0,0 @@
import data from './block7.json' with { type: 'json' }
export const model = data
export const groups = [...new Set(data.parts.map(p => p.group))]
export const palette = ['#b0b5aa', '#a3b3a6', '#a3b3a6', '#acb8b0', '#b2aa91', '#aeb9aa', '#aeb9aa', '#bab6a5', '#adb3a6', '#a2b5a0', '#a2b5a0']
export function profileVertices(part, section) {
const z = (section - .5) * data.length
return part[section === 0 ? 'a' : 'b'].map(([x, el]) => [x, el - 100, z])
}
export function partFaces(part) {
if (part.repeat) return []
const a = profileVertices(part, 0), b = profileVertices(part, 1), faces = []
for (let i = 0; i < a.length - 1; i++) {
faces.push([a[i], a[i + 1], b[i + 1]], [a[i], b[i + 1], b[i]])
}
return faces
}
export function partDimensions(part) {
const points = [...part.a, ...part.b]
const width = Math.max(...points.map(p => p[0])) - Math.min(...points.map(p => p[0]))
const elevation = [Math.min(...points.map(p => p[1])), Math.max(...points.map(p => p[1]))]
return { width, elevation, length: part.repeat ? null : data.length, sourceStations: data.sectionStations }
}
export function exportModel(hidden) {
return { ...data, parts: data.parts.filter(p => !hidden.has(p.id)), description: '원본 단면을 19.980m 직선 연결한 검토용 표면. 모든 연결부 확인 필요. 닫힌 체적 아님.', coordinateSystem: 'X=횡방향(m), Y=EL(m), Z=종단 경계 기준 거리(m); 실제 평면 좌표 미확정' }
}
export function exportOBJ(hidden = new Set()) {
const rows = ['# 광터지하차도 BLOCK7 검토용 표면 모델', '# 단위 m. X 횡방향 / Y EL / Z 종단 경계 기준 거리.', '# 모든 연결면 확인 필요. 평면 배치·중간 형상 미검증. 닫힌 체적 아님.']
let index = 1
for (const part of data.parts.filter(p => !hidden.has(p.id))) {
rows.push(`# ${part.name} / ${part.source}`, `o ${part.id.replace(/[^a-zA-Z0-9-]/g, '_')}`)
const a = profileVertices(part, 0), b = profileVertices(part, 1), n = a.length
;[...a, ...b].forEach(([x, y, z]) => rows.push(`v ${x} ${y + 100} ${z + data.length / 2}`))
rows.push(`l ${a.map((_, i) => index + i).join(' ')}`, `l ${b.map((_, i) => index + n + i).join(' ')}`)
if (!part.repeat) for (let i = 0; i < n - 1; i++) rows.push(`f ${index + i} ${index + i + 1} ${index + n + i + 1}`, `f ${index + i} ${index + n + i + 1} ${index + n + i}`)
index += n * 2
}
return rows.join('\n') + '\n'
}
-24
View File
@@ -1,24 +0,0 @@
# 광터지하차도 새 모델 프로젝트 작업기록
## 2026-10-20 새 프로젝트 구성
- 기존 `my-web-app`을 수정하지 않고 `/home/jiyoung/projects/광터지하차도-새모델`에 새 프로젝트를 만들었다.
- 원본 DXF, 프로젝트 지침, 기존 분석 참고본을 복사했다. 기존 3D 모델 코드는 가져오지 않았다.
- React/Vite 시작 화면과 실행 안내를 새로 구성했다.
- Git 저장소를 별도로 초기화하고 `4c331d8` (`광터지하차도 새 모델 프로젝트 시작`)으로 시작 파일을 저장했다.
- `npm ci --offline`, `npm run build`, `npm run lint`를 확인했다.
## 2026-10-20 원본 도면 1차 분석
- 새 프로젝트에서 원본 DXF 헤더, 시트 제목, 축척 문자, 측점·표고·폭 치수를 다시 읽고 `docs/도면분석.md`에 근거와 상태를 기록했다.
- 평면도 `(BLOCK7)` 표기 부근의 `CS-CONC-MAJR` 종방향 기준선 후보를 찾았다. 길이는 약 20.100m이고 측점 차 19.980m와 0.120m 차이가 난다.
- 해당 구간에 원호나 중간 곡선점은 아직 찾지 못했고, 이 선이 실제 선형중심선인지 A-A/B-B 절단 위치와 일치하는지도 확정하지 않았다.
- 단면 주변의 같은 레이어 선은 중복과 해칭 후보가 있어 좌표 범위만으로 구조 경계를 단정하지 않기로 했다.
- 다음 단계는 평면 기준선과 단면 위치를 확정하고, 확인된 주요 단면 구조부터 새 데이터와 코드로 모델링하는 것이다.
## 2026-10-20 도면 재분석·3D 모델 구현
- 본문 UTF-8 판독, 단면 구조선 각 114개 및 공동구 원본 윤곽 재추출.
- 단면 STA 299.000/319.000과 종단 경계 299.010/318.990의 각 끝 10mm 차이 기록. 평면 중심선·중심 EL은 확인 필요.
- 사용자의 “검토용 직선 연결 허용”에 따라 19.980m 직선 연결. 원본 단면 좌표와 STA는 보존.
- 154개 윤곽 객체, 연결 삼각면 468개, 선택·숨김·뷰 전환·기본/렌더링·원본 근거·OBJ 내보내기 구현.
- 원본 좌표 검산, 브라우저 검사 30개, 좁은 화면 가로 넘침 검사, 빌드와 정적 검사 통과.
- 분석·데이터·뷰어·내보내기·허용된 연결 및 문서를 작업 단위별 로컬 커밋으로 저장. 푸시·공개 없음.
- 미검증·생략·가정은 docs/도면분석.md와 docs/검증보고.md에 기록.