fix(461 B05 종단 직선화): 클릭 = 늘 측점 고름 · 끝 자동 R 지움 기본 L · 이미 직선 범위 부분 구간 · 실시간 범위 띠

- 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음
- 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 L 곡선
- 직선화 · 자유지정 끝 R 이 저장 자동 R 이면 지움 = 끝 곡선 L 20 · 사용자 R 은 둠
- 법정 미달 판정 오차 1e-3 짝(TS · 파이썬) — L 19.99998 거짓 미달 없앰
- 새 도우미 design_profile_range_band — 직선화 · 자유지정 범위 단계 실시간 띠 · 시작–끝 · 길이 글
- Esc = 시작만 취소 · 모드 유지 · 고름 풀기로 안 샘
- 시험 test_271_straighten_pick (거울 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:49:09 +09:00
co-authored by Claude Opus 5.5
parent 0f969c5a4d
commit 182de3b2e3
11 changed files with 825 additions and 53 deletions
+4 -22
View File
@@ -18,6 +18,7 @@ import {
type ChartWindow,
type FreeDraw,
} from "./logic/design_profile_free";
import { appendRangeBand } from "./design_profile_range_band";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -100,29 +101,10 @@ export function createFreeView(free: FreeDraw): FreeView {
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
}) as SVGSVGElement;
if (marks.band) {
const [left, right] = marks.band.map(x);
svg.append(
svgEl("rect", {
class: "b05-profile-freemark__band",
x: left,
y: 0,
width: Math.max(0, right - left),
height: heightPx,
}),
);
// 범위 띠 · 세로선 · 길이 글 = [직선화]와 한 벌(271-3) — 고르는 중엔 마우스 측점까지
if (marks.edges.length) {
appendRangeBand(svg, x, heightPx, marks.edges[0], marks.edges[1] ?? null);
}
marks.edges.forEach((chainage) =>
svg.append(
svgEl("line", {
class: "b05-profile-freemark__edge",
x1: x(chainage),
x2: x(chainage),
y1: 0,
y2: heightPx,
}),
),
);
marks.segments.forEach((segment) => {
const x1 = x(segment.from.chainage_m);
const x2 = x(segment.to.chainage_m);
@@ -0,0 +1,149 @@
/* =============================================================================
* A00_Common/design/design_profile_range_band.ts
* 종단 범위 실시간 띠 — 시작 세로선을 고른 뒤 마우스 측점까지 띠 · 세로선 · 「시작–끝 · 길이」 글(271-2 · 3).
*
* [직선화]는 `createRangeBandView` 로 그래프에 붙이고, [자유지정]은 `design_profile_free_view` 가
* 같은 `appendRangeBand` 로 그린다 — 띠 한 벌. 마우스 자리는 가장 가까운 측점선에 붙음 · 그래프 밖 =
* 시작선만 · 다시 들어오면 이어짐. 색 · 굵기는 자유지정 범위 띠(`b05-profile-freemark__*`)와 같음.
* ========================================================================== */
import { snapStation } from "./logic/design_profile_free";
import type { FreeChartGeom } from "./design_profile_free_view";
const SVG_NS = "http://www.w3.org/2000/svg";
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
return element;
}
/** 범위 띠를 붙인다 — 끝이 없거나 시작과 같으면 시작선만 · 반대 방향도 같은 띠. */
export function appendRangeBand(
svg: SVGElement,
x: (chainageM: number) => number,
heightPx: number,
startM: number,
endM: number | null,
): void {
const ends =
endM === null || Math.abs(endM - startM) < 1e-6
? [startM]
: [Math.min(startM, endM), Math.max(startM, endM)];
if (ends.length === 2) {
const [left, right] = ends.map(x);
svg.append(
svgEl("rect", {
class: "b05-profile-freemark__band",
x: left,
y: 0,
width: Math.max(0, right - left),
height: heightPx,
}),
);
const label = svgEl("text", {
class: "b05-profile-freemark__span",
x: (left + right) / 2,
y: 14,
"text-anchor": "middle",
});
label.textContent = `${ends[0].toFixed(1)}–${ends[1].toFixed(1)}m · ${(ends[1] - ends[0]).toFixed(1)}m`;
svg.append(label);
}
ends.forEach((chainage) =>
svg.append(
svgEl("line", {
class: "b05-profile-freemark__edge",
x1: x(chainage),
x2: x(chainage),
y1: 0,
y2: heightPx,
}),
),
);
}
export interface RangeBandView {
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
mount: (geom: FreeChartGeom | null, active: boolean) => void;
redraw: () => void;
}
/** 범위 띠 화면 — `source(마우스 측점)` 가 [시작, 끝 | null] 을 주면 그림 · null 이면 지움. */
export function createRangeBandView(
source: (hoverM: number | null) => [number, number | null] | null,
): RangeBandView {
let geom: FreeChartGeom | null = null;
let active = false;
let hover: number | null = null;
/** 마지막 마우스 x(화면) — 스크롤 · 다시 그림 뒤에도 커서 밑 측점을 다시 잡는다. */
let clientX: number | null = null;
let overlay: SVGElement | null = null;
let listeners: AbortController | null = null;
function redraw(): void {
overlay?.remove();
overlay = null;
const range = geom && active ? source(hover) : null;
if (!geom || !range) return;
const { widthPx, heightPx } = geom;
overlay = svgEl("svg", {
class: "b05-profile-freemark",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
});
appendRangeBand(overlay, geom.x, heightPx, range[0], range[1]);
geom.chart.append(overlay);
}
/** 커서 밑 측점을 다시 잡는다 — 바뀌었을 때만 다시 그림. */
function track(): void {
let next: number | null = null;
if (geom && clientX !== null) {
const at = geom.toChainage(clientX - geom.chart.getBoundingClientRect().left);
next = Number.isFinite(at) ? snapStation(geom.stations, at) : null;
}
if (next === hover) return;
hover = next;
redraw();
}
function mount(next: FreeChartGeom | null, on: boolean): void {
listeners?.abort();
listeners = null;
if (next) geom = next;
active = on;
if (!on) [hover, clientX] = [null, null];
if (!geom || !on || !geom.chart.isConnected) {
overlay?.remove();
overlay = null;
return;
}
listeners = new AbortController();
const { signal } = listeners;
const chart = geom.chart;
chart.addEventListener(
"mousemove",
(event) => {
clientX = event.clientX;
track();
},
{ signal },
);
chart.addEventListener(
"mouseleave",
() => {
clientX = null;
track();
},
{ signal },
);
geom.scroller.addEventListener("scroll", track, { signal, passive: true });
hover = null;
track();
redraw();
}
return { mount, redraw };
}
+32 -6
View File
@@ -4,7 +4,7 @@
*
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소(범위 첫 측점만 = 시작만 · 271-3) · ↶ ↷(Backspace = ↶) =
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
*
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
@@ -27,6 +27,7 @@ import {
import {
baseElevationAt,
defaultCurveLength,
dropAutoEndRadii,
pinEnds,
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight";
@@ -120,12 +121,14 @@ function elevationOnPath(path: FreeVertex[], chainageM: number): number {
/**
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음) ·
* 끝 R 이 저장 자동 R 이면 지워 기본 L(271-1).
*/
export function buildFreeEdits(
base: AlignmentBase,
edits: AlignmentEdits,
vertices: FreeVertex[],
autoRadii: Readonly<Record<string, number>> = {},
): AlignmentEdits {
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
if (path.length < 2) return edits;
@@ -148,6 +151,7 @@ export function buildFreeEdits(
pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => {
offsets[key] = Number(value.toFixed(6));
});
dropAutoEndRadii(radii, [fromM, toM], autoRadii);
inner.forEach((chainage, key) => {
const target = elevationOnPath(path, chainage);
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
@@ -191,6 +195,8 @@ export interface FreeDrawDeps {
edits: () => AlignmentEdits;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
apply: (next: AlignmentEdits) => boolean | void;
/** 저장 자동 R — 끝 R 이 이것과 같으면 지움(271-1). */
autoRadii?: () => Readonly<Record<string, number>>;
/** 모드를 끈다(적용 뒤 · Esc). */
close: () => void;
/** 단계 · 안내가 바뀜. */
@@ -222,6 +228,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
let anchor: number | null = null;
let points: FreeVertex[] = [];
let target: FreeVertex | null = null;
/** range 단계 첫 측점 뒤 마우스 측점 — 실시간 띠 끝(271-3). */
let hoverStation: number | null = null;
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
interface Snap {
phase: FreePhase;
@@ -249,6 +257,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
anchor = null;
points = [];
target = null;
hoverStation = null;
past = [];
future = [];
}
@@ -296,7 +305,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
const z = end === null ? null : heightAt(end);
if (!base || end === null || z === null) return;
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
const next = buildFreeEdits(base, deps.edits(), vertices);
const next = buildFreeEdits(base, deps.edits(), vertices, deps.autoRadii?.());
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
if (deps.apply(next) === false) return;
reset();
@@ -343,8 +352,19 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
},
hover(pointer) {
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
hoverStation =
pointer && phase === "range" && pending !== null
? snapStation(pointer.stations, pointer.chainageM)
: null;
},
key(key) {
// range 단계 첫 측점만 고른 채 Esc = 시작만 취소 · 모드 유지(271-3)
if (key === "Escape" && phase === "range" && pending !== null) {
pending = null;
hoverStation = null;
deps.changed();
return true;
}
if (key === "Escape") {
reset();
deps.close();
@@ -381,9 +401,15 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
preview: target !== null && index === line.length - 1,
});
}
// 범위 고르는 중엔 첫 측점 → 마우스 측점까지 띠(271-3) · 둘째 클릭 뒤엔 고른 범위로 고정
const live =
pending !== null && hoverStation !== null && !same(pending, hoverStation)
? ([Math.min(pending, hoverStation), Math.max(pending, hoverStation)] as [number, number])
: null;
const band = range ?? live;
return {
band: range,
edges: range ? [...range] : pending !== null ? [pending] : [],
band,
edges: band ? [...band] : pending !== null ? [pending] : [],
segments,
dots,
};
@@ -392,7 +418,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
if (phase === "range") {
return pending === null
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요 (Esc 시작 취소)`;
}
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
@@ -18,6 +18,8 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
# 법정 미달 판정 오차(m) — R = L/Δ 되셈 L 19.99998 거짓 미달 막음(271-1) · TS `SHORTFALL_TOLERANCE_M` 짝.
SHORTFALL_TOLERANCE_M = 1e-3
def flat_bends(base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None) -> np.ndarray:
@@ -60,7 +62,10 @@ def vertical_curve_shortfalls(
continue
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
if (
radius < min_radius_m - SHORTFALL_TOLERANCE_M
or length < min_length_m - SHORTFALL_TOLERANCE_M
):
out.append(
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
@@ -29,11 +29,13 @@ import {
hasStraightRuns,
shiftStraightRuns,
straightenBetween,
straightSubRun,
tiltStraightRun,
} from "./logic/B05_Profile_UI_Profile_Straighten";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free";
import { createFreeDraw, snapStation } from "../A00_Common/design/logic/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { createRangeBandView } from "../A00_Common/design/design_profile_range_band";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
@@ -121,17 +123,35 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
alignment: ctx.alignment,
edits: ctx.edits,
apply: (next) => ctx.applyEdits(next),
autoRadii: ctx.savedRadii,
close: () => tools.clear(),
changed: ctx.refresh,
});
const freeView = createFreeView(free);
let lastGeom: FreeChartGeom | null = null;
// [직선화] 실시간 띠(271-2) — 시작 측점 → 마우스 측점 · 고른 부분 구간은 고정 띠.
const bandView = createRangeBandView((hoverM) => {
const start = tools.pendingStation();
const sub = tools.subRun();
return start !== null ? [start, hoverM] : sub ? [sub.fromM, sub.toM] : null;
});
const mountViews = (mode: string): void => {
freeView.mount(lastGeom, mode === "free");
bandView.mount(lastGeom, mode === "straighten");
};
const tools = createProfileTools({
onStraighten: (fromM, toM) => {
const base = ctx.base();
if (!base) return;
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM));
// 이미 직선인 범위 = 부분 구간으로 고름(271-1) — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선
const alignment = ctx.alignment();
const sub = alignment ? straightSubRun(alignment, fromM, toM) : null;
if (sub) {
tools.handleRunPick(sub);
return;
}
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
tools.clear();
},
onShift: (runs, delta) => {
@@ -159,12 +179,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
const alignment = ctx.alignment();
return alignment ? hasStraightRuns(alignment) : false;
},
onChanged: ctx.refresh,
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
onChanged: () => (bandView.redraw(), ctx.refresh()),
onModeChange: mountViews,
freeHint: () => free.hint(),
mountOverlay: (geom) => {
lastGeom = geom;
freeView.mount(geom, tools.mode() === "free");
mountViews(tools.mode());
},
});
@@ -178,15 +198,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
if (mode === "shift") {
return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null);
}
// 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라
// 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다.
if (tools.pendingStation() === null && alignment) {
const run = detectStraightRun(alignment, chainageM);
if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) {
return tools.handleRunPick(run);
}
}
return tools.handleStationPick(chainageM);
// 직선화 클릭 = 늘 측점 고름 · 빈 곳은 가장 가까운 측점선에 붙음(271-1) — 직선 안 클릭이
// 직선 통째 고름(붉은 강조)으로 새던 갈림을 지움. 이미 직선인 범위는 `onStraighten` 이 가름.
const station = lastGeom ? snapStation(lastGeom.stations, chainageM) : null;
return tools.handleStationPick(station ?? chainageM);
}
function selectedIrregular(): IrregularStation | null {
@@ -355,10 +370,33 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
};
window.addEventListener("keydown", onKeyDown);
/* Esc = 도구 몫(271-2 · 3) — 잡는 단계에서 먼저 받아 고름 풀기(`design_pick_escape`)로 안 샘:
* [직선화] 시작 측점 · 부분 구간만 취소(모드 유지) · [자유지정]은 자기 규칙(범위 첫 측점만 = 시작만). */
const onEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || !ctx.root.isConnected) return;
const target = event.target as HTMLElement | null;
if (
target?.closest?.("input, select, textarea") ||
document.querySelector(".ui-modal__backdrop")
) {
return;
}
if (tools.mode() === "free") {
free.key("Escape");
freeView.redraw();
} else if (!(tools.mode() === "straighten" && tools.cancelPick())) return;
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("keydown", onEscape, true);
const setListening = (on: boolean): void => {
window.removeEventListener("keydown", onKeyDown);
if (on) window.addEventListener("keydown", onKeyDown);
else stopHold();
window.removeEventListener("keydown", onEscape, true);
if (on) {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keydown", onEscape, true);
} else stopHold();
};
return { tools, history, handleToolPick, setListening };
}
+24 -6
View File
@@ -7,10 +7,10 @@
* [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2).
*
* 선택 흐름:
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
* [직선화] → 측점 2개를 그래프에서 고름(클릭 = 늘 측점 · 271-1) → 두 측점의 라운드에 탄젠트한 직선으로 대체.
* 고른 범위가 이미 직선이면 그 범위를 부분 구간으로 고름 → [▲][▼]로 꺾으면 가운데에
* 라운드 · 양 끝에 기본 L 곡선. Esc = 시작(또는 부분 구간)만 취소 · 모드 유지.
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
* ========================================================================== */
@@ -55,8 +55,12 @@ export interface ProfileTools {
handleStationPick: (chainageM: number) => boolean;
/** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */
handleRunPick: (run: StraightRun | null) => boolean;
/** 선택 중인 직선 구간들(강조 표시용). */
/** [쉬프트]로 고른 직선 구간들(붉은 강조용) — [직선화] 부분 구간은 `subRun`(보라 띠 · 271-1). */
selectedRuns: () => StraightRun[];
/** [직선화] 중 고른 이미 직선인 부분 구간(▲▼ 꺾기 대상). */
subRun: () => StraightRun | null;
/** [직선화] 시작 측점 · 부분 구간만 취소(Esc · 모드 유지) — 취소한 것이 있으면 true. */
cancelPick: () => boolean;
/** 직선화 대기 중 첫 측점(강조 표시용). */
pendingStation: () => number | null;
/** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */
@@ -117,7 +121,15 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
return {
mode: () => mode,
nudge: step,
selectedRuns: () => runs,
selectedRuns: () => (mode === "shift" ? runs : []),
subRun: () => (mode === "straighten" ? (runs[0] ?? null) : null),
cancelPick() {
if (mode !== "straighten" || (pending === null && !runs.length)) return false;
pending = null;
runs = [];
callbacks.onChanged();
return true;
},
pendingStation: () => pending,
mountOverlay: callbacks.mountOverlay,
clear() {
@@ -129,6 +141,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
if (mode !== "straighten") return false;
if (pending === null) {
pending = chainageM;
runs = [];
callbacks.onChanged();
return true;
}
@@ -222,7 +235,12 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
} else if (mode === "straighten" && pending !== null) {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)`;
wrap.append(hint);
} else if (mode === "straighten" && runs.length === 1) {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선";
wrap.append(hint);
} else if (mode === "shift" && !runs.length) {
const hint = document.createElement("em");
@@ -358,6 +358,15 @@
stroke-width: 1.5;
}
/* 범위 「시작–끝 · 길이」 글 — [직선화] · [자유지정] 실시간 띠 위(271-2 · 3). */
.b05-profile-freemark__span {
fill: var(--color-primary);
font-size: var(--text-caption);
paint-order: stroke;
stroke: var(--color-surface);
stroke-width: 3px;
}
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
.b05-profile-freemark__line {
stroke: var(--color-success);
@@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A
export const DEFAULT_CURVE_LENGTH_M = 20;
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
export const INITIAL_STRAIGHT_RADIUS_M = 500;
/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */
export const SHORTFALL_TOLERANCE_M = 1e-3;
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */
export function flatBends(
@@ -72,7 +74,10 @@ export function verticalCurveShortfalls(
if (minR <= 0 && minL <= 0) return [];
return curves
.filter((curve) => !curve.omitted && !curve.skip_allowed)
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
.filter(
(curve) =>
curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M,
)
.map(
(curve) =>
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
@@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean {
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
}
/**
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
*/
export function straightSubRun(
alignment: ProfileAlignment,
fromChainageM: number,
toChainageM: number,
): StraightRun | null {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
const inside = alignment.pvi
.map((node, index) => ({ at: node.chainage_m, index }))
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
return pviChainages(alignment).filter(
@@ -131,17 +149,33 @@ function withOffsets(
return { station_offsets: stationOffsets, curve_radii: curveRadii };
}
/**
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
*/
export function dropAutoEndRadii(
radii: Record<string, number>,
ends: number[],
autoRadii: Readonly<Record<string, number>>,
): void {
ends.map(chainageKey).forEach((key) => {
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
});
}
/**
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
*
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
*/
export function straightenBetween(
base: AlignmentBase,
edits: AlignmentEdits,
fromChainageM: number,
toChainageM: number,
autoRadii: Readonly<Record<string, number>> = {},
): AlignmentEdits {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
@@ -156,11 +190,13 @@ export function straightenBetween(
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(
const next = withOffsets(
edits,
[...updates, ...pinEnds(base, current, [fromM, toM])],
inner.map(chainageKey),
);
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
return next;
}
/**
@@ -245,6 +281,8 @@ export function tiltStraightRun(
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
else place(chainage, pivot, pivotZ, run.toM, endZ);
});
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
@@ -0,0 +1,306 @@
/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` ·
* `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해
* 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음
const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } };
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const near = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
}
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts"));
const DESIGN = path.join(ROOT, "A00_Common", "design");
const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts"));
const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts"));
const policy = {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 20,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
min_vertical_radius_m: 100,
min_curve_length_m: 20,
};
// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L)
const S = Array.from({ length: 16 }, (_, i) => i * 20);
const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13);
const base = {
policy,
onlyExplicitCurves: false,
basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })),
chainage: S,
ground: S.map((s) => +g(s).toFixed(6)),
stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
const tidy = (e) => st.tidyEdits(base, e);
const build = (e) => align.buildAlignment(base, e);
const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m }));
const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달"));
const out = {};
const mirror = [];
const keep = (name, edits) => {
mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) });
return edits;
};
// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20
const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200)));
const a1 = build(e1);
out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) };
// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null
out.sub = st.straightSubRun(a1, 80, 160);
out.subReverse = st.straightSubRun(a1, 160, 80);
out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200);
out.subMixed = st.straightSubRun(a1, 100, 260);
out.subOneSpan = st.straightSubRun(a1, 80, 100);
// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음
const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5)));
out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) };
// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘
out.tolerance = law.SHORTFALL_TOLERANCE_M;
out.nearMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
out.realMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠
const saved = { "40.000": 5330, "200.000": 1084 };
const savedEdits = { station_offsets: {}, curve_radii: { ...saved } };
const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved)));
out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) };
const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } };
const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved));
out.userEnd = { radii: e4u.curve_radii };
// 자동 R 기준 없이(옛 부름) = 그대로 남음
out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii);
// ⑤ BP · EP — 시종점 곡선 없음
const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300)));
out.bpEp = curves(e5);
const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200));
const subBP = st.straightSubRun(build(eBP), 0, 80);
const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5)));
out.tiltBp = curves(e5t).map((c) => c.at);
// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20
const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } };
const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {})));
out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) };
// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠
const z = (s) => align.controlElevationAt(build(savedEdits), s);
const vertices = [
{ chainage_m: 40, elevation_m: z(40) },
{ chainage_m: 120, elevation_m: z(120) + 1 },
{ chainage_m: 200, elevation_m: z(200) },
];
const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved)));
out.freeAuto = Object.keys(e7.curve_radii);
out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii);
// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소
{
const stations = S;
let edits = align.emptyEdits();
const log = { closed: 0 };
const draw = free.createFreeDraw({
base: () => base,
alignment: () => build(edits),
edits: () => edits,
apply: (next) => (edits = next),
close: () => (log.closed += 1),
changed: () => undefined,
});
draw.click(41, 0, stations);
draw.hover({ chainageM: 117, elevationM: 0, stations });
out.liveBand = draw.marks().band;
out.liveEdges = draw.marks().edges;
draw.hover({ chainageM: 13, elevationM: 0, stations });
out.liveReverse = draw.marks().band;
draw.hover(null);
out.outside = { band: draw.marks().band, edges: draw.marks().edges };
out.hintPending = draw.hint();
out.escTaken = draw.key("Escape");
out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed };
draw.click(41, 0, stations);
draw.click(158, 0, stations);
draw.hover({ chainageM: 260, elevationM: 0, stations });
out.fixedBand = draw.marks().band;
out.armedPhase = draw.phase();
out.escArmed = draw.key("Escape");
out.afterEscArmed = { phase: draw.phase(), closed: log.closed };
}
// ⑩ 범위 띠 그림 — 가짜 DOM
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parent = null;
this.textContent = "";
this.listeners = {};
this.isConnected = true;
this.classList = { toggle: () => undefined, add: () => undefined };
}
setAttribute(name, value) {
this.attrs[name] = value;
}
append(...items) {
items.forEach((item) => {
item.parent = this;
this.children.push(item);
});
}
remove() {
if (!this.parent) return;
this.parent.children = this.parent.children.filter((c) => c !== this);
this.parent = null;
}
addEventListener(type, fn, opts) {
(this.listeners[type] ??= []).push(fn);
opts?.signal?.addEventListener("abort", () => {
this.listeners[type] = this.listeners[type].filter((f) => f !== fn);
});
}
fire(type, event = {}) {
(this.listeners[type] ?? []).forEach((fn) => fn(event));
}
getBoundingClientRect() {
return { left: 0, top: 0 };
}
}
global.document = {
createElementNS: (_ns, tag) => new El(tag),
createElement: (tag) => new El(tag),
};
const shape = (svg) =>
svg.children.map((c) =>
c.tag === "text"
? `text:${c.textContent}`
: c.tag === "rect"
? `rect:${c.attrs.x}+${c.attrs.width}`
: `line:${c.attrs.x1}`,
);
{
const x = (m) => m * 2;
const svg = new El("svg");
band.appendRangeBand(svg, x, 100, 120, 40);
out.bandShape = shape(svg);
const one = new El("svg");
band.appendRangeBand(one, x, 100, 40, null);
out.bandStart = shape(one);
const chart = new El("div");
const scroller = new El("div");
const geom = {
chart,
scroller,
x,
toChainage: (px) => px / 2,
widthPx: 640,
heightPx: 100,
stations: S,
};
let start = null;
const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover]));
view.mount(geom, true);
out.viewIdle = chart.children.length;
start = 40;
view.redraw();
out.viewStart = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 117 });
out.viewHover = shape(chart.children[0]);
chart.fire("mouseleave");
out.viewLeave = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 61 });
view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐
out.viewRemount = shape(chart.children[0]);
view.mount(geom, false);
out.viewOff = chart.children.length;
}
// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지
{
const picked = [];
const t = tools.createProfileTools({
onStraighten: (from, to) => picked.push([from, to]),
onShift: () => undefined,
onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]),
onResetToSaved: () => undefined,
canResetToSaved: () => false,
onUndo: () => undefined,
onRedo: () => undefined,
canUndo: () => false,
canRedo: () => false,
hasStraightRuns: () => true,
onChanged: () => undefined,
onModeChange: () => undefined,
freeHint: () => "",
mountOverlay: () => undefined,
});
const press = (label) => {
const button = t.render().children.find((c) => c.textContent === label);
button.fire("click", { stopPropagation: () => undefined });
};
const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null;
out.toolsNone = t.handleStationPick(40);
press("직선화");
t.handleStationPick(40);
out.pending = { at: t.pendingStation(), hint: hintOf() };
out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() };
out.cancelAgain = t.cancelPick();
t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] });
out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() };
press("▲");
out.tiltCall = picked.at(-1);
t.handleStationPick(60); // 새 시작 = 부분 구간 풂
out.newStart = { pending: t.pendingStation(), sub: t.subRun() };
t.handleStationPick(180);
out.straightenCall = picked.at(-1);
press("직선화"); // 끔
press("쉬프트");
t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] });
out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() };
}
out.mirror = { base, cases: mirror };
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""271-1~3 B05 종단 [직선화] 측점 고름 · 끝 R 기본 L · [직선화] · [자유지정] 실시간 띠.
요구 271 · PLAN 461.
계산(`logic/B05_Profile_UI_Profile_Straighten.ts` · `_Tools.ts` · `design_profile_free.ts` ·
`design_profile_range_band.ts` · `_CurveLaw.ts`)은 Node 헬퍼(`helper_271_straighten_pick.cjs`)가
트랜스파일해 돌리고, 파이썬 짝(`build_alignment` · `vertical_curve_shortfalls`)은 같은 base · 편집으로
곡선 자리 · R · L · 미달 경고가 같은지 거울로 본다.
./venv/Scripts/python.exe -m pytest resources/tester/test_271_straighten_pick.py -q
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_alignment,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import ( # noqa: E402
SHORTFALL_TOLERANCE_M,
vertical_curve_shortfalls,
)
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_271_straighten_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _at(curves, chainage):
return next((c for c in curves if abs(c["at"] - chainage) < 1e-6), None)
def test_직선화_끝_기본_L_사이_곡선_없음(out):
curves = out["straight"]["curves"]
assert not [c for c in curves if 40 < c["at"] < 200]
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
assert out["straight"]["radii"] == []
def test_이미_직선_범위_부분_구간_반대_방향_같음(out):
assert out["sub"] == {"fromM": 80, "toM": 160, "nodes": [80, 100, 120, 140, 160]}
assert out["subReverse"] == out["sub"]
assert out["subBent"] is None and out["subMixed"] is None
assert out["subOneSpan"] is None # 사이 꼭짓점 없음 = 꺾을 곳 없음
def test_부분_구간_꺾기_양_끝_L20_거짓_미달_없음(out):
curves = out["tiltSub"]["curves"]
for at in (80, 120, 160):
assert _at(curves, at)["l"] == pytest.approx(20, abs=1e-3)
assert not [c for c in curves if 80 < c["at"] < 160 and c["at"] != 120]
assert out["tiltSub"]["shortfalls"] == []
def test_법정_판정_오차_짝(out):
assert out["tolerance"] == SHORTFALL_TOLERANCE_M == 1e-3
assert out["nearMiss"] == []
assert len(out["realMiss"]) == 1
near = [{"chainage_m": 120, "l_m": 19.99998, "r_m": 800}]
assert vertical_curve_shortfalls(near, 100, 20) == []
real = [{"chainage_m": 120, "l_m": 19.9, "r_m": 800}]
assert vertical_curve_shortfalls(real, 100, 20) == out["realMiss"]
def test_끝_저장_자동_R_지움_사용자_R_둠(out):
assert out["autoEnds"]["radii"] == []
curves = out["autoEnds"]["curves"]
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
assert out["userEnd"]["radii"] == {"40.000": 600}
assert out["noAuto"] == ["40.000", "200.000"]
def test_BP_EP_곡선_없음(out):
assert out["bpEp"] == []
assert out["tiltBp"][:2] == [40, 80] # 시점 0 엔 곡선 없음 · 가운데 · 끝만
def test_곡선_섞인_범위_안쪽_R_지움_양_끝_L20(out):
assert out["mixed"]["radii"] == []
curves = out["mixed"]["curves"]
assert not [c for c in curves if 100 < c["at"] < 260]
assert _at(curves, 100)["l"] == pytest.approx(20) and _at(curves, 260)["l"] == pytest.approx(20)
def test_자유지정_끝_자동_R_지움_사용자_R_둠(out):
assert out["freeAuto"] == ["120.000"]
assert out["freeUser"] == ["40.000", "120.000"]
def test_자유지정_범위_실시간_띠_Esc_시작만(out):
assert out["liveBand"] == [40, 120] and out["liveEdges"] == [40, 120]
assert out["liveReverse"] == [20, 40]
assert out["outside"] == {"band": None, "edges": [40]} # 그래프 밖 = 시작선만
assert "Esc 시작 취소" in out["hintPending"]
assert out["escTaken"] and out["afterEsc"] == {"phase": "range", "edges": [], "closed": 0}
assert out["fixedBand"] == [40, 160] and out["armedPhase"] == "armed" # 둘째 클릭 뒤 고정
assert out["escArmed"] and out["afterEscArmed"] == {"phase": "range", "closed": 1}
def test_범위_띠_그림(out):
assert out["bandShape"] == ["rect:80+160", "text:40.0–120.0m · 80.0m", "line:80", "line:240"]
assert out["bandStart"] == ["line:80"]
assert out["viewIdle"] == 0 and out["viewOff"] == 0
assert out["viewStart"] == ["line:80"]
assert out["viewHover"] == out["bandShape"]
assert out["viewLeave"] == ["line:80"]
assert out["viewRemount"] == ["rect:80+40", "text:40.0–60.0m · 20.0m", "line:80", "line:120"]
def test_도구_직선화_부분_구간_붉은_강조_아님(out):
assert out["toolsNone"] is False
assert out["pending"] == {
"at": 40,
"hint": "40.0m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)",
}
assert out["cancel"] == {"taken": True, "pending": None, "mode": "straighten"}
assert out["cancelAgain"] is False
assert out["subPicked"]["red"] == [] and out["subPicked"]["sub"]["fromM"] == 80
assert out["subPicked"]["hint"] == "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선"
assert out["tiltCall"] == ["tilt", 80, 160, 0.1]
assert out["newStart"] == {"pending": 60, "sub": None}
assert out["straightenCall"] == [60, 180]
assert out["shiftRed"] == {"red": 1, "sub": None, "cancel": False} # [쉬프트] 붉은 강조 그대로
def test_파이썬_거울_곡선_자리_R_L_미달(out):
base = out["mirror"]["base"]
policy = AlignmentPolicy.from_dict(base["policy"])
for case in out["mirror"]["cases"]:
got = build_alignment(
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
chainage=np.array(base["chainage"]),
ground=np.array(base["ground"]),
stations=base["stations"],
policy=policy,
edits=case["edits"],
)
py = [[c["chainage_m"], c["r_m"], c["l_m"]] for c in got["curves"]]
ts = case["curves"]
assert [c[0] for c in py] == pytest.approx([c[0] for c in ts], abs=1e-6), case["name"]
assert [c[1] for c in py] == pytest.approx([c[1] for c in ts], rel=1e-6), case["name"]
assert [c[2] for c in py] == pytest.approx([c[2] for c in ts], abs=1e-6), case["name"]
assert not [w for w in got["warnings"] if "미달" in w], case["name"]
def test_배선():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert "tools.handleStationPick(station ?? chainageM)" in panel
assert "straightSubRun(alignment, fromM, toM)" in panel
assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel
assert "autoRadii: ctx.savedRadii" in panel
assert 'window.addEventListener("keydown", onEscape, true)' in panel
assert "createRangeBandView(" in panel
view = _read("A00_Common/design/design_profile_free_view.ts")
assert "appendRangeBand(svg, x, heightPx" in view
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
rule = css[css.index(".b05-profile-freemark__span {") :]
rule = rule[: rule.index("}")]
assert "--color-primary" in rule and "--color-surface" in rule # 밝음 · 어두움 토큰
def test_줄_수():
for relative in (
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
"B05_Profile/B05_Profile_UI_Profile_Tools.ts",
"B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts",
"A00_Common/design/logic/design_profile_free.ts",
"A00_Common/design/design_profile_free_view.ts",
"A00_Common/design/design_profile_range_band.ts",
):
assert len(_read(relative).splitlines()) <= 700