Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py
T
eomsangdonandClaude Opus 5.5 eaf9337638 fix(b06): 상세 고름 — 공용 팬 누름마다 moved 풂 · 암 경계선 나눔 · 고름 (259-1)
- 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄)
- 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 14:02:18 +09:00

346 lines
14 KiB
Python

"""횡단 수량표 손질(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 rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]:
"""암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙)."""
offset = _num(design.get("rock_boundary_offset_m"))
if design.get("geometry_preset") != "rock" or offset is None:
return []
return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)]
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"))],
"rock": rock_runs(design, samples),
}
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