diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 34ab74a7..0c79a83a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -15,6 +15,7 @@ export interface DesignDrawingItem { | "landuse" | "plan_lidar" | "cross_standard" + | "standard" | "blank"; label: string; chainage_m: number | null; @@ -100,6 +101,7 @@ export interface DesignDrawingResponse { | "landuse" | "plan_lidar" | "cross_standard" + | "standard" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py new file mode 100644 index 00000000..4f67dac4 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py @@ -0,0 +1,203 @@ +"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09). + +실무 원본 탭이 「위 치수조서 + 아래 수량산출서」인데, 아래 표는 1단계에서 섰고 여기가 위다. + +⚠ **기울기를 값으로 받는다 — 0.3 을 박지 않는다.** + 품셈 표준경사 표 자동 판정이 다른 창에서 붙는 중이고, 붙으면 이 그림이 **그 값으로** + 기울어져야 한다. `face_slope_ratio` 가 비면(=자동 미구현·미입력) 교본 7-3 기본 0.3 으로 + 그리되 **그 사실을 그림에 적는다**. + +⚠ **수량이 쓰는 그 상수로 그린다** — `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY` 를 + 직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5장). + + 상부 두께 = 0.45 + 0.10·H 하부 두께 = 0.45 + 0.40·H + 터파기 폭 = 평균두께 + 0.2 되메우기 두께 = 0.2 + + ⓘ 이 상수는 **뒷면이 수직인 1:0.3 벽**을 뜻한다 — 하부−상부 = 0.30·H 이고 전면이 뒤로 + 기우는 양도 0.3·H 라 딱 맞는다. 기울기가 0.3 이 아니면 그 차이만큼 **뒷면이 기운다**. + 지어낸 것이 아니라 상수에서 따라 나오는 결과라 그대로 그린다. + +⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서 + 몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다. + 없는 치수를 그림에 적으면 **표와 다른 두 번째 정본**이 생긴다. +""" + +from __future__ import annotations + +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _line_entity, + _text_entity, + polyline_entity, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY + +#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다. +SCALE_MM_PER_M = 40.0 + +#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다. +FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"}) + +#: 기울기를 못 받았을 때 쓰는 값 — 교본 7-3 「돌 찰쌓기 3.0m 이하 1:0.3」. +DEFAULT_SLOPE_RATIO = 0.3 + +_LABEL_FONT = 4.0 +_DIM_FONT = 3.4 + + +def slope_ratio_of(options: dict[str, Any] | None) -> tuple[float, bool]: + """전면 기울기와 **그것이 입력된 값인지**. 비어 있으면 `(0.3, False)`.""" + raw = (options or {}).get("face_slope_ratio") + if isinstance(raw, (int, float)) and float(raw) > 0: + return float(raw), True + return DEFAULT_SLOPE_RATIO, False + + +def section_points(height_m: float, slope_ratio: float) -> list[tuple[float, float]]: + """벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다.""" + top_t = STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_top_coeff"] * height_m + bottom_t = ( + STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_bottom_coeff"] * height_m + ) + lean = slope_ratio * height_m + return [ + (0.0, 0.0), + (lean, height_m), + (lean + top_t, height_m), + (bottom_t, 0.0), + (0.0, 0.0), + ] + + +def build_figure( + drawing_id: str, + sheet: dict[str, Any], + layer_id: str, + origin: tuple[float, float], + line_color: str, + label_color: str, + guide_color: str, +) -> tuple[list[dict[str, Any]], float]: + """장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`.""" + if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS: + return [], 0.0 + height_m = float(sheet.get("height_m") or 0.0) + if height_m <= 0: + return [], 0.0 + + options = sheet.get("options") or {} + slope, slope_given = slope_ratio_of(options) + scale = SCALE_MM_PER_M + ox, oy = origin + + def mm(point: tuple[float, float]) -> tuple[float, float]: + return (ox + point[0] * scale, oy + point[1] * scale) + + points = section_points(height_m, slope) + top_t = STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_top_coeff"] * height_m + bottom_t = ( + STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_bottom_coeff"] * height_m + ) + average_t = (top_t + bottom_t) / 2.0 + dig_width = average_t + STONE_MASONRY["excavation_extra_m"] + backfill_t = STONE_MASONRY["backfill_thickness_m"] + + entities: list[dict[str, Any]] = [] + wall = polyline_entity( + f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2 + ) + if wall is not None: + entities.append(wall) + + # 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선. + dig = polyline_entity( + f"{drawing_id}:fig:dig", + [mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))], + layer_id, + guide_color, + dash=[4, 3], + ) + if dig is not None: + entities.append(dig) + + # 되메우기 두께 선 — 밑면에서 0.2m 위. 값이 어디서 오는지 눈에 보이게. + entities.append( + _line_entity( + f"{drawing_id}:fig:backfill", + mm((0.0, backfill_t)), + mm((dig_width, backfill_t)), + layer_id, + guide_color, + ) + ) + + # 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀). + weep_y = height_m * 0.5 + entities.append( + _line_entity( + f"{drawing_id}:fig:weep", + mm((slope * weep_y, weep_y)), + mm((slope * weep_y + top_t, weep_y)), + layer_id, + guide_color, + ) + ) + + labels: list[tuple[str, tuple[float, float], str]] = [ + (f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"), + (f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"), + (f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"), + ( + f"1 : {slope:g}" + ("" if slope_given else " (자동 판정 전 — 교본 기본값)"), + (slope * height_m / 2.0 - 0.30, height_m * 0.72), + "right", + ), + ( + f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)", + (dig_width + 0.16, height_m * 0.92), + "left", + ), + (f"되메우기 {backfill_t:g} m", (dig_width + 0.16, backfill_t), "left"), + ( + f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소", + (slope * weep_y + top_t + 0.16, weep_y), + "left", + ), + ("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"), + ] + back = options.get("back_len_cm") or options.get("stone_back_length_cm") + if back: + labels.append((f"뒷길이 ℓ₃ = {int(back)}㎝", (dig_width + 0.16, height_m * 0.72), "left")) + + for index, (text, point, align) in enumerate(labels): + x, y = mm(point) + entities.append( + _text_entity( + f"{drawing_id}:fig:label:{index}", + text, + x, + y, + layer_id, + _DIM_FONT, + label_color, + align, + ) + ) + + title_x, title_y = mm((0.0, height_m + 0.52)) + entities.append( + _text_entity( + f"{drawing_id}:fig:title", + f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})", + title_x, + title_y, + layer_id, + _LABEL_FONT, + label_color, + "left", + ) + ) + + used_mm = (height_m + 0.95) * scale + return entities, used_mm diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py index 17ee4a46..08c60564 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py @@ -29,12 +29,25 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _layer, table_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( + SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox logger = logging.getLogger(__name__) STANDARD_LAYER_ID = "standard" +#: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`). +SHEET_ID_PREFIX = "standard_sheet" + + +def sheet_drawing_id(number: int, total: int) -> str: + """장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다.""" + return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}" + + # 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다. # ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가 # 붙은 근거 문구가 칸을 넘어 수량 칸을 덮었다(2026-09-09 실화면). 표는 글자를 안 접는다. @@ -47,6 +60,16 @@ _ROW_HEIGHT = 9.0 _FONT = 4.2 #: 표 사이 간격 — 장이 여럿일 때 서로 붙어 보이지 않게. _GAP = 14.0 +#: 그림을 왼쪽 여백에서 조금 들여 놓는다 — 치수 글자가 왼쪽으로 나가기 때문. +_FIGURE_INSET = 60.0 +#: 그림 제목이 들어갈 윗여백. +_FIGURE_TOP_PAD = 6.0 + + +def _figure_height(sheet: dict[str, Any]) -> float: + """그림이 차지할 높이(㎜) — 자리를 먼저 비워 두려고 미리 잰다.""" + height_m = float(sheet.get("height_m") or 0.0) + return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0 def _cell( @@ -145,6 +168,19 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any]) top = y1 for index, sheet in enumerate(sheets, start=1): + # 위 그림 — 실무 탭의 「치수조서」 자리. 못 그리는 종류는 표만 선다(2단계). + figure, used = build_figure( + drawing_id, + sheet, + STANDARD_LAYER_ID, + (x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)), + TABLE_LINE_COLOR, + TABLE_LABEL_COLOR, + TABLE_VALUE_COLOR, + ) + if figure: + entities.extend(figure) + top -= used + _GAP grid = _sheet_rows(sheet) entities.append( table_entity( @@ -168,11 +204,9 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any]) } -def standard_drawing_for(project_root: Path, drawing_id: str, label: str) -> dict[str, Any]: - """프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문. +def standard_payload(project_root: Path) -> dict[str, Any]: + """프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다. - ⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로 - 읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다. ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. """ @@ -182,8 +216,42 @@ def standard_drawing_for(project_root: Path, drawing_id: str, label: str) -> dic try: structures, names, _skipped = _collect_structures(str(project_root)) - payload = build_standard_sheets(build_unit_table(structures, names)) + return build_standard_sheets(build_unit_table(structures, names)) except Exception: - logger.exception("B07 표준도 하단표 실패 — 사유만 실어 연다: %s", drawing_id) - payload = {"sheets": [], "structure_count": 0} - return build_standard_drawing(drawing_id, label, payload) + logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root) + return {"sheets": [], "structure_count": 0} + + +def sheet_items(project_root: Path) -> list[tuple[str, str]]: + """좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다. + + 구조물이 없다고 단추가 통째로 사라지면 「없어진 것」처럼 보인다 — 눌러서 사유를 읽게 한다. + """ + sheets = standard_payload(project_root).get("sheets") or [] + if not sheets: + return [(SHEET_ID_PREFIX, "표준도")] + total = len(sheets) + return [ + (sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip()) + for index, sheet in enumerate(sheets, start=1) + ] + + +def standard_drawing_for(project_root: Path, drawing_id: str, label: str) -> dict[str, Any]: + """프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문. + + ⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로 + 읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다. + ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 + 모듈 맨 위에서 부르면 맞물린다. + """ + payload = standard_payload(project_root) + sheets = payload.get("sheets") or [] + # 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서). + total = len(sheets) + picked = [ + sheet + for index, sheet in enumerate(sheets, start=1) + if sheet_drawing_id(index, total) == drawing_id + ] + return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b114350d..556b8cdf 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -17,10 +17,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( - build_blank_drawing, - build_cover_drawing, -) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( LANDUSE_LABEL, build_landuse_drawing, @@ -47,11 +44,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( plan_cross_sheets, section_block_size, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_drawing_for from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( STANDARD_LABEL, build_standard_cross_drawing, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( + SHEET_ID_PREFIX, + sheet_items, + standard_drawing_for, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) @@ -107,11 +108,6 @@ COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" -# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). -# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. -BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),) -BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) - def _drawing_list( project_root: Path, @@ -208,10 +204,10 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) - # 아직 내용을 만들지 않은 도면도 **빈 도각으로 열린다**(2026-09-01 사용자 지시). - # 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. - for drawing_id, label in BLANK_DRAWINGS: - drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label)) + # 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은 + # 남긴다: 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을 자리도 없어진다. + for drawing_id, label in sheet_items(project_root): + drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label)) _store_drawing_numbers(project_root, drawings) return drawings @@ -444,14 +440,10 @@ def _read_drawing( stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table - if drawing_id in BLANK_LABELS: - label = BLANK_LABELS[drawing_id] - if drawing_id == "blank_standard": - # 표준도 — 이제 하단표를 싣는다(2026-09-09). 도각은 두르지 않는다(사용자 지시). - drawing = standard_drawing_for(project_root, drawing_id, label) - return "blank", label, drawing, False, None - # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다. - return "blank", label, build_blank_drawing(drawing_id, label), False, None + if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"): + # 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시). + label = dict(sheet_items(project_root)).get(drawing_id, "표준도") + return "standard", label, standard_drawing_for(project_root, drawing_id, label), False, None if drawing_id == COVER_ID: # 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다. diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index c129d9fc..9e3b2f07 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -20,6 +20,8 @@ class DesignDrawingItem(BaseModel): "landuse", "plan_lidar", "cross_standard", + # standard: 표준도(구조물도) — 제원 조합 하나가 한 장. + "standard", "blank", ] label: str @@ -54,6 +56,8 @@ class DesignDrawingResponse(BaseModel): "landuse", "plan_lidar", "cross_standard", + # standard: 표준도(구조물도) — 제원 조합 하나가 한 장. + "standard", "blank", ] label: str diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 05a5d1fd..79d1458f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -37,7 +37,8 @@ export const DRAWING_GROUPS: readonly { { label: "횡단면도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "표준도", blankId: "blank_standard" }, + // 표준도는 **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09) — 계획평면도와 같은 묶음. + { label: "표준도", idPrefix: "standard_sheet" }, { label: "용지도", idPrefix: "landuse" }, ];