From 7efee22ab4eef756bbea1ca83874500a41d292ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:08:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(365-2):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=E2=86=92=20=EB=AC=B6=EC=9D=8C=20cross=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=9E=85=EB=A0=A5=20=EC=A7=80?= =?UTF-8?q?=EB=AC=B8=20=EA=B3=A0=EB=A6=AC(=EC=B5=9C=EB=8C=80=203=EB=B0=94?= =?UTF-8?q?=ED=80=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 입력 = 부를 때마다 저장 상세 + 세션 고침(지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 측점 열쇠) + 표준 — 상세 객체마다 기억 걷음 - B06 고침(DesignSync 다시 셈 끝 · 암 경계) 뒤 pipelineCross — 노선 초안 동안 B06 고침이 카드(새 노선 상세)에 보임 - 고리 — 열 때 · 페이지 들어올 때 입력(세션 · 가게)을 묶음 입력(넘긴 값 · follow 뒤 값)과 견줌 · 다르면 한 바퀴 · 끝난 뒤 다시 · 3바퀴 넘으면 「계산 불안정」 한 줄 - 시험 test_365_page_pipeline(초안 동안 고침 카드 · 같은 입력 0바퀴 · 세션만 바뀜 1바퀴 · 불안정 3바퀴 알림 1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_page.ts | 2 + A00_Common/design/design_pipeline_cross.ts | 43 +++++++- A00_Common/design/design_pipeline_page.ts | 100 ++++++++++++++---- A00_Common/design/design_shared.ts | 1 + .../B06_Section_UI_Page_Design_Sync.ts | 4 + resources/tester/helper_365_page_pipeline.cjs | 54 +++++++++- resources/tester/test_365_page_pipeline.py | 17 +++ ui_template/ui_template_locale_b2.ts | 4 + 8 files changed, 203 insertions(+), 22 deletions(-) diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 63fc84c16..2a466c75f 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; +import { reconcilePagePipeline } from "./design_pipeline_page"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { if (slot) { slot.show(); applyView(view); + void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2) } else mountView(view, true); // 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다. const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } }; diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 4ef809138..382120c65 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -8,9 +8,11 @@ * 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 · * 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 * 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단 - * 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음). + * 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 · + * 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음. * ========================================================================== */ +import { readState } from "../b_page_state"; import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { effectiveStandardCross, @@ -71,11 +73,45 @@ function carried(section: CrossSection): Record { }; } -/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */ +/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */ +function sessionCells( + name: "crossdesign" | "rockb" | "cutslope", + projectId: string, + routeId: number, +): Array<[string, unknown]> { + const raw = readState>(name, projectId, routeId); + if (!raw || typeof raw !== "object") return []; + return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:")); +} + +/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */ +function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] { + const out: CrossInput["byKey"] = {}; + for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) { + const choice = (value ?? {}) as Record; + const entry = (out[key] ??= {}); + if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type; + if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type; + if ("ditch_choice" in choice) + entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null; + if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope); + } + for (const [key, value] of sessionCells("rockb", projectId, routeId)) + if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value; + for (const [key, value] of sessionCells("cutslope", projectId, routeId)) { + const ratio = finite(value); + if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null; + } + return out; +} + +/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 · + * 상세가 없으면 null. */ export function pipelineCrossSource( projectId: string, detail: SectionDetailResponse | null, savedStations: ReadonlyArray, + routeId: number | null = null, ): PipelineCrossSource | null { const standard = detail && effectiveStandardCross(projectId); if (!detail || !standard) return null; @@ -94,6 +130,9 @@ export function pipelineCrossSource( for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; specs.set(best.key, own as SectionSpecs); } + if (routeId !== null) + for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId))) + byKey[key] = { ...byKey[key], ...entry }; return { // 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p) input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) }, diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 49a4f9d9d..f072f1c3d 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -12,7 +12,10 @@ * * 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집 * (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점 - * 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. + * 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) · + * 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. + * 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 → + * 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄). * 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 · * 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림. * 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선). @@ -22,15 +25,18 @@ * ========================================================================== */ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline"; +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 type { + ChangeSource, FollowPatch, PipelineInputs, PipelineOutputs, PipelineRunRecord, PipelineStation, + RangePart, RouteDraft, StageFn, Vertex, @@ -102,6 +108,8 @@ import type { /** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */ export const DRAFT_ROUTE_ID = 0; +/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */ +export const RECONCILE_ROUNDS = 3; /** 종단도(`design_profile`)가 묶음에 내주는 것. */ export interface ProfileLink { @@ -137,6 +145,8 @@ export interface PagePipelineOptions { routeId: number, saved: SectionDetailResponse | null, ) => (stations: PipelineStation[]) => Promise; + /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ + onUnstable?: () => void; } interface PagePipeline { @@ -145,6 +155,8 @@ interface PagePipeline { grade(edits: AlignmentEdits): void; restart(): void; earthwork(context: MassHaulStageContext | null): void; + cross(): void; + reconcile(): Promise; detail(): SectionDetailResponse | null; dispose(): void; } @@ -193,6 +205,13 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void => pages.get(projectId)?.earthwork(context); +/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */ +export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross(); + +/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */ +export const reconcilePagePipeline = (projectId: string): Promise => + pages.get(projectId)?.reconcile() ?? Promise.resolve(0); + /** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */ export const pipelineDetail = (projectId: string): SectionDetailResponse | null => pages.get(projectId)?.detail() ?? null; @@ -219,10 +238,9 @@ function createPagePipeline( let savedStructures: PlanStructure[] = []; let savedPipes: PlanPipe[] = []; let earthwork: MassHaulStageContext | null = null; - /** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */ + /** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */ let savedStations: PipelineStation[] = []; - let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null = - null; + let crossSource: PipelineCrossSource | null = null; /** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을 * 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */ let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null = @@ -286,12 +304,17 @@ function createPagePipeline( emptyEdits() ); }; + /** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */ const crossNow = (): PipelineCrossSource | null => { - const detail = canon?.detail ?? null; - if (!detail) return null; - if (crossSource?.detail !== detail || !crossSource.source) - crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) }; - return crossSource.source; + const routeId = canon?.routeId; + const source = pipelineCrossSource( + projectId, + canon?.detail ?? null, + savedStations, + typeof routeId === "number" ? routeId : null, + ); + if (source) crossSource = source; + return source; }; const groundDeps = groundStageDeps(projectId); const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j) @@ -460,7 +483,7 @@ function createPagePipeline( ground, profile, pipe.output("cross"), - crossSource?.source?.specs ?? new Map(), + crossSource?.specs ?? new Map(), options.stationIntervalM ?? profile.policy.station_interval_m, ) : null; @@ -517,11 +540,48 @@ function createPagePipeline( // 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄 held = inputs.route ? (held ?? holdNow()) : null; dropping = null; - void pipe.start(inputs); + Object.assign(given, inputs); + const run = (lastRun = pipe.start(inputs)); openGeneration = pipe.generation; quietGeneration = inputs.route ? -1 : pipe.generation; + void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤 }; + /** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */ + const given: Partial = {}; + let lastRun: Promise = Promise.resolve(); + const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => { + given[SOURCE_INPUT[source]] = value as never; + const run = pipe.update(source, value, part); + if (run) lastRun = run; + return run; + }; + let reconciling: Promise | null = null; + /** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */ + function reconcile(): Promise { + if (!started || disposed) return Promise.resolve(0); + return (reconciling ??= (async () => { + for (let round = 0; ; round += 1) { + let seen: Promise | null = null; + while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지 + await pipe.settled(); + if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫 + const now = inputsNow(); + const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>) + .filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key])) + .map(([source, key]): [ChangeSource, unknown] => [source, now[key]]); + if (!stale.length) return round; + if (round >= RECONCILE_ROUNDS) { + options.onUnstable?.(); + return -1; + } + for (const [source, value] of stale) + if (source === "route") bridge.edited(value as RouteDraft | null); + else feed(source, value); + } + })().finally(() => (reconciling = null))); + } + /** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */ const loadPlan = (): Promise => fetchRoutePlan(projectId) @@ -555,13 +615,10 @@ function createPagePipeline( .catch(() => undefined); const unsubscribe = [ - store.subscribe( - "pipes", - (pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes), - ), + store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)), store.subscribe( "structures", - (list) => started && !echo && void pipe.update("structure", list ?? savedStructures), + (list) => started && !echo && void feed("structure", list ?? savedStructures), ), ]; @@ -582,7 +639,7 @@ function createPagePipeline( if (!started) return start(); const before = pipe.output("route")?.line ?? null; const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine(); - const run = pipe.update("route", draft, routeRange(before, after)); + const run = feed("route", draft, routeRange(before, after)); if (run && !draft && wasDraft) { quietGeneration = pipe.generation; dropping = run.then(finishDrop); @@ -598,7 +655,7 @@ function createPagePipeline( 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)); + void feed("grade", edits, gradeRange(before, edits, nodes)); }, restart() { if (!started) return start(); @@ -607,8 +664,13 @@ function createPagePipeline( earthwork(context) { earthwork = context; const cross = crossNow()?.input ?? null; - if (started && cross) void pipe.update("cross", cross); + if (started && cross) void feed("cross", cross); }, + cross() { + const cross = started ? (crossNow()?.input ?? null) : null; + if (cross) void feed("cross", cross); + }, + reconcile, detail: () => shown, dispose() { disposed = true; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 1ca8333de..275b58cfc 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -209,6 +209,7 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo stationIntervalM: SECTION_STATION_INTERVAL_M, onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), + onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), structureAreas: ({ cross_sections: rows }) => applyStructureAreaRows(rows, structureAreaRows(rows)), // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index fa04f3326..590837950 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -7,6 +7,7 @@ * - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] + * 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2). * ========================================================================== */ import { @@ -28,6 +29,7 @@ import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_ import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; import { useStructureIds } from "./B06_Section_Station_Keys"; +import { pipelineCross } from "../A00_Common/design/design_pipeline_page"; export interface DesignSyncContext { projectId: string | null; @@ -125,6 +127,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { .detail() ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design; if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true }); + else if (projectId) pipelineCross(projectId); } /** @@ -184,6 +187,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Design_Failed")}${detail}`, "error"); } + pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈) } /** diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index ae558e96a..c65ca075d 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -199,6 +199,7 @@ async function run() { const savedBase = await savedBaseOf(); const shop = store.useDesignStore(P, null); const holds = []; + let unstable = 0; const link = () => ({ pipelineSource: () => ({ base: savedBase, @@ -215,6 +216,7 @@ async function run() { holds.push(saved === job.detail ? "saved" : saved ? "other" : "null"); return async () => "saved"; }, + onUnstable: () => (unstable += 1), }; const open = (label) => { const bridge = pageMod.routeDraftBridge(P, link(), options); @@ -249,6 +251,56 @@ async function run() { stages: Object.keys(reopenRun.stages), }; + // ⑤ 초안 동안 B06 고침 — 세션 칸(측점 열쇠) → pipelineCross → 카드(묶음 새 노선 상세) + const pipe = pageMod.pagePipeline(P); + const target = pipe.output("ground").stations.find((s) => s.kind === "grid" && s.chainageM > 30); + const cell = (ground) => ({ [target.key]: { ground_type: ground, section_mode: "left_cut" } }); + const groundAt = () => + pageMod + .pipelineDetail(P) + .cross_sections.find((s) => Math.abs(s.chainage_m - target.chainageM) < 1e-3)?.design + ?.ground_type ?? null; + const before = groundAt(); + page.writeState("crossdesign", cell("blasting_rock"), P, 7); + let mark = runs().length; + pageMod.pipelineCross(P); + await settle(mark + 1); + await tick(20); + const edit = { + key: target.key, + before, + after: groundAt(), + input: pipe.input("cross").byKey[target.key]?.groundType ?? null, + runs: runs().length - mark, + sources: runs()[runs().length - 1].sources, + }; + // ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림 + mark = runs().length; + const same = await pageMod.reconcilePagePipeline(P); + const sameRuns = runs().length - mark; + page.writeState("crossdesign", cell("ripping_rock"), P, 7); + mark = runs().length; + const once = await pageMod.reconcilePagePipeline(P); + const loop = { + same, + sameRuns, + once, + onceRuns: runs().length - mark, + onceGround: groundAt(), + }; + let n = 0; + const realStandard = STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross; + STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = () => ({ + ...job.standard, + n: (n += 1), + }); + mark = runs().length; + loop.unstable = await pageMod.reconcilePagePipeline(P); + loop.unstableRuns = runs().length - mark; + loop.alerts = unstable; + STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = realStandard; + await pageMod.reconcilePagePipeline(P); + // ④ 초안 버림 — 열 때 맡긴 초안 전 상세로 되돌림(저장 정본) again.bridge.edited(null); const dropRun = await settle(runs().length + 1); @@ -260,7 +312,7 @@ async function run() { }; pageMod.disposePagePipeline(P); page.clearDrafts(P); - return { kept, b05, b06, dropped }; + return { kept, b05, b06, dropped, edit, loop }; } run() diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index fa8c67afc..45924176d 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -5,6 +5,9 @@ - 노선 초안 둔 채 다시 열기(새로고침 · B06 바로 열기 · 편집기 없음) = B05 길과 같은 초안 상세 (측점 · 계획고 · 면적) · 측점을 가게에 다시 냄 · 초안 전 = 저장 상세(열 때 맡김) → 버리면 saved. - 초안 동안 측점 갈아끼움은 메모리만 — 세션 상세(저장 노선)를 새 측점 + 옛 설계로 덮지 않음. +못박는 것(365-2) + - 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임. + - 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번. 짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · `helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). @@ -99,6 +102,20 @@ def test_365_1_초안_버림_저장_정본(r): assert d["restoreWhy"] == "saved" +def test_365_2_초안_동안_횡단_고침_카드에(r): + e = r["edit"] + assert e["before"] != "blasting_rock" + assert e["input"] == "blasting_rock" and e["after"] == "blasting_rock" + assert e["runs"] == 1 and e["sources"] == ["cross"] + + +def test_365_2_고리_바퀴_수(r): + loop = r["loop"] + assert loop["same"] == 0 and loop["sameRuns"] == 0 # 같은 입력 = 안 돎 + assert loop["once"] == 1 and loop["onceRuns"] == 1 and loop["onceGround"] == "ripping_rock" + assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1 + + @pytest.fixture(scope="module") def swap(tmp_path_factory) -> dict: work = tmp_path_factory.mktemp("b365") diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3470286fa..35c8f42dc 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1047,4 +1047,8 @@ export const ui_locales_b2 = { ], B06_Std_Note_Rock: ["암", "Rock"], B06_Std_Note_Paved: ["포장", "Paved"], + Design_Pipeline_Unstable: [ + "계산 불안정 — 입력과 결과가 세 바퀴 안에 안 맞음", + "Calculation unstable — inputs and results did not settle in three rounds", + ], } as const satisfies Record; From 495a3cd2d9c3fe5765f7b455b3fae5153cef5621 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:14:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(365-3):=20B05=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=ED=9A=A1=EB=8B=A8=20=ED=8C=90=20=3D=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=ED=9A=A1=EB=8B=A8=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?cross-preview=20=EA=B8=B8=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 평면 오른쪽 횡단 판 = 계산 묶음 그 측점(stationSection — B06 카드 · 새 노선 상세와 같은 함수) · cross 차례마다 다시 그림(노선 · 계획고) · 노선이 바뀌면 전후 - 서버 POST route/cross-preview · fetchCrossPreview · 편집기 안 refresh 두 줄 지움 - 시험 test_365(판 계획고 · 면적 · 표본 = 카드 · 서버 길 없음) · cross_width · 133-1 · 224-2 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_Api_Replan.ts | 30 +--- B05_Profile/B05_Profile_Router_Terrain.py | 148 +----------------- B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 - B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 120 +++++++++----- .../logic/B05_Profile_Pipeline_Detail.ts | 97 +++++++----- resources/tester/helper_365_page_pipeline.cjs | 26 ++- .../helper_b05_routeedit_cross_width.cjs | 41 +++-- .../tester/test_133_1_analysis_terrain.py | 8 +- resources/tester/test_224_2_station_snap.py | 27 ---- resources/tester/test_365_page_pipeline.py | 16 ++ .../tester/test_b05_routeedit_cross_width.py | 10 +- 11 files changed, 211 insertions(+), 314 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index d8e10a59b..22783377d 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -229,7 +229,7 @@ export async function fetchRouteElevations( return payload.z; } -/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +/** 횡단 미리보기 한 장(평면 오른쪽 판) — 계산 묶음 그 측점을 이 꼴로 그림(365-3 · 서버 길 없음). */ export interface CrossPreviewResponse { status: string; chainage_m: number; @@ -241,7 +241,7 @@ export interface CrossPreviewResponse { cross_half_width_m: number; /** 원지반 횡단 샘플. */ samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; - /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + /** 계획 횡단 — 묶음 cross 단계 설계(B06 카드와 같음). 계획고를 못 세우면 null. */ design: { design_line: Array<{ offset_m: number; elevation_m: number }>; cut_area_m2: number; @@ -250,32 +250,6 @@ export interface CrossPreviewResponse { } | null; } -export interface CrossPreviewRequest { - vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; - chainage_m: number; - min_radius_m: number; - station_interval_m: number; - /** 횡단 반폭(m) — 없으면 서버 기본값. */ - cross_half_width_m?: number; - /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ - station_snap?: boolean; -} - -/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ -export async function fetchCrossPreview( - projectId: string, - request: CrossPreviewRequest, -): Promise { - return requestJson( - `/projects/${projectId}/route/cross-preview`, - { - method: "POST", - body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), - }, - 120000, - ); -} - /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 379907567..bbf439ac5 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -1,15 +1,14 @@ """계획노선 편집 중 **지반고만** 묻는 가벼운 통로. 편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어 -**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가 -있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 +**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)는 지반고가 있어야 한다(측점 횡단 미리보기 = 계산 +묶음 · 365-3). 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다. 표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. 두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 - POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag) @@ -30,13 +29,7 @@ from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Engine_Sections_Core import ( - SectionGenerationOptions, - generate_sections, -) -from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( AnalysisTerrain, @@ -121,143 +114,6 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> } -class PreviewVertex(BaseModel): - """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" - - x: float - y: float - curve: bool = True - radius_m: float | None = None - - -class CrossPreviewRequest(BaseModel): - """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" - - vertices: list[PreviewVertex] = Field(..., min_length=2) - chainage_m: float = Field(..., ge=0) - #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. - min_radius_m: float = Field(12.0, gt=0) - station_interval_m: float | None = None - #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. - cross_half_width_m: float | None = Field(None, gt=0) - terrain: AnalysisTerrain | None = None - #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. - station_snap: bool = False - - -def _cross_preview( - project_root: Path, - params: dict, - request: CrossPreviewRequest, -) -> dict | None: - """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. - - **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 - 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` - 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. - - ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 - 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 - 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. - """ - try: - sampler = build_surface_sampler( - project_root / _MODELS_SUBDIR, - str(params["source_filter"]), - str(params["method"]), - bool(params["smooth"]), - ) - except (FileNotFoundError, KeyError, OSError, ValueError) as exc: - logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc) - return None - - built = build_planned_polyline( - [(vertex.x, vertex.y) for vertex in request.vertices], - min_radius_m=request.min_radius_m, - # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). - simplify=False, - curve_flags=[vertex.curve for vertex in request.vertices], - radii=[vertex.radius_m for vertex in request.vertices], - ) - interval = request.station_interval_m - defaults = SectionGenerationOptions() - options = SectionGenerationOptions( - station_interval_m=float(interval) - if interval and interval > 0 - else defaults.station_interval_m, - cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, - ) - # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. - result = generate_sections( - built.vertices, - sampler, - options, - design_curves=[curve.as_dict() for curve in built.curves], - station_snap=request.station_snap, - ) - sections = result["cross_sections"] - if not sections: - return None - section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) - - design = None - center_z = section.get("center_z") - if center_z is not None: - # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. - section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" - design = compute_cross_design( - section["samples"], - float(center_z), - ground_type="soil", - section_mode=section_mode, - **curve_widening_args(section), - ) - return { - "chainage_m": round(float(section["chainage_m"]), 3), - "label": section.get("label"), - "uphill_side": section.get("uphill_side"), - "plan_radius_m": section.get("plan_radius_m"), - "curve_widening_m": section.get("curve_widening_m"), - # 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다. - "cross_half_width_m": options.cross_half_width_m, - "samples": section["samples"], - "design": design, - "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) - if result.get("longitudinal", {}).get("total_length_m") is not None - else None, - } - - -@router.post("/{project_id}/route/cross-preview", response_model=None) -async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: - """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). - - 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. - """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored = await get_project_storage_relative_path(connection, project_id) - if not stored: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, - ) - params = await get_surface_confirmation_params(connection, str(project_id)) - params = analysis_terrain_params(params, request.terrain) - - project_root = Path(resolve_stored_project_path(stored)) - preview = await asyncio.to_thread(_cross_preview, project_root, params, request) - if preview is None: - return JSONResponse( - status_code=409, - content={ - "status": "error", - "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", - }, - ) - return {"status": "success", "project_id": str(project_id), **preview} - - @router.get("/{project_id}/route/terrain-sources", response_model=None) async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: """편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3d05ffc66..7a091bca8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -266,7 +266,6 @@ export async function mountRouteEditor( const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); stationSnapButton.addEventListener("click", () => { setStationSnap(!stationSnap); - void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. draw(); }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ @@ -684,7 +683,6 @@ export async function mountRouteEditor( useBackground(background); measure.clear(); slope.refresh(); - void crossPreview.refresh(); showStatus(); draw(); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index c0b491141..c93d4d2b9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -2,22 +2,24 @@ * B05_Profile_UI_RouteEdit_Cross.ts * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲). * - * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다. + * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다. * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** - * 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다. + * 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다. * - * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · + * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 · * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. * - * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 - * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. - * - * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 - * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. + * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 + * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · + * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { useDesignStore } from "../A00_Common/design/design_store"; +import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { buildCrossSvg, CROSS_PLOT_PAD, @@ -30,7 +32,7 @@ export interface CrossPreviewParams { projectId: string; /** 두 판이 들어앉을 오른쪽 세로 칸. */ side: HTMLElement; - /** 지금 편집값 — 셈을 부르는 순간에 읽는다. */ + /** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */ request: () => { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; @@ -51,7 +53,7 @@ export interface CrossWatch { export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; - /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ + /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */ watched: () => CrossWatch | null; @@ -158,54 +160,96 @@ function createPane(title: string, empty: string): CrossPane { }; } +/** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */ +export function pipelineCrossAt( + pipe: DesignPipeline | null, + chainageM: number, + intervalM: number, + halfWidthM: number | undefined, +): CrossPreviewResponse | null { + const stations = pipe?.output("ground")?.stations ?? []; + if (!pipe || !stations.length) return null; + const station = stations.reduce((best, next) => + Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best, + ); + const section = stationSection(station, pipe.output("cross"), {}, intervalM); + const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0))); + return { + status: "success", + chainage_m: section.chainage_m, + label: section.label ?? null, + uphill_side: section.uphill_side ?? null, + plan_radius_m: section.plan_radius_m ?? null, + curve_widening_m: section.curve_widening_m ?? null, + cross_half_width_m: halfWidthM ?? reach, + samples: section.samples, + design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null, + }; +} + export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다."); const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다."); params.side.append(current.root, previous.root); + const interval = (): number => params.request().station_interval_m; /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ let watching: number | null = null; - /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */ + /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; - /** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */ - let ticket = 0; + let drawnRoute: unknown = null; - async function load(chainageM: number, keepPrevious: boolean): Promise { - const mine = ++ticket; - const request = params.request(); - if (keepPrevious && shown) previous.show(shown, request.station_interval_m); - current.wait("읽는 중…"); - try { - const preview = await fetchCrossPreview(params.projectId, { - ...request, - chainage_m: chainageM, - cross_half_width_m: appliedHalfWidth(params.projectId), - }); - if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다. - shown = preview; - current.show(preview, request.station_interval_m); - } catch (error) { - if (mine !== ticket) return; - shown = null; - current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다."); - } + function redraw(): void { + if (watching === null) return; + const route = pipe?.input("route") ?? null; + if (route !== drawnRoute && shown) previous.show(shown, interval()); + drawnRoute = route; + shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId)); + if (shown) current.show(shown, interval()); + else current.wait("계산 중…"); params.onShown?.(); } + // 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음 + let pipe: DesignPipeline | null = null; + let stopPipe = (): void => undefined; + let connected = false; + const attach = (): void => { + if (current.root.isConnected) connected = true; + else if (connected) return stop(); // 편집 창이 닫혔다 + const now = pagePipeline(params.projectId); + if (now === pipe) return; + stopPipe(); + pipe = now; + stopPipe = + now?.subscribe((event) => { + if (event.kind === "stage" && event.stage === "cross") redraw(); + }, "평면 횡단 판") ?? ((): void => undefined); + }; + const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach); + const stop = (): void => { + stopStore(); + stopPipe(); + }; + attach(); + return { async open(chainageM) { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. - previous.show(null, params.request().station_interval_m); + previous.show(null, interval()); watching = chainageM; - await load(chainageM, false); + attach(); + drawnRoute = pipe?.input("route") ?? null; + shown = null; + redraw(); }, async refresh() { - if (watching === null) return; - await load(watching, true); + attach(); + redraw(); }, // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. - // 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다. + // 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음. watched: () => watching === null || !shown ? null diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 53f7ec7cd..12331fdd2 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -102,6 +102,53 @@ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { return out as SectionSpecs; } +/** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */ +export function stationSection( + station: PipelineStation, + cross: CrossOutput | null, + own: SectionSpecs, + intervalM: number, +): CrossSection { + const center = station.ground?.centerZ ?? null; + const head: SectionStation = { + station_id: stationIdOf(station.chainageM), + chainage_m: station.chainageM, + label: stationLabel(station.chainageM, intervalM), + kind: kindOf(station), + ...(own.structure ? { structure: own.structure } : {}), + center_z: center, + azimuth_deg: station.azimuthDeg, + plan_radius_m: station.planRadiusM, + curve_outer_side: station.curveOuterSide, + curve_widening_m: station.curveWideningM, + center_x: station.x, + center_y: station.y, + uphill_side: uphillSide(station), + frame: { left_xy: station.left }, + }; + const sample = (offset: number, z: number | null) => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null && Number.isFinite(z), + }); + const samples = [ + ...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)), + sample(0, center), + ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), + ]; + const entry = cross?.byKey[station.key] as { detail?: unknown; samples?: unknown[] } | undefined; + const design = entry?.detail as CrossSection["design"] | undefined; + // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) + return { + ...head, + ...own, + // 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n) + samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples, + ...(design ? { design: { ...design } } : {}), + preview: true, + }; +} + /** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */ export function draftSectionDetail( saved: SectionDetailResponse, @@ -111,48 +158,14 @@ export function draftSectionDetail( specs: ReadonlyMap, intervalM: number, ): SectionDetailResponse { - const sections: CrossSection[] = ground.stations.map((station) => { - const own = atChainage(specs.get(station.key) ?? {}, station.chainageM); - const center = station.ground?.centerZ ?? null; - const head: SectionStation = { - station_id: stationIdOf(station.chainageM), - chainage_m: station.chainageM, - label: stationLabel(station.chainageM, intervalM), - kind: kindOf(station), - ...(own.structure ? { structure: own.structure } : {}), - center_z: center, - azimuth_deg: station.azimuthDeg, - plan_radius_m: station.planRadiusM, - curve_outer_side: station.curveOuterSide, - curve_widening_m: station.curveWideningM, - center_x: station.x, - center_y: station.y, - uphill_side: uphillSide(station), - frame: { left_xy: station.left }, - }; - const sample = (offset: number, z: number | null) => ({ - offset_m: offset, - elevation_m: z, - valid: z !== null && Number.isFinite(z), - }); - const samples = [ - ...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)), - sample(0, center), - ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), - ]; - const entry = cross?.byKey[station.key] as - { detail?: unknown; samples?: unknown[] } | undefined; - const design = entry?.detail as CrossSection["design"] | undefined; - // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) - return { - ...head, - ...own, - // 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n) - samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples, - ...(design ? { design: { ...design } } : {}), - preview: true, - }; - }); + const sections: CrossSection[] = ground.stations.map((station) => + stationSection( + station, + cross, + atChainage(specs.get(station.key) ?? {}, station.chainageM), + intervalM, + ), + ); const { chainageM, z } = ground.longitudinal; return { longitudinal: { diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index c65ca075d..02fdb566e 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -177,6 +177,10 @@ async function savedBaseOf() { } const store = load("A00_Common", "design", "design_store.ts"); +// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜 +STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; +STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; +const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); /** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */ @@ -274,6 +278,26 @@ async function run() { runs: runs().length - mark, sources: runs()[runs().length - 1].sources, }; + // ⑦ B05 오른쪽 횡단 판 = 묶음 그 측점(카드와 같은 계획고 · 면적) + const card = pageMod.pipelineDetail(P).cross_sections; + const panes = card.map((section) => { + const shown = pane.pipelineCrossAt(pipe, section.chainage_m + 0.4, job.interval, undefined); + return [ + r3(shown?.chainage_m) === r3(section.chainage_m), + r3(shown?.design?.design_elevation_m) === r3(section.design?.design_elevation_m), + r3(shown?.design?.cut_area_m2) === r3(section.design?.cut_area_m2), + shown?.samples.length === section.samples.length, + ]; + }); + const paneCheck = { + count: card.length, + same: panes.filter((row) => row.every(Boolean)).length, + elevation: r3( + pane.pipelineCrossAt(pipe, target.chainageM, job.interval, undefined)?.design + ?.design_elevation_m, + ), + }; + // ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림 mark = runs().length; const same = await pageMod.reconcilePagePipeline(P); @@ -312,7 +336,7 @@ async function run() { }; pageMod.disposePagePipeline(P); page.clearDrafts(P); - return { kept, b05, b06, dropped, edit, loop }; + return { kept, b05, b06, dropped, edit, loop, pane: paneCheck }; } run() diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index a610047b4..baa25097f 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -1,6 +1,7 @@ /* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 — * `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다. - * 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다. + * 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 · + * 묶음 그 측점 원지반 표본 = 좌우 20 m). */ const fs = require("fs"); const path = require("path"); @@ -56,7 +57,6 @@ globalThis.ResizeObserver = class { disconnect() {} }; -const asked = []; const STUBS = { "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} }, "../common_util/common_util_station": { stationLabel: (m) => String(m) }, @@ -65,17 +65,24 @@ const STUBS = { CROSS_PLOT_PAD: 0, summarizeCross: () => "", }, - // 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20. - "./B05_Profile_Api_Replan": { - fetchCrossPreview: async (_project, request) => { - asked.push(request.cross_half_width_m ?? null); - return { - chainage_m: request.chainage_m, - cross_half_width_m: request.cross_half_width_m ?? 20, - samples: [], - design: null, - }; - }, + // 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m + "../A00_Common/design/design_pipeline_page": { + pagePipeline: () => ({ + output: (name) => + name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null, + input: () => null, + subscribe: () => () => undefined, + }), + }, + "../A00_Common/design/design_store": { + useDesignStore: () => ({ subscribe: () => () => undefined }), + }, + "./logic/B05_Profile_Pipeline_Detail": { + stationSection: (station) => ({ + chainage_m: station.chainageM, + label: null, + samples: [-20, 0, 20].map((offset_m) => ({ offset_m, elevation_m: 100, valid: true })), + }), }, }; @@ -111,17 +118,17 @@ async function run() { const out = {}; out.before = cross.watched(); await cross.open(100); - out.empty = { asked: asked.at(-1), watched: cross.watched() }; + out.empty = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:3", "15"); await cross.refresh(); - out.applied15 = { asked: asked.at(-1), watched: cross.watched() }; + out.applied15 = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:12", "8.5"); session.setItem("aislo:draft:cross-display:p-2:99", "40"); await cross.refresh(); - out.latestRoute = { asked: asked.at(-1), watched: cross.watched() }; + out.latestRoute = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:12", "abc"); await cross.refresh(); - out.badValue = { asked: asked.at(-1), watched: cross.watched() }; + out.badValue = { watched: cross.watched() }; return out; } run().then((out) => console.log(JSON.stringify(out))); diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py index eba2b8ab9..896998426 100644 --- a/resources/tester/test_133_1_analysis_terrain.py +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -138,10 +138,6 @@ def test_수치지형도_서피스_없으면_라이다만(tmp_path): def test_허용_목록_밖_거절(bad): with pytest.raises(ValidationError): terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad) - with pytest.raises(ValidationError): - terrain_router.CrossPreviewRequest( - vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad - ) with pytest.raises(ValidationError): replan.RouteInitialRequest(terrain=bad) @@ -167,8 +163,8 @@ def test_화면_고르기_배선(): assert '' in chrome assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api - # 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음 - assert api.count("terrain: analysisTerrain(projectId)") == 2 + # 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음 + assert api.count("terrain: analysisTerrain(projectId)") == 1 assert "terrain: analysisTerrain(projectId)" in initial_api replan_fn = api.split("export async function replanRoute")[1].split("export ")[0] assert "terrain" not in replan_fn diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py index a779c07df..dfb43a618 100644 --- a/resources/tester/test_224_2_station_snap.py +++ b/resources/tester/test_224_2_station_snap.py @@ -359,30 +359,3 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non with pytest.raises(RuntimeError): sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False) assert seen["station_snap"] is True - - -def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None: - from B05_Profile import B05_Profile_Router_Terrain as terrain - from common_util.common_util_route_polyline import build_planned_polyline - - monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat()) - built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False) - points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64)) - spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves]) - assert len(spans) == 1 - target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5) - assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음 - - def ask(snap: bool) -> float: - request = terrain.CrossPreviewRequest( - vertices=[{"x": x, "y": y} for x, y in _NODES], - chainage_m=target, - min_radius_m=40.0, - station_interval_m=20.0, - station_snap=snap, - ) - params = {"source_filter": "f", "method": "m", "smooth": False} - return terrain._cross_preview(tmp_path, params, request)["chainage_m"] - - assert ask(True) == target - assert ask(False) % 20 == 0 diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 45924176d..4bec14fa8 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -8,6 +8,8 @@ 못박는 것(365-2) - 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임. - 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번. +못박는 것(365-3) + - B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음. 짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · `helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). @@ -116,6 +118,20 @@ def test_365_2_고리_바퀴_수(r): assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1 +def test_365_3_평면_횡단_판_카드와_같음(r): + p = r["pane"] + assert p["count"] > 5 and p["same"] == p["count"] + assert p["elevation"] is not None + + +def test_365_3_서버_횡단_미리보기_길_없음(): + router = (ROOT / "B05_Profile/B05_Profile_Router_Terrain.py").read_text(encoding="utf-8") + api = (ROOT / "B05_Profile/B05_Profile_Api_Replan.ts").read_text(encoding="utf-8") + edit = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "cross-preview" not in router and "cross-preview" not in api + assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림 + + @pytest.fixture(scope="module") def swap(tmp_path_factory) -> dict: work = tmp_path_factory.mktemp("b365") diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 2242aaf25..4f7420a55 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증. -파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값. -선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다. +파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위 +(계산 묶음 그 측점 표본 · 365-3). Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다. """ @@ -31,21 +31,17 @@ def test_측점을_고르기_전에는_선이_없다(results): assert results["before"] is None -def test_적용한_적_없으면_서버_기본값_양쪽(results): - assert results["empty"]["asked"] is None +def test_적용한_적_없으면_원지반_범위_양쪽(results): assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20} def test_적용값이_있으면_그_값_양쪽(results): - assert results["applied15"]["asked"] == 15 assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15} def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results): - assert results["latestRoute"]["asked"] == 8.5 assert results["latestRoute"]["watched"]["leftM"] == 8.5 def test_망가진_값은_기본값으로(results): - assert results["badValue"]["asked"] is None assert results["badValue"]["watched"]["rightM"] == 20 From ec0bb044c25a15729b7d78ec938918646bf193c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:28:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(365-4):=20=ED=95=9C=20=EB=B0=94?= =?UTF-8?q?=ED=80=B4=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=3D=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20B06=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC=20=ED=95=9C=20=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 노선 cross = 다시 셈 창구 한 벌(refreshCrossDesigns · 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계) — 묶음 cross 가 부르고 그 설계를 냄(detailCross) · 종단도 계획고 편집 뒤 따로 예약 안 함(pipelineOwnsCross) - B06 다시 그림 — 묶음 차례는 cross 뒤에만 · 겹친 신호(갈아끼움 · 묶음 · 초안)는 refreshDetailForStructures 한 번(최신 차례만) - 중심점 좌우 = 노선 차례가 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례 · 시험으로 못박음) - stationDiff → logic/design_pipeline_stations(700줄) - 시험 test_365(저장 ▲▼ 다시 셈 1 · 상세 설계 그대로 · 중심점 좌우 cross 1) · 346-2 pills · 230-2 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipeline_page.ts | 65 ++++++++++--------- A00_Common/design/design_profile.ts | 4 +- A00_Common/design/design_shared.ts | 12 ++++ A00_Common/design/design_view_cross.ts | 4 +- .../design/logic/design_pipeline_stations.ts | 24 ++++++- .../logic/B05_Profile_Pipeline_Detail.ts | 27 ++++++++ .../B06_Section_UI_Page_Station_Swap.ts | 7 +- .../tester/helper_230_2_popout_frames.cjs | 2 +- resources/tester/helper_365_page_pipeline.cjs | 55 +++++++++++++++- .../tester/test_346_2_draft_cross_pills.py | 6 +- resources/tester/test_365_page_pipeline.py | 28 ++++++++ 11 files changed, 192 insertions(+), 42 deletions(-) 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")