"""구조물도 상단 그림 — 그림 치수가 **수량표와 같은 한 벌**인가 (PLAN 12장). ⚠ 옛 B07 그림은 두께 식을 따로 적어, 상부·하부 두께를 넣어도 **그림만 옛 두께**였음. """ from __future__ import annotations import math import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( # noqa: E402 expand_observed, load_observed_table, ) from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 attach_figures, build_figure, figure_reason, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 back_length_default_note, stone_masonry, ) def _sheet(**options) -> dict: return { "title": "돌쌓기(메)", "type_id": "masonry_dry", "height_m": 2.0, "options": {"height_m": 2.0, **options}, } def _paths(shapes: list[dict]) -> list[dict]: return [shape for shape in shapes if shape["kind"] == "path"] def _area(points: list[list[float]]) -> float: return abs(sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(points, points[1:]))) / 2 def test_벽_두께는_사용자가_넣은_값을_따른다() -> None: wall = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[0]["points"] # 식: 상부 0.35+0.30=0.65 · 하부 0.65+0.30×(2−1)=0.95 · 기울기 0.3 → 앞면 위 끝 0.6 assert wall[:4] == [[0.0, 0.0], [0.6, 2.0], [1.25, 2.0], [0.95, 0.0]] given = _paths( build_figure( _sheet( back_len_cm=35, face_slope_ratio=0.3, thickness_top_m=0.5, thickness_bottom_m=1.2 ) ) )[0]["points"] assert given[2] == [1.1, 2.0] and given[3] == [1.2, 0.0] def test_터파기_넓이는_수량식과_같다() -> None: dig = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[1] assert dig["dash"] assert abs(_area(dig["points"]) - 2.0 * ((0.65 + 0.95) / 2 + 0.2)) < 1e-9 def test_못_그리는_장은_그림_대신_까닭이_실린다() -> None: payload = { "sheets": [ _sheet(), {**_sheet(stone_cm="60~80"), "type_id": "boulder_masonry"}, _sheet(back_len_cm=45), ] } attach_figures(payload) no_back, boulder, drawn = payload["sheets"] # 뒷길이 미정은 기본값이 있어 **그림이 섬**(2026-09-14 브레인 판정) — 까닭은 직경 축만. assert no_back["figure_reason"] is None and no_back["figure"] assert boulder["figure"] is None and "직경" in boulder["figure_reason"] assert drawn["figure_reason"] is None and drawn["figure"] def _texts(shapes: list[dict]) -> list[str]: return [shape["text"] for shape in shapes if shape["kind"] == "text"] def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -> None: options = {"height_m": 2.0} _rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False) table_default = [note for note in table_notes if "뒷길이를 안 골라" in note] # 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말. assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)] assert "하한 36㎝ 는 규격 일곱" in table_default[0] texts = _texts(build_figure(_sheet())) assert table_default[0] in texts assert "뒷길이 ℓ₃ = 45㎝ (기본)" in texts # 두께도 기본 45 로 — 상부 0.45+0.30 assert "상부 0.75 m" in texts chosen = _texts(build_figure(_sheet(back_len_cm=45))) assert "뒷길이 ℓ₃ = 45㎝" in chosen and not any("안 골라" in text for text in chosen) assert back_length_default_note({"back_len_cm": 45}, 45) is None def _wall_sheet(**options) -> dict: return { "title": "옹벽 H=2", "type_id": "retaining_wall", "height_m": 2.0, "options": {"height_m": 2.0, "form": "반중력식", **options}, } def test_옹벽_단면_치수가_표의_관측_수량을_그대로_낸다() -> None: """표 = 그림 — 그림이 쓰는 단면으로 표 수량(라이브러리 반올림)이 다시 나와야 함.""" entry = load_observed_table().find("retaining_wall", {"form": "반중력식", "height_m": 2.0}) section = entry["section"] amounts = {item["name"]: item["amount"] for item in entry["components"]} footing, wall, key, blinding = (section[k] for k in ("footing", "wall", "key", "blinding")) bottom = wall["top_m"] + wall["front_batter_m"] + wall["back_batter_m"] assert abs(bottom - 0.45) < 1e-9 concrete = ( footing["width_m"] * footing["thickness_m"] + key["width_m"] * key["depth_m"] + (wall["top_m"] + bottom) / 2 * wall["height_m"] ) blind = blinding["thickness_m"] * ( footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"] ) form = math.hypot(wall["height_m"], wall["back_batter_m"]) + math.hypot( wall["height_m"], wall["front_batter_m"] ) pipe = wall["height_m"] / 2.0 * section["weep"]["pipe_length_m"] # 원문 값 그대로(2026-09-14 브레인 판정 — 라이브러리 반올림 1.35·0.15·3.2 에서 바꿈) assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 1.345 assert abs(blind - amounts["버림콘크리트"]) < 1e-9 # 0.145 assert abs(form - amounts["유로폼"]) < 5e-4 # 3.2047 → 3.205 assert abs(pipe - amounts["물구멍관"]) < 1e-9 # 0.32 (제원 칸 비었을 때) assert footing["thickness_m"] + wall["height_m"] == 2.0 def _wall_table(length: float = 10.0, rubble=None, **options) -> dict: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table structure = { "structure_id": "w1", "type_id": "retaining_wall", "start_m": 0.0, "end_m": length, "options": {"form": "반중력식", "height_m": 2.0, "length_m": length, **options}, } table = build_table([structure], {"retaining_wall": "옹벽"}, rubble_base_thickness_m=rubble) return table["structures"][0] def _amounts(structure: dict) -> dict: return {row["name"]: row for row in structure["components"]} def test_옹벽_터파기는_기초_폭과_양쪽_여유_옆면_1대0점5이고_토공으로만_간다() -> None: wall = _wall_table() rows = _amounts(wall) # 밑폭 1.6 + 0.3×2 = 2.2 · 깊이 0.4 + 0.1 + 0.2 = 0.7 · 옆면 1:0.5 → 윗폭 2.9 # (2.2 + 2.9) ÷ 2 × 0.7 = 1.785/m assert abs(rows["터파기"]["amount"] - 17.85) < 1e-9 # 수직일 때 15.4(2026-09-14 판정으로 고침) # 든 것 = 기초·전단키 0.745 + (버림 + 잡석) 1.45 × 0.3 = 1.18 → 되메우기 0.605/m assert abs(rows["잔토처리"]["amount"] - 11.8) < 1e-9 assert abs(rows["되메우기"]["amount"] - 6.05) < 1e-9 # 수직일 때 3.6 assert {rows[k]["destination"] for k in ("터파기", "되메우기", "잔토처리")} == {"earthwork"} assert abs(rows["기초잡석"]["amount"] - 2.9) < 1e-9 # 원문 버림 0.145 × 2 assert wall["trench_depth_m"] == 0.7 assert "1:0.5" in rows["터파기"]["basis"] and "지반선" in rows["터파기"]["basis"] # 잡석을 얇게 두면 전단키가 그 밑으로 나와 그 몫을 더 팜: (2.2+2.8)/2×0.6 + 0.35×0.1 thin = _amounts(_wall_table(rubble=0.1)) assert abs(thin["터파기"]["amount"] - 15.35) < 1e-9 def test_옹벽_물구멍관은_제원_칸이_닿고_돌쌓기와_같은_규격으로_합쳐진다() -> None: plain = _amounts(_wall_table())["물구멍관"] assert plain["spec"] == "Ø50" and abs(plain["amount"] - 3.2) < 1e-9 assert plain["basis_kind"] == "observed" and "관측값" in plain["basis"] given = _amounts(_wall_table(weep_hole_area_m2=2.5, weep_hole_diameter_mm=75))["물구멍관"] assert given["spec"] == "Ø75" and abs(given["amount"] - 1.6 / 2.5 * 0.4 * 10) < 1e-9 def test_옹벽_그림은_기초잡석_두께를_산출_조건에서_받고_사유는_표와_같다() -> None: shapes = build_figure(_wall_sheet(), rubble_thickness_m=0.3) texts = _texts(shapes) assert "기초잡석 T=0.3 m — 산출 조건 · 폭은 버림과 같음" in texts assert "버림 T=0.10 · 폭 1.80 − 키 0.35 = 1.45 m" in texts wall = _paths(shapes)[0]["points"] assert [0.95, 0.4] in wall and [1.068, 2.0] in wall and [1.368, 2.0] in wall # 표 사유와 같은 말 — 터파기를 어떻게 셌나(비탈분 제외) _rows, table_notes = expand_observed( "retaining_wall", {"form": "반중력식", "height_m": 2.0}, _wall_sheet() | {"options": {"length_m": 10}}, ) trench = [note for note in table_notes if "터파기" in note] assert trench and trench[0] in texts and "지반선" in trench[0] # 잡석 0.3 → 깊이 0.8 · 한쪽 0.4 assert ( "터파기 밑폭 2.20 · 윗폭 3.00 × 깊이 0.80 m (기초 1.6 + 여유 0.3×2 · 옆면 1:0.5)" in texts ) assert not any(text.startswith("기초잡석") for text in _texts(build_figure(_wall_sheet(), 0))) def test_옹벽_못_그리는_장은_표와_같은_까닭() -> None: assert "형식" in figure_reason({**_wall_sheet(), "options": {"height_m": 2.0}}) other = figure_reason(_wall_sheet(height_m=1.6) | {"height_m": 1.6}) assert other.startswith("그림 없음 — ") and "자료에 없습니다" in other label = [t for t in _texts(build_figure(_wall_sheet(weep_hole_area_m2=3))) if "물구멍" in t] assert label == ["물구멍 Ø50 — 3㎡당 1개소 · 관 0.4 m(관측값)"] def test_양식_장은_양식이_푼_뒷길이로_그린다() -> None: sheet = {**_sheet(), "formula_sheet": {"vars": {"L3": 55}}} texts = _texts(build_figure(sheet)) assert "뒷길이 ℓ₃ = 55㎝ (기본)" in texts and "상부 0.85 m" in texts