"""횡단 수량표 손질(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:` 열)만 행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). 열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). 짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` (조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.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 _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: points = [] for point in design.get("design_line") or []: 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 _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 def cell_value( source: dict[str, Any], design: dict[str, Any] | None ) -> tuple[float | None, str | None, bool]: """출처 하나 → (숫자, 글, 다시 고름).""" design = design if isinstance(design, dict) else {} kind = source.get("src") 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, ) -> tuple[dict[str, float | None], dict[str, str], list[str]]: """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만.""" 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) 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