- 새 `B05_Profile_Engine_InitialProfile` — 후보 노드 → 화면 펴기 짝(`draft_nodes` ↔ TS flattenServerPlan) → 노선 짜기(csv 4자리) → 관 · 구조물 · 추가 측점 따라감(새 세류선 교차 관 포함) → build_sections(438 1차 계획선) → 켬 = 1 m 균형 종단 얹기(seat_base_pvi)
- 새 `B05_Profile_Router_RouteInitial_Initial.initial_profile_of` — [적용] 체인과 같은 입력 모으기 · 지문(후보 · 지형 · 페이지 설정 · 시설 파일 · 측점 옵션 · 균형 인자 · 코드) · 저장본 `route_recommend/initial/<키>.json` · 실패 = {error}
- compute_initial 응답에 `initial_profile`(stations_m · ground_m · plan_m · line · pvi_count · curve_count · straightened · max_grade_pct · curve_length_m · warnings · with_profile · seconds)
- 시험 test_476_1_recommend_initial — 미리 셈 ↔ 체인 파일 길 꼭짓점 차 0 mm(끔 · 노선 바뀜 + 옹벽 + 추가 측점 · 켬) · 지문 · 응답 칸
- 위키 calculations N2 · rules 줄
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
19 KiB
TypeScript
430 lines
19 KiB
TypeScript
/* =============================================================================
|
||
* 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).
|
||
*
|
||
* ⚠ 짝(430) — `flattenServerPlan` 이 읽는 넣은 값 `edits` ↔ `B05_Profile_Route_NodeEdits.py`(쓰기) ·
|
||
* 거울 시험 `test_430_route_edit_rules.py`.
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
buildEditedPolyline,
|
||
DUPLICATE_TOLERANCE_M,
|
||
innerAngleDeg,
|
||
type EditedCurve,
|
||
type EditedNode,
|
||
type EditedPolyline,
|
||
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);
|
||
}
|
||
|
||
/** 선을 짜는 값 — 기본 반지름 · 못 넘는 하한(R · L). */
|
||
export interface CurveLimits {
|
||
minRadiusM: number;
|
||
limitRadiusM: number;
|
||
limitArcM: number;
|
||
}
|
||
|
||
/** 편집 창 `markEdited` 의 셈 — 길이를 붙든 자리 R 다시 잡기 → 지정값 하한까지 끌어올림(`curveRadius`
|
||
* 를 고침) → 선. 교각이 바뀌었을 수 있어 R 부터 잡고 그린다. */
|
||
export function rebuildLine(
|
||
cols: Omit<NodeColumns, "apexLock">,
|
||
limits: CurveLimits,
|
||
): EditedPolyline {
|
||
applyArcLocks(cols.planned, cols.curveLock, cols.curveArc, cols.curveRadius);
|
||
applyCurveLimits(
|
||
cols.planned,
|
||
cols.curveOn,
|
||
cols.curveRadius,
|
||
limits.limitRadiusM,
|
||
limits.limitArcM,
|
||
);
|
||
return buildEditedPolyline(cols.planned, cols.curveOn, cols.curveRadius, limits.minRadiusM);
|
||
}
|
||
|
||
/** 하한 글 꼬리 — 「(곡선반지름 12m · 곡선 길이 20m)」. */
|
||
export function floorNote(limitRadiusM: number, limitArcM: number): string {
|
||
return `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""})`;
|
||
}
|
||
|
||
/** 자리 번호 셋까지 「IP3 · IP5 외 2」. */
|
||
const ipList = (seats: ReadonlyArray<number>): string =>
|
||
seats
|
||
.slice(0, 3)
|
||
.map((seat) => `IP${seat}`)
|
||
.join(" · ") + (seats.length > 3 ? ` 외 ${seats.length - 3}` : "");
|
||
|
||
/**
|
||
* 이번 편집이 하한을 **새로** 깼으면 까닭 한 줄 · 아니면 ""(430 · 모든 평면 조작이 `applyEdit` 한 곳에서).
|
||
* `before` = 지난 걸음 사진 · `nodes` = 지금 노드 요약(스냅 맞춤 전 — 맞춤은 스스로 하한을 지킴).
|
||
* 노드를 하나 끼우거나 뺐으면 처음 달라진 자리에서 줄을 맞춰 견줌(새 노드 = 지키던 자리) · 둘 넘게 다르면 안 견줌.
|
||
*/
|
||
export function floorStop(
|
||
before: NodeColumns,
|
||
planned: ReadonlyArray<Vertex>,
|
||
nodes: ReadonlyArray<EditedNode>,
|
||
limits: CurveLimits,
|
||
): string {
|
||
const gap = planned.length - before.planned.length;
|
||
if (Math.abs(gap) > 1) return "";
|
||
const built = rebuildLine({ ...before, curveRadius: [...before.curveRadius] }, limits);
|
||
const was = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
|
||
const now = curveShortfalls(nodes, limits.limitRadiusM, limits.limitArcM);
|
||
const differ = planned.findIndex(
|
||
(vertex, i) => vertex[0] !== before.planned[i]?.[0] || vertex[1] !== before.planned[i]?.[1],
|
||
);
|
||
const at = differ < 0 ? planned.length : differ;
|
||
const old = (i: number): number =>
|
||
(gap === 0 || i < at ? was[i] : gap > 0 ? (i === at ? 0 : was[i - 1]) : was[i + 1]) ?? 0;
|
||
const seats = now.flatMap((value, i) => (value > 1e-6 && old(i) <= 1e-6 ? [i] : []));
|
||
if (!seats.length) return "";
|
||
return `하한에 걸려 되돌렸습니다 — ${ipList(seats)}${floorNote(limits.limitRadiusM, limits.limitArcM)}.`;
|
||
}
|
||
|
||
/** 상태줄 표시(430) — 겹친 노드(앞 노드에서 0.5 m 안 · 선을 짤 때 버림 · 짝 `DUPLICATE_TOLERANCE_M`) ·
|
||
* 못 그린 곡선(켬인데 곡선이 없음 · 곧은 자리 · 겹친 노드는 뺌). */
|
||
export function routeFlags(
|
||
planned: ReadonlyArray<Vertex>,
|
||
curveOn: ReadonlyArray<boolean>,
|
||
curves: ReadonlyArray<EditedCurve>,
|
||
): { overlap: number[]; undrawn: number[] } {
|
||
const overlap: number[] = [];
|
||
let last: Vertex | null = null;
|
||
planned.forEach((vertex, i) => {
|
||
if (last && Math.hypot(vertex[0] - last[0], vertex[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
|
||
overlap.push(i);
|
||
else last = vertex;
|
||
});
|
||
const drawn = new Set(curves.map((curve) => curve.node_first));
|
||
const undrawn = planned.flatMap((vertex, i) =>
|
||
i === 0 ||
|
||
i === planned.length - 1 ||
|
||
curveOn[i] === false ||
|
||
drawn.has(i) ||
|
||
overlap.includes(i) ||
|
||
!(innerAngleDeg(planned[i - 1], vertex, planned[i + 1]) < 179.9)
|
||
? []
|
||
: [i],
|
||
);
|
||
return { overlap, undrawn };
|
||
}
|
||
|
||
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;
|
||
/** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
|
||
offStation?: number;
|
||
/** 겹친 노드 · 못 그린 곡선 자리(430 · `routeFlags`). */
|
||
flags?: { overlap: number[]; undrawn: number[] };
|
||
}
|
||
|
||
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
|
||
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(" · ");
|
||
const { overlap, undrawn } = input.flags ?? { overlap: [], undrawn: [] };
|
||
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}곳` : ""}` +
|
||
`${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
|
||
`${overlap.length ? ` · 겹친 노드 ${ipList(overlap)}(0.5 m 안 · 선에서 빠짐)` : ""}` +
|
||
`${undrawn.length ? ` · 못 그린 곡선 ${ipList(undrawn)}` : ""}` +
|
||
`${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[];
|
||
}
|
||
|
||
/** 노드마다 넣은 편집값(430) — 곡선표 `edits` 칸 · 서버 `node_edits`(⚠ 짝 `B05_Profile_Route_NodeEdits.py`). */
|
||
export interface RouteNodeEdit {
|
||
curve: boolean;
|
||
/** 넣은 R(그린 R 아님 · 접선 자리에 눌렸어도 넣은 값) · null = 자동. */
|
||
radius_m: number | null;
|
||
lock: CurveLock;
|
||
arc_m: number | null;
|
||
}
|
||
|
||
/**
|
||
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
|
||
*
|
||
* 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
|
||
* 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도
|
||
* 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R
|
||
* 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은
|
||
* 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**.
|
||
*
|
||
* ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안
|
||
* 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집
|
||
* 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다.
|
||
*
|
||
* `edits`(430 · 노드 수와 같을 때만 서버가 줌)가 있으면 곡선 켬끔 · R · 고정 · 고정 길이는 **넣은 값**으로 —
|
||
* 그린 R 로 열면 접선 자리에 눌린 R 이 넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐.
|
||
*
|
||
* 짝(edits 없음) = 파이썬 `B05_Profile_Engine_InitialProfile.draft_nodes`(추천 초기 종단 476) ·
|
||
* 거울 `test_476_1_recommend_initial`.
|
||
*/
|
||
export function flattenServerPlan(
|
||
nodes: ServerNode[],
|
||
curves: EditedCurve[],
|
||
edits?: ReadonlyArray<RouteNodeEdit> | null,
|
||
): 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 ?? []),
|
||
});
|
||
const edit = edits?.[index];
|
||
out.curveOn.push(edit ? edit.curve : curve !== undefined);
|
||
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
|
||
out.curveRadius.push(
|
||
edit ? edit.radius_m : curve ? Math.round(curve.radius_m * 100) / 100 : null,
|
||
);
|
||
out.curveLock.push(edit?.lock ?? null);
|
||
out.curveArc.push(edit?.arc_m ?? null);
|
||
out.apexLock.push(false);
|
||
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** 꺾임점마다 한 칸씩 든 편집값 묶음 — 노드를 넣고 뺄 때 **모두 같은 자리**를 움직인다. */
|
||
export interface NodeColumns {
|
||
planned: Vertex[];
|
||
curveOn: boolean[];
|
||
curveRadius: Array<number | null>;
|
||
curveLock: CurveLock[];
|
||
curveArc: Array<number | null>;
|
||
apexLock: boolean[];
|
||
}
|
||
|
||
/** `vertex` 를 주면 `index` 자리에 새 노드를 끼우고(곡선 켬 · 값 비움), 안 주면 그 노드를 뺀다.
|
||
* 편집값도 같은 자리에서 움직인다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. */
|
||
export function spliceNode(columns: NodeColumns, index: number, vertex?: Vertex): void {
|
||
if (vertex) {
|
||
columns.planned.splice(index, 0, vertex);
|
||
columns.curveOn.splice(index, 0, true);
|
||
columns.curveRadius.splice(index, 0, null);
|
||
columns.curveLock.splice(index, 0, null);
|
||
columns.curveArc.splice(index, 0, null);
|
||
columns.apexLock.splice(index, 0, false);
|
||
return;
|
||
}
|
||
columns.planned.splice(index, 1);
|
||
columns.curveOn.splice(index, 1);
|
||
columns.curveRadius.splice(index, 1);
|
||
columns.curveLock.splice(index, 1);
|
||
columns.curveArc.splice(index, 1);
|
||
columns.apexLock.splice(index, 1);
|
||
}
|