From 93e86f87c664d021d1a424a212bb7097ae45b05d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 19:53:24 +0900 Subject: [PATCH] =?UTF-8?q?fix(b05,b06):=20=EC=B8=A1=EC=A0=90=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=8C=20=E2=80=94=20=EC=A0=95=EC=88=98=20m=20=C2=B7=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20m=20=EC=B8=A1=EC=A0=90=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=A7=9E=EC=B6=A4=20(272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 규칙: 구조물 · 관 기준 측점 = 정수 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 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- A00_Common/design/design_pick_profile.ts | 19 +- A00_Common/design/design_profile.ts | 66 ++++-- A00_Common/design/design_view_profile.ts | 10 + B05_Profile/B05_Profile_Engine_Sections.py | 12 +- .../B05_Profile_Engine_Sections_Core.py | 9 +- B05_Profile/B05_Profile_Repository.py | 39 ++++ B05_Profile/B05_Profile_Router_Confirm.py | 108 ++++++++-- B05_Profile/B05_Profile_Schema.py | 11 +- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 2 +- B05_Profile/B05_Profile_UI_Page_Stations.ts | 9 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 125 ++++++++---- B05_Profile/B05_Profile_UI_Profile_Guard.ts | 42 +--- B05_Profile/B05_Profile_UI_Profile_Render.ts | 14 +- .../B05_Profile_UI_Structures_Fields.ts | 10 +- .../B05_Profile_UI_Structures_Panel.ts | 22 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 13 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 2 + .../B05_Profile_UI_Structures_Pavement.ts | 6 +- B05_Profile/B05_Profile_UI_Structures_Snap.ts | 87 +++----- .../B05_Profile_UI_Style_Structures.css | 12 -- B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Router_Confirm.py | 30 +-- B06_Section/B06_Section_Router_Stations.py | 79 +++++-- B06_Section/B06_Section_Section_Store.ts | 62 +++++- common_util/common_util_structure_station.py | 33 +-- common_util/common_util_structure_station.ts | 181 +++++++++++++--- .../tester/helper_234_1_structure_station.cjs | 15 +- .../tester/helper_272_1_station_list.cjs | 29 +++ .../tester/helper_b05_station_roundtrip.cjs | 6 +- .../tester/test_234_1_structure_station.py | 39 ++-- ...est_238_1_save_keeps_structure_stations.py | 1 + resources/tester/test_272_1_station_list.py | 185 +++++++++++++++++ resources/tester/test_272_2_station_sync.py | 193 ++++++++++++++++++ .../tester/test_b05_b06_save_roundtrip.py | 10 + .../test_b05_extra_stations_supplier.py | 4 +- .../tester/test_b05_station_roundtrip.py | 6 +- resources/tester/test_b06_patch_station.py | 17 +- 37 files changed, 1147 insertions(+), 363 deletions(-) create mode 100644 resources/tester/helper_272_1_station_list.cjs create mode 100644 resources/tester/test_272_1_station_list.py create mode 100644 resources/tester/test_272_2_station_sync.py diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index 13c71b83e..f03582571 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -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 diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 7dda48d16..ce63338b4 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -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(); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 2c2113c78..218c5cfdd 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index c2c8c88c8..763eb0cd1 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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) diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 722796819..2505e8535 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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 = [] diff --git a/B05_Profile/B05_Profile_Repository.py b/B05_Profile/B05_Profile_Repository.py index 156c39dcc..540348c59 100644 --- a/B05_Profile/B05_Profile_Repository.py +++ b/B05_Profile/B05_Profile_Repository.py @@ -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: diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 97668b447..ea39175f2 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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"]) + ) diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 804107d7f..0578c7334 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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): diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 88036c07d..4875593c6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -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 += diff --git a/B05_Profile/B05_Profile_UI_Page_Stations.ts b/B05_Profile/B05_Profile_UI_Page_Stations.ts index da83c9639..5f6a9a3d1 100644 --- a/B05_Profile/B05_Profile_UI_Page_Stations.ts +++ b/B05_Profile/B05_Profile_UI_Page_Stations.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 70d0400fa..93c06881b 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -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; + /** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(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 { 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): 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(); + 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). diff --git a/B05_Profile/B05_Profile_UI_Profile_Guard.ts b/B05_Profile/B05_Profile_UI_Profile_Guard.ts index 6314fb40d..d909d0821 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Guard.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Guard.ts @@ -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 }; - }); -} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index b940a155a..67488241b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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`)와 본문 폭이 곧 보이는 구간이다. diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index d1bb93c0d..a0884d884 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -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), diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 1240ec5d3..09f7e76dc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 66e2b3966..7e3c6c64b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -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); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 676d199a2..71c974d11 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -154,6 +154,8 @@ export interface StructuresSection { /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ listRoot: HTMLElement; + /** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(종단 보기를 열 때 한 번 · 167) — 옮긴 수. */ + snapOld: () => number; /** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고, * 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */ setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void; diff --git a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts index ed3a7805d..5b94d9e66 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts @@ -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 }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index 3f29a57ac..982a3e666 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -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; @@ -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 { +/** 옛 소수 측점(관 · 구조물)을 정수 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; } diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 8345e861e..5f96903d1 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -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); -} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index acbf38e0a..1de0da091 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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 — 곡선부 확폭·법정 최소반경 판정용. */ diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 061bbd2e7..eff3ebc12 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -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)) # 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면 diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 3c8172d2c..8b61400dc 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -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") diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index d194c8c29..b979a8f86 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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 { 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>["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 { + 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); diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 79eb2cdd3..63d2b8622 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -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) diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 4c5cc21d2..c6a31545d 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -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, + structures: ReadonlyArray, + types: ReadonlyArray, +): 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( + server: ReadonlyArray, + planted: ReadonlyArray

    , +): Array { + 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( + nodes: ReadonlyArray, + gone: ReadonlyArray, + born: ReadonlyArray, +): 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 }]; + }); +} diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index e6717ba84..87fff283b 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -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"); diff --git a/resources/tester/helper_272_1_station_list.cjs b/resources/tester/helper_272_1_station_list.cjs new file mode 100644 index 000000000..d446f7156 --- /dev/null +++ b/resources/tester/helper_272_1_station_list.cjs @@ -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)); diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs index a967df824..fee14a6c5 100644 --- a/resources/tester/helper_b05_station_roundtrip.cjs +++ b/resources/tester/helper_b05_station_roundtrip.cjs @@ -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"; diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py index 885ec58af..4bbf71e9a 100644 --- a/resources/tester/test_234_1_structure_station.py +++ b/resources/tester/test_234_1_structure_station.py @@ -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 diff --git a/resources/tester/test_238_1_save_keeps_structure_stations.py b/resources/tester/test_238_1_save_keeps_structure_stations.py index d2ae972af..23b2baa33 100644 --- a/resources/tester/test_238_1_save_keeps_structure_stations.py +++ b/resources/tester/test_238_1_save_keeps_structure_stations.py @@ -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: diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py new file mode 100644 index 000000000..f4a03d24b --- /dev/null +++ b/resources/tester/test_272_1_station_list.py @@ -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 + ] diff --git a/resources/tester/test_272_2_station_sync.py b/resources/tester/test_272_2_station_sync.py new file mode 100644 index 000000000..34a2163a6 --- /dev/null +++ b/resources/tester/test_272_2_station_sync.py @@ -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 diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index 7bddfb6f8..1ead3975f 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -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): diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 9aa2c9c9d..fd7c24c47 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -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(): diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 6c8ad7142..0c82da4d2 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -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 diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py index 9abb3e398..ad7a80cb0 100644 --- a/resources/tester/test_b06_patch_station.py +++ b/resources/tester/test_b06_patch_station.py @@ -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]