diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 03fbc6088..92285c869 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -17,6 +17,7 @@ export interface DesignDrawingItem { | "plan_lidar" | "cross_standard" | "structure" + | "development" | "blank"; label: string; chainage_m: number | null; @@ -114,6 +115,7 @@ export interface DesignDrawingResponse { | "plan_lidar" | "cross_standard" | "structure" + | "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 new file mode 100644 index 000000000..e1c4381c9 --- /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 if index == 1 else f"{title} ({index})", + "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=f"development_{sheet['group']}", + plan_kind_name=GROUPS[sheet["group"]], + ) + 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..e1000df42 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py @@ -0,0 +1,440 @@ +"""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.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° + + +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 + + +# ── 무늬 ──────────────────────────────────────────────── + + +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: + 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)}" + ) + 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.025, 0.9, 900) + if group == "concrete" or form == "콘크리트": + return _dots(seed, rng, box, 0.045, 0.6, 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 _size(row["height_m"]) + 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(_size(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, + _TABLE_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_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 26d1cd72a..291e865da 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, ) @@ -344,6 +345,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 31b2b8965..d961ffe21 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, @@ -249,6 +254,8 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra ) # 구조물 도면(376) — 맨 뒤에 덧붙임(앞 도면번호 안 밀림) · 조합마다 한 장 · 더한장은 장마다. drawings.extend(structure_items(project_root, manifest_drawings)) + # 전개도(요구 241 · 377) — 맨 뒤 · (구조물 종류 · 형태)마다 장 + drawings += development_list(project_root, manifest_drawings) _store_drawing_numbers(project_root, drawings) return drawings @@ -488,6 +495,8 @@ def _read_drawing( kind = "plan" elif drawing_id.startswith(STRUCTURE_PREFIX): kind = "structure" + 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) @@ -539,6 +548,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_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 353b2a7af..de5f4376e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -22,6 +22,7 @@ class DesignDrawingItem(BaseModel): "cross_standard", # 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면. "structure", + "development", # 전개도(요구 241 · 377) "blank", ] label: str @@ -64,6 +65,7 @@ class DesignDrawingResponse(BaseModel): "cross_standard", # 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면. "structure", + "development", # 전개도(요구 241 · 377) "blank", ] label: str 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 e45c4d2d0..0144eef44 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"; @@ -39,6 +40,8 @@ export const DRAWING_GROUPS: readonly { { label: "토적도(유토곡선)", kind: "mass_haul" }, // 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합마다 한 장(더한장은 장마다) · 늘 묶음. { label: "구조물 도면", kind: "structure" }, + // 전개도(요구 241 · 377) — 늘 카드 · 구조물 종류마다 하위 묶음 + { label: "전개도", kind: "development" }, ]; /** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */ @@ -122,6 +125,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 new file mode 100644 index 000000000..61f11ffaa --- /dev/null +++ b/resources/tester/test_241_development.py @@ -0,0 +1,292 @@ +# -*- 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, + fill_entities, +) +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 + # 표 · 끝 글 = 면적이 쓴 폭 그대로(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): + 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, items[0].plan_kind_name) == ("development_pipe", "관보호공") + assert items[0].label == "관보호공 돌쌓기(찰) 전개도" + + +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] + assert sheets[1]["title"] == "흙막이 돌망태 전개도 (2)" + + +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 에도 남음) + + +@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") + 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) + + +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) diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 755ed6127..295101229 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -65,6 +65,7 @@ def test_B07_첫_그림_접힘(out): "cross": True, "mass_haul": False, "structure": False, # 376-2 늘 카드 · 기본 펼침 + "development": False, # 377 전개도 카드 — 기본 펼침 } @@ -75,6 +76,7 @@ def test_B07_다시_세워도_접힘_이어_받음(out): "cross": False, "mass_haul": True, "structure": False, + "development": False, }