BLOCK8 중심선과 단면 근거로 3D 모델 생성

This commit is contained in:
김지영 committed 2026-10-20 12:39:40 +09:00
1 parent e7d025075e
commit 9d11b4ed62
13 files changed
+18693 -269

No files matched your search

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 537 KiB

-41
View File
@@ -1,41 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 650" role="img" aria-labelledby="title desc">
<title id="title">광터지하차도 BLOCK8 기준 모델 도식</title>
<desc id="desc">STA. 0K+319.010부터 STA. 0K+338.990까지 19.980미터 기준 구간을 입체 기준선으로 표시. 구조물 단면 형상은 확인되지 않았음.</desc>
<defs>
<linearGradient id="bg" x2="0" y2="1"><stop stop-color="#f7faf6"/><stop offset="1" stop-color="#e9f0e9"/></linearGradient>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto-start-reverse"><path d="M0 0 10 5 0 10Z" fill="#788f7c"/></marker>
<filter id="shadow" x="-30%" y="-100%" width="160%" height="300%"><feGaussianBlur stdDeviation="8"/></filter>
</defs>
<rect width="1200" height="650" rx="24" fill="url(#bg)"/>
<text x="72" y="76" fill="#728579" font-family="sans-serif" font-size="16" font-weight="700" letter-spacing="2">광터지하차도 · BLOCK8</text>
<text x="72" y="126" fill="#263a32" font-family="sans-serif" font-size="34" font-weight="700">확인된 종단 기준 구간</text>
<rect x="914" y="54" width="214" height="42" rx="21" fill="#faf3e5" stroke="#e5d8bd"/>
<circle cx="938" cy="75" r="5" fill="#b58d4c"/>
<text x="954" y="80" fill="#927443" font-family="sans-serif" font-size="14">단면 형상 확인 필요</text>
<g stroke="#d8e2d9" stroke-width="1">
<path d="M130 430 890 430 1070 520 310 520Z" fill="#eef3ed"/>
<path d="M310 520V542M500 520V542M690 520V542M880 520V542M1070 520V542"/>
<path d="M130 430V452M320 452V474M510 475V497M700 497V520"/>
<path d="M130 430 310 520M320 430 500 520M510 430 690 520M700 430 880 520M890 430 1070 520"/>
</g>
<path d="M220 348 850 348" stroke="#b7c7b8" stroke-width="28" stroke-linecap="round" opacity=".55" filter="url(#shadow)"/>
<path d="M220 335 850 335" stroke="#547d62" stroke-width="9" stroke-linecap="round"/>
<path d="M220 302V369M850 302V369" stroke="#829982" stroke-width="3"/>
<circle cx="220" cy="335" r="13" fill="#fafcf8" stroke="#52785d" stroke-width="6"/>
<circle cx="850" cy="335" r="13" fill="#fafcf8" stroke="#52785d" stroke-width="6"/>
<path d="M238 260H832" stroke="#788f7c" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<path d="M220 270V300M850 270V300" stroke="#a8b8aa" stroke-width="2"/>
<rect x="482" y="224" width="108" height="50" rx="25" fill="#fff" stroke="#dbe4d7"/>
<text x="536" y="255" text-anchor="middle" fill="#49694f" font-family="sans-serif" font-size="20" font-weight="700">19.980 m</text>
<text x="220" y="402" text-anchor="middle" fill="#566e5c" font-family="sans-serif" font-size="17" font-weight="700">STA. 0K+319.010</text>
<text x="850" y="402" text-anchor="middle" fill="#566e5c" font-family="sans-serif" font-size="17" font-weight="700">STA. 0K+338.990</text>
<g transform="translate(86 520)" font-family="sans-serif" font-size="13" fill="#788a7d" stroke="#849987" stroke-width="2">
<path d="M0 0H70"/><path d="M0 0V-44"/><path d="M0 0 37 22"/>
<text x="78" y="5" stroke="none">종단 구간</text><text x="-14" y="-52" stroke="none">EL 미연결</text><text x="43" y="42" stroke="none">평면 배치 미확정</text>
</g>
<rect x="72" y="580" width="1056" height="42" rx="8" fill="#f8f3e8" stroke="#e6dcc7"/>
<text x="92" y="606" fill="#8d784e" font-family="sans-serif" font-size="14">이 도식은 측점 범위만 나타냅니다. 구조물 폭·높이·단면 윤곽은 근거 확인 전이라 모델에 포함하지 않았습니다.</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

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

After

Width:  |  Height:  |  Size: 537 KiB

