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:
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
|
||||
route_id: number;
|
||||
profile_alignment: unknown;
|
||||
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(
|
||||
base_s: np.ndarray,
|
||||
base_z: np.ndarray,
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
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_Engine import run_route_design
|
||||
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_Sections import run_section_generation
|
||||
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(
|
||||
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
||||
project_root: Path,
|
||||
longitudinal_file_path: str,
|
||||
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()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
||||
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)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
|
||||
else:
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
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)
|
||||
@@ -426,11 +455,18 @@ async def save_profile_alignment(
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
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,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
request.edits(),
|
||||
marks,
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
@@ -446,6 +482,7 @@ async def save_profile_alignment(
|
||||
route_id=request.route_id,
|
||||
profile_alignment=alignment,
|
||||
grade_summary=grade_summary,
|
||||
dropped_edit_chainages=dropped,
|
||||
)
|
||||
except FileNotFoundError as 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))
|
||||
relative = str(row["longitudinal_file_path"])
|
||||
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:
|
||||
await connection.begin()
|
||||
await update_longitudinal_grade_summary(
|
||||
|
||||
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
|
||||
route_id: int
|
||||
profile_alignment: dict[str, Any]
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
|
||||
dropped_edit_chainages: list[float] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
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";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -103,20 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
|
||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
||||
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김.
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
|
||||
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
|
||||
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
|
||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
||||
irregularStations
|
||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
||||
.forEach((station) => {
|
||||
const same = stations.find(
|
||||
(next) =>
|
||||
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
|
||||
!oldKeys.has(next.chainage_m.toFixed(3)),
|
||||
);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
const gone = irregularStations.filter(
|
||||
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
|
||||
);
|
||||
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||
gone.forEach((station) => {
|
||||
const same = followedStation(station, gone, born);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
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) 와 짝. */
|
||||
const SAME_STATION_SNAP_M = 0.006;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user