Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts
T
eomsangdonandClaude Opus 5 1fafe2548c fix(b05): 곡선 길이(L) 하한도 지키게 — 내각 155° 이상은 예외
R 하한만 막고 L 하한은 칸 입력에서만 막던 것을 고침. L = R·Δ 라 한 자리의 하한을
반지름 하나로 모아 봄(`radiusFloorM` = max(R 하한, L 하한/Δ)) — 값 끌어올리기·
모자란 양 판정·칸 막기가 모두 이 값을 씀.

내각 155° 이상은 별표2 Ⅰ.2.다.(1) 상 곡선을 안 둬도 되는 자리라 L 하한을 안 걺 —
걸면 교각이 0 에 가까워 R 이 수십 배로 부풀어 안 고친 구간이 휨. 그 자리는 곡선
패널에 「155° 이상 — 곡선 생략 가능」으로 적어 냄.

`deflectionRad` 를 화면 파일(`_Label`)에서 셈 파일(`_Edits`)로 옮김 — 순수 함수가
DOM 파일에 얹혀 있어 시험이 화면째 들여와야 했음(부르던 자리는 재수출로 지킴).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-12 21:48:39 +09:00

273 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_RouteEdit_Edits.ts
* 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약.
*
* **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이
* 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은
* 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다.
*
* · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다.
* · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다.
* · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다.
*
* ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`).
* 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다.
*
* **하한은 R 과 L 둘 다 본다**(2026-09-12 사용자 확정 「둘 다 지켜야 함」). L = R·Δ 라
* 한 자리의 하한은 결국 **반지름 하나**로 모인다 — `radiusFloorM` 이 그 값을 낸다.
* 다만 **내각 155° 이상은 뺀다**(별표2 .2.다.(1) 「내각 155도 이상 되는 장소는 곡선을
* 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로
* 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m).
* ========================================================================== */
import {
innerAngleDeg,
type EditedCurve,
type EditedNode,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
/** 무엇을 붙들고 있나. */
export type CurveLock = "radius" | "arc" | null;
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
/** 곡선을 **안 둘 수 있는** 내각(도) — 별표2 .2.다.(1) · .3.라.(1). */
export const CURVE_OPTIONAL_INNER_ANGLE_DEG = 155;
/** 그 자리에서 곡선을 생략해도 되나 — 내각이 155° 이상이면 그렇다. */
export function curveOptionalAt(innerAngleDeg: number | null | undefined): boolean {
return innerAngleDeg !== null && innerAngleDeg !== undefined
? innerAngleDeg >= CURVE_OPTIONAL_INNER_ANGLE_DEG
: false;
}
/**
* 그 꺾임점의 **못 넘는 반지름 하한**(m) — R 하한과 L 하한을 함께 본 값.
*
* L = R·Δ 이므로 「L ≥ 하한」은 「R ≥ 하한/Δ」와 같다. 둘 중 큰 쪽이 그 자리의 하한이다.
* 내각 155° 이상(곡선 생략 가능)이거나 교각을 모르면 **R 하한만** 본다.
*/
export function radiusFloorM(
innerAngleDeg: number | null | undefined,
limitRadiusM: number,
limitArcM: number,
): number {
if (!(limitArcM > 0) || curveOptionalAt(innerAngleDeg)) return limitRadiusM;
const deflection = deflectionRad(innerAngleDeg);
if (!(deflection > 1e-9)) return limitRadiusM;
return Math.max(limitRadiusM, limitArcM / deflection);
}
/**
* **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침).
*
* 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다.
* 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다.
*/
export function applyArcLocks(
planned: Vertex[],
curveLock: CurveLock[],
curveArc: Array<number | null>,
curveRadius: Array<number | null>,
): void {
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveLock[seat] !== "arc") continue;
const length = curveArc[seat];
if (length === null || length === undefined) continue;
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
const deflection = deflectionRad(inner);
if (deflection <= 1e-9) continue;
curveRadius[seat] = length / deflection;
}
}
/**
* **하한(R·L)을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
*
* **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서
* 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
*
* 하한은 `radiusFloorM` 이 R·L 을 함께 본 값이다 — 내각 155° 이상은 R 하한만 본다.
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
limitArcM = 0,
): void {
if (limitRadiusM <= 0 && limitArcM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
const floor = radiusFloorM(inner, limitRadiusM, limitArcM);
if (current < floor) curveRadius[seat] = floor;
}
}
/**
* 노드마다 **하한(R·L)** 을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0.
*
* 재는 자는 `radiusFloorM` 이다 — L 하한도 반지름으로 환산해 함께 본다. 거의 곧은 자리
* (내각 155° 이상)는 곡선을 안 둬도 되는 자리라 R 하한만 본다. 그 예외가 없으면 그런
* 자리에서 **노드를 한 뼘도 못 옮긴다**.
*
* 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면
* 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미
* 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12).
*
* ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로
* 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로
* 지나갔다). 자리마다 따로 견준다.
*/
export function curveShortfalls(
nodes: ReadonlyArray<EditedNode>,
limitRadiusM: number,
limitArcM = 0,
): number[] {
return nodes.map((node) => {
if (node.radius_m === null) return 0;
const floor = radiusFloorM(node.inner_angle_deg, limitRadiusM, limitArcM);
if (floor <= 0) return 0;
return Math.max(0, floor - node.radius_m);
});
}
/**
* 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기)
* 안 따진다.
*
* ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** —
* 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12
* 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는
* 것만** 막는다.
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
curveCount: number;
/** 법정 기준을 못 맞춘 자리 수. */
violationCount: number;
minRadiusM: number;
/** 아직 한 번도 안 그렸나(막 열었을 때). */
fresh: boolean;
}
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
export function curveSummary(input: CurveSummaryInput): string {
const off = input.curveOn.filter(
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
).length;
const forced = input.curveRadius.filter((value) => value !== null).length;
const locked =
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
const edits = [
off ? `곡선 지움 ${off}곳` : "",
forced ? `R 지정 ${forced}곳` : "",
locked ? `고정 ${locked}곳` : "",
]
.filter(Boolean)
.join(" · ");
if (input.fresh) {
const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : "";
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
}
return (
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
`${edits ? ` · ${edits}` : ""}`
);
}
/** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */
export interface FlattenedPlan {
planned: Vertex[];
nodes: EditedNode[];
curves: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
apexLock: boolean[];
}
interface ServerNode {
x: number;
y: number;
radius_m: number | null;
inner_angle_deg: number | null;
violations?: string[];
}
/**
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
*
* 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
* 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도
* 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R
* 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은
* 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**.
*
* ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안
* 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집
* 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다.
*/
export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan {
const replaced = new Map<number, EditedCurve>();
const dropped = new Set<number>();
curves.forEach((curve) => {
replaced.set(curve.node_first, curve);
for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) {
dropped.add(index);
}
});
const out: FlattenedPlan = {
planned: [],
nodes: [],
curves: [],
curveOn: [],
curveRadius: [],
curveLock: [],
curveArc: [],
apexLock: [],
};
nodes.forEach((node, index) => {
if (dropped.has(index)) return;
const curve = replaced.get(index);
const seat = out.planned.length;
out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]);
out.nodes.push({
radius_m: curve ? curve.radius_m : node.radius_m,
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
tangent_m: null,
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
});
out.curveOn.push(curve !== undefined);
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null);
out.curveArc.push(null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
return out;
}