"""구조물도 **상단 그림** — 돌쌓기 단면 (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_Pipe import NOTE_DEFAULT_WALL from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, 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", "revetment"}) #: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌). SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"}) #: 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그리는 종류 — 단면 없는 규격은 까닭. OBSERVED_TYPE_IDS: frozenset[str] = frozenset( {"retaining_wall", "pipe_inlet_basin", "pipe_wing_wall", "ford_pavement"} ) FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | OBSERVED_TYPE_IDS #: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조). _OPEN_DITCH_REASONS: dict[str, str] = { "콘크리트 개거 150×200": ( "그림 없음 — 원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)" " · 같은 탭 「개거(150-200)」 안에서 갈림" " · 어느 쪽으로 그릴지 확인 대기(임의로 고르지 않음)" ), "L형수로 H=0.2": ( "그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어" " 산출식만으로 모양을 정하지 않습니다" ), } #: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다 #: (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 {} wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0) solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3") back = ( int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options, wet=wet, height_m=height) ) return back, back_length_default_note(options, back, wet=wet, height_m=height) 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 in OBSERVED_TYPE_IDS: 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"): why = _NO_SECTION_REASONS.get(type_id) or _NO_SECTION_BY_SOURCE.get( str(entry.get("source") or ""), "이 규격은 자료에 수량만 있고 단면 치수가 없습니다" ) return None, f"그림 없음 — {why}" return entry, None #: 관측 줄에 단면이 없는 까닭 — 종류별(원문을 봤는데 못 정한 자리). _NO_SECTION_REASONS: dict[str, str] = { "pipe_wing_wall": ( "원문 「날개벽(…)산출근거」 탭에 구조도 그림이 없고(「구조도 참조」만) 날개 모양이" " 산출식에 손으로 적은 수로만 있어 모양을 정하지 않음 — 구조도 원본 확보 대기" ), } #: 출처별 — 수량 집계표 관측값은 치수 원문 자체가 없음. _NO_SECTION_BY_SOURCE: dict[str, str] = { "uljin_compare": "수량 집계표 관측값(울진 1공구)이라 구조도·치수 원문이 없습니다", } 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 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 ("pipe_inlet_basin", "ford_pavement"): from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small entry, _reason = _observed_section(sheet) if type_id == "pipe_inlet_basin": return small.inlet_basin_figure(sheet, entry or {}) return small.pavement_figure(sheet, entry or {}) 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) 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, ), ] # 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가 # 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14). wall_prefix = NOTE_DEFAULT_WALL.split("{")[0] lines = [default_note] + [ str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix) ] for index, line in enumerate(line for line in lines if line): shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "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 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") ) # 터파기 — 표 줄과 같은 식(기초 폭 + 양쪽 여유 · 수직 · 기초 윗면에서 기초잡석 밑까지). 점선. trench = section.get("trench") clearance = trench["clearance_m"] if trench else 0.0 side = (trench or {}).get("side_slope", 0.0) * (f_t + b_t + rubble) dig_bottom = -(b_t + rubble) if trench: shapes.append( _path( [ (-clearance - side, f_t), (-clearance, dig_bottom), (f_w + clearance, dig_bottom), (f_w + clearance + side, f_t), ], "guide", dash=True, ) ) right = max(f_w + over, f_w + clearance + side) + 0.2 weep_area, weep_dia, _weep_basis = weep_hole_spec(sheet.get("options") or {}) 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_dia} — {weep_area: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", ) ) if trench: shapes.append( _text( f"터파기 밑폭 {f_w + 2 * clearance:.2f} · 윗폭 {f_w + 2 * (clearance + side):.2f}" f" × 깊이 {f_t - dig_bottom:.2f} m (기초 {f_w:g} + 여유 {clearance:g}×2" f" · 옆면 1:{trench.get('side_slope', 0.0):g})", (right, f_t + 0.25), "left", ) ) below = -max(k_d, b_t + rubble) - 0.36 notes = [ f"단면 — 관측 원단위 {entry.get('source_note') or ''}의 수량이 나온 그 단면", earthwork_missing_note(entry), ] 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)