From a0a737768e1c7142987b37a5f292f4df861417a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 18:47:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=9A=A1=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=201/100=20=EC=B2=99=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=98=EA=B3=A0=20=ED=95=9C=20=EC=9E=A5?= =?UTF-8?q?=EC=97=90=20=EC=97=AC=EB=9F=AC=20=EB=8B=A8=EB=A9=B4=EC=9D=84=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성 척도(횡단 1/100)를 도면 좌표에 넣고, A1 한 장에 들어가는 만큼 단면을 담는다. - 횡단 좌표를 종이 mm로(x10). 수량표도 종이 치수로 재조정(폭 108mm, 행 5mm) - 단면 폭 = 설계선이 원지반에서 갈라졌다 만나는 구간 + 여유 1.5m. 지표선도 그 범위로 자른다. 실측 갈라짐 폭 4.0~15.0m (종전 전폭 40m) - Engine_Cad_Sheet 신설: 칸 크기는 최대 블록 기준, 좌하단부터 아래→위, 그다음 오른쪽 열로 채우고 넘치면 다음 장. 23측점 -> 4장(3열x2행) - 목록·확정을 장 단위로. 확정 시 담긴 측점 전부의 B06 단면적을 재계산하고 수량표는 측점별로 매니페스트에 보관 - 700줄 제한: 수량표를 Engine_Cad_Table로, 라우터 동기 헬퍼를 Router_Support로 분리 Co-Authored-By: Claude Fable 5 --- .../B07_DesignDetail_Engine_Cad.py | 434 +++++------------ .../B07_DesignDetail_Engine_Cad_Sheet.py | 179 +++++++ .../B07_DesignDetail_Engine_Cad_Table.py | 321 +++++++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 449 ++++-------------- .../B07_DesignDetail_Router_Support.py | 440 +++++++++++++++++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 67 +-- 6 files changed, 1155 insertions(+), 735 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 708a31de..ac460ad2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -17,6 +17,8 @@ from typing import Any from uuid import UUID, uuid5 +from config.config_system import DRAWING_SCALE_CROSS + # 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3). GROUND_LAYER_ID = "b08-ground" GROUND_COLOR = "#f5f7fa" @@ -277,8 +279,14 @@ def _cross_line_entities( design_line: list[Any] | None, design: dict[str, Any] | None, dy: float = 0.0, + paper: Any = None, + window: tuple[float, float] | None = None, ) -> list[dict[str, Any]]: - """횡단 설계선을 설계(b08-design)/구조물(b08-structure, 측구 구간)로 분리한다.""" + """횡단 설계선을 설계(b07-design)/구조물(b07-structure, 측구 구간)로 분리한다. + + paper가 오면 (실좌표 m) -> (종이 mm) 변환을 거쳐 좌표를 만들고, window가 오면 + 그 가로 범위로 자른다. dy는 paper 안에서 처리하므로 여기서 빼지 않는다. + """ points: list[tuple[float, float]] = [] for point in design_line if isinstance(design_line, list) else []: if not isinstance(point, dict): @@ -286,14 +294,21 @@ def _cross_line_entities( x = point.get("offset_m") y = point.get("elevation_m") if isinstance(x, (int, float)) and isinstance(y, (int, float)): - points.append((float(x), float(y) - dy)) + points.append((float(x), float(y) if paper else float(y) - dy)) if len(points) < 2: return [] + if window is not None: + points = _clip_polyline(points, window[0], window[1]) + if len(points) < 2: + return [] + + def place(part: list[tuple[float, float]]) -> list[tuple[float, float]]: + return [paper(p) for p in part] if paper else part bounds = _ditch_bounds(design) entities: list[dict[str, Any]] = [] if bounds is None: - design_poly = polyline_entity(drawing_id, points, DESIGN_LAYER_ID, DESIGN_COLOR) + design_poly = polyline_entity(drawing_id, place(points), DESIGN_LAYER_ID, DESIGN_COLOR) return [design_poly] if design_poly else [] lo, hi = bounds @@ -302,259 +317,64 @@ def _cross_line_entities( ditch_part = [p for p in points if lo - tolerance <= p[0] <= hi + tolerance] after = [p for p in points if p[0] >= hi - tolerance] for suffix, part in ((":a", before), (":b", after)): - poly = polyline_entity(drawing_id, part, DESIGN_LAYER_ID, DESIGN_COLOR, suffix) + poly = polyline_entity(drawing_id, place(part), DESIGN_LAYER_ID, DESIGN_COLOR, suffix) if poly: entities.append(poly) - structure = polyline_entity(drawing_id, ditch_part, STRUCTURE_LAYER_ID, STRUCTURE_COLOR) + structure = polyline_entity(drawing_id, place(ditch_part), STRUCTURE_LAYER_ID, STRUCTURE_COLOR) if structure: entities.append(structure) return entities -# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹. -# 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기 -# 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토) -# 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐 -_CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = ( - ("지반고", "ground"), - ("계획고", "planned"), - ("성토고", "fill"), - ("절토고", "cut"), -) -# 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로. -_CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = ( - ("깍기", "토사", "cut_soil"), - (None, "암석", "cut_rock"), - ("측구", "토사", "ditch_soil"), - (None, "암석", "ditch_rock"), - ("쌓기", None, "embankment"), - ("층따기", None, "benching"), -) -_CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = ( - ("면고르기", "성토", "grading_fill"), - (None, "절토", "grading_cut"), - ("지장목제거", "성토", "tree_removal_fill"), - (None, "절토", "tree_removal_cut"), - ("표토제거", "성토", "topsoil_fill"), - (None, "절토", "topsoil_cut"), -) -_CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = ( - ("편책", "fence"), - ("성토파종", "fill_seeding"), - ("절토살포", "cut_spraying"), - ("제근", "grubbing"), - (None, None), - ("노면다짐", "road_compaction"), -) - -QUANTITY_VALUE_KEYS: tuple[str, ...] = ( - "ground", - "planned", - "fill", - "cut", - "cut_soil", - "cut_rock", - "ditch_soil", - "ditch_rock", - "embankment", - "benching", - "grading_fill", - "grading_cut", - "tree_removal_fill", - "tree_removal_cut", - "topsoil_fill", - "topsoil_cut", - "fence", - "fill_seeding", - "cut_spraying", - "grubbing", - "road_compaction", -) - -_CROSS_TABLE_WIDTH = 26.0 -_CROSS_TABLE_ROW_HEIGHT = 1.6 -_CROSS_TABLE_FONT = 0.55 - -# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) -_CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) +# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0). +CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS +# 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m). +CROSS_SECTION_MARGIN_M = 1.5 +# 원지반과 "만난다"고 볼 표고 차이(m) — 이보다 작으면 붙어 있는 것으로 본다. +_TOUCH_TOLERANCE_M = 0.02 -def _cross_column_edges(width: float) -> list[float]: - total = sum(_CROSS_COLUMN_WEIGHTS) - left = -width / 2.0 - edges = [left] - accumulated = 0.0 - for weight in _CROSS_COLUMN_WEIGHTS: - accumulated += weight - edges.append(left + width * accumulated / total) - return edges +def _section_window( + ground: list[tuple[float, float]], + design: list[tuple[float, float]], + margin_m: float = CROSS_SECTION_MARGIN_M, +) -> tuple[float, float]: + """설계선이 원지반에서 갈라졌다가 다시 만나는 가로 범위(+여유)를 구한다. + + B06 설계선은 절·성토 구간 바깥에서 원지반을 그대로 따라가므로, 표고가 + 갈라지는 구간이 곧 "그려야 할 단면"이다 (2026-08-29 사용자 기준). + """ + left = right = None + for x, y in design: + ground_y = _interpolate(ground, x) + if ground_y is None or abs(y - ground_y) <= _TOUCH_TOLERANCE_M: + continue + left = x if left is None else min(left, x) + right = x if right is None else max(right, x) + if left is None or right is None: + xs = [x for x, _y in ground] or [-6.0, 6.0] + left, right = min(xs), max(xs) + return (left - margin_m, right + margin_m) -def _cross_table_entities( - drawing_id: str, - quantity_table: dict[str, float | None], - table_top: float, - title_label: str, -) -> list[dict[str, Any]]: - """횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다.""" - width = _CROSS_TABLE_WIDTH - row_h = _CROSS_TABLE_ROW_HEIGHT - font = _CROSS_TABLE_FONT - left = -width / 2.0 - right = width / 2.0 - edges = _cross_column_edges(width) - entities: list[dict[str, Any]] = [] - - def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]: - value = quantity_table.get(seed_key) - return _text_entity( - f"{drawing_id}:qtable:{seed_key}", - _format(value) if isinstance(value, (int, float)) else "-", - x, - y, - CROSS_TABLE_LAYER_ID, - font, - TABLE_VALUE_COLOR, - ) - - def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]: - return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR) - - def h_line(seed: str, x_from: float, x_to: float, y: float) -> None: - entities.append( - _line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) - ) - - def v_line(seed: str, x: float, y_from: float, y_to: float) -> None: - entities.append( - _line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) - ) - - # ── 행 y 좌표 (제목행, 헤더행, 본문 6행) - y_title_top = table_top - y_header_top = y_title_top - row_h - y_body_top = y_header_top - row_h - body_rows = len(_CROSS_LEFT_ROWS) - y_bottom = y_body_top - row_h * body_rows - - def body_y(row_index: int) -> float: - return y_body_top - row_h * row_index - - # ── 외곽/가로선 - h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top) - h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top) - h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top) - h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom) - v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom) - v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom) - - # 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다. - for boundary in range(1, body_rows): - y = body_y(boundary) - left_merged = _CROSS_LEFT_ROWS[boundary][0] is None - middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None - seed = f"{drawing_id}:qgrid:b{boundary}" - if left_merged: - h_line(f"{seed}:l", edges[1], edges[4], y) - else: - h_line(f"{seed}:l", edges[0], edges[4], y) - if middle_merged: - h_line(f"{seed}:m", edges[5], edges[8], y) - else: - h_line(f"{seed}:m", edges[4], edges[8], y) - h_line(f"{seed}:r", edges[8], edges[11], y) - - # ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할 - header_cell = width / 8.0 - for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS): - x_label = left + header_cell * (pair_index * 2 + 0.5) - x_value = left + header_cell * (pair_index * 2 + 1.5) - y_mid = y_header_top - row_h * 0.5 - entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid)) - entities.append(value_text(key, x_value, y_mid)) - if pair_index > 0: - v_line( - f"{drawing_id}:qgrid:hv{pair_index}", - left + header_cell * pair_index * 2, - y_header_top, - y_body_top, - ) - v_line( - f"{drawing_id}:qgrid:hvl{pair_index}", - left + header_cell * (pair_index * 2 + 1), - y_header_top, - y_body_top, - ) - - # ── 제목행 (No.측점) - entities.append( - _text_entity( - f"{drawing_id}:qtitle", - title_label, - left + width * 0.03, - y_title_top - row_h * 0.5, - CROSS_TABLE_LAYER_ID, - font * 1.15, - TABLE_LABEL_COLOR, - align="left", - ) - ) - - # ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만. - ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None] - if ab_rows: - v_line( - f"{drawing_id}:qgrid:vab", - edges[1], - body_y(min(ab_rows)), - body_y(max(ab_rows) + 1), - ) - for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10): - v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom) - - # ── 본문 셀 (좌/중/우 그룹) - def cell_mid(edge_from: int, edge_to: int) -> float: - return (edges[edge_from] + edges[edge_to]) / 2.0 - - for row_index in range(body_rows): - y_mid = body_y(row_index) - row_h * 0.5 - group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] - if group_l is not None and sub_l is not None: - # 그룹 라벨은 2행 병합 중앙 배치 - y_group = body_y(row_index) - row_h # 병합 2행의 중앙 - entities.append( - label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group) - ) - elif group_l is not None: - entities.append( - label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid) - ) - if sub_l is not None: - entities.append( - label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid) - ) - if key_l is not None: - entities.append(value_text(key_l, cell_mid(2, 3), y_mid)) - - group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] - if group_m is not None: - y_group = body_y(row_index) - row_h - entities.append( - label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group) - ) - entities.append( - label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid) - ) - entities.append(value_text(key_m, cell_mid(6, 7), y_mid)) - - label_r, key_r = _CROSS_RIGHT_ROWS[row_index] - if label_r is not None: - entities.append( - label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid) - ) - if key_r is not None: - entities.append(value_text(key_r, cell_mid(9, 10), y_mid)) - return entities +def _clip_polyline( + points: list[tuple[float, float]], x0: float, x1: float +) -> list[tuple[float, float]]: + """폴리라인을 [x0, x1]로 자른다 — 경계는 선형보간으로 새 점을 만든다.""" + if not points: + return [] + clipped: list[tuple[float, float]] = [] + for index, (x, y) in enumerate(points): + if index > 0: + px, py = points[index - 1] + for edge in (x0, x1): + if (px < edge < x) or (x < edge < px): + span = x - px + ratio = (edge - px) / span if span else 0.0 + clipped.append((edge, py + (y - py) * ratio)) + if x0 <= x <= x1: + clipped.append((x, y)) + return clipped def build_cross_drawing( @@ -566,18 +386,16 @@ def build_cross_drawing( title_label: str, design_elevation_m: float | None = None, frame: dict[str, float] | None = None, + origin: tuple[float, float] = (0.0, 0.0), ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. - 로컬 좌표 정규화: 계획고(design_elevation_m)를 y=0으로 두어 모든 측점 - 도면이 같은 화면 배치를 갖는다. - - 배치 규약: 이 단면의 선(지표+설계+암 경계) 전체 bbox 중심을 y=0에 두어 - 측점마다 콘텐츠가 화면 중앙에 온다(종단 경사 드리프트 제거). 테이블은 - frame(노선 공통 최대 반높이 half_height)이 오면 전 측점 동일 y에 고정해 - Fit-in-all 배율·중심이 측점 간 흔들리지 않게 한다. design_elevation_m는 - 현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. + 좌표는 종이 밀리미터(1/100 — 실거리 1 m = 10 mm)이고, 그리는 가로 범위는 + 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 + bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재 + 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. """ + ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") raw_design: list[tuple[float, float]] = [] for point in design_line if isinstance(design_line, list) else []: @@ -589,49 +407,64 @@ def build_cross_drawing( raw_design.append((float(x), float(y))) rock_offset = design.get("rock_boundary_offset_m") if isinstance(design, dict) else None - # 콘텐츠 bbox: 지표선 + 설계선 + 암 경계선(지표+오프셋) 표고 범위. - all_ys = [y for _x, y in raw_ground] + [y for _x, y in raw_design] + # 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다. + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + _cross_table_entities, + cross_table_height, + cross_table_width, + ) + + # 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유. + x0, x1 = _section_window(raw_ground, raw_design) + ground_clipped = _clip_polyline(raw_ground, x0, x1) + + # 세로 정렬: 이 범위 안의 선(지표+설계+암 경계) bbox 중심을 0으로. + all_ys = [y for _x, y in ground_clipped] + [y for x, y in raw_design if x0 <= x <= x1] if isinstance(rock_offset, (int, float)): - all_ys.extend(y + float(rock_offset) for _x, y in raw_ground) + all_ys.extend(y + float(rock_offset) for _x, y in ground_clipped) dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0 own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0 - ground_points = [(x, y - dy) for x, y in raw_ground] + def paper(point: tuple[float, float]) -> tuple[float, float]: + return (point[0] * CROSS_MM + ox, (point[1] - dy) * CROSS_MM + oy) + entities: list[dict[str, Any]] = [] - ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR) + ground = polyline_entity( + drawing_id, [paper(p) for p in ground_clipped], GROUND_LAYER_ID, GROUND_COLOR + ) if ground: entities.append(ground) - entities.extend(_cross_line_entities(drawing_id, design_line, design, dy)) + entities.extend(_cross_line_entities(drawing_id, design_line, design, dy, paper, (x0, x1))) # 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재. - if isinstance(rock_offset, (int, float)) and ground_points: - rock_points = [(x, y + float(rock_offset)) for x, y in ground_points] + if isinstance(rock_offset, (int, float)) and ground_clipped: + rock_points = [paper((x, y + float(rock_offset))) for x, y in ground_clipped] rock = polyline_entity(drawing_id, rock_points, ROCK_LAYER_ID, ROCK_COLOR, dash=[6, 4]) if rock: entities.append(rock) - # 테이블 상단: 노선 공통 최대 반높이 아래 고정(전 측점 동일). frame 없으면 자체 폴백. - half_height = ( - frame["half_height"] if frame and "half_height" in frame else own_half_height + 1.0 - ) - table_bottom = -half_height + # 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면 + # 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다. + center_x = ox + (x0 + x1) / 2.0 * CROSS_MM + half_height = own_half_height * CROSS_MM + 5.0 + table_bottom = oy - half_height if quantity_table is not None: - table_top = -half_height - 2.0 - entities.extend(_cross_table_entities(drawing_id, quantity_table, table_top, title_label)) - table_bottom = table_top - _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) + table_top = oy - half_height - 8.0 + entities.extend( + _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) + ) + table_bottom = table_top - cross_table_height() - # 외곽 테두리: 전 측점 동일 크기 사각형(노선 공통 half/half_height 기준) — - # Fit-in-all 바운딩박스를 고정해 측점 이동 시 배율·중심이 흔들리지 않는다. - half = frame["half"] if frame else max((abs(x) for x, _y in ground_points), default=12.0) - frame_x = max(half + 2.0, _CROSS_TABLE_WIDTH / 2.0 + 1.0) - frame_top = half_height + 1.0 - frame_bottom = table_bottom - 1.0 + # 외곽 테두리: 단면 범위와 표를 함께 감싼다. + frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) + frame_top = oy + half_height + 4.0 + frame_bottom = table_bottom - 4.0 corners = [ - (-frame_x, frame_bottom), - (frame_x, frame_bottom), - (frame_x, frame_top), - (-frame_x, frame_top), - (-frame_x, frame_bottom), + (center_x - frame_x, frame_bottom), + (center_x + frame_x, frame_bottom), + (center_x + frame_x, frame_top), + (center_x - frame_x, frame_top), + (center_x - frame_x, frame_bottom), ] border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) if border: @@ -649,44 +482,3 @@ def build_cross_drawing( _layer(FRAME_LAYER_ID, "Frame", locked=True), ], } - - -def extract_quantity_table( - drawing_id: str, drawing: dict[str, Any] -) -> dict[str, float | None] | None: - """확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다. - - 값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을 - 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다. - """ - id_to_key = { - str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS - } - entities = drawing.get("entities") - if not isinstance(entities, list): - return None - table: dict[str, float | None] = {} - found = False - for entity in entities: - if not isinstance(entity, dict): - continue - key = id_to_key.get(str(entity.get("id"))) - if key is None: - continue - found = True - shape = entity.get("shapeData") - label = shape.get("label") if isinstance(shape, dict) else None - try: - table[key] = float(str(label).replace(",", "")) - except (TypeError, ValueError): - table[key] = None - if not found: - return None - for key in QUANTITY_VALUE_KEYS: - table.setdefault(key, None) - ground = table.get("ground") - planned = table.get("planned") - if isinstance(ground, (int, float)) and isinstance(planned, (int, float)): - table["cut"] = max(ground - planned, 0.0) - table["fill"] = max(planned - ground, 0.0) - return table diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py new file mode 100644 index 00000000..2efff8cb --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -0,0 +1,179 @@ +"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다. + +채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다 +(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가 +정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이 +정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다. +""" + +import re +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + CROSS_MM, + CROSS_TABLE_LAYER_ID, + DESIGN_LAYER_ID, + DRAWING_FORMAT, + FRAME_LAYER_ID, + GROUND_LAYER_ID, + ROCK_LAYER_ID, + STRUCTURE_LAYER_ID, + _clip_polyline, + _layer, + _section_window, + build_cross_drawing, + points_from_samples, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + cross_table_height, + cross_table_width, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + frame_entities, + usable_area, +) + +# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않는 형식. +CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})") + +# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. +_BLOCK_GAP_MM = 8.0 +_BORDER_PAD_MM = 4.0 + + +def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: + points: list[tuple[float, float]] = [] + for point in design_line if isinstance(design_line, list) else []: + if not isinstance(point, dict): + continue + x = point.get("offset_m") + y = point.get("elevation_m") + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + return points + + +def section_block_size( + source: dict[str, Any], design_line: list[Any] | None, with_table: bool = True +) -> tuple[float, float]: + """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. + + build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와 + 수량표 중 넓은 쪽, 세로는 단면 높이 + 수량표. + """ + ground = points_from_samples(source.get("samples", []), "offset_m") + design = _design_points(design_line) + x0, x1 = _section_window(ground, design) + ground_clipped = _clip_polyline(ground, x0, x1) + ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1] + half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM + 5.0 + + width = 2.0 * max( + (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, + cross_table_width() / 2.0 + _BORDER_PAD_MM, + ) + # 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유 + top = half_height + _BORDER_PAD_MM + below = half_height + _BORDER_PAD_MM + if with_table: + below += 8.0 + cross_table_height() + return (width, top + below) + + +def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: + """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. + + 칸 크기는 전체 블록의 최대 폭·높이로 통일한다 — 장마다 칸이 달라지면 + 도면끼리 비교가 안 되기 때문이다. 한 장에 cols x rows개가 들어간다. + """ + if not blocks: + return [] + usable_w, usable_h = usable_area() + cell_w = max(w for _c, w, _h in blocks) + _BLOCK_GAP_MM + cell_h = max(h for _c, _w, h in blocks) + _BLOCK_GAP_MM + cols = max(1, int(usable_w // cell_w)) + rows = max(1, int(usable_h // cell_h)) + per_sheet = cols * rows + + sheets: list[dict[str, Any]] = [] + for index in range(0, len(blocks), per_sheet): + group = blocks[index : index + per_sheet] + number = index // per_sheet + 1 + sheets.append( + { + "id": f"cross_s{number:02d}", + "number": number, + "chainages": [chainage for chainage, _w, _h in group], + "cols": cols, + "rows": rows, + "cell": (cell_w, cell_h), + } + ) + return sheets + + +def _cell_center(index: int, rows: int, cell: tuple[float, float]) -> tuple[float, float]: + """좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다.""" + cell_w, cell_h = cell + usable_w, usable_h = usable_area() + column, row = divmod(index, rows) + x = -usable_w / 2.0 + cell_w * (column + 0.5) + y = -usable_h / 2.0 + cell_h * (row + 0.5) + return (x, y) + + +def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]: + """한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로).""" + entities: list[dict[str, Any]] = [] + rows = int(sheet.get("rows", 1)) + cell = tuple(sheet.get("cell", (100.0, 100.0))) # type: ignore[arg-type] + + for index, section in enumerate(sections): + seed_id = f"{sheet['id']}:{section['chainage']}" + # 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다. + probe = build_cross_drawing( + section["source"], + seed_id, + section.get("design_line"), + section.get("design"), + section.get("quantity_table"), + section.get("title", ""), + ) + bbox = entities_bbox(probe["entities"]) + if bbox is None: + continue + min_x, min_y, max_x, max_y = bbox + center_x, center_y = _cell_center(index, rows, cell) + # 2) 칸 가운데에 오도록 원점을 옮겨 다시 만든다. + origin = ( + center_x - (min_x + max_x) / 2.0, + center_y - (min_y + max_y) / 2.0, + ) + placed = build_cross_drawing( + section["source"], + seed_id, + section.get("design_line"), + section.get("design"), + section.get("quantity_table"), + section.get("title", ""), + origin=origin, + ) + entities.extend(placed["entities"]) + + bbox = entities_bbox(entities) + if bbox: + entities.extend(frame_entities(sheet["id"], bbox, fit=False)) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(GROUND_LAYER_ID, "Existing Ground", locked=True), + _layer(DESIGN_LAYER_ID, "Design Plan"), + _layer(STRUCTURE_LAYER_ID, "Structure"), + _layer(ROCK_LAYER_ID, "Rock Boundary"), + _layer(CROSS_TABLE_LAYER_ID, "Quantity Table"), + _layer(FRAME_LAYER_ID, "Frame", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py new file mode 100644 index 00000000..a401c0cd --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -0,0 +1,321 @@ +"""B07 횡단 수량 산출표 — 납품 양식 표 작도와 편집값 역추출. + +표는 도면 좌표(종이 mm) 기준이다. 값 셀 id는 uuid5(NS, "{drawing_id}:qtable:{key}")로 +정해져 있어, 사용자가 CAD에서 고친 숫자를 그대로 되읽을 수 있다. +""" + +from typing import Any +from uuid import uuid5 + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _ENTITY_NS, + CROSS_TABLE_LAYER_ID, + TABLE_LABEL_COLOR, + TABLE_LINE_COLOR, + TABLE_VALUE_COLOR, + _format, + _line_entity, + _text_entity, +) + +# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹. +# 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기 +# 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토) +# 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐 +_CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = ( + ("지반고", "ground"), + ("계획고", "planned"), + ("성토고", "fill"), + ("절토고", "cut"), +) +# 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로. +_CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = ( + ("깍기", "토사", "cut_soil"), + (None, "암석", "cut_rock"), + ("측구", "토사", "ditch_soil"), + (None, "암석", "ditch_rock"), + ("쌓기", None, "embankment"), + ("층따기", None, "benching"), +) +_CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = ( + ("면고르기", "성토", "grading_fill"), + (None, "절토", "grading_cut"), + ("지장목제거", "성토", "tree_removal_fill"), + (None, "절토", "tree_removal_cut"), + ("표토제거", "성토", "topsoil_fill"), + (None, "절토", "topsoil_cut"), +) +_CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = ( + ("편책", "fence"), + ("성토파종", "fill_seeding"), + ("절토살포", "cut_spraying"), + ("제근", "grubbing"), + (None, None), + ("노면다짐", "road_compaction"), +) + +QUANTITY_VALUE_KEYS: tuple[str, ...] = ( + "ground", + "planned", + "fill", + "cut", + "cut_soil", + "cut_rock", + "ditch_soil", + "ditch_rock", + "embankment", + "benching", + "grading_fill", + "grading_cut", + "tree_removal_fill", + "tree_removal_cut", + "topsoil_fill", + "topsoil_cut", + "fence", + "fill_seeding", + "cut_spraying", + "grubbing", + "road_compaction", +) + +# 표 치수는 종이 밀리미터다 (도면 좌표 = mm). +_CROSS_TABLE_WIDTH = 108.0 +_CROSS_TABLE_ROW_HEIGHT = 5.0 +_CROSS_TABLE_FONT = 2.0 + +# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) +_CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) + + +def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]: + total = sum(_CROSS_COLUMN_WEIGHTS) + left = center_x - width / 2.0 + edges = [left] + accumulated = 0.0 + for weight in _CROSS_COLUMN_WEIGHTS: + accumulated += weight + edges.append(left + width * accumulated / total) + return edges + + +def _cross_table_entities( + drawing_id: str, + quantity_table: dict[str, float | None], + table_top: float, + title_label: str, + center_x: float = 0.0, +) -> list[dict[str, Any]]: + """횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다. + + 표는 center_x를 가운데로 놓는다 — 한 장에 여러 단면을 배치할 때 각 단면 + 블록의 중심으로 옮기기 위한 것이다. + """ + width = _CROSS_TABLE_WIDTH + row_h = _CROSS_TABLE_ROW_HEIGHT + font = _CROSS_TABLE_FONT + left = center_x - width / 2.0 + right = center_x + width / 2.0 + edges = _cross_column_edges(width, center_x) + entities: list[dict[str, Any]] = [] + + def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]: + value = quantity_table.get(seed_key) + return _text_entity( + f"{drawing_id}:qtable:{seed_key}", + _format(value) if isinstance(value, (int, float)) else "-", + x, + y, + CROSS_TABLE_LAYER_ID, + font, + TABLE_VALUE_COLOR, + ) + + def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]: + return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR) + + def h_line(seed: str, x_from: float, x_to: float, y: float) -> None: + entities.append( + _line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) + ) + + def v_line(seed: str, x: float, y_from: float, y_to: float) -> None: + entities.append( + _line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) + ) + + # ── 행 y 좌표 (제목행, 헤더행, 본문 6행) + y_title_top = table_top + y_header_top = y_title_top - row_h + y_body_top = y_header_top - row_h + body_rows = len(_CROSS_LEFT_ROWS) + y_bottom = y_body_top - row_h * body_rows + + def body_y(row_index: int) -> float: + return y_body_top - row_h * row_index + + # ── 외곽/가로선 + h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top) + h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top) + h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top) + h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom) + v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom) + v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom) + + # 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다. + for boundary in range(1, body_rows): + y = body_y(boundary) + left_merged = _CROSS_LEFT_ROWS[boundary][0] is None + middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None + seed = f"{drawing_id}:qgrid:b{boundary}" + if left_merged: + h_line(f"{seed}:l", edges[1], edges[4], y) + else: + h_line(f"{seed}:l", edges[0], edges[4], y) + if middle_merged: + h_line(f"{seed}:m", edges[5], edges[8], y) + else: + h_line(f"{seed}:m", edges[4], edges[8], y) + h_line(f"{seed}:r", edges[8], edges[11], y) + + # ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할 + header_cell = width / 8.0 + for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS): + x_label = left + header_cell * (pair_index * 2 + 0.5) + x_value = left + header_cell * (pair_index * 2 + 1.5) + y_mid = y_header_top - row_h * 0.5 + entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid)) + entities.append(value_text(key, x_value, y_mid)) + if pair_index > 0: + v_line( + f"{drawing_id}:qgrid:hv{pair_index}", + left + header_cell * pair_index * 2, + y_header_top, + y_body_top, + ) + v_line( + f"{drawing_id}:qgrid:hvl{pair_index}", + left + header_cell * (pair_index * 2 + 1), + y_header_top, + y_body_top, + ) + + # ── 제목행 (No.측점) + entities.append( + _text_entity( + f"{drawing_id}:qtitle", + title_label, + left + width * 0.03, + y_title_top - row_h * 0.5, + CROSS_TABLE_LAYER_ID, + font * 1.15, + TABLE_LABEL_COLOR, + align="left", + ) + ) + + # ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만. + ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None] + if ab_rows: + v_line( + f"{drawing_id}:qgrid:vab", + edges[1], + body_y(min(ab_rows)), + body_y(max(ab_rows) + 1), + ) + for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10): + v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom) + + # ── 본문 셀 (좌/중/우 그룹) + def cell_mid(edge_from: int, edge_to: int) -> float: + return (edges[edge_from] + edges[edge_to]) / 2.0 + + for row_index in range(body_rows): + y_mid = body_y(row_index) - row_h * 0.5 + group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] + if group_l is not None and sub_l is not None: + # 그룹 라벨은 2행 병합 중앙 배치 + y_group = body_y(row_index) - row_h # 병합 2행의 중앙 + entities.append( + label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group) + ) + elif group_l is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid) + ) + if sub_l is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid) + ) + if key_l is not None: + entities.append(value_text(key_l, cell_mid(2, 3), y_mid)) + + group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] + if group_m is not None: + y_group = body_y(row_index) - row_h + entities.append( + label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group) + ) + entities.append( + label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid) + ) + entities.append(value_text(key_m, cell_mid(6, 7), y_mid)) + + label_r, key_r = _CROSS_RIGHT_ROWS[row_index] + if label_r is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid) + ) + if key_r is not None: + entities.append(value_text(key_r, cell_mid(9, 10), y_mid)) + return entities + + +def extract_quantity_table( + drawing_id: str, drawing: dict[str, Any] +) -> dict[str, float | None] | None: + """확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다. + + 값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을 + 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다. + """ + id_to_key = { + str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS + } + entities = drawing.get("entities") + if not isinstance(entities, list): + return None + table: dict[str, float | None] = {} + found = False + for entity in entities: + if not isinstance(entity, dict): + continue + key = id_to_key.get(str(entity.get("id"))) + if key is None: + continue + found = True + shape = entity.get("shapeData") + label = shape.get("label") if isinstance(shape, dict) else None + try: + table[key] = float(str(label).replace(",", "")) + except (TypeError, ValueError): + table[key] = None + if not found: + return None + for key in QUANTITY_VALUE_KEYS: + table.setdefault(key, None) + ground = table.get("ground") + planned = table.get("planned") + if isinstance(ground, (int, float)) and isinstance(planned, (int, float)): + table["cut"] = max(ground - planned, 0.0) + table["fill"] = max(planned - ground, 0.0) + return table + + +def cross_table_width() -> float: + """수량 산출표 가로 폭(mm).""" + return _CROSS_TABLE_WIDTH + + +def cross_table_height() -> float: + """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" + return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index ebd1bde8..8a36b5a1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -1,7 +1,6 @@ """B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터.""" import asyncio -import json import logging import re from pathlib import Path @@ -12,35 +11,34 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files -from B06_Section.B06_Section_Engine_Design import compute_cross_design from B06_Section.B06_Section_Repository import ( get_confirmed_route_context, get_cross_section_design, + get_cross_section_designs, get_longitudinal_section, merge_cross_section_design_by_round, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( - DRAWING_FORMAT, - QUANTITY_VALUE_KEYS, - build_cross_drawing, - extract_quantity_table, - infer_station_interval, - station_no_label, +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + CROSS_SHEET_ID, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( - build_longitudinal_drawing, - longitudinal_chunks, +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + extract_quantity_table, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + _cross_sheet_plan, + _drawing_list, + _invalidate_drawing, + _read_drawing, + _recompute_confirmed_design, + _store_confirmed_drawing, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingConfirmRequest, DesignDrawingConfirmResponse, DesignDrawingInvalidateResponse, - DesignDrawingItem, DesignDrawingListResponse, DesignDrawingResponse, ) -from common_util.common_util_route_profile import design_elevation_from_longitudinal from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage, start_stage from config.config_db import get_db_pool @@ -50,7 +48,6 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) _CROSS_ID = re.compile(r"^cross_(\d+)m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") -_STAGE_DIR = "B07_DesignDetail" async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: @@ -73,292 +70,18 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: return route_id, root, longitudinal_path -def _read_json(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") - return payload - - -def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: - cross_dir = longitudinal_path.parent.parent / "cross_sections" - if not cross_dir.is_dir(): - raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") - stations = longitudinal.get("stations") - valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) - files = sorted(cross_dir.glob("cross_*.json")) - if valid_names: - files = [path for path in files if path.name in valid_names] - return files - - -def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: - stations = longitudinal.get("stations", []) - if not isinstance(stations, list): - return {} +async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]: + """노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + rows = await get_cross_section_designs(connection, route_id) return { - round(float(station.get("chainage_m", 0))): station - for station in stations - if isinstance(station, dict) + int(round(float(row["chainage_m"]))): row["design"] + for row in rows + if isinstance(row, dict) and isinstance(row.get("design"), dict) } -def _design_root(project_root: Path) -> Path: - return project_root / _STAGE_DIR - - -def _read_manifest(project_root: Path) -> dict[str, Any]: - path = _design_root(project_root) / "manifest.json" - if not path.is_file(): - return {"drawings": {}} - payload = _read_json(path) - return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} - - -def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: - stage_root = _design_root(project_root) - stage_root.mkdir(parents=True, exist_ok=True) - path = stage_root / "manifest.json" - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(path) - - -def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]: - longitudinal = _read_json(longitudinal_path) - station_by_chainage = _station_map(longitudinal) - manifest_drawings = _read_manifest(project_root)["drawings"] - # 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1). - drawings = [ - DesignDrawingItem( - id=str(chunk["id"]), - kind="longitudinal", - label=str(chunk["label"]), - confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")), - ) - for chunk in longitudinal_chunks(longitudinal) - ] - for path in _cross_files(longitudinal_path, longitudinal): - match = _CROSS_ID.fullmatch(path.stem) - if not match: - continue - chainage = int(match.group(1)) - station = station_by_chainage.get(chainage, {}) - drawings.append( - DesignDrawingItem( - id=path.stem, - kind="cross", - label=str(station.get("label") or f"STA.{chainage // 1000}+{chainage % 1000:03d}"), - chainage_m=float(station.get("chainage_m", chainage)), - confirmed=bool(manifest_drawings.get(path.stem, {}).get("confirmed")), - ) - ) - return drawings - - -def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: - """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). - - center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. - 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 - 두어 CAD 테이블에서 사용자가 채운다. - """ - - def num(value: Any) -> float | None: - return float(value) if isinstance(value, (int, float)) else None - - ground = num(source.get("center_z")) - planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) - cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None - fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None - quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} - - table: dict[str, float | None] = { - "ground": ground, - "planned": planned, - "cut": cut, - "fill": fill, - } - for key in QUANTITY_VALUE_KEYS: - table.setdefault(key, num(quantities.get(key))) - return table - - -def _route_cross_frame( - longitudinal_path: Path, longitudinal: dict[str, Any] -) -> dict[str, float] | None: - """노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다. - - 측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기 - 콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블 - y 고정용). 실패 시 None(측점 자체 범위 폴백). - """ - half = half_height = 0.0 - found = False - try: - for path in _cross_files(longitudinal_path, longitudinal): - source = _read_json(path) - min_e: float | None = None - max_e: float | None = None - for sample in source.get("samples", []): - if not isinstance(sample, dict) or not sample.get("valid", False): - continue - offset = sample.get("offset_m") - elevation = sample.get("elevation_m") - if isinstance(offset, (int, float)): - half = max(half, abs(float(offset))) - if isinstance(elevation, (int, float)): - value = float(elevation) - min_e = value if min_e is None else min(min_e, value) - max_e = value if max_e is None else max(max_e, value) - if min_e is not None and max_e is not None: - half_height = max(half_height, (max_e - min_e) / 2.0) - found = True - except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError): - return None - if not found: - return None - # 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영. - return {"half": half or 12.0, "half_height": half_height + 1.5} - - -def _cross_design_line( - longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None -) -> list[Any] | None: - """횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산.""" - if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list): - return stored_design["design_line"] - try: - longitudinal = _read_json(longitudinal_path) - design = compute_cross_design( - source.get("samples", []), - design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), - ground_type="soil", - section_mode="left_cut", - ) - return design["design_line"] - except (ValueError, KeyError, OSError, json.JSONDecodeError): - return None - - -def _read_drawing( - project_root: Path, - longitudinal_path: Path, - drawing_id: str, - stored_design: dict[str, Any] | None = None, -) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: - """(kind, label, drawing, confirmed, quantity_table)를 반환한다. - - quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자 - 편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은 - stored_design(없으면 기본값)에서 만든다. - """ - manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) - saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" - if manifest_entry.get("confirmed") and saved_path.is_file(): - saved = _read_json(saved_path) - # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 - # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. - if saved.get("format") == DRAWING_FORMAT: - kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" - label = str(manifest_entry.get("label") or drawing_id) - stored_table = manifest_entry.get("quantity_table") - table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None - return kind, label, saved, True, table - if _LONG_ID.fullmatch(drawing_id): - source = _read_json(longitudinal_path) - chunk = next( - (item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None - ) - if chunk is None: - raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.") - return ( - "longitudinal", - str(chunk["label"]), - build_longitudinal_drawing(source, drawing_id, chunk), - False, - None, - ) - - if not _CROSS_ID.fullmatch(drawing_id): - raise ValueError("올바르지 않은 도면 ID입니다.") - path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" - if not path.is_file(): - raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") - source = _read_json(path) - label = str(source.get("label") or drawing_id) - design_line = _cross_design_line(longitudinal_path, source, stored_design) - quantity_table = _quantity_table(source) - # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) - longitudinal = _read_json(longitudinal_path) - interval = infer_station_interval(longitudinal.get("stations") or []) - title = station_no_label(float(source.get("chainage_m", 0.0)), interval) - # 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정. - design_elevation = design_elevation_from_longitudinal( - longitudinal, float(source.get("chainage_m", 0.0)) - ) - frame = _route_cross_frame(longitudinal_path, longitudinal) - return ( - "cross", - label, - build_cross_drawing( - source, - drawing_id, - design_line, - stored_design, - quantity_table, - title, - design_elevation, - frame, - ), - False, - quantity_table, - ) - - -def _store_confirmed_drawing( - project_root: Path, - item: DesignDrawingItem, - drawing: dict[str, Any], - expected_ids: set[str], - quantity_table: dict[str, Any] | None = None, -) -> bool: - if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): - raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") - # CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다. - drawing = {"format": DRAWING_FORMAT, **drawing} - drawings_dir = _design_root(project_root) / "drawings" - drawings_dir.mkdir(parents=True, exist_ok=True) - path = drawings_dir / f"{item.id}.json" - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(path) - - manifest = _read_manifest(project_root) - entry: dict[str, Any] = { - "kind": item.kind, - "label": item.label, - "confirmed": True, - "file": f"drawings/{item.id}.json", - } - if item.kind == "cross" and isinstance(quantity_table, dict): - entry["quantity_table"] = quantity_table - manifest["drawings"][item.id] = entry - _write_manifest(project_root, manifest) - confirmed_ids = { - item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed") - } - return expected_ids.issubset(confirmed_ids) - - -def _invalidate_drawing(project_root: Path, drawing_id: str) -> None: - manifest = _read_manifest(project_root) - entry = manifest["drawings"].get(drawing_id) - if entry: - entry["confirmed"] = False - _write_manifest(project_root, manifest) - - @router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse) async def get_design_drawing_list( project_id: UUID, @@ -366,7 +89,8 @@ async def get_design_drawing_list( """B07 좌측 패널용 도면 메타데이터만 캐시한다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) - drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + designs = await _designs_by_chainage(route_id) + drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) return DesignDrawingListResponse( project_id=str(project_id), route_id=route_id, drawings=drawings ) @@ -391,6 +115,7 @@ async def get_design_drawing( route_id, project_root, longitudinal_path = await _confirmed_source(project_id) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. design: dict[str, Any] | None = None + source_design: Any = None cross_match = _CROSS_ID.fullmatch(drawing_id) if cross_match: pool = get_db_pool() @@ -398,8 +123,12 @@ async def get_design_drawing( design = await get_cross_section_design( connection, route_id, int(cross_match.group(1)) ) + source_design = design + elif CROSS_SHEET_ID.fullmatch(drawing_id): + # 장은 여러 측점을 담으므로 노선 전체 지정을 한 번에 읽어 넘긴다. + source_design = await _designs_by_chainage(route_id) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( - _read_drawing, project_root, longitudinal_path, drawing_id, design + _read_drawing, project_root, longitudinal_path, drawing_id, source_design ) return DesignDrawingResponse( project_id=str(project_id), @@ -428,34 +157,6 @@ async def get_design_drawing( ) -def _recompute_confirmed_design( - longitudinal_path: Path, drawing_id: str, designation: dict[str, Any] -) -> dict[str, Any]: - """B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다. - - B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형· - 측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다. - """ - longitudinal = _read_json(longitudinal_path) - cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" - source = _read_json(cross_path) - samples = source.get("samples") - if not isinstance(samples, list): - raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") - design_elevation = design_elevation_from_longitudinal( - longitudinal, float(source.get("chainage_m", 0.0)) - ) - design = compute_cross_design( - samples, - design_elevation, - ground_type=designation["ground_type"], - section_mode=designation["section_mode"], - ditch_side=designation.get("ditch_side"), - ) - design["status"] = "confirmed" - return design - - @router.put( "/{project_id}/design-drawings/{drawing_id}/confirm", response_model=DesignDrawingConfirmResponse, @@ -469,16 +170,30 @@ async def confirm_design_drawing( """ try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) - items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + designs = await _designs_by_chainage(route_id) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) item = next((candidate for candidate in items if candidate.id == drawing_id), None) if not item: raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") + sheet = None + if CROSS_SHEET_ID.fullmatch(drawing_id): + plan = await asyncio.to_thread( + _cross_sheet_plan, project_root, longitudinal_path, designs + ) + sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) # 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백. - quantity_table = ( - extract_quantity_table(drawing_id, request.drawing) or request.quantity_table - if item.kind == "cross" - else None - ) + # 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}"). + quantity_table = None + quantity_tables: dict[str, Any] = {} + if sheet is not None: + for chainage in sheet["chainages"]: + table = extract_quantity_table(f"{drawing_id}:{chainage}", request.drawing) + if table: + quantity_tables[str(chainage)] = table + elif item.kind == "cross": + quantity_table = ( + extract_quantity_table(drawing_id, request.drawing) or request.quantity_table + ) # 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관. all_confirmed = await asyncio.to_thread( _store_confirmed_drawing, @@ -487,33 +202,47 @@ async def confirm_design_drawing( request.drawing, {candidate.id for candidate in items if candidate.kind == "cross"}, quantity_table, + quantity_tables or None, ) # 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음). - confirmed_design: dict[str, Any] | None = None - chainage_int: int | None = None + # 장은 담긴 측점 전부를 함께 확정한다. + recomputed: list[tuple[int, dict[str, Any]]] = [] cross_match = _CROSS_ID.fullmatch(drawing_id) pool = get_db_pool() - if item.kind == "cross" and cross_match: - chainage_int = int(cross_match.group(1)) - async with pool.acquire() as connection: - designation = await get_cross_section_design(connection, route_id, chainage_int) - if designation: - try: - confirmed_design = await asyncio.to_thread( - _recompute_confirmed_design, longitudinal_path, drawing_id, designation - ) - except (ValueError, KeyError, FileNotFoundError, OSError): - logger.warning( - "B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s", - drawing_id, - exc_info=True, + targets: list[int] = [] + if sheet is not None: + targets = list(sheet["chainages"]) + elif item.kind == "cross" and cross_match: + targets = [int(cross_match.group(1))] + for chainage_int in targets: + designation = designs.get(chainage_int) + if not designation: + continue + try: + recomputed.append( + ( + chainage_int, + await asyncio.to_thread( + _recompute_confirmed_design, + longitudinal_path, + f"cross_{chainage_int:05d}m", + designation, + ), ) + ) + except (ValueError, KeyError, FileNotFoundError, OSError): + logger.warning( + "B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s", + drawing_id, + chainage_int, + exc_info=True, + ) async with pool.acquire() as connection: await connection.begin() try: - if confirmed_design is not None and chainage_int is not None: + for chainage_int, confirmed_design in recomputed: await merge_cross_section_design_by_round( connection, route_id=route_id, @@ -534,7 +263,7 @@ async def confirm_design_drawing( id=drawing_id, confirmed=True, all_confirmed=all_confirmed, - design=confirmed_design, + design=recomputed[0][1] if len(recomputed) == 1 else None, ) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) @@ -566,16 +295,26 @@ async def invalidate_design_drawing( await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) cross_match = _CROSS_ID.fullmatch(drawing_id) + stale: list[int] = [] + if cross_match: + stale = [int(cross_match.group(1))] + elif CROSS_SHEET_ID.fullmatch(drawing_id): + designs = await _designs_by_chainage(route_id) + plan = await asyncio.to_thread( + _cross_sheet_plan, project_root, longitudinal_path, designs + ) + sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) + stale = list(sheet["chainages"]) if sheet else [] pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() try: - # 확정 도면을 편집하면 해당 측점 설계도 잠정 상태로 되돌린다. - if cross_match: + # 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다. + for chainage_int in stale: await merge_cross_section_design_by_round( connection, route_id=route_id, - chainage_int=int(cross_match.group(1)), + chainage_int=chainage_int, patch={"status": "provisional"}, ) async with connection.cursor() as cursor: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py new file mode 100644 index 00000000..2d3178b1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -0,0 +1,440 @@ +"""B07 도면 조립 지원 — 파일·매니페스트 읽기, 도면 목록·장 배치, 확정 저장. + +라우터에서 HTTP 처리와 무관한 동기 헬퍼만 떼어 놓은 모듈이다(단일 파일 700줄 제한). +""" + +import json +import logging +import re +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + build_cross_drawing, + infer_station_interval, + station_no_label, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( + build_longitudinal_drawing, + longitudinal_chunks, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + CROSS_SHEET_ID, + build_cross_sheet, + plan_cross_sheets, + section_block_size, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + QUANTITY_VALUE_KEYS, +) +from B07_DesignDetail.B07_DesignDetail_Schema import ( + DesignDrawingItem, +) +from common_util.common_util_route_profile import design_elevation_from_longitudinal + +logger = logging.getLogger(__name__) + +_STAGE_DIR = "B07_DesignDetail" + +_CROSS_ID = re.compile(r"^cross_(\d+)m$") +_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") + + +def _read_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") + return payload + + +def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: + cross_dir = longitudinal_path.parent.parent / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") + stations = longitudinal.get("stations") + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + files = sorted(cross_dir.glob("cross_*.json")) + if valid_names: + files = [path for path in files if path.name in valid_names] + return files + + +def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: + stations = longitudinal.get("stations", []) + if not isinstance(stations, list): + return {} + return { + round(float(station.get("chainage_m", 0))): station + for station in stations + if isinstance(station, dict) + } + + +def _design_root(project_root: Path) -> Path: + return project_root / _STAGE_DIR + + +def _read_manifest(project_root: Path) -> dict[str, Any]: + path = _design_root(project_root) / "manifest.json" + if not path.is_file(): + return {"drawings": {}} + payload = _read_json(path) + return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} + + +def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: + stage_root = _design_root(project_root) + stage_root.mkdir(parents=True, exist_ok=True) + path = stage_root / "manifest.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) + + +def _drawing_list( + project_root: Path, + longitudinal_path: Path, + designs: dict[int, dict[str, Any]] | None = None, +) -> list[DesignDrawingItem]: + longitudinal = _read_json(longitudinal_path) + station_by_chainage = _station_map(longitudinal) + manifest_drawings = _read_manifest(project_root)["drawings"] + # 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1). + drawings = [ + DesignDrawingItem( + id=str(chunk["id"]), + kind="longitudinal", + label=str(chunk["label"]), + confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")), + ) + for chunk in longitudinal_chunks(longitudinal) + ] + for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs): + chainages = sheet["chainages"] + first = station_by_chainage.get(chainages[0], {}) + span = f"{chainages[0]}m" + if len(chainages) > 1: + span = f"{chainages[0]}~{chainages[-1]}m" + drawings.append( + DesignDrawingItem( + id=sheet["id"], + kind="cross", + label=f"{sheet['number']}장 ({span})", + chainage_m=float(first.get("chainage_m", chainages[0])), + confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), + ) + ) + return drawings + + +def _cross_sheet_plan( + project_root: Path, + longitudinal_path: Path, + designs: dict[int, dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다. + + 한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 + + 설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다. + """ + longitudinal = _read_json(longitudinal_path) + blocks: list[tuple[int, float, float]] = [] + for path in sorted(_cross_files(longitudinal_path, longitudinal)): + match = _CROSS_ID.fullmatch(path.stem) + if not match: + continue + chainage = int(match.group(1)) + source = _read_json(path) + design = (designs or {}).get(chainage) + design_line = _cross_design_line(longitudinal_path, source, design) + width, height = section_block_size(source, design_line) + blocks.append((chainage, width, height)) + return plan_cross_sheets(blocks) + + +def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: + """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). + + center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. + 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 + 두어 CAD 테이블에서 사용자가 채운다. + """ + + def num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) else None + + ground = num(source.get("center_z")) + planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) + cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None + fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None + quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} + + table: dict[str, float | None] = { + "ground": ground, + "planned": planned, + "cut": cut, + "fill": fill, + } + for key in QUANTITY_VALUE_KEYS: + table.setdefault(key, num(quantities.get(key))) + return table + + +def _route_cross_frame( + longitudinal_path: Path, longitudinal: dict[str, Any] +) -> dict[str, float] | None: + """노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다. + + 측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기 + 콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블 + y 고정용). 실패 시 None(측점 자체 범위 폴백). + """ + half = half_height = 0.0 + found = False + try: + for path in _cross_files(longitudinal_path, longitudinal): + source = _read_json(path) + min_e: float | None = None + max_e: float | None = None + for sample in source.get("samples", []): + if not isinstance(sample, dict) or not sample.get("valid", False): + continue + offset = sample.get("offset_m") + elevation = sample.get("elevation_m") + if isinstance(offset, (int, float)): + half = max(half, abs(float(offset))) + if isinstance(elevation, (int, float)): + value = float(elevation) + min_e = value if min_e is None else min(min_e, value) + max_e = value if max_e is None else max(max_e, value) + if min_e is not None and max_e is not None: + half_height = max(half_height, (max_e - min_e) / 2.0) + found = True + except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError): + return None + if not found: + return None + # 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영. + return {"half": half or 12.0, "half_height": half_height + 1.5} + + +def _cross_design_line( + longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None +) -> list[Any] | None: + """횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산.""" + if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list): + return stored_design["design_line"] + try: + longitudinal = _read_json(longitudinal_path) + design = compute_cross_design( + source.get("samples", []), + design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), + ground_type="soil", + section_mode="left_cut", + ) + return design["design_line"] + except (ValueError, KeyError, OSError, json.JSONDecodeError): + return None + + +def _cross_section_input( + longitudinal_path: Path, + longitudinal: dict[str, Any], + chainage: int, + design: dict[str, Any] | None, +) -> dict[str, Any] | None: + """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.""" + path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json" + if not path.is_file(): + return None + source = _read_json(path) + interval = infer_station_interval(longitudinal.get("stations") or []) + return { + "chainage": chainage, + "source": source, + "design": design, + "design_line": _cross_design_line(longitudinal_path, source, design), + "quantity_table": _quantity_table(source), + "title": station_no_label(float(source.get("chainage_m", chainage)), interval), + } + + +def _read_cross_sheet( + project_root: Path, + longitudinal_path: Path, + drawing_id: str, + stored_designs: dict[str, Any] | None, +) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: + """횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다.""" + designs = stored_designs if isinstance(stored_designs, dict) else {} + plan = _cross_sheet_plan(project_root, longitudinal_path, designs) + sheet = next((item for item in plan if item["id"] == drawing_id), None) + if sheet is None: + raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") + longitudinal = _read_json(longitudinal_path) + sections = [ + section + for section in ( + _cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage)) + for chainage in sheet["chainages"] + ) + if section is not None + ] + label = f"횡단면도 {sheet['number']}장" + return "cross", label, build_cross_sheet(sheet, sections), False, None + + +def _read_drawing( + project_root: Path, + longitudinal_path: Path, + drawing_id: str, + stored_design: dict[str, Any] | None = None, +) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: + """(kind, label, drawing, confirmed, quantity_table)를 반환한다. + + quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자 + 편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은 + stored_design(없으면 기본값)에서 만든다. + """ + manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) + saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" + if manifest_entry.get("confirmed") and saved_path.is_file(): + saved = _read_json(saved_path) + # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 + # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. + if saved.get("format") == DRAWING_FORMAT: + kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" + label = str(manifest_entry.get("label") or drawing_id) + stored_table = manifest_entry.get("quantity_table") + table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None + return kind, label, saved, True, table + if _LONG_ID.fullmatch(drawing_id): + source = _read_json(longitudinal_path) + chunk = next( + (item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None + ) + if chunk is None: + raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.") + return ( + "longitudinal", + str(chunk["label"]), + build_longitudinal_drawing(source, drawing_id, chunk), + False, + None, + ) + + if CROSS_SHEET_ID.fullmatch(drawing_id): + return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design) + + if not _CROSS_ID.fullmatch(drawing_id): + raise ValueError("올바르지 않은 도면 ID입니다.") + path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" + if not path.is_file(): + raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") + source = _read_json(path) + label = str(source.get("label") or drawing_id) + design_line = _cross_design_line(longitudinal_path, source, stored_design) + quantity_table = _quantity_table(source) + # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) + longitudinal = _read_json(longitudinal_path) + interval = infer_station_interval(longitudinal.get("stations") or []) + title = station_no_label(float(source.get("chainage_m", 0.0)), interval) + # 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정. + design_elevation = design_elevation_from_longitudinal( + longitudinal, float(source.get("chainage_m", 0.0)) + ) + frame = _route_cross_frame(longitudinal_path, longitudinal) + return ( + "cross", + label, + build_cross_drawing( + source, + drawing_id, + design_line, + stored_design, + quantity_table, + title, + design_elevation, + frame, + ), + False, + quantity_table, + ) + + +def _store_confirmed_drawing( + project_root: Path, + item: DesignDrawingItem, + drawing: dict[str, Any], + expected_ids: set[str], + quantity_table: dict[str, Any] | None = None, + quantity_tables: dict[str, Any] | None = None, +) -> bool: + if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): + raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") + # CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다. + drawing = {"format": DRAWING_FORMAT, **drawing} + drawings_dir = _design_root(project_root) / "drawings" + drawings_dir.mkdir(parents=True, exist_ok=True) + path = drawings_dir / f"{item.id}.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) + + manifest = _read_manifest(project_root) + entry: dict[str, Any] = { + "kind": item.kind, + "label": item.label, + "confirmed": True, + "file": f"drawings/{item.id}.json", + } + if item.kind == "cross" and isinstance(quantity_table, dict): + entry["quantity_table"] = quantity_table + if isinstance(quantity_tables, dict) and quantity_tables: + # 장 도면: 측점별 수량표를 그대로 보관한다(뒷단계 수량산출이 측점 단위). + entry["quantity_tables"] = quantity_tables + manifest["drawings"][item.id] = entry + _write_manifest(project_root, manifest) + confirmed_ids = { + item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed") + } + return expected_ids.issubset(confirmed_ids) + + +def _invalidate_drawing(project_root: Path, drawing_id: str) -> None: + manifest = _read_manifest(project_root) + entry = manifest["drawings"].get(drawing_id) + if entry: + entry["confirmed"] = False + _write_manifest(project_root, manifest) + + +def _recompute_confirmed_design( + longitudinal_path: Path, cross_stem: str, designation: dict[str, Any] +) -> dict[str, Any]: + """B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다. + + B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형· + 측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다. + """ + longitudinal = _read_json(longitudinal_path) + cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json" + source = _read_json(cross_path) + samples = source.get("samples") + if not isinstance(samples, list): + raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") + design_elevation = design_elevation_from_longitudinal( + longitudinal, float(source.get("chainage_m", 0.0)) + ) + design = compute_cross_design( + samples, + design_elevation, + ground_type=designation["ground_type"], + section_mode=designation["section_mode"], + ditch_side=designation.get("ditch_side"), + ) + design["status"] = "confirmed" + return design diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index fdf0adf8..15744630 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -74,40 +74,9 @@ const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; -/** 측점 간격을 연속 chainage 차이의 최빈값으로 추정한다 (B06 그래프와 동일 방식). */ -function inferStationInterval(chainages: number[]): number { - const counts = new Map(); - const sorted = [...chainages].sort((a, b) => a - b); - for (let index = 1; index < sorted.length; index += 1) { - const difference = sorted[index] - sorted[index - 1]; - if (difference <= 0) continue; - const rounded = Math.round(difference * 10) / 10; - counts.set(rounded, (counts.get(rounded) ?? 0) + 1); - } - return ( - [...counts.entries()].sort( - ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, - )[0]?.[0] ?? 1 - ); -} - -/** 측점 번호+나머지 표기 (B06 그래프 영역 횡단도 라벨과 동일 형식, 예: "2+0.0"). */ -function stationLabel(chainage: number, interval: number): string { - const safeInterval = interval > 0 ? interval : 1; - let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); - let remainder = chainage - stationNumber * safeInterval; - if (Math.abs(remainder) < 0.05) remainder = 0; - if (remainder >= safeInterval - 0.05) { - stationNumber += 1; - remainder = 0; - } - return `${stationNumber}+${remainder.toFixed(1)}`; -} - /** B06 확정 산출물 기반 도면 목록 패널. */ function buildDrawingSidePanel( drawings: DesignDrawingItem[], - stationInterval: number, onSelect: (drawing: DesignDrawingItem) => void, errorMessage?: string, ): HTMLDivElement { @@ -152,10 +121,8 @@ function buildDrawingSidePanel( button.dataset.confirmed = String(drawing.confirmed); const name = document.createElement("span"); name.className = "b07-drawing-button__name"; - name.textContent = - drawing.kind === "cross" && typeof drawing.chainage_m === "number" - ? stationLabel(drawing.chainage_m, stationInterval) - : drawing.label; + // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. + name.textContent = drawing.label; button.append(name); button.addEventListener("click", () => onSelect(drawing)); section.append(button); @@ -294,11 +261,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { license.textContent = "Drawing engine based on OpenWebCAD · MIT License"; cadHost.append(frame, license); - const crossChainages = drawings - .filter((item) => item.kind === "cross" && typeof item.chainage_m === "number") - .map((item) => item.chainage_m as number); - const stationInterval = inferStationInterval(crossChainages); - let cadReady = false; let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined; let currentDrawing: DesignDrawingItem | undefined; @@ -318,10 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { infoPanelHost.replaceChildren(); return; } - const title = - typeof drawing.chainage_m === "number" - ? stationLabel(drawing.chainage_m, stationInterval) - : drawing.label; + const title = drawing.label; infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null)); }; @@ -349,10 +308,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { index: number, ): DesignMeta => ({ kind: drawing.kind, - title: - drawing.kind === "cross" && typeof drawing.chainage_m === "number" - ? stationLabel(drawing.chainage_m, stationInterval) - : drawing.label, + title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: response.confirmed, quantityTable: response.quantity_table ?? null, @@ -433,11 +389,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (button) button.dataset.confirmed = "true"; // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. if (currentDrawing.kind === "cross") { - const infoTitle = - typeof currentDrawing.chainage_m === "number" - ? stationLabel(currentDrawing.chainage_m, stationInterval) - : currentDrawing.label; - infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null)); + infoPanelHost.replaceChildren( + buildDesignInfoPanel(currentDrawing.label, result.design ?? null), + ); } showToast("현재 도면을 확정하고 저장했습니다.", "success"); if (result.all_confirmed) { @@ -502,12 +456,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } }); - const drawingPanel = buildDrawingSidePanel( - drawings, - stationInterval, - selectDrawing, - drawingError, - ); + const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로