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:
@@ -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(
|
||||
|
||||
@@ -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(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
|
||||
|
||||
@@ -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); // 서버가 지금 판으로 적음
|
||||
|
||||
@@ -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 = ()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user