feat(B05): 쉬프트 재정의(힌지 승격) + 최소고 판정점을 제어점으로

2026-08-23 사용자 개념 확정 반영.

- 틸트는 그 지점을 라운드로 주변 직선과 잇는 조작이고, 측점·횡단배수 지점의
  수직선에는 라운드 중심점이 놓인다. 따라서 최소고 판정도 제어점 z 기준이어야
  한다(controlElevationAt) - 곡선 샘플 계획고로 재던 탓에 배수 지점 옆을
  틸팅해 라운드 형상만 바뀌어도 락이 걸렸다.
- 쉬프트 재정의(shiftMovingPoints): 구간 끝이 사용자 틸팅점이면 그 점이 직접
  움직이고, 고정점(BP·EP·횡단배수 앵커)이면 움직이지 않는다. 대신 구간 안쪽
  미틸트 측점의 외곽 2개를 힌지로 승격해 라운드를 만들고 그 사이 직선을
  평행이동한다. 힌지 둘을 못 만드는 구간(안쪽 측점 2개 미만)은 쉬프트 대상이
  아니므로 ⬆⬇ 버튼을 만들지 않는다(canShift).
- shiftSegment 되먹임 보정 - 라운드 중앙종거가 낀 자리에서도 화면 계획고가
  정확히 1스텝 움직인다.

