diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py
index d1491a4b..f804f7e1 100644
--- a/B05_Profile/B05_Profile_Engine_Sections.py
+++ b/B05_Profile/B05_Profile_Engine_Sections.py
@@ -98,9 +98,39 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
)
+# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
+#: 이름 속 측점 글 — 정수 m `00120` · 소수 측점 `00119.80`
+CROSS_KEY_PATTERN = r"(\d+(?:\.\d{2})?)"
+
+
+def cross_station(chainage_m: float) -> int | float:
+ """측점 → 이름 열쇠 값 — 정수 m(0.005m 안)면 int(옛 이름 · 키 그대로) · 아니면 소수 2자리."""
+ value = round(float(chainage_m), 2)
+ whole = round(value)
+ return int(whole) if abs(value - whole) < 0.005 else value
+
+
+def cross_key(chainage_m: float) -> str:
+ """이름 속 측점 글 — `00120`(정수 · 옛 이름 그대로) · `00119.80`(소수 측점)."""
+ station = cross_station(chainage_m)
+ return f"{station:05d}" if isinstance(station, int) else f"{station:08.2f}"
+
+
+def parse_cross_key(text: str) -> int | float:
+ """`cross_key` 되읽기."""
+ return cross_station(float(text))
+
+
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
- return f"cross_{int(round(float(chainage_m))):05d}m.json"
+ return f"cross_{cross_key(chainage_m)}m.json"
+
+
+def find_cross_file(cross_dir: Path, chainage_m: float) -> Path:
+ """읽기용 — 지금 이름이 없으면 옛 정수 m 이름(소수 측점이 정수로 붙던 옛 프로젝트)."""
+ path = cross_dir / cross_filename(chainage_m)
+ legacy = cross_dir / f"cross_{int(round(float(chainage_m))):05d}m.json"
+ return path if path.exists() or not legacy.exists() else legacy
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
@@ -109,8 +139,9 @@ def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
"""
+ # 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐
valid = {
- cross_filename(station["chainage_m"])
+ find_cross_file(cross_dir, station["chainage_m"]).name
for station in stations
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
}
diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py
index a76490ee..72279681 100644
--- a/B05_Profile/B05_Profile_Engine_Sections_Core.py
+++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py
@@ -341,34 +341,19 @@ def generate_sections(
if 0.0 <= float(chainage) <= total
}
if extras:
- # 횡단 파일명이 정수 미터 반올림이라(cross_filename) 한 정수 미터에 측점은 하나만 둔다.
- # 관 · 구조물 자체 측점은 소수 그대로 — 격자 측점과 같은 정수 미터면 격자 대신 그 측점을
- # 쓴다(시 · 종점 격자만 그대로 두고 라벨을 얹음). 비정규끼리 겹치면 앞선 것만 남긴다.
- # ponytail: 파일명이 정수 미터인 동안의 가드. 소수 이름 규칙으로 바꾸면 지운다.
+ # 관 · 구조물 자리마다 그 소수 측점의 횡단을 따로 둔다(2026-09-29 사용자 확정) — 이름은
+ # 소수 2자리(cross_00119.80m · cross_filename). 같은 이름이 되는 0.005m 안 겹침만 하나로:
+ # 격자와 겹치면 격자 측점에 라벨을 얹고, 비정규끼리 겹치면 앞선 것만 남긴다.
grid = [round(float(value), 6) for value in station_chainage]
- ends = {grid[0], grid[-1]}
- buckets = {int(round(value)): value for value in grid}
- taken: set[int] = set()
- dropped: set[float] = set()
snapped: dict[float, str] = {}
for chainage in sorted(extras):
- bucket = int(round(chainage))
- if bucket in taken:
+ near = next((value for value in grid if abs(value - chainage) < 0.005), chainage)
+ if any(abs(near - taken) < 0.005 for taken in snapped):
continue
- taken.add(bucket)
- regular = buckets.get(bucket)
- if regular in ends:
- snapped[regular] = extras[chainage]
- continue
- if regular is not None:
- dropped.add(regular)
- snapped[chainage] = extras[chainage]
+ snapped[near] = extras[chainage]
extras = snapped
station_chainage = np.unique(
- np.r_[
- np.array([value for value in grid if value not in dropped], dtype=np.float64),
- np.array(sorted(extras), dtype=np.float64),
- ]
+ np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts
index e3837b76..798caaf8 100644
--- a/B05_Profile/B05_Profile_UI_IrregularStations.ts
+++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts
@@ -10,6 +10,7 @@
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
* ========================================================================== */
+import { stationPartsLabel } from "../common_util/common_util_station";
import {
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
ESCAPE_ROUTE_WIDTHS_M,
@@ -130,9 +131,9 @@ export function irregularStationId(id: string): string {
return `irregular:${id}`;
}
-/** 측점번호+잔여거리 표기 (예: 3+18.0). */
+/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
export function irregularLabel(station: IrregularStation): string {
- return `${station.station}+${station.remainder.toFixed(1)}`;
+ return stationPartsLabel(station.station, station.remainder);
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts
index 2ed105aa..65065cb9 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts
@@ -24,7 +24,7 @@ import {
designHalfWidths,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface CrossPreviewParams {
projectId: string;
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts
index 0d05da59..b7cffb06 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts
@@ -13,7 +13,7 @@
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
type Vertex = [number, number];
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
index c4ed86a3..7e603d50 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
@@ -26,7 +26,7 @@ import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 노드 반지름(px). */
const NODE_R = 4;
diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts
index ea48714c..a5d63704 100644
--- a/B05_Profile/B05_Profile_UI_Structures_List.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_List.ts
@@ -14,7 +14,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface StructureListParams {
/** 목록을 담을
. 그릴 때마다 통째로 갈아 끼운다. */
diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts
index 1c52a483..6b96079a 100644
--- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts
@@ -20,7 +20,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts
index 7e393dca..d11c095b 100644
--- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts
@@ -36,13 +36,19 @@ import {
loadPipeForm as loadPipeFormInto,
} from "./B05_Profile_UI_Structures_Panel_Commit";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
-import { formatStation } from "./B05_Profile_Util_Station";
+import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
-import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
+import {
+ DEFAULT_INTERVAL_LENGTH_M,
+ GROUP_LABELS,
+ hasComputedRange,
+ readBeforeOption,
+ readLengthOption,
+} from "./B05_Profile_UI_Structures_Panel_Types";
import type {
PipeFacilityItem,
StructuresCallbacks,
@@ -89,35 +95,12 @@ export function createStructuresSection(
return type.managed_by ? (type.type_id as PipeFacility) : null;
}
- /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
- * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
- * 준다(2026-08-19 사용자 지시). */
- function hasComputedRange(type: StructureType | null): boolean {
- return (
- !!type &&
- !type.managed_by &&
- type.placement === "interval" &&
- type.options.some((option) => option.key === "length_m" && isB05Option(option))
- );
- }
-
- /** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
function readLengthM(): number | null {
- const entry = optionInputs.find((input) => input.key === "length_m");
- if (!entry || entry.isEmpty()) return null;
- const length = Number(entry.read());
- return Number.isFinite(length) && length > 0 ? length : null;
+ return readLengthOption(optionInputs);
}
- /** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
- * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
function readBeforeM(): number {
- const entry = optionInputs.find((input) => input.key === "before_m");
- if (!entry || entry.isEmpty()) return 0;
- const before = Number(entry.read());
- if (!Number.isFinite(before) || before < 0) return 0;
- const length = readLengthM();
- return length !== null && before > length ? length : before;
+ return readBeforeOption(optionInputs);
}
/** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */
diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
index a04f3f5f..c883afc2 100644
--- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
@@ -9,7 +9,11 @@
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
-import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
+import {
+ isB05Option,
+ type StructureInstance,
+ type StructureType,
+} from "./B05_Profile_Api_Structures";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -98,3 +102,36 @@ export interface StructuresCallbacks {
*/
onReveal?: () => void;
}
+
+type OptionEntry = { key: string; read: () => string | number; isEmpty: () => boolean };
+
+/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
+export function readLengthOption(inputs: OptionEntry[]): number | null {
+ const entry = inputs.find((input) => input.key === "length_m");
+ if (!entry || entry.isEmpty()) return null;
+ const length = Number(entry.read());
+ return Number.isFinite(length) && length > 0 ? length : null;
+}
+
+/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
+ * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
+export function readBeforeOption(inputs: OptionEntry[]): number {
+ const entry = inputs.find((input) => input.key === "before_m");
+ if (!entry || entry.isEmpty()) return 0;
+ const before = Number(entry.read());
+ if (!Number.isFinite(before) || before < 0) return 0;
+ const length = readLengthOption(inputs);
+ return length !== null && before > length ? length : before;
+}
+
+/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
+ * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
+ * 준다(2026-08-19 사용자 지시). */
+export function hasComputedRange(type: StructureType | null): boolean {
+ return (
+ !!type &&
+ !type.managed_by &&
+ type.placement === "interval" &&
+ type.options.some((option) => option.key === "length_m" && isB05Option(option))
+ );
+}
diff --git a/B05_Profile/B05_Profile_Util_Station.ts b/B05_Profile/B05_Profile_Util_Station.ts
index b6859a0f..60a862d2 100644
--- a/B05_Profile/B05_Profile_Util_Station.ts
+++ b/B05_Profile/B05_Profile_Util_Station.ts
@@ -4,7 +4,8 @@
*
* 구조물 위치는 저장은 누가거리(m)로 하되 **화면 입출력은 측점번호+잔여거리**로 한다
* (2026-08-17 사용자 확정). 근거: 중심선측량 측점 20m + 구조물 지점 보조말뚝
- * (지식DB 설계제원_총괄 §8). 표기 예: "3+18.0" = 측점 3번에서 18.0m.
+ * (지식DB 설계제원_총괄 §8). 표기 예: "3+18" = 측점 3번에서 18m — 화면 표기는
+ * `common_util_station.stationLabel` 한 벌(거리는 정수로 보이기만 · 값은 소수).
*
* 변환식은 비정규 측점 UI(`B05_Profile_UI_IrregularStations.ts`)와 같다 —
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 호출 시점에 받는다
@@ -33,12 +34,6 @@ export function chainageToStation(
return { station, remainder };
}
-/** 누가거리 → "3+18.0" 표기. */
-export function formatStation(chainageM: number, intervalM: number): string {
- const { station, remainder } = chainageToStation(chainageM, intervalM);
- return `${station}+${remainder.toFixed(1)}`;
-}
-
/** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다.
* 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */
export function parseStationText(text: string, intervalM: number): number | null {
diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py
index 4e1d5d84..5cfe32ce 100644
--- a/B06_Section/B06_Section_Engine_SampleExtend.py
+++ b/B06_Section/B06_Section_Engine_SampleExtend.py
@@ -19,7 +19,7 @@ from typing import Any, Callable
import numpy as np
-from B05_Profile.B05_Profile_Engine_Sections import cross_filename
+from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
@@ -175,7 +175,7 @@ def extend_unclosed_sections(
recomputed[key] = design[key]
section["samples"] = samples
section["design"] = recomputed
- cross_path = cross_dir / cross_filename(chainage)
+ cross_path = find_cross_file(cross_dir, chainage)
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": samples})
diff --git a/B06_Section/B06_Section_Repository.py b/B06_Section/B06_Section_Repository.py
index 72807284..b9d243f6 100644
--- a/B06_Section/B06_Section_Repository.py
+++ b/B06_Section/B06_Section_Repository.py
@@ -406,23 +406,23 @@ async def merge_cross_section_design_by_round(
connection: aiomysql.Connection,
*,
route_id: int,
- chainage_int: int,
+ chainage: float,
patch: dict[str, Any],
) -> bool:
- """정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다.
+ """측점(도면 id 측점 — 정수 m 또는 소수 2자리)의 data.design에 patch를 병합 저장한다.
B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다.
- 도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다.
+ 이름 규칙(cross_key)의 0.005m 안으로 매칭한다. 갱신 여부를 반환한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM cross_sections
- WHERE route_id = %s AND ROUND(chainage_m) = %s
+ WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
- (route_id, chainage_int),
+ (route_id, chainage),
)
row = await cursor.fetchone()
if not row:
@@ -445,21 +445,21 @@ async def merge_cross_section_design_by_round(
async def get_cross_section_design(
- connection: aiomysql.Connection, route_id: int, chainage_int: int
+ connection: aiomysql.Connection, route_id: int, chainage: float
) -> dict[str, Any] | None:
- """정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다.
+ """도면 id 측점(cross_{정수m}m · cross_{소수2자리}m)의 잠정 설계(data.design)를 반환한다.
- B08이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다.
+ 이름 규칙(cross_key)의 0.005m 안으로 매칭한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT data FROM cross_sections
- WHERE route_id = %s AND ROUND(chainage_m) = %s
+ WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
- (route_id, chainage_int),
+ (route_id, chainage),
)
row = await cursor.fetchone()
if not row or not row[0]:
diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py
index f570baf2..b3104c32 100644
--- a/B06_Section/B06_Section_Router_Design.py
+++ b/B06_Section/B06_Section_Router_Design.py
@@ -5,7 +5,7 @@ import logging
from pathlib import Path
from typing import Any
-from B05_Profile.B05_Profile_Engine_Sections import cross_filename
+from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
@@ -309,7 +309,7 @@ def read_cross_design_inputs(
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
suggested = pavement.get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
- cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
+ cross_path = find_cross_file(cross_dir, chainage_m).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
@@ -492,7 +492,7 @@ def compute_default_designs(
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
- cross_path = cross_dir / cross_filename(chainage_m)
+ cross_path = find_cross_file(cross_dir, chainage_m)
if not cross_path.is_file():
continue
try:
diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
index 7a67125f..17b07270 100644
--- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
+++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
@@ -9,6 +9,7 @@
import re
from typing import Any
+from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_MM,
CROSS_TABLE_LAYER_ID,
@@ -38,7 +39,7 @@ from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
-CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
+CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거).
@@ -151,7 +152,7 @@ def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[flo
return slots
-def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
+def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
@@ -170,7 +171,7 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
- "id": f"cross_s{chainages[0]:05d}m",
+ "id": f"cross_s{cross_key(chainages[0])}m",
"number": len(sheets) + 1,
"chainages": chainages,
"rows": max(rows, 1),
diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py
index 592477a5..d07804e4 100644
--- a/B07_DesignDetail/B07_DesignDetail_Router.py
+++ b/B07_DesignDetail/B07_DesignDetail_Router.py
@@ -13,6 +13,11 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
+from B05_Profile.B05_Profile_Engine_Sections import (
+ CROSS_KEY_PATTERN,
+ cross_station,
+ parse_cross_key,
+)
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_design,
@@ -65,7 +70,7 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
-_CROSS_ID = re.compile(r"^cross_(\d+)m$")
+_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
@@ -249,13 +254,13 @@ async def title_block_fields(project_id: UUID) -> dict[str, str]:
return {key: str(value) for key, value in fields.items() if value}
-async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
+async def _designs_by_chainage(route_id: int) -> dict[int | float, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await get_cross_section_designs(connection, route_id)
return {
- int(round(float(row["chainage_m"]))): row["design"]
+ cross_station(row["chainage_m"]): row["design"]
for row in rows
if isinstance(row, dict) and isinstance(row.get("design"), dict)
}
@@ -304,7 +309,7 @@ async def get_design_drawing(
pool = get_db_pool()
async with pool.acquire() as connection:
design = await get_cross_section_design(
- connection, route_id, int(cross_match.group(1))
+ connection, route_id, parse_cross_key(cross_match.group(1))
)
source_design = design
elif CROSS_SHEET_ID.fullmatch(drawing_id):
@@ -448,25 +453,25 @@ async def confirm_design_drawing(
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
- targets: list[int] = []
+ targets: list[int | float] = []
if sheet is not None:
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
- targets = [int(cross_match.group(1))]
+ targets = [parse_cross_key(cross_match.group(1))]
confirmed_designs = [
- (chainage_int, {**designs[chainage_int], "status": "confirmed"})
- for chainage_int in targets
- if designs.get(chainage_int)
+ (chainage, {**designs[chainage], "status": "confirmed"})
+ for chainage in targets
+ if designs.get(chainage)
]
async with pool.acquire() as connection:
await connection.begin()
try:
- for chainage_int, _design in confirmed_designs:
+ for chainage, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
- chainage_int=chainage_int,
+ chainage=chainage,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
@@ -519,9 +524,9 @@ async def invalidate_design_drawing(
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
- stale: list[int] = []
+ stale: list[int | float] = []
if cross_match:
- stale = [int(cross_match.group(1))]
+ stale = [parse_cross_key(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
@@ -533,11 +538,11 @@ async def invalidate_design_drawing(
await connection.begin()
try:
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
- for chainage_int in stale:
+ for chainage in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
- chainage_int=chainage_int,
+ chainage=chainage,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py
index 08d99000..4287f1b2 100644
--- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py
+++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py
@@ -9,6 +9,12 @@ import re
from pathlib import Path
from typing import Any
+from B05_Profile.B05_Profile_Engine_Sections import (
+ CROSS_KEY_PATTERN,
+ cross_station,
+ find_cross_file,
+ parse_cross_key,
+)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
@@ -94,7 +100,7 @@ logger = logging.getLogger(__name__)
_STAGE_DIR = "B07_DesignDetail"
-_CROSS_ID = re.compile(r"^cross_(\d+)m$")
+_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
@@ -107,7 +113,7 @@ CROSS_STANDARD_ID = "cross_standard"
def _drawing_list(
project_root: Path,
longitudinal_path: Path,
- designs: dict[int, dict[str, Any]] | None = None,
+ designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -226,7 +232,7 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
- designs: dict[int, dict[str, Any]] | None = None,
+ designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
@@ -239,8 +245,9 @@ def _cross_sheet_plan(
match = _CROSS_ID.fullmatch(path.stem)
if not match:
continue
- chainage = int(match.group(1))
source = _read_json(path)
+ # 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
+ chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line)
@@ -352,11 +359,11 @@ def _cross_design_line(
def _cross_section_input(
longitudinal_path: Path,
longitudinal: dict[str, Any],
- chainage: int,
+ chainage: int | float,
design: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
- path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
+ path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
if not path.is_file():
return None
source = _read_json(path)
@@ -565,9 +572,12 @@ def _read_drawing(
if CROSS_SHEET_ID.fullmatch(drawing_id):
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
- if not _CROSS_ID.fullmatch(drawing_id):
+ cross_match = _CROSS_ID.fullmatch(drawing_id)
+ if not cross_match:
raise ValueError("올바르지 않은 도면 ID입니다.")
- path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
+ path = find_cross_file(
+ longitudinal_path.parent.parent / "cross_sections", parse_cross_key(cross_match.group(1))
+ )
if not path.is_file():
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py
index ad9630c9..00303a40 100644
--- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py
+++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py
@@ -9,7 +9,7 @@ import re
from pathlib import Path
from typing import Any
-from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
+from B05_Profile.B05_Profile_Engine_Sections import cross_station, prune_stale_cross_files
logger = logging.getLogger(__name__)
@@ -47,12 +47,12 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[
return files
-def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
+def _station_map(longitudinal: dict[str, Any]) -> dict[int | float, dict[str, Any]]:
stations = longitudinal.get("stations", [])
if not isinstance(stations, list):
return {}
return {
- round(float(station.get("chainage_m", 0))): station
+ cross_station(station.get("chainage_m", 0)): station
for station in stations
if isinstance(station, dict)
}
diff --git a/common_util/common_util_station.ts b/common_util/common_util_station.ts
new file mode 100644
index 00000000..3fb2d306
--- /dev/null
+++ b/common_util/common_util_station.ts
@@ -0,0 +1,25 @@
+/* =============================================================================
+ * common_util_station.ts
+ * 측점 화면 표기 한 벌 — B05 · B06 목록 · 폼 · 툴팁 · 종단 표시가 모두 이것을 씀
+ * (`common_util_svg` 가 다시 내보냄 · B05 는 `formatStation` 이름으로 가져다 씀). 가져오는 것이 없는
+ * 순수 함수라 서버 Node · 시험 헬퍼에서도 그대로 돎.
+ * ========================================================================== */
+
+/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`).
+ * 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */
+export function stationLabel(chainage: number, interval: number): string {
+ const safeInterval = interval > 0 ? interval : 1;
+ let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
+ let remainder = chainage - stationNumber * safeInterval;
+ if (Math.abs(remainder) < 0.05) remainder = 0;
+ if (remainder >= safeInterval - 0.05) {
+ stationNumber += 1;
+ remainder = 0;
+ }
+ return stationPartsLabel(stationNumber, remainder);
+}
+
+/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */
+export function stationPartsLabel(stationNumber: number, remainder: number): string {
+ return `${stationNumber}+${Math.round(remainder)}`;
+}
diff --git a/common_util/common_util_svg.ts b/common_util/common_util_svg.ts
index d2498638..e03e9e6c 100644
--- a/common_util/common_util_svg.ts
+++ b/common_util/common_util_svg.ts
@@ -49,15 +49,4 @@ export function inferStationInterval(stations: Array<{ chainage_m: number }>): n
);
}
-/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */
-export function stationLabel(chainage: number, interval: number): string {
- const safeInterval = interval > 0 ? interval : 1;
- let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
- let remainder = chainage - stationNumber * safeInterval;
- if (Math.abs(remainder) < 0.05) remainder = 0;
- if (remainder >= safeInterval - 0.05) {
- stationNumber += 1;
- remainder = 0;
- }
- return `${stationNumber}+${remainder.toFixed(1)}`;
-}
+export { stationLabel } from "./common_util_station";
diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs
index c31ff5cd..a967df82 100644
--- a/resources/tester/helper_b05_station_roundtrip.cjs
+++ b/resources/tester/helper_b05_station_roundtrip.cjs
@@ -101,4 +101,16 @@ out.moved = store.edits();
store.resetStation(200);
out.deleted = store.edits();
+// 화면 표기 한 벌 — 거리는 정수로 보이기만(PLAN 20장 · 119.8 → 5+20)
+const { stationLabel, stationPartsLabel } = loadTs(
+ path.join(ROOT, "common_util", "common_util_station.ts"),
+);
+out.labels = [
+ stationLabel(119.8, INTERVAL),
+ stationLabel(120, INTERVAL),
+ stationLabel(84.3, INTERVAL),
+ stationLabel(123.37, INTERVAL),
+ stationPartsLabel(5, 19.8),
+];
+
process.stdout.write(JSON.stringify(out));
diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py
index a3b25de8..dbbcd235 100644
--- a/resources/tester/test_b05_b06_save_roundtrip.py
+++ b/resources/tester/test_b05_b06_save_roundtrip.py
@@ -22,6 +22,8 @@ from typing import Any
import pytest
+from B05_Profile.B05_Profile_Engine_Sections import cross_filename
+
ROOT = Path(__file__).resolve().parents[2]
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
@@ -425,6 +427,10 @@ class Flow:
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
if not any(abs(n - c) < 0.005 for n in near):
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
+ # 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
+ name = cross_filename(c)
+ if not (root / "B06_Section" / "cross_sections" / name).is_file():
+ d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
self._check_designs()
def _check_designs(self) -> None:
diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py
index 689ea264..237ce6a7 100644
--- a/resources/tester/test_b05_extra_stations_supplier.py
+++ b/resources/tester/test_b05_extra_stations_supplier.py
@@ -192,7 +192,7 @@ def test_matching_signature_loads_points(tmp_path):
]
-# ── 횡단 파일명 충돌 — 한 정수 미터에 측점 하나 · 관 · 구조물 자체 측점 우선(PLAN 19장) ──
+# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ──────────────
def _stations(extra_stations):
@@ -204,31 +204,32 @@ def _stations(extra_stations):
return result["longitudinal"]["stations"]
-def test_extra_station_in_grid_meter_replaces_grid_station():
+def test_extra_station_in_grid_meter_keeps_both_with_own_names():
stations = _stations(((100.4, "X"),))
chainages = [station["chainage_m"] for station in stations]
- # 관 · 구조물 자체 측점 그대로 — 같은 정수 미터 격자 100 은 빠져 파일(cross_00100m)은 한 장
- assert 100.4 in chainages and 100.0 not in chainages
+ assert 100.4 in chainages and 100.0 in chainages
labelled = [station for station in stations if station.get("structure") == "X"]
assert [(station["chainage_m"], station["kind"]) for station in labelled] == [
(100.4, "irregular")
]
+ assert cross_filename(100.4) == "cross_00100.40m.json"
assert len({cross_filename(c) for c in chainages}) == len(chainages)
-def test_extra_station_at_route_end_keeps_end_station():
- stations = _stations(((0.3, "S"), (349.8, "E")))
- chainages = [station["chainage_m"] for station in stations]
- # 시 · 종점은 격자 그대로 두고 라벨만 얹음
- assert chainages[0] == 0.0 and chainages[-1] == 350.0
- assert 0.3 not in chainages and 349.8 not in chainages
- assert stations[0].get("structure") == "S" and stations[-1].get("structure") == "E"
+def test_extra_station_on_grid_within_5mm_labels_grid_station():
+ stations = _stations(((100.003, "X"), (0.3, "S")))
+ labelled = {station["chainage_m"]: station for station in stations if station.get("structure")}
+ assert sorted(labelled) == [0.3, 100.0]
+ assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular"
-def test_two_extras_in_same_meter_bucket_keep_one():
+def test_two_extras_in_same_meter_both_kept():
stations = _stations(((80.2, "A"), (80.4, "B")))
- assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.2]
- assert 80.0 not in [station["chainage_m"] for station in stations]
+ assert [station["chainage_m"] for station in stations if station.get("structure")] == [
+ 80.2,
+ 80.4,
+ ]
+ assert 80.0 in [station["chainage_m"] for station in stations]
def test_live_extras_stay_irregular_and_filenames_stay_unique():
@@ -287,7 +288,7 @@ def test_run_section_generation_derives_stations_from_truth_files(project):
]
cross_dir = project / "B06_Section" / "cross_sections"
- for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
+ for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"):
assert (cross_dir / f"{name}.json").is_file()
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
assert len(result["cross_sections"]) == len(stations)
diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py
index 68e956c5..22d11394 100644
--- a/resources/tester/test_b05_station_roundtrip.py
+++ b/resources/tester/test_b05_station_roundtrip.py
@@ -37,6 +37,10 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results):
assert results["r119"] == ["5", "19.8", 119.8]
+def test_화면_표기는_거리를_정수로(results):
+ assert results["labels"] == ["5+20", "6+0", "4+4", "6+3", "5+20"]
+
+
def test_칸을_고치면_친_값(results):
assert results["edited"] == pytest.approx(125.2)
assert results["normalized"] == ["2", "10.0", 50]
@@ -96,8 +100,31 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path):
SectionGenerationOptions(extra_stations=extras),
)
chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]]
- # 횡단 측점도 자체 측점 119.8(같은 정수 미터 격자 120 대신 · 파일 이름 cross_00120m 한 장)
- assert 119.8 in chainages and 120.0 not in chainages
- assert cross_filename(119.8) == cross_filename(120.0)
+ # 소수 측점 횡단을 따로 둠(PLAN 20장) — 119.8 · 격자 120 둘 다 · 이름도 따로
+ assert 119.8 in chainages and 120.0 in chainages
+ assert (cross_filename(119.8), cross_filename(120.0)) == (
+ "cross_00119.80m.json",
+ "cross_00120m.json",
+ )
_, again = load_structures(str(tmp_path))
assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만"
+
+
+def test_옛_프로젝트_정수_이름_횡단은_찾고_안_지움(tmp_path):
+ """소수 측점이 정수 m 이름으로 저장된 옛 프로젝트 — 열 때 지우지 않고 그 파일을 씀."""
+ from B05_Profile.B05_Profile_Engine_Sections import find_cross_file, prune_stale_cross_files
+
+ (tmp_path / "cross_00084m.json").write_text("{}", encoding="utf-8") # 옛 84.3
+ (tmp_path / "cross_00100m.json").write_text("{}", encoding="utf-8")
+ (tmp_path / "cross_00119.80m.json").write_text("{}", encoding="utf-8") # 새 이름
+ (tmp_path / "cross_00500m.json").write_text("{}", encoding="utf-8") # 잔재
+ stations = [{"chainage_m": 84.3}, {"chainage_m": 100.0}, {"chainage_m": 119.8}]
+ valid = prune_stale_cross_files(tmp_path, stations)
+ assert sorted(p.name for p in tmp_path.iterdir()) == [
+ "cross_00084m.json",
+ "cross_00100m.json",
+ "cross_00119.80m.json",
+ ]
+ assert valid == {"cross_00084m.json", "cross_00100m.json", "cross_00119.80m.json"}
+ assert find_cross_file(tmp_path, 84.3).name == "cross_00084m.json"
+ assert find_cross_file(tmp_path, 119.8).name == "cross_00119.80m.json"
diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py
index 58481163..9abb3e39 100644
--- a/resources/tester/test_b06_patch_station.py
+++ b/resources/tester/test_b06_patch_station.py
@@ -34,3 +34,9 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch):
def test_모든_수정에_행이_있으면_안_부름(monkeypatch):
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == []
assert _calls(monkeypatch, [], [120.0]) == []
+
+
+def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch):
+ # 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움
+ assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT]
+ assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == []