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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 — 곡선부 확폭·법정 최소반경 판정용. */
|
||||
|
||||
@@ -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))
|
||||
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user