fix(459 덧 노선 밖 시설): 시점 앞 · 종점 뒤 0.51 m 넘은 관 · 구조물 = 측점 계획 · 알약에서 뺌 · 좌표 그대로 · 노선 밖 알림

- 판정 routeOutside ↔ route_outside(TS ↔ 파이썬 · 끝 선분 연장으로 나간 거리 · 옆으로 먼 것은 아님)
- 따라감(followStructures · projectPipePoints)이 outside 표시(파생값 · 구조물 저장 때 뺌) · planStations 가 뺌
- 서버는 측점 세울 때 노선 좌표로 그때그때(resolve_extra_stations xy · 부르는 곳 여섯) · 노선 저장 응답 pipes · structures.outside
- 알림 「노선 밖 N 곳」 = [저장](design_save) · 초안 동안(followPlusDraft 가 follow 출력 통째 받음 · page 부름 인자만 바꿈 · 700줄 그대로)
- 시험 test_459 노선 밖 하나 · 344_2 · 346_3 응답 기대값에 outside

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 23:28:02 +09:00
co-authored by Claude Opus 5.5
parent 6ab8fb5322
commit 8ff4dce260
18 changed files with 230 additions and 42 deletions
+1 -1
View File
@@ -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(
+3 -1
View File
@@ -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(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
+33 -8
View File
@@ -75,16 +75,28 @@ const beforeRoute = new Map<string, number[] | null>();
/** follow 만 쓴 초안(사용자 초안 없던 때) — 그대로면 [저장]이 PUT 안 함(서버 따라감이 정본 · 459). */
const followedOnly = new Map<string, string>();
/** 노선 밖 알림 글(459 · 458 「노선 밖 N 곳」과 같은 말). */
export const outsideNotice = (count: number): string =>
`노선 밖 ${count} 곳 — 측점 · 알약에서 뺌(좌표는 그대로 · 노선을 다시 늘이면 돌아옴)`;
/** 프로젝트마다 마지막으로 알린 노선 밖 수 — 같으면 다시 안 알림. */
const warnedOutside = new Map<string, number>();
/** follow 출력 꼴(묶음 `FollowPatch` 중 쓰는 칸만). */
interface FollowedDraft {
plusStations?: ReadonlyArray<number> | 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<number> | 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)));
}
+3 -2
View File
@@ -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[];
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
+11 -1
View File
@@ -305,7 +305,17 @@ export async function saveStructures(
): Promise<StructureSaveResponse> {
const saved = await requestJson<StructureSaveResponse>(
`/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); // 서버가 지금 판으로 적음
+12 -3
View File
@@ -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 = ()
+6 -2
View File
@@ -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(
+3 -2
View File
@@ -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]
+3 -1
View File
@@ -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,
+9 -3
View File
@@ -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(
+7 -3
View File
@@ -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"]),
+24
View File
@@ -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)
+50 -12
View File
@@ -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<XY>,
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<T extends { x?: number | null; y?: number | null }>(
item: T,
line: ReadonlyArray<XY>,
): 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<P extends FollowingPoint>(
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<S extends FollowingStructure>(
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,
};
+6 -1
View File
@@ -200,11 +200,15 @@ export function structureStationMarks(
export interface PlanPipe extends PlantingPipe {
/** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */
id?: string | null;
/** 노선 밖(459 · 따라감이 붙이는 파생값) — 측점 계획에서 뺌. */
outside?: boolean;
options?: Record<string, unknown> | null;
}
export interface PlanStructure extends PlantingStructure {
structure_id?: string | null;
/** 노선 밖(459 · 따라감이 붙이는 파생값) — 측점 계획에서 뺌. */
outside?: boolean;
options?: Record<string, unknown> | 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));
@@ -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:
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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))
@@ -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]