Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-16 12:16:50 +09:00
42 changed files with 1816 additions and 488 deletions
+3 -3
View File
@@ -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;
}
+12
View File
@@ -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();
},
};
}
+231
View File
@@ -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). */
+26 -31
View File
@@ -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();
+44
View File
@@ -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,
})),
};
}
+1 -1
View File
@@ -300,7 +300,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
...(side
? {
pipes: async () => {
await side.savePipes();
await side.savePipes(confirm);
},
}
: {}),
+5
View File
@@ -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;
/** 시설 종류 — 응답에서 생략되면 기본 배관. */
+1 -20
View File
@@ -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;
}
+3 -3
View File
@@ -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
+42 -35
View File
@@ -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
+4 -57
View File
@@ -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 } : {}),
})),
);
+9 -2
View File
@@ -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 {
+13 -25
View File
@@ -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>`;
}
+39 -12
View File
@@ -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 };
}
+2 -2
View File
@@ -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;
+5 -60
View File
@@ -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,
};
}
+31 -3
View File
@@ -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
+12
View File
@@ -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": []})
+6 -3
View File
@@ -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 # 격자 있으면 서버 길 안 탐
+14 -9
View File
@@ -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},
+97
View File
@@ -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]
+19 -79
View File
@@ -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"}
# ── 시설 종류 ───────────────────────────────────────────────────────────────