From 951e39a923c3fc0915848e07ee2897746ce9128a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 06:53:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EA=B7=B8=EB=A6=BC=20=E2=80=94=20=EA=B8=B0=EC=8A=AD?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=C2=B7=20=EA=B3=A8=EB=A7=89=EC=9D=B4=20?= =?UTF-8?q?=C2=B7=20=EB=96=BC=ED=9D=99=EB=A7=89=EC=9D=B4=20=C2=B7=20?= =?UTF-8?q?=EB=B0=94=EB=8B=A5=EB=A7=89=EC=9D=B4=20=C2=B7=20=EA=B0=9C?= =?UTF-8?q?=EA=B1=B0=20=EA=B9=8C=EB=8B=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기슭막이(돌쌓기형) — 돌쌓기 그림 그대로 · 기울기 판정 대상에 넣고 찰/메는 형태에서(표와 한 벌) - 골막이 — 정면 사다리꼴 · 방수로 관 · 바닥파기 띠 + 옆 단면 · 치수는 표와 같이 쓰는 erosion_check_section 한 벌로 뽑음(값 불변) - 떼흙막이 — 정본 평균 붙박이 치수(dims)로 · 그 치수로 표 두 줄(떼 1.39 · 바닥파기 0.17)이 다시 나오는지 시험 - 바닥막이 — 돌 켜 · 버림 · 기초잡석(산출 조건) · 터파기 · 표 사유에 새 한 줄 「터파기는 돌 두께만 — 버림·기초잡석은 판 깊이에 안 들어감」을 그림도 같이 씀 - 표가 안 선 장은 표 사유를 그대로 까닭으로 · 개거 둘은 원문끼리 치수가 달라서/단면 그림이 없어서 까닭 - 화면 확인: 검증 프로젝트 936be972 에 넷을 잠깐 놓았다 되돌림(구조물 5개 원본과 같음 · 단계 상태 그대로 · 내역 124,424,547 그대로) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureFigure.py | 39 +++- ...8_Quantity_Engine_StructureFigure_Small.py | 213 ++++++++++++++++++ .../B08_Quantity_Engine_StructureSheet.py | 15 +- ..._Quantity_Engine_UnitQuantity_Revetment.py | 90 ++++++-- .../tester/test_b08_structure_figure_small.py | 111 +++++++++ 5 files changed, 440 insertions(+), 28 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py create mode 100644 resources/tester/test_b08_structure_figure_small.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index 83c220fd..4ef31717 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -49,8 +49,23 @@ SCALE_MM_PER_M = 40.0 #: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔ #: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫. #: ⓘ 옹벽(2026-09-14)은 **관측 원단위 줄에 실린 단면 치수**로 그림 — 표 수량이 나온 그 단면. -MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"}) -FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | {"retaining_wall"} +#: 기슭막이는 형태가 돌쌓기면 **돌쌓기 식 그대로**라 그림도 같음(표가 안 선 형태는 표 사유가 까닭). +MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "revetment"}) +#: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌). +SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"}) +FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | {"retaining_wall"} + +#: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조). +_OPEN_DITCH_REASONS: dict[str, str] = { + "콘크리트 개거 150×200": ( + "그림 없음 — 원문끼리 치수가 다릅니다: 산출식 터파기 0.3 × 0.1 ↔ 같은 탭 그림 " + "포장 슬래브 사이 홈 150×200 + 밑 보 400×200 · 어느 쪽으로 그릴지 판정 대기" + ), + "L형수로 H=0.2": ( + "그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어" + " 산출식만으로 모양을 정하지 않습니다" + ), +} #: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다 #: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다. @@ -81,8 +96,17 @@ def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]: def figure_reason(sheet: dict[str, Any]) -> str | None: """그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.""" type_id = str(sheet.get("type_id") or "") + if "rows" in sheet and not sheet["rows"]: + # 표가 안 선 장 — 표가 적은 그 사유를 그대로(표 = 그림 · 같은 말). + notes = [str(note).replace("**", "") for note in sheet.get("notes") or []] + return f"그림 없음 — {notes[0] if notes else '표가 서지 않았습니다'}" + if type_id == "open_ditch": + spec = str((sheet.get("options") or {}).get("ditch_spec") or "콘크리트 개거 150×200") + return _OPEN_DITCH_REASONS.get(spec, _NO_FIGURE_DEFAULT) if type_id not in FIGURE_TYPE_IDS: return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT) + if type_id in SMALL_TYPE_IDS: + return None # 치수가 없으면 표가 안 서고, 그때는 위 「표가 안 선 장」이 사유를 냄 if type_id == "retaining_wall": return _observed_section(sheet)[1] if float(sheet.get("height_m") or 0.0) <= 0: @@ -133,9 +157,18 @@ def build_figure( """ if figure_reason(sheet) is not None: return None - if sheet.get("type_id") == "retaining_wall": + type_id = sheet.get("type_id") + if type_id == "retaining_wall": entry, _reason = _observed_section(sheet) return _wall_figure(sheet, entry or {}, rubble_thickness_m) + if type_id in SMALL_TYPE_IDS: + from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small + + if type_id == "bed_sill": + return small.bed_sill_figure(sheet, rubble_thickness_m) + if type_id == "erosion_check": + return small.erosion_check_figure(sheet) + return small.soil_guard_figure(sheet) return _masonry_figure(sheet) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py new file mode 100644 index 00000000..49a5b8b6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py @@ -0,0 +1,213 @@ +"""구조물도 상단 그림 — **골막이 · 떼흙막이 · 바닥막이** (정본 개소당·㎡당 구조물, PLAN 12장). + +⚠ 치수는 **표가 쓴 그 한 벌**에서 읽음 — 골막이 `erosion_check_section` · + 떼흙막이 `SOIL_GUARD_SOD.dims` · 바닥막이 `BED_SILL_FORMS` + 산출 조건 기초잡석 두께. +⚠ 사유 글자도 표 사유와 같은 상수(`BED_SILL_TRENCH_NOTE` 등). +⚠ 좌표는 m · 위가 +y · 모양 목록 규약은 `B08_Quantity_Engine_StructureFigure` 와 같음. +""" + +from __future__ import annotations + +import math +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( + _LABEL_SIZE, + SCALE_MM_PER_M, + _path, + _text, +) + + +def _title(sheet: dict[str, Any], y: float) -> dict[str, Any]: + return _text( + f"{sheet.get('title') or ''} (축척 1/{int(1000 / SCALE_MM_PER_M)})", + (0.0, y), + "left", + _LABEL_SIZE, + ) + + +def _front_with_trench( + top: float, bottom: float, height: float, depth: float, x0: float = 0.0 +) -> list[dict[str, Any]]: + """정면 사다리꼴(윗변 `top` · 밑변 `bottom` 가운데 정렬) + 사면 둘·밑변을 따라 판 띠(점선).""" + inset = (top - bottom) / 2 + corners = [(x0, height), (x0 + inset, 0.0), (x0 + inset + bottom, 0.0), (x0 + top, height)] + shapes = [_path(corners + [corners[0]], "wall")] + if depth > 0: + # 사면 둘·밑변을 바깥으로 `depth` 만큼 민 선 — 이웃 변의 교점으로 잇음. + normals = [] + for (ax, ay), (bx, by) in zip(corners[:3], corners[1:4]): + length = math.hypot(bx - ax, by - ay) + normals.append(((by - ay) / length, -(bx - ax) / length)) + offset = [] + for index, (px, py) in enumerate(corners): + used = [normals[i] for i in (index - 1, index) if 0 <= i < 3] + nx = sum(n[0] for n in used) + ny = sum(n[1] for n in used) + dot = used[0][0] * nx + used[0][1] * ny + offset.append((px + nx * depth / dot, py + ny * depth / dot)) + shapes.append(_path(offset, "guide", dash=True)) + return shapes + + +def _circle(cx: float, cy: float, r: float) -> list[tuple[float, float]]: + return [ + (cx + r * math.cos(math.tau * i / 24), cy + r * math.sin(math.tau * i / 24)) + for i in range(25) + ] + + +def erosion_check_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]: + """골막이 — 정면(사다리꼴 · 방수로 관 · 바닥파기 띠) + 옆 단면(상부·하부 두께).""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_section + + geo = erosion_check_section(float(sheet.get("height_m") or 0.0), sheet.get("options") or {}) + top, bottom, height = geo["top_m"], geo["bottom_m"], geo["height_m"] + shapes = _front_with_trench(top, bottom, height, geo["trench_depth"]) + if geo["spillway"]: + shapes.append(_path(_circle(top / 2, geo["pipe_r"], geo["pipe_r"]), "guide")) + # 옆 단면 — 뒷면 수직 · 앞면이 하부 두께에서 상부 두께로 좁아짐(표의 두께식 그대로). + x0 = top + 1.2 + shapes.append( + _path( + [ + (x0, 0.0), + (x0, height), + (x0 + geo["top_t"], height), + (x0 + geo["bottom_t"], 0.0), + (x0, 0.0), + ], + "wall", + ) + ) + right = x0 + geo["bottom_t"] + 0.3 + shapes += [ + _title(sheet, height + 0.55), + _text(f"상장 ⓐ {top:g} m", (top / 2, height + 0.14), "center"), + _text(f"하장 ⓑ {bottom:g} m", (top / 2, -geo["trench_depth"] - 0.18), "center"), + _text(f"H = {height:g} m", (-0.3 - geo["trench_depth"], height / 2), "right"), + _text( + f"상부 {geo['top_t']:.2f} · 하부 {geo['bottom_t']:.2f} · 평균 {geo['thickness']:.2f} m" + f" (뒷길이 {geo['back_cm']}㎝ + 0.1H / 0.4H)", + (right, height * 0.85), + "left", + ), + _text(f"반수면 1:{geo['slope']:g}", (right, height * 0.62), "left"), + _text( + f"바닥파기 폭 {geo['trench_width']:.2f} × 깊이 {geo['trench_depth']:g} m" + f" (사면장 {geo['slant']:g} × 2 + 하장)", + (right, height * 0.39), + "left", + ), + ] + if geo["spillway"]: + shapes.append( + _text( + f"방수로 파형강관 Ø{geo['pipe_r'] * 2:g} — 정면적에서 뺌", + (right, height * 0.16), + "left", + ) + ) + return shapes + + +def soil_guard_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]: + """떼흙막이 — 정본 평균 붙박이 치수의 정면(떼 두께 · 바닥파기 띠).""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import SOIL_GUARD_SOD + + d = SOIL_GUARD_SOD["dims"] + top, bottom, height = d["top_m"], d["bottom_m"], d["height_m"] + shapes = _front_with_trench(top, bottom, height, d["trench_depth_m"]) + # 머리떼 — 윗변 위 떼 한 켜(두께 = 떼 폭 0.2 로 보임). + shapes.append( + _path( + [(0.0, height), (0.0, height + d["sod_m"]), (top, height + d["sod_m"]), (top, height)], + "guide", + ) + ) + right = top + 0.4 + shapes += [ + _title(sheet, height + d["sod_m"] + 0.45), + _text(f"상단 {top:g} m", (top / 2, height + d["sod_m"] + 0.12), "center"), + _text(f"하단 {bottom:g} m", (top / 2, -d["trench_depth_m"] - 0.15), "center"), + _text(f"H = {height:g} m", (-0.3, height / 2), "right"), + _text( + f"떼 20×20㎝ · 폭 {d['sod_m']:g} · 기슭(사면장) {d['slant_m']:g} m", + (right, height * 0.9), + "left", + ), + _text( + f"바닥파기 폭 {d['trench_width_m']:g} × 깊이 {d['trench_depth_m']:g} m" + " (기슭 × 2 + 하단)", + (right, height * 0.45), + "left", + ), + _text("치수 — 정본 「떼흙막이」 평균 붙박이(개소 제원을 안 봄)", (right, 0.0), "left"), + ] + return shapes + + +def bed_sill_figure( + sheet: dict[str, Any], rubble_thickness_m: float | None +) -> list[dict[str, Any]]: + """바닥막이(돌붙임) — ㎡당 한 칸의 단면: 돌 켜 · (찰) 버림 · 기초잡석 · 터파기(돌 두께만).""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( + BED_SILL_FORMS, + BED_SILL_TRENCH_NOTE, + ) + + form = str((sheet.get("options") or {}).get("form") or "").strip() + table = BED_SILL_FORMS[form] + back = table["back_len_m"] + blinding = table["blinding_m3_per_m2"] or 0.0 # ㎥/㎡ = 두께(m) + rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m) + rubble = rubble if blinding > 0 else 0.0 # 표도 버림이 선 곳에만 기초잡석을 세움 + width = 1.0 + shapes = [_path([(0.0, 0.0), (0.0, back), (width, back), (width, 0.0), (0.0, 0.0)], "wall")] + if blinding > 0: + shapes.append( + _path([(0.0, 0.0), (0.0, -blinding), (width, -blinding), (width, 0.0)], "guide") + ) + if rubble > 0: + shapes.append( + _path( + [ + (0.0, -blinding), + (0.0, -blinding - rubble), + (width, -blinding - rubble), + (width, -blinding), + ], + "guide", + dash=True, + ) + ) + # 터파기 — 표 식 그대로 면적 × 돌 두께(윗면에서 돌 켜 밑까지). + shapes.append( + _path( + [(-0.15, back), (-0.15, 0.0), (width + 0.15, 0.0), (width + 0.15, back)], + "guide", + dash=True, + ) + ) + right = width + 0.4 + shapes += [ + _title(sheet, back + 0.4), + _text( + f"{form} — 두께(뒷길이) {back:g} m · {table['stone_spec']}", + (right, back * 0.75), + "left", + ), + _text("폭 1 m 당(표는 ㎡당)", (width / 2, back + 0.12), "center"), + _text(f"터파기 깊이 {back:g} m (면적 × 두께)", (right, back * 0.3), "left"), + ] + if blinding > 0: + shapes.append(_text(f"버림 T={blinding:g} m", (right, -blinding / 2), "left")) + if rubble > 0: + shapes.append( + _text(f"기초잡석 T={rubble:g} m — 산출 조건", (right, -blinding - rubble / 2), "left") + ) + shapes.append(_text(BED_SILL_TRENCH_NOTE, (0.0, -blinding - rubble - 0.25), "left")) + return shapes diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py index c858db78..fd19cb2c 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -96,7 +96,11 @@ def sheet_key(structure: dict[str, Any]) -> str: #: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). #: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지 #: 사라졌다**(2026-09-09 화면 실측에서 잡음). -SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"}) +SLOPE_TYPE_IDS: frozenset[str] = frozenset( + {"masonry_wet", "masonry_dry", "boulder_masonry", "revetment"} +) +#: 기슭막이는 **형태가 돌쌓기일 때만** 돌쌓기 식이 섬(`_UnitQuantity_Revetment.STONE_FORMS`). +REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False} def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: @@ -111,6 +115,9 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: wet = False elif type_id == "boulder_masonry": wet = options.get("bond") != "메쌓기" + elif type_id == "revetment": + # 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메. + wet = REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True) else: wet = True return face_slope_ratio( @@ -127,8 +134,12 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: def _slope_or_none(structure: dict[str, Any]) -> float | None: """돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`.""" # ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다. - if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS: + type_id = str(structure.get("type_id") or "") + if type_id not in SLOPE_TYPE_IDS: return None + form = str((structure.get("options") or {}).get("form") or "").strip() + if type_id == "revetment" and form not in REVETMENT_STONE_FORMS: + return None # 돌쌓기형이 아닌 기슭막이는 기울기 판정이 없음(표도 안 섬) return slope_of(structure)[0] diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index de1d3782..be8189a2 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -23,13 +23,12 @@ import math from decimal import ROUND_HALF_UP, Decimal from typing import Any +from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry #: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다. -STONE_FORMS: dict[str, bool] = { - "돌쌓기(찰)": True, - "돌쌓기(메)": False, -} +#: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌). +STONE_FORMS: dict[str, bool] = REVETMENT_STONE_FORMS #: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다. WITHHELD_FORMS: dict[str, str] = { @@ -185,6 +184,43 @@ def _ceil2(value: float) -> float: return math.ceil(value * 100) / 100 +def erosion_check_section(height_m: float, options: dict[str, Any]) -> dict[str, Any]: + """골막이 한 개소의 치수 한 벌 — **표(`erosion_check_dam`)와 구조물도 그림이 같이 씀.** + + 상장·하장·높이 · 뒷길이 · 반수면 비탈 · 상부/하부/평균 두께 · 사면장 · 바닥파기 · 방수로 관. + ⚠ 자름(`_floor2`)도 표와 같게 — 그림 글자가 표 근거 글자와 같은 수를 보이게. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length + + const = EROSION_CHECK_DAM + top_m = _num(options.get("top_length_m"), 0.0) + bottom_m = _num(options.get("bottom_length_m"), 0.0) + back_cm = _back_length(options) + back_m = back_cm / 100.0 + given = _num(options.get("face_slope_ratio"), 0.0) + slope = given if given > 0 else const["default_slope_ratio"] + top_raw = back_m + const["thickness_top_per_m"] * height_m + bottom_raw = back_m + const["thickness_bottom_per_m"] * height_m + thickness = _floor2((top_raw + bottom_raw) / 2) + return { + "top_m": top_m, + "bottom_m": bottom_m, + "height_m": height_m, + "back_cm": back_cm, + "back_m": back_m, + "slope": slope, + "slope_given": given > 0, + "top_t": _floor2(top_raw), + "bottom_t": bottom_raw, + "thickness": thickness, + "slant": _floor2(math.hypot((top_m - bottom_m) / 2, height_m)), + "trench_width": thickness + const["trench_extra_m"], + "trench_depth": const["trench_depth_m"], + "spillway": str(options.get("spillway") or "").strip() != "없음", + "pipe_r": const["spillway_pipe_r_m"], + } + + def erosion_check_dam( height_m: float, options: dict[str, Any] ) -> tuple[list[Component], list[str]]: @@ -228,14 +264,13 @@ def erosion_check_dam( default_note = back_length_default_note(options, back_cm) if default_note: notes.append(default_note) - back_m = back_cm / 100.0 - - given = _num(options.get("face_slope_ratio"), 0.0) - if given > 0: - slope, slope_note = given, f"사용자 지정 1:{given:g}" - else: - slope = const["default_slope_ratio"] - slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)" + geo = erosion_check_section(height_m, options) + back_m, slope = geo["back_m"], geo["slope"] + slope_note = ( + f"사용자 지정 1:{slope:g}" + if geo["slope_given"] + else f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)" + ) # ① 정면적(사다리꼴) − ② 방수로 파형강관 단면 # ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.** @@ -255,14 +290,7 @@ def erosion_check_dam( ) masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0))) - top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m) - thickness = _floor2( - ( - (back_m + const["thickness_top_per_m"] * height_m) - + (back_m + const["thickness_bottom_per_m"] * height_m) - ) - / 2 - ) + top_t, thickness = geo["top_t"], geo["thickness"] volume = _floor2(front_area * thickness) facing = top_m * (top_t - back_m) base_area = masonry + facing @@ -323,9 +351,7 @@ def erosion_check_dam( ) # 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3. - slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m)) - width = thickness + const["trench_extra_m"] - depth = const["trench_depth_m"] + slant, width, depth = geo["slant"], geo["trench_width"], geo["trench_depth"] trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width) spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width)) rows.extend( @@ -431,6 +457,17 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { #: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다. SOIL_GUARD_SOD: dict[str, Any] = { "unit_label": "개소당", + #: 정본 평균 붙박이 치수(m) — 아래 줄 식의 수 그대로. 기슭 0.54 = 사면장 √(0.2²+0.5²). + #: 구조물도 그림이 이 치수로 그림(`test_b08_structure_figure_small` 이 줄 값과 대조). + "dims": { + "top_m": 1.5, + "bottom_m": 1.1, + "height_m": 0.5, + "sod_m": 0.2, + "slant_m": 0.54, + "trench_width_m": 0.4, + "trench_depth_m": 0.2, + }, "rows": ( ( "떼", @@ -578,4 +615,11 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], "버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 " "돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)" ) + notes.append(BED_SILL_TRENCH_NOTE) return components, notes + + +#: 바닥막이 터파기 사유 — 표 사유·구조물도 그림이 **같은 말**(2026-09-14 그림에서 드러남). +BED_SILL_TRENCH_NOTE = ( + "⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감" +) diff --git a/resources/tester/test_b08_structure_figure_small.py b/resources/tester/test_b08_structure_figure_small.py new file mode 100644 index 00000000..1428884b --- /dev/null +++ b/resources/tester/test_b08_structure_figure_small.py @@ -0,0 +1,111 @@ +"""구조물도 그림 — 기슭막이·골막이·떼흙막이·바닥막이·개거 (2026-09-14 · 표 = 그림). + +표를 세운 그 전개(`build_table`)로 장을 만들고 그림 글자가 **표 근거와 같은 수**를 보이는지 대조. +""" + +from __future__ import annotations + +import re +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_StructureFigure import attach_figures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 + BED_SILL_TRENCH_NOTE, + SOIL_GUARD_SOD, +) + + +def _sheet(type_id: str, rubble=None, **options) -> dict: + structure = { + "structure_id": type_id, + "type_id": type_id, + "start_m": 0.0, + "end_m": float(options.get("length_m") or 0.0), + "options": options, + } + table = build_table([structure], None, rubble_base_thickness_m=rubble) + payload = build_standard_sheets(table) + attach_figures(payload, rubble) + return payload["sheets"][0] + + +def _texts(sheet: dict) -> list[str]: + return [shape["text"] for shape in sheet["figure"] or [] if shape["kind"] == "text"] + + +def _basis(sheet: dict, name: str) -> str: + return next(row["basis"] for row in sheet["rows"] if row["name"] == name) + + +def test_기슭막이_돌쌓기형은_돌쌓기_그림이고_기울기_두께가_표와_같다() -> None: + sheet = _sheet("revetment", form="돌쌓기(메)", height_m=2.0, back_len_cm=35, length_m=10) + texts = _texts(sheet) + assert sheet["figure_reason"] is None + slope = re.search(r"1:([\d.]+)", _basis(sheet, "돌쌓기")).group(1) # 메쌓기 표준경사 + assert f"1 : {slope}" in texts + top = re.search(r"상부 ([\d.]+)", _basis(sheet, "입적")).group(1) + assert f"상부 {top} m" in texts + concrete = _sheet("revetment", form="콘크리트", height_m=2.0, length_m=10) + assert concrete["figure"] is None and "콘크리트 기슭막이" in concrete["figure_reason"] + + +def test_골막이_그림_치수는_표_두께식과_같다() -> None: + sheet = _sheet( + "erosion_check", form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2, back_len_cm=45 + ) + texts = _texts(sheet) + thickness = re.search(r"평균두께 ([\d.]+)m", _basis(sheet, "입적")).group(1) + label = next(t for t in texts if t.startswith("상부")) + assert f"평균 {float(thickness):.2f}" in label + trench = next(t for t in texts if t.startswith("바닥파기")) + width = re.search(r"× ([\d.]+) \)", _basis(sheet, "터파기").replace(")", " )")).group(1) + assert f"폭 {float(width):.2f}" in trench + band = [s for s in sheet["figure"] if s["kind"] == "path" and s["dash"]][0] + assert min(y for _x, y in band["points"]) < 0 # 바닥파기 띠가 밑변 바깥(아래)에 섬 + + +def test_떼흙막이_붙박이_치수로_표_두_줄이_다시_나온다() -> None: + d = SOIL_GUARD_SOD["dims"] + sod = ( + d["top_m"] * d["sod_m"] + + d["bottom_m"] * d["sod_m"] + + (d["top_m"] + d["bottom_m"]) / 2 * d["height_m"] + + d["slant_m"] * d["sod_m"] * 2 + ) + trench = (d["slant_m"] * d["trench_width_m"] * d["trench_depth_m"] * 2) + ( + d["bottom_m"] * d["trench_width_m"] * d["trench_depth_m"] + ) + rows = {row[0]: row[2] for row in SOIL_GUARD_SOD["rows"]} + assert round(sod, 2) == rows["떼"] and round(trench, 2) == rows["터파기"] + assert ( + abs(((d["top_m"] - d["bottom_m"]) / 2) ** 2 + d["height_m"] ** 2 - d["slant_m"] ** 2) < 0.01 + ) + sheet = _sheet("soil_guard", form="떼", height_m=0.5, length_m=10) + assert sheet["figure"] and sheet["unit_label"] == "개소당" + other = _sheet("soil_guard", form="돌(찰)", height_m=0.5, length_m=10) + assert other["figure"] is None and "떼흙막이" in other["figure_reason"] + + +def test_바닥막이_그림_사유는_표와_같은_말이고_기초잡석은_산출_조건() -> None: + sheet = _sheet("bed_sill", rubble=0.3, form="돌붙임(찰)", area_m2=12, height_m=0.3) + texts = _texts(sheet) + assert BED_SILL_TRENCH_NOTE in sheet["notes"] and BED_SILL_TRENCH_NOTE in texts + assert "기초잡석 T=0.3 m — 산출 조건" in texts + dry = _sheet("bed_sill", form="돌붙임(메)", area_m2=12, height_m=0.3) + assert ( + not any("기초잡석" in t for t in _texts(dry)) and BED_SILL_TRENCH_NOTE not in dry["notes"] + ) + + +def test_개거는_표가_서도_치수를_못_정한_까닭() -> None: + sheet = _sheet("open_ditch", ditch_spec="콘크리트 개거 150×200", length_m=5) + assert sheet["rows"] and sheet["figure"] is None and "판정 대기" in sheet["figure_reason"] + l_type = _sheet("open_ditch", ditch_spec="L형수로 H=0.2", length_m=5) + assert l_type["rows"] and "단면 그림이 없어" in l_type["figure_reason"]