diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 878f9515d..3c628cfc3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -10,7 +10,6 @@ """ import math -from bisect import bisect_left from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( @@ -22,7 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -36,6 +35,20 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( STREAM_COLOR, STRUCTURE_COLOR, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import ( + label_entities, + place_labels, + text_width, +) + +# 비탈 · 노선 따라가기 기하는 700줄을 넘어 떼어냄 — 호출부 import 경로는 그대로. +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: F401 + TICK_STEP_M, + route_frame, + side_is_cut, + slope_end, + slope_pieces, +) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, entities_bbox, @@ -130,7 +143,6 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "sgg": (3, [14, 5, 3, 5]), } PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값 -TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 _MAJOR_FONT = 4.5 @@ -142,178 +154,6 @@ def layer_id(piece: str) -> str: return f"b07-plan-{piece}" -# ── 노선 따라가기 ───────────────────────────────────── - - -def route_frame( - route: list[tuple[float, float, float]], chainage: float -) -> tuple[float, float, float, float]: - """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" - chainages = [item[0] for item in route] - index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) - (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] - dx, dy = x1 - x0, y1 - y0 - length = math.hypot(dx, dy) or 1.0 - ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 - return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) - - -def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: - chainages = [record["chainage_m"] for record in records] - index = bisect_left(chainages, chainage) - if index <= 0: - return records[0][key] - if index >= len(records): - return records[-1][key] - before, after = records[index - 1], records[index] - span = after["chainage_m"] - before["chainage_m"] - ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 - return before[key] + (after[key] - before[key]) * ratio - - -def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: - return sorted( - (float(item["offset_m"]), float(item["elevation_m"])) - for item in samples - if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) - ) - - -def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: - offsets = [x for x, _z in ground] - index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) - (x0, z0), (x1, z1) = ground[index - 1], ground[index] - return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) - - -def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: - """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" - ground = _ground(samples) - return len(ground) >= 2 and _ground_at(ground, edge) > elevation - - -def slope_end( - design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str -) -> float: - """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. - - 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. - 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. - """ - ground = _ground(samples) - points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) - outward = ( - [p for p in points if p[0] > edge] - if side == "left" - else [p for p in points[::-1] if p[0] < edge] - ) - if not ground: - return outward[-1][0] if outward else edge - end, differs = edge, False - for offset, elevation in outward: - if abs(elevation - _ground_at(ground, offset)) <= 1e-3: - return offset if differs else end - differs, end = True, offset - return end - - -def _split_runs( - points: list[list[float]], flags: list[bool] -) -> tuple[list[list[list[float]]], list[list[list[float]]]]: - """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" - runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} - current: list[list[float]] = [] - previous: bool | None = None - for point, flag in zip(points, flags): - if flag != previous: - if current: - current.append(point) - current = [point] - runs[flag].append(current) - previous = flag - else: - current.append(point) - return ( - [run for run in runs[True] if len(run) >= 2], - [run for run in runs[False] if len(run) >= 2], - ) - - -def slope_pieces( - route: list[tuple[float, float, float]], - records: list[dict[str, Any]], - start_m: float, - end_m: float, -) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: - """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. - - `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, - left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 - 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 - 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. - """ - records = sorted( - ( - { - **record, - **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, - } - for record in records - ), - key=lambda record: record["chainage_m"], - ) - if len(route) < 2 or not records: - return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} - keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") - by_chainage = {round(record["chainage_m"], 3): record for record in records} - samples = { - round(record["chainage_m"], 3) - for record in records - if start_m <= record["chainage_m"] <= end_m - } - step = start_m - while step <= end_m: - samples.add(round(step, 3)) - step += TICK_STEP_M - lines: dict[str, list[list[float]]] = {key: [] for key in keys} - cut: dict[str, list[bool]] = {"left": [], "right": []} - ticks: list[list[list[float]]] = [] - for index, chainage in enumerate(sorted(samples)): - record = by_chainage.get(chainage) - if record and all(k in record for k in ("x", "y", "nx", "ny")): - x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] - else: - x, y, nx, ny = route_frame(route, chainage) - offsets = {key: _interp(records, chainage, key) for key in keys} - points = { - key: [round(x + nx * value, 4), round(y + ny * value, 4)] - for key, value in offsets.items() - } - for key in keys: - lines[key].append(points[key]) - for side in cut: - cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) - # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). - for side in ("left", "right"): - edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] - if abs(end - edge) < 0.05: - continue - reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 - ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) - cut_lines: list[list[list[float]]] = [] - fill_lines: list[list[list[float]]] = [] - for side in cut: - cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) - cut_lines += cut_runs - fill_lines += fill_runs - return ( - {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, - {"lines": fill_lines}, - {"lines": cut_lines}, - {"ticks": ticks}, - ) - - def curve_pieces( curves: list[dict[str, Any]], route: list[tuple[float, float, float]], @@ -338,7 +178,7 @@ def curve_pieces( "end": list(end[:2]), } ) - # R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다. + # R 라벨 후보는 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽) · 바깥으로(종이 mm · 141-1). middle = min( ((x, y) for _c, x, y in route), key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]), @@ -349,7 +189,8 @@ def curve_pieces( radii.append( { "label": f"R={round(float(radius), 1):g}", - "at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0], + "middle": list(middle), + "dir": [dx / length, dy / length], } ) return {"items": ips}, {"items": radii} @@ -371,11 +212,30 @@ def _reading(nx: float, ny: float) -> tuple[float, float]: return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty) +def _side_spots(cx: float, cy: float, size: float) -> list[dict[str, Any]]: + """기호 곁 글 후보 — 오른쪽(옛 자리) → 왼 · 위 · 아래 → 멀리 오른 · 왼(지시선).""" + return [ + {"x": cx + size + 1.0, "y": cy, "align": "left"}, + {"x": cx - size - 1.0, "y": cy, "align": "right"}, + {"x": cx, "y": cy + size + 2.5}, + {"x": cx, "y": cy - size - 2.5}, + {"x": cx + size + 8.0, "y": cy + 4.0, "align": "left", "far": True}, + {"x": cx - size - 8.0, "y": cy - 4.0, "align": "right", "far": True}, + ] + + def _piece_entities( - drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM + drawing_id: str, + piece: str, + data: dict[str, Any], + paper: Any, + mm: float = MM, + labels: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: + """조각의 선 · 기호 엔티티. 글은 후보 자리째 `labels` 에 모음(자리는 `place_labels` · 141-1).""" layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) out: list[dict[str, Any]] = [] + labels = labels if labels is not None else [] def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None: entity = polyline_entity( @@ -390,11 +250,24 @@ def _piece_entities( if entity: out.append(entity) - def text(seed: str, label: str, at: tuple[float, float], size: float, **kw: Any) -> None: - out.append( - _text_entity( - f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw - ) + def text( + seed: str, + label: str, + size: float, + spots: list[dict[str, Any]], + anchor: tuple[float, float] | None = None, + ) -> None: + labels.append( + { + "piece": piece, + "seed": f"{drawing_id}:{piece}:{seed}", + "label": label, + "size": size, + "layer": layer, + "color": color, + "anchor": anchor, + "candidates": spots, + } ) if piece == "lidar": @@ -434,10 +307,39 @@ def _piece_entities( ) if out_circle: out.append(out_circle) - text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT) + ax, ay, reach = apex[0], apex[1], _CIRCLE_MM + 3.0 + text( + f"{index}", + item["name"], + _LABEL_FONT, + [ + {"x": ax, "y": ay - reach}, + {"x": ax, "y": ay + reach}, + {"x": ax - _CIRCLE_MM - 1.0, "y": ay, "align": "right"}, + {"x": ax + _CIRCLE_MM + 1.0, "y": ay, "align": "left"}, + {"x": ax, "y": ay - reach - 5.0, "far": True}, + {"x": ax, "y": ay + reach + 5.0, "far": True}, + ], + apex, + ) elif piece == "radius": for index, item in enumerate(data.get("items") or []): - text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) + if "middle" not in item: # 옛 조각 파일 — 자리 하나 + at = paper(item["at"]) + text(f"{index}", item["label"], _LABEL_FONT, [{"x": at[0], "y": at[1]}]) + continue + mx, my = paper(item["middle"]) + dx, dy = item["dir"] + text( + f"{index}", + item["label"], + _LABEL_FONT, + [ + {"x": mx + dx * d, "y": my + dy * d, "far": abs(d) > 6.0} + for d in (3.0, 6.0, -3.0, -6.0, 10.0, -10.0) + ], + (mx, my), + ) elif piece == "ip_table": out.extend(ip_table_entities(drawing_id, data)) elif piece == "station": @@ -457,13 +359,25 @@ def _piece_entities( ) if circle: out.append(circle) - gap = _MAJOR_FONT * 1.8 + gap, (tx, ty) = _MAJOR_FONT * 1.8, _reading(nx, ny) + shift = text_width(item["label"], _MAJOR_FONT) / 2.0 + 2.0 text( f"{index}", item["label"], - (cx + nx * gap, cy + ny * gap), _MAJOR_FONT, - direction=_reading(nx, ny), + [ + { + "x": cx + nx * g + tx * t, + "y": cy + ny * g + ty * t, + "direction": (tx, ty), + } + for g, t in ((gap, 0.0), (-gap, 0.0), (gap, shift), (gap, -shift)) + ] + + [ + {"x": cx + nx * g, "y": cy + ny * g, "direction": (tx, ty), "far": True} + for g in (gap * 1.8, -gap * 1.8) + ], + (cx, cy), ) elif piece == "endpoints": for index, item in enumerate(data.get("items") or []): @@ -478,7 +392,16 @@ def _piece_entities( width=2, ) cx, cy = paper((item["x"], item["y"])) - text(f"{index}", item["label"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5) + text( + f"{index}", + item["label"], + _LABEL_FONT + 0.5, + [ + {"x": cx + nx * g, "y": cy + ny * g, "far": abs(g) > 9.0} + for g in (9.0, -9.0, 15.0, -15.0) + ], + (cx, cy), + ) elif piece == "refuge": size = 2.0 # 네모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): @@ -486,7 +409,7 @@ def _piece_entities( corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)] line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) - text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left") + text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy)) elif piece == "drainage": size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): @@ -502,7 +425,7 @@ def _piece_entities( if diamond: out.append(diamond) text( - f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" + f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy) ) elif piece in _BOUNDARY_STYLE: width, dash = _BOUNDARY_STYLE[piece] @@ -526,6 +449,12 @@ def _piece_entities( return out +def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]: + shape = entity["shapeData"] + x, y = shape["origin"]["x"], shape["origin"]["y"] + return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y) + + def build_plan_kind_drawing( drawing_id: str, label: str, @@ -541,9 +470,23 @@ def build_plan_kind_drawing( return ((point[0] - min_x) * mm, (point[1] - min_y) * mm) entities: list[dict[str, Any]] = [] + labels: list[dict[str, Any]] = [] + obstacles: list[tuple[float, float, float, float]] = [] for piece in PIECE_IDS: if piece in pieces: - entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper, mm)) + made = _piece_entities(drawing_id, piece, pieces[piece], paper, mm, labels) + entities.extend(made) + # 글이 비켜 갈 자리 — IP 표 · 필지 상자(141-1). 제목은 내용 위에 따로 서서 안 겹침. + if piece == "ip_table": + obstacles += [_table_rect(e) for e in made if e.get("type") == "Table"] + elif piece == "parcel_info": + for item in pieces[piece].get("items") or []: + width, height = balloon_size(item["lines"], PARCEL_FONT) + cx, cy = paper(item["center"]) + obstacles.append( + (cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2) + ) + entities.extend(label_entities(drawing_id, place_labels(labels, obstacles))) # 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다. frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm) entities.extend( diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py new file mode 100644 index 000000000..2caf7bb57 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py @@ -0,0 +1,121 @@ +"""B07 평면도 글 자리 잡기(141-1) — 글끼리 · 장애물(IP 표 · 필지 상자)과 안 겹치게. + +조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로 +후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다. +우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③). + +좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 — +CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자. +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity + +PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge") +_GAP_MM = 0.6 # 글 사이 틈(종이 mm) + +Rect = tuple[float, float, float, float] + + +def text_width(label: str, size: float) -> float: + return sum(1.0 if ord(char) > 0x2E7F else 0.6 for char in label) * size + + +def text_rect( + label: str, + x: float, + y: float, + size: float, + align: str = "center", + direction: tuple[float, float] = (1.0, 0.0), +) -> Rect: + width = text_width(label, size) + left = {"left": 0.0, "right": -width}.get(align, -width / 2.0) + dx, dy = direction + length = math.hypot(dx, dy) or 1.0 + dx, dy = dx / length, dy / length + corners = [ + (x + cx * dx - cy * dy, y + cx * dy + cy * dx) + for cx in (left, left + width) + for cy in (-size / 2.0, size / 2.0) + ] + xs, ys = [p[0] for p in corners], [p[1] for p in corners] + return min(xs), min(ys), max(xs), max(ys) + + +def overlaps(a: Rect, b: Rect, gap: float = 0.0) -> bool: + return a[0] < b[2] + gap and b[0] < a[2] + gap and a[1] < b[3] + gap and b[1] < a[3] + gap + + +def _rect(item: dict[str, Any], spot: dict[str, Any]) -> Rect: + return text_rect( + item["label"], + spot["x"], + spot["y"], + item["size"], + spot.get("align", "center"), + spot.get("direction", (1.0, 0.0)), + ) + + +def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[dict[str, Any]]: + """우선순위 차례로 후보 중 첫 빈 자리 — 글마다 고른 자리(`spot`) · 사각(`rect`)을 붙여 냄. + + labels = [{piece, seed, label, size, layer, color, anchor?, candidates: [{x, y, align?, + direction?, far?}]}] — 첫 후보 = 옛 자리. + """ + taken = list(obstacles) + placed = [] + rank = {piece: index for index, piece in enumerate(PRIORITY)} + for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))): + rects = [_rect(item, spot) for spot in item["candidates"]] + # ponytail: 후보가 모두 막히면 첫 후보 그대로(겹침 남음) — 더 필요하면 후보 고리를 늘림 + index = next( + ( + i + for i, rect in enumerate(rects) + if not any(overlaps(rect, t, _GAP_MM) for t in taken) + ), + 0, + ) + taken.append(rects[index]) + placed.append({**item, "spot": item["candidates"][index], "rect": rects[index]}) + return placed + + +def _nearest_on_rect(point: tuple[float, float], rect: Rect) -> tuple[float, float]: + return (min(max(point[0], rect[0]), rect[2]), min(max(point[1], rect[1]), rect[3])) + + +def label_entities(drawing_id: str, placed: list[dict[str, Any]]) -> list[dict[str, Any]]: + """자리 잡은 글 + 멀리 놓인 글(후보 far)의 지시선.""" + out: list[dict[str, Any]] = [] + for item in placed: + spot = item["spot"] + out.append( + _text_entity( + item["seed"], + item["label"], + spot["x"], + spot["y"], + item["layer"], + item["size"], + item["color"], + align=spot.get("align", "center"), + direction=spot.get("direction", (1.0, 0.0)), + ) + ) + anchor = item.get("anchor") + if spot.get("far") and anchor: + leader = polyline_entity( + drawing_id, + [anchor, _nearest_on_rect(anchor, item["rect"])], + item["layer"], + item["color"], + suffix=f":leader:{item['seed']}", + ) + if leader: + out.append(leader) + return out diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py new file mode 100644 index 000000000..279372493 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -0,0 +1,182 @@ +"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). + +`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. +""" + +import math +from bisect import bisect_left +from typing import Any + +TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 + + +# ── 노선 따라가기 ───────────────────────────────────── + + +def route_frame( + route: list[tuple[float, float, float]], chainage: float +) -> tuple[float, float, float, float]: + """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" + chainages = [item[0] for item in route] + index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) + (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] + dx, dy = x1 - x0, y1 - y0 + length = math.hypot(dx, dy) or 1.0 + ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 + return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) + + +def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: + chainages = [record["chainage_m"] for record in records] + index = bisect_left(chainages, chainage) + if index <= 0: + return records[0][key] + if index >= len(records): + return records[-1][key] + before, after = records[index - 1], records[index] + span = after["chainage_m"] - before["chainage_m"] + ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 + return before[key] + (after[key] - before[key]) * ratio + + +def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: + return sorted( + (float(item["offset_m"]), float(item["elevation_m"])) + for item in samples + if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) + ) + + +def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: + offsets = [x for x, _z in ground] + index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) + (x0, z0), (x1, z1) = ground[index - 1], ground[index] + return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) + + +def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: + """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" + ground = _ground(samples) + return len(ground) >= 2 and _ground_at(ground, edge) > elevation + + +def slope_end( + design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str +) -> float: + """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. + + 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. + 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. + """ + ground = _ground(samples) + points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) + outward = ( + [p for p in points if p[0] > edge] + if side == "left" + else [p for p in points[::-1] if p[0] < edge] + ) + if not ground: + return outward[-1][0] if outward else edge + end, differs = edge, False + for offset, elevation in outward: + if abs(elevation - _ground_at(ground, offset)) <= 1e-3: + return offset if differs else end + differs, end = True, offset + return end + + +def _split_runs( + points: list[list[float]], flags: list[bool] +) -> tuple[list[list[list[float]]], list[list[list[float]]]]: + """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" + runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} + current: list[list[float]] = [] + previous: bool | None = None + for point, flag in zip(points, flags): + if flag != previous: + if current: + current.append(point) + current = [point] + runs[flag].append(current) + previous = flag + else: + current.append(point) + return ( + [run for run in runs[True] if len(run) >= 2], + [run for run in runs[False] if len(run) >= 2], + ) + + +def slope_pieces( + route: list[tuple[float, float, float]], + records: list[dict[str, Any]], + start_m: float, + end_m: float, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. + + `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, + left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 + 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. + """ + records = sorted( + ( + { + **record, + **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, + } + for record in records + ), + key=lambda record: record["chainage_m"], + ) + if len(route) < 2 or not records: + return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} + keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") + by_chainage = {round(record["chainage_m"], 3): record for record in records} + samples = { + round(record["chainage_m"], 3) + for record in records + if start_m <= record["chainage_m"] <= end_m + } + step = start_m + while step <= end_m: + samples.add(round(step, 3)) + step += TICK_STEP_M + lines: dict[str, list[list[float]]] = {key: [] for key in keys} + cut: dict[str, list[bool]] = {"left": [], "right": []} + ticks: list[list[list[float]]] = [] + for index, chainage in enumerate(sorted(samples)): + record = by_chainage.get(chainage) + if record and all(k in record for k in ("x", "y", "nx", "ny")): + x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] + else: + x, y, nx, ny = route_frame(route, chainage) + offsets = {key: _interp(records, chainage, key) for key in keys} + points = { + key: [round(x + nx * value, 4), round(y + ny * value, 4)] + for key, value in offsets.items() + } + for key in keys: + lines[key].append(points[key]) + for side in cut: + cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) + # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). + for side in ("left", "right"): + edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] + if abs(end - edge) < 0.05: + continue + reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 + ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) + cut_lines: list[list[list[float]]] = [] + fill_lines: list[list[list[float]]] = [] + for side in cut: + cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) + cut_lines += cut_runs + fill_lines += fill_runs + return ( + {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, + {"lines": fill_lines}, + {"lines": cut_lines}, + {"ticks": ticks}, + ) diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py index 080ea669c..406f49d41 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -12,7 +12,9 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토 (`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). 암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122). -풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None). +풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트 +구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 · +지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2). 짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · `splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). @@ -99,8 +101,8 @@ def summary_rows( r0 = excel_round free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] diverted = t["diverted_m3"] - tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2) m3 = "㎥" + unsure = "비율 미정" # 거창 계수 자리 — 값을 확인한 뒤 다시 잇기(143-2) return [ _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), @@ -121,26 +123,10 @@ def summary_rows( _row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137 _row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137 - _row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"), + _row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8 _row("표토제거", "", "", "㎡", None), - _row( - "초류종자살포", - "", - "씨드스프레이", - "㎡", - r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]), - "성토면50%반영+절토면", - ), + _row("초류종자살포", "", "씨드스프레이", "㎡", None, unsure), # 거창 = 성토 × 0.5 + 절토 _row("법면보호공", "", "", "㎡", None), - # 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8 - _row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"), - _row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"), - _row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"), - _row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None), - _row("구조물터파기", "경암", "발파 + 굴삭기", m3, None), - _row("토사다짐", "", "", m3, None, "구조물 수량"), - _row("되메우기", "", "", m3, None, "구조물 수량"), - _row("잔토", "", "", m3, None, "현장내 성토(유용토)"), + _row("지장목제거", "", "", "㎡", None, unsure), # 거창 = (성토 × 0.5 + 절토) × 0.8 _row("운반성토", "", "BH+DT+BH", m3, None), ] diff --git a/B08_Quantity/B08_Quantity_Engine_Hydro.py b/B08_Quantity/B08_Quantity_Engine_Hydro.py index fd70ed906..adf8ae043 100644 --- a/B08_Quantity/B08_Quantity_Engine_Hydro.py +++ b/B08_Quantity/B08_Quantity_Engine_Hydro.py @@ -102,8 +102,8 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]: props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {} elif sheet == "box": width, height = _num(inputs, "box_width_m"), _num(inputs, "box_height_m") - ratio = _num(inputs, "depth_ratio") or SHEET_DEFAULTS["box"]["depth_ratio"] - if width and height: + ratio = _num(inputs, "depth_ratio") # 빈칸 = 수심 · 단면 · 유출량 멈춤(143-2) + if width and height and ratio: depth = ratio * height props = rect_props(width, depth) else: diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index 311c4e4e0..c3065da53 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -56,10 +56,11 @@ SHEETS = { "ford_pavement": ("ford", "물넘이포장"), } -#: 장별 처음 값 — 박스 수심비 0.8H · n 0.013(울진 산출서 · 조사 69-7) · 내공 · 경사는 빈칸 +#: 장별 처음 값 — 박스 n 0.013(KDS 콘크리트관) · 수심비 · 내공 · 경사는 빈칸(수심비 0.8 = 울진 산출서 — +#: 근거 없어 안 채움 · 빈칸이면 단면 계산 멈춤 · 143-2) #: 세월교 · 물넘이 월류 폭 10 m(사용자 10-7) · n · 경사 = B05 세월교 높이 계산과 같은 값 SHEET_DEFAULTS: dict[str, dict[str, Any]] = { - "box": {"n": 0.013, "depth_ratio": 0.8, "box_width_m": None, "box_height_m": None}, + "box": {"n": 0.013, "depth_ratio": None, "box_width_m": None, "box_height_m": None}, "ford": { "n": FORD_MANNING_N, "overflow_width_m": 10.0, diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 8a97e8c2e..0f2dbe848 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -8,7 +8,8 @@ · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) - › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수 + › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 + 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120) › 날개벽 › 유입 · 유출 개소 @@ -46,6 +47,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_summary_item import GROUP_JOIN from config.config_db import get_db_pool +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs @@ -85,6 +87,23 @@ def _num(value: Any) -> float | None: return None +def _count(options: dict[str, Any], key: str) -> int | None: + """관경 · 련 수 — 빈칸이면 None(「미입력」 열 · 임의 값으로 안 채움 · 143-2).""" + value = _num(options.get(key)) + return int(value) if value else None + + +def _pipe_head(names: dict[str, str], diameter: int | None) -> list[str]: + """「관 공」 머리 — 관경 빈칸이면 「미입력」.""" + if diameter is None: + return ["관 공", fill.EMPTY_TEXT] + return ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + + +def _key(value: int | None) -> str: + return fill.EMPTY_LABEL if value is None else str(value) + + def _combo(item: Any) -> str | None: """집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None.""" if not isinstance(item, dict) or not item.get("key"): @@ -93,13 +112,10 @@ def _combo(item: Any) -> str | None: def one_pipe_length(project_root: str | Path) -> float | None: - """1본 길이(m) — 프로젝트 층 구조물집계표 변수 · 없으면 시스템 층 · 빈칸이면 None.""" - for folder in (layers.project_dir(project_root), layers.system_dir()): - found = layers.read_template(folder, "table", TABLE_NAME) - if found is not None: - value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) - return value if value and value > 0 else None - return None + """1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None.""" + found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME) + value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) if found else None + return value if value and value > 0 else None def couplings(length_m: float | None, one_m: float | None) -> int | None: @@ -159,6 +175,8 @@ class _Sheet: def __init__(self) -> None: self.columns: dict[str, dict[str, Any]] = {} self.rows: dict[int, dict[str, Any]] = {} + self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1) + self.unlinked: set[str] = set() def add( self, @@ -170,6 +188,9 @@ class _Sheet: value: float | None, combos: list[str | None], ) -> None: + if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1) + self.unlinked.add(self.owner) + return cid = f"pipe:{key}" column = self.columns.setdefault( cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort} @@ -343,10 +364,11 @@ def _ford( combos: dict[str, str | None], names: dict[str, str], ) -> None: - """세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소.""" - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - count = int(_num(base["options"].get("pipe_count")) or 1) - spec = f"Φ{names.get(f'ford.diameter={diameter}', diameter)}×{count}련" + """세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소(관경 · 련 빈칸 = 「미입력」).""" + diameter = _count(base["options"], "pipe_diameter_mm") + count = _count(base["options"], "pipe_count") + size = fill.EMPTY_TEXT if diameter is None else names.get(f"ford.diameter={diameter}", diameter) + spec = f"Φ{size}×{fill.EMPTY_TEXT if count is None else count}련" width_name = names.get("ford.width", "월류 폭").split(" (")[0] cells = ( ("width", width_name, "m", _num(base["options"].get("ford_width_m"))), @@ -355,8 +377,8 @@ def _ford( for index, (key, label, unit, value) in enumerate(cells): sheet.add( at, - f"ford|{diameter}|{count}|{key}", - (4, 0, diameter, count, index), + f"ford|{_key(diameter)}|{_key(count)}|{key}", + (4, 0, diameter or math.inf, count or math.inf, index), [names["종류"], spec, label], unit, value, @@ -372,35 +394,39 @@ def _ford_pipes( one_m: float | None, names: dict[str, str], pipes: list[dict[str, Any]], + body: str | None, ) -> None: """세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131). - `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈). + `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) · + 조합 = 시설 본체 + 본체 잇기(`body` · 143-1). """ - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - count = int(_num(base["options"].get("pipe_count")) or 1) - head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] - combos = [_combo(base.get("summary_item"))] + diameter = _count(base["options"], "pipe_diameter_mm") + count = _count(base["options"], "pipe_count") + head = _pipe_head(names, diameter) + combos = [_combo(base.get("summary_item")), body] each = couplings(length, one_m) sheet.add( at, - f"len|{diameter}", - (2, diameter, 0), + f"len|{_key(diameter)}", + (2, diameter or math.inf, 0), [*head, "관매설"], "m", - length * count if length else None, + length * count if length and count else None, combos, ) sheet.add( at, - f"cp|{diameter}", - (2, diameter, 1), + f"cp|{_key(diameter)}", + (2, diameter or math.inf, 1), [*head, "커플링"], "ea", - None if each is None else each * count, + None if each is None or count is None else each * count, combos, ) - pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count + if any(combos): + column = f"pipe:cp|{_key(diameter)}" + pipes += [{"chainage_m": at, "column": column, "length_m": length}] * (count or 0) def _pavement( @@ -473,12 +499,15 @@ def pipe_summary( names: dict[str, str], one_m: float | None, kinds: dict[str, Any] | None = None, + linked: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함). - `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음). + `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) · + `linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1). """ kinds = kinds or definition.read() + linked = snapshots.linked_snapshots(project_root) if linked is None else linked items = fill.collect_items( project_root, fill.pipe_lengths_from_designs(designs), @@ -494,15 +523,19 @@ def pipe_summary( box_lengths = _station_values(designs, "box_length_m") ford_lengths = _station_values(designs, "ford_pipe_length_m") by_owner: dict[tuple[str, str], dict[str, Any]] = {} + others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림) for item in items: - if item["type_id"] not in DRAIN_TYPES: - continue owner, _, link = item["복사본"].partition(GROUP_JOIN) + if item["type_id"] not in DRAIN_TYPES: + others[owner] = others.get(owner, False) or item["복사본"] in linked + continue entry = by_owner.setdefault((item["type_id"], owner), {"links": {}}) + # 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1) + combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None if link: - entry["links"][link] = _combo(item.get("summary_item")) + entry["links"][link] = combo else: - entry["base"] = item + entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None} sheet = _Sheet() pipes: list[dict[str, Any]] = [] @@ -512,28 +545,36 @@ def pipe_summary( if base is None: continue links = entry["links"] + sheet.owner = owner point = raw.get((type_id, owner)) options = dict(point.options or {}) if point else {} at = float(base["chainage_m"]) if type_id != "pipe": box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None - no_box += type_id == "box_culvert" and not box_length - group = (point.group_items if point else None) or {} + group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만 + gid: chosen + for gid, chosen in ((point.group_items if point else None) or {}).items() + if f"{owner}{GROUP_JOIN}{gid}" in linked + } _drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length) + body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"] + body = links.get(body_link) or _combo(group.get(body_link)) + counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음) + no_box += type_id == "box_culvert" and counted and not box_length if type_id == "ford_bridge": ford_length = fill._nearest(ford_lengths, at) - no_ford += not ford_length - _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes) + no_ford += counted and not ford_length + _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body) continue - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + diameter = _count(base["options"], "pipe_diameter_mm") + pipe_head = _pipe_head(names, diameter) pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])] length = base.get("관연장") - no_length += not length + no_length += any(pipe_combos) and not length sheet.add( at, - f"len|{diameter}", - (2, diameter, 0), + f"len|{_key(diameter)}", + (2, diameter or math.inf, 0), [*pipe_head, "관매설"], "m", length, @@ -541,21 +582,23 @@ def pipe_summary( ) sheet.add( at, - f"cp|{diameter}", - (2, diameter, 1), + f"cp|{_key(diameter)}", + (2, diameter or math.inf, 1), [*pipe_head, "커플링"], "ea", couplings(length, one_m), pipe_combos, ) - pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) + if any(pipe_combos): + column = f"pipe:cp|{_key(diameter)}" + pipes.append({"chainage_m": at, "column": column, "length_m": length}) shape = fill._nearest(basins, at) - no_basin += shape is None + no_basin += bool(links.get(LINKS["inlet"])) and shape is None if shape: sheet.add( at, - f"basin|{diameter}|{shape}", - (2, diameter, 2 + _order(SHAPES, shape), shape), + f"basin|{_key(diameter)}|{shape}", + (2, diameter or math.inf, 2 + _order(SHAPES, shape), shape), [*pipe_head, "관보호공"], names.get(f"inlet.form={shape}", shape), 1.0, @@ -564,11 +607,18 @@ def pipe_summary( sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL] sources = [(side, links.get(LINKS[side])) for side in sides] - _revets(sheet, at, options, links, sources, pipe_wall_min_height_m(diameter / 1000), names) + # 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름) + wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None + _revets(sheet, at, options, links, sources, wall, names) _chutes(sheet, at, options, links, names) out = sheet.result() notes = [] + unlinked = len(sheet.unlinked) + sum(not found for found in others.values()) + if unlinked: + notes.append( + f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" + ) if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: @@ -585,6 +635,7 @@ def pipe_summary( "one_length_m": one_m, "types": list(DRAIN_TYPES), "pipes": pipes, + "linked": linked, } diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index e228d23be..028295f9a 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -4,12 +4,14 @@ * * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 + * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. * ========================================================================== */ import { + GROUP_JOIN, readGroupItems, readSummaryItem, type SummaryItem, @@ -25,6 +27,35 @@ export interface PlacedItem { group_items?: unknown; } +/** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */ +export interface LinkedSnapshot { + 항목: SummaryItem; + 폴더: string; + /** 있는 부분(도면 · 면적 · 일위대가) */ + 부분: string[]; + 경로: string[]; +} + +/** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */ +export function linkedOnly( + things: readonly T[], + linked: Readonly>, +): T[] { + const out: T[] = []; + for (const thing of things) { + const id = thing.structure_id ?? ""; + const own = id in linked ? readSummaryItem(thing.summary_item) : null; + const groups = Object.fromEntries( + Object.entries(readGroupItems(thing.group_items)).filter( + ([gid]) => `${id}${GROUP_JOIN}${gid}` in linked, + ), + ); + if (own || Object.keys(groups).length) + out.push({ ...thing, summary_item: own, group_items: groups }); + } + return out; +} + /** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */ export interface ComboLabel { group: string; @@ -84,11 +115,12 @@ export function summaryColumns( ); } -/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */ +/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */ export interface StationCombo { id: string; item: SummaryItem; structureId: string | null; + copyId: string | null; } /** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */ @@ -99,16 +131,17 @@ export function combosAt(things: readonly PlacedItem[], station: number): Statio const at = stationOf(thing); if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue; const own = readSummaryItem(thing.summary_item); - for (const item of [ - ...(own ? [own] : []), - ...Object.values(readGroupItems(thing.group_items)), + const structureId = thing.structure_id ?? null; + for (const [gid, item] of [ + ...(own ? [["", own] as const] : []), + ...Object.entries(readGroupItems(thing.group_items)), ]) { - const structureId = thing.structure_id ?? null; const key = `${structureId} ${comboId(item)}`; + const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId); if (!seen.has(key)) { seen.add(key); - found.push({ id: comboId(item), item, structureId }); + found.push({ id: comboId(item), item, structureId, copyId }); } } } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index b3356214a..60dc1a94e 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -4,7 +4,7 @@ * 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD · * 여러 장이면 장 탭) + 면적산출. * - * 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만 + * 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만 * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ @@ -12,19 +12,11 @@ import "./B08_Quantity_UI_Drawing.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; -import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data"; -import { - PART_OF, - projectItems, - sameItem, - structureFolder, - type ProjectItemInfo, -} from "@ui/ui_template_item_picker_project"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { PART_OF, projectItems } from "@ui/ui_template_item_picker_project"; import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; -import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data"; +import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -42,48 +34,21 @@ export interface SheetsRequest { /** 모달 제목에 붙는 측점 글 */ station: string; combos: StationCombo[]; - /** `comboId` → M02 줄 */ - rows: Map; + /** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */ + linked: Record; describe: (item: SummaryItem) => ComboLabel | null; } -/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */ -const loadCopies = (projectId: string): Promise => - projectItems(projectId) - .list() - .catch(() => []); - -function sourceOf( - request: SheetsRequest, - combo: StationCombo, - row: PickerRow, - copies: ProjectItemInfo[], -): { source: ItemSource; copy: boolean } { - const own = combo.structureId - ? copies.find((info) => info.폴더 === structureFolder(combo.structureId!)) - : undefined; - if (own && sameItem(own, row)) { - const items = projectItems(request.projectId); - return { - copy: true, - source: { - layer: "project", - projectId: request.projectId, - read: async (part) => { - const doc = await items.read(own.폴더, PART_OF[part]); - if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); - return { 문서: doc }; - }, - }, - }; - } - const layer = layerOf(row); +/** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */ +function sourceOf(projectId: string, folder: string): ItemSource { + const items = projectItems(projectId); return { - copy: false, - source: { - layer, - projectId: null, - read: (part, column) => readTemplate(layer, part, column, null), + layer: "project", + projectId, + read: async (part) => { + const doc = await items.read(folder, PART_OF[part]); + if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); + return { 문서: doc }; }, }; } @@ -92,7 +57,6 @@ function sourceOf( function comboSection( request: SheetsRequest, combo: StationCombo, - copies: ProjectItemInfo[], createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards, handles: ItemCardsHandle[], ): HTMLElement { @@ -100,7 +64,7 @@ function comboSection( const name = label ? [label.group, label.name].filter(Boolean).join(" ") : combo.item.name || combo.item.key; - const row = request.rows.get(combo.id) ?? null; + const copy = combo.copyId ? request.linked[combo.copyId] : undefined; const section = document.createElement("section"); section.className = "b08-sheets__combo"; section.dataset.b08Combo = combo.id; @@ -109,27 +73,25 @@ function comboSection( title.textContent = name; title.title = summaryTooltip(combo.item); section.append(title); - if (!row?.열) { + if (!copy) { section.append(note(L("B08_Quantity_Drawing_Missing"))); return section; } - const picked = sourceOf(request, combo, row, copies); + const source = sourceOf(request.projectId, copy.폴더); const from = document.createElement("span"); from.className = "b08-sheets__from"; - from.textContent = L( - picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster", - ); + from.textContent = L("B08_Quantity_Sheets_FromCopy"); title.append(from); - if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); + if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); const cards = createItemCards({ - source: () => picked.source, + source: () => source, editable: () => false, onEdit: () => undefined, warnOnFail: () => false, // 못 읽으면 카드에 「없음」 }); handles.push(cards); section.append(cards.root); - void cards.load({ id: row.열, label: name }); + void cards.load({ id: copy.폴더, label: name }); return section; } @@ -151,14 +113,12 @@ export function openStructureSheets(request: SheetsRequest): void { body.append(note(L("B08_Quantity_Drawing_Loading"))); void (async () => { try { - const [{ createItemCards }, copies] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - loadCopies(request.projectId), - ]); + const { createItemCards } = + await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"); if (closed) return; body.replaceChildren( ...request.combos.map((combo) => - comboSection(request, combo, copies, createItemCards, handles), + comboSection(request, combo, createItemCards, handles), ), ); } catch (error) { diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 5f09a2827..ba97716f5 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", + "연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index bc0e84a06..e47659aee 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -430,7 +430,11 @@ export function buildHydroSheet( dims.append( rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")), rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")), - rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")), + rowF( + "수심비 h / H", + hint("빈칸이면 계산 멈춤 — 값 넣기"), + numberInput("depth_ratio", "입력"), + ), rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")), rowF("단면적", formula("A = B × h"), outCell("section_area")), rowF("윤변", formula("P = B + 2h"), outCell("perimeter")), diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 7d032c8a9..c0746c266 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -7,11 +7,13 @@ * 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로 * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 + * 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, + createCard, hideLoadingOverlay, showLoadingOverlay, showToast, @@ -49,6 +51,12 @@ const HINTS: Record = { concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", }; +const HIDDEN = "값 없는 줄은 숨김"; + +/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ +const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => + rows.flatMap((row, i) => (has(row) ? [i] : [])); +const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -99,7 +107,9 @@ interface Table { function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { const { materials, rows, total } = s.quantity; - const rowsOut: DataRow[] = rows.flatMap((row) => { + const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); + const rowsOut: DataRow[] = keep.flatMap((at) => { + const row = rows[at]; const unit: DataRow = { name: row.name, spec: row.spec, @@ -137,7 +147,7 @@ function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { total: totalRow, frozen: 5, editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), - target: (i, key) => [o.units, rows[i / 2].id, key.slice(2)], + target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], }; } @@ -149,9 +159,11 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; - const rowsOut: DataRow[] = rows.map((row, i) => { + const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts)); + const rowsOut: DataRow[] = keep.map((at, i) => { + const row = rows[at]; const out: DataRow = { - name: i > 0 && rows[i - 1].name === row.name ? "" : row.name, + name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name, spec: row.spec, qty: row.qty, }; @@ -187,12 +199,13 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { total: totalRow, frozen: 2, editable: (_i, key) => key.startsWith("per:"), - target: (i, key) => [o.mix, rows[i].key, key.slice(4)], + target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], }; } function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows } = s.material; + const keep = shown(rows, (row) => row.qty != null || row.amount != null); return { columns: [ { key: "group", label: "구분", width: 96 }, @@ -203,19 +216,22 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { numberColumn({ key: "amount", label: "합계", width: 96 }), { key: "note", label: "비고", width: 96 }, ], - rows: rows.map((row, i) => ({ - group: i > 0 && rows[i - 1].group === row.group ? "" : row.group, - spec: row.spec, - unit: row.unit, - qty: row.qty, - surcharge: row.surcharge, - amount: row.amount, - note: row.panel ? "할증 = 좌측 패널" : "", - })), + rows: keep.map((at, i) => { + const row = rows[at]; + return { + group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group, + spec: row.spec, + unit: row.unit, + qty: row.qty, + surcharge: row.surcharge, + amount: row.amount, + note: row.panel ? "할증 = 좌측 패널" : "", + }; + }), total: null, frozen: 2, - editable: (i, key) => key === "surcharge" && !rows[i].panel, - target: (i) => [o.surcharge, rows[i].key, null], + editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel, + target: (i) => [o.surcharge, rows[keep[i]].key, null], }; } @@ -254,7 +270,7 @@ export async function renderMaterialSheet( const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); - notes.append(note(BASIS), note(HINTS[sheet])); + notes.append(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); @@ -300,7 +316,7 @@ export async function renderMaterialSheet( built = BUILD[sheet](sheets, overrides); table.setRows(built.rows, built.total); }; - host.replaceChildren(toolbar, table.root); + host.replaceChildren(createCard({ body: [toolbar, table.root] })); onOptionsChange(host, () => { sheets = calc(); redraw(); diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index 2016d91ce..599fe4035 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -142,8 +142,9 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void rockRow.className = "b08-options__row"; rockRow.append(soft.root, hard.root); + // 1본 = 프로젝트 값만(읽기 전 빈칸 · 143-2) let saved: { one: number | null; soft: number; surcharge: Cells } = { - one: 8, + one: null, soft: 90, surcharge: PANEL_SURCHARGE, }; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4a4e13ff8..ddb310f21 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -4,7 +4,8 @@ * 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정. * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) - * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`) + * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음 * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -15,9 +16,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; -import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; -import { showToast } from "@ui/ui_template_elements"; +import { createCard, showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -28,10 +27,12 @@ import { summaryTooltip, type SummaryItem } from "../common_util/common_util_sum import { combosAt, comboId, + linkedOnly, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, + type LinkedSnapshot, type PlacedItem, type StationCombo, widthOf, @@ -72,6 +73,8 @@ interface PipeSummary { types: string[]; one_length_m: number | null; pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; } async function fetchPipeSummary(projectId: string): Promise { @@ -88,7 +91,7 @@ const stationKey = (station: number): number => Math.round(station * 100); const placesOf = (values: number[]): number => Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { const top = summary.columns @@ -168,12 +171,12 @@ async function openSheets( projectId: string, station: string, combos: StationCombo[], - rows: Map, + linked: Record, describe: (item: SummaryItem) => ComboLabel | null, ): Promise { try { const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); - openStructureSheets({ projectId, station, combos, rows, describe }); + openStructureSheets({ projectId, station, combos, linked, describe }); } catch (error) { showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); } @@ -183,9 +186,8 @@ async function openSheets( export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([ + const [structures, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), - loadPickerRows(), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 fetchDetailPipePoints(projectId).then( (response) => response.pipe_points, @@ -195,28 +197,28 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); - const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); const describe = (item: SummaryItem): ComboLabel | null => { - const row = m02.get(comboId(item)); - if (!row) return null; - const [group = "", ...rest] = pathOf(row); - return { group, name: rest.join(" ") || group || row.key }; + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; }; - const comboPath = (id: string): string | null => { - const row = m02.get(id); - return row ? pathOf(row).join(" ") : null; - }; - const placed = pipes.map((point) => ({ - ...point, - structure_id: pipeStructureId(point.chainage_m), - })); - const things: PlacedItem[] = [...structures.structures, ...placed]; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + const placed = linkedOnly( + pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; const stations = summaryStations(things); // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const saved = typeof pipe === "string" ? null : pipe; const served = new Set(saved?.types ?? []); const others = [ - ...structures.structures.filter((item) => !served.has(item.type_id)), + ...mine.filter((item) => !served.has(item.type_id)), ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; const columns = summaryColumns(others, describe); @@ -231,12 +233,12 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const draw = (): HTMLElement => stations.length ? buildTable(stations, columns, live(), comboPath, interval, (station, label) => - openSheets(projectId, label, combosAt(things, station), m02, describe), + openSheets(projectId, label, combosAt(things, station), linked, describe), ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); parts.push(table); - host.replaceChildren(...parts); + host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) onOptionsChange(host, () => { const next = draw(); table.replaceWith(next); diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 62773e8c5..8fff486b4 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -410,6 +410,34 @@ def complete_old(folder: Path) -> bool: return True +#: 「연결된 사본」 부분 이름 → 파일(있는 것만 `부분` 에 담음) +LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위대가", LABOR_FILE)) + + +def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]: + """연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1). + + 구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) · + 미지정 · 사본 없음 · 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) · + 원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) · + `경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용. + """ + base = structures_dir(project_root) + found: dict[str, dict[str, Any]] = {} + for owner, item in assignments(project_root).items(): + folder = base / folder_name(owner) + manifest = read_json(folder / MANIFEST_NAME) + if not same_item(manifest, item): + continue + found[owner] = { + "항목": item, + "폴더": folder.name, + "부분": [part for part, name in LINKED_PARTS if (folder / name).is_file()], + "경로": list(manifest["원본"].get("경로") or []), + } + return found + + def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None: """그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음).""" if items_dir(project_root).is_dir(): diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index fe920b3ac..2979af220 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -8,6 +8,8 @@ 400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4 500 물넘이 포장 월류 폭 8 · H=1.0 600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합) +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 견본은 칸을 내는 잇기를 모두 두고 연결된 사본 = +지정 전부 · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -22,6 +24,7 @@ import pytest from B06_Section.B06_Section_Schema import CrossSectionPatch from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -36,7 +39,7 @@ def _item(key: str) -> dict: POINTS = [ - {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000100")}, { "chainage_m": 300.0, "facility": "box_culvert", @@ -50,7 +53,11 @@ POINTS = [ "outlet_revet_after_m": 5, }, "summary_item": _item("SS000101"), - "group_items": {"gboxbody": _item("SS000102")}, + "group_items": { + "gboxbody": _item("SS000102"), + "gboxin": _item("SS000106"), + "gboxout": _item("SS000107"), + }, }, { "chainage_m": 340.0, @@ -61,6 +68,8 @@ POINTS = [ "wing_in": "없음", "wing_out": "날개벽", }, + "summary_item": _item("SS000101"), + "group_items": {"gboxout": _item("SS000107")}, }, { "chainage_m": 400.0, @@ -77,7 +86,11 @@ POINTS = [ "outlet_revet_before_m": 3, "outlet_revet_after_m": 4, }, - "group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")}, + "group_items": { + "gford": _item("SS000103"), + "gfordin": _item("SS000104"), + "gfordout": _item("SS000108"), + }, }, { "chainage_m": 500.0, @@ -90,15 +103,24 @@ POINTS = [ "ford_chute_1_bond": "찰붙임", "ford_chute_1_area": 12, }, + "summary_item": _item("SS000109"), + "group_items": {"gfordpavechute1": _item("SS000112")}, }, ] STRUCTURES = [ - {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600}, + { + "structure_id": "s-ditch", + "type_id": "open_ditch", + "placement": "point", + "chainage_m": 600, + "summary_item": _item("SS000110"), + }, { "structure_id": "s-exposed", "type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 600, + "summary_item": _item("SS000111"), }, { "structure_id": "s-ud", @@ -130,7 +152,8 @@ def out(tmp_path: Path) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 7b38b50f6..ddb457020 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -40,9 +40,9 @@ def root(tmp_path, monkeypatch) -> Path: return project -def test_기본값은_8_m_와_90_10_파일에_안_씀(root): +def test_1본_프로젝트_값_없으면_빈칸_90_10_파일에_안_씀(root): assert options.read_options(root) == { - "one_pipe_length_m": 8, + "one_pipe_length_m": None, # 143-2 — 시스템 층 8 m 로 안 채움 "rock_ratios_pct": {"연암": 90.0, "경암": 10.0}, "material_surcharge_pct": {"sand": 12, "gravel": 5, "cement": 3}, # 124 } @@ -51,7 +51,7 @@ def test_기본값은_8_m_와_90_10_파일에_안_씀(root): def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): - assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 + assert summary.couplings(22, summary.one_pipe_length(root)) is None # 프로젝트 값 없음 out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6)) assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root) assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2 @@ -63,6 +63,7 @@ def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): # 다시 저장 = 같은 사본 판 위에 씀 options.save_options(root, options.OptionsSave(one_pipe_length_m=8)) assert summary.one_pipe_length(root) == 8 + assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): @@ -79,7 +80,7 @@ def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): assert stored["rock_methods"] == {"연암": "ripping"} assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0 # 1본 길이는 안 보냈으니 그대로 - assert options.read_options(root)["one_pipe_length_m"] == 8 + assert options.read_options(root)["one_pipe_length_m"] is None def test_비율_읽기_자름_경암만_있을_때(): diff --git a/resources/tester/test_131_b08_material_fix.py b/resources/tester/test_131_b08_material_fix.py index fa1fb00d1..5f96af366 100644 --- a/resources/tester/test_131_b08_material_fix.py +++ b/resources/tester/test_131_b08_material_fix.py @@ -20,6 +20,7 @@ from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts from B08_Quantity import B08_Quantity_Router_Material as router_mod from B08_Quantity import B08_Quantity_Router_Summary as summary_mod from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -107,7 +108,8 @@ def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary_mod.pipe_summary(root, designs, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음 + return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_141_b07_plan_labels.py b/resources/tester/test_141_b07_plan_labels.py new file mode 100644 index 000000000..47c52df3b --- /dev/null +++ b/resources/tester/test_141_b07_plan_labels.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""141-1 B07 평면도 글자 겹침 피하기. + +못박는 것 + - 우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소 — 앞 글이 첫 후보(옛 자리)를 가짐. + - 후보 중 첫 빈 자리 · 모두 막히면 첫 후보 그대로(글은 남음). + - 장애물(IP 표 · 필지 상자) 비켜 감. + - 가까운 IP · R · 배수 둘 · NO.0 + 시점 한 장 → 글 사각 겹침 0 · 글 모두 남음. + - 멀리 놓인 글(후보 far)엔 지시선. +""" + +from itertools import combinations + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import ( + overlaps, + place_labels, + text_rect, +) + + +def _label(piece: str, label: str, spots: list[dict], anchor=None) -> dict: + return { + "piece": piece, + "seed": f"s:{piece}:{label}", + "label": label, + "size": 2.5, + "layer": "l", + "color": "#fff", + "anchor": anchor, + "candidates": spots, + } + + +def test_priority_keeps_first_spot_and_next_moves() -> None: + spot = [{"x": 0.0, "y": 0.0}, {"x": 0.0, "y": 10.0}] + placed = place_labels([_label("refuge", "대피소", spot), _label("endpoints", "시점", spot)], []) + by = {item["piece"]: item["spot"] for item in placed} + assert by["endpoints"] == spot[0] # 시종점이 먼저 — 옛 자리 + assert by["refuge"] == spot[1] + + +def test_all_blocked_falls_back_to_first() -> None: + spot = [{"x": 0.0, "y": 0.0}] + placed = place_labels([_label("ip", "IP1", spot), _label("radius", "R=12", spot)], []) + assert [item["spot"] for item in placed] == [spot[0], spot[0]] # 글은 남음 + + +def test_obstacle_is_avoided() -> None: + spots = [{"x": 0.0, "y": 0.0}, {"x": 30.0, "y": 0.0}] + placed = place_labels([_label("ip", "IP1", spots)], [(-5.0, -5.0, 5.0, 5.0)]) + assert placed[0]["spot"] == spots[1] + + +def _texts(drawing: dict) -> list[dict]: + return [ + e + for e in drawing["entities"] + if e["type"] == "Text" + and e["layerId"].startswith("b07-plan-") + and e["layerId"] != "b07-plan-title" + ] + + +def _rect(entity: dict) -> tuple: + shape = entity["shapeData"] + opts = shape["options"] + direction = (opts["textDirection"]["x"], opts["textDirection"]["y"]) + return text_rect( + shape["label"], + shape["basePoint"]["x"], + shape["basePoint"]["y"], + opts["fontSize"], + opts["textAlign"], + direction, + ) + + +def test_crowded_sheet_has_no_overlapping_labels() -> None: + box = (0.0, 0.0, 300.0, 200.0) + pieces = { + "ip": { + "items": [ + { + "name": "IP1", + "apex": [100.0, 100.0], + "start": [90.0, 100.0], + "end": [100.0, 110.0], + }, + { + "name": "IP2", + "apex": [103.0, 101.0], + "start": [95.0, 101.0], + "end": [103.0, 111.0], + }, + ] + }, + "radius": { + "items": [ + {"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]}, + {"label": "R=15", "middle": [103.0, 99.0], "dir": [0.0, -1.0]}, + ] + }, + "drainage": { + "items": [ + {"x": 101.0, "y": 96.0, "label": "D800 L=8.0"}, + {"x": 102.0, "y": 95.0, "label": "D600 L=6.0"}, + ] + }, + "station": { + "items": [ + {"x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0, "label": "NO.0", "major": True}, + ] + }, + "endpoints": { + "items": [{"label": "시점 NO.0", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}] + }, + } + drawing = build_plan_kind_drawing("plan_x", "시험", box, pieces) + texts = _texts(drawing) + labels = sorted(e["shapeData"]["label"] for e in texts) + assert labels == sorted( + ["IP1", "IP2", "R=12", "R=15", "D800 L=8.0", "D600 L=6.0", "NO.0", "시점 NO.0"] + ) + pairs = [ + (a["shapeData"]["label"], b["shapeData"]["label"]) + for a, b in combinations(texts, 2) + if overlaps(_rect(a), _rect(b)) + ] + assert pairs == [] + + +def test_far_spot_draws_leader() -> None: + near = [{"x": 0.0, "y": 0.0}, {"x": 40.0, "y": 0.0, "far": True}] + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities + + placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)]) + entities = label_entities("plan_x", placed) + assert [e["type"] for e in entities] == ["Text", "PolyLine"] diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py new file mode 100644 index 000000000..6fb1cda99 --- /dev/null +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 — 조합 사본 없으면 없음(PLAN 143-1). + +견본(관 지점 넷 + 세월교 둘 + 개거 하나): + 100 관 Φ800 L16 · 사본 있음 · 유출 흙막이 잇기 사본 없음 → 관 칸만 · 유출 흙막이 칸 없음 + 200 관 Φ800 L18 · 지정 있으나 사본 폴더가 다른 항목 → 없음 + 300 관 Φ800 L20 · 지정 있으나 사본 폴더 없음 → 없음 + 400 관 Φ800 L22 · 지정 없음 → 없음 + 500 세월교 Φ1000×2련 · 본체 잇기(gford) 사본 있음 → 관 몫 · 월류 폭 셈 + 600 세월교 · 잇기 없음 → 관 몫 · pipes 에도 없음 + 700 개거 · 지정 없음 → 「사본 없는 시설」 에 셈 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ROOT = Path(__file__).resolve().parents[2] + + +def _item(key: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": key} + + +def _soil() -> dict: + return { + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + } + + +POINTS = [ + { + "chainage_m": 100.0, + "options": {"pipe_diameter_mm": 800, **_soil()}, + "summary_item": _item("SS000001"), + "group_items": {"g9lq9uk": _item("SS000002")}, + }, + {"chainage_m": 200.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 400.0, "options": {"pipe_diameter_mm": 800}}, + { + "chainage_m": 500.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 2, "ford_width_m": 12}, + "group_items": {"gford": _item("SS000003")}, + }, + { + "chainage_m": 600.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 1, "ford_width_m": 8}, + }, +] +STRUCTURES = [ + {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 700}, +] +DESIGNS = [ + {"chainage_m": 100.0, "design": {"pipe_length_m": 16}}, + {"chainage_m": 200.0, "design": {"pipe_length_m": 18}}, + {"chainage_m": 300.0, "design": {"pipe_length_m": 20}}, + {"chainage_m": 400.0, "design": {"pipe_length_m": 22}}, + {"chainage_m": 500.0, "design": {"ford_pipe_length_m": 9}}, + {"chainage_m": 600.0, "design": {"ford_pipe_length_m": 7}}, +] + + +def _snapshot(root: Path, owner: str, item: dict, parts: tuple[str, ...] = ()) -> None: + folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) + folder.mkdir(parents=True) + origin = {**item, "경로": ["횡단배수", item["key"], "이름"]} + (folder / snapshots.MANIFEST_NAME).write_text( + json.dumps({"원본": origin}, ensure_ascii=False), encoding="utf-8" + ) + for name in parts: + (folder / name).write_text("{}", encoding="utf-8") + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + found = tmp_path / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + route = found / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + _snapshot(found, "pipe@100.0", _item("SS000001"), ("도면.json", "일위대가.json")) + _snapshot(found, "pipe@200.0", _item("SS000009")) # 다른 항목 사본 + _snapshot(found, "pipe@500.0__gford", _item("SS000003")) + return found + + +def _run(root: Path) -> dict: + names = definition.read()["종류"]["pipe"]["이름"] + return summary.pipe_summary(root, DESIGNS, names, 6.0) + + +def _cell(out: dict, at: float, cid: str): + row = next((r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01), None) + return None if row is None else row["values"].get(cid) + + +def test_연결된_사본은_지정_교집합_같은_항목(root): + linked = snapshots.linked_snapshots(root) + assert set(linked) == {"pipe@100.0", "pipe@500.0__gford"} + one = linked["pipe@100.0"] + assert one["항목"]["key"] == "SS000001" and one["폴더"] == "pipe@100.0" + assert one["부분"] == ["도면", "일위대가"] # 있는 파일만 + assert one["경로"] == ["횡단배수", "SS000001", "이름"] + + +def test_사본_없는_칸은_안_셈(root): + out = _run(root) + assert _cell(out, 100, "pipe:len|800") == 16 + assert _cell(out, 100, "pipe:cp|800") == 2 + # 유출 흙막이 잇기는 사본 없음 → 그 칸만 없음(열도 안 섬) + assert not any(c["id"].startswith("pipe:revet") for c in out["columns"]) + # 다른 항목 사본 · 사본 없음 · 지정 없음 → 줄 없음 + assert [round(r["chainage_m"]) for r in out["rows"]] == [100, 500] + assert out["total"]["pipe:len|800"] == 16 + + +def test_세월교_관_몫은_본체_잇기_사본으로(root): + out = _run(root) + assert _cell(out, 500, "pipe:len|1000") == 18 # 9 × 2련 + assert _cell(out, 500, "pipe:ford|1000|2|width") == 12 + assert _cell(out, 600, "pipe:len|1000") is None + assert [(round(p["chainage_m"]), p["column"]) for p in out["pipes"]] == [ + (100, "pipe:cp|800"), + (500, "pipe:cp|1000"), + (500, "pipe:cp|1000"), + ] + width = next(c for c in out["columns"] if c["id"] == "pipe:ford|1000|2|width") + assert width["combos"] == ["system||SS000003"] + + +def test_사본_없는_시설_알림_응답에_사본(root): + out = _run(root) + # 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳 + assert not any("wing" in c["id"] for c in out["columns"]) + assert "조합 사본 없는 시설 7곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" in out["notes"] + assert not any("관 연장 없음" in n for n in out["notes"]) + assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} + + +def test_화면_조합_이름_구조물도는_사본에서(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 + assert "linkedOnly(" in ui + drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") + assert "readTemplate" not in drawing # 층 폴백 없음 + assert "B08_Quantity_Sheets_NoCopy" in drawing + + +def test_화면_사본_있는_조합만_사본_id(): + """`linkedOnly` — 본체 · 그룹마다 사본 있는 것만 · 하나도 없으면 시설째 뺌 · `combosAt` 사본 id.""" + import re + import subprocess + + imports = re.compile(r"^import[^;]*;\n", re.MULTILINE) + sources = [ + ROOT / "common_util" / "common_util_summary_item.ts", + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts", + ] + things = [ + {"structure_id": "s1", "chainage_m": 40, "summary_item": _item("SS1"), + "group_items": {"g1": _item("SS3"), "g2": _item("SS4")}}, + {"structure_id": "s2", "chainage_m": 80, "summary_item": _item("SS2")}, + ] # fmt: skip + linked = {"s1__g1": {}, "s1": {}} + body = ( + f"\nconst kept = linkedOnly({json.dumps(things)}, {json.dumps(linked)});" + "\nconsole.log(JSON.stringify([kept.map((t) => [t.structure_id, Object.keys(t.group_items)])," + " combosAt(kept, 40).map((c) => c.copyId)]));" + ) + script = "\n".join(imports.sub("", p.read_text(encoding="utf-8")) for p in sources) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [[["s1", ["g1"]]], ["s1", "s1__g1"]] diff --git a/resources/tester/test_143_2_b08_blank_values.py b/resources/tester/test_143_2_b08_blank_values.py new file mode 100644 index 000000000..31fcd2f31 --- /dev/null +++ b/resources/tester/test_143_2_b08_blank_values.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 — 관경 · 련 빈칸 = 「미입력」(임의 1000 · 1 로 안 채움 · PLAN 143-2). + +견본: 100 관 관경 빈칸 L16 · 유출 흙막이 높이 빈칸 / 200 세월교 Φ1000 련 빈칸 · 관 길이 9. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ITEM = {"layer": "system", "owner": None, "key": "SS000001", "name": "견본"} +POINTS = [ + { + "chainage_m": 100.0, + "options": { + "pipe_diameter_mm": "", + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + }, + "summary_item": ITEM, + "group_items": {"g9lq9uk": ITEM}, + }, + { + "chainage_m": 200.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": "", "ford_width_m": 12}, + "summary_item": ITEM, + }, +] +DESIGNS = [ + {"chainage_m": 100.0, "design": {"pipe_length_m": 16}}, + {"chainage_m": 200.0, "design": {"ford_pipe_length_m": 9}}, +] + + +def _run(tmp_path: Path) -> dict: + root = tmp_path / "proj" + edits = root / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + names = definition.read()["종류"]["pipe"]["이름"] + linked = {owner: {} for owner in snapshots.assignments(root)} + return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked) + + +def _cell(out: dict, at: float, cid: str): + row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01) + return row["values"].get(cid) + + +def test_관경_빈칸은_미입력_열(tmp_path): + out = _run(tmp_path) + cols = {c["id"]: c for c in out["columns"]} + assert cols["pipe:len|-"]["group"] == ["관 공", "미입력", "관매설"] + assert _cell(out, 100, "pipe:len|-") == 16 + assert _cell(out, 100, "pipe:cp|-") == 2 # 1본 6 m — 관경과 무관 + assert not any(c.startswith("pipe:len|1000") and _cell(out, 100, c) for c in cols) + # 흙막이 빈 높이 = 관경 최소 높이를 모름 → 「미입력」 + assert cols["pipe:revet|돌쌓기(메)|-"]["group"][2] == "미입력" + assert _cell(out, 100, "pipe:revet|돌쌓기(메)|-") == 10 + + +def test_세월교_련_빈칸은_미입력_관_몫_빈칸(tmp_path): + out = _run(tmp_path) + cols = {c["id"]: c for c in out["columns"]} + assert cols["pipe:ford|1000|-|width"]["group"][1] == "Φ1000×미입력련" + assert _cell(out, 200, "pipe:ford|1000|-|width") == 12 + assert _cell(out, 200, "pipe:len|1000") is None # 련 수를 모르면 관 몫도 빈칸 + assert _cell(out, 200, "pipe:cp|1000") is None + assert not [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"] diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py new file mode 100644 index 000000000..9f3b3234b --- /dev/null +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 탭 — 자재 셋 값 없는 줄 숨김 · 표 넷 공용 카드 안(PLAN 143-3). + +숨김은 화면만 — 셈 · 열 · 단위수량은 그대로 · 고친 칸은 원래 줄 번호(`keep`)로 저장 자리를 찾음. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") +SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + + +def test_자재_셋_값_없는_줄_숨김(): + assert MATERIAL.count("const keep = shown(rows, ") == 3 # 총괄 · 콘크리트 · 자재수량 + assert "row.qty != null || anyValue(row.units)" in MATERIAL # 손 단위수량 있으면 보임 + assert "row.qty != null || anyValue(row.amounts)" in MATERIAL + assert "row.qty != null || row.amount != null" in MATERIAL + # 고친 칸 → 원래 줄(숨긴 줄 건너뜀) + for target in ( + "rows[keep[i / 2]].id", + "rows[keep[i]].key, key.slice(4)", + "rows[keep[i]].panel", + ): + assert target in MATERIAL, target + + +def test_표_넷_공용_카드_안(): + assert "createCard({ body: [toolbar, table.root] })" in MATERIAL + assert "createCard({ body: parts })" in SUMMARY diff --git a/resources/tester/test_61_2_b08_structure_sheets.py b/resources/tester/test_61_2_b08_structure_sheets.py index 05333c28e..a098d8555 100644 --- a/resources/tester/test_61_2_b08_structure_sheets.py +++ b/resources/tester/test_61_2_b08_structure_sheets.py @@ -32,10 +32,14 @@ def test_read_only(): assert "editable: () => false" in DRAWING -def test_copy_first_then_master(): - # 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층 - assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING - assert "readTemplate(layer, part, column, null)" in DRAWING +def test_copy_only(): + # 143-1 — 연결된 사본만(그룹은 `{id}__{그룹}` 사본) · 없으면 「사본 없음」 · M02 층 폴백 없음 + assert ( + "request.linked[combo.copyId]" in DRAWING + and "sourceOf(request.projectId, copy.폴더)" in DRAWING + ) + assert "readTemplate" not in DRAWING and "FromMaster" not in DRAWING + assert "사본 없음" in LOCALE def test_locale_keys_exist(): diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index f29e0890a..63ea0b737 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -195,12 +195,15 @@ def test_토공집계표_식() -> None: assert value[("토사운반", "", "19 TON B/D")] == 20 assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82) assert value[("잔토처리", "", "백호우")] == -98 - assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8) - assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3) - tree = excel_round(133.2 * 0.5 + 82.8, 2) - assert value[("지장목제거", "", "")] == excel_round(tree * 0.8) + # 143-2 — 거창 계수(0.8 · 0.5) 자리는 빈칸 + 「비율 미정」 · 구조물 쪽 줄 8 뺌 · 나머지 7 빈칸 + notes = {r["group"]: r["note"] for r in rows} + for group in ("성토면다짐", "초류종자살포", "지장목제거"): + assert next(r["value"] for r in rows if r["group"] == group) is None + assert notes[group] == "비율 미정" assert value[("표토제거", "", "")] is None - assert len(rows) == 33 # 시트 C5:C37 + assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows} + assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7 + assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8 def test_토적표_머리_글자는_사용자_견본대로(): diff --git a/resources/tester/test_73_11_b08_hydro_box_ford.py b/resources/tester/test_73_11_b08_hydro_box_ford.py index 1d5f6a96a..6593ffd1d 100644 --- a/resources/tester/test_73_11_b08_hydro_box_ford.py +++ b/resources/tester/test_73_11_b08_hydro_box_ford.py @@ -56,15 +56,27 @@ def test_장은_시설이_정함() -> None: def test_박스_울진_산출서_단면() -> None: - r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50)) - assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] == 0.8 + # 143-2 — 수심비 처음 값 빈칸(울진 0.8 은 손으로 넣은 값으로만) + r = compute( + _inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, depth_ratio=0.8) + ) + assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] is None assert r["n"] == 0.013 assert r["depth"] == pytest.approx(1.2) assert r["section_area"] == pytest.approx(3.6) assert r["perimeter"] == pytest.approx(5.4) assert r["radius"] == pytest.approx(3.6 / 5.4) # 실무 29.236 = 1/n 75 · R 0.67 반올림 — 같은 n 이면 R 반올림 차(0.3 %)만 남음 - r75 = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, n=1 / 75)) + r75 = compute( + _inputs( + "box", + box_width_m=3.0, + box_height_m=1.5, + slope_denominator=50, + n=1 / 75, + depth_ratio=0.8, + ) + ) assert r75["capacity"] == pytest.approx(29.236, rel=0.004) assert 75 * 0.67 ** (2 / 3) * math.sqrt(1 / 50) * 3.6 == pytest.approx(29.236, abs=0.001) assert r["capacity"] > r75["capacity"] # n 고치면 따라감 @@ -79,6 +91,9 @@ def test_박스_내공_경사_빈칸이면_대시() -> None: r = compute(_inputs("box")) assert r["design_flow"] is not None assert r["depth"] is None and r["capacity"] is None and r["ok"] is None + # 수심비 빈칸 = 내공 · 경사가 있어도 계산 멈춤(143-2) + r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50)) + assert r["depth"] is None and r["capacity"] is None and r["ok"] is None @pytest.fixture(scope="module") diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index feaa73c5a..f851b9b4a 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -7,6 +7,8 @@ 78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2 84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1 89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림 +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 픽스처는 관 · 유입 · 유출 · 흙막이 1 · 돌붙임 1 을 +모두 잇고 연결된 사본 = 지정 전부(`_run`) · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -17,12 +19,15 @@ from pathlib import Path import pytest from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"} OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"} WALL = {"side": "outlet", "key": "extra0"} +#: 컨테이너 잇기 전부(143-1 — 잇지 않은 칸은 안 셈) · 흙막이 칸 조합 = OUT +LINKED = {"gnbf9m9": COMBO, "g9lq9uk": OUT, "gsoiladd1": OUT, "gchuteadd1": COMBO} def _revet(side: str, form: str, height: float | None) -> dict: @@ -101,7 +106,15 @@ def root(tmp_path: Path) -> Path: found = tmp_path / "proj" edits = found / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True) - points = [{**p, "source": "user"} for p in POINTS] + points = [ + { + **p, + "source": "user", + "summary_item": COMBO, + "group_items": {**LINKED, **(p.get("group_items") or {})}, + } + for p in POINTS + ] (edits / "pipe_points.json").write_text( json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" ) @@ -110,7 +123,8 @@ def root(tmp_path: Path) -> Path: def _run(root: Path, one_m: float | None = 6.0) -> dict: names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, one_m) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, one_m, linked=linked) def _cell(out: dict, at: float, cid: str): @@ -201,7 +215,7 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch): json.dumps({"변수": {"파형강관_1본_m": 8}}), encoding="utf-8" ) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) - assert summary.one_pipe_length(root) == 8 + assert summary.one_pipe_length(root) is None # 143-2 — 시스템 층으로 안 채움 mine = layers.template_path(layers.project_dir(root), "table", "구조물집계표") mine.parent.mkdir(parents=True) mine.write_text(json.dumps({"변수": {"파형강관_1본_m": 6}}), encoding="utf-8") diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 8f2f5cab4..962572564 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -28,7 +28,6 @@ export const ui_locales_b4 = { B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"], B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"], B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"], - B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"], B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"], B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"], B08_Quantity_Drawing_Failed: [ @@ -36,15 +35,15 @@ export const ui_locales_b4 = { "Failed to load structure sheets", ], B08_Quantity_Drawing_Missing: [ - "M02 에 없는 조합입니다 — 도면 · 면적산출을 보일 수 없습니다.", - "This combination is not in M02 — no drawing or area sheet to show.", + "사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.", + "No project copy — link the combination in the B05 · B06 panel and save.", ], B08_Quantity_Drawing_NoDrawing: [ - "이 조합은 도면이 없습니다 — M02 구조물 조합에서 그립니다.", - "This combination has no drawing — draw it in M02 structure combinations.", + "이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].", + "This copy has no drawing — draw it in M02 structure combinations, then reapply.", ], B08_Quantity_Summary_Station: ["측점", "Station"], - B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"], + B08_Quantity_Summary_MissingGroup: ["경로 없음", "No path"], B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"], B08_Quantity_Summary_Failed: [ "구조물 집계표를 불러오지 못했습니다",