From 5e983018a307aa4ba2d09901d29731dadd9fc585 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 05:27:49 +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=EC=98=B9=EB=B2=BD=20=EB=8B=A8=EB=A9=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=E2=80=94=20=EA=B4=80=EC=B8=A1=20=EC=9B=90=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=A4=84=EC=97=90=20=EB=8B=A8=EB=A9=B4=20=EC=B9=98?= =?UTF-8?q?=EC=88=98=20=C2=B7=20=ED=91=9C=20=EC=82=AC=EC=9C=A0=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반중력식 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) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_ObservedUnit.py | 28 ++- .../B08_Quantity_Engine_StructureFigure.py | 178 +++++++++++++++++- .../B08_Quantity_Router_StructureSheet.py | 2 +- .../structure_unit_observed_2026-01-01.json | 8 + resources/tester/test_b08_structure_figure.py | 69 +++++++ 5 files changed, 276 insertions(+), 9 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index 1069c131..be3d7eda 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index 4d47aa47..a3ab3078 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 04d3e48e..e268d3c8 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -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 diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index 99660f72..d5d26701 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -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.8−0.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": "콘크리트", diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py index 908677a8..e8589556 100644 --- a/resources/tester/test_b08_structure_figure.py +++ b/resources/tester/test_b08_structure_figure.py @@ -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))