diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index f17dbfe97..ba84ea700 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -462,7 +462,7 @@ function createPagePipeline( pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes, structures: (patch.structures as Facilities["structures"]) ?? now.structures, }); - followPlusDraft(projectId, patch.plusStations); // 추가 측점 = 좌표 따라감(459) + followPlusDraft(projectId, patch); // 추가 측점 = 좌표 따라감 · 노선 밖 알림(459) const line = pipe.output("route")?.line ?? []; const far = Array.isArray(patch.structures) ? followStructures( diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 0a0b812ca..5e39a4669 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -36,7 +36,7 @@ import { readPendingStructures, writePendingStructures, } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations"; +import { flushPlusStations, outsideNotice } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { flushUphillOverrides, saveProfileAlignment, @@ -266,6 +266,8 @@ export async function saveDesign( if (routeSaved.route_id != null) routeId = routeSaved.route_id; if (before) await rebasePendingPipes(ctx.projectId, before); if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures); + const outside = (routeSaved.pipes?.outside ?? 0) + (routeSaved.structures?.outside ?? 0); + if (outside) showToast(outsideNotice(outside), "warning"); if (routeSaved.follow_failed?.length) showToast( `${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`, diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index 562ce9897..fd3789e0f 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -75,16 +75,28 @@ const beforeRoute = new Map(); /** follow 만 쓴 초안(사용자 초안 없던 때) — 그대로면 [저장]이 PUT 안 함(서버 따라감이 정본 · 459). */ const followedOnly = new Map(); +/** 노선 밖 알림 글(459 · 458 「노선 밖 N 곳」과 같은 말). */ +export const outsideNotice = (count: number): string => + `노선 밖 ${count} 곳 — 측점 · 알약에서 뺌(좌표는 그대로 · 노선을 다시 늘이면 돌아옴)`; + +/** 프로젝트마다 마지막으로 알린 노선 밖 수 — 같으면 다시 안 알림. */ +const warnedOutside = new Map(); + +/** follow 출력 꼴(묶음 `FollowPatch` 중 쓰는 칸만). */ +interface FollowedDraft { + plusStations?: ReadonlyArray | null; + pipes?: unknown; + structures?: unknown; +} + /** - * 노선 초안 동안 추가 측점 따라감(459) — 묶음 follow 출력(`plusStations` · 새 노선 정수 m)을 초안에 씀 · - * `next = null` = 노선 초안을 버림(초안 전 값으로). 부르는 곳 = design_pipeline_page follow 구독 - * (관 · 구조물 `writeFacilities` 곁 · 초안 버림 갈래도). + * 노선 초안 동안 따라감 뒤처리(459) — 묶음 follow 출력의 추가 측점(새 노선 정수 m)을 초안에 씀 · 노선 밖 관 · + * 구조물 수를 알림(바뀐 때만). `next = null` = 노선 초안을 버림(초안 전 값으로). 부르는 곳 = + * design_pipeline_page follow 구독(관 · 구조물 `writeFacilities` 곁 · 초안 버림 갈래도). */ -export function followPlusDraft( - projectId: string, - next: ReadonlyArray | null | undefined, -): void { +export function followPlusDraft(projectId: string, next: FollowedDraft | null | undefined): void { if (next === null) { + warnedOutside.delete(projectId); if (!beforeRoute.has(projectId)) return; writePlusDraft(projectId, beforeRoute.get(projectId)!); beforeRoute.delete(projectId); @@ -92,8 +104,21 @@ export function followPlusDraft( return; } if (!next) return; + const outside = [next.pipes, next.structures] + .flatMap((list) => (Array.isArray(list) ? list : [])) + .filter((item) => (item as { outside?: boolean })?.outside).length; + if (outside !== (warnedOutside.get(projectId) ?? 0)) { + warnedOutside.set(projectId, outside); + // 알림 틀은 늦게 불러옴(이 모듈을 쓰는 Node 시험이 화면 모듈 없이 돎) + if (outside) + void import("@ui/ui_template_elements").then(({ showToast }) => + showToast(outsideNotice(outside), "warning", 6000), + ); + } + const plus = next.plusStations; + if (!plus) return; if (!beforeRoute.has(projectId)) beforeRoute.set(projectId, readPlusDraft(projectId)); - writePlusDraft(projectId, [...next]); + writePlusDraft(projectId, [...plus]); if (beforeRoute.get(projectId)) followedOnly.delete(projectId); else followedOnly.set(projectId, JSON.stringify(readPlusDraft(projectId))); } diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index dde65c0c8..02530740f 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -133,8 +133,9 @@ export interface RouteReplanResponse { total_length_m: number | null; vertex_count?: number; /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */ - pipes?: { kept: number; crossing: number }; - structures?: { moved: number; far: number }; + pipes?: { kept: number; crossing: number; outside?: number }; + /** `outside` = 노선 밖(시점 앞 · 종점 뒤 · 측점 안 세움 · 459). */ + structures?: { moved: number; far: number; outside?: number }; /** 따라감 실패한 것(「구조물」 · 「추가 측점」) — 옛 누가거리 그대로(459). */ follow_failed?: string[]; /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 2c7f147dd..a7588a8cc 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -305,7 +305,17 @@ export async function saveStructures( ): Promise { const saved = await requestJson( `/projects/${projectId}/route/structures`, - { method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) }, + { + method: "PUT", + body: JSON.stringify({ + base_revision: baseRevision, + // 노선 밖 표시(459)는 따라감 파생값 — 정본에 안 씀(서버 스키마가 모르는 칸 거부) + structures: structures.map((item) => { + const { outside: _outside, ...rest } = item as StructureInstance & { outside?: boolean }; + return rest; + }), + }), + }, ); noteSnapshotFailed(saved.snapshot_failed); staleSaved.delete(projectId); // 서버가 지금 판으로 적음 diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 2acef599e..2a6509a62 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -11,7 +11,7 @@ import logging import re from dataclasses import replace from pathlib import Path -from typing import Any +from typing import Any, Sequence from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, design_grade_line from B05_Profile.B05_Profile_Engine_Grade_Profile import ( @@ -276,7 +276,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any def resolve_extra_stations( - project_root: Path, pipes: list[PipePoint] + project_root: Path, pipes: list[PipePoint], xy: Sequence[Sequence[float]] | None = None ) -> tuple[tuple[float, str], ...]: """구조물(비정규) 측점의 **단일 공급자** — 관 정본 + 구조물 정본 A군에서 파생한다. @@ -292,14 +292,23 @@ def resolve_extra_stations( 먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다. 거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`. 추가 측점(368-2) = `plus_stations.json` · 이름 "". + `xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459). """ from B05_Profile.B05_Profile_Plus_Stations import plus_chainages + from common_util.common_util_route_follow import route_outside try: structures = load_structures(str(project_root))[1] except Exception: logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)") structures = [] + if xy: + pipes = [p for p in pipes if not route_outside(p.x, p.y, xy)] + structures = [ + s + for s in structures + if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy) + ] return tuple( (float(station["chainage_m"]), str(station["title"])) for station in plan_stations(pipes, structures, plus_chainages(project_root)) @@ -451,7 +460,7 @@ def run_section_generation( # 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다. pipes = _load_pipe_points(project_root, polyline) try: - extras = resolve_extra_stations(project_root, pipes) + extras = resolve_extra_stations(project_root, pipes, polyline) except Exception: logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)") extras = () diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index db3b15115..9053f0b7b 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -10,7 +10,8 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan `terrain` 대신 이것. `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, - initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped, + initial_refreshed, pipes: {kept, crossing, outside}, structures: {moved, far, outside}, + follow_failed, cross_dropped, drainage_stale: true, seconds}`. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 @@ -183,6 +184,7 @@ async def _place_pipes( route_signature, save_pipe_points, ) + from common_util.common_util_route_follow import route_outside prepared = await _prepare(project_id) streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음 @@ -198,7 +200,9 @@ async def _place_pipes( [pipe for _, pipe in pairs], vertices, ) - return pairs, {"kept": len(old_pipes), "crossing": crossing} + line = [(p["x"], p["y"]) for p in points] + outside = sum(route_outside(pipe.x, pipe.y, line) for _, pipe in pairs) # 459 · 측점 안 세움 + return pairs, {"kept": len(old_pipes), "crossing": crossing, "outside": outside} async def _carry_profile( diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 88a34f7b5..0ef9bb69c 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -407,8 +407,9 @@ def _structure_marks(project_root: Path, route_data_path: str) -> list[float]: resolve_extra_stations, ) - pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path)) - extras = resolve_extra_stations(project_root, pipes) + polyline = _load_route_polyline(project_root, route_data_path) + pipes = _load_pipe_points(project_root, polyline) + extras = resolve_extra_stations(project_root, pipes, polyline) return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras] diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index d9a1c43cc..256a655f5 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -560,7 +560,9 @@ async def _append_irregular_cross_sections( # 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은 # 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1). polyline = _load_route_polyline(project_root, route_data_path) - extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + extras = resolve_extra_stations( + project_root, _load_pipe_points(project_root, polyline), polyline + ) return generate_irregular_sections( project_root, route_data_path, diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 3efa4ca10..12e8e060b 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -43,7 +43,11 @@ from common_util.common_util_drainage_pipes import ( pipe_points_path_in, read_pipe_points_file, ) -from common_util.common_util_route_follow import fill_structure_coordinates, follow_structures +from common_util.common_util_route_follow import ( + fill_structure_coordinates, + follow_structures, + route_outside, +) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_structure_station import plan_stations from config.config_db import get_db_pool @@ -120,7 +124,7 @@ async def _route_xy(project_id: UUID, root: str) -> list[list[float]] | None: def follow_structures_to_route( project_root: Path, old_route_data_path: str | None, new_xy: list[dict[str, float]] ) -> dict[str, int]: - """노선이 바뀔 때 구조물이 땅 위 자리를 따라감(344-2) → 정본 저장. {옮김, 멀어짐} 수. + """노선이 바뀔 때 구조물이 땅 위 자리를 따라감(344-2) → 정본 저장. {옮김, 멀어짐, 노선 밖} 수. 좌표가 없는 옛 자료는 옛 노선(아직 안 바뀐 경로 파일)으로 먼저 채운다. 실패는 비치명 — 구조물은 옛 누가거리 그대로 남는다(지금까지와 같음). @@ -145,12 +149,14 @@ def follow_structures_to_route( for index, item in enumerate(items) ) far = sum(1 for _, flag in result if flag) + new_line = [(point["x"], point["y"]) for point in new_xy] + outside = sum(route_outside(item.x, item.y, new_line) for item in items) # 459 · 측점 안 세움 if moved: # 범위는 따라감이 0 ~ 새 연장 안으로 이미 자름 — DB 연장(옛 노선)으로 재지 않음 save_structures(root, items, base_revision=revision) if far: logger.warning("B05 구조물 %d건이 새 노선에서 멀어짐(버리지 않음)", far) - return {"moved": moved, "far": far} + return {"moved": moved, "far": far, "outside": outside} def _migrate_pipe_facilities( diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 1a039a781..a72d0eee1 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -93,7 +93,9 @@ def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str] ) polyline = _load_route_polyline(project_root, route_data_path) - extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + extras = resolve_extra_stations( + project_root, _load_pipe_points(project_root, polyline), polyline + ) # 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄) end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:])) return [ @@ -186,7 +188,9 @@ def _curve_marks(project_root: Path, route_data_path: str, options: Any) -> int: if station.get("kind") == "curve": standing.add(round(float(station.get("chainage_m") or 0.0), 2)) polyline = _load_route_polyline(project_root, route_data_path) - extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + extras = resolve_extra_stations( + project_root, _load_pipe_points(project_root, polyline), polyline + ) planned = planned_curve_stations( polyline, replace(options, extra_stations=extras), @@ -254,7 +258,7 @@ async def _regenerate_structure_stations( def _regenerate() -> tuple[int, dict[str, Any] | None]: polyline = _load_route_polyline(project_root, str(route["route_data_path"])) pipes = _load_pipe_points(project_root, polyline) - extras = resolve_extra_stations(project_root, pipes) + extras = resolve_extra_stations(project_root, pipes, polyline) stations = generate_irregular_sections( project_root, str(route["route_data_path"]), diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 5eaebca5e..e6a3bf8c0 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -8,6 +8,8 @@ 지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움. - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리. + - `route_outside` — 노선 밖(시점 앞 · 종점 뒤 0.51 m 넘음) · 측점 세울 때 셈(459 · 정본에 안 씀). + 짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints` (관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음 판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`. @@ -15,6 +17,7 @@ from __future__ import annotations +import math from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point @@ -28,6 +31,8 @@ from common_util.common_util_structure_station import ( from config.config_system import STRUCTURE_ROUTE_DRIFT_MAX_M _POSITION_KEYS = ("chainage_m", "start_m", "end_m") +#: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝). +ROUTE_OUTSIDE_M = 0.51 def _line(xy: Sequence[Sequence[float]]) -> LineString | None: @@ -38,6 +43,25 @@ def _line(xy: Sequence[Sequence[float]]) -> LineString | None: return line if line.length > 0 else None +def route_outside( + x: float | None, y: float | None, xy: Sequence[Sequence[float]], tol_m: float = ROUTE_OUTSIDE_M +) -> bool: + """노선 밖인가(459) — 가장 가까운 자리가 시점 · 종점이고 그 끝 선분 연장으로 `tol_m` 넘게 나감 + (옆으로 먼 것은 아님) · 좌표 없으면 거짓. TS `routeOutside` 짝 · 측점 계획에서 뺌.""" + line = _line(xy) + if line is None or x is None or y is None: + return False + at = float(line.project(Point(float(x), float(y)))) + if 0.0 < at < float(line.length): + return False + coords = list(line.coords) + (ax, ay), (bx, by) = (coords[0], coords[1]) if at <= 0.0 else (coords[-1], coords[-2]) + span = math.hypot(bx - ax, by - ay) + if span <= 0.0: + return False + return -((float(x) - ax) * (bx - ax) + (float(y) - ay) * (by - ay)) / span > tol_m + + def _positions(item: Any) -> tuple[Any, ...]: return tuple(getattr(item, key, None) for key in _POSITION_KEYS) diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 68cc09a62..9595495c9 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -15,6 +15,8 @@ * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). * - `followPlusStations` ↔ 파이썬 `B05_Profile_Plus_Stations.follow_plus_stations`(459 · 노선 초안 동안). + * - `routeOutside` ↔ 파이썬 `route_outside`(459 · 노선 밖 = 측점 계획 · 알약에서 뺌 · 좌표 그대로) — 따라감이 + * `outside: true` 를 붙임(파생값 · 저장 때 뺌 · 서버는 측점 세울 때 다시 셈). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ @@ -28,6 +30,9 @@ import { /** 「노선에서 멀어짐」 거리(m) — 파이썬 `config_system.STRUCTURE_ROUTE_DRIFT_MAX_M` 짝(거울 시험이 대조). */ export const STRUCTURE_ROUTE_DRIFT_MAX_M = 20; +/** 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘음(459 · 파이썬 `ROUTE_OUTSIDE_M` · 458 주인 측점 상한과 같은 값). */ +export const ROUTE_OUTSIDE_M = 0.51; + export interface XY { x: number; y: number; @@ -57,6 +62,33 @@ export function projectOnPolyline( return best && walked > 0 ? { ...best, length_m: walked } : null; } +/** 노선 밖인가(459) — 가장 가까운 자리가 시점 · 종점이고 그 끝 선분 연장으로 `tolM` 넘게 나감(옆으로 먼 것은 아님). + * 파이썬 `route_outside` 짝. */ +export function routeOutside( + line: ReadonlyArray, + point: XY, + tolM: number = ROUTE_OUTSIDE_M, +): boolean { + const hit = projectOnPolyline(line, point); + if (!hit || (hit.chainage_m > 0 && hit.chainage_m < hit.length_m)) return false; + const [a, b] = + hit.chainage_m <= 0 ? [line[0], line[1]] : [line[line.length - 1], line[line.length - 2]]; + const span = Math.hypot(b.x - a.x, b.y - a.y); + if (!(span > 0)) return false; + return -((point.x - a.x) * (b.x - a.x) + (point.y - a.y) * (b.y - a.y)) / span > tolM; +} + +/** 노선 밖 표시를 다시 붙인 사본 — 좌표가 있고 노선 밖이면 `outside: true` · 아니면 칸 뺌(파생값 · 정본에 안 씀). */ +function markOutside( + item: T, + line: ReadonlyArray, +): T { + const { outside: _was, ...rest } = item as T & { outside?: boolean }; + return item.x != null && item.y != null && routeOutside(line, { x: item.x, y: item.y }) + ? ({ ...rest, outside: true } as unknown as T) + : (rest as T); +} + /** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬 * `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */ const holdsStation = ( @@ -232,12 +264,15 @@ export function projectPipePoints