검증: tsc 통과 · pytest 185 passed(힌지 규칙 4건 신규) · 공용 브라우저 실측 -
구간 0~84 ⬆에서 힌지(20·80)만 이동하고 BP·앵커 오프셋 미생성, 두 힌지 계획고
정확히 +0.1000m(구배 보존), 앵커 이웃 80.0m ▼ 차단 해제, 앵커 84.3m ▼ 차단 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:01:45 +09:00
co-authored by Claude Fable 5
parent eb716ffbb1
commit d8080109c1
3 changed files with 71 additions and 20 deletions
+58 -16
View File
@@ -487,10 +487,46 @@ export function adjustStation(
}
/**
* 직선 구간 전체를 평행이동한다 (기울기 유지, 양 끝 변화점 동시 이동).
* 제어점 표고 — 그 자리에 변화점이 있으면 그 z(라운드 중심점), 없으면 곡선 샘플.
*
* 측점 버튼은 그 점을 꺾는 조작이라 "구배는 그대로 두고 높이만" 옮길 수 없다.
* 구간 양 끝에 같은 델타를 주면 그 직선의 기울기는 보존되고 인접 직선의 각도만 바뀐다.
* 틸팅은 지점을 라운드로 주변 직선과 잇는 조작이라, 이웃을 틸팅하면 라운드 **형상**은
* 변해도 제어점 z는 그대로다(2026-08-23 사용자 개념 확정). 횡단배수 최소고 판정도
* 이 값 기준이어야 이웃 틸팅이 부당하게 잠기지 않는다.
*/
export function controlElevationAt(alignment: ProfileAlignment, chainageM: number): number {
const node = alignment.pvi.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
return node ? node.elevation_m : planElevationAt(alignment, chainageM);
}
/**
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
*
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
* 안쪽 미틸트 측점 2개 미만).
*/
export function shiftMovingPoints(
alignment: ProfileAlignment,
segment: AlignmentSegment,
): [number, number] | null {
const offsets = alignment.edits.station_offsets;
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
const interior = alignment.stations
.map((station) => station.chainage_m)
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
const left = movable(segment.from_m) ? segment.from_m : interior[0];
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
if (left === undefined || right === undefined) return null;
if (right - left < 1e-6) return null;
return [left, right];
}
/**
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
*/
export function shiftSegment(
base: AlignmentBase,
@@ -498,24 +534,30 @@ export function shiftSegment(
segment: AlignmentSegment,
delta: number,
): AlignmentEdits {
const current = buildAlignment(base, edits);
const moving = shiftMovingPoints(current, segment);
if (!moving) return edits;
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const current = buildAlignment(base, edits);
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
const offsets = { ...edits.station_offsets };
// 구간 끝은 항상 변화점이다 — 초기 오프셋은 **변화점 실제 z** 기준으로 잡는다.
// 곡선 샘플 계획고(= PVI z − 중앙종거)를 쓰면 첫 이동에서 변화점이 중앙종거만큼
// 내려앉아 ⬆ 뒤 ⬇ 복귀가 원위치보다 낮아진다(2026-08-23 화면 실측: 복귀가
// 최소고 가드에 걸림). 변화점이 못 찾아지는 예외에서만 샘플 보간으로 폴백한다.
const nodeElevationAt = (chainage: number): number => {
const node = current.pvi.find((entry) => Math.abs(entry.chainage_m - chainage) < 1e-6);
return node ? node.elevation_m : planElevationAt(current, chainage);
};
[segment.from_m, segment.to_m].forEach((chainage) => {
moving.forEach((chainage) => {
const key = chainageKey(chainage);
const existing =
offsets[key] ?? nodeElevationAt(chainage) - interpolate(baseS, baseZ, chainage);
offsets[key] = Number((existing + delta).toFixed(6));
const seed =
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
offsets[key] = Number((seed + delta).toFixed(6));
});
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
let worst = 0;
moving.forEach((chainage, index) => {
const key = chainageKey(chainage);
const error = targets[index] - planElevationAt(trial, chainage);
worst = Math.max(worst, Math.abs(error));
offsets[key] = Number((offsets[key] + error).toFixed(6));
});
if (worst < FEEDBACK_TOLERANCE_M) break;
}
return { ...edits, station_offsets: offsets };
}
@@ -180,6 +180,8 @@ export interface EditOverlayOptions {
onResetStation: (chainageM: number) => void;
/** 구간 평행이동 편집을 자동 선형으로 원복(양 끝 오프셋 삭제). */
onResetSegment: (segment: AlignmentSegment) => void;
/** 쉬프트 가능 구간 판정 — false면 그 구간의 ⬆⬇를 만들지 않는다(힌지 부족). */
canShift?: (segment: AlignmentSegment) => boolean;
}
function overlayButton(
@@ -344,6 +346,8 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const left = x(segment.from_m);
const right = x(segment.to_m);
if (right - left < 36) return;
// 힌지(안쪽 미틸트 측점 2개)를 못 만드는 구간은 쉬프트 대상이 아니다(2026-08-23).
if (options.canShift && !options.canShift(segment)) return;
const center = avoidStations((left + right) / 2);
const label =
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
+9 -4
View File
@@ -19,8 +19,9 @@ import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profil
import {
adjustStation,
buildAlignment,
planElevationAt,
controlElevationAt,
setCurveRadius,
shiftMovingPoints,
shiftSegment,
type AlignmentBase,
type AlignmentEdits,
@@ -293,14 +294,16 @@ export function renderProfile(ctx: ProfileRenderContext): void {
if (!base) return false;
const targets = ctx.minCoverTargets();
if (!targets.length) return false;
// 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만
// 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨").
const candidate = buildAlignment(base, next);
const planned = findMinCoverViolations(targets, groundAt, (chainageM) =>
planElevationAt(candidate, chainageM),
controlElevationAt(candidate, chainageM),
);
if (!planned.length) return false;
const current = new Map(
findMinCoverViolations(targets, groundAt, (chainageM) =>
planElevationAt(alignment, chainageM),
controlElevationAt(alignment, chainageM),
).map((violation) => [violation.chainage_m, violation.shortfall_m]),
);
const worsened = planned.find(
@@ -333,9 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void {
onSegment: (segment, delta) => {
if (!base) return;
const next = shiftSegment(base, store.edits(), segment, delta);
if (blocksMinCover(next)) return;
if (next === store.edits() || blocksMinCover(next)) return;
ctx.applyEdits(next);
},
// 쉬프트 가능 구간 판정 — 안쪽 미틸트 측점 2개(힌지) 확보 못 하면 버튼 숨김.
canShift: (segment) => shiftMovingPoints(alignment, segment) !== null,
onResetStation: (chainage) => store.resetStation(chainage),
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
onResetSegment: (segment) => {