feat(b08): 구조물도 옹벽 단면 그림 — 관측 원단위 줄에 단면 치수 · 표 사유와 같은 말

- 반중력식 H=2.0 관측 줄에 단면(기초 1.6×0.4 · 벽체 1.6 상단 0.3 앞면 0.118 뒷면 0.032 · 전단키 0.35×0.3 · 버림 0.1 · 물빼기 관 0.4)을 실음 — 출처 소광리 07-구조도 「옹벽2.0」 산출식·일반도
- 그 치수로 표 수량이 다시 나오는지 시험(콘크리트 1.345→1.35 · 버림 0.145→0.15 · 유로폼 3.205→3.2 · 관 0.32)
- 그림: 기초·벽체·전단키 + 버림(키 자리 비움) + 기초잡석(산출 조건 두께 · 버림과 같은 폭) + 물구멍
- 표 사유에 새 한 줄 둘(그림도 같은 말): 관측 줄에 터파기·되메우기·잔토가 없음 · 물빼기 칸을 채워도 관측 값이 붙박이라 안 닿음
- 까닭은 표가 못 선 장만(형식 미정 · 자료에 없는 규격 · 단면 치수 없는 규격) — 표 사유와 같은 말

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 05:27:49 +09:00
co-authored by Claude Opus 5
parent bc1b2fdad0
commit 5e983018a3
5 changed files with 276 additions and 9 deletions
@@ -151,6 +151,25 @@ def billing_of(
return str(found.get("unit") or "개소"), scale
def earthwork_missing_note(entry: dict[str, Any]) -> str | None:
"""관측 줄에 토공 줄이 없으면 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
if any(item.get("destination") == "earthwork" for item in entry.get("components") or []):
return None
return "⚠ 터파기·되메우기·잔토가 안 섬 — 관측 원단위 표에 그 줄이 없음(값을 지어내지 않음)"
def weep_option_note(entry: dict[str, Any], options: dict[str, Any]) -> str | None:
"""제원의 물빼기 칸을 채웠는데 관측 값이 붙박이라 **표에 안 닿을 때** 한 줄(표·그림 같은 말)."""
weep = (entry.get("section") or {}).get("weep")
keys = ("weep_hole_diameter_mm", "weep_hole_area_m2")
if not weep or all(options.get(key) in (None, "") for key in keys):
return None
return (
f"⚠ 물빼기 지름·면적 칸이 이 표에 안 닿음 — 관측 원단위 붙박이(Ø{weep['diameter_mm']}"
f" · {weep['area_m2']:g}㎡당 1개소 · 관 {weep['pipe_length_m']:g} m)"
)
def expand_observed(
type_id: str,
spec: dict[str, Any],
@@ -189,4 +208,11 @@ def expand_observed(
"source": source_key,
}
)
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
for note in (
earthwork_missing_note(found),
weep_option_note(found, structure.get("options") or {}),
):
if note:
notes.append(note)
return components, notes
@@ -44,11 +44,13 @@ from common_util.common_util_excavation import (
#: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: 이 그림을 그리는 종류. 집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데
#: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔
#: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: ⓘ 옹벽(2026-09-14)은 **관측 원단위 줄에 실린 단면 치수**로 그림 — 표 수량이 나온 그 단면.
MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | {"retaining_wall"}
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
@@ -57,7 +59,6 @@ _NO_FIGURE_REASONS: dict[str, str] = {
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다",
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
@@ -82,11 +83,37 @@ def figure_reason(sheet: dict[str, Any]) -> str | None:
type_id = str(sheet.get("type_id") or "")
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if type_id == "retaining_wall":
return _observed_section(sheet)[1]
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
return None
def _observed_section(sheet: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
"""(관측 원단위 줄, 못 그리는 까닭) — 표가 줄을 꺼낸 **그 조회**(`OBSERVED_SPEC_KEYS`)를 씀.
표가 안 선 장은 표 사유와 같은 말 앞에 「그림 없음」만 붙임.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import OBSERVED_SPEC_KEYS
from B08_Quantity.B08_Quantity_Wording import option_missing, spec_missing
type_id = str(sheet.get("type_id") or "")
keys = OBSERVED_SPEC_KEYS.get(type_id) or ()
options = sheet.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
table = load_observed_table()
if not spec:
return None, f"그림 없음 — {option_missing(keys[0], type_id)}"
entry = table.find(type_id, spec)
if entry is None:
return None, f"그림 없음 — {spec_missing(type_id, table.specs_for(type_id))}"
if not entry.get("section"):
return None, "그림 없음 — 이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
return entry, None
def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]:
return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash}
@@ -97,10 +124,23 @@ def _text(
return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size}
def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None:
"""장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`)."""
def build_figure(
sheet: dict[str, Any], rubble_thickness_m: float | None = None
) -> list[dict[str, Any]] | None:
"""장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`).
`rubble_thickness_m` — 산출 조건 기초잡석 두께(표의 기초잡석 줄과 같은 값 · 비면 기본).
"""
if figure_reason(sheet) is not None:
return None
if sheet.get("type_id") == "retaining_wall":
entry, _reason = _observed_section(sheet)
return _wall_figure(sheet, entry or {}, rubble_thickness_m)
return _masonry_figure(sheet)
def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""돌쌓기 단면 — 두께는 수량표 함수 한 벌."""
options = sheet.get("options") or {}
height_m = float(sheet.get("height_m") or 0.0)
back_cm, default_note = back_length_cm(sheet)
@@ -188,8 +228,132 @@ def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None:
return shapes
def attach_figures(payload: dict[str, Any]) -> None:
def _wall_figure(
sheet: dict[str, Any], entry: dict[str, Any], rubble_thickness_m: float | None
) -> list[dict[str, Any]]:
"""옹벽 단면 — 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그림.
x 는 앞굽 끝이 0(앞면 왼쪽), y 는 기초 밑면이 0. 버림·기초잡석은 기초 밑, 전단키가 뚫고 내려감.
⚠ 기초잡석 두께는 표의 기초잡석 줄과 같은 산출 조건 값(비면 기본) — 폭은 버림과 같음.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
earthwork_missing_note,
weep_option_note,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
section = entry["section"]
footing, wall, key = section["footing"], section["wall"], section["key"]
blinding, weep = section["blinding"], section["weep"]
f_w, f_t, toe = footing["width_m"], footing["thickness_m"], footing["toe_m"]
w_h, w_top = wall["height_m"], wall["top_m"]
front, back = wall["front_batter_m"], wall["back_batter_m"]
w_bottom = w_top + front + back
heel = f_w - toe - w_bottom
k_w, k_d = key["width_m"], key["depth_m"]
k_left = f_w - key["from_heel_m"] - k_w
b_t, over = blinding["thickness_m"], blinding["overhang_m"]
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
top = f_t + w_h
shapes = [
_path(
[
(0.0, 0.0),
(0.0, f_t),
(toe, f_t),
(toe + front, top),
(toe + front + w_top, top),
(toe + w_bottom, f_t),
(f_w, f_t),
(f_w, 0.0),
(k_left + k_w, 0.0),
(k_left + k_w, -k_d),
(k_left, -k_d),
(k_left, 0.0),
(0.0, 0.0),
],
"wall",
),
]
# 버림(키 자리는 비움) · 그 밑 기초잡석(같은 폭).
layers = [(0.0, b_t, False)] + ([(b_t, rubble, True)] if rubble > 0 else [])
for depth, thickness, dash in layers:
for x0, x1 in ((-over, k_left), (k_left + k_w, f_w + over)):
shapes.append(
_path(
[
(x0, -depth),
(x0, -depth - thickness),
(x1, -depth - thickness),
(x1, -depth),
],
"guide",
dash=dash,
)
)
weep_y = f_t + w_h * 0.5
ratio = (weep_y - f_t) / w_h
shapes.append(
_path([(toe + front * ratio, weep_y), (toe + w_bottom - back * ratio, weep_y)], "guide")
)
right = f_w + over + 0.2
blinding_width = f_w + 2 * over - k_w
shapes += [
_text(
f"{sheet.get('title') or '옹벽'} {sheet.get('options', {}).get('form') or ''}"
f" (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, top + 0.52),
"left",
_LABEL_SIZE,
),
_text(f"H = {top:g} m", (-0.28, top / 2.0), "right"),
_text(f"상단 {w_top:.2f} m", (toe + front + w_top / 2.0, top + 0.14), "center"),
_text(
f"벽체 높이 {w_h:.2f} · 하단 {w_bottom:.2f} m (앞면 {front:g} · 뒷면 {back:g} 기움)",
(right, top * 0.9),
"left",
),
_text(
f"물구멍 Ø{weep['diameter_mm']}{weep['area_m2']:g}㎡당 1개소"
f" · 관 {weep['pipe_length_m']:g} m",
(right, weep_y),
"left",
),
_text(
f"기초 {f_w:.2f} × {f_t:.2f} m (앞굽 {toe:.2f} · 뒷굽 {heel:.2f})",
(right, f_t / 2.0),
"left",
),
_text(
f"버림 T={b_t:.2f} · 폭 {f_w + 2 * over:.2f} {k_w:.2f} = {blinding_width:.2f} m",
(right, -b_t / 2.0),
"left",
),
_text(f"전단키 {k_w:.2f} × {k_d:.2f} m", (k_left + k_w / 2.0, -k_d - 0.14), "center"),
]
if rubble > 0:
shapes.append(
_text(
f"기초잡석 T={rubble:g} m — 산출 조건 · 폭은 버림과 같음",
(right, -b_t - rubble / 2.0),
"left",
)
)
below = -max(k_d, b_t + rubble) - 0.36
notes = [
f"단면 — 관측 원단위 {entry.get('source_note') or ''}의 수량이 나온 그 단면",
earthwork_missing_note(entry),
weep_option_note(entry, sheet.get("options") or {}),
]
for index, note in enumerate(note for note in notes if note):
shapes.append(_text(note, (0.0, below - index * 0.16), "left"))
return shapes
def attach_figures(payload: dict[str, Any], rubble_thickness_m: float | None = None) -> None:
"""장마다 `figure`(모양 목록 또는 `None`)와 `figure_reason` 을 실음 — 화면은 둘 중 하나."""
for sheet in payload.get("sheets") or []:
sheet["figure_reason"] = figure_reason(sheet)
sheet["figure"] = build_figure(sheet)
sheet["figure"] = build_figure(sheet, rubble_thickness_m)
@@ -76,7 +76,7 @@ def project_structure_sheets(
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings, templates)
# 상단 그림(읽기 전용 SVG 자료) — 못 그리는 장은 까닭(PLAN 12장).
attach_figures(payload)
attach_figures(payload, settings.get("rubble_base_thickness_m"))
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return payload
@@ -33,6 +33,14 @@
"unit": "m",
"source": "uljin_library",
"source_note": "§7 옹벽류 — 반중력식옹벽 H=2.0",
"section": {
"note": "단면 치수(m) — 위 수량이 나온 그 단면. 출처 소광리 07-구조도 「옹벽2.0」 탭 산출식(T22 기초 0.4×1.6+0.3×0.35 · T23 벽체 (0.3+0.45)÷2×1.6 · T25 버림 0.1×(1.80.35) · T26·T27 유로폼 √(1.6²+0.032²)+√(1.6²+0.118²) · T31 파이프 1.6÷2×0.4)과 같은 탭 일반도(앞굽 950·뒷굽 200·키 자리 뒷굽 끝에서 500). 원문 1.345·0.145·3.205 를 라이브러리가 1.35·0.15·3.2 로 적음",
"footing": { "width_m": 1.6, "thickness_m": 0.4, "toe_m": 0.95 },
"wall": { "height_m": 1.6, "top_m": 0.3, "front_batter_m": 0.118, "back_batter_m": 0.032 },
"key": { "width_m": 0.35, "depth_m": 0.3, "from_heel_m": 0.5 },
"blinding": { "thickness_m": 0.1, "overhang_m": 0.1 },
"weep": { "area_m2": 2.0, "diameter_mm": 50, "pipe_length_m": 0.4 }
},
"components": [
{
"name": "콘크리트",
@@ -5,6 +5,7 @@
from __future__ import annotations
import math
import sys
from pathlib import Path
@@ -12,9 +13,14 @@ 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,
@@ -96,6 +102,69 @@ def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -
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"]
)
weep = section["weep"]
pipe = wall["height_m"] / weep["area_m2"] * weep["pipe_length_m"]
assert round(concrete, 2) == amounts["콘크리트"] # 1.345
assert round(blind, 2) == amounts["버림콘크리트"] # 0.145
assert round(form, 1) == amounts["유로폼"] # 3.205
assert round(pipe, 2) == amounts["물구멍관"]
assert footing["thickness_m"] + wall["height_m"] == 2.0
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}},
)
missing = [note for note in table_notes if "터파기" in note]
assert missing and missing[0] 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
note = [t for t in _texts(build_figure(_wall_sheet(weep_hole_area_m2=3))) if "안 닿음" in t]
assert note and "Ø50" in note[0]
def test_양식_장은_양식이_푼_뒷길이로_그린다() -> None:
sheet = {**_sheet(), "formula_sheet": {"vars": {"L3": 55}}}
texts = _texts(build_figure(sheet))