Files
Aislo/common_util/common_util_route_follow.ts
T
eomsangdonandClaude Opus 5.5 2140719c98 fix(저장): 349-0u 덧 노선을 저장한 [저장] — 세션 관 초안이 서버 따라감을 덮지 않음
- 노선 저장 전 · 뒤 정본 관을 받아 초안을 얹음(rebasePipeDraft) — 옛 자리 그대로인 관 = 서버가 옮긴 자리 · 옵션 고침은 그대로 · 사용자가 옮긴 · 새로 놓은 관 그대로 · 지운 관 없음 · 서버가 새로 놓은 교차 관 붙음
- 관 초안이 없거나 노선 저장이 없으면 전과 같음
- 따라감 미리보기 — 좌표 없는 초안 관 중 안 옮긴 것은 정본 좌표로(서버 노선 저장과 같은 측점)
- 시험 test_349_0u_save_rebase(실제 design_save · 서버 따라감 = project_pipe_points) · test_349_0u_follow_draft_xy 서버 대조 · design_store_save 가짜 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 18:12:46 +09:00

181 lines
8.1 KiB
TypeScript

/* =============================================================================
* common_util/common_util_route_follow.ts
* 노선이 바뀔 때 관 · 구조물이 땅 위 자리(좌표)를 따라감 — 누가거리 다시 매김(344-2).
*
* 자리 정본 = 좌표(x · y) · 누가거리 = 그 자리를 지금 노선에서 읽은 값(정수 m · 234-1).
* 짝:
* - `projectPipePoints` ↔ 파이썬 `common_util_drainage_pipes.project_pipe_points`(xy 투영 + 정수 m ·
* 구간 끝 같은 만큼 밂 · 누가거리 순).
* - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 ·
* 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음).
* - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`.
* - `placeOnLine` · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 전 자리로 ·
* 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
* ========================================================================== */
import { SAME_STATION_M, snapStructureStation } from "./common_util_structure_station";
/** 「노선에서 멀어짐」 거리(m) — 파이썬 `config_system.STRUCTURE_ROUTE_DRIFT_MAX_M` 짝(거울 시험이 대조). */
export const STRUCTURE_ROUTE_DRIFT_MAX_M = 20;
export interface XY {
x: number;
y: number;
}
/** 노선 위 자리 — 누가거리 · 노선과의 거리. 노선이 없으면(점 둘 미만 · 길이 0) null. */
export function projectOnPolyline(
line: ReadonlyArray<XY>,
point: XY,
): { chainage_m: number; distance_m: number; length_m: number } | null {
let walked = 0;
let best: { chainage_m: number; distance_m: number } | null = null;
for (let index = 1; index < line.length; index += 1) {
const a = line[index - 1];
const b = line[index];
const dx = b.x - a.x;
const dy = b.y - a.y;
const span = Math.hypot(dx, dy);
if (span > 0) {
const t = Math.min(1, Math.max(0, ((point.x - a.x) * dx + (point.y - a.y) * dy) / span ** 2));
const distance = Math.hypot(a.x + dx * t - point.x, a.y + dy * t - point.y);
if (best === null || distance < best.distance_m)
best = { chainage_m: walked + span * t, distance_m: distance };
}
walked += span;
}
return best && walked > 0 ? { ...best, length_m: walked } : null;
}
/** 좌표 없는 관 · 구조물(세션 초안 = 정본 PUT 꼴 · 좌표 뺌)에 자리를 붙인 사본 — 같은 id · 같은 누가거리인
* `saved`(정본 · 좌표 있음)가 있으면 그 좌표(안 옮긴 것 = 서버 따라감과 같은 자리) · 없으면 그 누가거리의
* `line` 위 자리(연장 밖은 끝 선분 위로 자름). 좌표가 있거나 누가거리가 없으면 그대로(같은 객체). */
export function placeOnLine<S extends FollowingStructure & { id?: string | null }>(
items: ReadonlyArray<S>,
line: ReadonlyArray<XY>,
saved: ReadonlyArray<FollowingStructure & { id?: string | null }> = [],
): S[] {
const byId = new Map(saved.map((item) => [item.id, item]));
return items.map((item) => {
if ((item.x != null && item.y != null) || item.chainage_m == null) return item;
const was = item.id ? byId.get(item.id) : undefined;
if (
was?.x != null &&
was.y != null &&
was.chainage_m != null &&
Math.abs(was.chainage_m - item.chainage_m) <= SAME_STATION_M
)
return { ...item, x: was.x, y: was.y };
let walked = 0;
for (let index = 1; index < line.length; index += 1) {
const a = line[index - 1];
const b = line[index];
const span = Math.hypot(b.x - a.x, b.y - a.y);
if (span > 0 && (walked + span >= item.chainage_m || index === line.length - 1)) {
const t = Math.min(1, Math.max(0, (item.chainage_m - walked) / span));
return { ...item, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
walked += span;
}
return item;
});
}
/** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순).
* 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) 서버 자리로(구간 끝 · 좌표도) · 사용자가
* 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로 놓은 관(옛 정본에 없던 id)은 붙임 · 짝은 id. */
export function rebasePipeDraft<P extends FollowingPoint & { id?: string | null }>(
draft: ReadonlyArray<P>,
before: ReadonlyArray<FollowingPoint & { id?: string | null }>,
after: ReadonlyArray<P>,
): P[] {
const old = new Map(before.map((pipe) => [pipe.id, pipe]));
const moved = new Map(after.map((pipe) => [pipe.id, pipe]));
const mine = new Set(draft.map((pipe) => pipe.id));
const kept = draft.map((pipe) => {
const was = pipe.id ? old.get(pipe.id) : undefined;
const now = pipe.id ? moved.get(pipe.id) : undefined;
if (!was || !now || Math.abs(pipe.chainage_m - was.chainage_m) > SAME_STATION_M) return pipe;
const shift = now.chainage_m - pipe.chainage_m;
return {
...pipe,
chainage_m: now.chainage_m,
...(pipe.start_m != null ? { start_m: pipe.start_m + shift } : {}),
...(pipe.end_m != null ? { end_m: pipe.end_m + shift } : {}),
...(now.x != null && now.y != null ? { x: now.x, y: now.y } : {}),
};
});
const added = after.filter((pipe) => pipe.id && !old.has(pipe.id) && !mine.has(pipe.id));
return [...kept, ...added].sort((a, b) => a.chainage_m - b.chainage_m);
}
export interface FollowingPoint {
chainage_m: number;
x?: number | null;
y?: number | null;
start_m?: number | null;
end_m?: number | null;
}
/** 저장 좌표를 새 노선에 투영해 누가거리를 다시 매긴 사본(누가거리 순) — 관 지점. */
export function projectPipePoints<P extends FollowingPoint>(
points: ReadonlyArray<P>,
line: ReadonlyArray<XY>,
): P[] {
const length = line.reduce(
(sum, point, index) =>
index ? sum + Math.hypot(point.x - line[index - 1].x, point.y - line[index - 1].y) : 0,
0,
);
if (!(length > 0)) return [...points]; // 파이썬도 노선이 없으면 그대로(차례도)
return points
.map((point) => {
if (point.x == null || point.y == null) return { ...point };
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 } : {}),
};
})
.sort((a, b) => a.chainage_m - b.chainage_m);
}
export interface FollowingStructure {
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
x?: number | null;
y?: number | null;
}
/** 구조물이 좌표를 따라감 — 사본 · 노선과 거리(좌표 없으면 null) · 멀어짐(차례는 그대로). */
export function followStructures<S extends FollowingStructure>(
items: ReadonlyArray<S>,
line: ReadonlyArray<XY>,
maxDriftM: number = STRUCTURE_ROUTE_DRIFT_MAX_M,
): Array<{ item: S; drift_m: number | null; far: boolean }> {
return items.map((item) => {
const hit =
item.x != null && item.y != null ? projectOnPolyline(line, { x: item.x, y: item.y }) : null;
if (hit === null || item.chainage_m == null)
return { item: { ...item }, drift_m: null, far: false };
const shift = snapStructureStation(hit.chainage_m, [hit.length_m]) - 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) } : {}),
},
drift_m: hit.distance_m,
far: hit.distance_m > maxDriftM,
};
});
}