From ccccb9f5273147481dfac221800836db55da0b02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:26:18 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7?= =?UTF-8?q?=20=EA=B4=80=20=EC=9E=90=EB=A6=AC):=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=EC=A2=8C=ED=91=9C=20=EB=94=B0=EB=9D=BC=EA=B0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EC=A7=9D=20id=20=C2=B7=20B06=20=EA=B4=80?= =?UTF-8?q?=20=EC=97=B4=EC=87=A0=20pipe=20(410=20=C2=B7=20411-B=20=C2=B7?= =?UTF-8?q?=20411-D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌표 낡음 판정 — 좌표가 그 누가거리를 안 낳으면(마커 · 방향키 · 폼으로 누가거리만 옮김) 노선 위 자리로 다시 적음 · TS placeOnLine ↔ 파이썬 fill_structure_coordinates 짝(보낸 좌표가 누가거리를 낳으면 살림) - 따라감 단계 — 좌표 없는 관 초안은 앞 따라감 좌표 · site 간격 넘김 - 배수 편집기 — 노선 초안 follow 가 다시 매긴 관 자리를 id 짝으로 받음(시설 속성 · 고름 함께) · 초안 버림 = 저장분 다시 읽음 - 관 짝 차례 → id — 관 알림에 고정 id · followPipeReports · shownPipes(movePipeIds 한 번에 옮김) · followedMarks 같은 자리 먼저 - B06 측점 열쇠 — 서버 관 id 도 pipe: (계산 묶음과 같은 갈래) - 시험 test_410_follow_xy · test_411_b_pipe_id_pairing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipe_ids.ts | 29 ++- A00_Common/design/design_pipeline_page.ts | 11 +- A00_Common/design/design_view_profile.ts | 29 ++- B05_Profile/B05_Profile_Api_Structures.ts | 3 + B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 2 + .../B05_Profile_UI_Drainage_Panel_Types.ts | 4 + B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 28 +++ B05_Profile/B05_Profile_UI_Drainage_Report.ts | 3 +- .../B05_Profile_UI_Profile_Panel_Types.ts | 2 + .../logic/B05_Profile_Pipeline_Follow.ts | 17 +- .../logic/B05_Profile_Structure_Follow.ts | 20 +- B06_Section/B06_Section_Section_Drafts.ts | 2 +- B06_Section/B06_Section_Station_Keys.ts | 8 +- common_util/common_util_route_follow.py | 27 ++- common_util/common_util_route_follow.ts | 35 ++- resources/tester/helper_410_follow_xy.cjs | 50 +++++ resources/tester/helper_411_b_entry.ts | 18 ++ .../tester/helper_411_b_pipe_id_pairing.mjs | 129 +++++++++++ resources/tester/test_410_follow_xy.py | 203 ++++++++++++++++++ .../tester/test_411_b_pipe_id_pairing.py | 104 +++++++++ 20 files changed, 674 insertions(+), 50 deletions(-) create mode 100644 resources/tester/helper_410_follow_xy.cjs create mode 100644 resources/tester/helper_411_b_entry.ts create mode 100644 resources/tester/helper_411_b_pipe_id_pairing.mjs create mode 100644 resources/tester/test_410_follow_xy.py create mode 100644 resources/tester/test_411_b_pipe_id_pairing.py diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 7ce5f39a6..c24a2d3d1 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -58,19 +58,26 @@ export function pipeChainageOf(id: string, projectId: string | null = active): n /** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */ export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string { if (!projectId) return `pipe-${toM.toFixed(2)}`; + return movePipeIds([[fromM, toM]], projectId)[0] ?? pipeIdAt(toM, projectId); +} + +/** 관 여럿을 한 번에 옮김(노선 따라감 · 411-B) — 옮길 기록을 먼저 다 찾고 옮김(앞 관이 뒤 관 옛 자리로 가도 + * 뒤 관 기록을 안 지움) · 새 자리에 남은 옮기지 않는 옛 기록은 뺌 · 옮긴 관마다 id(못 찾으면 null). */ +export function movePipeIds( + moves: ReadonlyArray, + projectId: string | null = active, +): Array { + if (!projectId) return moves.map(() => null); const book = bookOf(projectId); - const moving = nearestWithin(book.ids, fromM, (entry) => entry.at); - if (!moving) return pipeIdAt(toM, projectId); - const stale = nearestWithin( - book.ids.filter((entry) => entry !== moving), - toM, - (entry) => entry.at, - ); + const moving = moves.map(([fromM]) => nearestWithin(book.ids, fromM, (entry) => entry.at)); + const to = new Map(moving.flatMap((entry, i) => (entry ? [[entry, moves[i][1]]] : []))); + const still = book.ids.filter((entry) => !to.has(entry)); + const stale = new Set([...to.values()].map((at) => nearestWithin(still, at, (e) => e.at))); const ids = book.ids - .filter((entry) => entry !== stale) - .map((entry) => (entry === moving ? { ...entry, at: toM } : entry)); - writeState("pipe-ids", { next: book.next, ids }, projectId); - return moving.id; + .filter((entry) => !stale.has(entry)) + .map((entry) => (to.has(entry) ? { ...entry, at: to.get(entry)! } : entry)); + if (to.size) writeState("pipe-ids", { next: book.next, ids }, projectId); + return moving.map((entry) => entry?.id ?? null); } /** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */ diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 99226eb25..a95ba7a9e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -91,7 +91,10 @@ import { draftSectionDetail, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; -import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { + buildStations, + SECTION_STATION_INTERVAL_M, +} from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; import { createMassHaulStage, @@ -315,10 +318,12 @@ function createPagePipeline( if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) const line = ctx.before.route?.line ?? savedLine() ?? []; const { pipes, structures } = ctx.inputs; + // 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410) + const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[]; const inputs = { ...ctx.inputs, - pipes: placed(pipes, line, savedPipes as FollowingStructure[]), - structures: placed(structures, line, []), + pipes: placed(pipes, line, [...(savedPipes as FollowingStructure[]), ...followed]), + structures: placed(structures, line, [], SECTION_STATION_INTERVAL_M), }; return followStage({ ...ctx, inputs }); }, diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index fd1dde7ec..e7adc9de9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { movePipeId, pipeIdAt } from "./design_pipe_ids"; +import { movePipeIds, pipeIdAt } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { @@ -113,18 +113,21 @@ export async function mountProfileView( /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; const designStore = useDesignStore(activeProjectId, null); - /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */ + /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도 + * 옮김 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 그 관) · id 없는 관은 안 옮김(막 놓은 관). */ let lastShown: PipeReport = []; const shownPipes = (pipes: PipeReport): PipeReport => { - const byChainage = (x: PipeReport[number], y: PipeReport[number]) => - x.chainage_m - y.chainage_m; - const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage); - const before = [...lastShown].sort(byChainage); - if (before.length === shown.length) - shown.forEach((pipe, i) => { - if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005) - movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId); - }); + const shown = followPipeReports(pipes, designStore.get("pipes")).sort( + (x, y) => x.chainage_m - y.chainage_m, + ); + const was = new Map(lastShown.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); + const moves = shown.flatMap((pipe) => { + const from = pipe.id ? was.get(pipe.id) : undefined; + return from === undefined || Math.abs(pipe.chainage_m - from) <= 0.005 + ? [] + : [[from, pipe.chainage_m] as const]; + }); + if (moves.length) movePipeIds(moves, activeProjectId); lastShown = shown; return shown; }; @@ -145,6 +148,10 @@ export async function mountProfileView( `구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`, "warning", ); + // 배수 편집기도 따라간 관 자리(410) — 초안을 버려 가게가 비면 저장분 다시 읽음 + const followed = designStore.get("pipes"); + if (followed) profilePanel.drainage.followPipes(followed); + else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload(); if (!host.visible()) return; if (lastPipes) applyPipes(lastPipes, false); bridge.takeStructures(designStore.get("structures")); diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 005ac7089..3a1deaa1c 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -128,6 +128,9 @@ export interface StructureInstance { summary_item?: SummaryItem | null; /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ group_items?: GroupItems | null; + /** 땅 위 자리(344-2 · 410) — 서버가 채움 · 누가거리만 옮기면 낡음(따라감 · 저장이 노선 위로 다시). */ + x?: number | null; + y?: number | null; } interface StructureTypesResponse { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 62e613a0f..295dc6546 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -59,6 +59,7 @@ import { basinIndexOfPipe, pipeMarkerColor, adoptDraftPipes, + followEditorPipes, reconcilePipes, WIDTH_KEY, type DrainageLayer, @@ -569,6 +570,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (projectId && meta) void run(loadSaved); }, pipeChainages: () => pipeEditor.chainages(), + followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes), movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 5a0586d03..f80f31a15 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -25,6 +25,8 @@ export interface DrainagePanel { setRoute: (points: ReadonlyArray) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ pipeChainages: () => number[]; + /** 노선 초안 follow 가 다시 매긴 관 자리를 받음(짝 = 관 id · 재계산 없음 · 410). */ + followPipes: (pipes: ReadonlyArray<{ id?: string | null; chainage_m: number }>) => void; /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ movePipe: (fromChainage: number, toChainage: number) => void; /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). @@ -64,6 +66,8 @@ export interface DrainagePanel { /** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */ export interface DrainagePipeReport { chainage_m: number; + /** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */ + id?: string; effective_diameter_mm: number | null; /** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */ design_flow_m3s?: number | null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index babcebadf..c58a3704f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -22,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; +import type { FacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { viewOf } from "../common_util/common_util_view_window"; @@ -341,6 +342,33 @@ export function mountDrainageToggles( ); } +/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id · 시설 속성 · + * 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */ +export function followEditorPipes( + editor: PipeEditor, + facilities: FacilityStore, + followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>, +): void { + const to = new Map(followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); + const before = editor.pipes(); + const moves = before.flatMap((pipe) => { + const at = pipe.id ? to.get(pipe.id) : undefined; + return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005 + ? [] + : [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }]; + }); + if (!moves.length) return; + const picked = before[editor.selected() ?? -1]; + moves.forEach(({ pipe }) => facilities.set(pipe.chainage_m, null)); // 다 뺀 뒤 얹음 — 자리 겹침 막음 + moves.forEach(({ at, attributes }) => attributes && facilities.set(at, attributes)); + const moved = new Map(moves.map(({ pipe, at }) => [pipe, at])); + editor.setPipes( + before.map((pipe) => ({ ...pipe, chainage_m: moved.get(pipe) ?? pipe.chainage_m })), + ); + const pickedAt = picked && (moved.get(picked) ?? picked.chainage_m); + if (picked) editor.select(editor.pipes().findIndex((pipe) => pipe.chainage_m === pickedAt)); +} + /** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다. * 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */ /** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index ea809b3a2..c26739c26 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -15,7 +15,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; * 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 * 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */ export function buildPipeReports( - pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>, + pipes: ReadonlyArray<{ chainage_m: number; reason?: string; id?: string }>, basins: ReadonlyArray, facilityStore: ReturnType, ): DrainagePipeReport[] { @@ -25,6 +25,7 @@ export function buildPipeReports( const attributes = facilityStore.get(chainage); return { chainage_m: chainage, + ...(pipe.id ? { id: pipe.id } : {}), // 관 고정 id — 옮겨도 짝(411-B) effective_diameter_mm: basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, // 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index c91aaa6c2..9c71a8d0d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -18,6 +18,8 @@ export interface RouteProfilePanelCallbacks { onPipesChanged?: ( pipes: Array<{ chainage_m: number; + /** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */ + id?: string; effective_diameter_mm: number | null; /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ design_flow_m3s?: number | null; diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts index 4d50e62f4..7db14dbe3 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts @@ -1,8 +1,8 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Pipeline_Follow.ts * 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안 - * (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라 - * 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침). + * (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 관 고정 id(411-B · 옮겨 차례가 바뀌어도 + * 그 관) · id 없는 관(막 놓은 관)끼리는 누가거리 차례 · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침). * 순수 계산(DOM · fetch · 세션 없음). * ========================================================================== */ @@ -10,6 +10,7 @@ interface Span { chainage_m: number; start_m?: number | null; end_m?: number | null; + id?: string | null; } /** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */ @@ -19,9 +20,15 @@ export function followPipeReports( ): R[] { if (!pipes || pipes.length !== reports.length) return [...reports]; const byChainage = (a: Span, b: Span): number => a.chainage_m - b.chainage_m; - const to = [...pipes].sort(byChainage); - return [...reports].sort(byChainage).map((report, index) => { - const pipe = to[index]; + const byId = new Map(pipes.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe]] : []))); + const paired = (report: R): boolean => Boolean(report.id && byId.has(report.id)); + const loose = [...pipes].filter((pipe) => !reports.some((r) => r.id && r.id === pipe.id)); + const to = loose.sort(byChainage); + const sorted = [...reports].sort(byChainage); + let next = 0; + return sorted.map((report) => { + const pipe = paired(report) ? byId.get(report.id!)! : to[next++]; + if (!pipe) return report; const chainage = Math.round(pipe.chainage_m * 100) / 100; // 알림 목록 자릿수(`buildPipeReports`) const shift = chainage - report.chainage_m; const moved = (own: number | null | undefined, from: number | null | undefined) => diff --git a/B05_Profile/logic/B05_Profile_Structure_Follow.ts b/B05_Profile/logic/B05_Profile_Structure_Follow.ts index e1fd44a99..3857aefc9 100644 --- a/B05_Profile/logic/B05_Profile_Structure_Follow.ts +++ b/B05_Profile/logic/B05_Profile_Structure_Follow.ts @@ -1,8 +1,8 @@ /* ============================================================================= * B05_Profile_Structure_Follow.ts * 노선 초안 동안 종단 알약 자리(PLAN 346-2 덧 3) — 계산 묶음 follow 가 좌표로 따라간 관 · 구조물의 새 - * 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt`) 라 묶음 관 id - * (서버 고정 id)와 다르면 누가거리 차례로 짝. + * 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt` · 자리 장부)라 + * 묶음 관 id(서버 고정 id)와 다르면 같은 자리(장부가 따라간 자리 = 그 관 · 411-B) → 남은 것끼리 누가거리 차례. * * 순수 — 화면 · fetch · 세션 없음. * ========================================================================== */ @@ -22,6 +22,9 @@ interface Moved extends Placed { structure_id?: string | null; } +/** 같은 관으로 볼 자리 차이(m) — `PIPE_MATCH_M`(design_selection) 같은 값. */ +const SAME_PIPE_M = 0.51; + const list = (value: unknown): Moved[] => (Array.isArray(value) ? (value as Moved[]) : []); const at = (item: Placed): number => item.chainage_m ?? item.start_m ?? 0; @@ -35,14 +38,21 @@ export function followedMarks( for (const item of list(structures)) if (item.structure_id) byId.set(item.structure_id, item); const movedPipes = list(pipes).filter((pipe) => typeof pipe.chainage_m === "number"); for (const pipe of movedPipes) if (pipe.id) byId.set(pipe.id, pipe); - // ponytail: 차례 짝 — 노선을 고쳐 관 차례가 뒤바뀌면 틀림 · 알약이 서버 관 id 를 들면 id 짝으로 const loose = marks .filter((mark) => mark.structure_id?.startsWith("pipe-") && !byId.has(mark.structure_id)) .sort((a, b) => at(a) - at(b)); const ids = new Set(marks.map((mark) => mark.structure_id)); - const free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b)); + let free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b)); const paired = new Map(); - if (loose.length === free.length) loose.forEach((mark, index) => paired.set(mark, free[index])); + for (const mark of loose) { + const same = free.find((pipe) => Math.abs(at(pipe) - at(mark)) < SAME_PIPE_M); + if (!same) continue; + paired.set(mark, same); + free = free.filter((pipe) => pipe !== same); + } + // ponytail: 남은 것은 차례 짝 — 알약이 옛 자리인 채 노선 고쳐 관 차례가 뒤바뀌면 틀림 + const rest = loose.filter((mark) => !paired.has(mark)); + if (rest.length === free.length) rest.forEach((mark, index) => paired.set(mark, free[index])); return marks.map((mark) => { const moved = (mark.structure_id ? byId.get(mark.structure_id) : undefined) ?? paired.get(mark); if (!moved) return mark; diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index acf760890..856a372a5 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -182,7 +182,7 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); - useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3) + useStationOwners(projectId, planned, pipes); // 측점 열쇠 = 덮는 구조물 id(344-3) · 관 = pipe: const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index f6c1cb6ae..f28f8f3e3 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -58,17 +58,21 @@ let current: { projectId: string; routeId: number } | null = null; const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; -/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ +/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 = `pipe-` 머리(세션 · 자리 이름) 또는 `pipes` 의 + * 서버 관 id(`p8017` · 계산 묶음 측점 열쇠 `pipe:p8017` 과 같은 규칙 · 411-D). */ export function useStationOwners( projectId: string, planned: ReadonlyArray<{ chainage_m: number; owners: string[]; role?: StationKeyRole }>, + pipes: ReadonlyArray<{ id?: string | null }> = [], ): void { + const pipeIds = new Set(pipes.map((pipe) => pipe.id)); owners.set( projectId, planned.flatMap((station) => station.owners.slice(0, 1).map((id) => ({ id, - kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), + kind: + id.startsWith("pipe-") || pipeIds.has(id) ? ("pipe" as const) : ("structure" as const), at: station.chainage_m, role: station.role ?? "anchor", })), diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 6d4acddcf..5eaebca5e 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -5,12 +5,12 @@ 새 노선에서 `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 멀면 「노선에서 멀어짐」 표시만(버리지 않음). - `fill_structure_coordinates` — 저장 때 노선 위 기준 자리로 x · y 채움(자리가 그대로면 옛 좌표 - 지킴) · 옛 자료는 읽을 때 없는 것만 채움. + 지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움. - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리. 짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints` -(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · 거울 시험 = -`resources/tester/test_344_2_route_pairs.py`. +(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음 +판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`. """ from __future__ import annotations @@ -20,6 +20,7 @@ from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point from common_util.common_util_structure_station import ( + SAME_STATION_M, route_general_stations, snap_existing_station, snap_structure_station, @@ -41,6 +42,20 @@ def _positions(item: Any) -> tuple[Any, ...]: return tuple(getattr(item, key, None) for key in _POSITION_KEYS) +def _holds_station(item: Any, line: LineString) -> bool: + """좌표가 그 기준 측점을 낳는가 — 같은 노선 따라감이 누가거리를 안 옮김(TS `holdsStation`).""" + at = float(line.project(Point(float(item.x), float(item.y)))) + length = float(line.length) + target = ( + snap_existing_station(at, route_general_stations(length)) + if getattr(item, "placement", None) == "site" + else snap_structure_station(at, [length]) + ) + anchor = float(item.chainage_m) + shift = target - anchor + return abs(min(max(anchor + shift, 0.0), length) - anchor) <= SAME_STATION_M + + def fill_structure_coordinates( items: Iterable[Any], xy: Sequence[Sequence[float]], @@ -51,7 +66,9 @@ def fill_structure_coordinates( """구조물 x · y 를 노선 위 기준 자리(`chainage_m`)로 채운다(제자리) · 채운 수. `previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다 - (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). `only_missing` = 읽을 때. + (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를 + 낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은 + 좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때. """ line = _line(xy) if line is None: @@ -68,6 +85,8 @@ def fill_structure_coordinates( if old is not None and _positions(old) == _positions(item) and old.x is not None: item.x, item.y = old.x, old.y continue + if has_xy and _holds_station(item, line): + continue point = line.interpolate(float(anchor)) item.x, item.y = round(float(point.x), 4), round(float(point.y), 4) filled += 1 diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 180a79279..9641471c5 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -9,8 +9,10 @@ * - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 · * 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음). * - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`. - * - `placeOnLine` · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 전 자리로 · - * 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). + * - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 · + * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. + * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 + * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ @@ -53,18 +55,35 @@ export function projectOnPolyline( return best && walked > 0 ? { ...best, length_m: walked } : null; } -/** 좌표 없는 관 · 구조물(세션 초안 = 정본 PUT 꼴 · 좌표 뺌)에 자리를 붙인 사본 — 같은 id · 같은 누가거리인 - * `saved`(정본 · 좌표 있음)가 있으면 그 좌표(안 옮긴 것 = 서버 따라감과 같은 자리) · 없으면 그 누가거리의 - * `line` 위 자리(연장 밖은 끝 선분 위로 자름). 좌표가 있거나 누가거리가 없으면 그대로(같은 객체). */ +/** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬 + * `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */ +const holdsStation = ( + item: FollowingStructure, + line: ReadonlyArray, + intervalM: number | null, +): boolean => { + const [moved] = followStructures([item], line, Infinity, intervalM); + return ( + moved.drift_m === null || Math.abs(moved.item.chainage_m! - item.chainage_m!) <= SAME_STATION_M + ); +}; + +/** 관 · 구조물에 자리를 붙인 사본 — 좌표가 그 누가거리를 낳으면 그대로(같은 객체 · 410) · 좌표 없음(세션 관 + * 초안 = 정본 PUT 꼴 · 좌표 뺌)이면 같은 id · 같은 누가거리인 `saved`(정본 · 앞 따라감 · 좌표 있음)의 좌표(안 + * 옮긴 것 = 서버 따라감과 같은 자리) · 그 밖(옮김 · 좌표 낡음)은 그 누가거리의 `line` 위 자리(연장 밖은 끝 선분 + * 위로 자름 = 옮긴 자리가 새 기준). 누가거리가 없으면 그대로. `intervalM` = site 맞춤 간격(`followStructures`). */ export function placeOnLine( items: ReadonlyArray, line: ReadonlyArray, saved: ReadonlyArray = [], + intervalM: number | null = null, ): S[] { const byId = new Map(saved.map((item) => [item.id, item])); return items.map((item) => { - if ((item.x != null && item.y != null) || item.chainage_m == null) return item; - const was = item.id ? byId.get(item.id) : undefined; + if (item.chainage_m == null) return item; + const placed = item.x != null && item.y != null; + if (placed && holdsStation(item, line, intervalM)) return item; + const was = !placed && item.id ? byId.get(item.id) : undefined; if ( was?.x != null && was.y != null && @@ -196,11 +215,13 @@ export const placedOnLine = ( list: unknown, line: ReadonlyArray<[number, number]>, saved: FollowingStructure[], + intervalM: number | null = null, ): unknown => Array.isArray(list) ? placeOnLine( list as FollowingStructure[], line.map(([x, y]) => ({ x, y })), saved, + intervalM, ) : list; diff --git a/resources/tester/helper_410_follow_xy.cjs b/resources/tester/helper_410_follow_xy.cjs new file mode 100644 index 000000000..089cc32fb --- /dev/null +++ b/resources/tester/helper_410_follow_xy.cjs @@ -0,0 +1,50 @@ +/* 410 좌표 따라감 거울 — 진짜 TS(`common_util_route_follow`)를 Node 로 돌림. + * 입력 argv[2] JSON = { lines: [L1, L2, ...], structures, pipes, followed } · 표준출력 JSON 한 벌. + * 차례(구조물) = L1 에서 자리 붙임(placeOnLine) → L2 따라감 → L2 에서 자리 붙임 → L3 따라감 … + * 관 = 좌표 없는 초안 + 앞 따라감(followed) → L1 자리 붙임 → L2 투영. + */ +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, stubs = {}) { + 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) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const catalog = require("./helper_322_catalog_module.cjs"); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": catalog, +}); +const follow = loadTs("common_util/common_util_route_follow.ts", { + "./common_util_structure_station": station, +}); + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const lines = input.lines.map((line) => line.map(([x, y]) => ({ x, y }))); +const steps = []; +let items = input.structures; +for (let i = 0; i + 1 < lines.length; i += 1) { + const placed = follow.placeOnLine(items, lines[i], [], input.interval); + items = follow + .followStructures(placed, lines[i + 1], undefined, input.interval) + .map((moved) => moved.item); + steps.push({ placed, followed: items }); +} +const pipesPlaced = follow.placeOnLine(input.pipes, lines[0], input.followed); +const out = { + steps, + pipesPlaced, + pipesFollowed: follow.projectPipePoints(pipesPlaced, lines[1]), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_411_b_entry.ts b/resources/tester/helper_411_b_entry.ts new file mode 100644 index 000000000..eec78f5c4 --- /dev/null +++ b/resources/tester/helper_411_b_entry.ts @@ -0,0 +1,18 @@ +/* 411-B · 411-D 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. + * 관 짝(id) · 관 세션 id 장부 옮김 · 알약 따라감 짝 · 배수 편집기 따라감 · B06 측점 열쇠 갈래를 화면 · 서버 없이 잰다. + */ +export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; +export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow"; +export { + movePipeId, + movePipeIds, + pipeChainageOf, + pipeIdAt, + usePipeIds, +} from "../../A00_Common/design/design_pipe_ids"; +export { stationKeysOf, useStationOwners } from "../../B06_Section/B06_Section_Station_Keys"; +export { followEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts"; +export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility"; +export { planStations } from "../../common_util/common_util_structure_station"; +export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes"; +export { stationKey as pipelineStationKey } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs new file mode 100644 index 000000000..7bc24f426 --- /dev/null +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -0,0 +1,129 @@ +/* 411-B · 411-D 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_411_b_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_411_b_pipe_id_pairing.mjs <묶음.mjs> + * 사용자 길: 관 p2 를 앞 관 p1 을 지나 옮김(150 → 80) · 노선 따라감이 두 관을 함께 밂. + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); + +const m = await import(pathToFileURL(process.argv[2]).href); +const out = {}; +const P = "p411"; +m.usePipeIds(P); + +/* 1. 알림 목록 ↔ 가게 관 초안 짝 = id — 옮겨 차례가 뒤바뀌어도 그 관 */ +const reports = [ + { id: "p10000", chainage_m: 100, facility: "pipe" }, + { id: "p15000", chainage_m: 150, facility: "pipe", start_m: 145, end_m: 155 }, +]; +const draft = [ + { id: "p15000", chainage_m: 80 }, + { id: "p10000", chainage_m: 100 }, +]; +out.reports = m + .followPipeReports(reports, draft) + .map((r) => [r.id, r.chainage_m, r.start_m ?? null]); + +/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) 뒤 화면 목록 짝(shownPipes 규칙 = id) */ +const idA = m.pipeIdAt(100, P); +const idB = m.pipeIdAt(150, P); +m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로 +const was = new Map(reports.map((r) => [r.id, r.chainage_m])); +const shown = m.followPipeReports(reports, draft); +const moves = shown.flatMap((r) => { + const from = was.get(r.id); + return from === undefined || Math.abs(from - r.chainage_m) <= 0.005 ? [] : [[from, r.chainage_m]]; +}); +m.movePipeIds(moves, P); +out.ids = { idA, idB, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) }; +out.bookNext = m.pipeIdAt(999, P); // 새 관 = 다음 차례(앞에서 헛 id 가 안 생겼으면 s3) + +/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 한 번에 옮김 · 기록 안 잃음 */ +const Q = "q411"; +const q1 = m.pipeIdAt(100, Q); +const q2 = m.pipeIdAt(103, Q); +m.movePipeIds( + [ + [100, 103], + [103, 106], + ], + Q, +); +out.batch = { + q1, + q2, + at103: m.pipeIdAt(103, Q), + at106: m.pipeIdAt(106, Q), + next: m.pipeIdAt(500, Q), +}; + +/* 4. 알약 따라감 — 알약이 이미 따라간 자리면 같은 자리 짝(차례 뒤바뀜에도 그 관) */ +const marks = [ + { structure_id: idB, chainage_m: 80 }, + { structure_id: idA, chainage_m: 100 }, + { structure_id: "st-1", chainage_m: 40 }, +]; +out.marks = m + .followedMarks(marks, [{ structure_id: "st-1", chainage_m: 42 }], draft) + .map((mk) => [mk.structure_id, mk.chainage_m]); + +/* 5. 배수 편집기 — 따라간 관 자리(id 짝)를 받음 · 시설 속성 · 고름도 따라감 */ +const editor = m.createPipeEditor(() => {}); +editor.setPipes([ + { chainage_m: 100, reason: "stream", id: "p10000" }, + { chainage_m: 150, reason: "confirmed", id: "p15000" }, +]); +editor.select(1); +const facilities = m.createFacilityStore(); +facilities.set(150, { facility: "ford", options: { 월류높이: 0.3 } }); +m.followEditorPipes(editor, facilities, [ + { id: "p10000", chainage_m: 103 }, + { id: "p15000", chainage_m: 98 }, +]); +out.editor = { + pipes: editor.pipes().map((p) => [p.id, p.chainage_m, p.reason]), + selected: editor.pipes()[editor.selected()]?.id ?? null, + ford98: facilities.get(98)?.facility ?? null, + at150: facilities.get(150), +}; +m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로 +out.editorSame = editor.pipes().map((p) => p.chainage_m); + +/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */ +const planned = [ + { chainage_m: 80.17, owners: ["p8017"], role: "anchor" }, + { chainage_m: 52.5, owners: ["st-1"], role: "anchor" }, +]; +const sections = [ + { chainage_m: 52.5, kind: "irregular" }, + { chainage_m: 80.17, kind: "irregular" }, +]; +m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]); +out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key); +m.useStationOwners(P, planned); +out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key); +out.pipelineKey = m.pipelineStationKey("pipe", "p8017"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_410_follow_xy.py b/resources/tester/test_410_follow_xy.py new file mode 100644 index 000000000..a0cd394c6 --- /dev/null +++ b/resources/tester/test_410_follow_xy.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""410 구조물 · 관 자리 = 등고선 좌표(x · y) 지킴 — 교점 옮김 · R 바꿈 뒤 좌표 그대로 · +누가거리 다시 매김. + +거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값): + ① 좌표 낡음 판정 — `common_util_route_follow.fill_structure_coordinates` ↔ TS `placeOnLine` + (좌표가 그 누가거리를 낳으면 살림 · 누가거리만 옮긴 구조물 = 그 누가거리의 노선 위 자리). + ② 따라감 — `follow_structures` ↔ `followStructures`(L1 → L2 교점 옮김 → L3 곡선 바꿈). + ③ 관 — 좌표 없는 초안 관은 앞 따라감(같은 id · 같은 자리)의 좌표 · 옮긴 관은 노선 위 자리. +핵심: 마커로 옮긴 구조물은 다음 노선 고침에 옛 자리로 안 튐 · 안 옮긴 구조물 · 관은 좌표 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from shapely.geometry import LineString, Point + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 +from common_util.common_util_route_follow import ( # noqa: E402 + fill_structure_coordinates, + follow_structures, +) +from common_util.common_util_route_geometry import RouteVertex # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +L1 = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)] +L2 = [(0.0, 0.0), (100.0, 12.0), (200.0, 50.0)] # 교점 옮김 +L3 = [(0.0, 0.0), (85.0, 4.0), (100.0, 10.0), (115.0, 18.0), (200.0, 50.0)] # 곡선(R) 바꿈 +LINES = [L1, L2, L3] +INTERVAL = 20.0 + + +def _at(line, chainage, offset=0.0): + """노선 위 누가거리 자리 + 왼쪽 법선 offset(m).""" + geo = LineString(line) + p = geo.interpolate(chainage) + q = geo.interpolate(min(chainage + 0.01, geo.length)) + dx, dy = q.x - p.x, q.y - p.y + norm = (dx * dx + dy * dy) ** 0.5 or 1.0 + return p.x - dy / norm * offset, p.y + dx / norm * offset + + +def _structure(sid, placement, chainage, xy, start=None, end=None): + return { + "structure_id": sid, + "placement": placement, + "chainage_m": chainage, + "start_m": start, + "end_m": end, + "x": xy[0], + "y": xy[1], + } + + +# S1 = 노선 밖 5 m 땅 자리(안 옮김) · S2 = 140 m 에 놓았다 마커로 60 m 로 옮김(좌표 옛 값) · +# S3 = 구간(안 옮김 · 3 m 밖) · S4 = 사토장(측점 80 · 땅 자리 84 m) · +# S5 = 구간을 폼으로 옮김(좌표 옛 값) +STRUCTURES = [ + _structure("S1", "point", 50.0, _at(L1, 50.0, 5.0)), + _structure("S2", "point", 60.0, _at(L1, 140.0)), + _structure("S3", "interval", 120.0, _at(L1, 120.0, -3.0), 110.0, 135.0), + _structure("S4", "site", 80.0, _at(L1, 84.0, 2.0)), + _structure("S5", "interval", 30.0, _at(L1, 170.0), 25.0, 40.0), +] + +# 관 — p1 = 앞 따라감 그대로(좌표 없는 초안 · 같은 자리) · p2 = 초안에서 옮김(앞 따라감과 자리 다름) +FOLLOWED = [ + {"id": "p1", "chainage_m": 70.0, "x": _at(L1, 70.0, 1.5)[0], "y": _at(L1, 70.0, 1.5)[1]}, + {"id": "p2", "chainage_m": 150.0, "x": _at(L1, 150.0)[0], "y": _at(L1, 150.0)[1]}, +] +PIPES = [{"id": "p1", "chainage_m": 70.0}, {"id": "p2", "chainage_m": 30.0}] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s410") / "in.json" + source.write_text( + json.dumps( + { + "lines": LINES, + "structures": STRUCTURES, + "interval": INTERVAL, + "pipes": PIPES, + "followed": FOLLOWED, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_410_follow_xy.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) + + +@pytest.fixture(scope="module") +def py() -> list[dict]: + """서버 차례 — 저장(좌표 채움 · L1) → 노선 저장(따라감 · L2) → 저장(L2) → 노선 저장(L3).""" + items = [SimpleNamespace(**item) for item in STRUCTURES] + steps = [] + for index in range(len(LINES) - 1): + fill_structure_coordinates(items, LINES[index]) + placed = [dict(vars(item)) for item in items] + follow_structures(items, LINES[index + 1]) + steps.append({"placed": placed, "followed": [dict(vars(item)) for item in items]}) + return steps + + +def _by_id(rows): + return {row["structure_id"]: row for row in rows} + + +def _close(a, b, tol=1e-3): + return (a is None and b is None) or abs(float(a) - float(b)) <= tol + + +@pytest.mark.parametrize("step", [0, 1]) +def test_파이썬_TS_같은_값(ts, py, step): + for phase in ("placed", "followed"): + got, want = _by_id(ts["steps"][step][phase]), _by_id(py[step][phase]) + for sid, row in want.items(): + for key in ("chainage_m", "start_m", "end_m", "x", "y"): + assert _close(got[sid].get(key), row[key]), (step, phase, sid, key) + + +def test_안_옮긴_구조물_좌표_그대로(py): + for sid in ("S1", "S3", "S4"): + first = _by_id(py[0]["placed"])[sid] + for step in py: + for phase in ("placed", "followed"): + now = _by_id(step[phase])[sid] + assert _close(now["x"], first["x"]) and _close(now["y"], first["y"]), sid + + +def test_누가거리는_새_노선_투영(py): + for step, line in zip(py, LINES[1:]): + geo = LineString(line) + for sid in ("S1", "S2"): + row = _by_id(step["followed"])[sid] + assert row["chainage_m"] == float(round(geo.project(Point(row["x"], row["y"])))) + # 사토장 = 새 노선 일반 측점 중 가장 가까운 곳 + site = _by_id(step["followed"])["S4"] + assert site["chainage_m"] % INTERVAL == 0 + + +def test_마커로_옮긴_구조물은_옛_자리로_안_튐(py): + moved_xy = _at(L1, 60.0) + for step in py: + for phase in ("placed", "followed"): + row = _by_id(step[phase])["S2"] + assert _close(row["x"], moved_xy[0]) and _close(row["y"], moved_xy[1]) + assert 50 <= row["chainage_m"] <= 70 # 옛 자리 140 m 근처로 안 돌아감 + form = _by_id(py[1]["followed"])["S5"] + assert 20 <= form["chainage_m"] <= 40 and form["end_m"] - form["start_m"] == 15 + + +def test_서버는_보낸_좌표가_누가거리를_낳으면_살림(): + # 노선 고친 초안에서 옮긴 구조물 — 옛 정본과 자리가 달라도 보낸 좌표(노선 밖 4 m)가 + # 그 누가거리를 낳음 + xy = _at(L2, 90.0, 4.0) + item = SimpleNamespace(**_structure("S9", "point", 90.0, xy)) + old = SimpleNamespace(**_structure("S9", "point", 140.0, _at(L2, 140.0))) + fill_structure_coordinates([item], L2, previous={"S9": old}) + assert (item.x, item.y) == xy + stale = SimpleNamespace(**_structure("S9", "point", 30.0, xy)) # 누가거리만 옮김 = 낡은 좌표 + fill_structure_coordinates([stale], L2, previous={"S9": old}) + assert _close(stale.x, _at(L2, 30.0)[0]) and _close(stale.y, _at(L2, 30.0)[1]) + + +def test_관_초안은_앞_따라감_좌표_옮긴_관은_노선_위(ts): + placed = {row["id"]: row for row in ts["pipesPlaced"]} + assert _close(placed["p1"]["x"], FOLLOWED[0]["x"]) and _close( + placed["p1"]["y"], FOLLOWED[0]["y"] + ) + assert _close(placed["p2"]["x"], _at(L1, 30.0)[0]) and _close(placed["p2"]["y"], 0.0) + vertices = [RouteVertex(x=x, y=y, z=0.0, chainage_m=0.0) for x, y in L2] + points = [ + PipePoint(chainage_m=70.0, x=FOLLOWED[0]["x"], y=FOLLOWED[0]["y"], id="p1"), + PipePoint(chainage_m=30.0, x=_at(L1, 30.0)[0], y=0.0, id="p2"), + ] + want = {point.id: point.chainage_m for point in project_pipe_points(points, vertices)} + got = {row["id"]: row["chainage_m"] for row in ts["pipesFollowed"]} + assert got == want diff --git a/resources/tester/test_411_b_pipe_id_pairing.py b/resources/tester/test_411_b_pipe_id_pairing.py new file mode 100644 index 000000000..368b400b1 --- /dev/null +++ b/resources/tester/test_411_b_pipe_id_pairing.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""411-B · 411-D 관 짝 = id — 앞 관을 지나 옮겨도 알약 · 측점 수 · id 그대로 · +B06 열쇠 = 계산 묶음과 같음. + +사용자 길(요구 252-6): 관 마커를 앞 관을 지나 옮김 → 옛 차례 짝이 이웃 id 를 빼앗고 +새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로 +묶어(`helper_411_b_entry.ts`) Node 로 잰다: + ① `followPipeReports` 짝 = 관 고정 id + ② 세션 id 장부(`movePipeId` · `movePipeIds`) + ③ `followedMarks` + ④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음) + ⑤ B06 `useStationOwners` · `stationKeysOf`. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b411") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_411_b_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_411_b_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_411_b_pipe_id_pairing.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_알림_목록_짝은_id(out): + # 옮긴 관(p15000)이 앞 관 자리를 빼앗지 않음 · 구간도 그 관과 함께 밂 + assert sorted(out["reports"]) == [["p10000", 100, None], ["p15000", 80, 75]] + + +def test_앞_관_지나_옮겨도_세션_id_그대로(out): + ids = out["ids"] + assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id + assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님) + assert out["bookNext"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로 + + +def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out): + batch = out["batch"] + assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"] + assert batch["next"] == "pipe-s3" + + +def test_알약은_그_관_자리(out): + assert out["marks"] == [["pipe-s2", 80], ["pipe-s1", 100], ["st-1", 42]] + + +def test_배수_편집기는_따라간_자리를_받음(out): + editor = out["editor"] + assert editor["pipes"] == [["p15000", 98, "confirmed"], ["p10000", 103, "stream"]] + assert editor["selected"] == "p15000" # 고름도 그 관 + assert editor["ford98"] == "ford" and editor["at150"] is None # 시설 속성도 새 자리 + assert out["editorSame"] == [98, 103] + + +def test_B06_열쇠는_서버_관_id_도_pipe(out): + assert out["keys"] == ["structure:st-1", "pipe:p8017"] + assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음 + assert out["keysNoPipes"][1] == "structure:p8017" # 관 목록을 안 주면 옛 갈래(대조)