From d8080109c1309919639fa130bf2dfc1f54268732 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 17:01:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=89=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EC=9D=98(=ED=9E=8C=EC=A7=80=20=EC=8A=B9?= =?UTF-8?q?=EA=B2=A9)=20+=20=EC=B5=9C=EC=86=8C=EA=B3=A0=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=A0=90=EC=9D=84=20=EC=A0=9C=EC=96=B4=EC=A0=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../B05_Profile_UI_Profile_Alignment.ts | 74 +++++++++++++++---- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 4 + B05_Profile/B05_Profile_UI_Profile_Render.ts | 13 +++- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index e19c7d66..2c84fa49 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -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 }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 629abe72..d762e5b5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -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 ` + diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 9b14028b..dd03bab8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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) => {