feat(계산묶음): 346-1 · 346-2 남은 단계 붙임 — cross · masshaul · haul · drainage · corridor · 노선 초안 동안 유토곡선 = 새 노선 횡단

- design_pipeline_page: 단계 다섯 한 줄씩(부르기만) · 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) · haul · corridor 는 노선 초안 동안만 · 열 때 한 번 셈(발자국 바로 보임)
- 노선 초안 동안 종단도 = 새 노선 상세(pipelineDetail — 지반 · 측점 · 횡단 면적 → 유토곡선) · 저장 노선 횡단 다시 셈 안 함
- 횡단 입력 = 저장 상세의 측점 입력을 측점 열쇠로(design_pipeline_cross · 넘기는 칸 = 서버 CARRY_INPUT_KEYS 와 같음)
- 저장 관 목록(좌표 · id) = 관 입력 바탕 · 첫 계산도 저장 노선 기준으로 좌표 따라감(createFollowStage)
- 노선 초안 동안 계획고 = alignment-draft 노선 0 칸(DRAFT_ROUTE_ID) · 초안 버리면 비움 · [저장] 이음 = sub5
- 상단측 짝(logic/B05_Profile_Pipeline_Detail uphillSide ↔ Engine_Sections_Core uphill_side) · 측점 열쇠 = common_util_station_key 한 벌
- 시험 test_346_2_pipeline_cross_page 14 · 옛 시험 둘 새 꼴에 맞춤(317 · 230_2) · 346-1 도움 파일 창구 셋 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 12:32:11 +09:00
co-authored by Claude Opus 5.5
parent 5795f359cc
commit a35d49b4e2
13 changed files with 1098 additions and 86 deletions
@@ -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<string, SectionSpecs>;
}
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<string, unknown> {
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
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<PipelineStation>,
): PipelineCrossSource | null {
const standard = detail && effectiveStandardCross(projectId);
if (!detail || !standard) return null;
const byKey: CrossInput["byKey"] = {};
const specs = new Map<string, SectionSpecs>();
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<string, unknown> = {};
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 };
}
+176 -47
View File
@@ -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<void> =>
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());
+19 -14
View File
@@ -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();
+1
View File
@@ -488,6 +488,7 @@ export async function mountProfileView(
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [노선 반영]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel, {
stationIntervalM: panel.values().stationInterval ?? undefined,
onCorridorStale: () => sections.setCorridorPending(true),
}),
});
@@ -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
@@ -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<string, Record<string, unknown>>;
}
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<PipelineStation>,
): 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<string, SectionSpecs>,
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,
};
}
+27 -19
View File
@@ -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`) — 없으면 아직 못 셈. */
@@ -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:<id>` ·
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. */
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `: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. */
@@ -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(),
@@ -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");
@@ -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);
});
+2 -1
View File
@@ -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
@@ -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