diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index f072f1c3d..895ecb64e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -28,9 +28,10 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log import { SOURCE_INPUT } from "./logic/design_pipeline_types"; export { pipelineWork } from "./logic/design_pipeline"; import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; -import { createStationsStage } from "./logic/design_pipeline_stations"; +import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; import type { ChangeSource, + CrossOutput, FollowPatch, PipelineInputs, PipelineOutputs, @@ -147,6 +148,13 @@ export interface PagePipelineOptions { ) => (stations: PipelineStation[]) => Promise; /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ onUnstable?: () => void; + /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null + * (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */ + savedCross?: ( + detail: SectionDetailResponse, + routeId: number, + stations: ReadonlyArray, + ) => Promise; } interface PagePipeline { @@ -157,6 +165,7 @@ interface PagePipeline { earthwork(context: MassHaulStageContext | null): void; cross(): void; reconcile(): Promise; + ownsCross: boolean; detail(): SectionDetailResponse | null; dispose(): void; } @@ -167,22 +176,6 @@ type Facilities = Pick; const pages = new Map(); const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); -/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */ -function stationDiff( - detail: SectionDetailResponse, - stations: ReadonlyArray, -): NonNullable | null { - const saved = detail.cross_sections.map((section) => section.chainage_m); - const n = Math.max(saved.length, stations.length); - for (let i = 0; i < n; i += 1) { - const pipelineM = stations[i]?.chainageM ?? null; - if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3) - continue; - return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length }; - } - return null; -} - /** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */ export function routeDraftBridge( projectId: string, @@ -208,6 +201,10 @@ export const pipelineEarthwork = (projectId: string, context: MassHaulStageConte /** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */ export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross(); +/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */ +export const pipelineOwnsCross = (projectId: string): boolean => + pages.get(projectId)?.ownsCross ?? false; + /** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */ export const reconcilePagePipeline = (projectId: string): Promise => pages.get(projectId)?.reconcile() ?? Promise.resolve(0); @@ -363,18 +360,27 @@ function createPagePipeline( ground: createGroundStage(groundDeps), planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }), profile: profileStage, - // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 - cross: (ctx) => - crossStage.stage({ - ...ctx, - inputs: { - ...ctx.inputs, - cross: crossWithUphill( - ctx.inputs.cross as CrossInput | null, - ctx.out.ground?.stations ?? [], - ), - }, - }), + // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈 + // 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4) + cross: (ctx) => { + const engine = () => + crossStage.stage({ + ...ctx, + inputs: { + ...ctx.inputs, + cross: crossWithUphill( + ctx.inputs.cross as CrossInput | null, + ctx.out.ground?.stations ?? [], + ), + }, + }); + const { detail = null, routeId } = savedSource(); + const saved = + !ctx.inputs.route && detail && typeof routeId === "number" + ? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? []) + : null; + return saved ? saved.then((out) => out ?? engine()) : engine(); + }, footprint: footprintStage, masshaul: createMassHaulStage(() => earthwork), haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)), @@ -671,6 +677,7 @@ function createPagePipeline( if (cross) void feed("cross", cross); }, reconcile, + ownsCross: Boolean(options.savedCross), detail: () => shown, dispose() { disposed = true; diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index faa2750cb..0ef86c6b5 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -353,8 +353,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { pagePipeline.pipelineGrade(projectId, store.edits()); // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 - // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). - scheduleCrossPreview(); + // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4). + if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview(); drawSoon(); } /** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */ diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 275b58cfc..3d87fd8c8 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -40,6 +40,8 @@ import { restoreSectionDetail, swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; +import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh"; +import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -210,6 +212,16 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), + // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을 + // 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진 + savedCross: async (detail, routeId, stations) => { + if (!detailCross(detail, stations)) return null; + const edits = profile.pipelineSource().edits; + const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits }); + if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated); + profile.redraw(); + return detailCross(detail, stations); + }, structureAreas: ({ cross_sections: rows }) => applyStructureAreaRows(rows, structureAreaRows(rows)), // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 85da53112..8d9063801 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -225,11 +225,11 @@ export async function mountCrossView( const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9) try { + await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4) const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; sectionDetail = fresh; - // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 - // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). + // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측). if (sectionDetail) { applyStructureAreaRows( sectionDetail.cross_sections, diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 9dc61c492..fcd29e639 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -14,7 +14,13 @@ import type { PlanStructure, PlanType, } from "../../../common_util/common_util_structure_station"; -import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types"; +import type { + PipelineRunRecord, + PipelineStation, + StageContext, + StageFn, +} from "./design_pipeline_types"; +import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; /** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */ export function reuseOutside( @@ -54,3 +60,19 @@ export function createStationsStage( return reuseOutside(next, ctx.prev, ctx.range); }; } + +/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */ +export function stationDiff( + detail: SectionDetailResponse, + stations: ReadonlyArray, +): NonNullable | null { + const saved = detail.cross_sections.map((section) => section.chainage_m); + const n = Math.max(saved.length, stations.length); + for (let i = 0; i < n; i += 1) { + const pipelineM = stations[i]?.chainageM ?? null; + if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3) + continue; + return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length }; + } + return null; +} diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 12331fdd2..9ddf421bf 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -102,6 +102,33 @@ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { return out as SectionSpecs; } +/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와 + * 다르면(갈아끼움 전) null. */ +export function detailCross( + detail: SectionDetailResponse, + stations: ReadonlyArray, +): CrossOutput | null { + const sections = detail.cross_sections; + const same = + sections.length === stations.length && + stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3); + if (!same) return null; + const byKey: CrossOutput["byKey"] = {}; + stations.forEach((station, i) => { + const { design, samples } = sections[i]; + if (!design) return; + byKey[station.key] = { + key: station.key, + chainageM: station.chainageM, + cutAreaM2: design.cut_area_m2, + fillAreaM2: design.fill_area_m2, + detail: design, + ...(samples ? { samples } : {}), + } as CrossOutput["byKey"][string]; + }); + return { byKey }; +} + /** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */ export function stationSection( station: PipelineStation, diff --git a/B06_Section/B06_Section_UI_Page_Station_Swap.ts b/B06_Section/B06_Section_UI_Page_Station_Swap.ts index 9f9632cea..e3abf001b 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Swap.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Swap.ts @@ -6,6 +6,8 @@ * 종단 보기도 같은 가게를 듣고 같은 함수를 부름 — 먼저 부른 쪽이 갈아끼우고 다음 쪽은 「같은 목록」. * 노선 초안 동안(346-2 덧 3) 카드 = 묶음 새 노선 상세(`pipelineDetail` · cross 단계 결과) — 새 상세가 오면 * 같은 길로 다시 그림 · 초안을 버리면(상세 null) 저장 노선 카드로. + * 묶음 차례는 cross 뒤에만 다시 그림(profile 뒤 중간 상세는 안 그림 · 365-4) — 겹친 신호(갈아끼움 · 묶음 · 초안)는 + * 횡단 보기 다시 읽기가 한 번으로 묶음(`design_view_cross` refreshDetailForStructures · 최신 차례만). * ========================================================================== */ import { useDesignStore } from "../A00_Common/design/design_store"; @@ -42,7 +44,7 @@ export function followSwaps( }; } -/** 묶음 새 노선 상세가 바뀌면(profile · cross 단계 뒤) 부름 — 묶음은 종단 보기가 세우므로 차례 번호가 날 때 붙음. */ +/** 묶음 새 노선 상세가 바뀌면(cross 단계 뒤) 부름 — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음. */ function followDraftDetail(projectId: string, changed: () => void): () => void { let pipe: DesignPipeline | null = null; let stopPipe = (): void => undefined; @@ -54,8 +56,7 @@ function followDraftDetail(projectId: string, changed: () => void): () => void { pipe = now; stopPipe = now?.subscribe((event) => { - if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) - return; + if (event.kind !== "stage" || event.stage !== "cross") return; const next = pipelineDetail(projectId); if (next === drawn) return; drawn = next; diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs index 23a06bbd6..d208a03df 100644 --- a/resources/tester/helper_230_2_popout_frames.cjs +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -99,7 +99,7 @@ out.viewOf = { buildAlignment: () => null, store: { edits: () => ({}) }, projectId: "p", - pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음 + pagePipeline: { pipelineGrade: () => undefined, pipelineOwnsCross: () => false }, // 계산 묶음(346-1 · 365-4) — 묶음 없으면 아무 일 없음 scheduleCrossPreview: () => s.previews++, draw: () => s.draws++, root: inPopup(), diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index 02fdb566e..8ee5eecee 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -336,7 +336,60 @@ async function run() { }; pageMod.disposePagePipeline(P); page.clearDrafts(P); - return { kept, b05, b06, dropped, edit, loop, pane: paneCheck }; + + // ⑨ 저장 노선 ▲▼ 한 번 = 횡단 한 벌(365-4) — 묶음 cross 가 다시 셈 창구를 부르고 그 상세 설계를 냄 + const Q = "proj-365b"; + window.localStorage.setItem("frd_current_project_id", Q); + let refreshCalls = 0; + const qBridge = pageMod.routeDraftBridge(Q, link(), { + stationIntervalM: job.interval, + savedCross: async (detail, _routeId, stations) => { + if (!detailMod.detailCross(detail, stations)) return null; + refreshCalls += 1; + return detailMod.detailCross(detail, stations); + }, + }); + pageMod.pipelineEarthwork(Q, { conversion: job.conversion }); + const qPipe = pageMod.pagePipeline(Q); + await settle(runs().length + 1); + await tick(20); + const openCalls = refreshCalls; + const key = alignment.chainageKey(savedBase.stations[3].chainage_m); + mark = runs().length; + pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.3 }, curve_radii: {} }); + const gradeRun = await settle(mark + 1); + const firstKey = qPipe.output("ground").stations[0].key; + const saved = { + owns: pageMod.pipelineOwnsCross(Q), + openCalls, + gradeCalls: refreshCalls - openCalls, + runs: runs().length - mark, + sources: gradeRun.sources, + fromDetail: + qPipe.output("cross").byKey[firstKey]?.detail === job.detail.cross_sections[0].design, + }; + + // ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번) + let graded = false; + const stopShift = qPipe.subscribe((event) => { + if (event.kind !== "stage" || event.stage !== "profile" || graded) return; + if (event.generation !== qPipe.generation) return; + graded = true; + void Promise.resolve().then(() => + pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }), + ); + }, "중심점 좌우"); + mark = runs().length; + qBridge.edited(draftOf(shift(1, 0, 1))); + await settle(mark + 2); + await tick(job.haulDelayMs + 30); + stopShift(); + const shiftRuns = runs() + .slice(mark) + .map((r) => ({ outcome: r.outcome, cross: "cross" in r.stages, sources: r.sources })); + pageMod.disposePagePipeline(Q); + page.clearDrafts(Q); + return { kept, b05, b06, dropped, edit, loop, pane: paneCheck, saved, shiftRuns }; } run() diff --git a/resources/tester/test_346_2_draft_cross_pills.py b/resources/tester/test_346_2_draft_cross_pills.py index 2ef7bcf4e..3e424dea6 100644 --- a/resources/tester/test_346_2_draft_cross_pills.py +++ b/resources/tester/test_346_2_draft_cross_pills.py @@ -38,20 +38,20 @@ def out(): def test_노선_초안_동안_카드는_새_노선_상세(out): cards = out["cards"] assert cards["before"] == "saved" - assert cards["afterDraft"] == ["draft-profile-1", "draft-cross-1"] + assert cards["afterDraft"] == ["draft-cross-1"] # profile 차례 중간 상세는 안 그림(365-4) assert cards["draftNow"] == "draft-cross-1" def test_숨은_동안은_안_그림(out): cards = out["cards"] - assert cards["whileHidden"] == ["draft-profile-1", "draft-cross-1"] + assert cards["whileHidden"] == ["draft-cross-1"] assert cards["hiddenNow"] == "draft-cross-2" def test_초안_버리면_저장_노선_카드(out): cards = out["cards"] assert cards["discarded"][-1] == "saved" and cards["savedNow"] == "saved" - assert cards["afterStop"] == 3 # 끊은 뒤엔 안 그림 + assert cards["afterStop"] == 2 # 끊은 뒤엔 안 그림 def test_알약은_따라간_누가거리(out): diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 4bec14fa8..a77d6797b 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -10,6 +10,10 @@ - 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번. 못박는 것(365-3) - B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음. +못박는 것(365-4) + - 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔. + - 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만). + - B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림. 짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · `helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). @@ -132,6 +136,30 @@ def test_365_3_서버_횡단_미리보기_길_없음(): assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림 +def test_365_4_저장_노선_계획고_횡단_한_벌(r): + s = r["saved"] + assert s["owns"] and s["openCalls"] == 1 + assert s["gradeCalls"] == 1 and s["runs"] == 1 and s["sources"] == ["grade"] + assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈) + + +def test_365_4_중심점_좌우_횡단_한_번(r): + runs = r["shiftRuns"] + assert [x["outcome"] for x in runs] == ["dropped", "done"], runs + assert [x["cross"] for x in runs] == [False, True] + assert runs[1]["sources"] == ["route", "grade"] # 밀린 노선 범위 + 계획고 = 한 차례 + + +def test_365_4_다시_그림_한_번(): + swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8") + view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8") + profile = (ROOT / "A00_Common/design/design_profile.ts").read_text(encoding="utf-8") + assert 'event.stage !== "cross") return;' in swap # profile 차례 중간 상세는 안 그림 + body = view.split("async function refreshDetailForStructures")[1].split("loadSectionDetail")[0] + assert "setTimeout(next, 0)" in body # 겹친 신호는 seq 가 앞 차례를 버림 = 한 번 + assert "if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();" in profile + + @pytest.fixture(scope="module") def swap(tmp_path_factory) -> dict: work = tmp_path_factory.mktemp("b365")