"""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"