From 9b01cafb5201a882b6be142c46c72f8858b97e7e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:48:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(377-1=20B07=20=EC=A0=84=EA=B0=9C?= =?UTF-8?q?=EB=8F=84):=20=EC=A0=84=EA=B0=9C=EB=8F=84=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B8=B0=20=E2=80=94=20(=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A2=85=EB=A5=98=20=C2=B7=20=ED=98=95=ED=83=9C)?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=ED=95=9C=20=EC=9E=A5=20=C2=B7=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=3D=20=EA=B1=B0=EB=A6=AC=20=C3=97=20H(=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=20B)=20=C2=B7=20=ED=98=95=ED=83=9C=20=EB=AC=B4?= =?UTF-8?q?=EB=8A=AC=20=EA=B8=B0=EB=B3=B8=20=EB=8F=84=ED=98=95(DXF=20?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EB=82=A8=EC=9D=8C)=20=C2=B7=20=EC=96=91?= =?UTF-8?q?=20=EB=81=9D=20=EC=B8=A1=EC=A0=90=20No.04+08=20=C2=B7=20?= =?UTF-8?q?=EC=9A=B0=ED=95=98=EB=8B=A8=20=ED=91=9C(=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B1=B0=EB=A6=AC=20=C2=B7=20=EB=86=92=EC=9D=B4/?= =?UTF-8?q?=ED=8F=AD=20=C2=B7=20=EB=A9=B4=EC=A0=81=20=C2=B7=20=EA=B3=84)?= =?UTF-8?q?=20=C2=B7=20=EB=8F=84=EA=B0=81=20=C2=B7=20=EC=B6=95=EC=B2=99=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=C2=B7=20=EA=B8=B8=EC=9D=B4=20=EB=AF=B8?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관보호공(배관 유입 · 유출 + 추가 흙막이) · BOX · 세월교 흙막이 · 흙막이(숨김 벽 종류 포함) · 옹벽(wall_form) · 콘크리트포장 · 쇄석포설 - 흙막이 길이 · 빈 높이 = B08 구조물집계표 규칙 재사용 · 추가 단 = 별도 박스 · 넘치면 _2 장 - 시험 test_241_development.py(그림 예 No.22+00~No.26+05 = 85 m × 3.5 = 297.5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- ...B07_DesignDetail_Engine_Cad_Development.py | 328 +++++++++++++ ...esignDetail_Engine_Cad_Development_Draw.py | 430 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_Schema.py | 2 + resources/tester/test_241_development.py | 248 ++++++++++ 4 files changed, 1008 insertions(+) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py create mode 100644 resources/tester/test_241_development.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py new file mode 100644 index 000000000..7d5b481cc --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py @@ -0,0 +1,328 @@ +"""B07 전개도 자료 · 묶음 — 종단 길이를 가진 구조물을 (종류 · 형태)마다 한 장으로(요구 241-2 · 3). + +서버 단독(CLAUDE.md 5장 ③) · 저장 안 함 · 값을 짓지 않고 정본을 모으기만: + 관보호공 배관 유입 · 유출(`{쪽}_type == 기슭막이`) `{쪽}_revet_*` + + 추가 흙막이 `soil_add_{n}_*` + BOX · 세월교 유입 · 유출 구조 == 흙막이(B08 `_guard`) + 추가 흙막이 + 흙막이 · 옹벽 C군 벽(표 형상 wall) start_m ~ end_m · 높이 height_m · 추가 단 = 별도 박스 + 옹벽 형태 = `wall_form`(`form` 은 형식) · 숨김 벽 종류 = 흙막이 묶음 + 포장 · 쇄석 G군 `pavement_specs_from` 구간 · 폭 B = `width_m`(빈칸 = 횡단 노폭 평균) +흙막이 길이 · 빈 높이 = B08 구조물집계표와 같은 규칙(`_revet_length` · 관경 최소 높이 · 박스 높이) · +시점 = 기준 − 전 · 종점 = 기준 + 후(둘 다 비면 길이 반씩) · 거리 = 종점 − 시점 · 면적 = 거리 × H(B). +추가 흙막이 단 수 = 지형이 세운 유출 다단까지(`container_rows` · 설계가 있을 때만). +그림 = `B07_DesignDetail_Engine_Cad_Development_Draw.py`. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from B06_Section.B06_Section_Engine_Culvert import pipe_wall_height_m, pipe_wall_min_height_m +from B06_Section.B06_Section_Engine_Structures_Wall import tier_extras +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval +from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem +from B08_Quantity.B08_Quantity_Router_Summary import ( + FORMS, + GUARD_SOIL, + SOIL, + _guard, + _num, + _revet_length, +) +from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file +from common_util.common_util_structure_catalog import base_type, catalog_row +from common_util.common_util_structure_pavement import pavement_specs_from +from common_util.common_util_summary_item import GROUP_JOIN +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows, soil_tiers_from_designs + +DEVELOPMENT_ID = re.compile(r"^development_([a-z]+\d?)(?:_(\d+))?$") +#: 묶음 차례 · 이름 — 포장은 형태 없이 한 묶음 +GROUPS = { + "pipe": "관보호공", + "box": "BOX암거 흙막이", + "ford": "세월교 흙막이", + "soil": "흙막이", + "wall": "옹벽", + "concrete": "콘크리트포장", + "gravel": "쇄석포설", +} +PAVEMENTS = {"pavement_concrete": "concrete", "gravel_surfacing": "gravel"} +_BODY_GROUP = {"pipe": "pipe", "box_culvert": "box", "ford_bridge": "ford"} +#: 한 장에 담는 박스 수 — 넘치면 `_2` 장(우하단 표가 작도 영역 안에 들게) +MAX_ROWS = 30 +_EMPTY_FORM = 9 # 형태 빈칸 id 자리 + + +def station_text(chainage_m: float, interval_m: float) -> str: + """전개도 측점 — `No.04+08` · 소수면 `No.22+05.5`(그림 241 표기).""" + safe = interval_m if interval_m > 0 else 20.0 + number = int((chainage_m + 1e-6) // safe) + rest = round(chainage_m - number * safe, 1) + if rest >= safe - 0.05: + number, rest = number + 1, 0.0 + if abs(rest - round(rest)) < 0.05: + return f"No.{number:02d}+{int(round(rest)):02d}" + return f"No.{number:02d}+{rest:04.1f}" + + +def _row(group: str, form: Any, start: float | None, end: float | None, height: Any, key: str): + """박스 한 줄 — 시점 · 종점 중 하나라도 없으면 길이 미입력.""" + if start is not None and end is not None and end < start: + start, end = end, start + return { + "group": group, + "form": str(form or ""), + "start_m": start, + "end_m": end, + "height_m": _num(height), + "key": key, + } + + +def _span(at: float, options: dict[str, Any], head: str, unit: str): + """기준 측점 + 전 · 후(둘 다 비면 길이 반씩) → (시점, 종점) · 길이도 없으면 (None, None).""" + before = _num(options.get(f"{head}before{unit}")) + after = _num(options.get(f"{head}after{unit}")) + if before is None and after is None: + length = _revet_length(options, head, unit) + if not length: + return None, None + before = after = length / 2.0 + return at - (before or 0.0), at + (after or 0.0) + + +def _empty_heights(type_id: str, item: dict[str, Any]) -> dict[str, float | None]: + """빈 높이 — 관 = 관경 최소(유입 + 0.7) · 박스 = 박스 높이 · 세월교 = 미입력(B08 같은 규칙).""" + options = item["options"] + if type_id == "pipe": + diameter = _num(options.get("pipe_diameter_mm")) + if not diameter: + return {"inlet": None, "outlet": None, "soil_add": None} + meters = diameter / 1000.0 + wall = pipe_wall_min_height_m(meters) + return {"inlet": pipe_wall_min_height_m(meters, "inlet"), "outlet": wall, "soil_add": wall} + height = _num(options.get("body_height_m")) if type_id == "box_culvert" else None + return {"inlet": height, "outlet": height, "soil_add": height} + + +def _body_rows(item: dict[str, Any], raw: dict[str, Any]) -> list[dict[str, Any]]: + """배관 · 박스 · 세월교 한 곳 — 유입 · 유출 흙막이 + 추가 흙막이 n.""" + type_id = item["type_id"] + group = _BODY_GROUP[type_id] + at = float(item["chainage_m"]) + empty = _empty_heights(type_id, item) + diameter = _num(item["options"].get("pipe_diameter_mm")) + if type_id == "pipe": + sides = [side for side in ("inlet", "outlet") if raw.get(f"{side}_type") == SOIL] + else: + sides = [ + s for s in ("inlet", "outlet") if _guard(type_id, item["options"], s) == GUARD_SOIL + ] + rows = [] + for side in sides: + height = _num(raw.get(f"{side}_revet_height_m")) + if type_id == "pipe" and diameter: + height = pipe_wall_height_m(height, diameter / 1000.0) + start, end = _span(at, raw, f"{side}_revet_", "_m") + form = raw.get(f"{side}_revet_form") + key = f"{item['복사본']}:{side}" + rows.append(_row(group, form, start, end, empty[side] if height is None else height, key)) + for extra in container_rows(item, {}): + link = extra["복사본"].partition(GROUP_JOIN)[2] + if not link.startswith("gsoiladd"): + continue + head = f"soil_add_{link[len('gsoiladd') :]}_" + height = _num(raw.get(f"{head}height")) + start, end = _span(at, raw, head, "") + height = empty["soil_add"] if height is None else height + rows.append(_row(group, raw.get(f"{head}form"), start, end, height, extra["복사본"])) + return rows + + +def _wall_rows(item: dict[str, Any]) -> list[dict[str, Any]]: + """C군 벽 — 본 단 + 추가 단(별도 박스 · 같은 구간).""" + type_id = item["type_id"] + options = item["options"] + wall = base_type(type_id) == "retaining_wall" + group = "wall" if wall else "soil" + form = ( + options.get("wall_form") + if wall + else options.get("form") + or ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form") + ) + start, end = _num(item.get("start_m")), _num(item.get("end_m")) + key = item["복사본"] + rows = [_row(group, form, start, end, options.get("height_m"), key)] + for n, extra in enumerate(tier_extras(type_id, options), start=1): + tier_form = extra["form"] or form + rows.append(_row(group, tier_form, start, end, extra["height_m"], f"{key}:tier{n}")) + return rows + + +def _road_width(designs: dict[float, Any], start: float, end: float) -> float | None: + """구간 안 측점 횡단 노폭(`carriageway_width_m`) 평균 — 없으면 None.""" + widths = [ + width + for chainage, design in (designs or {}).items() + if start - 0.02 <= float(chainage) <= end + 0.02 + and isinstance(design, dict) + and (width := _num(design.get("carriageway_width_m"))) + ] + return round(sum(widths) / len(widths), 2) if widths else None + + +def _pavement_rows(item: dict[str, Any], designs: dict[float, Any]) -> list[dict[str, Any]]: + """콘크리트 포장 · 쇄석 포설 — 구간 × 폭 B.""" + specs = pavement_specs_from([item], {item["type_id"]}) + group = PAVEMENTS[base_type(item["type_id"])] + if not specs: + return [_row(group, "", None, None, None, item["복사본"])] + start, end = specs[0]["start_m"], specs[0]["end_m"] + width = _num(item["options"].get("width_m")) or _road_width(designs, start, end) + if end <= start: + start = end = None + return [_row(group, "", start, end, width, item["복사본"])] + + +def development_rows( + project_root: str | Path, designs: dict[float, Any] | None = None +) -> list[dict[str, Any]]: + """설계 정본 → 전개도 박스 줄 전부(묶음 · 형태 · 시점 · 종점 · 높이(폭)). + + `designs` = `{측점: 횡단 설계}` — 추가 흙막이 단 수 · 포장 빈 폭에 씀(없으면 저장된 것 그대로). + """ + rows_in = [{"chainage_m": k, "design": v} for k, v in (designs or {}).items()] + items = fill.collect_items( + project_root, soil_tiers=soil_tiers_from_designs(rows_in) if designs else None + ) + raw = { + (point.facility or "pipe", f"pipe@{round(float(point.chainage_m), 3)}"): point.options + for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))) + } + rows: list[dict[str, Any]] = [] + for item in items: + owner, _, link = item["복사본"].partition(GROUP_JOIN) + if link: + continue # 그룹 · 컨테이너 줄은 본체 줄에서 다시 셈(단 수 거름) + type_id = item["type_id"] + if type_id in _BODY_GROUP: + rows += _body_rows(item, dict(raw.get((type_id, owner)) or {})) + elif (catalog_row(type_id) or {}).get("shape") == "wall": + rows += _wall_rows(item) + elif base_type(type_id) in PAVEMENTS: + rows += _pavement_rows(item, designs or {}) + return rows + + +def _form_index(form: str) -> int: + return FORMS.index(form) if form in FORMS else _EMPTY_FORM + + +def development_sheets(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """(묶음 · 형태)마다 한 장 · 넘치면 `_2` — `{id, kind_id, title, group, rows, missing}`.""" + found: dict[tuple[int, int, str], list[dict[str, Any]]] = {} + order = list(GROUPS) + for row in rows: + paved = row["group"] in ("concrete", "gravel") + form = "" if paved else row["form"] + key = (order.index(row["group"]), _form_index(form) if not paved else 0, form) + found.setdefault(key, []).append(row) + sheets = [] + for (group_at, form_at, form), members in sorted(found.items()): + group = order[group_at] + paved = group in ("concrete", "gravel") + kind_id = f"development_{group}" if paved else f"development_{group}{form_at}" + title = ( + f"{GROUPS[group]} 전개도" + if paved + else f"{GROUPS[group]} {form or '형태 미입력'} 전개도" + ) + drawn = sorted( + (r for r in members if r["start_m"] is not None), key=lambda r: (r["start_m"], r["key"]) + ) + missing = len(members) - len(drawn) + chunks = [drawn[i : i + MAX_ROWS] for i in range(0, len(drawn), MAX_ROWS)] or [[]] + for index, chunk in enumerate(chunks, start=1): + sheets.append( + { + "id": kind_id if index == 1 else f"{kind_id}_{index}", + "kind_id": kind_id, + "title": title, + "group": group, + "form": form, + "rows": chunk, + "missing": missing if index == 1 else 0, + } + ) + return sheets + + +def table_rows(rows: list[dict[str, Any]], interval_m: float) -> list[dict[str, Any]]: + """우하단 표 줄 — 측점 범위 · 거리 · 높이(폭) · 면적(높이 빈칸이면 빈칸).""" + out = [] + for row in rows: + distance = round(row["end_m"] - row["start_m"], 3) + height = row["height_m"] + out.append( + { + "station": f"{station_text(row['start_m'], interval_m)}~" + f"{station_text(row['end_m'], interval_m)}", + "distance": distance, + "height": height, + "area": None if height is None else round(distance * height, 3), + } + ) + return out + + +def _read_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def development_list( + project_root: Path, manifest_drawings: dict[str, Any] +) -> list[DesignDrawingItem]: + """좌측 「전개도」 카드 단추 — 장마다 한 줄(plan_kind = 묶음 · 형태 · 하위 묶음).""" + return [ + DesignDrawingItem( + id=sheet["id"], + kind="development", + label=sheet["title"], + confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), + plan_kind=sheet["kind_id"], + plan_kind_name=sheet["title"], + ) + for sheet in development_sheets(development_rows(project_root)) + ] + + +def read_development( + project_root: Path, longitudinal_path: Path, drawing_id: str, designs: Any +) -> tuple[str, str, dict[str, Any], bool, None]: + """전개도 한 장 — 목록 뒤 설계로 단 수가 줄어 장이 사라졌으면 빈 장 + 안내.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import ( + build_development_drawing, + ) + + designs = designs if isinstance(designs, dict) else {} + sheets = development_sheets(development_rows(project_root, designs)) + sheet = next((s for s in sheets if s["id"] == drawing_id), None) + if sheet is None: + match = DEVELOPMENT_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 도면 ID입니다.") + base = next((s for s in sheets if s["kind_id"] == f"development_{match.group(1)}"), None) + sheet = {**(base or {"title": "전개도", "group": "soil", "form": ""}), "rows": []} + sheet["missing"] = 0 + longitudinal = _read_json(longitudinal_path) + interval = infer_station_interval(longitudinal.get("stations") or []) or 20.0 + drawing = build_development_drawing(drawing_id, sheet, interval) + return "development", sheet["title"], drawing, False, None diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py new file mode 100644 index 000000000..4b0984400 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py @@ -0,0 +1,430 @@ +"""B07 전개도 그림 — 한 장 = 박스 줄바꿈 배치 · 형태 무늬 · 양 끝 측점 · 우하단 표 · 도각. + +그림 241(콘크리트포장 · 전석메쌓기 전개도) 꼴: + 박스 = 거리(종점 − 시점) × H(포장 B) · 좌우 끝 = 시점 · 종점 측점(붉은 지시선 + 기울인 글) + · 끝 옆 「H=2.0」(포장 「B=3.5」) · 박스 아래 「L=6.0m」 + · 우하단 표(측점 · 거리 · 높이/폭 · 면적 · 계) +무늬 = 기본 도형(PolyLine · Circle · Line)으로 직접 — CAD Hatch 는 DXF 내보내면 채움이 사라짐. +흔들림 = 도면 id · 박스 열쇠 씨앗(같은 자료 = 같은 그림 · 엔티티 id 도 uuid5 결정). +좌표 = 종이 mm · 가로 축척 자동(한 장에 드는 가장 큰 그림) · 세로는 가장 높은 박스가 12~40 mm. +""" + +from __future__ import annotations + +import math +import random +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + TABLE_VALUE_COLOR, + _circle_entity, + _layer, + _line_entity, + _text_entity, + polyline_entity, + table_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import station_text, table_rows +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + drawing_area, + frame_entities, + scale_fields, +) + +BOX_LAYER_ID = "b07-dev-box" +FILL_LAYER_ID = "b07-dev-fill" +LABEL_LAYER_ID = "b07-dev-label" +TABLE_LAYER_ID = "b07-dev-table" +BOX_COLOR = "#f5f7fa" +FILL_COLOR = "#aab4c0" +LEADER_COLOR = "#ff4d4d" +TABLE_COLOR = "#e03131" +#: 가로 축척 후보(분모) — 작은 것부터 대어 보고 처음 드는 것 +SCALES = (50, 100, 200, 300, 500, 600, 1000, 1200, 2000, 2500, 3000, 5000) +_MARGIN = 10.0 +_TITLE_H = 26.0 # 제목 자리 +_LEAD = 18.0 # 박스 위 지시선 · 측점 글 자리 +_BELOW = 9.0 # 박스 아래 L= 자리 +_GAP = 16.0 # 박스 사이(오른 끝 기울인 글 자리) +_MIN_H, _MAX_H = 12.0, 40.0 # 가장 높은 박스 세로 mm +_EMPTY_H = 0.6 # 높이 빈칸 박스 = 가장 높은 박스의 몫 +_FONT, _TITLE_FONT = 2.2, 7.0 +_ROW_H = 6.0 +_COLUMNS = (44.0, 18.0, 18.0, 22.0) +_SLANT = (0.5, math.sqrt(3) / 2) # 60° + + +def _fmt(value: float | None) -> str: + return "" if value is None else f"{value:,.1f}" + + +# ── 무늬 ──────────────────────────────────────────────── + + +def _stone(rng: random.Random, cx: float, cy: float, r: float) -> list[tuple[float, float]]: + """흔든 7각 돌 하나(닫힌 점열).""" + turn = rng.uniform(0, math.tau) + points = [ + ( + cx + r * rng.uniform(0.7, 1.0) * math.cos(turn + i * math.tau / 7), + cy + r * rng.uniform(0.7, 1.0) * math.sin(turn + i * math.tau / 7), + ) + for i in range(7) + ] + return [*points, points[0]] + + +def _stones(seed, rng, box, rows, cap=500): + """박스 안 엇갈린 격자에 흔든 돌 — 높이에 `rows` 줄(크기 = 높이 몫) · 박스를 안 넘음.""" + x0, y0, x1, y1 = box + r = min((y1 - y0) / (rows * 2.3), (y1 - y0) / 2 - 0.3, (x1 - x0) / 2 - 0.3) + if r < 0.3: + return [] + step = 2.3 * r + out = [] + y, row = y0 + 0.3 + r, 0 + while y <= y1 - 0.3 - r + 1e-9 and len(out) < cap: + x = x0 + 0.3 + r + (step / 2 if row % 2 else 0.0) + while x <= x1 - 0.3 - r + 1e-9 and len(out) < cap: + jx = min(rng.uniform(-0.15, 0.15) * step, x1 - 0.3 - r - x, x - x0 - 0.3 - r) + line = polyline_entity( + seed, _stone(rng, x + jx, y, r), FILL_LAYER_ID, FILL_COLOR, f":s{len(out)}" + ) + if line: + out.append(line) + x += step + y += step * 0.87 + row += 1 + return out + + +def _dots(seed, rng, box, per_mm2, radius, cap): + x0, y0, x1, y1 = box + count = min(cap, int((x1 - x0) * (y1 - y0) * per_mm2)) + return [ + _circle_entity( + f"{seed}:d{i}", + (rng.uniform(x0 + radius, x1 - radius), rng.uniform(y0 + radius, y1 - radius)), + radius, + FILL_LAYER_ID, + FILL_COLOR, + ) + for i in range(count) + ] + + +def _grid(seed, box, step): + x0, y0, x1, y1 = box + out = [] + x = x0 + step + while x < x1 - 0.1: + out.append( + _line_entity(f"{seed}:gx{len(out)}", (x, y0), (x, y1), FILL_LAYER_ID, FILL_COLOR) + ) + x += step + y = y0 + step + while y < y1 - 0.1: + out.append( + _line_entity(f"{seed}:gy{len(out)}", (x0, y), (x1, y), FILL_LAYER_ID, FILL_COLOR) + ) + y += step + return out + + +def _logs(seed, box): + """통나무 · 목재틀 = 가운데 원 한 줄.""" + x0, y0, x1, y1 = box + r = min((y1 - y0) / 2 - 0.3, 4.0) + if r < 0.3: + return [] + count = max(int((x1 - x0 - 0.4) // (2 * r + 0.4)), 0) + cy = (y0 + y1) / 2 + return [ + _circle_entity( + f"{seed}:o{i}", (x0 + 0.4 + r + i * (2 * r + 0.4), cy), r, FILL_LAYER_ID, FILL_COLOR + ) + for i in range(count) + ] + + +def _stakes(seed, box, step=2.5): + """바자 = 세운 말뚝 줄.""" + x0, y0, x1, y1 = box + count = max(int((x1 - x0) // step), 0) + return [ + _line_entity( + f"{seed}:k{i}", + (x0 + (i + 0.5) * step, y0), + (x0 + (i + 0.5) * step, y1), + FILL_LAYER_ID, + FILL_COLOR, + ) + for i in range(count) + ] + + +def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, float, float]): + """형태 → 무늬(찰 작은 돌 · 메 큰 돌 · 콘크리트 · 쇄석 점 · 돌망태 격자+돌 · 통나무 원).""" + rng = random.Random(seed) + if group == "gravel": + return _dots(seed, rng, box, 0.12, 0.35, 900) + if group == "concrete" or form == "콘크리트": + return _dots(seed, rng, box, 0.2, 0.15, 1200) + if form == "돌쌓기(찰)": + return _stones(seed, rng, box, 5) + if form == "돌쌓기(메)": + return _stones(seed, rng, box, 2.5) + if form == "돌망태": + cells = max(round((box[3] - box[1]) / 12.0), 1) # 망태 한 단 ≈ 12 mm + return _grid(seed, box, (box[3] - box[1]) / cells) + _stones(seed, rng, box, 3 * cells) + if form == "통나무·목재틀": + return _logs(seed, box) + if form == "바자": + return _stakes(seed, box) + return [] + + +# ── 배치 ──────────────────────────────────────────────── + + +def _vertical(rows: list[dict[str, Any]], mm_h: float) -> float: + """세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게.""" + tallest = max((r["height_m"] for r in rows if r["height_m"]), default=None) + if not tallest: + return _MIN_H + return min(max(mm_h, _MIN_H / tallest), _MAX_H / tallest) + + +def layout(rows, area, table_box, mm_h, strict=True): + """박스 줄바꿈 배치 → [(row, x0, y0, x1, y1)] · 영역을 넘치면 None(strict).""" + ax0, ay0, ax1, ay1 = area + v = _vertical(rows, mm_h) + tallest = max((r["height_m"] or 0.0 for r in rows), default=0.0) + empty_h = (tallest * v if tallest else _MIN_H) * _EMPTY_H + left, top = ax0 + _MARGIN, ay1 - _TITLE_H + placed, line, x, line_h = [], [], left, 0.0 + + def right_limit(height: float) -> float: + bottom = top - _LEAD - height - _BELOW + return table_box[0] - _GAP if bottom < table_box[3] + 4.0 else ax1 - _MARGIN + + for row in rows: + w = max((row["end_m"] - row["start_m"]) * mm_h, 2.0) + h = row["height_m"] * v if row["height_m"] else empty_h + if line and x + w > right_limit(max(line_h, h)): + top -= _LEAD + line_h + _BELOW + placed += line + line, x, line_h = [], left, 0.0 + if strict and ( + x + w > right_limit(max(line_h, h)) or top - _LEAD - h - _BELOW < ay0 + _MARGIN + ): + return None + line.append((row, x, top - _LEAD - h, x + w, top - _LEAD)) + x += w + _GAP + line_h = max(line_h, h) + return placed + line + + +def _choose(rows, area, table_box): + """처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고).""" + for denominator in SCALES: + placed = layout(rows, area, table_box, 1000.0 / denominator) + if placed is not None: + return denominator, placed + denominator = SCALES[-1] + return denominator, layout(rows, area, table_box, 1000.0 / denominator, strict=False) + + +# ── 글 · 표 ───────────────────────────────────────────── + + +def _box_entities(drawing_id, index, row, box, label, station): + x0, y0, x1, y1 = box + seed = f"{drawing_id}:b{index}" + out = [ + polyline_entity( + drawing_id, + [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], + BOX_LAYER_ID, + BOX_COLOR, + f":b{index}", + ) + ] + out += fill_entities(f"{seed}:{row['key']}", row["form"], row["group"], (x0, y0, x1, y1)) + height = "미입력" if row["height_m"] is None else f"{row['height_m']:.1f}" + for end, x, chainage in (("s", x0, row["start_m"]), ("e", x1, row["end_m"])): + out.append( + _line_entity( + f"{seed}:{end}:lead", (x, y1), (x, y1 + _LEAD - 2.0), LABEL_LAYER_ID, LEADER_COLOR + ) + ) + out.append( + _text_entity( + f"{seed}:{end}:no", + station(chainage), + x + 0.8, + y1 + 1.2, + LABEL_LAYER_ID, + _FONT, + TABLE_LABEL_COLOR, + "left", + _SLANT, + ) + ) + out.append( + _text_entity( + f"{seed}:{end}:h", + f"{label}={height}", + x + 3.6, + y1 + 1.2, + LABEL_LAYER_ID, + _FONT, + TABLE_VALUE_COLOR, + "left", + _SLANT, + ) + ) + length = row["end_m"] - row["start_m"] + out.append( + _text_entity( + f"{seed}:len", + f"L={length:.1f}m", + (x0 + x1) / 2, + y0 - 5.0, + LABEL_LAYER_ID, + _FONT, + TABLE_LABEL_COLOR, + ) + ) + return [entity for entity in out if entity] + + +def _table(drawing_id, lines, label, origin): + """측점(NO) · 거리(M) · 높이/폭(M) · 면적(㎡) · 계.""" + + def cell(text, align="center", color=TABLE_LABEL_COLOR): + return {"text": text, "align": align, "color": color} + + head = "폭" if label == "B" else "높이" + cells = [ + [cell("측 점"), cell("거 리"), cell(head), cell("면 적")], + [cell("(NO)"), cell("(M)"), cell("(M)"), cell("(㎡)")], + ] + for item in lines: + cells.append( + [ + cell(item["station"]), + cell(_fmt(item["distance"]), "right", TABLE_VALUE_COLOR), + cell(_fmt(item["height"]), "right", TABLE_VALUE_COLOR), + cell(_fmt(item["area"]), "right", TABLE_VALUE_COLOR), + ] + ) + areas = [item["area"] for item in lines if item["area"] is not None] + cells.append( + [ + cell("계"), + cell(_fmt(sum(item["distance"] for item in lines)), "right", TABLE_VALUE_COLOR), + cell(""), + cell(_fmt(sum(areas)) if areas else "", "right", TABLE_VALUE_COLOR), + ] + ) + return table_entity( + f"{drawing_id}:table", + origin, + list(_COLUMNS), + [_ROW_H] * len(cells), + cells, + TABLE_LAYER_ID, + TABLE_COLOR, + _FONT, + TABLE_LABEL_COLOR, + ) + + +def build_development_drawing( + drawing_id: str, sheet: dict[str, Any], interval_m: float +) -> dict[str, Any]: + """전개도 한 장 → {format, entities, layers} — 작도 영역에 그대로 놓고 도각을 두름.""" + area = drawing_area() + ax0, ay0, ax1, ay1 = area + rows = sheet["rows"] + lines = table_rows(rows, interval_m) + label = "B" if sheet["group"] in ("concrete", "gravel") else "H" + table_w, table_h = sum(_COLUMNS), _ROW_H * (len(lines) + 3) + table_box = (ax1 - _MARGIN - table_w, ay0 + _MARGIN, ax1 - _MARGIN, ay0 + _MARGIN + table_h) + denominator, placed = _choose(rows, area, table_box) + + def station(chainage: float) -> str: + return station_text(chainage, interval_m) + + entities: list[dict[str, Any]] = [ + _text_entity( + f"{drawing_id}:title", + sheet["title"], + (ax0 + ax1) / 2, + ay1 - 14.0, + LABEL_LAYER_ID, + _TITLE_FONT, + TABLE_LABEL_COLOR, + ), + _text_entity( + f"{drawing_id}:scale", + f"S=1:{denominator:,}", + ax1 - _MARGIN, + ay1 - 14.0, + LABEL_LAYER_ID, + _FONT, + TABLE_LABEL_COLOR, + "right", + ), + ] + for index, (row, *box) in enumerate(placed or []): + entities += _box_entities(drawing_id, index, row, tuple(box), label, station) + entities.append(_table(drawing_id, lines, label, (table_box[0], table_box[3]))) + if sheet.get("missing"): + entities.append( + _text_entity( + f"{drawing_id}:missing", + f"길이 미입력 {sheet['missing']}곳 — 그리지 않음", + table_box[2], + table_box[3] + 4.0, + LABEL_LAYER_ID, + _FONT, + TABLE_VALUE_COLOR, + "right", + ) + ) + if not rows and not sheet.get("missing"): + entities.append( + _text_entity( + f"{drawing_id}:empty", + "그릴 구조물이 없습니다 — 목록을 새로 고침", + (ax0 + ax1) / 2, + (ay0 + ay1) / 2, + LABEL_LAYER_ID, + _FONT * 2, + TABLE_VALUE_COLOR, + ) + ) + title = sheet["title"] + entities += frame_entities( + drawing_id, + area, + fit=False, + bottom_left=True, + fields={"도면명": title, **scale_fields(("", denominator))}, + ) + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(BOX_LAYER_ID, "전개도 박스"), + _layer(FILL_LAYER_ID, "재질 무늬"), + _layer(LABEL_LAYER_ID, "측점 · 치수"), + _layer(TABLE_LAYER_ID, "전개도 표"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 265cf5f6d..e127f66c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -21,6 +21,7 @@ class DesignDrawingItem(BaseModel): "plan_lidar", "cross_standard", # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌. + "development", # 전개도(요구 241 · 377) "blank", ] label: str @@ -62,6 +63,7 @@ class DesignDrawingResponse(BaseModel): "plan_lidar", "cross_standard", # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌. + "development", # 전개도(요구 241 · 377) "blank", ] label: str diff --git a/resources/tester/test_241_development.py b/resources/tester/test_241_development.py new file mode 100644 index 000000000..5c145cf9e --- /dev/null +++ b/resources/tester/test_241_development.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +"""B07 전개도(요구 241-2 · 3 · PLAN 377-1) — 측점 표기 · 묶음 · 거리 · 면적 · 계 · 그림 · DXF. + +견본(그림 241 콘크리트포장): No.22+00~No.26+05 = 85 m × B 3.5 = 297.5 ㎡ (측점 간격 20 m). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path +from B07_DesignDetail import B07_DesignDetail_Engine_Cad_Development as dev +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import ( + FILL_LAYER_ID, + build_development_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf + +POINTS = [ + { # 관 Φ800 — 유입 찰 H2.0(전 3 · 후 3) · 유출 메 높이 빈칸(관경 최소 1.3) · 추가 흙막이 1 · 2 + "chainage_m": 88.0, + "options": { + "pipe_diameter_mm": 800, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_height_m": 2.0, + "inlet_revet_before_m": 3, + "inlet_revet_after_m": 3, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_length_m": 8, # 전 · 후 빈칸 → 반씩 + "soil_add_1_form": "돌쌓기(메)", + "soil_add_1_height": 1.5, + "soil_add_1_before": 2, + "soil_add_1_after": 4.5, + "soil_add_2_form": "돌쌓기(메)", + "soil_add_2_height": 1.5, + }, + }, + { # 박스 — 유출 흙막이(높이 빈칸 = 박스 높이 2.5) · 유입 날개벽 + "chainage_m": 300.0, + "facility": "box_culvert", + "options": { + "body_height_m": 2.5, + "wing_in": "날개벽", + "wing_out": "흙막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_before_m": 3, + "outlet_revet_after_m": 3, + }, + }, +] +STRUCTURES = [ + { # 콘크리트 포장 No.22+00 ~ No.26+05 · 폭 3.5 + "type_id": "pavement_concrete", + "placement": "interval", + "start_m": 440.0, + "end_m": 525.0, + "options": {"width_m": 3.5}, + }, + { # 폭 빈칸 = 횡단 노폭 구간 평균 + "type_id": "pavement_concrete", + "placement": "interval", + "start_m": 720.0, + "end_m": 760.0, + "options": {}, + }, + { # 흙막이 메 H2.0 + 추가 단 하나(찰 H1.0 = 별도 박스 · 찰 장) + "type_id": "soil_guard", + "placement": "interval", + "start_m": 80.0, + "end_m": 88.0, + "options": { + "form": "돌쌓기(메)", + "height_m": 2.0, + "tier_1_form": "돌쌓기(찰)", + "tier_1_height": 1.0, + }, + }, + { # 숨김 벽 종류(돌쌓기(메)) = 흙막이 묶음 · 높이 빈칸 + "type_id": "masonry_dry", + "placement": "interval", + "start_m": 100.0, + "end_m": 110.0, + "options": {"height_m": None}, + }, + { # 옹벽 — 형태 = wall_form(form 은 형식) + "type_id": "retaining_wall", + "placement": "interval", + "start_m": 200.0, + "end_m": 210.0, + "options": {"form": "중력식", "wall_form": "콘크리트", "height_m": 3.0}, + }, +] +DESIGNS = { + 88.0: {"extra_walls": [{"side": "outlet"}]}, # 유출 다단 1 → 추가 흙막이 2 는 안 섬 + 720.0: {"carriageway_width_m": 4.0}, + 740.0: {"carriageway_width_m": 5.0}, + 760.0: {"carriageway_width_m": 4.5}, +} + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + found = tmp_path / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + (edits / "pipe_points.json").write_text( + json.dumps({"points": [{**p, "source": "user"} for p in POINTS]}, ensure_ascii=False), + encoding="utf-8", + ) + path = Path(structures_file_path(str(found))) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), encoding="utf-8" + ) + return found + + +def _sheet(sheets, sheet_id): + return next(s for s in sheets if s["id"] == sheet_id) + + +def test_측점_표기(): + assert dev.station_text(440.0, 20) == "No.22+00" + assert dev.station_text(525.0, 20) == "No.26+05" + assert dev.station_text(88.0, 20) == "No.04+08" + assert dev.station_text(445.5, 20) == "No.22+05.5" + assert dev.station_text(459.98, 20) == "No.23+00" + + +def test_그림_예_거리_면적_계(): + rows = [ + dev._row("concrete", "", 440.0, 525.0, 3.5, "a"), + dev._row("concrete", "", 720.0, 860.0, 3.5, "b"), + ] + lines = dev.table_rows(rows, 20.0) + assert lines[0] == { + "station": "No.22+00~No.26+05", + "distance": 85.0, + "height": 3.5, + "area": 297.5, + } + assert lines[1]["area"] == 490.0 + drawing = build_development_drawing( + "development_concrete", + { + "title": "콘크리트포장 전개도", + "group": "concrete", + "form": "", + "rows": rows, + "missing": 0, + }, + 20.0, + ) + table = next(e for e in drawing["entities"] if e["type"] == "Table") + cells = table["shapeData"]["cells"] + assert cells[0][2]["text"] == "폭" + assert [c["text"] for c in cells[2]] == ["No.22+00~No.26+05", "85.0", "3.5", "297.5"] + assert [c["text"] for c in cells[-1]] == ["계", "225.0", "", "787.5"] + + +def test_묶음_거리_높이(root): + sheets = dev.development_sheets(dev.development_rows(root, DESIGNS)) + ids = [s["id"] for s in sheets] + # 차례 = 관보호공 → BOX → 흙막이 → 옹벽 → 콘크리트포장 · 형태마다 한 장 + assert ids == [ + "development_pipe0", + "development_pipe1", + "development_box1", + "development_soil0", + "development_soil1", + "development_wall2", + "development_concrete", + ] + pipe = _sheet(sheets, "development_pipe0") + assert pipe["title"] == "관보호공 돌쌓기(찰) 전개도" + assert [(r["start_m"], r["end_m"], r["height_m"]) for r in pipe["rows"]] == [(85.0, 91.0, 2.0)] + meh = _sheet(sheets, "development_pipe1")["rows"] + # 유출 길이 8 반씩(84~92 · 높이 빈칸 = 관경 최소 1.3) · 추가 흙막이 1(86~92.5) · 2 는 다단 밖 + assert [(r["start_m"], r["end_m"], r["height_m"]) for r in meh] == [ + (84.0, 92.0, 1.3), + (86.0, 92.5, 1.5), + ] + box = _sheet(sheets, "development_box1") + assert box["title"] == "BOX암거 흙막이 돌쌓기(메) 전개도" + assert [(r["start_m"], r["end_m"], r["height_m"]) for r in box["rows"]] == [(297.0, 303.0, 2.5)] + # 추가 단 = 별도 박스(자기 형태 장) · 숨김 돌쌓기(메) = 흙막이 묶음 · 높이 빈칸 + assert [(r["height_m"]) for r in _sheet(sheets, "development_soil0")["rows"]] == [1.0] + soil = _sheet(sheets, "development_soil1")["rows"] + assert [(r["start_m"], r["height_m"]) for r in soil] == [(80.0, 2.0), (100.0, None)] + wall = _sheet(sheets, "development_wall2") + assert wall["title"] == "옹벽 콘크리트 전개도" and wall["rows"][0]["height_m"] == 3.0 + pave = _sheet(sheets, "development_concrete") + assert pave["title"] == "콘크리트포장 전개도" + assert [r["height_m"] for r in pave["rows"]] == [3.5, 4.5] # 빈 폭 = (4 + 5 + 4.5) / 3 + + +def test_설계_없으면_단수_안_거름_목록(root): + rows = dev.development_rows(root) + meh = [r for r in rows if r["group"] == "pipe" and r["form"] == "돌쌓기(메)"] + assert len(meh) == 3 # 추가 흙막이 2 도(단 수 모름) · 2 는 길이 빈칸 + sheet = _sheet(dev.development_sheets(rows), "development_pipe1") + assert sheet["missing"] == 1 and len(sheet["rows"]) == 2 + items = dev.development_list(root, {}) + assert {i.kind for i in items} == {"development"} + assert items[0].plan_kind == "development_pipe0" + + +def test_넘치면_2장(): + rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(35)] + sheets = dev.development_sheets(rows) + assert [s["id"] for s in sheets] == ["development_soil3", "development_soil3_2"] + assert [len(s["rows"]) for s in sheets] == [30, 5] + + +def test_높이_빈칸_박스와_미입력_글_무늬_DXF(root): + sheets = dev.development_sheets(dev.development_rows(root, DESIGNS)) + sheet = _sheet(sheets, "development_soil1") + drawing = build_development_drawing(sheet["id"], sheet, 20.0) + texts = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"] + assert "흙막이 돌쌓기(메) 전개도" in texts + assert "No.04+00" in texts and "No.05+10" in texts + assert "H=2.0" in texts and "H=미입력" in texts and "L=8.0m" in texts + fills = [e for e in drawing["entities"] if e["layerId"] == FILL_LAYER_ID] + assert len(fills) > 10 # 돌 무늬 + table = next(e for e in drawing["entities"] if e["type"] == "Table") + assert [c["text"] for c in table["shapeData"]["cells"][-1]] == ["계", "18.0", "", "16.0"] + # 같은 자료 = 같은 그림(씨앗 고정) + assert build_development_drawing(sheet["id"], sheet, 20.0) == drawing + dxf, _skipped = drawing_to_dxf(drawing) + text = dxf.decode("utf-8", "ignore") + assert "b07-dev-fill" in text and "No.04+00" in text and "297.5" not in text + assert text.count("LINE\r\n") > 50 # 돌 무늬 = 선(DXF 에도 남음) + + +def test_목록_전_장_없으면_빈_장(root, tmp_path): + long_path = tmp_path / "long.json" + long_path.write_text(json.dumps({"stations": []}), encoding="utf-8") + kind, label, drawing, confirmed, _ = dev.read_development( + root, long_path, "development_pipe1_3", DESIGNS + ) + assert kind == "development" and label == "관보호공 돌쌓기(메) 전개도" and not confirmed + with pytest.raises(ValueError): + dev.read_development(root, long_path, "development_x-1", DESIGNS) From bb5f07eb0c4caa28294a6b714cc8630c78474bdb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:53:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(377-2=20B07=20=EC=A0=84=EA=B0=9C?= =?UTF-8?q?=EB=8F=84):=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C=EC=A0=84=EA=B0=9C?= =?UTF-8?q?=EB=8F=84=E3=80=8D=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EB=8B=A8=EA=B1=B4=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20kind=20=E2=80=94=20=EB=8A=98=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=A7=88=EB=8B=A4=20=ED=95=98=EC=9C=84=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C(=EC=9D=B4=EB=A6=84=20=EC=A4=84=20+=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=20=EB=8B=A8=EC=B6=94)=20=C2=B7=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EB=A7=A8=20=EB=92=A4=20=C2=B7=20=EC=A0=84=EA=B0=9C=EB=8F=84?= =?UTF-8?q?=20id=20=EB=A9=B4=20=EC=B8=A1=EC=A0=90=EB=B3=84=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=8B=A4=EC=96=B4=20=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Router_Support _drawing_list 맨 뒤 development_list · _read_drawing 분기 · 확정본 kind development - Router 단건 = _designs_by_chainage · Schema · Api_Fetch kind development - UI_Development.ts 새 카드(groupPlanSheets 재사용) · test_311 접힘 대조에 전개도 카드(기본 펼침) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B07_DesignDetail_Api_Fetch.ts | 2 + ...B07_DesignDetail_Engine_Cad_Development.py | 8 +-- B07_DesignDetail/B07_DesignDetail_Router.py | 4 ++ .../B07_DesignDetail_Router_Support.py | 13 +++++ .../B07_DesignDetail_UI_Development.ts | 50 +++++++++++++++++++ .../B07_DesignDetail_UI_Panels.ts | 8 +++ resources/tester/test_241_development.py | 4 +- resources/tester/test_311_panel_folded.py | 2 + 8 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_Development.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 2efd5bcc4..6258b53d6 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -16,6 +16,7 @@ export interface DesignDrawingItem { | "landuse" | "plan_lidar" | "cross_standard" + | "development" | "blank"; label: string; chainage_m: number | null; @@ -112,6 +113,7 @@ export interface DesignDrawingResponse { | "landuse" | "plan_lidar" | "cross_standard" + | "development" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py index 7d5b481cc..e1c4381c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development.py @@ -252,7 +252,7 @@ def development_sheets(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: { "id": kind_id if index == 1 else f"{kind_id}_{index}", "kind_id": kind_id, - "title": title, + "title": title if index == 1 else f"{title} ({index})", "group": group, "form": form, "rows": chunk, @@ -290,15 +290,15 @@ def _read_json(path: Path) -> dict[str, Any]: def development_list( project_root: Path, manifest_drawings: dict[str, Any] ) -> list[DesignDrawingItem]: - """좌측 「전개도」 카드 단추 — 장마다 한 줄(plan_kind = 묶음 · 형태 · 하위 묶음).""" + """좌측 「전개도」 카드 단추 — 장마다 한 줄 · 하위 묶음(plan_kind) = 구조물 종류.""" return [ DesignDrawingItem( id=sheet["id"], kind="development", label=sheet["title"], confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), - plan_kind=sheet["kind_id"], - plan_kind_name=sheet["title"], + plan_kind=f"development_{sheet['group']}", + plan_kind_name=GROUPS[sheet["group"]], ) for sheet in development_sheets(development_rows(project_root)) ] diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 7ffc5ceb4..84436bea2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -27,6 +27,7 @@ from B06_Section.B06_Section_Repository import ( get_project_standard_cross_section, merge_cross_section_design_by_round, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, ) @@ -341,6 +342,9 @@ async def get_design_drawing( if row else None ) + elif DEVELOPMENT_ID.fullmatch(drawing_id): + # 전개도 — 추가 흙막이 단 수 · 포장 빈 폭을 측점별 설계에서 읽음(377) + source_design = await _designs_by_chainage(route_id) elif PLAN_KIND_ID.fullmatch(drawing_id): # 계획평면도 — 정보 조각을 서버가 계산 · 파일로 두고 그 종류가 고른 것만 얹는다. context, reason = await load_drainage_context(project_id) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b6cb11762..397e8f814 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -34,6 +34,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( station_no_label as station_no_label_upper, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import ( + DEVELOPMENT_ID, + development_list, + read_development, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -243,6 +248,8 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra ) ) # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④. + # 전개도(요구 241 · 377) — 맨 뒤 · (구조물 종류 · 형태)마다 장 + drawings += development_list(project_root, manifest_drawings) _store_drawing_numbers(project_root, drawings) return drawings @@ -480,6 +487,8 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" + elif DEVELOPMENT_ID.fullmatch(drawing_id): + kind = "development" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -531,6 +540,10 @@ def _read_drawing( None, ) + if DEVELOPMENT_ID.fullmatch(drawing_id): + # stored_design = 노선 전체 측점별 설계(추가 흙막이 단 수 · 포장 빈 폭) + return read_development(project_root, longitudinal_path, drawing_id, stored_design) + if PLAN_KIND_ID.fullmatch(drawing_id): # stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것. if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Development.ts b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts new file mode 100644 index 000000000..a91ad770d --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts @@ -0,0 +1,50 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Development.ts + * 좌측 「전개도」 카드(요구 241 · PLAN 377) — 늘 카드 · 구조물 종류마다 하위 묶음(이름 줄 + 형태 단추). + * + * 묶음 = `groupPlanSheets`(plan_kind = 구조물 종류 · plan_kind_name = 이름) 재사용 · 모양 = 평면도 묶음 class. + * 단추 글 = 장 이름에서 종류 이름 · 「전개도」를 뺀 형태(전체 이름은 툴팁). + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import type { DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; +import { groupPlanSheets } from "./B07_DesignDetail_UI_PlanKinds"; + +/** 「관보호공 돌쌓기(찰) 전개도 (2)」 → 「돌쌓기(찰) (2)」 · 포장 「콘크리트포장 전개도」 → 「콘크리트포장」. */ +function shortLabel(label: string, kindName: string): string { + const rest = label.replace(" 전개도", ""); + return rest.startsWith(`${kindName} `) ? rest.slice(kindName.length + 1) : rest; +} + +export function buildDevelopmentSection( + items: DesignDrawingItem[], + makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, + collapsed = false, +): HTMLElement { + const section = createPanelCard(`전개도 ${items.length}`, collapsed); + section.classList.add("b07-drawing-group"); + section.dataset.kind = "development"; + if (items.length === 0) { + const empty = document.createElement("span"); + empty.className = "b07-plan-kind__name"; + empty.textContent = "종단 길이를 가진 구조물이 없습니다."; + section.append(empty); + return section; + } + for (const { kindId, name, sheets } of groupPlanSheets(items)) { + const block = document.createElement("div"); + block.className = "b07-plan-kind"; + block.dataset.planKind = kindId; + const title = document.createElement("span"); + title.className = "b07-plan-kind__name"; + title.textContent = name; + block.append(title); + for (const { drawing } of sheets) { + const button = makeButton(drawing, shortLabel(drawing.label, name)); + button.title = drawing.label; + block.append(button); + } + section.append(block); + } + return section; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index a9eb48d9f..4be3b3c98 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -10,6 +10,7 @@ import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible" import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; +import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development"; import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage"; @@ -37,6 +38,8 @@ export const DRAWING_GROUPS: readonly { // 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5). { label: "횡단도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, + // 전개도(요구 241 · 377) — 늘 카드 · 구조물 종류마다 하위 묶음 + { label: "전개도", kind: "development" }, ]; /** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */ @@ -120,6 +123,11 @@ export function buildDrawingSidePanel( ); continue; } + if (group.kind === "development") { + const sheets = drawings.filter((item) => item.kind === "development"); + panel.append(buildDevelopmentSection(sheets, drawingButton, isFolded("development"))); + continue; + } const items = group.kind ? drawings.filter((item) => item.kind === group.kind) : group.idPrefix diff --git a/resources/tester/test_241_development.py b/resources/tester/test_241_development.py index 5c145cf9e..19a568b25 100644 --- a/resources/tester/test_241_development.py +++ b/resources/tester/test_241_development.py @@ -207,7 +207,8 @@ def test_설계_없으면_단수_안_거름_목록(root): assert sheet["missing"] == 1 and len(sheet["rows"]) == 2 items = dev.development_list(root, {}) assert {i.kind for i in items} == {"development"} - assert items[0].plan_kind == "development_pipe0" + assert (items[0].plan_kind, items[0].plan_kind_name) == ("development_pipe", "관보호공") + assert items[0].label == "관보호공 돌쌓기(찰) 전개도" def test_넘치면_2장(): @@ -215,6 +216,7 @@ def test_넘치면_2장(): sheets = dev.development_sheets(rows) assert [s["id"] for s in sheets] == ["development_soil3", "development_soil3_2"] assert [len(s["rows"]) for s in sheets] == [30, 5] + assert sheets[1]["title"] == "흙막이 돌망태 전개도 (2)" def test_높이_빈칸_박스와_미입력_글_무늬_DXF(root): diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 7b4aaf40a..87a079efd 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -64,6 +64,7 @@ def test_B07_첫_그림_접힘(out): "longitudinal": True, "cross": True, "mass_haul": False, + "development": False, # 377 전개도 카드 — 기본 펼침 } @@ -73,6 +74,7 @@ def test_B07_다시_세워도_접힘_이어_받음(out): "longitudinal": True, "cross": False, "mass_haul": True, + "development": False, } From bbfe7ce27ed6d382cfff5a1e01a7627fae08bf1a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 15:20:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(377=20B07=20=EC=A0=84=EA=B0=9C=EB=8F=84?= =?UTF-8?q?):=20=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=A6=9D=20=EC=86=90?= =?UTF-8?q?=EC=A7=88=20=E2=80=94=20=EB=8F=8C=20=EB=AC=B4=EB=8A=AC=EA=B0=80?= =?UTF-8?q?=20=EB=B0=95=EC=8A=A4=20=EC=99=BC=EC=AA=BD=EC=9D=84=20=EB=84=98?= =?UTF-8?q?=EB=8D=98=20=ED=9D=94=EB=93=A4=EB=A6=BC=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=C2=B7=20=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EC=87=84=EC=84=9D=20=EC=A0=90=20=ED=81=AC=EA=B8=B0(=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=B0=EC=9C=A8=EC=97=90=EC=84=9C=20=EC=95=88=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=84)=20=C2=B7=20=ED=91=9C=20=ED=81=AC=EA=B2=8C(?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20241=20=EA=BC=B4)=20=C2=B7=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20=ED=8F=AD=20=EC=86=8C=EC=88=98=20=EB=91=98?= =?UTF-8?q?=EC=A7=B8=20=EC=9E=90=EB=A6=AC=20=ED=91=9C=EA=B8=B0(=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=9D=B4=20=EC=93=B4=20=EA=B0=92=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시험: 무늬는 박스 안(형태 다섯 · 견본 서른) · 확정본 kind development · 3.55 폭 표기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- ...esignDetail_Engine_Cad_Development_Draw.py | 28 +++++++++---- resources/tester/test_241_development.py | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py index 4b0984400..e1000df42 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py @@ -51,9 +51,10 @@ _BELOW = 9.0 # 박스 아래 L= 자리 _GAP = 16.0 # 박스 사이(오른 끝 기울인 글 자리) _MIN_H, _MAX_H = 12.0, 40.0 # 가장 높은 박스 세로 mm _EMPTY_H = 0.6 # 높이 빈칸 박스 = 가장 높은 박스의 몫 -_FONT, _TITLE_FONT = 2.2, 7.0 -_ROW_H = 6.0 -_COLUMNS = (44.0, 18.0, 18.0, 22.0) +_FONT, _TITLE_FONT = 2.5, 7.0 +_TABLE_FONT = 3.2 # 표는 그림 241 처럼 크게(작도 영역 폭의 약 1/4) +_ROW_H = 9.0 +_COLUMNS = (80.0, 32.0, 32.0, 40.0) _SLANT = (0.5, math.sqrt(3) / 2) # 60° @@ -61,6 +62,14 @@ def _fmt(value: float | None) -> str: return "" if value is None else f"{value:,.1f}" +def _size(value: float | None) -> str: + """높이 · 폭 — 소수 둘째 자리까지(3.55 · 3.5 · 2.0) · 면적이 쓴 값 그대로 보이게.""" + if value is None: + return "" + text = f"{value:.2f}".rstrip("0") + return f"{text}0" if text.endswith(".") else text + + # ── 무늬 ──────────────────────────────────────────────── @@ -89,7 +98,8 @@ def _stones(seed, rng, box, rows, cap=500): while y <= y1 - 0.3 - r + 1e-9 and len(out) < cap: x = x0 + 0.3 + r + (step / 2 if row % 2 else 0.0) while x <= x1 - 0.3 - r + 1e-9 and len(out) < cap: - jx = min(rng.uniform(-0.15, 0.15) * step, x1 - 0.3 - r - x, x - x0 - 0.3 - r) + room = (x - x0 - 0.3 - r, x1 - 0.3 - r - x) # 왼 · 오 여유 — 돌이 박스를 안 넘게 + jx = max(-room[0], min(rng.uniform(-0.15, 0.15) * step, room[1])) line = polyline_entity( seed, _stone(rng, x + jx, y, r), FILL_LAYER_ID, FILL_COLOR, f":s{len(out)}" ) @@ -170,9 +180,9 @@ def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, flo """형태 → 무늬(찰 작은 돌 · 메 큰 돌 · 콘크리트 · 쇄석 점 · 돌망태 격자+돌 · 통나무 원).""" rng = random.Random(seed) if group == "gravel": - return _dots(seed, rng, box, 0.12, 0.35, 900) + return _dots(seed, rng, box, 0.025, 0.9, 900) if group == "concrete" or form == "콘크리트": - return _dots(seed, rng, box, 0.2, 0.15, 1200) + return _dots(seed, rng, box, 0.045, 0.6, 1200) if form == "돌쌓기(찰)": return _stones(seed, rng, box, 5) if form == "돌쌓기(메)": @@ -254,7 +264,7 @@ def _box_entities(drawing_id, index, row, box, label, station): ) ] out += fill_entities(f"{seed}:{row['key']}", row["form"], row["group"], (x0, y0, x1, y1)) - height = "미입력" if row["height_m"] is None else f"{row['height_m']:.1f}" + height = "미입력" if row["height_m"] is None else _size(row["height_m"]) for end, x, chainage in (("s", x0, row["start_m"]), ("e", x1, row["end_m"])): out.append( _line_entity( @@ -318,7 +328,7 @@ def _table(drawing_id, lines, label, origin): [ cell(item["station"]), cell(_fmt(item["distance"]), "right", TABLE_VALUE_COLOR), - cell(_fmt(item["height"]), "right", TABLE_VALUE_COLOR), + cell(_size(item["height"]), "right", TABLE_VALUE_COLOR), cell(_fmt(item["area"]), "right", TABLE_VALUE_COLOR), ] ) @@ -339,7 +349,7 @@ def _table(drawing_id, lines, label, origin): cells, TABLE_LAYER_ID, TABLE_COLOR, - _FONT, + _TABLE_FONT, TABLE_LABEL_COLOR, ) diff --git a/resources/tester/test_241_development.py b/resources/tester/test_241_development.py index 19a568b25..61f11ffaa 100644 --- a/resources/tester/test_241_development.py +++ b/resources/tester/test_241_development.py @@ -16,6 +16,7 @@ from B07_DesignDetail import B07_DesignDetail_Engine_Cad_Development as dev from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import ( FILL_LAYER_ID, build_development_drawing, + fill_entities, ) from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf @@ -197,6 +198,13 @@ def test_묶음_거리_높이(root): pave = _sheet(sheets, "development_concrete") assert pave["title"] == "콘크리트포장 전개도" assert [r["height_m"] for r in pave["rows"]] == [3.5, 4.5] # 빈 폭 = (4 + 5 + 4.5) / 3 + # 표 · 끝 글 = 면적이 쓴 폭 그대로(3.55 를 3.5 로 줄여 보이지 않음) + odd = {**pave, "rows": [{**pave["rows"][0], "height_m": 3.55}]} + drawing = build_development_drawing(odd["id"], odd, 20.0) + cells = next(e for e in drawing["entities"] if e["type"] == "Table")["shapeData"]["cells"] + assert [c["text"] for c in cells[2]] == ["No.22+00~No.26+05", "85.0", "3.55", "301.8"] + labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"] + assert "B=3.55" in labels def test_설계_없으면_단수_안_거름_목록(root): @@ -239,6 +247,24 @@ def test_높이_빈칸_박스와_미입력_글_무늬_DXF(root): assert text.count("LINE\r\n") > 50 # 돌 무늬 = 선(DXF 에도 남음) +@pytest.mark.parametrize( + "form", ["돌쌓기(찰)", "돌쌓기(메)", "돌망태", "통나무·목재틀", "콘크리트"] +) +def test_무늬는_박스_안(form): + for seed in range(30): + width, height = 10 + seed * 9.7, 8 + seed % 7 * 5.0 + for entity in fill_entities(f"s{seed}", form, "soil", (0.0, 0.0, width, height)): + for one in entity.get("children") or [entity]: + shape = one["shapeData"] + if "center" in shape: + r, (x, y) = shape["radius"], (shape["center"]["x"], shape["center"]["y"]) + points = [(x - r, y - r), (x + r, y + r)] + else: + points = [(p["x"], p["y"]) for p in (shape["startPoint"], shape["endPoint"])] + for x, y in points: + assert -1e-6 <= x <= width + 1e-6 and -1e-6 <= y <= height + 1e-6, (seed, x, y) + + def test_목록_전_장_없으면_빈_장(root, tmp_path): long_path = tmp_path / "long.json" long_path.write_text(json.dumps({"stations": []}), encoding="utf-8") @@ -248,3 +274,19 @@ def test_목록_전_장_없으면_빈_장(root, tmp_path): assert kind == "development" and label == "관보호공 돌쌓기(메) 전개도" and not confirmed with pytest.raises(ValueError): dev.read_development(root, long_path, "development_x-1", DESIGNS) + + +def test_확정본은_전개도_kind_로_읽음(root, tmp_path): + from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT + from B07_DesignDetail.B07_DesignDetail_Router_Support import _design_root, _read_drawing + + stage = _design_root(root) + (stage / "drawings").mkdir(parents=True) + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []} + (stage / "drawings" / "development_pipe0.json").write_text(json.dumps(saved), "utf-8") + manifest = {"drawings": {"development_pipe0": {"confirmed": True, "label": "관보호공"}}} + (stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8") + kind, label, drawing, confirmed, table = _read_drawing( + root, tmp_path / "long.json", "development_pipe0" + ) + assert (kind, label, confirmed, table) == ("development", "관보호공", True, None)