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
+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));