feat(b08): 구조물도 그림 — 기슭막이 · 골막이 · 떼흙막이 · 바닥막이 · 개거 까닭

- 기슭막이(돌쌓기형) — 돌쌓기 그림 그대로 · 기울기 판정 대상에 넣고 찰/메는 형태에서(표와 한 벌)
- 골막이 — 정면 사다리꼴 · 방수로 관 · 바닥파기 띠 + 옆 단면 · 치수는 표와 같이 쓰는 erosion_check_section 한 벌로 뽑음(값 불변)
- 떼흙막이 — 정본 평균 붙박이 치수(dims)로 · 그 치수로 표 두 줄(떼 1.39 · 바닥파기 0.17)이 다시 나오는지 시험
- 바닥막이 — 돌 켜 · 버림 · 기초잡석(산출 조건) · 터파기 · 표 사유에 새 한 줄 「터파기는 돌 두께만 — 버림·기초잡석은 판 깊이에 안 들어감」을 그림도 같이 씀
- 표가 안 선 장은 표 사유를 그대로 까닭으로 · 개거 둘은 원문끼리 치수가 달라서/단면 그림이 없어서 까닭
- 화면 확인: 검증 프로젝트 936be972 에 넷을 잠깐 놓았다 되돌림(구조물 5개 원본과 같음 · 단계 상태 그대로 · 내역 124,424,547 그대로)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 06:53:31 +09:00
co-authored by Claude Opus 5
parent 2c0ae48ac7
commit 951e39a923
5 changed files with 440 additions and 28 deletions
@@ -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)
@@ -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
@@ -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]
@@ -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 = (
"⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감"
)
@@ -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"]