fix(B05·B06): 노선 고친 [저장] 계획고 = 서버 옮김 위에 사용자 손댐만 (PLAN 498-2 · 요구 411~417)

- 브라우저 — 노선 바뀐 [저장]의 계획고 본문 = 묶음 계획고 대 저장분 옮김(같은 carryEdits · 이 초안 첫 기준)의 다른 열쇠 · 지운 열쇠만(`gradeTouch` · `routeGradeTouch`) · 못 가르면 옛 길(노선 0 칸 통째)
- 서버 — `alignment_cleared` 가 오면 관 · 구조물 · 추가 측점 차례 뒤 새 종단 편집(서버 옮김) 위에 손댐만 얹어 한 번 저장(`touched_alignment` · 얹기 실패도 되돌림 안)
- follow 옮김 측점 = stations 단계와 같은 추가 측점(`plus` 넘김)
- 재현: 꼬리 추가 측점 ▲ 뒤 앞 교점 끌기 [저장] = 990 m 편집 버림 · ▲ 잃음 · 최대 −0.50 m → 고친 뒤 버림 0 · 끌 때 = 저장 뒤
- 시험: test_498_2_grade_touch 새로 · helper_design_store_save 에 묶음 창구 가짜 하나(옛 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:51:29 +09:00
co-authored by Claude Opus 5.5
parent 76bb0c3fd3
commit b92fa06f34
10 changed files with 453 additions and 20 deletions
@@ -5,7 +5,8 @@
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3).
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3) ·
* 노선 바뀐 [저장]의 계획고 손댐(`touch` · 저장분 옮김과 다른 것만 · 498-2).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
@@ -15,12 +16,14 @@
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
* ========================================================================== */
import type { DesignPipeline } from "../../A00_Common/design/logic/design_pipeline";
import type {
FollowPatch,
PipelineInputs,
PipelineStation,
RangePart,
RouteLine,
StageContext,
StageFn,
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
@@ -120,11 +123,16 @@ export function createFollowStage(deps: {
savedPlus?(): ReadonlyArray<FollowingStructure>;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
}): FollowStage {
let ref: GradeRef | null = null;
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
return (ctx) => {
if (!ctx.inputs.route && deps.saved) return deps.saved();
/** 이 노선 초안 첫 기준(저장 노선 · 자동 꼭짓점 · 반 간격) — 저장분 옮김을 다시 셈(498-2). */
let first: { line: XY[]; basePvi: AlignmentBase["basePvi"]; half: number } | null = null;
const stage = (ctx: StageContext<"follow">): FollowPatch => {
if (!ctx.inputs.route && deps.saved) {
first = null;
return deps.saved();
}
const before = ctx.before.route?.line ?? deps.savedLine();
const now = ctx.out.route;
if (!before || !now) return {};
@@ -163,9 +171,10 @@ export function createFollowStage(deps: {
alignment && planline
? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits }
: null;
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
if (ref && !first) first = { line: ref.line, basePvi: ref.basePvi, half };
if (ref && ctx.before.route) {
const span = changedSpan(ref.line, line);
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : [];
carried = patch.alignment = span
? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half)
@@ -173,6 +182,55 @@ export function createFollowStage(deps: {
}
return patch;
};
return Object.assign(stage, {
touch(saved: AlignmentEdits, pipe: PipelineReader): GradeTouch | null {
const route = pipe.output("route");
const now = pipe.input("alignment") as AlignmentEdits | null;
if (!first || !route || !now || !pipe.input("route")) return null;
const line = xy(route.line);
if (!changedSpan(first.line, line)) return gradeTouch(now, saved);
const inputs = Object.fromEntries(INPUT_KEYS.map((key) => [key, pipe.input(key)]));
const stations =
deps.stationsAt?.(route, {}, inputs as unknown as PipelineInputs) ?? gridOf(route);
return gradeTouch(
now,
carryEdits(first.line, line, first.basePvi, saved, stations, first.half),
);
},
});
}
/** 묶음 읽기 — 지금 입력 · 출력(`DesignPipeline` 의 읽는 쪽). */
type PipelineReader = Pick<DesignPipeline, "input" | "output">;
const INPUT_KEYS = ["route", "alignment", "cross", "pipes", "structures", "plusStations"] as const;
/** follow 단계 + 노선 바뀐 [저장]의 계획고 손댐 — 묶음 계획고 대 이 초안 첫 기준에서 `saved` 를 지금 노선 ·
* 측점에 한 번에 옮긴 값(서버 노선 차례 옮김과 같은 셈) · 첫 기준이 없으면(열 때 초안 · 서버 follow) null · 498-2. */
export type FollowStage = StageFn<"follow"> & {
touch(saved: AlignmentEdits, pipe: PipelineReader): GradeTouch | null;
};
/** 노선 바뀐 [저장]의 계획고 손댐(498-2) — 사용자가 고친 열쇠(값) · 지운 열쇠. */
export interface GradeTouch {
edits: AlignmentEdits;
cleared: { station_offsets: string[]; curve_radii: string[] };
}
/** 묶음 계획고(`now`) 대 저장분 옮김(`carried`) — 다른 값 · 새 열쇠 = 손댐 · 옮김에만 있는 열쇠 = 지움. 서버가 자기
* 옮김 위에 이것만 얹음(관 · 구조물 다시 얹기와 같은 꼴 · 498-2). */
export function gradeTouch(now: AlignmentEdits, carried: AlignmentEdits): GradeTouch {
const out: GradeTouch = {
edits: emptyEdits(),
cleared: { station_offsets: [], curve_radii: [] },
};
for (const field of ["station_offsets", "curve_radii"] as const) {
const was = carried[field] ?? {};
const is = now[field] ?? {};
for (const [key, value] of Object.entries(is))
if (!(key in was) || Math.abs(was[key] - value) > 1e-6) out.edits[field][key] = value;
out.cleared[field] = Object.keys(was).filter((key) => !(key in is));
}
return out;
}
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */