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,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)
|
||||
Reference in new issue
Block a user