Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -491,6 +491,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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user