From 4c96e8f8ddab83024765b2c29f4bf7f1d83515ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 22:59:53 +0900 Subject: [PATCH] =?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=A1=B0=EA=B0=81=20=EC=9E=98=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20API=20(67-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성토사면 외곽선 → 노견선 · 성토 외곽선 · 절토 외곽선 셋 · 배수시설 기호 조각 신설 · 라이다 음영 - 기본 넷 처음 체크 = 지금 그림(배수시설 기호는 빼 둠) · 옛 종류 파일 slope_outline 은 셋으로 펼침 - PUT /plan-kinds/{id} — 이름 · 조각 고침(기본 넷 포함) · 그 종류 확정본 풀림 - GET /plan-kinds 에 측점 간격 실음 — 측점 NO + 거리 입력용 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 152 +++++++++++++----- .../B07_DesignDetail_Router_PlanKinds.py | 36 ++++- .../B07_DesignDetail_Router_Support_Plan.py | 82 ++++++++-- resources/tester/test_b07_plan_kinds.py | 77 ++++++++- 4 files changed, 296 insertions(+), 51 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 34e5cc64b..cd51607fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -1,9 +1,10 @@ """B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). 계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. -정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · -대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 -무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). +정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO · +시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 +있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 +한다(파일 입출력 없음). 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). """ @@ -25,10 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_SIZE, _FONT_SIZE, _ROUTE_WIDTH, + _STRUCTURE_FONT_SIZE, + _STRUCTURE_SIZE_MM, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, + STRUCTURE_COLOR, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, @@ -40,21 +44,29 @@ from config.config_system import DRAWING_SCALE_PLAN # 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저). PIECES: tuple[tuple[str, str, bool], ...] = ( - ("lidar", "라이다 배경", True), + ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), ("slope_ticks", "비탈 눈금", False), - ("slope_outline", "성토사면 외곽선", False), + ("road_edge", "노견선", False), + ("fill_outline", "성토 외곽선", False), + ("cut_outline", "절토 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), + ("drainage", "배수시설 기호", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) +# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. +LEGACY_PIECES: dict[str, tuple[str, ...]] = { + "slope_outline": ("road_edge", "fill_outline", "cut_outline"), +} +_OUTLINE = LEGACY_PIECES["slope_outline"] -_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius") +_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") # 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, @@ -80,13 +92,16 @@ _COLORS = { "contour": CONTOUR_COLOR, "stream": STREAM_COLOR, "centerline": "#ff4d4d", - "slope_outline": "#4dd8e6", + "road_edge": "#4dd8e6", + "fill_outline": "#4dd8e6", + "cut_outline": "#4dd8e6", "slope_ticks": "#ff4d4d", "ip": "#4d7dff", "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", "refuge": "#7ddc7d", + "drainage": STRUCTURE_COLOR, } TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) @@ -129,6 +144,27 @@ def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float 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: @@ -137,11 +173,7 @@ def slope_end( 설계선(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)) - ) + 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] @@ -150,36 +182,61 @@ def slope_end( ) 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: + 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]]: - """측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다. +) -> 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, - (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 - 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. + left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 + 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted(records, key=lambda record: record["chainage_m"]) + 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": [], "stations": []}, {"ticks": []} + 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 = { @@ -192,6 +249,7 @@ def slope_pieces( 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) @@ -206,6 +264,8 @@ def slope_pieces( } 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"] @@ -213,15 +273,18 @@ def slope_pieces( 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} + 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( @@ -326,7 +389,7 @@ def _piece_entities( }, } ) - elif piece in ("contour", "stream", "slope_outline"): + elif piece in ("contour", "stream", *_OUTLINE): for index, points in enumerate(data.get("lines") or []): line(points, f":{index}") elif piece == "centerline": @@ -395,6 +458,23 @@ def _piece_entities( 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") + elif piece == "drainage": + size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) + for index, item in enumerate(data.get("items") or []): + cx, cy = paper((item["x"], item["y"])) + corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)] + diamond = polyline_entity( + drawing_id, + [(cx + sx * size, cy + sy * size) for sx, sy in corners], + layer, + color, + suffix=f":{index}", + ) + if diamond: + out.append(diamond) + text( + f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" + ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index a6ec7848d..051b9622e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -1,6 +1,6 @@ """B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장). -종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. +종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. 대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다. """ @@ -13,13 +13,17 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES +from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( add_kind, delete_kind, list_kinds, read_marks, + update_kind, write_marks, ) @@ -55,16 +59,27 @@ def _error(status: int, exc: Exception) -> JSONResponse: return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)}) +async def _station_interval(project_id: UUID) -> float | None: + """종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None.""" + try: + _route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + except (FileNotFoundError, PermissionError, OSError, ValueError): + return None + return infer_station_interval(longitudinal.get("stations") or []) + + @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) + state = await asyncio.to_thread(_state, root) + return {**state, "station_interval_m": await _station_interval(project_id)} @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) @@ -73,6 +88,19 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: return _error(400, exc) +@router.put("/{project_id}/plan-kinds/{kind_id}") +async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any: + """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + except FileNotFoundError as exc: + return _error(404, exc) + + @router.delete("/{project_id}/plan-kinds/{kind_id}") async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: """종류 지우기 — 기본 넷도 지울 수 있다.""" diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 70770f60e..1274d78e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -6,7 +6,7 @@ plan_marks.json 사용자가 정한 대피소 · 사토장 자리 `plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 -기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다. +기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) 에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. """ @@ -24,23 +24,35 @@ from B05_Profile.B05_Profile_Engine_Sections import ( 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_Plan import ( + _structure_label, + plan_area_mm, + plan_chunks, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( DEFAULT_KINDS, + LEGACY_PIECES, PIECE_IDS, curve_pieces, route_frame, + side_is_cut, slope_end, slope_pieces, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, + _plan_structures, _sheet_box, map_background, plan_stations, ) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _design_root, + _read_json, + _read_manifest, + _write_manifest, +) 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 @@ -76,7 +88,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: document = _read_json(path) except (OSError, ValueError): continue - pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS] + stored = set() + for piece in document.get("pieces") or []: + stored.update(LEGACY_PIECES.get(piece, (piece,))) + pieces = [piece for piece in PIECE_IDS if piece in stored] rows.append( ( int(document.get("order") or 0), @@ -100,13 +115,18 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: return kinds -def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: +def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]: 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("얹을 정보를 하나 이상 고르세요.") + return text, chosen + + +def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: + text, chosen = _checked(name, pieces) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") @@ -118,6 +138,31 @@ def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any] return {"id": kind_id, "name": text, "pieces": chosen} +def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: + """종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다.""" + if not KIND_ID.fullmatch(kind_id): + raise ValueError("올바르지 않은 종류 id 입니다.") + text, chosen = _checked(name, pieces) + kinds = _materialize(project_root) + if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): + raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") + path = _kinds_dir(project_root) / f"{kind_id}.json" + if not path.is_file(): + raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") + order = int(_read_json(path).get("order") or 0) + atomic_write_json(path, {"name": text, "pieces": chosen, "order": order}) + manifest = _read_manifest(project_root) + released = False + for drawing_id, entry in manifest["drawings"].items(): + match = PLAN_KIND_ID.fullmatch(drawing_id) + if match and match.group(1) == kind_id and entry.get("confirmed"): + entry["confirmed"] = False + released = True + if released: + _write_manifest(project_root, manifest) + 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 입니다.") @@ -223,6 +268,10 @@ def slope_records( "right_edge_m": right_edge, "right_end_m": slope_end(line, samples, right_edge, "right"), } + for side in ("left", "right"): + elevation = edges[side].get("elevation_m") + if isinstance(elevation, (int, float)): + record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation) 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: @@ -298,11 +347,14 @@ def build_pieces( pieces["stream"] = {"lines": background["streams"]} if "centerline" in wanted: pieces["centerline"] = {"lines": [route_xy]} - if {"slope_outline", "slope_ticks"} & set(wanted): + if {*LEGACY_PIECES["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 - ) + ( + pieces["road_edge"], + pieces["fill_outline"], + pieces["cut_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: @@ -341,6 +393,18 @@ def build_pieces( pieces["endpoints"] = {"items": items} if "refuge" in wanted: pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + if "drainage" in wanted: + min_x, min_y, max_x, max_y = box + pieces["drainage"] = { + "items": [ + {"x": float(item["x"]), "y": float(item["y"]), "label": _structure_label(item)} + for item in _plan_structures(context) + if isinstance(item.get("x"), (int, float)) + and isinstance(item.get("y"), (int, float)) + and min_x <= item["x"] <= max_x + and min_y <= item["y"] <= max_y + ] + } if "lidar" in wanted: try: image, image_box = _hillshade_for_box(context, box) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index da75092e7..a3cc127a6 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -4,6 +4,8 @@ ② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 ③ 대피소 · 사토장 자리 — 한 자리에 둘 다 ④ 고른 조각만 도면 층으로 얹힘 +⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · + 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 """ import json @@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( layer_id, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + _kinds_dir, _refuge_items, add_kind, delete_kind, list_kinds, read_marks, slope_records, + update_kind, write_marks, ) @@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No 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"] + edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0) + left_edge, right_edge = edge["lines"] + # 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐 + assert all(r["left_cut"] and not r["right_cut"] for r in records) + (left_end,), (right_end,) = cut["lines"], fill["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) @@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None: 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 + + +def test_split_runs_switch_cut_fill_and_stay_joined() -> None: + route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] + base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0} + records = [ + {"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False}, + {"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False}, + ] + _edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0) + (cut_left,) = cut["lines"] + fill_left = next(line for line in fill["lines"] if line[0][0] < 0) + assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐 + assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0 + + +def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: + root = tmp_path / "project" + outline = {"road_edge", "fill_outline", "cut_outline"} + for ( + kind + ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 + assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + folder = _kinds_dir(root) + folder.mkdir(parents=True) + (folder / "old.json").write_text( + json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8" + ) + (kind,) = list_kinds(root) + assert set(kind["pieces"]) == outline | {"station"} + + +def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: + root = tmp_path / "project" + manifest = _read_manifest(root) + manifest["drawings"] = { + "plan_terrain": {"confirmed": True}, + "plan_route": {"confirmed": True}, + } + _write_manifest(root, manifest) + update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침 + again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음 + assert again["terrain"] == { + "id": "terrain", + "name": "지형2", + "pieces": ["contour", "centerline"], + } + assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 + drawings = _read_manifest(root)["drawings"] + assert drawings["plan_terrain"]["confirmed"] is False + assert drawings["plan_route"]["confirmed"] is True + with pytest.raises(ValueError): + update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름 + with pytest.raises(ValueError): + update_kind(root, "terrain", "지형", []) + with pytest.raises(FileNotFoundError): + update_kind(root, "nokind", "없음", ["contour"]) + + +def test_drainage_symbol_piece() -> None: + pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}} + drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces) + mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] + assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 + assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)