fix(B05·B06): 유령 계획고 편집 근본 막음 — 옮긴 관 편집 따라감 · [저장] 때 자리 없는 편집 버림 · 알림(213-2)
- ① 관 옮김 = 편집 키도 새 자리로: B06 movePipeTo 가 resetStationEdit(옛, 새) · B05 측점 갱신이 사라진 것 하나 · 새것 하나(또는 같은 id · 0.006 m 안)를 짝으로 옮김(followedStation) — 세션 초안도 새 키라 옛 키가 다시 안 나감 - ② [저장](PUT profile-alignment)에서 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버림(drop_unplaced_edits · 저장 차례가 관 · 구조물 뒤라 최신) · 응답 dropped_edit_chainages · 화면 알림 「계획고 편집 N곳을 지웠습니다」 · 자리 못 읽으면 안 버림 - ③ test_192_1 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 460~840 m) · 새 시험 test_213_2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
@@ -86,6 +86,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U
|
|||||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||||
|
import { noticeDroppedEdits } from "./design_save";
|
||||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||||
@@ -650,6 +651,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
async save(): Promise<void> {
|
async save(): Promise<void> {
|
||||||
if (!routeId || !store.dirty()) return;
|
if (!routeId || !store.dirty()) return;
|
||||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||||
|
noticeDroppedEdits(saved);
|
||||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||||
if (next?.base_pvi?.length) {
|
if (next?.base_pvi?.length) {
|
||||||
base = toAlignmentBase(next);
|
base = toAlignmentBase(next);
|
||||||
|
|||||||
@@ -65,11 +65,26 @@ function reportOnly(label: string): (error: unknown) => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
|
||||||
|
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
|
||||||
|
const dropped = saved.dropped_edit_chainages ?? [];
|
||||||
|
if (!dropped.length) return;
|
||||||
|
const where = dropped
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((value) => `${value.toFixed(2)}m`)
|
||||||
|
.join(" · ");
|
||||||
|
showToast(
|
||||||
|
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
|
||||||
|
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||||
const draft = pendingAlignment(routeId);
|
const draft = pendingAlignment(routeId);
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
await saveProfileAlignment(projectId, routeId, draft);
|
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
|
||||||
markAlignmentSaved(routeId);
|
markAlignmentSaved(routeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
|
|||||||
route_id: number;
|
route_id: number;
|
||||||
profile_alignment: unknown;
|
profile_alignment: unknown;
|
||||||
grade_summary: RouteGradeSummary | null;
|
grade_summary: RouteGradeSummary | null;
|
||||||
|
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
|
||||||
|
dropped_edit_chainages?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -214,6 +214,30 @@ def snap_to_stations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_unplaced_edits(
|
||||||
|
edits: dict[str, dict[str, float]], places: np.ndarray
|
||||||
|
) -> tuple[dict[str, dict[str, float]], list[float]]:
|
||||||
|
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
|
||||||
|
|
||||||
|
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
|
||||||
|
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
|
||||||
|
"""
|
||||||
|
kept: dict[str, dict[str, float]] = {}
|
||||||
|
dropped: set[float] = set()
|
||||||
|
for name, values in edits.items():
|
||||||
|
kept[name] = {}
|
||||||
|
for key, value in values.items():
|
||||||
|
try:
|
||||||
|
chainage = float(key)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
|
||||||
|
kept[name][key] = value
|
||||||
|
else:
|
||||||
|
dropped.add(round(chainage, _KEY_DECIMALS))
|
||||||
|
return kept, sorted(dropped)
|
||||||
|
|
||||||
|
|
||||||
def resolve_pvi(
|
def resolve_pvi(
|
||||||
base_s: np.ndarray,
|
base_s: np.ndarray,
|
||||||
base_z: np.ndarray,
|
base_z: np.ndarray,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import aiomysql
|
import aiomysql
|
||||||
|
import numpy as np
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
|||||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||||
from B05_Profile.B05_Profile_Engine import run_route_design
|
from B05_Profile.B05_Profile_Engine import run_route_design
|
||||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||||
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
||||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||||
@@ -384,13 +386,30 @@ async def update_contour_interval(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
|
||||||
|
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
|
||||||
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
|
_load_pipe_points,
|
||||||
|
_load_route_polyline,
|
||||||
|
resolve_extra_stations,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
|
||||||
|
extras = resolve_extra_stations(project_root, pipes)
|
||||||
|
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
|
||||||
|
|
||||||
|
|
||||||
def _apply_alignment_edits(
|
def _apply_alignment_edits(
|
||||||
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
project_root: Path,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
longitudinal_file_path: str,
|
||||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
edits: dict[str, Any],
|
||||||
|
marks: list[float] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
|
||||||
|
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
|
||||||
|
|
||||||
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
||||||
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
|
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
|
||||||
|
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
|
||||||
"""
|
"""
|
||||||
root = project_root.resolve()
|
root = project_root.resolve()
|
||||||
path = (root / longitudinal_file_path).resolve()
|
path = (root / longitudinal_file_path).resolve()
|
||||||
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
||||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
dropped: list[float] = []
|
||||||
|
if marks is not None:
|
||||||
|
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
|
||||||
|
edits, dropped = drop_unplaced_edits(
|
||||||
|
{
|
||||||
|
"station_offsets": dict(edits.get("station_offsets") or {}),
|
||||||
|
"curve_radii": dict(edits.get("curve_radii") or {}),
|
||||||
|
},
|
||||||
|
np.array(stations + marks, dtype=np.float64),
|
||||||
|
)
|
||||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||||
longitudinal["profile_alignment"] = alignment
|
longitudinal["profile_alignment"] = alignment
|
||||||
profiles = longitudinal.get("design_profiles")
|
profiles = longitudinal.get("design_profiles")
|
||||||
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
|
|||||||
else:
|
else:
|
||||||
longitudinal["design_profiles"] = [profile]
|
longitudinal["design_profiles"] = [profile]
|
||||||
atomic_write_json(path, longitudinal)
|
atomic_write_json(path, longitudinal)
|
||||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
return alignment, {"id": profile["id"], **profile["summary"]}, dropped
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
||||||
@@ -426,11 +455,18 @@ async def save_profile_alignment(
|
|||||||
)
|
)
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
alignment, grade_summary = await asyncio.to_thread(
|
route = await get_latest_route(connection, project_id)
|
||||||
|
marks = None
|
||||||
|
if route and route["id"] == request.route_id and route.get("route_data_path"):
|
||||||
|
marks = await asyncio.to_thread(
|
||||||
|
_structure_marks, project_root, str(route["route_data_path"])
|
||||||
|
)
|
||||||
|
alignment, grade_summary, dropped = await asyncio.to_thread(
|
||||||
_apply_alignment_edits,
|
_apply_alignment_edits,
|
||||||
project_root,
|
project_root,
|
||||||
str(longitudinal["longitudinal_file_path"]),
|
str(longitudinal["longitudinal_file_path"]),
|
||||||
request.edits(),
|
request.edits(),
|
||||||
|
marks,
|
||||||
)
|
)
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
@@ -446,6 +482,7 @@ async def save_profile_alignment(
|
|||||||
route_id=request.route_id,
|
route_id=request.route_id,
|
||||||
profile_alignment=alignment,
|
profile_alignment=alignment,
|
||||||
grade_summary=grade_summary,
|
grade_summary=grade_summary,
|
||||||
|
dropped_edit_chainages=dropped,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ async def apply_profile_target(
|
|||||||
root = Path(resolve_stored_project_path(stored_path))
|
root = Path(resolve_stored_project_path(stored_path))
|
||||||
relative = str(row["longitudinal_file_path"])
|
relative = str(row["longitudinal_file_path"])
|
||||||
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
||||||
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {})
|
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||||
|
_apply_alignment_edits, root, relative, {}
|
||||||
|
)
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
await update_longitudinal_grade_summary(
|
await update_longitudinal_grade_summary(
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
|
|||||||
route_id: int
|
route_id: int
|
||||||
profile_alignment: dict[str, Any]
|
profile_alignment: dict[str, Any]
|
||||||
grade_summary: dict[str, Any] | None = None
|
grade_summary: dict[str, Any] | None = None
|
||||||
|
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
|
||||||
|
dropped_edit_chainages: list[float] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class RouteSolveResponse(BaseModel):
|
class RouteSolveResponse(BaseModel):
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||||
|
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||||
|
|
||||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||||
export interface PipeProjection {
|
export interface PipeProjection {
|
||||||
@@ -103,20 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
|
|
||||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
|
||||||
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김.
|
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
|
||||||
|
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
|
||||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||||
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
||||||
irregularStations
|
const gone = irregularStations.filter(
|
||||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
|
||||||
.forEach((station) => {
|
);
|
||||||
const same = stations.find(
|
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||||
(next) =>
|
gone.forEach((station) => {
|
||||||
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
|
const same = followedStation(station, gone, born);
|
||||||
!oldKeys.has(next.chainage_m.toFixed(3)),
|
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||||
);
|
});
|
||||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
|
||||||
});
|
|
||||||
irregularStations = stations;
|
irregularStations = stations;
|
||||||
deps.renderStationLines();
|
deps.renderStationLines();
|
||||||
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
||||||
|
|||||||
@@ -182,6 +182,22 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와
|
||||||
|
* 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움).
|
||||||
|
*/
|
||||||
|
export function followedStation<T extends { id: string; chainage_m: number }>(
|
||||||
|
station: T,
|
||||||
|
gone: ReadonlyArray<T>,
|
||||||
|
born: ReadonlyArray<T>,
|
||||||
|
): T | null {
|
||||||
|
return (
|
||||||
|
born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ??
|
||||||
|
born.find((next) => next.id === station.id) ??
|
||||||
|
(gone.length === 1 && born.length === 1 ? born[0] : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
|
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
|
||||||
const SAME_STATION_SNAP_M = 0.006;
|
const SAME_STATION_SNAP_M = 0.006;
|
||||||
|
|
||||||
|
|||||||
@@ -475,7 +475,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
|||||||
const hit = pipeFacilities.find(
|
const hit = pipeFacilities.find(
|
||||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||||
);
|
);
|
||||||
if (hit) hit.chainage_m = to;
|
if (hit) {
|
||||||
|
// 그 관 측점의 계획고 편집도 새 자리로 — 옛 키가 유령 꼭짓점으로 남지 않게(213-2)
|
||||||
|
deps.shared.profile?.resetStationEdit(hit.chainage_m, to);
|
||||||
|
hit.chainage_m = to;
|
||||||
|
}
|
||||||
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
||||||
currentChainageM = to;
|
currentChainageM = to;
|
||||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@
|
|||||||
769.28 에 남음 → B06(측점 769.28)과 B05(관 769.38) 두 자리에 계획고 편집이 남아 0.1m 사이
|
769.28 에 남음 → B06(측점 769.28)과 B05(관 769.38) 두 자리에 계획고 편집이 남아 0.1m 사이
|
||||||
−465% 계단 · 769.38 은 측점선 없는 꼭짓점.
|
−465% 계단 · 769.38 은 측점선 없는 꼭짓점.
|
||||||
고침: 판정 폭 0.005m(측점 스냅 폭) · 측점을 다시 뜨면 사라진 측점의 꼭짓점 · 편집을 지움
|
고침: 판정 폭 0.005m(측점 스냅 폭) · 측점을 다시 뜨면 사라진 측점의 꼭짓점 · 편집을 지움
|
||||||
(반올림 차이 0.006m 안은 옮김). 사용자 storage 는 읽기만 — 사본(tmp_path)으로 재현.
|
(반올림 차이 0.006m 안은 옮김). 자료 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json ·
|
||||||
|
226fc805 종단 460~840m · 측점 769.28 인 192 이전 상태 · 213-2) — 사용자 storage 에 기대지 않음.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,17 +23,13 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
import B05_Profile.B05_Profile_Engine_Sections as sections # noqa: E402
|
import B05_Profile.B05_Profile_Engine_Sections as sections # noqa: E402
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
|
||||||
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
|
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
|
||||||
from B06_Section.B06_Section_Router_Stations import _missing_marks # noqa: E402
|
from B06_Section.B06_Section_Router_Stations import _missing_marks # noqa: E402
|
||||||
|
|
||||||
SOURCE = (
|
SOURCE = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
|
||||||
PROJECT_ROOT
|
|
||||||
/ "storage/1/3/226fc805-26c8-4f5a-9956-bacb200e10a5/B06_Section/longitudinal/longitudinal.json"
|
|
||||||
)
|
|
||||||
REL = "B06_Section/longitudinal/longitudinal.json"
|
REL = "B06_Section/longitudinal/longitudinal.json"
|
||||||
|
|
||||||
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="검증_랩탑메인 storage 없음")
|
|
||||||
|
|
||||||
|
|
||||||
def _copy(tmp_path: Path) -> Path:
|
def _copy(tmp_path: Path) -> Path:
|
||||||
target = tmp_path / REL
|
target = tmp_path / REL
|
||||||
@@ -58,20 +55,18 @@ def _irregular(data: dict, old: float, new: float) -> list[dict]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@needs_copy
|
|
||||||
def test_원본_자료가_숨은_꼭짓점을_가짐() -> None:
|
def test_원본_자료가_숨은_꼭짓점을_가짐() -> None:
|
||||||
"""까닭 근거 — 769.38 꼭짓점은 측점 목록에 없고 두 편집 사이 −465%."""
|
"""까닭 근거 — 769.38 꼭짓점은 측점 목록에 없고 두 편집 사이 −465%."""
|
||||||
data = _load(SOURCE)
|
data = _load(SOURCE)
|
||||||
alignment = data["profile_alignment"]
|
alignment, _ = rebuild_alignment_profile(data, data["profile_alignment"]["edits"])
|
||||||
stations = [s["chainage_m"] for s in data["stations"]]
|
stations = [s["chainage_m"] for s in data["stations"]]
|
||||||
pvis = [p["chainage_m"] for p in alignment["pvi"]]
|
pvis = [p["chainage_m"] for p in alignment["pvi"]]
|
||||||
assert 769.38 in pvis and 769.38 not in stations and 769.28 in stations
|
assert 769.38 in pvis and 769.38 not in stations and 769.28 in stations
|
||||||
assert set(alignment["edits"]["station_offsets"]) == {"769.280", "769.380"}
|
assert {"769.280", "769.380"} <= set(alignment["edits"]["station_offsets"])
|
||||||
step = next(s for s in alignment["segments"] if s["from_m"] == 769.28)
|
step = next(s for s in alignment["segments"] if s["from_m"] == 769.28)
|
||||||
assert step["to_m"] == 769.38 and step["grade_percent"] < -400
|
assert step["to_m"] == 769.38 and step["grade_percent"] < -400
|
||||||
|
|
||||||
|
|
||||||
@needs_copy
|
|
||||||
def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Path) -> None:
|
def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Path) -> None:
|
||||||
path = _copy(tmp_path)
|
path = _copy(tmp_path)
|
||||||
data = _load(path)
|
data = _load(path)
|
||||||
@@ -90,7 +85,6 @@ def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Pa
|
|||||||
assert after["design_profiles"][0]["summary"]["max_grade_pct"] == summary["max_grade_pct"]
|
assert after["design_profiles"][0]["summary"]["max_grade_pct"] == summary["max_grade_pct"]
|
||||||
|
|
||||||
|
|
||||||
@needs_copy
|
|
||||||
def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
|
def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
|
||||||
path = _copy(tmp_path)
|
path = _copy(tmp_path)
|
||||||
data = _load(path)
|
data = _load(path)
|
||||||
@@ -101,7 +95,6 @@ def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
|
|||||||
assert "484.210" not in offsets and offsets["484.213"] == pytest.approx(0.3)
|
assert "484.210" not in offsets and offsets["484.213"] == pytest.approx(0.3)
|
||||||
|
|
||||||
|
|
||||||
@needs_copy
|
|
||||||
def test_측점이_그대로면_계획선을_건드리지_않음(tmp_path: Path) -> None:
|
def test_측점이_그대로면_계획선을_건드리지_않음(tmp_path: Path) -> None:
|
||||||
path = _copy(tmp_path)
|
path = _copy(tmp_path)
|
||||||
data = _load(path)
|
data = _load(path)
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""213-2 — 유령 계획고 편집 근본 막음.
|
||||||
|
|
||||||
|
① 관을 옮기면 편집 키가 새 자리로 따라감 — 화면 `followedStation`
|
||||||
|
(B05_Profile_UI_Profile_Alignment.ts · B05 applyIrregularStations) ·
|
||||||
|
B06 movePipeTo 는 resetStationEdit(옛, 새).
|
||||||
|
② [저장] 때 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버리고 자리 목록을 돌려줌
|
||||||
|
(`drop_unplaced_edits` · `_apply_alignment_edits(marks=...)`).
|
||||||
|
자료 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 종단 460~840m).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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_Grade_Alignment import drop_unplaced_edits # noqa: E402
|
||||||
|
from B05_Profile.B05_Profile_Router import _apply_alignment_edits # noqa: E402
|
||||||
|
|
||||||
|
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||||
|
FIXTURE = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json"
|
||||||
|
REL = "B06_Section/longitudinal/longitudinal.json"
|
||||||
|
GHOST = {"station_offsets": {"769.280": 0.506058, "769.380": 0.051428}, "curve_radii": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_keeps_station_and_marks_only():
|
||||||
|
edits = {
|
||||||
|
"station_offsets": {"20.000": 1.0, "263.206": 0.5, "263.310": 0.9, "400.000": 0.2},
|
||||||
|
"curve_radii": {"263.206": 300.0, "500.000": 80.0},
|
||||||
|
}
|
||||||
|
kept, dropped = drop_unplaced_edits(edits, np.array([0.0, 20.0, 263.21, 263.31]))
|
||||||
|
# 263.206 = 측점 263.21 의 0.006m 안(반올림) · 263.31 = 관 자리 → 남음
|
||||||
|
assert kept["station_offsets"] == {"20.000": 1.0, "263.206": 0.5, "263.310": 0.9}
|
||||||
|
assert kept["curve_radii"] == {"263.206": 300.0}
|
||||||
|
assert dropped == [400.0, 500.0]
|
||||||
|
|
||||||
|
|
||||||
|
def _project(tmp_path: Path, station_m: float) -> Path:
|
||||||
|
"""고정 사본 — 측점 769.28(옮긴 관, 측점 안 다시 뜸) 또는 769.38(다시 뜸 · 옛 꼭짓점 없음)."""
|
||||||
|
data = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
if station_m != 769.28:
|
||||||
|
for station in data["stations"]:
|
||||||
|
if abs(station["chainage_m"] - 769.28) < 1e-6:
|
||||||
|
station.update(chainage_m=station_m, station_id="station_000000769380")
|
||||||
|
base = data["profile_alignment"]["base_pvi"]
|
||||||
|
data["profile_alignment"]["base_pvi"] = [
|
||||||
|
node for node in base if abs(node["chainage_m"] - 769.28) > 1e-6
|
||||||
|
]
|
||||||
|
target = tmp_path / REL
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_drops_ghost_after_station_regenerated(tmp_path):
|
||||||
|
"""226fc805 꼴 — 측점 769.38 로 다시 섰는데 세션 초안이 옛 키 769.280 을 다시 보냄."""
|
||||||
|
path = _project(tmp_path, 769.38)
|
||||||
|
alignment, summary, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, [769.38])
|
||||||
|
assert dropped == [769.28]
|
||||||
|
assert alignment["edits"]["station_offsets"] == {"769.380": 0.051428}
|
||||||
|
near = [p["chainage_m"] for p in alignment["pvi"] if 760 < p["chainage_m"] < 780]
|
||||||
|
assert near == [769.38]
|
||||||
|
assert summary["max_grade_pct"] < 100
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
|
||||||
|
assert "769.280" not in saved["edits"]["station_offsets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_keeps_moved_pipe_edit_before_regeneration(tmp_path):
|
||||||
|
"""관만 옮기고 측점은 옛 자리(769.28) — 옛 측점 편집 · 새 관 자리 편집 둘 다 지킴."""
|
||||||
|
_project(tmp_path, 769.28)
|
||||||
|
alignment, _, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, [769.38])
|
||||||
|
assert dropped == []
|
||||||
|
assert set(alignment["edits"]["station_offsets"]) == {"769.280", "769.380"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_without_marks_drops_nothing(tmp_path):
|
||||||
|
"""노선이 안 맞아 관 · 구조물 자리를 못 읽으면 버리지 않음(잃는 쪽보다 남는 쪽)."""
|
||||||
|
_project(tmp_path, 769.38)
|
||||||
|
_, _, dropped = _apply_alignment_edits(tmp_path, REL, GHOST, None)
|
||||||
|
assert dropped == []
|
||||||
|
|
||||||
|
|
||||||
|
_RUNNER = """
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { followedStation } from "./B05_Profile_UI_Profile_Alignment.js";
|
||||||
|
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||||
|
const out = cases.map((c) => followedStation(c.station, c.gone, c.born)?.chainage_m ?? null);
|
||||||
|
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||||
|
"""
|
||||||
|
|
||||||
|
ST = lambda sid, m: {"id": sid, "chainage_m": m} # noqa: E731
|
||||||
|
CASES = [
|
||||||
|
# 옮긴 관 하나(키 한 번 +0.1) → 따라감
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"station": ST("pipe-263.21", 263.21),
|
||||||
|
"gone": [ST("pipe-263.21", 263.21)],
|
||||||
|
"born": [ST("pipe-263.31", 263.31)],
|
||||||
|
},
|
||||||
|
263.31,
|
||||||
|
),
|
||||||
|
# 반올림(263.206 → 263.21) → 따라감
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"station": ST("pipe-263.21", 263.206),
|
||||||
|
"gone": [ST("pipe-263.21", 263.206)],
|
||||||
|
"born": [ST("pipe-263.21", 263.21)],
|
||||||
|
},
|
||||||
|
263.21,
|
||||||
|
),
|
||||||
|
# A군 구조물 = 같은 id 로
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"station": ST("s1", 100.0),
|
||||||
|
"gone": [ST("s1", 100.0), ST("pipe-5.00", 5.0)],
|
||||||
|
"born": [ST("pipe-9.00", 9.0), ST("s1", 130.0)],
|
||||||
|
},
|
||||||
|
130.0,
|
||||||
|
),
|
||||||
|
# 둘이 한꺼번에 바뀜(지움 + 새로 놓음 둘) → 안 따라감(지움)
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"station": ST("pipe-5.00", 5.0),
|
||||||
|
"gone": [ST("pipe-5.00", 5.0), ST("pipe-7.00", 7.0)],
|
||||||
|
"born": [ST("pipe-50.00", 50.0), ST("pipe-70.00", 70.0)],
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# 지우기만 → 지움
|
||||||
|
({"station": ST("pipe-5.00", 5.0), "gone": [ST("pipe-5.00", 5.0)], "born": []}, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_followed_station_ts(tmp_path):
|
||||||
|
if not TSC.is_file():
|
||||||
|
pytest.skip("프론트엔드 의존성 미설치")
|
||||||
|
out = tmp_path / "js"
|
||||||
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(TSC),
|
||||||
|
str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"),
|
||||||
|
"--outDir",
|
||||||
|
str(out),
|
||||||
|
"--module",
|
||||||
|
"esnext",
|
||||||
|
"--target",
|
||||||
|
"es2022",
|
||||||
|
"--moduleResolution",
|
||||||
|
"bundler",
|
||||||
|
"--ignoreConfig",
|
||||||
|
],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||||
|
src, dst = tmp_path / "in.json", tmp_path / "out.json"
|
||||||
|
src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8")
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||||||
|
)
|
||||||
|
assert json.loads(dst.read_text(encoding="utf-8")) == [want for _, want in CASES]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_sites_follow_edits():
|
||||||
|
"""옮김 자리 둘이 편집을 따라 옮김 — B06 movePipeTo · B05 측점 갱신(문자열 짝 확인)."""
|
||||||
|
b06 = (PROJECT_ROOT / "B06_Section/B06_Section_UI_Page_Structures_Panel.ts").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
b05 = (PROJECT_ROOT / "B05_Profile/B05_Profile_UI_Page_Structures.ts").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "deps.shared.profile?.resetStationEdit(hit.chainage_m, to)" in b06
|
||||||
|
assert "followedStation(station, gone, born)" in b05
|
||||||
Reference in New Issue
Block a user