( const hit = projectOnPolyline(line, { x: point.x, y: point.y })!; const moved = snapStructureStation(hit.chainage_m, [hit.length_m]); const shift = moved - point.chainage_m; - return { - ...point, - chainage_m: moved, - ...(point.start_m != null ? { start_m: point.start_m + shift } : {}), - ...(point.end_m != null ? { end_m: point.end_m + shift } : {}), - }; + return markOutside( + { + ...point, + chainage_m: moved, + ...(point.start_m != null ? { start_m: point.start_m + shift } : {}), + ...(point.end_m != null ? { end_m: point.end_m + shift } : {}), + }, + line, + ); }) .sort((a, b) => a.chainage_m - b.chainage_m); } @@ -271,12 +306,15 @@ export function followStructures( const shift = target - item.chainage_m; const clamp = (value: number): number => Math.min(Math.max(value + shift, 0), hit.length_m); return { - item: { - ...item, - chainage_m: clamp(item.chainage_m), - ...(item.start_m != null ? { start_m: clamp(item.start_m) } : {}), - ...(item.end_m != null ? { end_m: clamp(item.end_m) } : {}), - }, + item: markOutside( + { + ...item, + chainage_m: clamp(item.chainage_m), + ...(item.start_m != null ? { start_m: clamp(item.start_m) } : {}), + ...(item.end_m != null ? { end_m: clamp(item.end_m) } : {}), + }, + line, + ), drift_m: hit.distance_m, far: hit.distance_m > maxDriftM, }; diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 09b644cbc..f79a8af90 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -200,11 +200,15 @@ export function structureStationMarks( export interface PlanPipe extends PlantingPipe { /** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */ id?: string | null; + /** 노선 밖(459 · 따라감이 붙이는 파생값) — 측점 계획에서 뺌. */ + outside?: boolean; options?: Record | null; } export interface PlanStructure extends PlantingStructure { structure_id?: string | null; + /** 노선 밖(459 · 따라감이 붙이는 파생값) — 측점 계획에서 뺌. */ + outside?: boolean; options?: Record | null; } @@ -249,13 +253,14 @@ export function planStations( const own: Mark[] = [ ...pipes.flatMap((pipe) => { const facility = pipe.facility ?? "pipe"; - if (!catalogRow(facility)) return []; + if (!catalogRow(facility) || pipe.outside) return []; // 노선 밖(459) = 측점 안 세움 const title = cardTitle(facility, pipe.options, defaults(facility)); const owner = pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`; const blank = blankKeys(facility, pipe.options); return pipeStationMarks(pipe).map((m) => ({ ...m, title, owner, blank, pipe: true })); }), ...structures.flatMap((structure) => { + if (structure.outside) return []; // 노선 밖(459 · 따라감 표시) const marks = structureStationMarks(structure, byId.get(structure.type_id)); const anchor = marks.find((m) => m.role === "anchor")?.chainage_m ?? 0; const title = cardTitle(structure.type_id, structure.options, defaults(structure.type_id)); diff --git a/resources/tester/helper_459_route_seat.cjs b/resources/tester/helper_459_route_seat.cjs index 039c69347..c140ca4f3 100644 --- a/resources/tester/helper_459_route_seat.cjs +++ b/resources/tester/helper_459_route_seat.cjs @@ -91,6 +91,19 @@ const out = cases.map((item) => { return carry.carryBase(xy(item.oldXy), xy(item.newXy), item.oldBase, item.groundPvi); case "rebase": return follow.rebaseStructureDraft(item.draft, item.before, item.after); + case "outside": { + // 노선 밖 판정 · 따라감 표시 · 측점 계획에서 뺌(459 덧) + const line = xy(item.line); + const station = loadTs("common_util/common_util_structure_station.ts"); + const pipes = follow.projectPipePoints(item.pipes, line); + const structures = follow.followStructures(item.structures, line).map(({ item: s }) => s); + return { + flags: item.points.map(([x, y]) => follow.routeOutside(line, { x, y })), + pipes: pipes.map((p) => [p.id, p.chainage_m, p.outside ?? false]), + structures: structures.map((s) => [s.structure_id, s.chainage_m, s.outside ?? false]), + planned: station.planStations(pipes, structures, []).map((m) => m.chainage_m), + }; + } case "plus": return follow.followPlusStations(item.list, xy(item.from), xy(item.to)); default: diff --git a/resources/tester/test_344_2_route_pairs.py b/resources/tester/test_344_2_route_pairs.py index 0032176c1..3c140cfaf 100644 --- a/resources/tester/test_344_2_route_pairs.py +++ b/resources/tester/test_344_2_route_pairs.py @@ -329,7 +329,7 @@ def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch): # 새 노선 = 앞쪽 10 m 늘림(같은 땅 자리는 누가거리 +10) new_xy = [{"x": -10.0, "y": 0.0}, {"x": 100.0, "y": 0.0}] result = structures_router.follow_structures_to_route(tmp_path, "route.geojson", new_xy) - assert result == {"moved": 2, "far": 0} + assert result == {"moved": 2, "far": 0, "outside": 0} assert saved["revision"] == 4 assert [(i.chainage_m, i.start_m, i.end_m) for i in items] == [ (40.0, None, None), diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 5960dd7c2..4ed12bff6 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -395,7 +395,7 @@ def test_차례와_배수유역_뗌(chain_world): ] assert result["cross_dropped"] == 2 and result["drainage_stale"] is True # 456 m 노선 · 관 하나 — 100 m 간격 보충 없음(369) - assert result["pipes"] == {"kept": 1, "crossing": 0} + assert result["pipes"] == {"kept": 1, "crossing": 0, "outside": 0} # 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」 saved = json.loads(chain_world.pipes_file.read_text("utf-8")) assert saved["route_signature"] == route_signature(vertices_of(NEW)) diff --git a/resources/tester/test_459_route_seat_mirror.py b/resources/tester/test_459_route_seat_mirror.py index 5437f0816..2127afb86 100644 --- a/resources/tester/test_459_route_seat_mirror.py +++ b/resources/tester/test_459_route_seat_mirror.py @@ -48,6 +48,7 @@ from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 ) from B05_Profile.B05_Profile_Router_Confirm import _seat_born_nodes # noqa: E402 from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 +from common_util.common_util_route_follow import route_outside # noqa: E402 OLD_XY = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]] NEW_XY = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]] @@ -354,3 +355,46 @@ def test_관_따라감_선은_사용_범위_자른_points_좌표_없는_옛_관 pairs, _ = asyncio.run(route_save._place_pipes("p", "s", old, old_xy, points)) assert [pipe.chainage_m for _, pipe in pairs] == [110.0, 210.0] assert [pipe.chainage_m for pipe in saved[0]] == [110.0, 210.0] + + +def test_노선_밖_시설_측점_안_세움_좌표_그대로(tmp_path: Path) -> None: + """시점 앞 · 종점 뒤 0.51 m 넘게 나간 관 · 구조물 = 노선 밖(TS ↔ 파이썬) · 측점 계획에서 뺌.""" + from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations + + line = [[0.0, 0.0], [100.0, 0.0], [200.0, 50.0]] + end = ( + 200.0 + 0.6 * 100.0 / math.hypot(100.0, 50.0), + 50.0 + 0.6 * 50.0 / math.hypot(100.0, 50.0), + ) + points = [(-0.6, 0.0), (-0.4, 0.0), (-0.3, 9.0), (50.0, 30.0), end, (200.0, 50.3)] + pipes = [ + {"id": "in", "chainage_m": 50.0, "facility": "pipe", "x": 50.0, "y": 3.0}, + {"id": "before", "chainage_m": 3.0, "facility": "pipe", "x": -7.0, "y": 0.0}, + ] + structures = [ + {"structure_id": "after", "type_id": "x", "chainage_m": 200.0, "x": 210.0, "y": 55.0}, + ] + [ts] = _ts( + tmp_path, + [ + { + "op": "outside", + "line": line, + "points": points, + "pipes": pipes, + "structures": structures, + } + ], + ) + py = [route_outside(x, y, line) for x, y in points] + assert ts["flags"] == py == [True, False, False, False, True, False] + assert ts["pipes"] == [["before", 0.0, True], ["in", 50.0, False]] # 좌표 그대로 · 표시만 + assert ts["structures"][0][2] is True + assert ts["planned"] == [50.0] + # 서버 — 측점 세울 때 노선 좌표로 그때그때(정본에 안 씀) + extras = resolve_extra_stations( + tmp_path, + [PipePoint(chainage_m=50.0, x=50.0, y=3.0), PipePoint(chainage_m=0.0, x=-7.0, y=0.0)], + line, + ) + assert [at for at, _ in extras] == [50.0]