Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureFigure.py
T
eomsangdonandClaude Opus 5 81286400bd fix(b08,b09): 기본값으로 선 구조물은 줄만 서고 금액 밖 — 「미확정 N건 — 금액에 안 들어감」
- 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기
- 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌
- 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지
- 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준)
- 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건

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

452 lines
21 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_Pipe import NOTE_DEFAULT_WALL
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", "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 {}
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 "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)