diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts new file mode 100644 index 000000000..c3d92c4fc --- /dev/null +++ b/A00_Common/design/design_pipeline_cross.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_cross.ts + * 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 횡단 입력 · 얹힌 시설 제원을 + * **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음. + * 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 — + * 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). + * + * 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 · + * 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 + * 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단 + * 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음). + * ========================================================================== */ + +import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import { effectiveStandardCross } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +import type { CrossInput, SectionSpecs } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import type { PipelineStation } from "./design_pipeline_types"; + +export interface PipelineCrossSource { + input: CrossInput; + /** 측점 열쇠 → 얹힌 시설 제원. */ + specs: Map; +} + +const SPEC_KEYS = [ + "structure", + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", +] as const; + +/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */ +const SAME_M = 0.01; + +const finite = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */ +function carried(section: CrossSection): Record { + const design = (section.design ?? {}) as unknown as Record; + const berm = design.berm as + { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined; + const cutSlope = finite(design.cut_slope_ratio_user); + const drop = Number(section.ford?.overflow_depth_m); + return { + ...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}), + ...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}), + ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null, + twoStageSlope: Boolean(design.two_stage_slope), + rockBoundaryOffsetM: finite(design.rock_boundary_offset_m), + cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null, + berm: berm + ? { + widthM: berm.width_m, + intervalM: berm.interval_m, + slopeDeg: berm.slope_deg, + depthM: berm.depth_m, + } + : null, + paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement), + tilted: Boolean(section.ford_pavement || section.underdrain), + tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, + surfaceDropM: drop > 0 ? drop : 0, + }; +} + +/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */ +export function pipelineCrossSource( + projectId: string, + detail: SectionDetailResponse | null, + savedStations: ReadonlyArray, +): PipelineCrossSource | null { + const standard = detail && effectiveStandardCross(projectId); + if (!detail || !standard) return null; + const byKey: CrossInput["byKey"] = {}; + const specs = new Map(); + for (const section of detail.cross_sections) { + let best: PipelineStation | null = null; + for (const station of savedStations) { + const gap = Math.abs(station.chainageM - section.chainage_m); + if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m))) + best = station; + } + if (!best || byKey[best.key]) continue; + byKey[best.key] = carried(section); + const own: Record = {}; + for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; + specs.set(best.key, own as SectionSpecs); + } + return { input: { standard, byKey }, specs }; +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 9d25f59df..fd941dd97 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -1,39 +1,58 @@ /* ============================================================================= * A00_Common/design/design_pipeline_page.ts - * B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1) — 프로젝트마다 하나 · 페이지를 떠나면 버림 - * (종단도 `dispose`). 단계 붙임 · 입력 모음 · 가게 잇기만 — 셈은 단계 파일 몫. + * B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1 · 346-2 붙임) — 프로젝트마다 하나 · 페이지를 떠나면 + * 버림(종단도 `dispose`). 단계 붙임 · 입력 모음 · 가게 잇기만 — 셈은 단계 파일 몫. * * route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8) * follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts` * stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6) - * cross · masshaul · haul(sub2) · drainage(sub3) · corridor(sub5) — 붙으면 `stages` 한 줄씩 - * (없으면 건너뜀 · 계약 규칙). + * cross · masshaul · haul `B06_Section_Cross_Stage` · `logic/B05_Profile_MassHaul_Stage` · + * `B05_Profile_Api_HaulPlan`(sub2) · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」. + * haul · corridor 는 노선 초안 동안만(저장 노선은 유토곡선 판 · 횡단 다시 셈이 맡음). * * 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집 - * (`pipelineGrade`) · 관 · 구조물 = 가게 초안(없으면 구조물은 저장분). 출력 = 가게 stations · - * profile · footprint · generation + 노선 초안이 있으면 종단도가 새 첫 계획선 · 옮긴 계획고를 그림. - * 첫 계산은 처음 고칠 때(또는 저장 안 한 노선 초안이 있을 때) — 열기만 하면 안 셈. - * [노선 반영] · [저장]이 서버 정본 — 여기 값은 미리보기(346-3 전까지). + * (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점 + * 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. + * 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 · + * 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림. + * 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선). + * [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄. * ========================================================================== */ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./design_pipeline"; +import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; import { createStationsStage } from "./design_pipeline_stations"; -import type { PipelineInputs, RouteDraft, Vertex } from "./design_pipeline_types"; +import type { + PipelineInputs, + PipelineStation, + RouteDraft, + StageFn, + Vertex, +} from "./design_pipeline_types"; import { useDesignStore } from "./design_store"; +import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import { createCrossStage } from "../../B06_Section/B06_Section_Cross_Stage"; import { emptyEdits, type AlignmentBase, type AlignmentEdits, + type ProfileAlignment, } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { + markAlignmentSaved, + readAlignmentDraft, +} from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { analysisTerrain, fetchRoutePlan, type RoutePlanResponse, } from "../../B05_Profile/B05_Profile_Api_Replan"; +import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import { fetchStructures, fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { - designTerrainGridNow, groundStageDeps, + loadDesignTerrainGrid, } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; import { createGroundStage, @@ -41,25 +60,57 @@ import { draftLine, } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; import { + createFollowStage, createPlanlineStage, - followStage, gradeRange, profileStage, routeRange, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +import { + crossWithUphill, + draftSectionDetail, + type CrossInput, +} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; -import type { PlanType } from "../../common_util/common_util_structure_station"; +import { + createMassHaulStage, + type MassHaulStageContext, +} from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage"; +import type { + PlanPipe, + PlanStructure, + PlanType, +} from "../../common_util/common_util_structure_station"; import type { RouteDraftBridge, RouteDraftEdit, } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Draft"; +/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */ +export const DRAFT_ROUTE_ID = 0; + /** 종단도(`design_profile`)가 묶음에 내주는 것. */ export interface ProfileLink { - /** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집. */ - pipelineSource(): { base: AlignmentBase | null; edits: AlignmentEdits }; + /** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집 · 저장 상세 · 노선 번호. */ + pipelineSource(): { + base: AlignmentBase | null; + edits: AlignmentEdits; + detail?: SectionDetailResponse | null; + routeId?: number | null; + }; /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */ adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void; + /** 새 노선 횡단이 왔음 — 다시 그림(유토곡선). */ + redraw?(): void; + /** 배수유역 판 — drainage 단계. */ + side?: { pipelineStage: StageFn<"drainage"> } | null; +} + +export interface PagePipelineOptions { + stationIntervalM?: number; + /** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */ + onCorridorStale?: () => void; } interface PagePipeline { @@ -67,6 +118,8 @@ interface PagePipeline { bridge: RouteDraftBridge; grade(edits: AlignmentEdits): void; restart(): void; + earthwork(context: MassHaulStageContext | null): void; + detail(): SectionDetailResponse | null; dispose(): void; } @@ -77,7 +130,7 @@ const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.str export function routeDraftBridge( projectId: string, link: ProfileLink, - options: { stationIntervalM?: number } = {}, + options: PagePipelineOptions = {}, ): RouteDraftBridge { let page = pages.get(projectId); if (!page) pages.set(projectId, (page = createPagePipeline(projectId, link, options))); @@ -88,9 +141,17 @@ export function routeDraftBridge( export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void => pages.get(projectId)?.grade(edits); -/** 저장 종단 기준이 바뀜(새 상세 · 구조물 측점) — 셈 중이면 처음부터 다시. */ +/** 저장 종단 기준이 바뀜(새 상세 · 구조물 측점) — 처음부터 다시. */ export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart(); +/** 유토곡선 설정(토량환산계수 · 자연방토 경사) — masshaul 단계 재료 · 표준단면이 막 왔으면 횡단 입력도. */ +export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void => + pages.get(projectId)?.earthwork(context); + +/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */ +export const pipelineDetail = (projectId: string): SectionDetailResponse | null => + pages.get(projectId)?.detail() ?? null; + /** 지금 묶음(개발 · 시험) — 없으면 null. */ export const pagePipeline = (projectId: string): DesignPipeline | null => pages.get(projectId)?.pipe ?? null; @@ -103,12 +164,21 @@ export function disposePagePipeline(projectId: string): void { function createPagePipeline( projectId: string, link: ProfileLink, - options: { stationIntervalM?: number }, + options: PagePipelineOptions, ): PagePipeline { const store = useDesignStore(projectId, null); + /** 노선 0 칸 — 종단도 편집 가게(`createProfileEditStore`)와 같은 자리(지금 프로젝트). */ + const draftAlignment = useDesignStore(null, DRAFT_ROUTE_ID); let plan: RoutePlanResponse | null = null; let types: PlanType[] = []; - let savedStructures: unknown[] = []; + let savedStructures: PlanStructure[] = []; + let savedPipes: PlanPipe[] = []; + let earthwork: MassHaulStageContext | null = null; + /** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */ + let savedStations: PipelineStation[] = []; + let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null = + null; + let shown: SectionDetailResponse | null = null; let ready = false; let started = false; /** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */ @@ -117,75 +187,127 @@ function createPagePipeline( const savedLine = (): Vertex[] | null => plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null; + const savedCurves = () => + (plan?.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })); + const crossNow = (): PipelineCrossSource | null => { + const detail = link.pipelineSource().detail ?? null; + if (!detail) return null; + if (crossSource?.detail !== detail || !crossSource.source) + crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) }; + return crossSource.source; + }; + const crossStage = createCrossStage(); + const haulStage = createHaulStage(projectId, () => link.pipelineSource().routeId ?? undefined); const pipe = createDesignPipeline({ stages: { route: createRouteStage({ minRadiusM: () => plan?.min_radius_m ?? 0, - savedLine: () => - plan && { - line: savedLine()!, - curves: plan.curves.map(({ start, apex, end, radius_m }) => ({ - start, - apex, - end, - radius_m, - })), - }, + savedLine: () => plan && { line: savedLine()!, curves: savedCurves() }, }), - follow: followStage, + follow: createFollowStage({ savedLine }), stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }), ground: createGroundStage(groundStageDeps(projectId)), planline: createPlanlineStage({ savedBase: () => link.pipelineSource().base, savedLine }), profile: profileStage, + // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 + cross: (ctx) => + crossStage.stage({ + ...ctx, + inputs: { + ...ctx.inputs, + cross: crossWithUphill( + ctx.inputs.cross as CrossInput | null, + ctx.out.ground?.stations ?? [], + ), + }, + }), footprint: footprintStage, + masshaul: createMassHaulStage(() => earthwork), + haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)), + drainage: link.side?.pipelineStage, + corridor: (ctx) => { + if (ctx.inputs.route) options.onCorridorStale?.(); + return null; + }, }, }); pipe.subscribe(publishToStore(store)); pipe.subscribe((event) => { - if (event.kind !== "stage" || event.stage !== "profile") return; + if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return; const draft = pipe.input("route") !== null; + const saved = link.pipelineSource().detail ?? null; + const ground = pipe.output("ground"); + const profile = pipe.output("profile") as ProfileAlignment | null; + shown = + draft && saved && ground && profile + ? draftSectionDetail( + saved, + ground, + profile, + pipe.output("cross"), + crossSource?.source?.specs ?? new Map(), + options.stationIntervalM ?? profile.policy.station_interval_m, + ) + : null; + if (event.stage === "cross") return void (draft && link.redraw?.()); const base = pipe.output("planline"); const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); + if (draft) draftAlignment.set("alignmentDraft", edits); + else if (drawnDraft) markAlignmentSaved(DRAFT_ROUTE_ID); // 초안을 버림 — 노선 0 칸도 비움 if ((draft || drawnDraft) && base) link.adoptPipeline(base, edits, draft); drawnDraft = draft; }); - const inputsNow = (): PipelineInputs => ({ - route: store.get("routeDraft"), - alignment: link.pipelineSource().edits, - cross: null, - // ponytail: 저장 관 목록(좌표)은 아직 안 받음 — 관 초안만. 346-3 에서 관 정본을 가게로. - pipes: store.get("pipes") ?? [], - structures: store.get("structures") ?? savedStructures, - }); + const inputsNow = (): PipelineInputs => { + const route = store.get("routeDraft"); + return { + route, + alignment: (route && readAlignmentDraft(DRAFT_ROUTE_ID)) || link.pipelineSource().edits, + cross: crossNow()?.input ?? null, + pipes: store.get("pipes") ?? savedPipes, + structures: store.get("structures") ?? savedStructures, + }; + }; const start = (): void => { if (disposed || !ready || !link.pipelineSource().base) return; started = true; void pipe.start(inputsNow()); }; - /** 저장 노선(그린 선 · 곡선표 · 기본 반경) — [노선 반영] 뒤 다시 받음(`restart`). */ + /** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */ const loadPlan = (): Promise => fetchRoutePlan(projectId) - .then((routePlan) => void (plan = routePlan)) + .then((routePlan) => { + plan = routePlan; + savedStations = buildStations(savedLine()!, savedCurves(), savedPipes, savedStructures, { + intervalM: options.stationIntervalM, + types, + stationSnap: routePlan.station_snap ?? false, + }); + crossSource = null; + }) .catch(() => undefined); - designTerrainGridNow(projectId); // 지형 격자 미리 받기(받는 동안은 서버 길) void Promise.all([ - loadPlan(), fetchStructureTypes().catch(() => []), fetchStructures(projectId).catch(() => null), + fetchDetailPipePoints(projectId).catch(() => null), + loadDesignTerrainGrid(projectId).catch(() => null), // 지형 격자(없으면 서버 길) ]) - .then(([, structureTypes, list]) => { + .then(([structureTypes, list, pipes]) => { types = structureTypes as PlanType[]; - savedStructures = list?.structures ?? []; + savedStructures = (list?.structures ?? []) as PlanStructure[]; + savedPipes = (pipes?.pipe_points ?? []).map(({ lonlat: _lonlat, ...pipe }) => pipe); + return loadPlan(); + }) + .then(() => { ready = plan !== null; - if (store.get("routeDraft")) start(); // 저장 안 한 노선 초안 — 열 때부터 그 노선으로 + start(); // 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로 }) .catch(() => undefined); const unsubscribe = [ - store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? [])), + store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? savedPipes)), store.subscribe( "structures", (list) => started && void pipe.update("structure", list ?? savedStructures), @@ -220,8 +342,15 @@ function createPagePipeline( void pipe.update("grade", edits, gradeRange(before, edits, nodes)); }, restart() { - if (started) void loadPlan().then(start); + if (!started) return start(); + void loadPlan().then(start); }, + earthwork(context) { + earthwork = context; + const cross = crossNow()?.input ?? null; + if (started && cross) void pipe.update("cross", cross); + }, + detail: () => shown, dispose() { disposed = true; unsubscribe.forEach((stop) => stop()); diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 07d569032..ecf90d80b 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -71,7 +71,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import { listedBase } from "./design_profile_base"; -import { disposePagePipeline, pipelineGrade, restartPagePipeline } from "./design_pipeline_page"; +import * as pagePipeline from "./design_pipeline_page"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; @@ -220,7 +220,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { let storedBase: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); - /** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 메모리만(346-1). */ + /** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */ let preview: typeof store | null = null; /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ let savedEdits: AlignmentEdits = emptyEdits(); @@ -350,7 +350,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { */ function rebuild(): void { if (base) alignment = buildAlignment(base, store.edits()); - pipelineGrade(projectId, store.edits()); + pagePipeline.pipelineGrade(projectId, store.edits()); // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). @@ -369,14 +369,15 @@ export function createDesignProfile(ctx: DesignProfileContext) { detail: () => detail, routeId: () => routeId, edits: () => store.edits(), - alignment: () => alignment, + alignment: () => (store === preview ? null : alignment), debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), onApplied: (updated) => { draw(); if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated); }, }); - const scheduleCrossPreview = (): void => crossPreview.schedule(); + // 노선 초안 동안은 저장 노선 횡단을 안 고침 — 새 노선 횡단은 계산 묶음 cross(346-2) + const scheduleCrossPreview = (): void => void (store !== preview && crossPreview.schedule()); /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 @@ -404,13 +405,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { function draw(): void { renderProfile({ - onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1) + // 그린 뒤 생긴 낡음도 다시 셈(317-1) · 노선 초안 동안은 묶음 cross 를 기다림 + onStaleDesigns: (key) => (store === preview ? key !== "" : crossPreview.scheduleIfStale(key)), body, callbacks, massHaul, tableOverlay, store, - detail: () => detail, + detail: () => pagePipeline.pipelineDetail(projectId) ?? detail, alignment: () => alignment, base: () => base, stationInterval: () => stationInterval, @@ -502,7 +504,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; - restartPagePipeline(projectId); + pagePipeline.restartPagePipeline(projectId); // 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다. // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다. configureBalloonOffsets( @@ -540,6 +542,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { */ setEarthworkContext(next: RouteMassHaulContext | null) { massHaul.setContext(next); + pagePipeline.pipelineEarthwork(projectId, next); draw(); }, setSelectedStation(stationId: string | null) { @@ -579,7 +582,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { irregularStations = stations; base = listed(); if (base) alignment = buildAlignment(base, store.edits()); - restartPagePipeline(projectId); + pagePipeline.restartPagePipeline(projectId); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ @@ -625,14 +628,16 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, isDirty: () => store.dirty(), /** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */ - pipelineSource: () => ({ base: listed(), edits: store.edits() }), - /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 메모리만(저장 노선에 - * 안 씀 · [노선 반영] · [저장]이 서버 정본) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */ + pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }), + /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에 + * 안 씀 · [저장]이 노선 뒤 새 노선에 보냄) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */ adoptPipeline(nextBase: AlignmentBase, edits: AlignmentEdits, draft: boolean) { const sameEdits = JSON.stringify(edits) === JSON.stringify(store.edits()); if (draft && store === preview && base === nextBase && sameEdits) return; // 이미 그림 if (draft && (store !== preview || !sameEdits)) - store = preview = createProfileEditStore(null, edits, () => rebuild()); + store = preview = createProfileEditStore(pagePipeline.DRAFT_ROUTE_ID, edits, () => + rebuild(), + ); if (!draft && store === preview) store = createProfileEditStore(routeId, savedEdits, () => rebuild()); if (!draft) preview = null; @@ -682,7 +687,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { drainagePanel?.load(projectId); }, dispose() { - disposePagePipeline(projectId); + pagePipeline.disposePagePipeline(projectId); setListening(false); drag.dispose(); crossPreview.dispose(); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 61525c4bf..bd9795fb5 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -491,6 +491,7 @@ export async function mountProfileView( // 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [노선 반영]이 정본). draft: routeDraftBridge(activeProjectId, profilePanel, { stationIntervalM: panel.values().stationInterval ?? undefined, + onCorridorStale: () => sections.setCorridorPending(true), }), }); diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index fb7d9fe76..e7ee4aad1 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -13,6 +13,8 @@ ⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) · `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts` (`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. +⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` +(`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`. """ import math diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts new file mode 100644 index 000000000..e47e9ce49 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * B05_Profile_Pipeline_Detail.ts + * 계산 묶음(PLAN 346-1 · 346-2) 출력 → 종단도 · 유토곡선이 읽는 상세 꼴(`SectionDetailResponse`). + * 노선 초안 동안 종단도가 저장 노선 상세 대신 이 상세를 그림(지반 · 측점 · 횡단 면적이 새 노선 것). + * + * uphillSide 측점 상단측(좌 · 우 지반 평균이 높은 쪽) — 서버 단면 유형 기본값(`{쪽}_cut`) 재료. + * ⚠ ① 짝 — `B05_Profile_Engine_Sections_Core.py` `uphill_side` · 거울 시험 + * `resources/tester/test_346_2_pipeline_cross_page.py`. + * crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 · + * 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력). + * draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 얹힌 시설 제원). + * + * 순수 — 화면 · fetch · 세션 없음. + * ========================================================================== */ + +import type { + CrossSection, + SectionDetailResponse, + SectionStation, +} from "../../B06_Section/B06_Section_Api_Types"; +import type { + CrossOutput, + GroundOutput, + PipelineStation, +} from "../../A00_Common/design/design_pipeline_types"; +import type { ProfileAlignment } from "../B05_Profile_UI_Profile_Alignment"; +import { stationLabel } from "../../common_util/common_util_station"; +import { stationIdOf } from "./B05_Profile_Pipeline_Grade"; + +/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음. */ +export type SectionSpecs = Partial< + Pick< + CrossSection, + | "structure" + | "culvert" + | "ford" + | "box" + | "ford_pavement" + | "revetment" + | "pavement" + | "underdrain" + > +>; + +/** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */ +export interface CrossInput { + standard: unknown; + byKey: Record>; +} + +const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length; + +/** 상단측 — 좌 · 우 유효 지반 평균이 높은 쪽 · 한쪽이 비거나 차이가 1e-6 안이면 null(서버와 같음). */ +export function uphillSide(station: PipelineStation): "left" | "right" | null { + const valid = (side: "left" | "right"): number[] => + (station.ground?.[side] ?? []) + .filter((sample) => sample.offsetM > 1e-9 && sample.z !== null && Number.isFinite(sample.z)) + .map((sample) => sample.z as number); + const left = valid("left"); + const right = valid("right"); + if (!left.length || !right.length) return null; + const gap = mean(left) - mean(right); + if (Math.abs(gap) <= 1e-6) return null; + return gap > 0 ? "left" : "right"; +} + +/** 측점마다 단면 유형 기본값(상단측 깎기 · 모르면 좌) — 입력이 정한 단면 유형은 그대로. */ +export function crossWithUphill( + input: CrossInput | null, + stations: ReadonlyArray, +): CrossInput | null { + if (!input) return null; + const byKey: CrossInput["byKey"] = {}; + for (const station of stations) + byKey[station.key] = { + sectionMode: `${uphillSide(station) ?? "left"}_cut`, + ...input.byKey[station.key], + }; + return { standard: input.standard, byKey }; +} + +const kindOf = (station: PipelineStation): SectionStation["kind"] => + station.key === "end:BP" + ? "bp" + : station.key === "end:EP" + ? "ep" + : station.kind === "grid" + ? "regular" + : "irregular"; + +/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */ +export function draftSectionDetail( + saved: SectionDetailResponse, + ground: GroundOutput, + profile: ProfileAlignment, + cross: CrossOutput | null, + specs: ReadonlyMap, + intervalM: number, +): SectionDetailResponse { + const sections: CrossSection[] = ground.stations.map((station) => { + const own = specs.get(station.key) ?? {}; + 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 design = cross?.byKey[station.key]?.detail as CrossSection["design"] | undefined; + return { ...head, ...own, samples, ...(design ? { design } : {}), preview: true }; + }); + const { chainageM, z } = ground.longitudinal; + return { + longitudinal: { + ...saved.longitudinal, + length_m: chainageM[chainageM.length - 1] ?? 0, + samples: chainageM.map((chainage, index) => ({ + chainage_m: chainage, + elevation_m: z[index], + z: z[index], + valid: z[index] !== null, + })), + stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head), + profile_alignment: profile, + }, + cross_sections: sections, + balloon_offsets: saved.balloon_offsets, + }; +} diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index a1a729082..20afd7852 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -4,7 +4,7 @@ * `A00_Common/design/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터): * * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` - * `carryProfileEdits` 의 edits 몫) · 옛 노선(`before.route`)이 있을 때만. + * `carryProfileEdits` 의 edits 몫 · 옛 노선 `before.route` 가 있을 때만). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로 * 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. @@ -72,24 +72,32 @@ export function gradeRange( }; } -/** follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. */ -export const followStage: StageFn<"follow"> = (ctx) => { - const before = ctx.before.route; - const now = ctx.out.route; - if (!before || !now) return {}; - const line = xy(now.line); - const patch: FollowPatch = {}; - const { pipes, structures, alignment } = ctx.inputs; - if (Array.isArray(pipes) && pipes.length) - patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); - if (Array.isArray(structures) && structures.length) - patch.structures = followStructures(structures as FollowingStructure[], line).map( - (moved) => moved.item, - ); - const span = changedSpan(xy(before.line), line); - if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; - return patch; -}; +/** + * follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 첫 계산(옛 노선 없음)은 + * 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 — + * 열 때의 계획고 초안은 이미 그 노선 기준). + */ +export function createFollowStage(deps: { savedLine(): Vertex[] | null }): StageFn<"follow"> { + return (ctx) => { + const before = ctx.before.route?.line ?? deps.savedLine(); + const now = ctx.out.route; + if (!before || !now) return {}; + const line = xy(now.line); + const patch: FollowPatch = {}; + const { pipes, structures, alignment } = ctx.inputs; + if (Array.isArray(pipes) && pipes.length) + patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); + if (Array.isArray(structures) && structures.length) + patch.structures = followStructures(structures as FollowingStructure[], line).map( + (moved) => moved.item, + ); + const span = ctx.before.route ? changedSpan(xy(before), line) : null; + if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; + return patch; + }; +} + +export const followStage = createFollowStage({ savedLine: () => null }); export interface PlanlineDeps { /** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */ diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index d23f7aefb..c184d750c 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -27,6 +27,7 @@ import { type PlanStructure, type PlanType, } from "../../common_util/common_util_structure_station"; +import { stationKey as commonStationKey } from "../../common_util/common_util_station_key"; import type { DesignCurve, PipelineStation, @@ -66,15 +67,18 @@ const round = (value: number, digits: number): number => Number(value.toFixed(di export const stationText = (chainageM: number): string => chainageM.toFixed(2); /** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:` · - * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. */ + * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. + * 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */ export function stationKey( kind: StationKind, ref: number | string, role: "start" | "anchor" | "end" = "anchor", ): string { - if (kind === "grid") return `grid:${ref}`; - if (kind === "end") return `end:${ref}`; - return `${kind}:${ref}${role === "anchor" ? "" : `:${role}`}`; + if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null); + if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1) + const base = commonStationKey(kind, 0, null, String(ref)); + // ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김 + return role === "anchor" ? base : `${base}:${role}`; } /** 짝: `curve_widening_m` — 직선 · 45 m 이상 · 값 없음은 0. */ diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs index 5ba3c4308..c0a9c9e88 100644 --- a/resources/tester/helper_230_2_popout_frames.cjs +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -97,7 +97,7 @@ out.viewOf = { buildAlignment: () => null, store: { edits: () => ({}) }, projectId: "p", - pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음 + pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음 scheduleCrossPreview: () => s.previews++, draw: () => s.draws++, root: inPopup(), diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 029a86878..2c1b7cb3f 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -68,8 +68,13 @@ STUBS["B05_Profile_Api_Structures"] = { }; STUBS["B05_Profile_Api_TerrainGrid"] = { designTerrainGridNow: () => local, + loadDesignTerrainGrid: async () => local, groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }), }; +// 346-2 붙임 뒤 창구(관 목록 · 표준단면 · 흙 배분) — 이 시험은 노선 · 종단만 봄 +STUBS["B04_PreProcess_Api_Fetch"] = { fetchDetailPipePoints: async () => ({ pipe_points: [] }) }; +STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => null }; +STUBS["B05_Profile_Api_HaulPlan"] = { createHaulStage: () => async () => null }; const pipeMod = load("A00_Common", "design", "design_pipeline.ts"); const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); diff --git a/resources/tester/helper_346_2_pipeline_cross_page.cjs b/resources/tester/helper_346_2_pipeline_cross_page.cjs new file mode 100644 index 000000000..d033971c5 --- /dev/null +++ b/resources/tester/helper_346_2_pipeline_cross_page.cjs @@ -0,0 +1,328 @@ +/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · masshaul · haul · drainage · corridor 를 + * 붙인 것. 진짜 TS(`design_pipeline_page` · `_cross` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로 + * 돌림. 서버 창구(노선 · 구조물 · 관 목록 · 지형 격자 · 표준단면 · 흙 배분 fetch)만 가짜. + * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_346_2_pipeline_cross_page.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +const haul = { calls: 0, aborted: 0, answered: 0 }; +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + haul.calls += 1; + const timer = setTimeout(() => { + haul.answered += 1; + resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }); + }, job.haulDelayMs); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + haul.aborted += 1; + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: [], + min_radius_m: job.minRadius, + station_snap: false, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => job.standard }; + +const pipeMod = load("A00_Common", "design", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"); +const alignment = load("B05_Profile", "B05_Profile_UI_Profile_Alignment.ts"); +const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +async function settle(count) { + for (let i = 0; i < 400; i += 1) { + const last = runs()[runs().length - 1]; + if (runs().length >= count && last && last.outcome !== "running") return last; + await tick(5); + } + throw new Error("묶음이 끝나지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); + +/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */ +async function savedBaseOf() { + const seed = pipeMod.createDesignPipeline({ + stages: { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + stations: stationsMod.createStationsStage({ + intervalM: job.interval, + types: () => job.types, + }), + ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }), + }, + }); + await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] }); + const ground = seed.output("ground"); + seed.dispose(); + const samples = ground.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground.longitudinal.z[i], + })); + const profile = gradeGround.groundProfile(samples); + return { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; +} + +async function run() { + const P = "proj-3462"; + window.localStorage.setItem("frd_current_project_id", P); // 지금 프로젝트(노선 0 칸 자리) + const savedBase = await savedBaseOf(); + const seedRuns = runs().length; + const adopted = []; + let redraws = 0; + let corridor = 0; + const drainage = []; + const link = { + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: (base, edits, draft) => adopted.push({ draft, saved: base === savedBase }), + redraw: () => (redraws += 1), + side: { + pipelineStage: (ctx) => { + drainage.push(ctx.inputs.route !== null); + return null; + }, + }, + }; + const bridge = pageMod.routeDraftBridge(P, link, { + stationIntervalM: job.interval, + onCorridorStale: () => (corridor += 1), + }); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + const pipe = pageMod.pagePipeline(P); + const commits = []; + pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); + + // ① 열 때 한 번 — 저장 노선 · 발자국 · 뒤 단계는 초안 없으니 3D 낡음 · 흙 배분 안 함 + const openRun = await settle(seedRuns + 1); + await tick(job.haulDelayMs + 20); + const open = { + outcome: openRun.outcome, + stages: openRun.stages, + footprint: pipe.output("footprint")?.records.length ?? 0, + crossCount: Object.keys(pipe.output("cross")?.byKey ?? {}).length, + stations: pipe.output("ground").stations.length, + carried: Object.keys(pipe.input("cross")?.byKey ?? {}).length, + corridor, + haulCalls: haul.calls, + detail: pageMod.pipelineDetail(P), + pipeKeys: pipe + .output("ground") + .stations.filter((s) => s.kind === "pipe") + .map((s) => [s.key, s.chainageM]), + }; + + // ② 늦은 응답 버림 — 끝점 옮김(흙 배분 기다리는 중) → 바로 첫 점 옮김 + bridge.edited(draftOf(shift(n - 1, 6, -4))); + const runA = await settle(runs().length + 1); + await tick(2); + bridge.edited(draftOf(shift(0, -5, 3))); + const runB = await settle(runs().length + 1); + await tick(job.haulDelayMs + 30); + const genA = runA.generation; + const genB = runB.generation; + + const ground = pipe.output("ground"); + const profile = pipe.output("profile"); + const shown = pageMod.pipelineDetail(P); + const mass = pipe.output("masshaul"); + const moved = { + run: { + outcome: runB.outcome, + stages: runB.stages, + totalMs: runB.totalMs, + sources: runB.sources, + }, + line: pipe.output("route").line, + curves: pipe.output("route").curves, + stations: ground.stations.map((s) => ({ + key: s.key, + kind: s.kind, + chainage: s.chainageM, + uphill: detailMod.uphillSide(s), + elevation: alignment.planElevationAt(profile, s.chainageM), + planRadius: s.planRadiusM, + outer: s.curveOuterSide, + widening: s.curveWideningM, + cut: pipe.output("cross").byKey[s.key]?.cutAreaM2 ?? null, + fill: pipe.output("cross").byKey[s.key]?.fillAreaM2 ?? null, + })), + finalCumulative: mass?.haulInput?.final_cumulative_m3 ?? null, + footprint: pipe.output("footprint")?.records.length ?? 0, + detail: shown && { + sections: shown.cross_sections.length, + designs: shown.cross_sections.filter((s) => s.design).length, + longitudinal: shown.longitudinal.samples.length, + stationIds: shown.longitudinal.stations.length, + profileMatches: shown.longitudinal.profile_alignment === profile, + }, + alignmentDraft: editMod.readAlignmentDraft(pageMod.DRAFT_ROUTE_ID), + adoptedLast: adopted[adopted.length - 1], + redraws, + corridor, + drainage, + }; + const late = { + genA, + genB, + haulCommits: commits.filter((c) => c.startsWith("haul@")), + haul: { ...haul }, + }; + + // ③ 계획고 고침(초안 동안) — 노선 0 칸에 남음 + const edits = { + station_offsets: { [alignment.chainageKey(savedBase.stations[3].chainage_m)]: 0.4 }, + curve_radii: {}, + }; + const draftEdits = editMod.createProfileEditStore( + pageMod.DRAFT_ROUTE_ID, + alignment.emptyEdits(), + () => undefined, + ); + draftEdits.replace(edits); + pageMod.pipelineGrade(P, edits); + const gradeRun = await settle(runs().length + 1); + const gradeDraft = { + pending: editMod.pendingAlignment(pageMod.DRAFT_ROUTE_ID), + sources: gradeRun.sources, + stages: Object.keys(gradeRun.stages), + }; + + // ④ 열 때로(초안 버림) — 노선 0 칸 · 새 노선 상세 비움 + bridge.edited(null); + await settle(runs().length + 1); + await tick(20); + const reverted = { + detail: pageMod.pipelineDetail(P), + alignmentDraft: editMod.readAlignmentDraft(pageMod.DRAFT_ROUTE_ID), + pending: editMod.pendingAlignment(pageMod.DRAFT_ROUTE_ID), + adoptedLast: adopted[adopted.length - 1], + }; + pageMod.disposePagePipeline(P); + page.clearDrafts(P); + return { open, moved, late, gradeDraft, reverted, n }; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py index d4c20a1f8..518c79650 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -54,7 +54,8 @@ def test_same_stale_key_schedules_once(result: dict) -> None: def test_draw_and_button_wiring() -> None: panel = _read("A00_Common", "design", "design_profile.ts") - assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel + # 노선 초안 동안(346-2 붙임)은 계산 묶음 cross 를 기다림 — 그 밖은 같은 낡음 한 번만 다시 셈 + assert "crossPreview.scheduleIfStale(key)" in panel and "onStaleDesigns: (key) =>" in panel render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") # 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1) assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render diff --git a/resources/tester/test_346_2_pipeline_cross_page.py b/resources/tester/test_346_2_pipeline_cross_page.py new file mode 100644 index 000000000..0673e0347 --- /dev/null +++ b/resources/tester/test_346_2_pipeline_cross_page.py @@ -0,0 +1,285 @@ +"""PLAN 346-1 · 346-2 붙임 — 페이지 계산 묶음에 cross · masshaul · haul · drainage · corridor. + +· 열 때 한 번(저장 노선 · 발자국 · 저장 횡단 입력이 측점 열쇠로 모임) + 초안 없으면 3D 낡음 · 흙 배분 안 함 +· 노선 점 옮김 한 번에 cross · footprint · masshaul 까지 · 단계 합 0.3 s 안 +· 누가토량 = 서버 엔진(generate_sections · compute_cross_design)으로 + 같은 노선을 셈한 값과 같은 자리수 +· 상단측(단면 유형 기본값) = 서버 `uphill_side` 짝(거울) +· 늦은 응답 버림(흙 배분 끊김 · 옛 차례 haul 안 들어옴) +· 저장 관 목록이 좌표로 새 노선을 따라감 +· 노선 초안 동안 계획고 = alignment-draft 노선 0 칸 · 초안을 버리면 비움 +짝: `helper_346_2_pipeline_cross_page.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import csv +import json +import math +import subprocess +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +from shapely.geometry import LineString, Point + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, +) +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 +from common_util.common_util_structure_station import plan_stations # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +PROJECT = ROOT / "storage/1/3/6d3b2812-6d5a-4168-a932-40e4ae303d5a" # 검증_sub7 +REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" +REAL_NODES = PROJECT / "B05_Profile/route/planned_route_nodes.csv" +MIN_RADIUS_M = 12.0 +INTERVAL_M = 20.0 +BUDGET_MS = 300.0 +ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} + + +def _types() -> list[dict]: + return [ + { + "type_id": t.type_id, + "group": t.group, + "placement": t.placement, + "managed_by": t.managed_by, + } + for t in structure_type_map().values() + ] + + +def _line(nodes: list[list[float]]) -> list[list[float]]: + built = build_planned_polyline( + [tuple(n) for n in nodes], + min_radius_m=MIN_RADIUS_M, + simplify=False, + curve_flags=[True] * len(nodes), + radii=[None] * len(nodes), + ) + return [list(v) for v in built.vertices] + + +def _fake(folder: Path) -> tuple[Path, list[list[float]]]: + """가짜 격자 — 칸 폭 고르지 않음 · 기운 땅(상단측이 갈림).""" + rng = np.random.default_rng(3462) + x = (np.cumsum(rng.uniform(0.95, 1.1, 300)) + 1000.0).astype(np.float32) + y = (np.cumsum(rng.uniform(0.95, 1.1, 200)) + 2000.0).astype(np.float32) + xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64)) + z = 500 + 0.12 * (xx - 1000) - 0.35 * (yy - 2000) + 2.5 * np.sin(xx / 11.0) + npz = folder / "dtm_fake.npz" + np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=np.ones(z.shape, dtype=bool)) + return npz, [[1020.0, 2030.0], [1100.0, 2050.0], [1170.0, 2120.0], [1270.0, 2150.0]] + + +def _real(_folder: Path) -> tuple[Path, list[list[float]]]: + if not (REAL_GRID.is_file() and REAL_NODES.is_file()): + pytest.skip("검증_sub7 없음") + with REAL_NODES.open(encoding="utf-8") as handle: + return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] + + +def _pipes(line: list[list[float]]) -> list[dict]: + shape = LineString(line) + out = [] + for ratio, pid in ((0.3, "p-a"), (0.62, "p-b")): + at = round(shape.length * ratio, 3) + point = shape.interpolate(at) + out.append({"id": pid, "chainage_m": at, "x": point.x, "y": point.y, "source": "user"}) + return out + + +def _sections(line, sampler, pipes, curves=None) -> dict: + extras = tuple( + (float(s["chainage_m"]), str(s["title"])) + for s in plan_stations(parse_pipe_points(pipes), []) + ) + return core.generate_sections( + np.asarray(line, dtype=np.float64), + sampler, + core.SectionGenerationOptions(station_interval_m=INTERVAL_M, extra_stations=extras), + design_curves=curves or None, + ) + + +def _saved_detail(data: dict) -> dict: + """저장 상세 — 측점마다 넘기는 입력만 담은 설계(지반 유형).""" + sections = [ + {**s, "design": {"ground_type": "ripping_rock", "design_elevation_m": s["center_z"]}} + for s in data["cross_sections"] + ] + return {"longitudinal": {**data["longitudinal"]}, "cross_sections": sections} + + +def _run(case) -> dict: + with tempfile.TemporaryDirectory() as folder: + npz, nodes = case(Path(folder)) + line = _line(nodes) + sampler = DtmGridSampler.from_npz(npz) + pipes = _pipes(line) + saved = _sections(line, sampler, pipes) + options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None) + policy = design_ground_following_profile(saved["longitudinal"], options)[0]["policy"] + job = { + "nodes": nodes, + "line": line, + "minRadius": MIN_RADIUS_M, + "policy": policy, + "types": _types(), + "interval": INTERVAL_M, + "pipes": pipes, + "standard": STANDARD_CROSS_SECTION, + "conversion": ONES, + "detail": _saved_detail(saved), + "haulDelayMs": 40, + } + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(dtm_grid_bytes(npz)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_346_2_pipeline_cross_page.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + # 서버 엔진으로 같은 노선(옮긴 뒤) — 측점 · 지반 · 횡단 면적 · 상단측 + moved = out["moved"] + ts_pipes = [ + {"id": s["key"].split(":", 1)[1], "chainage_m": s["chainage"], "source": "user"} + for s in moved["stations"] + if s["kind"] == "pipe" + ] + server = _sections(moved["line"], sampler, ts_pipes, moved["curves"]) + out["server"] = [ + { + "chainage": s["chainage_m"], + "uphill": s.get("uphill_side"), + "samples": s["samples"], + "planRadius": s.get("plan_radius_m"), + "outer": s.get("curve_outer_side"), + "widening": s.get("curve_widening_m"), + } + for s in server["cross_sections"] + ] + out["pipes"] = pipes + return out + + +@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub7"]) +def r(request) -> dict: + return _run(request.param) + + +def test_열_때_한_번_발자국_저장_횡단_입력(r): + o = r["open"] + assert o["outcome"] == "done" + assert {"cross", "footprint", "masshaul"} <= set(o["stages"]) + assert o["crossCount"] == o["stations"] and o["footprint"] > 2 + assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로 + assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None + assert sorted(k for k, _ in o["pipeKeys"]) == ["pipe:p-a", "pipe:p-b"] + + +def test_노선_옮김_한_번에_유토곡선까지_시간(r): + m = r["moved"] + run = m["run"] + assert run["outcome"] == "done" and run["sources"] == ["route"] + for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"): + assert stage in run["stages"], stage + assert "masshaul" in run["stages"] + assert ( + sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor")) + < BUDGET_MS + ) + assert run["totalMs"] < BUDGET_MS + d = m["detail"] + assert d["sections"] == len(m["stations"]) == d["designs"] == d["stationIds"] + assert d["profileMatches"] and d["longitudinal"] > 10 + assert m["adoptedLast"]["draft"] is True and m["redraws"] >= 1 + assert m["corridor"] >= 1 and True in m["drainage"] + + +def test_상단측_서버_짝(r): + ts_up = [s["uphill"] for s in r["moved"]["stations"]] + py_up = [s["uphill"] for s in r["server"]] + assert len(ts_up) == len(py_up) + assert ts_up == py_up + + +def test_누가토량_서버_엔진과_같은_자리수(r): + m = r["moved"] + ts = m["stations"] + py = r["server"] + assert len(ts) == len(py) + nets = [] + for a, b in zip(ts, py): + assert abs(a["chainage"] - b["chainage"]) < 1e-3 + design = compute_cross_design( + b["samples"], + a["elevation"], + ground_type="ripping_rock", + section_mode=f"{b['uphill'] or 'left'}_cut", + plan_radius_m=b["planRadius"], + curve_outer_side=b["outer"], + curve_widening_m=b["widening"], + ) + assert abs(design["cut_area_m2"] - a["cut"]) < 0.05 + 0.01 * design["cut_area_m2"] + assert abs(design["fill_area_m2"] - a["fill"]) < 0.05 + 0.01 * design["fill_area_m2"] + nets.append((b["chainage"], design["cut_area_m2"] - design["fill_area_m2"])) + final = sum((n0 + n1) / 2 * (c1 - c0) for (c0, n0), (c1, n1) in zip(nets, nets[1:])) + got = m["finalCumulative"] + assert got is not None + assert abs(got - final) <= max(1.0, abs(final) * 0.005), (got, final) + + +def test_늦은_응답_버림(r): + late = r["late"] + assert late["genB"] > late["genA"] + assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김 + assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴 + assert late["haulCommits"][-1] == f"haul@{late['genB']}" + + +def test_저장_관_목록이_좌표로_따라감(r): + m = r["moved"] + line = LineString(m["line"]) + got = {s["key"]: s["chainage"] for s in m["stations"] if s["kind"] == "pipe"} + for pipe in r["pipes"]: + want = math.floor(line.project(Point(pipe["x"], pipe["y"])) + 0.5) # 구조물 측점 = 1 m + assert abs(got[f"pipe:{pipe['id']}"] - want) < 1e-6, (pipe["id"], got, want) + assert any(abs(got[f"pipe:{p['id']}"] - p["chainage_m"]) > 0.5 for p in r["pipes"]) # 정말 밀림 + + +def test_초안_동안_계획고_노선_0_칸(r): + assert r["moved"]["alignmentDraft"] is not None # 노선 초안 = 노선 0 칸에 남음 + g = r["gradeDraft"] + assert g["pending"] and list(g["pending"]["station_offsets"].values()) == [0.4] + assert g["sources"] == ["grade"] and "stations" not in g["stages"] and "cross" in g["stages"] + rv = r["reverted"] + assert rv["detail"] is None and rv["alignmentDraft"] is None and rv["pending"] is None + assert rv["adoptedLast"]["draft"] is False