"""구조물도 **상단 그림** — 돌쌓기 단면 (PLAN 12장 · 3장 한 장의 짜임 「상단 그림」). ⚠ **2026-09-14 B07 에서 이관** — 옛 자리 `B07_DesignDetail_Engine_Cad_StandardFigure.py`. CAD 엔티티 대신 **모양 목록(선·글자, m 단위, 위가 +y)** 을 냄 — 화면이 SVG 로 그리고(읽기 전용), CAD 는 뒤 일감이 같은 목록을 받아 씀(B07 `StandardSheet` 가 이미 옮겨 적음). ⚠ **기울기를 값으로 받는다 — 0.3 을 박지 않는다.** 판정은 B08 `slope_of()` 한 벌(품셈 13-4-4 [주]⑪ 표준경사 — 직고·메찰·성절토). 사용자가 정했으면 그 값이 이긴다(확정 ⑨). 그림은 **수량이 쓴 바로 그 값**으로 기울고 근거 문구도 같이 적는다. ⚠ **두께는 수량표가 쓰는 그 함수**(`UnitQuantity.wall_thickness`) — 사용자가 넣은 상부·하부 두께가 식을 이긴다. 옛 B07 그림은 식을 따로 적어 **두께를 넣어도 그림만 옛 두께**로 섰음(이관 때 잡음). 상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0) 터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림) ⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다. ⚠ 뒷길이를 안 정한 장은 **표가 선 그 기본값으로 그리고 표 사유와 같은 말을 적는다** (2026-09-14 브레인 판정 — 표가 서면 그림도 섬). 까닭은 정말 못 그리는 장(큰돌쌓기 등)만. ⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서 몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다. """ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( _back_length, back_length_default_note, wall_thickness, weep_hole_spec, ) from common_util.common_util_excavation import ( WALL_BLINDING_DEPTH_M, WALL_BLINDING_WIDTH_M, WALL_FOUNDATION_DEPTH_M, WALL_FOUNDATION_WIDTH_M, WALL_TRENCH_CLEARANCE_M, ) #: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀. SCALE_MM_PER_M = 40.0 #: 이 그림을 그리는 종류. 집수정처럼 단면이 다른 것은 아직 그리지 않는다. #: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데 #: 큰돌쌓기는 규격 축이 **직경**(`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"} #: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다 #: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다. _NO_FIGURE_REASONS: dict[str, str] = { "boulder_masonry": ( "그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다" "(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)" ), } _NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다" #: 글자 크기(m) — 종이 ㎜ 를 축척으로 나눈 값(옛 CAD 그림과 같은 크기). _LABEL_SIZE = 4.0 / SCALE_MM_PER_M _DIM_SIZE = 3.4 / SCALE_MM_PER_M def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]: """(표가 쓴 뒷길이 ㎝, 기본으로 섰으면 표 사유와 같은 한 줄). 양식 장은 양식이 푼 값(`formula_sheet.vars.L3`), 전개 장은 `_back_length` — 표와 한 벌. """ options = sheet.get("options") or {} solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3") back = int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options) return back, back_length_default_note(options, back) def figure_reason(sheet: dict[str, Any]) -> str | None: """그림이 **안 서는 까닭** 한 줄. 서는 장이면 `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} def _text( text: str, at: tuple[float, float], align: str, size: float = _DIM_SIZE ) -> dict[str, Any]: return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size} 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) slope, slope_note = slope_of(sheet) top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m) lean = slope * height_m average_t = (top_t + bottom_t) / 2.0 dig_width = average_t + WALL_TRENCH_CLEARANCE_M # 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). 안 정한 장은 안 그린다. foundation = str(options.get("foundation") or "") base_w, base_d = { "기초유": (WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M), "기초버림": (WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M), }.get(foundation, (0.0, 0.0)) shapes = [ _path( [(0.0, 0.0), (lean, height_m), (lean + top_t, height_m), (bottom_t, 0.0), (0.0, 0.0)], "wall", ), # 터파기 — 수량식 H × (평균두께 + 0.2) 와 **넓이가 같은** 평행사변형(앞면 따라 기움). _path( [ (0.0, 0.0), (lean, height_m), (lean + dig_width, height_m), (dig_width, 0.0), (0.0, 0.0), ], "guide", dash=True, ), ] if base_d > 0: shapes.append( _path([(0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0)], "guide") ) # 물구멍 — 벽을 가로지르는 짧은 선. 값은 **그 장의 제원**에서(수량표와 같은 함수). weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(options) weep_y = height_m * 0.5 shapes.append(_path([(slope * weep_y, weep_y), (slope * weep_y + top_t, weep_y)], "guide")) right = max(lean + dig_width, base_w) + 0.16 shapes += [ _text(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"), _text(f"상부 {top_t:.2f} m", (lean + top_t / 2.0, height_m + 0.14), "center"), _text(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30 - base_d), "center"), _text(f"1 : {slope:g}", (lean / 2.0 - 0.30, height_m * 0.72), "right"), _text( f"터파기 폭 {dig_width:.2f} m " f"(평균두께 {average_t:.2f} + {WALL_TRENCH_CLEARANCE_M:g})", (right, height_m * 0.92), "left", ), _text( f"뒷길이 ℓ₃ = {back_cm}㎝{' (기본)' if default_note else ''}", (right, height_m * 0.72), "left", ), _text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"), _text( f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소", (right, weep_y - 0.2), "left", ), _text( f"기초 {base_w:g} × {base_d:g} m ({foundation})" if base_d > 0 else "기초 — 안 정함(기초유/기초버림)", (right, -base_d / 2.0 if base_d else 0.0), "left", ), # 기울기 근거는 그림 밑에 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다. _text(f"전면 기울기 — {slope_note}", (0.0, -0.62 - base_d), "left"), _text( f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})", (0.0, height_m + 0.52), "left", _LABEL_SIZE, ), ] if default_note: # 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). shapes.append(_text(default_note, (0.0, -0.80 - base_d), "left")) return shapes 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, rubble_thickness_m)