feat(계산묶음): 346-1 묶음을 B05 페이지 · 노선 편집에 붙임 — 노선 초안 캐시 칸 · 노선 고칠 때마다 종단 따라옴 · ▲▼ = grade · 단계 붙임
- design_pipeline_page.ts 새 — 프로젝트마다 묶음 하나(종단도 dispose 때 버림) · route · ground(sub8) · follow · planline · profile · stations · footprint(sub6) 붙임 · cross · masshaul · haul · drainage 는 붙으면 한 줄 · 첫 계산은 처음 고칠 때 · 가게 stations · profile · footprint · generation - B05_Profile_UI_RouteEdit_Draft.ts 새 — 사진첩 감싸기(쌓기 · 되돌리기 · 다시하기 · 열 때로 = 초안 넘김 · 같으면 null) · 저장 안 한 초안 되살림 · RouteEdit.ts 는 줄 셋(700줄 안) - logic/B05_Profile_Pipeline_Grade.ts 새 — follow(관 · 구조물 좌표 · 계획고 옮김) · planline(저장 기준 · 초안이면 바뀐 구간만 새 지반 첫 계획선) · profile(buildAlignment) · routeRange · gradeRange — sub1 짝 부르기만 - design_profile — listed 를 design_profile_base.ts 로 나눔 · rebuild = pipelineGrade · render · 구조물 측점 = 다시 셈 · adoptPipeline(초안 동안 편집은 메모리만 · [저장]이 안 보냄) · pipelineSource - design_pipeline — input() · dispose() - 시험 test_346_1_pipeline_page 12 건(실제 단계 · 노선 1,078 m 점 옮김 23 ms · 늦은 응답 버림 · 초안 캐시 칸 살아남음) · popout 시험 문맥에 pipelineGrade 자리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -83,11 +83,15 @@ export interface DesignPipeline {
|
|||||||
): Promise<PipelineRunRecord> | null;
|
): Promise<PipelineRunRecord> | null;
|
||||||
subscribe(fn: (event: PipelineEvent) => void): () => void;
|
subscribe(fn: (event: PipelineEvent) => void): () => void;
|
||||||
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
||||||
|
/** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */
|
||||||
|
input<K extends InputKey>(key: K): PipelineInputs[K];
|
||||||
readonly generation: number;
|
readonly generation: number;
|
||||||
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
|
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
|
||||||
readonly blockedWrites: number;
|
readonly blockedWrites: number;
|
||||||
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
|
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
|
||||||
settled(): Promise<void>;
|
settled(): Promise<void>;
|
||||||
|
/** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */
|
||||||
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
|
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
|
||||||
@@ -327,6 +331,9 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
|||||||
output(name) {
|
output(name) {
|
||||||
return out[name] as never;
|
return out[name] as never;
|
||||||
},
|
},
|
||||||
|
input(key) {
|
||||||
|
return inputs[key];
|
||||||
|
},
|
||||||
get generation() {
|
get generation() {
|
||||||
return generation;
|
return generation;
|
||||||
},
|
},
|
||||||
@@ -336,6 +343,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
|||||||
async settled() {
|
async settled() {
|
||||||
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
||||||
},
|
},
|
||||||
|
dispose() {
|
||||||
|
generation += 1;
|
||||||
|
controller.abort();
|
||||||
|
listeners.clear();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pipeline_page.ts
|
||||||
|
* B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1) — 프로젝트마다 하나 · 페이지를 떠나면 버림
|
||||||
|
* (종단도 `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` 한 줄씩
|
||||||
|
* (없으면 건너뜀 · 계약 규칙).
|
||||||
|
*
|
||||||
|
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
||||||
|
* (`pipelineGrade`) · 관 · 구조물 = 가게 초안(없으면 구조물은 저장분). 출력 = 가게 stations ·
|
||||||
|
* profile · footprint · generation + 노선 초안이 있으면 종단도가 새 첫 계획선 · 옮긴 계획고를 그림.
|
||||||
|
* 첫 계산은 처음 고칠 때(또는 저장 안 한 노선 초안이 있을 때) — 열기만 하면 안 셈.
|
||||||
|
* [노선 반영] · [저장]이 서버 정본 — 여기 값은 미리보기(346-3 전까지).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./design_pipeline";
|
||||||
|
import { createStationsStage } from "./design_pipeline_stations";
|
||||||
|
import type { PipelineInputs, RouteDraft, Vertex } from "./design_pipeline_types";
|
||||||
|
import { useDesignStore } from "./design_store";
|
||||||
|
import {
|
||||||
|
emptyEdits,
|
||||||
|
type AlignmentBase,
|
||||||
|
type AlignmentEdits,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
|
import {
|
||||||
|
analysisTerrain,
|
||||||
|
fetchRoutePlan,
|
||||||
|
type RoutePlanResponse,
|
||||||
|
} from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||||
|
import { fetchStructures, fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
|
import {
|
||||||
|
designTerrainGridNow,
|
||||||
|
groundStageDeps,
|
||||||
|
} from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||||
|
import {
|
||||||
|
createGroundStage,
|
||||||
|
createRouteStage,
|
||||||
|
draftLine,
|
||||||
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||||
|
import {
|
||||||
|
createPlanlineStage,
|
||||||
|
followStage,
|
||||||
|
gradeRange,
|
||||||
|
profileStage,
|
||||||
|
routeRange,
|
||||||
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||||
|
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||||
|
import type { PlanType } from "../../common_util/common_util_structure_station";
|
||||||
|
import type {
|
||||||
|
RouteDraftBridge,
|
||||||
|
RouteDraftEdit,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Draft";
|
||||||
|
|
||||||
|
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
|
||||||
|
export interface ProfileLink {
|
||||||
|
/** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집. */
|
||||||
|
pipelineSource(): { base: AlignmentBase | null; edits: AlignmentEdits };
|
||||||
|
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */
|
||||||
|
adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagePipeline {
|
||||||
|
pipe: DesignPipeline;
|
||||||
|
bridge: RouteDraftBridge;
|
||||||
|
grade(edits: AlignmentEdits): void;
|
||||||
|
restart(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = new Map<string, PagePipeline>();
|
||||||
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
|
||||||
|
export function routeDraftBridge(
|
||||||
|
projectId: string,
|
||||||
|
link: ProfileLink,
|
||||||
|
options: { stationIntervalM?: number } = {},
|
||||||
|
): RouteDraftBridge {
|
||||||
|
let page = pages.get(projectId);
|
||||||
|
if (!page) pages.set(projectId, (page = createPagePipeline(projectId, link, options)));
|
||||||
|
return page.bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 종단도 계획고 편집(▲▼ · 끌기 · 되돌리기) — 묶음이 없으면(편집기 안 뜸) 아무 일 없음. */
|
||||||
|
export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void =>
|
||||||
|
pages.get(projectId)?.grade(edits);
|
||||||
|
|
||||||
|
/** 저장 종단 기준이 바뀜(새 상세 · 구조물 측점) — 셈 중이면 처음부터 다시. */
|
||||||
|
export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart();
|
||||||
|
|
||||||
|
/** 지금 묶음(개발 · 시험) — 없으면 null. */
|
||||||
|
export const pagePipeline = (projectId: string): DesignPipeline | null =>
|
||||||
|
pages.get(projectId)?.pipe ?? null;
|
||||||
|
|
||||||
|
export function disposePagePipeline(projectId: string): void {
|
||||||
|
pages.get(projectId)?.dispose();
|
||||||
|
pages.delete(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPagePipeline(
|
||||||
|
projectId: string,
|
||||||
|
link: ProfileLink,
|
||||||
|
options: { stationIntervalM?: number },
|
||||||
|
): PagePipeline {
|
||||||
|
const store = useDesignStore(projectId, null);
|
||||||
|
let plan: RoutePlanResponse | null = null;
|
||||||
|
let types: PlanType[] = [];
|
||||||
|
let savedStructures: unknown[] = [];
|
||||||
|
let ready = false;
|
||||||
|
let started = false;
|
||||||
|
/** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */
|
||||||
|
let drawnDraft = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const savedLine = (): Vertex[] | null =>
|
||||||
|
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
follow: followStage,
|
||||||
|
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }),
|
||||||
|
ground: createGroundStage(groundStageDeps(projectId)),
|
||||||
|
planline: createPlanlineStage({ savedBase: () => link.pipelineSource().base, savedLine }),
|
||||||
|
profile: profileStage,
|
||||||
|
footprint: footprintStage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pipe.subscribe(publishToStore(store));
|
||||||
|
pipe.subscribe((event) => {
|
||||||
|
if (event.kind !== "stage" || event.stage !== "profile") return;
|
||||||
|
const draft = pipe.input("route") !== null;
|
||||||
|
const base = pipe.output("planline");
|
||||||
|
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
|
||||||
|
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 start = (): void => {
|
||||||
|
if (disposed || !ready || !link.pipelineSource().base) return;
|
||||||
|
started = true;
|
||||||
|
void pipe.start(inputsNow());
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) — [노선 반영] 뒤 다시 받음(`restart`). */
|
||||||
|
const loadPlan = (): Promise<void> =>
|
||||||
|
fetchRoutePlan(projectId)
|
||||||
|
.then((routePlan) => void (plan = routePlan))
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
designTerrainGridNow(projectId); // 지형 격자 미리 받기(받는 동안은 서버 길)
|
||||||
|
void Promise.all([
|
||||||
|
loadPlan(),
|
||||||
|
fetchStructureTypes().catch(() => []),
|
||||||
|
fetchStructures(projectId).catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([, structureTypes, list]) => {
|
||||||
|
types = structureTypes as PlanType[];
|
||||||
|
savedStructures = list?.structures ?? [];
|
||||||
|
ready = plan !== null;
|
||||||
|
if (store.get("routeDraft")) start(); // 저장 안 한 노선 초안 — 열 때부터 그 노선으로
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
const unsubscribe = [
|
||||||
|
store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? [])),
|
||||||
|
store.subscribe(
|
||||||
|
"structures",
|
||||||
|
(list) => started && void pipe.update("structure", list ?? savedStructures),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const bridge: RouteDraftBridge = {
|
||||||
|
saved: () => store.get("routeDraft"),
|
||||||
|
edited(edit: RouteDraftEdit | null) {
|
||||||
|
const stored = store.get("routeDraft");
|
||||||
|
let draft: RouteDraft | null = null;
|
||||||
|
if (edit) {
|
||||||
|
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) };
|
||||||
|
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
|
||||||
|
}
|
||||||
|
store.set("routeDraft", draft);
|
||||||
|
if (!started) return start();
|
||||||
|
const before = pipe.output("route")?.line ?? null;
|
||||||
|
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
|
||||||
|
void pipe.update("route", draft, routeRange(before, after));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pipe,
|
||||||
|
bridge,
|
||||||
|
grade(edits) {
|
||||||
|
if (!started) return start();
|
||||||
|
if (same(edits, pipe.input("alignment"))) return;
|
||||||
|
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
|
||||||
|
const before = pipe.input("alignment") as AlignmentEdits | null;
|
||||||
|
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
|
||||||
|
},
|
||||||
|
restart() {
|
||||||
|
if (started) void loadPlan().then(start);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe.forEach((stop) => stop());
|
||||||
|
pipe.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,11 +70,8 @@ import {
|
|||||||
createProfileDrag,
|
createProfileDrag,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||||
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||||
import {
|
import { listedBase } from "./design_profile_base";
|
||||||
irregularGraphStations,
|
import { disposePagePipeline, pipelineGrade, restartPagePipeline } from "./design_pipeline_page";
|
||||||
maxChainageOf,
|
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
|
||||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
|
||||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||||
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||||
import { frameOf } from "@util/common_util_view_window";
|
import { frameOf } from "@util/common_util_view_window";
|
||||||
@@ -223,6 +220,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
let storedBase: AlignmentBase | null = null;
|
let storedBase: AlignmentBase | null = null;
|
||||||
let alignment: ProfileAlignment | null = null;
|
let alignment: ProfileAlignment | null = null;
|
||||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||||
|
/** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 메모리만(346-1). */
|
||||||
|
let preview: typeof store | null = null;
|
||||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||||
let savedEdits: AlignmentEdits = emptyEdits();
|
let savedEdits: AlignmentEdits = emptyEdits();
|
||||||
let redrawPending = false;
|
let redrawPending = false;
|
||||||
@@ -342,31 +341,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
history.record();
|
history.record();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
|
||||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
|
const listed = (): AlignmentBase | null => listedBase(storedBase, detail, irregularStations);
|
||||||
function listed(): AlignmentBase | null {
|
|
||||||
if (!storedBase || !detail) return storedBase;
|
|
||||||
const server = detail.longitudinal.stations;
|
|
||||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
|
||||||
const list = oneStationList(server, planted);
|
|
||||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
|
||||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
|
||||||
const gone = server
|
|
||||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
|
||||||
.map((station) => station.chainage_m)
|
|
||||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
|
||||||
const born = list
|
|
||||||
.map((station) => station.chainage_m)
|
|
||||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
|
||||||
return {
|
|
||||||
...storedBase,
|
|
||||||
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
|
||||||
stations: list.map((station) => ({
|
|
||||||
station_id: station.station_id,
|
|
||||||
chainage_m: station.chainage_m,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||||
@@ -374,6 +350,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
*/
|
*/
|
||||||
function rebuild(): void {
|
function rebuild(): void {
|
||||||
if (base) alignment = buildAlignment(base, store.edits());
|
if (base) alignment = buildAlignment(base, store.edits());
|
||||||
|
pipelineGrade(projectId, store.edits());
|
||||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||||
@@ -525,6 +502,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||||
if (carried) store.replace(carried);
|
if (carried) store.replace(carried);
|
||||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||||
|
restartPagePipeline(projectId);
|
||||||
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
|
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
|
||||||
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
|
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
|
||||||
configureBalloonOffsets(
|
configureBalloonOffsets(
|
||||||
@@ -601,6 +579,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
irregularStations = stations;
|
irregularStations = stations;
|
||||||
base = listed();
|
base = listed();
|
||||||
if (base) alignment = buildAlignment(base, store.edits());
|
if (base) alignment = buildAlignment(base, store.edits());
|
||||||
|
restartPagePipeline(projectId);
|
||||||
draw();
|
draw();
|
||||||
},
|
},
|
||||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
||||||
@@ -645,9 +624,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||||
},
|
},
|
||||||
isDirty: () => store.dirty(),
|
isDirty: () => store.dirty(),
|
||||||
|
/** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */
|
||||||
|
pipelineSource: () => ({ base: listed(), edits: store.edits() }),
|
||||||
|
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 메모리만(저장 노선에
|
||||||
|
* 안 씀 · [노선 반영] · [저장]이 서버 정본) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */
|
||||||
|
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());
|
||||||
|
if (!draft && store === preview)
|
||||||
|
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||||
|
if (!draft) preview = null;
|
||||||
|
base = draft ? nextBase : listed();
|
||||||
|
rebuild();
|
||||||
|
},
|
||||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||||
async save(): Promise<void> {
|
async save(): Promise<void> {
|
||||||
if (!routeId || !store.dirty()) return;
|
if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄
|
||||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||||
noticeDroppedEdits(saved);
|
noticeDroppedEdits(saved);
|
||||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||||
@@ -688,6 +682,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
drainagePanel?.load(projectId);
|
drainagePanel?.load(projectId);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
disposePagePipeline(projectId);
|
||||||
setListening(false);
|
setListening(false);
|
||||||
drag.dispose();
|
drag.dispose();
|
||||||
crossPreview.dispose();
|
crossPreview.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_profile_base.ts
|
||||||
|
* 종단도 계획선 기준 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — `design_profile` 에서 나눔(700줄).
|
||||||
|
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
|
import type { AlignmentBase } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
|
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||||
|
import {
|
||||||
|
irregularGraphStations,
|
||||||
|
maxChainageOf,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||||
|
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||||
|
|
||||||
|
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||||
|
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
|
||||||
|
export function listedBase(
|
||||||
|
storedBase: AlignmentBase | null,
|
||||||
|
detail: SectionDetailResponse | null,
|
||||||
|
irregularStations: IrregularStation[],
|
||||||
|
): AlignmentBase | null {
|
||||||
|
if (!storedBase || !detail) return storedBase;
|
||||||
|
const server = detail.longitudinal.stations;
|
||||||
|
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||||
|
const list = oneStationList(server, planted);
|
||||||
|
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||||
|
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||||
|
const gone = server
|
||||||
|
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||||
|
.map((station) => station.chainage_m)
|
||||||
|
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||||
|
const born = list
|
||||||
|
.map((station) => station.chainage_m)
|
||||||
|
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||||
|
return {
|
||||||
|
...storedBase,
|
||||||
|
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
||||||
|
stations: list.map((station) => ({
|
||||||
|
station_id: station.station_id,
|
||||||
|
chainage_m: station.chainage_m,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil
|
|||||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||||
import { currentRoute, remountRoute } from "../router";
|
import { currentRoute, remountRoute } from "../router";
|
||||||
import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
|
import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
|
||||||
|
import { routeDraftBridge } from "./design_pipeline_page";
|
||||||
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||||
import {
|
import {
|
||||||
readStructurePick,
|
readStructurePick,
|
||||||
@@ -488,6 +489,10 @@ export async function mountProfileView(
|
|||||||
smooth: latestResponse.surface_params.smooth,
|
smooth: latestResponse.surface_params.smooth,
|
||||||
tools: [view3d.modal.button],
|
tools: [view3d.modal.button],
|
||||||
recommend: panel.recommend,
|
recommend: panel.recommend,
|
||||||
|
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [노선 반영]이 정본).
|
||||||
|
draft: routeDraftBridge(activeProjectId, profilePanel, {
|
||||||
|
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
type RouteEditSnapshot,
|
type RouteEditSnapshot,
|
||||||
} from "./B05_Profile_UI_RouteEdit_History";
|
} from "./B05_Profile_UI_RouteEdit_History";
|
||||||
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||||
|
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||||
|
|
||||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||||
@@ -106,6 +107,7 @@ export interface RouteEditOptions {
|
|||||||
tools?: HTMLElement[];
|
tools?: HTMLElement[];
|
||||||
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
|
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
|
||||||
recommend: InitialPanelParams["controls"];
|
recommend: InitialPanelParams["controls"];
|
||||||
|
draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
|
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
|
||||||
@@ -326,14 +328,11 @@ export async function mountRouteEditor(
|
|||||||
|
|
||||||
const toScreen = (vertex: Vertex): [number, number] =>
|
const toScreen = (vertex: Vertex): [number, number] =>
|
||||||
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
||||||
|
|
||||||
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
|
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
|
||||||
const toMetric = (px: number, py: number): Vertex =>
|
const toMetric = (px: number, py: number): Vertex =>
|
||||||
meta ? screenToMetric(meta, view, px, py) : [0, 0];
|
meta ? screenToMetric(meta, view, px, py) : [0, 0];
|
||||||
|
|
||||||
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
|
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
|
||||||
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
|
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
|
||||||
|
|
||||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
||||||
const contourStepM = (): number =>
|
const contourStepM = (): number =>
|
||||||
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
||||||
@@ -631,6 +630,8 @@ export async function mountRouteEditor(
|
|||||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||||
history = createRouteEditHistory(snapshotNow());
|
history = createRouteEditHistory(snapshotNow());
|
||||||
|
history = draftLink.watch(history, options.draft, () => stationSnap);
|
||||||
|
draftLink.restore(options.draft, restoreSnapshot);
|
||||||
historyControls.sync();
|
historyControls.sync();
|
||||||
syncCurveBar();
|
syncCurveBar();
|
||||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B05_Profile_UI_RouteEdit_Draft.ts
|
||||||
|
* 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 ·
|
||||||
|
* 되돌리기 · 다시하기 · [반영 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
|
||||||
|
* (사진첩에 쌓이는 때 = 손을 놓은 때).
|
||||||
|
*
|
||||||
|
* 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은
|
||||||
|
* 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림
|
||||||
|
* (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { RouteDraft } from "../A00_Common/design/design_pipeline_types";
|
||||||
|
import type { RouteEditHistory, RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||||
|
|
||||||
|
/** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */
|
||||||
|
export type RouteDraftEdit = Omit<RouteDraft, "rev" | "terrain">;
|
||||||
|
|
||||||
|
export interface RouteDraftBridge {
|
||||||
|
/** 노선이 바뀜 — 열 때(저장분)와 같으면 null. */
|
||||||
|
edited(draft: RouteDraftEdit | null): void;
|
||||||
|
/** 저장 안 한 초안(캐시 칸) — 없으면 null. */
|
||||||
|
saved(): RouteDraft | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님). */
|
||||||
|
export function draftOfSnapshot(snapshot: RouteEditSnapshot, stationSnap: boolean): RouteDraftEdit {
|
||||||
|
return {
|
||||||
|
nodes: snapshot.planned.map(([x, y]): [number, number] => [x, y]),
|
||||||
|
curveOn: [...snapshot.curveOn],
|
||||||
|
curveRadius: [...snapshot.curveRadius],
|
||||||
|
curveLock: [...snapshot.curveLock],
|
||||||
|
curveArc: [...snapshot.curveArc],
|
||||||
|
stationSnap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 노선 초안 → 사진 — 고정은 편집기가 좌표로 다시 짚음(`restoreSnapshot` 새 걸음). */
|
||||||
|
export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
|
||||||
|
return {
|
||||||
|
planned: draft.nodes.map(([x, y]): [number, number] => [x, y]),
|
||||||
|
curveOn: [...draft.curveOn],
|
||||||
|
curveRadius: [...draft.curveRadius],
|
||||||
|
curveLock: [...draft.curveLock],
|
||||||
|
curveArc: [...draft.curveArc],
|
||||||
|
apexLock: draft.nodes.map(() => false),
|
||||||
|
picked: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. */
|
||||||
|
export function watch(
|
||||||
|
history: RouteEditHistory,
|
||||||
|
bridge: RouteDraftBridge | undefined,
|
||||||
|
stationSnap: () => boolean,
|
||||||
|
): RouteEditHistory {
|
||||||
|
if (!bridge) return history;
|
||||||
|
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
|
||||||
|
if (snapshot)
|
||||||
|
bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null);
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...history,
|
||||||
|
commit: (snapshot) => {
|
||||||
|
history.commit(snapshot);
|
||||||
|
tell(snapshot);
|
||||||
|
},
|
||||||
|
undo: () => tell(history.undo()),
|
||||||
|
redo: () => tell(history.redo()),
|
||||||
|
reset: () => tell(history.reset()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 안 한 초안을 편집기에 한 번 되살림 — 사진첩에 새 걸음으로(되돌리면 저장분). */
|
||||||
|
export function restore(
|
||||||
|
bridge: RouteDraftBridge | undefined,
|
||||||
|
apply: (snapshot: RouteEditSnapshot, message: string, record: boolean) => void,
|
||||||
|
): void {
|
||||||
|
const kept = bridge?.saved();
|
||||||
|
if (kept?.nodes.length)
|
||||||
|
apply(snapshotOfDraft(kept), "저장 안 한 노선 초안을 되살렸습니다.", true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B05_Profile_Pipeline_Grade.ts
|
||||||
|
* 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 =
|
||||||
|
* `A00_Common/design/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
|
||||||
|
*
|
||||||
|
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
|
||||||
|
* `carryProfileEdits` 의 edits 몫) · 옛 노선(`before.route`)이 있을 때만.
|
||||||
|
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로
|
||||||
|
* 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫).
|
||||||
|
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
|
||||||
|
*
|
||||||
|
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FollowPatch,
|
||||||
|
RangePart,
|
||||||
|
StageFn,
|
||||||
|
Vertex,
|
||||||
|
} from "../../A00_Common/design/design_pipeline_types";
|
||||||
|
import {
|
||||||
|
followStructures,
|
||||||
|
projectPipePoints,
|
||||||
|
type FollowingPoint,
|
||||||
|
type FollowingStructure,
|
||||||
|
} from "../../common_util/common_util_route_follow";
|
||||||
|
import {
|
||||||
|
buildAlignment,
|
||||||
|
emptyEdits,
|
||||||
|
type AlignmentBase,
|
||||||
|
type AlignmentEdits,
|
||||||
|
} from "../B05_Profile_UI_Profile_Alignment";
|
||||||
|
import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry";
|
||||||
|
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||||
|
|
||||||
|
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
|
||||||
|
line.map(([x, y]) => ({ x, y }));
|
||||||
|
|
||||||
|
/** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */
|
||||||
|
export const stationIdOf = (chainageM: number): string =>
|
||||||
|
`station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`;
|
||||||
|
|
||||||
|
/** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */
|
||||||
|
export function routeRange(
|
||||||
|
before: ReadonlyArray<Vertex> | null,
|
||||||
|
after: ReadonlyArray<Vertex> | null,
|
||||||
|
): RangePart {
|
||||||
|
const span = before && after ? changedSpan(xy(before), xy(after)) : null;
|
||||||
|
return { fromM: span ? span.start_m : 0, toM: Infinity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */
|
||||||
|
export function gradeRange(
|
||||||
|
before: AlignmentEdits | null,
|
||||||
|
after: AlignmentEdits | null,
|
||||||
|
nodes: ReadonlyArray<number>,
|
||||||
|
): RangePart | null {
|
||||||
|
const changed: number[] = [];
|
||||||
|
for (const field of ["station_offsets", "curve_radii"] as const) {
|
||||||
|
const a = before?.[field] ?? {};
|
||||||
|
const b = after?.[field] ?? {};
|
||||||
|
for (const key of new Set([...Object.keys(a), ...Object.keys(b)]))
|
||||||
|
if (a[key] !== b[key]) changed.push(Number(key));
|
||||||
|
}
|
||||||
|
if (!changed.length) return null;
|
||||||
|
const low = Math.min(...changed);
|
||||||
|
const high = Math.max(...changed);
|
||||||
|
const ahead = nodes.filter((node) => node > high + 1e-6);
|
||||||
|
return {
|
||||||
|
fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)),
|
||||||
|
toM: ahead.length ? Math.min(...ahead) : Infinity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlanlineDeps {
|
||||||
|
/** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */
|
||||||
|
savedBase(): AlignmentBase | null;
|
||||||
|
/** 저장 노선(그린 선) — 바뀐 구간을 가름. */
|
||||||
|
savedLine(): Vertex[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||||
|
return (ctx) => {
|
||||||
|
const saved = deps.savedBase();
|
||||||
|
if (!saved) throw new Error("저장 종단 기준이 아직 없습니다.");
|
||||||
|
const ground = ctx.out.ground;
|
||||||
|
const route = ctx.out.route;
|
||||||
|
if (!ctx.inputs.route || !ground || !route) return saved;
|
||||||
|
const { chainageM, z } = ground.longitudinal;
|
||||||
|
const samples = chainageM.map((chainage, index) => ({
|
||||||
|
chainage_m: chainage,
|
||||||
|
elevation_m: z[index],
|
||||||
|
}));
|
||||||
|
const profile = groundProfile(samples);
|
||||||
|
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
|
||||||
|
const groundPvi =
|
||||||
|
groundFollowingBasePvi({
|
||||||
|
samples,
|
||||||
|
stations: ground.stations.map((station) => station.chainageM),
|
||||||
|
policy: saved.policy,
|
||||||
|
}) ?? [];
|
||||||
|
const savedLine = deps.savedLine();
|
||||||
|
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
||||||
|
return {
|
||||||
|
policy: saved.policy,
|
||||||
|
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||||
|
basePvi: span
|
||||||
|
? carryProfileEdits(saved.basePvi, null, span, groundPvi).basePvi
|
||||||
|
: saved.basePvi,
|
||||||
|
chainage: profile.chainage,
|
||||||
|
ground: profile.ground,
|
||||||
|
stations: ground.stations.map((station) => ({
|
||||||
|
station_id: stationIdOf(station.chainageM),
|
||||||
|
chainage_m: station.chainageM,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */
|
||||||
|
export const profileStage: StageFn<"profile"> = (ctx) => {
|
||||||
|
const base = ctx.out.planline;
|
||||||
|
if (!base) throw new Error("첫 계획선이 없습니다.");
|
||||||
|
return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits());
|
||||||
|
};
|
||||||
@@ -96,6 +96,8 @@ out.viewOf = {
|
|||||||
alignment: null,
|
alignment: null,
|
||||||
buildAlignment: () => null,
|
buildAlignment: () => null,
|
||||||
store: { edits: () => ({}) },
|
store: { edits: () => ({}) },
|
||||||
|
projectId: "p",
|
||||||
|
pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
|
||||||
scheduleCrossPreview: () => s.previews++,
|
scheduleCrossPreview: () => s.previews++,
|
||||||
draw: () => s.draws++,
|
draw: () => s.draws++,
|
||||||
root: inPopup(),
|
root: inPopup(),
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/* PLAN 346-1 단위검증 헬퍼 — 계산 묶음을 페이지 · 노선 편집에 붙인 것.
|
||||||
|
* 진짜 TS(`design_pipeline` · `design_pipeline_page` · `design_pipeline_stations` · `logic/*Pipeline*` ·
|
||||||
|
* `_RouteEdit_Draft` · sub1 · sub8 · sub6 짝)를 그 자리에서 트랜스파일해 Node 로 돌림. 서버 창구
|
||||||
|
* (`Api_Replan` · `Api_Structures` · `Api_TerrainGrid`)만 가짜. 입력 argv[2] JSON · argv[3] 격자 이진.
|
||||||
|
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_346_1_pipeline_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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const session = memoryStorage();
|
||||||
|
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||||
|
globalThis.fetch = async () => ({ ok: false });
|
||||||
|
|
||||||
|
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("@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" };
|
||||||
|
|
||||||
|
let remoteCalls = 0;
|
||||||
|
STUBS["B05_Profile_Api_Replan"] = {
|
||||||
|
analysisTerrain: () => "las",
|
||||||
|
fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }),
|
||||||
|
};
|
||||||
|
STUBS["B05_Profile_Api_Structures"] = {
|
||||||
|
fetchStructures: async () => ({ structures: [] }),
|
||||||
|
fetchStructureTypes: async () => job.types,
|
||||||
|
};
|
||||||
|
STUBS["B05_Profile_Api_TerrainGrid"] = {
|
||||||
|
designTerrainGridNow: () => local,
|
||||||
|
groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 footprint = load("B05_Profile", "logic", "B05_Profile_Footprint_Stage.ts");
|
||||||
|
const alignment = load("B05_Profile", "B05_Profile_UI_Profile_Alignment.ts");
|
||||||
|
const draftLink = load("B05_Profile", "B05_Profile_UI_RouteEdit_Draft.ts");
|
||||||
|
const page = load("A00_Common", "b_page_state.ts");
|
||||||
|
|
||||||
|
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const n = job.nodes.length;
|
||||||
|
const draftOf = (nodes) => ({
|
||||||
|
rev: 1,
|
||||||
|
nodes,
|
||||||
|
curveOn: Array(n).fill(true),
|
||||||
|
curveRadius: Array(n).fill(null),
|
||||||
|
curveLock: Array(n).fill(null),
|
||||||
|
curveArc: Array(n).fill(null),
|
||||||
|
stationSnap: false,
|
||||||
|
terrain: null,
|
||||||
|
});
|
||||||
|
const movedNodes = job.nodes.map((node, i) => (i === n - 1 ? [node[0] + 6, node[1] - 4] : node));
|
||||||
|
const inputs = (route = null, edits = alignment.emptyEdits()) => ({
|
||||||
|
route,
|
||||||
|
alignment: edits,
|
||||||
|
cross: null,
|
||||||
|
pipes: [],
|
||||||
|
structures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 단계 한 벌(실제) — 저장 기준은 부르는 쪽. */
|
||||||
|
function realStages(savedBase, remote = null) {
|
||||||
|
return {
|
||||||
|
route: routeGround.createRouteStage({
|
||||||
|
minRadiusM: () => job.minRadius,
|
||||||
|
savedLine: () => ({ line: job.line, curves: [] }),
|
||||||
|
}),
|
||||||
|
follow: grade.followStage,
|
||||||
|
stations: stationsMod.createStationsStage({ types: () => job.types }),
|
||||||
|
ground: routeGround.createGroundStage(
|
||||||
|
remote ? { grid: () => null, remote } : { grid: () => local, remote: async () => [] },
|
||||||
|
),
|
||||||
|
planline: grade.createPlanlineStage({ savedBase: () => savedBase, savedLine: () => job.line }),
|
||||||
|
profile: grade.profileStage,
|
||||||
|
footprint: footprint.footprintStage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const out = { n };
|
||||||
|
|
||||||
|
// ⓪ 저장 기준 — 저장 노선 지반으로 첫 계획선(서버 정책 그대로)
|
||||||
|
const seed = pipeMod.createDesignPipeline({
|
||||||
|
stages: { ...realStages(null), planline: undefined, profile: undefined, footprint: undefined },
|
||||||
|
});
|
||||||
|
await seed.start(inputs());
|
||||||
|
const ground0 = seed.output("ground");
|
||||||
|
const samples = ground0.longitudinal.chainageM.map((c, i) => ({
|
||||||
|
chainage_m: c,
|
||||||
|
elevation_m: ground0.longitudinal.z[i],
|
||||||
|
}));
|
||||||
|
const profile0 = gradeGround.groundProfile(samples);
|
||||||
|
const savedBase = {
|
||||||
|
policy: job.policy,
|
||||||
|
onlyExplicitCurves: false,
|
||||||
|
basePvi: gradeGround.groundFollowingBasePvi({
|
||||||
|
samples,
|
||||||
|
stations: ground0.stations.map((s) => s.chainageM),
|
||||||
|
policy: job.policy,
|
||||||
|
}),
|
||||||
|
chainage: profile0.chainage,
|
||||||
|
ground: profile0.ground,
|
||||||
|
stations: ground0.stations.map((s) => ({
|
||||||
|
station_id: grade.stationIdOf(s.chainageM),
|
||||||
|
chainage_m: s.chainageM,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ① 실제 단계 한 번 — 저장 노선 · 노선 한 점 옮김 · 계획고 한 점
|
||||||
|
{
|
||||||
|
const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase) });
|
||||||
|
const first = await pipe.start(inputs());
|
||||||
|
const savedPlanline = pipe.output("planline") === savedBase;
|
||||||
|
const beforeLine = pipe.output("route").line;
|
||||||
|
const beforeKeys = pipe.output("ground").stations.map((s) => [s.key, s.chainageM]);
|
||||||
|
const draft = draftOf(movedNodes);
|
||||||
|
const after = routeGround.draftLine(draft, job.minRadius).line;
|
||||||
|
const range = grade.routeRange(beforeLine, after);
|
||||||
|
const routeRun = await pipe.update("route", draft, range);
|
||||||
|
const moved = {
|
||||||
|
basePvi: pipe.output("planline").basePvi,
|
||||||
|
stations: pipe.output("ground").stations.map((s) => [s.key, s.chainageM]),
|
||||||
|
profileStations: pipe.output("profile").stations.length,
|
||||||
|
footprint: pipe.output("footprint") !== null,
|
||||||
|
};
|
||||||
|
const nodes = pipe.output("planline").basePvi.map((p) => p.chainage_m);
|
||||||
|
const target = nodes[Math.floor(nodes.length / 2)];
|
||||||
|
const edits = { station_offsets: { [alignment.chainageKey(target)]: 0.8 }, curve_radii: {} };
|
||||||
|
const gRange = grade.gradeRange(alignment.emptyEdits(), edits, nodes);
|
||||||
|
const gradeRun = await pipe.update("grade", edits, gRange);
|
||||||
|
// 꼭짓점 높이(종단곡선은 그 아래로 둥글게 지나감)
|
||||||
|
const pviAt = (profile) =>
|
||||||
|
profile.pvi.find((p) => Math.abs(p.chainage_m - target) < 1e-6).elevation_m;
|
||||||
|
const raised = pviAt(pipe.output("profile"));
|
||||||
|
const plain = pviAt(alignment.buildAlignment(pipe.output("planline"), alignment.emptyEdits()));
|
||||||
|
out.real = {
|
||||||
|
first: { outcome: first.outcome, totalMs: first.totalMs, stages: first.stages },
|
||||||
|
savedPlanline,
|
||||||
|
range,
|
||||||
|
routeRun: {
|
||||||
|
outcome: routeRun.outcome,
|
||||||
|
totalMs: routeRun.totalMs,
|
||||||
|
stages: routeRun.stages,
|
||||||
|
skipped: routeRun.skipped,
|
||||||
|
},
|
||||||
|
savedBasePvi: savedBase.basePvi,
|
||||||
|
moved,
|
||||||
|
beforeKeys,
|
||||||
|
gradeRange: gRange,
|
||||||
|
target,
|
||||||
|
gradeRun: {
|
||||||
|
outcome: gradeRun.outcome,
|
||||||
|
stages: Object.keys(gradeRun.stages),
|
||||||
|
skipped: gradeRun.skipped,
|
||||||
|
totalMs: gradeRun.totalMs,
|
||||||
|
},
|
||||||
|
raised: raised - plain,
|
||||||
|
log: globalThis.__aisloPipeline.runs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 늦은 응답 버림 — 격자 없음(서버 길) · 첫 지반 응답이 늦는 사이 노선을 또 옮김
|
||||||
|
{
|
||||||
|
const waits = [];
|
||||||
|
const remote = (points, _terrain, signal) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
waits.push(() => resolve(points.map(([x, y]) => sampler.heightAt(grid, x, y))));
|
||||||
|
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
||||||
|
});
|
||||||
|
const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase, remote) });
|
||||||
|
const commits = [];
|
||||||
|
pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`));
|
||||||
|
const a = pipe.start(inputs(draftOf(movedNodes)));
|
||||||
|
await tick(5);
|
||||||
|
const b = pipe.update("route", draftOf(job.nodes), { fromM: 0, toM: Infinity });
|
||||||
|
await tick(5);
|
||||||
|
waits.forEach((go) => go());
|
||||||
|
const ra = await a;
|
||||||
|
await tick(5);
|
||||||
|
waits.slice(1).forEach((go) => go());
|
||||||
|
const rb = await b;
|
||||||
|
out.late = {
|
||||||
|
a: ra.outcome,
|
||||||
|
b: rb.outcome,
|
||||||
|
commits,
|
||||||
|
profileGen: commits.filter((c) => c.startsWith("profile")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림
|
||||||
|
{
|
||||||
|
const told = [];
|
||||||
|
let dirty = true;
|
||||||
|
const fake = {
|
||||||
|
commit: () => undefined,
|
||||||
|
undo: () => ({
|
||||||
|
planned: [
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
],
|
||||||
|
curveOn: [true, true],
|
||||||
|
curveRadius: [null, null],
|
||||||
|
curveLock: [null, null],
|
||||||
|
curveArc: [null, null],
|
||||||
|
apexLock: [false, false],
|
||||||
|
picked: -1,
|
||||||
|
}),
|
||||||
|
redo: () => null,
|
||||||
|
reset: () => ({
|
||||||
|
planned: [[0, 0]],
|
||||||
|
curveOn: [true],
|
||||||
|
curveRadius: [null],
|
||||||
|
curveLock: [null],
|
||||||
|
curveArc: [null],
|
||||||
|
apexLock: [false],
|
||||||
|
picked: -1,
|
||||||
|
}),
|
||||||
|
canUndo: () => true,
|
||||||
|
canRedo: () => false,
|
||||||
|
isDirty: () => dirty,
|
||||||
|
};
|
||||||
|
const bridge = {
|
||||||
|
edited: (d) => told.push(d && d.nodes.length),
|
||||||
|
saved: () => draftOf(job.nodes),
|
||||||
|
};
|
||||||
|
const watched = draftLink.watch(fake, bridge, () => true);
|
||||||
|
watched.commit({
|
||||||
|
...fake.undo(),
|
||||||
|
planned: [
|
||||||
|
[0, 0],
|
||||||
|
[2, 2],
|
||||||
|
[3, 3],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
watched.undo();
|
||||||
|
watched.redo();
|
||||||
|
dirty = false;
|
||||||
|
watched.reset();
|
||||||
|
const restored = [];
|
||||||
|
draftLink.restore(bridge, (snap, message, record) =>
|
||||||
|
restored.push([snap.planned.length, record]),
|
||||||
|
);
|
||||||
|
draftLink.restore({ saved: () => null }, () => restored.push("none"));
|
||||||
|
out.draftLink = {
|
||||||
|
told,
|
||||||
|
restored,
|
||||||
|
unwatched: draftLink.watch(fake, undefined, () => false) === fake,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 페이지 묶음 — 다리 → 노선 초안 캐시 칸 · 종단도 받기 · 떠남 · 다시 엶(초안 살아남음) · ▲▼
|
||||||
|
{
|
||||||
|
const P = "proj-3461";
|
||||||
|
const adopted = [];
|
||||||
|
let edits = alignment.emptyEdits();
|
||||||
|
const link = {
|
||||||
|
pipelineSource: () => ({ base: savedBase, edits }),
|
||||||
|
adoptPipeline: (base, nextEdits, draft) =>
|
||||||
|
adopted.push({ draft, saved: base === savedBase, stations: base.stations.length }),
|
||||||
|
};
|
||||||
|
const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 });
|
||||||
|
await tick(10);
|
||||||
|
const before = { saved: bridge.saved(), pipe: pageMod.pagePipeline(P) !== null };
|
||||||
|
const edit = { ...draftOf(movedNodes) };
|
||||||
|
delete edit.rev;
|
||||||
|
delete edit.terrain;
|
||||||
|
bridge.edited(edit);
|
||||||
|
await tick(50);
|
||||||
|
const key = `aislo:draft:route-draft:${P}`;
|
||||||
|
const cached = JSON.parse(session.getItem(key));
|
||||||
|
bridge.edited(edit); // 같은 노선 — 판 번호 그대로 · 다시 안 셈
|
||||||
|
const sameRev = JSON.parse(session.getItem(key)).rev;
|
||||||
|
await tick(50);
|
||||||
|
const adoptedDraft = adopted.slice();
|
||||||
|
bridge.edited(null); // 열 때로(되돌리기 끝)
|
||||||
|
await tick(50);
|
||||||
|
const afterRevert = { session: session.getItem(key), last: adopted[adopted.length - 1] };
|
||||||
|
bridge.edited(edit);
|
||||||
|
await tick(50);
|
||||||
|
pageMod.disposePagePipeline(P);
|
||||||
|
const disposed = pageMod.pagePipeline(P);
|
||||||
|
adopted.length = 0;
|
||||||
|
const again = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 });
|
||||||
|
const survived = again.saved();
|
||||||
|
await tick(80); // 받기 끝 → 저장 안 한 초안이 있으면 그 노선으로 셈
|
||||||
|
const reopened = adopted.slice();
|
||||||
|
edits = { station_offsets: { "100.000": 0.5 }, curve_radii: {} };
|
||||||
|
pageMod.pipelineGrade(P, edits);
|
||||||
|
await tick(30);
|
||||||
|
const gradeRuns = globalThis.__aisloPipeline.runs.slice(-1)[0];
|
||||||
|
page.clearDrafts(P);
|
||||||
|
out.page = {
|
||||||
|
before,
|
||||||
|
cached: { rev: cached.rev, terrain: cached.terrain, nodes: cached.nodes.length },
|
||||||
|
sameRev,
|
||||||
|
adoptedDraft,
|
||||||
|
afterRevert,
|
||||||
|
disposed,
|
||||||
|
survived: survived && survived.nodes.length,
|
||||||
|
reopened,
|
||||||
|
grade: { sources: gradeRuns.sources, stages: Object.keys(gradeRuns.stages) },
|
||||||
|
clearedDraft: session.getItem(key),
|
||||||
|
remoteCalls,
|
||||||
|
};
|
||||||
|
pageMod.disposePagePipeline(P);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""PLAN 346-1 — 계산 묶음을 B05 페이지 · 노선 편집에 붙임.
|
||||||
|
|
||||||
|
· 실제 단계 한 번(route · follow · stations · ground · planline · profile · footprint) — 노선 한 점 옮김
|
||||||
|
뒤 종단까지 0.3 s 안 · 바뀐 구간 앞 첫 계획선 꼭짓점 · 측점 열쇠 그대로 · 계획고 한 점은 측점 · 지반 안 셈
|
||||||
|
· 늦은 응답 버림(격자 없음 · 서버 길 지반이 늦는 사이 노선을 또 옮김)
|
||||||
|
· 편집기 다리(사진첩 감싸기 · 열 때와 같으면 null · 초안 되살림)
|
||||||
|
· 페이지 묶음 — 노선 초안 캐시 칸 · 종단도가 받음 · 떠났다 다시 열어도 초안 살아남음 · ▲▼ = grade
|
||||||
|
짝: `helper_346_1_pipeline_page.cjs`(실제 TS 를 Node 로 돌림 · 서버 창구만 가짜).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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 common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
||||||
|
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||||
|
reason="프론트엔드 의존성 미설치",
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
|
||||||
|
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
|
||||||
|
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
|
||||||
|
MIN_RADIUS_M = 12.0
|
||||||
|
BUDGET_MS = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
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(3461)
|
||||||
|
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
|
||||||
|
y = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 2000.0).astype(np.float32)
|
||||||
|
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
|
||||||
|
z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + 2 * np.sin(xx / 13.0)
|
||||||
|
mask = np.ones(z.shape, dtype=bool)
|
||||||
|
mask[60:70, 100:115] = False
|
||||||
|
npz = folder / "dtm_fake.npz"
|
||||||
|
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
|
||||||
|
return npz, [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _real(_folder: Path) -> tuple[Path, list[list[float]]]:
|
||||||
|
if not (REAL_GRID.is_file() and REAL_NODES.is_file()):
|
||||||
|
pytest.skip("검증_sub8 없음")
|
||||||
|
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 _run(case) -> dict:
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
npz, nodes = case(Path(folder))
|
||||||
|
line = _line(nodes)
|
||||||
|
longitudinal = core.generate_sections(
|
||||||
|
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
|
||||||
|
)["longitudinal"]
|
||||||
|
options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
|
||||||
|
policy = design_ground_following_profile(longitudinal, options)[0]["policy"]
|
||||||
|
job = {
|
||||||
|
"nodes": nodes,
|
||||||
|
"line": line,
|
||||||
|
"minRadius": MIN_RADIUS_M,
|
||||||
|
"policy": policy,
|
||||||
|
"types": _types(),
|
||||||
|
}
|
||||||
|
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_1_pipeline_page.cjs"), str(path), str(grid)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub8"])
|
||||||
|
def r(request) -> dict:
|
||||||
|
return _run(request.param)
|
||||||
|
|
||||||
|
|
||||||
|
def test_실제_단계_한_번_노선_옮김_종단까지_시간(r):
|
||||||
|
real = r["real"]
|
||||||
|
assert real["first"]["outcome"] == "done" and real["savedPlanline"] is True
|
||||||
|
run = real["routeRun"]
|
||||||
|
assert run["outcome"] == "done"
|
||||||
|
for stage in ("route", "follow", "stations", "ground", "planline", "profile"):
|
||||||
|
assert stage in run["stages"], stage
|
||||||
|
to_profile = sum(
|
||||||
|
run["stages"][s] for s in ("route", "follow", "stations", "ground", "planline", "profile")
|
||||||
|
)
|
||||||
|
assert to_profile < BUDGET_MS, run["stages"]
|
||||||
|
assert run["totalMs"] < BUDGET_MS
|
||||||
|
assert real["moved"]["profileStations"] > 2 and real["moved"]["footprint"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_바뀐_구간_앞은_그대로(r):
|
||||||
|
real = r["real"]
|
||||||
|
from_m = real["range"]["fromM"]
|
||||||
|
assert 0 < from_m and real["range"]["toM"] is None # JSON 의 Infinity = null
|
||||||
|
saved = [p for p in real["savedBasePvi"] if p["chainage_m"] < from_m - 1e-6]
|
||||||
|
moved = [p for p in real["moved"]["basePvi"] if p["chainage_m"] < from_m - 1e-6]
|
||||||
|
assert saved and moved == saved
|
||||||
|
before = {k: c for k, c in real["beforeKeys"] if c < from_m - 20}
|
||||||
|
after = dict(real["moved"]["stations"])
|
||||||
|
assert before and all(after.get(k) == c for k, c in before.items())
|
||||||
|
|
||||||
|
|
||||||
|
def test_계획고_한_점은_측점_지반을_안_셈(r):
|
||||||
|
real = r["real"]
|
||||||
|
g = real["gradeRun"]
|
||||||
|
assert g["outcome"] == "done"
|
||||||
|
assert {"route", "follow", "stations", "ground", "planline"} <= set(g["skipped"])
|
||||||
|
assert "profile" in g["stages"]
|
||||||
|
rng = real["gradeRange"]
|
||||||
|
assert rng["fromM"] < real["target"] < (rng["toM"] if rng["toM"] is not None else float("inf"))
|
||||||
|
assert abs(real["raised"] - 0.8) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_늦은_응답_버림(r):
|
||||||
|
late = r["late"]
|
||||||
|
assert late["a"] == "dropped" and late["b"] == "done"
|
||||||
|
assert late["profileGen"] == ["profile@2"]
|
||||||
|
assert not [c for c in late["commits"] if c.startswith(("ground@1", "planline@1"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_편집기_다리(r):
|
||||||
|
d = r["draftLink"]
|
||||||
|
assert d["told"] == [3, 2, None] # 쌓기 · 되돌리기 · 열 때로(null) — 다시하기 없음은 안 알림
|
||||||
|
assert d["restored"] == [
|
||||||
|
[r["n"], True]
|
||||||
|
] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름
|
||||||
|
assert d["unwatched"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_페이지_묶음_노선_초안_캐시_칸(r):
|
||||||
|
p = r["page"]
|
||||||
|
assert p["before"] == {"saved": None, "pipe": True}
|
||||||
|
assert (
|
||||||
|
p["cached"]["rev"] == 1
|
||||||
|
and p["cached"]["terrain"] == "las"
|
||||||
|
and p["cached"]["nodes"] == r["n"]
|
||||||
|
)
|
||||||
|
assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로
|
||||||
|
assert p["adoptedDraft"] and all(a["draft"] and not a["saved"] for a in p["adoptedDraft"])
|
||||||
|
assert p["afterRevert"]["session"] is None and p["afterRevert"]["last"]["draft"] is False
|
||||||
|
assert p["disposed"] is None
|
||||||
|
assert p["survived"] == r["n"] # 떠났다 다시 열어도 초안 살아남음
|
||||||
|
assert p["reopened"] and p["reopened"][-1]["draft"] is True
|
||||||
|
assert p["grade"]["sources"] == ["grade"] and "stations" not in p["grade"]["stages"]
|
||||||
|
assert p["clearedDraft"] is None # clearDrafts 가 함께 비움
|
||||||
|
assert p["remoteCalls"] == 0 # 격자 있으면 서버 길 안 탐
|
||||||
Reference in New Issue
Block a user