diff --git a/A00_Common/design/design_pipeline.ts b/A00_Common/design/design_pipeline.ts index b43310c52..7b2f1728a 100644 --- a/A00_Common/design/design_pipeline.ts +++ b/A00_Common/design/design_pipeline.ts @@ -83,11 +83,15 @@ export interface DesignPipeline { ): Promise | null; subscribe(fn: (event: PipelineEvent) => void): () => void; output(name: K): PipelineOutputs[K] | null; + /** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */ + input(key: K): PipelineInputs[K]; readonly generation: number; /** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */ readonly blockedWrites: number; /** 떠 있는 뒤 단계가 다 끝날 때까지. */ settled(): Promise; + /** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */ + dispose(): void; } type Outs = { [S in StageName]: PipelineOutputs[S] | null }; @@ -327,6 +331,9 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig output(name) { return out[name] as never; }, + input(key) { + return inputs[key]; + }, get generation() { return generation; }, @@ -336,6 +343,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig async settled() { while (inFlight.size) await Promise.allSettled([...inFlight]); }, + dispose() { + generation += 1; + controller.abort(); + listeners.clear(); + }, }; } diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts new file mode 100644 index 000000000..9d25f59df --- /dev/null +++ b/A00_Common/design/design_pipeline_page.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_page.ts + * B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1) — 프로젝트마다 하나 · 페이지를 떠나면 버림 + * (종단도 `dispose`). 단계 붙임 · 입력 모음 · 가게 잇기만 — 셈은 단계 파일 몫. + * + * route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8) + * follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts` + * stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6) + * cross · masshaul · haul(sub2) · drainage(sub3) · corridor(sub5) — 붙으면 `stages` 한 줄씩 + * (없으면 건너뜀 · 계약 규칙). + * + * 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집 + * (`pipelineGrade`) · 관 · 구조물 = 가게 초안(없으면 구조물은 저장분). 출력 = 가게 stations · + * profile · footprint · generation + 노선 초안이 있으면 종단도가 새 첫 계획선 · 옮긴 계획고를 그림. + * 첫 계산은 처음 고칠 때(또는 저장 안 한 노선 초안이 있을 때) — 열기만 하면 안 셈. + * [노선 반영] · [저장]이 서버 정본 — 여기 값은 미리보기(346-3 전까지). + * ========================================================================== */ + +import { createDesignPipeline, publishToStore, type DesignPipeline } from "./design_pipeline"; +import { createStationsStage } from "./design_pipeline_stations"; +import type { PipelineInputs, RouteDraft, Vertex } from "./design_pipeline_types"; +import { useDesignStore } from "./design_store"; +import { + emptyEdits, + type AlignmentBase, + type AlignmentEdits, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { + analysisTerrain, + fetchRoutePlan, + type RoutePlanResponse, +} from "../../B05_Profile/B05_Profile_Api_Replan"; +import { fetchStructures, fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { + designTerrainGridNow, + groundStageDeps, +} from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; +import { + createGroundStage, + createRouteStage, + draftLine, +} from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; +import { + createPlanlineStage, + followStage, + gradeRange, + profileStage, + routeRange, +} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; +import type { PlanType } from "../../common_util/common_util_structure_station"; +import type { + RouteDraftBridge, + RouteDraftEdit, +} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Draft"; + +/** 종단도(`design_profile`)가 묶음에 내주는 것. */ +export interface ProfileLink { + /** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집. */ + pipelineSource(): { base: AlignmentBase | null; edits: AlignmentEdits }; + /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */ + adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void; +} + +interface PagePipeline { + pipe: DesignPipeline; + bridge: RouteDraftBridge; + grade(edits: AlignmentEdits): void; + restart(): void; + dispose(): void; +} + +const pages = new Map(); +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */ +export function routeDraftBridge( + projectId: string, + link: ProfileLink, + options: { stationIntervalM?: number } = {}, +): RouteDraftBridge { + let page = pages.get(projectId); + if (!page) pages.set(projectId, (page = createPagePipeline(projectId, link, options))); + return page.bridge; +} + +/** 종단도 계획고 편집(▲▼ · 끌기 · 되돌리기) — 묶음이 없으면(편집기 안 뜸) 아무 일 없음. */ +export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void => + pages.get(projectId)?.grade(edits); + +/** 저장 종단 기준이 바뀜(새 상세 · 구조물 측점) — 셈 중이면 처음부터 다시. */ +export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart(); + +/** 지금 묶음(개발 · 시험) — 없으면 null. */ +export const pagePipeline = (projectId: string): DesignPipeline | null => + pages.get(projectId)?.pipe ?? null; + +export function disposePagePipeline(projectId: string): void { + pages.get(projectId)?.dispose(); + pages.delete(projectId); +} + +function createPagePipeline( + projectId: string, + link: ProfileLink, + options: { stationIntervalM?: number }, +): PagePipeline { + const store = useDesignStore(projectId, null); + let plan: RoutePlanResponse | null = null; + let types: PlanType[] = []; + let savedStructures: unknown[] = []; + let ready = false; + let started = false; + /** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */ + let drawnDraft = false; + let disposed = false; + + const savedLine = (): Vertex[] | null => + plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null; + const pipe = createDesignPipeline({ + stages: { + route: createRouteStage({ + minRadiusM: () => plan?.min_radius_m ?? 0, + savedLine: () => + plan && { + line: savedLine()!, + curves: plan.curves.map(({ start, apex, end, radius_m }) => ({ + start, + apex, + end, + radius_m, + })), + }, + }), + follow: followStage, + stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }), + ground: createGroundStage(groundStageDeps(projectId)), + planline: createPlanlineStage({ savedBase: () => link.pipelineSource().base, savedLine }), + profile: profileStage, + footprint: footprintStage, + }, + }); + pipe.subscribe(publishToStore(store)); + pipe.subscribe((event) => { + if (event.kind !== "stage" || event.stage !== "profile") return; + const draft = pipe.input("route") !== null; + const base = pipe.output("planline"); + const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); + if ((draft || drawnDraft) && base) link.adoptPipeline(base, edits, draft); + drawnDraft = draft; + }); + + const inputsNow = (): PipelineInputs => ({ + route: store.get("routeDraft"), + alignment: link.pipelineSource().edits, + cross: null, + // ponytail: 저장 관 목록(좌표)은 아직 안 받음 — 관 초안만. 346-3 에서 관 정본을 가게로. + pipes: store.get("pipes") ?? [], + structures: store.get("structures") ?? savedStructures, + }); + const start = (): void => { + if (disposed || !ready || !link.pipelineSource().base) return; + started = true; + void pipe.start(inputsNow()); + }; + + /** 저장 노선(그린 선 · 곡선표 · 기본 반경) — [노선 반영] 뒤 다시 받음(`restart`). */ + const loadPlan = (): Promise => + fetchRoutePlan(projectId) + .then((routePlan) => void (plan = routePlan)) + .catch(() => undefined); + + designTerrainGridNow(projectId); // 지형 격자 미리 받기(받는 동안은 서버 길) + void Promise.all([ + loadPlan(), + fetchStructureTypes().catch(() => []), + fetchStructures(projectId).catch(() => null), + ]) + .then(([, structureTypes, list]) => { + types = structureTypes as PlanType[]; + savedStructures = list?.structures ?? []; + ready = plan !== null; + if (store.get("routeDraft")) start(); // 저장 안 한 노선 초안 — 열 때부터 그 노선으로 + }) + .catch(() => undefined); + + const unsubscribe = [ + store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? [])), + store.subscribe( + "structures", + (list) => started && void pipe.update("structure", list ?? savedStructures), + ), + ]; + + const bridge: RouteDraftBridge = { + saved: () => store.get("routeDraft"), + edited(edit: RouteDraftEdit | null) { + const stored = store.get("routeDraft"); + let draft: RouteDraft | null = null; + if (edit) { + draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) }; + if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored; + } + store.set("routeDraft", draft); + if (!started) return start(); + const before = pipe.output("route")?.line ?? null; + const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine(); + void pipe.update("route", draft, routeRange(before, after)); + }, + }; + + return { + pipe, + bridge, + grade(edits) { + if (!started) return start(); + if (same(edits, pipe.input("alignment"))) return; + const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? []; + const before = pipe.input("alignment") as AlignmentEdits | null; + void pipe.update("grade", edits, gradeRange(before, edits, nodes)); + }, + restart() { + if (started) void loadPlan().then(start); + }, + dispose() { + disposed = true; + unsubscribe.forEach((stop) => stop()); + pipe.dispose(); + }, + }; +} diff --git a/A00_Common/design/design_pipeline_types.ts b/A00_Common/design/design_pipeline_types.ts index 2b73d0f35..74e08a4dd 100644 --- a/A00_Common/design/design_pipeline_types.ts +++ b/A00_Common/design/design_pipeline_types.ts @@ -212,6 +212,14 @@ export interface PlanFootprint { roadRight: Vertex[]; slopeLeft: Vertex[]; slopeRight: Vertex[]; + /** 비탈 끝 점마다 깎기(true) · 쌓기 — slopeLeft · slopeRight 와 같은 차례(그리기가 색을 가름). */ + slopeLeftCut: boolean[]; + slopeRightCut: boolean[]; + /** 비탈 표시 짧은 선 — [도로 끝 점, 비탈 쪽 점] 묶음. */ + ticks: number[][][]; + /** 측점마다 원자료(측점 열쇠 · 누가거리 · 좌우 도로 끝 · 비탈 끝 m · 깎기) — 범위 밖 재사용 · + * 모양 = `B05_Profile/logic/B05_Profile_PlanFootprint.ts` `FootprintRecord`(sub6). */ + records: Array<{ key?: string; chainage_m: number }>; } /** 단계 이름 → 출력. drainage · corridor 는 표시만 하므로 출력이 없음(null). */ diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 84fe62bc7..07d569032 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -70,11 +70,8 @@ import { createProfileDrag, } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; -import { - irregularGraphStations, - maxChainageOf, -} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; -import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; +import { listedBase } from "./design_profile_base"; +import { disposePagePipeline, pipelineGrade, restartPagePipeline } from "./design_pipeline_page"; 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"; @@ -223,6 +220,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { let storedBase: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); + /** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 메모리만(346-1). */ + let preview: typeof store | null = null; /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ let savedEdits: AlignmentEdits = emptyEdits(); let redrawPending = false; @@ -342,31 +341,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { history.record(); } - /** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이 - * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`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, - })), - }; - } + /** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */ + const listed = (): AlignmentBase | null => listedBase(storedBase, detail, irregularStations); /** * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 @@ -374,6 +350,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { */ function rebuild(): void { if (base) alignment = buildAlignment(base, store.edits()); + pipelineGrade(projectId, store.edits()); // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). @@ -525,6 +502,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; + restartPagePipeline(projectId); // 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다. // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다. configureBalloonOffsets( @@ -601,6 +579,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { irregularStations = stations; base = listed(); if (base) alignment = buildAlignment(base, store.edits()); + restartPagePipeline(projectId); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ @@ -645,9 +624,24 @@ export function createDesignProfile(ctx: DesignProfileContext) { if (key !== undefined) store.resetStation(Number(key), moveTo); }, isDirty: () => store.dirty(), + /** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */ + pipelineSource: () => ({ base: listed(), edits: store.edits() }), + /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 메모리만(저장 노선에 + * 안 씀 · [노선 반영] · [저장]이 서버 정본) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */ + adoptPipeline(nextBase: AlignmentBase, edits: AlignmentEdits, draft: boolean) { + const sameEdits = JSON.stringify(edits) === JSON.stringify(store.edits()); + if (draft && store === preview && base === nextBase && sameEdits) return; // 이미 그림 + if (draft && (store !== preview || !sameEdits)) + store = preview = createProfileEditStore(null, edits, () => rebuild()); + if (!draft && store === preview) + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + if (!draft) preview = null; + base = draft ? nextBase : listed(); + rebuild(); + }, /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { - if (!routeId || !store.dirty()) return; + if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄 const saved = await saveProfileAlignment(projectId, routeId, store.edits()); noticeDroppedEdits(saved); const next = saved.profile_alignment as ProfileAlignment | undefined; @@ -688,6 +682,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { drainagePanel?.load(projectId); }, dispose() { + disposePagePipeline(projectId); setListening(false); drag.dispose(); crossPreview.dispose(); diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts new file mode 100644 index 000000000..b706bfe86 --- /dev/null +++ b/A00_Common/design/design_profile_base.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * A00_Common/design/design_profile_base.ts + * 종단도 계획선 기준 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — `design_profile` 에서 나눔(700줄). + * 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음. + * ========================================================================== */ + +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import type { AlignmentBase } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import { + irregularGraphStations, + maxChainageOf, +} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; +import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; + +/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이 + * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */ +export function listedBase( + storedBase: AlignmentBase | null, + detail: SectionDetailResponse | null, + irregularStations: IrregularStation[], +): 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, + })), + }; +} diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index b03e06de9..920f92c0e 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -20,6 +20,7 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil import { leaveForDashboard } from "../b_missing_data_guard"; import { currentRoute, remountRoute } from "../router"; import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; +import { routeDraftBridge } from "./design_pipeline_page"; import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { readStructurePick, @@ -484,6 +485,10 @@ export async function mountProfileView( smooth: latestResponse.surface_params.smooth, tools: [view3d.modal.button], recommend: panel.recommend, + // 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [노선 반영]이 정본). + draft: routeDraftBridge(activeProjectId, profilePanel, { + stationIntervalM: panel.values().stationInterval ?? undefined, + }), }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index d1e344d79..9354d5d44 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -284,10 +284,10 @@ async def _carry_cross( new_long = _read_json(project_root / relative) _, new_structures = load_structures(str(project_root)) - old_keys = station_keys( - old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m + old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m) + new_keys = station_keys( + new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m ) - new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m) designs = { round(float(row["chainage_m"]), 3): row["design"] for row in old_designs diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index f3ec307f5..d31a7277a 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -8,9 +8,9 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름 (사용자 관 편집 지킴). - 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음). - 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음. - - 측점 열쇠(계약 `design_pipeline_types.ts` 머리) = 일반 `grid:k`(누가거리 ÷ 측점 간격 반올림) · - 관 `pipe:` · 구조물 `structure:`(구간 시작 · 끝은 `:start` · `:end`) · 시종점 - `end:BP` · `end:EP`. 관 id = 저장 파일 차례(노선 저장 한 번 안에서 옛 · 새가 같음). + - 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` · + 관 `pipe:` · 구조물 `structure:` · 시종점 `end:BP` · `end:EP` · + id 없으면 `at:<누가거리 0.01 m>`. - 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면 `STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 = 「노선 밖 고침」. @@ -32,6 +32,8 @@ from common_util.common_util_drainage_pipes import ( project_pipe_points, ) from common_util.common_util_route_geometry import RouteVertex +from common_util.common_util_station_key import chainage_key as station_chainage_key +from common_util.common_util_station_key import station_key from common_util.common_util_structure_station import plan_stations, snap_structure_station from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M @@ -59,7 +61,7 @@ def vertices_of(xy: Sequence[Sequence[float]]) -> list[RouteVertex]: def follow_pipes( old_pipes: list[PipePoint], vertices: list[RouteVertex] ) -> list[tuple[int, PipePoint]]: - """관이 새 노선 좌표를 따라감 → [(관 id = 옛 차례, 새 관)] 누가거리 순 · 옛 목록 그대로.""" + """관이 새 노선 좌표를 따라감 → [(옛 차례 · 더한 관 -1, 새 관)] 누가거리 순 · 옛 목록 그대로.""" pairs = [(index, copy.deepcopy(pipe)) for index, pipe in enumerate(old_pipes)] for _, pipe in pairs: project_pipe_points([pipe], vertices) @@ -145,49 +147,54 @@ def fill_concave_pipes( added += 1 -def _facility_keys( - pipes: Sequence[tuple[int, PipePoint]], structures: Sequence[Any] -) -> dict[float, str]: - """측점 자리(0.01 m) → 관 · 구조물 열쇠. 같은 자리면 관이 먼저(`plan_stations` 이름 규칙).""" - keys: dict[float, str] = {} - - def put(marks: list[dict[str, object]], head: str) -> None: - for mark in marks: - role = str(mark.get("role") or "anchor") - keys.setdefault( - round(float(mark["chainage_m"]), 2), head if role == "anchor" else f"{head}:{role}" +def _facility_owners( + pipes: Sequence[PipePoint], structures: Sequence[Any] +) -> dict[float, tuple[str, str | None]]: + """측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙).""" + owners: dict[float, tuple[str, str | None]] = {} + for pipe in pipes: + for mark in plan_stations([pipe], []): + owners.setdefault( + round(float(mark["chainage_m"]), 2), ("pipe", getattr(pipe, "id", None)) ) - - for pipe_id, pipe in pipes: - if pipe_id >= 0: - put(plan_stations([pipe], []), f"pipe:{pipe_id}") for item in structures: - if getattr(item, "structure_id", None): - put(plan_stations([], [item]), f"structure:{item.structure_id}") - return keys + for mark in plan_stations([], [item]): + owners.setdefault( + round(float(mark["chainage_m"]), 2), + ("structure", getattr(item, "structure_id", None)), + ) + return owners def station_keys( stations: Sequence[dict[str, Any]], - pipes: Sequence[tuple[int, PipePoint]], + pipes: Sequence[PipePoint], structures: Sequence[Any], interval_m: float, ) -> dict[str, float]: - """측점 열쇠 → 누가거리. 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만).""" - facility = _facility_keys(pipes, structures) + """측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리. + + 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` · + 같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음). + """ + owners = _facility_owners(pipes, structures) values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] out: dict[str, float] = {} for index, chainage in enumerate(values): - key = facility.get(round(chainage, 2)) - if key is None and index == 0: - key = "end:BP" - elif key is None and index == len(values) - 1: - key = "end:EP" - elif key is None and interval_m > 0: - k = round(chainage / interval_m) - key = f"grid:{k}" if abs(chainage - k * interval_m) < _SAME_M else None - if key is not None: - out.setdefault(key, chainage) + owner = owners.get(round(chainage, 2)) + if owner is not None: + key = station_key(owner[0], chainage, interval_m, owner[1]) + elif index == 0: + key = station_key("bp", chainage, interval_m) + elif index == len(values) - 1: + key = station_key("ep", chainage, interval_m) + elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M: + key = station_key("regular", chainage, interval_m) + else: + continue + if key in out: + key = f"at:{station_chainage_key(chainage)}" + out.setdefault(key, chainage) return out diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 84143a10a..68c58f846 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -83,6 +83,7 @@ import { type RouteEditSnapshot, } from "./B05_Profile_UI_RouteEdit_History"; import type { DesignPageHost } from "../A00_Common/design/design_page"; +import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -105,6 +106,7 @@ export interface RouteEditOptions { tools?: HTMLElement[]; /** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */ recommend: InitialPanelParams["controls"]; + draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`) } /** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ @@ -309,14 +311,11 @@ export async function mountRouteEditor( const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; - /** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */ const toMetric = (px: number, py: number): Vertex => meta ? screenToMetric(meta, view, px, py) : [0, 0]; - /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */ const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1); - /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ const contourStepM = (): number => contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0; @@ -612,6 +611,8 @@ export async function mountRouteEditor( // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null); history = createRouteEditHistory(snapshotNow()); + history = draftLink.watch(history, options.draft, () => stationSnap); + draftLink.restore(options.draft, restoreSnapshot); historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts new file mode 100644 index 000000000..1b08b9619 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Draft.ts + * 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 · + * 되돌리기 · 다시하기 · [반영 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음 + * (사진첩에 쌓이는 때 = 손을 놓은 때). + * + * 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은 + * 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림 + * (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫. + * ========================================================================== */ + +import type { RouteDraft } from "../A00_Common/design/design_pipeline_types"; +import type { RouteEditHistory, RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; + +/** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */ +export type RouteDraftEdit = Omit; + +export interface RouteDraftBridge { + /** 노선이 바뀜 — 열 때(저장분)와 같으면 null. */ + edited(draft: RouteDraftEdit | null): void; + /** 저장 안 한 초안(캐시 칸) — 없으면 null. */ + saved(): RouteDraft | null; +} + +/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님). */ +export function draftOfSnapshot(snapshot: RouteEditSnapshot, stationSnap: boolean): RouteDraftEdit { + return { + nodes: snapshot.planned.map(([x, y]): [number, number] => [x, y]), + curveOn: [...snapshot.curveOn], + curveRadius: [...snapshot.curveRadius], + curveLock: [...snapshot.curveLock], + curveArc: [...snapshot.curveArc], + stationSnap, + }; +} + +/** 노선 초안 → 사진 — 고정은 편집기가 좌표로 다시 짚음(`restoreSnapshot` 새 걸음). */ +export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot { + return { + planned: draft.nodes.map(([x, y]): [number, number] => [x, y]), + curveOn: [...draft.curveOn], + curveRadius: [...draft.curveRadius], + curveLock: [...draft.curveLock], + curveArc: [...draft.curveArc], + apexLock: draft.nodes.map(() => false), + picked: -1, + }; +} + +/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. */ +export function watch( + history: RouteEditHistory, + bridge: RouteDraftBridge | undefined, + stationSnap: () => boolean, +): RouteEditHistory { + if (!bridge) return history; + const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => { + if (snapshot) + bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null); + return snapshot; + }; + return { + ...history, + commit: (snapshot) => { + history.commit(snapshot); + tell(snapshot); + }, + undo: () => tell(history.undo()), + redo: () => tell(history.redo()), + reset: () => tell(history.reset()), + }; +} + +/** 저장 안 한 초안을 편집기에 한 번 되살림 — 사진첩에 새 걸음으로(되돌리면 저장분). */ +export function restore( + bridge: RouteDraftBridge | undefined, + apply: (snapshot: RouteEditSnapshot, message: string, record: boolean) => void, +): void { + const kept = bridge?.saved(); + if (kept?.nodes.length) + apply(snapshotOfDraft(kept), "저장 안 한 노선 초안을 되살렸습니다.", true); +} diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts new file mode 100644 index 000000000..a1a729082 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B05_Profile_Pipeline_Grade.ts + * 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 = + * `A00_Common/design/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터): + * + * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` + * `carryProfileEdits` 의 edits 몫) · 옛 노선(`before.route`)이 있을 때만. + * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로 + * 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫). + * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. + * + * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. + * ========================================================================== */ + +import type { + FollowPatch, + RangePart, + StageFn, + Vertex, +} from "../../A00_Common/design/design_pipeline_types"; +import { + followStructures, + projectPipePoints, + type FollowingPoint, + type FollowingStructure, +} from "../../common_util/common_util_route_follow"; +import { + buildAlignment, + emptyEdits, + type AlignmentBase, + type AlignmentEdits, +} from "../B05_Profile_UI_Profile_Alignment"; +import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry"; +import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; + +const xy = (line: ReadonlyArray): Array<{ x: number; y: number }> => + line.map(([x, y]) => ({ x, y })); + +/** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */ +export const stationIdOf = (chainageM: number): string => + `station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`; + +/** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */ +export function routeRange( + before: ReadonlyArray | null, + after: ReadonlyArray | null, +): RangePart { + const span = before && after ? changedSpan(xy(before), xy(after)) : null; + return { fromM: span ? span.start_m : 0, toM: Infinity }; +} + +/** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */ +export function gradeRange( + before: AlignmentEdits | null, + after: AlignmentEdits | null, + nodes: ReadonlyArray, +): RangePart | null { + const changed: number[] = []; + for (const field of ["station_offsets", "curve_radii"] as const) { + const a = before?.[field] ?? {}; + const b = after?.[field] ?? {}; + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) + if (a[key] !== b[key]) changed.push(Number(key)); + } + if (!changed.length) return null; + const low = Math.min(...changed); + const high = Math.max(...changed); + const ahead = nodes.filter((node) => node > high + 1e-6); + return { + fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)), + toM: ahead.length ? Math.min(...ahead) : Infinity, + }; +} + +/** follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. */ +export const followStage: StageFn<"follow"> = (ctx) => { + const before = ctx.before.route; + const now = ctx.out.route; + if (!before || !now) return {}; + const line = xy(now.line); + const patch: FollowPatch = {}; + const { pipes, structures, alignment } = ctx.inputs; + if (Array.isArray(pipes) && pipes.length) + patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); + if (Array.isArray(structures) && structures.length) + patch.structures = followStructures(structures as FollowingStructure[], line).map( + (moved) => moved.item, + ); + const span = changedSpan(xy(before.line), line); + if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; + return patch; +}; + +export interface PlanlineDeps { + /** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */ + savedBase(): AlignmentBase | null; + /** 저장 노선(그린 선) — 바뀐 구간을 가름. */ + savedLine(): Vertex[] | null; +} + +export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { + return (ctx) => { + const saved = deps.savedBase(); + if (!saved) throw new Error("저장 종단 기준이 아직 없습니다."); + const ground = ctx.out.ground; + const route = ctx.out.route; + if (!ctx.inputs.route || !ground || !route) return saved; + const { chainageM, z } = ground.longitudinal; + const samples = chainageM.map((chainage, index) => ({ + chainage_m: chainage, + elevation_m: z[index], + })); + const profile = groundProfile(samples); + if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈) + const groundPvi = + groundFollowingBasePvi({ + samples, + stations: ground.stations.map((station) => station.chainageM), + policy: saved.policy, + }) ?? []; + const savedLine = deps.savedLine(); + const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; + return { + policy: saved.policy, + onlyExplicitCurves: saved.onlyExplicitCurves, + basePvi: span + ? carryProfileEdits(saved.basePvi, null, span, groundPvi).basePvi + : saved.basePvi, + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((station) => ({ + station_id: stationIdOf(station.chainageM), + chainage_m: station.chainageM, + })), + }; + }; +} + +/** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */ +export const profileStage: StageFn<"profile"> = (ctx) => { + const base = ctx.out.planline; + if (!base) throw new Error("첫 계획선이 없습니다."); + return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits()); +}; diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs index 00fbd81f7..5ba3c4308 100644 --- a/resources/tester/helper_230_2_popout_frames.cjs +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -96,6 +96,8 @@ out.viewOf = { alignment: null, buildAlignment: () => null, store: { edits: () => ({}) }, + projectId: "p", + pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음 scheduleCrossPreview: () => s.previews++, draw: () => s.draws++, root: inPopup(), diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs new file mode 100644 index 000000000..029a86878 --- /dev/null +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -0,0 +1,363 @@ +/* PLAN 346-1 단위검증 헬퍼 — 계산 묶음을 페이지 · 노선 편집에 붙인 것. + * 진짜 TS(`design_pipeline` · `design_pipeline_page` · `design_pipeline_stations` · `logic/*Pipeline*` · + * `_RouteEdit_Draft` · sub1 · sub8 · sub6 짝)를 그 자리에서 트랜스파일해 Node 로 돌림. 서버 창구 + * (`Api_Replan` · `Api_Structures` · `Api_TerrainGrid`)만 가짜. 입력 argv[2] JSON · argv[3] 격자 이진. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_346_1_pipeline_page.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; + }, + }; +} +const session = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; +globalThis.fetch = async () => ({ ok: false }); + +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("@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" }; + +let remoteCalls = 0; +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + designTerrainGridNow: () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }), +}; + +const pipeMod = load("A00_Common", "design", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const footprint = load("B05_Profile", "logic", "B05_Profile_Footprint_Stage.ts"); +const alignment = load("B05_Profile", "B05_Profile_UI_Profile_Alignment.ts"); +const draftLink = load("B05_Profile", "B05_Profile_UI_RouteEdit_Draft.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const n = job.nodes.length; +const draftOf = (nodes) => ({ + rev: 1, + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, + terrain: null, +}); +const movedNodes = job.nodes.map((node, i) => (i === n - 1 ? [node[0] + 6, node[1] - 4] : node)); +const inputs = (route = null, edits = alignment.emptyEdits()) => ({ + route, + alignment: edits, + cross: null, + pipes: [], + structures: [], +}); + +/** 단계 한 벌(실제) — 저장 기준은 부르는 쪽. */ +function realStages(savedBase, remote = null) { + return { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + follow: grade.followStage, + stations: stationsMod.createStationsStage({ types: () => job.types }), + ground: routeGround.createGroundStage( + remote ? { grid: () => null, remote } : { grid: () => local, remote: async () => [] }, + ), + planline: grade.createPlanlineStage({ savedBase: () => savedBase, savedLine: () => job.line }), + profile: grade.profileStage, + footprint: footprint.footprintStage, + }; +} + +async function run() { + const out = { n }; + + // ⓪ 저장 기준 — 저장 노선 지반으로 첫 계획선(서버 정책 그대로) + const seed = pipeMod.createDesignPipeline({ + stages: { ...realStages(null), planline: undefined, profile: undefined, footprint: undefined }, + }); + await seed.start(inputs()); + const ground0 = seed.output("ground"); + const samples = ground0.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground0.longitudinal.z[i], + })); + const profile0 = gradeGround.groundProfile(samples); + const savedBase = { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground0.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile0.chainage, + ground: profile0.ground, + stations: ground0.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; + + // ① 실제 단계 한 번 — 저장 노선 · 노선 한 점 옮김 · 계획고 한 점 + { + const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase) }); + const first = await pipe.start(inputs()); + const savedPlanline = pipe.output("planline") === savedBase; + const beforeLine = pipe.output("route").line; + const beforeKeys = pipe.output("ground").stations.map((s) => [s.key, s.chainageM]); + const draft = draftOf(movedNodes); + const after = routeGround.draftLine(draft, job.minRadius).line; + const range = grade.routeRange(beforeLine, after); + const routeRun = await pipe.update("route", draft, range); + const moved = { + basePvi: pipe.output("planline").basePvi, + stations: pipe.output("ground").stations.map((s) => [s.key, s.chainageM]), + profileStations: pipe.output("profile").stations.length, + footprint: pipe.output("footprint") !== null, + }; + const nodes = pipe.output("planline").basePvi.map((p) => p.chainage_m); + const target = nodes[Math.floor(nodes.length / 2)]; + const edits = { station_offsets: { [alignment.chainageKey(target)]: 0.8 }, curve_radii: {} }; + const gRange = grade.gradeRange(alignment.emptyEdits(), edits, nodes); + const gradeRun = await pipe.update("grade", edits, gRange); + // 꼭짓점 높이(종단곡선은 그 아래로 둥글게 지나감) + const pviAt = (profile) => + profile.pvi.find((p) => Math.abs(p.chainage_m - target) < 1e-6).elevation_m; + const raised = pviAt(pipe.output("profile")); + const plain = pviAt(alignment.buildAlignment(pipe.output("planline"), alignment.emptyEdits())); + out.real = { + first: { outcome: first.outcome, totalMs: first.totalMs, stages: first.stages }, + savedPlanline, + range, + routeRun: { + outcome: routeRun.outcome, + totalMs: routeRun.totalMs, + stages: routeRun.stages, + skipped: routeRun.skipped, + }, + savedBasePvi: savedBase.basePvi, + moved, + beforeKeys, + gradeRange: gRange, + target, + gradeRun: { + outcome: gradeRun.outcome, + stages: Object.keys(gradeRun.stages), + skipped: gradeRun.skipped, + totalMs: gradeRun.totalMs, + }, + raised: raised - plain, + log: globalThis.__aisloPipeline.runs.length, + }; + } + + // ② 늦은 응답 버림 — 격자 없음(서버 길) · 첫 지반 응답이 늦는 사이 노선을 또 옮김 + { + const waits = []; + const remote = (points, _terrain, signal) => + new Promise((resolve, reject) => { + waits.push(() => resolve(points.map(([x, y]) => sampler.heightAt(grid, x, y)))); + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }); + const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase, remote) }); + const commits = []; + pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); + const a = pipe.start(inputs(draftOf(movedNodes))); + await tick(5); + const b = pipe.update("route", draftOf(job.nodes), { fromM: 0, toM: Infinity }); + await tick(5); + waits.forEach((go) => go()); + const ra = await a; + await tick(5); + waits.slice(1).forEach((go) => go()); + const rb = await b; + out.late = { + a: ra.outcome, + b: rb.outcome, + commits, + profileGen: commits.filter((c) => c.startsWith("profile")), + }; + } + + // ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림 + { + const told = []; + let dirty = true; + const fake = { + commit: () => undefined, + undo: () => ({ + planned: [ + [0, 0], + [1, 1], + ], + curveOn: [true, true], + curveRadius: [null, null], + curveLock: [null, null], + curveArc: [null, null], + apexLock: [false, false], + picked: -1, + }), + redo: () => null, + reset: () => ({ + planned: [[0, 0]], + curveOn: [true], + curveRadius: [null], + curveLock: [null], + curveArc: [null], + apexLock: [false], + picked: -1, + }), + canUndo: () => true, + canRedo: () => false, + isDirty: () => dirty, + }; + const bridge = { + edited: (d) => told.push(d && d.nodes.length), + saved: () => draftOf(job.nodes), + }; + const watched = draftLink.watch(fake, bridge, () => true); + watched.commit({ + ...fake.undo(), + planned: [ + [0, 0], + [2, 2], + [3, 3], + ], + }); + watched.undo(); + watched.redo(); + dirty = false; + watched.reset(); + const restored = []; + draftLink.restore(bridge, (snap, message, record) => + restored.push([snap.planned.length, record]), + ); + draftLink.restore({ saved: () => null }, () => restored.push("none")); + out.draftLink = { + told, + restored, + unwatched: draftLink.watch(fake, undefined, () => false) === fake, + }; + } + + // ④ 페이지 묶음 — 다리 → 노선 초안 캐시 칸 · 종단도 받기 · 떠남 · 다시 엶(초안 살아남음) · ▲▼ + { + const P = "proj-3461"; + const adopted = []; + let edits = alignment.emptyEdits(); + const link = { + pipelineSource: () => ({ base: savedBase, edits }), + adoptPipeline: (base, nextEdits, draft) => + adopted.push({ draft, saved: base === savedBase, stations: base.stations.length }), + }; + const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 }); + await tick(10); + const before = { saved: bridge.saved(), pipe: pageMod.pagePipeline(P) !== null }; + const edit = { ...draftOf(movedNodes) }; + delete edit.rev; + delete edit.terrain; + bridge.edited(edit); + await tick(50); + const key = `aislo:draft:route-draft:${P}`; + const cached = JSON.parse(session.getItem(key)); + bridge.edited(edit); // 같은 노선 — 판 번호 그대로 · 다시 안 셈 + const sameRev = JSON.parse(session.getItem(key)).rev; + await tick(50); + const adoptedDraft = adopted.slice(); + bridge.edited(null); // 열 때로(되돌리기 끝) + await tick(50); + const afterRevert = { session: session.getItem(key), last: adopted[adopted.length - 1] }; + bridge.edited(edit); + await tick(50); + pageMod.disposePagePipeline(P); + const disposed = pageMod.pagePipeline(P); + adopted.length = 0; + const again = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 }); + const survived = again.saved(); + await tick(80); // 받기 끝 → 저장 안 한 초안이 있으면 그 노선으로 셈 + const reopened = adopted.slice(); + edits = { station_offsets: { "100.000": 0.5 }, curve_radii: {} }; + pageMod.pipelineGrade(P, edits); + await tick(30); + const gradeRuns = globalThis.__aisloPipeline.runs.slice(-1)[0]; + page.clearDrafts(P); + out.page = { + before, + cached: { rev: cached.rev, terrain: cached.terrain, nodes: cached.nodes.length }, + sameRev, + adoptedDraft, + afterRevert, + disposed, + survived: survived && survived.nodes.length, + reopened, + grade: { sources: gradeRuns.sources, stages: Object.keys(gradeRuns.stages) }, + clearedDraft: session.getItem(key), + remoteCalls, + }; + pageMod.disposePagePipeline(P); + } + return out; +} + +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_346_1_pipeline_page.py b/resources/tester/test_346_1_pipeline_page.py new file mode 100644 index 000000000..f100e72c2 --- /dev/null +++ b/resources/tester/test_346_1_pipeline_page.py @@ -0,0 +1,199 @@ +"""PLAN 346-1 — 계산 묶음을 B05 페이지 · 노선 편집에 붙임. + +· 실제 단계 한 번(route · follow · stations · ground · planline · profile · footprint) — 노선 한 점 옮김 + 뒤 종단까지 0.3 s 안 · 바뀐 구간 앞 첫 계획선 꼭짓점 · 측점 열쇠 그대로 · 계획고 한 점은 측점 · 지반 안 셈 +· 늦은 응답 버림(격자 없음 · 서버 길 지반이 늦는 사이 노선을 또 옮김) +· 편집기 다리(사진첩 감싸기 · 열 때와 같으면 null · 초안 되살림) +· 페이지 묶음 — 노선 초안 캐시 칸 · 종단도가 받음 · 떠났다 다시 열어도 초안 살아남음 · ▲▼ = grade +짝: `helper_346_1_pipeline_page.cjs`(실제 TS 를 Node 로 돌림 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import numpy as np +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 import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, +) +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a" +REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" +REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv" +MIN_RADIUS_M = 12.0 +BUDGET_MS = 300.0 + + +def _types() -> list[dict]: + return [ + { + "type_id": t.type_id, + "group": t.group, + "placement": t.placement, + "managed_by": t.managed_by, + } + for t in structure_type_map().values() + ] + + +def _line(nodes: list[list[float]]) -> list[list[float]]: + built = build_planned_polyline( + [tuple(n) for n in nodes], + min_radius_m=MIN_RADIUS_M, + simplify=False, + curve_flags=[True] * len(nodes), + radii=[None] * len(nodes), + ) + return [list(v) for v in built.vertices] + + +def _fake(folder: Path) -> tuple[Path, list[list[float]]]: + """가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠.""" + rng = np.random.default_rng(3461) + x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32) + y = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 2000.0).astype(np.float32) + xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64)) + z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + 2 * np.sin(xx / 13.0) + mask = np.ones(z.shape, dtype=bool) + mask[60:70, 100:115] = False + npz = folder / "dtm_fake.npz" + np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask) + return npz, [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]] + + +def _real(_folder: Path) -> tuple[Path, list[list[float]]]: + if not (REAL_GRID.is_file() and REAL_NODES.is_file()): + pytest.skip("검증_sub8 없음") + with REAL_NODES.open(encoding="utf-8") as handle: + return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] + + +def _run(case) -> dict: + with tempfile.TemporaryDirectory() as folder: + npz, nodes = case(Path(folder)) + line = _line(nodes) + longitudinal = core.generate_sections( + np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz) + )["longitudinal"] + options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None) + policy = design_ground_following_profile(longitudinal, options)[0]["policy"] + job = { + "nodes": nodes, + "line": line, + "minRadius": MIN_RADIUS_M, + "policy": policy, + "types": _types(), + } + 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(npz)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_346_1_pipeline_page.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub8"]) +def r(request) -> dict: + return _run(request.param) + + +def test_실제_단계_한_번_노선_옮김_종단까지_시간(r): + real = r["real"] + assert real["first"]["outcome"] == "done" and real["savedPlanline"] is True + run = real["routeRun"] + assert run["outcome"] == "done" + for stage in ("route", "follow", "stations", "ground", "planline", "profile"): + assert stage in run["stages"], stage + to_profile = sum( + run["stages"][s] for s in ("route", "follow", "stations", "ground", "planline", "profile") + ) + assert to_profile < BUDGET_MS, run["stages"] + assert run["totalMs"] < BUDGET_MS + assert real["moved"]["profileStations"] > 2 and real["moved"]["footprint"] is True + + +def test_바뀐_구간_앞은_그대로(r): + real = r["real"] + from_m = real["range"]["fromM"] + assert 0 < from_m and real["range"]["toM"] is None # JSON 의 Infinity = null + saved = [p for p in real["savedBasePvi"] if p["chainage_m"] < from_m - 1e-6] + moved = [p for p in real["moved"]["basePvi"] if p["chainage_m"] < from_m - 1e-6] + assert saved and moved == saved + before = {k: c for k, c in real["beforeKeys"] if c < from_m - 20} + after = dict(real["moved"]["stations"]) + assert before and all(after.get(k) == c for k, c in before.items()) + + +def test_계획고_한_점은_측점_지반을_안_셈(r): + real = r["real"] + g = real["gradeRun"] + assert g["outcome"] == "done" + assert {"route", "follow", "stations", "ground", "planline"} <= set(g["skipped"]) + assert "profile" in g["stages"] + rng = real["gradeRange"] + assert rng["fromM"] < real["target"] < (rng["toM"] if rng["toM"] is not None else float("inf")) + assert abs(real["raised"] - 0.8) < 1e-6 + + +def test_늦은_응답_버림(r): + late = r["late"] + assert late["a"] == "dropped" and late["b"] == "done" + assert late["profileGen"] == ["profile@2"] + assert not [c for c in late["commits"] if c.startswith(("ground@1", "planline@1"))] + + +def test_편집기_다리(r): + d = r["draftLink"] + assert d["told"] == [3, 2, None] # 쌓기 · 되돌리기 · 열 때로(null) — 다시하기 없음은 안 알림 + assert d["restored"] == [ + [r["n"], True] + ] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름 + assert d["unwatched"] is True + + +def test_페이지_묶음_노선_초안_캐시_칸(r): + p = r["page"] + assert p["before"] == {"saved": None, "pipe": True} + assert ( + p["cached"]["rev"] == 1 + and p["cached"]["terrain"] == "las" + and p["cached"]["nodes"] == r["n"] + ) + assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로 + assert p["adoptedDraft"] and all(a["draft"] and not a["saved"] for a in p["adoptedDraft"]) + assert p["afterRevert"]["session"] is None and p["afterRevert"]["last"]["draft"] is False + assert p["disposed"] is None + assert p["survived"] == r["n"] # 떠났다 다시 열어도 초안 살아남음 + assert p["reopened"] and p["reopened"][-1]["draft"] is True + assert p["grade"]["sources"] == ["grade"] and "stations" not in p["grade"]["stages"] + assert p["clearedDraft"] is None # clearDrafts 가 함께 비움 + assert p["remoteCalls"] == 0 # 격자 있으면 서버 길 안 탐 diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 3a0618cb5..55bf9a055 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -131,31 +131,36 @@ def _structure(sid, type_id, anchor, start=None, end=None): def test_측점_열쇠_규칙(): - stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)] - pipes = [(3, PipePoint(chainage_m=50.0))] + """`common_util_station_key` 한 벌 — 관 · 구조물 id · id 없으면 at: · 같은 열쇠 둘째는 at:.""" + stations = [{"chainage_m": s} for s in (0.0, 20.0, 33.3, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)] + with_id = PipePoint(chainage_m=50.0) + with_id.id = "p-7" # sub3 이 넣는 관 id + no_id = PipePoint(chainage_m=33.3) structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)] - keys = station_keys(stations, pipes, structures, 20.0) + keys = station_keys(stations, [with_id, no_id], structures, 20.0) assert keys == { "end:BP": 0.0, "grid:1": 20.0, + "at:33.30": 33.3, # id 없는 관 "grid:2": 40.0, - "pipe:3": 50.0, - "structure:w1:start": 60.0, - "structure:w1": 77.0, - "structure:w1:end": 80.0, + "pipe:p-7": 50.0, + "structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐 + "at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at: + "at:80.00": 80.0, "end:EP": 95.5, } + assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음 def test_이월은_열쇠로_누가거리가_밀려도(): old_keys = { "end:BP": 0.0, "grid:1": 20.0, - "pipe:0": 50.0, + "pipe:p-7": 50.0, "structure:w1": 77.0, "grid:5": 100.0, } - new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0} + new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:p-7": 57.0, "structure:w1": 84.0} designs = { 0.0: {"ground_type": "soil"}, 20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0},