diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 53f92167a..d4b2ff545 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -10,8 +10,8 @@ display: none; } -/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에 - 선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */ +/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 평면(노선 편집) 위에 + 떠 있는 판(absolute · 평면은 전체 영역 · 345-5)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */ .design-page__main { position: relative; display: flex; @@ -35,7 +35,7 @@ min-height: 0; } -.design-page__main > .design-page__profile > .b05-route-profile { +.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile { position: relative; flex: none; } 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_shared.ts b/A00_Common/design/design_shared.ts index 7985fd101..803c7c158 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -300,7 +300,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea ...(side ? { pipes: async () => { - await side.savePipes(); + await side.savePipes(confirm); }, } : {}), 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/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 3cd0632df..b406cc303 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -510,6 +510,8 @@ export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_brid export interface DetailPipePoint { chainage_m: number; + /** 관 고정 id(347 덧 · 측점 열쇠 `pipe:`) — 서버가 붙임 · 옮겨도 그대로. */ + id?: string; lonlat: [number, number]; source: PipeSource; /** 시설 종류 — 응답에서 생략되면 기본 배관. */ diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 90e775521..fcfcd16e7 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -5,10 +5,9 @@ * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - * POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠) * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) * - * 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 + * 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 * 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음. * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) @@ -260,21 +259,3 @@ export async function resetRoutePlan(projectId: string): Promise { - const payload = await requestJson<{ profile: unknown }>( - `/projects/${projectId}/route/profile-preview`, - { - method: "POST", - body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }), - }, - 120000, - ); - return payload.profile; -} 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_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 2da4ffe04..7a7de9076 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -9,11 +9,10 @@ import functools import logging import time from pathlib import Path -from typing import Any, Literal +from typing import Any from uuid import UUID from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice @@ -277,19 +276,12 @@ async def balanced_profile_of( points: list[dict[str, float]], criteria: Any, design_curves: list[dict[str, Any]] | None = None, - terrain: Literal["las", "sheet"] | None = None, ) -> dict[str, Any] | None: - """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀. - - `terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인). - """ + """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 몫(지금 설계 지형).""" from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR - from common_util.common_util_surface_confirmation import ( - analysis_terrain_params, - get_surface_confirmation_params, - ) + from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler grade_class, design_speed, terrain_type = await replan._road_settings(project_id) @@ -299,9 +291,8 @@ async def balanced_profile_of( pool = get_db_pool() async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) - selection = analysis_terrain_params(selection, terrain) try: - # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값 + # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) surface = await asyncio.to_thread( build_surface_sampler, project_root / _MODELS_SUBDIR, @@ -358,47 +349,3 @@ async def refresh_initial_snapshot( logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id) except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다 logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id) - - -class ProfilePreviewRequest(BaseModel): - """편집 창 종단 띠(PLAN 75-1) — 지금 노드 · 지금 추천 제약.""" - - vertices: list[replan.RouteVertexInput] = Field(..., min_length=2) - criteria: replan.RouteInitialCriteria | None = None - terrain: Literal["las", "sheet"] | None = None - - -@replan.router.post("/{project_id}/route/profile-preview", response_model=None) -async def profile_preview( - project_id: UUID, request: ProfilePreviewRequest -) -> dict[str, Any] | JSONResponse: - """지금 노드의 균형 종단 — [확인]과 같은 폴리라인(같은 R · CSV 자릿수) · 같은 셈. - - 파일 · DB 를 쓰지 않는다. 「종단 반영」 켬일 때 편집 창 띠가 노드를 놓을 때마다 부른다. - """ - from common_util.common_util_route_polyline import build_planned_polyline - - paths = await replan._project_paths(project_id) - if paths is None: - return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING) - project_root, _ = paths - radius_m, _, _ = await replan._plan_criteria(project_id) - built = build_planned_polyline( - [(v.x, v.y) for v in request.vertices], - min_radius_m=radius_m, - simplify=False, - curve_flags=[bool(v.curve) for v in request.vertices], - radii=[v.radius_m for v in request.vertices], - ) - # [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로. - points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices] - curves = [curve.as_dict() for curve in built.curves] - profile = await balanced_profile_of( - project_id, project_root, points, request.criteria, curves, request.terrain - ) - if profile is None: - return JSONResponse( - status_code=409, - content={"status": "error", "message": "이 노선으로 균형 종단을 세우지 못했습니다."}, - ) - return {"status": "success", "project_id": str(project_id), "profile": profile} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index c7a482e8b..8ce773bbb 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -358,6 +358,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra response.pipe_points.map((pipe) => ({ chainage_m: pipe.chainage_m, reason: pipe.source, + id: pipe.id, })), ); // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음). @@ -395,6 +396,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.pipes().map((pipe) => ({ chainage_m: pipe.chainage_m, source: (pipe.reason || "user") as PipeSource, + ...(pipe.id ? { id: pipe.id } : {}), })), ); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index f6e4539ba..fc92c1ec2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -354,9 +354,16 @@ export function reconcilePipes( if (next.length === current.length && next.every((value, index) => value === current[index])) { return null; } + const near = (a: number, b: number): boolean => Math.abs(a - b) < PIPE_MATCH_M; + // 사라진 관 하나 · 새 관 하나 = 옮긴 것 — 고정 id 를 따라 옮김(시설 속성 `follow` 와 같은 규칙) + const gone = before.filter((pipe) => !next.some((value) => near(pipe.chainage_m, value))); + const added = next.filter((value) => !before.some((pipe) => near(pipe.chainage_m, value))); return next.map((chainage) => { - const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M); - return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" }; + const matched = + before.find((pipe) => near(pipe.chainage_m, chainage)) ?? + (gone.length === 1 && added.length === 1 && added[0] === chainage ? gone[0] : undefined); + const reason = matched && near(matched.chainage_m, chainage) ? matched.reason : "confirmed"; + return { chainage_m: chainage, reason, ...(matched?.id ? { id: matched.id } : {}) }; }); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 341a74acb..075625509 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -12,6 +12,8 @@ import { snapStructureStation } from "../common_util/common_util_structure_stati export interface PipePoint { chainage_m: number; reason: string; + /** 관 고정 id(347 덧 · 측점 열쇠 `pipe:`) — 옮겨도 그대로 · 새 관은 없음(서버가 붙임). */ + id?: string; } interface RoutePointLike { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index b2a88106c..6d916379f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -54,6 +54,7 @@ import { captureView, createViewMemory, fitCanvas, + fitView, openingView, restoreSavedView, screenToMetric, @@ -68,7 +69,6 @@ import { writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial"; -import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip"; import { applyArcLocks, applyCurveLimits, @@ -84,6 +84,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)보다 **넉넉히** 둔다 @@ -106,6 +107,7 @@ export interface RouteEditOptions { tools?: HTMLElement[]; /** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */ recommend: InitialPanelParams["controls"]; + draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`) } /** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ @@ -277,22 +279,6 @@ export async function mountRouteEditor( planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); - /** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */ - const strip = createProfileStrip({ - root: overlay.querySelector("[data-strip]")!, - projectId, - withProfile: initial.withProfile, - now: () => ({ - vertices: planned.map(([x, y], index) => ({ - x, - y, - curve: curveOn[index] !== false, - radius_m: curveRadius[index] ?? null, - })), - line: plannedLine.length ? plannedLine : planned, - criteria: initial.current().criteria, - }), - }); // [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림. overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => { const snapshot = history?.reset(); @@ -323,17 +309,21 @@ export async function mountRouteEditor( rotation.set(opening.rotationRad); viewReady = true; } + // [맞춤] — 노선 전체를 뜬 종단 판 위 보이는 영역에(회전 0 · 345-5 덧 2). 셈은 `_View` 몫. + overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => { + if (!meta || view.width <= 0 || !planned.length) return; + view = fitView(meta, view, planned); + rotation.set(0); + draw(); + }); 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; @@ -463,7 +453,6 @@ export async function mountRouteEditor( draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); - strip.refresh(); } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ @@ -544,7 +533,6 @@ export async function mountRouteEditor( commit: () => { history?.commit(snapshotNow()); historyControls.sync(); - strip.refresh(); }, }); @@ -594,7 +582,7 @@ export async function mountRouteEditor( terrain: pickedTerrain, stationSnap: () => stationSnap, initial, - profileShown: strip.settle, + profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함 onApplied, }); @@ -631,6 +619,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]]); @@ -682,7 +672,6 @@ export async function mountRouteEditor( useBackground(background); measure.clear(); slope.refresh(); - strip.refresh(); void crossPreview.refresh(); showStatus(); draw(); @@ -692,7 +681,6 @@ export async function mountRouteEditor( resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); draw(); - strip.refresh(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 08f3e7d91..63c5000ed 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -59,12 +59,14 @@ export function createRouteEditChrome( + @@ -112,12 +114,6 @@ export function createRouteEditChrome( -
-
- 종단을 읽는 중… -
-
-