From 401c53251be7563b8c3075ce88e19787fba42821 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 15:01:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B3=84=EC=82=B0=EB=AC=B6=EC=9D=8C):=203?= =?UTF-8?q?49-0d=20=EB=85=B8=EC=84=A0=EC=9D=84=20=EB=86=93=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EA=B4=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=83=88=20=EC=B8=A1=EC=A0=90=EC=9D=84=20=EA=B0=80=EA=B2=8C?= =?UTF-8?q?=EC=97=90=20=EB=B0=94=EB=A1=9C=20=E2=80=94=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 노선 조작 뒤 follow 가 좌표로 다시 매긴 관 · 구조물 = 가게 초안(pipes · structures) · 놓은 틱 안에 써 되돌리기 한 걸음 · 묶음 다시 돎 0(가게 메아리는 입력으로 안 받음) - 초안을 버리면(되돌리기 · 버림) 초안 전 가게 값으로 · 열기만 하면 안 씀(349-0c) - 새 노선에서 20 m 넘게 먼 구조물 = 알림 한 줄(구조물 N건이 새 노선에서 20 m 넘게 멀어짐) - 종단 보기 왼쪽 목록 · 구조물 알약 · 측점선 = 가게 측점(배수유역도 관 알림을 가게 관 측점으로 · 관 id 옮김) · 따라간 측점은 계획고 편집 안 옮김(follow 몫) - 시험 test_349_0d_follow_store 5건(검증_sub7 노선 · 관 · 옛 코드 2 실패 재현) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipeline_page.ts | 84 ++++++- A00_Common/design/design_view_profile.ts | 38 ++- B05_Profile/B05_Profile_UI_Page_Structures.ts | 39 ++- .../logic/B05_Profile_Pipeline_Follow.ts | 36 +++ .../tester/helper_349_0d_follow_store.cjs | 233 ++++++++++++++++++ resources/tester/test_349_0d_follow_store.py | 202 +++++++++++++++ 6 files changed, 609 insertions(+), 23 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_Pipeline_Follow.ts create mode 100644 resources/tester/helper_349_0d_follow_store.cjs create mode 100644 resources/tester/test_349_0d_follow_store.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index ffe069533..93c4afb0e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -23,13 +23,18 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; import { createStationsStage } from "./logic/design_pipeline_stations"; import type { + FollowPatch, PipelineInputs, PipelineStation, RouteDraft, StageFn, Vertex, } from "./logic/design_pipeline_types"; -import { useDesignStore } from "./design_store"; +import { useDesignStore, type DesignStoreValues } from "./design_store"; +import { + followStructures, + type FollowingStructure, +} from "../../common_util/common_util_route_follow"; import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; @@ -112,6 +117,9 @@ export interface PagePipelineOptions { stationIntervalM?: number; /** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */ onCorridorStale?: () => void; + /** 노선 조작 뒤 follow 가 가게 관 · 구조물을 다시 씀(목록 · 알약 다시) — `far` = 새 노선에서 + * `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 먼 구조물 수(초안을 버려 되돌렸으면 0). */ + onFacilitiesFollowed?: (far: number) => void; } interface PagePipeline { @@ -124,6 +132,9 @@ interface PagePipeline { dispose(): void; } +/** 가게 관 · 구조물 초안(null = 저장분). */ +type Facilities = Pick; + const pages = new Map(); const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); @@ -191,6 +202,11 @@ function createPagePipeline( let disposed = false; /** 열 때 · 정본이 바뀐 차례(start) 번호 — 측점 · 종단을 가게에 안 냄(상세 = 저장 정본 그대로). */ let openGeneration = -1; + /** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */ + let beforeDraft: Facilities | null = null; + /** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */ + let written: Facilities | null = null; + let echo = false; const savedLine = (): Vertex[] | null => plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null; @@ -231,11 +247,14 @@ function createPagePipeline( }), follow: createFollowStage({ savedLine, - saved: () => ({ - pipes: store.get("pipes") ?? savedPipes, - structures: store.get("structures") ?? savedStructures, - alignment: savedEdits(), - }), + saved: () => { + const before = beforeDraft ?? facilitiesNow(); + return { + pipes: before.pipes ?? savedPipes, + structures: before.structures ?? savedStructures, + alignment: savedEdits(), + }; + }, }), stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }), ground: createGroundStage(groundStageDeps(projectId)), @@ -268,6 +287,36 @@ function createPagePipeline( const saved = event.kind === "stage" && (event.stage === "ground" || event.stage === "profile"); if (!(saved && event.generation === openGeneration)) publish(event); }); + // 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) · + // 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘) + pipe.subscribe((event) => { + if (event.kind !== "stage" || event.stage !== "follow") return; + if (event.generation === openGeneration) return; + const patch = event.value as FollowPatch; + queueMicrotask(() => { + if (event.generation !== pipe.generation) return; + if (!pipe.input("route")) { + if (!beforeDraft) return; + writeFacilities(beforeDraft); + beforeDraft = null; + return options.onFacilitiesFollowed?.(0); + } + const now = facilitiesNow(); + beforeDraft ??= now; + writeFacilities({ + pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes, + structures: (patch.structures as Facilities["structures"]) ?? now.structures, + }); + const line = pipe.output("route")?.line ?? []; + const far = Array.isArray(patch.structures) + ? followStructures( + patch.structures as FollowingStructure[], + line.map(([x, y]) => ({ x, y })), + ).filter((moved) => moved.far).length + : 0; + options.onFacilitiesFollowed?.(far); + }); + }); pipe.subscribe((event) => { if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return; const draft = pipe.input("route") !== null; @@ -294,6 +343,22 @@ function createPagePipeline( drawnDraft = draft; }); + function facilitiesNow(): Facilities { + return { pipes: store.get("pipes"), structures: store.get("structures") }; + } + /** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */ + function writeFacilities(next: Facilities): void { + echo = true; + try { + if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes); + if (!written || !same(written.structures, next.structures)) + store.set("structures", next.structures); + } finally { + echo = false; + } + written = next; + } + const inputsNow = (): PipelineInputs => { const route = store.get("routeDraft"); return { @@ -345,10 +410,13 @@ function createPagePipeline( .catch(() => undefined); const unsubscribe = [ - store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? savedPipes)), + store.subscribe( + "pipes", + (pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes), + ), store.subscribe( "structures", - (list) => started && void pipe.update("structure", list ?? savedStructures), + (list) => started && !echo && void pipe.update("structure", list ?? savedStructures), ), ]; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 21e23ccd5..a28ad3914 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,10 @@ 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 { pipeIdAt } from "./design_pipe_ids"; +import { movePipeId, pipeIdAt } from "./design_pipe_ids"; +import { useDesignStore } from "./design_store"; +import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; +import { STRUCTURE_ROUTE_DRIFT_MAX_M } from "../../common_util/common_util_route_follow"; import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, @@ -103,15 +106,43 @@ export async function mountProfileView( }); /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; - const applyPipes = (pipes: PipeReport): void => { + const designStore = useDesignStore(activeProjectId, null); + /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 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); + }); + lastShown = shown; + return shown; + }; + const applyPipes = (reported: PipeReport, carryEdits = true): void => { + const pipes = shownPipes(reported); // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. - bridge.setPipes(pipes); + bridge.setPipes(pipes, carryEdits); panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); profilePanel.setMinCoverPipes(pipes); snapOld(); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; + /** 노선을 놓아 계산 묶음이 가게 관 · 구조물 측점을 다시 매김(되돌림 포함) — 목록 · 알약 · 측점선. */ + const followFacilities = (far: number): void => { + if (far) + showToast( + `구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`, + "warning", + ); + if (!host.visible()) return; + if (lastPipes) applyPipes(lastPipes, false); + bridge.takeStructures(designStore.get("structures")); + }; /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */ const selectGraphAt = (id: string | null, chainageM: number | null): void => { @@ -492,6 +523,7 @@ export async function mountProfileView( draft: routeDraftBridge(activeProjectId, profilePanel, { stationIntervalM: panel.values().stationInterval ?? undefined, onCorridorStale: () => sections.setCorridorPending(true), + onFacilitiesFollowed: followFacilities, }), }); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 39e67f418..adf0e3264 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -72,8 +72,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { deps.profilePanel().setIrregularStations([]); } - /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ - function applyIrregularStations(stations: IrregularStation[]): void { + /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). + * `carryEdits` false = 노선이 움직여 따라간 측점(계획고 편집은 계산 묶음 follow 가 옮김 · 349-0d). */ + function applyIrregularStations(stations: IrregularStation[], carryEdits = true): void { // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다. // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2) // — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움. @@ -83,11 +84,12 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { (station) => !nextKeys.has(station.chainage_m.toFixed(3)), ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); - gone.forEach((station) => { - // 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272) - const same = followedStation(station, gone, born) ?? followSnapped(station, born); - deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); - }); + if (carryEdits) + gone.forEach((station) => { + // 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272) + const same = followedStation(station, gone, born) ?? followSnapped(station, born); + deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); + }); irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(stations); @@ -117,11 +119,12 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저). * 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */ - function syncCrossDrainStations(): void { + function syncCrossDrainStations(carryEdits = true): void { if (!planTypes.length) return; const interval = deps.panel().values().stationInterval || 20; applyIrregularStations( plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval), + carryEdits, ); } @@ -146,6 +149,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. * 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */ let ownStructures: StructureInstance[] = []; + /** 서버 정본 목록 — 가게 초안이 비면(노선 초안을 버림) 이것으로. */ + let storedStructures: StructureInstance[] = []; let pipeMarks: StructureInstance[] = []; function syncGraphStructures(): void { @@ -220,7 +225,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { writePending(null); structureRevision = stored.revision; deps.panel().structures.setStructures(stored.structures); - ownStructures = stored.structures; + ownStructures = storedStructures = stored.structures; syncGraphStructures(); syncCrossDrainStations(); return true; @@ -295,6 +300,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ]), ); structureRevision = stored.revision; + storedStructures = stored.structures; const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2 if (note) showToast(note); // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). @@ -333,10 +339,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, - /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ - setPipes(pipes: PipeProjection[]): void { + /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다(`carryEdits` = 계획고 편집 옮김). */ + setPipes(pipes: PipeProjection[], carryEdits = true): void { planPipes = [...pipes]; - syncCrossDrainStations(); + syncCrossDrainStations(carryEdits); pipeMarks = pipesToMarks(pipes); syncGraphStructures(); }, @@ -353,6 +359,15 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { markChainage, /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ applyStructures, + /** 가게 구조물 초안을 계산 묶음이 다시 씀(노선 따라 측점 다시 매김 · 되돌림 · 349-0d) — 화면만 맞춤. */ + takeStructures(next: StructureInstance[] | null): void { + const list = next ?? storedStructures; + if (JSON.stringify(list) === JSON.stringify(ownStructures)) return; + ownStructures = list; + deps.panel().structures.setStructures(ownStructures); + syncGraphStructures(); + syncCrossDrainStations(false); + }, saveStructuresIfDirty, /** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */ refreshFromServer: refreshStructuresFromServer, diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts new file mode 100644 index 000000000..4d50e62f4 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Pipeline_Follow.ts + * 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안 + * (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라 + * 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침). + * 순수 계산(DOM · fetch · 세션 없음). + * ========================================================================== */ + +interface Span { + chainage_m: number; + start_m?: number | null; + end_m?: number | null; +} + +/** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */ +export function followPipeReports( + reports: ReadonlyArray, + pipes: ReadonlyArray | null, +): 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 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) => + own ?? (from == null ? from : from + shift); + return { + ...report, + chainage_m: chainage, + ...(report.start_m != null ? { start_m: moved(pipe.start_m, report.start_m) } : {}), + ...(report.end_m != null ? { end_m: moved(pipe.end_m, report.end_m) } : {}), + }; + }); +} diff --git a/resources/tester/helper_349_0d_follow_store.cjs b/resources/tester/helper_349_0d_follow_store.cjs new file mode 100644 index 000000000..e850be837 --- /dev/null +++ b/resources/tester/helper_349_0d_follow_store.cjs @@ -0,0 +1,233 @@ +/* PLAN 349-0d sub7 헬퍼 — 노선을 놓으면 follow 가 다시 매긴 관 · 구조물이 가게(design_store)에 바로 씀 · + * 묶음 한 번 · 사진첩 한 걸음 안(놓은 뒤 setTimeout 0 전에 씀) · 되돌리기 · 초안 버림 = 초안 전 값 · 열기만 = 안 씀 · + * 새 노선에서 먼 구조물 수. 진짜 TS 를 트랜스파일해 Node 로 · 서버 창구만 가짜(`helper_349_pipeline_once` 와 같은 틀). + * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0d_follow_store.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), + 20, + ); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: job.curves, + min_radius_m: job.minRadius, + station_snap: job.stationSnap, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: job.structures }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => job.standard }; + +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const storeMod = load("A00_Common", "design", "design_store.ts"); +const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +/** 묶음이 조용해질 때까지 — 마지막 기록이 끝나고 새 기록이 더 안 생김(고리면 계속 생김). */ +async function quiet() { + let seen = -1; + for (let i = 0; i < 400; i += 1) { + await tick(15); + const last = runs()[runs().length - 1]; + const count = runs().length; + if (count === seen && (!last || last.outcome !== "running")) return; + seen = count; + } + throw new Error("묶음이 조용해지지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: job.stationSnap, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); +const chainagesOf = (stations) => (stations ?? []).map((s) => s.chainageM); + +const P = "proj-3490d"; +const ids = (list) => (list ?? []).map((item) => [item.id ?? item.structure_id, item.chainage_m]); + +async function run() { + window.localStorage.setItem("frd_current_project_id", P); + const savedAlignment = job.detail.longitudinal.profile_alignment; + const savedBase = alignment.toAlignmentBase(savedAlignment); + const followed = []; + const link = { + pipelineSource: () => ({ + base: savedBase, + edits: savedAlignment.edits, + detail: job.detail, + routeId: 7, + }), + adoptPipeline: (base, edits, draft) => + pageMod.pipelineGrade(P, draft ? edits : savedAlignment.edits), + redraw: () => undefined, + side: null, + }; + const store = storeMod.useDesignStore(P, null); + const bridge = pageMod.routeDraftBridge(P, link, { + stationIntervalM: job.interval, + onFacilitiesFollowed: (far) => followed.push(far), + }); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + await quiet(); + const pipe = pageMod.pagePipeline(P); + const open = { + storePipes: store.get("pipes"), + storeStructures: store.get("structures"), + followed: [...followed], + }; + + // 사진첩 흉내 — 놓기 전 세션 사진 + const snapshot = {}; + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + snapshot[key] = window.sessionStorage.getItem(key); + } + // 꼭짓점 한 번 끌어 놓기 — 사진첩은 놓은 뒤 setTimeout 0 에 한 걸음 찍음 + let mark = runs().length; + bridge.edited(draftOf(shift(1, 9, -6))); + await tick(0); + const atRecord = { pipes: ids(store.get("pipes")), structures: ids(store.get("structures")) }; + await quiet(); + const drag = { + runs: runs() + .slice(mark) + .map((r) => r.sources), + atRecord, + pipes: ids(store.get("pipes")), + structures: ids(store.get("structures")), + inputPipes: ids(pipe.input("pipes")), + followed: [...followed], + blocked: pipe.blockedWrites, + }; + + // 되돌리기 — 사진첩이 세션을 되쓴 뒤 노선 편집기가 초안 없음으로 알림 + for (const key of Object.keys(snapshot)) window.sessionStorage.setItem(key, snapshot[key]); + for (let i = window.sessionStorage.length - 1; i >= 0; i -= 1) { + const key = window.sessionStorage.key(i); + if (!(key in snapshot)) window.sessionStorage.removeItem(key); + } + mark = runs().length; + bridge.edited(null); + await quiet(); + const undo = { + runs: runs() + .slice(mark) + .map((r) => r.sources), + storePipes: store.get("pipes"), + storeStructures: store.get("structures"), + inputPipes: ids(pipe.input("pipes")), + followed: [...followed], + }; + + // 다시 끌고 사진첩 없이 초안만 버림(편집기 [버림] 등) — 가게 = 초안 전 값 + bridge.edited(draftOf(shift(1, 9, -6))); + await quiet(); + const second = ids(store.get("pipes")); + bridge.edited(null); + await quiet(); + const drop = { + second, + storePipes: store.get("pipes"), + inputPipes: ids(pipe.input("pipes")), + blocked: pipe.blockedWrites, + }; + pageMod.disposePagePipeline(P); + return { open, drag, undo, drop, saved: ids(job.pipes), savedStructures: ids(job.structures) }; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_349_0d_follow_store.py b/resources/tester/test_349_0d_follow_store.py new file mode 100644 index 000000000..d58965e54 --- /dev/null +++ b/resources/tester/test_349_0d_follow_store.py @@ -0,0 +1,202 @@ +"""PLAN 349-0d sub7 — 노선을 놓으면 관 · 구조물 새 측점이 가게에 바로. + +· 열기만 = 가게 안 씀(저장값 그대로) +· 꼭짓점 한 번 끌기 = 묶음 한 번 · 가게 관 · 구조물 측점 바뀜 + · 사진첩이 찍기 전(setTimeout 0)에 씀(한 걸음) +· 새 노선에서 20 m 넘게 먼 구조물 수 알림 +· 되돌리기(사진첩이 세션을 되씀) · 초안만 버림 = 초안 전 값(가게 비움 · 입력 = 저장 관) +· 계산 · 알림 안 입력 쓰기 0 +짝: `helper_349_0d_follow_store.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 검증_sub7 노선 · 관). +""" + +from __future__ import annotations + +import csv +import json +import math +import subprocess +import sys +import tempfile +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_Structures_Schema import structure_type_map # noqa: E402 +from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +PROJECT = ROOT / "storage/1/3/6d3b2812-6d5a-4168-a932-40e4ae303d5a" # 검증_sub7 +ROUTE = PROJECT / "B05_Profile/route" +SECTION = PROJECT / "B06_Section" +GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" +ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} + + +def _csv(path: Path) -> list[list[float]]: + with path.open(encoding="utf-8") as handle: + return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] + + +def _json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def _structure(sid: str, line: list[list[float]], ratio: float, off: float) -> dict: + """노선 위(off 0) · 옆(off m) 점 구조물 — 저장 노선 누가거리 · 좌표.""" + lengths = [math.dist(a, b) for a, b in zip(line, line[1:])] + at = sum(lengths) * ratio + walked = 0.0 + for (a, b), length in zip(zip(line, line[1:]), lengths): + if walked + length >= at: + t = (at - walked) / length + dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length + x, y = a[0] + dx * length * t - dy * off, a[1] + dy * length * t + dx * off + break + walked += length + return { + "structure_id": sid, + "type_id": "erosion_check", + "placement": "point", + "chainage_m": round(at, 2), + "start_m": None, + "end_m": None, + "x": x, + "y": y, + "options": {}, + } + + +@pytest.fixture(scope="module") +def r() -> dict: + if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): + pytest.skip("검증_sub7 없음") + curves = _json(ROUTE / "planned_route_curves.json") + sections = sorted( + (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), + key=lambda s: s["chainage_m"], + ) + pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] + line = _csv(ROUTE / "planned_route.csv") + structures = [_structure("s-near", line, 0.5, 0.0), _structure("s-far", line, 0.7, 30.0)] + job = { + "line": line, + "structures": structures, + "nodes": _csv(ROUTE / "planned_route_nodes.csv"), + "curves": [ + {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] + ], + "stationSnap": bool(curves.get("station_snap")), + "minRadius": curves["min_radius_m"], + "interval": 20.0, + "types": [ + {"type_id": t.type_id, "group": t.group, "placement": t.placement} + for t in structure_type_map().values() + ], + "pipes": pipes, + "standard": STANDARD_CROSS_SECTION, + "conversion": ONES, + "detail": { + "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), + "cross_sections": sections, + }, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(dtm_grid_bytes(GRID)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0d_follow_store.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_열기만_하면_가게_안_씀(r): + o = r["open"] + assert o["storePipes"] is None and o["storeStructures"] is None + assert o["followed"] == [] + + +def test_꼭짓점_끌기_가게에_새_측점_한_번(r): + d = r["drag"] + assert d["runs"] == [["route"]], d["runs"] + assert d["pipes"] == d["inputPipes"] + assert d["pipes"] != r["saved"] + assert len(d["pipes"]) == len(r["saved"]) # 같은 관 그대로(자리만 다시 매김) + assert {i: c for i, c in d["structures"]} != {i: c for i, c in r["savedStructures"]} + assert d["atRecord"] == {"pipes": d["pipes"], "structures": d["structures"]} # 한 걸음 안 + assert d["followed"] == [1] # s-far 만 20 m 넘음 + assert d["blocked"] == 0 + + +def test_되돌리기_초안_전_값(r): + u = r["undo"] + assert u["runs"] == [["route"]], u["runs"] + assert u["storePipes"] is None and u["storeStructures"] is None + assert u["inputPipes"] == r["saved"] + assert u["followed"] == [1, 0] + + +def test_초안만_버림_초안_전_값(r): + d = r["drop"] + assert d["second"] != r["saved"] + assert d["storePipes"] is None + assert d["inputPipes"] == r["saved"] + assert d["blocked"] == 0 + + +_REPORTS_JS = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const file = process.argv[1] + "/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts"; +const src = fs.readFileSync(file, "utf8"); +const options = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, options).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const f = box.exports.followPipeReports; +const reports = [ + { chainage_m: 86, effective_diameter_mm: 800, start_m: 84, end_m: 88 }, + { chainage_m: 175, effective_diameter_mm: 1000 }, +]; +const pipes = [{ chainage_m: 179.374 }, { chainage_m: 89.123, start_m: 87.5 }]; +process.stdout.write(JSON.stringify({ + moved: f(reports, pipes), + saved: f(reports, null), + count: f(reports, pipes.slice(1)), +})); +""" + + +def test_관_알림_목록_가게_측점으로(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", _REPORTS_JS, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + first, second = out["moved"] + assert (first["chainage_m"], first["effective_diameter_mm"]) == (89.12, 800) + assert first["start_m"] == 87.5 # 가게 구간 그대로 + assert abs(first["end_m"] - 91.12) < 1e-9 # 없으면 옮긴 만큼 + assert (second["chainage_m"], second["effective_diameter_mm"]) == (179.37, 1000) + assert [p["chainage_m"] for p in out["saved"]] == [86, 175] + assert [p["chainage_m"] for p in out["count"]] == [86, 175] # 관 수 다르면 그대로