⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""B07 토적도·유역도 엔진 — 좌표 환산과 표기가 척도대로 나오는지 확인한다."""
|
|
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import (
|
|
MM_V,
|
|
auto_scale_h,
|
|
build_mass_haul_drawing,
|
|
)
|
|
|
|
|
|
def _texts(drawing):
|
|
"""도면 안 모든 글자(자식 포함) — Text 라벨과 Table 칸 글자를 함께 훑는다.
|
|
|
|
정보표는 선·문자를 따로 두지 않고 Table 엔티티 하나로 나간다(DXF의 표로 내보내기
|
|
위함) — 칸 글자는 `shapeData.cells` 안에 있어 Text만 훑으면 통째로 안 보인다.
|
|
"""
|
|
out = []
|
|
|
|
def walk(entity):
|
|
if entity.get("type") == "Text":
|
|
out.append(entity["shapeData"]["label"])
|
|
if entity.get("type") == "Table":
|
|
for row in entity["shapeData"].get("cells") or []:
|
|
out.extend(cell["text"] for cell in row if cell and cell.get("text") is not None)
|
|
for child in entity.get("children") or []:
|
|
walk(child)
|
|
|
|
for entity in drawing["entities"]:
|
|
walk(entity)
|
|
return out
|
|
|
|
|
|
def _lines(drawing, layer_id):
|
|
"""레이어의 Line 좌표쌍 목록(PolyLine 자식 포함)."""
|
|
out = []
|
|
|
|
def walk(entity):
|
|
if entity.get("type") == "Line" and entity.get("layerId") == layer_id:
|
|
shape = entity["shapeData"]
|
|
out.append(
|
|
(
|
|
(shape["startPoint"]["x"], shape["startPoint"]["y"]),
|
|
(shape["endPoint"]["x"], shape["endPoint"]["y"]),
|
|
)
|
|
)
|
|
for child in entity.get("children") or []:
|
|
walk(child)
|
|
|
|
for entity in drawing["entities"]:
|
|
walk(entity)
|
|
return out
|
|
|
|
|
|
LONGITUDINAL = {
|
|
"stations": [
|
|
{"chainage_m": 0.0, "station_id": "s0"},
|
|
{"chainage_m": 20.0, "station_id": "s1"},
|
|
{"chainage_m": 40.0, "station_id": "s2"},
|
|
]
|
|
}
|
|
|
|
MASS_HAUL = {
|
|
"basis": "compacted",
|
|
"points": [
|
|
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
|
|
{"station_id": "s1", "chainage_m": 20.0, "cumulative_volume_m3": 1000.0},
|
|
{"station_id": "s2", "chainage_m": 40.0, "cumulative_volume_m3": 400.0},
|
|
],
|
|
"haul_plan": {
|
|
"blocks": [
|
|
{
|
|
"index": 1,
|
|
"from_m": 0.0,
|
|
"to_m": 40.0,
|
|
"base_m3": 0.0,
|
|
"volume_m3": 1000.0,
|
|
"direction": "forward",
|
|
"bands": [
|
|
{
|
|
"index": 1,
|
|
"equipment": "free_haul",
|
|
"volume_m3": 600.0,
|
|
"haul_distance_m": 18.5,
|
|
"ea_m3": 400.0,
|
|
"rr_m3": 150.0,
|
|
"br_m3": 50.0,
|
|
"level_base_m3": 0.0,
|
|
"level_apex_m3": 600.0,
|
|
"boundary_from_m": 0.0,
|
|
"boundary_to_m": 40.0,
|
|
"haul_from_m": 6.0,
|
|
"haul_to_m": 34.0,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"residuals": [
|
|
{
|
|
"index": 1,
|
|
"kind": "spoil",
|
|
"from_m": 30.0,
|
|
"to_m": 40.0,
|
|
"volume_m3": 120.0,
|
|
"natural_m3": 40.0,
|
|
"level_from_m3": 400.0,
|
|
"level_to_m3": 280.0,
|
|
"ea_m3": 80.0,
|
|
"rr_m3": 30.0,
|
|
"br_m3": 10.0,
|
|
}
|
|
],
|
|
"transfers": [],
|
|
},
|
|
}
|
|
|
|
|
|
def test_mass_haul_scale_follows_route_length():
|
|
"""가로 축척은 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림(2026-09-03 사용자 결정)."""
|
|
assert auto_scale_h(40.0) == 500 # 40m -> 80mm
|
|
assert auto_scale_h(1106.0) == 2000 # 용화 1,106m -> 553mm (1:1,500이면 737mm로 넘침)
|
|
assert auto_scale_h(3465.0) == 5000
|
|
assert MM_V == 0.02 # 세로는 고정(종이 1mm = 50㎥)
|
|
|
|
|
|
def test_mass_haul_curve_uses_paper_scale():
|
|
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m."""
|
|
mm_h = 1000.0 / auto_scale_h(40.0)
|
|
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
|
curve = _lines(drawing, "b08-masshaul-curve")
|
|
assert curve[0][0] == (0.0, 0.0)
|
|
assert curve[0][1] == (20.0 * mm_h, 1000.0 * 0.02)
|
|
assert curve[1][1] == (40.0 * mm_h, 400.0 * 0.02)
|
|
|
|
|
|
def test_mass_haul_balloon_texts():
|
|
"""balloon 표기를 납품 도면에 맞춘다 — 종무대·등호 뒤 한 칸·M.N 소수 2자리
|
|
(2026-09-03 사용자 확정 8행)."""
|
|
labels = _texts(build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul"))
|
|
assert "종무대" in labels # 장비명 뒤 숫자는 정체 미확인이라 적지 않는다
|
|
assert "Q= 600.00M3" in labels
|
|
assert "L= 18.50M" in labels
|
|
assert "EA= 400.00M3" in labels
|
|
assert "사토 1" in labels
|
|
assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점
|
|
assert "유 토 곡 선" in labels
|
|
assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척
|
|
|
|
|
|
def test_mass_haul_table_marks_match_delivery_form():
|
|
"""측점은 `n+ 0.0`, 행 이름은 자간을 벌린 큰 글씨, 눈금은 정규 빨강·추가 회색."""
|
|
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
|
labels = _texts(drawing)
|
|
assert "1+ 0.0" in labels and "2+ 0.0" in labels
|
|
assert not any(text.startswith("No.") for text in labels)
|
|
assert "누 가 토 량" in labels and "측 점" in labels
|
|
# 눈금은 표 레이어의 짧은 세로선 — 정규 측점은 빨강(#ff4d4d).
|
|
ticks = [
|
|
entity
|
|
for entity in drawing["entities"]
|
|
if entity.get("layerId") == "b08-masshaul-table"
|
|
and entity.get("type") == "Line"
|
|
and entity.get("lineColor") == "#ff4d4d"
|
|
]
|
|
assert ticks, "표 눈금이 없다"
|
|
for tick in ticks:
|
|
shape = tick["shapeData"]
|
|
assert abs(shape["startPoint"]["x"] - shape["endPoint"]["x"]) < 1e-9 # 세로선
|
|
assert abs(shape["startPoint"]["y"] - shape["endPoint"]["y"]) == 2.0
|
|
|
|
|
|
def test_mass_haul_leader_is_stepped_from_corner():
|
|
"""지시선은 balloon 모서리에서 나가는 계단형(가로 → 세로) 폴리선이다."""
|
|
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
|
# 지시선은 띠 레이어의 자식 2개짜리 PolyLine(가로 → 대각) 이다.
|
|
leaders = [
|
|
entity
|
|
for entity in drawing["entities"]
|
|
if entity.get("type") == "PolyLine"
|
|
and entity.get("layerId") == "b08-masshaul-band"
|
|
and len(entity.get("children") or []) == 2
|
|
]
|
|
assert leaders, "지시선이 없다"
|
|
for leader in leaders:
|
|
first = leader["children"][0]["shapeData"]
|
|
assert abs(first["startPoint"]["y"] - first["endPoint"]["y"]) < 1e-9 # 첫 구간은 수평
|
|
|
|
|
|
def test_mass_haul_band_chords_sit_at_stored_levels():
|
|
"""경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다."""
|
|
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
|
chords = _lines(drawing, "b08-masshaul-band")
|
|
mm_h = 1000.0 / auto_scale_h(40.0)
|
|
assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0
|
|
assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥
|
|
|
|
|
|
BASINS = [
|
|
{
|
|
"ring": [(100.0, 100.0), (400.0, 100.0), (400.0, 400.0), (100.0, 400.0)],
|
|
"props": {
|
|
"kind": "detail_basin",
|
|
"index": 1,
|
|
"chainage_m": 120.0,
|
|
"area_m2": 15000.0,
|
|
"relief_m": 165.0,
|
|
"flow_length_m": 210.0,
|
|
"pipe_diameter_mm": 800,
|
|
"bridge_required": False,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_watershed_info_box_matches_delivery_form():
|
|
"""정보표는 유역면적 ha 환산·유역표고·유하거리·배수규격을 납품 표기로 적는다."""
|
|
drawing = build_watershed_drawing(
|
|
"watershed",
|
|
route_xy=[(0.0, 0.0), (500.0, 0.0)],
|
|
basins=BASINS,
|
|
contours=[[(0.0, 50.0), (500.0, 60.0)]],
|
|
streams=[[(200.0, 0.0), (200.0, 400.0)]],
|
|
interval_m=20.0,
|
|
)
|
|
labels = _texts(drawing)
|
|
# 측점 표기는 토적도와 같은 `n+ 0.0`(2026-09-03 사용자 확정 — 유역도까지 통일).
|
|
assert "(1) 6+ 0.0" in labels
|
|
assert "배수규격 Φ800mm" in labels
|
|
assert "1.50 ha" in labels # 15,000㎡
|
|
assert "165.0 m" in labels
|
|
assert "210.0 m" in labels
|
|
assert "수 리 집 수 면 적 유 역 도" in labels
|
|
assert "S = 1/6,000" in labels
|
|
|
|
|
|
def test_watershed_route_uses_plan_scale():
|
|
"""평면 좌표는 콘텐츠 최소점을 원점으로 1/6,000 (1m = 1/6mm)로 옮겨진다."""
|
|
drawing = build_watershed_drawing(
|
|
"watershed",
|
|
route_xy=[(0.0, 0.0), (600.0, 0.0)],
|
|
basins=[],
|
|
contours=[],
|
|
streams=[],
|
|
)
|
|
route = _lines(drawing, "b08-basin-route")
|
|
assert route[0][0] == (0.0, 0.0)
|
|
assert abs(route[0][1][0] - 100.0) < 1e-9 # 600m / 6 = 100mm
|
|
|
|
|
|
def test_clip_line_to_box_splits_outside_runs():
|
|
"""도엽 등고선처럼 긴 선은 범위 안 구간만 남고, 끝점은 경계 위에 정확히 놓인다.
|
|
|
|
2026-08-31 이전에는 경계 바깥 점을 하나씩 물고 나와 외곽이 들쭉날쭉했다.
|
|
지금은 교차점을 계산해 자른다(Liang-Barsky).
|
|
"""
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support import clip_line_to_box
|
|
|
|
box = (0.0, 0.0, 10.0, 10.0)
|
|
line = [(-5.0, 5.0), (2.0, 5.0), (5.0, 5.0), (20.0, 5.0), (30.0, 5.0), (8.0, 5.0)]
|
|
runs = clip_line_to_box(line, box)
|
|
assert runs == [
|
|
[(0.0, 5.0), (2.0, 5.0), (5.0, 5.0), (10.0, 5.0)],
|
|
[(10.0, 5.0), (8.0, 5.0)],
|
|
]
|
|
assert clip_line_to_box([(50.0, 50.0), (60.0, 60.0)], box) == []
|
|
# 남은 점이 상자를 벗어나지 않는다
|
|
for run in runs:
|
|
for x, y in run:
|
|
assert 0.0 - 1e-9 <= x <= 10.0 + 1e-9
|
|
assert 0.0 - 1e-9 <= y <= 10.0 + 1e-9
|
|
|
|
|
|
def test_watershed_diameter_label_snaps_to_standard_size():
|
|
"""소요 관경(Φ929)만 있는 옛 저장본도 도면에는 규격관(Φ1000)으로 적는다."""
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _diameter_label
|
|
|
|
assert _diameter_label({"pipe_diameter_mm": 929.4}) == "Φ1000mm"
|
|
assert (
|
|
_diameter_label({"pipe_diameter_mm": 929.4, "recommended_diameter_mm": 1200}) == "Φ1200mm"
|
|
)
|
|
assert _diameter_label({"pipe_diameter_mm": 2000}) == "Φ1500mm"
|
|
assert _diameter_label({"bridge_required": True, "pipe_diameter_mm": 1600}) == "물넘이포장"
|
|
|
|
|
|
def test_watershed_number_badge_is_circle_with_disc():
|
|
"""유역 번호는 흰 원판(solid 해치) + 원 테두리 + 숫자로 그려 해칭 위에서도 읽힌다."""
|
|
drawing = build_watershed_drawing(
|
|
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
|
|
)
|
|
# 방위표 템플릿에도 원이 있으므로 유역 레이어만 센다.
|
|
circles = [
|
|
e
|
|
for e in drawing["entities"]
|
|
if e.get("type") == "Circle" and e.get("layerId") == "b08-basin-area"
|
|
]
|
|
assert len(circles) == 1
|
|
assert circles[0]["shapeData"]["radius"] == 3.6
|
|
disc = [
|
|
e
|
|
for e in drawing["entities"]
|
|
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "solid"
|
|
]
|
|
assert len(disc) == 1
|
|
assert "1" in _texts(drawing)
|
|
|
|
|
|
def test_watershed_basin_has_hatch():
|
|
"""유역마다 격자 해칭(Hatch 엔티티)이 경계선과 함께 들어간다."""
|
|
drawing = build_watershed_drawing(
|
|
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
|
|
)
|
|
hatches = [
|
|
e
|
|
for e in drawing["entities"]
|
|
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "cross"
|
|
]
|
|
assert len(hatches) == 1
|
|
shape = hatches[0]["shapeData"]
|
|
assert shape["options"]["style"] == "cross"
|
|
assert len(shape["points"]) == 4
|
|
assert hatches[0]["layerId"] == "b08-basin-area"
|