From 138e80c79feaf823b2dc441f0c8de0deb0d32e87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:51:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=EA=B0=81=20=EC=84=9C=EB=B2=84=20(PLAN=206?= =?UTF-8?q?0-1=20=C2=B7=2060-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정보 조각(등고선 · 계류 · 중심선 · 성토사면 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · 대피소/사토장 · 라이다 배경)마다 장별 파일(plan_pieces/{장}/{조각}.json) - 외곽선 = 노견 끝 + 설계선이 지반에 닿는 자리(B06 횡단 설계 결과) · 측점 자리는 횡단 파일 중심 · 좌축 - 종류 = 조각 고르기 목록(plan_kinds/{id}.json) · 기본 넷(지형 · 노선배치도 · 대피소 · 라이다) · 더하기 · 지우기 API - 대피소 · 사토장 = 구조물 정본 refuge · spoil_bank + 사용자 지정 자리(plan_marks.json) - 도면 목록 · 단건 조회를 종류 × 장으로 · 옛 plan_* id 그대로 이어짐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 475 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 21 +- .../B07_DesignDetail_Router_PlanKinds.py | 98 ++++ .../B07_DesignDetail_Router_Support.py | 92 +--- .../B07_DesignDetail_Router_Support_Plan.py | 404 +++++++++++++++ B07_DesignDetail/B07_DesignDetail_Schema.py | 3 + main.py | 2 + resources/tester/test_b07_plan_kinds.py | 159 ++++++ 8 files changed, 1168 insertions(+), 86 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py create mode 100644 resources/tester/test_b07_plan_kinds.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py new file mode 100644 index 000000000..34e5cc64b --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -0,0 +1,475 @@ +"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). + +계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. +정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · +대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 +무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). +""" + +import math +from bisect import bisect_left +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _ROUTE_WIDTH, + _TITLE_FONT_SIZE, + CONTOUR_COLOR, + MM, + STREAM_COLOR, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +# 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저). +PIECES: tuple[tuple[str, str, bool], ...] = ( + ("lidar", "라이다 배경", True), + ("contour", "등고선", True), + ("stream", "계류", True), + ("slope_ticks", "비탈 눈금", False), + ("slope_outline", "성토사면 외곽선", False), + ("centerline", "중심선", False), + ("ip", "IP", False), + ("radius", "R", False), + ("station", "측점 NO", False), + ("endpoints", "시종점", False), + ("refuge", "대피소 · 사토장", False), +) +PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) + +_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius") +# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. +DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( + {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, + { + "id": "route", + "name": "노선배치도", + "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints"], + }, + { + "id": "layout", + "name": "대피소", + "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"], + }, + # 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신). + { + "id": "lidar", + "name": "라이다", + "pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"], + }, +) + +_COLORS = { + "contour": CONTOUR_COLOR, + "stream": STREAM_COLOR, + "centerline": "#ff4d4d", + "slope_outline": "#4dd8e6", + "slope_ticks": "#ff4d4d", + "ip": "#4d7dff", + "radius": "#4d7dff", + "station": "#ffe066", + "endpoints": "#ff9d4d", + "refuge": "#7ddc7d", +} +TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 +_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) +_LABEL_FONT = 2.5 +_MAJOR_FONT = 4.5 +_TICK_MM = 1.5 +_CIRCLE_MM = 1.2 + + +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 slope_end( + design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str +) -> float: + """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. + + 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. + 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. + """ + ground = 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)) + ) + 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 + offsets = [offset for offset, _z in ground] + + def ground_at(offset: float) -> float: + 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) + + end, differs = edge, False + for offset, elevation in outward: + if abs(elevation - ground_at(offset)) <= 1e-3: + return offset if differs else end + differs, end = True, offset + return end + + +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]]: + """측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다. + + `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, + (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 + 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. + """ + records = sorted(records, key=lambda record: record["chainage_m"]) + if len(route) < 2 or not records: + return {"lines": [], "stations": []}, {"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} + 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 ("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]]) + outline = { + "lines": [lines[key] for key in keys], + "stations": [ + {key: record[key] for key in ("chainage_m", *keys)} + for record in records + if start_m <= record["chainage_m"] <= end_m + ], + } + return outline, {"ticks": ticks} + + +def curve_pieces( + curves: list[dict[str, Any]], + route: list[tuple[float, float, float]], + box: tuple[float, float, float, float], +) -> tuple[dict[str, Any], dict[str, Any]]: + """곡선표에서 IP(교각점 · 접선)와 R 라벨을 만든다. 번호는 노선 전체 차례(IP1 …).""" + ips: list[dict[str, Any]] = [] + radii: list[dict[str, Any]] = [] + min_x, min_y, max_x, max_y = box + for number, curve in enumerate(curves, start=1): + apex, start, end = curve.get("apex"), curve.get("start"), curve.get("end") + radius = curve.get("radius_m") + if not (apex and start and end) or not isinstance(radius, (int, float)): + continue + if not (min_x <= apex[0] <= max_x and min_y <= apex[1] <= max_y): + continue + ips.append( + { + "name": f"IP{number}", + "apex": list(apex[:2]), + "start": list(start[:2]), + "end": list(end[:2]), + } + ) + # R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다. + middle = min( + ((x, y) for _c, x, y in route), + key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]), + default=(apex[0], apex[1]), + ) + dx, dy = middle[0] - apex[0], middle[1] - apex[1] + length = math.hypot(dx, dy) or 1.0 + radii.append( + { + "label": f"R={round(float(radius), 1):g}", + "at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0], + } + ) + return {"items": ips}, {"items": radii} + + +# ── 도면 조립 ───────────────────────────────────────── + + +def _circle(center: tuple[float, float], radius: float) -> list[tuple[float, float]]: + return [ + (center[0] + radius * math.cos(a), center[1] + radius * math.sin(a)) + for a in (2 * math.pi * i / 24 for i in range(25)) + ] + + +def _reading(nx: float, ny: float) -> tuple[float, float]: + """글자 방향 — 노선 방향(법선을 돌린 것)으로 쓰되 거꾸로 서지 않게.""" + tx, ty = ny, -nx + return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty) + + +def _piece_entities( + drawing_id: str, piece: str, data: dict[str, Any], paper: Any +) -> list[dict[str, Any]]: + layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) + out: list[dict[str, Any]] = [] + + def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None: + entity = polyline_entity( + drawing_id, + [paper(p) for p in points], + layer, + color, + suffix=suffix, + width=width, + dash=dash, + ) + 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 + ) + ) + + if piece == "lidar": + image, box = data.get("image"), data.get("box") + if image and box: + left, bottom = paper((box[0], box[1])) + right, top = paper((box[2], box[3])) + corners = [(left, bottom), (right, bottom), (right, top), (left, top)] + out.append( + { + "id": f"{drawing_id}:lidar", + "type": "Image", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": layer, + "shapeData": { + "points": [{"x": x, "y": y} for x, y in corners], + "imageData": image, + }, + } + ) + elif piece in ("contour", "stream", "slope_outline"): + for index, points in enumerate(data.get("lines") or []): + line(points, f":{index}") + elif piece == "centerline": + for index, points in enumerate(data.get("lines") or []): + line(points, f":{index}", width=_ROUTE_WIDTH) + elif piece == "slope_ticks": + for index, points in enumerate(data.get("ticks") or []): + line(points, f":{index}") + elif piece == "ip": + for index, item in enumerate(data.get("items") or []): + line([item["start"], item["apex"], item["end"]], f":{index}", dash=[4, 2]) + apex = paper(item["apex"]) + out_circle = polyline_entity( + drawing_id, _circle(apex, _CIRCLE_MM), layer, color, suffix=f":c:{index}" + ) + if out_circle: + out.append(out_circle) + text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT) + elif piece == "radius": + for index, item in enumerate(data.get("items") or []): + text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) + elif piece == "station": + for index, item in enumerate(data.get("items") or []): + cx, cy = paper((item["x"], item["y"])) + nx, ny = item["nx"], item["ny"] + line( + [ + (item["x"] - nx * _TICK_MM / MM, item["y"] - ny * _TICK_MM / MM), + (item["x"] + nx * _TICK_MM / MM, item["y"] + ny * _TICK_MM / MM), + ], + f":{index}", + ) + if item.get("major"): + circle = polyline_entity( + drawing_id, _circle((cx, cy), _CIRCLE_MM), layer, color, suffix=f":c:{index}" + ) + if circle: + out.append(circle) + gap = _MAJOR_FONT * 1.8 + text( + f"{index}", + item["label"], + (cx + nx * gap, cy + ny * gap), + _MAJOR_FONT, + direction=_reading(nx, ny), + ) + elif piece == "endpoints": + for index, item in enumerate(data.get("items") or []): + nx, ny = item["nx"], item["ny"] + reach = 6.0 / MM # 종이 6mm 막대 + line( + [ + (item["x"] - nx * reach, item["y"] - ny * reach), + (item["x"] + nx * reach, item["y"] + ny * reach), + ], + f":{index}", + 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) + elif piece == "refuge": + size = 2.0 # 네모 기호 반크기(종이 mm) + for index, item in enumerate(data.get("items") or []): + x, y, half = item["x"], item["y"], size / MM + 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") + return out + + +def build_plan_kind_drawing( + drawing_id: str, + label: str, + box: tuple[float, float, float, float], + pieces: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m).""" + min_x, min_y = box[0], box[1] + + def paper(point: Any) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + for piece in PIECE_IDS: + if piece in pieces: + entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper)) + # 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다. + frame_box = (0.0, 0.0, (box[2] - min_x) * MM, (box[3] - min_y) * MM) + entities.extend( + compass_entities( + drawing_id, + ( + frame_box[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, + frame_box[3] - _COMPASS_SIZE / 2.0, + ), + _COMPASS_SIZE, + ) + ) + bbox = entities_bbox(entities) or frame_box + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + "b07-plan-title", + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + "b07-plan-title", + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + layers = [ + _layer(layer_id(piece), name, locked=locked) + for piece, name, locked in PIECES + if piece in pieces + ] + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + *layers, + _layer("b07-plan-title", "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index d07804e43..c1188e2c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -39,9 +39,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, LANDUSE_ID, - LIDAR_ID, MASS_HAUL_ID, - PLAN_ID, WATERSHED_ID, _cross_sheet_plan, _drawing_list, @@ -50,10 +48,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_json, _store_confirmed_drawing, landuse_source, - lidar_source, - plan_source, watershed_source, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingConfirmRequest, DesignDrawingConfirmResponse, @@ -342,13 +339,16 @@ async def get_design_drawing( if row else None ) - elif LIDAR_ID.fullmatch(drawing_id): - # 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다. + elif PLAN_KIND_ID.fullmatch(drawing_id): + # 계획평면도 — 정보 조각을 서버가 계산 · 파일로 두고 그 종류가 고른 것만 얹는다. context, reason = await load_drainage_context(project_id) if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) - source_design = await asyncio.to_thread(lidar_source, context, longitudinal, drawing_id) + designs = await _designs_by_chainage(route_id) + source_design = await asyncio.to_thread( + plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id + ) elif LANDUSE_ID.fullmatch(drawing_id): # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. context, reason = await load_drainage_context(project_id) @@ -358,13 +358,6 @@ async def get_design_drawing( source_design = await asyncio.to_thread( landuse_source, context, longitudinal, drawing_id ) - elif PLAN_ID.fullmatch(drawing_id): - # 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다. - context, reason = await load_drainage_context(project_id) - if context is None: - return JSONResponse(status_code=404, content={"status": "error", "message": reason}) - longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) - source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py new file mode 100644 index 000000000..a6ec7848d --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -0,0 +1,98 @@ +"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장). + +종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. +대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다. +""" + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES +from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + add_kind, + delete_kind, + list_kinds, + read_marks, + write_marks, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + + +class PlanKindRequest(BaseModel): + name: str + pieces: list[str] = Field(default_factory=list) + + +class PlanMark(BaseModel): + chainage_m: float = Field(ge=0) + refuge: bool = False + spoil: bool = False + + +class PlanMarksRequest(BaseModel): + marks: list[PlanMark] = Field(default_factory=list) + + +def _state(root: Any) -> dict[str, Any]: + return { + "status": "success", + "pieces": [{"id": piece, "name": name} for piece, name, _locked in PIECES], + "kinds": list_kinds(root), + "marks": read_marks(root), + } + + +def _error(status: int, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)}) + + +@router.get("/{project_id}/plan-kinds") +async def get_plan_kinds(project_id: UUID) -> Any: + """고를 수 있는 조각 · 지금 종류 · 대피소 자리.""" + root = await _project_root(project_id) + return await asyncio.to_thread(_state, root) + + +@router.post("/{project_id}/plan-kinds") +async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: + """종류 더하기 — 조각을 골라 이름을 붙인다.""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(add_kind, root, request.name, request.pieces) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + + +@router.delete("/{project_id}/plan-kinds/{kind_id}") +async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: + """종류 지우기 — 기본 넷도 지울 수 있다.""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(delete_kind, root, kind_id) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + except FileNotFoundError as exc: + return _error(404, exc) + + +@router.put("/{project_id}/plan-marks") +async def put_plan_marks(project_id: UUID, request: PlanMarksRequest) -> Any: + """사용자가 정한 대피소 · 사토장 자리를 통째로 갈아 쓴다.""" + try: + root = await _project_root(project_id) + marks = [mark.model_dump() for mark in request.marks] + await asyncio.to_thread(write_marks, root, marks) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 4287f1b22..bc0640286 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -28,22 +28,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( LANDUSE_LABEL, build_landuse_drawing, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import ( - LIDAR_LABEL, - build_lidar_plan_drawing, -) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( - PLAN_KINDS, - build_plan_drawing, - plan_chunks, - plan_drawing_id, - plan_drawing_label, -) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, @@ -90,6 +81,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _station_map, _write_manifest, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + PLAN_KIND_ID, + plan_kind_items, +) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) @@ -151,32 +146,16 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), ) ) - # 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다). - plan_sheets = plan_chunks(plan_stations(longitudinal)) - for kind, _label, *_rest in PLAN_KINDS: - for chunk in plan_sheets: - drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets)) - drawings.append( - DesignDrawingItem( - id=drawing_id, - kind="plan", - label=plan_drawing_label(kind, chunk, len(plan_sheets)), - confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), - ) - ) - # 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔. - for chunk in plan_sheets: - drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}" + # 계획평면도 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가) × 장(PLAN 60장). + for entry in plan_kind_items(project_root, longitudinal): drawings.append( DesignDrawingItem( - id=drawing_id, - kind="plan_lidar", - label=LIDAR_LABEL - if len(plan_sheets) <= 1 - else f"{LIDAR_LABEL} {chunk['number']}장", - confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + **entry, + kind="plan", + confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")), ) ) + plan_sheets = plan_chunks(plan_stations(longitudinal)) # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. for chunk in plan_sheets: drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" @@ -427,12 +406,10 @@ def _read_drawing( if saved.get("format") == DRAWING_FORMAT: if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 - elif PLAN_ID.fullmatch(drawing_id): + elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" elif LANDUSE_ID.fullmatch(drawing_id): kind = "landuse" - elif LIDAR_ID.fullmatch(drawing_id): - kind = "plan_lidar" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -468,20 +445,16 @@ def _read_drawing( None, ) - if LIDAR_ID.fullmatch(drawing_id): - # stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림. + if PLAN_KIND_ID.fullmatch(drawing_id): + # stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것. if not isinstance(stored_design, dict): - raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.") - label = str(stored_design.get("label") or LIDAR_LABEL) + raise FileNotFoundError("계획평면도 자료가 없습니다.") + label = str(stored_design.get("label") or drawing_id) return ( - "plan_lidar", + "plan", label, - build_lidar_plan_drawing( - drawing_id, - label, - stored_design.get("route_xy") or [], - stored_design.get("shade_image"), - stored_design.get("shade_box"), + build_plan_kind_drawing( + drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} ), False, None, @@ -508,31 +481,6 @@ def _read_drawing( None, ) - if PLAN_ID.fullmatch(drawing_id): - # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). - if not isinstance(stored_design, dict): - raise FileNotFoundError("계획평면도 자료가 없습니다.") - longitudinal = _read_json(longitudinal_path) - interval = infer_station_interval(longitudinal.get("stations") or []) - label = str(stored_design.get("label") or drawing_id) - return ( - "plan", - label, - build_plan_drawing( - str(stored_design.get("kind") or "plan_terrain"), - drawing_id, - label, - stored_design.get("route_xy") or [], - stored_design.get("stations") or [], - stored_design.get("contours") or [], - stored_design.get("streams") or [], - stored_design.get("structures") or [], - interval, - ), - False, - None, - ) - if drawing_id == WATERSHED_ID: # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py new file mode 100644 index 000000000..70770f60e --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -0,0 +1,404 @@ +"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장). + +저장 자리(프로젝트 저장소 `B07_DesignDetail/`): + plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩 + plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) + plan_marks.json 사용자가 정한 대피소 · 사토장 자리 + +`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 +기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다. +조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) +에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. +""" + +import re +import uuid +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections import ( + _load_design_curves, + cross_station, + find_cross_file, +) +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_area_mm, plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + DEFAULT_KINDS, + PIECE_IDS, + curve_pieces, + route_frame, + slope_end, + slope_pieces, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + _chunk_route, + _hillshade_for_box, + _sheet_box, + map_background, + plan_stations, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json +from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_profile import design_elevation_from_longitudinal +from config.config_system import DRAWING_SCALE_PLAN + +KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$") +PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$") +_NAME_MAX = 40 +_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"} +_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측) + + +def plan_title(name: str) -> str: + return f"계획평면도({name})" + + +# ── 종류 ───────────────────────────────────────────── + + +def _kinds_dir(project_root: Path) -> Path: + return _design_root(project_root) / "plan_kinds" + + +def list_kinds(project_root: Path) -> list[dict[str, Any]]: + """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷.""" + folder = _kinds_dir(project_root) + if not folder.is_dir(): + return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS] + rows: list[tuple[int, str, dict[str, Any]]] = [] + for path in folder.glob("*.json"): + if not KIND_ID.fullmatch(path.stem): + continue + try: + document = _read_json(path) + except (OSError, ValueError): + continue + pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS] + rows.append( + ( + int(document.get("order") or 0), + path.stem, + {"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces}, + ) + ) + return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])] + + +def _materialize(project_root: Path) -> list[dict[str, Any]]: + """기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번.""" + kinds = list_kinds(project_root) + folder = _kinds_dir(project_root) + if not folder.is_dir(): + for order, kind in enumerate(kinds): + atomic_write_json( + folder / f"{kind['id']}.json", + {"name": kind["name"], "pieces": kind["pieces"], "order": order}, + ) + return kinds + + +def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: + text = str(name or "").strip() + if not text or len(text) > _NAME_MAX: + raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.") + chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])] + if not chosen: + raise ValueError("얹을 정보를 하나 이상 고르세요.") + kinds = _materialize(project_root) + if any(kind["name"] == text for kind in kinds): + raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") + kind_id = f"k{uuid.uuid4().hex[:8]}" + atomic_write_json( + _kinds_dir(project_root) / f"{kind_id}.json", + {"name": text, "pieces": chosen, "order": len(kinds)}, + ) + return {"id": kind_id, "name": text, "pieces": chosen} + + +def delete_kind(project_root: Path, kind_id: str) -> None: + if not KIND_ID.fullmatch(kind_id): + raise ValueError("올바르지 않은 종류 id 입니다.") + _materialize(project_root) + path = _kinds_dir(project_root) / f"{kind_id}.json" + if not path.is_file(): + raise FileNotFoundError("지울 종류를 찾을 수 없습니다.") + path.unlink() + + +# ── 대피소 · 사토장 자리(사용자 지정) ───────────────── + + +def _marks_path(project_root: Path) -> Path: + return _design_root(project_root) / "plan_marks.json" + + +def read_marks(project_root: Path) -> list[dict[str, Any]]: + path = _marks_path(project_root) + if not path.is_file(): + return [] + try: + marks = _read_json(path).get("marks") + except (OSError, ValueError): + return [] + return [mark for mark in marks or [] if isinstance(mark, dict)] + + +def write_marks(project_root: Path, marks: list[dict[str, Any]]) -> list[dict[str, Any]]: + clean: list[dict[str, Any]] = [] + for mark in marks: + chainage = mark.get("chainage_m") + if not isinstance(chainage, (int, float)) or chainage < 0: + raise ValueError("대피소 · 사토장 자리의 측점(m)이 올바르지 않습니다.") + refuge, spoil = bool(mark.get("refuge")), bool(mark.get("spoil")) + if refuge or spoil: + clean.append( + {"chainage_m": round(float(chainage), 2), "refuge": refuge, "spoil": spoil} + ) + clean.sort(key=lambda mark: mark["chainage_m"]) + atomic_write_json(_marks_path(project_root), {"marks": clean}) + return clean + + +# ── 정보 조각 ───────────────────────────────────────── + + +def _station_design( + longitudinal_path: Path, + longitudinal: dict[str, Any], + source: dict[str, Any], + stored: dict[str, Any] | None, +) -> dict[str, Any] | None: + """그 측점의 횡단 설계 — B06 저장분 우선, 없으면 기본값으로 계산(횡단면도와 같은 규칙).""" + if isinstance(stored, dict) and stored.get("design_line") and stored.get("road_edges"): + return stored + try: + return compute_cross_design( + source.get("samples", []), + design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), + ground_type="soil", + section_mode="left_cut", + **curve_widening_args(source), + ) + except (ValueError, KeyError): + return None + + +def slope_records( + longitudinal_path: Path, + longitudinal: dict[str, Any], + designs: dict[int | float, dict[str, Any]], + start_m: float, + end_m: float, +) -> list[dict[str, Any]]: + """측점별 노견 끝 · 비탈 끝 오프셋(좌 + · 우 -) — 장 범위와 앞뒤 한 측점.""" + cross_dir = longitudinal_path.parent.parent / "cross_sections" + chainages = sorted(chainage for chainage, _x, _y in plan_stations(longitudinal)) + inside = [i for i, c in enumerate(chainages) if start_m <= c <= end_m] + if not inside: + return [] + chosen = chainages[max(inside[0] - 1, 0) : inside[-1] + 2] + records: list[dict[str, Any]] = [] + for chainage in chosen: + path = find_cross_file(cross_dir, chainage) + if not path.is_file(): + continue + source = _read_json(path) + design = _station_design( + longitudinal_path, longitudinal, source, designs.get(cross_station(chainage)) + ) + edges = (design or {}).get("road_edges") or {} + try: + left_edge = float(edges["left"]["offset_m"]) + right_edge = float(edges["right"]["offset_m"]) + except (KeyError, TypeError, ValueError): + continue + line, samples = (design or {}).get("design_line") or [], source.get("samples") or [] + record: dict[str, Any] = { + "chainage_m": float(chainage), + "left_edge_m": left_edge, + "left_end_m": slope_end(line, samples, left_edge, "left"), + "right_edge_m": right_edge, + "right_end_m": slope_end(line, samples, right_edge, "right"), + } + frame = source.get("frame") or {} + origin, axis = frame.get("origin") or {}, frame.get("left_xy") + if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin: + record.update(x=float(origin["x"]), y=float(origin["y"]), nx=axis[0], ny=axis[1]) + records.append(record) + return records + + +def _refuge_items( + project_root: Path, route: list[tuple[float, float, float]], start_m: float, end_m: float +) -> dict[str, Any]: + """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능.""" + spots: dict[float, set[str]] = {} + _revision, structures = load_structures(str(project_root)) + for structure in structures: + label = _REFUGE_TYPES.get(structure.type_id) + if not label: + continue + chainage = structure.chainage_m + if chainage is None and structure.start_m is not None and structure.end_m is not None: + chainage = (structure.start_m + structure.end_m) / 2.0 + if chainage is not None: + spots.setdefault(round(float(chainage), 1), set()).add(label) + for mark in read_marks(project_root): + key = round(float(mark["chainage_m"]), 1) + if mark.get("refuge"): + spots.setdefault(key, set()).add("대피소") + if mark.get("spoil"): + spots.setdefault(key, set()).add("사토장") + items = [] + for chainage, labels in sorted(spots.items()): + if not (start_m <= chainage <= end_m) or len(route) < 2: + continue + x, y, nx, ny = route_frame(route, chainage) + items.append( + { + "chainage_m": chainage, + "label": " · ".join(name for name in ("대피소", "사토장") if name in labels), + "x": x + nx * _REFUGE_OFFSET_M, + "y": y + ny * _REFUGE_OFFSET_M, + } + ) + return {"items": items} + + +def build_pieces( + context: Any, + longitudinal: dict[str, Any], + longitudinal_path: Path, + designs: dict[int | float, dict[str, Any]], + number: int, + wanted: list[str], +) -> tuple[dict[str, dict[str, Any]], tuple[float, float, float, float], dict[str, Any], int]: + """그 장의 조각들을 계산해 파일로 쓰고 (조각들, 도곽 범위, 장 구간, 전체 장수)를 낸다.""" + route_xy, chunk, total = _chunk_route(context, longitudinal, number) + if not route_xy: + raise FileNotFoundError("계획평면도에 그릴 노선이 없습니다.") + box = _sheet_box(route_xy) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + project_root = Path(context.project_root) + route = sorted( + (float(vertex.chainage_m), float(vertex.x), float(vertex.y)) for vertex in context.vertices + ) + stations = plan_stations(longitudinal) + interval = infer_station_interval(longitudinal.get("stations") or []) + pieces: dict[str, dict[str, Any]] = {} + + if {"contour", "stream"} & set(wanted): + background = map_background( + project_root, context.crs, DRAWING_SCALE_PLAN, plan_area_mm(), route_xy + ) + pieces["contour"] = {"lines": background["contours"]} + pieces["stream"] = {"lines": background["streams"]} + if "centerline" in wanted: + pieces["centerline"] = {"lines": [route_xy]} + if {"slope_outline", "slope_ticks"} & set(wanted): + records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m) + pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces( + route, records, start_m, end_m + ) + if {"ip", "radius"} & set(wanted): + pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box) + if "station" in wanted: + items = [] + for chainage, x, y in stations: + if not (start_m <= chainage <= end_m): + continue + _x, _y, nx, ny = route_frame(route, chainage) + label = station_no_label(chainage, interval).replace("No.", "NO.") + number_only = "+" not in label + major = number_only and int(label[3:]) % 5 == 0 + items.append( + { + "chainage_m": chainage, + "x": x, + "y": y, + "nx": nx, + "ny": ny, + "label": label, + "major": major, + } + ) + pieces["station"] = {"items": items} + if "endpoints" in wanted and stations: + items = [] + first, last = stations[0], stations[-1] + length = last[0] - first[0] + for (chainage, x, y), word in ((first, "시점"), (last, "종점")): + if not (start_m <= chainage <= end_m): + continue + _x, _y, nx, ny = route_frame(route, chainage) + label = f"{word} {station_no_label(chainage, interval)}" + if word == "종점": + label += f" L={length:.1f}m" + items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny}) + pieces["endpoints"] = {"items": items} + if "refuge" in wanted: + pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + if "lidar" in wanted: + try: + image, image_box = _hillshade_for_box(context, box) + pieces["lidar"] = {"image": image, "box": list(image_box)} + except (FileNotFoundError, ValueError, OSError): + pieces["lidar"] = {"image": None, "box": None} + + folder = _design_root(project_root) / "plan_pieces" / str(number) + for piece, data in pieces.items(): + atomic_write_json(folder / f"{piece}.json", data) + return {piece: pieces[piece] for piece in wanted if piece in pieces}, box, chunk, total + + +def plan_kind_source( + context: Any, + longitudinal: dict[str, Any], + longitudinal_path: Path, + designs: dict[int | float, dict[str, Any]], + drawing_id: str, +) -> dict[str, Any]: + """종류 도면 한 장의 입력 — 그 종류가 고른 조각 + 도곽 범위 + 도면명.""" + match = PLAN_KIND_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 계획평면도 ID입니다.") + kind = next( + (item for item in list_kinds(Path(context.project_root)) if item["id"] == match.group(1)), + None, + ) + if kind is None: + raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.") + number = int(match.group(2)) if match.group(2) else 1 + pieces, box, chunk, total = build_pieces( + context, longitudinal, longitudinal_path, designs, number, kind["pieces"] + ) + title = plan_title(kind["name"]) + return { + "label": title if total <= 1 else f"{title} {chunk['number']}장", + "box": box, + "pieces": pieces, + } + + +def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[dict[str, Any]]: + """도면 목록에 실을 종류 × 장 [{id, label, plan_kind}].""" + sheets = plan_chunks(plan_stations(longitudinal)) + items = [] + for kind in list_kinds(project_root): + title = plan_title(kind["name"]) + for chunk in sheets: + single = len(sheets) <= 1 + items.append( + { + "id": f"plan_{kind['id']}" + if single + else f"plan_{kind['id']}_{chunk['number']}", + "label": title if single else f"{title} {chunk['number']}장", + "plan_kind": kind["id"], + "plan_kind_name": kind["name"], + } + ) + return items diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 14dd24886..84d73179f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -26,6 +26,9 @@ class DesignDrawingItem(BaseModel): label: str chainage_m: float | None = None confirmed: bool = False + # 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다. + plan_kind: str | None = None + plan_kind_name: str | None = None class DesignDrawingListResponse(BaseModel): diff --git a/main.py b/main.py index 3a2c31b48..2157c0f1e 100644 --- a/main.py +++ b/main.py @@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( ) from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router +from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -638,6 +639,7 @@ app.include_router(b06_section_confirm_router, dependencies=protected_with_compa app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) +app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py new file mode 100644 index 000000000..da75092e7 --- /dev/null +++ b/resources/tester/test_b07_plan_kinds.py @@ -0,0 +1,159 @@ +"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장). + +① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리) +② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 +③ 대피소 · 사토장 자리 — 한 자리에 둘 다 +④ 고른 조각만 도면 층으로 얹힘 +""" + +import json +import math +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + DEFAULT_KINDS, + build_plan_kind_drawing, + layer_id, + slope_pieces, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + _refuge_items, + add_kind, + delete_kind, + list_kinds, + read_marks, + slope_records, + write_marks, +) + + +def _samples(slope: float) -> list[dict[str, float]]: + """좌(+)가 높은 비탈 지반 — 오프셋 -20 ~ 20 m, 0.5 m 간격.""" + return [ + {"offset_m": i * 0.5, "elevation_m": 100.0 + slope * i * 0.5, "valid": True} + for i in range(-40, 41) + ] + + +def _project(tmp_path: Path) -> tuple[Path, dict, dict]: + """+y 로 곧게 가는 노선 · 측점 0 · 20 · 40 · 횡단 파일 · B06 저장 설계.""" + root = tmp_path / "project" + cross_dir = root / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal_path.parent.mkdir(parents=True) + stations, designs = [], {} + for chainage, slope in ((0, 0.3), (20, 0.5), (40, 0.4)): + stations.append( + {"chainage_m": float(chainage), "center_x": 0.0, "center_y": float(chainage)} + ) + source = { + "chainage_m": float(chainage), + "samples": _samples(slope), + # +y 진행의 좌측 = -x + "frame": {"origin": {"x": 0.0, "y": float(chainage)}, "left_xy": [-1.0, 0.0]}, + } + (cross_dir / f"cross_{chainage:05d}m.json").write_text(json.dumps(source), "utf-8") + designs[chainage] = compute_cross_design( + source["samples"], 100.0, ground_type="soil", section_mode="left_cut" + ) + longitudinal = {"stations": stations} + longitudinal_path.write_text(json.dumps(longitudinal), "utf-8") + return longitudinal_path, longitudinal, designs + + +def _meets_ground(design: dict, side: str) -> float: + """설계선이 바깥으로 가며 지반과 다시 같아지는 첫 오프셋 — 시험 쪽 독립 계산.""" + edge = design["road_edges"][side]["offset_m"] + line = sorted((p["offset_m"], p["elevation_m"]) for p in design["design_line"]) + outward = ( + [p for p in line if p[0] > edge] + if side == "left" + else [p for p in line[::-1] if p[0] < edge] + ) + slope = SLOPES[design["_c"]] + for offset, elevation in outward: + # 지반은 직선 비탈이라 어느 오프셋에서나 식으로 바로 잰다(설계선 꺾임점 포함). + if abs(100.0 + slope * offset - elevation) < 1e-3: + return offset + raise AssertionError("설계선이 지반에 닿지 않음") + + +SLOPES = {0: 0.3, 20: 0.5, 40: 0.4} + + +def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> None: + longitudinal_path, longitudinal, designs = _project(tmp_path) + records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0) + route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] + outline, ticks = slope_pieces(route, records, 0.0, 40.0) + left_edge, left_end, right_edge, right_end = outline["lines"] + for chainage in (20, 40): # 견본 측점 둘 + design = {**designs[chainage], "_c": chainage} + index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6) + expected = { + "left_edge": design["road_edges"]["left"]["offset_m"], + "left_end": _meets_ground(design, "left"), + "right_edge": design["road_edges"]["right"]["offset_m"], + "right_end": _meets_ground(design, "right"), + } + # 좌측은 -x 쪽 — 중심(0, c)에서 잰 거리가 횡단 설계 오프셋이다. + assert -left_edge[index][0] == pytest.approx(expected["left_edge"], abs=1e-3) + assert -left_end[index][0] == pytest.approx(expected["left_end"], abs=1e-3) + assert -right_edge[index][0] == pytest.approx(expected["right_edge"], abs=1e-3) + assert -right_end[index][0] == pytest.approx(expected["right_end"], abs=1e-3) + # 성토 · 절토 비탈이 실제로 노견 밖에 있다 + assert abs(expected["left_end"]) > abs(expected["left_edge"]) + assert abs(expected["right_end"]) > abs(expected["right_edge"]) + # 비탈 눈금은 노견 끝에서 바깥으로 · 긴 것과 짧은 것이 섞임 + lengths = {round(math.dist(*tick), 2) for tick in ticks["ticks"]} + assert len(ticks["ticks"]) > 20 and len(lengths) > 2 + + +def test_kinds_add_delete_persist(tmp_path: Path) -> None: + root = tmp_path / "project" + assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"] + added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) + assert added["pieces"] == ["centerline", "station"] + with pytest.raises(ValueError): + add_kind(root, "내 평면도", ["centerline"]) # 같은 이름 + with pytest.raises(ValueError): + add_kind(root, "빈 것", []) + delete_kind(root, "terrain") # 기본 넷도 지움 + again = list_kinds(root) # 다시 읽어 같음 + assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"] + assert again[-1]["pieces"] == ["centerline", "station"] + with pytest.raises(FileNotFoundError): + delete_kind(root, "terrain") + for kind in list(again): + delete_kind(root, kind["id"]) + assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음) + assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"} + + +def test_refuge_marks_same_spot(tmp_path: Path) -> None: + root = tmp_path / "project" + write_marks(root, [{"chainage_m": 20, "refuge": True, "spoil": True}, {"chainage_m": 5}]) + assert read_marks(root) == [{"chainage_m": 20.0, "refuge": True, "spoil": True}] + route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] + items = _refuge_items(root, route, 0.0, 40.0)["items"] + assert [item["label"] for item in items] == ["대피소 · 사토장"] + assert items[0]["y"] == pytest.approx(20.0) + + +def test_drawing_has_only_chosen_pieces() -> None: + pieces = { + "centerline": {"lines": [[[0.0, 0.0], [0.0, 40.0]]]}, + "radius": {"items": [{"label": "R=15", "at": [1.0, 20.0]}]}, + } + drawing = build_plan_kind_drawing( + "plan_k1", "계획평면도(시험)", (-50.0, -10.0, 50.0, 50.0), pieces + ) + layers = {entity.get("layerId") for entity in drawing["entities"]} + assert layer_id("centerline") in layers and layer_id("radius") in layers + assert layer_id("contour") not in layers and layer_id("station") not in layers + labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"] + assert "R=15" in labels and "계획평면도(시험)" in labels From 11337a15956c722574d9fc2108a136fa66aac351 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:59:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=ED=95=9C=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=8D=94=ED=95=98=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EB=8C=80=ED=94=BC?= =?UTF-8?q?=EC=86=8C=20=EC=9E=90=EB=A6=AC=20=ED=99=94=EB=A9=B4=20(PLAN=206?= =?UTF-8?q?0-1=20=C2=B7=2060-3=20=C2=B7=2060-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 목록 계획평면도 컨테이너 하나 · 종류별 단추 + × 지우기(기본 넷 포함) - 종류 더하기 = 이름 + 정보 조각 고르기 · 대피소 · 사토장 자리 = 측점 + 대피소/사토장(한 자리 둘 다) - 고친 뒤 목록 · 도면을 서버에서 다시 받아 화면 다시 세움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 48 +++++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 18 +- .../B07_DesignDetail_UI_Panels.ts | 14 +- .../B07_DesignDetail_UI_PlanKinds.ts | 196 ++++++++++++++++++ .../B07_DesignDetail_UI_Style.css | 75 +++++++ 5 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 51c9d19f6..c7e856856 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -19,6 +19,9 @@ export interface DesignDrawingItem { label: string; chainage_m: number | null; confirmed: boolean; + /** 계획평면도만 — 종류 id · 이름(PLAN 60장). */ + plan_kind?: string | null; + plan_kind_name?: string | null; } export interface CadDrawing { @@ -170,6 +173,51 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } +/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */ +export interface PlanKind { + id: string; + name: string; + pieces: string[]; +} +export interface PlanMark { + chainage_m: number; + refuge: boolean; + spoil: boolean; +} +export interface PlanKindsState { + pieces: { id: string; name: string }[]; + kinds: PlanKind[]; + marks: PlanMark[]; +} + +export function fetchPlanKinds(projectId: string): Promise { + return requestJson(`/projects/${projectId}/plan-kinds`); +} + +export function addPlanKind( + projectId: string, + name: string, + pieces: string[], +): Promise { + return requestJson(`/projects/${projectId}/plan-kinds`, { + method: "POST", + body: JSON.stringify({ name, pieces }), + }); +} + +export function deletePlanKind(projectId: string, kindId: string): Promise { + return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { + method: "DELETE", + }); +} + +export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise { + return requestJson(`/projects/${projectId}/plan-marks`, { + method: "PUT", + body: JSON.stringify({ marks }), + }); +} + /** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */ export interface FrameTemplateResponse { status: string; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 5cd7b9bca..8625e4f5f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -420,7 +420,23 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); - const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass); + const drawingPanel = buildDrawingSidePanel( + drawings, + selectDrawing, + drawingError, + devBypass, + projectId + ? { + projectId, + // 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. + onChanged: () => { + if (!mayDiscardEdits()) return; + cad.destroy(); + void renderB07DesignDetail(root); + }, + } + : undefined, + ); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index a3fde8268..59bfc6f72 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -9,6 +9,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; +import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -28,10 +29,8 @@ export const DRAWING_GROUPS: readonly { idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - { label: "계획평면도(지형)", idPrefix: "plan_terrain" }, - { label: "계획평면도(노선배치도)", idPrefix: "plan_route" }, - { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, - { label: "계획평면도(라이다)", idPrefix: "plan_lidar" }, + // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장. + { label: "계획평면도", kind: "plan" }, { label: "종단면도", kind: "longitudinal" }, { label: "표준 횡단면도", kind: "cross_standard" }, { label: "횡단면도", kind: "cross" }, @@ -46,6 +45,7 @@ export function buildDrawingSidePanel( onSelect: (drawing: DesignDrawingItem) => void, errorMessage?: string, devBypass = false, + plan?: PlanKindsControls, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; @@ -89,6 +89,12 @@ export function buildDrawingSidePanel( }; for (const group of DRAWING_GROUPS) { + if (group.kind === "plan") { + // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. + const plans = drawings.filter((item) => item.kind === "plan"); + panel.append(buildPlanSection(plans, drawingButton, plan)); + continue; + } const items = group.kind ? drawings.filter((item) => item.kind === group.kind) : group.idPrefix diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts new file mode 100644 index 000000000..65a634851 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -0,0 +1,196 @@ +/* ============================================================================= + * B07_DesignDetail_UI_PlanKinds.ts + * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · + * 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장). + * + * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { + addPlanKind, + deletePlanKind, + fetchPlanKinds, + savePlanMarks, + type DesignDrawingItem, + type PlanKindsState, + type PlanMark, +} from "./B07_DesignDetail_Api_Fetch"; + +export interface PlanKindsControls { + projectId: string; + /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ + onChanged: () => void; +} + +function el( + tag: K, + className: string, + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */ +async function run(action: () => Promise, controls: PlanKindsControls): Promise { + try { + await action(); + controls.onChanged(); + } catch (error) { + showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error"); + } +} + +function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { + const form = el("div", "b07-plan-tools__block"); + form.append(el("strong", "", "종류 더하기")); + const name = el("input", "b07-plan-tools__name"); + name.type = "text"; + name.placeholder = "종류 이름"; + name.maxLength = 40; + const boxes = state.pieces.map((piece) => { + const label = el("label", "b07-plan-tools__piece"); + const box = el("input", ""); + box.type = "checkbox"; + box.value = piece.id; + label.append(box, document.createTextNode(piece.name)); + return { label, box }; + }); + const pieceList = el("div", "b07-plan-tools__pieces"); + pieceList.append(...boxes.map((item) => item.label)); + const add = el("button", "b07-plan-tools__button", "더하기"); + add.type = "button"; + add.addEventListener("click", () => { + const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value); + void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls); + }); + form.append(name, pieceList, add); + return form; +} + +function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { + const form = el("div", "b07-plan-tools__block"); + form.append(el("strong", "", "대피소 · 사토장 자리")); + form.append( + el( + "p", + "b07-plan-tools__hint", + "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.", + ), + ); + const rows = el("div", "b07-plan-tools__marks"); + const readers = new Map PlanMark>(); + const addRow = (mark: PlanMark): void => { + const row = el("div", "b07-plan-tools__mark"); + const chainage = el("input", "b07-plan-tools__chainage"); + chainage.type = "number"; + chainage.min = "0"; + chainage.step = "0.1"; + chainage.value = String(mark.chainage_m); + const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => { + const label = el("label", "b07-plan-tools__piece"); + const box = el("input", ""); + box.type = "checkbox"; + box.checked = checked; + label.append(box, document.createTextNode(text)); + return [label, box]; + }; + const [refugeLabel, refuge] = flag("대피소", mark.refuge); + const [spoilLabel, spoil] = flag("사토장", mark.spoil); + const remove = el("button", "b07-plan-tools__remove", "×"); + remove.type = "button"; + remove.title = "이 자리 빼기"; + remove.addEventListener("click", () => { + readers.delete(row); + row.remove(); + }); + row.append(chainage, refugeLabel, spoilLabel, remove); + readers.set(row, () => ({ + chainage_m: Number(chainage.value), + refuge: refuge.checked, + spoil: spoil.checked, + })); + rows.append(row); + }; + state.marks.forEach(addRow); + const more = el("button", "b07-plan-tools__button", "자리 더하기"); + more.type = "button"; + more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false })); + const save = el("button", "b07-plan-tools__button", "자리 저장"); + save.type = "button"; + save.addEventListener("click", () => { + const marks = [...readers.values()].map((read) => read()); + void run(() => savePlanMarks(controls.projectId, marks), controls); + }); + const actions = el("div", "b07-plan-tools__actions"); + actions.append(more, save); + form.append(rows, actions); + return form; +} + +/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */ +export function buildPlanSection( + items: DesignDrawingItem[], + makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, + controls?: PlanKindsControls, +): HTMLElement { + const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); + section.dataset.kind = "plan"; + section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`)); + + const kinds = new Map(); + for (const item of items) { + const key = item.plan_kind ?? item.id; + kinds.set(key, [...(kinds.get(key) ?? []), item]); + } + for (const [kindId, sheets] of kinds) { + const name = sheets[0].plan_kind_name ?? sheets[0].label; + const block = el("div", "b07-plan-kind"); + block.dataset.planKind = kindId; + // 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추. + const head = el("div", "b07-plan-kind__head"); + const buttons = sheets.map((drawing, index) => + makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), + ); + head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); + if (controls) { + const remove = el("button", "b07-plan-tools__remove", "×"); + remove.type = "button"; + remove.title = `「${name}」 종류 지우기`; + remove.addEventListener("click", () => { + if ( + !window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`) + ) + return; + void run(() => deletePlanKind(controls.projectId, kindId), controls); + }); + head.append(remove); + } + block.append(head, ...(sheets.length > 1 ? buttons : [])); + section.append(block); + } + + if (controls) { + // 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. + const tools = el("details", "b07-plan-tools"); + tools.append(el("summary", "", "종류 더하기 · 대피소 자리")); + tools.addEventListener("toggle", () => { + if (!tools.open || tools.dataset.loaded) return; + tools.dataset.loaded = "true"; + void fetchPlanKinds(controls.projectId) + .then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls))) + .catch((error: unknown) => { + delete tools.dataset.loaded; + showToast( + error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.", + "error", + ); + }); + }); + section.append(tools); + } + return section; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 3af80ed70..d278b1b24 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -234,3 +234,78 @@ } /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ + +/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */ +.b07-plan-kind { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.b07-plan-kind__head, +.b07-plan-tools__mark, +.b07-plan-tools__actions { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.b07-plan-kind__name { + flex: 1; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-plan-tools { + margin-top: var(--spacing-4); + font-size: var(--text-body-sm); +} + +.b07-plan-tools summary { + color: var(--color-text-muted); + cursor: pointer; +} + +.b07-plan-tools__block { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-top: var(--spacing-8); +} + +.b07-plan-tools__pieces { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); +} + +.b07-plan-tools__piece { + display: flex; + align-items: center; + gap: var(--spacing-4); + white-space: nowrap; +} + +.b07-plan-tools__hint { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-plan-tools__chainage { + width: 72px; + min-width: 0; +} + +.b07-plan-tools__button, +.b07-plan-tools__remove { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; +} + +.b07-plan-tools__remove { + padding: 0 var(--spacing-8); +}