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:
commit
964a841de1
3978 files changed
+58013356
No files matched your search
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
@@ -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)
|
||||
@@ -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\` 에 있습니다.
|
||||
@@ -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)를 어떻게 일반화할지 정해야 합니다.
|
||||
@@ -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`)
|
||||
@@ -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 폴더에서) |
|
||||
@@ -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에서 확인 필요)
|
||||
@@ -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시간 | 대기 (레이어·형식 코드 일반화 결정 필요) |
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""테스트에서 scripts/ 폴더의 모듈을 불러올 수 있게 경로를 추가합니다."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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 드라이브에 저장합니다.
|
||||
Reference in new issue
Block a user