fix(b05,b06): 측점 한 벌 — 정수 m · 같은 m 측점 하나 · 목록 전체 다시 맞춤 (272)

- 규칙: 구조물 · 관 기준 측점 = 정수 m 사사오입(일반 측점 1 m 붙기 뺌) · 측점 심는 자리 TS 짝(plantedChainages ↔ resolve_extra_stations) · 같은 m 면 구조물 측점 하나(격자 자리 grid) · 떠나면 일반 측점(oneStationList ↔ _merge_irregular_into_longitudinal)
- 서버: B05 [저장] · B06 저장 · [측점 만들기]가 목록 전체 다시 맞춤(빈 목록도 · 옛 글 · 횡단 파일 글) · 계획선 측점 줄 다시 셈 · 정본에 없는 횡단 행은 0.5 m 안 새 측점으로 옮기고 나머지 지움 · 옛 소수 측점 꼭짓점 · 편집은 정수 자리로 · 맞춤 실패는 저장을 안 막음
- 화면: 종단 그래프 · 3D · 표 · ▲▼ 한 목록(초안 반영) · C군 구간형 · 독립 기슭막이 시작 · 끝 측점 · B06 카드는 초안에 없는 옛 구조물 측점 숨김 · 고름은 같은 m 구조물 측점 먼저 · 폼 거리 칸 정수 표기 · C군 계산 구간 · 포장 나눔 경계 정수
- 옛 자료: [정수 맞춤] 단추 없앰 · 종단 보기 열 때 한 번 자동 정수 · 알림 한 줄 · [저장]하면 남음
- 시험: test_272_1 · test_272_2 새로 · 234_1 · 238_1 · b05_extra_stations_supplier · b06_patch_station · b05_station_roundtrip · b05_b06_save_roundtrip 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 19:53:24 +09:00
co-authored by Claude Opus 5.5
parent 06390f7a0d
commit 93e86f87c6
37 changed files with 1147 additions and 363 deletions
+10 -9
View File
@@ -46,7 +46,8 @@ const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
/**
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
* ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
*/
export function profilePickOf(
selection: DesignSelection | null,
@@ -55,16 +56,16 @@ export function profilePickOf(
if (!selection || selection.chainageM === null) return { stationId: null };
if (selection.structureId) return { structureId: selection.structureId };
const at = selection.chainageM;
const irregular = source.irregular();
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
if (exact) return { stationId: irregularStationId(exact.id) };
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
if (card) return { stationId: card.station_id };
const irregular = source.irregular();
const own =
irregular.find((entry) => sameAt(entry.chainage_m, at)) ??
nearestWithin(
irregular.filter((entry) => entry.origin === "pipe"),
at,
(entry) => entry.chainage_m,
);
const own = nearestWithin(
irregular.filter((entry) => entry.origin === "pipe"),
at,
(entry) => entry.chainage_m,
);
if (own) return { stationId: irregularStationId(own.id) };
const target = graphTargetAt(null, at, source.pipes(), source.sections());
return "pipeChainageM" in target
+52 -14
View File
@@ -42,7 +42,6 @@ import type {
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
@@ -70,11 +69,12 @@ import {
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import {
planCurveWarning,
sampleAt,
snapToMasterStations,
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
irregularGraphStations,
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
@@ -219,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
@@ -330,6 +332,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record();
}
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
const list = oneStationList(server, planted);
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
const gone = server
.filter((station) => station.kind === "irregular" && !station.grid)
.map((station) => station.chainage_m)
.filter((chainage) => !after.has(chainage.toFixed(3)));
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
/**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
@@ -472,7 +501,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
storedBase = stored ? toAlignmentBase(stored) : null;
base = listed();
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
@@ -535,9 +565,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
const policy = { ...base.policy, max_grade_pct: maxGradePct };
storedBase = { ...storedBase, policy };
base = { ...base, policy };
alignment = buildAlignment(base, store.edits());
draw();
},
@@ -547,7 +579,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations);
irregularStations = stations;
base = listed();
if (base) alignment = buildAlignment(base, store.edits());
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
@@ -583,10 +617,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/
resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM);
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
store.resetStation(chainageM, moveTo);
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
},
isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
@@ -596,8 +632,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
base = toAlignmentBase(next);
alignment = next;
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
@@ -606,6 +643,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
clear() {
detail = null;
base = null;
storedBase = null;
alignment = null;
selectedStationId = null;
balanceBar.replaceChildren();
+10
View File
@@ -122,6 +122,7 @@ export async function mountProfileView(
bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
@@ -245,6 +246,14 @@ export async function mountProfileView(
selectStationOfPipe(chainageM);
};
let oldSnapped = false;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
function snapOld(): void {
if (oldSnapped || restoring || !lastPipes) return;
oldSnapped = true;
panel.structures.snapOld();
}
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
@@ -653,6 +662,7 @@ export async function mountProfileView(
} finally {
progress.remove();
restoring = false;
snapOld();
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
}
+8 -4
View File
@@ -272,6 +272,8 @@ def resolve_extra_stations(
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
되짚기 때문이다(B05_Profile_Structures_Migration).
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
거울 시험 = `resources/tester/test_272_1_station_list.py`.
"""
types = structure_type_map()
derived: list[tuple[float, str]] = []
@@ -560,9 +562,10 @@ def generate_irregular_sections(
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
격자 측점과 똑같은 접선·오프셋
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
@@ -589,17 +592,18 @@ def generate_irregular_sections(
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
# 구조물 측점 전부 — 격자 · 시종점과 같은 m 라 그 측점에 얹힌 것(`grid` · 272)도 함께
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular"
if station.get("kind") == "irregular" or station.get("structure")
]
if not irregular_stations:
return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]:
if cross_section.get("kind") != "irregular":
if cross_section.get("kind") != "irregular" and not cross_section.get("structure"):
continue
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section)
@@ -415,8 +415,10 @@ def generate_sections(
kind = "bp"
elif abs(float(value) - total) <= 1e-6:
kind = "ep"
elif rounded in extras and rounded not in regular_rounded:
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선.
elif rounded in extras:
# 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
kind = "irregular"
else:
kind = "regular"
@@ -466,9 +468,10 @@ def generate_sections(
"uphill_side": uphill_side,
"frame": frame,
}
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
if rounded in extras:
station["structure"] = extras[rounded]
if rounded in regular_rounded:
station["grid"] = True # 숨은 일반 측점 자리(272)
stations.append(station)
samples = []
+39
View File
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
)
async def sync_cross_section_rows(
connection: aiomysql.Connection, route_id: int, chainages: list[float]
) -> int:
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
아무것도 안 건드린다. 옮기거나 지운 행 수.
"""
if not chainages:
return 0
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
)
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
orphans = [
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
]
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
pairs = sorted(
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
)
moved: dict[int, float] = {}
for _gap, row_id, chainage in pairs:
if row_id not in moved and chainage not in moved.values():
moved[row_id] = chainage
for row_id, chainage in moved.items():
await cursor.execute(
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
)
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
if dropped:
marks = ", ".join(["%s"] * len(dropped))
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
return len(orphans)
async def get_surface_crs_epsg(
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
) -> int | None:
+90 -18
View File
@@ -6,6 +6,7 @@
import asyncio
import json
import math
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -18,11 +19,16 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
find_cross_file,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
from B05_Profile.B05_Profile_Repository import (
get_surface_crs_epsg,
sync_cross_section_rows,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
@@ -193,12 +199,16 @@ async def sync_uphill_overrides_into_designs(
_SAME_STATION_M = 0.006
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None:
def _follow_moved_stations(
data: dict[str, Any], before: list[float], previous: list[float] | None = None
) -> dict[str, Any] | None:
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
자리(272)만 옮기고 나머지는 지운다.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
"""
stored = data.get("profile_alignment")
if not isinstance(stored, dict) or not stored.get("base_pvi"):
@@ -218,6 +228,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
),
None,
)
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
snapped = chainage_key(math.floor(old + 0.5))
if near is None and snapped in after_keys and snapped not in before_keys:
near = float(snapped)
moves[chainage_key(old)] = None if near is None else chainage_key(near)
def moved(value: float) -> tuple[bool, str | None]:
@@ -254,7 +269,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
changed = True
if new_key is not None:
base.append({**node, "chainage_m": float(new_key)})
if not changed:
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
rows = stored.get("stations")
listed = (
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
if isinstance(rows, list)
else previous
)
if not changed and listed in (None, [round(value, 6) for value in after]):
return None
stored["base_pvi"] = base
alignment, profile = rebuild_alignment_profile(data, next_edits)
@@ -270,12 +293,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
"""종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
`resources/tester/test_272_1_station_list.py`.
"""
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
@@ -290,15 +318,53 @@ def _merge_irregular_into_longitudinal(
for station in stations
if station.get("kind") == "irregular"
]
regular = [station for station in stations if station.get("kind") != "irregular"]
merged = regular + list(irregular_stations)
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
data["stations"] = merged
summary = _follow_moved_stations(data, before)
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
if station.get("kind") == "irregular" and not station.get("grid"):
continue # 옛 구조물 측점 — 새 목록이 갈음
key = round(float(station.get("chainage_m", 0.0)), 6)
if key not in fresh and ("structure" in station or station.get("grid")):
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
if station.get("kind") == "irregular":
station["kind"] = "regular"
left.append(station)
kept[key] = station
kept.update(fresh)
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
summary = _follow_moved_stations(data, before, previous)
atomic_write_json(path, data)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
return summary
async def drop_rows_off_stations(
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
) -> None:
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
path = (project_root.resolve() / longitudinal_path).resolve()
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
await sync_cross_section_rows(
connection, route_id, [float(station["chainage_m"]) for station in stations]
)
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
for station in stations:
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
if not cross_path.is_file():
continue
cross = json.loads(cross_path.read_text(encoding="utf-8"))
cross.pop("structure", None)
cross.pop("grid", None)
cross["kind"] = station.get("kind")
atomic_write_json(cross_path, cross)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json"
@@ -373,14 +439,20 @@ async def _append_irregular_cross_sections(
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
irregular_stations = await asyncio.to_thread(_generate)
if not irregular_stations:
return
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
if longitudinal:
await asyncio.to_thread(
summary = await asyncio.to_thread(
_merge_irregular_into_longitudinal,
project_root,
str(longitudinal["longitudinal_file_path"]),
irregular_stations,
)
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
await update_longitudinal_grade_summary(
connection, route_id=route["id"], grade_summary=summary
)
await drop_rows_off_stations(
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
)
+4 -7
View File
@@ -268,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
"""경로 확정 요청.
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
전체로 다시 맞춘다(빈 목록이어도 · 272).
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
"""
@@ -286,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
def can_regenerate(self) -> bool:
return bool(
self.irregular_stations
and self.filter_key
and self.method
and self.surface_model_id is not None
)
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
return bool(self.filter_key and self.method and self.surface_model_id is not None)
class RouteConfirmResponse(BaseModel):
+1 -1
View File
@@ -154,7 +154,7 @@ export function renderBasinRows(
.replace("{relief}", basin.relief_m.toFixed(1))
.replace("{flow}", String(Math.round(basin.flow_length_m)))
.replace("{pipe}", pipe);
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167)
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
row.title +=
+4 -5
View File
@@ -4,7 +4,7 @@
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
* ========================================================================== */
import { dropStationsNear } from "./B05_Profile_Util_Station";
import { oneStationList } from "../common_util/common_util_structure_station";
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
@@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) {
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
const regular = oneStationList(detail.longitudinal.stations, []);
const injected = interpolateIrregularStations(
regular,
input.irregular,
@@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) {
return best.elevation_m;
};
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
return [...dropStationsNear(regular, injected), ...injected].map((station) => {
// 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`).
return oneStationList(detail.longitudinal.stations, injected).map((station) => {
const design = designAt(station.chainage_m);
return {
...station,
+86 -39
View File
@@ -34,6 +34,12 @@ import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
import {
pipeStationMarks,
SAME_STATION_M,
structureStationMarks,
type StationMark,
} from "../common_util/common_util_structure_station";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection {
@@ -41,6 +47,9 @@ export interface PipeProjection {
effective_diameter_mm: number | null;
facility: PipeFacility;
options?: Record<string, string | number>;
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
start_m?: number | null;
end_m?: number | null;
}
export interface StructuresBridgeDeps {
@@ -58,17 +67,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
let irregularStations: IrregularStation[] = [];
function pipesToStations(
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
facility: PipeFacility;
}>,
): IrregularStation[] {
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
function stationAt(
chainage: number,
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
const interval = deps.panel().values().stationInterval || 20;
return pipes.map((pipe) => {
const station = Math.floor(pipe.chainage_m / interval);
const remainder = pipe.chainage_m - station * interval;
const station = Math.floor(chainage / interval);
return { station, remainder: chainage - station * interval, chainage_m: chainage };
}
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
marks
.filter((mark) => mark.role !== "anchor")
.map((mark) => ({
id: `${id}:${mark.role}`,
...stationAt(mark.chainage_m),
structure: label,
}));
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
return pipes.flatMap((pipe) => {
const label =
pipe.facility === "pipe"
? structureLabel({
@@ -77,15 +97,17 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
})
: FACILITY_NAMES[pipe.facility];
return {
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
station,
remainder,
chainage_m: pipe.chainage_m,
structure: label,
structureType: "배관",
origin: "pipe",
};
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
return [
{
id,
...stationAt(pipe.chainage_m),
structure: label,
structureType: "배관" as const,
origin: "pipe" as const,
},
...spanStations(id, label, pipeStationMarks(pipe)),
];
});
}
@@ -115,7 +137,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
);
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
gone.forEach((station) => {
// 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1)
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
@@ -143,29 +165,45 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
let pipeStations: IrregularStation[] = [];
let structureTypeMap = new Map<string, { group: string; name: string }>();
let structureTypeMap = new Map<
string,
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
>();
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
const interval = deps.panel().values().stationInterval || 20;
return structures
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
.map((structure) => {
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
const station = Math.floor(chainage / interval);
return {
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
station,
remainder: chainage - station * interval,
chainage_m: chainage,
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
};
});
return structures.flatMap((structure) => {
const type = structureTypeMap.get(structure.type_id);
const marks = structureStationMarks(structure, type);
const anchor = marks.find((mark) => mark.role === "anchor");
if (!anchor) return [];
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
const label = type?.name ?? structure.type_id;
return [
{ id, ...stationAt(anchor.chainage_m), structure: label },
...spanStations(id, label, marks),
];
});
}
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
* 지우지 않게) · 물넘이포장은 측점을 안 심어 견주지 않음. */
function syncCrossDrainStations(): void {
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
const planting = (station: IrregularStation) =>
station.structure !== FACILITY_NAMES.ford_pavement;
const kept: IrregularStation[] = [];
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
const taken =
station.origin !== "pipe" &&
kept.some(
(other) =>
planting(other) && Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M,
);
if (!taken) kept.push(station);
}
applyIrregularStations(kept);
}
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
@@ -326,7 +364,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
deps.profilePanel().setStructureTypes(allTypes);
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
structureTypeMap = new Map(
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]),
allTypes.map((type) => [
type.type_id,
{
type_id: type.type_id,
group: type.group,
name: type.name,
placement: type.placement,
managed_by: type.managed_by,
},
]),
);
structureRevision = stored.revision;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
+2 -40
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* B05_Profile_UI_Profile_Guard.ts
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고,
* 구조물 측점을 종단 정본 측점으로 맞추기.
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고
* (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272).
*
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
@@ -9,11 +9,6 @@
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
const STATION_SNAP_TOLERANCE_M = 0.1;
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
export function sampleAt(
@@ -64,36 +59,3 @@ export function planCurveWarning(
});
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
}
/**
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
*
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
*
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
*/
export function snapToMasterStations(
alignment: ProfileAlignment | null,
stations: IrregularStation[],
): IrregularStation[] {
const master = alignment?.stations;
if (!master?.length) return stations;
return stations.map((station) => {
let best: number | null = null;
let gap = STATION_SNAP_TOLERANCE_M;
for (const node of master) {
const distance = Math.abs(node.chainage_m - station.chainage_m);
if (distance <= gap) {
gap = distance;
best = node.chainage_m;
}
}
return best === null || best === station.chainage_m
? station
: { ...station, chainage_m: best };
});
}
+4 -10
View File
@@ -8,7 +8,7 @@
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
* ========================================================================== */
import { dropStationsNear } from "./B05_Profile_Util_Station";
import { oneStationList } from "../common_util/common_util_structure_station";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
@@ -225,18 +225,12 @@ export function renderProfile(ctx: ProfileRenderContext): void {
: (longitudinal.design_profiles ?? []);
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal);
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
// 그대로 남는다(2026-08-02 사용자 보고).
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로
// 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다.
const graphLongitudinal = {
...graphData,
stations: [...dropStationsNear(regular, injected), ...injected].sort(
(a, b) => a.chainage_m - b.chainage_m,
),
stations: oneStationList(graphData.stations, injected),
};
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
@@ -168,13 +168,13 @@ export function stationFields(
if (rest < intervalM && Number.isInteger(stationNo)) return;
const parts = chainageToStation(stationToChainage(stationNo, rest, intervalM), intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
};
// 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다.
[station, remainder].forEach((input) => input.addEventListener("change", normalize));
}
// 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
// (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
// 칸은 잔여를 정수로 보임(167) — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
// (표시값을 다시 읽으면 옛 소수 값이 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
let written: { chainageM: number; station: string; remainder: string } | null = null;
return {
wrap,
@@ -196,7 +196,7 @@ export function stationFields(
const valid =
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
markInvalid(!valid);
// 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로
// 친 값은 정수 m(반올림 · 167) — 안 고친 칸은 위에서 옛 값 그대로
return valid ? snapStructureStation(stationNo * intervalM + rest) : null;
},
write(chainageM, intervalM) {
@@ -208,7 +208,7 @@ export function stationFields(
}
const parts = chainageToStation(chainageM, intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
written = { chainageM, station: station.value, remainder: remainder.value };
},
clearInvalid: () => markInvalid(false),
+14 -8
View File
@@ -47,7 +47,11 @@ import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define";
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
import { movedStructure, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap";
import {
movedStructure,
snapOldStations,
snapStructureStation,
} from "./B05_Profile_UI_Structures_Snap";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
@@ -542,13 +546,15 @@ export function createStructuresSection(
return {
root,
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
listRoot: snapDock(list, {
structures,
pipes: () => pipeFacilities,
emit,
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
closeForm: () => resetForm(),
}),
listRoot: list,
snapOld: () =>
snapOldStations({
structures,
pipes: () => pipeFacilities,
emit,
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
closeForm: () => resetForm(),
}),
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1)
setTypes(next, pick = () => true) {
@@ -22,10 +22,7 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures
import { asRoad } from "./B05_Profile_UI_Structures_Define";
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import {
isSnappedStation,
snapStructureStation,
} from "../common_util/common_util_structure_station";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import {
readGroupItems,
readSummaryItem,
@@ -309,11 +306,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
beforeInput?.focus();
return;
}
// 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫)
const fit = (value: number): number =>
isSnappedStation(anchor as number) ? snapStructureStation(value) : value;
start = fit(Math.max(anchor - before, 0));
end = Math.max(fit(start + length), start + Math.min(length, 1));
// 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167)
start = snapStructureStation(Math.max(anchor - before, 0));
end = Math.max(snapStructureStation(start + length), start + 1);
} else if (placement === "interval") {
start = ctx.startFields.read(step, true);
end = ctx.endFields.read(step, true);
@@ -154,6 +154,8 @@ export interface StructuresSection {
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
listRoot: HTMLElement;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(종단 보기를 열 때 한 번 · 167) — 옮긴 수. */
snapOld: () => number;
/** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고,
* 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */
setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void;
@@ -63,8 +63,10 @@ export async function splitPavementRange(
const segments: Span[] = [];
let cursor = start;
for (const ford of overlaps) {
if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start });
cursor = ford.end;
// 경계도 정수 m(167) — 물넘이를 안 덮게 앞 구간 끝은 내림 · 뒤 구간 시작은 올림
const before = Math.floor(ford.start + EPS);
if (before - cursor > EPS) segments.push({ start: cursor, end: before });
cursor = Math.ceil(ford.end - EPS);
}
if (end - cursor > EPS) segments.push({ start: cursor, end });
+31 -56
View File
@@ -1,14 +1,14 @@
/* =============================================================================
* B05_Profile_UI_Structures_Snap.ts
* 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
* 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌).
* 구조물 기준 측점 정수 맞춤(234-1 · 272) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
* 옛 소수 자료를 정수 m 로 옮기는 자동 맞춤(종단 보기를 열 때 한 번 · 단추 없음 · 요구 167).
*
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤
* 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집
* 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다.
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 구조물은 한 번에, 관은 하나씩
* 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 따라가기(213-2) · 시설 속성 이어받기가
* 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다(알림 한 줄).
* ========================================================================== */
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import {
isSnappedStation,
snapStructureStation,
@@ -26,8 +26,7 @@ export function movedStructure(target: StructureInstance, toChainageM: number):
if (target.placement !== "interval") return { ...target, chainage_m: anchor };
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0)));
const snappedEnd = snapStructureStation(start + length);
const end = snappedEnd > start ? snappedEnd : start + length;
const end = Math.max(snapStructureStation(start + length), start + 1); // 끝도 정수 · 1 m 이상(167)
return {
...target,
chainage_m: Math.min(Math.max(anchor, start), end),
@@ -55,7 +54,8 @@ export const structureOffGrid = (structure: StructureInstance): boolean =>
export interface SnapPlan {
/** 목록 자리 · 옮긴 모양. */
structures: Array<{ index: number; next: StructureInstance }>;
pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>;
/** 옛 자리 → 새 자리 · 시작 · 끝도 맞춘 속성(독립 기슭막이 구간). */
pipes: Array<{ from: number; to: number; attributes: FacilityAttributes }>;
/** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */
skipped: number;
}
@@ -73,16 +73,20 @@ export function snapPlan(
const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m);
const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 };
[...pipes]
.filter((pipe) => offGrid(pipe.chainage_m))
.filter((pipe) => offGrid(pipe.chainage_m) || offGrid(pipe.start_m) || offGrid(pipe.end_m))
.sort((left, right) => left.chainage_m - right.chainage_m)
.forEach((pipe) => {
const to = snapStructureStation(pipe.chainage_m);
if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
if (to !== pipe.chainage_m && taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
plan.skipped += 1;
return;
}
taken.push(to);
plan.pipes.push({ from: pipe.chainage_m, to, pipe });
const attributes = pipeAttributes(pipe);
if (attributes.start_m !== undefined)
attributes.start_m = snapStructureStation(attributes.start_m);
if (attributes.end_m !== undefined) attributes.end_m = snapStructureStation(attributes.end_m);
plan.pipes.push({ from: pipe.chainage_m, to, attributes });
});
return plan;
}
@@ -97,7 +101,7 @@ export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({
group_items: pipe.group_items ?? null,
});
export interface SnapDockContext {
export interface SnapContext {
/** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */
structures: StructureInstance[];
pipes: () => ReadonlyArray<PipeFacilityItem>;
@@ -109,52 +113,23 @@ export interface SnapDockContext {
closeForm: () => void;
}
/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */
export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-route__irregular-btn b05-structure__snap";
button.textContent = "정수 맞춤";
button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점";
button.addEventListener("click", () => void runSnap(ctx));
const head = document.createElement("div");
head.className = "b05-structure__snap-row";
head.append(button);
const dock = document.createElement("div");
dock.className = "b05-structure__list-dock";
dock.append(head, list);
return dock;
}
export async function runSnap(ctx: SnapDockContext): Promise<number> {
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(요구 167 · 확인 창 없이) — 옮긴 수. */
export function snapOldStations(ctx: SnapContext): number {
const plan = snapPlan(ctx.structures, ctx.pipes());
const count = plan.structures.length + plan.pipes.length;
if (!count) {
if (count) {
ctx.closeForm();
if (plan.structures.length) {
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
ctx.emit();
}
plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes));
}
if (count || plan.skipped)
showToast(
plan.skipped
? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.`
: "소수 기준 측점이 없습니다.",
"info",
(count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") +
(plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""),
plan.skipped ? "info" : "success",
);
return 0;
}
const ok = await showConfirmDialog(
`소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` +
"정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.",
"정수 맞춤",
);
if (!ok) return 0;
ctx.closeForm();
if (plan.structures.length) {
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
ctx.emit();
}
plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe)));
showToast(
`정수 맞춤 ${count}건` +
(plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") +
" — [저장]을 눌러 남기세요.",
"success",
);
return count;
}
@@ -650,15 +650,3 @@
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
color: var(--color-text-secondary);
}
/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */
.b05-structure__snap-row {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-4);
}
.b05-structure__snap {
flex: 0 0 auto;
padding: var(--spacing-4) var(--spacing-8);
}
+2
View File
@@ -118,6 +118,8 @@ export interface SectionStation {
kind: "bp" | "ep" | "regular" | "irregular";
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
structure?: string;
/** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */
grid?: boolean;
center_z: number | null;
azimuth_deg: number | null;
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
+17 -13
View File
@@ -89,20 +89,20 @@ def _rowless_station_chainages(
return result
async def _stand_patch_stations(
project_id: UUID, request: SectionConfirmRequest | None, known: list[float]
) -> None:
"""행 없는 측점의 횡단 수정 — 버리지 않고 그 측점부터 세운다(B05·B06 한 페이지처럼).
async def _stand_patch_stations(project_id: UUID) -> None:
"""저장 · 확정 앞에 구조물 측점을 지금 관 · 구조물 목록으로 맞춘다(B05·B06 한 페이지처럼).
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없다. 여기서
[측점 만들기]와 같은 조건으로 세우면 아래 `_rowless_station_chainages` 가 행을 만들고
수정이 그 행에 얹힌다.
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없고, 옛 자리 측점은
남아 있다 — 행 없는 측점의 수정이 버려지고 옛 측점이 카드 · 수량에 남음. [측점 만들기]와 같은
조건으로 세우고 낡은 것을 지우면(맞출 것이 없으면 그냥 돌아옴 · 272) 아래
`_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다.
"""
patches = (request.cross_patches or []) if request else []
if any(all(abs(p.chainage_m - k) >= 0.01 for k in known) for p in patches):
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
try:
await ensure_structure_stations(project_id)
except Exception: # 맞춤이 안 돼도 저장은 진행 — 옛 측점이 남을 뿐(다음 저장에서 다시)
logger.exception("B06 저장: 구조물 측점 맞춤 실패(저장은 진행): project_id=%s", project_id)
async def _apply_section_edits(
@@ -230,6 +230,9 @@ async def save_sections(
if stale:
return stale
stored_path = await get_project_storage_relative_path(connection, project_id)
# 측점부터 맞춤 — 아래 행 조회가 맞춘(옮기고 지운) 뒤 행을 봄(272)
await _stand_patch_stations(project_id)
async with pool.acquire() as connection:
known = await get_cross_section_chainages(connection, route_id)
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
# 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만
@@ -238,7 +241,6 @@ async def save_sections(
# ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다.
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
await _stand_patch_stations(project_id, request, known)
project_root = Path(resolve_stored_project_path(stored_path))
rowless = missing + await asyncio.to_thread(
_rowless_station_chainages,
@@ -311,10 +313,12 @@ async def confirm_sections(
if stale:
return stale
stored_path = await get_project_storage_relative_path(connection, project_id)
await _stand_patch_stations(
project_id
) # 측점부터 맞춤 — 아래 행 조회가 맞춘 뒤 행을 봄(272)
async with pool.acquire() as connection:
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
known = await get_cross_section_chainages(connection, route_id)
await _stand_patch_stations(project_id, request, known)
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
project_root = Path(resolve_stored_project_path(stored_path))
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
+57 -22
View File
@@ -83,8 +83,8 @@ async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str
}
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]:
"""관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
@@ -92,23 +92,28 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A
)
polyline = _load_route_polyline(project_root, route_data_path)
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
if not extras:
return []
existing = _station_chainages(project_root)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
# 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄)
end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:]))
missing = []
for chainage, label in extras:
value = float(chainage)
if value < -STATION_MATCH_TOLERANCE_M or (
end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M
):
continue
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
continue
missing.append({"chainage_m": round(value, 3), "label": label})
return [
(float(chainage), label)
for chainage, label in extras
if float(chainage) >= -STATION_MATCH_TOLERANCE_M
and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M)
]
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
planted = _planted(project_root, route_data_path)
if not planted:
return []
existing = _station_chainages(project_root)
missing = [
{"chainage_m": round(value, 3), "label": label}
for value, label in planted
if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing)
]
return sorted(missing, key=lambda item: item["chainage_m"])
@@ -130,6 +135,24 @@ def _station_chainages(project_root: Path) -> list[float]:
return values
def _stale_marks(project_root: Path, route_data_path: str) -> int:
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
import json
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
standing: set[float] = set()
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
if station.get("kind") == "irregular" or station.get("structure"):
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
return len(standing ^ planted)
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
@@ -158,6 +181,7 @@ async def _regenerate_structure_stations(
from B05_Profile.B05_Profile_Router_Confirm import (
_merge_irregular_into_longitudinal,
_section_options_from_stored,
drop_rows_off_stations,
)
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
@@ -189,13 +213,20 @@ async def _regenerate_structure_stations(
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
summary = None
if stations and longitudinal:
if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272)
summary = _merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations), summary
made, summary = await asyncio.to_thread(_regenerate)
if longitudinal:
await run_with_connection(
drop_rows_off_stations,
route["id"],
project_root,
str(longitudinal["longitudinal_file_path"]),
)
if summary is not None:
# 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길).
await run_with_connection(
@@ -207,7 +238,7 @@ async def _regenerate_structure_stations(
async def ensure_structure_stations(project_id: UUID) -> int:
"""빠진 구조물 측점을 세운다 — 횡단 저장이 **행 없는 측점의 수정**을 받을 때 부름.
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
@@ -218,14 +249,18 @@ async def ensure_structure_stations(project_id: UUID) -> int:
return 0
project_root, route = paths
missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"]))
if not missing:
# 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272)
stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"]))
if not missing and not stale:
return 0
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
logger.warning("B06 저장: 빠진 구조물 측점 %d곳 — 샘플링 조건이 없어 못 세움", len(missing))
logger.warning(
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
)
return 0
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
return len(missing)
return len(missing) or stale
@router.get("/{project_id}/section/missing-stations")
+61 -1
View File
@@ -18,7 +18,9 @@
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
@@ -39,7 +41,11 @@ import {
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import { setGeneralStations } from "../common_util/common_util_structure_station";
import {
plantedChainages,
SAME_STATION_M,
setGeneralStations,
} from "../common_util/common_util_structure_station";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -65,6 +71,7 @@ async function withDraftWalls(
projectId: string,
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
await withDraftStations(detail, projectId);
const drafts = readPendingStructures(projectId);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
@@ -101,6 +108,58 @@ async function withDraftWalls(
return withStructureAreas(detail);
}
/** 저장된 관 지점 — 구조물 초안만 있을 때 심는 자리 셈에 씀. 저장 뒤(`invalidateSectionDetail`)
* 다시 받음 · 고칠 때마다 서버에 안 물음. */
const savedPipes = new Map<
string,
Promise<Awaited<ReturnType<typeof fetchDetailPipePoints>>["pipe_points"]>
>();
function savedPipesOf(projectId: string) {
let hit = savedPipes.get(projectId);
if (!hit) {
hit = fetchDetailPipePoints(projectId).then((res) => res.pipe_points);
hit.catch(() => savedPipes.delete(projectId));
savedPipes.set(projectId, hit);
}
return hit;
}
/** 초안이 빼 둔 옛 자리 측점 — 초안이 다시 그 자리를 심거나 초안이 없어지면 돌려 놓음. */
const parkedSections = new WeakMap<
SectionDetailResponse,
SectionDetailResponse["cross_sections"]
>();
/**
* 옮긴 관 · 구조물의 옛 자리 측점(서버가 다시 맞추기 전)을 횡단 카드 · 유토곡선에서 뺀다 — 초안이
* 심는 자리(`plantedChainages` · 서버와 같은 규칙)에 없는 서버 구조물 측점(272 · 측점 한 벌). 초안이
* 없으면 서버 목록이 지금 것이라 다 돌려 놓음. [저장] 뒤는 서버가 맞춘 새 상세라 뺄 것이 없음.
*/
async function withDraftStations(detail: SectionDetailResponse, projectId: string): Promise<void> {
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
let planted: number[] | null = null;
if (pipesDraft || structuresDraft) {
const [pipes, structures, types] = await Promise.all([
pipesDraft ?? savedPipesOf(projectId),
structuresDraft ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true),
]).catch(() => [null, null, null] as const);
if (pipes && structures && types) planted = plantedChainages(pipes, structures, types);
}
const all = [...detail.cross_sections, ...(parkedSections.get(detail) ?? [])].sort(
(a, b) => a.chainage_m - b.chainage_m,
);
const stale = (section: (typeof all)[number]): boolean =>
planted !== null &&
section.kind === "irregular" &&
!section.grid &&
planted.every((mark) => Math.abs(mark - section.chainage_m) >= SAME_STATION_M);
detail.cross_sections.splice(0, detail.cross_sections.length, ...all.filter((s) => !stale(s)));
parkedSections.set(detail, all.filter(stale));
}
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
@@ -224,6 +283,7 @@ export function replaceSectionDetail(
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
*/
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
savedPipes.delete(projectId);
if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId));
clearState("section-detail", projectId, routeId);
+11 -22
View File
@@ -1,8 +1,12 @@
"""구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1).
"""구조물 · 관 기준 측점 = 정수 m(반올림 · .5 는 올림) · 노선 끝 안쪽(요구 167 · 272).
TS 짝 = `common_util/common_util_structure_station.ts`(화면 입력 · 끌기 · 방향키 · 정수 맞춤 단추).
측점 값(측점 + 거리 · 누가거리)에 소수를 안 씀 — 일반 측점(격자)과 같은 m 면 서버 측점
만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`).
234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167).
TS 짝 = `common_util/common_util_structure_station.ts`
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김).
거울 시험 = `resources/tester/test_234_1_structure_station.py`.
일반 측점 = 격자(간격) · 시종점 — 서버 자동 배치 · 노선 재투영이 쓴다.
일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀.
"""
from __future__ import annotations
@@ -17,8 +21,6 @@ from config.config_system_design import SECTION_STATION_INTERVAL_M
_Candidate = TypeVar("_Candidate")
# 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도).
GENERAL_SNAP_M = 1.0
_EPS = 1e-6
@@ -32,27 +34,14 @@ def general_stations(total_m: float, interval_m: float) -> list[float]:
return values
def _near_general(value: float, stations: Sequence[float]) -> float | None:
best: float | None = None
for station in stations:
gap = abs(station - value)
if gap < GENERAL_SNAP_M - _EPS and (best is None or gap < abs(best - value)):
best = float(station)
return best
def snap_structure_station(chainage_m: float, stations: Sequence[float]) -> float:
"""기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반
측점 1 m 안이면 그 측점. 두 번 맞춰도 같다."""
"""기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안 · 멱등."""
value = float(chainage_m)
if not math.isfinite(value):
return value
direct = _near_general(value, stations)
if direct is not None:
return direct
end = max(stations) if stations else math.inf
whole = float(math.floor(value + 0.5))
near = _near_general(whole, stations)
return whole if near is None else near
return max(0.0, min(whole, float(math.floor(end + _EPS))))
def route_general_stations(total_m: float) -> list[float]:
@@ -71,7 +60,7 @@ def snap_candidates(
snapped: list[_Candidate] = []
taken: set[float] = set()
for candidate in candidates:
at = min(snap_structure_station(candidate.chainage_m, stations), total_m)
at = snap_structure_station(candidate.chainage_m, stations)
if at in taken:
continue
taken.add(at)
+158 -23
View File
@@ -1,16 +1,26 @@
/* =============================================================================
* common_util/common_util_structure_station.ts
* 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1).
* 구조물 · 관 기준 측점 = 정수 m(반올림 · .5 는 올림) · 노선 끝 안쪽(요구 167 · 272).
*
* 측점 값(측점 + 거리 · 누가거리)에 소수를 안 씀 — 일반 측점(격자)과 같은 m 면 서버 측점 만들기가
* 한 측점으로 합침(구조물 측점이 위). 234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167).
* 파이썬 짝 = `common_util/common_util_structure_station.py`(서버 자동 배치 · 노선 재투영).
* 거울 시험 = `resources/tester/test_234_1_structure_station.py`.
* 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 상세를 읽을 때 한 번 둔다.
* 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 끝(노선 길이)을 넘지 않게 자르는 데만 ·
* 상세를 읽을 때 한 번 둔다.
*
* 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations`
* (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버
* `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 =
* `resources/tester/test_272_1_station_list.py`. 종단 그래프 · 3D · 계획선 측점 줄(표 · ▲▼)이
* 이 목록 하나를 씀.
* ========================================================================== */
/** 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). */
export const GENERAL_SNAP_M = 1;
const EPS = 1e-6;
/** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */
export const SAME_STATION_M = 0.005;
let general: number[] = [];
/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */
@@ -24,46 +34,171 @@ export function setGeneralStations(
export const generalStations = (): readonly number[] => general;
function nearGeneral(value: number, stations: readonly number[]): number | null {
let best: number | null = null;
for (const station of stations) {
const gap = Math.abs(station - value);
if (gap < GENERAL_SNAP_M - EPS && (best === null || gap < Math.abs(best - value)))
best = station;
}
return best;
}
/** 기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 측점
* 1 m 안이면 그 측점(끝 119.8 옆 119 → 119.8). 두 번 맞춰도 같다. */
/** 기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안. 두 번 맞춰도 같다. */
export function snapStructureStation(
chainageM: number,
stations: readonly number[] = general,
): number {
if (!Number.isFinite(chainageM)) return chainageM;
const direct = nearGeneral(chainageM, stations);
if (direct !== null) return direct;
const end = stations.length ? Math.max(...stations) : Infinity;
const whole = Math.floor(chainageM + 0.5);
return nearGeneral(whole, stations) ?? whole;
return Math.max(0, Math.min(whole, Math.floor(end + EPS)));
}
/** 방향키 한 걸음(1 m) — 붙기가 제자리로 되돌리면 한 걸음 더(끝 119.8 → 118). 노선 밖이면 제자리. */
/** 방향키 한 걸음(1 m) — 맞춘 값이 제자리면 한 걸음 더 · 노선 밖이면 제자리. */
export function stepStructureStation(
fromM: number,
direction: 1 | -1,
stations: readonly number[] = general,
): number {
const end = stations.length ? Math.max(...stations) : Infinity;
for (let steps = 1; steps <= 3; steps += 1) {
const next = snapStructureStation(fromM + direction * steps, stations);
if (next < 0 || next > end + EPS) return fromM;
if ((next - fromM) * direction > EPS) return next;
}
return fromM;
}
/** 이미 맞춘 값인가(정수 맞춤 단추가 옮길 것을 가름). */
/** 이미 정수 m 인가(옛 소수 자료를 옮길 것을 가름). */
export const isSnappedStation = (
chainageM: number,
stations: readonly number[] = general,
): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS;
/* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */
export interface PlantingPipe {
chainage_m: number;
facility?: string | null;
start_m?: number | null;
end_m?: number | null;
}
export interface PlantingStructure {
type_id: string;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
}
export interface PlantingType {
type_id: string;
group: string;
placement: string;
managed_by?: string | null;
}
/** 측점 하나 — 구간형(독립 기슭막이 · C · D군)은 시작 · 기준 · 끝 셋, 그 밖은 기준 하나. */
export interface StationMark {
chainage_m: number;
role: "start" | "anchor" | "end";
}
function marksOf(
start: number | null | undefined,
anchor: number | null | undefined,
end: number | null | undefined,
interval: boolean,
): StationMark[] {
const marks: Array<[StationMark["role"], number | null | undefined]> = interval
? [
["start", start],
["anchor", anchor],
["end", end],
]
: [["anchor", anchor]];
return marks.flatMap(([role, at]) =>
typeof at === "number" && Number.isFinite(at) ? [{ chainage_m: at, role }] : [],
);
}
/** 관 시설 하나가 심는 측점 — 물넘이포장 없음 · 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖 = 기준. */
export function pipeStationMarks(pipe: PlantingPipe): StationMark[] {
const facility = pipe.facility ?? "pipe";
if (facility === "ford_pavement") return [];
return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, facility === "revetment");
}
/** 구조물 하나가 심는 측점 — A군 = 기준 · C · D군 구간형 = 시작 · 기준 · 끝 · 다른 정본 소관 · 그 밖 = 없음. */
export function structureStationMarks(
structure: PlantingStructure,
type: PlantingType | undefined,
): StationMark[] {
if (!type || type.managed_by) return [];
const interval = type.placement === "interval";
if (type.group !== "A" && !(interval && (type.group === "C" || type.group === "D"))) return [];
const anchor = structure.chainage_m ?? structure.start_m;
return marksOf(structure.start_m, anchor, structure.end_m, interval);
}
/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */
export function plantedChainages(
pipes: ReadonlyArray<PlantingPipe>,
structures: ReadonlyArray<PlantingStructure>,
types: ReadonlyArray<PlantingType>,
): number[] {
const byId = new Map(types.map((type) => [type.type_id, type]));
return [
...pipes.flatMap(pipeStationMarks),
...structures.flatMap((structure) =>
structureStationMarks(structure, byId.get(structure.type_id)),
),
]
.map((mark) => mark.chainage_m)
.sort((a, b) => a - b);
}
/* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */
export interface ListedStation {
chainage_m: number;
kind?: string;
/** 구조물 측점이 얹힌 격자 자리 — 구조물이 떠나면 일반 측점으로 돌아옴(서버 표시). */
grid?: boolean;
structure?: string;
}
/**
* 측점 한 벌 = 서버 측점 중 일반 측점(격자 · 시종점 · 구조물이 떠난 격자 자리는 글 떼고 일반으로) +
* 지금 심긴 구조물 측점(`planted`). 같은 m 면 구조물 측점 하나(일반은 숨김) · 서버의 구조물 측점은
* 지금 목록이 갈음(옮긴 관의 옛 자리가 안 남음).
*/
export function oneStationList<S extends ListedStation, P extends { chainage_m: number }>(
server: ReadonlyArray<S>,
planted: ReadonlyArray<P>,
): Array<S | P> {
const general = server
.filter((station) => station.kind !== "irregular" || station.grid)
.filter((station) =>
planted.every((mark) => Math.abs(mark.chainage_m - station.chainage_m) >= SAME_STATION_M),
)
.map((station) => {
if (!station.grid && station.structure === undefined) return station;
const { grid: _grid, structure: _structure, ...rest } = station;
return { ...rest, kind: station.kind === "irregular" ? "regular" : station.kind } as S;
});
return [...general, ...planted].sort((a, b) => a.chainage_m - b.chainage_m);
}
/**
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지운다 — 서버
* `_follow_moved_stations`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 · 옛 소수 측점의
* 정수 m 맞춤 자리(272)면 높이 그대로 옮기고, 나머지는 지움(측점 없는 꼭짓점이 계획선을 꺾지 않게).
*/
export function followMovedNodes<N extends { chainage_m: number }>(
nodes: ReadonlyArray<N>,
gone: ReadonlyArray<number>,
born: ReadonlyArray<number>,
): N[] {
const target = (old: number): number | null => {
const near = born.find((value) => Math.abs(value - old) < 0.006);
if (near !== undefined) return near;
const snapped = Math.floor(old + 0.5);
return born.some((value) => Math.abs(value - snapped) < EPS) ? snapped : null;
};
return nodes.flatMap((node) => {
const old = gone.find((value) => Math.abs(value - node.chainage_m) < 0.006);
if (old === undefined) return [node];
const to = target(old);
return to === null ? [] : [{ ...node, chainage_m: to }];
});
}
@@ -1,4 +1,4 @@
/* 234-1 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤].
/* 234-1 · 272 구조물 기준 측점 = 정수 m · 옛 소수 자동 맞춤(단추 없음 · 167).
* 진짜 `common_util_structure_station` · `_Structures_Snap` · `_Structures_Fields` · `_Drainage_Pipes` ·
* `followedStation` 을 Node 로 돌림. 입력은 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌.
*/
@@ -93,7 +93,7 @@ out.typedOne = anchor.read(20, true);
anchor.write(80.17, 20);
out.untouched = anchor.read(20, true);
// ③ 구조물 옮김 · [정수 맞춤] 셈 · 단추
// ③ 구조물 옮김 · 옛 소수 자동 맞춤 셈 · 실행
const toasts = [];
let confirmText = null;
const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", {
@@ -134,24 +134,25 @@ out.plan = {
const moves = [];
let emits = 0;
let closed = 0;
snap
.runSnap({
Promise.resolve(
snap.snapOldStations({
structures,
pipes: () => pipes,
emit: () => (emits += 1),
movePipe: (from, to, attributes) => moves.push({ from, to, attributes }),
closeForm: () => (closed += 1),
})
}),
)
.then(async (count) => {
out.run = { count, emits, closed, moves, structures, confirmText, toasts: [...toasts] };
// 두 번째 누름 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤)
// 두 번째 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤)
moves.forEach(({ from, to }) => {
const hit = pipes.find((pipe) => pipe.chainage_m === from);
if (hit) hit.chainage_m = to;
});
toasts.length = 0;
const again = { structures, pipes: () => pipes, emit() {}, movePipe() {}, closeForm() {} };
out.second = { count: await snap.runSnap(again), toasts: [...toasts] };
out.second = { count: snap.snapOldStations(again), toasts: [...toasts] };
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts");
@@ -0,0 +1,29 @@
/* 272-1 측점 한 벌 — 진짜 `common_util_structure_station.ts` 의 `plantedChainages` · `oneStationList` 를
* Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
throw new Error(`stub 없음: ${request} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const station = loadTs("common_util/common_util_structure_station.ts");
const out = {
planted: station.plantedChainages(input.pipes, input.structures, input.types),
list: station.oneStationList(input.server, input.fresh),
nodes: station.followMovedNodes(input.nodes, input.gone, input.born),
};
process.stdout.write(JSON.stringify(out));
@@ -66,13 +66,13 @@ const INTERVAL = 20;
const out = {};
const fields = stationFields("기준", () => INTERVAL);
// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3.4)
// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3 · 칸도 정수 m 167)
fields.write(123.37, INTERVAL);
out.shown = [fields.station.value, fields.remainder.value];
out.untouched = fields.read(INTERVAL, true);
fields.write(123.3721, INTERVAL);
out.untouchedRaw = fields.read(INTERVAL, true);
// 사용자가 놓은 119.8 — 다시 열면 5 + 19.8 로 보이고 그대로 읽힘
// 옛 소수 119.8 — 다시 열면 5 + 20 으로 보이고 그대로 읽힘
fields.write(119.8, INTERVAL);
out.r119 = [fields.station.value, fields.remainder.value, fields.read(INTERVAL, true)];
fields.write(123.3721, INTERVAL);
@@ -82,7 +82,7 @@ fields.remainder.fire("change");
out.edited = fields.read(INTERVAL, true);
// 같은 글로 되돌려 치면 — 여전히 실어 둔 값(칸 글이 쓴 그대로)
fields.write(123.37, INTERVAL);
fields.remainder.value = "3.4";
fields.remainder.value = "3";
out.retypedSame = fields.read(INTERVAL, true);
// 넘침 정규화(1 + 30 → 2 + 10)는 친 값으로
fields.station.value = "1";
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
"""234-1 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추.
"""234-1 · 272-1 구조물 · 관 기준 측점 = 정수 m(반올림) · 노선 끝 안쪽.
요구 167 — 측점 값에 소수를 안 씀 · 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수).
9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈
(사용자 확정 10-10 · 조사 AF 7절 A+B+C).
@@ -33,11 +35,11 @@ from common_util.common_util_structure_station import ( # noqa: E402
TOTAL = 279.8
STATIONS = general_stations(TOTAL, 20.0)
# (입력, 기대) — 소수 → 정수 · 일반 측점 1 m 안 붙기 · 끝 측점(소수) 붙기
# (입력, 기대) — 소수 → 정수(반올림) · 끝(소수 279.8)은 넘지 않음
CASES = [
(80.17, 80.0), # 관 80.17 = 카드 80 한 자리
(80.6, 80.0), # 1 m 안 → 일반 측점
(80.99, 80.0),
(80.6, 81.0), # 그냥 반올림(일반 측점 붙기 없음 · 167)
(80.99, 81.0),
(81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음)
(50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴)
(50.4, 50.0),
@@ -45,15 +47,16 @@ CASES = [
(263.206, 263.0),
(127.86, 128.0),
(0.3, 0.0),
(279.3, 279.8), # 끝 측점 1 m 안 → 끝 측점(정수가 아닌 일반 측점)
(278.6, 279.8), # 반올림 279 가 끝 측점 1 m 안
(279.3, 279.0), # 끝 279.8 안쪽 정수
(279.7, 279.0), # 반올림 280 은 끝 밖 → 279
(278.6, 279.0),
(277.4, 277.0),
]
STEPS = [
((80.0, 1), 81.0),
((80.17, 1), 81.0),
((80.17, -1), 80.0), # 79.17 은 일반 측점 80 의 1 m 안
((279.8, -1), 278.0), # 279 는 끝 측점에 붙어 제자리 → 한 걸음 더
((80.17, -1), 79.0),
((279.8, -1), 279.0), # 옛 끝 소수 → 안쪽 정수
((279.8, 1), 279.8), # 노선 밖은 제자리
((0.0, -1), 0.0),
]
@@ -132,9 +135,9 @@ def test_general_registry_skips_irregular(ts) -> None:
def test_form_input_integer(ts) -> None:
assert ts["remainderStep"] == "1"
assert ts["typed"] == 80 # 입력 4+0.17 = 80.17 → 80
assert ts["typedNear"] == 80 # 4+0.6 → 일반 측점 80 에 붙음
assert ts["typedNear"] == 81 # 4+0.6 → 반올림 81(붙기 없음 · 167)
assert ts["typedOne"] == 81
assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(단추 몫)
assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(열 때 자동 맞춤 몫 · 272-4)
def test_moved_structure_range_ends_integer(ts) -> None:
@@ -145,7 +148,8 @@ def test_moved_structure_range_ends_integer(ts) -> None:
assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145)
def test_snap_button_plan_and_run(ts) -> None:
def test_old_station_auto_snap_plan_and_run(ts) -> None:
"""272-4 — 단추 없이 종단 보기를 열 때 한 번 · 확인 창 없음 · 알림 한 줄."""
plan = ts["plan"]
assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3]
assert plan["pipes"] == [[80.17, 80], [263.31, 263]]
@@ -158,12 +162,12 @@ def test_snap_button_plan_and_run(ts) -> None:
assert [(m["from"], m["to"]) for m in run["moves"]] == [(80.17, 80), (263.31, 263)]
assert run["moves"][0]["attributes"]["options"] == {"pipe_kind": "흄관"}
assert run["moves"][0]["attributes"]["facility"] == "pipe"
assert "5건" in run["confirmText"]
assert run["toasts"][-1]["message"].startswith("정수 맞춤 5건")
assert run["confirmText"] is None # 확인 창 없음
assert run["toasts"][-1]["message"].startswith("옛 소수 측점 5건을 정수 m 로 옮겼습니다")
assert "1건은 그대로" in run["toasts"][-1]["message"]
# 두 번째 누름 — 옮길 것 없음(겹친 관만 알림)
# 두 번째 — 옮길 것 없음(겹친 관만 알림)
assert ts["second"]["count"] == 0
assert "1건은 맞출 자리에 다른 관" in ts["second"]["toasts"][0]["message"]
assert "맞출 자리에 다른 관이 있는 관 1건" in ts["second"]["toasts"][0]["message"]
def test_plan_edit_follows_batch_move(ts) -> None:
@@ -172,7 +176,7 @@ def test_plan_edit_follows_batch_move(ts) -> None:
def test_drainage_map_pipe_integer(ts) -> None:
assert ts["editor"] == [141, 200] # 옮김 141.37 → 141 · 추가 200.6 → 일반 측점 200
assert ts["editor"] == [141, 201] # 옮김 141.37 → 141 · 추가 200.6 → 201
def _vertices() -> list[RouteVertex]:
@@ -215,7 +219,8 @@ def test_wiring_source() -> None:
assert "movedStructure(structures[index], toChainageM)" in read(
"B05_Profile/B05_Profile_UI_Structures_Panel.ts"
)
assert "snapDock(list" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts")
assert "snapOldStations({" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts")
assert "panel.structures.snapOld()" in read("A00_Common/design/design_view_profile.ts")
assert "stepStructureStation(fromM" in read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
b06 = read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts")
assert "const to = snapStructureStation(toChainageM)" in b06
@@ -93,6 +93,7 @@ def _patch(monkeypatch, root: Path) -> None:
"get_longitudinal_section",
lambda *_a: _value({"longitudinal_file_path": LONG}),
)
monkeypatch.setattr(confirm, "sync_cross_section_rows", lambda *_a: _value(0))
def _plant_like_b06(root: Path) -> None:
+185
View File
@@ -0,0 +1,185 @@
"""272-1 측점 한 벌 — 화면(TS)과 서버(파이썬)가 같은 측점 목록을 냄(요구 167 · 측점 겹침 근본).
- 심는 자리: TS `plantedChainages` ↔ 서버 `resolve_extra_stations`(관 · 독립 기슭막이 시작 ·
기준 · 끝 · A군 · C군 구간형 시작 · 기준 · 끝 · 물넘이포장 · 다른 군 · 점형 C/D 는 안 심음).
- 같은 m = 측점 하나 · 구조물이 위 · 떠나면 일반 측점: TS `oneStationList` ↔ 서버
`_merge_irregular_into_longitudinal`.
진짜 TS 는 `helper_272_1_station_list.cjs` 가 Node 로 돌림.
"""
from __future__ import annotations
import json
import math
import subprocess
import sys
from pathlib import Path
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.B05_Profile_Engine_Sections import resolve_extra_stations # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
PIPES = [
{"chainage_m": 95.0, "source": "user"},
{"chainage_m": 160.0, "source": "user", "facility": "box_culvert"},
{"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"},
{"chainage_m": 300.0, "source": "user", "facility": "ford_bridge"},
{
"chainage_m": 400.0,
"source": "user",
"facility": "revetment",
"start_m": 395.0,
"end_m": 410.0,
},
]
STRUCTURES = [
{"type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 120.0},
{
"type_id": "retaining_wall",
"placement": "interval",
"chainage_m": 510.0,
"start_m": 500.0,
"end_m": 530.0,
},
{ # 옹벽 끝 530 과 같은 자리에서 시작 — 겹친 자리도 그대로 셈(합치기는 측점 만들기 몫)
"type_id": "berm",
"placement": "interval",
"chainage_m": 545.0,
"start_m": 530.0,
"end_m": 560.0,
},
{"type_id": "erosion_check", "placement": "point", "chainage_m": 600.0}, # D군 점형
{
"type_id": "pavement_concrete",
"placement": "interval",
"chainage_m": 650.0,
"start_m": 650.0,
"end_m": 700.0,
},
{"type_id": "evac_road", "placement": "point", "chainage_m": 720.0},
]
EXPECTED = [95, 120, 160, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560]
# 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) ·
# 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35
SERVER = [
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 20.0, "kind": "regular"},
{"chainage_m": 40.0, "kind": "irregular", "grid": True, "structure": "옹벽"},
{"chainage_m": 60.0, "kind": "regular", "structure": "옛 글"},
{"chainage_m": 80.0, "kind": "regular"},
{"chainage_m": 95.38, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 100.0, "kind": "regular"},
]
FRESH = [
{"chainage_m": 35.0, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": "BOX암거"},
]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def _types() -> list[dict]:
return [
{
"type_id": item.type_id,
"group": item.group,
"placement": item.placement,
"managed_by": item.managed_by,
}
for item in structure_type_map().values()
]
FIXTURE = ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
def _snapped(data: dict) -> list[dict]:
"""옛 소수 구조물 측점을 정수 m 로 옮긴 새 목록(272-4 자동 맞춤 뒤)."""
return [
{**s, "chainage_m": float(math.floor(s["chainage_m"] + 0.5))}
for s in data["stations"]
if s.get("kind") == "irregular"
]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s272") / "in.json"
fixture = json.loads(FIXTURE.read_text(encoding="utf-8"))
payload = {
"pipes": PIPES,
"structures": STRUCTURES,
"types": _types(),
"server": SERVER,
"fresh": FRESH,
"nodes": fixture["profile_alignment"]["base_pvi"],
"gone": [s["chainage_m"] for s in fixture["stations"] if s.get("kind") == "irregular"],
"born": [s["chainage_m"] for s in _snapped(fixture)],
}
source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_272_1_station_list.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_planted_mirror(tmp_path: Path, ts) -> None:
path = tmp_path / "B05_Profile/route/structures.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
encoding="utf-8",
)
server = [mark for mark, _label in resolve_extra_stations(tmp_path, parse_pipe_points(PIPES))]
assert sorted(server) == EXPECTED
assert ts["planted"] == EXPECTED
def test_one_list_mirror(tmp_path: Path, ts) -> None:
rel = "B06_Section/longitudinal/longitudinal.json"
path = tmp_path / rel
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"stations": SERVER}, ensure_ascii=False), encoding="utf-8")
_merge_irregular_into_longitudinal(tmp_path, rel, FRESH)
merged = json.loads(path.read_text(encoding="utf-8"))["stations"]
assert [s["chainage_m"] for s in merged] == [0, 20, 35, 40, 60, 80, 100]
assert [s["chainage_m"] for s in ts["list"]] == [s["chainage_m"] for s in merged]
# 일반 측점은 글을 떼고 일반으로 · 같은 m 는 구조물 측점 하나(새 목록 것)
for side in (merged, ts["list"]):
by = {s["chainage_m"]: s for s in side}
assert by[40]["kind"] == "regular" and "structure" not in by[40] and "grid" not in by[40]
assert "structure" not in by[60]
assert by[100]["structure"] == "BOX암거"
assert 95.38 not in by
def test_moved_nodes_mirror(tmp_path: Path, ts) -> None:
"""옛 소수 → 정수 m 맞춤 뒤 꼭짓점 — 화면(저장 전 그림)과 서버([저장] 뒤)가 같음."""
rel = "B06_Section/longitudinal/longitudinal.json"
path = tmp_path / rel
path.parent.mkdir(parents=True)
fixture = json.loads(FIXTURE.read_text(encoding="utf-8"))
path.write_text(json.dumps(fixture, ensure_ascii=False), encoding="utf-8")
_merge_irregular_into_longitudinal(tmp_path, rel, _snapped(fixture))
server = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["base_pvi"]
assert [(n["chainage_m"], n["elevation_m"]) for n in ts["nodes"]] == [
(n["chainage_m"], n["elevation_m"]) for n in server
]
+193
View File
@@ -0,0 +1,193 @@
"""272-2 — 구조물 측점을 지금 목록 전체로 다시 맞춤(요구 167 · 측점 겹침 근본).
서버는 관 · 구조물 목록 **전체**로 종단 정본의 구조물 측점을 다시 맞춘다 — 빈 목록이어도 옛 구조물
측점을 지우고, 구조물이 떠난 격자 자리는 일반 측점으로 되돌리며(횡단 파일 글까지), 같은 m 면
측점 하나(구조물이 위 · `grid`). 종단 정본에 없는 측점의 횡단 행도 지운다(B08 이 옛 자리를 안 셈).
"""
from __future__ import annotations
import asyncio
import json
import math
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402
from B05_Profile.B05_Profile_Repository import sync_cross_section_rows # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
REL = "B06_Section/longitudinal/longitudinal.json"
PIPE = "파형강관 D800"
def _write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def _cross(root: Path, chainage: float) -> Path:
return root / "B06_Section/cross_sections" / cross_filename(chainage)
def _setup(root: Path) -> None:
"""격자 0~100(20 m) · 옛 관 측점 95.38 · 옛 판 「regular + 글」 60 m."""
grid = [
{"chainage_m": float(c), "kind": "bp" if c == 0 else "regular"} for c in range(0, 101, 20)
]
grid[3]["structure"] = "옛 글"
stations = sorted(
[*grid, {"chainage_m": 95.38, "kind": "irregular", "structure": PIPE}],
key=lambda s: s["chainage_m"],
)
_write(root / REL, {"stations": stations})
for station in stations:
_write(_cross(root, station["chainage_m"]), {**station, "samples": []})
def _stations(root: Path) -> list[dict]:
return json.loads((root / REL).read_text(encoding="utf-8"))["stations"]
def _at(root: Path, chainage: float) -> dict:
return next(s for s in _stations(root) if s["chainage_m"] == chainage)
def test_관이_격자_위로_가면_측점_하나_구조물이_위(tmp_path: Path) -> None:
_setup(tmp_path)
moved = {"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": PIPE}
_merge_irregular_into_longitudinal(tmp_path, REL, [moved])
chainages = [s["chainage_m"] for s in _stations(tmp_path)]
assert 95.38 not in chainages and chainages.count(100.0) == 1
assert _at(tmp_path, 100.0) == moved
# 옛 판 「regular + 글」 도 글을 지움 — 횡단 파일 글까지
assert _at(tmp_path, 60.0) == {"chainage_m": 60.0, "kind": "regular"}
cross = json.loads(_cross(tmp_path, 60.0).read_text(encoding="utf-8"))
assert cross["kind"] == "regular" and "structure" not in cross
def test_구조물이_떠나면_일반_측점이_돌아옴_빈_목록도(tmp_path: Path) -> None:
_setup(tmp_path)
on_grid = {"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": PIPE}
_merge_irregular_into_longitudinal(tmp_path, REL, [on_grid])
_write(_cross(tmp_path, 100.0), {**on_grid, "samples": []}) # 측점 만들기가 쓴 파일 흉내
_merge_irregular_into_longitudinal(tmp_path, REL, [])
stations = _stations(tmp_path)
assert [s["chainage_m"] for s in stations] == [0.0, 20.0, 40.0, 60.0, 80.0, 100.0]
assert all(s["kind"] in ("bp", "regular") and "structure" not in s for s in stations)
assert all("grid" not in s for s in stations)
cross = json.loads(_cross(tmp_path, 100.0).read_text(encoding="utf-8"))
assert cross["kind"] == "regular" and "structure" not in cross and "grid" not in cross
def test_시점_위_구조물이_떠나도_시점은_시점(tmp_path: Path) -> None:
_setup(tmp_path)
at_bp = {"chainage_m": 0.0, "kind": "bp", "grid": True, "structure": PIPE}
_merge_irregular_into_longitudinal(tmp_path, REL, [at_bp])
assert _at(tmp_path, 0.0) == at_bp
_merge_irregular_into_longitudinal(tmp_path, REL, [])
assert _at(tmp_path, 0.0) == {"chainage_m": 0.0, "kind": "bp"}
def test_측점만_옮겨도_계획선_측점_줄을_다시_셈(tmp_path: Path) -> None:
"""편집 없는 관 측점 581.9 → 582 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않음."""
source = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
path = tmp_path / REL
_write(path, json.loads(source.read_text(encoding="utf-8")))
data = json.loads(path.read_text(encoding="utf-8"))
moved = [
{**s, "chainage_m": 582.0} if s["chainage_m"] == 581.9 else s
for s in data["stations"]
if s.get("kind") == "irregular"
]
assert _merge_irregular_into_longitudinal(tmp_path, REL, moved) is not None
rows = [row["chainage_m"] for row in _stations_rows(path)]
assert 582.0 in rows and 581.9 not in rows
assert rows == [s["chainage_m"] for s in _stations(tmp_path)]
# 그대로 한 번 더 — 줄이 측점과 같으면 안 건드림
assert _merge_irregular_into_longitudinal(tmp_path, REL, moved) is None
def test_옛_소수_측점을_정수로_옮기면_꼭짓점_편집도_따라감(tmp_path: Path) -> None:
"""484.213 · 581.902 · 676.591 · 769.28 → 정수 m — 꼭짓점이 높이 그대로 옮겨 감(지우면 계획선이
그 자리에서 바뀜 · 남기면 측점 없는 꼭짓점이 1000% 넘게 꺾음 · 브라우저 실측)."""
source = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
data = json.loads(source.read_text(encoding="utf-8"))
_write(tmp_path / REL, data)
snapped = [
{**s, "chainage_m": float(math.floor(s["chainage_m"] + 0.5))}
for s in data["stations"]
if s.get("kind") == "irregular"
]
_merge_irregular_into_longitudinal(tmp_path, REL, snapped)
after = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
nodes = {n["chainage_m"]: n["elevation_m"] for n in after["base_pvi"]}
old_nodes = {n["chainage_m"]: n["elevation_m"] for n in data["profile_alignment"]["base_pvi"]}
for old, new in ((484.213, 484.0), (581.902, 582.0), (676.591, 677.0), (769.28, 769.0)):
assert old not in nodes and nodes[new] == old_nodes[old]
offsets = after["edits"]["station_offsets"]
assert "769.280" not in offsets and "769.000" in offsets
def _stations_rows(path: Path) -> list[dict]:
return json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["stations"]
class _Cursor:
def __init__(self, rows: list[tuple[int, float]]) -> None:
self.rows = rows
self.moved: list[tuple[float, int]] = []
self.deleted: list[int] | None = None
async def __aenter__(self) -> "_Cursor":
return self
async def __aexit__(self, *_a) -> bool:
return False
async def execute(self, sql: str, args=None) -> None:
if sql.startswith("UPDATE"):
self.moved.append(tuple(args))
elif sql.startswith("DELETE"):
self.deleted = list(args)
async def fetchall(self) -> list[tuple[int, float]]:
return self.rows
class _Connection:
def __init__(self, rows: list[tuple[int, float]]) -> None:
self.cursor_ = _Cursor(rows)
def cursor(self) -> _Cursor:
return self.cursor_
def _sync(rows: list[tuple[int, float]], stations: list[float]) -> tuple[int, _Cursor]:
connection = _Connection(rows)
return asyncio.run(sync_cross_section_rows(connection, 7, stations)), connection.cursor_
def test_옛_소수_측점_행은_새_정수_측점으로_옮김() -> None:
"""95.38 → 95 — 사용자 설계 값이 든 행을 그대로 옮김(면적은 저장 뒤 서버 재계산)."""
count, cursor = _sync([(1, 0.0), (2, 95.38), (3, 100.004)], [0.0, 20.0, 95.0, 100.0])
assert count == 1 and cursor.moved == [(95.0, 2)] and cursor.deleted is None
def test_가까운_새_측점이_없으면_지움() -> None:
count, cursor = _sync([(1, 0.0), (2, 95.38), (5, 140.0)], [0.0, 20.0, 100.0])
assert count == 2 and cursor.moved == [] and cursor.deleted == [2, 5]
def test_한_측점에_둘이_다투면_가까운_행만_옮김() -> None:
count, cursor = _sync([(2, 95.38), (6, 94.7), (7, 100.0)], [95.0, 100.0])
assert count == 2 and cursor.moved == [(95.0, 6)] and cursor.deleted == [2]
def test_측점_목록이_비면_아무것도_안_건드림() -> None:
count, cursor = _sync([(1, 0.0), (2, 95.38)], [])
assert count == 0 and cursor.moved == [] and cursor.deleted is None
@@ -72,10 +72,12 @@ def api(tmp_path, monkeypatch):
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
import B05_Profile.B05_Profile_Router as route
import B05_Profile.B05_Profile_Router_Confirm as route_confirm
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
import B05_Profile.B05_Profile_Structures_Router as structures
import B06_Section.B06_Section_Repository_Bulk as bulk
import B06_Section.B06_Section_Router_Confirm as sections
import B06_Section.B06_Section_Router_Stations as station_router
import common_util.common_util_storage as storage
import config.config_db as config_db
from config import config_system as cfg
@@ -98,6 +100,14 @@ def api(tmp_path, monkeypatch):
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute"))
# 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기
monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0))
monkeypatch.setattr(
route_confirm, "update_longitudinal_grade_summary", rec.fake("grade_summary")
)
monkeypatch.setattr(
station_router, "update_longitudinal_grade_summary", rec.fake("grade_summary")
)
real_known = sections.get_cross_section_chainages
async def spy_known(connection, route_id):
@@ -247,7 +247,9 @@ 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"
# 같은 m 면 측점 하나 · 구조물 측점이 위(272) — 격자 자리 표시(`grid`)로 떠나면 되돌림
assert labelled[100.0]["kind"] == "irregular" and labelled[100.0].get("grid") is True
assert labelled[0.3]["kind"] == "irregular" and "grid" not in labelled[0.3]
def test_two_extras_in_same_meter_both_kept():
@@ -30,11 +30,11 @@ def results():
def test_칸을_안_고치면_실어_둔_측점_그대로(results):
assert results["shown"] == ["6", "3.4"]
assert results["shown"] == ["6", "3"] # 칸 표기도 정수 m(167) · 값은 실어 둔 그대로
assert results["untouched"] == 123.37
assert results["untouchedRaw"] == 123.3721
assert results["retypedSame"] == 123.37
assert results["r119"] == ["5", "19.8", 119.8]
assert results["r119"] == ["5", "20", 119.8]
def test_화면_표기는_거리를_정수로(results):
@@ -44,7 +44,7 @@ def test_화면_표기는_거리를_정수로(results):
def test_칸을_고치면_친_값(results):
# 친 값 125.2 → 정수 m 125(234-1 · 저장값도 정수 m — 9-29 「소수 그대로」를 바꿈)
assert results["edited"] == 125
assert results["normalized"] == ["2", "10.0", 50]
assert results["normalized"] == ["2", "10", 50]
assert results["cleared"] is None
+8 -9
View File
@@ -9,7 +9,6 @@ import asyncio
import B06_Section.B06_Section_Router_Stations as stations
from B06_Section.B06_Section_Router_Confirm import _stand_patch_stations
from B06_Section.B06_Section_Schema import SectionConfirmRequest
PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d"
@@ -22,8 +21,7 @@ def _calls(monkeypatch, patches: list[float], known: list[float]) -> list[str]:
return 1
monkeypatch.setattr(stations, "ensure_structure_stations", fake)
request = SectionConfirmRequest(cross_patches=[{"chainage_m": c} for c in patches])
asyncio.run(_stand_patch_stations(PROJECT, request, known))
asyncio.run(_stand_patch_stations(PROJECT))
return called
@@ -31,12 +29,13 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch):
assert _calls(monkeypatch, [120.0, 263.47], [120.0, 140.0]) == [PROJECT]
def test_모든_수정에_행이_있으면_안_부름(monkeypatch):
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == []
assert _calls(monkeypatch, [], [120.0]) == []
def test_모든_수정에_행이_있어도_맞춤은_부름(monkeypatch):
# 272 — 옮긴 관의 옛 측점 · 떠난 격자 자리 글을 지우려고 늘 부름(맞출 것이 없으면
# `ensure_structure_stations` 가 0 으로 그냥 돌아옴)
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == [PROJECT]
assert _calls(monkeypatch, [], [120.0]) == [PROJECT]
def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch):
# 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움
def test_소수_측점_수정도_맞춤을_부름(monkeypatch):
assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT]
assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == []
assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == [PROJECT]