+64 -35
View File
@@ -1,48 +1,77 @@
# BLOCK8 도면 분석 기록
# 광터지하차도 BLOCK8 도면 분석 및 모델링 기록
## 사용 자료
## 사용 도면과 연결 근거
| 자료 | 파일 | 확인 범위 |
|---|---|---|
| 전체 일반도 DXF | `public/drawings/광터지하차도_BLOCK8.dxf` | 평면·상부평면·종단·단면과 치수/구조물 레이어 |
| 설계도 원본 DWG 묶음 | `C0050201~C0059901` | 사용자 제공 폴더에 존재 확인. 이 환경에서 직접 열람/대조는 미완료 |
| 도면 | 자료 위치 | 읽은 내용 | 상태 |
|---|---|---|---|
| 평면도 | `public/drawings/광터지하차도_BLOCK8.dxf`, `CS-CNTL` 원호와 `CS-CONC-MAJR` 끝 구조선 | BLOCK8 표기 평면 중심선 2개, 반경 1,700m 원호, 원호와 끝 구조선의 교점 | 직접 좌표·레이어 대조 |
| 상부 평면 및 종단 검토 영역 | 같은 DXF, BLOCK8 주변 측점·EL 주석 | 모델 시종점 측점 319.010/338.990, 길이 19.980m | 측점은 도면값, 길이는 교점 간 계산값 |
| 단면 A-A | 같은 DXF, `STA. 0K+319.000` 주석이 있는 단면 영역 | 단면 구조선 114개, EL 표기와 폭 분할 | 직접 추출 및 대조 |
| 단면 B-B | 같은 DXF, `STA. 0K+339.000` 주석이 있는 단면 영역 | 단면 구조선 114개, 종점 EL·폭 분할 | 직접 추출 및 대조 |
| 블록 내부 콘크리트 윤곽 | `A$C2017021411375356` 블록, 단면별 INSERT 4곳 | 내부 `CS-CONC-MAJR` 18선과 원호 2개를 확인해 좌우 삽입 형상으로 처리 | 블록 기준점·삽입 위치·좌우 반전 반영 |
| 측구 상세 | 전체 일반도 DXF의 별도 상세 영역 | BLOCK8 본체 단면과 측구 세부의 연결 표기를 확인하지 못함 | 별도 모델 반영 보류 |
| 제원·특기사항 및 구조도 | 사용자 제공 폴더의 DWG | 파일 존재 확인. DWG 직접 렌더링/교차검토는 이 실행 환경에서 수행하지 못함 | 후속 검증 필요 |
DXF 내부 표제 및 주석에서 `평면도`, `상부평면도`, `종단면도`, `(BLOCK8)`, `U-Type 구간`을 확인했다. 입력 도면은 여러 구간/도면을 포함하므로 BLOCK8 경계 표기로 자료를 선별했다.
두 단면은 DXF의 동일 구조 윤곽 영역을 측점 주석(`319.000`, `339.000`)과 연결했다. 각 단면의 `CS-CNTL` 중심 표시 교차점 EL은 각각 102.094m와 101.517m이며, 같은 단면 영역의 EL 주석과 일치한다. 벽체 상단 EL 107.215m/107.857m도 종단 검토 영역에 반복 표기되어 각 단면과 맞는다. 각 측점 단면의 폭 분할은 양쪽 단부벽·중앙벽·두 통로 폭으로 이어지며, 평면의 두 `BLOCK8` 중심선과 함께 모델의 두 배치 객체로 구성했다. 두 평면 배치에 대표 단면을 공통 적용한 관계는 구조도 대조가 끝나지 않아 `확인 필요`로 표시한다.
## 도면 연결 및 기준값
## 축척 및 좌표 단위 확인
| 항목 | 읽은 값 | 근거/위치 | 상태 |
- 평면도 영역의 축척 표기: `S=1:100`.
- 단면 영역의 축척 표기: `S=1:100`.
- 종단 검토 영역의 축척 표기: `S=1:100`.
- 상세 영역에는 `S=1:30`, `S=1:20`, `S=1:3500` 등 다른 표기도 있으므로 각 상세별 표기를 따로 적용해야 한다.
- DXF HEADER는 `$INSUNITS=0`으로 단위를 지정하지 않았다. 다만 단면 구조선의 좌표 차 20,300이 표기 치수 20.300m와 맞고, 중심선 원호를 구조물 끝선에 교차시켜 계산한 길이가 19.980m로 측점 차와 일치한다. 이 교차 검산에 따라 모델 생성에 한해 좌표를 mm로 보고 m로 환산했다. 단위 판정은 계산 근거이며 HEADER 명시값은 아니다.
## 모델링 값과 근거
| 대상/값 | 모델 값 | 근거 위치 | 상태 |
|---|---:|---|---|
| BLOCK8 시점 측점 | STA. 0K+319.010 | 일반도 DXF, BLOCK8 경계 주석 | 도면 명시값 |
| BLOCK8 종점 측점 | STA. 0K+338.990 | 일반도 DXF, BLOCK8 경계 주석 | 도면 명시값 |
| 측점 차이 | 19.980 m | 위 두 측점 차 계산 | 계산으로 도출한 값 |
| 인접 표기 측점 | STA. 0K+319.000, 0K+339.000 | 평면/구조 배치 주변 주석 | 도면 명시값; 경계 측점과 각각 0.010m 차이 |
| 단면 기준 EL 후보(시점) | 101.777~107.215 | BLOCK8 시점 쪽 단면 주변 EL 주석 | 확인 필요: 각 표고가 바닥/벽체 상단인지 구조물선과 대조 필요 |
| 단면 기준 EL 후보(종점) | 101.292~107.857 | BLOCK8 종점 쪽 단면 주변 EL 주석 | 확인 필요: 각 표고가 바닥/벽체 상단인지 구조물선과 대조 필요 |
| 상부/평면 치수 후보 | 19.5, 20.099, 20.3, 8.75, 1.0 등 | BLOCK8 표기 주변의 치수 문자열 | 확인 필요: 중복 뷰/치수선과 구조물 외곽 연결 전 모델값으로 확정하지 않음 |
| 도면 단위 설정 | `$INSUNITS=0` | DXF HEADER | 확인 필요: 미지정. 치수 표기와 좌표 변환으로 단위 검증 필요 |
| 모델 시점 | STA. 0K+319.010 | 평면도 BLOCK8 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 모델 종점 | STA. 0K+338.990 | 반대쪽 구조 끝단과 중심선 교점 부근 주석 | 도면 명시값 |
| 중심선 반경 | 1,700m | 평면도 `CS-CNTL` ARC 반경 1,700,000 CAD 단위 | 좌표 단위 검산 후 환산값 |
| 평면 중심선 길이 | 19.980m, 각 배치 | ARC와 `CS-CONC-MAJR` 양 끝선 교점 사이 원호 길이. 교점 각도 약 269.663303°~270.336697° | 계산으로 도출한 값; 측점 차와 일치 |
| 두 평면 중심선 간 CAD 배치 차이 | 35.000m | 두 원호 중심 X 좌표 차 35,000 CAD 단위 | 계산으로 도출; 실제 독립 구조물 관계는 확인 필요 |
| 단면 A-A 측점 | STA. 0K+319.000 | 단면 A 표기 | 도면 명시값 |
| 단면 A-A 벽체 상단 EL | 107.215 | 상단 EL 주석 및 상단 구조선 | 도면 명시값 |
| 단면 A-A 기초 하단 EL | 100.877 | 하단 EL 주석 및 기초 구조선 | 도면 명시값 |
| 단면 A-A 기초 상단 EL | 101.777 | EL100.877 + 구조선상 기초 두께 0.900m | 계산으로 도출한 값 |
| 단면 A-A 중심 기준 EL | 102.094 | `CS-CNTL` 중심 표시 교점과 단면 EL 주석 | 좌표 환산 계산, 표기값과 일치 |
| 단면 B-B 측점 | STA. 0K+339.000 | 단면 B 표기 | 도면 명시값 |
| 단면 B-B 벽체 상단 EL | 107.857 | 상단 EL 주석 및 상단 구조선 | 도면 명시값 |
| 단면 B-B 기초 하단 EL | 100.392 | 하단 EL 주석 및 기초 구조선 | 도면 명시값 |
| 단면 B-B 기초 상단 EL | 101.292 | EL100.392 + 구조선상 기초 두께 0.900m | 계산으로 도출한 값 |
| 단면 B-B 중심 기준 EL | 101.517 | `CS-CNTL` 중심 표시 교점과 단면 EL 주석 | 좌표 환산 계산, 표기값과 일치 |
| 단면 주 통로 폭 분할 | 0.900 + 8.750 + 1.000 + 8.750 + 0.900 = 20.300m | 단면 치수 표기와 `CS-CONC-MAJR/MINR` 좌표 간격 | 도면 명시값 및 좌표 대조 |
| 구조선 최대 외곽 폭 | 20.500m | 단면 구조선 X 좌표 최댓값-최솟값 | 축척·좌표 측정값; 표기 폭과 별도 관리 |
| 구조선 기초 두께 | 0.900m | 단면 상·하부 구조선 좌표 차 | 계산으로 도출한 값 |
| 원시 구조선 수/단면 | MAJR 108 + MINR 6 = 114 | 각 단면의 두 구조 레이어 | 추출 개수 |
| 블록 내부 구조선 | MAJR 18선 + 원호 2개; 단면당 좌우 INSERT 2개 | `A$C2017021411375356` 블록과 삽입 참조 | 내부 레이어·반전 변환 적용 |
| 블록 연결 길이 | 19.980m | 단면 A/B 내부 블록 대응을 따라 선형 보간 | 계산으로 도출 |
## 레이어 및 형상 해석
모델은 두 단면의 구조선을 대응시켜 각 구조선을 길이 방향 표면으로 연결했다. 평면 원호는 양 끝 구조선과의 교점에 맞춰 길이 19.980m로 잘랐다. 단면 도면은 STA 319.000/339.000에 있고 모델 경계는 각각 0.010m 안쪽의 STA 319.010/338.990이므로, 두 끝에서 0.010m씩 단면 형상을 선형 보간해 경계에 맞췄다.
- DXF 모델 공간에는 `CS-CONC-MAJR` 510개, `CS-CONC-MINR` 57개 엔티티가 있다. `CS-DIMT`/`CS-DIML`은 치수, `CS-TEXT`/`CS-NOTE`는 문자·주석으로 구분했다.
- 구조 윤곽 추출은 `CS-CONC-MAJR`, `CS-CONC-MINR`만 대상으로 해야 한다. 단순히 도면 전체 선을 모델링하면 치수선과 보조선이 섞인다.
- 주요 형상 중 일부가 INSERT 블록으로 저장되어 있어 블록 내부 변환(삽입점·축척·회전)을 풀어 원 좌표로 검증해야 한다. 현재 레이어별 원시 개수는 확인했으나, 모든 블록 내부 윤곽의 완전 추출은 아직 끝나지 않았다.
- BLOCK8 표기와 측점에 인접한 CAD 좌표 구역에서는 `CS-CONC-MAJR` 직선 6개를 확인했으며, 그중 2개는 EL 주석 부근의 수직선이다. 같은 2D 창에서 `CS-CONC-MINR` 구조선은 확인되지 않았다. 따라서 이 구역만으로 단면 윤곽 전체를 구성할 근거가 부족하다.
- 평면 중심선과 종단면, 시점·종점 단면, 측구 상세의 동일 부재 대응은 원본 DWG/블록 도형과의 위치 검토가 더 필요하다.
## 오류 및 확인 필요 사항
## 축척
1. 단면 외곽 구조선 폭은 20.500m인데 표기된 주 통로/본체 폭은 20.300m다. 좌우 0.100m씩 추가되는 외곽 선의 용도는 구조도 DWG와 확인해야 한다. 모델에는 두 값을 임의로 하나로 합치지 않고 원 구조선을 그대로 보존했다.
2. 구조선 최저 외곽점은 표기된 기초 하단 EL보다 약 0.100m 낮게 계산된다(A: 100.777m, B: 100.292m). 기초 상세 또는 버림층 여부가 확인될 때까지 외곽 구조선 그대로 표시하고 차이를 남긴다.
3. 평면도에서 동일 `(BLOCK8)` 표기가 있는 중심선 2개를 찾았고 중심 간 도면 좌표 차는 35.000m다. 두 중심선이 독립 구조물인지, 도면상 중복/배치 복제인지 구조도와 종평면도로 확인해야 한다. 웹 뷰어에서 서로 독립적으로 숨김 처리할 수 있게 했다.
4. 종단면의 상단 벽체 EL과 두 단면 상단 EL은 현재 일반도 DXF의 구조선/EL 위치로 읽었으나, 제원 DWG와 별도 구조도에서 재확인하지 못했다.
5. 측구 상세도 기호와 본체 구조선의 실제 접합 위치, 부재 두께/재료, 닫힌 체적 경계는 미검증이다.
DXF HEADER의 `$INSUNITS=0`으로 파일 단위는 지정되지 않았다. 도면 축척 문구를 모든 뷰에서 확인하고, 표기 치수(예: 19.980m)와 CAD 좌표 차이를 비교해 좌표 단위를 확정해야 한다. 이 확인 전에는 화면상 축척 측정값을 도면 명시 치수로 취급하지 않는다.
## 모델 구성과 화면 기능
## 오류·누락·확인 항목
- 생성 데이터: `src/model/block8.json`. 각 부재에 A/B 단면점, 원본 레이어와 HANDLE을 기록한다.
- 추출 스크립트: `scripts/extract_block8_model.py`. 표준 Python만으로 DXF 단면 선, 원호, INSERT 내부 레이어를 재추출한다.
- 화면: `src/ModelViewer.jsx`와 `src/App.jsx`. 끌어서 회전, 휠 확대/축소, Shift/오른쪽 끌기로 이동, 전체·평면·정면·측면·A-A·B-B 시점을 제공한다.
- 객체는 2개 평면 중심선에 각각 154개씩 구성했다(단면 구조선 114개와 삽입 블록 윤곽 40개). 원본선 단위 선택·숨김과 MAJR/MINR/블록 윤곽 그룹 숨김을 지원한다.
- 화면의 면은 검토용 열린 표면 모델이다. 구조선 사이를 연결했으며 모든 두께/접합을 확정한 닫힌 체적이나 실사 렌더링은 아니다.
- 실행: `npm run dev`. 분석 파일과 작업 로그는 화면 상단 링크에서 연다.
1. 319.000/339.000 표기와 BLOCK8 경계 319.010/338.990 사이에 각각 0.010m 차이가 있다. 어떤 경계를 모델 끝으로 쓸지 구조 이음/상세에서 확인한다.
2. 종단/단면 EL 후보를 벽체 상단 및 중심 기준 EL로 확정하려면 각 주석의 지시선과 CS-CONC 구조선을 연결해야 한다.
3. 단면 전체 윤곽, 두께, 측구/공동구 상세 치수 및 부재 개수는 INSERT 내부 요소까지 추출해 확인해야 한다.
4. 원본 DWG의 제원·특기사항 및 구조도와 교차 대조가 미완료다.
## 검증 결과
따라서 현재 확정 가능한 모델 기준은 BLOCK8 경계 측점과 19.980m 길이뿐이다. 미확정 단면 윤곽과 높이는 임의로 만들어 넣지 않는다.
## 모델 생성 진행 상태
현재는 도면 원본 보존과 1차 분석까지 완료했다. 실제 3D 형상 생성은 보류 상태다. 이유는 BLOCK8 단면의 구조물선 전체와 A-A/B-B 대응을 확인하지 못했고, DXF 단위도 미지정이기 때문이다. 해당 구간이 포함된 구조도/단면 원본을 CAD에서 BLOCK8만 분리해 DXF로 내보내거나, 읽을 수 있는 PDF로 제공하면 치수·레이어·EL 연결을 재확인한 뒤 모델링을 이어갈 수 있다.
- 추출 스크립트 실행 결과: 중심선 2개, 각 길이 19.980m, 단면 구조선 MAJR 108개와 MINR 6개, 내부 블록 객체를 양쪽 단면에서 대응했다.
- `npm run build`: 통과.
- `npm run lint`: 미사용 함수 경고를 제거한 뒤 최종 재실행 예정.
- `npm run lint`: 통과, 경고 없음.
- `git diff --check`: 통과.
- 미검증: 원본 DWG에서 제원·특기사항, 구조 상세, 2개 중심선의 공간상 관계 및 측구 접합부를 CAD/PDF로 교차 대조하지 못했다.
+9
View File
@@ -22,3 +22,12 @@
- 단면 형상이 미확정임을 웹 화면에 표시
- 분석 파일과 작업 로그를 웹 화면에서 열 수 있도록 연결
- 초기화 이후 커밋 이력만 이 파일에 정리
5. 작성지침 반영 재분석 및 3D 모델링
- CS-CNTL 원호와 CS-CONC-MAJR 끝 구조선의 교점으로 중심선 길이 19.980m를 재계산
- 단면 A/B 구조선 114개씩( MAJR 108, MINR 6 ) 추출
- A$C2017021411375356 블록 내부 콘크리트 레이어 18선과 원호 2개를 좌우 단면에 반영
- 중심 기준 EL 102.094 → 101.517, 벽체 상단 EL 107.215 → 107.857, 폭 분할 20.300m 대조
- 두 평면 중심선에 각각 3D 연결 표면 모델 생성, 부재 선택/숨김 및 보기 방향 제공
- 구조도와 대조가 필요한 배치 관계·외곽 폭 차이·기초 최저선 차이는 검토 항목으로 유지
- 모델 미리보기 SVG와 갱신된 분석 문서 추가
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""BLOCK8 단면 구조선과 블록 내부 콘크리트선을 모델 데이터로 변환한다."""
import json
import math
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "public/drawings/광터지하차도_BLOCK8.dxf"
OUTPUT = ROOT / "src/model/block8.json"
def read_pairs(path):
lines = path.read_text(encoding="utf-8-sig").splitlines()
if len(lines) % 2:
raise ValueError("DXF 그룹 코드 줄 수가 짝수가 아닙니다.")
return [(int(lines[i].strip()), lines[i + 1].strip()) for i in range(0, len(lines), 2)]
def records(pairs, start, end):
result = []
current = None
for code, value in pairs[start:end]:
if code == 0:
if current:
result.append(current)
current = {"type": value.strip(), "data": []}
elif current:
current["data"].append((code, value.strip()))
if current:
result.append(current)
return result
def parse_sections(pairs, section_name):
begin = next(i for i, (code, value) in enumerate(pairs) if code == 0 and value.strip() == "SECTION" and pairs[i + 1][0] == 2 and pairs[i + 1][1].strip() == section_name)
end = next(i for i in range(begin + 2, len(pairs)) if pairs[i][0] == 0 and pairs[i][1].strip() == "ENDSEC")
return begin, end
def parse_blocks(pairs):
begin, end = parse_sections(pairs, "BLOCKS")
blocks = {}
i = begin + 2
while i < end:
if pairs[i][0] != 0 or pairs[i][1].strip() != "BLOCK":
i += 1
continue
block_start = i
j = i + 1
while j < end and not (pairs[j][0] == 0 and pairs[j][1].strip() == "ENDBLK"):
j += 1
header_end = next((k for k in range(block_start + 1, j) if pairs[k][0] == 0), j)
header = pairs[block_start + 1:header_end]
header_values = [value.strip() for code, value in header]
name_pos = next(k for k, (code, value) in enumerate(header) if code == 100 and value.strip() == "AcDbBlockBegin")
name = next(value.strip() for code, value in header[name_pos + 1:] if code == 2)
base = []
for code, value in header[name_pos + 1:]:
if code in (10, 20, 30):
base.append(float(value))
if len(base) == 3:
break
base += [0.0] * (3 - len(base))
blocks[name] = {"base": base, "entities": records(pairs, header_end, j)}
i = j + 1
return blocks
def parse_modelspace(pairs):
begin, end = parse_sections(pairs, "ENTITIES")
return records(pairs, begin + 2, end)
def props(entity):
result = defaultdict(list)
for code, value in entity["data"]:
result[code].append(value)
return result
def first(p, code, default=None):
return p[code][0] if code in p else default
def layer(entity):
return first(props(entity), 8, "0")
def line_points(entity):
p = props(entity)
return [(float(first(p, 10)), float(first(p, 20))), (float(first(p, 11)), float(first(p, 21)))]
def normalized_segment(entity, center_x, y0, el0):
points = line_points(entity)
points.sort(key=lambda p: (round(p[0], 6), p[1]))
profile = [[(x - center_x) / 1000, el0 + (y - y0) / 1000] for x, y in points]
key = (layer(entity), round((points[0][0] - center_x) / 1000, 3), round((points[1][0] - center_x) / 1000, 3))
average = sum(y for x, y in profile) / 2
slope = profile[1][1] - profile[0][1]
return key, (average, slope), profile
def cross_section(entities, y_bounds, center_x, y0, el0):
grouped = defaultdict(list)
for entity in entities:
if entity["type"] != "LINE" or layer(entity) not in ("CS-CONC-MAJR", "CS-CONC-MINR"):
continue
points = line_points(entity)
if not all(y_bounds[0] <= y <= y_bounds[1] for x, y in points):
continue
if not all(1_280_000 <= x <= 1_310_000 for x, y in points):
continue
key, sort_key, profile = normalized_segment(entity, center_x, y0, el0)
grouped[key].append({"profile": profile, "sort": sort_key, "handle": first(props(entity), 5, "")})
return grouped
def arc_points(entity, steps=16):
p = props(entity)
cx, cy = float(first(p, 10)), float(first(p, 20))
radius = float(first(p, 40))
start = math.radians(float(first(p, 50)))
end = math.radians(float(first(p, 51)))
while end < start:
end += 2 * math.pi
return [(cx + radius * math.cos(start + (end - start) * i / steps), cy + radius * math.sin(start + (end - start) * i / steps)) for i in range(steps + 1)]
def block_entity_points(entity):
p = props(entity)
if entity["type"] == "LINE":
return [(float(first(p, 10)), float(first(p, 20))), (float(first(p, 11)), float(first(p, 21)))]
if entity["type"] == "ARC":
return arc_points(entity, 16)
return []
def transform_block(points, insert, base):
p = props(insert)
ix, iy = float(first(p, 10)), float(first(p, 20))
sx, sy = float(first(p, 41, "1")), float(first(p, 42, "1"))
angle = math.radians(float(first(p, 50, "0")))
result = []
for x, y in points:
x, y = (x - base[0]) * sx, (y - base[1]) * sy
result.append((ix + x * math.cos(angle) - y * math.sin(angle), iy + x * math.sin(angle) + y * math.cos(angle)))
return result
def intersect_line_arc(line, arc):
(x0, y0), (x1, y1) = line
p = props(arc)
cx, cy, radius = float(first(p, 10)), float(first(p, 20)), float(first(p, 40))
dx, dy = x1 - x0, y1 - y0
fx, fy = x0 - cx, y0 - cy
aa = dx * dx + dy * dy
bb = 2 * (fx * dx + fy * dy)
cc = fx * fx + fy * fy - radius * radius
disc = bb * bb - 4 * aa * cc
if disc < 0:
return []
roots = [(-bb - math.sqrt(disc)) / (2 * aa), (-bb + math.sqrt(disc)) / (2 * aa)]
start, end = float(first(p, 50)), float(first(p, 51))
points = []
for t in roots:
if not 0 <= t <= 1:
continue
x, y = x0 + t * dx, y0 + t * dy
theta = math.degrees(math.atan2(y - cy, x - cx)) % 360
if start - 0.01 <= theta <= end + 0.01:
points.append((theta, x, y))
return points
def alignment_units(entities):
arcs = []
for entity in entities:
p = props(entity)
if entity["type"] == "ARC" and first(p, 8) == "CS-CNTL" and abs(float(first(p, 40, "0")) - 1_700_000) < 1:
arcs.append(entity)
cross_lines = []
for entity in entities:
if entity["type"] != "LINE" or layer(entity) != "CS-CONC-MAJR":
continue
(x0, y0), (x1, y1) = line_points(entity)
if -90_000 < y0 < -60_000 and -90_000 < y1 < -60_000 and abs(y1 - y0) > 19_000 and abs(y1 - y0) < 21_000 and abs(x1 - x0) < 200:
cross_lines.append(entity)
if len(arcs) != 2:
raise ValueError(f"평면 중심선 원호 수가 예상과 다릅니다: {len(arcs)}")
center_x_origin = sum(float(first(props(arc), 10)) for arc in arcs) / len(arcs)
center_y_origin = float(first(props(arcs[0]), 20)) - float(first(props(arcs[0]), 40))
units = []
for index, arc in enumerate(sorted(arcs, key=lambda e: float(first(props(e), 10)))):
acx = float(first(props(arc), 10))
candidates = [line for line in cross_lines if abs(sum(x for x, y in line_points(line)) / 2 - acx) < 12_000]
intersections = []
boundary_handles = []
for line in candidates:
found = intersect_line_arc(line_points(line), arc)
if found:
intersections.extend(found)
boundary_handles.append(first(props(line), 5, ""))
intersections.sort()
if len(intersections) != 2:
raise ValueError(f"평면 중심선 {index + 1}의 구조선 교점이 2개가 아닙니다: {len(intersections)}")
theta0, theta1 = intersections[0][0], intersections[1][0]
radius_m = float(first(props(arc), 40)) / 1000
length = radius_m * math.radians(theta1 - theta0)
units.append({
"id": f"alignment-{index + 1}",
"name": f"BLOCK8 평면 중심선 {index + 1}",
"center": [(acx - center_x_origin) / 1000, (float(first(props(arc), 20)) - center_y_origin) / 1000],
"planOrigin": [center_x_origin, center_y_origin],
"radius": radius_m,
"startAngle": theta0,
"endAngle": theta1,
"length": length,
"centerlineHandle": first(props(arc), 5, ""),
"boundaryHandles": boundary_handles,
})
return units
def add_block_parts(parts, entities, blocks, center_x, sections):
block_name = "A$C2017021411375356"
block = blocks[block_name]
concrete_entities = [e for e in block["entities"] if layer(e) in ("CS-CONC-MAJR", "CS-CONC-MINR") and e["type"] in ("LINE", "ARC")]
inserts = []
for entity in entities:
if entity["type"] != "INSERT" or first(props(entity), 2) != block_name:
continue
p = props(entity)
x, y = float(first(p, 10)), float(first(p, 20))
if 1_280_000 < x < 1_310_000 and any(abs(y - sy) < 100 for sy in (-76_987.31555584459, -96_306.66955584464)):
inserts.append(entity)
# 각 단면에서 좌우 삽입점 한 개씩 확인한다.
section_inserts = []
for section in sections:
chosen = [e for e in inserts if abs(float(first(props(e), 20)) - section["insertY"]) < 1]
chosen.sort(key=lambda e: float(first(props(e), 10)))
if len(chosen) != 2:
raise ValueError(f"블록 내부 콘크리트 삽입점 수가 예상과 다릅니다: {len(chosen)}")
section_inserts.append(chosen)
result = []
for side_idx, side_name in enumerate(("left", "right")):
start_insert, end_insert = section_inserts[0][side_idx], section_inserts[1][side_idx]
for entity_idx, block_entity in enumerate(concrete_entities):
start_raw = transform_block(block_entity_points(block_entity), start_insert, block["base"])
end_raw = transform_block(block_entity_points(block_entity), end_insert, block["base"])
start_profile = [[(x - center_x) / 1000, sections[0]["elTop"] + (y - sections[0]["yTop"]) / 1000] for x, y in start_raw]
end_profile = [[(x - center_x) / 1000, sections[1]["elTop"] + (y - sections[1]["yTop"]) / 1000] for x, y in end_raw]
result.append({
"id": f"insert-{side_name}-{entity_idx + 1:02d}",
"name": f"블록 내부 윤곽 {side_name} {entity_idx + 1:02d}",
"layer": layer(block_entity),
"group": "insert",
"sourceHandle": first(props(block_entity), 5, ""),
"sourceInsertHandles": [first(props(start_insert), 5, ""), first(props(end_insert), 5, "")],
"entityType": block_entity["type"],
"a": start_profile,
"b": end_profile,
})
return result
def main():
pairs = read_pairs(SOURCE)
blocks = parse_blocks(pairs)
entities = parse_modelspace(pairs)
center_x = 1_296_487.109248054
section_defs = [
{"id": "A", "station": 319.000, "yBounds": (-80_000, -70_000), "y0": -78_059.37855593952, "el0": 101.777, "yTop": -72_621.37855593952, "elTop": 107.215, "centerY": -77_742.31539793951, "insertY": -76_987.31555584459},
{"id": "B", "station": 339.000, "yBounds": (-99_000, -89_000), "y0": -97_286.37855593952, "el0": 101.292, "yTop": -90_721.37855593952, "elTop": 107.857, "centerY": -97_061.6690599395, "insertY": -96_306.66955584464},
]
grouped_a = cross_section(entities, section_defs[0]["yBounds"], center_x, section_defs[0]["y0"], section_defs[0]["el0"])
grouped_b = cross_section(entities, section_defs[1]["yBounds"], center_x, section_defs[1]["y0"], section_defs[1]["el0"])
if set(grouped_a) != set(grouped_b):
raise ValueError("A-A/B-B 구조선의 X 범위/레이어 구성이 일치하지 않습니다.")
parts = []
line_no = defaultdict(int)
for key in sorted(grouped_a):
a_list = sorted(grouped_a[key], key=lambda item: (item["sort"][0], item["sort"][1]))
b_list = sorted(grouped_b[key], key=lambda item: (item["sort"][0], item["sort"][1]))
if len(a_list) != len(b_list):
raise ValueError(f"단면 대응 선 개수가 다릅니다: {key}")
for a_item, b_item in zip(a_list, b_list):
source_layer = key[0]
group = "major" if source_layer == "CS-CONC-MAJR" else "minor"
line_no[group] += 1
parts.append({
"id": f"{group}-{line_no[group]:03d}",
"name": f"{source_layer} 구조선 {line_no[group]:03d}",
"layer": source_layer,
"group": group,
"sourceHandles": [a_item["handle"], b_item["handle"]],
"entityType": "LINE",
"a": a_item["profile"],
"b": b_item["profile"],
})
if line_no != {"major": 108, "minor": 6}:
raise ValueError(f"구조선 수 확인 실패: {dict(line_no)}")
parts += add_block_parts(parts, entities, blocks, center_x, section_defs)
units = alignment_units(entities)
for unit in units:
unit["parts"] = [dict(part, id=f"{unit['id']}-{part['id']}") for part in parts]
data = {
"source": "광터지하차도_BLOCK8.dxf",
"unit": "m",
"coordinateNote": "CAD 좌표 차이를 치수 문자열로 검산해 mm로 해석. 표고는 종단/단면 EL 주석에 연결.",
"sections": [
{"id": "A-A", "station": 319.000, "centerEL": section_defs[0]["elTop"] + (section_defs[0]["centerY"] - section_defs[0]["yTop"]) / 1000, "topEL": 107.215, "baseTopEL": 101.777, "baseBottomEL": 100.877, "sourceHandles": ["STA.0K+319.000", "CS-CNTL center mark", "EL.102.094", "EL.107.215", "EL.100.877"]},
{"id": "B-B", "station": 339.000, "centerEL": section_defs[1]["elTop"] + (section_defs[1]["centerY"] - section_defs[1]["yTop"]) / 1000, "topEL": 107.857, "baseTopEL": 101.292, "baseBottomEL": 100.392, "sourceHandles": ["STA.0K+339.000", "CS-CNTL center mark", "EL.101.517", "EL.107.857", "EL.100.392"]},
],
"sectionStationRange": [319.000, 339.000],
"modelStationRange": [319.010, 338.990],
"modelLength": 19.980,
"sectionTrim": 0.010,
"sectionLineCount": {"CS-CONC-MAJR": 108, "CS-CONC-MINR": 6},
"expandedBlock": {"name": "A$C2017021411375356", "layerCounts": {"CS-CONC-MAJR": 18, "CS-CONC-MINR": 0}, "curveCount": 2, "insertCount": 4},
"profileWidth": {"dimensionText": 20.300, "outerLineEnvelope": 20.500, "status": "dimension value and outer contour differ; retain separately"},
"units": units,
}
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"units": len(units), "lengths": [round(u["length"], 6) for u in units], "partsPerUnit": len(parts), "layers": dict(line_no), "expandedBlockPartsPerSide": 20}, ensure_ascii=False))
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Write a dependency-free SVG preview from src/model/block8.json."""
import json
import math
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODEL = ROOT / "src/model/block8.json"
OUTPUT = ROOT / "docs/BLOCK8_3D모델_미리보기.svg"
COLORS = {"major": "#78917c", "minor": "#d0aa69", "insert": "#9a9074"}
def point_at(unit, profile_a, profile_b, index, t):
theta = math.radians(unit["startAngle"] + (unit["endAngle"] - unit["startAngle"]) * t)
cx = unit["center"][0] + unit["radius"] * math.cos(theta)
cz = unit["center"][1] + unit["radius"] * math.sin(theta)
normal_x, normal_z = -math.cos(theta), -math.sin(theta)
station_t = (0.010 + t * 19.980) / 20
u = profile_a[index][0] + (profile_b[index][0] - profile_a[index][0]) * station_t
el = profile_a[index][1] + (profile_b[index][1] - profile_a[index][1]) * station_t
return [cx + u * normal_x, el, cz + u * normal_z]
def project(point):
x, y, z = point
yaw, pitch, scale = -0.66, 0.43, 14.0
x1 = x * math.cos(yaw) - z * math.sin(yaw)
z1 = x * math.sin(yaw) + z * math.cos(yaw)
y1 = y - 104
return [600 + x1 * scale, 330 - (y1 * math.cos(pitch) - z1 * math.sin(pitch)) * scale, y1 * math.sin(pitch) + z1 * math.cos(pitch)]
def main():
model = json.loads(MODEL.read_text(encoding="utf-8"))
faces = []
for unit in model["units"]:
for part in unit["parts"]:
if len(part["a"]) != len(part["b"]):
continue
for step in range(8):
t0, t1 = step / 8, (step + 1) / 8
for edge in range(len(part["a"]) - 1):
corners = [
point_at(unit, part["a"], part["b"], edge, t0),
point_at(unit, part["a"], part["b"], edge + 1, t0),
point_at(unit, part["a"], part["b"], edge + 1, t1),
point_at(unit, part["a"], part["b"], edge, t1),
]
projected = [project(point) for point in corners]
depth = sum(point[2] for point in projected) / 4
faces.append((depth, part["group"], projected))
faces.sort(key=lambda face: face[0])
svg = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 660" role="img" aria-labelledby="title desc">',
'<title id="title">광터지하차도 BLOCK8 도면 기반 3D 구조 모델</title>',
'<desc id="desc">두 평면 중심선을 따라 시점과 종점 단면 구조선을 연결한 열린 표면 검토 모델. 일부 배치 대응은 확인 필요.</desc>',
'<defs><linearGradient id="bg" x2="0" y2="1"><stop stop-color="#f7faf6"/><stop offset="1" stop-color="#e8eee8"/></linearGradient></defs>',
'<rect width="1200" height="660" rx="22" fill="url(#bg)"/>',
'<text x="62" y="61" fill="#728579" font-family="sans-serif" font-size="14" font-weight="700" letter-spacing="2">광터지하차도 · 도면 기반 검토 모델</text>',
'<text x="62" y="105" fill="#263a32" font-family="sans-serif" font-size="29" font-weight="700">BLOCK8 · 구조선 연결 표면</text>',
'<g fill="none" stroke="#d4dfd6" stroke-width="1">',
]
for x in range(170, 1031, 60):
svg.append(f'<path d="M{x} 165 L{x} 540"/>')
for y in range(180, 541, 36):
svg.append(f'<path d="M145 {y} L1055 {y}"/>')
svg.append('</g>')
for depth, group, points in faces:
coordinates = ' '.join(f'{p[0]:.1f},{p[1]:.1f}' for p in points)
fill = COLORS.get(group, COLORS["major"])
opacity = 0.64 if group != "insert" else 0.46
svg.append(f'<polygon points="{coordinates}" fill="{fill}" fill-opacity="{opacity}" stroke="#53685a" stroke-opacity=".48" stroke-width=".55"/>')
for idx, unit in enumerate(model["units"]):
left = point_at(unit, [[0, 104]], [[0, 104]], 0, 0)
right = point_at(unit, [[0, 104]], [[0, 104]], 0, 1)
p0, p1 = project(left), project(right)
svg.append(f'<g font-family="sans-serif" font-size="12" fill="#55715c"><text x="{p0[0]:.1f}" y="{p0[1]-13:.1f}">중심선 {idx+1} · STA 319.010</text><text x="{p1[0]:.1f}" y="{p1[1]-12:.1f}">STA 338.990</text></g>')
svg.extend([
'<g font-family="sans-serif" font-size="12"><rect x="62" y="570" width="1076" height="54" rx="9" fill="#f8f3e8" stroke="#e5dbc4"/>',
'<text x="82" y="593" fill="#8c774e">길이 19.980m · 평면 중심선 교점으로 계산 · 시점/종점 단면 구조선 114개씩 + 블록 내부 윤곽</text>',
'<text x="82" y="612" fill="#8c774e">열린 표면 모델이며 닫힌 체적은 아닙니다. 두 중심선에 같은 단면을 적용한 대응은 검토가 필요합니다.</text></g>',
'</svg>',
])
OUTPUT.write_text('\n'.join(svg) + '\n', encoding="utf-8")
print(f"{OUTPUT.relative_to(ROOT)}: {len(faces)} polygons")
if __name__ == "__main__":
main()
+132 -65
View File
@@ -1,54 +1,67 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import model from './model/block8.json' with { type: 'json' }
import ModelViewer from './ModelViewer'
const views = {
전체: { transform: 'rotate(-8 450 180) skewY(-2deg)' },
평면: { transform: 'rotate(0 450 180)' },
정면: { transform: 'rotate(-90 450 180)' },
측면: { transform: 'rotate(90 450 180)' },
const categoryLabels = {
major: 'CS-CONC-MAJR 구조선',
minor: 'CS-CONC-MINR 구조선',
insert: '블록 내부 콘크리트 윤곽',
}
const viewNames = ['전체', '평면', '정면', '측면', 'A-A', 'B-B']
function StationModel({ view }) {
return (
<svg className="model-svg" viewBox="0 0 900 360" role="img" aria-labelledby="model-title model-desc">
<title id="model-title">BLOCK8 확인된 종단 기준선</title>
<desc id="model-desc">측점 319.010에서 338.990까지 19.980미터의 기준 구간입니다. 단면 형상은 미확정입니다.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto-start-reverse">
<path d="M 0 0 L 8 4 L 0 8 z" fill="#718b78" />
</marker>
</defs>
<g className="model-grid">
<path d="M90 250 L810 250 M130 280 L850 280 M110 220 L830 220" />
<path d="M180 200 L180 290 M360 200 L360 290 M540 200 L540 290 M720 200 L720 290" />
</g>
<g style={views[view]} className="station-line">
<path className="station-shadow" d="M180 185 L720 185" />
<path className="station-main" d="M180 185 L720 185" />
<path className="station-cap" d="M180 164 L180 207 M720 164 L720 207" />
<circle cx="180" cy="185" r="9" />
<circle cx="720" cy="185" r="9" />
<path className="dimension" d="M190 137 L710 137" markerStart="url(#arrow)" markerEnd="url(#arrow)" />
<path className="dimension-tick" d="M180 145 L180 164 M720 145 L720 164" />
<text x="450" y="122" textAnchor="middle" className="length-label">19.980 m · 측점 차이</text>
<text x="180" y="228" textAnchor="middle" className="endpoint-label">STA. 0K+319.010</text>
<text x="720" y="228" textAnchor="middle" className="endpoint-label">STA. 0K+338.990</text>
</g>
<g className="axis-labels">
<path d="M82 303 L150 303" /><path d="M82 303 L82 263" />
<text x="157" y="307">종단 기준</text><text x="76" y="255">EL 기준 미연결</text>
</g>
</svg>
)
function pointRange(parts, key, coordinate) {
const values = parts.flatMap(part => [...part.a, ...part.b].map(point => point[coordinate]))
return values.length ? [Math.min(...values), Math.max(...values)] : [0, 0]
}
export default function App() {
const [view, setView] = useState('전체')
const [renderMode, setRenderMode] = useState(false)
const [hidden, setHidden] = useState(new Set())
const [hiddenUnits, setHiddenUnits] = useState(new Set())
const [selected, setSelected] = useState(null)
const [tab, setTab] = useState('부재')
const [search, setSearch] = useState('')
const [resetToken, setResetToken] = useState(0)
const allParts = useMemo(() => model.units.flatMap(unit => unit.parts.map(part => ({ ...part, unitId: unit.id, unitName: unit.name }))), [])
const selectedPart = selected ? allParts.find(part => part.id === selected.partId && part.unitId === selected.unitId) : null
const selectedUnit = selected ? model.units.find(unit => unit.id === selected.unitId) : null
const visibleCount = allParts.filter(part => !hidden.has(part.id) && !hiddenUnits.has(part.unitId)).length
const filteredParts = allParts.filter(part => `${part.name} ${part.layer} ${part.unitName}`.toLowerCase().includes(search.toLowerCase()))
const dimensionRange = pointRange(allParts, 'a', 0)
const elevationRange = pointRange(allParts, 'a', 1)
const counts = {
major: allParts.filter(part => part.group === 'major').length,
minor: allParts.filter(part => part.group === 'minor').length,
insert: allParts.filter(part => part.group === 'insert').length,
}
const togglePart = id => setHidden(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const toggleGroup = group => setHidden(current => {
const ids = allParts.filter(part => part.group === group).map(part => part.id)
const hide = ids.some(id => !current.has(id))
const next = new Set(current)
ids.forEach(id => hide ? next.add(id) : next.delete(id))
return next
})
const toggleUnit = id => setHiddenUnits(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
return (
<main className="page-shell">
<header className="topbar">
<a className="brand" href="#top"><span className="brand-mark">B8</span><span>도면 모델 작업실</span></a>
<span className="project-chip">광터지하차도 · BLOCK8</span>
<nav className="file-links" aria-label="작업 자료">
<a href="/analysis/BLOCK8_3D모델_미리보기.svg" target="_blank" rel="noreferrer">모델 이미지 ↗</a>
<a href="/analysis/도면분석.md" target="_blank" rel="noreferrer">분석 파일 ↗</a>
<a href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 ↗</a>
<a href="/drawings/광터지하차도_BLOCK8.dxf" download>원본 DXF ↓</a>
@@ -57,44 +70,98 @@ export default function App() {
<section className="intro" id="top">
<div>
<span className="kicker">도면 근거가 확인된 범위부터 표시합니다</span>
<h1>BLOCK8 <em>모델 보기</em></h1>
<p>현재 도면에서 확인된 종단 구간 기준선입니다. 구조물 단면은 추가 확인이 필요합니다.</p>
<span className="kicker">평면 중심선 · 종단 표고 · 단면 구조선을 연결한 검토 모델</span>
<h1>광터지하차도 <em>BLOCK8</em></h1>
<p>구조선과 블록 내부 콘크리트 선을 추출해 두 측점 단면 사이를 연결했습니다.</p>
</div>
<span className="status-pill"><i /> 단면 윤곽 확인 필요</span>
<span className="status-pill"><i /> 일부 배치 대응 확인 필요</span>
</section>
<section className="summary-grid" aria-label="확인된 기준값">
<article><span>시점</span><strong>0K+319.010</strong><small>도면 명시값</small></article>
<article><span>종점</span><strong>0K+338.990</strong><small>도면 명시값</small></article>
<article><span>구간 길이</span><strong>19.980 <i>m</i></strong><small>측점 차이로 계산</small></article>
<article className="unknown"><span>단면 폭 · 높이</span><strong>확인 필요</strong><small>구조선과 EL 연결 전</small></article>
<section className="metrics" aria-label="주요 모델 기준값">
<article><small>평면 중심선 길이</small><strong>19.980 <i>m</i></strong><p>중심선 원호와 양 끝 구조선 교점 간 계산</p></article>
<article><small>단면 도면 표기 폭</small><strong>20.300 <i>m</i></strong><p>단면 치수 · 외곽 구조선 최대 폭은 20.500m</p></article>
<article><small>벽체 상단 EL</small><strong className="el">107.215 <i>→</i> 107.857</strong><p>STA 319.000 → 339.000</p></article>
<article><small>중심 기준 EL</small><strong className="el">102.094 <i>→</i> 101.517</strong><p>단면 중심 표시와 EL 주석 대조</p></article>
</section>
<section className="model-card" aria-labelledby="model-heading">
<div className="card-heading">
<div><span className="eyebrow">3D 기준 보기 · 형상 검토 중</span><h2 id="model-heading">확인된 종단 구간</h2></div>
<div className="view-controls" aria-label="보기 방향">
{Object.keys(views).map(name => <button key={name} className={view === name ? 'active' : ''} onClick={() => setView(name)}>{name}</button>)}
<div className="notice">
<b>검토 상태</b>
<p>단면 A/B 구조선 114개씩과 내부 블록 윤곽을 반영했습니다. 평면도에 BLOCK8 표기가 있는 중심선이 2개 있어 둘 다 별도 객체로 표시했습니다. 두 배치에 같은 대표 단면을 적용한 것은 확인 필요 항목입니다.</p>
</div>
<section className="workspace">
<div className="viewer-card">
<div className="viewer-head">
<div><span className="eyebrow">도면 구조선 기반 · 열린 표면 모델</span><h2>BLOCK8 3D 구조 보기</h2></div>
<div className="segmented"><button className={!renderMode ? 'active' : ''} onClick={() => setRenderMode(false)}>기본 모델</button><button className={renderMode ? 'active' : ''} onClick={() => setRenderMode(true)}>명암 보기</button></div>
</div>
<div className="viewbar">
<div className="views">{viewNames.map(name => <button key={name} className={view === name ? 'active' : ''} onClick={() => setView(name)}>{name}</button>)}</div>
<span>{visibleCount} / {allParts.length} 객체 표시</span>
</div>
<div className={`canvas-wrap ${renderMode ? 'render-mode' : ''}`}>
<ModelViewer units={model.units} hidden={hidden} hiddenUnits={hiddenUnits} selected={selected} onSelect={setSelected} view={view} renderMode={renderMode} resetToken={resetToken} />
<span className="canvas-tag">{renderMode ? '명암을 더한 도면 검토 시각화 · 실사 렌더링 아님' : '구조선 연결 표면 · 두 배치 중심선 표시'}</span>
</div>
<div className="viewer-foot"><span>끌기: 회전 · 휠: 확대/축소 · Shift+끌기 또는 오른쪽 끌기: 이동</span><button onClick={() => { setView('전체'); setResetToken(value => value + 1); setSelected(null) }}>전체 보기 ↗</button></div>
<div className="selection" aria-live="polite">
{selectedPart && selectedUnit ? <>
<div className="selection-title"><span className="eyebrow">선택한 구조선</span><button onClick={() => setSelected(null)} aria-label="선택 해제">×</button></div>
<h3>{selectedPart.name}</h3>
<div className="selection-grid">
<p>도면 레이어<strong>{selectedPart.layer}</strong></p>
<p>종단 연결 길이<strong>{selectedUnit.length.toFixed(3)} m</strong></p>
<p>단면 EL 범위<strong>{Math.min(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)} ~ {Math.max(...selectedPart.a.concat(selectedPart.b).map(p => p[1])).toFixed(3)}</strong></p>
<p>배치 객체<strong>{selectedUnit.name}</strong></p>
</div>
<p className="source-note">원본 HANDLE: {(selectedPart.sourceHandles || [selectedPart.sourceHandle, ...(selectedPart.sourceInsertHandles || [])]).filter(Boolean).join(' / ') || '블록 내부 객체'} · 이 표면은 닫힌 체적이 아닙니다.</p>
<button className="subtle" onClick={() => togglePart(selectedPart.id)}>{hidden.has(selectedPart.id) ? '선택 객체 표시' : '선택 객체 숨기기'}</button>
</> : <><span className="selection-dot" /><div><strong>모델 표면을 선택하면 원본 레이어와 EL 범위를 확인할 수 있습니다.</strong><p>옆 부재 목록에서 이름을 눌러 선택하거나 체크를 해제해 개별 객체를 숨길 수 있습니다.</p></div></>}
</div>
</div>
<div className="model-stage"><StationModel view={view} /><span className="stage-note">선은 측점 범위를 나타냅니다 · 구조물 단면/폭/높이를 표현한 형상이 아닙니다</span></div>
<div className="model-caption"><span><i className="legend-line" /> 도면 명시 측점으로 계산한 기준선</span><span>단면 윤곽 · 배치 · EL 연결: 확인 필요</span></div>
<aside className="side-card">
<div className="tabs">{['부재', '도면·검증'].map(name => <button key={name} className={tab === name ? 'active' : ''} onClick={() => setTab(name)}>{name}</button>)}</div>
{tab === '부재' ? <>
<div className="panel-heading"><h2>모델 객체</h2><span>{visibleCount} 표시</span></div>
<div className="unit-toggles">{model.units.map((unit, index) => <label key={unit.id}><input type="checkbox" checked={!hiddenUnits.has(unit.id)} onChange={() => toggleUnit(unit.id)} />평면 중심선 {index + 1}<small>CAD상 중심 간격 {index ? '35.000' : '—'} m</small></label>)}</div>
<input className="search" aria-label="모델 객체 검색" placeholder="레이어 또는 객체 이름 검색" value={search} onChange={event => setSearch(event.target.value)} />
<div className="group-toggles">
{Object.entries(categoryLabels).map(([group, label]) => {
const groupParts = allParts.filter(part => part.group === group)
const shown = groupParts.filter(part => !hidden.has(part.id)).length
return <div key={group}><span className={`swatch ${group}`} /><span className="group-label">{label}<small>{shown} / {counts[group]}</small></span><button onClick={() => toggleGroup(group)}>{shown ? '숨김' : '표시'}</button></div>
})}
</div>
<div className="part-list">
{filteredParts.map(part => <div className={`part-row ${selected?.partId === part.id ? 'chosen' : ''} ${hidden.has(part.id) ? 'is-hidden' : ''}`} key={part.id}>
<button onClick={() => { setSelected({ unitId: part.unitId, partId: part.id }); setTab('부재') }} title={part.name}>{part.name}<small>{part.unitName.replace('BLOCK8 평면 중심선 ', '배치 ')}</small></button>
<input type="checkbox" aria-label={`${part.name} 표시`} checked={!hidden.has(part.id)} onChange={() => togglePart(part.id)} />
</div>)}
{!filteredParts.length && <p className="empty">검색 결과가 없습니다.</p>}
</div>
<p className="panel-note">단면 구조선은 원본 선 단위로 나누었습니다. 연결 면은 두 단면 사이의 계산 표면입니다.</p>
</> : <>
<h2 className="panel-title">도면 대조 및 추가 확인</h2>
<ul className="checks">
<li><b>확인</b><span>측점 간격 19.980m<small>중심선 원호와 CS-CONC-MAJR 끝선 교점으로 계산</small></span></li>
<li><b>확인</b><span>단면 구조선 114개씩<small>CS-CONC-MAJR 108 + CS-CONC-MINR 6</small></span></li>
<li><b>확인</b><span>블록 내부 콘크리트<small>A$C2017021411375356 내부 18선 + 2원호, 4개 삽입 참조</small></span></li>
<li className="pending"><b>검토</b><span>두 중심선과 대표 단면 대응<small>두 평면 배치는 표시했으며 구조도에서 동일 단면 적용 여부 확인 필요</small></span></li>
<li className="pending"><b>검토</b><span>폭 20.300m / 외곽선 20.500m<small>단면 치수와 외곽 구조선 범위 차이 0.200m</small></span></li>
<li className="pending"><b>검토</b><span>기초 최저선과 EL 주석 차이<small>모델 선 끝이 표기 기초 EL보다 0.100m 낮음</small></span></li>
</ul>
<div className="source-links"><a href="/analysis/도면분석.md" target="_blank" rel="noreferrer">상세 도면 분석 열기 ↗</a><a href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 열기 ↗</a><a href="/drawings/광터지하차도_BLOCK8.dxf" download>원본 DXF 받기 ↓</a></div>
<p className="panel-note">렌더링은 구조 검토용 시각화입니다. 실제 재료·폐합 체적을 나타내지 않습니다.</p>
</>}
</aside>
</section>
<section className="bottom-grid">
<article className="info-card">
<span className="eyebrow">진행 상태</span><h2>분석은 완료, 구조 형상은 미확정</h2>
<p>도면에서 측점과 구간 길이를 읽었습니다. DXF 단위가 지정되지 않았고 BLOCK8의 전체 구조물 윤곽을 확인하지 못해, 임의의 폭·높이를 넣은 구조물 모델은 표시하지 않습니다.</p>
<a className="text-link" href="/analysis/도면분석.md" target="_blank" rel="noreferrer">도면 분석 내용 보기 <span>↗</span></a>
</article>
<article className="info-card log-card">
<span className="eyebrow">작업 기록</span><h2>분석 자료와 변경 이력</h2>
<p>초기화 이후 도면 등록과 분석 기록을 날짜·커밋 단위로 정리했습니다.</p>
<a className="text-link" href="/작업기록.txt" target="_blank" rel="noreferrer">작업 로그 파일 열기 <span>↗</span></a>
</article>
<article className="info-card"><span className="eyebrow">단면 기준 A-A → B-B</span><h2>EL값과 폭 분할</h2><p>중심 EL은 102.094 → 101.517m, 벽체 상단은 107.215 → 107.857m입니다. 표기 기초 하단은 100.877 → 100.392m입니다. 폭 치수는 0.9 + 8.75 + 1.0 + 8.75 + 0.9 = 20.300m입니다.</p></article>
<article className="info-card"><span className="eyebrow">모델 근거</span><h2>좌표 단위 검산</h2><p>DXF의 단위 설정은 비어 있지만, 구조선 좌표 차 20,300과 도면 치수 20.300m가 맞아 좌표를 mm로 환산했습니다. 현재 범위 EL {elevationRange[0].toFixed(3)}~{elevationRange[1].toFixed(3)}m, 단면 외곽선 폭 {dimensionRange[1] - dimensionRange[0] === 0 ? '—' : '20.500'}m입니다.</p></article>
</section>
<footer>단위 m · 도면 명시값과 계산값을 구분해 표시합니다.</footer>
<footer>출처: BLOCK8 DXF · 3D 면은 두 단면 선형 보간 · 미확정 대응은 검토 패널에 표시</footer>
</main>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useRef } from 'react'
const DEFAULT_CAMERA = { yaw: -0.66, pitch: 0.43, zoom: 1, panX: 0, panY: 0 }
const COLORS = { major: '#78917c', minor: '#d0aa69', insert: '#9a9074' }
const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
function worldPoint(unit, pointA, pointB, pointIndex, t) {
const angle = (unit.startAngle + (unit.endAngle - unit.startAngle) * t) * Math.PI / 180
const cx = unit.center[0] + unit.radius * Math.cos(angle)
const cz = unit.center[1] + unit.radius * Math.sin(angle)
const tangentX = -Math.sin(angle)
const tangentZ = Math.cos(angle)
const normalX = -tangentZ
const normalZ = tangentX
const stationT = (0.01 + t * 19.98) / 20
const lateral = pointA[pointIndex][0] + (pointB[pointIndex][0] - pointA[pointIndex][0]) * stationT
const elevation = pointA[pointIndex][1] + (pointB[pointIndex][1] - pointA[pointIndex][1]) * stationT
return [cx + lateral * normalX, elevation, cz + lateral * normalZ]
}
function hexRgb(hex) {
return [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16))
}
function colorShade(hex, amount, alpha) {
const [r, g, b] = hexRgb(hex).map(value => Math.round(value * amount))
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function inPolygon(point, polygon) {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i], [xj, yj] = polygon[j]
const intersect = ((yi > point[1]) !== (yj > point[1])) && (point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi || 1) + xi)
if (intersect) inside = !inside
}
return inside
}
export default function ModelViewer({ units, hidden, hiddenUnits, selected, onSelect, view, renderMode, resetToken }) {
const canvasRef = useRef(null)
const cameraRef = useRef({ ...DEFAULT_CAMERA })
const sceneRef = useRef({ units, hidden, hiddenUnits, selected, view, renderMode, onSelect })
const drawRef = useRef(() => {})
useEffect(() => {
sceneRef.current = { units, hidden, hiddenUnits, selected, view, renderMode, onSelect }
drawRef.current()
}, [units, hidden, hiddenUnits, selected, view, renderMode, onSelect])
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
let hitFaces = []
let pointerStart = null
let lastPoint = null
let dragged = false
const project = (point, width, height) => {
const camera = cameraRef.current
const y = point[1] - 104
const x = point[0] * Math.cos(camera.yaw) - point[2] * Math.sin(camera.yaw)
const z = point[0] * Math.sin(camera.yaw) + point[2] * Math.cos(camera.yaw)
const scale = Math.min(width / 66, height / 24) * camera.zoom
return [width / 2 + x * scale + camera.panX, height / 2 - (y * Math.cos(camera.pitch) - z * Math.sin(camera.pitch)) * scale + camera.panY, y * Math.sin(camera.pitch) + z * Math.cos(camera.pitch)]
}
const draw = () => {
const rect = canvas.getBoundingClientRect()
const width = rect.width, height = rect.height
if (!width || !height) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr)
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const { units, hidden, hiddenUnits, selected, renderMode } = sceneRef.current
const background = ctx.createLinearGradient(0, 0, 0, height)
background.addColorStop(0, renderMode ? '#e8efea' : '#f8faf8')
background.addColorStop(1, renderMode ? '#cbd8cf' : '#e9efea')
ctx.fillStyle = background; ctx.fillRect(0, 0, width, height)
const drawLine = (a, b, color, lineWidth = 1) => {
const pa = project(a, width, height), pb = project(b, width, height)
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1])
ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke()
}
for (let x = -40; x <= 40; x += 5) drawLine([x, 100.2, -22], [x, 100.2, 22], '#aab9ad38', .6)
for (let z = -20; z <= 20; z += 5) drawLine([-40, 100.2, z], [40, 100.2, z], '#aab9ad38', .6)
const faces = []
const longitudinalSteps = 8
for (const unit of units) {
if (hiddenUnits.has(unit.id)) continue
for (const part of unit.parts) {
if (hidden.has(part.id) || part.a.length !== part.b.length) continue
const color = COLORS[part.group] || COLORS.major
const active = selected?.unitId === unit.id && selected?.partId === part.id
for (let step = 0; step < longitudinalSteps; step++) {
const t0 = step / longitudinalSteps, t1 = (step + 1) / longitudinalSteps
for (let edge = 0; edge < part.a.length - 1; edge++) {
const points3d = [
worldPoint(unit, part.a, part.b, edge, t0),
worldPoint(unit, part.a, part.b, edge + 1, t0),
worldPoint(unit, part.a, part.b, edge + 1, t1),
worldPoint(unit, part.a, part.b, edge, t1),
]
const projected = points3d.map(point => project(point, width, height))
const depth = projected.reduce((sum, point) => sum + point[2], 0) / projected.length
faces.push({ projected, part, unit, color, depth, active, shade: .82 + .18 * Math.abs(Math.sin(edge * .73 + step * .11)) })
}
}
}
}
faces.sort((a, b) => a.depth - b.depth)
hitFaces = []
for (const face of faces) {
ctx.beginPath()
face.projected.forEach((point, index) => index ? ctx.lineTo(point[0], point[1]) : ctx.moveTo(point[0], point[1]))
ctx.closePath()
ctx.fillStyle = colorShade(face.active ? '#d27c42' : face.color, renderMode ? face.shade : 1, face.active ? .92 : face.part.group === 'insert' ? .58 : .72)
ctx.fill()
ctx.strokeStyle = face.active ? '#9b4f20' : renderMode ? '#51675a99' : '#566b5b8c'
ctx.lineWidth = face.active ? 1.8 : .65
ctx.stroke()
hitFaces.push(face)
}
ctx.font = '11px sans-serif'
ctx.textAlign = 'left'
ctx.fillStyle = '#68806d'
units.forEach((unit, index) => {
if (hiddenUnits.has(unit.id)) return
const start = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 0), width, height)
const end = project(worldPoint(unit, [[0, 104]], [[0, 104]], 0, 1), width, height)
ctx.fillText(`중심선 ${index + 1} · STA 319.010`, start[0] - 5, start[1] - 16)
ctx.fillText(`STA 338.990`, end[0] - 8, end[1] - 14)
})
ctx.fillStyle = '#6f8275'
ctx.fillText('X·Z 평면: 도면 중심선 · Y: 표고 EL', 16, height - 18)
ctx.textAlign = 'right'
ctx.fillText(`확대 ${Math.round(cameraRef.current.zoom * 100)}%`, width - 16, height - 18)
}
drawRef.current = draw
const local = event => {
const rect = canvas.getBoundingClientRect()
return [event.clientX - rect.left, event.clientY - rect.top]
}
const down = event => {
canvas.setPointerCapture(event.pointerId)
pointerStart = local(event); lastPoint = pointerStart; dragged = false
event.preventDefault()
}
const move = event => {
if (pointerStart === null) return
const point = local(event)
const dx = point[0] - lastPoint[0], dy = point[1] - lastPoint[1]
if (Math.hypot(point[0] - pointerStart[0], point[1] - pointerStart[1]) > 4) dragged = true
const camera = cameraRef.current
if (event.shiftKey || event.buttons === 2 || event.buttons === 4) {
camera.panX += dx; camera.panY += dy
} else {
camera.yaw += dx * .008
camera.pitch = clamp(camera.pitch + dy * .006, -.4, 1.45)
}
lastPoint = point; drawRef.current()
}
const up = event => {
if (pointerStart === null) return
if (!dragged) {
const point = local(event)
for (let index = hitFaces.length - 1; index >= 0; index--) {
const face = hitFaces[index]
if (inPolygon(point, face.projected)) {
sceneRef.current.onSelect({ unitId: face.unit.id, partId: face.part.id })
break
}
}
}
pointerStart = null; lastPoint = null
}
const wheel = event => {
event.preventDefault()
cameraRef.current.zoom = clamp(cameraRef.current.zoom * (event.deltaY < 0 ? 1.1 : .9), .3, 5)
drawRef.current()
}
const context = event => event.preventDefault()
const resize = new ResizeObserver(() => drawRef.current())
resize.observe(canvas)
canvas.addEventListener('pointerdown', down)
canvas.addEventListener('pointermove', move)
canvas.addEventListener('pointerup', up)
canvas.addEventListener('pointercancel', up)
canvas.addEventListener('wheel', wheel, { passive: false })
canvas.addEventListener('contextmenu', context)
draw()
return () => {
resize.disconnect()
canvas.removeEventListener('pointerdown', down)
canvas.removeEventListener('pointermove', move)
canvas.removeEventListener('pointerup', up)
canvas.removeEventListener('pointercancel', up)
canvas.removeEventListener('wheel', wheel)
canvas.removeEventListener('contextmenu', context)
}
}, [])
useEffect(() => {
const viewCameras = {
전체: { yaw: -.66, pitch: .43 },
평면: { 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 },
}
cameraRef.current = { ...DEFAULT_CAMERA, ...viewCameras[view] }
drawRef.current()
}, [view, resetToken])
return <canvas ref={canvasRef} className="model-canvas" aria-label="BLOCK8 도면 기반 3D 구조 모델. 끌어 회전하고 휠로 확대할 수 있습니다." tabIndex={0} />
}
+141 -91
View File
@@ -1,94 +1,144 @@
:root {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
color: #263a32;
background: #eff2ed;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
: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 { min-width:320px; min-height:100vh; margin:0; }
button,input { font:inherit; }
button { border:0; background:none; color:inherit; cursor:pointer; }
a { color:inherit; text-decoration:none; }
button:focus-visible,a:focus-visible,input:focus-visible { outline:2px solid #bc763b; outline-offset:3px; }
.page-shell { width:min(100% - 64px,1600px); margin:auto; padding-bottom:24px; }
.topbar { min-height:72px; display:flex; align-items:center; gap:18px; border-bottom:1px solid #d9e1d7; }
.brand { display:flex; align-items:center; gap:10px; font-size:14px; font-weight:700; white-space:nowrap; }
.brand-mark { display:grid; width:34px; height:34px; place-items:center; border-radius:10px; background:#446b58; color:#fff; font-size:11px; }
.project-chip { margin-right:auto; color:#7a897e; font-size:11px; }
.file-links { display:flex; gap:7px; }
.file-links a { padding:9px 11px; border:1px solid #d3ddd2; border-radius:7px; background:#fafcf8; color:#536d5b; font-size:10px; white-space:nowrap; }
.file-links a:hover,.source-links a:hover { border-color:#92a994; color:#315c42; }
.intro { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:29px 0 20px; }
.kicker,.eyebrow { display:block; color:#7a8d7e; font-size:10px; font-weight:700; letter-spacing:.08em; }
h1 { margin:8px 0; font-size:clamp(26px,3.3vw,34px); letter-spacing:-.055em; }
h1 em { margin-left:7px; color:#738b79; font-size:.8em; font-style:normal; font-weight:400; }
.intro p { margin:0; color:#7c8d80; font-size:11px; line-height:1.7; }
.status-pill { display:flex; align-items:center; gap:8px; padding:9px 13px; border:1px solid #e0d6bd; border-radius:30px; background:#f8f4e9; color:#9b793c; font-size:10px; white-space:nowrap; }
.status-pill i { width:7px; height:7px; border-radius:50%; background:#b08a45; }
.metrics { display:grid; grid-template-columns:repeat(4,1fr); margin-bottom:13px; padding:17px 0; border:1px solid #dce4d9; border-radius:11px; background:#f9fbf7; }
.metrics article { min-width:0; padding:0 18px; border-right:1px solid #e3e8df; }
.metrics article:last-child { border:0; }
.metrics small { display:block; color:#7d8b7d; font-size:9px; }
.metrics strong { display:block; margin:8px 0 5px; font-family:Georgia,serif; font-size:clamp(19px,2.3vw,26px); font-weight:400; white-space:nowrap; }
.metrics strong.el { font-size:clamp(17px,2vw,22px); }
.metrics strong i { color:#899584; font-family:sans-serif; font-size:11px; font-style:normal; }
.metrics p { margin:0; color:#8b9687; font-size:9px; line-height:1.55; }
.notice { display:flex; align-items:flex-start; gap:11px; margin-bottom:15px; padding:10px 13px; border:1px solid #e6dcc7; border-radius:8px; background:#f5f0e4; }
.notice b { padding-top:2px; color:#967136; font-size:10px; white-space:nowrap; }
.notice p { margin:0; color:#8b7e62; font-size:10px; line-height:1.65; }
.workspace { display:grid; grid-template-columns:minmax(0,1fr) 322px; gap:16px; align-items:start; }
.viewer-card,.side-card,.info-card { min-width:0; overflow:hidden; border:1px solid #dbe4d7; border-radius:11px; background:#fafcf8; }
.viewer-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:17px 19px 13px; }
h2 { margin:5px 0 0; font-size:16px; letter-spacing:-.04em; }
.segmented { display:flex; padding:3px; border-radius:7px; background:#eaf0e7; }
.segmented button { padding:6px 9px; border-radius:5px; color:#7f8d7b; font-size:9px; white-space:nowrap; }
.segmented button.active { background:#fff; color:#496c52; box-shadow:0 1px 4px #233c2912; }
.viewbar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:0 17px 10px; }
.views { display:flex; gap:3px; }
.views button { padding:6px 9px; border:1px solid transparent; border-radius:5px; font-size:9px; }
.views button.active { border-color:#ccdac9; background:#eef3ea; color:#426a50; }
.viewbar>span { color:#8b9687; font-size:9px; white-space:nowrap; }
.canvas-wrap { position:relative; overflow:hidden; border-top:1px solid #dfe8da; border-bottom:1px solid #dfe8da; background:#f4f7f3; }
.model-canvas { display:block; width:100%; height:485px; touch-action:none; cursor:grab; }
.model-canvas:active { cursor:grabbing; }
.model-canvas:focus-visible { outline:2px solid #7d9a83; outline-offset:-2px; }
.canvas-tag { position:absolute; top:12px; left:14px; max-width:calc(100% - 28px); padding:6px 8px; border:1px solid #d5e0d2; border-radius:5px; background:#fcfffbd9; color:#6c846f; font-size:8px; pointer-events:none; }
.render-mode .model-canvas { filter:saturate(.92) contrast(1.04); }
.viewer-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:11px 17px; color:#8b9885; font-size:8px; }
.viewer-foot button { color:#56735b; font-size:9px; white-space:nowrap; }
.selection { min-height:95px; margin:0 18px 17px; padding:14px 0 0; border-top:1px solid #e2e9dd; }
.selection:has(h3) { display:block; }
.selection:not(:has(h3)) { display:flex; align-items:center; gap:11px; }
.selection-dot { width:9px; height:9px; flex:0 0 auto; border-radius:50%; background:#91a78f; }
.selection strong { font-size:10px; }
.selection p { margin:5px 0 0; color:#819084; font-size:9px; line-height:1.6; }
.selection-title { display:flex; justify-content:space-between; align-items:center; }
.selection-title button { color:#8b958d; font-size:20px; }
.selection h3 { margin:7px 0 9px; font-size:13px; }
.selection-grid { display:flex; flex-wrap:wrap; gap:8px 20px; }
.selection-grid p { min-width:100px; margin:0; }
.selection-grid strong { display:block; margin-top:3px; color:#526c58; }
.selection .source-note { font-size:8px; }
.subtle { margin-top:7px; padding:6px 9px; border:1px solid #d8e1d6; border-radius:5px; color:#637a68; font-size:9px; }
.side-card { padding-bottom:13px; }
.tabs { display:flex; gap:17px; padding:14px 17px 11px; border-bottom:1px solid #e2e8df; }
.tabs button { padding:0 0 6px; color:#7e8b81; font-size:10px; }
.tabs button.active { border-bottom:2px solid #547862; color:#46684f; font-weight:700; }
.panel-heading { display:flex; justify-content:space-between; align-items:end; padding:12px 15px 9px; }
.panel-heading h2 { font-size:14px; }
.panel-heading span { color:#829084; font-size:9px; }
.unit-toggles { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:0 14px 10px; }
.unit-toggles label { display:block; padding:8px; border:1px solid #e0e7dd; border-radius:6px; color:#607665; font-size:9px; }
.unit-toggles input { margin:0 5px 0 0; accent-color:#547862; }
.unit-toggles small { display:block; margin:5px 0 0 17px; color:#8a968b; font-size:8px; }
.search { width:calc(100% - 28px); margin:0 14px 8px; padding:8px 9px; border:1px solid #dce4d9; border-radius:6px; background:#fff; color:#37473d; font-size:10px; }
.group-toggles { display:grid; gap:5px; padding:0 14px 9px; }
.group-toggles>div { display:flex; align-items:center; gap:7px; min-width:0; }
.swatch { width:8px; height:8px; flex:0 0 auto; border-radius:2px; background:#78917c; }
.swatch.minor { background:#d0aa69; }
.swatch.insert { background:#9a9074; }
.group-label { flex:1; color:#68776d; font-size:9px; }
.group-label small { display:block; margin-top:2px; color:#97a096; font-size:8px; }
.group-toggles button { padding:4px 6px; border:1px solid #dce4d9; border-radius:4px; color:#68806d; font-size:8px; }
.part-list { max-height:270px; margin:0 7px; overflow:auto; border-top:1px solid #e6ebe4; }
.part-row { display:flex; align-items:center; gap:5px; padding:2px 7px; border-bottom:1px solid #eff2ed; }
.part-row.chosen { background:#edf3eb; }
.part-row.is-hidden button { color:#a1aaa1; }
.part-row>button { min-width:0; flex:1; padding:5px 0; overflow:hidden; text-align:left; text-overflow:ellipsis; white-space:nowrap; color:#53665a; font-size:9px; }
.part-row small { display:block; margin-top:2px; color:#96a095; font-size:8px; }
.part-row input { accent-color:#547862; }
.empty { padding:10px; color:#879387; font-size:9px; }
.panel-note { margin:9px 14px 0; color:#8b9687; font-size:8px; line-height:1.6; }
.panel-title { padding:14px 15px 7px; font-size:14px; }
.checks { display:grid; gap:0; margin:0; padding:0 14px; list-style:none; }
.checks li { display:grid; grid-template-columns:44px 1fr; gap:8px; padding:9px 0; border-bottom:1px solid #edf0eb; }
.checks li>b { width:max-content; height:max-content; padding:3px 5px; border-radius:4px; background:#e9f1e8; color:#608064; font-size:8px; }
.checks li.pending>b { background:#f5efe1; color:#9a7c45; }
.checks li span { color:#5a6d60; font-size:9px; line-height:1.5; }
.checks li small { display:block; margin-top:3px; color:#929d92; font-size:8px; }
.source-links { display:grid; gap:5px; padding:11px 14px 0; }
.source-links a { padding:8px 9px; border:1px solid #dce4d9; border-radius:5px; color:#58745f; font-size:9px; }
.bottom-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }
.info-card { min-height:110px; padding:15px 17px; }
.info-card h2 { margin:5px 0 7px; font-size:13px; }
.info-card p { margin:0; color:#7d8b80; font-size:9px; line-height:1.7; }
footer { padding-top:15px; color:#89958a; font-size:8px; }
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
a { color: inherit; text-decoration: none; }
button { font: inherit; cursor: pointer; }
.page-shell { width: min(100% - 64px, 1380px); margin: 0 auto; padding-bottom: 25px; }
.topbar { min-height: 72px; display: flex; align-items: center; gap: 20px; border-bottom: 1px solid #d8e0d6; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 700; white-space: nowrap; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; background: #446b58; color: #fff; font-size: 12px; }
.project-chip { color: #819087; font-size: 11px; }
.file-links { display: flex; gap: 8px; margin-left: auto; }
.file-links a { padding: 9px 11px; border: 1px solid #d7dfd4; border-radius: 7px; background: #fafcf8; color: #536d5b; font-size: 11px; white-space: nowrap; }
.file-links a:hover,.text-link:hover { border-color: #89a28d; color: #2d6045; }
.intro { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 36px 0 25px; }
.kicker,.eyebrow { display: block; color: #788a7d; font-size: 10px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 8px 0; font-size: clamp(27px, 4vw, 34px); letter-spacing: -.06em; }
h1 em { margin-left: 7px; color: #778b7d; font-size: .8em; font-style: normal; font-weight: 400; }
.intro p,.info-card p { margin: 0; color: #79877c; font-size: 12px; line-height: 1.8; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid #e5dbc2; border-radius: 30px; background: #faf5e9; color: #967641; font-size: 11px; white-space: nowrap; }
.status-pill i { width: 7px; height: 7px; border-radius: 50%; background: #b98f48; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; overflow: hidden; border: 1px solid #dce4d9; border-radius: 11px; background: #fafcf8; }
.summary-grid article { min-height: 112px; padding: 18px 22px; border-right: 1px solid #e3e9e1; }
.summary-grid article:last-child { border: 0; }
.summary-grid span,.summary-grid small { display: block; color: #839084; font-size: 10px; }
.summary-grid strong { display: block; margin: 10px 0 6px; font-family: Georgia,serif; font-size: clamp(19px, 2.5vw, 26px); font-weight: 400; white-space: nowrap; }
.summary-grid strong i { color: #78877c; font-family: sans-serif; font-size: 12px; font-style: normal; }
.summary-grid .unknown strong { color: #a38048; font-family: inherit; font-size: 18px; }
.model-card,.info-card { overflow: hidden; border: 1px solid #dbe4d7; border-radius: 12px; background: #fafcf8; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 19px 22px 12px; }
h2 { margin: 5px 0 0; font-size: 17px; letter-spacing: -.04em; }
.view-controls { display: flex; gap: 4px; padding: 3px; border-radius: 7px; background: #edf1ea; }
.view-controls button { padding: 7px 11px; border: 0; border-radius: 5px; background: transparent; color: #768579; font-size: 10px; }
.view-controls button.active { background: white; color: #426a50; box-shadow: 0 1px 4px #233c2912; }
.model-stage { position: relative; display: grid; min-height: 330px; place-items: center; overflow: hidden; border-top: 1px solid #e0e7dd; border-bottom: 1px solid #e0e7dd; background: linear-gradient(180deg,#f6f9f5,#edf2ec); }
.model-svg { width: min(100%, 940px); height: auto; min-height: 270px; overflow: visible; }
.model-grid path { fill: none; stroke: #dbe4dc; stroke-width: 1; }
.station-line { transform-box: view-box; transform-origin: center; transition: transform .35s ease; }
.station-shadow { fill: none; stroke: #d2ddd2; stroke-width: 15; stroke-linecap: round; }
.station-main { fill: none; stroke: #547d62; stroke-width: 5; stroke-linecap: round; }
.station-cap { fill: none; stroke: #819782; stroke-width: 2; }
.station-line circle { fill: #fafcf8; stroke: #52785d; stroke-width: 4; }
.dimension { fill: none; stroke: #718b78; stroke-width: 1.5; }
.dimension-tick { fill: none; stroke: #a5b5a5; stroke-width: 1; }
.length-label { fill: #49694f; font-size: 16px; font-weight: 700; }
.endpoint-label { fill: #566e5c; font-size: 12px; }
.axis-labels path { stroke: #758e79; stroke-width: 2; }
.axis-labels text { fill: #809083; font-size: 10px; }
.stage-note { position: absolute; right: 15px; bottom: 12px; color: #9a835b; font-size: 9px; text-align: right; }
.model-caption { display: flex; justify-content: space-between; gap: 14px; padding: 13px 20px; color: #839084; font-size: 10px; }
.model-caption>span:first-child { display: inline-flex; align-items: center; gap: 8px; }
.legend-line { width: 19px; height: 3px; border-radius: 2px; background: #547d62; }
.bottom-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 16px; margin-top: 17px; }
.info-card { min-height: 172px; padding: 19px 21px; }
.info-card h2 { margin-bottom: 10px; }
.info-card p { max-width: 720px; }
.text-link { display: inline-flex; align-items: center; gap: 9px; margin-top: 14px; padding: 8px 10px; border: 1px solid #d8e1d6; border-radius: 6px; color: #4b6b52; font-size: 10px; }
.text-link span { font-size: 13px; }
footer { padding-top: 18px; color: #89958a; font-size: 9px; }
@media (max-width: 760px) {
.page-shell { width: min(100% - 32px, 620px); }
.topbar { flex-wrap: wrap; gap: 10px; padding: 13px 0; }
.project-chip { margin-left: auto; }
.file-links { width: 100%; margin: 0; }
.file-links a { flex: 1; text-align: center; }
.intro { align-items: flex-start; flex-direction: column; padding: 27px 0 19px; }
.summary-grid { grid-template-columns: 1fr 1fr; }
.summary-grid article { min-height: 98px; padding: 15px; border-bottom: 1px solid #e3e9e1; }
.summary-grid article:nth-child(2) { border-right: 0; }
.summary-grid article:nth-child(n+3) { border-bottom: 0; }
.card-heading { align-items: flex-start; flex-direction: column; padding: 17px 15px 12px; }
.model-stage { min-height: 260px; }
.model-svg { min-height: 220px; }
.endpoint-label { font-size: 11px; }
.stage-note { max-width: 260px; line-height: 1.5; }
.model-caption { flex-direction: column; padding: 12px 15px; }
.bottom-grid { grid-template-columns: 1fr; gap: 11px; }
.info-card { min-height: 0; padding: 17px; }
@media(max-width:1050px) {
.workspace { grid-template-columns:minmax(0,1fr) 290px; gap:12px; }
.metrics article { padding:0 12px; }
.model-canvas { height:430px; }
}
@media (max-width: 400px) {
.file-links { gap: 5px; }
.file-links a { padding: 8px 5px; font-size: 10px; }
.view-controls button { padding: 7px 9px; }
.model-stage { min-height: 220px; }
.length-label { font-size: 13px; }
@media(max-width:760px) {
.page-shell { width:min(100% - 30px,620px); }
.topbar { flex-wrap:wrap; gap:9px; padding:12px 0; }
.project-chip { margin-left:auto; margin-right:0; }
.file-links { width:100%; }
.file-links a { flex:1; padding:8px 5px; text-align:center; }
.intro { align-items:flex-start; flex-direction:column; padding:24px 0 17px; }
.metrics { grid-template-columns:1fr 1fr; padding:0; }
.metrics article { min-height:91px; padding:14px; border-bottom:1px solid #e3e8df; }
.metrics article:nth-child(2) { border-right:0; }
.metrics article:nth-child(n+3) { border-bottom:0; }
.notice { gap:8px; }
.workspace { grid-template-columns:1fr; }
.model-canvas { height:380px; }
.side-card { order:2; }
.part-list { max-height:330px; }
.bottom-grid { grid-template-columns:1fr; gap:9px; }
}
@media(max-width:440px) {
.viewer-head { align-items:flex-start; flex-direction:column; padding:15px; }
.viewbar { align-items:flex-start; flex-direction:column; padding:0 13px 9px; }
.views { width:100%; flex-wrap:wrap; }
.views button { padding:6px 8px; }
.model-canvas { height:300px; }
.viewer-foot { align-items:flex-start; }
.metrics strong { font-size:20px; }
.metrics strong.el { font-size:16px; }
}
+10701
View File
File diff suppressed because it is too large. Load diff