fix(B06): 364-7 중심점 좌우 = 노면 그대로 — 바뀐 구간 첫 계획선 맞춤에 쓸린 모든 측점 계획고 · 꺾임 · R 을 옮기기 전 종단으로 되살림(holdGrade · 한 측점 보정 대신)

- 까닭: 노선이 바뀌면 planline 단계가 바뀐 구간을 새 지반 첫 계획선으로 다시 맞춤(B05 꺾임점 끌기와 같은 길) · 옛 보정은 누른 측점 하나만 되돌림
- 고침: 옛 · 새 꺾은선 꼭짓점 · 측점을 견줘 다른 곳만 계획고 편집(바뀐 구간 끝까지 측점 열쇠 그대로 · 뒤는 길이 차) · 옛 R 되살림 · B05 꺾임점 끌기 규칙은 그대로
- 시험: 356 묶음에 바뀐 구간 안 사용자 계획고 · R 넣고 모든 측점 ±0.001 · 최대 기울기 · R 그대로 · 356-4 헬퍼 새 함수로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-17 00:32:55 +09:00
co-authored by Claude Opus 5.5
parent 1cdfb68fab
commit 842473bcea
5 changed files with 123 additions and 34 deletions
+9 -12
View File
@@ -3,8 +3,8 @@
* 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록.
*
* 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) →
* 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫
* 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume).
* 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 옮기기 전 종단을 그대로(노면 그대로 · 364-7 ·
* 바뀐 구간 첫 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume).
* ========================================================================== */
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
@@ -15,12 +15,8 @@ import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
emptyEdits,
planElevationAt,
type AlignmentEdits,
} from "./logic/B05_Profile_UI_Profile_Alignment";
import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift";
import { emptyEdits, type AlignmentEdits } from "./logic/B05_Profile_UI_Profile_Alignment";
import { holdGrade, planRouteShift } from "./logic/B05_Profile_RouteShift";
type Vertex = [number, number];
/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */
@@ -72,8 +68,8 @@ export function bindRouteShift(
});
if (!plan) return "노선을 아직 못 읽었습니다.";
if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요.";
const profile = pipe?.output("profile");
const zBefore = profile ? planElevationAt(profile, chainageM) : null;
const before = pipe?.output("profile");
const lineBefore = pipe?.output("route")?.line ?? [...line]; // 끼움 · 옮김이 산 배열을 고침
const album = pageHistory();
album?.pause();
try {
@@ -82,10 +78,11 @@ export function bindRouteShift(
else spliceNode(now, plan.index, plan.at);
const side = deltaM > 0 ? "좌" : "우";
apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`);
if (!pipe || zBefore === null || !(await routed)) return null;
if (!pipe || !before || !(await routed)) return null;
const base = pipe.output("planline");
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
const pinned = base && pinStationHeight(base, edits, chainageM, zBefore);
const lineAfter = pipe.output("route")?.line ?? drawn()[0];
const pinned = base && holdGrade(base, edits, before, lineBefore, lineAfter);
if (!pinned) return null;
const graded = nextProfile(pipe);
pipelineGrade(projectId, pinned);
+70 -9
View File
@@ -7,18 +7,21 @@
* · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김)
* · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김)
* · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움
* · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`)
* · 높이 = 노면 그대로(364-7) — 옮기기 전 종단의 꼭짓점 · R 을 계획고 편집으로 되살림(`holdGrade`) ·
* 바뀐 구간 첫 계획선 맞춤(`Pipeline_Grade` planline)에 휩쓸리지 않게 · 지반만 새로
*
* 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음).
* ========================================================================== */
import {
adjustStation,
buildAlignment,
planElevationAt,
chainageKey,
evaluateAt,
type AlignmentBase,
type AlignmentEdits,
type ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import { changedSpan } from "./B05_Profile_Grade_Carry";
import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame";
type Vertex = [number, number];
@@ -27,6 +30,8 @@ type Vertex = [number, number];
export const NODE_SNAP_M = 0.5;
/** 이보다 작은 높이 차는 보정 안 함(m). */
export const PIN_TOLERANCE_M = 5e-4;
/** 노면 그대로 견줌 횟수. */
const HOLD_PASSES = 4;
export type RouteShiftPlan =
| { kind: "move"; index: number; to: Vertex }
@@ -85,13 +90,69 @@ export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null {
return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) };
}
/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */
export function pinStationHeight(
const xy = (line: ReadonlyArray<Vertex>) => line.map(([x, y]) => ({ x, y }));
/**
* 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤).
* [옛 → 새, 새 → 옛].
*/
function carryChainage(oldLine: ReadonlyArray<Vertex>, newLine: ReadonlyArray<Vertex>) {
const span = changedSpan(xy(oldLine), xy(newLine));
const shift = (end: number, delta: number) => (s: number) => (span && s >= end ? s + delta : s);
return [
shift(span?.old_end_m ?? 0, span?.delta_m ?? 0),
shift(span?.new_end_m ?? 0, -(span?.delta_m ?? 0)),
];
}
/**
* 노면 그대로(364-7) — 노선을 옮긴 뒤 새 기준(`base` · 바뀐 구간 첫 계획선) 위에 옮기기 전 종단(`before`)의
* 꼭짓점 · 종단곡선 R 을 계획고 편집으로 되살림. 두 꺾은선의 꼭짓점을 모두 맞추면 두 선이 같음 —
* 다른 꼭짓점 · 새 기준에 없는 옛 꼭짓점만 고정(바뀐 구간 밖은 이미 같아 편집 안 늚). 차가 없으면 null.
* ponytail: 새 기준에만 있는 꼭짓점(옛 선 위로 고정)이 옛 종단곡선 반쪽 한계를 줄일 수 있음 — 측점마다
* 꼭짓점인 기준(지금 첫 계획선)이면 옛 선과 꼭짓점이 같아 없음.
*/
export function holdGrade(
base: AlignmentBase,
edits: AlignmentEdits,
chainageM: number,
zBefore: number,
before: ProfileAlignment,
oldLine: ReadonlyArray<Vertex>,
newLine: ReadonlyArray<Vertex>,
): AlignmentEdits | null {
const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM);
return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift);
const [carry, back] = carryChainage(oldLine, newLine);
const oldS = before.pvi.map((node) => node.chainage_m);
const oldZ = before.pvi.map((node) => node.elevation_m);
const oldKeys = new Set(oldS.map((s) => chainageKey(carry(s))));
const stationS = base.stations.map((station) => station.chainage_m);
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const line = (s: number[], z: number[], at: number): number => evaluateAt(s, z, [], at);
const offsets = { ...edits.station_offsets };
const radii = { ...edits.curve_radii };
let changed = false;
// 고정한 꼭짓점이 이웃 측점 사이 선을 바꿈 — 다시 견줘 남은 측점까지
for (let pass = 0; pass < HOLD_PASSES; pass += 1) {
const now = buildAlignment(base, { ...edits, station_offsets: offsets }).pvi;
const nowS = now.map((node) => node.chainage_m);
const nowZ = now.map((node) => node.elevation_m);
const nowKeys = new Set(nowS.map(chainageKey));
let pinned = false;
for (const key of new Set([...oldS.map(carry), ...nowS, ...stationS].map(chainageKey))) {
const at = Number(key);
const z = line(oldS, oldZ, back(at));
const lost = oldKeys.has(key) && !nowKeys.has(key); // 옛 꺾임이 새 선에 없음
if (!lost && Math.abs(line(nowS, nowZ, at) - z) < PIN_TOLERANCE_M) continue;
offsets[key] = z - line(baseS, baseZ, at); // 반올림 안 함 — 옛 선 위 꼭짓점이 꺾임 안 만듦
pinned = true;
}
if (!pinned) break;
changed = true;
}
Object.entries(before.edits.curve_radii).forEach(([key, radius]) => {
const at = chainageKey(carry(Number(key)));
if (radii[at] !== undefined) return;
radii[at] = radius;
changed = true;
});
return changed ? { ...edits, station_offsets: offsets, curve_radii: radii } : null;
}
+7 -4
View File
@@ -407,15 +407,18 @@ async function run() {
// 중심점 좌우 꼴 — 끌기 + C 계획고를 끌기 전 값으로(한 걸음)
const zBefore = state().z;
await step(async () => {
const before = pipe().output("profile");
const lineBefore = pipe().output("route").line;
bridge.edited(draftOf(shift(1, 12, -8)));
await quiet();
const p = pipe();
const pinned = shiftMod.pinStationHeight(
const pinned = shiftMod.holdGrade(
p.output("planline"),
p.input("alignment"),
C,
zBefore,
);
before,
lineBefore,
p.output("route").line,
); // 364-7 노면 그대로
if (pinned) await grade(pinned);
});
out.pinned = { ...state(), zBefore };
+31 -8
View File
@@ -107,15 +107,17 @@ const policy = {
curve_skip_delta_pct: 0,
paved: true,
};
const stations = [0, 20, 40, 60, 80, 100];
const baseOf = (pvi) => ({
const stationsTo = (end) => [0, 20, 40, 60, 80, end]; // 끝 측점 = 노선 길이
const baseOf = (pvi, end = 100) => ({
policy,
onlyExplicitCurves: true,
basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })),
chainage: stations,
ground: stations.map(() => 100),
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
chainage: stationsTo(end),
ground: stationsTo(end).map(() => 100),
stations: stationsTo(end).map((s) => ({ station_id: `S${s}`, chainage_m: s })),
});
const lengthOf = (line) =>
line.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - line[i][0], y - line[i][1]), 0);
const savedBase = baseOf([
[0, 100],
[100, 110],
@@ -128,7 +130,15 @@ const refitBase = baseOf([
]);
let generation = 0;
const listeners = new Set();
const state = { base: savedBase, edits: align.emptyEdits() };
// 바뀐 구간 안 사용자 계획고 · R(364-7) — 묶음이 버려도 노면 그대로 되살아나야 함
const state = {
line: straight.map((p) => [...p]),
base: savedBase,
edits: { station_offsets: { "20.000": 1 }, curve_radii: { "20.000": 200 } },
};
const heights = () => pipe.output("profile").stations.map((row) => row.plan_elevation_m);
const maxGrade = () =>
Math.max(...pipe.output("profile").segments.map((g) => Math.abs(g.grade_percent)));
const emit = () => {
const gen = generation;
setTimeout(() => {
@@ -145,10 +155,17 @@ pipe = {
? state.base
: name === "profile"
? align.buildAlignment(state.base, state.edits)
: null,
: name === "route"
? { line: state.line }
: null,
input: (key) => (key === "alignment" ? state.edits : null),
route(planned) {
state.base = refitBase;
const end = lengthOf(planned);
state.line = planned.map((p) => [...p]);
state.base = baseOf(
[...refitBase.basePvi.slice(0, -1).map((n) => [n.chainage_m, n.elevation_m]), [end, 110]],
end,
);
state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림
sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned));
sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits));
@@ -192,6 +209,9 @@ const mountEditor = () =>
(async () => {
const zBefore = align.planElevationAt(pipe.output("profile"), 40);
out.heightsBefore = heights();
out.gradeBefore = maxGrade();
out.curvesBefore = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]);
const sessionBefore = JSON.stringify([...session.entries()]);
// B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움
let opened = 0;
@@ -209,6 +229,9 @@ const mountEditor = () =>
out.graded = graded.length;
out.zBefore = zBefore;
out.zAfter = align.planElevationAt(pipe.output("profile"), 40);
out.heightsAfter = heights();
out.gradeAfter = maxGrade();
out.curvesAfter = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]);
out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40);
out.canUndo = album.canUndo();
album.undo();
+6 -1
View File
@@ -4,7 +4,8 @@
- 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개.
- 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 ·
◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) ·
놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 ·
놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 노면 그대로(364-7 · 모든 측점 계획고 ·
기울기 · R 그대로) · 되돌리기 한 걸음 ·
B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움.
진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림.
@@ -67,6 +68,10 @@ def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts):
assert len(ts["applied"]) == 1 # 편집기 한 걸음
assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림
assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3)
# 364-7 노면 그대로 — 바뀐 구간 모든 측점 계획고 · 최대 기울기 · 종단곡선 R 그대로
assert ts["heightsAfter"] == pytest.approx(ts["heightsBefore"], abs=1e-3)
assert ts["gradeAfter"] == pytest.approx(ts["gradeBefore"], abs=1e-6)
assert sum(ts["curvesAfter"], []) == pytest.approx(sum(ts["curvesBefore"], []))
assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음
locked = ts["locked"]
assert locked["moved"] is False and locked["toast"][1] == "info"