Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에
|
||||
선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */
|
||||
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 평면(노선 편집) 위에
|
||||
떠 있는 판(absolute · 평면은 전체 영역 · 345-5)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
|
||||
.design-page__main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -35,7 +35,7 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.design-page__main > .design-page__profile > .b05-route-profile {
|
||||
.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -83,11 +83,15 @@ export interface DesignPipeline {
|
||||
): Promise<PipelineRunRecord> | null;
|
||||
subscribe(fn: (event: PipelineEvent) => void): () => void;
|
||||
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
||||
/** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */
|
||||
input<K extends InputKey>(key: K): PipelineInputs[K];
|
||||
readonly generation: number;
|
||||
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
|
||||
readonly blockedWrites: number;
|
||||
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
|
||||
settled(): Promise<void>;
|
||||
/** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
|
||||
@@ -327,6 +331,9 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
||||
output(name) {
|
||||
return out[name] as never;
|
||||
},
|
||||
input(key) {
|
||||
return inputs[key];
|
||||
},
|
||||
get generation() {
|
||||
return generation;
|
||||
},
|
||||
@@ -336,6 +343,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
||||
async settled() {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -212,6 +212,14 @@ export interface PlanFootprint {
|
||||
roadRight: Vertex[];
|
||||
slopeLeft: Vertex[];
|
||||
slopeRight: Vertex[];
|
||||
/** 비탈 끝 점마다 깎기(true) · 쌓기 — slopeLeft · slopeRight 와 같은 차례(그리기가 색을 가름). */
|
||||
slopeLeftCut: boolean[];
|
||||
slopeRightCut: boolean[];
|
||||
/** 비탈 표시 짧은 선 — [도로 끝 점, 비탈 쪽 점] 묶음. */
|
||||
ticks: number[][][];
|
||||
/** 측점마다 원자료(측점 열쇠 · 누가거리 · 좌우 도로 끝 · 비탈 끝 m · 깎기) — 범위 밖 재사용 ·
|
||||
* 모양 = `B05_Profile/logic/B05_Profile_PlanFootprint.ts` `FootprintRecord`(sub6). */
|
||||
records: Array<{ key?: string; chainage_m: number }>;
|
||||
}
|
||||
|
||||
/** 단계 이름 → 출력. drainage · corridor 는 표시만 하므로 출력이 없음(null). */
|
||||
|
||||
@@ -70,11 +70,8 @@ import {
|
||||
createProfileDrag,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import { listedBase } from "./design_profile_base";
|
||||
import { disposePagePipeline, pipelineGrade, restartPagePipeline } 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";
|
||||
@@ -223,6 +220,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
let storedBase: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 메모리만(346-1). */
|
||||
let preview: typeof store | null = null;
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
let savedEdits: AlignmentEdits = emptyEdits();
|
||||
let redrawPending = false;
|
||||
@@ -342,31 +341,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
history.record();
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
|
||||
const listed = (): AlignmentBase | null => listedBase(storedBase, detail, irregularStations);
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
@@ -374,6 +350,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
*/
|
||||
function rebuild(): void {
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
pipelineGrade(projectId, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
@@ -525,6 +502,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
if (carried) store.replace(carried);
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
restartPagePipeline(projectId);
|
||||
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
|
||||
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
|
||||
configureBalloonOffsets(
|
||||
@@ -601,6 +579,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
irregularStations = stations;
|
||||
base = listed();
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
restartPagePipeline(projectId);
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
||||
@@ -645,9 +624,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||
},
|
||||
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> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
noticeDroppedEdits(saved);
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
@@ -688,6 +682,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
drainagePanel?.load(projectId);
|
||||
},
|
||||
dispose() {
|
||||
disposePagePipeline(projectId);
|
||||
setListening(false);
|
||||
drag.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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
...(side
|
||||
? {
|
||||
pipes: async () => {
|
||||
await side.savePipes();
|
||||
await side.savePipes(confirm);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil
|
||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { currentRoute, remountRoute } from "../router";
|
||||
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 {
|
||||
readStructurePick,
|
||||
@@ -484,6 +485,10 @@ export async function mountProfileView(
|
||||
smooth: latestResponse.surface_params.smooth,
|
||||
tools: [view3d.modal.button],
|
||||
recommend: panel.recommend,
|
||||
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [노선 반영]이 정본).
|
||||
draft: routeDraftBridge(activeProjectId, profilePanel, {
|
||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
|
||||
@@ -510,6 +510,8 @@ export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_brid
|
||||
|
||||
export interface DetailPipePoint {
|
||||
chainage_m: number;
|
||||
/** 관 고정 id(347 덧 · 측점 열쇠 `pipe:<id>`) — 서버가 붙임 · 옮겨도 그대로. */
|
||||
id?: string;
|
||||
lonlat: [number, number];
|
||||
source: PipeSource;
|
||||
/** 시설 종류 — 응답에서 생략되면 기본 배관. */
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
* GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m)
|
||||
* POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
* POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠)
|
||||
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
*
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
|
||||
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
|
||||
*
|
||||
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
|
||||
@@ -260,21 +259,3 @@ export async function resetRoutePlan(projectId: string): Promise<RouteReplanResp
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** 지금 노드의 **균형 종단**(PLAN 75-1) — [확인]과 같은 폴리라인 · 같은 셈. 파일은 안 쓴다.
|
||||
* 답의 `profile` 은 추천 창과 같은 꼴(`parseBalance` 로 편다). */
|
||||
export async function fetchProfilePreview(
|
||||
projectId: string,
|
||||
vertices: RouteReplanVertex[],
|
||||
criteria: unknown,
|
||||
): Promise<unknown> {
|
||||
const payload = await requestJson<{ profile: unknown }>(
|
||||
`/projects/${projectId}/route/profile-preview`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }),
|
||||
},
|
||||
120000,
|
||||
);
|
||||
return payload.profile;
|
||||
}
|
||||
|
||||
@@ -284,10 +284,10 @@ async def _carry_cross(
|
||||
|
||||
new_long = _read_json(project_root / relative)
|
||||
_, new_structures = load_structures(str(project_root))
|
||||
old_keys = station_keys(
|
||||
old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m
|
||||
old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m)
|
||||
new_keys = station_keys(
|
||||
new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m
|
||||
)
|
||||
new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m)
|
||||
designs = {
|
||||
round(float(row["chainage_m"]), 3): row["design"]
|
||||
for row in old_designs
|
||||
|
||||
@@ -8,9 +8,9 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
(사용자 관 편집 지킴).
|
||||
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음).
|
||||
- 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음.
|
||||
- 측점 열쇠(계약 `design_pipeline_types.ts` 머리) = 일반 `grid:k`(누가거리 ÷ 측점 간격 반올림) ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>`(구간 시작 · 끝은 `:start` · `:end`) · 시종점
|
||||
`end:BP` · `end:EP`. 관 id = 저장 파일 차례(노선 저장 한 번 안에서 옛 · 새가 같음).
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
|
||||
id 없으면 `at:<누가거리 0.01 m>`.
|
||||
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면
|
||||
`STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
|
||||
「노선 밖 고침」.
|
||||
@@ -32,6 +32,8 @@ from common_util.common_util_drainage_pipes import (
|
||||
project_pipe_points,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
from common_util.common_util_station_key import chainage_key as station_chainage_key
|
||||
from common_util.common_util_station_key import station_key
|
||||
from common_util.common_util_structure_station import plan_stations, snap_structure_station
|
||||
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
|
||||
|
||||
@@ -59,7 +61,7 @@ def vertices_of(xy: Sequence[Sequence[float]]) -> list[RouteVertex]:
|
||||
def follow_pipes(
|
||||
old_pipes: list[PipePoint], vertices: list[RouteVertex]
|
||||
) -> list[tuple[int, PipePoint]]:
|
||||
"""관이 새 노선 좌표를 따라감 → [(관 id = 옛 차례, 새 관)] 누가거리 순 · 옛 목록 그대로."""
|
||||
"""관이 새 노선 좌표를 따라감 → [(옛 차례 · 더한 관 -1, 새 관)] 누가거리 순 · 옛 목록 그대로."""
|
||||
pairs = [(index, copy.deepcopy(pipe)) for index, pipe in enumerate(old_pipes)]
|
||||
for _, pipe in pairs:
|
||||
project_pipe_points([pipe], vertices)
|
||||
@@ -145,49 +147,54 @@ def fill_concave_pipes(
|
||||
added += 1
|
||||
|
||||
|
||||
def _facility_keys(
|
||||
pipes: Sequence[tuple[int, PipePoint]], structures: Sequence[Any]
|
||||
) -> dict[float, str]:
|
||||
"""측점 자리(0.01 m) → 관 · 구조물 열쇠. 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
|
||||
keys: dict[float, str] = {}
|
||||
|
||||
def put(marks: list[dict[str, object]], head: str) -> None:
|
||||
for mark in marks:
|
||||
role = str(mark.get("role") or "anchor")
|
||||
keys.setdefault(
|
||||
round(float(mark["chainage_m"]), 2), head if role == "anchor" else f"{head}:{role}"
|
||||
def _facility_owners(
|
||||
pipes: Sequence[PipePoint], structures: Sequence[Any]
|
||||
) -> dict[float, tuple[str, str | None]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
|
||||
owners: dict[float, tuple[str, str | None]] = {}
|
||||
for pipe in pipes:
|
||||
for mark in plan_stations([pipe], []):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2), ("pipe", getattr(pipe, "id", None))
|
||||
)
|
||||
|
||||
for pipe_id, pipe in pipes:
|
||||
if pipe_id >= 0:
|
||||
put(plan_stations([pipe], []), f"pipe:{pipe_id}")
|
||||
for item in structures:
|
||||
if getattr(item, "structure_id", None):
|
||||
put(plan_stations([], [item]), f"structure:{item.structure_id}")
|
||||
return keys
|
||||
for mark in plan_stations([], [item]):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("structure", getattr(item, "structure_id", None)),
|
||||
)
|
||||
return owners
|
||||
|
||||
|
||||
def station_keys(
|
||||
stations: Sequence[dict[str, Any]],
|
||||
pipes: Sequence[tuple[int, PipePoint]],
|
||||
pipes: Sequence[PipePoint],
|
||||
structures: Sequence[Any],
|
||||
interval_m: float,
|
||||
) -> dict[str, float]:
|
||||
"""측점 열쇠 → 누가거리. 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만)."""
|
||||
facility = _facility_keys(pipes, structures)
|
||||
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
|
||||
|
||||
관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
|
||||
같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음).
|
||||
"""
|
||||
owners = _facility_owners(pipes, structures)
|
||||
values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
|
||||
out: dict[str, float] = {}
|
||||
for index, chainage in enumerate(values):
|
||||
key = facility.get(round(chainage, 2))
|
||||
if key is None and index == 0:
|
||||
key = "end:BP"
|
||||
elif key is None and index == len(values) - 1:
|
||||
key = "end:EP"
|
||||
elif key is None and interval_m > 0:
|
||||
k = round(chainage / interval_m)
|
||||
key = f"grid:{k}" if abs(chainage - k * interval_m) < _SAME_M else None
|
||||
if key is not None:
|
||||
out.setdefault(key, chainage)
|
||||
owner = owners.get(round(chainage, 2))
|
||||
if owner is not None:
|
||||
key = station_key(owner[0], chainage, interval_m, owner[1])
|
||||
elif index == 0:
|
||||
key = station_key("bp", chainage, interval_m)
|
||||
elif index == len(values) - 1:
|
||||
key = station_key("ep", chainage, interval_m)
|
||||
elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M:
|
||||
key = station_key("regular", chainage, interval_m)
|
||||
else:
|
||||
continue
|
||||
if key in out:
|
||||
key = f"at:{station_chainage_key(chainage)}"
|
||||
out.setdefault(key, chainage)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ import functools
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice
|
||||
@@ -277,19 +276,12 @@ async def balanced_profile_of(
|
||||
points: list[dict[str, float]],
|
||||
criteria: Any,
|
||||
design_curves: list[dict[str, Any]] | None = None,
|
||||
terrain: Literal["las", "sheet"] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.
|
||||
|
||||
`terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인).
|
||||
"""
|
||||
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 몫(지금 설계 지형)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
|
||||
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
analysis_terrain_params,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
|
||||
@@ -299,9 +291,8 @@ async def balanced_profile_of(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
selection = analysis_terrain_params(selection, terrain)
|
||||
try:
|
||||
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값
|
||||
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님)
|
||||
surface = await asyncio.to_thread(
|
||||
build_surface_sampler,
|
||||
project_root / _MODELS_SUBDIR,
|
||||
@@ -358,47 +349,3 @@ async def refresh_initial_snapshot(
|
||||
logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id)
|
||||
except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다
|
||||
logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
class ProfilePreviewRequest(BaseModel):
|
||||
"""편집 창 종단 띠(PLAN 75-1) — 지금 노드 · 지금 추천 제약."""
|
||||
|
||||
vertices: list[replan.RouteVertexInput] = Field(..., min_length=2)
|
||||
criteria: replan.RouteInitialCriteria | None = None
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
|
||||
|
||||
@replan.router.post("/{project_id}/route/profile-preview", response_model=None)
|
||||
async def profile_preview(
|
||||
project_id: UUID, request: ProfilePreviewRequest
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""지금 노드의 균형 종단 — [확인]과 같은 폴리라인(같은 R · CSV 자릿수) · 같은 셈.
|
||||
|
||||
파일 · DB 를 쓰지 않는다. 「종단 반영」 켬일 때 편집 창 띠가 노드를 놓을 때마다 부른다.
|
||||
"""
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
paths = await replan._project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
|
||||
project_root, _ = paths
|
||||
radius_m, _, _ = await replan._plan_criteria(project_id)
|
||||
built = build_planned_polyline(
|
||||
[(v.x, v.y) for v in request.vertices],
|
||||
min_radius_m=radius_m,
|
||||
simplify=False,
|
||||
curve_flags=[bool(v.curve) for v in request.vertices],
|
||||
radii=[v.radius_m for v in request.vertices],
|
||||
)
|
||||
# [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로.
|
||||
points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices]
|
||||
curves = [curve.as_dict() for curve in built.curves]
|
||||
profile = await balanced_profile_of(
|
||||
project_id, project_root, points, request.criteria, curves, request.terrain
|
||||
)
|
||||
if profile is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"status": "error", "message": "이 노선으로 균형 종단을 세우지 못했습니다."},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), "profile": profile}
|
||||
|
||||
@@ -358,6 +358,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
response.pipe_points.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
reason: pipe.source,
|
||||
id: pipe.id,
|
||||
})),
|
||||
);
|
||||
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
|
||||
@@ -395,6 +396,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
...(pipe.id ? { id: pipe.id } : {}),
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -354,9 +354,16 @@ export function reconcilePipes(
|
||||
if (next.length === current.length && next.every((value, index) => value === current[index])) {
|
||||
return null;
|
||||
}
|
||||
const near = (a: number, b: number): boolean => Math.abs(a - b) < PIPE_MATCH_M;
|
||||
// 사라진 관 하나 · 새 관 하나 = 옮긴 것 — 고정 id 를 따라 옮김(시설 속성 `follow` 와 같은 규칙)
|
||||
const gone = before.filter((pipe) => !next.some((value) => near(pipe.chainage_m, value)));
|
||||
const added = next.filter((value) => !before.some((pipe) => near(pipe.chainage_m, value)));
|
||||
return next.map((chainage) => {
|
||||
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M);
|
||||
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
|
||||
const matched =
|
||||
before.find((pipe) => near(pipe.chainage_m, chainage)) ??
|
||||
(gone.length === 1 && added.length === 1 && added[0] === chainage ? gone[0] : undefined);
|
||||
const reason = matched && near(matched.chainage_m, chainage) ? matched.reason : "confirmed";
|
||||
return { chainage_m: chainage, reason, ...(matched?.id ? { id: matched.id } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { snapStructureStation } from "../common_util/common_util_structure_stati
|
||||
export interface PipePoint {
|
||||
chainage_m: number;
|
||||
reason: string;
|
||||
/** 관 고정 id(347 덧 · 측점 열쇠 `pipe:<id>`) — 옮겨도 그대로 · 새 관은 없음(서버가 붙임). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface RoutePointLike {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
captureView,
|
||||
createViewMemory,
|
||||
fitCanvas,
|
||||
fitView,
|
||||
openingView,
|
||||
restoreSavedView,
|
||||
screenToMetric,
|
||||
@@ -68,7 +69,6 @@ import {
|
||||
writeRouteLocks,
|
||||
} from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
type RouteEditSnapshot,
|
||||
} from "./B05_Profile_UI_RouteEdit_History";
|
||||
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";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -106,6 +107,7 @@ export interface RouteEditOptions {
|
||||
tools?: HTMLElement[];
|
||||
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
|
||||
recommend: InitialPanelParams["controls"];
|
||||
draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`)
|
||||
}
|
||||
|
||||
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
|
||||
@@ -277,22 +279,6 @@ export async function mountRouteEditor(
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
/** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */
|
||||
const strip = createProfileStrip({
|
||||
root: overlay.querySelector<HTMLElement>("[data-strip]")!,
|
||||
projectId,
|
||||
withProfile: initial.withProfile,
|
||||
now: () => ({
|
||||
vertices: planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
line: plannedLine.length ? plannedLine : planned,
|
||||
criteria: initial.current().criteria,
|
||||
}),
|
||||
});
|
||||
// [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
|
||||
const snapshot = history?.reset();
|
||||
@@ -323,17 +309,21 @@ export async function mountRouteEditor(
|
||||
rotation.set(opening.rotationRad);
|
||||
viewReady = true;
|
||||
}
|
||||
// [맞춤] — 노선 전체를 뜬 종단 판 위 보이는 영역에(회전 0 · 345-5 덧 2). 셈은 `_View` 몫.
|
||||
overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => {
|
||||
if (!meta || view.width <= 0 || !planned.length) return;
|
||||
view = fitView(meta, view, planned);
|
||||
rotation.set(0);
|
||||
draw();
|
||||
});
|
||||
|
||||
const toScreen = (vertex: Vertex): [number, number] =>
|
||||
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
||||
|
||||
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
|
||||
const toMetric = (px: number, py: number): Vertex =>
|
||||
meta ? screenToMetric(meta, view, px, py) : [0, 0];
|
||||
|
||||
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
|
||||
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
|
||||
|
||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
||||
const contourStepM = (): number =>
|
||||
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
||||
@@ -463,7 +453,6 @@ export async function mountRouteEditor(
|
||||
draw();
|
||||
if (record) history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
strip.refresh();
|
||||
}
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
@@ -544,7 +533,6 @@ export async function mountRouteEditor(
|
||||
commit: () => {
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
strip.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -594,7 +582,7 @@ export async function mountRouteEditor(
|
||||
terrain: pickedTerrain,
|
||||
stationSnap: () => stationSnap,
|
||||
initial,
|
||||
profileShown: strip.settle,
|
||||
profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함
|
||||
onApplied,
|
||||
});
|
||||
|
||||
@@ -631,6 +619,8 @@ export async function mountRouteEditor(
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
history = draftLink.watch(history, options.draft, () => stationSnap);
|
||||
draftLink.restore(options.draft, restoreSnapshot);
|
||||
historyControls.sync();
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
@@ -682,7 +672,6 @@ export async function mountRouteEditor(
|
||||
useBackground(background);
|
||||
measure.clear();
|
||||
slope.refresh();
|
||||
strip.refresh();
|
||||
void crossPreview.refresh();
|
||||
showStatus();
|
||||
draw();
|
||||
@@ -692,7 +681,6 @@ export async function mountRouteEditor(
|
||||
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
|
||||
showStatus();
|
||||
draw();
|
||||
strip.refresh();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
|
||||
}
|
||||
|
||||
@@ -59,12 +59,14 @@ export function createRouteEditChrome(
|
||||
<span data-slot="tools"></span>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 종단 띠 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
<option value="las">라이다</option>
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="fit"
|
||||
title="노선 전체를 종단 판 위 보이는 영역에 맞춥니다 (회전은 풂)">맞춤</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
@@ -112,12 +114,6 @@ export function createRouteEditChrome(
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__strip" data-strip>
|
||||
<div class="b05-routeedit__strip-head">
|
||||
<span data-strip-note>종단을 읽는 중…</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__strip-graph" data-strip-graph></div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>
|
||||
<aside class="b05-routeedit__side-wrap">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cover.ts
|
||||
* 노선 편집 평면 아래를 **뜬 종단 판**이 덮은 높이를 잰다(345-5 덧).
|
||||
*
|
||||
* 칸 · 판 크기가 바뀔 때마다(창 · 판 높이 끌기 · 접기 · 새 창 떼기) 다시 재서
|
||||
* 칸에 `--b05-routeedit-cover`(px)를 적는다 — 상태 · 범례 · 잰 값 글판이 판 바로 위로 오름 ·
|
||||
* 곡선 바는 `onCoverChange` 로 다시 자리 잡음.
|
||||
* 맞춤(`_View.fitView`)은 `coveredPx()` 를 읽는다. 셈은 `logic/_RouteEdit_Visible`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { coveredBottomPx } from "./logic/B05_Profile_RouteEdit_Visible";
|
||||
|
||||
// ponytail: 편집기는 페이지에 하나 — 덮인 높이 한 값 · 둘이 되면 칸마다 값으로.
|
||||
let covered = 0;
|
||||
const watching = new WeakSet<HTMLElement>();
|
||||
const listeners = new Set<() => boolean>();
|
||||
|
||||
/** 지금 평면 아래 덮인 높이(px). */
|
||||
export function coveredPx(): number {
|
||||
return covered;
|
||||
}
|
||||
|
||||
/** 덮인 높이가 바뀌면 부름(곡선 바 자리 · 345-5 덧 2) — `false` 를 돌려주면 뗌(떠난 편집기). */
|
||||
export function onCoverChange(listener: () => boolean): void {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
/** 같은 가운데 칸의 종단 판 — 새 창으로 떼면 없음. */
|
||||
function panelOf(wrap: HTMLElement): HTMLElement | null {
|
||||
return (
|
||||
wrap.closest(".design-page__main")?.querySelector<HTMLElement>(".b05-route-profile") ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function span(element: HTMLElement): { top: number; bottom: number } {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { top: rect.top, bottom: rect.bottom };
|
||||
}
|
||||
|
||||
function measure(wrap: HTMLElement, panel: HTMLElement | null): void {
|
||||
const before = covered;
|
||||
covered = coveredBottomPx(span(wrap), panel ? span(panel) : null);
|
||||
wrap.style.setProperty("--b05-routeedit-cover", `${covered}px`);
|
||||
if (covered !== before)
|
||||
for (const listener of listeners) if (!listener()) listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** 칸을 지켜본다(여러 번 불러도 한 번만 붙음) · 부를 때마다 한 번 잰다. */
|
||||
export function watchCover(wrap: HTMLElement): void {
|
||||
if (watching.has(wrap)) return measure(wrap, panelOf(wrap));
|
||||
watching.add(wrap);
|
||||
let observed: HTMLElement | null = null;
|
||||
const watch = new ResizeObserver(() => {
|
||||
if (!wrap.isConnected) return watch.disconnect(); // 떠남
|
||||
const panel = panelOf(wrap);
|
||||
if (panel && panel !== observed) watch.observe((observed = panel));
|
||||
measure(wrap, panel);
|
||||
});
|
||||
watch.observe(wrap);
|
||||
measure(wrap, panelOf(wrap));
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createCurveLabel,
|
||||
type CurveLabel,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import { coveredPx, onCoverChange } from "./B05_Profile_UI_RouteEdit_Cover";
|
||||
|
||||
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
||||
export interface CurveBarState {
|
||||
@@ -145,7 +146,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
// 뜬 종단 판 밑은 안 보임 — 판 바로 위까지(345-5 덧 2 · 접으면 0).
|
||||
bottom: rect.bottom - 8 - coveredPx(),
|
||||
};
|
||||
const last = planned.length - 1;
|
||||
if (picked === 0 || picked === last) {
|
||||
@@ -202,5 +204,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
});
|
||||
}
|
||||
|
||||
// 판 끌기 · 접기 — 덮인 높이가 바뀌면 다시 자리 잡음(그리기 없이 · 떠나면 뗌).
|
||||
onCoverChange(() => (params.container.isConnected ? (sync(), true) : false));
|
||||
|
||||
return { label, sync };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_ProfileStrip.ts
|
||||
* 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 ·
|
||||
* 측점 눈금 한 줄.
|
||||
*
|
||||
* 보여 주기만 한다(PLAN 77-5) — 종단 여부는 지금 추천 갈래가 정한다(`withProfile`). 종단 갈래 =
|
||||
* 계획선은 지금 노드로 서버가 다시 낸 균형 종단(`/route/profile-preview` — [확인] 체인과 같은 셈).
|
||||
* 예상노선 추적 = 지반선만(계획고 = 지반고 · `/route/elevations`). [확인]도 같은 규칙을 탄다
|
||||
* (`_Apply` 의 `balanced_profile`).
|
||||
*
|
||||
* 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게.
|
||||
* 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchProfilePreview,
|
||||
fetchRouteElevations,
|
||||
type RouteReplanVertex,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { parseBalance, profileSvg } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
import { lengthOf, pointsAt, stationChainages } from "./B05_Profile_UI_RouteEdit_Slope";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 지반선만 그릴 때 측점 간격(m) — 균형 종단(서버 `STATION_M`)과 같음. */
|
||||
const STATION_M = 20;
|
||||
|
||||
export interface ProfileStripParams {
|
||||
root: HTMLElement;
|
||||
projectId: string;
|
||||
/** 지금 추천 갈래가 종단을 반영하는가(예상노선 추적만 아님). */
|
||||
withProfile: () => boolean;
|
||||
/** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */
|
||||
now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray<Vertex>; criteria: unknown };
|
||||
}
|
||||
|
||||
export interface ProfileStrip {
|
||||
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용) — 다시 묻는다. */
|
||||
refresh: () => void;
|
||||
/** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지
|
||||
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 갈래 값. */
|
||||
settle: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
|
||||
const { root, projectId } = params;
|
||||
const note = root.querySelector<HTMLElement>("[data-strip-note]")!;
|
||||
const graph = root.querySelector<HTMLElement>("[data-strip-graph]")!;
|
||||
let seq = 0;
|
||||
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 갈래 종단이면 다시 묻지 않는다. */
|
||||
let lastKey = "";
|
||||
/** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
let shownOn: boolean | null = null;
|
||||
|
||||
async function groundOnly(line: ReadonlyArray<Vertex>) {
|
||||
const stationsM = stationChainages(lengthOf(line), STATION_M);
|
||||
const z = await fetchRouteElevations(projectId, pointsAt(line, stationsM));
|
||||
const known = stationsM.flatMap((s, i) => (z[i] === null ? [] : [[s, z[i]!] as const]));
|
||||
if (known.length < 2) throw new Error("노선 위 지반고를 읽지 못했습니다.");
|
||||
return {
|
||||
stationsM: known.map(([s]) => s),
|
||||
groundM: known.map(([, h]) => h),
|
||||
planM: [] as number[],
|
||||
};
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const on = params.withProfile();
|
||||
const { vertices, line, criteria } = params.now();
|
||||
const key = JSON.stringify([on, vertices, criteria, analysisTerrain(projectId)]);
|
||||
if (vertices.length < 2 || line.length < 2 || key === lastKey) return;
|
||||
lastKey = key;
|
||||
pending = draw(on, vertices, line, criteria);
|
||||
}
|
||||
|
||||
async function draw(
|
||||
on: boolean,
|
||||
vertices: RouteReplanVertex[],
|
||||
line: ReadonlyArray<Vertex>,
|
||||
criteria: unknown,
|
||||
): Promise<void> {
|
||||
const ticket = ++seq;
|
||||
root.classList.add("is-waiting");
|
||||
note.textContent = "종단을 읽는 중…";
|
||||
try {
|
||||
const profile = on
|
||||
? parseBalance(await fetchProfilePreview(projectId, vertices, criteria))
|
||||
: await groundOnly(line);
|
||||
if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다.
|
||||
if (!profile) throw new Error("균형 종단을 세우지 못했습니다.");
|
||||
const total = profile.stationsM[profile.stationsM.length - 1];
|
||||
graph.innerHTML = profileSvg(profile, true);
|
||||
shownOn = on;
|
||||
note.textContent = `${on ? "원지반선 · 계획 종단선" : "원지반선(계획고 = 지반고)"} · 연장 ${total.toFixed(1)}m`;
|
||||
} catch (error) {
|
||||
if (ticket !== seq) return;
|
||||
lastKey = ""; // 실패 — 다음 번에 다시 묻는다.
|
||||
shownOn = null;
|
||||
graph.innerHTML = "";
|
||||
note.textContent = error instanceof Error ? error.message : "종단을 읽지 못했습니다.";
|
||||
} finally {
|
||||
if (ticket === seq) root.classList.remove("is-waiting");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
settle: async () => {
|
||||
refresh();
|
||||
await pending;
|
||||
return shownOn ?? params.withProfile();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -85,11 +85,9 @@ const PROFILE_H = 96;
|
||||
const PAD = 6;
|
||||
|
||||
/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림).
|
||||
* `ticks` 면 아래 가장자리에 측점 눈금(100 m 마다 길게) — 편집 창 종단 띠(PLAN 75-1).
|
||||
* 계획선이 비면(종단 반영 끔) 지반선만. */
|
||||
export function profileSvg(
|
||||
balance: Pick<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
|
||||
ticks = false,
|
||||
): string {
|
||||
if (!balance) return "";
|
||||
const { stationsM, groundM, planM } = balance;
|
||||
@@ -104,18 +102,8 @@ export function profileSvg(
|
||||
return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join("");
|
||||
const tickPath = ticks
|
||||
? stationsM
|
||||
.map((s) => {
|
||||
const x = (PAD + ((PROFILE_W - 2 * PAD) * s) / total).toFixed(1);
|
||||
const long = Math.abs(s / 100 - Math.round(s / 100)) < 1e-6;
|
||||
return `M${x} ${PROFILE_H}V${PROFILE_H - (long ? 7 : 3)}`;
|
||||
})
|
||||
.join("")
|
||||
: "";
|
||||
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
|
||||
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
|
||||
${ticks ? `<path class="is-tick" d="${tickPath}" vector-effect="non-scaling-stroke" />` : ""}
|
||||
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
|
||||
<path class="is-plan" d="${planM.length ? path(planM) : ""}" vector-effect="non-scaling-stroke" /></svg>`;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
computeMapRect,
|
||||
computeMaxScale,
|
||||
computeRouteView,
|
||||
metricToScreen,
|
||||
ROUTE_VIEW_MARGIN_M,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { coveredPx, watchCover } from "./B05_Profile_UI_RouteEdit_Cover";
|
||||
import { fitRouteToVisible } from "./logic/B05_Profile_RouteEdit_Visible";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -114,22 +116,45 @@ export function openingView(
|
||||
const reach = Math.min(view.width, view.height) / 2;
|
||||
const seen = nodes.some(([x, y]) => {
|
||||
const [px, py] = metricToScreen(meta, restored, x, y);
|
||||
return Math.hypot(px - view.width / 2, py - view.height / 2) < reach;
|
||||
// 뜬 종단 판 밑은 안 보임 — 판 위 영역 안만(345-5 덧).
|
||||
return (
|
||||
py < view.height - coveredPx() &&
|
||||
Math.hypot(px - view.width / 2, py - view.height / 2) < reach
|
||||
);
|
||||
});
|
||||
if (seen) return { view: restored, rotationRad };
|
||||
}
|
||||
return { view: fitView(meta, view, nodes), rotationRad: 0 };
|
||||
}
|
||||
|
||||
/** 노선 전체를 뜬 종단 판 위 보이는 영역 가운데에(회전 0 기준) — 처음 열기 · [맞춤](345-5 덧). */
|
||||
export function fitView(
|
||||
meta: VWorldMeta,
|
||||
view: ViewState,
|
||||
nodes: ReadonlyArray<Vertex>,
|
||||
): ViewState {
|
||||
const xs = nodes.map((vertex) => vertex[0]);
|
||||
const ys = nodes.map((vertex) => vertex[1]);
|
||||
const bounds = {
|
||||
x_min: Math.min(...xs),
|
||||
x_max: Math.max(...xs),
|
||||
y_min: Math.min(...ys),
|
||||
y_max: Math.max(...ys),
|
||||
};
|
||||
return {
|
||||
view: { ...view, ...computeRouteView(meta, bounds, view.width, view.height) },
|
||||
rotationRad: 0,
|
||||
};
|
||||
const [xMin, xMax, yMin, yMax] = [
|
||||
Math.min(...xs),
|
||||
Math.max(...xs),
|
||||
Math.min(...ys),
|
||||
Math.max(...ys),
|
||||
];
|
||||
// 노선 맞춤 = 뜬 종단 판 위 보이는 영역 가운데(345-5 덧 · 덮인 높이 0 이면 computeRouteView 와 같음).
|
||||
const fitted = fitRouteToVisible({
|
||||
mapWidthM: meta.width_meters,
|
||||
mapHeightM: meta.height_meters,
|
||||
mapRect: computeMapRect(meta, view.width, view.height),
|
||||
width: view.width,
|
||||
height: view.height,
|
||||
coveredPx: coveredPx(),
|
||||
centerXM: (xMin + xMax) / 2 - meta.x_min,
|
||||
centerYM: (yMin + yMax) / 2 - meta.y_min,
|
||||
wantWidthM: Math.max(xMax - xMin, 1) + ROUTE_VIEW_MARGIN_M * 2,
|
||||
wantHeightM: Math.max(yMax - yMin, 1) + ROUTE_VIEW_MARGIN_M * 2,
|
||||
});
|
||||
return { ...view, ...fitted };
|
||||
}
|
||||
|
||||
/** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */
|
||||
@@ -146,6 +171,8 @@ export function fitCanvas(
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
// 편집 평면만(추천 지도도 이 셈을 씀) — 뜬 종단 판이 덮은 높이 · 맞춤 · 글판 자리(345-5 덧).
|
||||
if (wrap.classList.contains("b05-routeedit__canvas-wrap")) watchCover(wrap);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
}
|
||||
|
||||
/* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다.
|
||||
페이지 안에서는 위 칸(평면 · 횡단 카드) 아래 **흐름**에 선다(`design_page.css` · 345-3) —
|
||||
여기 absolute 는 페이지 밖 쓰임 꼴. 접기 핸들로 내리면 위 칸이 전체 영역을 갖는다. */
|
||||
평면(노선 편집)을 밀어내지 않고 그 위를 덮는 오버레이로 띄운다(345-5) — 판을 여닫아도 평면
|
||||
크기가 안 바뀜 · 가려진 아래쪽은 팬으로 봄. 횡단 보기에서는 카드 칸 아래 흐름(`design_page.css`). */
|
||||
.b05-route-profile {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
|
||||
@@ -93,9 +93,10 @@
|
||||
max-width: 52%;
|
||||
}
|
||||
|
||||
/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */
|
||||
/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 판 바로 위(345-5 덧 ·
|
||||
`--b05-routeedit-cover` = `_RouteEdit_Cover` 가 잰 덮인 높이 · 접으면 0). */
|
||||
.b05-routeedit__info {
|
||||
bottom: var(--spacing-12);
|
||||
bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px));
|
||||
left: var(--spacing-12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -274,11 +275,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판 위 · 345-5 덧). 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
right: var(--spacing-12);
|
||||
bottom: var(--spacing-12);
|
||||
bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -399,59 +400,3 @@
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 지도 아래 종단 띠(PLAN 75-1) — 원지반선 · 계획선 · 측점 눈금 한 줄. 묻는 동안 옛 그림은 흐리게. */
|
||||
.b05-routeedit__strip {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__strip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 그림 칸 높이는 미리 잡아 둠 — 응답이 늦게 와도 띠 높이가 안 바뀌어 지도 크기 셈이 안 어긋남. */
|
||||
.b05-routeedit__strip-graph {
|
||||
flex: none;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.b05-routeedit__strip-graph svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.b05-routeedit__strip.is-waiting .b05-routeedit__strip-graph svg {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.b05-routeedit__strip-graph .is-ground {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-routeedit__strip-graph .is-plan {
|
||||
fill: none;
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b05-routeedit__strip-graph .is-tick {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_RouteEdit_Visible.ts
|
||||
* 노선 편집 평면의 **보이는 영역**(345-5 덧) — 아래 뜬 종단 판이 덮은 높이 · 그 위 영역에 노선 맞춤.
|
||||
*
|
||||
* 덮인 높이 = 칸 아래 끝 − 판 위 끝(0 ~ 칸 높이) · 판 없음 · 접힘(높이 0)이면 0.
|
||||
* 맞춤 = B04 `computeRouteView` 와 같은 공식 · 세로 몫만 보이는 높이 비율로 줄이고 가운데를 그 영역
|
||||
* 가운데로 · 덮인 높이 0 이면 `computeRouteView` 와 같은 값.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface VerticalSpan {
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** 칸(`wrap`) 아래쪽을 판(`panel`)이 덮은 높이(px). */
|
||||
export function coveredBottomPx(wrap: VerticalSpan, panel: VerticalSpan | null): number {
|
||||
if (!panel || panel.bottom - panel.top <= 1) return 0; // 없음 · 접힘(높이 0 + 테두리)
|
||||
const height = Math.max(wrap.bottom - wrap.top, 0);
|
||||
return Math.min(Math.max(wrap.bottom - panel.top, 0), height);
|
||||
}
|
||||
|
||||
export interface FitInput {
|
||||
/** 도엽 크기(m) — `meta.width_meters` · `height_meters`. */
|
||||
mapWidthM: number;
|
||||
mapHeightM: number;
|
||||
/** 배율 1 일 때 도엽이 놓이는 칸 안 사각형(`computeMapRect`). */
|
||||
mapRect: { x: number; y: number; width: number; height: number };
|
||||
/** 칸 크기(px) · 아래 덮인 높이(px). */
|
||||
width: number;
|
||||
height: number;
|
||||
coveredPx: number;
|
||||
/** 노선 가운데 · 크기(m) — 도엽 왼쪽 아래 기준 · 여유 포함. */
|
||||
centerXM: number;
|
||||
centerYM: number;
|
||||
wantWidthM: number;
|
||||
wantHeightM: number;
|
||||
}
|
||||
|
||||
/** 노선(+여유)이 보이는 영역 가운데 들도록 배율 · 이동량. */
|
||||
export function fitRouteToVisible(input: FitInput): {
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} {
|
||||
const { mapRect, width, height } = input;
|
||||
const ratio = height > 0 ? Math.max(height - input.coveredPx, 1) / height : 1;
|
||||
const fit = Math.min(
|
||||
input.mapWidthM / input.wantWidthM,
|
||||
(input.mapHeightM / input.wantHeightM) * ratio,
|
||||
);
|
||||
// 하한 1(빈 여백 없음 · computeRouteView) — 덮인 만큼 내려 줌(노선이 판 밑에 안 숨게).
|
||||
const scale = Math.max(fit, Math.min(1, ratio));
|
||||
const baseX = mapRect.x + (input.centerXM / input.mapWidthM) * mapRect.width;
|
||||
const baseY = mapRect.y + (1 - input.centerYM / input.mapHeightM) * mapRect.height;
|
||||
return {
|
||||
scale,
|
||||
offsetX: -(baseX - width / 2) * scale,
|
||||
offsetY: -(baseY - height / 2) * scale - input.coveredPx / 2,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +101,9 @@ class PipePoint:
|
||||
summary_item: dict[str, Any] | None = None
|
||||
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
|
||||
group_items: dict[str, Any] | None = None
|
||||
# 관 고정 id(347 덧) — 측점 열쇠 `pipe:<id>`(common_util_station_key) · 옮겨도 · 노선이
|
||||
# 바뀌어도 그대로 · 빈 id 는 `ensure_pipe_ids` 가 읽기 · 되붙이기 · 저장 때 채움.
|
||||
id: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
|
||||
@@ -112,6 +115,8 @@ class PipePoint:
|
||||
"chainage_m": round(float(self.chainage_m), 3),
|
||||
"source": self.source,
|
||||
}
|
||||
if self.id:
|
||||
payload["id"] = self.id
|
||||
if self.facility != PIPE_FACILITY_PIPE:
|
||||
payload["facility"] = self.facility
|
||||
if self.start_m is not None and self.end_m is not None:
|
||||
@@ -129,6 +134,26 @@ class PipePoint:
|
||||
return payload
|
||||
|
||||
|
||||
def ensure_pipe_ids(points: list[PipePoint]) -> list[PipePoint]:
|
||||
"""id 없는 관에 id 를 채운다(제자리) — `p<누가거리 cm>` · 겹치면 `-2` 꼬리.
|
||||
|
||||
누가거리로 지어 같은 자료를 두 번 읽어도 같은 id(저장 전 GET 을 되풀이해도 열쇠가 안 흔들림).
|
||||
한 번 붙은 id 는 관을 옮겨도 그대로다.
|
||||
"""
|
||||
taken = {point.id for point in points if point.id}
|
||||
for point in points:
|
||||
if point.id:
|
||||
continue
|
||||
base = f"p{int(round(float(point.chainage_m) * 100))}"
|
||||
candidate, order = base, 1
|
||||
while candidate in taken:
|
||||
order += 1
|
||||
candidate = f"{base}-{order}"
|
||||
point.id = candidate
|
||||
taken.add(candidate)
|
||||
return points
|
||||
|
||||
|
||||
def edits_dir(stored_path: str) -> Path:
|
||||
return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME
|
||||
|
||||
@@ -402,10 +427,11 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
),
|
||||
summary_item=parse_summary_item(item.get("summary_item")),
|
||||
group_items=parse_group_items(item.get("group_items")),
|
||||
id=str(item["id"]) if item.get("id") else None,
|
||||
)
|
||||
)
|
||||
points.sort(key=lambda point: point.chainage_m)
|
||||
return points
|
||||
return ensure_pipe_ids(points)
|
||||
|
||||
|
||||
# 시설 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
@@ -440,7 +466,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint])
|
||||
붙이기는 `carry_indices` — 허용 오차 밖의 관은 속성 없이 둔다(옆 관 속성을 가져가지 않음).
|
||||
"""
|
||||
if not reference:
|
||||
return base
|
||||
return ensure_pipe_ids(base)
|
||||
picks = carry_indices(
|
||||
[point.chainage_m for point in base], [ref.chainage_m for ref in reference]
|
||||
)
|
||||
@@ -454,7 +480,8 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint])
|
||||
point.options = ref.options
|
||||
point.summary_item = ref.summary_item
|
||||
point.group_items = ref.group_items
|
||||
return base
|
||||
point.id = ref.id
|
||||
return ensure_pipe_ids(base)
|
||||
|
||||
|
||||
def save_pipe_points(
|
||||
@@ -470,6 +497,7 @@ def save_pipe_points(
|
||||
"""
|
||||
if vertices:
|
||||
fill_pipe_coordinates(points, vertices)
|
||||
ensure_pipe_ids(points)
|
||||
path = pipe_points_path(stored_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(
|
||||
|
||||
@@ -96,6 +96,8 @@ out.viewOf = {
|
||||
alignment: null,
|
||||
buildAlignment: () => null,
|
||||
store: { edits: () => ({}) },
|
||||
projectId: "p",
|
||||
pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
|
||||
scheduleCrossPreview: () => s.previews++,
|
||||
draw: () => s.draws++,
|
||||
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,39 @@
|
||||
/* 347 덧 관 고정 id — `reconcilePipes`(되돌리기 · 구조물 폼으로 관 목록이 바뀔 때)가 id 를 따라 옮기는지.
|
||||
* `B05_Profile_UI_Drainage_Parts.ts` 를 그 자리에서 트랜스파일 · 그 밖 import 는 빈 껍데기(틀 = helper_281_1).
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const stub = new Proxy(function () {}, { get: () => stub, apply: () => stub });
|
||||
|
||||
function loadTs(file, real) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) =>
|
||||
real.some((name) => request.endsWith(name))
|
||||
? loadTs(path.join(path.dirname(file), `${request}.ts`), [])
|
||||
: stub;
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { reconcilePipes } = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts"),
|
||||
["design_selection"],
|
||||
);
|
||||
const before = [
|
||||
{ chainage_m: 100, reason: "stream", id: "a1" },
|
||||
{ chainage_m: 200, reason: "user", id: "b2" },
|
||||
];
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
same: reconcilePipes(before, [100, 200]),
|
||||
moved: reconcilePipes(before, [100, 250]),
|
||||
added: reconcilePipes(before, [100, 200, 300]),
|
||||
twoMoved: reconcilePipes(before, [150, 250]),
|
||||
}),
|
||||
);
|
||||
@@ -15,7 +15,6 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as initial
|
||||
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
SHEET_ANALYSIS_FILTER,
|
||||
@@ -143,8 +142,6 @@ def test_허용_목록_밖_거절(bad):
|
||||
terrain_router.CrossPreviewRequest(
|
||||
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
initial.ProfilePreviewRequest(vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], terrain=bad)
|
||||
with pytest.raises(ValidationError):
|
||||
replan.RouteInitialRequest(terrain=bad)
|
||||
|
||||
@@ -170,8 +167,8 @@ def test_화면_고르기_배선():
|
||||
assert '<option value="sheet">수치지형도</option>' in chrome
|
||||
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
|
||||
assert 'saved === "sheet" || saved === "las" ? saved : fallback' in api
|
||||
# 모달 분석 넷이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
|
||||
assert api.count("terrain: analysisTerrain(projectId)") == 3
|
||||
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
|
||||
assert api.count("terrain: analysisTerrain(projectId)") == 2
|
||||
assert "terrain: analysisTerrain(projectId)" in initial_api
|
||||
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
|
||||
assert "terrain" not in replan_fn
|
||||
|
||||
@@ -115,7 +115,19 @@ def test_POST_빈_목록은_관_0_유역_0(api) -> None:
|
||||
assert len(_file_points(api)) > 0 # 계산만 · 저장 안 함
|
||||
|
||||
|
||||
def _fresh_analysis(api) -> None:
|
||||
"""사본의 배수유역 분석에 지금 노선 지문을 찍음 — 옛 분석(지문 없음)은 낡음(342-3)."""
|
||||
signature = api.get(f"{BASE}/drainage/pipe-points").json()["route_signature"]
|
||||
path = api.root / "B04_PreProcess/drainage/00_watershed_response.json"
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
path.write_text(json.dumps({**saved, "route_signature": signature}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_칸_없음_파일_없음_DELETE_노선_바뀜은_자동_배치(api) -> None:
|
||||
# 옛 분석(노선 지문 없음) = 낡음 — 관 목록이 없으면 자동 배치 대신 409(342-3 지문)
|
||||
stale = api.post(f"{BASE}/drainage/detail-basins", json={})
|
||||
assert stale.status_code == 409 and stale.json()["status"] == "stale"
|
||||
_fresh_analysis(api)
|
||||
auto = api.post(f"{BASE}/drainage/detail-basins", json={}).json()["pipe_points"]
|
||||
assert len(auto) > 0 # 요청에 칸 없음 = 자동 배치
|
||||
api.put(f"{BASE}/drainage/pipe-points", json={"points": []})
|
||||
|
||||
@@ -78,11 +78,14 @@ def test_편집기는_본문_페이지_수명():
|
||||
assert len(view.splitlines()) <= 700
|
||||
|
||||
|
||||
def test_종단도_판은_두_보기_모두_흐름():
|
||||
def test_종단도_판은_횡단_보기만_흐름():
|
||||
"""345-5 — 종단 보기는 다시 뜬 판(평면 위 오버레이) · 횡단 보기만 흐름."""
|
||||
css = _read("A00_Common/design/design_page.css")
|
||||
rule = _block(css, ".design-page__main > .design-page__profile > .b05-route-profile")
|
||||
rule = _block(
|
||||
css, '.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile'
|
||||
)
|
||||
assert "position: relative;" in rule and "flex: none;" in rule
|
||||
assert '[data-view="cross"] > .design-page__profile > .b05-route-profile' not in css
|
||||
assert "\n.design-page__main > .design-page__profile > .b05-route-profile" not in css
|
||||
|
||||
|
||||
def test_계획노선_칸_편집_단추_지움():
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""345-5 사용자 의견(요구 233) — 종단 보기 평면 = 가운데 전체 영역 · 아래 종단 판 = 뜬 판 ·
|
||||
노선 편집 안 「원지반선 · 계획 종단선」 띠 없음 · 아래 종단 판 종단 그래프는 그대로.
|
||||
|
||||
꼴은 글로 봄(화면 확인은 브레인).
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_345_5_plan_full_overlay.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
found = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.M)
|
||||
assert found, selector
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def test_평면은_가운데_전체_영역():
|
||||
page = _read("A00_Common/design/design_page.css")
|
||||
main = _block(page, ".design-page__main")
|
||||
assert "position: relative;" in main and "height: 100%;" in main
|
||||
assert "flex: 1 1 auto;" in _block(page, ".design-page__center")
|
||||
# 종단 보기에서 판을 흐름에 세우는 규칙 없음 — 가운데가 위아래 끝까지
|
||||
flow = re.findall(r"^(\.design-page__main[^{\n]*\.b05-route-profile) \{", page, re.M)
|
||||
assert flow == [
|
||||
'.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile'
|
||||
]
|
||||
route = _read("B05_Profile/B05_Profile_UI_Style_RouteEdit.css")
|
||||
assert "flex: 1 1 auto;" in _block(route, ".b05-routeedit")
|
||||
assert "flex: 1 1 auto;" in _block(route, ".b05-routeedit__canvas-wrap")
|
||||
# 캔버스 크기 = 칸 크기(ResizeObserver) · 평면은 가운데 칸에 붙음
|
||||
editor = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts")
|
||||
assert "new ResizeObserver(() => resize())" in editor
|
||||
assert (
|
||||
'sizeWatch.observe(overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!)'
|
||||
in editor
|
||||
)
|
||||
view = _read("A00_Common/design/design_view_profile.ts")
|
||||
assert "center.append(mainContent);" in view
|
||||
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
|
||||
|
||||
|
||||
def test_종단_판은_평면_위_뜬_판():
|
||||
style = _read("B05_Profile/B05_Profile_UI_Style.css")
|
||||
panel = _block(style, ".b05-route-profile")
|
||||
for line in ("position: absolute;", "z-index: 3;", "bottom: 0;", "left: 0;", "right: 0;"):
|
||||
assert line in panel
|
||||
# 끌어 높이 · 접기 · 새 창 떼기 그대로
|
||||
assert "height: var(--b05-profile-height, 60dvh);" in panel
|
||||
assert ".b05-route-profile.is-collapsed > .ui-resizer" in style
|
||||
page = _read("A00_Common/design/design_page.css")
|
||||
assert "position: relative;" in _block(page, ".b05-route-profile.is-popout")
|
||||
|
||||
|
||||
def test_띠_없음_종단_그래프_있음():
|
||||
chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert "data-strip" not in chrome
|
||||
assert not (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts").exists()
|
||||
# 아래 종단 판(종단 그래프 · 표 · 유토곡선)은 그대로 세움
|
||||
view = _read("A00_Common/design/design_view_profile.ts")
|
||||
assert "const profilePanel = createRouteProfilePanel(" in view
|
||||
assert (ROOT / "B05_Profile/B05_Profile_UI_Profile_Panel.ts").exists()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""345-5 덧 — 노선 편집 평면의 보이는 영역(뜬 종단 판 위) · 노선 맞춤 · 글판 · 곡선 바 자리.
|
||||
|
||||
`logic/B05_Profile_RouteEdit_Visible.ts` 를 tsc 로 옮겨 node 로 돌림 · 배선 · CSS 는 글로 봄.
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_345_5b_plan_visible_area.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
TS_FILE = B05 / "logic" / "B05_Profile_RouteEdit_Visible.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
# 도엽 1000 × 800 m · 칸 1200 × 900 px · 노선 가운데 (500, 400) · 크기 300 × 200 m(+여유 200 m)
|
||||
_FIT = {
|
||||
"mapWidthM": 1000,
|
||||
"mapHeightM": 800,
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"centerXM": 500,
|
||||
"centerYM": 400,
|
||||
"wantWidthM": 700,
|
||||
"wantHeightM": 600,
|
||||
}
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as visible from "./B05_Profile/logic/B05_Profile_RouteEdit_Visible.js";
|
||||
const { coveredBottomPx, fitRouteToVisible } = visible;
|
||||
const fit = JSON.parse(process.argv[3]);
|
||||
const rect = { x: 37.5, y: 0, width: 1125, height: 900 };
|
||||
const at = (coveredPx) => fitRouteToVisible({ ...fit, mapRect: rect, coveredPx });
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
cover: [
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, null),
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, { top: 360, bottom: 900 }),
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, { top: 500, bottom: 900 }),
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, { top: 899, bottom: 900 }),
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, { top: -50, bottom: 900 }),
|
||||
coveredBottomPx({ top: 0, bottom: 900 }, { top: 950, bottom: 1300 }),
|
||||
],
|
||||
none: at(0),
|
||||
half: at(450),
|
||||
most: at(880),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--rootDir",
|
||||
str(ROOT),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result), json.dumps(_FIT)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _screen_center(fit: dict) -> tuple[float, float]:
|
||||
"""노선 가운데의 화면 자리 — B04 `createMetricProjector` 와 같은 식."""
|
||||
rect = {"x": 37.5, "y": 0, "width": 1125, "height": 900}
|
||||
s = fit["scale"]
|
||||
nx = _FIT["centerXM"] / _FIT["mapWidthM"]
|
||||
ny = 1 - _FIT["centerYM"] / _FIT["mapHeightM"]
|
||||
x = rect["x"] * s + _FIT["width"] / 2 * (1 - s) + fit["offsetX"] + nx * rect["width"] * s
|
||||
y = rect["y"] * s + _FIT["height"] / 2 * (1 - s) + fit["offsetY"] + ny * rect["height"] * s
|
||||
return x, y
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_덮인_높이와_보이는_영역_맞춤(tmp_path: Path) -> None:
|
||||
out = _run(tmp_path)
|
||||
# 판 없음 · 판 높이 540 · 400 · 접힘(테두리만) · 칸보다 큼(칸 높이까지) · 칸 밖
|
||||
assert out["cover"] == [0, 540, 400, 0, 900, 0]
|
||||
# 덮인 높이 0 = B04 computeRouteView 와 같음(배율 = min(1000/700, 800/600) · 가운데 = 칸 가운데)
|
||||
none = out["none"]
|
||||
assert none["scale"] == pytest.approx(min(1000 / 700, 800 / 600))
|
||||
assert _screen_center(none) == pytest.approx((600, 450))
|
||||
# 아래 반을 덮음 — 노선 가운데 = 보이는 영역(0 ~ 450) 가운데 · 노선 높이가 그 영역 안
|
||||
half = out["half"]
|
||||
assert _screen_center(half) == pytest.approx((600, 225))
|
||||
route_px = _FIT["wantHeightM"] * (900 / 800) * half["scale"]
|
||||
assert route_px <= 450 + 1e-6
|
||||
# 거의 다 덮음 — 남은 20 px 에 들게 줄임 · 가운데는 남은 20 px 가운데
|
||||
most = out["most"]
|
||||
assert most["scale"] == pytest.approx((800 / 600) * (20 / 900))
|
||||
assert _screen_center(most)[1] == pytest.approx(10)
|
||||
|
||||
|
||||
def test_배선과_글판_자리() -> None:
|
||||
view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8")
|
||||
assert "coveredPx: coveredPx()," in view and "computeRouteView(" not in view
|
||||
assert 'wrap.classList.contains("b05-routeedit__canvas-wrap")) watchCover(wrap);' in view
|
||||
cover = (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").read_text(encoding="utf-8")
|
||||
assert 'querySelector<HTMLElement>(".b05-route-profile")' in cover
|
||||
assert 'setProperty("--b05-routeedit-cover"' in cover
|
||||
css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8")
|
||||
# 상태 · 범례 · 잰 값 글판 = 판 바로 위(접으면 덮인 높이 0 = 원래 자리)
|
||||
assert css.count("bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px));") == 2
|
||||
# 접힌 판은 높이 0 — 덮인 높이도 0 이 됨
|
||||
style = (B05 / "B05_Profile_UI_Style.css").read_text(encoding="utf-8")
|
||||
assert ".b05-route-profile.is-collapsed {\n height: 0;" in style
|
||||
|
||||
|
||||
def test_곡선_바는_판_위_맞춤_단추():
|
||||
"""345-5 덧 2 — 곡선 바 아래 한계 = 판 바로 위 · 판 끌기 · 접기 따라감 · [맞춤] 단추."""
|
||||
bar = (B05 / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text(encoding="utf-8")
|
||||
assert "bottom: rect.bottom - 8 - coveredPx()," in bar
|
||||
assert "onCoverChange(() => (params.container.isConnected ? (sync(), true) : false));" in bar
|
||||
cover = (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").read_text(encoding="utf-8")
|
||||
assert "if (covered !== before)" in cover and "listeners.delete(listener)" in cover
|
||||
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
|
||||
assert 'data-act="fit"' in chrome and ">맞춤</button>" in chrome
|
||||
editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
at = editor.index("""overlay.querySelector('[data-act="fit"]')!""")
|
||||
handler = editor[at : at + 260]
|
||||
assert "view = fitView(meta, view, planned);" in handler and "rotation.set(0);" in handler
|
||||
assert len(editor.splitlines()) <= 700
|
||||
view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8")
|
||||
assert "return { view: fitView(meta, view, nodes), rotationRad: 0 };" in view
|
||||
@@ -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 # 격자 있으면 서버 길 안 탐
|
||||
@@ -131,31 +131,36 @@ def _structure(sid, type_id, anchor, start=None, end=None):
|
||||
|
||||
|
||||
def test_측점_열쇠_규칙():
|
||||
stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)]
|
||||
pipes = [(3, PipePoint(chainage_m=50.0))]
|
||||
"""`common_util_station_key` 한 벌 — 관 · 구조물 id · id 없으면 at: · 같은 열쇠 둘째는 at:."""
|
||||
stations = [{"chainage_m": s} for s in (0.0, 20.0, 33.3, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)]
|
||||
with_id = PipePoint(chainage_m=50.0)
|
||||
with_id.id = "p-7" # sub3 이 넣는 관 id
|
||||
no_id = PipePoint(chainage_m=33.3)
|
||||
structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)]
|
||||
keys = station_keys(stations, pipes, structures, 20.0)
|
||||
keys = station_keys(stations, [with_id, no_id], structures, 20.0)
|
||||
assert keys == {
|
||||
"end:BP": 0.0,
|
||||
"grid:1": 20.0,
|
||||
"at:33.30": 33.3, # id 없는 관
|
||||
"grid:2": 40.0,
|
||||
"pipe:3": 50.0,
|
||||
"structure:w1:start": 60.0,
|
||||
"structure:w1": 77.0,
|
||||
"structure:w1:end": 80.0,
|
||||
"pipe:p-7": 50.0,
|
||||
"structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐
|
||||
"at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at:
|
||||
"at:80.00": 80.0,
|
||||
"end:EP": 95.5,
|
||||
}
|
||||
assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음
|
||||
|
||||
|
||||
def test_이월은_열쇠로_누가거리가_밀려도():
|
||||
old_keys = {
|
||||
"end:BP": 0.0,
|
||||
"grid:1": 20.0,
|
||||
"pipe:0": 50.0,
|
||||
"pipe:p-7": 50.0,
|
||||
"structure:w1": 77.0,
|
||||
"grid:5": 100.0,
|
||||
}
|
||||
new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0}
|
||||
new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:p-7": 57.0, "structure:w1": 84.0}
|
||||
designs = {
|
||||
0.0: {"ground_type": "soil"},
|
||||
20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""347 덧 관 고정 id — 측점 열쇠 `pipe:<id>` · 옮겨도 · 노선이 바뀌어도 · 다시 셈에도 그대로.
|
||||
|
||||
- 옛 자료(id 없음)는 읽을 때 `p<누가거리 cm>` · 두 번 읽어도 같음 · 다음 저장에 남음.
|
||||
- 세부유역 다시 셈(계산기 왕복)은 허용 오차 안 원본 id 를 되붙임 · 새 자동 관 = 새 id · 겹치면 꼬리.
|
||||
- 노선 바뀜(좌표 투영)에도 id 유지 · 측점 열쇠 = pipe:<id>(common_util_station_key).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
||||
from common_util import common_util_drainage_pipes as pipes_mod
|
||||
from common_util.common_util_drainage_detail import DrainageDetail
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PipePoint,
|
||||
carry_facility_attributes,
|
||||
ensure_pipe_ids,
|
||||
parse_pipe_points,
|
||||
project_pipe_points,
|
||||
read_pipe_points_file,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex, StructureCandidate
|
||||
from common_util.common_util_station_key import station_key
|
||||
|
||||
|
||||
def test_old_data_gets_stable_ids():
|
||||
raw = [{"chainage_m": 120.0, "source": "stream"}, {"chainage_m": 40.25}]
|
||||
first = parse_pipe_points(raw)
|
||||
again = parse_pipe_points(raw)
|
||||
assert [p.id for p in first] == ["p4025", "p12000"] == [p.id for p in again]
|
||||
kept = parse_pipe_points([{"chainage_m": 120.0, "id": "k7"}])
|
||||
assert kept[0].id == "k7" and kept[0].as_dict()["id"] == "k7"
|
||||
|
||||
|
||||
def test_collision_gets_suffix():
|
||||
points = [PipePoint(chainage_m=100.0, id="p10000"), PipePoint(chainage_m=100.004)]
|
||||
assert [p.id for p in ensure_pipe_ids(points)] == ["p10000", "p10000-2"]
|
||||
|
||||
|
||||
def test_recompute_carries_ids_and_names_new_pipe():
|
||||
reference = [PipePoint(chainage_m=100.0, id="a1", facility="ford_bridge")]
|
||||
rebuilt = [PipePoint(chainage_m=100.004), PipePoint(chainage_m=250.0, source="spacing")]
|
||||
carried = carry_facility_attributes(rebuilt, reference)
|
||||
assert carried[0].id == "a1" and carried[0].facility == "ford_bridge"
|
||||
assert carried[1].id == "p25000"
|
||||
|
||||
|
||||
def test_save_writes_ids(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pipes_mod, "pipe_points_path", lambda stored: tmp_path / "p.json")
|
||||
pipes_mod.save_pipe_points("x", "sig", [PipePoint(chainage_m=55.5)])
|
||||
saved = json.loads((tmp_path / "p.json").read_text("utf-8"))
|
||||
assert saved["points"][0]["id"] == "p5550"
|
||||
assert read_pipe_points_file(tmp_path / "p.json")[0].id == "p5550"
|
||||
|
||||
|
||||
def test_route_change_keeps_id():
|
||||
vertices = [RouteVertex(0.0, 0.0, 0.0, 0.0), RouteVertex(0.0, 300.0, 0.0, 300.0)]
|
||||
moved = project_pipe_points([PipePoint(chainage_m=10.0, x=0.0, y=120.0, id="a1")], vertices)
|
||||
assert moved[0].chainage_m == 120.0 and moved[0].id == "a1"
|
||||
|
||||
|
||||
def test_station_key_is_pipe_id():
|
||||
point = parse_pipe_points([{"chainage_m": 120.0, "id": "a1"}])[0]
|
||||
assert station_key("pipe", point.chainage_m, 20.0, point.id) == "pipe:a1"
|
||||
|
||||
|
||||
def test_build_keeps_request_ids(monkeypatch):
|
||||
detail = DrainageDetail(
|
||||
pipes=[
|
||||
StructureCandidate(chainage_m=100.0, x=0.0, y=0.0, reason="confirmed"),
|
||||
StructureCandidate(chainage_m=200.0, x=0.0, y=0.0, reason="confirmed"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(basins, "build_detail", lambda *args: detail)
|
||||
monkeypatch.setattr(basins, "drainage_dir", lambda path: Path("."))
|
||||
points = [PipePoint(chainage_m=100.0, id="a1"), PipePoint(chainage_m=200.0, id="b2")]
|
||||
_, carried = basins._build("x", SimpleNamespace(vertices=[]), points)
|
||||
assert [p.id for p in carried] == ["a1", "b2"]
|
||||
|
||||
|
||||
def test_screen_reconcile_carries_id():
|
||||
"""화면 `reconcilePipes` — 제자리 관 · 하나 옮긴 관은 id 그대로 · 새 관 · 둘 넘게 바뀐 관은 id 없음."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
helper = os.path.join(os.path.dirname(__file__), "helper_347_pipe_id_reconcile.cjs")
|
||||
proc = subprocess.run(
|
||||
["node", helper], capture_output=True, text=True, timeout=60, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
result = json.loads(proc.stdout)
|
||||
assert result["same"] is None
|
||||
assert [p.get("id") for p in result["moved"]] == ["a1", "b2"]
|
||||
assert result["moved"][1]["reason"] == "confirmed"
|
||||
assert [p.get("id") for p in result["added"]] == ["a1", "b2", None]
|
||||
assert [p.get("id") for p in result["twoMoved"]] == [None, None]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""편집 창 종단 띠 — [확인]과 같은 노선 점으로 균형 종단을 셈한다(PLAN 75-1).
|
||||
"""편집 창 종단 띠 — 없앰(345-5 · 아래 종단 판 종단 그래프가 그 몫) · [확인] 목표 셈 그대로.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_75_profile_strip.py -q
|
||||
"""
|
||||
@@ -7,90 +7,30 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as initial
|
||||
from common_util.common_util_route_geometry import read_planned_route_csv
|
||||
|
||||
NODES = [(0.0, 0.0), (120.0, 35.0), (180.0, 140.0), (310.0, 150.5)]
|
||||
RADIUS_M = 25.0
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
|
||||
def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, object]:
|
||||
seen: dict = {}
|
||||
|
||||
async def paths(_pid):
|
||||
return tmp_path, "x"
|
||||
|
||||
async def plan_criteria(_pid):
|
||||
return RADIUS_M, 0.0, 0.0
|
||||
|
||||
async def profile_of(_pid, _root, points, crit, curves=None, _terrain="las"):
|
||||
seen["points"], seen["criteria"], seen["curves"] = points, crit, curves
|
||||
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
|
||||
|
||||
monkeypatch.setattr(replan, "_project_paths", paths)
|
||||
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
|
||||
monkeypatch.setattr(initial, "balanced_profile_of", profile_of)
|
||||
request = initial.ProfilePreviewRequest(
|
||||
vertices=[
|
||||
replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None)
|
||||
for i, (x, y) in enumerate(NODES)
|
||||
],
|
||||
criteria=criteria,
|
||||
)
|
||||
answer = asyncio.run(initial.profile_preview(uuid4(), request))
|
||||
assert answer["profile"]["plan_m"] == [1.0, 2.0]
|
||||
return seen["points"], seen["criteria"]
|
||||
def _read(name: str) -> str:
|
||||
return (B05 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_띠_노선점은_확인이_읽는_CSV와_같다(monkeypatch, tmp_path: Path) -> None:
|
||||
points, _ = _strip_points(monkeypatch, tmp_path)
|
||||
working = tmp_path / "planned_route.csv"
|
||||
replan._write_planned_polyline(
|
||||
working,
|
||||
NODES,
|
||||
RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True, True, False, True],
|
||||
radii=[None, 40.0, None, None],
|
||||
)
|
||||
stored = read_planned_route_csv(working)
|
||||
assert stored is not None
|
||||
assert [(p["x"], p["y"]) for p in points] == [(v.x, v.y) for v in stored.vertices]
|
||||
assert len(points) > len(NODES) # 원호가 펴져 있다
|
||||
|
||||
|
||||
def test_띠는_그_노선의_곡선표로_확폭을_맨다(monkeypatch, tmp_path: Path) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
async def paths(_pid):
|
||||
return tmp_path, "x"
|
||||
|
||||
async def plan_criteria(_pid):
|
||||
return RADIUS_M, 0.0, 0.0
|
||||
|
||||
async def profile_of(_pid, _root, _points, _crit, curves=None, _terrain="las"):
|
||||
seen["curves"] = curves
|
||||
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
|
||||
|
||||
monkeypatch.setattr(replan, "_project_paths", paths)
|
||||
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
|
||||
monkeypatch.setattr(initial, "balanced_profile_of", profile_of)
|
||||
request = initial.ProfilePreviewRequest(
|
||||
vertices=[
|
||||
replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None)
|
||||
for i, (x, y) in enumerate(NODES)
|
||||
]
|
||||
)
|
||||
asyncio.run(initial.profile_preview(uuid4(), request))
|
||||
assert seen["curves"] and 40.0 in [c["radius_m"] for c in seen["curves"]]
|
||||
assert all({"start", "apex", "end", "radius_m"} <= set(c) for c in seen["curves"])
|
||||
|
||||
|
||||
def test_띠는_지금_추천_제약을_싣는다(monkeypatch, tmp_path: Path) -> None:
|
||||
criteria = replan.RouteInitialCriteria(max_grade_pct=12.0)
|
||||
_, got = _strip_points(monkeypatch, tmp_path, criteria)
|
||||
assert got.max_grade_pct == 12.0
|
||||
def test_띠_없음_화면_요청_서버_길():
|
||||
assert not (B05 / "B05_Profile_UI_RouteEdit_ProfileStrip.ts").exists()
|
||||
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert "data-strip" not in chrome and "종단 띠" not in chrome
|
||||
assert "routeedit__strip" not in _read("B05_Profile_UI_Style_RouteEdit.css")
|
||||
editor = _read("B05_Profile_UI_RouteEdit.ts")
|
||||
assert "ProfileStrip" not in editor and "strip." not in editor
|
||||
# [노선 반영]의 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙)
|
||||
assert "profileShown: async () => initial.withProfile()," in editor
|
||||
assert "profile-preview" not in _read("B05_Profile_Api_Replan.ts")
|
||||
assert "is-tick" not in _read("B05_Profile_UI_RouteEdit_Recommend_Profile.ts")
|
||||
assert not hasattr(initial, "profile_preview") and not hasattr(initial, "ProfilePreviewRequest")
|
||||
paths = {getattr(r, "path", "") for r in initial.replan.router.routes}
|
||||
assert not any(p.endswith("/route/profile-preview") for p in paths)
|
||||
|
||||
|
||||
def test_확인_목표는_같은_셈의_측점_계획고(monkeypatch, tmp_path: Path) -> None:
|
||||
|
||||
@@ -38,9 +38,9 @@ def test_legacy_bare_number_still_parses():
|
||||
|
||||
|
||||
def test_legacy_dict_roundtrip_stays_compact():
|
||||
"""구 형식 저장분은 확장 필드 없이 그대로 다시 저장된다 (파일 불변)."""
|
||||
"""구 형식 저장분은 확장 필드 없이 다시 저장된다 — 고정 id 만 더함(347 덧 · 다음 저장)."""
|
||||
point = parse_pipe_points([{"chainage_m": 60.0, "source": "user"}])[0]
|
||||
assert point.as_dict() == {"chainage_m": 60.0, "source": PIPE_SOURCE_USER}
|
||||
assert point.as_dict() == {"chainage_m": 60.0, "source": PIPE_SOURCE_USER, "id": "p6000"}
|
||||
|
||||
|
||||
# ── 시설 종류 ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user