From ae515ee778e82363a45856accfd14372900ca488 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:02:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06=20=EC=B8=A1=EC=A0=90):=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=B8=A1=EC=A0=90=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=C2=B7=20=EC=8B=AC=EA=B8=B0=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=C2=B7=20=EC=A2=8C=ED=91=9C=20=EB=94=B0=EB=9D=BC=EA=B0=90=20(36?= =?UTF-8?q?8-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 계획 짝 planStations(plus) ↔ plan_stations(plus=) — 주인 plus-{m} · 이름 "" · 같은 m 은 관 · 구조물이 이김 - 정본 B05_Profile/route/plus_stations.json(x · y) · GET/PUT /section/plus-stations · PUT 뒤 측점 맞춤 - 초안 plus-stations(설계 가게 plusStations) · [저장] 차례 구조물 뒤 · 계획고 앞 - 노선 저장 = 좌표로 따라감 · 되살림 대상 · B05 종단 · B06 목록 바뀜 신호 - 종단 세로선 plus 표시(점선 · 끌기 없음 자리) - 거울 시험 272_1 · 322_3 · 344_3 에 추가 측점 경우 · 322_3 관 id 고정(347) 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/b_page_state.ts | 2 + A00_Common/design/design_save.ts | 7 +- A00_Common/design/design_store.ts | 3 + A00_Common/design/design_structure_status.ts | 3 +- A00_Common/design/design_view_profile.ts | 4 + B05_Profile/B05_Profile_Api_PlusStations.ts | 78 ++++++++++++++++ B05_Profile/B05_Profile_Engine_Sections.py | 5 +- B05_Profile/B05_Profile_Plus_Stations.py | 93 +++++++++++++++++++ B05_Profile/B05_Profile_Route_Save.py | 8 ++ B05_Profile/B05_Profile_UI_Page_Structures.ts | 2 + B05_Profile/B05_Profile_UI_Profile_Layout.ts | 3 + B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Router_Stations.py | 50 ++++++++++ B06_Section/B06_Section_Section_Drafts.ts | 4 +- B06_Section/B06_Section_Section_Store.ts | 5 +- B06_Section/B06_Section_UI_Longitudinal.ts | 4 +- common_util/common_util_structure_station.py | 25 ++++- common_util/common_util_structure_station.ts | 33 ++++++- .../tester/helper_272_1_station_list.cjs | 3 + .../tester/helper_273_2_preview_stations.cjs | 1 + resources/tester/helper_322_3_plan.cjs | 6 +- .../tester/helper_344_3_pipeline_stations.cjs | 11 ++- resources/tester/helper_design_store_save.cjs | 3 + resources/tester/test_272_1_station_list.py | 18 ++++ resources/tester/test_322_3_plan_mirror.py | 52 ++++++++++- .../test_344_3_pipeline_stations_mirror.py | 18 +++- resources/tester/test_368_2_plus_stations.py | 39 ++++++++ resources/tester/test_design_store_save.py | 3 + 28 files changed, 461 insertions(+), 24 deletions(-) create mode 100644 B05_Profile/B05_Profile_Api_PlusStations.ts create mode 100644 B05_Profile/B05_Profile_Plus_Stations.py create mode 100644 resources/tester/test_368_2_plus_stations.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3b0edc52f..d2d6d8187 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -124,6 +124,8 @@ export const STATE_REGISTRY = { "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, + /** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */ + "plus-stations": { bucket: "draft", scope: "project" }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` }, /** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림. diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index d16dc1e04..a1fb71aec 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -2,7 +2,7 @@ * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * - * 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 @@ -28,6 +28,7 @@ import { import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { rebasePipeDraft } from "../../common_util/common_util_route_follow"; import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { flushUphillOverrides, saveProfileAlignment, @@ -51,6 +52,7 @@ export const DESIGN_SAVE_ORDER = [ "route", "pipes", "structures", + "plusStations", "alignment", "culvertOptions", "uphill", @@ -130,6 +132,7 @@ async function flushAlignmentDraft( const TOLERANT: Partial> = { pipes: "배수관", structures: "구조물", + plusStations: "추가 측점", alignment: "계획선", }; @@ -145,6 +148,8 @@ function defaultStep( return () => flushPendingPipes(projectId); case "structures": return () => flushPendingStructures(projectId); + case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) + return () => flushPlusStations(projectId); case "alignment": return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 9f5199e22..03742a1fa 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -42,6 +42,8 @@ export interface DesignSelection { export interface DesignStoreValues { /** 미저장 구조물 목록(정본 `structures.json`). */ structures: StructureInstance[] | null; + /** 미저장 추가 측점(정수 m · 정본 `plus_stations.json` · 368-2). */ + plusStations: number[] | null; /** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */ pipes: DetailPipeInput[] | null; /** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */ @@ -119,6 +121,7 @@ function slotOf( const name = ( { structures: "structures", + plusStations: "plus-stations", pipes: "pipes", sectionDetail: "section-detail", selection: "selection", diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 79203c190..a656bc223 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -45,7 +45,7 @@ export function plannedIrregular( } /** - * 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 · + * 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 · * 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). * 돌려주는 함수로 끊음. */ @@ -66,6 +66,7 @@ export function watchDrafts(projectId: string | null, onChange: () => void): () let seen = layout(shop.get("pipes")); const offs = [ shop.subscribe("structures", onChange), + shop.subscribe("plusStations", onChange), // 추가 측점(368-2) shop.subscribe("pipes", (pipes) => { const key = layout(pipes); if (key === seen) return; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index b64c50514..eb68b34c7 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -419,6 +419,10 @@ export async function mountProfileView( // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load()); host.onDispose(onStaleStructureTypes(reloadIfShown)); + // 추가 측점(368-2) — 초안 · 저장분이 바뀌면 측점 계획을 다시 맞춤(종단 측점선 · 3D) + host.onDispose( + useDesignStore(activeProjectId, null).subscribe("plusStations", () => bridge.resyncStations()), + ); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts new file mode 100644 index 000000000..3f1ebd26c --- /dev/null +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * B05_Profile_Api_PlusStations.ts + * 추가 측점(368-2 · 요구 240-21) — 종단 원지반선에서 넣은 일반 측점(정수 m · 이름 없음). + * + * 초안 = 설계 가게 `plusStations`(세션 · 되돌리기 사진첩에 듦) · 저장분 = 서버 `plus_stations.json` + * (`GET/PUT /section/plus-stations`) · [저장] 차례 = 구조물 뒤 · 계획고 앞(`design_save`). + * 측점 계획(`planStations`)이 읽는 곳 = 초안 ?? 저장분(`setPlusStations` · 부를 때마다 읽음). + * ========================================================================== */ + +import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend"; +import { useDesignStore } from "../A00_Common/design/design_store"; + +const saved = new Map(); +const loaded = new Map>(); + +async function request(projectId: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/plus-stations`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + ...init, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) + throw new Error(payload?.message ?? `요청이 실패했습니다 (${response.status}).`); + return Array.isArray(payload?.chainages) ? payload.chainages.map(Number) : []; + } finally { + window.clearTimeout(timer); + } +} + +export const readPlusDraft = (projectId: string): number[] | null => + useDesignStore(projectId, null).get("plusStations"); + +/** 지금 추가 측점 — 초안이 있으면 초안(빈 목록 = 다 지움) · 없으면 저장분. */ +export const plusStationsOf = (projectId: string): number[] => + readPlusDraft(projectId) ?? saved.get(projectId) ?? []; + +/** 초안을 씀(정렬 · 같은 m 하나) — 바뀜 신호로 B05 종단 · B06 목록이 다시 맞춤. */ +export function writePlusDraft(projectId: string, next: number[] | null): void { + const list = next && [...new Set(next.map(Math.round))].sort((a, b) => a - b); + useDesignStore(projectId, null).set("plusStations", list); +} + +/** 저장분이 바뀜 — 듣는 쪽을 부름(초안 칸 값 그대로 다시 씀). */ +function notify(projectId: string): void { + useDesignStore(projectId, null).set("plusStations", readPlusDraft(projectId)); +} + +/** 저장분을 한 번 받음(못 받으면 빈 목록) — 읽는 곳 두기(`setPlusStations`)는 횡단 상세 가게 몫. */ +export function loadSavedPlusStations(projectId: string): Promise { + if (!loaded.has(projectId)) + loaded.set( + projectId, + request(projectId) + .then((list) => { + saved.set(projectId, list); + if (list.length) notify(projectId); + }) + .catch(() => undefined), + ); + return loaded.get(projectId)!; +} + +/** [저장] 차례 — 초안이 있으면 정본에 씀 · 없으면 저장분만 다시 받음(노선 저장이 좌표로 옮겼을 수 있음). */ +export async function flushPlusStations(projectId: string): Promise { + const draft = readPlusDraft(projectId); + const list = draft + ? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) }) + : await request(projectId); + saved.set(projectId, list); + loaded.set(projectId, Promise.resolve()); + if (draft) useDesignStore(projectId, null).set("plusStations", null); + else notify(projectId); +} diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 335c8f503..6c38e87f6 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -282,7 +282,10 @@ def resolve_extra_stations( (anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이 먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다. 거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`. + 추가 측점(368-2) = `plus_stations.json` · 이름 "". """ + from B05_Profile.B05_Profile_Plus_Stations import plus_chainages + try: structures = load_structures(str(project_root))[1] except Exception: @@ -290,7 +293,7 @@ def resolve_extra_stations( structures = [] return tuple( (float(station["chainage_m"]), str(station["title"])) - for station in plan_stations(pipes, structures) + for station in plan_stations(pipes, structures, plus_chainages(project_root)) ) diff --git a/B05_Profile/B05_Profile_Plus_Stations.py b/B05_Profile/B05_Profile_Plus_Stations.py new file mode 100644 index 000000000..7865737e2 --- /dev/null +++ b/B05_Profile/B05_Profile_Plus_Stations.py @@ -0,0 +1,93 @@ +"""추가 측점 정본(`B05_Profile/route/plus_stations.json`) — 구조물 정본 곁(368-2 · 요구 240-21). + +사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이 +바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(`follow_structures` 재사용). +측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Sequence + +from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_follow import follow_structures + +PLUS_STATIONS_FILE_NAME = "plus_stations.json" + + +def plus_stations_path(project_root: str | Path) -> Path: + return Path(project_root) / "B05_Profile" / "route" / PLUS_STATIONS_FILE_NAME + + +def load_plus_stations(project_root: str | Path) -> list[dict[str, Any]]: + """[{chainage_m, x, y}] — 없거나 깨졌으면 빈 목록.""" + path = plus_stations_path(project_root) + if not path.is_file(): + return [] + try: + rows = json.loads(path.read_text(encoding="utf-8")).get("stations") or [] + except (OSError, ValueError, AttributeError): + return [] + return [ + row + for row in rows + if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float)) + ] + + +def plus_chainages(project_root: str | Path) -> list[float]: + return sorted({float(row["chainage_m"]) for row in load_plus_stations(project_root)}) + + +def save_plus_stations( + project_root: str | Path, chainages: Sequence[float], xy: Sequence[Sequence[float]] | None +) -> list[float]: + """정수 m 로 맞춰(0 ~ 노선 끝 · 같은 m 하나) 좌표와 함께 씀 → 쓴 누가거리.""" + from shapely.geometry import LineString + + line = LineString([(float(p[0]), float(p[1])) for p in xy]) if xy and len(xy) > 1 else None + end = float(line.length) if line is not None else float("inf") + values = sorted({float(round(c)) for c in chainages if 0 <= round(c) <= end}) + rows = [] + for value in values: + point = line.interpolate(value) if line is not None else None + rows.append( + { + "chainage_m": value, + "x": round(float(point.x), 4) if point is not None else None, + "y": round(float(point.y), 4) if point is not None else None, + } + ) + path = plus_stations_path(project_root) + if not rows and not path.exists(): + return [] + os.makedirs(path.parent, exist_ok=True) + atomic_write_json(str(path), {"stations": rows}) + return values + + +def follow_plus_stations(project_root: str | Path, new_xy: Sequence[Sequence[float]]) -> int: + """노선이 바뀔 때 좌표를 새 노선에 투영해 누가거리를 다시 매김 → 옮긴 수(같은 m 는 하나).""" + rows = load_plus_stations(project_root) + items = [ + SimpleNamespace(chainage_m=float(r["chainage_m"]), x=r.get("x"), y=r.get("y")) for r in rows + ] + if not items: + return 0 + before = [item.chainage_m for item in items] + follow_structures(items, new_xy) + moved = sum(a != item.chainage_m for a, item in zip(before, items)) + if moved: + seen: set[float] = set() + kept = [] + for item in sorted(items, key=lambda i: i.chainage_m): + if item.chainage_m in seen: + continue + seen.add(item.chainage_m) + kept.append({"chainage_m": item.chainage_m, "x": item.x, "y": item.y}) + atomic_write_json(str(plus_stations_path(project_root)), {"stations": kept}) + return moved diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index fc4b175bb..9829b0680 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -356,6 +356,7 @@ async def run_route_save_chain( from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR + from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations, plus_stations_path from B05_Profile.B05_Profile_Router import ( _section_options, confirm_latest_route, @@ -399,6 +400,7 @@ async def run_route_save_chain( project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"), pipe_points_path(stored_path), Path(structures_file_path(str(project_root))), + plus_stations_path(project_root), ], [stage_dir / "longitudinal", stage_dir / "cross_sections"], ) @@ -412,6 +414,12 @@ async def run_route_save_chain( except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) structures = {"moved": 0, "far": 0} + try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로) + await asyncio.to_thread( + follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] + ) + except Exception: # noqa: BLE001 + logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) request = redesign_solve_request(params, selection, surface_model_id) solved: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solved, JSONResponse): diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index adf0e3264..9445652df 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -335,6 +335,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId), }); }, + /** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */ + resyncStations: () => syncCrossDrainStations(), /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index 859c49c75..f5cdbfcd3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -16,6 +16,7 @@ import { irregularStationId, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; +import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station"; /** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서 * 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */ @@ -62,6 +63,8 @@ export function irregularGraphStations( chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, + // 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음 + ...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}), center_z: null, azimuth_deg: null, center_x: 0, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 817804c38..1c614bb0a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -121,6 +121,8 @@ export interface SectionStation { structure?: string; /** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */ grid?: boolean; + /** 추가 측점(368-2 · 사용자가 원지반선에서 넣은 일반측점) — 일반측점 색 점선 · 끌기 없음. */ + plus?: boolean; center_z: number | null; azimuth_deg: number | null; /** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */ diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index f0e7fc6db..3dcfde6c2 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -347,6 +347,56 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse: ) +class PlusStationsRequest(BaseModel): + """추가 측점 목록(정수 m · 368-2) — 통째로 갈음.""" + + model_config = ConfigDict(extra="forbid") + + chainages: list[float] = Field(default_factory=list, max_length=500) + + +@router.get("/{project_id}/section/plus-stations") +async def get_plus_stations(project_id: UUID) -> JSONResponse: + """추가 측점 정본(누가거리 목록) — 없으면 빈 목록.""" + from B05_Profile.B05_Profile_Plus_Stations import plus_chainages + + paths = await _project_paths(project_id) + chainages = await asyncio.to_thread(plus_chainages, paths[0]) if paths else [] + return JSONResponse(content={"status": "success", "chainages": chainages}) + + +@router.put("/{project_id}/section/plus-stations") +async def put_plus_stations(project_id: UUID, request: PlusStationsRequest) -> JSONResponse: + """추가 측점 정본을 씀([저장] 차례 · 계획고 앞) — 좌표(x · y) = 지금 노선 위 · 측점을 맞춤 + (`ensure_structure_stations` — 새 자리 세움 · 지운 자리 걷음).""" + from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline + from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations + + chainages = [c for c in request.chainages if math.isfinite(c)] + try: + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 노선이 없습니다."}, + ) + project_root, route = paths + + def _save() -> list[float]: + xy = _load_route_polyline(project_root, str(route["route_data_path"])) + return save_plus_stations(project_root, chainages, xy) + + saved = await asyncio.to_thread(_save) + await ensure_structure_stations(project_id) + except Exception: + logger.exception("B06 추가 측점 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "추가 측점을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "chainages": saved}) + + #: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게. PREVIEW_MAX_STATIONS = 60 diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 64d6f1927..605d953b2 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -21,6 +21,7 @@ import { type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { readPlusDraft } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { registryDefaults } from "@util/common_util_culvert_sets"; import { attachShapes } from "@util/common_util_structure_attach"; import { @@ -184,7 +185,8 @@ export async function withDraftStations( let live = all; let stale: CrossSection[] = []; let fresh: CrossSection[] = []; - if (pipesDraft || structuresDraft) { + // 추가 측점 초안(368-2)도 측점 계획을 바꿈 — 계획은 그 초안을 스스로 읽음 + if (pipesDraft || structuresDraft || readPlusDraft(projectId)) { const inputs = await statusOf(projectId, pipesDraft, structuresDraft); if (inputs) { const [pipes, structures, types] = inputs; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b6bedd8c1..8b95f0752 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -19,7 +19,8 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { setGeneralStations } from "../common_util/common_util_structure_station"; +import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station"; +import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { forgetDraftStations, withDraftShapes, @@ -65,6 +66,8 @@ async function withDraftWalls( routeId: number, ): Promise { setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) + setPlusStations(() => plusStationsOf(projectId)); // 측점 계획이 읽을 추가 측점(368-2) + await loadSavedPlusStations(projectId); await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) await withDraftShapes(detail, projectId); return withStructureAreas(detail); diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 3026c01fe..19f1f1236 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -408,7 +408,7 @@ export function createLongitudinalProfile( if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); }); // 구조물 측점선만 끌 수 있다 — 규칙 측점은 격자라 옮길 대상이 아니다. - if (onDragStation && station.kind === "irregular") { + if (onDragStation && station.kind === "irregular" && !station.plus) { attachStationDrag(marker, svg, station, x, xInverse, onDragStation); } marker.append( @@ -424,7 +424,7 @@ export function createLongitudinalProfile( y1: LONG_PAD.top, x2: stationX, y2: heightPx - padBottom, - class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, + class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.plus ? "plus" : station.kind}`, }), // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index ef0370865..1a4af5c82 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -5,7 +5,7 @@ 234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167). TS 짝 = `common_util/common_util_structure_station.ts` (화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 · -측점 계획 `plan_stations` ↔ `planStations` · 322-3). +측점 계획 `plan_stations` ↔ `planStations` · 322-3 · 추가 측점 `plus` 368-2). 거울 시험 = `resources/tester/test_234_1_structure_station.py`. 일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀. """ @@ -106,12 +106,20 @@ def _station_marks( ] -def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]: +#: 추가 측점 주인 id 앞머리(368-2) — TS `PLUS_OWNER_PREFIX` 짝 +PLUS_OWNER_PREFIX = "plus-" + + +def plan_stations( + pipes: Sequence[object], structures: Sequence[object], plus: Sequence[float] = () +) -> list[dict[str, object]]: """관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`. 오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름) · 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상 부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌. + `plus` = 추가 측점(정수 m · 368-2) — 주인 `plus-{m}` · 이름 "" · 같은 m 에 관 · 구조물이 + 있으면 뺌. """ from common_util.common_util_structure_catalog import card_title, catalog_row @@ -164,6 +172,19 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list "pipe": False, } ) + own = list(marks) + for at in plus: + if all(abs(float(at) - mark["chainage_m"]) >= SAME_STATION_M for mark in own): + marks.append( + { + "chainage_m": float(at), + "role": "anchor", + "title": "", + "owner": f"{PLUS_OWNER_PREFIX}{round(float(at))}", + "blank": [], + "pipe": False, + } + ) marks.sort(key=lambda mark: mark["chainage_m"]) out: list[dict[str, object]] = [] for mark in marks: diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 9d14c770d..0bd1463b2 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -21,6 +21,10 @@ * `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 = * `resources/tester/test_272_1_station_list.py`. 종단 그래프 · 3D · 계획선 측점 줄(표 · ▲▼)이 * 이 목록 하나를 씀. + * + * 추가 측점(368-2 · 요구 240-21) — 사용자가 원지반선에서 넣은 정수 m(`plusStations`) · 측점 계획 끝에 + * 붙임(주인 `plus-{m}` · 이름 "" · 같은 m 에 관 · 구조물이 있으면 그쪽이 이김) · 파이썬 `plan_stations(plus=)` + * 짝 · 읽는 곳은 화면이 `setPlusStations` 로 둠(초안 · 저장분 · 서버 묶음 = 없음). * ========================================================================== */ import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog"; @@ -48,6 +52,18 @@ export function setGeneralStations( export const generalStations = (): readonly number[] => general; +let plusSource: () => readonly number[] = () => []; + +/** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */ +export function setPlusStations(source: (() => readonly number[]) | null): void { + plusSource = source ?? (() => []); +} + +export const plusStations = (): readonly number[] => plusSource(); + +/** 추가 측점 주인 id 앞머리 — 측점 계획 · 종단 측점 id 가 `plus-{m}`. */ +export const PLUS_OWNER_PREFIX = "plus-"; + /** 기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안. 두 번 맞춰도 같다. */ export function snapStructureStation( chainageM: number, @@ -187,13 +203,14 @@ export function planStations( structures: ReadonlyArray, types: ReadonlyArray, registry?: Readonly>>, + plus: ReadonlyArray = plusSource(), ): PlannedStation[] { const byId = new Map(types.map((type) => [type.type_id, type])); const defaults = (typeId: string): Record => registry?.[typeId] ?? Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default])); type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean }; - const marks: Mark[] = [ + const own: Mark[] = [ ...pipes.flatMap((pipe) => { const facility = pipe.facility ?? "pipe"; if (!catalogRow(facility)) return []; @@ -210,7 +227,19 @@ export function planStations( const blank = blankKeys(structure.type_id, structure.options); return marks.map((m) => ({ ...m, title, owner, blank, pipe: false })); }), - ].sort((a, b) => a.chainage_m - b.chainage_m); + ]; + // 추가 측점 — 같은 m 에 관 · 구조물이 있으면 뺌(그쪽이 이김) + const added: Mark[] = plus + .filter((at) => own.every((m) => Math.abs(m.chainage_m - at) >= SAME_STATION_M)) + .map((at) => ({ + chainage_m: at, + role: "anchor", + title: "", + owner: `${PLUS_OWNER_PREFIX}${Math.round(at)}`, + blank: [], + pipe: false, + })); + const marks = [...own, ...added].sort((a, b) => a.chainage_m - b.chainage_m); const out: Array = []; for (const mark of marks) { const last = out[out.length - 1]; diff --git a/resources/tester/helper_272_1_station_list.cjs b/resources/tester/helper_272_1_station_list.cjs index a5d9e158c..d3b9fcefe 100644 --- a/resources/tester/helper_272_1_station_list.cjs +++ b/resources/tester/helper_272_1_station_list.cjs @@ -28,4 +28,7 @@ const out = { list: station.oneStationList(input.server, input.fresh), nodes: station.followMovedNodes(input.nodes, input.gone, input.born), }; +// 368-2 추가 측점 — 화면이 둔 읽는 곳(setPlusStations)으로 심는 자리에 듦 +station.setPlusStations(() => input.plus); +out.plantedPlus = station.plantedChainages(input.pipes, input.structures, input.types); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 759e05c8f..8dcd9faf3 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -45,6 +45,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { readPendingStructures: () => null, }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes }, + "../B05_Profile/B05_Profile_Api_PlusStations": { readPlusDraft: () => null }, "@util/common_util_culvert_sets": { registryDefaults: () => ({ pipe: { pipe_kind: "파형강관", pipe_diameter_mm: 800 } }), buildCulvertSets: (points) => new Map(points.map((p) => [p.chainage_m, { kind: "pipe" }])), diff --git a/resources/tester/helper_322_3_plan.cjs b/resources/tester/helper_322_3_plan.cjs index 5c10edd8c..4caf9328e 100644 --- a/resources/tester/helper_322_3_plan.cjs +++ b/resources/tester/helper_322_3_plan.cjs @@ -1,6 +1,6 @@ /* PLAN 322-3 측점 계획 한 벌 거울 헬퍼 — 진짜 `common_util_structure_station.ts`(planStations) · * `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. - * 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures}}}`. 짝 = `test_322_3_plan_mirror.py`. + * 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures, plus?}}}`(plus = 추가 측점 368-2). 짝 = `test_322_3_plan_mirror.py`. */ const fs = require("fs"); const path = require("path"); @@ -38,6 +38,6 @@ const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catal const station = load(path.join(ROOT, "common_util", "common_util_structure_station.ts")); catalog.useCatalogDefinition({ types: input.types, ...input.definition }); const out = {}; -for (const [name, { pipes, structures }] of Object.entries(input.cases)) - out[name] = station.planStations(pipes, structures, input.types); +for (const [name, { pipes, structures, plus }] of Object.entries(input.cases)) + out[name] = station.planStations(pipes, structures, input.types, undefined, plus ?? []); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs index 602e7a910..d5dd84ffd 100644 --- a/resources/tester/helper_344_3_pipeline_stations.cjs +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -29,6 +29,7 @@ const logic = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Sectio const stage = loadTs( path.join(ROOT, "A00_Common", "design", "logic", "design_pipeline_stations.ts"), ); +const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts")); const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const out = { @@ -40,15 +41,17 @@ const out = { arm: logic.PLAN_RADIUS_ARM_M, straight: logic.PLAN_RADIUS_STRAIGHT_M, }, - cases: job.cases.map((c) => - logic.buildStations(c.line, c.curves, job.pipes, job.structures, { + cases: job.cases.map((c) => { + station.setPlusStations(() => c.plus ?? []); // 368-2 추가 측점 — 화면이 둔 읽는 곳 + return logic.buildStations(c.line, c.curves, job.pipes, job.structures, { types: job.types, stationSnap: c.snap, - }), - ), + }); + }), text: [0, 12.345, 40, 699.996].map(logic.stationText), }; +station.setPlusStations(null); // 노선 한 점 옮김 — 단계가 범위 밖 측점을 앞 객체 그대로 · 열쇠 그대로 const run = (line, curves, prev, range) => stage.createStationsStage({ types: () => job.types })({ diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 2d90fc0de..7f3e4aca7 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -63,6 +63,9 @@ const STUBS = { "../../B05_Profile/B05_Profile_Api_Structures": { flushPendingStructures: () => record("structures", failStructures)(), }, + "../../B05_Profile/B05_Profile_Api_PlusStations": { + flushPlusStations: () => record("plusStations")(), + }, "../../B05_Profile/B05_Profile_Api_Fetch": { flushUphillOverrides: () => record("uphill")(), saveProfileAlignment: async (_p, routeId, edits) => diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index f7b7cc380..de9c205d1 100644 --- a/resources/tester/test_272_1_station_list.py +++ b/resources/tester/test_272_1_station_list.py @@ -68,6 +68,8 @@ STRUCTURES = [ {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, ] EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 545, 560] +# 368-2 추가 측점 — 47 만 듦(95 관 · 300 세월교 자리는 그쪽이 이김) +PLUS = [47.0, 95.0, 300.0] # 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · # 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 @@ -128,6 +130,7 @@ def ts(tmp_path_factory) -> dict: "nodes": fixture["profile_alignment"]["base_pvi"], "gone": [s["chainage_m"] for s in fixture["stations"] if s.get("kind") == "irregular"], "born": [s["chainage_m"] for s in _snapped(fixture)], + "plus": PLUS, } source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") proc = subprocess.run( # noqa: S603 — 고정 실행 파일 @@ -154,6 +157,21 @@ def test_planted_mirror(tmp_path: Path, ts) -> None: assert ts["planted"] == EXPECTED +def test_planted_mirror_with_plus(tmp_path: Path, ts) -> None: + route = tmp_path / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + rows = [{"chainage_m": at, "x": None, "y": None} for at in PLUS] + (route / "plus_stations.json").write_text(json.dumps({"stations": rows}), encoding="utf-8") + extras = resolve_extra_stations(tmp_path, parse_pipe_points(PIPES)) + assert sorted(mark for mark, _label in extras) == sorted(EXPECTED + [47]) + assert dict(extras)[47.0] == "" + assert ts["plantedPlus"] == sorted(EXPECTED + [47]) + + def test_one_list_mirror(tmp_path: Path, ts) -> None: rel = "B06_Section/longitudinal/longitudinal.json" path = tmp_path / rel diff --git a/resources/tester/test_322_3_plan_mirror.py b/resources/tester/test_322_3_plan_mirror.py index ae2e51d0b..6854ae3ea 100644 --- a/resources/tester/test_322_3_plan_mirror.py +++ b/resources/tester/test_322_3_plan_mirror.py @@ -81,16 +81,42 @@ CASES = { "structures": [], }, "empty": {"pipes": [], "structures": []}, + # 368-2 추가 측점 — 이름 "" · 주인 plus-{m} · 같은 m 에 관 · 구조물이 있으면 그쪽이 이김 + "plus": { + "pipes": [{"chainage_m": 95.0, "source": "user"}], + "structures": [ + _s( + "retaining_wall", + "s2", + start_m=500.0, + chainage_m=510.0, + end_m=530.0, + options={"height_m": 3}, + ) + ], + "plus": [47.0, 12.0, 95.0, 510.0, 47.0], + }, } @pytest.fixture(scope="module") def ts() -> dict: definition = load_definition() + # 관 id = 파이썬이 읽을 때 매기는 고정 id(347) — TS 에도 같은 id 로 넘김 + cases = { + name: { + **case, + "pipes": [ + {**pipe, "id": parsed.id} + for pipe, parsed in zip(case["pipes"], parse_pipe_points(case["pipes"])) + ], + } + for name, case in CASES.items() + } payload = { "definition": definition, "types": with_visible().model_dump()["types"], - "cases": CASES, + "cases": cases, } with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "input.json" @@ -109,7 +135,7 @@ def ts() -> dict: def _py(case: dict) -> list[dict]: pipes = parse_pipe_points(case["pipes"]) structures = [StructureInstance.model_validate(s) for s in case["structures"]] - return plan_stations(pipes, structures) + return plan_stations(pipes, structures, case.get("plus", ())) def test_mirror_python_equals_ts(ts: dict) -> None: @@ -133,7 +159,7 @@ def test_plan_rules(ts: dict) -> None: 560.0, ] first = mixed[0] # 관 95 + 맹암거 95.003 = 한 측점 · 이름 = 관 · 빈칸 = 관경 + 맹암거 높이 - assert first["owners"] == ["pipe-95.00", "s6"] + assert first["owners"] == ["p9500", "s6"] assert first["title"] == "파형강관 D1000" assert first["blank"] == ["pipe_diameter_mm", "culvert_height_m"] joint = next(s for s in mixed if s["chainage_m"] == 530.0) # 옹벽 끝 = 소단 시작 @@ -145,6 +171,26 @@ def test_plan_rules(ts: dict) -> None: assert ts["empty"] == [] +def test_plus_stations_rules(ts: dict) -> None: + plus = ts["plus"] + assert [(s["chainage_m"], s["title"], s["owners"]) for s in plus][:3] == [ + (12.0, "", ["plus-12"]), + (47.0, "", ["plus-47"]), + (95.0, "파형강관 D1000", ["p9500"]), + ] + assert next(s for s in plus if s["chainage_m"] == 510.0)["owners"] == ["s2"] + + +def test_server_supply_reads_plus_file(tmp_path: Path) -> None: + """서버 공급자 = 추가 측점 정본(plus_stations.json)까지 — 이름 "".""" + route = tmp_path / "B05_Profile" / "route" + route.mkdir(parents=True) + rows = [{"chainage_m": 47.0, "x": 1, "y": 2}, {"chainage_m": 12.0, "x": 0, "y": 0}] + (route / "plus_stations.json").write_text(json.dumps({"stations": rows}), encoding="utf-8") + extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 95.0}])) + assert list(extras) == [(12.0, ""), (47.0, ""), (95.0, "파형강관 D1000")] + + def test_server_supply_is_plan(tmp_path: Path) -> None: case = CASES["mixed"] route = tmp_path / "B05_Profile" / "route" diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py index 80a6a9157..60efa31d6 100644 --- a/resources/tester/test_344_3_pipeline_stations_mirror.py +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -112,11 +112,13 @@ def _types() -> list[dict]: ] -def _server(line, curves, snap): +def _server(line, curves, snap, plus=()): extras = tuple( (float(s["chainage_m"]), str(s["title"])) for s in plan_stations( - parse_pipe_points(PIPES), [SimpleNamespace(options=None, **item) for item in STRUCTURES] + parse_pipe_points(PIPES), + [SimpleNamespace(options=None, **item) for item in STRUCTURES], + plus, ) ) data = core.generate_sections( @@ -141,6 +143,8 @@ CASES = [ {"name": "곡선표", "line": LINE, "curves": CURVES, "snap": False}, {"name": "스냅", "line": LINE, "curves": CURVES, "snap": True}, {"name": "곡선표없음", "line": LINE, "curves": [], "snap": False}, + # 368-2 추가 측점 — 77 = 새 측점(주인 plus-77) · 57 = 관 자리(관이 이김) + {"name": "추가측점", "line": LINE, "curves": CURVES, "snap": False, "plus": [77.0, 57.0]}, ] @@ -194,7 +198,7 @@ _KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe" @pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) def test_서버_측점_목록과_같음(r, index): case = CASES[index] - server = _server(case["line"], case["curves"], case["snap"]) + server = _server(case["line"], case["curves"], case["snap"], case.get("plus", ())) ours = r["cases"][index] assert [s["chainage_m"] for s in server] == pytest.approx( [s["chainageM"] for s in ours], abs=_TOL @@ -238,6 +242,14 @@ def test_열쇠(r): assert r["text"] == ["0.00", "12.35", "40.00", "700.00"] +def test_추가_측점_열쇠(r): + ours = r["cases"][3] + keys = {s["key"]: s for s in ours} + assert keys["structure:plus-77"]["chainageM"] == 77.0 + assert "structure:plus-57" not in keys and keys["pipe:p57"]["chainageM"] == 57.0 + assert len(ours) == len(r["cases"][0]) + 1 + + def test_스냅_측점은_원래_격자_번호(r): plain = {s["key"]: s["chainageM"] for s in r["cases"][0] if s["kind"] == "grid"} snapped = {s["key"]: s["chainageM"] for s in r["cases"][1] if s["kind"] == "grid"} diff --git a/resources/tester/test_368_2_plus_stations.py b/resources/tester/test_368_2_plus_stations.py new file mode 100644 index 000000000..5a6c3fc72 --- /dev/null +++ b/resources/tester/test_368_2_plus_stations.py @@ -0,0 +1,39 @@ +"""368-2 추가 측점 정본(`plus_stations.json`) — 정수 m · 좌표 · 노선 바뀌면 좌표로 따라감 · 저장 차례.""" + +from __future__ import annotations + +from pathlib import Path + +from B05_Profile.B05_Profile_Plus_Stations import ( + follow_plus_stations, + load_plus_stations, + plus_chainages, + save_plus_stations, +) + +ROOT = Path(__file__).resolve().parents[2] +LINE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0)] + + +def test_저장_정수_m_좌표_노선_안(tmp_path: Path) -> None: + saved = save_plus_stations(tmp_path, [12.4, 47.0, 47.2, -3.0, 140.0], LINE) + assert saved == [12.0, 47.0] # 반올림 · 같은 m 하나 · 노선 밖 버림 + rows = load_plus_stations(tmp_path) + assert [(r["chainage_m"], r["x"], r["y"]) for r in rows] == [ + (12.0, 12.0, 0.0), + (47.0, 47.0, 0.0), + ] + assert save_plus_stations(tmp_path, [], LINE) == [] and plus_chainages(tmp_path) == [] + + +def test_노선이_바뀌면_좌표로_따라감(tmp_path: Path) -> None: + save_plus_stations(tmp_path, [12.0, 47.0], LINE) + # 시점을 10 m 앞으로 늘린 새 노선 — 같은 땅 자리 = 누가거리 +10 + moved = follow_plus_stations(tmp_path, [(-10.0, 0.0), (100.0, 0.0)]) + assert moved == 2 and plus_chainages(tmp_path) == [22.0, 57.0] + + +def test_저장_차례_계획고_앞() -> None: + save = (ROOT / "A00_Common/design/design_save.ts").read_text(encoding="utf-8") + order = save[save.index("DESIGN_SAVE_ORDER = [") :] + assert order.index('"structures"') < order.index('"plusStations"') < order.index('"alignment"') diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 59295fe34..8a3bf9664 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -52,6 +52,7 @@ def test_저장_차례(out): "route", "pipes", "structures", + "plusStations", # 368-2 추가 측점 — 계획고 앞 "alignment", "culvertOptions", "uphill", @@ -64,6 +65,7 @@ def test_저장_차례(out): "pipes", "pipes:screen", "structures", + "plusStations", 'alignment:7:{"20.000":0.1}', "culvert:aislo:draft:culvertopt:p1:7", "uphill", @@ -98,6 +100,7 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out): "route", "pipes", "structures", + "plusStations", 'alignment:9:{"30.000":0.2}', "culvert:aislo:draft:culvertopt:p1:7", "uphill",