chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합

- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
.venv/
+38
View File
@@ -0,0 +1,38 @@
# 작업 규칙
## 언어
- 코드와 변수명은 영어. 응답, 문서, 코드 주석은 한국어 존댓말. 일본어·중국어 금지.
- 쉬운 문장으로 설명. 나는 교각과 코딩을 배우는 중임.
## 문서
- 설계 규칙(피복, 철근 형상, R값, 레이어 이름 등)은 docs/ 폴더 md 파일에 정리.
- 코드와 문서가 충돌하면 먼저 알려줄 것.
- 작업이 끝나면 docs/ 와 README 업데이트 여부를 물어볼 것.
## 개발 방식
- 기능 추가 전에 테스트를 먼저 만들 것. 계산(좌표, 길이, 간격, 철근 수량)은 Rhino 없이 테스트 가능하게 분리.
- 버그는 재현 테스트가 실패하는 것을 먼저 확인한 뒤 수정.
- 같은 방법으로 2번 실패하면 다른 가설을 세워 시도.
- 철근 코드는 일회성으로 실행하지 말고 scripts/ 폴더에 .py 파일로 저장하고 재사용.
## Git
- 브랜치를 임의로 만들지 말 것. 현재 브랜치에 완료된 작업만 커밋.
- 커밋 메시지에 docs/tasks.md 의 작업 번호를 넣을 것.
- 할 일은 docs/tasks.md 에 번호, 내용, 예상 시간, 상태로 기록.
## 경로 안내
- 작업에서 쓰는 모든 경로는 docs/paths.md 에 기록하고, 새 경로가 생기면 바로 추가할 것.
- 파일을 만들거나 수정하거나 읽을 때마다 응답 끝에 "이번에 쓴 경로"를 전체 경로로 알려줄 것. 사용자가 경로를 자주 헷갈려 함.
## 저장 공간
- C 드라이브 Python(약 156MB)은 그대로 둠. 용량이 큰 패키지(numpy, 3D 라이브러리 등)가 필요하면 설치 전에 알리고, D 드라이브(`D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\.venv`)에 가상환경으로 설치할 것.
- 용량이 큰 파일(렌더링, 백업, DXF, Rhino 파일)은 항상 D 드라이브에 저장.
## 폴더 구조
- `docs/` 설계 규칙과 할 일 (`design_rules.md`, `tasks.md`)
- `scripts/` 재사용 철근 코드. 계산 모듈(Rhino 없이 실행)과 Rhino 실행 스크립트를 분리
- `tests/` 계산 모듈 테스트
## 참고 (기존 프로젝트)
- 기존 코드와 방법: `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\` (README 먼저 읽을 것)
- 설계기준: KDS 24 14 21 (상세는 docs/design_rules.md)
+11
View File
@@ -0,0 +1,11 @@
# 교각 철근 프로그램
교각 두부보 등의 철근을 Rhino에 자동으로 배치하는 프로그램입니다.
- `CLAUDE.md` 작업 규칙
- `docs/design_rules.md` 설계 규칙 (KDS 24 14 21 기준)
- `docs/tasks.md` 할 일 목록
- `scripts/` 재사용 코드 (계산 모듈은 Rhino 없이 실행 가능하게 분리)
- `tests/` 계산 모듈 테스트
기존 작업 자료는 `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\` 에 있습니다.
+46
View File
@@ -0,0 +1,46 @@
# 설계 규칙
기준 문서: KDS 24 14 21 : 2025 콘크리트교 설계기준(한계상태설계법).
조항 추출본: `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\kds\KDS_24_14_21.txt` (조항 번호로 검색).
충돌 시 이 기준이 우선입니다. 기준과 다르게 배근된 철근은 철근재료표 비고에 별도 색(자홍)으로 적습니다.
## 1. 철근 구부림 최소 내면 지름 (KDS 24 14 21 표 4.5-1)
- D16 이하: 2db
- D19 이상: 3.5db
- 참고: 기존 프로젝트는 건축기준(KDS 14 20 50) 반지름 R = 3db(D10~D25), 4db(D29~D35), 5db(D38 이상)를 써 왔고, 이 값은 위 최소값보다 크므로 만족합니다.
- R값은 구부림 각도와 무관합니다. 각도에 따라 달라지는 것은 갈고리 뒤 연장 길이입니다.
## 2. 갈고리
- 표준갈고리 90°~150° 구부림 뒤 연장 길이는 5db 이상 (KDS 24 14 21).
- 기존 프로젝트 적용값: 90°=12db, 135°=6db, 180°=4db(또는 60mm 이상).
## 3. 철근 간격 (4.5.3)
- 수평 순간격: max(1.5db, 1.5Gmax, 40mm) 이상
- 다단 연직 순간격: max(25mm, db) 이상, 같은 연직면
## 4. 피복 (4.4.4)
- 최소피복 = max(부착 db, 환경 표 4.4-4) + 설계편차 10mm
- 피복이 70mm를 넘으면 표피철근(0.005Act,ext) 필요
- 기존 프로젝트 두부보 상면 피복: 120mm
## 5. 전단철근 (4.6.2)
- 종방향 간격 ≤ 0.75d(1+cotα), 폭방향 간격 ≤ 0.75d, 600mm
- ρv,min = 0.08√fck/fy
- 단면 내 50% 이상 폐쇄형 또는 U형
## 6. 겹침이음 (4.5.5)
- 겹친 두 철근의 횡방향 순거리 ≤ 4db, 50mm
## 7. 공칭 철근직경 (KS D 3504)
H13=12.7, H16=15.9, H19=19.1, H22=22.2, H25=25.4, H29=28.6 (mm)
## 8. 코핑(두부보) 배근 공통 규칙
- 단차나 경사가 있는 코핑 상단을 지나는 주철근은 단순 직선으로 둡니다. 기준면의 가장 낮은 지점에서 피복을 재고, 그 높이에 평평하게 유지합니다.
- 일반도와 구조도는 항상 함께 대조합니다. 형상이 서로 다를 수 있습니다.
## 9. 레이어 이름 규칙 (기존 프로젝트 방식)
- `P1_두부_주철근_1CYCLE::C1_H25` 처럼 "교각_부위_종류_사이클::마크_규격"
- 철근은 블록(InstanceReference)으로 배치
## 확인 필요
- 새 프로그램에서 레이어 이름과 철근 형상 코드(I~VIII)를 어떻게 일반화할지 정해야 합니다.
+42
View File
@@ -0,0 +1,42 @@
# 다음 작업 안내 (2026-10-07 저장)
새 세션을 시작하면 이 문서와 `docs/tasks.md`, `docs/paths.md`를 먼저 읽고 이어서 하세요.
## 지금까지 (커밋 7개, 테스트 119개 통과)
| 작업 | 내용 | 파일 | 커밋 |
|---|---|---|---|
| T-001 | Python 3.12.10, pytest 설치 | (C 드라이브 설치) | - |
| T-002 | 구부림 반지름, 갈고리 길이 | `scripts/rebar_calc.py` | 65e02b6 |
| T-003 | 철근 간격·수량·중량 | `scripts/rebar_layout.py` | 5072ff3 |
| T-004 | 기존 스크립트 재사용 계획 | `docs/reuse_plan.md` | c008c0e |
| T-005 | 월드 ↔ 코핑 로컬 좌표 | `scripts/frame.py` | d55e74e |
| T-006 | 철근 중심선 꺾임점 | `scripts/centerline.py` | 6f20a6d |
| T-007 | 철근 간섭 검사 | `scripts/clash.py` | fddf8de |
## 내일 할 일
### T-008 철근재료표 DXF 작성 (문자열 생성 부분) — 예상 3시간
- 참고할 기존 코드: `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\rhino\04_table_writer.py` (82줄)
- 참고할 기존 도구: `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\dxf\leftalign.ps1` (글자 왼쪽 정렬), `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\dxf\kds_note_column.ps1` (KDS 상이 비고 열)
- **시작 전 사용자에게 확인할 것**: 새 모듈은 처음부터 CP949(R12 DXF)와 왼쪽 정렬(cell-left+200, DXF 72=0/73=2)을 지원하게 만들면 되는지. (아직 답을 받지 못함)
- 단위중량은 `scripts/rebar_layout.py`의 `UNIT_WEIGHT`를 쓰고 중복해서 만들지 않을 것.
- 설계기준과 다르게 배근된 철근은 비고 3 열에 자홍색(62=6)으로 적는 규칙이 있음 (`CLAUDE.md` 구 프로젝트 §0 참고).
- 기존 방식: UTF-8로 쓴 뒤 `iconv -f UTF-8 -t CP949` 로 변환. 파이썬은 `encoding="cp949"`로 바로 쓸 수 있음.
### T-009 Rhino 연결 코드 — 예상 4시간 (먼저 결정이 필요함)
시작하려면 사용자가 정해야 하는 것:
1. **레이어 이름 규칙**을 새 프로그램에서 어떻게 일반화할지. 기존: `P1_두부_주철근_1CYCLE::C1_H25` (교각_부위_종류_사이클::마크_규격)
2. **철근 형상 코드(I~VIII)** 를 어떻게 일반화할지.
3. 새 모듈을 Rhino 8의 IronPython 2.7에서 `import` 하는 방법 (`sys.path`에 `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts` 추가가 필요할 것으로 보이나 **아직 실제 Rhino에서 확인하지 않음**).
- 주의: 기존 스크립트 규칙 — `execute_rhino_code`에서는 중첩 def, lambda, 리스트 컴프리헨션, f-string 금지. 마크 하나씩 실행(소켓 타임아웃 방지).
- 새 모듈에서 가져다 쓸 것: `frame.py`(좌표), `centerline.py`(꺾임점), `rebar_calc.py`(R값), `clash.py`(간섭).
## 알려진 가정과 빈칸
- 굵은골재 최대치수(Gmax)는 25mm로 **가정**했음 (`rebar_layout.DEFAULT_GMAX`). 설계서 값 확인 필요.
- 단위중량은 H16, H19, H22, H25, H29만 등록됨. H13 등은 값을 확인한 뒤 추가해야 함.
- `centerline.py`가 구하는 길이는 중심선 길이이고, 재료표 전개길이(꺾임 보정 포함)와 다를 수 있음.
- 새 모듈의 Rhino 8 `import` 동작은 시험하지 않음.
## 명령어 (새 터미널에서)
- 테스트: `cd D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program` 후 `python -m pytest -q`
- `python`이 안 되면: `C:\Users\User\AppData\Local\Programs\Python\Python312\python.exe -m pytest -q`
- git 사용자 이름은 `yjahn` (이 폴더에만 적용, 이메일은 가짜 주소 `yjahn@localhost`)
+43
View File
@@ -0,0 +1,43 @@
# 경로 목록 (헷갈릴 때 여기를 보세요)
작업 중 새 경로가 생기면 이 표에 추가합니다.
## 새 프로그램 (지금 작업하는 곳)
| 이름 | 경로 | 설명 |
|---|---|---|
| 프로젝트 폴더 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program` | 새 교각 철근 프로그램 (git 저장소) |
| 작업 규칙 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\CLAUDE.md` | 언어, 문서, 개발, Git 규칙 |
| 설계 규칙 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\design_rules.md` | KDS 기준, R값, 피복 등 |
| 할 일 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\tasks.md` | 작업 번호 T-001~ |
| 다음 작업 안내 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\next_steps.md` | 내일 이어서 할 일(T-008, T-009)과 확인할 점 |
| 재사용 계획 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\reuse_plan.md` | 기존 스크립트 8개 선별 결과 (T-004) |
| 사용법 (메모장) | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\사용법.txt` | 실행 방법 안내. 더블클릭하면 메모장으로 열림 |
| 재사용 코드 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\` | 철근 .py 파일 |
| 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\` | 계산 모듈 테스트 |
| 계산 모듈 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\rebar_calc.py` | 구부림 반지름, 갈고리 길이 (T-002) |
| 계산 모듈 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_rebar_calc.py` | 25개 테스트 |
| 배치 계산 모듈 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\rebar_layout.py` | 중심에서 바깥 배치, 피복 맞춤, 간격 검사, 수량·중량 (T-003) |
| 좌표 변환 모듈 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\frame.py` | 월드 좌표 ↔ 코핑 로컬 좌표 (s, t, z). 원점과 각도를 값으로 받음 (T-005) |
| 좌표 변환 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_frame.py` | 23개 테스트 |
| 중심선 모듈 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\centerline.py` | 단면 오프셋 교점으로 철근 중심선 꺾임점 계산 (T-006) |
| 중심선 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_centerline.py` | 22개 테스트 |
| 간섭 검사 모듈 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\clash.py` | 철근 중심선 선분 최단거리, 간섭 판정 (T-007) |
| 간섭 검사 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_clash.py` | 23개 테스트 |
| 배치 계산 테스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_rebar_layout.py` | 26개 테스트 |
| 테스트 설정 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\conftest.py` | scripts 폴더를 불러오는 경로 설정 |
## 기존 프로젝트 (참고용, 수정하지 않음)
| 이름 | 경로 | 설명 |
|---|---|---|
| 기존 프로젝트 폴더 | `D:\Rhino-MCP\rhino-mcp-main` | 대호지교 P1 작업과 Rhino MCP 서버 |
| 기존 철근 스크립트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\rhino\` | 01~08 번호 스크립트 |
| 기존 README | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\README.md` | 방법 설명 |
| KDS 추출 텍스트 | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\kds\KDS_24_14_21.txt` | 조항 번호로 검색 |
| KDS 원본 | `D:\★Pier 자료\KDS 24 14 21 콘크리트교 설계기준(한계상태설계법).hwp` | 설계기준 원문 |
## 도구
| 이름 | 경로 | 설명 |
|---|---|---|
| Python 3.12 | `C:\Users\User\AppData\Local\Programs\Python\Python312\python.exe` | 새 터미널에서는 `python`으로도 실행될 수 있음 |
| 가상환경 (필요할 때만) | `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\.venv` | 큰 패키지가 필요해지면 여기에 설치 (아직 없음) |
| pytest | `python -m pytest` | 테스트 실행 (pier-program 폴더에서) |
+37
View File
@@ -0,0 +1,37 @@
# 기존 스크립트 재사용 계획 (T-004)
대상: `D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\rebar_pipeline\rhino\` 의 스크립트 8개. 원본은 수정하지 않습니다.
## 먼저 알아둘 점
- 8개 모두 **Rhino 안에서만 실행**되고, 계산과 Rhino 호출이 섞여 있습니다. 그대로는 테스트를 만들 수 없습니다.
- 대호지교 P1 전용 숫자(원점 `480714926 / 153833329.5`, 각도 `atan2(4255, 12284)`, 마크 이름, 객체 ID)가 코드 안에 박혀 있습니다.
- 기존 스크립트는 IronPython 2.7용이라 f-string, lambda, 리스트 컴프리헨션을 못 씁니다. 새 모듈(`rebar_calc.py`, `rebar_layout.py`)은 이 제한을 지켜서 썼지만, 실제 Rhino에서 불러 쓰는 것은 아직 확인하지 않았습니다.
- 그래서 방법은 따라가되, **계산 부분만 뽑아 테스트 가능한 모듈로 새로 만드는 방식**을 권합니다.
## 선별표
| 스크립트 | 하는 일 | 판단 | 이유 |
|---|---|---|---|
| 01_section_profile.py | 코핑의 길이방향 각도와 단면 외곽 (s, z) 추출 | **재사용 (우선순위 높음)** | 모든 교각에서 가장 먼저 필요합니다. 각도 히스토그램과 좌표 변환은 순수 계산으로 분리 가능합니다. |
| 02_build_shape_VII.py | 단면을 안쪽 120 오프셋한 직선의 교점으로 철근 중심선을 만들고 필렛과 파이프로 생성 | **재사용 (핵심)** | 오프셋 교점은 순수 2D 계산이라 테스트 가능합니다. 필렛과 파이프 생성은 Rhino 쪽에 둡니다. 숫자가 형식 VII에 고정돼 있어 일반화가 필요합니다. |
| 03_audit.py | 생성된 철근을 다시 중심선으로 복원해 치수, 각도, 피복 검사 | 재사용 (보통) | 검증용으로 유용합니다. 08과 중심선 복원 부분이 겹칩니다. |
| 04_table_writer.py | 철근재료표 DXF 작성 | **재사용 (우선순위 높음)** | DXF 문자열 만들기는 순수 계산이라 테스트 가능합니다. 단위중량이 `rebar_layout.UNIT_WEIGHT`와 중복되므로 한곳으로 합칩니다. |
| 05_realign_base_pieces.py | ⊔ 바닥 조각을 ⊓ 뚜껑 평면에 맞춰 재정렬 | **재사용 안 함 (참고만)** | P1에서 생긴 어긋남을 고친 일회성 보정 코드입니다. 새 프로그램이 처음부터 맞게 만들면 필요 없습니다. |
| 06_flip_hooks.py | 갈고리 방향이 반대인 철근을 거울 반전 | **재사용 안 함 (참고만)** | 도면과 달랐던 부분을 고친 일회성 코드입니다. 대신 갈고리 방향을 입력값으로 받게 설계합니다. |
| 07_ctc_extract.py | 철근 중심을 코핑 로컬 좌표로 뽑아 도면 `@` 간격과 비교 | 재사용 (보통) | 검증에 유용합니다. 좌표 변환은 01과 공통 모듈로 합칩니다. |
| 08_clash_check.py | 철근 중심선끼리 간섭 검사 | **재사용 (우선순위 높음)** | 선분 사이 최단거리 계산은 순수 수학이라 테스트가 쉽고, 어느 프로젝트에나 필요한 품질 검사입니다. |
## 공통 문제: 좌표 변환이 5곳에 복붙되어 있음
02, 05, 06, 07, 08 모두 `OX, OY, ANG`로 월드 좌표와 코핑 로컬 좌표 (s, t, z)를 바꾸는 코드를 각자 갖고 있습니다.
**가장 먼저 이 변환을 하나의 모듈(`frame.py`)로 만들고 테스트하는 것**을 권합니다. 이후 모든 스크립트가 이 모듈을 씁니다.
## 권장 순서 (tasks.md에 T-005~T-009로 추가)
1. 코핑 좌표 변환 `frame.py` (월드 ↔ 로컬)
2. 단면 오프셋 교점으로 중심선 꺾임점 계산 (02의 순수 계산 부분)
3. 선분 최단거리와 간섭 판정 (08의 순수 계산 부분)
4. 재료표 DXF 작성 (04)
5. Rhino 연결 코드 (필렛, 파이프, 블록, 레이어): 위 모듈을 불러서 실행
## 확인이 필요한 결정
- 레이어 이름 규칙과 형식 코드(I~VIII)를 새 프로그램에서 어떻게 일반화할지 (`design_rules.md`의 "확인 필요"와 같은 항목)
- 새 모듈을 Rhino 8의 IronPython 2.7에서 `import` 할 때 경로를 어떻게 지정할지 (실제 Rhino에서 확인 필요)
+13
View File
@@ -0,0 +1,13 @@
# 할 일
| 번호 | 내용 | 예상 시간 | 상태 |
|---|---|---|---|
| T-001 | Python 설치와 pytest 실행 확인 | 0.5시간 | 완료 (Python 3.12.10, pytest 9.1.1) |
| T-002 | 계산 모듈 뼈대 `scripts/rebar_calc.py` (구부림 반지름, 갈고리 연장 길이) + 테스트 | 1시간 | 완료 (테스트 25개 통과) |
| T-003 | 철근 간격·수량 계산 (중심에서 바깥으로 배치, 피복 맞춤) + 테스트 | 2시간 | 완료 (테스트 51개 통과, `scripts/rebar_layout.py`) |
| T-004 | 기존 `rebar_pipeline/rhino/` 스크립트 중 재사용할 것 선별 | 1시간 | 완료 (`docs/reuse_plan.md`: 재사용 4개, 보통 2개, 참고만 2개) |
| T-005 | 코핑 좌표 변환 `scripts/frame.py` (월드 ↔ 로컬 s, t, z) + 테스트 | 1.5시간 | 완료 (테스트 74개 통과, `scripts/frame.py`) |
| T-006 | 단면 오프셋 교점으로 철근 중심선 꺾임점 계산 + 테스트 | 3시간 | 완료 (테스트 96개 통과, `scripts/centerline.py`, 형식 VII 도면 b=4167.1, c=3158.8 재현) |
| T-007 | 선분 최단거리와 간섭 판정 + 테스트 | 2시간 | 완료 (테스트 119개 통과, `scripts/clash.py`, 무작위 검증 포함) |
| T-008 | 철근재료표 DXF 작성 (문자열 생성 부분) + 테스트 | 3시간 | 대기 |
| T-009 | Rhino 연결 코드 (필렛, 파이프, 블록, 레이어) 와 import 경로 확인 | 4시간 | 대기 (레이어·형식 코드 일반화 결정 필요) |
+123
View File
@@ -0,0 +1,123 @@
"""단면 외곽선에서 철근 중심선 꺾임점을 계산하는 모듈.
Rhino 없이 실행됩니다. 단위는 mm, 각도는 도(degree)입니다.
2D 좌표는 (s, z): s = 길이방향, z = 높이 (frame.py 의 로컬 좌표와 같은 방향).
방법 (기존 프로젝트 rebar_pipeline/README.md §3):
1. 단면 외곽의 각 면을 안쪽으로 피복만큼 옮긴 직선으로 바꿉니다.
2. 이웃한 두 직선의 교점이 철근 중심선의 꺾임점입니다.
3. 시작과 끝은 도면의 고정 길이만큼 연장합니다.
"""
import math
EPS = 1e-9
def _unit_direction(p0, p1):
dx = p1[0] - p0[0]
dz = p1[1] - p0[1]
length = math.hypot(dx, dz)
if length < EPS:
raise ValueError("길이가 0인 선분입니다: %r" % (p0,))
return (dx / length, dz / length)
def _normal(direction, side):
"""진행 방향의 왼쪽 또는 오른쪽 단위 법선."""
dx, dz = direction
if side == "left":
return (-dz, dx)
if side == "right":
return (dz, -dx)
raise ValueError("side 는 'left' 또는 'right' 여야 합니다: %r" % (side,))
def offset_line(p0, p1, distance, side):
"""선분 p0->p1 을 진행 방향의 side(left/right)로 distance 만큼 옮긴 직선.
돌려주는 값: (직선 위의 한 점, 진행 방향 단위벡터)
"""
direction = _unit_direction(p0, p1)
n = _normal(direction, side)
point = (p0[0] + n[0] * distance, p0[1] + n[1] * distance)
return point, direction
def line_intersection(line_a, line_b):
"""두 직선 (점, 방향) 의 교점. 평행하면 ValueError."""
(ax, az), (adx, adz) = line_a
(bx, bz), (bdx, bdz) = line_b
cross = adx * bdz - adz * bdx
if abs(cross) < EPS:
raise ValueError("두 직선이 평행해서 교점이 없습니다")
t = ((bx - ax) * bdz - (bz - az) * bdx) / cross
return (ax + adx * t, az + adz * t)
def centerline_points(segments, cover, side, start_len=None, end_len=None):
"""철근 중심선의 꺾임점 목록 (s, z).
segments : 외곽선 선분 목록 [(시작점, 끝점), ...]. 철근이 지나가는 순서와 방향대로 적습니다.
cover : 피복 (철근 중심에서 면까지 거리)
side : 콘크리트 안쪽이 진행 방향의 'left' 인지 'right' 인지
start_len: 첫 꺾임점에서 첫 선분 반대 방향으로 연장할 길이. None 이면 첫 선분의 시작점을 그대로 씁니다.
end_len : 마지막 꺾임점에서 마지막 선분 방향으로 연장할 길이. None 이면 마지막 선분의 끝점을 그대로 씁니다.
"""
if not segments:
raise ValueError("선분이 하나 이상 필요합니다")
lines = [offset_line(p0, p1, cover, side) for (p0, p1) in segments]
if len(lines) == 1:
if start_len is not None or end_len is not None:
raise ValueError("선분이 1개이면 고정 길이를 쓸 수 없습니다")
(p0, d) = lines[0]
length = math.hypot(segments[0][1][0] - segments[0][0][0],
segments[0][1][1] - segments[0][0][1])
return [p0, (p0[0] + d[0] * length, p0[1] + d[1] * length)]
vertices = [line_intersection(lines[i], lines[i + 1]) for i in range(len(lines) - 1)]
if start_len is None:
first = lines[0][0]
else:
d0 = lines[0][1]
first = (vertices[0][0] - d0[0] * start_len, vertices[0][1] - d0[1] * start_len)
if end_len is None:
seg_end = segments[-1][1]
n = _normal(lines[-1][1], side)
last = (seg_end[0] + n[0] * cover, seg_end[1] + n[1] * cover)
else:
dn = lines[-1][1]
last = (vertices[-1][0] + dn[0] * end_len, vertices[-1][1] + dn[1] * end_len)
return [first] + vertices + [last]
def segment_lengths(points):
"""꺾임점 목록의 각 구간 길이."""
return [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(points, points[1:])]
def polyline_length(points):
"""꺾임점을 이은 전체 길이."""
return sum(segment_lengths(points))
def turn_angles(points):
"""각 안쪽 꺾임점에서 진행 방향이 바뀌는 각도(도, 0~180). 직선이면 0, 직각이면 90."""
angles = []
for i in range(1, len(points) - 1):
d1 = _unit_direction(points[i - 1], points[i])
d2 = _unit_direction(points[i], points[i + 1])
dot = max(-1.0, min(1.0, d1[0] * d2[0] + d1[1] * d2[1]))
angles.append(math.degrees(math.acos(dot)))
return angles
def distance_to_line(point, line_p0, line_p1):
"""점에서 (선분이 아니라) 무한 직선 line_p0-line_p1 까지의 수직거리."""
d = _unit_direction(line_p0, line_p1)
vx = point[0] - line_p0[0]
vz = point[1] - line_p0[1]
return abs(vx * d[1] - vz * d[0])
+164
View File
@@ -0,0 +1,164 @@
"""철근 중심선 간섭 검사 모듈.
Rhino 없이 실행됩니다. 단위는 mm. 좌표는 (x, y, z) 3D 점입니다.
계산을 정확하게 하려면 월드 좌표(약 4.8억)보다 frame.py 로 바꾼 로컬 좌표를 쓰는 것이 좋습니다.
규칙 (기존 프로젝트 rebar_pipeline/rhino/08_clash_check.py):
- 철근을 중심선 선분으로 쪼개서, 두 선분의 최단거리 d 에 대해
관입 = rA + rB - d 가 min_penetration(기본 0.5mm)보다 크면 간섭입니다.
- 같은 철근, 같은 그룹(겹침이음 모델링인 박스) 선분끼리는 제외합니다.
- 철근 쌍마다 가장 깊은 관입 하나만 기록합니다.
"""
import math
EPS = 1e-12
BBOX_MARGIN = 1.0 # 후보를 찾을 때 반지름에 더하는 여유(mm)
class Seg(object):
"""철근 중심선의 선분 하나.
p, q : 끝점 (x, y, z)
radius : 철근 반지름
bar : 철근 번호 (같은 철근끼리는 검사 제외)
group : 그룹 번호 (같은 박스의 뚜껑·바닥 조각처럼 검사 제외할 묶음)
mark : 철근 마크 이름 (보고용)
"""
def __init__(self, p, q, radius, bar, group, mark=""):
self.p = (float(p[0]), float(p[1]), float(p[2]))
self.q = (float(q[0]), float(q[1]), float(q[2]))
self.radius = float(radius)
self.bar = bar
self.group = group
self.mark = mark
def _sub(a, b):
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _clamp01(x):
if x < 0.0:
return 0.0
if x > 1.0:
return 1.0
return x
def segment_distance(p1, q1, p2, q2):
"""두 선분 p1-q1, p2-q2 사이의 최단거리 (3D, 길이 0인 선분도 가능)."""
d1 = _sub(q1, p1)
d2 = _sub(q2, p2)
r = _sub(p1, p2)
a = _dot(d1, d1)
e = _dot(d2, d2)
f = _dot(d2, r)
if a <= EPS and e <= EPS:
s = 0.0
t = 0.0
elif a <= EPS:
s = 0.0
t = _clamp01(f / e)
else:
c = _dot(d1, r)
if e <= EPS:
t = 0.0
s = _clamp01(-c / a)
else:
b = _dot(d1, d2)
denom = a * e - b * b
if denom > 1e-12 * a * e:
s = _clamp01((b * f - c * e) / denom)
else:
s = 0.0
t = (b * s + f) / e
if t < 0.0:
t = 0.0
s = _clamp01(-c / a)
elif t > 1.0:
t = 1.0
s = _clamp01((b - c) / a)
c1 = (p1[0] + d1[0] * s, p1[1] + d1[1] * s, p1[2] + d1[2] * s)
c2 = (p2[0] + d2[0] * t, p2[1] + d2[1] * t, p2[2] + d2[2] * t)
diff = _sub(c1, c2)
return math.sqrt(_dot(diff, diff))
def _box(s):
"""선분을 반지름 + 여유만큼 부풀린 상자 (xmin, ymin, zmin, xmax, ymax, zmax)."""
m = s.radius + BBOX_MARGIN
return (min(s.p[0], s.q[0]) - m, min(s.p[1], s.q[1]) - m, min(s.p[2], s.q[2]) - m,
max(s.p[0], s.q[0]) + m, max(s.p[1], s.q[1]) + m, max(s.p[2], s.q[2]) + m)
def _boxes_overlap(a, b):
return (a[0] <= b[3] and b[0] <= a[3] and
a[1] <= b[4] and b[1] <= a[4] and
a[2] <= b[5] and b[2] <= a[5])
def find_clashes(segments, min_penetration=0.5):
"""간섭 목록을 돌려줍니다 (관입이 깊은 것부터).
각 항목: {"bar_a", "bar_b", "mark_a", "mark_b", "penetration", "point"}
bar_a < bar_b 로 정렬되고, point 는 간섭 선분 네 끝점의 평균입니다.
"""
boxes = [_box(s) for s in segments]
order = sorted(range(len(segments)), key=lambda i: boxes[i][0])
best = {}
for oi in range(len(order)):
i = order[oi]
a = segments[i]
for oj in range(oi + 1, len(order)):
j = order[oj]
if boxes[j][0] > boxes[i][3]:
break # x 방향으로 이미 떨어져 있음. 뒤쪽은 더 멀리 있음
b = segments[j]
if a.bar == b.bar or a.group == b.group:
continue
if not _boxes_overlap(boxes[i], boxes[j]):
continue
d = segment_distance(a.p, a.q, b.p, b.q)
pen = a.radius + b.radius - d
if pen <= min_penetration:
continue
first, second = (a, b) if a.bar <= b.bar else (b, a)
key = (first.bar, second.bar)
old = best.get(key)
if old is None or pen > old["penetration"]:
point = ((a.p[0] + a.q[0] + b.p[0] + b.q[0]) / 4.0,
(a.p[1] + a.q[1] + b.p[1] + b.q[1]) / 4.0,
(a.p[2] + a.q[2] + b.p[2] + b.q[2]) / 4.0)
best[key] = {
"bar_a": first.bar, "bar_b": second.bar,
"mark_a": first.mark, "mark_b": second.mark,
"penetration": pen, "point": point,
}
hits = list(best.values())
hits.sort(key=lambda h: -h["penetration"])
return hits
def summarize_by_mark(hits):
"""마크 쌍별 간섭 개수와 최대 관입. 키는 'C1|C8' 처럼 이름순으로 정렬된 마크 쌍."""
summary = {}
for h in hits:
names = sorted([h["mark_a"], h["mark_b"]])
key = names[0] + "|" + names[1]
item = summary.get(key)
if item is None:
summary[key] = {"count": 1, "max_penetration": h["penetration"]}
else:
item["count"] += 1
if h["penetration"] > item["max_penetration"]:
item["max_penetration"] = h["penetration"]
return summary
+75
View File
@@ -0,0 +1,75 @@
"""월드 좌표와 코핑 로컬 좌표를 바꾸는 모듈.
Rhino 없이 실행됩니다. 단위는 mm, 각도는 라디안입니다.
월드 좌표 (x, y, z) <-> 로컬 좌표 (s, t, z)
s = 길이방향 (코핑 축 방향), t = 폭방향 (s를 반시계로 90도 돌린 방향)
z = 월드 높이를 그대로 사용
원점(origin_x, origin_y)과 각도(angle)는 값으로 받으므로 언제든 바꿀 수 있습니다.
기존 프로젝트 rebar_pipeline/rhino/07_ctc_extract.py 의 OX, OY, ANG 방식과 같습니다.
"""
import math
class Frame(object):
"""코핑 로컬 좌표계. 원점(월드 x, y)과 s축 각도(월드 +X 기준, 반시계)로 정해집니다."""
def __init__(self, origin_x, origin_y, angle):
self.origin_x = float(origin_x)
self.origin_y = float(origin_y)
self.angle = float(angle)
self._cos = math.cos(self.angle)
self._sin = math.sin(self.angle)
def to_local(self, x, y, z):
"""월드 점 (x, y, z) -> 로컬 점 (s, t, z)."""
dx = x - self.origin_x
dy = y - self.origin_y
return (dx * self._cos + dy * self._sin,
-dx * self._sin + dy * self._cos,
z)
def to_world(self, s, t, z):
"""로컬 점 (s, t, z) -> 월드 점 (x, y, z)."""
return (self.origin_x + s * self._cos - t * self._sin,
self.origin_y + s * self._sin + t * self._cos,
z)
def direction_to_local(self, dx, dy, dz):
"""월드 방향 벡터 -> 로컬 방향 벡터 (원점 이동 없이 회전만)."""
return (dx * self._cos + dy * self._sin,
-dx * self._sin + dy * self._cos,
dz)
def direction_to_world(self, ds, dt, dz):
"""로컬 방향 벡터 -> 월드 방향 벡터 (원점 이동 없이 회전만)."""
return (ds * self._cos - dt * self._sin,
ds * self._sin + dt * self._cos,
dz)
def to_local_many(self, points):
"""월드 점 목록 -> 로컬 점 목록."""
return [self.to_local(x, y, z) for (x, y, z) in points]
def to_world_many(self, points):
"""로컬 점 목록 -> 월드 점 목록."""
return [self.to_world(s, t, z) for (s, t, z) in points]
def from_two_points(a, b):
"""두 점 a, b (월드 (x, y))로 프레임을 만듭니다.
원점 = 두 점의 가운데, s축 = a에서 b로 향하는 방향.
(예: 2주식 교각의 두 기둥 중심)
"""
dx = b[0] - a[0]
dy = b[1] - a[1]
if dx == 0 and dy == 0:
raise ValueError("두 점이 같아서 방향을 정할 수 없습니다: %r" % (a,))
return Frame((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, math.atan2(dy, dx))
def convert(point, src, dst):
"""src 프레임의 로컬 점을 dst 프레임의 로컬 점으로 바꿉니다 (월드를 거쳐서)."""
return dst.to_local(*src.to_world(*point))
+90
View File
@@ -0,0 +1,90 @@
"""철근 구부림 반지름과 갈고리 길이 계산 모듈.
Rhino 없이 실행됩니다. 단위는 모두 mm입니다.
기준값 출처: docs/design_rules.md
"""
# KS D 3504 공칭 철근 직경 (mm)
NOMINAL_DIAMETER = {
10: 9.5,
13: 12.7,
16: 15.9,
19: 19.1,
22: 22.2,
25: 25.4,
29: 28.6,
32: 31.8,
35: 34.9,
38: 38.1,
}
# 갈고리 뒤 연장 길이 (공칭 직경의 배수). 프로젝트 적용값
HOOK_EXTENSION_FACTOR = {90: 12.0, 135: 6.0, 180: 4.0}
HOOK_180_MIN_EXTENSION = 60.0 # 180도 갈고리는 4db 또는 60mm 중 큰 값
# KDS 24 14 21: 90도~150도 표준갈고리 연장 길이 최소값 (db의 배수)
KDS_HOOK_MIN_FACTOR = 5.0
def bar_number(name):
"""'H25', 'D25', 'h25' 같은 이름에서 호칭 번호(25)를 꺼냅니다."""
text = str(name).strip().upper()
if len(text) < 2 or text[0] not in ("H", "D") or not text[1:].isdigit():
raise ValueError("알 수 없는 철근 이름입니다: %r" % (name,))
return int(text[1:])
def nominal_diameter(name):
"""철근 이름(예: 'H25')의 공칭 직경을 mm로 돌려줍니다."""
number = bar_number(name)
if number not in NOMINAL_DIAMETER:
raise ValueError("지원하지 않는 철근 규격입니다: %r" % (name,))
return NOMINAL_DIAMETER[number]
def bend_radius(name, is_stirrup=False):
"""프로젝트 적용 구부림 최소 내면 반지름(mm).
D10~D25: 3db, D29~D35: 4db, D38 이상: 5db.
스터럽/띠철근이고 D16 이하이면 2db.
"""
number = bar_number(name)
db = nominal_diameter(name)
if is_stirrup and number <= 16:
return 2.0 * db
if number <= 25:
return 3.0 * db
if number <= 35:
return 4.0 * db
return 5.0 * db
def kds_min_inner_diameter(name):
"""KDS 24 14 21 표 4.5-1 최소 내면 지름(mm). D16 이하 2db, D19 이상 3.5db."""
number = bar_number(name)
db = nominal_diameter(name)
if number <= 16:
return 2.0 * db
return 3.5 * db
def satisfies_kds_bend(name, radius):
"""내면 반지름(mm)이 KDS 최소 내면 지름을 만족하는지 확인합니다."""
return 2.0 * radius >= kds_min_inner_diameter(name)
def hook_extension(name, angle):
"""프로젝트 적용 갈고리 뒤 연장 길이(mm). angle은 90, 135, 180 중 하나."""
if angle not in HOOK_EXTENSION_FACTOR:
raise ValueError("지원하지 않는 갈고리 각도입니다: %r" % (angle,))
length = HOOK_EXTENSION_FACTOR[angle] * nominal_diameter(name)
if angle == 180:
length = max(length, HOOK_180_MIN_EXTENSION)
return length
def kds_min_hook_extension(name, angle):
"""KDS 24 14 21 갈고리 연장 길이 최소값(mm). 90~150도는 5db."""
if not 90 <= angle <= 150:
raise ValueError("KDS 5db 규정은 90~150도에만 적용됩니다: %r" % (angle,))
return KDS_HOOK_MIN_FACTOR * nominal_diameter(name)
+105
View File
@@ -0,0 +1,105 @@
"""철근 배치(간격, 수량, 중량) 계산 모듈.
Rhino 없이 실행됩니다. 단위는 mm이고, 중량만 kg입니다.
기준값 출처: docs/design_rules.md, 기존 프로젝트 rebar_pipeline/README.md
"""
import rebar_calc
# 단위중량 kg/m (기존 프로젝트 재료표 값). 아래에 없는 규격은 값을 확인한 뒤 추가하세요.
UNIT_WEIGHT = {
"H16": 1.560,
"H19": 2.250,
"H22": 3.040,
"H25": 3.980,
"H29": 5.040,
}
DEFAULT_GMAX = 25.0 # 굵은골재 최대치수(mm). 가정값이므로 설계서의 값으로 바꿔 쓰세요.
EPS = 1e-9
def center_out_positions(count, spacing):
"""중심(0)에서 바깥으로 spacing 간격으로 놓은 폭방향 위치 목록(작은 값부터).
짝수 개이면 중심에 철근이 없고(±spacing/2부터), 홀수 개이면 중심(0)에 철근이 있습니다.
"""
if count < 0:
raise ValueError("철근 수는 0 이상이어야 합니다: %r" % (count,))
if spacing <= 0:
raise ValueError("간격은 0보다 커야 합니다: %r" % (spacing,))
mid = (count - 1) / 2.0
return [spacing * (i - mid) for i in range(count)]
def snapped_array(count, spacing, width, cover):
"""바깥 철근 2본은 피복에 맞추고, 나머지는 중심에서 바깥으로 배치합니다 (1CYCLE 방식).
바깥 철근 위치 = ±(width/2 - cover). 안쪽 철근은 중심에서 바깥으로 spacing 간격입니다.
안쪽 철근이 바깥 철근 위치를 넘어가면 ValueError.
"""
if count < 2:
raise ValueError("바깥 철근 2본이 필요하므로 count는 2 이상이어야 합니다: %r" % (count,))
outer = width / 2.0 - cover
inner = center_out_positions(count - 2, spacing)
if inner and max(inner) >= outer:
raise ValueError("폭 %r 안에 철근 %r본을 간격 %r로 놓을 수 없습니다" % (width, count, spacing))
return [-outer] + inner + [outer]
def min_horizontal_clear(name, gmax=DEFAULT_GMAX):
"""수평 최소 순간격 = max(1.5db, 1.5Gmax, 40mm) (KDS 24 14 21 4.5.3)."""
db = rebar_calc.nominal_diameter(name)
return max(1.5 * db, 1.5 * gmax, 40.0)
def clear_spacing(center_spacing, name):
"""중심 간격에서 철근 직경을 뺀 순간격."""
return center_spacing - rebar_calc.nominal_diameter(name)
def horizontal_spacing_ok(positions, name, gmax=DEFAULT_GMAX):
"""이웃한 철근 사이 순간격이 모두 수평 최소 순간격 이상이면 True."""
ordered = sorted(positions)
need = min_horizontal_clear(name, gmax)
for a, b in zip(ordered, ordered[1:]):
if clear_spacing(b - a, name) < need - EPS:
return False
return True
def min_vertical_clear(name):
"""다단 배근의 연직 최소 순간격 = max(25mm, db)."""
return max(25.0, rebar_calc.nominal_diameter(name))
def vertical_pitch_ok(name, pitch):
"""단 사이 중심 간격(pitch)의 순간격이 연직 최소 순간격 이상이면 True."""
return clear_spacing(pitch, name) >= min_vertical_clear(name) - EPS
def tier_elevations(top_z, cover, tiers, pitch):
"""맨 위 단은 상면에서 cover만큼 아래, 다음 단부터 pitch씩 아래로 내린 높이 목록."""
return [top_z - cover - i * pitch for i in range(tiers)]
def unit_weight(name):
"""단위중량(kg/m)."""
key = "H%d" % rebar_calc.bar_number(name)
if key not in UNIT_WEIGHT:
raise ValueError("단위중량이 등록되지 않은 규격입니다: %r" % (name,))
return UNIT_WEIGHT[key]
def bar_weight(name, length_mm, count=1):
"""철근 length_mm 길이 count본의 중량(kg)."""
return unit_weight(name) * length_mm / 1000.0 * count
def array_summary(name, length_mm, positions):
"""한 배열의 본수, 총길이(m), 중량(kg)."""
count = len(positions)
return {
"count": count,
"total_length_m": length_mm / 1000.0 * count,
"weight_kg": bar_weight(name, length_mm, count),
}
+5
View File
@@ -0,0 +1,5 @@
"""테스트에서 scripts/ 폴더의 모듈을 불러올 수 있게 경로를 추가합니다."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
+177
View File
@@ -0,0 +1,177 @@
"""centerline 테스트 (Rhino 없이 실행됩니다).
단면 외곽선을 안쪽으로 피복만큼 옮긴 직선들의 교점으로 철근 중심선 꺾임점을 구합니다.
2D 좌표는 (s, z): s = 길이방향, z = 높이.
기준: 기존 프로젝트 rebar_pipeline/rhino/02_build_shape_VII.py (형식 VII, 도면 b=4167.1, c=3158.8)
"""
import math
import pytest
import centerline as cl
# ---- 직선 오프셋 ----
def test_offset_line_right_side_of_rightward_segment_is_below():
# +s 방향으로 가는 선분의 오른쪽은 아래(-z)
point, direction = cl.offset_line((0.0, 0.0), (100.0, 0.0), 10.0, "right")
assert point == pytest.approx((0.0, -10.0))
assert direction == pytest.approx((1.0, 0.0))
def test_offset_line_left_side_of_rightward_segment_is_above():
point, direction = cl.offset_line((0.0, 0.0), (100.0, 0.0), 10.0, "left")
assert point == pytest.approx((0.0, 10.0))
def test_offset_line_downward_segment_right_side_is_minus_s():
# 아래로 가는 선분의 오른쪽은 -s 쪽 (끝면 s=12100 에서 안쪽)
point, _ = cl.offset_line((12100.0, 10738.0), (12100.0, 9238.0), 120.0, "right")
assert point[0] == pytest.approx(11980.0)
def test_offset_line_direction_is_unit_length():
_, d = cl.offset_line((0.0, 0.0), (30.0, 40.0), 5.0, "left")
assert math.hypot(*d) == pytest.approx(1.0)
def test_offset_line_rejects_zero_length_segment():
with pytest.raises(ValueError):
cl.offset_line((1.0, 1.0), (1.0, 1.0), 10.0, "left")
def test_offset_line_rejects_bad_side():
with pytest.raises(ValueError):
cl.offset_line((0.0, 0.0), (1.0, 0.0), 10.0, "up")
# ---- 직선 교점 ----
def test_line_intersection_perpendicular_lines():
a = ((0.0, 5.0), (1.0, 0.0))
b = ((7.0, 0.0), (0.0, 1.0))
assert cl.line_intersection(a, b) == pytest.approx((7.0, 5.0))
def test_line_intersection_diagonal():
a = ((0.0, 0.0), (1.0, 1.0))
b = ((10.0, 0.0), (-1.0, 1.0))
assert cl.line_intersection(a, b) == pytest.approx((5.0, 5.0))
def test_line_intersection_parallel_raises():
a = ((0.0, 0.0), (1.0, 0.0))
b = ((0.0, 5.0), (1.0, 0.0))
with pytest.raises(ValueError):
cl.line_intersection(a, b)
# ---- 중심선 꺾임점: 단순한 경우 ----
def test_centerline_of_u_shape_inside_a_box():
# 바깥 외곽: 위에서 왼쪽 벽을 따라 내려가 바닥을 지나 오른쪽 벽을 올라가는 ㄷ자 경로
segs = [
((0.0, 100.0), (0.0, 0.0)), # 왼쪽 벽, 아래로
((0.0, 0.0), (200.0, 0.0)), # 바닥, 오른쪽으로
((200.0, 0.0), (200.0, 100.0)), # 오른쪽 벽, 위로
]
pts = cl.centerline_points(segs, 10.0, "left")
# 아래로 가는 선분의 왼쪽은 +s, 오른쪽으로 가는 선분의 왼쪽은 +z (안쪽)
assert pts[0] == pytest.approx((10.0, 100.0))
assert pts[1] == pytest.approx((10.0, 10.0))
assert pts[2] == pytest.approx((190.0, 10.0))
assert pts[3] == pytest.approx((190.0, 100.0))
def test_centerline_fixed_start_and_end_lengths():
segs = [
((0.0, 100.0), (0.0, 0.0)),
((0.0, 0.0), (200.0, 0.0)),
((200.0, 0.0), (200.0, 100.0)),
]
pts = cl.centerline_points(segs, 10.0, "left", start_len=30.0, end_len=40.0)
assert pts[0] == pytest.approx((10.0, 40.0)) # 첫 꺾임점(10,10)에서 위로 30
assert pts[-1] == pytest.approx((190.0, 50.0)) # 마지막 꺾임점(190,10)에서 위로 40
assert len(pts) == 4
def test_centerline_single_segment_is_just_offset():
pts = cl.centerline_points([((0.0, 0.0), (100.0, 0.0))], 10.0, "left")
assert pts == [pytest.approx((0.0, 10.0)), pytest.approx((100.0, 10.0))]
def test_centerline_single_segment_rejects_fixed_lengths():
with pytest.raises(ValueError):
cl.centerline_points([((0.0, 0.0), (100.0, 0.0))], 10.0, "left", start_len=5.0)
def test_centerline_parallel_neighbors_raise():
segs = [((0.0, 0.0), (100.0, 0.0)), ((100.0, 0.0), (200.0, 0.0))]
with pytest.raises(ValueError):
cl.centerline_points(segs, 10.0, "left")
def test_centerline_rejects_empty():
with pytest.raises(ValueError):
cl.centerline_points([], 10.0, "left")
# ---- 길이와 꺾임각 ----
def test_segment_lengths_and_total():
pts = [(0.0, 0.0), (3.0, 4.0), (3.0, 10.0)]
assert cl.segment_lengths(pts) == pytest.approx([5.0, 6.0])
assert cl.polyline_length(pts) == pytest.approx(11.0)
def test_turn_angles_right_angle():
pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
assert cl.turn_angles(pts) == pytest.approx([90.0])
def test_turn_angles_straight_is_zero():
pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
assert cl.turn_angles(pts) == pytest.approx([0.0])
# ---- 형식 VII (대호지교 P1 C8/C10) 도면 값 재현 ----
P1_VII_SEGMENTS = [
((12100.0, 10738.0), (12100.0, 9238.0)), # 끝면, 아래로
((12100.0, 9238.0), (8100.0, 7738.0)), # 끝헌치
((8100.0, 7738.0), (4900.0, 7738.0)), # 바닥
((4900.0, 7738.0), (3400.0, 8238.0)), # 내측헌치
]
def test_shape_vii_matches_drawing_b_and_c():
pts = cl.centerline_points(P1_VII_SEGMENTS, 120.0, "right", start_len=630.0, end_len=2331.0)
lengths = cl.segment_lengths(pts)
assert len(pts) == 5
assert lengths[0] == pytest.approx(630.0) # a
assert lengths[1] == pytest.approx(4167.1, abs=0.1) # b (도면)
assert lengths[2] == pytest.approx(3158.8, abs=0.1) # c (도면)
assert lengths[3] == pytest.approx(2331.0) # d
def test_shape_vii_key_points():
pts = cl.centerline_points(P1_VII_SEGMENTS, 120.0, "right", start_len=630.0, end_len=2331.0)
assert pts[0] == pytest.approx((11980.0, 9951.16), abs=0.01) # A
assert pts[1] == pytest.approx((11980.0, 9321.16), abs=0.01) # 끝면-끝헌치 교점
assert pts[2][1] == pytest.approx(7858.0) # 바닥 위 120 (7738+120)
assert pts[3][1] == pytest.approx(7858.0)
assert pts[3][0] == pytest.approx(4919.47, abs=0.01)
def test_shape_vii_cover_is_120_from_every_face():
# 중심선 위의 각 꺾임점은 이웃한 두 면에서 모두 120 떨어져 있어야 합니다.
pts = cl.centerline_points(P1_VII_SEGMENTS, 120.0, "right", start_len=630.0, end_len=2331.0)
for i, (p0, p1) in enumerate(P1_VII_SEGMENTS[:-1]):
vertex = pts[i + 1]
for seg in (P1_VII_SEGMENTS[i], P1_VII_SEGMENTS[i + 1]):
assert cl.distance_to_line(vertex, seg[0], seg[1]) == pytest.approx(120.0, abs=1e-6)
def test_distance_to_line_basic():
assert cl.distance_to_line((5.0, 7.0), (0.0, 0.0), (10.0, 0.0)) == pytest.approx(7.0)
+197
View File
@@ -0,0 +1,197 @@
"""clash 테스트 (Rhino 없이 실행됩니다).
철근 중심선(선분)끼리의 간섭 검사.
기준: 기존 프로젝트 rebar_pipeline/rhino/08_clash_check.py
- 간섭 = rA + rB - (두 선분 최단거리) > 0.5mm
- 같은 철근, 같은 박스(겹침이음 모델링) 선분끼리는 제외
- 철근 쌍마다 가장 깊은 관입 하나만 기록
"""
import time
import pytest
import clash as ck
def seg(p, q, radius=12.7, bar=0, group=0, mark="C1"):
return ck.Seg(p, q, radius, bar, group, mark)
# ---- 선분 사이 최단거리 (3D) ----
def test_distance_crossing_skew_segments():
# x축 선분과, z=3 높이에서 y 방향으로 가로지르는 선분 -> 3
d = ck.segment_distance((-5, 0, 0), (5, 0, 0), (0, -5, 3), (0, 5, 3))
assert d == pytest.approx(3.0)
def test_distance_parallel_segments():
d = ck.segment_distance((0, 0, 0), (10, 0, 0), (0, 4, 0), (10, 4, 0))
assert d == pytest.approx(4.0)
def test_distance_intersecting_segments_is_zero():
d = ck.segment_distance((-5, 0, 0), (5, 0, 0), (0, -5, 0), (0, 5, 0))
assert d == pytest.approx(0.0)
def test_distance_collinear_with_gap():
d = ck.segment_distance((0, 0, 0), (1, 0, 0), (3, 0, 0), (4, 0, 0))
assert d == pytest.approx(2.0)
def test_distance_closest_at_endpoint():
d = ck.segment_distance((0, 0, 0), (10, 0, 0), (12, 5, 0), (12, 10, 0))
assert d == pytest.approx((2 ** 2 + 5 ** 2) ** 0.5)
def test_distance_is_symmetric():
a = ((0, 0, 0), (10, 3, 1))
b = ((4, 8, -2), (9, -6, 5))
assert ck.segment_distance(*(a + b)) == pytest.approx(ck.segment_distance(*(b + a)))
def test_distance_point_to_segment():
# 길이 0 선분(점)과 선분
d = ck.segment_distance((5, 3, 0), (5, 3, 0), (0, 0, 0), (10, 0, 0))
assert d == pytest.approx(3.0)
def test_distance_two_points():
d = ck.segment_distance((0, 0, 0), (0, 0, 0), (3, 4, 0), (3, 4, 0))
assert d == pytest.approx(5.0)
def test_distance_matches_brute_force_on_random_segments():
# 무작위 선분 200쌍을 "촘촘히 점을 찍어 가장 가까운 쌍을 찾는 느린 방법"과 비교
import random
rng = random.Random(12345)
def pt():
return (rng.uniform(-50, 50), rng.uniform(-50, 50), rng.uniform(-50, 50))
n = 60
for _ in range(200):
p1, q1, p2, q2 = pt(), pt(), pt(), pt()
best = None
for i in range(n + 1):
u = i / float(n)
a = tuple(p1[k] + (q1[k] - p1[k]) * u for k in range(3))
for j in range(n + 1):
v = j / float(n)
b = tuple(p2[k] + (q2[k] - p2[k]) * v for k in range(3))
dist = sum((a[k] - b[k]) ** 2 for k in range(3)) ** 0.5
if best is None or dist < best:
best = dist
fast = ck.segment_distance(p1, q1, p2, q2)
# 정확한 값은 점 찍기 값보다 작거나 같고, 격자 간격만큼만 차이가 남
assert fast <= best + 1e-9
assert best - fast < 3.0
# ---- 간섭 판정 ----
def crossing_pair(gap, **kw):
"""x축 선분과, z = gap 높이에서 y 방향으로 가로지르는 선분."""
a = seg((-100, 0, 0), (100, 0, 0), bar=1, group=1, **kw)
b = seg((0, -100, gap), (0, 100, gap), bar=2, group=2, **kw)
return [a, b]
def test_clash_found_when_bars_overlap():
hits = ck.find_clashes(crossing_pair(20.0)) # 25.4 - 20 = 5.4 관입
assert len(hits) == 1
assert hits[0]["penetration"] == pytest.approx(5.4)
def test_no_clash_when_touching():
assert ck.find_clashes(crossing_pair(25.4)) == []
def test_no_clash_below_min_penetration():
# 관입 0.4mm 는 기준 0.5mm 이하라서 간섭이 아님
assert ck.find_clashes(crossing_pair(25.0)) == []
def test_clash_when_just_above_min_penetration():
assert len(ck.find_clashes(crossing_pair(24.8))) == 1 # 관입 0.6
def test_min_penetration_can_be_changed():
assert len(ck.find_clashes(crossing_pair(25.0), min_penetration=0.1)) == 1
def test_different_radii_are_added():
a = seg((-100, 0, 0), (100, 0, 0), radius=14.3, bar=1, group=1)
b = seg((0, -100, 20), (0, 100, 20), radius=9.55, bar=2, group=2)
hits = ck.find_clashes([a, b])
assert hits[0]["penetration"] == pytest.approx(14.3 + 9.55 - 20.0)
def test_same_bar_is_skipped():
a = seg((-100, 0, 0), (100, 0, 0), bar=1, group=1)
b = seg((0, -100, 10), (0, 100, 10), bar=1, group=2)
assert ck.find_clashes([a, b]) == []
def test_same_group_is_skipped():
# 같은 박스 안의 뚜껑과 바닥 조각은 겹침이음이라 간섭으로 보지 않음
a = seg((-100, 0, 0), (100, 0, 0), bar=1, group=7)
b = seg((0, -100, 10), (0, 100, 10), bar=2, group=7)
assert ck.find_clashes([a, b]) == []
def test_far_apart_bars_are_not_checked():
a = seg((0, 0, 0), (100, 0, 0), bar=1, group=1)
b = seg((0, 0, 5000), (100, 0, 5000), bar=2, group=2)
assert ck.find_clashes([a, b]) == []
def test_only_deepest_penetration_per_bar_pair_is_kept():
# 같은 두 철근이 두 곳에서 겹침 (관입 5.4 와 10.4)
a1 = seg((-100, 0, 0), (0, 0, 0), bar=1, group=1)
a2 = seg((0, 0, 0), (100, 0, 0), bar=1, group=1)
b1 = seg((-50, -100, 20), (-50, 100, 20), bar=2, group=2) # a1 과 간격 20 -> 관입 5.4
b2 = seg((50, -100, 15), (50, 100, 15), bar=2, group=2) # a2 와 간격 15 -> 관입 10.4
hits = ck.find_clashes([a1, a2, b1, b2])
assert len(hits) == 1
assert hits[0]["penetration"] == pytest.approx(10.4)
def test_bar_pair_reported_once_regardless_of_order():
a = seg((-100, 0, 0), (100, 0, 0), bar=5, group=1)
b = seg((0, -100, 10), (0, 100, 10), bar=2, group=2)
hits = ck.find_clashes([a, b])
assert len(hits) == 1
assert (hits[0]["bar_a"], hits[0]["bar_b"]) == (2, 5)
def test_hit_has_marks_and_location():
a = seg((-100, 0, 0), (100, 0, 0), bar=1, group=1, mark="C1")
b = seg((0, -100, 20), (0, 100, 20), bar=2, group=2, mark="C8")
h = ck.find_clashes([a, b])[0]
assert (h["mark_a"], h["mark_b"]) == ("C1", "C8")
# 위치 = 네 끝점의 평균
assert h["point"] == pytest.approx((0.0, 0.0, 10.0))
def test_parallel_bars_at_250_have_no_clash_and_run_fast():
segs = []
for i in range(300):
segs.append(seg((i * 250.0, 0, 0), (i * 250.0, 5000, 0), bar=i, group=i))
start = time.time()
assert ck.find_clashes(segs) == []
assert time.time() - start < 2.0
# ---- 마크 쌍별 요약 ----
def test_summarize_by_mark_pair():
hits = [
{"mark_a": "C1", "mark_b": "C8", "penetration": 3.0},
{"mark_a": "C8", "mark_b": "C1", "penetration": 7.0},
{"mark_a": "C2", "mark_b": "C2", "penetration": 1.5},
]
s = ck.summarize_by_mark(hits)
assert s["C1|C8"] == {"count": 2, "max_penetration": 7.0}
assert s["C2|C2"] == {"count": 1, "max_penetration": 1.5}
+168
View File
@@ -0,0 +1,168 @@
"""frame 테스트 (Rhino 없이 실행됩니다).
월드 좌표 (x, y, z) <-> 코핑 로컬 좌표 (s, t, z)
s = 길이방향 (코핑 축), t = 폭방향 (s를 반시계로 90도 돌린 방향), z = 월드 높이 그대로
기존 프로젝트: rebar_pipeline/rhino/07_ctc_extract.py 의 OX, OY, ANG 방식과 같습니다.
"""
import math
import pytest
import frame as fr
# ---- 기본 변환 ----
def test_identity_frame_keeps_coordinates():
f = fr.Frame(0.0, 0.0, 0.0)
assert f.to_local(10.0, 20.0, 5.0) == pytest.approx((10.0, 20.0, 5.0))
def test_origin_maps_to_zero_zero():
f = fr.Frame(1000.0, 2000.0, 0.3)
s, t, z = f.to_local(1000.0, 2000.0, 77.0)
assert (s, t) == pytest.approx((0.0, 0.0))
assert z == 77.0
def test_z_is_not_changed():
f = fr.Frame(1000.0, 2000.0, 0.3)
assert f.to_local(1500.0, 2500.0, 9331.0)[2] == 9331.0
assert f.to_world(10.0, 20.0, 9331.0)[2] == 9331.0
def test_angle_90_degrees_swaps_axes():
f = fr.Frame(0.0, 0.0, math.pi / 2)
# 월드 +Y 방향이 로컬 +s 방향
assert f.to_local(0.0, 100.0, 0.0) == pytest.approx((100.0, 0.0, 0.0))
# 월드 -X 방향이 로컬 +t 방향
assert f.to_local(-50.0, 0.0, 0.0) == pytest.approx((0.0, 50.0, 0.0))
def test_angle_45_degrees():
f = fr.Frame(0.0, 0.0, math.pi / 4)
r = math.sqrt(2.0) * 50.0
assert f.to_local(50.0, 50.0, 0.0) == pytest.approx((r, 0.0, 0.0))
def test_to_world_basic():
f = fr.Frame(100.0, 200.0, math.pi / 2)
# 로컬 s=10 -> 월드 +Y 방향으로 10
assert f.to_world(10.0, 0.0, 0.0) == pytest.approx((100.0, 210.0, 0.0))
# 로컬 t=10 -> 월드 -X 방향으로 10
assert f.to_world(0.0, 10.0, 0.0) == pytest.approx((90.0, 200.0, 0.0))
# ---- 왕복 변환 (월드 -> 로컬 -> 월드가 원래 값이어야 함) ----
@pytest.mark.parametrize("point", [
(480708784.0, 153831202.0, 7738.0),
(480721068.0, 153835457.0, -342.0),
(0.0, 0.0, 0.0),
(-5.5, 1234.5, 10.0),
])
def test_round_trip_world_local_world(point):
f = fr.from_two_points((480708784.0, 153831202.0), (480721068.0, 153835457.0))
back = f.to_world(*f.to_local(*point))
assert back == pytest.approx(point, abs=1e-6)
def test_round_trip_local_world_local():
f = fr.Frame(480714926.0, 153833329.5, 0.3333)
local = (1234.5, -678.9, 10405.3)
back = f.to_local(*f.to_world(*local))
assert back == pytest.approx(local, abs=1e-6)
# ---- 두 기둥 중심으로 프레임 만들기 (대호지교 P1 값) ----
def test_from_two_points_origin_is_midpoint():
f = fr.from_two_points((480708784.0, 153831202.0), (480721068.0, 153835457.0))
assert f.origin_x == pytest.approx(480714926.0)
assert f.origin_y == pytest.approx(153833329.5)
def test_from_two_points_angle_matches_p1():
f = fr.from_two_points((480708784.0, 153831202.0), (480721068.0, 153835457.0))
assert f.angle == pytest.approx(math.atan2(4255.0, 12284.0))
assert math.degrees(f.angle) == pytest.approx(19.105, abs=0.001)
def test_p1_columns_are_on_s_axis_at_half_spacing():
a = (480708784.0, 153831202.0)
b = (480721068.0, 153835457.0)
f = fr.from_two_points(a, b)
sa, ta, _ = f.to_local(a[0], a[1], 0.0)
sb, tb, _ = f.to_local(b[0], b[1], 0.0)
# 기둥 중심간격 13,000 (좌표 반올림 때문에 0.1mm 이내)
assert sa == pytest.approx(-6500.0, abs=0.1)
assert sb == pytest.approx(6500.0, abs=0.1)
assert ta == pytest.approx(0.0, abs=1e-6)
assert tb == pytest.approx(0.0, abs=1e-6)
def test_from_two_points_rejects_same_point():
with pytest.raises(ValueError):
fr.from_two_points((1.0, 1.0), (1.0, 1.0))
# ---- 프레임 바꾸기 (언제든 다시 바꿀 수 있어야 함) ----
def test_convert_between_frames_matches_going_through_world():
f1 = fr.Frame(100.0, 200.0, 0.2)
f2 = fr.Frame(-500.0, 40.0, 1.1)
local1 = (321.0, -45.0, 12.0)
via_world = f2.to_local(*f1.to_world(*local1))
assert fr.convert(local1, f1, f2) == pytest.approx(via_world, abs=1e-6)
def test_convert_there_and_back_gives_original():
f1 = fr.Frame(100.0, 200.0, 0.2)
f2 = fr.Frame(-500.0, 40.0, 1.1)
local1 = (321.0, -45.0, 12.0)
back = fr.convert(fr.convert(local1, f1, f2), f2, f1)
assert back == pytest.approx(local1, abs=1e-6)
def test_convert_same_frame_changes_nothing():
f = fr.Frame(100.0, 200.0, 0.2)
assert fr.convert((1.0, 2.0, 3.0), f, f) == pytest.approx((1.0, 2.0, 3.0))
def test_new_origin_changes_local_values_but_not_world():
# 원점만 바꿔도 월드 위치는 그대로, 로컬 값만 달라집니다.
world = (1000.0, 2000.0, 5.0)
f1 = fr.Frame(0.0, 0.0, 0.3)
f2 = fr.Frame(500.0, 500.0, 0.3)
l1 = f1.to_local(*world)
l2 = f2.to_local(*world)
assert l1 != pytest.approx(l2)
assert f1.to_world(*l1) == pytest.approx(world, abs=1e-6)
assert f2.to_world(*l2) == pytest.approx(world, abs=1e-6)
# ---- 방향 벡터 (이동 없이 회전만) ----
def test_direction_to_local_ignores_origin():
f = fr.Frame(1000.0, 2000.0, math.pi / 2)
assert f.direction_to_local(0.0, 1.0, 0.0) == pytest.approx((1.0, 0.0, 0.0))
def test_direction_round_trip():
f = fr.Frame(1000.0, 2000.0, 0.7)
d = (0.3, -0.8, 0.5)
assert f.direction_to_world(*f.direction_to_local(*d)) == pytest.approx(d)
# ---- 점 여러 개 ----
def test_to_local_many_matches_single_calls():
f = fr.Frame(10.0, 20.0, 0.4)
pts = [(1.0, 2.0, 3.0), (100.0, -50.0, 7.0)]
assert f.to_local_many(pts) == [f.to_local(*p) for p in pts]
def test_to_world_many_matches_single_calls():
f = fr.Frame(10.0, 20.0, 0.4)
pts = [(1.0, 2.0, 3.0), (100.0, -50.0, 7.0)]
assert f.to_world_many(pts) == [f.to_world(*p) for p in pts]
+117
View File
@@ -0,0 +1,117 @@
"""rebar_calc 테스트 (Rhino 없이 실행됩니다).
기준값 출처: docs/design_rules.md
"""
import pytest
import rebar_calc as rc
# ---- 철근 이름 -> 공칭 직경 ----
@pytest.mark.parametrize("name, expected", [
("H13", 12.7),
("H16", 15.9),
("H19", 19.1),
("H22", 22.2),
("H25", 25.4),
("H29", 28.6),
])
def test_nominal_diameter(name, expected):
assert rc.nominal_diameter(name) == expected
def test_nominal_diameter_accepts_d_prefix_and_lowercase():
assert rc.nominal_diameter("D25") == 25.4
assert rc.nominal_diameter("h25") == 25.4
def test_nominal_diameter_unknown_bar_raises():
with pytest.raises(ValueError):
rc.nominal_diameter("H99")
# ---- 구부림 반지름 (프로젝트 적용값, 건축기준 기반) ----
def test_bend_radius_h25_is_3db():
# 두부보 H25 주철근: R = 76.2mm
assert rc.bend_radius("H25") == pytest.approx(76.2)
def test_bend_radius_h29_is_4db():
# 두부보 H29: R = 114.4mm
assert rc.bend_radius("H29") == pytest.approx(114.4)
def test_bend_radius_h38_is_5db():
assert rc.bend_radius("H38") == pytest.approx(5 * 38.1)
def test_bend_radius_stirrup_d16_or_less_is_2db():
assert rc.bend_radius("H16", is_stirrup=True) == pytest.approx(2 * 15.9)
assert rc.bend_radius("H13", is_stirrup=True) == pytest.approx(2 * 12.7)
def test_bend_radius_stirrup_d19_or_more_uses_general_table():
assert rc.bend_radius("H19", is_stirrup=True) == pytest.approx(3 * 19.1)
def test_bend_radius_main_bar_h16_is_3db_not_stirrup_rule():
assert rc.bend_radius("H16") == pytest.approx(3 * 15.9)
# ---- KDS 24 14 21 최소 내면 지름 ----
def test_kds_min_inner_diameter_d16_or_less_is_2db():
assert rc.kds_min_inner_diameter("H16") == pytest.approx(2 * 15.9)
def test_kds_min_inner_diameter_d19_or_more_is_3_5db():
assert rc.kds_min_inner_diameter("H25") == pytest.approx(3.5 * 25.4)
def test_project_radius_satisfies_kds_for_all_bars():
# 프로젝트 R(반지름)의 2배 = 내면 지름이 KDS 최소값 이상이어야 합니다.
for name in ["H13", "H16", "H19", "H22", "H25", "H29"]:
assert rc.satisfies_kds_bend(name, rc.bend_radius(name))
assert rc.satisfies_kds_bend(name, rc.bend_radius(name, is_stirrup=True))
def test_too_small_radius_fails_kds():
# H25는 내면 지름 88.9mm 이상이 필요 -> 반지름 40mm(지름 80mm)는 부족
assert not rc.satisfies_kds_bend("H25", 40.0)
# ---- 갈고리 연장 길이 ----
def test_hook_extension_90_is_12db():
assert rc.hook_extension("H25", 90) == pytest.approx(12 * 25.4)
def test_hook_extension_135_is_6db():
assert rc.hook_extension("H25", 135) == pytest.approx(6 * 25.4)
def test_hook_extension_180_is_4db():
assert rc.hook_extension("H25", 180) == pytest.approx(4 * 25.4)
def test_hook_extension_180_has_60mm_minimum():
# H13: 4db = 50.8mm < 60mm 이므로 60mm
assert rc.hook_extension("H13", 180) == pytest.approx(60.0)
def test_hook_extension_unsupported_angle_raises():
with pytest.raises(ValueError):
rc.hook_extension("H25", 45)
def test_kds_min_hook_extension_is_5db_for_90_to_150():
assert rc.kds_min_hook_extension("H25", 90) == pytest.approx(5 * 25.4)
assert rc.kds_min_hook_extension("H25", 135) == pytest.approx(5 * 25.4)
def test_project_hook_extension_satisfies_kds_for_90_and_135():
for name in ["H13", "H16", "H19", "H22", "H25", "H29"]:
for angle in (90, 135):
assert rc.hook_extension(name, angle) >= rc.kds_min_hook_extension(name, angle)
@@ -0,0 +1,154 @@
"""rebar_layout 테스트 (Rhino 없이 실행됩니다).
기준: docs/design_rules.md, 기존 프로젝트 rebar_pipeline/README.md §3
"""
import pytest
import rebar_layout as rl
# ---- 중심에서 바깥으로 배치 ----
def test_center_out_even_count_has_no_bar_on_center():
assert rl.center_out_positions(4, 250) == [-375, -125, 125, 375]
def test_center_out_odd_count_has_bar_on_center():
assert rl.center_out_positions(3, 250) == [-250, 0, 250]
def test_center_out_13_bars_goes_to_1500():
# 2CYCLE: 0, ±250 ... ±1500
pos = rl.center_out_positions(13, 250)
assert len(pos) == 13
assert pos[0] == -1500
assert pos[-1] == 1500
assert 0 in pos
def test_center_out_zero_bars_is_empty():
assert rl.center_out_positions(0, 250) == []
def test_center_out_rejects_bad_input():
with pytest.raises(ValueError):
rl.center_out_positions(-1, 250)
with pytest.raises(ValueError):
rl.center_out_positions(5, 0)
# ---- 바깥 철근을 피복에 맞추는 배치 (1CYCLE) ----
def test_snapped_array_matches_1cycle_drawing():
# 두부보 폭 3500, 피복 120, 14본, 간격 250
pos = rl.snapped_array(14, 250, 3500, 120)
assert len(pos) == 14
assert pos[0] == -1630
assert pos[1] == -1375
assert pos[6] == -125
assert pos[7] == 125
assert pos[-2] == 1375
assert pos[-1] == 1630
def test_snapped_array_is_symmetric_and_sorted():
pos = rl.snapped_array(14, 250, 3500, 120)
assert pos == sorted(pos)
assert pos == [-p for p in reversed(pos)]
def test_snapped_array_outer_bars_sit_at_cover():
pos = rl.snapped_array(14, 250, 3500, 120)
assert max(pos) == 3500 / 2 - 120
def test_snapped_array_rejects_too_few_bars():
with pytest.raises(ValueError):
rl.snapped_array(1, 250, 3500, 120)
def test_snapped_array_rejects_when_inner_bars_pass_outer_bars():
# 폭이 너무 좁아서 안쪽 철근이 바깥 철근을 넘어감
with pytest.raises(ValueError):
rl.snapped_array(14, 250, 2000, 120)
# ---- 수평 순간격 (KDS 24 14 21 4.5.3) ----
def test_min_horizontal_clear_h25_is_40mm():
# max(1.5*25.4=38.1, 1.5*25=37.5, 40) = 40
assert rl.min_horizontal_clear("H25", gmax=25) == pytest.approx(40.0)
def test_min_horizontal_clear_h29_uses_1_5db():
assert rl.min_horizontal_clear("H29", gmax=25) == pytest.approx(1.5 * 28.6)
def test_min_horizontal_clear_large_aggregate_governs():
assert rl.min_horizontal_clear("H25", gmax=40) == pytest.approx(60.0)
def test_clear_spacing_is_center_gap_minus_diameter():
assert rl.clear_spacing(250, "H25") == pytest.approx(250 - 25.4)
def test_horizontal_spacing_ok_for_250_array():
pos = rl.snapped_array(14, 250, 3500, 120)
assert rl.horizontal_spacing_ok(pos, "H25", gmax=25)
def test_horizontal_spacing_fails_when_too_close():
# 중심 간격 50 -> 순간격 24.6mm < 40mm
assert not rl.horizontal_spacing_ok([0, 50], "H25", gmax=25)
# ---- 다단 연직 배치 ----
def test_min_vertical_clear_h25_is_db():
# max(25, 25.4) = 25.4
assert rl.min_vertical_clear("H25") == pytest.approx(25.4)
def test_min_vertical_clear_small_bar_uses_25mm():
assert rl.min_vertical_clear("H16") == pytest.approx(25.0)
def test_vertical_pitch_100_ok_for_h25():
assert rl.vertical_pitch_ok("H25", 100)
def test_vertical_pitch_40_fails_for_h25():
# 순간격 14.6mm < 25.4mm
assert not rl.vertical_pitch_ok("H25", 40)
def test_tier_elevations_go_down_from_top_cover():
assert rl.tier_elevations(1000, 120, 2, 100) == [880, 780]
def test_tier_elevations_single_tier():
assert rl.tier_elevations(1000, 120, 1, 100) == [880]
# ---- 수량, 중량 ----
def test_unit_weight_h25():
assert rl.unit_weight("H25") == pytest.approx(3.98)
def test_unit_weight_unknown_bar_raises():
with pytest.raises(ValueError):
rl.unit_weight("H99")
def test_bar_weight_one_bar():
# H25, 9.930m 1본 = 3.98 * 9.93
assert rl.bar_weight("H25", 9930) == pytest.approx(3.98 * 9.93)
def test_array_summary_2cycle_main_bars():
pos = rl.center_out_positions(13, 250)
s = rl.array_summary("H25", 9930, pos)
assert s["count"] == 13
assert s["total_length_m"] == pytest.approx(13 * 9.93)
assert s["weight_kg"] == pytest.approx(3.98 * 9.93 * 13)
+193
View File
@@ -0,0 +1,193 @@
==================================================
교각 철근 프로그램 사용법 (메모장용)
==================================================
[1] 이 프로그램이 뭔가요?
- 철근의 구부림 반지름, 갈고리 길이 같은 값을 계산하는 도구입니다.
- 지금은 계산 모듈 1개(rebar_calc.py)와 테스트가 있습니다.
- 앞으로 철근 수량, 간격, Rhino 배치 코드를 이 폴더에 계속 추가합니다.
[2] 폴더 위치 (전부 D 드라이브)
프로젝트 폴더 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program
계산 모듈 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\scripts\rebar_calc.py
테스트 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\tests\test_rebar_calc.py
설계 규칙 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\design_rules.md
할 일 목록 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\tasks.md
경로 목록 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\paths.md
이 사용법 : D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\사용법.txt
※ 기존 프로젝트 D:\Rhino-MCP\rhino-mcp-main 은 참고용입니다. 수정하지 마세요.
[3] 터미널(PowerShell) 여는 방법
1) 윈도우 탐색기에서 D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program 폴더를 엽니다.
2) 위쪽 주소창을 클릭하고 powershell 이라고 입력한 뒤 Enter.
3) 파란 창이 열리면 이미 프로젝트 폴더 안에 있는 상태입니다.
[4] 테스트 실행 (계산이 맞는지 확인)
터미널에서 아래를 입력하고 Enter:
python -m pytest -q
- 마지막 줄에 "119 passed" 라고 나오면 모두 정상입니다.
(앞의 숫자는 테스트가 늘어나면 같이 늘어납니다. "failed" 가 없으면 정상입니다.)
- "failed" 라고 나오면 어느 계산이 틀렸는지 표시됩니다. 그 내용을 Claude에게 붙여넣으세요.
※ "python" 이 안 되면 아래처럼 전체 경로로 실행하세요:
C:\Users\User\AppData\Local\Programs\Python\Python312\python.exe -m pytest -q
[5] 계산 모듈 직접 써보기
터미널에서 아래를 입력하면 계산기처럼 쓸 수 있습니다.
cd scripts
python
파란 창에 ">>>" 가 나오면 한 줄씩 입력합니다.
>>> import rebar_calc as rc
>>> rc.nominal_diameter("H25") # 공칭 직경
25.4
>>> round(rc.bend_radius("H25"), 1) # 구부림 반지름 (3db)
76.2
>>> round(rc.bend_radius("H29"), 1) # H29는 4db
114.4
>>> round(rc.bend_radius("H16", is_stirrup=True), 1) # 스터럽 D16 이하는 2db
31.8
>>> round(rc.hook_extension("H25", 90), 1) # 90도 갈고리 연장 길이 (12db)
304.8
>>> round(rc.hook_extension("H25", 135), 1) # 135도는 6db
152.4
>>> rc.satisfies_kds_bend("H25", 76.2) # KDS 기준을 만족하나요?
True
끝낼 때는 exit() 를 입력합니다.
모든 길이의 단위는 mm 입니다.
※ round(값, 1) 은 소수 첫째 자리까지 반올림하는 명령입니다.
round 없이 쓰면 76.19999999999999 처럼 아주 작은 오차가 보일 수 있습니다.
컴퓨터가 소수를 계산하는 방식 때문이고, 값은 맞습니다.
[6] 함수 설명표
nominal_diameter(이름) 공칭 직경 (예: "H25" -> 25.4)
bend_radius(이름, is_stirrup) 프로젝트 적용 구부림 반지름
(D10~D25 3db, D29~D35 4db, D38 이상 5db,
스터럽 D16 이하 2db)
kds_min_inner_diameter(이름) KDS 24 14 21 최소 내면 지름
(D16 이하 2db, D19 이상 3.5db)
satisfies_kds_bend(이름, 반지름) KDS 최소값을 만족하면 True
hook_extension(이름, 각도) 갈고리 연장 길이 (90도 12db, 135도 6db,
180도 4db 또는 60mm 중 큰 값)
kds_min_hook_extension(이름, 각도) KDS 최소 연장 길이 (90~150도 5db)
철근 이름은 "H25" 처럼 씁니다. "D25", 소문자 "h25" 도 됩니다.
지원 규격: H10, H13, H16, H19, H22, H25, H29, H32, H35, H38
--- 배치 계산 (scripts\rebar_layout.py) ---
사용: import rebar_layout as rl
center_out_positions(본수, 간격) 중심에서 바깥으로 놓은 폭방향 위치 목록
(짝수=중심에 철근 없음, 홀수=중심에 1본)
snapped_array(본수, 간격, 폭, 피복) 바깥 2본은 피복에 맞추고 안쪽은 중심에서 바깥으로
(1CYCLE 방식. 예: 14본, 250, 3500, 120)
min_horizontal_clear(이름, gmax) 수평 최소 순간격 max(1.5db, 1.5Gmax, 40)
horizontal_spacing_ok(위치목록, 이름) 간격이 충분하면 True
min_vertical_clear(이름) 다단 연직 최소 순간격 max(25, db)
vertical_pitch_ok(이름, 단 간격) 단 간격이 충분하면 True
tier_elevations(상면높이, 피복, 단수, 간격) 각 단의 높이 목록
bar_weight(이름, 길이mm, 본수) 중량(kg)
array_summary(이름, 길이mm, 위치목록) 본수, 총길이(m), 중량(kg)
예) >>> import rebar_layout as rl
>>> rl.snapped_array(14, 250, 3500, 120)
[-1630.0, -1375.0, ..., -125.0, 125.0, ..., 1375.0, 1630.0]
※ 굵은골재 최대치수(gmax)는 가정값 25mm 입니다. 설계서 값이 다르면 알려주세요.
※ 단위중량은 H16, H19, H22, H25, H29 만 등록되어 있습니다. 다른 규격은 오류가 납니다.
--- 좌표 변환 (scripts\frame.py) ---
월드 좌표 (x, y, z) 를 코핑 기준 좌표 (s, t, z) 로 바꾸거나 되돌립니다.
s = 코핑 길이방향, t = 폭방향, z = 높이(그대로)
원점과 각도는 언제든 바꿀 수 있습니다. (값으로 받기 때문입니다)
사용: import frame as fr
fr.Frame(원점x, 원점y, 각도) 프레임 직접 만들기 (각도는 라디안)
fr.from_two_points((x1,y1), (x2,y2)) 두 점으로 만들기 (원점=가운데, s축=1번->2번 방향)
예: 2주식 교각의 두 기둥 중심
f.to_local(x, y, z) 월드 -> 로컬
f.to_world(s, t, z) 로컬 -> 월드
fr.convert(점, 프레임1, 프레임2) 프레임1 기준 값을 프레임2 기준 값으로
예) >>> import frame as fr
>>> f = fr.from_two_points((480708784, 153831202), (480721068, 153835457))
>>> f.to_local(480708784, 153831202, 7738)
(-6500.032326842691, -2.2737367544323206e-13, 7738)
<- 기둥 중심: 코핑 중앙에서 s 방향으로 약 -6500, t는 0 (e-13 은 0에 아주 가까운 계산 오차)
원점이나 각도를 바꾸고 싶으면 새 프레임을 만들어 쓰면 됩니다.
월드 위치는 변하지 않고 로컬 숫자만 달라집니다.
--- 철근 중심선 꺾임점 (scripts\centerline.py) ---
단면 외곽선을 안쪽으로 피복만큼 옮긴 직선들의 교점으로 철근 중심선을 구합니다.
2D 좌표는 (s, z) 입니다. (s=길이방향, z=높이)
사용: import centerline as cl
cl.centerline_points(선분목록, 피복, "left"/"right", start_len=, end_len=)
선분목록 : 철근이 지나가는 순서와 방향대로 [(시작점, 끝점), ...]
"left"/"right" : 콘크리트 안쪽이 진행 방향의 왼쪽인지 오른쪽인지
start_len / end_len : 도면에 정해진 시작·끝 연장 길이 (없으면 생략)
cl.segment_lengths(점목록) 각 구간 길이 (도면의 a, b, c, d 와 비교)
cl.polyline_length(점목록) 전체 길이
cl.turn_angles(점목록) 꺾임 각도(도)
예) 대호지교 P1 형식 VII (C8/C10), 도면과 같은 값이 나옵니다:
>>> import centerline as cl
>>> segs = [((12100,10738),(12100,9238)), ((12100,9238),(8100,7738)),
... ((8100,7738),(4900,7738)), ((4900,7738),(3400,8238))]
>>> pts = cl.centerline_points(segs, 120, "right", start_len=630, end_len=2331)
>>> [round(x, 1) for x in cl.segment_lengths(pts)]
[630.0, 4167.1, 3158.8, 2331.0] <- 도면 a, b, c, d
※ 길이는 중심선 길이입니다. 재료표의 전개길이(꺾임 보정 포함)와는 다를 수 있습니다.
--- 철근 간섭 검사 (scripts\clash.py) ---
철근 중심선을 선분으로 쪼개서 서로 겹치는(간섭) 철근을 찾습니다.
간섭 = 두 철근 반지름의 합 - 중심선 최단거리 가 0.5mm 보다 클 때.
같은 철근이나 같은 그룹(겹침이음인 뚜껑+바닥 조각)끼리는 검사하지 않습니다.
사용: import clash as ck
ck.Seg(시작점, 끝점, 반지름, 철근번호, 그룹번호, 마크이름) 선분 하나 만들기
ck.find_clashes(선분목록, min_penetration=0.5) 간섭 목록 (깊은 것부터)
ck.summarize_by_mark(간섭목록) 마크 쌍별 개수와 최대 관입
ck.segment_distance(p1, q1, p2, q2) 선분 두 개 사이 최단거리
예) >>> import clash as ck
>>> a = ck.Seg((-100,0,0), (100,0,0), 12.7, 1, 1, "C1")
>>> b = ck.Seg((0,-100,20), (0,100,20), 12.7, 2, 2, "C8")
>>> ck.find_clashes([a, b])
[{'bar_a': 1, 'bar_b': 2, 'mark_a': 'C1', 'mark_b': 'C8',
'penetration': 5.4, 'point': (0.0, 0.0, 10.0)}]
<- 철근 중심이 20mm 떨어졌는데 반지름 합이 25.4 라서 5.4mm 겹침
(실제 출력은 5.399999999999999 처럼 미세한 오차가 보일 수 있습니다)
※ 월드 좌표(약 4.8억)보다 frame.py 로 바꾼 로컬 좌표를 쓰면 계산이 더 정확합니다.
[7] Claude와 같이 작업하는 순서
1) Claude에게 할 일을 말합니다. (docs\tasks.md 의 번호로 말하면 쉽습니다. 예: "T-003 시작해줘")
2) Claude가 테스트를 먼저 만들고, 실패하는 것을 확인한 뒤 코드를 만듭니다.
3) 끝나면 Claude가 docs 와 README 를 업데이트할지 묻습니다.
4) 커밋(저장 기록)은 Claude가 물어본 뒤 "응" 이라고 하면 합니다.
5) Claude는 매번 응답 끝에 "이번에 쓴 경로"를 알려줍니다.
[8] 헷갈릴 때
- 경로가 헷갈리면 D:\★Pier 자료\pier_v1\Pier_T_rebar\Code\pier-program\docs\paths.md 를 보세요.
- 새 프로그램은 pier-program, 옛날 자료는 rhino-mcp-main 입니다.
- 큰 파일(렌더링, 백업, DXF, Rhino 파일)은 항상 D 드라이브에 저장합니다.