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:
@@ -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 ` +
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user