fix(483-3 B05 스냅): 손대지 않은 곡선 MC 꼴(N+0 · N+10) 유지 — 고친 곡선만 고른 간격
- StationFit fitAfter · fitDrag(before) — 고치기 전 노선의 곡선 꼴을 IP 자리 + 곡선 칸 열쇠로 기억 · 밀 거리 = 측점 간격 - 10 m 배수(No.N+10)도 측점 위(offStationMcs · 맞춤) · 서로 다른 측점 = 누가거리 - RouteEdit = fitAfter 한 줄 · fitLimits stationM · 694줄 - 시험 = 새 test_280_4_fit(고치기 전 동작 = 190 → 180 대조) · 366_3 · 390_5 이음 글 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -436,8 +436,7 @@ export async function mountRouteEditor(
|
||||
markEdited();
|
||||
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
|
||||
if (stop && before) return restoreSnapshot(before, stop, false);
|
||||
const from = fit.fitStart(before, snapshotNow());
|
||||
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
|
||||
if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits());
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
@@ -457,7 +456,7 @@ export async function mountRouteEditor(
|
||||
|
||||
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
|
||||
function fitLimits(): fit.FitLimits {
|
||||
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
|
||||
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap(), stationM: stationIntervalM };
|
||||
}
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
@@ -542,8 +541,8 @@ export async function mountRouteEditor(
|
||||
},
|
||||
snapStepM: snap,
|
||||
fitDrag: (index) => {
|
||||
const base = stationSnap ? history?.current?.()?.planned : undefined;
|
||||
if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits());
|
||||
const base = stationSnap ? history?.current?.() : undefined; // 누르기 전 노선 — 280-4 유지
|
||||
if (base) planned = fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base);
|
||||
if (base) markEdited();
|
||||
return base !== undefined;
|
||||
},
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454).
|
||||
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
||||
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
||||
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
||||
* · 간격 = 스냅 간격(390-2 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
||||
* · 유지(280-4) — 고치기 전 노선(`before`)에서 MC 가 측점 · 10 m 배수(No.N+10)였던 곡선은 그 IP · 곡선 칸이
|
||||
* 그대로면 옛 꼴(측점 간격 배수 + 0 · 10)로만 맞춤(밀 거리 = 측점 간격) · 손으로 고친 곡선만 고른 간격.
|
||||
* MC 가 고른 간격 배수 · 10 m 배수면 「위」(No.N+10 특별측점 · 저장 칸 없음 = 노선 모양으로 기억).
|
||||
* · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로
|
||||
* 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값.
|
||||
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
|
||||
@@ -29,14 +32,21 @@ export const MC_TOL_M = 0.05;
|
||||
/** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */
|
||||
export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20);
|
||||
|
||||
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
|
||||
/** 특별측점 간격(m) — MC 가 일반측점 사이 10 m 배수(No.N+10)면 측점 위로 봄(280-4). */
|
||||
export const SPECIAL_STATION_M = 10;
|
||||
|
||||
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격(스냅) · 측점 간격(없으면 스냅 간격). */
|
||||
export interface FitLimits {
|
||||
minRadiusM: number;
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
intervalM: number;
|
||||
stationM?: number;
|
||||
}
|
||||
|
||||
/** MC 를 놓을 격자 — 간격 · 어긋남(N+10 꼴 = 10) · 밀 거리. */
|
||||
type Grid = { step: number; offset: number; reach: number };
|
||||
|
||||
type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curveArc" | "apexLock">;
|
||||
|
||||
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
|
||||
@@ -60,24 +70,56 @@ export function offStationCount(
|
||||
return offStationMcs(line, curves, intervalM).length;
|
||||
}
|
||||
|
||||
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
|
||||
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정 ·
|
||||
* 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」. */
|
||||
export function offStationMcs(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<EditedCurve>,
|
||||
intervalM: number,
|
||||
): number[] {
|
||||
if (!(intervalM > 0)) return [];
|
||||
const tol = mcTolM(intervalM);
|
||||
const hits = new Set<number>();
|
||||
const off: number[] = [];
|
||||
for (const [start, end] of curveSpans(line, curves)) {
|
||||
const mc = (start + end) / 2;
|
||||
const n = Math.round(mc / intervalM);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
|
||||
const at = [intervalM, SPECIAL_STATION_M]
|
||||
.map((step) => Math.round(mc / step) * step)
|
||||
.find((c) => c > tol && Math.abs(mc - c) <= tol && !hits.has(Math.round(c * 1000)));
|
||||
if (at !== undefined) hits.add(Math.round(at * 1000));
|
||||
else off.push(mc);
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
/** 꺾임점 `k` 의 IP 자리 + 곡선 칸 — 고치기 전 · 뒤에 같으면 손대지 않은 곡선. */
|
||||
const nodeKey = (cols: Columns & Pick<NodeColumns, "planned">, k: number): string =>
|
||||
JSON.stringify([
|
||||
cols.planned[k],
|
||||
cols.curveOn[k],
|
||||
cols.curveRadius[k],
|
||||
cols.curveLock[k],
|
||||
cols.curveArc[k],
|
||||
]);
|
||||
|
||||
/** 고치기 전 노선의 곡선 꼴 — MC 가 10 m 배수인 곡선의 `nodeKey` → 측점 간격 안 어긋남(0 · 10). */
|
||||
function keptForms(
|
||||
before: Columns & Pick<NodeColumns, "planned">,
|
||||
limits: FitLimits,
|
||||
stationM: number,
|
||||
): Map<string, number> {
|
||||
const built = build(before, before.planned, limits);
|
||||
const out = new Map<string, number>();
|
||||
for (let k = 1; k < before.planned.length - 1; k += 1) {
|
||||
const mc = mcOf(built, k);
|
||||
if (mc === null) continue;
|
||||
const at = Math.round(mc / SPECIAL_STATION_M) * SPECIAL_STATION_M;
|
||||
if (at <= 0 || Math.abs(mc - at) > mcTolM(SPECIAL_STATION_M)) continue;
|
||||
out.set(nodeKey(before, k), ((at % stationM) + stationM) % stationM);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
|
||||
export function fitStart(
|
||||
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
|
||||
@@ -98,7 +140,8 @@ export function fitStart(
|
||||
|
||||
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
|
||||
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 ±reach 안 d 밀어 MC = target(할선법 · 기울기 ≈ 1) ·
|
||||
* reach = 간격(한가운데 18+9 ~ 19+1 도 가까운 측점까지 · 441 · 반 간격이면 못 닿음). */
|
||||
function solve(
|
||||
cols: Columns,
|
||||
limits: FitLimits,
|
||||
@@ -106,6 +149,7 @@ function solve(
|
||||
k: number,
|
||||
target: number,
|
||||
mc: number,
|
||||
reach: number,
|
||||
): { planned: Vertex[]; built: EditedPolyline; mc: number } | null {
|
||||
const base = planned[k];
|
||||
const before = planned[k - 1];
|
||||
@@ -113,7 +157,6 @@ function solve(
|
||||
if (!(length > 1e-9)) return null;
|
||||
const ux = (base[0] - before[0]) / length;
|
||||
const uy = (base[1] - before[1]) / length;
|
||||
const reach = limits.intervalM; // 한가운데(18+9 ~ 19+1)도 가까운 측점까지(441 · 반 간격이면 못 닿음)
|
||||
const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
|
||||
const at = (d: number) => {
|
||||
const next = planned.slice();
|
||||
@@ -141,11 +184,13 @@ function solve(
|
||||
|
||||
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로).
|
||||
* 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②)
|
||||
* — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */
|
||||
* — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐.
|
||||
* `before` = 고치기 전 노선 — 손대지 않은 곡선은 그 MC 꼴(N+0 · N+10) 유지(280-4). */
|
||||
export function fitCurveStations(
|
||||
cols: Columns & Pick<NodeColumns, "planned">,
|
||||
limits: FitLimits,
|
||||
from: number,
|
||||
before?: (Columns & Pick<NodeColumns, "planned">) | null,
|
||||
): Vertex[] {
|
||||
let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
|
||||
const interval = limits.intervalM;
|
||||
@@ -153,19 +198,33 @@ export function fitCurveStations(
|
||||
let built = build(cols, planned, limits);
|
||||
const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
|
||||
const tol = mcTolM(interval);
|
||||
type Fit = { planned: Vertex[]; built: EditedPolyline };
|
||||
/** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */
|
||||
const near = (mc: number, last: number): number[] => {
|
||||
const lo = Math.floor(mc / interval);
|
||||
return [lo, lo + 1]
|
||||
.filter((n) => n > last)
|
||||
.sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc));
|
||||
const stationM = limits.stationM ?? interval;
|
||||
const forms = before ? keptForms(before, limits, stationM) : new Map<string, number>();
|
||||
const gridOf = (k: number): Grid => {
|
||||
const offset = forms.get(nodeKey(cols, k));
|
||||
return offset === undefined
|
||||
? { step: interval, offset: 0, reach: interval }
|
||||
: { step: stationM, offset, reach: stationM };
|
||||
};
|
||||
const onAt = (mc: number, last: number) =>
|
||||
near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol);
|
||||
/** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */
|
||||
const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => {
|
||||
const fitted = solve(cols, limits, at.planned, k, n * interval, mc);
|
||||
type Fit = { planned: Vertex[]; built: EditedPolyline };
|
||||
/** 곡선을 놓을 측점(누가거리) — 내림 · 올림 중 앞 측점 뒤 · 가까운 차례. */
|
||||
const near = (mc: number, last: number, grid: Grid): number[] => {
|
||||
const lo = Math.floor((mc - grid.offset) / grid.step);
|
||||
return [lo, lo + 1]
|
||||
.map((n) => n * grid.step + grid.offset)
|
||||
.filter((c) => c > last + tol)
|
||||
.sort((a, b) => Math.abs(a - mc) - Math.abs(b - mc));
|
||||
};
|
||||
/** 이미 측점 위 — 그 곡선 격자 · 고른 간격 · 10 m 배수(N+10) 중 하나. */
|
||||
const loose: Grid[] = [
|
||||
{ step: interval, offset: 0, reach: 0 },
|
||||
{ step: SPECIAL_STATION_M, offset: 0, reach: 0 },
|
||||
];
|
||||
const onAt = (mc: number, last: number, grid: Grid) =>
|
||||
[grid, ...loose].flatMap((g) => near(mc, last, g)).find((c) => Math.abs(mc - c) <= tol);
|
||||
/** 곡선 `k` 를 측점 `c` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */
|
||||
const attempt = (at: Fit, k: number, mc: number, c: number, previous: number): Fit | null => {
|
||||
const fitted = solve(cols, limits, at.planned, k, c, mc, gridOf(k).reach);
|
||||
if (
|
||||
!fitted ||
|
||||
shortfallCrossed(
|
||||
@@ -185,46 +244,60 @@ export function fitCurveStations(
|
||||
for (let j = k + 1; j < at.planned.length - 1; j += 1) {
|
||||
const mc = mcOf(at.built, j);
|
||||
if (mc === null) continue;
|
||||
if (onAt(mc, last) !== undefined) return true;
|
||||
return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null);
|
||||
if (onAt(mc, last, gridOf(j)) !== undefined) return true;
|
||||
const grid = gridOf(j);
|
||||
return (
|
||||
!cols.apexLock[j] && near(mc, last, grid).some((c) => attempt(at, j, mc, c, k) !== null)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
|
||||
let lastStation = 0; // 앞 곡선이 놓인 측점 누가거리(없으면 0 = 시점 · 서로 다른 측점)
|
||||
let previous = -1; // 앞 곡선 꺾임점
|
||||
for (let k = 1; k < planned.length - 1; k += 1) {
|
||||
const mc = mcOf(built, k);
|
||||
if (mc === null) continue;
|
||||
const on = onAt(mc, lastStation);
|
||||
const on = onAt(mc, lastStation, gridOf(k));
|
||||
if (on !== undefined) lastStation = on;
|
||||
else if (k >= from && !cols.apexLock[k]) {
|
||||
let pick: { fit: Fit; n: number } | null = null;
|
||||
for (const n of near(mc, lastStation)) {
|
||||
const fit = attempt({ planned, built }, k, mc, n, previous);
|
||||
let pick: { fit: Fit; c: number } | null = null;
|
||||
for (const c of near(mc, lastStation, gridOf(k))) {
|
||||
const fit = attempt({ planned, built }, k, mc, c, previous);
|
||||
if (!fit) continue;
|
||||
pick ??= { fit, n };
|
||||
if (nextSettles(fit, k, n)) {
|
||||
pick = { fit, n };
|
||||
pick ??= { fit, c };
|
||||
if (nextSettles(fit, k, c)) {
|
||||
pick = { fit, c };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n));
|
||||
if (pick) (({ planned, built } = pick.fit), (lastStation = pick.c));
|
||||
}
|
||||
previous = k;
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
/** 편집 한 걸음 뒤 맞춤 — 고친 자리(`fitStart`)부터 · 손대지 않은 곡선은 `before` 꼴 유지(280-4). */
|
||||
export function fitAfter(
|
||||
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
|
||||
now: Columns & Pick<NodeColumns, "planned">,
|
||||
limits: FitLimits,
|
||||
): Vertex[] {
|
||||
return fitCurveStations(now, limits, fitStart(before, now), before);
|
||||
}
|
||||
|
||||
/** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고
|
||||
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */
|
||||
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작) · `before` = 누르기 전 노선(없으면 `base` + 지금 칸 · 280-4 유지). */
|
||||
export function fitDrag(
|
||||
cols: Columns & Pick<NodeColumns, "planned">,
|
||||
base: ReadonlyArray<Vertex>,
|
||||
k: number,
|
||||
limits: FitLimits,
|
||||
before?: (Columns & Pick<NodeColumns, "planned">) | null,
|
||||
): Vertex[] {
|
||||
if (base.length !== cols.planned.length)
|
||||
return fitCurveStations(cols, limits, Math.max(1, k - 1));
|
||||
return fitCurveStations(cols, limits, Math.max(1, k - 1), before);
|
||||
const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex));
|
||||
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1));
|
||||
const kept = before ?? { ...cols, planned: base.map((v): Vertex => [v[0], v[1]]) };
|
||||
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1), kept);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/* PLAN 483-3(요구 280-4) 「유지」 — `logic/B05_Profile_RouteEdit_StationFit.ts` 를 Node 로 돌려 JSON 한 줄
|
||||
* (`test_280_4_fit.py` 가 판정). 분석 D 재현 2 노선(helper_366_3 와 같은 지그재그).
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
|
||||
const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
|
||||
|
||||
const STATION = 20;
|
||||
const BASE = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, stationM: STATION };
|
||||
const STEP10 = { ...BASE, intervalM: 10 };
|
||||
const STEPNO = { ...BASE, intervalM: STATION };
|
||||
const ROUTE = [
|
||||
[0, 0],
|
||||
[93, 7],
|
||||
[171, 61],
|
||||
[262, 48],
|
||||
[337, 112],
|
||||
[431, 101],
|
||||
[503, 160],
|
||||
];
|
||||
|
||||
function columns(planned) {
|
||||
const n = planned.length;
|
||||
return {
|
||||
planned: planned.map(([x, y]) => [x, y]),
|
||||
curveOn: planned.map(() => true),
|
||||
curveRadius: planned.map(() => null),
|
||||
curveLock: planned.map(() => null),
|
||||
curveArc: planned.map(() => null),
|
||||
apexLock: planned.map((_, i) => i === 0 || i === n - 1),
|
||||
};
|
||||
}
|
||||
const withPlanned = (cols, planned) => ({ ...cols, planned: planned.map(([x, y]) => [x, y]) });
|
||||
const round2 = (v) => Math.round(v * 100) / 100;
|
||||
function mcs(cols) {
|
||||
const built = edits.rebuildLine({ ...cols, curveRadius: [...cols.curveRadius] }, STEPNO);
|
||||
return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => round2((a + b) / 2));
|
||||
}
|
||||
function off(cols, step) {
|
||||
const built = edits.rebuildLine({ ...cols, curveRadius: [...cols.curveRadius] }, STEPNO);
|
||||
return fit.offStationCount(built.vertices, built.curves, step);
|
||||
}
|
||||
/** IP `k` 를 들어오는 접선 따라 d 밀기 — 손 편집 흉내. */
|
||||
function nudge(cols, k, d) {
|
||||
const p = cols.planned;
|
||||
const len = Math.hypot(p[k][0] - p[k - 1][0], p[k][1] - p[k - 1][1]);
|
||||
const ux = (p[k][0] - p[k - 1][0]) / len;
|
||||
const uy = (p[k][1] - p[k - 1][1]) / len;
|
||||
const next = p.map(([x, y]) => [x, y]);
|
||||
next[k] = [p[k][0] + ux * d, p[k][1] + uy * d];
|
||||
return withPlanned(cols, next);
|
||||
}
|
||||
/** IP `k` 를 화면 좌표로 (dx, dy) 옮김 — 앞 교점 손 편집(접선 밖 · 맞춤이 되밀어도 제자리로 안 돌아감). */
|
||||
function shift(cols, k, dx, dy) {
|
||||
const next = cols.planned.map(([x, y]) => [x, y]);
|
||||
next[k] = [next[k][0] + dx, next[k][1] + dy];
|
||||
return withPlanned(cols, next);
|
||||
}
|
||||
/** 손 편집 한 걸음 = 편집기 applyEdit(`fitAfter(before, now, limits)`). */
|
||||
const step = (before, now, limits) => withPlanned(now, fit.fitAfter(before, now, limits));
|
||||
|
||||
const FRONT = [0, 3]; // 앞 IP1 을 y +3 m
|
||||
|
||||
// ① 측점 No. 로 맞춘 노선
|
||||
const s1 = withPlanned(columns(ROUTE), fit.fitCurveStations(columns(ROUTE), STEPNO, 1));
|
||||
// ② 10 m · IP2 를 손으로 밀어 그 곡선 MC = N+10(밀 거리 훑어 첫 N+10)
|
||||
let s2 = null;
|
||||
let pushed = null;
|
||||
for (let d = 1; d <= 12 && !s2; d += 0.5) {
|
||||
const now = step(s1, nudge(s1, 2, d), STEP10);
|
||||
const mc = mcs(now)[1];
|
||||
if (Math.abs(mc / 10 - Math.round(mc / 10)) < 0.005 && Math.round(mc / 10) % 2 === 1)
|
||||
((s2 = now), (pushed = d));
|
||||
}
|
||||
// ③ 간격만 측점 No. 로(편집기 = 간격 바꿈 → applyEdit · 노선 그대로)
|
||||
const s3 = step(s2, s2, STEPNO);
|
||||
// ④ 측점 No. 로 돌린 뒤 앞 IP1 +3 m
|
||||
const s4 = step(s3, shift(s3, 1, FRONT[0], FRONT[1]), STEPNO);
|
||||
// ⑤ 10 m 그대로 앞 IP1 +3 m
|
||||
const s5 = step(s2, shift(s2, 1, FRONT[0], FRONT[1]), STEP10);
|
||||
// ⑥ 그 곡선 IP2 를 손으로 다시 밀기(측점 No.) → N+0
|
||||
const s6 = step(s3, nudge(s3, 2, 4), STEPNO);
|
||||
// ⑦ before 없이(옛 동작) 측점 No. 로 다시 맞춤 = N+10 이 흔들림(기준이 고침을 재는지)
|
||||
const s7 = withPlanned(s4, fit.fitCurveStations(shift(s3, 1, FRONT[0], FRONT[1]), STEPNO, 1));
|
||||
// ⑧ 끄는 중 미리보기(fitDrag · before) = 놓을 때(fitAfter)
|
||||
const dragNow = shift(s3, 1, FRONT[0], FRONT[1]);
|
||||
const preview = withPlanned(dragNow, fit.fitDrag(dragNow, s3.planned, 1, STEPNO, s3));
|
||||
const release = step(s3, preview, STEPNO);
|
||||
const gap = Math.max(
|
||||
...preview.planned.map((v, i) =>
|
||||
Math.hypot(v[0] - release.planned[i][0], v[1] - release.planned[i][1]),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
pushed,
|
||||
mc: {
|
||||
s1: mcs(s1),
|
||||
s2: mcs(s2),
|
||||
s3: mcs(s3),
|
||||
s4: mcs(s4),
|
||||
s5: mcs(s5),
|
||||
s6: mcs(s6),
|
||||
s7: mcs(s7),
|
||||
},
|
||||
off: { s2no: off(s2, STATION), s2ten: off(s2, 10), s4: off(s4, STATION) },
|
||||
dragGap: gap,
|
||||
previewMc: mcs(preview),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
"""PLAN 483-3(요구 280-4) 「유지」 — 손대지 않은 곡선은 MC 꼴(N+0 · N+10) 그대로 · 손으로 고친 곡선만 고른 간격.
|
||||
|
||||
`logic/B05_Profile_RouteEdit_StationFit.ts`(`fitAfter` · `fitDrag` · `offStationCount`)를
|
||||
`helper_280_4_fit.cjs` 로 돌림 — 분석 D 재현 2 노선. 파이썬 짝 아님(서버는 저장 IP 그대로).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_280_4_fit.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def _near(values: list[float], want: list[float]) -> bool:
|
||||
return values == pytest.approx(want, abs=0.05)
|
||||
|
||||
|
||||
def test_측점_No_맞춤(got) -> None:
|
||||
assert _near(got["mc"]["s1"], [100, 180, 280, 380, 460])
|
||||
|
||||
|
||||
def test_10m_손으로_N_10(got) -> None:
|
||||
assert got["pushed"] is not None
|
||||
assert _near(got["mc"]["s2"], [100, 190, 280, 380, 460])
|
||||
|
||||
|
||||
def test_간격만_측점_No_로_바꿔도_남음(got) -> None:
|
||||
assert _near(got["mc"]["s3"], [100, 190, 280, 380, 460])
|
||||
|
||||
|
||||
def test_앞_교점_옮긴_뒤에도_남음(got) -> None:
|
||||
assert _near(got["mc"]["s4"], [100, 190, 280, 380, 460]) # 측점 No.
|
||||
assert _near(got["mc"]["s5"], [100, 190, 280, 380, 460]) # 10 m · N+0 곡선은 N+10 으로 안 감
|
||||
|
||||
|
||||
def test_기준이_고침을_잼(got) -> None:
|
||||
"""before 없이(고치기 전 동작) 같은 편집 = 특별측점 사라짐 → 위 시험이 새 길을 거침."""
|
||||
assert got["mc"]["s7"][1] == pytest.approx(180, abs=0.05)
|
||||
|
||||
|
||||
def test_그_곡선을_손으로_밀면_고른_간격(got) -> None:
|
||||
assert _near(got["mc"]["s6"], [100, 200, 280, 380, 460]) # N+0 = 특별측점 없어짐
|
||||
|
||||
|
||||
def test_N_10_은_측점_밖이_아님(got) -> None:
|
||||
assert got["off"] == {"s2no": 0, "s2ten": 0, "s4": 0}
|
||||
|
||||
|
||||
def test_끄는_중_미리보기_놓을_때_같음(got) -> None:
|
||||
assert got["dragGap"] < 1e-6
|
||||
assert _near(got["previewMc"], [100, 190, 280, 380, 460])
|
||||
|
||||
|
||||
def test_편집기_이음() -> None:
|
||||
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit
|
||||
assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit
|
||||
assert "stationM: stationIntervalM" in edit
|
||||
assert len(edit.splitlines()) <= 695
|
||||
@@ -89,7 +89,7 @@ def test_시작_자리는_고친_꺾임점_앞(got) -> None:
|
||||
|
||||
def test_편집기_이음() -> None:
|
||||
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
|
||||
assert "fitCurveStations(" in edit
|
||||
assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit # 280-4
|
||||
# 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤
|
||||
at = edit.index("function restoreSnapshot(")
|
||||
assert "applyEdit(message, record, false);" in edit[at : at + 700]
|
||||
|
||||
@@ -117,7 +117,7 @@ def test_끌기_방향키_편집기_이음() -> None:
|
||||
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "params.fitDrag(index)" in pointer and "fitPreview(after.planned, node);" in pointer
|
||||
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
|
||||
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
|
||||
assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit
|
||||
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
|
||||
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer
|
||||
assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys
|
||||
|
||||
Reference in New Issue
Block a user