"""횡단 수량표 손질(179-4) — 횡단 설계 patch `detail_table` → 칸 값 · 표 배치 한 벌. 모달(`B06_Section_Router_CrossTable`)과 B07 도면 표(`_Engine_Cad_Table`)가 같은 함수를 쓴다. 칸 출처(`detail_table.cells` · 표 키 → 출처) — 그릴 때마다 서버가 다시 셈(브라우저 값 안 받음): area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키) length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」 text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록: length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」) 조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선 (두 끝이 선 밖 → 「다시 고름」) · 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선. 행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). 열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). 짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` (조작 중 즉시 표시) · 조각 길 · 넓이 = `B06_Section/B06_Section_UI_Cross_Detail_Geom.ts` · 거울 시험 `resources/tester/test_179_4_detail_table.py` · `test_209_1_detail_select.py`. """ from __future__ import annotations from math import hypot from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import AREA_KEYS _AREA_DESIGN = dict(AREA_KEYS) #: 두 끝이 설계선에서 이만큼(m) 안이면 「선 위」 LINE_TOLERANCE_M = 0.05 #: 더한 행 · 열 상한 — 도면 표가 장 칸을 넘지 않게 MAX_ROWS = 12 MAX_COLUMNS = 4 Slot = tuple[str | None, str | None, str | None] def _edit(design: dict[str, Any] | None) -> dict[str, Any]: edit = design.get("detail_table") if isinstance(design, dict) else None return edit if isinstance(edit, dict) else {} def _num(value: Any) -> float | None: return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None def _point(value: Any) -> tuple[float, float] | None: if not isinstance(value, list) or len(value) != 2: return None x, y = _num(value[0]), _num(value[1]) return (x, y) if x is not None and y is not None else None def _polyline(raw: Any) -> list[tuple[float, float]]: points = [] for point in raw if isinstance(raw, list) else []: if isinstance(point, dict): x, y = _num(point.get("offset_m")), _num(point.get("elevation_m")) if x is not None and y is not None: points.append((x, y)) return points def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: return _polyline(design.get("design_line")) def ground_runs(samples: Any) -> list[list[tuple[float, float]]]: """원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(카드 `appendGroundProfile` 과 같은 규칙).""" runs: list[list[tuple[float, float]]] = [[]] for sample in samples if isinstance(samples, list) else []: sample = sample if isinstance(sample, dict) else {} x = _num(sample.get("offset_m")) y = _num(sample.get("elevation_m", sample.get("z"))) if sample.get("valid") is False or x is None or y is None: runs.append([]) else: runs[-1].append((x, y)) return [run for run in runs if len(run) > 1] def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool: """점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가.""" px, py = point for (ax, ay), (bx, by) in zip(line, line[1:]): dx, dy = bx - ax, by - ay length2 = dx * dx + dy * dy t = 0.0 if length2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length2)) if hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M: return True return False Point = tuple[float, float] def _locate(point: Point, run: list[Point]) -> float | None: """선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 None.""" best: tuple[float, float] | None = None along = 0.0 for (ax, ay), (bx, by) in zip(run, run[1:]): dx, dy = bx - ax, by - ay size = hypot(dx, dy) t = 0.0 if size == 0 else ((point[0] - ax) * dx + (point[1] - ay) * dy) / size**2 t = max(0.0, min(1.0, t)) gap = hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)) if gap <= LINE_TOLERANCE_M and (best is None or gap < best[0]): best = (gap, along + t * size) along += size return best[1] if best else None def _at(run: list[Point], s: float) -> Point: """선 위 자리 s(m) 의 점.""" for (ax, ay), (bx, by) in zip(run, run[1:]): size = hypot(bx - ax, by - ay) if s <= size: t = 0.0 if size == 0 else s / size return (ax + t * (bx - ax), ay + t * (by - ay)) s -= size return run[-1] def sub_path(run: list[Point], s0: float, s1: float) -> list[Point]: """선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함.""" lo, hi = min(s0, s1), max(s0, s1) inner: list[Point] = [] along = 0.0 for a, b in zip(run, run[1:]): along += hypot(b[0] - a[0], b[1] - a[1]) if lo < along < hi: inner.append(b) path = [_at(run, lo), *inner, _at(run, hi)] return path if s0 <= s1 else path[::-1] def piece_path(piece: Any, lines: dict[str, list[list[Point]]]) -> list[Point] | None: """고른 조각 하나 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 None(구조물 선 = 두 끝 직선).""" if not isinstance(piece, dict): return None start, end = _point(piece.get("start")), _point(piece.get("end")) if not start or not end: return None if piece.get("line") == "structure": return [start, end] for run in lines.get(str(piece.get("line")), []): s0, s1 = _locate(start, run), _locate(end, run) if s0 is not None and s1 is not None: return sub_path(run, s0, s1) return None def path_length(path: list[Point]) -> float: return sum(hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:])) def _near(a: Point, b: Point) -> bool: return hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M def ring_area(paths: list[list[Point]]) -> float | None: """조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = None.""" if not paths: return None ring = list(paths[0]) rest = [list(path) for path in paths[1:]] while rest: index = next( (i for i, p in enumerate(rest) if _near(p[0], ring[-1]) or _near(p[-1], ring[-1])), None, ) if index is None: return None path = rest.pop(index) ring += (path if _near(path[0], ring[-1]) else path[::-1])[1:] if len(ring) < 4 or not _near(ring[0], ring[-1]): return None ring.append(ring[0]) # 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:]))) / 2 return area if area > 1e-9 else None def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], } def cell_value( source: dict[str, Any], design: dict[str, Any] | None, samples: Any = None ) -> tuple[float | None, str | None, bool]: """출처 하나 → (숫자, 글, 다시 고름). `samples` = 측점 원지반 샘플(조각 검산).""" design = design if isinstance(design, dict) else {} kind = source.get("src") pieces = source.get("pieces") if kind in ("length", "area") and isinstance(pieces, list): lines = pick_lines(design, samples) paths = [piece_path(piece, lines) for piece in pieces] valid = [path for path in paths if path is not None] if not valid or len(valid) != len(paths): return None, None, True number = sum(map(path_length, valid)) if kind == "length" else ring_area(valid) return number, None, number is None if kind == "area": return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False if kind == "length": start, end = _point(source.get("start")), _point(source.get("end")) line = _design_line(design) if start and end and _on_line(start, line) and _on_line(end, line): return hypot(end[0] - start[0], end[1] - start[1]), None, False return None, None, True if kind == "text": text = source.get("text") return None, text if isinstance(text, str) else "", False return None, None, False def edited_cells( design: dict[str, Any] | None, samples: Any = None ) -> tuple[dict[str, float | None], dict[str, str], list[str]]: """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만 · `samples` = 원지반.""" values: dict[str, float | None] = {} texts: dict[str, str] = {} repick: list[str] = [] cells = _edit(design).get("cells") for key, source in (cells if isinstance(cells, dict) else {}).items(): if not isinstance(source, dict): continue number, text, again = cell_value(source, design, samples) if text is not None: if key[:2] in ("r:", "c:"): texts[key] = text continue values[key] = number if again: repick.append(key) return values, texts, repick def _labels(row: dict[str, Any]) -> list[str]: raw = row.get("labels") raw = raw if isinstance(raw, list) else [] return [str(raw[i]) if i < len(raw) and isinstance(raw[i], str) else "" for i in range(3)] def _added_rows(edit: dict[str, Any]) -> list[dict[str, Any]]: """쓸 수 있는 더한 행 — id = 「r…」 · 겹친 id 는 앞 것만 · 상한 `MAX_ROWS`.""" raw = edit.get("rows") rows: list[dict[str, Any]] = [] for row in raw if isinstance(raw, list) else []: row_id = row.get("id") if isinstance(row, dict) else None if ( isinstance(row_id, str) and row_id.startswith("r") and all(r["id"] != row_id for r in rows) ): rows.append(row) return rows[:MAX_ROWS] def added_row_count(design: dict[str, Any] | None) -> int: """더한 행 수 — 도면 표 높이(`cross_table_height`)가 씀.""" return len(_added_rows(_edit(design))) def _slot(anchor: Slot | None, label: str, key: str) -> Slot: """묶음(하위 이름이 있는 줄) 아래면 하위 줄 · 아니면 홀로 선 이름.""" if anchor is not None and anchor[1] is not None: return (None, label, key) return (label, None, key) def table_layout( design: dict[str, Any] | None, header: tuple[tuple[str, str], ...], left: tuple[Slot, ...], middle: tuple[Slot, ...], right: tuple[tuple[str | None, str | None], ...], ) -> dict[str, Any]: """납품 양식(머리 넷 쌍 + 본문 좌 · 중 · 우) + 더한 행 · 열 = 실제 표 배치.""" edit = _edit(design) rows: list[tuple[str, Slot, Slot, tuple[str | None, str | None]]] = [ (f"b{index}", left[index], middle[index], right[index]) for index in range(len(left)) ] for row in _added_rows(edit): row_id = row["id"] labels = _labels(row) index = next((i for i, r in enumerate(rows) if r[0] == row.get("after")), None) anchor = rows[index] if index is not None and row.get("grouped") else None rows.insert( len(rows) if index is None else index + 1, ( row_id, _slot(anchor[1] if anchor else None, labels[0], f"r:{row_id}:0"), _slot(anchor[2] if anchor else None, labels[1], f"r:{row_id}:1"), (labels[2], f"r:{row_id}:2"), ), ) columns = [] raw_columns = edit.get("columns") for column in (raw_columns if isinstance(raw_columns, list) else [])[:MAX_COLUMNS]: column_id = column.get("id") if isinstance(column, dict) else None if not isinstance(column_id, str) or not column_id: continue label = column.get("label") columns.append( { "id": column_id, "label": label if isinstance(label, str) else "", "keys": [f"c:{column_id}:{r[0]}" for r in rows], } ) return { "header": [list(pair) for pair in header], "left": [list(r[1]) for r in rows], "middle": [list(r[2]) for r in rows], "right": [list(r[3]) for r in rows], "rows": [r[0] for r in rows], "columns": columns, } def group_span(slots: list[list[Any]] | list[Slot], index: int) -> int: """묶음 이름 칸이 덮는 줄 수 — 이 줄 + 뒤따르는 하위 줄(이름 없음 · 하위 이름 있음).""" span = 1 while ( index + span < len(slots) and slots[index + span][0] is None and slots[index + span][1] is not None ): span += 1 return span