Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureFigure.py
T
eomsangdonandClaude Opus 5 aba96f6116 fix(b08): 옹벽 기초 터파기 옆면 1:0.5 — 실무 식생옹벽블럭 기초 터파기와 같은 자리에서 기울기(브레인 판정 고침)
- 밑폭 2.2(기초 1.6 + 여유 0.3×2) · 윗폭 = 밑폭 + 0.5 × 판 깊이 × 2 = 2.9 · 깊이 0.7 → m당 터파기 1.54 → 1.785 · 되메우기 0.36 → 0.605 · 잔토 1.18 그대로
- 검증 프로젝트 내역 본체 124,202,681 → 124,424,547원(+221,866 = 구조물터파기 0~1m 15.4→17.85㎥ +213,375 · 되메우기 24.6→27.05㎥ +8,491)
- 그림 터파기 점선을 사다리꼴로 · 「밑폭 2.20 · 윗폭 2.90 × 깊이 0.70」 · 표 사유와 같은 말

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:11:47 +09:00

385 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물도 **상단 그림** — 돌쌓기 단면 (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
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)