fix(483-1·2 B05 스냅): 일반측점을 MC 로 옮기지 않음 · MC 특별측점 더함 · 스냅 간격 1 m · 0.1 m 지움

- 서버 snap_curve_stations ↔ TS snapCurveStations · gridIndices 지움 — 스냅 켬 = 끔과 같은 측점(20 m 그대로 + 「No.N+10 (MC)」)
- 고르기 = 10 m · 측점 No. 둘 · load_station_snap_step = 10 만 · 옛 0.1 · 1 = 측점 No.
- 시험 = 새 test_280_4_stations(거울 다섯 경우) · 224_2 · 344_3 · 366_3 · 390_4 · 390_5 · 407 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 21:58:53 +09:00
co-authored by Claude Opus 5.5
parent afe6e4a799
commit 20d0fd5e51
20 changed files with 292 additions and 235 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ export const STATE_REGISTRY = {
"routeedit-locks": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */
"routeedit-terrain": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 측점 스냅 간격(0.1 · 1 · 10 · station) */
/** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */
"routeedit-snap-step": { bucket: "pref", scope: "project" },
"masshaul-open": {
bucket: "pref",
@@ -39,7 +39,7 @@
*
* 측점 열쇠(344-3 · `stationKey` · `B05_Profile/logic/B05_Profile_Sections_Stations.ts`):
* | 측점 | 열쇠 | 뜻 |
* | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 · 스냅으로 옮긴 측점은 원래 번호 |
* | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 |
* | 관 | pipe:<id> | 관 세션 id(없으면 `pipe-{누가거리 2자리}`) |
* | 구조물 | structure:<id> | structure_id · 구간형 시작 · 끝 = `:start` · `:end` 덧붙임 |
* | 시종점 | end:BP · end:EP | 그 자리 관 · 구조물이 있어도 시종점(서버 bp · ep 와 같음) |
@@ -72,7 +72,7 @@ export interface RouteDraft {
curveArc: Array<number | null>;
/** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */
stationSnap: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */
/** 스냅 간격(m · 10 · null·없음 = 측점 No. · 280-4) — MC 측점 값 자릿수(390-5). */
stationSnapStep?: number | null;
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
terrain: string | null;
+1 -1
View File
@@ -117,7 +117,7 @@ export interface RoutePlanResponse {
edited: boolean;
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
station_snap?: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
/** 스냅 간격(m · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
+2 -2
View File
@@ -107,10 +107,10 @@ def load_station_snap(project_root: Path) -> bool:
def load_station_snap_step(project_root: Path) -> float | None:
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
칸 없으면 None(측점 No.).
10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4).
"""
step = _read_design_curve_table(project_root).get("station_snap_step")
return float(step) if isinstance(step, int | float) and step > 0 else None
return 10.0 if isinstance(step, int | float) and step == 10 else None
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
@@ -6,10 +6,10 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ·
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `curve_stations` ·
`_mc_scale` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` ·
`snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
간격별 MC 값 `test_390_5_snap_step_ops.py`.
`curveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
`test_280_4_stations.py` · 간격별 MC 값 `test_390_5_snap_step_ops.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) ·
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
@@ -295,35 +295,6 @@ def _mc_scale(step_m: float | None) -> int:
return 10 if step_m is not None and step_m < 1 else 1
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
step_m: float | None = None,
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 MC
(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른
측점과 같은 값이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리) · `step_m` = 스냅 간격
(0.1 · 1 · 10 · None = 측점 No. · 390-5).
"""
scale = _mc_scale(step_m)
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
middle = (start + end) / 2.0
inside = [
index
for index, value in enumerate(values)
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
]
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = math.floor(middle * scale + 0.5) / scale
return np.unique(np.asarray(values, dtype=np.float64))
def curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
@@ -331,7 +302,7 @@ def curve_stations(
step_m: float | None = None,
) -> list[float]:
"""곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이
없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
없으면 그 값 · 0.005 m 안 측점(일반 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
BC · EC 는 안 더함. `curveStations` 짝."""
scale = _mc_scale(step_m)
taken = [float(value) for value in stations]
@@ -393,9 +364,8 @@ def _station_layout(
곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길."""
total = float(route_chainage[-1])
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
# 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans, station_snap_step)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
@@ -459,9 +429,9 @@ def generate_sections(
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함.
`station_snap` · `station_snap_step` — MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.) ·
일반측점은 옮기지 않음(요구 280-4). MC 에 측점이 없으면 곡선 측점(kind `curve` ·
`curve_stations` · 요구 252-2 · 「No.2+10 (MC)」) 하나를 더함.
"""
options = options or SectionGenerationOptions()
options.validate()
+1 -1
View File
@@ -433,4 +433,4 @@ class RouteReplanRequest(BaseModel):
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
station_snap_step: float | None = None
"""스냅 간격(m · 0.1 · 1 · 10 · None = 측점 No. · 390-5) — 곡선표 `"station_snap_step"`."""
"""스냅 간격(m · 10 · None = 측점 No. · 390-5 · 280-4) — 곡선표 `"station_snap_step"`."""
@@ -110,12 +110,10 @@ export function createRouteEditChrome(
<i class="b05-route__view-separator" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
aria-pressed="false"
title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
title="켜면 고친 곡선의 가운데(MC)를 고른 간격 배수에 맞춥니다 — 일반측점(20 m)은 그대로 · MC 가 일반측점 위가 아니면 「No.N+10 (MC)」 특별측점을 더함 · 손대지 않은 곡선은 그 자리 유지 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
<label class="b05-routeedit__terrain b05-routeedit__snap-step"
title="측점 스냅 간격 — 곡선 가운데(MC)를 이 간격 배수에 맞춥니다(측점 No. = 측점 간격)">간격
<select class="ui-select ui-select--sm" data-act="snap-step">
<option value="0.1">0.1 m</option>
<option value="1">1 m</option>
<option value="10">10 m</option>
<option value="station">측점 No.</option>
</select>
@@ -2,27 +2,23 @@
* B05_Profile_UI_RouteEdit_SnapStep.ts
* 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11).
*
* 간격 = 0.1 m · 1 m · 10 m · 측점 No.(편집 창 측점 간격 · 기본) — 곡선 MC 를 그 간격 배수에 맞춤.
* 간격 = 10 m · 측점 No.(편집 창 측점 간격 · 기본 · 280-4) — 곡선 MC 를 그 간격 배수에 맞춤.
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · 스냅 켬이면 저장 노선에도 같이
* (`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.).
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
* 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 10 m = 「10 m 배수에」.
* 평면 눈금 = 일반측점(스냅이 옮기지 않음 · 280-4) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
curveSpans,
curveStations,
snapCurveStations,
stationsAlong,
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
import { curveSpans, curveStations, stationsAlong } from "./logic/B05_Profile_UI_RouteEdit_Frame";
const STEPS = ["0.1", "1", "10", "station"];
/** 고르기 값 — 옛 "0.1" · "1"(pref · 저장)은 측점 No. 로 읽음(280-4). */
const STEPS = ["10", "station"];
/** 저장 · 초안에 실리는 스냅 두 칸. */
export type SnapSetting = Pick<RouteDraft, "stationSnap" | "stationSnapStep">;
@@ -35,7 +31,7 @@ export function paintStationSnap(button: HTMLButtonElement, on: boolean): void {
button.setAttribute("aria-pressed", String(on));
}
/** 스냅 뒤 상태줄 글 — `step` = 고르기 값("0.1" · "1" · "10" · "station"). */
/** 스냅 뒤 상태줄 글 — `step` = 고르기 값("10" · "station"). */
export function snapFitText(step: string): string {
const where = step === "station" ? "일반측점" : `${step} m 배수`;
return `곡선 가운데(MC)를 ${where}에 맞췄습니다.`;
@@ -52,8 +48,8 @@ export type SnapStep = {
load: (plan: SavedSnap) => boolean;
/** 읽은 저장 노선과 켬끔 · 간격이 다른가([확인] 보낼지). */
changed: () => boolean;
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
/** 편집 창 일반측점 — 끔이면 null · 켬이면 측점 간격 그대로(옮기지 않음 · 280-4). */
stations: (line: ReadonlyArray<Vertex>, curves?: ReadonlyArray<EditedCurve>) => number[] | null;
/** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */
ticks: (
line: ReadonlyArray<Vertex>,
@@ -80,14 +76,8 @@ export function bindStationSnap(
const step = (): number | null => (select.value === "station" ? null : Number(select.value));
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
const stations = (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
on()
? snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curves),
step(),
)
: null;
const stations = (line: ReadonlyArray<Vertex>) =>
on() ? stationsAlong(line, stationIntervalM, false) : null;
const stepM = () => step() ?? stationIntervalM;
return Object.assign(stepM, {
text: () => snapFitText(select.value),
@@ -107,11 +97,11 @@ export function bindStationSnap(
},
stations,
ticks: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => {
const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false);
const base = stationsAlong(line, stationIntervalM, false);
// 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안
const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null);
return {
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves),
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line),
curveStations: curve,
};
},
@@ -3,7 +3,7 @@
* 교각점 방향키(383-2 · 요구 241-16) — 화면 축 걸음 · 뗄 때 맞춘 결과 한 줄.
*
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* · 측점 스냅 켬이면 한 번 = 스냅 간격(10 m · 측점 No. · Shift 안 씀 · 390-5 · 280-4) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
@@ -18,7 +18,6 @@
import {
curveSpans,
curveStations,
snapCurveStations,
stationFrameAt,
stationsAlong,
} from "./B05_Profile_UI_RouteEdit_Frame";
@@ -239,31 +238,6 @@ export interface StationsOptions {
plus?: ReadonlyArray<number>;
}
/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */
function gridIndices(
original: number[],
snapped: number[],
intervalM: number,
): Map<number, number> {
const has = (list: number[], value: number): boolean =>
list.some((other) => Math.abs(other - value) < 1e-9);
const gone = original
.map((value, index) => ({ value, index }))
.filter((o) => !has(snapped, o.value));
const out = new Map<number, number>();
for (const value of snapped) {
const same = original.findIndex((other) => Math.abs(other - value) < 1e-9);
if (same >= 0) {
out.set(value, same);
continue;
}
const near = gone.sort((a, b) => Math.abs(a.value - value) - Math.abs(b.value - value))[0];
out.set(value, near ? near.index : Math.round(value / intervalM));
if (near) gone.splice(gone.indexOf(near), 1);
}
return out;
}
/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */
const lineLength = (line: ReadonlyArray<Vertex>): number =>
line.reduce(
@@ -283,20 +257,9 @@ export function buildStations(
if (line.length < 2) return [];
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
const total = lineLength(line);
const original = stationsAlong(
line,
intervalM,
options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT,
);
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
const spans = designSpans(line, curves);
const grid = options.stationSnap
? snapCurveStations(
original,
spans.map(([bc, ec]) => [bc, ec] as const),
options.stationSnapStep,
)
: original;
const gridIndex = gridIndices(original, grid, intervalM);
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
@@ -341,7 +304,7 @@ export function buildStations(
const tangent: Vertex = [frame.left[1], -frame.left[0]];
const mark = planted.get(chainage);
let kind: StationKind = "grid";
let key = stationKey("grid", gridIndex.get(chainage) ?? Math.round(chainage / intervalM));
let key = stationKey("grid", Math.round(chainage / intervalM));
if (index === 0 || Math.abs(chainage - total) <= 1e-6) {
kind = "end";
key = stationKey("end", index === 0 ? "BP" : "EP");
@@ -6,10 +6,10 @@
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
*
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` ·
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2 · 280-4) `stationsAlong` · `curveSpans` · `curveStations` ·
* `mcScale` ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
* 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
* `curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
* `test_280_4_stations.py` · 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
* ========================================================================== */
type Vertex = [number, number];
@@ -160,29 +160,7 @@ export function curveSpans(
* (390-5). `_mc_scale` 짝. */
export const mcScale = (stepM?: number | null): number => (stepM != null && stepM < 1 ? 10 : 1);
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC·s+0.5)/s 로 옮김(s = `mcScale`) ·
* 측점 없는 호는 그대로 · 같은 값은 하나로(오름차순). `snap_curve_stations` 짝. */
export function snapCurveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
stepM?: number | null,
): number[] {
const scale = mcScale(stepM);
const values = [...stations];
for (const [start, end] of spans) {
const middle = (start + end) / 2;
let nearest = -1;
values.forEach((value, index) => {
if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return;
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
nearest = index;
});
if (nearest >= 0) values[nearest] = Math.floor(middle * scale + 0.5) / scale;
}
return [...new Set(values)].sort((a, b) => a - b);
}
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 관 · 구조물)이
* 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */
export function curveStations(
stations: ReadonlyArray<number>,
@@ -0,0 +1,40 @@
/* PLAN 483(요구 280-4) 측점 목록 거울 헬퍼 — 진짜 `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
* `buildStations` 를 트랜스파일해 Node 로 돌림. 입력 argv[2] JSON(사례 목록) · 표준출력 JSON.
* 짝 = `test_280_4_stations.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function loadTs(full) {
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 localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const logic = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
station.setPlusStations(() => []);
const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = cases.map((c) =>
logic
.buildStations(c.line, c.curves, [], [], {
types: [],
stationSnap: c.snap,
stationSnapStep: c.step,
})
.map((s) => ({ m: s.chainageM, kind: s.kind, key: s.key })),
);
process.stdout.write(JSON.stringify(out));
@@ -1,5 +1,5 @@
/* 390-5 스냅 간격 = 조작 단위 — 방향키 걸음(`KeyNudge.arrowStep`) · 끌기 미리보기(`StationFit.fitDrag`) ·
* MC 측점 값 자릿수(`Frame.snapCurveStations` · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄
* MC 곡선 측점(`Frame.curveStations` · 280-4) · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄
* (`test_390_5_snap_step_ops.py` 가 판정 · 파이썬 짝 거울은 그쪽).
* node helper_390_5_snap_step_ops.cjs '<{"grid":[…],"spans":[[bc,ec]…]}>'
*/
@@ -45,7 +45,7 @@ const ROUTE = [
[431, 101],
[503, 160],
];
const STEPS = [0.1, 1, 10, INTERVAL];
const STEPS = [10, INTERVAL]; // 280-4 — 0.1 · 1 m 지움
function columns(planned) {
const n = planned.length;
@@ -310,13 +310,11 @@ const sweptHandle = {
// ③ MC 측점 값 · 눈금 글
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
const snapped = Object.fromEntries(
const curveAt = Object.fromEntries(
[
["0.1", 0.1],
["1", 1],
["10", 10],
["station", null],
].map(([key, step]) => [key, frame.snapCurveStations(input.grid, input.spans, step)]),
].map(([key, step]) => [key, frame.curveStations(input.grid, input.spans, 1e9, step)]),
);
const labels = {
int: station.stationLabel(119.8, 20),
@@ -332,7 +330,7 @@ console.log(
drag,
swept,
sweptHandle,
snapped,
curve: curveAt,
labels,
scale: [0.1, 1, 10, null].map(frame.mcScale),
}),
+43 -52
View File
@@ -1,9 +1,9 @@
"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅].
"""요구 224-2 · PLAN 326 · 483(요구 280-4) — 계획노선 편집 [측점 스냅].
짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` ·
`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` ·
`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운
하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로.
`curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` ·
`curveSpans` · `curveStations`). 스냅은 일반측점을 옮기지 않음(280-4) — MC(floor(MC+0.5))에
측점이 없으면 곡선 측점 하나를 더함 · 0.005 m 안 측점 있으면 안 더함.
"""
from __future__ import annotations
@@ -30,44 +30,32 @@ TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GRID = [float(value) for value in range(0, 1001, 20)]
def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]:
return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)]
def _curve(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]:
return core.curve_stations(stations or GRID, spans, 1e9)
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
def test_곡선_없음_그대로() -> None:
assert _snap([]) == GRID
def test_곡선_없음_곡선_측점_없음() -> None:
assert _curve([]) == []
def test_측점_없는_호는_안_심음() -> None:
assert _snap([(621.0, 639.0)]) == GRID
def test_MC_가_측점_밖이면_더함() -> None:
assert _curve([(621.0, 639.0)]) == [630.0]
assert _curve([(612.0, 634.0)]) == [623.0]
def test_호_안_하나를_정수_MC_로() -> None:
out = _snap([(612.0, 634.0)]) # MC 623
assert 623.0 in out and 620.0 not in out and len(out) == len(GRID)
def test_호_안_둘이면_MC_에_가까운_하나만() -> None:
out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움
assert 631.0 in out and 640.0 not in out and 620.0 in out
def test_BC_위_측점은_호_밖() -> None:
assert _snap([(620.0, 630.0)]) == GRID
assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖
def test_MC_가_일반측점_위면_안_더함() -> None:
assert _curve([(610.0, 630.0)]) == [] # MC 620
def test_반_올림() -> None:
out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖
assert 626.0 in out and 620.0 not in out and 640.0 in out
assert _curve([(611.0, 640.0)]) == [626.0] # MC 625.5 → 626
def test_같은_m_는_하나로() -> None:
# 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음
assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0]
def test_0005_m_안_측점이면_안_더함() -> None:
assert _curve([(20.1, 20.6)], [0.0, 20.0, 20.354, 40.0]) == [] # MC 20.35 → 20
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────────────
@@ -125,7 +113,7 @@ const out = {
rows: cases.map((item) => {
const grid = frame.stationsAlong(item.line, item.interval, item.end);
const spans = frame.curveSpans(item.line, item.curves);
return { grid, spans, snapped: frame.snapCurveStations(grid, spans) };
return { grid, spans, curve: frame.curveStations(grid, spans, 1e9) };
}),
};
writeFileSync(process.argv[3], JSON.stringify(out));
@@ -139,7 +127,7 @@ def _python_row(case: dict) -> dict:
return {
"grid": [float(v) for v in grid],
"spans": [[span[0], span[1]] for span in spans],
"snapped": [float(v) for v in core.snap_curve_stations(grid, spans)],
"curve": core.curve_stations(grid, spans, 1e9),
}
@@ -183,11 +171,11 @@ def _ts_rows(tmp_path: Path) -> dict:
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None:
def test_측점_셈이_두_쪽에서_같다(tmp_path: Path) -> None:
actual = _ts_rows(tmp_path)
assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M
assert actual["edge"] == core.CURVE_STATION_EDGE_M
moved = 0
added = 0
for index, case in enumerate(CASES):
expected = _python_row(case)
row = actual["rows"][index]
@@ -195,15 +183,16 @@ def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None:
assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수"
for got, want in zip(row["spans"], expected["spans"], strict=True):
assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선"
assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅"
moved += row["snapped"] != row["grid"]
assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌
assert row["curve"] == pytest.approx(expected["curve"], abs=1e-9), f"사례 {index} 곡선 측점"
added += bool(row["curve"])
assert added >= 3 # 곡선 측점이 실제로 생긴 사례를 견줌
def test_짝_머리글이_서로를_가리킨다() -> None:
text = TS_FILE.read_text(encoding="utf-8")
assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text
assert "snapCurveStations" in (core.__doc__ or "")
assert "B05_Profile_Engine_Sections_Core.py" in text and "curve_stations" in text
assert "curveStations" in (core.__doc__ or "")
assert "snapCurveStations" not in text and not hasattr(core, "snap_curve_stations")
# ── generate_sections ────────────────────────────────────────────────────────
@@ -236,13 +225,13 @@ def test_끔은_지금과_같음() -> None:
assert 640.0 in [s["chainage_m"] for s in _stations(False)]
def test_켬은_640_없고_631_있음() -> None:
def test_켬은_640_남고_631_곡선_측점() -> None:
# 280-4 — 켬도 일반측점을 옮기지 않음 · 끔과 같은 목록(640 남고 MC 631 에 곡선 측점)
assert _stations(True) == _stations(False)
by_chainage = {s["chainage_m"]: s for s in _stations(True)}
assert 640.0 not in by_chainage and 631.0 in by_chainage
assert by_chainage[631.0]["kind"] == "regular"
assert by_chainage[620.0]["kind"] == "regular" and by_chainage[640.0]["kind"] == "regular"
assert by_chainage[631.0]["kind"] == "curve"
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
# 끔 = 640 남고 MC(631)에 곡선 측점 하나 더(252-2) · 켬 = 640 을 631 로 옮김(측점 수 그대로)
assert len(by_chainage) == len(_stations(False)) - 1
def test_끔은_MC_에_곡선_측점() -> None:
@@ -252,13 +241,13 @@ def test_끔은_MC_에_곡선_측점() -> None:
assert by_chainage[631.0]["label"].endswith(" (MC)")
def test_631_에_관이면_grid() -> None:
by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))}
assert by_chainage[631.0]["kind"] == "irregular"
assert by_chainage[631.0]["grid"] is True
# 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점
off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))}
assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0]
def test_640_에_관이면_grid() -> None:
on_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))}
assert on_grid[640.0]["kind"] == "irregular" and on_grid[640.0]["grid"] is True
# 631(MC)에 놓인 관은 따로 선 측점 · 곡선 측점은 안 더함(0.005 m 안 측점)
at_mc = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))}
assert at_mc[631.0]["kind"] == "irregular" and "grid" not in at_mc[631.0]
assert 640.0 in at_mc
# ── 서버 저장 · 정본 (326-2) ─────────────────────────────────────────────────
@@ -349,11 +338,13 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non
)
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE)
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat())
# [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리
# [저장] 때 구조물 측점 셈 — 스냅 켬도 격자 그대로(280-4) · 640 은 격자 · 631 은 따로 선 측점
stations, _ = sections.sample_structure_sections(
tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),)
tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"), (640.0, "관"))
)
assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True
by_chainage = {s["chainage_m"]: s for s in stations}
assert sorted(by_chainage) == [631.0, 640.0]
assert by_chainage[640.0]["grid"] is True and "grid" not in by_chainage[631.0]
seen: dict = {}
+124
View File
@@ -0,0 +1,124 @@
"""PLAN 483-1(요구 280-4) — 스냅이 일반측점을 MC 로 옮기지 않음 · MC 특별측점 더함.
서버 `_station_layout`(`generate_sections`) ↔ TS `buildStations` 거울 — 분석 D 재현 1 다섯 경우
(BP–EP 200 m 직선 · 곡선 하나 · 측점 간격 20 m). 짝 = `helper_280_4_stations.cjs`.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
LINE = [[0.0, 0.0], [200.0, 0.0]]
def _curve(bc: float, ec: float, radius: float = 30.0) -> list[dict]:
return [
{"start": [bc, 0.0], "apex": [(bc + ec) / 2, 3.0], "end": [ec, 0.0], "radius_m": radius}
]
CASES = {
"A_10m_긴호_MC50": {"curves": _curve(34.3, 65.7), "snap": True, "step": 10.0},
"B_10m_짧은호_MC50": {"curves": _curve(45.6, 54.4, 8.5), "snap": True, "step": 10.0},
"C_10m_MC60": {"curves": _curve(44.3, 75.7), "snap": True, "step": 10.0},
"D_측점No_MC47": {"curves": _curve(31.3, 62.7), "snap": True, "step": None},
"E_스냅끔_MC50": {"curves": _curve(34.3, 65.7), "snap": False, "step": None},
}
GRID = [float(v) for v in range(0, 201, 20)]
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy))
return np.zeros(count), np.ones(count, dtype=bool)
def _server(case: dict) -> list[dict]:
data = core.generate_sections(
[[x, y, 0.0] for x, y in LINE],
_Flat(),
core.SectionGenerationOptions(),
design_curves=case["curves"],
station_snap=case["snap"],
station_snap_step=case["step"],
)
return data["longitudinal"]["stations"]
@pytest.fixture(scope="module")
def ts() -> dict:
jobs = [{"line": LINE, **case} for case in CASES.values()]
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(jobs), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_280_4_stations.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return dict(zip(CASES, json.loads(proc.stdout), strict=True))
_KIND = {"bp": "end", "ep": "end", "regular": "grid", "curve": "curve"}
EXPECT_CURVE = {
"A_10m_긴호_MC50": 50.0,
"B_10m_짧은호_MC50": 50.0,
"C_10m_MC60": None,
"D_측점No_MC47": 47.0,
"E_스냅끔_MC50": 50.0,
}
@pytest.mark.parametrize("name", list(CASES))
def test_일반측점_그대로_MC_특별측점(name, ts):
server = _server(CASES[name])
regular = [s["chainage_m"] for s in server if s["kind"] in ("bp", "regular", "ep")]
assert regular == GRID # 20 m 일반측점 하나도 안 사라짐
curves = [s for s in server if s["kind"] == "curve"]
mc = EXPECT_CURVE[name]
if mc is None: # N+0 = 일반측점 위 MC → 특별측점 없음
assert curves == []
else:
assert [s["chainage_m"] for s in curves] == [mc]
assert curves[0]["label"].endswith(" (MC)")
# 거울 — 누가거리 · 종류
ours = ts[name]
assert [s["m"] for s in ours] == pytest.approx([s["chainage_m"] for s in server], abs=1e-6)
assert [s["kind"] for s in ours] == [_KIND[s["kind"]] for s in server]
def test_A_열쇠(ts):
keys = {s["key"]: s["m"] for s in ts["A_10m_긴호_MC50"]}
assert keys["grid:2"] == 40.0 and keys["curve:50.00"] == 50.0
for s in ts["A_10m_긴호_MC50"]:
if s["kind"] == "grid":
assert s["key"] == f"grid:{round(s['m'] / 20)}"
def test_스냅_켬_끔_같은_목록():
on = _server(CASES["A_10m_긴호_MC50"])
off = _server(CASES["E_스냅끔_MC50"])
assert on == off
@@ -256,19 +256,19 @@ def test_추가_측점_열쇠(r):
assert len(ours) == len(r["cases"][0]) + 1
def test_스냅_측점은_원래_격자_번호(r):
plain = {s["key"]: s["chainageM"] for s in r["cases"][0] if s["kind"] == "grid"}
snapped = {s["key"]: s["chainageM"] for s in r["cases"][1] if s["kind"] == "grid"}
assert plain.keys() == snapped.keys() # 옮겨도 번호 그대로 · 겹침 없음
moved = {k: (plain[k], snapped[k]) for k in plain if plain[k] != snapped[k]}
assert moved and all(abs(a - b) <= 10 for a, b in moved.values())
def test_스냅은_측점을_옮기지_않음(r):
"""280-4 — 스냅 켬 = 끔과 같은 측점 · 열쇠(일반측점 그대로 + MC 곡선 측점)."""
plain = [(s["key"], s["chainageM"]) for s in r["cases"][0]]
assert [(s["key"], s["chainageM"]) for s in r["cases"][1]] == plain
assert any(s["kind"] == "curve" for s in r["cases"][1])
def test_초안_없으면_저장_스냅_따름(r):
"""441 — 초안 없는 저장 노선 측점 단계 = 저장 station_snap · 초안이 있으면 초안 값."""
"""441 — 초안 없는 저장 노선 측점 단계 = 저장 station_snap · 초안이 있으면 초안 값.
280-4 뒤 스냅은 측점을 옮기지 않아 켬 · 끔 목록이 같음."""
snapped = [s["chainageM"] for s in r["cases"][1]]
plain = [s["chainageM"] for s in r["cases"][0]]
assert snapped != plain
assert snapped == plain
got = r["savedSnap"]
assert got["saved"] == snapped
assert got["savedOff"] == plain and got["noPlan"] == plain
+1 -1
View File
@@ -97,4 +97,4 @@ def test_편집기_이음() -> None:
edits = (B05 / "logic" / "B05_Profile_UI_RouteEdit_Edits.ts").read_text("utf-8")
assert "측점 밖 곡선 " in edits
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8")
assert "곡선 가운데(MC)를 가까운 일반측점에" in chrome
assert "고친 곡선의 가운데(MC)를 고른 간격 배수에" in chrome # 280-4
+12 -3
View File
@@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
SNAP = B05 / "B05_Profile_UI_RouteEdit_SnapStep.ts"
TS = ROOT / "config" / "node_modules" / "typescript"
STEPS = ["0.1", "1", "10", "station"]
STEPS = ["10", "station"] # 280-4 — 0.1 · 1 m 지움
NODE = """
const fs = require("fs");
@@ -46,8 +46,6 @@ def texts() -> dict:
("step", "want"),
[
("station", "곡선 가운데(MC)를 일반측점에 맞췄습니다."),
("0.1", "곡선 가운데(MC)를 0.1 m 배수에 맞췄습니다."),
("1", "곡선 가운데(MC)를 1 m 배수에 맞췄습니다."),
("10", "곡선 가운데(MC)를 10 m 배수에 맞췄습니다."),
],
)
@@ -62,3 +60,14 @@ def test_편집기는_고른_간격_글을_씀() -> None:
assert 'applyEdit(stationSnap ? snap.text() : "옮겼습니다.")' in edit # 놓을 때(430)
snap = SNAP.read_text(encoding="utf-8")
assert "text: () => snapFitText(select.value)" in snap
def test_고르기는_10_m_측점_No_둘() -> None:
"""280-4 — 고르기 option 둘 · STEPS 둘(옛 pref 0.1 · 1 = 측점 No.)."""
import re
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
block = chrome[chrome.index('data-act="snap-step"') :]
block = block[: block.index("</select>")]
assert re.findall(r'<option value="([^"]+)"', block) == ["10", "station"]
assert 'const STEPS = ["10", "station"];' in SNAP.read_text(encoding="utf-8")
+15 -20
View File
@@ -27,7 +27,7 @@ from common_util.common_util_route_geometry import planned_route_working_path #
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_390_5_snap_step_ops.cjs")
GRID = [float(v) for v in range(0, 1001, 20)]
STEPS = {"0.1": 0.1, "1": 1.0, "10": 10.0, "station": None}
STEPS = {"10": 10.0, "station": None} # 280-4 — 0.1 · 1 m 지움
def _spans() -> list[list[float]]:
@@ -65,7 +65,7 @@ def test_스냅_끔_방향키는_0_1_Shift_1(got) -> None:
assert got["keys"]["offShift"] == pytest.approx(1.0)
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
@pytest.mark.parametrize("step", ["10", "20"])
def test_스냅_켬_방향키_한_번_고른_간격_Shift_무시(got, step) -> None:
key = got["keys"]["snap"][step]
assert key["plain"] == pytest.approx(float(step))
@@ -75,7 +75,7 @@ def test_스냅_켬_방향키_한_번_고른_간격_Shift_무시(got, step) -> N
# ── ② 끌기 미리보기 ──
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
@pytest.mark.parametrize("step", ["10", "20"])
def test_끄는_동안_MC_가_간격_배수(got, step) -> None:
drag = got["drag"][step]
assert drag["off"] == 0
@@ -103,7 +103,7 @@ def test_손잡이_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None:
assert got["sweptHandle"]["unguarded"][mode]["offAfter"] > 0
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
@pytest.mark.parametrize("step", ["10", "20"])
def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None:
drag = got["drag"][step]
assert drag["stale"] == 0
@@ -145,25 +145,20 @@ def test_자릿수_배수(got) -> None:
@pytest.mark.parametrize("key", list(STEPS))
def test_MC_측점_값_자릿수(got, key) -> None:
values = got["snapped"][key]
moved = [v for v in values if v not in GRID]
assert moved, "호 안 측점이 MC 로 옮겨져야 함"
scale = 10 if key == "0.1" else 1
assert all(abs(v * scale - round(v * scale)) < 1e-9 for v in values)
if key == "0.1":
assert any(abs(v - round(v)) > 1e-9 for v in moved) # 소수 한 자리 MC 가 실제로 생김
def test_MC_측점_값_정수(got, key) -> None:
values = got["curve"][key]
assert values, "MC 곡선 측점이 생겨야 함"
assert all(v == round(v) for v in values)
@pytest.mark.parametrize("key", list(STEPS))
def test_파이썬_짝_거울(got, key) -> None:
py = [float(v) for v in core.snap_curve_stations(GRID, SPANS, STEPS[key])]
assert py == got["snapped"][key]
assert core.curve_stations(GRID, SPANS, 1e9, STEPS[key]) == got["curve"][key]
def test_옛_부르기는_정수_그대로() -> None:
old = [float(v) for v in core.snap_curve_stations(GRID, SPANS)]
assert old == [float(v) for v in core.snap_curve_stations(GRID, SPANS, None)]
old = core.curve_stations(GRID, SPANS, 1e9)
assert old == core.curve_stations(GRID, SPANS, 1e9, None)
assert all(v == round(v) for v in old)
@@ -183,9 +178,9 @@ def test_눈금_글_0_1_때만_소수_한_자리(got) -> None:
def test_초안_간격이_요청으로_펴짐() -> None:
nodes = [(0.0, 0.0), (50.0, 10.0), (100.0, 0.0)]
draft = RouteDraftInput(nodes=nodes, stationSnap=True, stationSnapStep=0.1)
draft = RouteDraftInput(nodes=nodes, stationSnap=True, stationSnapStep=10.0)
resolved = RouteSaveRequest(draft=draft).resolved()
assert resolved.station_snap is True and resolved.station_snap_step == 0.1
assert resolved.station_snap is True and resolved.station_snap_step == 10.0
old = RouteSaveRequest(draft=RouteDraftInput(nodes=nodes, stationSnap=True))
assert old.resolved().station_snap_step is None
@@ -193,8 +188,8 @@ def test_초안_간격이_요청으로_펴짐() -> None:
@pytest.mark.parametrize(
("snap", "step", "want"),
[
(True, 0.1, 0.1),
(True, 1.0, 1.0),
(True, 0.1, None), # 280-4 — 옛 0.1 · 1 m = 측점 No.
(True, 1.0, None),
(True, 10.0, 10.0),
(True, None, None),
(False, 0.1, None),
+7 -6
View File
@@ -5,7 +5,7 @@
`buildStations`(헬퍼 `helper_344_3_pipeline_stations.cjs`). 열쇠 `curve:<누가거리>` ·
이름 뒤 「 (MC)」 ·
값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 일반측점 값과 같으면 안 더함 ·
스냅 켬 · 호 안 일반측점 = 224-2 대로 옮김(곡선 측점 없음) · BC · EC 안 더함.
스냅 켬도 일반측점을 옮기지 않음(280-4 · 끔과 같은 목록) · BC · EC 안 더함.
"""
from __future__ import annotations
@@ -91,18 +91,19 @@ def test_스냅_끔_MC_마다_곡선_측점() -> None:
assert 101.0 not in by and 113.6 not in by # BC · EC 안 더함
def test_스냅_켬_짧은_호만_곡선_측점() -> None:
def test_스냅_켬도_끔과_같은_곡선_측점() -> None:
stations = _server(True)
by = {s["chainage_m"]: s for s in stations}
assert _curves(stations) == [107.0]
assert by[631.0]["kind"] == "regular" and 640.0 not in by # 224-2 옮김 그대로
assert stations == _server(False) # 280-4 — 일반측점 안 옮김
assert _curves(stations) == [107.0, 631.0]
assert by[640.0]["kind"] == "regular" and by[620.0]["kind"] == "regular"
def test_스냅_켬_0_1_간격() -> None:
stations = _server(True, 0.1)
by = {s["chainage_m"]: s for s in stations}
assert _curves(stations) == [107.3]
assert 631.3 in by and by[200.1]["kind"] == "regular" # 옮긴 일반측점
assert _curves(stations) == [107.3, 200.1, 631.3] # 일반측점 그대로 + MC 소수 한 자리
assert by[200.0]["kind"] == "regular" and by[640.0]["kind"] == "regular"
def test_MC_에_구조물이면_구조물_측점_하나() -> None: