Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티

This commit is contained in:
2026-10-18 16:58:22 +09:00
274 changed files with 15050 additions and 2135 deletions
+52
View File
@@ -91,3 +91,55 @@
.b05-route__panel [class*="b06-structure-panel__struct--"] {
display: none;
}
/* 횡단 보기 위 평면 겹침 패널(요구 258 · 425 · `design_plan_peek`) — 카드 칸 위에 뜸(자리 안 차지) ·
닫히면 손잡이만 위 가운데 · 열면 가운데 칸 높이 45 % · 보기 · 팬 · 휠만이라 편집 도구는 감춤. */
.design-plan-peek {
position: absolute;
z-index: 4;
top: 0;
right: 0;
left: 0;
height: 0;
pointer-events: none;
}
.design-plan-peek.is-open {
height: 45%;
}
.design-plan-peek__body {
display: none;
height: 100%;
border-bottom: 1px solid var(--panel-handle-border, var(--color-border));
background: var(--color-surface);
box-shadow: var(--shadow-lg);
pointer-events: auto;
}
.design-plan-peek.is-open > .design-plan-peek__body {
display: flex;
}
.design-plan-peek__note {
margin: auto;
color: var(--color-text-secondary);
}
/* 손잡이 = 패널 아래 가장자리 가운데(위로 접힘) — 공용 bottom 손잡이를 위아래 뒤집음. */
.design-plan-peek > .ui-workflow-overlay__handle--bottom {
top: 100%;
border-top: 0;
border-bottom: 1px solid var(--panel-handle-border, var(--color-border));
border-radius: 0 0 var(--radius-buttons) var(--radius-buttons);
}
.design-plan-peek .b05-routeedit__tools,
.design-plan-peek .b05-routeedit__hint,
.design-plan-peek .b05-routeedit__info,
.design-plan-peek .b05-routeedit__measure,
.design-plan-peek .b05-routeedit__side-wrap,
.design-plan-peek .b05-routeedit__label,
.design-plan-peek .b05-contourlock {
display: none;
}
+11 -3
View File
@@ -32,9 +32,11 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import { pinDraftProject } from "./design_store";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page";
import { forgetPageData, onPageBusy } from "./design_page_data";
import { pageRouteId } from "./design_page_route";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import { peekSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -113,6 +115,13 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
setPipeSectionsSource(
() =>
(pipelineDetail(projectId) ?? peekSectionDetail(projectId, pageRouteId(projectId)))
?.cross_sections ?? null,
);
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
@@ -145,7 +154,6 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
};
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3)
let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2)
const stopBusy = onPageBusy(projectId, (now) => shared.panel?.dock.setBusy((busy = now)));
+4
View File
@@ -12,6 +12,10 @@ import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
/** 평면 노선 편집 창이 낸 고름(눈금 클릭 · 구조물 마커 고름 풂) — 오른쪽 횡단 판은 제 것이라 거름 ·
* 풂(값 없음)이면 Esc 처럼 좌측 폼까지 닫음(409-2). */
export const PLAN_PICK_FROM = "b05-plan";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
+6 -4
View File
@@ -15,7 +15,7 @@ import {
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { bindEscapeRelease, ESCAPE_FROM, PLAN_PICK_FROM } from "./design_pick_escape";
import { pipeIdAt } from "./design_pipe_ids";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
@@ -149,10 +149,12 @@ export function bindProfilePick(
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
};
host.onDispose(
subscribeSelection(projectId, (_selection, from) => {
subscribeSelection(projectId, (selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
// 풀기 = Esc · 평면 마커 고름 풂(409-2) — 폼 닫기는 펴지 않음(좌측 패널 접힘 · 펼침 그대로 · 398-1)
const release =
from === ESCAPE_FROM || (from === PLAN_PICK_FROM && selection.chainageM === null);
if (release && host.visible() && target.form?.hasSelection()) target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
+31
View File
@@ -46,6 +46,20 @@ export function pipeIdAt(chainageM: number, projectId: string | null = active):
return id;
}
/** 그 자리 관의 id — 장부에 있을 때만(안 지음 · 411-E). */
export function pipeIdNear(chainageM: number, projectId: string | null = active): string | null {
if (!projectId) return null;
return nearestWithin(bookOf(projectId).ids, chainageM, (entry) => entry.at)?.id ?? null;
}
/** 지금 관 자리(`keep`) 근처에 없는 기록을 뺌 — 옮긴 사이 옛 측점이 지은 헛 id 정리(411-E). */
export function prunePipeIds(keep: ReadonlyArray<number>, projectId: string | null = active): void {
if (!projectId || !keep.length) return; // 빈 목록 = 아직 못 받음 · 다 지움 — 장부 그대로
const book = bookOf(projectId);
const ids = book.ids.filter((entry) => keep.some((at) => isNear(entry.at, at)));
if (ids.length !== book.ids.length) writeState("pipe-ids", { next: book.next, ids }, projectId);
}
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
if (!id.startsWith("pipe-")) return null;
@@ -73,6 +87,23 @@ export function movePipeId(fromM: number, toM: number, projectId: string | null
return moving.id;
}
/** 관 세션 id 를 그 자리로(화면 목록 맞춤 · 411-B) — 짝 = 관 고정 id 가 쥔 세션 id(노선 따라감 · 끌기 · 되돌리기
* 어느 길로 옮겨도 그 id) · 그 자리 근처의 다른 기록(옛 측점 열쇠가 지은 헛 id)은 뺌 · 장부에 없는 id 는 적음. */
export function placePipeIds(
places: ReadonlyArray<readonly [string, number]>,
projectId: string | null = active,
): void {
if (!projectId || !places.length) return;
const book = bookOf(projectId);
const to = new Map(places);
const ids = book.ids
.filter((entry) => to.has(entry.id) || !places.some(([, at]) => isNear(entry.at, at)))
.map((entry) => (to.has(entry.id) ? { ...entry, at: to.get(entry.id)! } : entry));
for (const [id, at] of places) if (!ids.some((entry) => entry.id === id)) ids.push({ id, at });
if (JSON.stringify(ids) !== JSON.stringify(book.ids))
writeState("pipe-ids", { next: book.next, ids }, projectId);
}
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
export function withPipeIds<T extends { chainage_m: number }>(
pipes: ReadonlyArray<T>,
+32 -11
View File
@@ -6,10 +6,11 @@
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id).
*
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
* 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음.
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 얹힌 시설
* 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 방향은 새 지형이 정함
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
* 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에
* 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음.
* ========================================================================== */
import { readState } from "../b_page_state";
@@ -85,12 +86,28 @@ function carried(section: CrossSection, saved?: CrossSection["design"]): Record<
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
// 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로
...sides(design, "shoulder_extend", "shoulderExtend"),
...sides(design, "berm_depth", "bermDepth"),
};
}
/** 저장분 쪽마다 값(`{field}_{left|right}_m`) → 입력 칸(`{name}LeftM` · `{name}RightM`) — 있는 쪽만. */
function sides(design: Record<string, unknown>, field: string, name: string) {
const out: Record<string, number> = {};
for (const [side, tail] of [
["left", "LeftM"],
["right", "RightM"],
] as const) {
const value = finite(design[`${field}_${side}_m`]);
if (value !== null) out[`${name}${tail}`] = value;
}
return out;
}
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
function sessionCells(
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth",
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth" | "shoulderext",
projectId: string,
routeId: number,
): Array<[string, unknown]> {
@@ -117,12 +134,16 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"]
const ratio = finite(value);
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
}
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5)
for (const [key, value] of sessionCells("bermdepth", projectId, routeId)) {
const sides = (value ?? {}) as Record<string, unknown>;
if (finite(sides.left) !== null) (out[key] ??= {}).bermDepthLeftM = sides.left;
if (finite(sides.right) !== null) (out[key] ??= {}).bermDepthRightM = sides.right;
}
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) · 사면 쉬프트(139-6 · 417 M3)
for (const [state, name] of [
["bermdepth", "bermDepth"],
["shoulderext", "shoulderExtend"],
] as const)
for (const [key, value] of sessionCells(state, projectId, routeId)) {
const sides = (value ?? {}) as Record<string, unknown>;
if (finite(sides.left) !== null) (out[key] ??= {})[`${name}LeftM`] = sides.left;
if (finite(sides.right) !== null) (out[key] ??= {})[`${name}RightM`] = sides.right;
}
return out;
}
@@ -0,0 +1,73 @@
/* =============================================================================
* A00_Common/design/design_pipeline_facilities.ts
* 계산 묶음 관 · 구조물 · 추가 측점 먹이기(PLAN 415 · 429 ⑨) — 가게 초안이 바뀌면 묶음에 넣음(비면 저장분).
* · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync`
* (저장 목록 다시 읽기 · 입력 맞춤) 한 번.
* · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은
* 노선 길이 맡음).
* 저장 창구는 늦게 불러옴(묶음 시험이 화면 모듈을 안 끌게).
* ========================================================================== */
import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import type { useDesignStore } from "./design_store";
import type { ChangeSource } from "./logic/design_pipeline_types";
type Key = "pipes" | "structures" | "plusStations";
const FACILITIES: ReadonlyArray<{ source: ChangeSource; key: Key }> = [
{ source: "pipe", key: "pipes" },
{ source: "structure", key: "structures" },
{ source: "plus", key: "plusStations" },
];
export interface FacilityFeed {
saving(): boolean;
/** 저장이 끝나면 `resync` 한 번 — 기다리는 동안 다시 불러도 한 번. */
afterSave(): void;
stop(): void;
}
export function feedFacilities(o: {
projectId: string;
store: ReturnType<typeof useDesignStore>;
/** 묶음이 받을 때인가(열었음 · 묶음이 가게에 쓰는 메아리 아님). */
ready: () => boolean;
gone: () => boolean;
saved: (key: Key) => unknown;
input: (key: Key) => unknown;
/** 노선 초안이 걸렸나 — 되돌리기는 노선 길이 맡음. */
routed: () => boolean;
feed: (source: ChangeSource, value: unknown) => void;
resync: () => Promise<unknown>;
}): FacilityFeed {
let savingOf: ((id: string) => boolean) | null = null;
// 한 번 불러 봄 — 덜 선 모듈(시험 싣개의 고리)은 안 받음
void import("./design_save")
.then((m) => (m.savingDesign(o.projectId), (savingOf = m.savingDesign)))
.catch(() => undefined);
const saving = (): boolean => savingOf?.(o.projectId) ?? false;
let waiting: Promise<void> | null = null;
const afterSave = (): void => {
waiting ??= (async () => {
// ponytail: 100 ms 마다 물음 — 저장 끝 알림이 생기면 그것으로
while (saving() && !o.gone()) await new Promise((done) => setTimeout(done, 100));
if (!o.gone()) await o.resync();
})().finally(() => (waiting = null));
};
const put = (source: ChangeSource, value: unknown): void =>
saving() ? afterSave() : o.feed(source, value);
const stops = [
...FACILITIES.map(({ source, key }) =>
o.store.subscribe(key, (value) => o.ready() && put(source, value ?? o.saved(key))),
),
onPageHistory({
restore: () => {
if (!o.ready() || o.routed()) return;
for (const { source, key } of FACILITIES) {
const now = o.store.get(key) ?? o.saved(key);
if (JSON.stringify(now) !== JSON.stringify(o.input(key))) put(source, now);
}
},
}),
];
return { saving, afterSave, stop: () => stops.forEach((stop) => stop()) };
}
+58 -12
View File
@@ -58,6 +58,7 @@ import {
readAlignmentDraft,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { plusStationsOf } from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import {
analysisTerrain,
@@ -66,8 +67,9 @@ import {
} from "../../B05_Profile/B05_Profile_Api_Replan";
import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
import { feedFacilities } from "./design_pipeline_facilities";
export type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
import {
groundStageDeps,
@@ -91,7 +93,10 @@ import {
draftSectionDetail,
type CrossInput,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import {
buildStations,
SECTION_STATION_INTERVAL_M,
} from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
import {
createMassHaulStage,
@@ -286,6 +291,20 @@ function createPagePipeline(
alignment: savedEdits(),
};
},
// 옮긴 편집이 놓일 새 측점 — stations 단계와 같은 셈(400)
stationsAt: (route, patch, inputs) =>
buildStations(
route.line,
route.curves,
(patch.pipes ?? inputs.pipes ?? []) as PlanPipe[],
(patch.structures ?? inputs.structures ?? []) as PlanStructure[],
{
intervalM: options.stationIntervalM,
types: enabled,
stationSnap: inputs.route?.stationSnap ?? false,
stationSnapStep: inputs.route?.stationSnapStep,
},
).map((station) => station.chainageM),
});
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
const savedOut = new WeakSet<object>();
@@ -300,11 +319,26 @@ function createPagePipeline(
replay = false;
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
const line = ctx.before.route?.line ?? savedLine() ?? [];
// 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410)
const frame = (list: unknown, saved: unknown) =>
!ctx.before.route && ctx.inputs.route && list !== saved
? (ctx.out.route?.line ?? line)
: line;
const { pipes, structures } = ctx.inputs;
// 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410)
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
const inputs = {
...ctx.inputs,
pipes: placed(pipes, line, savedPipes as FollowingStructure[]),
structures: placed(structures, line, []),
pipes: placed(pipes, frame(pipes, savedPipes), [
...(savedPipes as FollowingStructure[]),
...followed,
]),
structures: placed(
structures,
frame(structures, savedStructures),
[],
SECTION_STATION_INTERVAL_M,
),
};
return followStage({ ...ctx, inputs });
},
@@ -500,6 +534,7 @@ function createPagePipeline(
cross: crossNow()?.input ?? null,
pipes: store.get("pipes") ?? savedPipes,
structures: store.get("structures") ?? savedStructures,
plusStations: plusStationsOf(projectId),
};
};
const start = (): void => {
@@ -537,6 +572,7 @@ function createPagePipeline(
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
await pipe.settled();
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
if (facilities.saving()) return (facilities.afterSave(), round); // 저장 도중 = 옛 저장분 · 끝나면 다시
const now = inputsNow();
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
@@ -585,13 +621,23 @@ function createPagePipeline(
})
.catch(() => undefined);
const unsubscribe = [
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
store.subscribe(
"structures",
(list) => started && !echo && void feed("structure", list ?? savedStructures),
),
];
/** 관 · 구조물 먹이기 — [저장] 도중 멈춤(끝나면 저장 목록 다시 읽고 맞춤) · 되돌리기 뒤 다시 견줌(415). */
const facilities = feedFacilities({
projectId,
store,
ready: () => started && !echo,
gone: () => disposed,
saved: (key) =>
key === "pipes"
? savedPipes
: key === "structures"
? savedStructures
: plusStationsOf(projectId),
input: (key) => pipe.input(key),
routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null,
feed: (source, value) => void feed(source, value),
resync: async () => (forgetPageData(projectId), await loadPlan(), reconcile()),
});
const bridge: RouteDraftBridge = {
saved: () => store.get("routeDraft"),
@@ -647,7 +693,7 @@ function createPagePipeline(
detail: () => shown,
dispose() {
disposed = true;
unsubscribe.forEach((stop) => stop());
facilities.stop();
pipe.dispose();
},
};
+87
View File
@@ -0,0 +1,87 @@
/* =============================================================================
* design_plan_peek.ts
* 횡단 보기 위 **평면 겹침 패널**(요구 258 · 계획서 425) — 종단 보기의 평면(노선 편집) 노드를
* 그대로 옮겨 붙여 보여 줌(둘째 캔버스 없음 · 편집기는 페이지에 하나). 열 때 옮기고 · 닫을 때 ·
* 횡단 보기가 숨을 때 제자리(자리표 주석)로 돌림. 누름 · 휠은 캔버스에 붙어 함께 옴.
*
* 보기 · 가운데 버튼 팬 · 휠 확대만 — 왼쪽 · 오른쪽 누름(끌기 · 끼우기 · 지우기 · 재기)은 패널이
* 캡처에서 먼저 받아 캔버스로 안 보냄(편집은 B05 평면). 단추 줄 · 상태줄 등은 `design_page.css`.
* ========================================================================== */
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type { DesignPageHost } from "./design_page";
import type { DesignPageShared } from "./design_shared";
/** 횡단 보기 가운데(`center`)에 겹침 패널 · 손잡이를 세운다. */
export function mountPlanPeek(
center: HTMLElement,
host: DesignPageHost,
shared: DesignPageShared,
): void {
const root = document.createElement("div");
root.className = "design-plan-peek";
const body = document.createElement("div");
body.className = "design-plan-peek__body";
const note = document.createElement("p");
note.className = "design-plan-peek__note";
body.append(note);
const handle = createWorkflowPanelHandle("bottom", "up", "평면");
root.append(body, handle.root);
center.append(root);
// 편집 몫 누름 막음 — 가운데 버튼(팬)만 통과 · 두 번 클릭(끼우기) · 오른쪽 클릭(지우기)도 막음.
// ponytail: 손가락 팬도 막힘(왼쪽 누름과 같은 단추 0) — 터치 기기에서 필요하면 pointerType 으로 가름.
body.addEventListener(
"pointerdown",
(event) => {
if (event.button !== 1) event.stopPropagation();
},
true,
);
for (const type of ["dblclick", "contextmenu"])
body.addEventListener(
type,
(event) => {
event.preventDefault();
event.stopPropagation();
},
true,
);
let opened = false;
let plan: HTMLElement | null = null;
let home: Comment | null = null;
const setOpen = (open: boolean): void => {
opened = open;
root.classList.toggle("is-open", open);
handle.setOpen(open);
};
const close = (): void => {
setOpen(false);
if (plan && home) home.replaceWith(plan);
home = null;
};
const open = async (): Promise<void> => {
setOpen(true);
note.hidden = false;
note.textContent = "평면을 여는 중…";
await shared.ensureView("profile"); // B06 바로 열기 = 종단 보기를 숨긴 채 세움
if (!opened || !host.visible() || home) return;
plan =
center
.closest(".design-page__center")
?.querySelector<HTMLElement>(".b05-route__main > .b05-routeedit") ?? null;
if (!plan) return void (note.textContent = "평면을 불러오지 못했습니다.");
note.hidden = true;
home = document.createComment("design-plan-peek");
plan.replaceWith(home);
body.append(plan); // 크기 0 → 첫 크기 알림에 편집기가 다시 그림
};
handle.root.addEventListener("click", () => (opened ? close() : void open()));
host.onHide(close);
setOpen(false);
}
+2
View File
@@ -71,6 +71,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard";
import { listedBase } from "./design_profile_base";
import { tidyEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
import * as pagePipeline from "./design_pipeline_page";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
@@ -303,6 +304,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
function applyEdits(next: AlignmentEdits): boolean {
if (!base || !alignment) return false;
next = tidyEdits(base, next); // 직선 위 R · 0 오프셋 지움 · 모양 그대로(426)
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
+11 -2
View File
@@ -12,9 +12,12 @@ import {
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat";
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
* 늘 저장분에서 셈. */
export function listedBase(
storedBase: AlignmentBase | null,
detail: SectionDetailResponse | null,
@@ -33,9 +36,15 @@ export function listedBase(
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
const moved = followMovedNodes(
storedBase.basePvi,
gone,
born,
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
);
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
+5 -2
View File
@@ -14,7 +14,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { clearDrafts, clearResults } from "./../b_page_state";
import { clearResults, clearState, namesInBucket } from "./../b_page_state";
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
@@ -43,7 +43,10 @@ export async function resetDesign(
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
// 복원은 새 노선 번호를 내므로 모든 노선의 초안 · 결과를 버린다(PLAN 77).
clearDrafts(projectId);
// 저장 안 한 B08 · B09 초안은 남김 — [초기화]는 B05 · B06 범위만(418).
namesInBucket("draft")
.filter((name) => !name.startsWith("b08-") && !name.startsWith("b09-"))
.forEach((name) => clearState(name, projectId));
clearResults(projectId);
onCleared?.();
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
+51 -47
View File
@@ -9,6 +9,8 @@
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
* 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄).
* 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④).
*
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
@@ -86,12 +88,22 @@ export function savingDesign(projectId: string | null): boolean {
return projectId !== null && saving.has(projectId);
}
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
function reportOnly(label: string): (error: unknown) => void {
return (error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
};
/** 차례 이름 — 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게 ·
* 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
const STEP_LABEL: Record<DesignSaveStep, string> = {
route: "노선",
pipes: "배수관",
structures: "구조물",
plusStations: "추가 측점",
alignment: "계획선",
culvertOptions: "암거 옵션",
uphill: "상단측",
cross: "횡단",
};
function stepFailed(step: DesignSaveStep, error: unknown): Error {
const detail = error instanceof Error ? ` ${error.message}` : "";
return new Error(`${STEP_LABEL[step]} 차례에서 멈췄습니다(뒤 차례 안 보냄).${detail}`);
}
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
@@ -128,15 +140,7 @@ async function flushAlignmentDraft(
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
}
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
pipes: "배수관",
structures: "구조물",
plusStations: "추가 측점",
alignment: "계획선",
};
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
/** 차례마다 기본 창구. */
function defaultStep(
step: Exclude<DesignSaveStep, "cross" | "route">,
projectId: string,
@@ -153,12 +157,9 @@ function defaultStep(
case "alignment":
return () => flushAlignmentDraft(projectId, routeId, fromRouteId);
case "culvertOptions":
return () =>
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch(
() => undefined,
);
return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId));
case "uphill":
return () => flushUphillOverrides(projectId).catch(() => undefined);
return () => flushUphillOverrides(projectId);
}
}
@@ -201,35 +202,38 @@ export async function saveDesign(
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
if (step === "route") {
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
if (!body) continue;
const before = await savedPipesIfDraft(ctx.projectId);
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
if (before) await rebasePendingPipes(ctx.projectId, before);
continue;
}
if (step === "cross") {
if (!routeSaved) await ctx.cross(confirm);
else {
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
showToast(routeSaveSummary(routeSaved, outside), "info");
try {
if (step === "route") {
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
if (!body) continue;
const before = await savedPipesIfDraft(ctx.projectId);
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
if (before) await rebasePendingPipes(ctx.projectId, before);
continue;
}
continue;
if (step === "cross") {
if (!routeSaved) await ctx.cross(confirm);
else {
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
showToast(routeSaveSummary(routeSaved, outside), "info");
}
continue;
}
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
const own = routeSaved ? undefined : ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
await run();
} catch (error) {
throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨)
}
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
const own = routeSaved ? undefined : ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
const label = TOLERANT[step];
await (label ? run().catch(reportOnly(label)) : run());
}
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
+21 -4
View File
@@ -37,8 +37,10 @@ import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts";
import {
holdSectionDetail,
invalidateSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
@@ -166,6 +168,13 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 횡단 설계가 다시 셈됨 — 두 보기에 알리고 좌측 시설 폼 자동 칸(관 길이 · 흙막이 수)을 공용 캐시 새 값으로 다시
* 그림(416 · 관을 옮긴 뒤 측점 횡단이 늦게 서도 B05 좌측 = B06 카드). */
function crossDesignsUpdated(shared: DesignPageShared, updated: ReadonlyArray<number>): void {
toAll(shared, "onCrossDesignsUpdated")(updated);
shared.structures?.facility.recount();
}
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
@@ -182,7 +191,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
shared.lastPipes = pipes;
toAll(shared, "onPipesChanged")(pipes);
},
onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"),
onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated),
onStructureMove: toView(shared, "onStructureMove"),
onStructureRemove: toView(shared, "onStructureRemove"),
onPipeAdd: toView(shared, "onPipeAdd"),
@@ -226,13 +235,18 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
if (updated.length) crossDesignsUpdated(shared, updated);
profile.redraw();
return detailCross(detail, stations);
},
plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관)
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
structureAreas: (detail) => {
// 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞
const routeId = routeIdOf(shared);
if (routeId !== null) withDraftAdjusts(detail, projectId, routeId);
const rows = detail.cross_sections;
applyStructureAreaRows(rows, structureAreaRows(rows));
},
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
@@ -382,6 +396,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
}
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
// 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림)
invalidateSectionDetail(shared.projectId);
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
+2 -3
View File
@@ -79,7 +79,6 @@ import {
setPipeExtraCountSource,
setPipeInletSource,
setPipeRevetChoiceBridge,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import { pipeRevetChoiceBridge } from "../../B06_Section/B06_Section_UI_Page_Link_Session";
import {
@@ -94,6 +93,7 @@ import {
type DesignPageShared,
} from "./design_shared";
import "@util/common_util_mass_haul.css";
import { mountPlanPeek } from "./design_plan_peek";
export async function mountCrossView(
center: HTMLElement,
@@ -103,8 +103,6 @@ export async function mountCrossView(
const projectId = shared.projectId || null;
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
// 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1)
setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null);
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
@@ -412,6 +410,7 @@ export async function mountCrossView(
mainArea.className = "b06-profile__main";
mainArea.append(sectionView.root);
center.append(mainArea);
mountPlanPeek(center, host, shared); // 위 평면 겹침 패널(요구 258 · 425)
function showSectionView(): void {
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
+63 -38
View File
@@ -59,14 +59,14 @@ import {
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import { setPipeCrossOpener } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { movePipeId, pipeIdAt } from "./design_pipe_ids";
import { pipeIdAt, placePipeIds, prunePipeIds } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { setPlusStations } from "../../common_util/common_util_structure_station";
import {
@@ -84,6 +84,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections";
import { createProfile3d } from "./design_view_profile_3d";
import { setPlanMarkSource } from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks";
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
@@ -112,19 +113,29 @@ export async function mountProfileView(
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const designStore = useDesignStore(activeProjectId, null);
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */
let lastShown: PipeReport = [];
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도
* 그 자리로 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */
let sessionIds = new Map<string, string>(); // 관 고정 id → 세션 id(알약 id)
const shownPipes = (pipes: PipeReport): PipeReport => {
const byChainage = (x: PipeReport[number], y: PipeReport[number]) =>
x.chainage_m - y.chainage_m;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage);
const before = [...lastShown].sort(byChainage);
if (before.length === shown.length)
shown.forEach((pipe, i) => {
if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005)
movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId);
});
lastShown = shown;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(
(x, y) => x.chainage_m - y.chainage_m,
);
placePipeIds(
shown.flatMap((pipe) => {
const sid = pipe.id && sessionIds.get(pipe.id);
return sid ? [[sid, pipe.chainage_m] as const] : [];
}),
activeProjectId,
);
sessionIds = new Map(
shown.flatMap((pipe) =>
pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [],
),
);
prunePipeIds(
shown.map((pipe) => pipe.chainage_m),
activeProjectId,
); // 헛 id 정리(411-E)
return shown;
};
const applyPipes = (reported: PipeReport, carryEdits = true): void => {
@@ -144,6 +155,10 @@ export async function mountProfileView(
`구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`,
"warning",
);
// 배수 편집기도 따라간 관 자리(410) — 초안을 버려 가게가 비면 저장분 다시 읽음
const followed = designStore.get("pipes");
if (followed) profilePanel.drainage.followPipes(followed);
else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload();
if (!host.visible()) return;
if (lastPipes) applyPipes(lastPipes, false);
bridge.takeStructures(designStore.get("structures"));
@@ -159,6 +174,28 @@ export async function mountProfileView(
profilePanel.setSelectedStation(target.stationId);
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
};
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). 평면 구조물 마커(399)도 이 길.
const onStructureSelect = (structureId: string | null): void => {
const chainage = bridge.pipeMarkChainage(structureId);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
};
const onStructureMarkMove = (structureId: string, toChainage: number): void => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
};
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
const profilePanel = createRouteProfilePanel(
shared,
@@ -192,28 +229,8 @@ export async function mountProfileView(
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
},
onStructureMarkMove: (structureId, toChainage) => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
},
onStructureSelect,
onStructureMarkMove,
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
},
);
@@ -228,8 +245,6 @@ export async function mountProfileView(
routeReady: false,
restoring: true,
};
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
@@ -376,6 +391,16 @@ export async function mountProfileView(
renderStationLines: () => sections.redrawStationLines(),
isRestoring: () => state.restoring,
});
// 평면 구조물 측점 마커(399) — 알약과 같은 목록 · 고름 · 옮김
host.onDispose(
setPlanMarkSource(activeProjectId, {
marks: bridge.marks,
types: bridge.types,
intervalM: () => panel.values().stationInterval || 20,
select: onStructureSelect,
move: onStructureMarkMove,
}),
);
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
+3 -1
View File
@@ -50,7 +50,7 @@ type Dep = StageName | `input.${InputKey}`;
export const STAGE_DEPS: Record<StageName, Dep[]> = {
route: ["input.route"],
follow: ["route"],
stations: ["route", "input.pipes", "input.structures"],
stations: ["route", "input.pipes", "input.structures", "input.plusStations"],
ground: ["stations"],
planline: ["ground"],
profile: ["planline", "input.alignment"],
@@ -152,6 +152,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
cross: null,
pipes: null,
structures: null,
plusStations: null,
};
const inputVer: Record<InputKey, number> = {
route: 0,
@@ -159,6 +160,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
cross: 0,
pipes: 0,
structures: 0,
plusStations: 0,
};
const out = emptyOuts();
const outVer = {} as Record<StageName, number>;
@@ -5,7 +5,8 @@
* 계산 묶음 본체 = `design_pipeline.ts`.
*
* 흐름 — 한 방향:
* 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물)
* 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물) ·
* plusStations(추가 측점 · 429 ⑨)
* ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림
* 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄)
* route → follow → stations → ground → planline → profile → cross → footprint → masshaul
@@ -24,7 +25,7 @@
* | 단계 | 주인 | deps | 출력 |
* | route | sub8 | input.route | RouteLine |
* | follow | sub1 | route | FollowPatch(입력 고침) |
* | stations | sub7 | route · input.pipes · input.structures | PipelineStation[](지반 null) |
* | stations | sub7 | route · input.pipes · input.structures · input.plusStations | PipelineStation[](지반 null) |
* | ground | sub8 | stations | GroundOutput |
* | planline | sub1 | ground | AlignmentBase(첫 계획선) |
* | profile | sub1 | planline · input.alignment | ProfileAlignment |
@@ -89,12 +90,14 @@ export interface PipelineInputs {
pipes: unknown;
/** 구조물 목록(`StructureInstance[]`) — 위와 같음. */
structures: unknown;
/** 추가 측점(정수 m · 초안 ?? 저장분) — 바뀌면 stations 부터(429 ⑨) · 셈은 `plusStations()` 가 같은 값을 읽음. */
plusStations?: ReadonlyArray<number> | null;
}
export type InputKey = keyof PipelineInputs;
/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물. */
export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure";
/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물 · 추가 측점. */
export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure" | "plus";
/** 출처 → 입력 칸. */
export const SOURCE_INPUT: Record<ChangeSource, InputKey> = {
@@ -103,6 +106,7 @@ export const SOURCE_INPUT: Record<ChangeSource, InputKey> = {
cross: "cross",
pipe: "pipes",
structure: "structures",
plus: "plusStations",
};
/**
@@ -152,8 +156,8 @@ export interface FollowPatch {
alignment?: AlignmentEdits;
}
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점). */
export type StationKind = "grid" | "structure" | "pipe" | "end";
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */
export type StationKind = "grid" | "structure" | "pipe" | "end" | "curve";
/** 횡단 지반 표본 한 점 — 중심에서 거리(m · 좌 +) · 높이(null = 빈칸). */
export interface GroundSample {
@@ -27,7 +27,9 @@ import {
import {
baseElevationAt,
defaultCurveLength,
pinEnds,
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight";
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
const SAME_M = 1e-6;
@@ -131,7 +133,8 @@ export function buildFreeEdits(
const toM = path[path.length - 1].chainage_m;
if (toM - fromM <= SAME_M) return edits;
const inner = new Map<string, number>();
buildAlignment(base, edits).pvi.forEach((node) => {
const current = buildAlignment(base, edits);
current.pvi.forEach((node) => {
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
inner.set(chainageKey(node.chainage_m), node.chainage_m);
}
@@ -141,6 +144,10 @@ export function buildFreeEdits(
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
const offsets = { ...edits.station_offsets };
const radii = { ...edits.curve_radii };
// 끝이 꼭짓점 아닌 측점이면 지금 높이로 꼭짓점(426) — 직선화와 같음
pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => {
offsets[key] = Number(value.toFixed(6));
});
inner.forEach((chainage, key) => {
const target = elevationOnPath(path, chainage);
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
@@ -150,7 +157,7 @@ export function buildFreeEdits(
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
const deltaGrade = Math.abs(gradeOut - gradeIn);
if (deltaGrade < 1e-9) continue;
if (isStraightNode(path[index - 1], path[index], path[index + 1])) continue;
radii[chainageKey(path[index].chainage_m)] = Number(
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
);
@@ -366,6 +366,9 @@ def _raw_payload(
**point.as_dict(),
"chainage_m": round(pipe.chainage_m, 2),
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
# 노선 위 자리(사업지 CRS) — 화면이 따라가기만 한 관을 가림(rebasePipeDraft · 428)
"x": round(float(pipe.x), 3),
"y": round(float(pipe.y), 3),
"source": pipe.reason,
}
for pipe, point in zip(detail.pipes, points)
@@ -568,6 +571,20 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
return _payload(project_id, context, detail, points, False, stale)
async def _stand_pipe_stations(project_id: UUID) -> None:
"""관 PUT 끝 — 측점을 지금 관 자리로 다시 세움(`put_plus_stations` 처럼 · 부르는 길 모두 · 428).
노선 [저장] 뒤 관 차례가 화면 누가거리로 관을 덮으면 서버가 노선 저장 때 세운 측점과 어긋남 —
여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로.
"""
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
try:
await ensure_structure_stations(project_id)
except Exception:
logger.exception("관 저장: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id)
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
async def put_pipe_points(
project_id: UUID,
@@ -610,6 +627,7 @@ async def put_pipe_points(
failed = await asyncio.to_thread(
safe_sync, resolve_stored_project_path(context.stored_path), importer(session)
)
await _stand_pipe_stations(project_id)
result = _payload(project_id, context, detail, points, True, stale)
result["saved_count"] = saved
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
@@ -214,6 +214,8 @@ export interface StationTickOptions {
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2).
* 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */
chainages?: ReadonlyArray<number>;
/** 곡선 측점(MC · B05 요구 252-2) 누가거리 — 그 눈금 이름 뒤 「 (MC)」 · 늘 그림(솎지 않음). */
mcChainages?: ReadonlyArray<number>;
}
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
@@ -260,8 +262,9 @@ export function drawStationTicks(
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
for (const chainage of marks) {
const mc = options.mcChainages?.includes(chainage) ?? false;
const stationNo = Math.round(chainage / interval);
if (stationNo % step !== 0) continue;
if (stationNo % step !== 0 && !mc) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = points[cursor - 1];
const front = points[cursor];
@@ -288,7 +291,7 @@ export function drawStationTicks(
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
context.stroke();
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0);
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0) + (mc ? " (MC)" : "");
const width = context.measureText(label).width + 6;
const half = width / 2;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
+3 -2
View File
@@ -19,6 +19,7 @@ import { readState, writeState } from "../A00_Common/b_page_state";
import { heightAt } from "../common_util/common_util_surface_sampler";
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
export type AnalysisTerrain = "las" | "sheet";
@@ -121,8 +122,8 @@ export interface RoutePlanResponse {
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] };
/** 노드마다 넣은 편집값(430) — 넣은 R · 고정 · 고정 길이 · 켬끔 · 노드 수와 다르거나 옛 노선이면 null. */
node_edits?: RouteNodeEdit[] | null;
}
export interface RouteReplanResponse {
@@ -42,6 +42,8 @@ export interface RouteInitialRequest {
criteria?: RouteInitialCriteria;
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
with_profile?: boolean;
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
recompute?: boolean;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
@@ -128,6 +128,9 @@ export interface StructureInstance {
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
/** 땅 위 자리(344-2 · 410) — 서버가 채움 · 누가거리만 옮기면 낡음(따라감 · 저장이 노선 위로 다시). */
x?: number | null;
y?: number | null;
}
interface StructureTypesResponse {
@@ -13,6 +13,9 @@
수치 최적화(직선 분할 DP·표고 결정)는 [[B05_Profile_Engine_Grade_Solver]]가,
기준 해석과 오케스트레이션은 [[B05_Profile_Engine_Grade]]가 담당한다. 이 모듈은
scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다.
직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts`
(거울 시험 `test_426_profile_straight.py`).
"""
from dataclasses import dataclass
@@ -20,6 +23,7 @@ from typing import Any
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import vertical_curve_shortfalls
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA
ALIGNMENT_SCHEMA_VERSION = 1
@@ -27,6 +31,17 @@ ALIGNMENT_SCHEMA_VERSION = 1
_KEY_DECIMALS = 3
# 직선으로 볼 꼭짓점 높이 어긋남(m) — TS `STRAIGHT_HEIGHT_M` 과 같은 값(426).
STRAIGHT_HEIGHT_M = 1e-5
def is_straight_bend(delta: float, span_left: float, span_right: float) -> bool:
"""꼭짓점이 이웃 둘 잇는 선에서 `STRAIGHT_HEIGHT_M` 안이면 직선 — 높이 = |Δ|·a·b/(a+b)."""
if not (span_left > 0 and span_right > 0):
return True
return abs(delta) * span_left * span_right / (span_left + span_right) < STRAIGHT_HEIGHT_M
def chainage_key(value: float) -> str:
"""chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙)."""
return f"{float(value):.{_KEY_DECIMALS}f}"
@@ -55,6 +70,12 @@ class AlignmentPolicy:
min_plan_radius_m: float = 0.0
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
hairpin_min_radius_m: float = 0.0
#: 설계속도별 종단곡선 최소 R · L(m) — 미달 경고만(432 · 별표2 마.). 0이면 판정 안 함.
min_vertical_radius_m: float = 0.0
min_curve_length_m: float = 0.0
#: 초기값에서 곡선을 두지 않은 꼭짓점 열쇠(432 · `initial_no_curve_keys`) — 손 편집 · R 이면
#: 곡선.
no_curve_keys: tuple[str, ...] = ()
@classmethod
def from_config(
@@ -65,6 +86,8 @@ class AlignmentPolicy:
curve_skip_delta_pct: float,
paved: bool,
min_plan_radius_m: float = 0.0,
min_vertical_radius_m: float = 0.0,
min_curve_length_m: float = 0.0,
) -> "AlignmentPolicy":
config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls(
@@ -83,6 +106,8 @@ class AlignmentPolicy:
paved=bool(paved),
min_plan_radius_m=float(min_plan_radius_m),
hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]),
min_vertical_radius_m=float(min_vertical_radius_m),
min_curve_length_m=float(min_curve_length_m),
)
@classmethod
@@ -120,6 +145,9 @@ class AlignmentPolicy:
max_grade_pct=float(payload.get("max_grade_pct") or 9.0),
curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0),
paved=bool(payload.get("paved", False)),
min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0),
min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0),
no_curve_keys=tuple(str(key) for key in payload.get("no_curve_keys") or ()),
)
@property
@@ -150,6 +178,9 @@ class AlignmentPolicy:
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
"min_plan_radius_m": self.min_plan_radius_m,
"hairpin_min_radius_m": self.hairpin_min_radius_m,
"min_vertical_radius_m": self.min_vertical_radius_m,
"min_curve_length_m": self.min_curve_length_m,
"no_curve_keys": list(self.no_curve_keys),
}
@@ -279,6 +310,7 @@ def build_curves(
curve_radii: dict[str, float] | None = None,
*,
only_explicit: bool = False,
edited: frozenset[str] = frozenset(),
) -> tuple[list[dict[str, Any]], list[str]]:
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
@@ -303,10 +335,13 @@ def build_curves(
delta = grade_out - grade_in
chainage = float(pvi_s[index])
key = chainage_key(chainage)
if abs(delta) < 1e-9:
continue
if is_straight_bend(delta, float(spans[index - 1]), float(spans[index])):
continue # 직선 판정 한 벌(426)
if only_explicit and overrides.get(key) is None:
continue
# 초기값 직선 꼭짓점(432) — 사용자가 고치거나(`edited`) R 을 넣기 전까지 곡선 없음
if key in policy.no_curve_keys and key not in edited and overrides.get(key) is None:
continue
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
@@ -504,8 +539,14 @@ def build_alignment(
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
edited = frozenset(
chainage_key(s) for s, source in zip(pvi_s.tolist(), sources) if source == "user"
)
curves, warnings = build_curves(
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves, edited=edited
)
warnings += vertical_curve_shortfalls(
curves, policy.min_vertical_radius_m, policy.min_curve_length_m
)
segments = _segments(pvi_s, pvi_z)
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
+188 -59
View File
@@ -1,25 +1,38 @@
"""노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 민다(344-2).
"""노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다.
요구 250-2둘째(400): 평면을 고쳐도 크게 안 바뀐 자리의 종단 편집(직선화 · 자유지정 · 쉬프트 ·
▲▼)은 이어받음.
- 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데(`changed_span`) — 바뀐
노드 앞 · 뒤 접선점 사이. 원호 정점까지 같은지 보므로 곡선이 바뀐 이웃 노드 범위도 들어간다.
- 구간 앞 = 같은 땅 · 같은 누가거리 → 그대로. 구간 뒤 = 같은 땅 · 누가거리만 길이 차만큼 밀림.
- 구간 안 = 새 지반 첫 계획선(`design_ground_following_profile` 의 base_pvi) 꼭짓점으로
갈아 끼움 · 그 안 편집(계획고 · 종단곡선 R)은 버림.
- 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
- `changed_span` = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데 —
「노선 그대로?」 · 범위.
- 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
(측점 스냅이 뒤 교점을 밀어도 이어받음).
- 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
R 은 가까운 측점.
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
`resources/tester/test_344_2_route_pairs.py`. 서버 정본은 이쪽(346-3 [저장]이 부름).
`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`.
서버 정본은 이쪽(346-3 [저장]이 부름).
"""
from __future__ import annotations
import math
from collections.abc import Callable
from typing import Any
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key, resolve_pvi
# 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · TS `SAME_LINE_TOL_M` 짝.
SAME_LINE_TOL_M = 1.0
# 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌.
_SAME_DIRECTION_COS = 0.8660254037844386
# 꼭짓점 위로 볼 거리(m) — `SAME_STATION_SNAP_M` 짝.
_ON_NODE_M = 0.006
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
SAME_VERTEX_M = 1e-3
_EPS = 1e-6
@@ -67,61 +80,177 @@ def changed_span(
}
def _before(span: dict[str, Any], s: float) -> bool:
"""구간 앞(그대로 둠) — 앞머리가 같으면 그 끝점까지."""
start = float(span["start_m"])
return s < start - _EPS or (bool(span["start_fixed"]) and s <= start + _EPS)
def _segments(xy: list[tuple[float, float]]) -> np.ndarray:
"""토막 표 [x, y, ux, uy, 길이, 시작 누가거리] — 길이 0 토막은 뺌(누가거리는 셈)."""
rows: list[tuple[float, ...]] = []
start = 0.0
for (x0, y0), (x1, y1) in zip(xy, xy[1:]):
dx, dy = float(x1) - float(x0), float(y1) - float(y0)
length = math.sqrt(dx * dx + dy * dy)
if length > _EPS:
rows.append((float(x0), float(y0), dx / length, dy / length, length, start))
start += length
return np.array(rows, dtype=np.float64).reshape(-1, 6)
def _after(span: dict[str, Any], s: float, end: float) -> bool:
"""구간 뒤(밂) — 꼬리가 같으면 그 시작점부터."""
return s > end + _EPS or (bool(span["end_fixed"]) and s >= end - _EPS)
def line_matcher(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
tol_m: float = SAME_LINE_TOL_M,
) -> Callable[[float], float | None]:
"""새 누가거리 → 같은 자리 옛 누가거리(옆 `tol_m` · 방향 30° 안) · 아니면 None."""
olds, news = _segments(old_xy), _segments(new_xy)
def match(chainage: float) -> float | None:
if not len(olds) or not len(news):
return None
ahead = np.nonzero(chainage <= news[:, 5] + news[:, 4])[0]
x, y, ux, uy, length, start = news[ahead[0] if len(ahead) else -1]
along = min(max(chainage - start, 0.0), length)
qx, qy = x + ux * along, y + uy * along
facing = olds[olds[:, 2] * ux + olds[:, 3] * uy >= _SAME_DIRECTION_COS]
if not len(facing):
return None
ox, oy, oux, ouy, olen, ostart = facing.T
p = np.minimum(np.maximum((qx - ox) * oux + (qy - oy) * ouy, 0.0), olen)
dx, dy = qx - (ox + oux * p), qy - (oy + ouy * p)
gap = np.sqrt(dx * dx + dy * dy)
best = int(np.argmin(gap))
return float(ostart[best] + p[best]) if gap[best] <= tol_m else None
return match
def carry_profile_edits(
base_pvi: list[dict[str, float]],
edits: dict[str, Any] | None,
span: dict[str, Any],
def carry_base(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_base: list[dict[str, float]],
ground_pvi: list[dict[str, float]],
) -> tuple[list[dict[str, float]], dict[str, dict[str, float]]]:
"""옛 꼭짓점 · 편집을 새 노선으로 옮긴다 → (새 base_pvi, 새 edits).
`ground_pvi` = 새 노선 지반 첫 계획선 꼭짓점(전 구간 · `design_ground_following_profile`) —
바뀐 구간 안 몫만 쓴다.
"""
delta = float(span["delta_m"])
old_end, new_end = float(span["old_end_m"]), float(span["new_end_m"])
def moved(s: float) -> float | None:
if _before(span, s):
return s
if _after(span, s, old_end):
return s + delta
return None # 바뀐 구간 안 — 버림
nodes: dict[float, dict[str, float]] = {}
for node in base_pvi:
at = moved(float(node["chainage_m"]))
if at is not None:
key = round(at, 3)
nodes[key] = {"chainage_m": round(at, 6), "elevation_m": float(node["elevation_m"])}
tol_m: float = SAME_LINE_TOL_M,
) -> list[dict[str, float]]:
"""새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반."""
if not old_base or not ground_pvi:
return [dict(node) for node in old_base]
match = line_matcher(old_xy, new_xy, tol_m)
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
out = []
for node in ground_pvi:
s = float(node["chainage_m"])
if _before(span, s) or _after(span, s, new_end):
continue
nodes.setdefault(
round(s, 3), {"chainage_m": round(s, 6), "elevation_m": float(node["elevation_m"])}
s = match(float(node["chainage_m"]))
z = (
float(node["elevation_m"])
if s is None
else round(float(np.interp(s, base_s, base_z)), 6)
)
out.append({"chainage_m": float(node["chainage_m"]), "elevation_m": z})
return out
carried: dict[str, dict[str, float]] = {}
for name in ("station_offsets", "curve_radii"):
values: dict[str, float] = {}
for key, value in ((edits or {}).get(name) or {}).items():
try:
at = moved(float(key))
except (TypeError, ValueError):
continue
if at is not None and value is not None:
values[chainage_key(at)] = float(value)
carried[name] = values
return [nodes[key] for key in sorted(nodes)], carried
def carry_edits(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_base: list[dict[str, float]],
edits: dict[str, Any] | None,
stations: list[float],
half_m: float,
tol_m: float = SAME_LINE_TOL_M,
) -> dict[str, dict[str, float]]:
"""새 편집 — 새 측점마다 같은 자리 · 옛 사용자 꼭짓점이 닿은 자리면 옛 계획선 − 옛 자동선.
닿은 자리 = 꼭짓점 위(그 꼭짓점이 사용자) · 앞뒤 꼭짓점 하나가 사용자. 종단곡선 R = 옛 키에서
`half_m` 안 가장 가까운 같은 자리 측점. 새 자동 꼭짓점이 같은 자리에서 옛 자동선
높이(`carry_base`)라 계획고가 그대로.
"""
out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}}
if not old_base:
return out
match = line_matcher(old_xy, new_xy, tol_m)
mapped: list[tuple[float, float]] = []
for t in sorted({round(float(v), 3) for v in stations if math.isfinite(float(v))}):
s = match(t)
if s is not None:
mapped.append((t, s))
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, (edits or {}).get("station_offsets") or {})
def touched(s: float) -> bool:
on = np.nonzero(np.abs(pvi_s - s) < _ON_NODE_M)[0]
if len(on):
return sources[int(on[0])] == "user"
ahead = np.nonzero(pvi_s > s)[0]
nxt = int(ahead[0]) if len(ahead) else -1
return nxt > 0 and (sources[nxt - 1] == "user" or sources[nxt] == "user")
for t, s in mapped:
if touched(s):
out["station_offsets"][chainage_key(t)] = round(
float(np.interp(s, pvi_s, pvi_z)) - float(np.interp(s, base_s, base_z)), 6
)
gaps: dict[str, float] = {}
for key, value in ((edits or {}).get("curve_radii") or {}).items():
try:
chainage = float(key)
except (TypeError, ValueError):
continue
if not math.isfinite(chainage) or value is None:
continue
slot, best = None, math.inf
for t, s in mapped:
gap = round(abs(s - chainage), 3) # mm — 가운데 같은 거리면 앞 측점
if gap <= half_m and gap < best:
slot, best = chainage_key(t), gap
if slot is None or best >= gaps.get(slot, math.inf):
continue
gaps[slot] = best
out["curve_radii"][slot] = float(value)
return out
def carry_no_curve_keys(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_keys: list[str],
ground_keys: list[str],
ground_pvi: list[dict[str, float]],
tol_m: float = SAME_LINE_TOL_M,
) -> list[str]:
"""새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) ·
바뀐 자리면 새 지반 첫 계획선 열쇠. 초기값만 · 그 뒤는 사용자 몫(요구 262)."""
match = line_matcher(old_xy, new_xy, tol_m)
old = [float(key) for key in old_keys]
ground = set(ground_keys)
out = []
for node in ground_pvi:
key = chainage_key(float(node["chainage_m"]))
s = match(float(node["chainage_m"]))
if (s is None and key in ground) or (
s is not None and any(abs(s - value) < _ON_NODE_M for value in old)
):
out.append(key)
return out
def carry_policy(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_alignment: dict[str, Any],
new_alignment: dict[str, Any],
) -> dict[str, Any]:
"""노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옮긴 직선 열쇠 · 옛 정책 없으면 새 정책."""
new_policy = dict(new_alignment.get("policy") or {})
old_policy = old_alignment.get("policy") or {}
if not old_policy:
return new_policy
keys = carry_no_curve_keys(
old_xy,
new_xy,
list(old_policy.get("no_curve_keys") or []),
list(new_policy.get("no_curve_keys") or []),
list(new_alignment.get("base_pvi") or []),
)
return {
**new_policy,
"default_curve_length_m": old_policy.get("default_curve_length_m"),
"no_curve_keys": keys,
}
@@ -0,0 +1,56 @@
"""B05 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
- `initial_no_curve_keys` : 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L
기준 R 이 `initial_straight_radius_m`(500 m)보다 큰(대수차 L/R 안쪽) 꼭짓점 열쇠. 정책
`no_curve_keys` 로 저장돼 그 꼭짓점엔 종단곡선을 두지 않음 · 사용자가 그 꼭짓점을 고치거나 R 을
넣으면 다시 곡선(`build_curves`). 꼭짓점 높이는 그대로.
- `vertical_curve_shortfalls` : 설계속도별 종단곡선 최소 R · L 미달(지식DB 별표2 마.) — 막지 않고
경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대.
짝: TS `logic/B05_Profile_Profile_CurveLaw.ts` — 거울 시험 `test_432_1_curve_law.py`.
"""
from typing import Any
import numpy as np
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
def initial_no_curve_keys(
base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None
) -> tuple[str, ...]:
"""안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것의 열쇠(소수 3자리)."""
if not curve_length_m or len(base_s) < 3:
return ()
limit = float(curve_length_m) / INITIAL_STRAIGHT_RADIUS_M
spans = base_s[1:] - base_s[:-1]
grades = (base_z[1:] - base_z[:-1]) / np.where(spans > 0, spans, 1.0)
keys = []
for index in range(1, len(base_s) - 1):
if abs(float(grades[index] - grades[index - 1])) < limit:
keys.append(f"{float(base_s[index]):.3f}")
return tuple(keys)
def vertical_curve_shortfalls(
curves: list[dict[str, Any]], min_radius_m: float, min_length_m: float
) -> list[str]:
"""법정 최소 R · L 을 못 채운 종단곡선 경고 글(누가거리 순) — 기준 0 이면 판정 안 함."""
if min_radius_m <= 0 and min_length_m <= 0:
return []
out = []
for curve in curves:
if curve.get("omitted") or curve.get("skip_allowed"):
continue
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
out.append(
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
)
return out
@@ -27,6 +27,7 @@
"""
from collections import Counter
from dataclasses import replace
from typing import Any
import numpy as np
@@ -45,6 +46,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
chainage_key,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
grade_limits,
integration_weights,
@@ -181,7 +183,8 @@ def design_ground_following_profile(
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
- **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는
- **변화점마다 종단곡선을 넣는다**(432: 기본 L 기준 R > 500 m 꼭짓점은 빼고 · 정책
`no_curve_keys`). 직선과 직선 사이에는 반드시 호가 있고, 호는
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
@@ -207,6 +210,9 @@ def design_ground_following_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
@@ -234,6 +240,10 @@ def design_ground_following_profile(
if note:
warnings.append(note)
# 초기값 직선(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 = 지반고 그대로)
policy = replace(
policy, no_curve_keys=initial_no_curve_keys(base_s, base_z, policy.default_curve_length_m)
)
base_z = pass_through(base_s, base_z, policy)
alignment = build_alignment(
@@ -289,6 +299,9 @@ def design_pipe_anchored_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
@@ -406,6 +419,9 @@ def design_alignment_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
+147 -72
View File
@@ -28,9 +28,11 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
`ends_over_limit`(종단으로도 못 풂 · 화면 알림).
· 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
@@ -52,29 +54,31 @@ from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
GRADE_SMOOTH_M,
SAMPLE_M,
STATION_M,
_resample,
route_metrics,
smoothed_ground,
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
pick,
choose,
hard_limits,
settle,
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_reference,
attach_profile,
cover_shortfall,
plain_metrics,
@@ -196,6 +200,7 @@ def read_initial_choice(project_root: Path) -> dict[str, Any]:
`criteria` 는 **사용자가 겹쳐 쓴 값만**(비운 칸은 안 담김) — 나머지는 프로젝트 기본값이라
화면이 다시 열 때 겹친 칸만 채우고 나머지는 placeholder(기본값)로 비워 둔다.
`with_profile` = [적용] 때 「종단 반영」(없으면 켬 · 420).
"""
try:
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
@@ -205,9 +210,15 @@ def read_initial_choice(project_root: Path) -> dict[str, Any]:
"logic": logic,
"weights": resolve_weights(logic, data.get("weights")),
"criteria": criteria if isinstance(criteria, dict) and criteria else None,
"with_profile": data.get("with_profile") is not False,
}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return {"logic": "follow", "weights": resolve_weights("follow", None), "criteria": None}
return {
"logic": "follow",
"weights": resolve_weights("follow", None),
"criteria": None,
"with_profile": True,
}
def write_initial_choice(
@@ -215,6 +226,7 @@ def write_initial_choice(
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
with_profile: bool = True,
) -> dict[str, Any]:
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값.
@@ -225,6 +237,7 @@ def write_initial_choice(
"logic": logic,
"weights": resolve_weights(logic, weights),
"criteria": criteria or None,
"with_profile": bool(with_profile),
}
path = _choice_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
@@ -288,6 +301,7 @@ def generate_initial_route(
design_speed: int = 20,
pipes: list[tuple[float, float, float]] | None = None,
with_profile: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
@@ -295,9 +309,10 @@ def generate_initial_route(
후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을
안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다.
토공 · 공사비 갈래는 후보를 노선 횡단 토공(`..._CrossEarth` · 상한 누른 계획선)으로 줄 세워 위
몇 개에만 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 고른다(419) — 편집 창 추천 점수 · 미리
보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 계획고 지반고 횡단 토공으로 고른다.
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
@@ -311,59 +326,38 @@ def generate_initial_route(
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
) -> dict[str, Any]:
return route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
# 못 지킨 곳 수 = R · 계류 · 측점 — 원지반 기울기는 참고만(목록엔 남음 · 419)
return hard_limits(
route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
)
smooth_z = RegularGridInterpolator(
(terrain.y, terrain.x),
smoothed_ground(terrain, grade_smooth),
bounds_error=False,
fill_value=None,
)
def grade_excess(points: list[tuple[float, float]]) -> float:
# 측점 맞춤(351-2)이 옮긴 조각 — 20 m 사이 고른 지반 기울기(비교값과 같은 잣대)가 상한을
# 넘은 만큼. 20 m 보다 짧으면 양 끝 사이.
xy, chain = _resample(points, SAMPLE_M)
z = smooth_z(xy[:, ::-1])
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
if span < 1:
return 0.0
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade)
def fit(outline: Any, follow: bool = False) -> Any:
# R = 일반측점(383-1) — 갈래는 옮긴 조각이 원래보다 더 가파르면 그 곡선만 그 자리(추적은
# 제약을 안 따짐 · 새 초기 노선 `snapped_initial` 과 같은 잣대).
moved = snap_curves_to_stations(
outline, min_radius_m, excess=None if follow else grade_excess
)
def fit(outline: Any, guard: Any = None) -> Any:
# R = 일반측점(383-1) — 갈래 후보는 직접 잼(`guard` · 406 · 거리 한도 없음) · 추적은 제약을
# 안 따짐(새 초기 노선 `snapped_initial` 과 같은 잣대 · 거리 한도 그대로).
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
unsnapped: dict[int, Any] = {}
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(
route, lambda line: fit(line, True), measure, unsnapped, bound=None, follow=True
)
return settle(route, fit, measure, bound=None, follow=True)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -375,8 +369,64 @@ def generate_initial_route(
design_speed=design_speed,
design_curves=[curve.as_dict() for curve in route[0].curves],
exact=exact,
standard=standard,
)
def quick(route: Any) -> None:
profiled(route, exact=False)
def cross(line: Any, at: Any = None, design_z: Any = None) -> dict[str, Any]:
return cross_earthwork(
line.vertices,
at,
design_z,
terrain,
standard,
design_curves=[curve.as_dict() for curve in line.curves],
)
def route_earth(line: Any) -> float:
# 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음)
try:
found = cross(line)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float:
# 바뀐 자리 측점만 — 뒤 측점은 같은 선을 따라 밀린 것뿐(옛 선 끝 s1 → 새 선 s1 + 연장 차)
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def cross_score(route: Any, capped: bool) -> float:
# 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단
held = pool.scores if pool is not None else {}
key = (id(route[1]), capped)
if key not in held:
try:
found = cross(route[0])
if capped:
at = np.asarray([row["chainage_m"] for row in found["per_station"]])
ground = np.asarray([row["ground_m"] for row in found["per_station"]])
found = cross(route[0], at, _reference(ground, np.diff(at), max_grade))
held[key] = found["cut"] + found["fill"]
except ValueError:
held[key] = math.inf
return held[key]
bound = None
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
@@ -399,24 +449,33 @@ def generate_initial_route(
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
_POOLS.pop(next(iter(_POOLS)))
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit)
# 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419)
_POOLS[key] = build_pool(
graph,
follow_line(),
max_grade,
min_radius_m,
measure,
fit,
cross=RouteEarth(route_earth, merge_delta),
)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
if with_profile:
for route in [*pool.candidates, pool.follow]:
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
outline, metrics = pick(target, pool.candidates, with_profile)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선
# 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른
# 판) · 고른 하나만 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None)
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
line, values = route
# 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판 그대로 둬 다음 고르기가 같은 잣대끼리
# 견줌(앞서 보인 후보만 정밀이면 캐시 순서 따라 고르는 선이 바뀜 · 419-2)
line, values = route[0], dict(route[1])
if with_profile:
profiled(route)
profiled((line, values))
if pipes and "error" not in values["profile"]:
values["profile"].update(cover_shortfall(values["profile"], line.vertices, pipes))
else:
@@ -431,6 +490,7 @@ def generate_initial_route(
}
shown = finish(snapped)
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
"logic": mode,
@@ -439,10 +499,13 @@ def generate_initial_route(
# 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
**shown,
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
"unsnapped": None if raw is None else finish(raw),
# 시종점 높이 차 ÷ 노선 연장(%) — 상한 넘으면 종단으로도 못 풂(노선을 늘려야 함 · 화면 알림)
"ends_grade_pct": ends,
"ends_over_limit": ends > max_grade * 100.0 + 1e-6,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline, with_profile),
"similar": None
if pool is None
else similar(pool, target, outline, with_profile, quick, cross_score),
"with_profile": with_profile,
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
@@ -459,6 +522,18 @@ def generate_initial_route(
}
def ends_grade(expected: list[tuple[float, float]], terrain: Terrain, length_m: float) -> float:
"""시종점 지반 높이 차 ÷ 노선 연장(%) — 균형 종단이 끝점을 지반고에 두므로 이 값이 상한을 넘으면
어떤 계획선도 상한 안에 못 듦(419 · 원지반 기울기를 참고로 돌린 뒤 남는 알림)."""
from scipy.interpolate import RegularGridInterpolator
sample = RegularGridInterpolator(
(terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None
)
z = sample([expected[0][::-1], expected[-1][::-1]])
return round(abs(float(z[1] - z[0])) / max(length_m, 1e-9) * 100.0, 2)
def _weighted_end(weights: dict[str, float]) -> str:
"""비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색."""
for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")):
@@ -0,0 +1,177 @@
"""노선 추천 횡단 토공(PLAN 420 · 요구 254).
측점마다 공식 횡단 엔진(B06 `compute_cross_design` — [저장] 정본과 같은 파이썬)으로 절 · 성
단면적을 재고 평균단면법으로 토량을 낸다. 띠 가늠(폭 4 m · 비탈 뺌 · `..._Metrics`)을 갈음하는
추천 토공 — 후보 줄 세움 · 합치기 판정 · 결과 표 절 · 성.
계산 자리 = 서버 단독(③). 측점 횡단 뜨기 · 단면적은 균형 종단(`..._Profile`)과 같은 함수 —
종단 반영 끔 = 계획고 = 지반고([적용] 뒤 1차 종단과 같음).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_station_areas,
route_sections,
)
if TYPE_CHECKING:
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain
from common_util.common_util_surface_sampler import SurfaceElevationSampler
def _end_area_weights(at: np.ndarray) -> np.ndarray:
"""평균단면법 무게 — 측점마다 앞 · 뒤 구간 반씩(Σ (A_i + A_i+1) / 2 · L = Σ A_i · w_i)."""
spans = np.diff(at)
weight = np.zeros(len(at))
weight[:-1] += spans / 2.0
weight[1:] += spans / 2.0
return weight
def _areas(
line: list[tuple[float, float]],
chainages: Any,
design_z: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None,
design_curves: list[dict[str, Any]] | None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""(누가거리, 지반고, 계획고, [절 자연 · 절 다짐 · 성] ㎡ 행렬)."""
at, ground, sections, modes, widen = route_sections(line, terrain, design_curves, at=chainages)
plan = ground if design_z is None else np.asarray(design_z, dtype=np.float64)
if plan.shape != at.shape:
raise ValueError("계획고 수가 측점 수와 다릅니다.")
rows = np.asarray(
[
_station_areas(sections[i], modes[i], float(plan[i]), widen[i], standard)
for i in range(len(at))
]
).reshape(-1, 3)
return at, ground, plan, rows
def cross_earthwork(
line: list[tuple[float, float]],
chainages: Any,
design_z: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
*,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""노선 하나의 횡단 토공 — `{cut, cut_compacted, fill, per_station}`(㎥ · 절 = 자연 상태).
`chainages` = 잴 누가거리(None = 20 m 측점 + 종점 · 균형 종단과 같은 틀) · `design_z` =
그 측점 계획고(None = 지반고 · 종단 반영 끔) · `standard` = 프로젝트 저장 표준 횡단면 설정값
(None = 설정 기본값) · `design_curves` = 곡선표(확폭 · 없으면 측점별 실측 반경)."""
at, ground, plan, rows = _areas(line, chainages, design_z, terrain, standard, design_curves)
weight = _end_area_weights(at)
cut, cut_c, fill = (float((weight * rows[:, k]).sum()) for k in range(3))
return {
"cut": round(cut, 1),
"cut_compacted": round(cut_c, 1),
"fill": round(fill, 1),
"per_station": [
{
"chainage_m": round(float(s), 2),
"ground_m": round(float(g), 3),
"plan_m": round(float(p), 3),
"cut_m2": round(float(r[0]), 4),
"cut_compacted_m2": round(float(r[1]), 4),
"fill_m2": round(float(r[2]), 4),
}
for s, g, p, r in zip(at, ground, plan, rows)
],
}
def cross_earthwork_delta(
before: list[tuple[float, float]] | dict[str, Any],
after: list[tuple[float, float]],
changed_chainages: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
*,
before_chainages: Any = None,
z_before: Any = None,
z_after: Any = None,
design_curves_before: list[dict[str, Any]] | None = None,
design_curves_after: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""바뀐 측점만 횡단으로 잰 토공 차(새 − 옛 · ㎥) — 합치기 판정(3~6 측점).
측점 하나 = `STATION_M` 몫(가운데 측점 평균단면법 무게). `before` = 옛 노선 정점 또는 그
노선의 `cross_earthwork` 결과(그 측점 단면적을 다시 안 셈 · 누가거리 가장 가까운 것) ·
`before_chainages` = 옛 노선에서 잴 누가거리(없으면 `changed_chainages`) · `z_*` = 그 측점
계획고(None = 지반고). 결과 `cut` · `fill` · `earth` = 차 · `before` 가 결과면 `*_total` =
고친 뒤 노선 전체 어림(옛 합 + 차)."""
at_after = np.asarray(changed_chainages, dtype=np.float64).reshape(-1)
at_before = at_after if before_chainages is None else np.asarray(before_chainages, float)
if at_after.size == 0:
new = old = np.zeros((0, 3))
else:
new = _areas(after, at_after, z_after, terrain, standard, design_curves_after)[3]
if isinstance(before, dict):
rows = before["per_station"]
held = np.asarray([row["chainage_m"] for row in rows])
pick = [int(np.argmin(np.abs(held - s))) for s in at_before]
old = np.asarray(
[[rows[i]["cut_m2"], rows[i]["cut_compacted_m2"], rows[i]["fill_m2"]] for i in pick]
).reshape(-1, 3)
else:
old = _areas(before, at_before, z_before, terrain, standard, design_curves_before)[3]
cut, cut_c, fill = (float(STATION_M * (new[:, k].sum() - old[:, k].sum())) for k in range(3))
out = {
"cut": round(cut, 1),
"cut_compacted": round(cut_c, 1),
"fill": round(fill, 1),
"earth": round(cut + fill, 1),
"stations": int(at_after.size),
}
if isinstance(before, dict):
out["cut_total"] = round(before["cut"] + cut, 1)
out["fill_total"] = round(before["fill"] + fill, 1)
return out
def attach_cross_earth(
result: dict[str, Any],
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
) -> None:
"""추천 결과 표 절 · 성 = 횡단 값(420) — 종단 켬 = 균형 종단 횡단 ㎥ · 끔 = 계획고 지반고.
띠 가늠은 `band_cut_m3` · `band_fill_m3` 로 남긴다. 맞추기 전 선(`unsnapped`)도 같이."""
for item in (result, result.get("unsnapped")):
if not item or not item.get("planned"):
continue
metrics = item["metrics"]
if "band_cut_m3" in metrics:
continue
profile = metrics.get("profile")
if profile and "error" not in profile:
cut, fill = float(profile["cut_m3"]), float(profile["fill_m3"])
else:
try:
earth = cross_earthwork(
[tuple(p) for p in item["planned"]],
None,
None,
terrain,
standard,
design_curves=item.get("curves") or None,
)
except ValueError: # 지반을 못 뜬 노선 — 띠 가늠 그대로
continue
cut, fill = earth["cut"], earth["fill"]
metrics["band_cut_m3"] = metrics.get("cut_m3")
metrics["band_fill_m3"] = metrics.get("fill_m3")
metrics["cut_m3"] = round(cut, 1)
metrics["fill_m3"] = round(fill, 1)
metrics["surplus_m3"] = round(cut - fill, 1)
@@ -0,0 +1,314 @@
"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(PLAN 406 · 요구 251-2).
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 큰 R 판 뒤 · 측점 맞춤 앞에서 부름. 노선 추천
후보만 — 손 편집 · 옛 노선 · 예상노선 추적(전처리 초기값) · 공용 선 짜기 짝은 안 거침.
합치기(`merge_same_side`) = 같은 쪽으로 도는 이웃 꺾임점 둘 → 바깥 두 직선 교점 하나(큰 단곡선 ·
사이 직선 없어짐) · 되풀이. 반대 방향(S자) 이웃은 안 합침(사이 직선 남김). 접선 규칙은 그대로
(앞뒤 직선 절반 · 398-4 안 함). 고르는 차례 = 지금 선에서 덜 벗어나는 합치기부터.
거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`):
· 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상.
· 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선
이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀).
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
· 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다
`TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 =
바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠).
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
"""
from __future__ import annotations
import copy
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import numpy as np
import shapely
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CORRIDOR_M,
ROAD_WIDTH_M,
STREAM_CROSS_COS,
SearchGrid,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _corner, _Reference
from common_util.common_util_route_polyline import (
PlannedPolyline,
_inner_angle_deg,
_line_intersection,
_turn_sign,
build_planned_polyline,
)
Xy = tuple[float, float]
Route = tuple[Any, dict[str, Any]]
# 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각)
EARTH_TOL = 0.05
EARTH_FLOOR_M3 = 5.0
# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254)
TOTAL_EARTH_TOL = 0.10
# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터)
RADIUS_STEPS = 12
# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t)
# 놓기 하나는 직접 잼을 지나야 함(측점 맞춤이 IP 를 들어오는 접선 따라 밂 · 합치기 풀기 없음)
PLACE_OFFSETS_M = (0.0, 5.0, 10.0, 15.0)
# 잰 조각 기억 수 — 측점 맞춤 단계 · 되돌림이 같은 조각을 자주 다시 잼
SEEN_LIMIT = 200_000
def _mid(a: Xy, b: Xy) -> Xy:
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
class Guard:
"""거리 한도 대신 직접 잼 — 옮긴 조각이 같은 자리 옛 조각보다 나빠지지 않나(탐색 격자 잣대)."""
def __init__(self, grid: SearchGrid, limit: float) -> None:
self.grid, self.limit = grid, limit
stream = np.where(np.isfinite(grid.d_stream), grid.d_stream, 1e9)
# 겹쳐 놓은 칸 값(잼마다 보간기를 새로 안 짬)
expected = np.minimum(grid.d_expected, CORRIDOR_M)
self.fields = np.stack(
[grid.z, grid.grad_x, grid.grad_y, grid.away_x, grid.away_y, stream, expected]
)
# 예상노선 이격도 잼(비중 계산 · 예상 몫 있을 때 — 그 갈래 잣대 · `keeping_offset`)
self.offset = False
self.seen: dict[bytes, tuple[float, float, float, bool]] = {} # 같은 조각 다시 안 잼
def _sample(self, xy: np.ndarray) -> np.ndarray:
"""칸 값 [7, n] — 쌍선형(격자 밖은 가장자리 칸)."""
grid, fields = self.grid, self.fields
rows_n, cols_n = fields.shape[1:]
r = np.clip((xy[:, 1] - grid.ys[0]) / grid.cell, 0.0, rows_n - 1.0)
c = np.clip((xy[:, 0] - grid.xs[0]) / grid.cell, 0.0, cols_n - 1.0)
r0 = np.minimum(r.astype(int), rows_n - 2)
c0 = np.minimum(c.astype(int), cols_n - 2)
fr, fc = r - r0, c - c0
return (
fields[:, r0, c0] * (1 - fr) * (1 - fc)
+ fields[:, r0 + 1, c0] * fr * (1 - fc)
+ fields[:, r0, c0 + 1] * (1 - fr) * fc
+ fields[:, r0 + 1, c0 + 1] * fr * fc
)
def keeping_offset(self) -> Guard:
"""예상노선 이격도 안 늘게 재는 판(잰 조각 기억은 함께 씀)."""
twin = copy.copy(self)
twin.offset = True
return twin
def _look(self, points: list[Xy]) -> tuple[float, float, float, bool, float]:
"""(기울기 넘은 만큼 · 토공 가늠 ㎥ · 계류 버퍼 안 나란히 m · 복도 안 · 예상노선 이격 ㎡) —
탐색 몫과 같은 셈(`SearchGrid.objectives` 이격 · 토공 · 계류 몫) · 기울기는 20 m 사이 고른
지반(비교값 잣대)."""
key = np.round(np.asarray(points, dtype=float), 3).tobytes()
if key in self.seen:
return self.seen[key]
if len(self.seen) >= SEEN_LIMIT:
self.seen.clear()
grid = self.grid
xy, chain = _resample(points, SAMPLE_M)
if len(xy) < 2:
return 0.0, 0.0, 0.0, True, 0.0
z = self._sample(xy)[0]
span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1)
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
excess = max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - self.limit)
cols = np.rint((xy[:, 0] - grid.xs[0]) / grid.cell).astype(int)
rows = np.rint((xy[:, 1] - grid.ys[0]) / grid.cell).astype(int)
rows_n, cols_n = grid.corridor.shape
inside = bool(
((cols >= 0) & (cols < cols_n) & (rows >= 0) & (rows < rows_n)).all()
and grid.corridor[rows, cols].all()
)
step = np.diff(xy, axis=0)
length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9)
_, gx, gy, ax, ay, stream, offset = self._sample(0.5 * (xy[1:] + xy[:-1]))
over = np.maximum(0.0, np.abs(np.diff(z)) / length - self.limit)
cross = np.abs(-gx * step[:, 1] + gy * step[:, 0]) / length
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
size = np.hypot(ax, ay)
across = np.abs(ax * step[:, 0] + ay * step[:, 1]) >= STREAM_CROSS_COS * length * size
along = (stream < grid.stream_offset_m) & ~across & (size > 1e-6)
found = (
excess,
float((earth * length).sum()),
float(length[along].sum()),
inside,
float((offset * length).sum()),
)
self.seen[key] = found
return found
def judge(self, local: list[Xy], reference: list[Xy]) -> tuple[bool, tuple[bool, float, float]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset`
이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥)."""
_, earth, along, inside, offset = self._look(local)
_, old_earth, old_along, _, old_offset = self._look(reference)
passed = (
inside
and along <= old_along + 1e-6
and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3
and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3)
)
cost = (not inside, round(max(0.0, along - old_along), 3), earth - old_earth)
return passed, cost
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
return self.judge(local, reference)[0]
@staticmethod
def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None:
"""곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙)."""
corner = _corner(before, apex, after, radius)
if corner is None:
return None
return [_mid(before, apex), *corner.arc, _mid(apex, after)]
def curve_ok(self, before: Xy, apex: Xy, after: Xy, radius: float, reference: list[Xy]) -> bool:
local = self.piece(before, apex, after, radius)
return local is not None and self.ok(local, reference)
def _join(a: Xy, p: Xy, q: Xy, b: Xy) -> Xy | None:
"""같은 쪽으로 도는 이웃 꺾임 p · q → 바깥 두 직선(a-p · q-b) 교점 · S자 · 뒤집힘이면 None."""
side = _turn_sign(a, p, q)
if side == 0 or side != _turn_sign(p, q, b):
return None
cross = _line_intersection(a, p, q, b)
if cross is None:
return None
ahead = (cross[0] - a[0]) * (p[0] - a[0]) + (cross[1] - a[1]) * (p[1] - a[1]) > 0 and (
b[0] - cross[0]
) * (b[0] - q[0]) + (b[1] - cross[1]) * (b[1] - q[1]) > 0
return (round(cross[0], 4), round(cross[1], 4)) if ahead else None
def _radius(
guard: Guard, before: Xy, apex: Xy, after: Xy, min_radius_m: float, reference: _Reference
) -> tuple[float, list[Xy]] | None:
"""접선 자리 안 · 직접 잼을 지나는 가장 큰 R(최소 R 이상)과 그 조각 · 없으면 None.
MC 가 측점 사이 어디에 떨어져도 교점을 들어오는 접선 따라 밀어 놓을 수 있어야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 MC 를 늘 측점에)."""
u = (
(apex[0] - before[0]) / math.dist(before, apex),
(apex[1] - before[1]) / math.dist(before, apex),
)
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
if half_tan <= 1e-9:
return None
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
if room < min_radius_m:
return None
steps = np.geomspace(room, min_radius_m, RADIUS_STEPS)
for radius in sorted({max(float(math.floor(r)), min_radius_m) for r in steps}, reverse=True):
local = guard.piece(before, apex, after, radius)
if local is None or not guard.ok(local, reference.window(local)):
continue
seen: dict[float, bool] = {0.0: True}
def placed(d: float) -> bool:
if d not in seen:
at = (apex[0] + u[0] * d, apex[1] + u[1] * d)
moved = guard.piece(before, at, after, radius)
seen[d] = moved is not None and guard.ok(moved, reference.window(moved))
return seen[d]
if all(placed(-t) or placed(STATION_M - t) for t in PLACE_OFFSETS_M):
return radius, local
return None
def _earth(metrics: dict[str, Any]) -> float:
return metrics["cut_m3"] + metrics["fill_m3"]
@dataclass
class RouteEarth:
"""합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이."""
total: Callable[[PlannedPolyline], float]
# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥)
delta: Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float]
def merge_same_side(
route: Route,
guard: Guard,
min_radius_m: float,
measure: Callable[[PlannedPolyline], dict[str, Any]],
route_earth: RouteEarth | None = None,
) -> Route:
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.
멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의
1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이
누적) · 없으면 노선 잣대 토공(`measure` 절 + 성 · 띠 가늠)."""
outline, base = route
if len(outline.curves) < 2:
return route
points, flags, radii = _flat(outline)
points = [(round(x, 4), round(y, 4)) for x, y in points]
current = route
earth = (
_earth(base) if route_earth is None else route_earth.total(outline)
) # 지금 선 토공 — 차이를 누적
limit = earth * (1 + TOTAL_EARTH_TOL)
blocked: set[tuple[Xy, ...]] = set()
while True:
reference = _Reference(current[0].vertices)
found: list[tuple[float, int, Xy, float]] = []
for k in range(1, len(points) - 2):
key = tuple(points[k - 1 : k + 3])
if key in blocked:
continue
a, p, q, b = key
cross = _join(a, p, q, b)
fitted = None if cross is None else _radius(guard, a, cross, b, min_radius_m, reference)
if fitted is None:
blocked.add(key)
continue
radius, local = fitted
gap = float(
shapely.hausdorff_distance(
LineString(local), LineString(reference.window(local)), densify=0.1
)
)
found.append((gap, k, cross, radius, local))
merged = None
for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]):
trial_points = points[:k] + [cross] + points[k + 2 :]
trial_flags = flags[:k] + [True] + flags[k + 2 :]
trial_radii = radii[:k] + [radius] + radii[k + 2 :]
line = build_planned_polyline(
trial_points,
min_radius_m=min_radius_m,
simplify=False,
curve_flags=trial_flags,
radii=trial_radii,
)
metrics = measure(line)
if route_earth is None:
total = _earth(metrics)
else:
window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1]))
total = earth + route_earth.delta(current[0], line, (window[0], window[1]))
if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit:
blocked.add(tuple(points[k - 1 : k + 3]))
continue
merged = (trial_points, trial_flags, trial_radii, (line, metrics), total)
break
if merged is None:
return current
points, flags, radii, current, earth = merged
@@ -4,18 +4,23 @@
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할
때 · 막은 기울기 안) — PLAN 23-1.
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
쓸기 최대 기울기).
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
종단 반영 켬 토공 · 공사비(`choose`) = 상한 누른 계획선 토공으로 줄 세운 위 `SHORTLIST` 개만 균형
종단(빠른 판)을 붙여 고름(419).
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
@@ -33,13 +38,14 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
@@ -61,8 +67,9 @@ TARGETS: dict[str, dict[str, float]] = {
"crossings": {"crossing": 100.0},
"cost": {},
}
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른
# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5)
SHORTLIST = 5
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
@@ -80,8 +87,8 @@ TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
# 선 → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any], Any]
# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any, Any], Any]
@dataclass
@@ -101,18 +108,30 @@ class Pool:
follow_cap: float = np.inf
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔)
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에."""
def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
"""원지반 기울기 = 참고만(419) — 못 지킨 곳 수(`over_limit.total`)에서 뺀 잰 값.
기울기 넘은 곳은 목록(`violations` · kind grade)과 `over_limit.grade` 에 참고로 남는다."""
over = metrics["over_limit"]
return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}}
def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1)."""
outline, metrics = route
moved = snap(outline)
if moved is not outline:
raw[id(moved)] = (outline, with_station_misses(metrics, outline))
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
@@ -122,11 +141,16 @@ def _trace(
) -> Route:
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
# 고른 판을 측점에 맞춤(`settle` · 383-1).
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
guard = guard.keeping_offset()
key = (tuple(nodes), tuple(sorted(kw.items())))
if key not in traced:
floor = build_planned_polyline(
@@ -135,7 +159,11 @@ def _trace(
least = (floor, measure(floor, **kw))
path = densify_route(nodes, TRACE_DENSIFY_M)
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted)
larger = (
widen(fitted, path, min_radius_m, guard=guard),
widen(floor, path, min_radius_m, guard=guard),
fitted,
)
chosen = next(
(
route
@@ -144,7 +172,14 @@ def _trace(
),
least,
)
traced[key] = settle(chosen, pool.snap, measure, pool.raw, **kw)
if guard is not None:
line = curve_every_kink(chosen[0], min_radius_m)
if line is not chosen[0]:
chosen = (line, measure(line, **kw))
chosen = merge_same_side(
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
return traced[key]
@@ -170,22 +205,18 @@ def _candidates(
) -> tuple[list[Route], float | None, int]:
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반
기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 ·
목표를 바꿔 찾은 노선들.
"""
graph = pool.graph
found: list[Route] = []
for target in TARGETS:
for step in GRADE_CAP_STEPS:
try:
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
break
try:
nodes = _solve(graph, target, cap=max_grade, strict_stream=True)
except NoRoute:
break # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None, least
@@ -222,9 +253,12 @@ def build_pool(
min_radius_m: float,
measure: Measure,
snap: Snap,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
pool = Pool(graph, snap, follow=follow)
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
@@ -245,14 +279,22 @@ def build_pool(
return pool
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
def similar(
pool: Pool,
mode: str,
outline: Any,
with_profile: bool = True,
profile: Callable[[Route], None] | None = None,
earth: CrossScore | None = None,
) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` · `earth`)."""
here = LineString(outline.vertices)
others = {
"follow": pool.follow,
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
**{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
@@ -319,6 +361,40 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(420)
CrossScore = Callable[[Route, bool], float]
def choose(
mode: str,
candidates: list[Route],
with_profile: bool,
profile: Callable[[Route], None] | None,
earth: CrossScore | None = None,
) -> Route:
"""갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`earth` · 없으면 띠 가늠)으로 줄 세움(419):
종단 반영 켬 = 상한 누른 계획선 횡단 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여
그 사이에서 고름(후보마다 종단을 세우지 않음) · 끔 = 계획고 지반고 횡단으로 바로 고름.
나머지 갈래는 `pick` 그대로."""
if mode not in ("earthwork", "cost"):
return pick(mode, candidates, with_profile)
unique = list({id(item[1]): item for item in candidates}.values()) # 같은 노선 겹침 뺌
def score(item: Route, profiled: bool) -> tuple:
plain = plain_metrics(item[1])
value = _earth(plain) if earth is None else earth(item, profiled)
if mode == "cost": # 공사비 어림의 토공 몫을 횡단 값으로 갈음
value += plain["cost_index"] - _earth(plain)
return (item[1]["over_limit"].get("station", 0) > 0, value)
if not (with_profile and profile is not None):
return min(unique, key=lambda item: score(item, False))
shortlist = sorted(unique, key=lambda item: score(item, True))[:SHORTLIST]
for route in shortlist:
profile(route)
return pick(mode, shortlist, with_profile)
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
`with_profile` 끔 = 원지반 가늠 점수."""
@@ -63,6 +63,8 @@ LAMBDA_STEPS = 14
EXACT_STEPS = 6 # 고른 계획선의 실제 누가토량으로 λ 를 더 좁히는 횟수
PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관으로 봄
GRADE_EPS = 1e-6
# [적용] 종단 꼭짓점 표고 반올림(0.1 mm) 두 끝 몫 — 직선 기울기 누를 때 남기는 여유(m · 419-3)
ROUND_SLACK_M = 1e-4
def _sample(
@@ -85,20 +87,25 @@ def route_sections(
vertices: list[tuple[float, float]],
terrain: Terrain | SurfaceElevationSampler,
design_curves: list[dict[str, Any]] | None = None,
at: Any = None,
) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]:
"""측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭.
샘플은 `CROSS_WIDE_M` 까지 — ±`CROSS_HALF_M` 밖은 지표면이 끊기기 전까지만 담는다."""
`at` = 뜰 누가거리(없으면 20 m 측점 + 종점) · 샘플은 `CROSS_WIDE_M` 까지 — ±`CROSS_HALF_M`
밖은 지표면이 끊기기 전까지만 담는다."""
xy = np.asarray(vertices, dtype=np.float64)
keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9]
xy = xy[keep]
seg = np.hypot(*np.diff(xy, axis=0).T)
chain = np.concatenate([[0.0], np.cumsum(seg)])
total = float(chain[-1])
at = np.arange(0.0, total, STATION_M)
if len(at) > 1 and total - at[-1] < TAIL_MERGE_M:
at = at[:-1]
at = np.append(at, total)
if at is not None:
at = np.clip(np.asarray(at, dtype=np.float64), 0.0, total)
else:
at = np.arange(0.0, total, STATION_M)
if len(at) > 1 and total - at[-1] < TAIL_MERGE_M:
at = at[:-1]
at = np.append(at, total)
index = np.clip(np.searchsorted(chain, at, side="right") - 1, 0, len(seg) - 1)
tangent = np.diff(xy, axis=0)[index] / seg[index][:, None]
left = np.column_stack([-tangent[:, 1], tangent[:, 0]])
@@ -165,10 +172,15 @@ def _widenings(
def _station_areas(
samples: list[dict[str, Any]], mode: str, elevation: float, widen: dict[str, Any]
samples: list[dict[str, Any]],
mode: str,
elevation: float,
widen: dict[str, Any],
standard: dict[str, Any] | None = None,
) -> tuple[float, float, float]:
"""측점 하나 · 계획고 하나의 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡) — 본 설계 기본 설계와
같은 입력. 반폭에서 비탈이 안 닫히면 넓힌 샘플로 다시 셈(본 설계 `extend_unclosed`)."""
같은 입력. 반폭에서 비탈이 안 닫히면 넓힌 샘플로 다시 셈(본 설계 `extend_unclosed`).
`standard` = 프로젝트에 저장된 표준 횡단면 설정값(없으면 설정 기본값 · 420)."""
from B06_Section.B06_Section_Engine_Design import compute_cross_design
def design(pick: list[dict[str, Any]]) -> dict[str, Any]:
@@ -177,6 +189,7 @@ def _station_areas(
elevation,
ground_type=GROUND_TYPE,
section_mode=mode,
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
**widen,
)
@@ -204,6 +217,7 @@ def _area_curves(
ground: np.ndarray,
levels: np.ndarray,
curves: list[dict[str, Any]] | None = None,
standard: dict[str, Any] | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 ㎡, 성토 ㎡) — `AREA_STEP_M` 마디 보간."""
shape = levels.shape
@@ -213,7 +227,9 @@ def _area_curves(
delta = levels[i] - ground[i]
lo = np.floor(delta.min() / AREA_STEP_M) * AREA_STEP_M
at = np.arange(lo, delta.max() + AREA_STEP_M, AREA_STEP_M)
table = np.asarray([_station_areas(samples, mode, float(ground[i] + d), widen) for d in at])
table = np.asarray(
[_station_areas(samples, mode, float(ground[i] + d), widen, standard) for d in at]
)
# 단면적은 계획고에 대해 볼록(대략 2차)이라 직선 보간은 크게 잡힌다 — 단조 3차(PCHIP)
if len(at) > 1:
curve = PchipInterpolator(at, table, axis=0)(delta)
@@ -235,6 +251,52 @@ def _reference(ground: np.ndarray, spans: np.ndarray, max_grade: float) -> np.nd
return 0.5 * (forward + backward)
def _hold(s: np.ndarray, z: np.ndarray, limit: float) -> np.ndarray:
"""변화점 사이 직선 기울기를 `limit` 안으로 — 시종점은 그대로 두고 앞으로 · 뒤로 누름(시종점
사이가 상한 안이면 몇 번 안에 둘 다 맞음). 마디마다 `ROUND_SLACK_M` 남김 — 종단 정본이 표고를
0.1 mm 로 반올림해도 상한 안(검증_sub4 14.001 %)."""
z = z.copy()
start, end = z[0], z[-1]
rise = np.maximum(limit * np.diff(s) - ROUND_SLACK_M, 0.0)
for _ in range(4):
z[0] = start
for i in range(1, len(z)):
z[i] = min(max(z[i], z[i - 1] - rise[i - 1]), z[i - 1] + rise[i - 1])
z[-1] = end
for i in range(len(z) - 2, -1, -1):
z[i] = min(max(z[i], z[i + 1] - rise[i]), z[i + 1] + rise[i])
if abs(z[0] - start) < 1e-9:
break
return z
def held_pass_through(
base_s: np.ndarray, stations: list[float], plan: list[float], policy: Any
) -> np.ndarray:
"""균형 종단(측점 · 계획고)을 1차 변화점에 옮긴 꼭짓점 표고 — 종단곡선을 넣어도 측점마다
계획고를 지나게 꼭짓점을 미는 `pass_through` 와 같은 되풀이에 직선 기울기 상한을 함께 둠(419-3).
밀기만 하면 꼭짓점 사이 직선이 균형 종단 기울기보다 가팔라짐(검증_sub4 14 % → 14.47 %).
상한 = 균형 종단 측점 사이 가장 센 기울기(이미 상한 안 · 화면 겹침 상한도 그대로)."""
from B05_Profile.B05_Profile_Engine_Grade_Alignment import build_curves, evaluate
stations_a, plan_a = np.asarray(stations, float), np.asarray(plan, float)
limit = float(np.abs(np.diff(plan_a) / np.diff(stations_a)).max())
# 계획고 1 mm 반올림(`plan_m`)으로 상한을 살짝 넘은 몫은 상한으로
legal = policy.max_grade_pct / 100.0
limit = legal if legal <= limit <= legal + 1e-4 else limit + GRADE_EPS
target = np.interp(base_s, stations_a, plan_a)
base_z = _hold(base_s, target, limit)
for _ in range(12):
curves, _warnings = build_curves(base_s, base_z, policy)
error = target - evaluate(base_s, base_z, curves, base_s)
error[0] = error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = _hold(base_s, base_z + error, limit)
return base_z
def _dp(
levels: np.ndarray, cost: np.ndarray, spans: np.ndarray, max_grade: float, delta_max: float
) -> np.ndarray | None:
@@ -277,11 +339,13 @@ def balanced_profile(
design_speed: int = 20,
curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량.
시점 · 종점은 지반고(확정 체인 기본 오프셋 0 과 같음) — 그 길이 없을 때만 풀어 다시 찾는다.
`exact` 끄면 보간값 그대로(후보 줄 세우기 — 빠름) · 켜면 고른 선을 바로 셈해 누가토량을 맞춘다.
`standard` = 프로젝트 저장 표준 횡단면 설정값(없으면 설정 기본값).
"""
started = time.perf_counter()
spans = np.diff(chainage)
@@ -291,7 +355,7 @@ def balanced_profile(
offsets = np.arange(-LEVEL_RANGE_M, LEVEL_RANGE_M + 1e-9, LEVEL_STEP_M)
levels = _reference(ground, spans, max_grade)[:, None] + offsets[None, :]
cut, cut_c, fill = (
weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves)
weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves, standard)
)
criteria = (
FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(design_speed)
@@ -312,7 +376,9 @@ def balanced_profile(
plan = levels[np.arange(len(chainage)), result["path"]]
rows = np.asarray(
[
_station_areas(sections[i], modes[i], float(plan[i]), curves[i] if curves else {})
_station_areas(
sections[i], modes[i], float(plan[i]), curves[i] if curves else {}, standard
)
for i in range(len(chainage))
]
)
@@ -423,6 +489,7 @@ def route_profile(
design_speed: int,
design_curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`.
@@ -439,6 +506,7 @@ def route_profile(
design_speed=design_speed,
curves=curves,
exact=exact,
standard=standard,
)
@@ -496,6 +564,7 @@ def attach_profile(
design_speed: int,
design_curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> None:
"""후보 비교값에 균형 종단을 붙이고 토공 점수를 그것으로 바꾼다(묶음 캐시에 남음).
@@ -513,6 +582,7 @@ def attach_profile(
design_speed=design_speed,
design_curves=design_curves,
exact=exact,
standard=standard,
)
except ValueError as error:
metrics["profile"] = {"error": str(error)}
@@ -6,6 +6,12 @@
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
추천 후보(`guard` 줌 · PLAN 406)는 거리 한도 대신 직접 잼 — 곡선 조각이 같은 자리 탐색 경로보다
복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이
`GUARD_STEPS` 벌 · 1 m 내림).
꺾임만(`curve_every_kink` · 419) — 추천 후보에 R 없는 교점을 안 남김(곡선 또는 교점 지움).
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
@@ -13,6 +19,7 @@
from __future__ import annotations
import math
from typing import Any
import numpy as np
from shapely.geometry import LineString, Point
@@ -25,6 +32,8 @@ from common_util.common_util_route_polyline import (
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
Xy = tuple[float, float]
# 직접 잼 R 후보 수(자리 R ~ 맞춘 R · 큰 것부터)
GUARD_STEPS = 12
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
@@ -44,6 +53,30 @@ def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float |
return points, flags, radii
def curve_every_kink(outline: PlannedPolyline, min_radius_m: float) -> PlannedPolyline:
"""꺾임만(R 없는 교점 · 삼각) 없앤 선(419) — 최소 R 원호가 들어가면 곡선(내각 155° 이상도) ·
아니면 교점 지움(곧게). 꺾임만이 없으면 받은 그대로."""
points, flags, radii = _flat(outline)
if all(flags[1:-1]):
return outline
index = 1
while index < len(points) - 1:
if flags[index]:
index += 1
continue
before, apex, after = points[index - 1 : index + 2]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
room = min(math.dist(before, apex), math.dist(apex, after)) / 2
if half_tan > 1e-9 and room / half_tan >= min_radius_m:
flags[index] = True
index += 1
else:
del points[index], flags[index], radii[index]
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
@@ -77,8 +110,12 @@ def widen(
path: list[Xy],
min_radius_m: float,
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
*,
guard: Any = None,
) -> PlannedPolyline:
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.
`guard` 를 주면 이탈 한도 대신 직접 잼(`..._Merge.Guard.curve_ok`)을 지나는 가장 큰 R."""
if not outline.curves:
return outline
points, flags, radii = _flat(outline)
@@ -102,6 +139,20 @@ def widen(
samples = coords[(along >= min(ends)) & (along <= max(ends))]
if not candidates.size or not len(samples):
continue
if guard is not None:
if len(samples) < 2:
continue
steps = np.geomspace(room, float(radii[index]), GUARD_STEPS)
reference = [tuple(p) for p in samples]
radii[index] = next(
(
r
for r in sorted({float(math.floor(r)) for r in steps}, reverse=True)
if r > radii[index] and guard.curve_ok(before, apex, after, r, reference)
),
radii[index],
)
continue
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
if ok.size:
radii[index] = float(ok[0])
@@ -20,6 +20,17 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
그 전에 이웃 곡선과 한 측점을 다투면 덜 꺾인 쪽을 끔 — 꺾임점째 빼거나 꺾임만(364-5).
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
· 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4).
예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로).
고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점
하나로 펴서(`flattenServerPlan`) [확인] 때 다시 짜는 입력과 같아 저장 뒤에도 MC 가 측점 위(TS 짝
불필요). 누가거리는 짜인 선의 정점(원호 5° 현)을 따라 잼 — 측점 생성과 같은 잣대.
@@ -30,6 +41,7 @@ from __future__ import annotations
import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass, replace
from typing import Any
import numpy as np
import shapely
@@ -293,30 +305,43 @@ class _Snapper:
min_radius_m: float,
max_gap_m: float,
excess: Excess | None,
guard: Any = None,
) -> None:
points, flags, radii = _flat(outline)
points = [(round(x, 4), round(y, 4)) for x, y in points] # 화면이 펴는 자릿수
self.start = _State(_Plan(points, flags, radii), 1, points[0], 0.0, 0.0, 0.0)
self.reference = _Reference(outline.vertices)
self.min_radius = min_radius_m
self.max_gap = max_gap_m
self.max_gap = math.inf if guard is not None else max_gap_m
self.excess = excess
self.guard = guard
self.keep = False
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
def _steeper(self, local: list[Xy]) -> bool:
"""맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나."""
"""맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나(직접 잼이면 그 잼 전부)."""
if self.guard is not None:
return not self.guard.ok(local, self.reference.window(local))
if self.excess is None:
return False
return self.excess(local) > self.excess(self.reference.window(local)) + 1e-6
def _far(self, local: list[Xy]) -> bool:
"""원래 선에서 이탈 한도 밖(직접 잼이면 그 잼)."""
if self.guard is not None:
return self._steeper(local)
return self.reference.gap(local) > self.max_gap
@staticmethod
def _kink(state: _State, plan: _Plan) -> _State:
at = plan.points[state.k]
end_s = state.end_s + math.dist(state.end_xy, at)
return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed)
def _placements(self, state: _State, plan: _Plan) -> Iterator[_State]:
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터."""
def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]:
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터.
직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순."""
k = state.k
before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1]
start = _try(state, plan, k, at, plan.radii[k] or self.min_radius)
@@ -335,7 +360,7 @@ class _Snapper:
max(lowest, STATION_M * math.floor(mc / STATION_M)),
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
}
found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = []
found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = []
for station in stations:
for radius in radii:
solved = _solve(state, plan, k, station, radius)
@@ -344,13 +369,23 @@ class _Snapper:
new_at, new_corner, new_mc = solved
# 뒤 직선 절반에 여유를 남긴 것 먼저 — 다음 IP 가 뒤로도 밀리게
tight = math.dist(new_at, after) / 2 - new_corner.tangent < SLACK_M
rank = (tight, abs(station - mc), abs(radius - corner.radius))
found.append((rank, new_at, new_corner, new_mc, station))
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
local = [state.end_xy, *new_corner.arc, middle]
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
if self.guard is not None:
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
passed, (out, along, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft:
continue
rank = (along, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, new_at, new_corner, new_mc, station in sorted(found, key=lambda item: item[0]):
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
local = [state.end_xy, *new_corner.arc, middle]
if self.reference.gap(local) > self.max_gap or self._steeper(local):
for _, local, new_at, new_corner, new_mc, station in sorted(
found, key=lambda item: item[0]
):
if self.guard is None and (self._far(local) or self._steeper(local)):
continue
placed = plan.copy()
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
@@ -371,22 +406,50 @@ class _Snapper:
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
plan, k = state.plan, state.k
before, at, after = plan.points[k - 1 : k + 2]
if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
if self.guard is not None:
# 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로
# 들어온 자리도 곡선으로 놓아 봄
if _corner(before, at, after, plan.radii[k] or 1.0) is None:
gone = plan.copy()
del gone.points[k], gone.flags[k], gone.radii[k]
yield replace(state, plan=gone)
return
if not plan.flags[k]:
plan = plan.copy()
plan.flags[k] = True
state = replace(state, plan=plan)
elif not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
kink = plan.copy()
kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함)
yield self._kink(state, kink)
return
trial = plan.copy()
for _ in range(MERGE_TRIES + 1):
yield from self._placements(state, trial)
trial = trial.copy()
variants = [plan.copy()]
for _ in range(MERGE_TRIES):
trial = variants[-1].copy()
if not _absorb_next(trial, k):
break
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
variants.append(trial)
if self.guard is not None:
# 직접 잼(406) — 같은 쪽으로 가장 많이 합친 꼴부터(`merged_first` · S자 뺌 · 남은 꼴은
# 옛 차례) · 잼 지나는 놓기를 모두 본 뒤 덜 나쁜 놓기(MC 는 늘 측점에)
same = 1
while same < len(variants):
p = variants[same - 1].points
if _turn_sign(*p[k - 1 : k + 2]) != _turn_sign(*p[k : k + 3]):
break
same += 1
if self.merged_first:
variants = variants[:same][::-1] + variants[same:]
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
# 안 함 · 155° 이상도 곡선 · 419)
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if (
_inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG
and self.reference.gap([state.end_xy, at, middle]) <= self.max_gap
self.guard is None
and _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG
and not self._far([state.end_xy, at, middle])
):
kink = plan.copy()
kink.flags[k], kink.radii[k] = False, None
@@ -427,7 +490,7 @@ class _Snapper:
# 꺾임점째 뺌(이웃 곡선 접선 자리가 넓어짐) → 안 되면 꺾임만 남김. 이 꺾임점은 앞
# 곡선이 꺾임만일 때만 뺌(앞 곡선 EC 가 이 꺾임점 쪽 직선 위)
movable = small > k or not plan.flags[k - 1]
if movable and self.reference.gap(chord) <= self.max_gap and not self._steeper(chord):
if movable and not self._far(chord) and not self._steeper(chord):
gone = plan.copy()
del gone.points[small], gone.flags[small], gone.radii[small]
yield replace(state, plan=gone)
@@ -436,7 +499,8 @@ class _Snapper:
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self.reference.gap(piece) > self.max_gap or self._steeper(piece):
# 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419)
if self.guard is not None or self._far(piece) or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
@@ -455,7 +519,8 @@ class _Snapper:
corner, mc = found
at, after = plan.points[k], plan.points[k + 1]
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if self.reference.gap([state.end_xy, *corner.arc, middle]) > self.max_gap:
# 직접 잼(406)은 안 거름 — 옮기지 않은 곡선(앞 곡선이 민 만큼만 달라짐)
if self.guard is None and self._far([state.end_xy, *corner.arc, middle]):
return
kept = plan.copy()
kept.radii[k] = round(corner.radius, 3)
@@ -508,16 +573,32 @@ def snap_curves_to_stations(
max_gap_m: float = ROUTE_STATION_SNAP_MAX_GAP_M,
*,
excess: Excess | None = None,
guard: Any = None,
) -> PlannedPolyline | None:
"""곡선 MC 를 일반측점에 놓아 다시 짠 선 · 못 하면 None(원래 선을 씀). 곡선 없으면 그대로.
모두 놓는 길을 먼저 찾고, 없으면 못 놓는 곡선만 그 자리에 둔 부분 맞춤(`off_station_count`).
`excess`(점 묶음 → 기울기가 상한을 넘은 만큼)를 주면 옮긴 조각이 원래 자리보다 더 넘을 때 그
놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게.
놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게. `guard`(추천 후보 · 406)를 주면
이탈 한도 · `excess` 대신 그 직접 잼으로 거르고 자리를 고름.
"""
if not outline.curves:
return outline
snapper = _Snapper(outline, min_radius_m, max_gap_m, excess)
snapped = _snap(_Snapper(outline, min_radius_m, max_gap_m, excess, guard), min_radius_m)
if guard is not None and (snapped is None or off_station_count(snapped) > 0):
# 합친 꼴부터 놓다 뒤 곡선이 측점을 잃으면 옛 차례(그대로 → 합침)로도 맞춰 측점 밖이 적은
# 쪽 · 같으면 곡선이 적은 쪽(406-4 · 합친 곡선 때문에 측점 밖이 늘지 않게)
plain = _Snapper(outline, min_radius_m, max_gap_m, excess, guard)
plain.merged_first = False
found = [line for line in (snapped, _snap(plain, min_radius_m)) if line is not None]
snapped = min(
found, key=lambda line: (off_station_count(line), len(line.curves)), default=None
)
return snapped
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = snapper.run(keep=False) or snapper.run(keep=True)
if state is None:
return None
@@ -8,16 +8,20 @@
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · 표준 횡단면 · `ALGO_VERSION` · 엔진 소스 지문. 관
지점은 뺀다 — 덮개 수는 꺼낼 때 다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을
올린다 — 빠뜨려도 엔진 소스 지문이 바뀌어 새로 셈. 다른 판 · 지문 파일은 안 읽고 남기며 30일 지난
것만 미리 셈 때 지운다(420-2 · storage 를 두 PC · 여러 워크트리가 같이 씀).
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
"""
from __future__ import annotations
import asyncio
import functools
import hashlib
import json
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
@@ -32,22 +36,46 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
)
from common_util.common_util_json import atomic_write_json
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1)
ALGO_VERSION = 5
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420)
ALGO_VERSION = 8
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
SOURCE_GLOBS = (
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
"B05_Profile/B05_Profile_Engine_Sections_Core.py",
"B06_Section/B06_Section_Engine_Design*.py",
"common_util/common_util_route_polyline.py",
)
STORE_DIR = "B05_Profile/route_recommend"
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
KEEP_PER_KIND = 2
# 다른 판 · 소스 지문 저장본을 이 날수 지나면 미리 셈 때 지움(420-2)
STALE_DAYS = 30
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음)
_LOCKS: dict[str, asyncio.Lock] = {}
# 묶음 지문마다 그 묶음을 짤 때 표준 횡단면(`forget_pool_on_standard`)
_POOL_STANDARDS: dict[str, str] = {}
def store_dir(project_root: Path) -> Path:
return Path(project_root) / STORE_DIR
@functools.cache
def source_fingerprint() -> str:
"""엔진 소스 지문 — `SOURCE_GLOBS` 파일 내용(줄끝 LF 로 맞춤 · 두 PC 같은 코드 = 같은 지문)."""
root = Path(__file__).resolve().parents[1]
digest = hashlib.blake2b(digest_size=8)
for path in sorted({p for pattern in SOURCE_GLOBS for p in root.glob(pattern)}):
digest.update(path.name.encode())
digest.update(path.read_bytes().replace(b"\r\n", b"\n"))
return digest.hexdigest()
def request_fingerprint(
expected: list[tuple[float, float]],
terrain: Terrain,
@@ -62,6 +90,7 @@ def request_fingerprint(
target_grade: float | None,
with_profile: bool,
design_speed: int,
standard: dict[str, Any] | None = None,
) -> tuple[str, str]:
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
pool = _pool_key(
@@ -73,7 +102,15 @@ def request_fingerprint(
*resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade),
).hex()
used = resolve_weights(mode, weights)
tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION)
tail = (
mode,
sorted(used.items()),
bool(with_profile),
int(design_speed),
json.dumps(standard, sort_keys=True, default=str) if standard else None,
ALGO_VERSION,
source_fingerprint(),
)
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
return key, pool
@@ -101,14 +138,22 @@ def is_default(
)
def _current(data: Any) -> bool:
return (
isinstance(data, dict)
and data.get("version") == ALGO_VERSION
and data.get("source") == source_fingerprint()
)
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
"""저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None."""
"""저장본 한 벌 — 없거나 못 읽거나 판 · 소스 지문이 다르면 None."""
path = store_dir(project_root) / f"{key}.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
if not _current(data):
return None
try:
path.touch() # 최근에 쓴 것 = 정리 때 남김
@@ -133,6 +178,7 @@ def write_stored(
path,
{
"version": ALGO_VERSION,
"source": source_fingerprint(),
"key": key,
"pool": pool,
"default": default,
@@ -153,14 +199,15 @@ def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 지운 수.
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 같은 판 ·
소스 지문 안에서만 셈 — 다른 지문(다른 PC · 워크트리 코드) 파일은 안 건드림(420-2). 지운 수.
"""
kind = _kind(request, default)
same: list[tuple[int, Path]] = []
for path in store_dir(project_root).glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
if _kind(data.get("request"), data.get("default")) == kind:
if _current(data) and _kind(data.get("request"), data.get("default")) == kind:
same.append((path.stat().st_mtime_ns, path))
except (OSError, ValueError, AttributeError):
continue
@@ -170,6 +217,59 @@ def prune_stored(project_root: Path, request: dict[str, Any], default: bool = Fa
return max(0, len(same) - KEEP_PER_KIND)
def _remove_where(project_root: Path, drop: Any) -> int:
"""저장본 중 `drop(data, path)` 가 참인 파일을 지운다(못 읽는 파일 = data None) — 지운 수."""
removed = 0
for path in store_dir(project_root).glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = None
if drop(data, path):
path.unlink(missing_ok=True)
removed += 1
return removed
def purge_outdated(project_root: Path, now: float | None = None) -> int:
"""다른 판 · 소스 지문 저장본 중 `STALE_DAYS` 지난 것만 지운다(미리 셈 첫머리 · 420-2).
storage 를 두 PC · 여러 워크트리가 같이 써 코드가 다르면 지문이 다름 — 새것은 안 읽고 둠.
지운 수."""
limit = (time.time() if now is None else now) - STALE_DAYS * 86400.0
def stale(data: Any, path: Path) -> bool:
try:
return not _current(data) and path.stat().st_mtime < limit
except OSError:
return False
return _remove_where(project_root, stale)
def drop_pool_results(project_root: Path, pool: str) -> int:
"""[재계산] — 이 후보 묶음(다섯 갈래 · 종단 켬끔)의 저장본을 지우고 메모리 묶음도 버린다.
`pool_lock(pool)` 안에서 부름(미리 셈이 다시 써 버리지 않게) · 지금 판 · 소스 지문 것만
(다른 지문 = 다른 PC · 워크트리 것 · 420-2). 지운 수."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
_POOLS.pop(bytes.fromhex(pool), None)
return _remove_where(
project_root, lambda data, _path: _current(data) and data.get("pool") == pool
)
def forget_pool_on_standard(pool: str, standard: dict[str, Any] | None) -> None:
"""메모리 묶음 후보에 붙은 균형 종단은 그때 표준 횡단면으로 셈 — 바뀌면 묶음을 버린다."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
mark = json.dumps(standard, sort_keys=True, default=str)
if _POOL_STANDARDS.get(pool, mark) != mark:
_POOLS.pop(bytes.fromhex(pool), None)
_POOL_STANDARDS[pool] = mark
@asynccontextmanager
async def pool_lock(pool: str) -> AsyncIterator[None]:
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
+10 -2
View File
@@ -553,14 +553,18 @@ def sample_structure_sections(
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
with_curves: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
`with_curves` — 곡선 측점(`curve` · 252-2)도 같이(정본 쓰기 · 구조물이 MC 를 비우면
되살림 · 407-3).
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
"""
if not extra_stations:
if not extra_stations and not with_curves:
return [], []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
@@ -587,6 +591,8 @@ def sample_structure_sections(
)
def structural(entry: dict[str, Any]) -> bool:
if with_curves and entry.get("kind") == "curve":
return True
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
return (
@@ -606,7 +612,8 @@ def generate_irregular_sections(
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
"""구조물 측점(곡선 측점 포함 · 407-3)만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을
돌려준다(빈 목록 = 없음).
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
@@ -621,6 +628,7 @@ def generate_irregular_sections(
extra_stations=extra_stations,
options=options,
crs=crs,
with_curves=True,
)
if not irregular_stations:
return []
+90 -26
View File
@@ -11,7 +11,7 @@
`snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
간격별 MC 값 `test_390_5_snap_step_ops.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) ·
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) ·
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts`
@@ -324,6 +324,27 @@ def snap_curve_stations(
return np.unique(np.asarray(values, dtype=np.float64))
def curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
total: float,
step_m: float | None = None,
) -> list[float]:
"""곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이
없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
BC · EC 는 안 더함. `curveStations` 짝."""
scale = _mc_scale(step_m)
taken = [float(value) for value in stations]
out: list[float] = []
for span in spans:
value = math.floor((float(span[0]) + float(span[1])) / 2.0 * scale + 0.5) / scale
if not 0.0 < value < total or any(abs(value - other) < 0.005 for other in taken):
continue
taken.append(value)
out.append(value)
return sorted(out)
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
@@ -360,31 +381,17 @@ def _design_curve_widenings(
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
options: SectionGenerationOptions | None = None,
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
"""
options = options or SectionGenerationOptions()
options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
def _station_layout(
points: np.ndarray,
route_chainage: np.ndarray,
options: SectionGenerationOptions,
design_curves: list[dict[str, Any]] | None,
station_snap: bool,
station_snap_step: float | None,
) -> tuple[np.ndarray, list, set[float], dict[float, str], set[float]]:
"""측점 자리 한 벌(지반 표본 없음) — (측점 누가거리 · 곡선 구간 · 격자 자리 · 구조물 측점 ·
곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길."""
total = float(route_chainage[-1])
long_chainage = _chainages(total, options.long_sample_interval_m, True)
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
@@ -413,6 +420,61 @@ def generate_sections(
station_chainage = np.unique(
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
# 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m)
step = station_snap_step if station_snap else None
curves = {round(value, 6) for value in curve_stations(station_chainage, spans, total, step)}
if curves:
station_chainage = np.unique(np.r_[station_chainage, np.array(sorted(curves))])
return station_chainage, spans, regular_rounded, extras, curves
def planned_curve_stations(
polyline: np.ndarray | list[list[float]],
options: SectionGenerationOptions | None = None,
*,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> list[float]:
"""지금 노선 · 관 · 구조물(`options.extra_stations`)이 세울 곡선 측점 누가거리(오름차순) —
`generate_sections` 와 같은 값 · 표본은 안 뜸."""
options = options or SectionGenerationOptions()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
layout = _station_layout(
points, route_chainage, options, design_curves, station_snap, station_snap_step
)
return sorted(layout[4])
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
options: SectionGenerationOptions | None = None,
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함.
"""
options = options or SectionGenerationOptions()
options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
total = float(route_chainage[-1])
long_chainage = _chainages(total, options.long_sample_interval_m, True)
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage, spans, regular_rounded, extras, curves = _station_layout(
points, route_chainage, options, design_curves, station_snap, station_snap_step
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
[
@@ -477,6 +539,8 @@ def generate_sections(
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
kind = "irregular"
elif rounded in curves:
kind = "curve" # 곡선 측점(252-2) — 격자 번호 없음 · 열쇠 `curve:<누가거리>`
else:
kind = "regular"
center_index = int(np.argmin(np.abs(offsets)))
@@ -509,7 +573,7 @@ def generate_sections(
station = {
"station_id": station_id,
"chainage_m": round(float(value), 6),
"label": format_station(float(value)),
"label": format_station(float(value)) + (" (MC)" if kind == "curve" else ""),
"kind": kind,
"center_x": round(float(station_xy[index, 0]), 6),
"center_y": round(float(station_xy[index, 1]), 6),
@@ -0,0 +1,48 @@
"""노드마다 넣은 편집값(430) — 곡선 켬끔 · 넣은 R · 고정(R | 길이) · 고정 길이 → 곡선표 `edits`.
⚠ 짝: `logic/B05_Profile_UI_RouteEdit_Edits.ts` `flattenServerPlan`(`RouteNodeEdit`)이 읽음 ·
거울 시험 `resources/tester/test_430_route_edit_rules.py`.
곡선표 `curves` 는 **그린** R(접선 자리에 눌린 값)만 있어, 그것으로 다시 열면 눌린 R 이
넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐 — 넣은 값을 따로 둠. 자리 = 저장 노드
(앞 노드에서 0.5 m 안인 점은 `build_planned_polyline` 과 같이 거름).
"""
from __future__ import annotations
import math
from typing import Any
from common_util.common_util_route_polyline import DUPLICATE_TOLERANCE_M
_LOCKS = ("radius", "arc")
def node_edits(
points: list[tuple[float, float]],
flags: list[bool] | None,
radii: list[float | None] | None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> list[dict[str, Any]]:
"""점마다의 편집값 — 겹친 점은 빼고 저장 노드와 같은 자리로. 칸이 짧으면 기본값(켬 · 자동)."""
def at(values: list | None, index: int, default: Any = None) -> Any:
return values[index] if values is not None and index < len(values) else default
kept: list[tuple[float, float]] = []
edits: list[dict[str, Any]] = []
for index, point in enumerate(points):
if kept and math.dist(kept[-1], point) <= DUPLICATE_TOLERANCE_M:
continue
kept.append(point)
lock = at(locks, index)
edits.append(
{
"curve": bool(at(flags, index, True)),
"radius_m": at(radii, index),
"lock": lock if lock in _LOCKS else None,
"arc_m": at(arcs, index),
}
)
return edits
+18 -9
View File
@@ -19,9 +19,9 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
규칙:
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과
같은 선) · 균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만.
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만.
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
"""
@@ -199,8 +199,13 @@ async def _carry_profile(
old_xy: list[list[float]],
new_xy: list[list[float]],
) -> None:
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
carry_base,
carry_edits,
carry_policy,
changed_span,
)
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
from common_util.common_util_json import atomic_write_json
@@ -214,9 +219,12 @@ async def _carry_profile(
if span is None: # 같은 노선 — 옛 계획선 통째
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
else:
base, edits = carry_profile_edits(
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
)
old_base = old_alignment["base_pvi"]
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
alignment["base_pvi"] = base
await asyncio.to_thread(atomic_write_json, path, longitudinal)
_alignment, summary, _dropped = await asyncio.to_thread(
@@ -246,6 +254,7 @@ async def _carry_cross(
) -> int:
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
from B06_Section.B06_Section_Router_Design import (
@@ -266,7 +275,7 @@ async def _carry_cross(
if isinstance(row.get("design"), dict)
}
inputs, adjusts, dropped = carry_cross_inputs(
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
old_keys, new_keys, designs, (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), STRUCTURE_ADJUST_KEYS
)
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
entries = await asyncio.to_thread(
+13 -6
View File
@@ -11,7 +11,8 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
- 측점 열쇠 = `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` 일곱 · 관 · 구조물 열쇠면
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 + `ROUTE_CARRY_KEYS`(사면 쉬프트 ·
소단 깊이 · 측점 폭 · 메모 · 상세 표 · 417) · 관 · 구조물 열쇠면
`STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
「노선 밖 고침」.
"""
@@ -31,7 +32,7 @@ from common_util.common_util_drainage_pipes import (
)
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_station_key import curve_moves, station_key
from common_util.common_util_structure_station import plan_stations, snap_structure_station
from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M
@@ -117,16 +118,20 @@ def station_keys(
) -> dict[str, float]:
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) ·
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]
rows = [row for row in stations if row.get("chainage_m") is not None]
values = [float(row["chainage_m"]) for row in rows]
out: dict[str, float] = {}
for index, chainage in enumerate(values):
owner = owners.get(round(chainage, 2))
if owner is not None:
key = station_key(owner[0], chainage, interval_m, owner[1])
elif rows[index].get("kind") == "curve":
key = station_key("curve", chainage, interval_m)
elif index == 0:
key = station_key("bp", chainage, interval_m)
elif index == len(values) - 1:
@@ -151,8 +156,10 @@ def carry_cross_inputs(
"""같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수).
`old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에
같은 열쇠가 없는 것(「노선 밖 고침」).
같은 열쇠가 없는 것(「노선 밖 고침」) · MC 가 움직인 곡선 열쇠는 가까운 새 곡선 측점
(`curve_moves`).
"""
moves = curve_moves(list(old_keys.items()), list(new_keys.items()))
inputs: dict[float, dict[str, Any]] = {}
adjusts: dict[float, dict[str, Any]] = {}
dropped = 0
@@ -160,7 +167,7 @@ def carry_cross_inputs(
design = old_designs.get(round(old_at, 3))
if not isinstance(design, dict):
continue
new_at = new_keys.get(key)
new_at = new_keys.get(moves.get(key, key))
if new_at is None:
dropped += 1
continue
+98 -11
View File
@@ -12,9 +12,15 @@ from typing import Any
from uuid import UUID
import aiomysql
import numpy as np
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
AlignmentPolicy,
build_curves,
chainage_key,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
@@ -112,11 +118,14 @@ async def sync_uphill_overrides_into_designs(
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
pavement_suggestions,
read_cross_design_inputs,
resolve_longitudinal_path,
stored_berm,
stored_cut_slope,
)
if not overrides:
@@ -166,23 +175,26 @@ async def sync_uphill_overrides_into_designs(
ground_type=str(design.get("ground_type", "soil")),
section_mode=next_mode,
ditch_side=side,
ditch_type=str(design.get("ditch_type", "standard")),
ditch_type=str(design.get("ditch_type") or "standard"), # 측구 끔 = None 저장분
paved=bool(design.get("paved", False)),
standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", False)),
cut_slope_ratio=stored_cut_slope(design), # 측점별 깎기 경사(429 ⑤)
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
berm=stored_berm(design), # 소단 · 쪽마다 옮긴 깊이(429 ⑤)
**curve_widening_args(cross_record),
**stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
)
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
# 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다.
if design.get("display_half_width_m") is not None:
next_design["display_half_width_m"] = design["display_half_width_m"]
# 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `enforce_design_lines` 와 같음
for key in USER_TOUCHED_KEYS:
if design.get(key) is not None:
next_design[key] = design[key]
out.append((float(chainage), next_design))
return out
@@ -201,6 +213,53 @@ async def sync_uphill_overrides_into_designs(
_SAME_STATION_M = 0.006
def _seat_born_nodes(
stored: dict[str, Any], base: list[dict[str, Any]], born: list[float]
) -> list[dict[str, Any]]:
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). TS 짝 = `seatBornNodes`
(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = `test_427_1_follow_seat.py`.
"""
samples = stored.get("samples") or []
if not base or len(samples) < 2:
return base
first, last = float(base[0]["chainage_m"]), float(base[-1]["chainage_m"])
fresh = [
value
for value in born
if first < value < last
and all(abs(value - float(node["chainage_m"])) >= _SAME_STATION_M for node in base)
]
if not fresh:
return base
ground_s = np.array([float(row["chainage_m"]) for row in samples], dtype=np.float64)
ground_z = np.array([float(row["ground_elevation_m"]) for row in samples], dtype=np.float64)
rows = sorted(
[(float(node["chainage_m"]), float(node["elevation_m"]), node) for node in base]
+ [(value, float(np.interp(value, ground_s, ground_z)), None) for value in fresh],
key=lambda row: row[0],
)
base_s = np.array([row[0] for row in rows], dtype=np.float64)
target = np.array([row[1] for row in rows], dtype=np.float64)
free = np.array([row[2] is None for row in rows])
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
base_z = target.copy()
for _ in range(20):
curves, _warnings = build_curves(base_s, base_z, policy)
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
if float(np.max(np.abs(error))) < 1e-6:
break
base_z = base_z + error
return [
node
if node is not None
else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
for (s, _target, node), z in zip(rows, base_z.tolist())
]
def _follow_moved_stations(
data: dict[str, Any], before: list[float], previous: list[float] | None = None
) -> dict[str, Any] | None:
@@ -208,7 +267,10 @@ def _follow_moved_stations(
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
자리(272)만 옮기고 나머지는 지운다.
자리(272)는 옮김. 옮긴 관 · 구조물(427 · 요구 255-2) — 짝 못 찾은 옛 측점과 새 측점이 같은
수면 차례대로 짝 · 높이 고친 꼭짓점(`station_offsets`)은 높이째 · 편집째 옮기고, 나머지 새
측점은 자동 계획선 규칙(원지반 지남 · `_seat_born_nodes`)으로 꼭짓점을 세움(되돌리면 처음
값) · 짝 없는 옛 측점 꼭짓점은 지움.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
"""
@@ -236,6 +298,25 @@ def _follow_moved_stations(
if near is None and snapped in after_keys and snapped not in before_keys:
near = float(snapped)
moves[chainage_key(old)] = None if near is None else chainage_key(near)
# 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점 ↔ 새 측점(같은 수 · 차례대로)
previous_keys = before_keys if previous is None else {chainage_key(v) for v in previous}
taken = {key for key in moves.values() if key is not None}
born = sorted(
value
for value in after
if chainage_key(value) not in previous_keys and chainage_key(value) not in taken
)
lost = sorted((key for key, target in moves.items() if target is None), key=float)
offsets = (stored.get("edits") or {}).get("station_offsets") or {}
# 꼭짓점을 새로 세울 측점 — 짝이면 높이 안 고친 것만(편집 R 은 옮김) · 짝 없으면 새 측점 전부
seat = born
if lost and len(lost) == len(born):
seat = []
for old_key, value in zip(lost, born):
moves[old_key] = chainage_key(value)
if not any(abs(float(key) - float(old_key)) < _SAME_STATION_M for key in offsets):
seat.append(value)
reseat = {chainage_key(value) for value in seat}
def moved(value: float) -> tuple[bool, str | None]:
"""옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1).
@@ -269,8 +350,11 @@ def _follow_moved_stations(
base.append(node)
continue
changed = True
if new_key is not None:
if new_key is not None and new_key not in reseat:
base.append({**node, "chainage_m": float(new_key)})
seated = _seat_born_nodes(stored, base, seat)
changed = changed or len(seated) != len(base)
base = seated
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
rows = stored.get("stations")
@@ -300,7 +384,8 @@ def _merge_irregular_into_longitudinal(
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 곡선 측점(`curve`)도 새 목록이 통째로
갈음(구조물이 MC 를 비우면 되살아남 · 407-3). 표고 샘플은 손대지 않는다.
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
@@ -318,14 +403,16 @@ def _merge_irregular_into_longitudinal(
before = [
float(station.get("chainage_m", 0.0))
for station in stations
if station.get("kind") == "irregular"
if station.get("kind") in ("irregular", "curve")
]
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
if station.get("kind") == "irregular" and not station.get("grid"):
continue # 옛 구조물 측점 — 새 목록이 갈음
if station.get("kind") == "curve" or (
station.get("kind") == "irregular" and not station.get("grid")
):
continue # 옛 구조물 측점 · 곡선 측점(407-3) — 새 목록이 갈음
key = round(float(station.get("chainage_m", 0.0)), 6)
if key not in fresh and ("structure" in station or station.get("grid")):
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
+18 -3
View File
@@ -8,9 +8,10 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -23,8 +24,9 @@ from B05_Profile.B05_Profile_Router_Confirm import (
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse
from B06_Section.B06_Section_Repository import get_longitudinal_section
from common_util.common_util_auth import get_optional_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
@@ -140,7 +142,10 @@ async def confirm_latest_route(
@router.post("/{project_id}/route/reset")
async def reset_route_design(project_id: UUID) -> JSONResponse:
async def reset_route_design(
project_id: UUID,
session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이
) -> JSONResponse:
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
@@ -224,6 +229,10 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
restored_route_id = await restore_initial_snapshot(
connection, project_root, str(project_id)
)
# 재계산 길(체인)과 같은 단계 — stage 2 · 3 검토 대기 · B07 이후 STALE
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 2)
await start_stage(cursor, str(project_id), 3)
await connection.commit()
except Exception:
await connection.rollback()
@@ -258,6 +267,12 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
},
)
# M02 구조물 사본을 되돌린 구조물 · 관 지정에 맞춤(실패해도 초기화 유지)
if project_root:
from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync
await asyncio.to_thread(safe_sync, project_root, importer(session))
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
try:
if project_root:
+24 -22
View File
@@ -42,6 +42,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
@@ -211,8 +212,8 @@ def _curves_path(path: Path) -> Path:
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path) -> list[dict]:
"""저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트)."""
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
target = _curves_path(path)
if not target.is_file():
return []
@@ -221,28 +222,10 @@ def _read_curves(path: Path) -> list[dict]:
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
curves = data.get(key) if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
def _initial_shape(project_root: Path, fallback: list, radius_m: float) -> dict[str, list]:
"""전처리가 만든 **초기 노선**의 노드 · 곡선 — 화면 [초기화]가 재계산 없이 놓는 자리.
수정본이 있어도 초기 폴리라인 파일은 그대로라 항상 같은 노선이 나온다.
"""
path = planned_route_initial_path(project_root)
saved_nodes = _vertices_of(_nodes_path(path))
outline = build_planned_polyline(
[(x, y) for x, y in (saved_nodes or _vertices_of(path) or fallback)],
min_radius_m=radius_m,
simplify=not saved_nodes,
)
return {
"nodes": [node.as_dict() for node in outline.nodes],
"curves": _read_curves(path) or [curve.as_dict() for curve in outline.curves],
}
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
@@ -253,11 +236,14 @@ def _write_planned_polyline(
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
@@ -280,6 +266,8 @@ def _write_planned_polyline(
table["station_snap"] = bool(station_snap)
if station_snap and station_snap_step is not None:
table["station_snap_step"] = float(station_snap_step)
if curve_flags is not None or radii is not None:
table["edits"] = node_edits(points, curve_flags, radii, locks, arcs)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
@@ -525,6 +513,13 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다.
simplify=not saved_nodes,
)
saved_edits = await asyncio.to_thread(
_read_curves,
planned_route_working_path(project_root)
if working
else planned_route_initial_path(project_root),
"edits",
)
return {
"status": "success",
"project_id": str(project_id),
@@ -545,7 +540,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
# 넣은 R · 고정 · 고정 길이(430) — 저장 노드와 자리가 같을 때만(옛 곡선표 = null)
"node_edits": saved_edits
if saved_nodes and len(saved_edits) == len(outline.nodes)
else None,
}
@@ -580,6 +578,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
curve_flags = [bool(vertex.curve) for vertex in request.vertices]
radii = [vertex.radius_m for vertex in request.vertices]
draft = getattr(body, "draft", None) # R · 길이 고정(430) — 초안에만 있음
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
@@ -595,6 +594,8 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
radii=radii,
station_snap=request.station_snap,
station_snap_step=request.station_snap_step,
locks=list(draft.curveLock) if draft else None,
arcs=list(draft.curveArc) if draft else None,
),
working_path,
nodes,
@@ -646,6 +647,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
choice.mode,
choice.weights.model_dump() if choice.weights else None,
(choice.criteria.model_dump(exclude_none=True) or None) if choice.criteria else None,
request.balanced_profile, # 「종단 반영」 — 다시 열면 되살림(420)
)
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
+57 -8
View File
@@ -6,6 +6,7 @@
import asyncio
import functools
import json
import logging
import time
from pathlib import Path
@@ -44,17 +45,21 @@ async def compute_initial(
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
`recompute` = [재계산] — 묶음 잠금 안에서 이 묶음 저장본 · 메모리 묶음을 버리고 새로 셈(420).
"""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import (
generate_initial_route,
load_terrain_and_streams,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import attach_cross_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
cover_shortfall,
project_pipes,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
drop_pool_results,
forget_pool_on_standard,
is_default,
pool_lock,
read_stored,
@@ -115,6 +120,8 @@ async def compute_initial(
"target_grade": None if target_pct is None else target_pct / 100.0,
"design_speed": design_speed or 20,
"with_profile": request.with_profile,
# 균형 종단 · 횡단 토공 표준 횡단면 = 프로젝트 저장 패널값(420)
"standard": await project_standard(project_id),
}
started = time.perf_counter()
try:
@@ -126,6 +133,10 @@ async def compute_initial(
functools.partial(request_fingerprint, route, terrain, streams, **inputs)
)
async with pool_lock(pool_key):
if request.recompute: # 잠금 안 — 밖이면 미리 셈이 지운 것을 다시 써 버림
removed = await asyncio.to_thread(drop_pool_results, project_root, pool_key)
logger.info("추천 [재계산]: project_id=%s 저장본 %d 지움", project_id, removed)
forget_pool_on_standard(pool_key, inputs["standard"])
stored = await asyncio.to_thread(read_stored, project_root, key)
if stored is not None:
result = stored["result"]
@@ -136,12 +147,14 @@ async def compute_initial(
generate_initial_route, route, terrain, streams, pipes=None, **inputs
)
)
# 결과 표 절 · 성 = 횡단(420) — 저장본에 남김
await asyncio.to_thread(attach_cross_earth, result, terrain, inputs["standard"])
await asyncio.to_thread(
write_stored,
project_root,
key,
pool_key,
request.model_dump(),
request.model_dump(exclude={"recompute"}),
result,
is_default(
request.mode,
@@ -180,13 +193,36 @@ async def compute_initial(
}
async def project_standard(project_id: UUID) -> dict[str, Any] | None:
"""프로젝트에 저장된 표준 횡단면 설정값(B06 패널 · 확정 때 종단 options) — 없으면 None.
공식 재계산(`B06_Section_Server_Calc_Prebuild` · `stored_standard_cross_section`)과 같은
자리 · 같은 읽기 — 마지막 종단 정본 행."""
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT data FROM longitudinal_sections WHERE project_id = %s "
"ORDER BY id DESC LIMIT 1",
(str(project_id),),
)
row = await cursor.fetchone()
data = row[0] if row else None
if isinstance(data, str):
data = json.loads(data)
return stored_standard_cross_section({"data": data if isinstance(data, dict) else None})
async def precompute_defaults(project_id: UUID) -> None:
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2).
"""
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES, purge_outdated
started = time.perf_counter()
variants: list[tuple[Any, Any]] = [(None, None)]
@@ -196,6 +232,9 @@ async def precompute_defaults(project_id: UUID) -> None:
except LookupError:
paths = None
if paths is not None:
removed = await asyncio.to_thread(purge_outdated, paths[0])
if removed:
logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed)
saved = await asyncio.to_thread(read_initial_choice, paths[0])
weights = saved["weights"] if saved["logic"] == "weighted" else None
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
@@ -209,10 +248,10 @@ async def precompute_defaults(project_id: UUID) -> None:
for mode in modes:
if progress is not None:
progress["mode"] = mode
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`)
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
request = replan.RouteInitialRequest(
mode=mode,
with_profile=mode != "follow",
with_profile=True,
criteria=criteria,
weights=weights if mode == "weighted" else None,
)
@@ -321,16 +360,24 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
from B05_Profile.B05_Profile_Engine_Grade_Profile import pass_through
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
from common_util.common_util_json import atomic_write_json
longitudinal = json.loads(path.read_text(encoding="utf-8"))
alignment = longitudinal["profile_alignment"]
base = alignment["base_pvi"]
base_s = np.array([float(item["chainage_m"]) for item in base])
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게.
base_z = pass_through(
base_s, np.interp(base_s, stations, plan), AlignmentPolicy.from_dict(alignment["policy"])
# 초기값 직선(432) — 균형 종단에서 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이
policy = AlignmentPolicy.from_dict(alignment["policy"])
keys = initial_no_curve_keys(
base_s, np.interp(base_s, stations, plan), policy.default_curve_length_m
)
alignment["policy"]["no_curve_keys"] = list(keys)
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
# 직선은 균형 종단 상한 안(419-3)
base_z = held_pass_through(
base_s, stations, plan, AlignmentPolicy.from_dict(alignment["policy"])
)
for item, value in zip(base, base_z, strict=True):
item["elevation_m"] = round(float(value), 4)
@@ -416,6 +463,7 @@ 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))
standard = await project_standard(project_id) # 추천 창과 같은 저장 패널값(420)
try:
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님)
surface = await asyncio.to_thread(
@@ -433,6 +481,7 @@ async def balanced_profile_of(
max_grade=max_grade,
design_speed=design_speed or 20,
design_curves=design_curves,
standard=standard,
)
)
except (FileNotFoundError, ValueError):
+2
View File
@@ -367,6 +367,8 @@ class RouteInitialRequest(BaseModel):
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
# [재계산](420) — 이 후보 묶음 저장본 · 메모리 묶음을 지우고 새로 셈. 저장본 request 엔 안 적힘.
recompute: bool = False
class RouteReplanRequest(BaseModel):
@@ -59,6 +59,7 @@ import {
basinIndexOfPipe,
pipeMarkerColor,
adoptDraftPipes,
followEditorPipes,
reconcilePipes,
WIDTH_KEY,
type DrainageLayer,
@@ -569,6 +570,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (projectId && meta) void run(loadSaved);
},
pipeChainages: () => pipeEditor.chainages(),
followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes),
movePipe(fromChainage, toChainage) {
const index = pipeEditor
.pipes()
@@ -25,6 +25,8 @@ export interface DrainagePanel {
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
pipeChainages: () => number[];
/** 노선 초안 follow 가 다시 매긴 관 자리를 받음(짝 = 관 id · 재계산 없음 · 410). */
followPipes: (pipes: ReadonlyArray<{ id?: string | null; chainage_m: number }>) => void;
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
movePipe: (fromChainage: number, toChainage: number) => void;
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
@@ -64,6 +66,8 @@ export interface DrainagePanel {
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
export interface DrainagePipeReport {
chainage_m: number;
/** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */
id?: string;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
@@ -22,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
import type { FacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { viewOf } from "../common_util/common_util_view_window";
@@ -341,6 +342,42 @@ export function mountDrainageToggles(
);
}
/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id(막 놓아 초안에 id
* 없는 관끼리는 누가거리 차례 · 수가 같을 때만) · 시설 속성 · 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가
* 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */
export function followEditorPipes(
editor: PipeEditor,
facilities: FacilityStore,
followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>,
): void {
const to = new Map<PipePoint | string, number>(
followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])),
);
const before = editor.pipes();
const byOrder = (a: { chainage_m: number }, b: { chainage_m: number }) =>
a.chainage_m - b.chainage_m;
const looseEditor = before.filter((pipe) => !(pipe.id && to.has(pipe.id))).sort(byOrder);
const looseFollowed = followed.filter((pipe) => !pipe.id).sort(byOrder);
if (looseEditor.length === looseFollowed.length)
looseEditor.forEach((pipe, i) => to.set(pipe, looseFollowed[i].chainage_m));
const moves = before.flatMap((pipe) => {
const at = (pipe.id ? to.get(pipe.id) : undefined) ?? to.get(pipe);
return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005
? []
: [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }];
});
if (!moves.length) return;
const picked = before[editor.selected() ?? -1];
moves.forEach(({ pipe }) => facilities.set(pipe.chainage_m, null)); // 다 뺀 뒤 얹음 — 자리 겹침 막음
moves.forEach(({ at, attributes }) => attributes && facilities.set(at, attributes));
const moved = new Map(moves.map(({ pipe, at }) => [pipe, at]));
editor.setPipes(
before.map((pipe) => ({ ...pipe, chainage_m: moved.get(pipe) ?? pipe.chainage_m })),
);
const pickedAt = picked && (moved.get(picked) ?? picked.chainage_m);
if (picked) editor.select(editor.pipes().findIndex((pipe) => pipe.chainage_m === pickedAt));
}
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
/** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도
@@ -15,7 +15,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
export function buildPipeReports(
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
pipes: ReadonlyArray<{ chainage_m: number; reason?: string; id?: string }>,
basins: ReadonlyArray<DetailBasin>,
facilityStore: ReturnType<typeof createFacilityStore>,
): DrainagePipeReport[] {
@@ -25,6 +25,7 @@ export function buildPipeReports(
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
...(pipe.id ? { id: pipe.id } : {}), // 관 고정 id — 옮겨도 짝(411-B)
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
+4 -3
View File
@@ -9,7 +9,6 @@
import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
@@ -74,10 +73,12 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
},
},
// 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움)
// 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 ·
// 평면 오른쪽 판을 그림(416 · 노선 안 바뀐 [저장]도 · B06 다녀오지 않아도 면적 = B06)
done: async () => {
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
const routeId = pageRouteId(projectId);
if (routeId !== null) await ctx.restoreSections(routeId);
},
};
}
@@ -37,6 +37,7 @@ import { planStations } from "../common_util/common_util_structure_station";
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids";
import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data";
import { planMarksChanged } from "./B05_Profile_UI_RouteEdit_StructureMarks";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection {
@@ -155,6 +156,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
function syncGraphStructures(): void {
deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]);
planMarksChanged(deps.projectId); // 평면 구조물 마커 = 알약과 같은 목록(399)
}
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
@@ -337,6 +339,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
},
/** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */
resyncStations: () => syncCrossDrainStations(),
/** 알약 레인 목록 · 종류 — 평면 구조물 마커가 같은 것을 씀(399). */
marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks],
types: (): StructureType[] => planTypes,
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
@@ -1,13 +1,12 @@
/* =============================================================================
* B05_Profile_UI_Panel_Recommend.ts
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 ·
* [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의
* `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만.
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」.
* 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창)은 편집기의 `_RouteEdit_Initial` 이 이 요소를
* 받아 건다 — 여기는 생김새만. 칸 [초기화]는 지움(418) — 되돌림은 좌측 아래 [초기화] 하나.
* ========================================================================== */
export interface RecommendPart {
recommendButton: HTMLButtonElement;
resetButton: HTMLButtonElement;
nowLine: HTMLElement;
/** 칸 몸 — 화면 순서대로. */
nodes: HTMLElement[];
@@ -20,10 +19,7 @@ export function createRecommendPart(): RecommendPart {
actions.className = "b05-route__rec-actions";
actions.innerHTML = `
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="초기값 노선(전처리 · 추천 [적용] 때 정한 것)으로 되돌립니다 — 다시 계산은 [저장]에서">초기화</button>`;
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>`;
const recommendButton = actions.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
const resetButton = actions.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] };
return { recommendButton, nowLine, nodes: [nowLine, actions] };
}
@@ -16,6 +16,8 @@
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
import { bentEditCount } from "./logic/B05_Profile_Profile_Straight";
import { verticalCurveShortfalls } from "./logic/B05_Profile_Profile_CurveLaw";
export interface BalanceBarParams {
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
@@ -97,9 +99,12 @@ export function renderBalanceBar(params: BalanceBarParams): void {
}
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
// 종단곡선 법정 최소 R · L 미달(별표2 마. · 432) — 막지 않고 경고만 · 내역은 툴팁
const shortfalls = verticalCurveShortfalls(alignment.curves, policy);
if (shortfalls.length) entries.push(["종단곡선 미달", `${shortfalls.length} 곳`, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
const editedCount = Object.keys(alignment.edits.station_offsets).length;
const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426)
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
entries.forEach(([label, value, tone]) => {
const item = document.createElement("span");
@@ -109,6 +114,7 @@ export function renderBalanceBar(params: BalanceBarParams): void {
item.append(caption, document.createTextNode(value));
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
if (label === "종단곡선 미달") item.title = shortfalls.join("\n");
if (label === "곡선반경 부족" && plan) {
item.title = [
`법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`,
@@ -18,6 +18,8 @@ export interface RouteProfilePanelCallbacks {
onPipesChanged?: (
pipes: Array<{
chainage_m: number;
/** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */
id?: string;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */
design_flow_m3s?: number | null;
+47 -16
View File
@@ -19,6 +19,7 @@ import type {
ProfileAlignment,
} from "./logic/B05_Profile_UI_Profile_Alignment";
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers";
import {
irregularStationId,
isPipeStation,
@@ -74,7 +75,7 @@ const CHAR_WIDTH_EM = 0.6;
const CELL_PADDING_PX = 2;
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
export const TABLE_TARGET_FONT_PX = 12;
/** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */
/** 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭 위 끝(px · 이웃 사이 틈이 더 좁으면 그 틈). */
const CURVE_ROTATED_WIDTH_PX = 16;
/**
* 같은 측점으로 볼 누가거리 차이(m).
@@ -339,17 +340,17 @@ function buildCurveRows(
: curveByChainage.get(key);
// 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다.
const curve = found && !isStraightCurve(found) ? found : undefined;
return { center: centers[index], curve, rotated: false };
return { center: centers[index], curve };
});
const withCurve = curveEntries.filter((entry) => entry.curve);
for (let pair = 1; pair < withCurve.length; pair += 1) {
if (withCurve[pair].center - withCurve[pair - 1].center < options.cellWidth) {
withCurve[pair - 1].rotated = true;
withCurve[pair].rotated = true;
}
}
// 세운 칸 두께 = 이웃 사이 틈에 맞춤(407-5 — 1.6 m 이웃도 겹치지 않게 · 측점 값 행과 같은 규칙)
const thickness = crowdedThickness(
curveEntries.map((entry) => (entry.curve ? entry.center : null)),
options.cellWidth,
CURVE_ROTATED_WIDTH_PX,
);
curveEntries.forEach(({ center, curve, rotated }) => {
curveEntries.forEach(({ center, curve }, entryIndex) => {
const rotated = thickness[entryIndex];
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
if (curve) {
@@ -370,16 +371,16 @@ function buildCurveRows(
);
radiusCell.append(radiusInput);
lengthCell.append(lengthInput);
if (rotated) {
if (rotated !== null) {
[lengthCell, radiusCell].forEach((cell) => {
cell.classList.add("is-rotated");
cell.style.width = `${CURVE_ROTATED_WIDTH_PX}px`;
cell.style.width = `${rotated}px`;
});
[lengthInput, radiusInput].forEach((input) => {
const byLength = input.value.length
? (rowHeightPx - 6) / (input.value.length * CHAR_WIDTH_EM)
: fontPx;
input.style.fontSize = `${Math.max(7, Math.min(fontPx, CURVE_ROTATED_WIDTH_PX - 4, byLength))}px`;
input.style.fontSize = `${Math.max(6, Math.min(fontPx, rotated - 2, byLength))}px`;
});
}
@@ -585,21 +586,51 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.append(
...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM),
);
buildStationRows(alignment, stationInterval, display).forEach((spec, index) => {
// 이웃 칸과 글이 겹치는 값 칸(1.6 m 이웃 등)만 그 자리에서 세움 — 판정 = 세 행 가운데 가장 긴 글 폭 ·
// 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 함수)
const stationSpecs = buildStationRows(alignment, stationInterval, display);
const isStructural = alignment.stations.map((station) =>
isNearChainage(station.chainage_m, structureChainageList),
);
const longest = Math.max(
0,
...stationSpecs.flatMap((spec) =>
alignment.stations.map((_, stationIndex) =>
isStructural[stationIndex] ? 0 : spec.cell(stationIndex).length,
),
),
);
const stationThickness = crowdedThickness(
alignment.stations.map((_, stationIndex) =>
isStructural[stationIndex] ? null : centers[stationIndex],
),
longest * fontSize * CHAR_WIDTH_EM + CELL_PADDING_PX * 2,
CURVE_ROTATED_WIDTH_PX,
);
stationSpecs.forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
spec.label,
spec.unit,
);
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
alignment.stations.forEach((station, stationIndex) => {
alignment.stations.forEach((_, stationIndex) => {
// 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다.
const structural = isNearChainage(station.chainage_m, structureChainageList);
const structural = isStructural[stationIndex];
const cell = element(
"span",
`b05-profile-table__cell${structural ? " is-structure" : ""}`,
structural ? "" : spec.cell(stationIndex),
);
const rotated = stationThickness[stationIndex];
if (rotated !== null) {
const text = cell.textContent ?? "";
const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontSize;
cell.classList.add("is-rotated");
cell.style.width = `${rotated}px`;
cell.style.fontSize = `${Math.max(6, Math.min(fontSize, rotated - 2, byLength))}px`;
cell.title = text;
}
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
+38 -38
View File
@@ -39,11 +39,7 @@ import {
type AnalysisTerrain,
} from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
type EditedCurve,
type EditedNode,
} from "./logic/B05_Profile_UI_RouteEdit_Curve";
import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
@@ -69,14 +65,7 @@ import {
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
import {
applyArcLocks,
applyCurveLimits,
curveSummary,
flattenServerPlan,
spliceNode,
type CurveLock,
} from "./logic/B05_Profile_UI_RouteEdit_Edits";
import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
bindHistoryControls,
createRouteEditHistory,
@@ -85,7 +74,6 @@ import {
} 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 { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
import "./B05_Profile_UI_Style_RouteEdit.css";
@@ -148,7 +136,7 @@ export async function mountRouteEditor(
let curveRadius: Array<number | null> = [];
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
let curveLock: CurveLock[] = [];
let curveLock: edits.CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점
* (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다.
@@ -162,6 +150,7 @@ export async function mountRouteEditor(
const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
if (toggle) setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음
tellDraft(); // 끔 · 간격 바꿈도 계산 줄에 바로(430)
draw();
});
const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo);
@@ -348,7 +337,7 @@ export async function mountRouteEditor(
locked: apexLock,
picked,
stationIntervalM,
stations: stations(),
...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo),
});
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
@@ -362,12 +351,11 @@ export async function mountRouteEditor(
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
* 서버가 정본으로 다시 내는 것은 그대로다. */
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
// 길이 고정 R → 지정값 하한(R·L · 내각 155° 이상은 R 만) 끌어올림 → 선(셈은 `_Edits` · 0-9 ④).
const built = edits.rebuildLine(
{ planned, curveOn, curveRadius, curveLock, curveArc },
fitLimits(),
);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
@@ -389,7 +377,7 @@ export async function mountRouteEditor(
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
edits.curveSummary({
nodeCount: planned.length,
curveOn,
curveRadius,
@@ -400,6 +388,7 @@ export async function mountRouteEditor(
minRadiusM,
fresh: nodeInfo.length === 0,
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0,
flags: edits.routeFlags(planned, curveOn, curveInfo), // 겹친 노드 · 못 그린 곡선(430)
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
@@ -418,6 +407,8 @@ export async function mountRouteEditor(
apexLock,
limitRadiusM,
limitArcM,
line: plannedLine,
snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
@@ -431,9 +422,14 @@ export async function mountRouteEditor(
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤).
* 하한 검사 한 곳(430) — 지난 걸음이 지키던 하한을 새로 깨면 그 걸음으로 되돌리고 까닭을 상태줄에. */
function applyEdit(message: string, record = true, fitting = true): void {
const from = fit.fitStart(history?.current?.(), snapshotNow());
const before = history?.current?.();
markEdited();
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
if (stop && before) return restoreSnapshot(before, stop, false);
const from = fit.fitStart(before, snapshotNow());
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
@@ -444,6 +440,13 @@ export async function mountRouteEditor(
historyControls.sync();
}
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */
function tellDraft(): void {
if (!history) return;
const dirty = history.isDirty() || pickedTerrain() !== designTerrain || snap.changed();
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
}
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
function fitLimits(): fit.FitLimits {
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
@@ -524,10 +527,10 @@ export async function mountRouteEditor(
routeHead,
curveHint,
contourHint,
commit: () => {
if (stationSnap) return applyEdit(snap.text()); // 놓을 때(366-3)
history?.commit(snapshotNow());
historyControls.sync();
commit: (index) => {
const before = history?.current?.()?.planned ?? []; // 놓을 때 맞춤(366-3) · 하한 · 보고(430)
applyEdit(stationSnap ? snap.text() : "옮겼습니다.");
if (stationSnap && index !== undefined) curveBar.noteFit(snap.text(), before, index);
},
snapStepM: snap,
fitDrag: (index) => {
@@ -544,7 +547,7 @@ export async function mountRouteEditor(
segmentAt: pointer.segmentAt,
nodeAt: pointer.nodeAt,
insert: (segment, px, py) => {
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
edits.spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
},
@@ -552,7 +555,7 @@ export async function mountRouteEditor(
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
if (apexLock[index])
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
spliceNode(snapshotNow(), index);
edits.spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
},
@@ -576,7 +579,7 @@ export async function mountRouteEditor(
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () =>
(history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(),
@@ -610,17 +613,16 @@ export async function mountRouteEditor(
limitArcM = plan.limit_curve_length_m ?? 0;
setStationSnap(snap.load(plan));
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
const flat = edits.flattenServerPlan(nodes, plan.curves ?? [], plan.node_edits);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다.
({ nodes: nodeInfo, curves: curveInfo } = flat);
picked = -1;
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
initial.ready(snapshotNow(), plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, snap.draft);
draftLink.restore(options.draft, restoreSnapshot);
bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3
historyControls.sync();
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
@@ -666,9 +668,7 @@ export async function mountRouteEditor(
let terrainTicket = 0;
terrainSelect.addEventListener("change", () => {
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const mine = ++terrainTicket;
void loadBackground().then((background) => {
if (closed || mine !== terrainTicket) return;
+47 -8
View File
@@ -21,7 +21,14 @@ import {
loadSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { carryStationDrafts, useStationKeys } from "../B06_Section/B06_Section_Station_Keys";
import {
carryChainageStates,
carryStationDrafts,
useStationKeys,
} from "../B06_Section/B06_Section_Station_Keys";
import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import {
replanRoute,
@@ -32,7 +39,10 @@ import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
type Vertex = [number, number];
type RouteSettings = SnapSetting & { terrain: string | null };
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> } & {
curveLock?: RouteDraft["curveLock"];
curveArc?: Array<number | null>;
};
export interface RouteApplyParams {
/** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */
@@ -65,15 +75,15 @@ interface RouteSource {
const sources = new Map<string, RouteSource>();
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(R · 길이 고정도 실음 — 서버가 곡선표 `edits` 에 둠 · 430). */
export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft {
return {
rev: 0,
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false),
curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null),
curveLock: nodes.planned.map(() => null),
curveArc: nodes.planned.map(() => null),
curveLock: nodes.planned.map((_, index) => nodes.curveLock?.[index] ?? null),
curveArc: nodes.planned.map((_, index) => nodes.curveArc?.[index] ?? null),
stationSnap: settings.stationSnap,
stationSnapStep: settings.stationSnapStep ?? null,
terrain: settings.terrain,
@@ -112,6 +122,7 @@ export async function saveRouteDraft(
projectId: string,
body: RouteSaveBody,
): Promise<RouteReplanResponse> {
pipesBeforeSave.set(projectId, await savedPipes(projectId));
const saved = await replanRoute(projectId, body);
useDesignStore(projectId, null).set("routeDraft", null);
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
@@ -134,9 +145,18 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin
return `노선 저장 — ${parts.join(" · ")}`;
}
type SavedPipe = { id?: string; chainage_m: number };
/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */
const pipesBeforeSave = new Map<string, SavedPipe[]>();
const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
fetchDetailPipePoints(projectId)
.then((res) => res.pipe_points)
.catch(() => []);
/**
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠) → 새 노선으로 넘기고 그 고침을 저장 — 초안을 통째로
* 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
*/
export async function carryAfterRouteSave(
projectId: string,
@@ -146,7 +166,26 @@ export async function carryAfterRouteSave(
invalidateSectionDetail(projectId, toRouteId);
const detail = await loadSectionDetail(projectId, toRouteId);
useStationKeys(projectId, toRouteId, detail);
const outside = carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true });
// 관 열쇠 → 새 자리 — 세션 id(`pipe-s3`) = 관 장부 · 저장 관 id(`p8017`) = 서버가 옮긴 관
const saved = await savedPipes(projectId);
const before = pipesBeforeSave.get(projectId) ?? [];
pipesBeforeSave.delete(projectId);
const newAt = (id: string | undefined): number | null =>
saved.find((pipe) => id !== undefined && pipe.id === id)?.chainage_m ?? null;
const pipeAt = (key: string): number | null => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? pipeChainageOf(id, projectId) : newAt(id);
};
// 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리
const movedAt = (chainageM: number): number | null =>
newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id);
const outside =
carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) +
carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt });
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
withDraftAdjusts(detail, projectId, toRouteId);
withCarriedEdits(detail, projectId, toRouteId);
await saveCachedCrossPatches(projectId, toRouteId);
return outside;
}
@@ -2,6 +2,7 @@ import { createButton } from "@ui/ui_template_elements";
import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels";
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
@@ -205,6 +206,33 @@ export function createRouteEditChrome(
setInfo(infoCollapsed);
infoToggle.addEventListener("click", () => setInfo(!infoCollapsed));
// 뜬 종단 판(이웃 칸)이 지도 아래를 덮으면 상태 · 범례를 그 위로(408-2) — 판 높이 끌기 · 접기 ·
// 창 분할(판이 다른 창으로 감 = 못 찾음)을 판 크기 관찰로 따라감. 횡단 판이 넓어 아래 줄이 좁으면 잰 값 창을
// 상태 글판 위로 쌓음(408-4 · 횡단 판 · 글판 크기도 관찰).
let watchedProfile: Element | null = null;
const liftInfo = (): void => {
if (!overlay.isConnected) return; // 붙기 전 첫 알림 · 걷힌 뒤 — 판 관찰은 판과 함께 사라짐
const profile =
overlay.closest(".design-page__main")?.querySelector(".b05-route-profile") ?? null;
if (profile && profile !== watchedProfile) lift.observe((watchedProfile = profile));
const map = overlay.getBoundingClientRect();
const top = profile?.isConnected ? profile.getBoundingClientRect().top : null;
overlay.style.setProperty(
"--b05-profile-cover",
`${infoLiftPx(map.bottom, map.height, top)}px`,
);
const sideOpen = parseFloat(overlay.style.getPropertyValue("--b05-side-open")) || 0;
overlay.classList.toggle("is-narrow-bottom", bottomRowNarrow(map.width, sideOpen));
overlay.style.setProperty(
"--b05-info-h",
`${Math.round(info.getBoundingClientRect().height)}px`,
);
};
const lift = new ResizeObserver(liftInfo);
lift.observe(overlay);
lift.observe(info);
lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!);
const measureCloseButton = createButton({ label: "✕", variant: "ghost" });
measureCloseButton.classList.add("b05-routeedit__measure-close");
measureCloseButton.setAttribute("aria-label", "닫기");
+17 -18
View File
@@ -14,7 +14,8 @@
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
*
* 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 ·
* 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름).
* 구조물 · 관 고름 = 그 측점 횡단(411-A · 법선은 평면 마커 몫) · 옮겨도 열쇠로 따라감 ·
* 평면 눈금 클릭 · 마커 고름 풂 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름 = 판 그대로).
* 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점).
* 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색).
* ========================================================================== */
@@ -34,6 +35,7 @@ import {
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { useDesignStore } from "../A00_Common/design/design_store";
import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape";
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
import {
buildCrossSvg,
@@ -74,23 +76,20 @@ export interface CrossWatch {
/** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */
leftCut?: boolean;
rightCut?: boolean;
/** 구조물 · 관 자리 — 법선 없이 마름모만. */
/** 구조물 · 관 자리 — 법선은 평면 마커가 그림(399). */
structure?: true;
}
/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */
export const PLAN_PICK_FROM = "b05-plan";
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 · 곡선 측점 포함). */
const isGridStation = (station: PipelineStation): boolean =>
station.kind === "grid" || station.kind === "end";
station.kind === "grid" || station.kind === "end" || station.kind === "curve";
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 `structure`. 고른 것 없으면 null. */
watched: () => CrossWatch | null;
}
@@ -247,7 +246,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
params.side.append(current.root, previous.root);
const interval = (): number => params.request().station_interval_m;
/** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */
/** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 · 관 측점인가. */
let watching: { key: string | null; chainageM: number; structure: boolean } | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
let shown: CrossPreviewResponse | null = null;
@@ -269,7 +268,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
}
function redraw(): void {
if (watching === null || watching.structure) return;
if (watching === null) return;
const station = located();
if (station && watching.key === null) watching.key = station.key; // 묶음 뒤 열쇠로 붙듦(옮겨도 따라감)
const route = pipe?.input("route") ?? null;
if (route !== drawnRoute && shown)
previous.show(
@@ -284,7 +285,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
},
);
drawnRoute = route;
const at = located()?.chainageM ?? watching.chainageM;
const at = station?.chainageM ?? watching.chainageM;
shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId));
shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur");
if (shown) current.show(shown, interval(), shownSource);
@@ -316,11 +317,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
};
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
function showStation(key: string | null, chainageM: number): void {
function showStation(key: string | null, chainageM: number, structure = false): void {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
watching = { key, chainageM, structure: false };
watching = { key, chainageM, structure };
drawnRoute = pipe?.input("route") ?? null;
shown = null;
redraw();
@@ -328,7 +329,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
}
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */
function follow(selection: DesignSelection): void {
attach();
const at = selection.chainageM;
@@ -341,10 +342,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
return;
}
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
if (!selection.structureId && (!station || isGridStation(station)))
return showStation(station?.key ?? null, station?.chainageM ?? at);
watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true };
params.onShown?.();
const structure = !!selection.structureId || (!!station && !isGridStation(station));
showStation(station?.key ?? null, station?.chainageM ?? at, structure);
}
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
if (from !== PLAN_PICK_FROM) follow(selection);
@@ -32,6 +32,7 @@ import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel
import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View";
import type { RockBoundaryControl } from "../B06_Section/B06_Section_UI_Cross_Design";
import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common";
import { readRockBoundaryDefault } from "../B06_Section/B06_Section_UI_Standard_Panel";
/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */
export interface RouteCrossSource {
@@ -40,18 +41,24 @@ export interface RouteCrossSource {
link: CulvertLink | undefined;
/** 보기 반폭(m) — B06 입력칸과 같은 값 · 그릴 때마다 읽음(B06 에서 바꾸면 다음 그림부터). */
halfWidthM: () => number;
/** 암 경계 기본 오프셋(m · config). */
rockDefaultM: number;
}
const none = (): void => undefined;
/** 암 경계선 = 정본 오프셋(없으면 기본 −0.5 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary: RockBoundaryControl = {
/** 암 경계선 = 정본 오프셋(없으면 config 기본 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary = (defaultOffsetM: number): RockBoundaryControl => ({
stepM: 0.1,
defaultOffsetM: -0.5,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? -0.5, 0),
defaultOffsetM,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? defaultOffsetM, 0),
adjust: none,
reset: none,
};
});
/** config 암 경계 기본(`STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M` · 종단 보기가 context 로 기억 · 416) —
* context 전이면 서버 Schema 기본과 같은 값. */
const CONTEXT_PENDING_ROCK_M = -0.5;
/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */
export function routeCrossSource(
@@ -60,8 +67,10 @@ export function routeCrossSource(
chainageM: number,
pane: string,
): RouteCrossSource | null {
const routeId = pageRouteId(projectId);
const detail =
pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId));
pipelineDetail(projectId) ??
(pipe?.input("route") ? null : peekSectionDetail(projectId, routeId));
const sections = detail?.cross_sections ?? [];
const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
if (!detail || !found) return null;
@@ -78,6 +87,7 @@ export function routeCrossSource(
? undefined
: culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null),
halfWidthM: () => displayHalfWidth(projectId, pageRouteId(projectId), sections),
rockDefaultM: readRockBoundaryDefault(projectId) ?? CONTEXT_PENDING_ROCK_M,
};
}
@@ -122,7 +132,7 @@ export function routeCrossCard(
plotHeightPx,
source.designZ,
undefined,
rockBoundary,
rockBoundary(source.rockDefaultM),
null,
undefined,
undefined,
@@ -10,8 +10,10 @@
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
CURVE_OPTIONAL_INNER_ANGLE_DEG,
curveOptionalAt,
deflectionRad,
radiusFloorM,
@@ -22,6 +24,8 @@ import {
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
@@ -38,6 +42,10 @@ export interface CurveBarState {
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
/** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */
line: Vertex[];
/** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */
snap: { stepM: number; stationM: number } | null;
}
export interface CurveBarParams {
@@ -57,6 +65,8 @@ export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
/** 끌기 · 손잡이 놓을 때 맞춤 결과를 상태줄 `message` 뒤에(430 · 스냅 끔이면 그대로). */
noteFit: (message: string, before: Vertex[], picked: number) => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
@@ -64,16 +74,16 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
const { picked, curveRadius, planned } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "반지름");
noteResult(message, "반지름", planned);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
@@ -83,7 +93,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const message =
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "곡선 길이");
noteResult(message, "곡선 길이", planned);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
@@ -114,32 +124,62 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
const { picked, curveOn, nodeInfo } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
// 내각 155° 미만 = 곡선을 둬야 하는 자리(별표2 Ⅰ.2.다.(1)) — 막지 않고 경고(430).
const inner = nodeInfo[picked]?.inner_angle_deg ?? null;
const warn =
!on && inner !== null && !curveOptionalAt(inner)
? ` — 내각 ${inner.toFixed(1)}° < ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 곡선 자리(경고)`
: "";
params.applyEdit(on ? "곡선을 넣었습니다." : `곡선을 지웠습니다${warn}.`);
if (warn) showToast(`곡선을 지웠습니다${warn}.`, "warning", 5000);
},
},
params.container,
);
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */
function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void {
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return;
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
if (!status) return;
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
const head =
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
? message.replace(/\.$/, "")
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
status.textContent = (status.textContent ?? "").replace(
message,
`${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`,
`${head}${fitTail(before, picked, false)}.`,
);
}
/** 맞춤이 민 교점 · 못 맞춘 곡선 꼬리 — 스냅 끔이면 ""(`hand` = 고른 IP 를 손이 옮김). */
function fitTail(before: Vertex[], picked: number, hand: boolean): string {
const { curveInfo, planned, line, snap } = params.state();
if (!snap) return "";
const off = offStationMcs(line, curveInfo, snap.stepM);
return fitNote(before, planned, picked, off, snap.stationM, snap.stepM, hand);
}
/** 끌기 · 손잡이 놓을 때(430) — 상태줄 `message` 뒤에 맞춤 결과(`before` = 누르기 전 교점). */
function noteFit(message: string, before: Vertex[], picked: number): void {
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
const tail = fitTail(before, picked, true);
if (status && tail)
status.textContent = (status.textContent ?? "").replace(
message,
`${message.replace(/\.$/, "")}${tail}.`,
);
}
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
const {
@@ -221,5 +261,5 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
});
}
return { label, sync };
return { label, sync, noteFit };
}
+28 -57
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 밖(`_Panel_Recommend`)에서 받는다.
*
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
* `initial_route` 한 곳에서 읽는다. 손으로 고친 노선의 다시 계산은 페이지 [저장]에서(`_Apply` · 346-3) —
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) —
* 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은
* 페이지 [저장]에서(`_Apply` · 346-3) —
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
*
@@ -17,7 +17,6 @@
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
@@ -29,14 +28,13 @@ import {
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
choiceWithProfile,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
initialRouteSnapshot,
normalizeRouteInitial,
readInitialChoice,
sameBase,
serverBase,
withProfileOf,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
@@ -49,7 +47,6 @@ export interface InitialPanelParams {
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
controls: {
recommendButton: HTMLButtonElement;
resetButton: HTMLButtonElement;
nowLine: HTMLElement;
};
projectId: string;
@@ -62,16 +59,17 @@ export interface InitialPanelParams {
}
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** 노선을 읽었다 — 창을 연 편집값 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, server?: unknown) => void;
/** [저장]에 실어 보낼 지금 추천. */
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
with_profile: boolean;
};
/** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */
/** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */
withProfile: () => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
tag: () => InitialChoice | null;
@@ -87,7 +85,7 @@ const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const { projectId } = params;
const { recommendButton, resetButton, nowLine } = params.controls;
const { recommendButton, nowLine } = params.controls;
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
@@ -97,14 +95,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
function loadPlan(): Promise<RoutePlanResponse> {
@@ -119,7 +116,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const now = choice ?? base;
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
recommendButton.disabled = busy || !opening;
resetButton.disabled = busy || !opening;
}
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
@@ -128,14 +124,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
...(withProfile ? {} : { with_profile: false }),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
});
return normalizeRouteInitial(response, logic, weights, criteria);
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
}
recommendButton.addEventListener("click", () => {
@@ -153,6 +151,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
logic: (choice ?? base).logic,
// 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작)
withProfile: choice?.withProfile ?? base.withProfile ?? true,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
@@ -174,54 +174,20 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
mode: picked.logic,
weights: picked.weights,
criteria: picked.criteriaOverrides,
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
withProfileOf(picked.logic),
choiceWithProfile(picked),
);
},
});
})();
});
// [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다.
resetButton.addEventListener("click", () => {
void (async () => {
if (busy || !opening) return;
busy = true;
render();
try {
const plan = await loadPlan().catch(() => null);
// 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다.
const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening);
if (!snapshot) {
showToast("전처리 초기값 노선을 읽지 못했습니다.", "error");
return;
}
if (!resetButton.isConnected) return;
choice = {
logic: "follow",
weights: null,
snapshot: copy(snapshot),
metrics: null,
criteria: null,
criteriaOverrides: null,
};
writeInitialChoice(projectId, choice);
params.apply(
copy(snapshot),
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [저장]에서.",
);
} finally {
busy = false;
if (resetButton.isConnected) render();
}
})();
});
render();
return {
ready(snapshot, wasEdited, server) {
ready(snapshot, server) {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다.
if (server) {
@@ -235,9 +201,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
current: () => {
const now = choice ?? base;
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
return {
mode: now.logic,
weights: now.weights,
criteria,
with_profile: choiceWithProfile(now),
};
},
withProfile: () => withProfileOf((choice ?? base).logic),
withProfile: () => choiceWithProfile(choice ?? base),
tag: () => choice,
restoreTag(tag) {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -27,9 +27,15 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
weighted: "비중 계산",
};
/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */
/** 옛 꼴(종단 반영 값 없음)의 갈래별 종단 — 예상노선 추적만 종단 없음(PLAN 77-2). */
export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow";
/** 이 추천의 종단 반영 — 추천 창 「종단 반영」 단추 값(421) · 옛 캐시 · 옛 서버 답이면 갈래로. */
export const choiceWithProfile = (choice: {
logic: RouteInitialLogic;
withProfile?: boolean | null;
}): boolean => choice.withProfile ?? withProfileOf(choice.logic);
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
@@ -154,7 +160,7 @@ export interface InitialMetrics {
violations: InitialViolation[] | null;
}
/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */
/** 서버 목록 한 줄 — 곡선반지름 · 계류 이격 · 측점 밖(못 지킴) · 원지반 기울기(`grade` · 참고만 · 419). */
export interface InitialViolation {
kind: string;
station: string;
@@ -197,8 +203,11 @@ export interface InitialChoice {
line?: Vertex[] | null;
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
offStationCurves?: number | null;
/** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */
unsnapped?: InitialChoice | null;
/** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */
withProfile?: boolean;
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
endsGradePct?: number | null;
endsOverLimit?: boolean;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -233,13 +242,6 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
};
}
/** 서버 `/route/plan` 의 `initial_route`(전처리 초기값 노선)를 편집값으로 편다. 없거나 모자라면 null. */
export function initialRouteSnapshot(initialRoute: unknown): RouteEditSnapshot | null {
const body = (initialRoute ?? {}) as Record<string, unknown>;
if (!Array.isArray(body.nodes) || body.nodes.length < 2) return null;
return snapshotOf(body.nodes, body.curves);
}
/** 미리 보기 지도 한 겹 — 점 목록과 종류. */
export interface MapLayer {
kind: "expected" | "current" | "preview";
@@ -299,6 +301,7 @@ export function normalizeRouteInitial(
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
withProfile?: boolean,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
@@ -328,7 +331,12 @@ export function normalizeRouteInitial(
gradeDevPct: pick(m, "grade_dev_pct"),
costIndex: pick(m, "cost_index"),
surplusM3: pick(m, "surplus_m3"),
overLimitCount: pick(over, "total"),
// 「못 지킨 구간」과 같은 목록에서 셈(원지반 기울기 뺌 · 421-2) — 목록 없는 옛 답만 서버 수
overLimitCount: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).filter(
(item) => item.kind !== "grade",
).length
: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
violations: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).map((item) => ({
@@ -346,15 +354,10 @@ export function normalizeRouteInitial(
bandWidthM: num(body.band_width_m),
stored: body.stored === true,
offStationCurves: num(body.off_station_curves),
unsnapped:
body.unsnapped && typeof body.unsnapped === "object"
? normalizeRouteInitial(
{ ...body, ...(body.unsnapped as Record<string, unknown>), unsnapped: null },
logic,
weights,
criteriaOverrides,
)
: null,
endsGradePct: num(body.ends_grade_pct),
endsOverLimit: body.ends_over_limit === true,
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
balance: parseBalance(m.profile),
line: Array.isArray(body.planned)
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
@@ -379,6 +382,8 @@ export interface InitialBase {
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
/** [적용] 때 「종단 반영」(421 · 서버 `with_profile`) — 옛 기록이면 없음. */
withProfile?: boolean;
}
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
@@ -406,7 +411,8 @@ export function serverBase(server: unknown): InitialBase {
? (body.logic as RouteInitialLogic)
: "follow";
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const withProfile = typeof body.with_profile === "boolean" ? body.with_profile : undefined;
if (logic !== "weighted") return { logic, weights: null, criteria, withProfile };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
@@ -414,7 +420,7 @@ export function serverBase(server: unknown): InitialBase {
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria, withProfile };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
@@ -426,6 +432,9 @@ export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
) {
return false;
}
// 서버가 종단 반영을 적어 둔 때만 견줌(옛 기록은 갈래 · 비중 · 제약만)
if (base.withProfile !== undefined && choiceWithProfile(choice) !== base.withProfile)
return false;
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
@@ -556,9 +565,17 @@ export function violationText(item: InitialViolation): string {
return `${item.station} ${value}${head ? `(${head})` : ""}`;
}
/** 원지반 기울기 — 참고만(419 · 계획선 높이로 푸는 값) · 「못 지킨 구간」 · 「기준 초과」에 안 듦. */
const isGroundGrade = (item: InitialViolation): boolean => item.kind === "grade";
/** 제약 못 지킨 구간(R · 계류 · 측점) — 서버 목록에서 원지반 기울기를 뺀 것. 옛 답(목록 없음)이면 null. */
export function brokenItems(metrics: InitialMetrics | null): InitialViolation[] | null {
return metrics?.violations?.filter((item) => !isGroundGrade(item)) ?? null;
}
/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */
export function violationsLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
const items = brokenItems(metrics);
if (!items) return "";
if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴";
const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · ");
@@ -566,12 +583,33 @@ export function violationsLine(metrics: InitialMetrics | null): string {
return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`;
}
/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */
/** 원지반 기울기(참고) 한 줄 — 최대 · 상한 넘는 곳 수. 서버가 목록을 안 줬으면 빈 글. */
export function groundGradeLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
if (!items) return "";
const over = items.filter(isGroundGrade).length;
return `원지반 기울기(참고) 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 상한 넘는 곳 ${over}`;
}
/** 원지반 기울기 넘는 곳 전부 — 참고 줄 풍선. */
export function groundGradeTitle(metrics: InitialMetrics | null): string {
return (metrics?.violations ?? []).filter(isGroundGrade).map(violationText).join("\n");
}
/** 시종점 높이 차가 상한을 넘을 때만 — 종단으로 못 맞춤(노선을 늘려야 함 · 419). 아니면 빈 글. */
export function endsLine(choice: InitialChoice | null): string {
if (!choice?.endsOverLimit) return "";
return `시종점 높이 차가 상한을 넘음(평균 ${fixed(choice.endsGradePct ?? null, 1, "%")}) — 종단으로 못 맞춤 · 노선을 늘려야 함`;
}
/** 탐색 링크 한도를 풀었을 때만 — 「탐색 링크 한도 14.0 → 23.5% 로 풂(지형상 14.0% 로 못 이음 · 4 m 마디)」. 아니면 빈 글.
* 엔진 탐색 격자(4 m) 링크 기울기 한도 — 원지반 기울기(참고 · 측점 20 m)와 잣대가 다름(421-3). */
export function relaxedLine(choice: InitialChoice | null): string {
const bound = choice?.terrainBoundPct;
if (bound === null || bound === undefined) return "";
const limit = fixed(choice?.criteria?.maxGradePct ?? null, 1, "%");
return `지형상 ${limit} 로 못 이어져 상한을 ${fixed(bound, 1, "%")} 로 풂`;
const limitPct = choice?.criteria?.maxGradePct ?? null;
const limit = fixed(limitPct, 1, "%");
return `탐색 링크 한도 ${fixed(limitPct, 1, "")} → ${fixed(bound, 1, "%")} 로 풂(지형상 ${limit} 로 못 이음 · 4 m 마디)`;
}
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추적). */
@@ -583,10 +621,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
// 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
// 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
writeState(
"routeedit-initial",
choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined },
choice && { ...choice, balance: undefined, line: undefined },
projectId,
);
}
+19 -11
View File
@@ -47,7 +47,9 @@ export interface RouteEditNavigationParams {
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
const { canvas, getView, setView, getMeta, draw } = params;
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 팬 중인 포인터 — 직전 자리만 쥔다(401). 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮으면
* 끄는 중 휠 확대 · Ctrl+휠 회전이 바꾼 화면을 옛 값이 덮어 지도가 튄다 → 걸음마다 지금 화면에 더함. */
let drag: { id: number; x: number; y: number; mask: number } | null = null;
canvas.addEventListener(
"wheel",
@@ -92,31 +94,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
!(params.leftPan && event.button === 0)
)
return;
const view = getView();
dragStart = {
if (drag) return; // 둘째 손가락은 팬을 넘겨받지 않음(벌림 확대 몫)
// 쥔 단추(buttons 비트) — 가운데 4 · 왼쪽 1. 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔.
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
offsetX: view.offsetX,
offsetY: view.offsetY,
mask: event.button === 1 ? 4 : 1,
};
canvas.style.cursor = "grabbing";
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
if (!drag || event.pointerId !== drag.id) return;
if (event.pointerType === "mouse" && !(event.buttons & drag.mask)) return stop();
const [dx, dy] = unrotateDelta(event.clientX - drag.x, event.clientY - drag.y);
drag.x = event.clientX;
drag.y = event.clientY;
const view = getView();
setView({ ...view, offsetX: view.offsetX + dx, offsetY: view.offsetY + dy });
draw();
});
const stop = (): void => {
if (!dragStart) return;
dragStart = null;
const stop = (event?: PointerEvent): void => {
if (!drag || (event && event.pointerId !== drag.id)) return;
drag = null;
canvas.style.removeProperty("cursor");
};
canvas.addEventListener("pointerup", stop);
canvas.addEventListener("pointercancel", stop);
canvas.addEventListener("lostpointercapture", stop);
}
export interface NodeSpliceParams {
+1 -1
View File
@@ -105,7 +105,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
function onKeyDown(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return;
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-route__main .b05-routeedit")) return; // B06 겹침 평면 = 키 안 먹음(425)
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
// 평면이 삼킴 — 종단도(▲▼ 계획고 · `defaultPrevented` 면 비킴) · 횡단 조정창으로 안 감
+12 -13
View File
@@ -54,6 +54,17 @@ export function centerDirectionOf(
return length <= 1e-9 ? null : [sx / length, sy / length];
}
/** 패널 하단 글 — **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) · 고정 여부는 단추 색, 하한은
* 칸이 막음. 155° 이상은 L 하한을 안 거는 까닭이 보이게. 곡선 없는 교각점(삼각)도 내각(403-1). */
export function curveInfoText(curveOn: boolean, inner: number | null, optional: boolean): string {
const angle = inner ? `내각 ${Math.round(inner)}°` : "";
// 한 줄에 들게 — 내각이 있으면 풀이 글은 뺌.
if (!curveOn) return angle ? `${angle} · 곡선 없음` : "곡선 없음 — 직선이 그대로 꺾입니다";
return angle && optional
? `${angle} · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
: angle;
}
/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */
const GAP_PX = 40;
/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */
@@ -342,19 +353,7 @@ export function createCurveLabel(
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
arc.value =
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
const inner = state.innerAngleDeg;
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
// 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이
// 여기서 보여야 한다(2026-09-12 사용자 확정).
info.textContent = state.curveOn
? inner
? `내각 ${Math.round(inner)}°` +
(state.curveOptional
? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
: "")
: ""
: "곡선 없음 — 직선이 그대로 꺾입니다";
info.textContent = curveInfoText(state.curveOn, state.innerAngleDeg, state.curveOptional);
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
requestAnimationFrame(() => {
+44 -10
View File
@@ -12,6 +12,7 @@ import { pickPx } from "../A00_Common/touch_bridge";
import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys";
import { bindStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
import {
dragHandleTo as curveDragTo,
type EditedCurve,
@@ -33,6 +34,7 @@ import {
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import {
curveShortfalls,
floorNote,
radiusFloorM,
shortfallCrossed,
type CurveLock,
@@ -97,8 +99,8 @@ export interface RouteEditPointerParams {
routeHead: () => string;
curveHint: () => string;
contourHint: () => string;
/** 끌기 한 번을 되돌리기에 쌓는다. */
commit: () => void;
/** 끌기 한 번을 되돌리기에 쌓는다 — `index` = 끈 교각점(430 · 놓을 때 맞춤 보고 · 방향키는 안 줌). */
commit: (index?: number) => void;
/** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */
snapStepM: () => number;
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 ·
@@ -191,6 +193,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
let dragMoved = false;
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */
let dragRaw: Vertex[] | null = null;
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
@@ -203,6 +207,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
}
const state = params.state();
hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3)
// 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫)
if (marks.press(px, py)) {
if (state.picked >= 0) {
params.setPicked(-1);
params.syncCurveBar();
params.draw();
}
canvas.setPointerCapture(event.pointerId);
return;
}
marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로)
const pick = pickAt(px, py);
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
@@ -288,11 +303,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const state = params.state();
if (marks.move(px, py)) return;
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
// 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const kept = [state.planned[node], state.curveRadius[node], state.curveOn[node]] as const;
state.planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
@@ -304,8 +323,21 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.setPicked(node);
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
dragMoved = true;
params.markEdited();
const after = params.state();
if (
shortfallCrossed(
before,
curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM),
)
) {
[state.planned[node], state.curveRadius[node], state.curveOn[node]] = kept;
params.markEdited();
status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`;
params.draw();
return;
}
dragMoved = true;
params.syncCurveBar();
status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`;
params.draw();
@@ -335,9 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
state.planned[dragNode] = previous;
params.markEdited();
fitPreview(state.planned);
status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`;
params.draw();
return;
}
@@ -352,12 +382,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const measuring = event.shiftKey || params.measureMode();
if (measuring && params.measure.preview(px, py)) params.draw();
// 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔.
const over = measuring ? null : pickAt(px, py);
// 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘.
const onMark = !measuring && marks.over(px, py);
const over = measuring || onMark ? null : pickAt(px, py);
hover(over);
const overNode = over && "node" in over ? over.node : -1;
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
canvas.style.cursor = onMark
? "grab"
: overNode >= 0
? state.apexLock[overNode]
? "not-allowed"
: "grab"
@@ -373,10 +406,11 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
marks.release();
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
params.commit();
params.commit(dragHandle?.node ?? dragNode);
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void params.crossPreview.refresh();
@@ -3,7 +3,8 @@
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
* 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`).
* 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값.
* [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`).
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은
* 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는
@@ -38,9 +39,12 @@ import {
SIMILAR_HINT,
similarLine,
violationsLine,
brokenItems,
endsLine,
groundGradeLine,
groundGradeTitle,
violationText,
WEIGHT_KEYS,
withProfileOf,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
@@ -67,7 +71,7 @@ type Vertex = [number, number];
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)",
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 + 종단(종단 반영 끄면 계획고 = 지반고)",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
gentle: "종단기울기가 가장 낮은 노선 + 종단",
@@ -121,6 +125,8 @@ export interface RecommendParams {
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
logic: RouteInitialLogic;
/** 「종단 반영」 처음 값 — 적용한 추천의 값 · 없으면 켬(421). */
withProfile: boolean;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -131,6 +137,7 @@ export interface RecommendParams {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
@@ -148,10 +155,8 @@ export function openRecommendModal(params: RecommendParams): void {
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
const invalid = new Set<WeightKey>();
let preview: InitialChoice | null = null;
/** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */
let snapOn = true;
const shownChoice = (): InitialChoice | null =>
preview && !snapOn ? (preview.unsnapped ?? preview) : preview;
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
let profileOn = params.withProfile;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
@@ -218,6 +223,8 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
</div>
<div data-line="violations" hidden></div>
<div data-line="ground" hidden></div>
<div class="is-warn" data-line="ends" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div class="is-warn" data-line="stations" hidden></div>
<div data-line="similar" hidden></div>
@@ -307,13 +314,14 @@ export function openRecommendModal(params: RecommendParams): void {
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => {
snapOn = active;
render();
const profileToggle = toggleButton("종단 반영", profileOn, (active) => {
profileOn = active;
void run(); // 누르면 바로 — 저장본 있으면 그것
});
snapToggle.dataset.act = "snap";
snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선";
rerun.before(snapToggle);
profileToggle.dataset.act = "profile";
profileToggle.title = "켬 = 후보마다 측점 횡단으로 균형 종단 · 끔 = 계획고 = 지반고";
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
rerun.before(profileToggle);
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
@@ -345,9 +353,9 @@ export function openRecommendModal(params: RecommendParams): void {
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
// 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점
const view = shownChoice();
if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned });
// 서버가 짠 원호 그대로(351-2) — 옛 답이면 꺾임점
if (preview)
layers.push({ kind: "preview", points: preview.line ?? preview.snapshot.planned });
map?.setLayers(layers);
}
@@ -397,9 +405,12 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 측점 맞춤 오른쪽 · 늘 보임 — 바꾼 것이 있을 때만 켜짐(383-3)
rerun.disabled = busy || !dirty || invalid.size > 0;
const view = shownChoice();
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
rerun.disabled = busy || invalid.size > 0;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
const view = preview;
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
const shown = metricsLines(view?.metrics ?? null);
line("metrics").textContent = view
@@ -418,10 +429,18 @@ export function openRecommendModal(params: RecommendParams): void {
line("candidates").hidden = seen.size === 0;
const violations = violationsLine(view?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (view?.metrics?.violations ?? [])
line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
// 원지반 기울기 = 참고 줄 따로 · 시종점 높이 차 알림(421-2 · 엔진 419)
const ground = groundGradeLine(view?.metrics ?? null);
line("ground").textContent = ground;
line("ground").title = groundGradeTitle(view?.metrics ?? null);
line("ground").hidden = !ground;
const ends = endsLine(view);
line("ends").textContent = ends;
line("ends").hidden = !ends;
const relaxed = relaxedLine(view);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
@@ -442,7 +461,8 @@ export function openRecommendModal(params: RecommendParams): void {
drawMap();
}
async function run(): Promise<void> {
/** `recompute` = [재계산] — 이 입력의 서버 저장본을 지우고 새로 셈. */
async function run(recompute = false): Promise<void> {
if (logic === "weighted" && invalid.size > 0) {
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
return;
@@ -450,13 +470,13 @@ export function openRecommendModal(params: RecommendParams): void {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = withProfileOf(logic);
const withProfile = profileOn;
busy = true;
dirty = false;
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = choiceLabel(logic, sentWeights);
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
@@ -486,6 +506,7 @@ export function openRecommendModal(params: RecommendParams): void {
sentWeights,
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -497,7 +518,7 @@ export function openRecommendModal(params: RecommendParams): void {
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${
stateEl.textContent = `미리 보기: ${label}${
choice.stored ? " (저장된 결과)" : ""
} — [적용]하면 바로 저장 · 새 초기값이 됩니다.`;
} catch (error) {
@@ -574,11 +595,10 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
rerun.addEventListener("click", () => void run());
rerun.addEventListener("click", () => void run(true));
applyButton.addEventListener("click", () => {
const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔)
if (!view) return;
params.onApply(view);
if (!preview) return;
params.onApply(preview);
closed = true;
close();
});
@@ -30,12 +30,13 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙 — 「측점 맞춤」을 끄면 같은 선",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
"전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤",
"계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음 · 원지반 기울기는 참고 줄",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
profile: "없음 — 전처리 계획선처럼 계획고 = 지반고",
caution:
"계류 이격 · 기울기를 보장하지 않음 — 못 지킨 구간 · 원지반 기울기(참고) · 시종점 알림을 꼭 확인",
profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
@@ -83,11 +84,11 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
text: "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두) — 누르면 바로 다시 셈(저장본 있으면 그것). 끄면 계획고 = 지반고로 흙 양만 셈. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
},
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
text: "지반이 이보다 가파른 쪽으로 곧장 오르는 길은 찾지 않음. 측점(20m) 사이 원지반 기울기는 참고만 — 계획선 높이(종단)로 푸는 값이라 「못 지킨 구간」에 안 듦. 시종점 높이 차가 상한을 넘으면 종단으로도 못 맞춰 알림. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%)",
},
{
name: "최소 반지름(m)",
@@ -102,8 +103,12 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
{
name: "측점 맞춤",
text: "켬(기본) = 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 6m 안에서 밀고 반지름을 맞추며, 측점이 모자라면 곡선을 합침. 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 다시 셈. 끔 = 옮기기 전 선. [적용]은 보이는 쪽",
name: "측점 맞춤(늘)",
text: "모든 갈래에서 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 셈",
},
{
name: "재계산",
text: "지금 칸 값으로 서버 저장본을 지우고 새로 셈(덮어쓰기 아님) — 셈하는 동안 진행 줄이 보임. [적용]한 노선 · [초기화] 자리는 안 바뀜",
},
];
@@ -114,11 +119,19 @@ const LINES_HELP: Array<{ name: string; text: string }> = [
},
{
name: "못 지킨 구간",
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
text: "최소 반지름 · 계류 이격 · 측점 밖 곡선을 넘은 곳만(「기준 초과」 수와 같음 · 5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "상한을 풂",
text: "지형상 상한 안으로는 시점 · 종점을 이을 수 없어 꼭 필요한 만큼만 상한을 올린 경우",
name: "원지반 기울기(참고)",
text: "측점(20m) 사이 원지반 기울기 최대 · 상한 넘는 곳 수 — 종단으로 푸는 값이라 판정에 안 듦(넘는 곳은 마우스를 올리면 보임)",
},
{
name: "시종점 높이 차",
text: "시점 · 종점 높이 차 ÷ 노선 연장이 상한을 넘으면 알림 — 종단으로 못 맞춤 · 노선을 늘려야 함",
},
{
name: "탐색 링크 한도를 풂",
text: "노선 찾기(4 m 마디 원지반)에서 상한 안 링크로는 시점 · 종점을 못 이어 꼭 필요한 만큼만 링크 한도를 올린 경우 — 원지반 기울기(참고 · 측점 20 m)와 잣대가 달라 값이 다를 수 있음",
},
{
name: "거의 같은 노선",
+34 -46
View File
@@ -29,12 +29,12 @@ import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdi
import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
const NODE_R = 6;
@@ -98,6 +98,8 @@ export interface RouteEditScene {
stationIntervalM: number;
/** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
stations: ReadonlyArray<number> | null;
/** 곡선 측점(MC · 252-2) 누가거리 — 눈금 이름 뒤 「(MC)」. */
curveStations?: ReadonlyArray<number>;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<MeasureMark>;
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
@@ -234,6 +236,32 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
// 예상노선 뒤 · 노선 앞(414-2) — 노선이 네모 위를 지나감 · 고른 곡선은 `drawPickedCurve` 가 위에.
context.save();
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
@@ -269,7 +297,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
? style.getPropertyValue("--color-danger") || "#dc2626"
: scene.locked[index]
? lockedColor
: style.getPropertyValue("--map-route") || "#f97316";
: style.getPropertyValue("--map-route-node") || "#15803d";
// 곡선 없는 교각점(155° 생략 · 손 지움)은 같은 크기 삼각형 · 가운데 비움(391-2).
const noCurve =
scene.curveOn.length > 0 &&
@@ -292,32 +320,10 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
drawPickedCurve(context, scene, style);
drawPickHover(context, scene, line, style, NODE_R);
drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위)
context.restore(); // 회전 끝
}
@@ -396,33 +402,14 @@ function drawMeasureMarks(
}
/** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 =
* 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */
* 구조물 측점 마커가 법선 · 정보 창까지(399 · `drawStructureMarks`). 절성토 색은 발자국 선이 보여 줌. */
function drawCrossWidth(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
const watch = scene.crossWidth;
if (!watch) return;
if (!watch.structure) {
drawStationNormal(context, scene, line, watch.chainageM);
return;
}
const frame = stationFrameAt(line, watch.chainageM);
if (!frame) return;
const [cx, cy] = scene.toScreen(frame.origin);
context.save();
context.setLineDash([]);
context.translate(cx, cy);
context.rotate(Math.PI / 4);
context.beginPath();
context.rect(-7, -7, 14, 14);
context.fillStyle = "rgba(255, 196, 0, 0.35)";
context.fill();
context.lineWidth = 2.5;
context.strokeStyle = "#ff9800";
context.stroke();
context.restore();
if (watch && !watch.structure) drawStationNormal(context, scene, line, watch.chainageM);
}
/**
@@ -502,6 +489,7 @@ function drawStationMarks(
uprightRad: scene.uprightRad,
onLabel: (box) => labels.push(box),
chainages: scene.stations ?? undefined,
mcChainages: scene.curveStations,
},
);
}
@@ -1,96 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Shift.ts
* 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록.
*
* 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) →
* 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 옮기기 전 종단을 그대로(노면 그대로 · 364-7 ·
* 바뀐 구간 첫 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume).
* ========================================================================== */
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page";
import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center";
import { pageHistory } from "./B05_Profile_UI_Profile_History";
import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import { emptyEdits, type AlignmentEdits } from "./logic/B05_Profile_UI_Profile_Alignment";
import { holdGrade, planRouteShift } from "./logic/B05_Profile_RouteShift";
type Vertex = [number, number];
/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */
const PROFILE_WAIT_MS = 10000;
/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */
function nextProfile(pipe: DesignPipeline): Promise<boolean> {
return new Promise((resolve) => {
const done = (ok: boolean): void => {
clearTimeout(timer);
stop();
resolve(ok);
};
const timer = setTimeout(() => done(false), PROFILE_WAIT_MS);
const stop = pipe.subscribe((event) => {
if (
event.kind === "stage" &&
event.stage === "profile" &&
event.generation === pipe.generation
)
done(true);
}, "중심점 좌우");
});
}
/**
* 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) ·
* `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌.
*/
export function bindRouteShift(
host: RouteEditHost,
projectId: string,
columns: () => RouteEditSnapshot,
apply: (message: string) => void,
drawn: () => [Vertex[], EditedCurve[]],
): void {
setRouteShiftHost(async (chainageM, deltaM) => {
const pipe = pagePipeline(projectId);
if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈
const now = columns();
const [line, curves] = drawn();
const plan = planRouteShift({
planned: now.planned,
line: line.length ? line : now.planned,
curves,
locked: now.apexLock,
chainageM,
deltaM,
});
if (!plan) return "노선을 아직 못 읽었습니다.";
if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요.";
const before = pipe?.output("profile");
const lineBefore = pipe?.output("route")?.line ?? [...line]; // 끼움 · 옮김이 산 배열을 고침
const album = pageHistory();
album?.pause();
try {
const routed = pipe ? nextProfile(pipe) : null;
if (plan.kind === "move") now.planned[plan.index] = plan.to;
else spliceNode(now, plan.index, plan.at);
const side = deltaM > 0 ? "좌" : "우";
apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`);
if (!pipe || !before || !(await routed)) return null;
const base = pipe.output("planline");
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
const lineAfter = pipe.output("route")?.line ?? drawn()[0];
const pinned = base && holdGrade(base, edits, before, lineBefore, lineAfter);
if (!pinned) return null;
const graded = nextProfile(pipe);
pipelineGrade(projectId, pinned);
await graded;
return null;
} finally {
album?.resume();
}
});
host.onDispose(() => setRouteShiftHost(null));
}
@@ -8,6 +8,7 @@
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
* 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
@@ -16,6 +17,7 @@ import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
curveSpans,
curveStations,
snapCurveStations,
stationsAlong,
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
@@ -52,6 +54,11 @@ export type SnapStep = {
changed: () => boolean;
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
/** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */
ticks: (
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
) => { stations: number[] | null; curveStations: number[] };
};
/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */
@@ -73,6 +80,14 @@ export function bindStationSnap(
const step = (): number | null => (select.value === "station" ? null : Number(select.value));
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
const stations = (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
on()
? snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curves),
step(),
)
: null;
const stepM = () => step() ?? stationIntervalM;
return Object.assign(stepM, {
text: () => snapFitText(select.value),
@@ -90,13 +105,15 @@ export function bindStationSnap(
now.stationSnap !== loaded.stationSnap || now.stationSnapStep !== loaded.stationSnapStep
);
},
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
on()
? snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curves),
step(),
)
: null,
stations,
ticks: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => {
const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false);
// 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안
const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null);
return {
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves),
curveStations: curve,
};
},
});
}
@@ -0,0 +1,431 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_StructureMarks.ts
* 평면 **구조물 측점 마커**(PLAN 399 · 요구 250-2).
*
* · 목록 = 종단 아래 알약과 같은 것(`laneStructures` · 관 · 구조물 · 물넘이 · 사토장 …) · 노선 위 그 측점에
* 알약 꼴(종류 색) · 일반 측점 눈금 · 스냅 · 간격과 무관.
* · 고름 = 알약 고름과 같은 길(`select` · 공용 고름 382-2 — 평면 마커 고름 = 종단 알약 강조 · 반대도).
* 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391).
* · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 ·
* 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본).
* · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름.
* · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 ·
* 좌측 목록 강조 · 폼은 풂 409-2).
* 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`.
* ========================================================================== */
import { pickPx } from "../A00_Common/touch_bridge";
import { draftProjectId } from "../A00_Common/design/design_store";
import {
NO_SELECTION,
PIPE_MATCH_M,
readSelection,
selectDesign,
} from "../A00_Common/design/design_selection";
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import {
balloonElement,
laneStructures,
positionText,
structurePillTexts,
} from "./B05_Profile_UI_Structures_Marks";
import { balloonLines } from "./logic/B05_Profile_UI_Structures_Balloon";
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input";
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape";
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { arrowStep, isArrowKey, type ArrowKey } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import {
arrowSign,
markChainage,
markStep,
pickedMarkId,
} from "./logic/B05_Profile_RouteEdit_StructureMarks";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마커를 잡았다고 볼 거리(px). */
const MARK_HIT_PX = 10;
/** 알약 마커 반 길이 · 굵기(px) — 고르면 조금 크게. */
const MARK_HALF_PX = 5;
const MARK_WIDTH_PX = 8;
/** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */
const PICKED_RING = "#0ea5e9";
/** 법선 좌우 길이(m) — `_Hover` 법선과 같은 값(391-1) · 정보 창을 그 밖으로. */
const NORMAL_HALF_M = 20;
/** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */
export interface PlanMarkSource {
marks: () => ReadonlyArray<StructureInstance>;
types: () => ReadonlyArray<StructureType>;
intervalM: () => number;
select: (structureId: string) => void;
move: (structureId: string, toChainageM: number) => void;
}
const sources = new Map<string, PlanMarkSource>();
/** 목록이 바뀌면 다시 그릴 편집 창(프로젝트마다). */
const redraws = new Map<string, Set<() => void>>();
/** 알약 목록이 바뀜(추가 · 옮김 · 노선 따라감) — 그 프로젝트 편집 창을 다시 그림. */
export function planMarksChanged(projectId: string): void {
redraws.get(projectId)?.forEach((redraw) => redraw());
}
/** 프로젝트의 마커 재료를 건넴 — 돌려준 함수로 뗌(보기를 떠날 때). */
export function setPlanMarkSource(projectId: string, source: PlanMarkSource): () => void {
sources.set(projectId, source);
return () => void (sources.get(projectId) === source && sources.delete(projectId));
}
interface PlanMark {
id: string;
chainageM: number;
structure: StructureInstance;
type: StructureType | undefined;
name: string;
}
/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로.
* `done` = 옮겼고 알약 목록이 아직 안 따라옴(관 목록은 늦게 옴) — 따라올 때까지 그 자리 · 그 고름 그대로. */
interface Moving {
id: string;
name: string;
from: number;
to: number;
totalM: number;
done?: true;
}
const movings = new WeakMap<HTMLCanvasElement, Moving | null>();
/** 끄는 중 · 누르는 중인 것(옮긴 뒤 기다리는 것은 아님). */
const activeMoving = (canvas: HTMLCanvasElement): Moving | null => {
const moving = movings.get(canvas);
return moving && !moving.done ? moving : null;
};
const balloons = new WeakMap<HTMLCanvasElement, { key: string; element: HTMLElement }>();
const lengthOf = (line: ReadonlyArray<Vertex>): number =>
line.reduce(
(sum, vertex, index) =>
index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0,
0,
);
/** 지금 마커 목록(옮기는 것은 그 자리) · 고른 것 — 재료가 없으면 null. */
function planMarks(
canvas: HTMLCanvasElement,
): { source: PlanMarkSource; marks: PlanMark[]; picked: string | null } | null {
const projectId = draftProjectId();
const source = sources.get(projectId);
if (!source) return null;
const list = laneStructures(source.marks());
const types = new Map(source.types().map((type) => [type.type_id, type]));
const texts = structurePillTexts(list, types);
const held = movings.get(canvas) ?? null;
const lagging = list.some(
(structure) => structure.structure_id === held?.id && structureAnchorM(structure) !== held?.to,
);
if (held?.done && !lagging) movings.set(canvas, null); // 목록이 따라옴(같은 id 가 없어도 끝)
const moving = held?.done && !lagging ? null : held;
const marks = list
.filter((structure) => structure.structure_id)
.map((structure): PlanMark => {
const id = structure.structure_id as string;
return {
id,
chainageM: moving?.id === id ? moving.to : structureAnchorM(structure),
structure,
type: types.get(structure.type_id),
name: texts.get(id)?.name ?? structure.type_id,
};
});
// 옮기는 중엔 그것 — 관 고름은 누가거리만 들어 옮긴 자리로는 못 찾음
const picked = moving?.id ?? pickedMarkId(marks, readSelection(projectId), PIPE_MATCH_M);
return { source, marks, picked };
}
/** 알약 꼴 마커 한 개 — 노선 방향으로 누운 둥근 막대(종류 색 · 흰 테 · 고르면 하늘 테). */
function paintMark(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
mark: PlanMark,
picked: boolean,
halo: string,
): void {
const frame = stationFrameAt(line, mark.chainageM);
if (!frame) return;
const [x, y] = scene.toScreen(frame.origin);
const [fx, fy] = scene.toScreen([
frame.origin[0] + frame.left[1],
frame.origin[1] - frame.left[0],
]); // 앞쪽 1 m(왼쪽 축을 시계로 90°)
const half = picked ? MARK_HALF_PX + 2 : MARK_HALF_PX;
context.save();
context.translate(x, y);
context.rotate(Math.atan2(fy - y, fx - x));
context.lineCap = "round";
const stroke = (color: string, width: number): void => {
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
context.moveTo(-half, 0);
context.lineTo(half, 0);
context.stroke();
};
if (picked) stroke(PICKED_RING, MARK_WIDTH_PX + 7);
stroke(halo, MARK_WIDTH_PX + 3);
stroke(mark.type?.style?.color ?? "#888", MARK_WIDTH_PX);
context.restore();
}
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */
export function drawStructureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
style: CSSStyleDeclaration,
): void {
const canvas = context.canvas;
const found = line.length >= 2 ? planMarks(canvas) : null;
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
const picked = found?.marks.find((mark) => mark.id === found.picked) ?? null;
found?.marks.forEach(
(mark) => mark !== picked && paintMark(context, scene, line, mark, false, halo),
);
if (picked) {
drawStationNormal(context, scene, line, picked.chainageM);
paintMark(context, scene, line, picked, true, halo);
}
showBalloon(canvas, scene, line, picked && found ? { mark: picked, source: found.source } : null);
}
/** 고른 마커 위 정보 창 — 알약 벌룬 그대로 · 마커 위(위가 모자라면 아래) · 누름은 지나감. */
function showBalloon(
canvas: HTMLCanvasElement,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
pick: { mark: PlanMark; source: PlanMarkSource } | null,
): void {
const kept = balloons.get(canvas);
const frame = pick && stationFrameAt(line, pick.mark.chainageM);
if (!pick || !frame || !canvas.parentElement) {
kept?.element.remove();
balloons.delete(canvas);
return;
}
const { mark } = pick;
const structure = { ...mark.structure, chainage_m: mark.chainageM };
const position = positionText(structure, pick.source.intervalM());
const lines = balloonLines(structure, mark.type, { name: mark.name, position });
const key = JSON.stringify([lines, mark.type?.style?.color]);
let element = kept?.element;
if (!element || kept?.key !== key) {
const next = balloonElement(lines, mark.type);
next.classList.add("is-center");
next.style.pointerEvents = "none";
next.style.zIndex = "1"; // 오른쪽 횡단 판(z 2) · 펼친 판 아래로 — 지도 위에만
if (element) element.replaceWith(next);
else canvas.parentElement.append(next);
element = next;
balloons.set(canvas, { key, element });
}
// 그림은 캔버스 가운데를 축으로 돎 — 창 자리도 같은 만큼 돌림
const [sx, sy] = scene.toScreen(frame.origin);
const cx = scene.view.width / 2;
const cy = scene.view.height / 2;
const cos = Math.cos(scene.rotationRad);
const sin = Math.sin(scene.rotationRad);
const x = cx + (sx - cx) * cos - (sy - cy) * sin;
const y = cy + (sx - cx) * sin + (sy - cy) * cos;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.bottom = "auto";
// 법선(좌우 20 m) 끝 밖으로 띄움 — 크게 확대해도 60 px 넘게는 안 떨어짐
const gap = Math.min(NORMAL_HALF_M * scene.pxPerMeter, 60) + 10;
element.style.transform =
y < element.offsetHeight + gap + 10
? `translate(-50%, ${gap}px)`
: `translate(-50%, calc(-100% - ${gap}px))`;
}
export interface StructureMarkInput {
/** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */
press: (px: number, py: number) => boolean;
/** 끄는 중이면 자리 바꿈 · true. */
move: (px: number, py: number) => boolean;
/** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */
release: () => boolean;
/** 마커 위인가 — 커서 몫. */
over: (px: number, py: number) => boolean;
/** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */
deselect: () => boolean;
}
/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */
export function bindStructureMarks(
params: RouteEditPointerParams,
dragging: () => boolean,
): StructureMarkInput {
const { canvas, status } = params;
const projectId = draftProjectId();
const list = redraws.get(projectId) ?? new Set<() => void>();
redraws.set(projectId, list);
const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw));
list.add(redraw);
const lineNow = (): Vertex[] => {
const state = params.state();
return state.plannedLine.length ? state.plannedLine : state.planned;
};
const markAt = (px: number, py: number): PlanMark | null => {
const found = planMarks(canvas);
const line = lineNow();
let best: PlanMark | null = null;
let bestDistance = pickPx(MARK_HIT_PX);
for (const mark of found?.marks ?? []) {
const frame = stationFrameAt(line, mark.chainageM);
if (!frame) continue;
const [x, y] = params.toScreen(frame.origin);
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) [best, bestDistance] = [mark, distance];
}
return best;
};
const say = (moving: Moving, done: boolean): void => {
const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM;
const shift = moving.to - moving.from;
status.textContent =
`${params.routeHead()} — 구조물 ${moving.name} ${formatStation(moving.from, interval)} → ` +
`${formatStation(moving.to, interval)}(${shift > 0 ? "+" : ""}${shift} m)` +
(done ? " 옮김 — [저장]에서 정본." : " 옮기는 중.");
};
/** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */
const finish = (): boolean => {
const moving = activeMoving(canvas);
if (!moving) return false;
movings.set(canvas, moving.to !== moving.from ? { ...moving, done: true } : null);
if (moving.to !== moving.from) {
sources.get(draftProjectId())?.move(moving.id, moving.to);
say(moving, true);
}
params.draw();
return true;
};
const start = (mark: PlanMark): Moving => {
const moving = {
id: mark.id,
name: mark.name,
from: mark.chainageM,
to: mark.chainageM,
totalM: lengthOf(lineNow()),
};
movings.set(canvas, moving);
return moving;
};
/** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */
const deselect = (): boolean => {
if (!planMarks(canvas)?.picked) return false;
selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });
params.draw();
return true;
};
// 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복)
let holdKey: ArrowKey | null = null;
let timer = 0;
const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y));
const step = (moving: Moving, key: ArrowKey): void => {
const frame = stationFrameAt(lineNow(), moving.to);
if (!frame) return;
const tangent: Vertex = [frame.left[1], -frame.left[0]];
moving.to = markStep(
moving.to,
arrowSign(key, arrowStep(key, false, metricAt), tangent),
moving.totalM,
);
say(moving, false);
params.draw();
};
const release = (): void => {
window.clearTimeout(timer);
window.clearInterval(timer);
if (holdKey === null) return;
holdKey = null;
finish();
};
const detach = (): void => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", onKeyUp, true);
window.removeEventListener("blur", release);
};
function onKeyDown(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
const escape = event.key === "Escape";
if (
(!escape && !isArrowKey(event.key)) ||
!lastPointerWithin(".b05-route__main .b05-routeedit")
)
return; // 겹침 평면 = 안 먹음(425)
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
// Esc = 마커 고름만 풂(공용 Esc 풀기는 오른쪽 판까지 비우므로 앞에서 받음)
if (escape) return void (!activeMoving(canvas) && deselect() && event.preventDefault());
if (event.repeat || holdKey || dragging() || params.measureMode()) return;
if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫
const found = planMarks(canvas);
const mark = found?.marks.find((entry) => entry.id === found.picked);
if (!mark) return;
event.preventDefault();
const moving = start(mark);
const key = (holdKey = event.key);
step(moving, key);
timer = window.setTimeout(() => {
timer = window.setInterval(() => holdKey === key && step(moving, key), HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
}
function onKeyUp(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (event.key === holdKey) release();
}
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", onKeyUp, true);
window.addEventListener("blur", release);
return {
press(px, py) {
const mark = markAt(px, py);
if (!mark) return false;
release();
planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름)
start(mark);
params.draw();
return true;
},
move(px, py) {
const moving = activeMoving(canvas);
if (!moving || holdKey) return false;
const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity);
const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to;
// 옛 소수 자리(80.17)는 반올림 자리(80)에 머무는 동안 그대로 — 눌렀다 떼기만 해도 옮겨지지 않게
const to = near === Math.round(moving.from) ? moving.from : near;
if (to !== moving.to) {
moving.to = to;
say(moving, false);
params.draw();
}
return true;
},
release: () => !holdKey && finish(),
over: (px, py) => markAt(px, py) !== null,
deselect,
};
}
+22 -10
View File
@@ -78,7 +78,7 @@ export interface StructureMarksOptions {
}
/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */
function positionText(structure: StructureInstance, intervalM: number): string {
export function positionText(structure: StructureInstance, intervalM: number): string {
return structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}`
: formatStation(structure.chainage_m ?? 0, intervalM);
@@ -141,6 +141,16 @@ export function structurePillTexts(
);
}
/** 알약 자리 목록 — 노선 초안 동안은 묶음 follow 뒤 자리(평면 구조물 마커 399 도 이 목록). */
export function laneStructures(
structures: ReadonlyArray<StructureInstance>,
): ReadonlyArray<StructureInstance> {
const pipe = pagePipeline(draftProjectId());
return pipe?.input("route")
? followedMarks(structures, pipe.input("structures"), pipe.input("pipes"))
: structures;
}
/**
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
*/
@@ -151,10 +161,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`;
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const pipe = pagePipeline(draftProjectId());
const structures = pipe?.input("route")
? followedMarks(options.structures, pipe.input("structures"), pipe.input("pipes"))
: options.structures;
const structures = laneStructures(options.structures);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이
// 모두 이 번호를 따른다.
const texts = structurePillTexts(structures, typeMap);
@@ -266,13 +273,20 @@ function buildBalloon(
anchorPx: number,
row: number | null,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
const balloon = balloonElement(lines, type);
balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center");
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
// 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2).
queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon)));
return balloon;
}
/** 벌룬 몸(제목 + 줄 · 테 색) — 자리는 부르는 쪽(알약 레인 · 평면 구조물 마커 399). */
export function balloonElement(lines: string[], type: StructureType | undefined): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.style.borderColor = type?.style?.color ?? "#888";
const [name, ...rest] = lines;
const title = document.createElement("strong");
title.textContent = name;
@@ -284,8 +298,6 @@ function buildBalloon(
return span;
}),
);
// 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2).
queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon)));
return balloon;
}
+24 -9
View File
@@ -5,6 +5,9 @@
(`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */
.b05-routeedit {
--b05-side-open: 0px; /* 횡단 판이 지도를 덮는 폭(손잡이 포함 · `_Chrome` 이 적음) */
/* 아래 줄 반 폭(408-4) — 횡단 판 왼쪽 빈 폭을 상태 글판 · 잰 값 창이 반씩(안 겹침 · 좁으면 줄바꿈) ·
% 는 쓰는 자리(지도 칸) 기준 · 반이 모자라면(`is-narrow-bottom` · `_Chrome`) 빈 폭 전부 · 위아래로 쌓음. */
--b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2));
position: relative;
display: flex;
flex: 1 1 auto;
@@ -88,16 +91,16 @@
display: grid;
}
/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 가려진 채(349-0).
/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 그 위로(408-2 · `_Chrome` 이 적음).
두 줄 안 · 좁은 폭(345-5 덧 3) — 왼쪽 상태 글(두 줄에서 자름) · 가운데 범례 두 줄 · 오른쪽 ▾. */
.b05-routeedit__info {
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
left: var(--spacing-12);
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: start;
gap: 0 var(--spacing-12);
max-width: min(460px, 46%);
max-width: min(460px, 46%, var(--b05-bottom-half));
padding: var(--spacing-4, 4px) var(--spacing-8);
line-height: 1.45;
}
@@ -184,13 +187,14 @@
}
/* 오른쪽 덮는 판 — 패널 양식(382-1 · `ui_template_side_panels` · 손잡이 · 머리 · 폭 끌기) 을 지도 위에 얹음 ·
위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 아래 종단 판(z 3)이 그 위를 덮음 · 빈 자리는 지도 조작을 통과. */
위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 뜬 종단 판이 덮으면 그 위까지(408-3 · 손잡이 · 아래 반 보임) ·
빈 자리는 지도 조작을 통과. */
.b05-routeedit__side-wrap {
position: absolute;
z-index: 2;
top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */
right: var(--spacing-12);
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
display: flex;
min-height: 0;
pointer-events: none;
@@ -364,16 +368,16 @@
opacity: 0.5;
}
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 그 위로 · 408-3). 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: calc(var(--spacing-12) + var(--b05-side-open));
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
z-index: 1;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
max-width: 52%;
max-width: var(--b05-bottom-half);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
border-radius: var(--radius-8, 6px);
@@ -384,6 +388,17 @@
font-size: var(--text-caption);
}
/* 아래 줄 좁음(408-4) — 잰 값 창은 상태 글판 위에 · 둘 다 횡단 판 왼쪽 빈 폭 전부. */
.b05-routeedit.is-narrow-bottom {
--b05-bottom-half: max(0px, calc(100% - 2 * var(--spacing-12) - var(--b05-side-open)));
}
.b05-routeedit.is-narrow-bottom .b05-routeedit__measure {
bottom: calc(
var(--spacing-12) + var(--b05-profile-cover, 0px) + var(--b05-info-h, 0px) + var(--spacing-8)
);
}
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
.b05-routeedit__measure-text {
min-width: 0;
@@ -410,7 +425,7 @@
.b05-routeedit__label {
/* 편집 칸 안 절대 위치(345-3) — 자리는 지도 칸 안으로 가둔다(`_Label`). */
position: absolute;
z-index: 3;
z-index: 1; /* 지도 위 표지 층 — 도구줄 · 횡단 판(2) 밑(401-3) */
display: flex;
flex-direction: column;
gap: 4px;
@@ -1,7 +1,7 @@
/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */
.b05-contourlock {
position: absolute;
z-index: 3;
z-index: 1; /* 지도 위 표지 층 — 도구줄 · 횡단 판(2) 밑(401-3) */
display: inline-flex;
align-items: center;
justify-content: center;
@@ -409,7 +409,7 @@
align-self: flex-start;
}
/* 측점 맞춤 · 재계산 한 줄(383-3) */
/* 종단 반영 · 재계산 한 줄(421) */
.b05-routerec__row {
display: flex;
gap: var(--spacing-8);
@@ -84,6 +84,12 @@
/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로
세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */
/* 측점 값 칸도 같은 규칙(407-5) — 이웃과 겹치면 칸을 얇게 · 글을 세움 · 지우지 않음. */
.b05-profile-table__cell.is-rotated {
writing-mode: vertical-rl;
white-space: nowrap;
}
.b05-profile-table__curve.is-rotated input {
writing-mode: vertical-rl;
width: 100%;
+188 -53
View File
@@ -1,21 +1,31 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Carry.ts
* 노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 밂(344-2).
* 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
*
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 =
* resources/tester/test_344_2_route_pairs.py.
* - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 꼬리를 뺀 가운데(바뀐 노드 앞 · 뒤 접선점 사이).
* - 앞 = 그대로 · 뒤 = 누가거리만 길이 차만큼 밂 · 안 = 새 지반 첫 계획선 꼭짓점(`groundFollowingBasePvi`)
* · 안의 편집(계획고 · 종단곡선 R)은 버림.
* - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
* resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py.
* - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만.
* - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
* (측점 스냅이 뒤 교점을 밀어도 이어받음).
* - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리
* 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점.
* ========================================================================== */
import {
chainageKey,
resolvePvi,
type AlignmentBase,
type AlignmentEdits,
type AlignmentNode,
} from "./B05_Profile_UI_Profile_Alignment";
/** 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · 파이썬 `SAME_LINE_TOL_M` 짝. */
export const SAME_LINE_TOL_M = 1.0;
/** 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. */
const SAME_DIRECTION_COS = 0.8660254037844386;
/** 꼭짓점 위로 볼 거리(m) — 서버 `SAME_STATION_SNAP_M` 짝. */
const ON_NODE_M = 0.006;
/** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */
export const SAME_VERTEX_M = 1e-3;
const EPS = 1e-6;
@@ -75,55 +85,180 @@ export function changedSpan(
};
}
const before = (span: ChangedSpan, s: number): boolean =>
s < span.start_m - EPS || (span.start_fixed && s <= span.start_m + EPS);
/** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */
function interp(xs: number[], ys: number[], value: number): number {
if (value <= xs[0]) return ys[0];
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
let low = 0;
let high = xs.length - 1;
while (high - low > 1) {
const mid = (low + high) >> 1;
if (xs[mid] <= value) low = mid;
else high = mid;
}
const span = xs[high] - xs[low];
return span <= 0 ? ys[high] : ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span;
}
const after = (span: ChangedSpan, s: number, end: number): boolean =>
s > end + EPS || (span.end_fixed && s >= end - EPS);
interface Segment {
x: number;
y: number;
ux: number;
uy: number;
length: number;
from: number;
}
/** 옛 꼭짓점 · 편집을 새 노선으로 옮김 — `groundPvi` = 새 노선 지반 첫 계획선(전 구간). */
export function carryProfileEdits(
basePvi: ReadonlyArray<AlignmentNode>,
edits: Partial<AlignmentEdits> | null | undefined,
span: ChangedSpan,
groundPvi: ReadonlyArray<AlignmentNode>,
): { basePvi: AlignmentNode[]; edits: AlignmentEdits } {
const moved = (s: number): number | null => {
if (before(span, s)) return s;
if (after(span, s, span.old_end_m)) return s + span.delta_m;
return null; // 바뀐 구간 안 — 버림
};
function segmentsOf(xy: ReadonlyArray<XY>): Segment[] {
const out: Segment[] = [];
let from = 0;
for (let index = 1; index < xy.length; index += 1) {
const dx = xy[index].x - xy[index - 1].x;
const dy = xy[index].y - xy[index - 1].y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length > EPS) {
const { x, y } = xy[index - 1];
out.push({ x, y, ux: dx / length, uy: dy / length, length, from });
}
from += length;
}
return out;
}
const nodes = new Map<number, AlignmentNode>();
basePvi.forEach((node) => {
const at = moved(node.chainage_m);
if (at !== null)
nodes.set(roundTo(at, 3), { chainage_m: roundTo(at, 6), elevation_m: node.elevation_m });
});
groundPvi.forEach((node) => {
const s = node.chainage_m;
if (before(span, s) || after(span, s, span.new_end_m)) return;
const slot = roundTo(s, 3);
if (!nodes.has(slot))
nodes.set(slot, { chainage_m: roundTo(s, 6), elevation_m: node.elevation_m });
});
const carry = (values: Record<string, number> | undefined): Record<string, number> => {
const out: Record<string, number> = {};
Object.entries(values ?? {}).forEach(([key, value]) => {
const chainage = Number(key);
if (!Number.isFinite(chainage) || value == null) return;
const at = moved(chainage);
if (at !== null) out[chainageKey(at)] = Number(value);
});
return out;
};
return {
basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!),
edits: {
station_offsets: carry(edits?.station_offsets),
curve_radii: carry(edits?.curve_radii),
},
/** 새 누가거리 → 같은 자리 옛 누가거리(옆 `tolM` · 방향 30° 안) · 아니면 null(파이썬 `line_matcher` 짝). */
export function lineMatcher(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
tolM: number = SAME_LINE_TOL_M,
): (chainageM: number) => number | null {
const olds = segmentsOf(oldXy);
const news = segmentsOf(newXy);
return (chainageM) => {
if (!olds.length || !news.length) return null;
const seg = news.find((item) => chainageM <= item.from + item.length) ?? news[news.length - 1];
const along = Math.min(Math.max(chainageM - seg.from, 0), seg.length);
const qx = seg.x + seg.ux * along;
const qy = seg.y + seg.uy * along;
let best = Infinity;
let at: number | null = null;
for (const item of olds) {
if (item.ux * seg.ux + item.uy * seg.uy < SAME_DIRECTION_COS) continue;
const p = Math.min(
Math.max((qx - item.x) * item.ux + (qy - item.y) * item.uy, 0),
item.length,
);
const dx = qx - (item.x + item.ux * p);
const dy = qy - (item.y + item.uy * p);
const gap = Math.sqrt(dx * dx + dy * dy);
if (gap < best) {
best = gap;
at = item.from + p;
}
}
return best <= tolM ? at : null;
};
}
/** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */
export function carryBase(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldBase: ReadonlyArray<AlignmentNode>,
groundPvi: ReadonlyArray<AlignmentNode>,
tolM: number = SAME_LINE_TOL_M,
): AlignmentNode[] {
if (!oldBase.length || !groundPvi.length) return oldBase.map((node) => ({ ...node }));
const match = lineMatcher(oldXy, newXy, tolM);
const baseS = oldBase.map((node) => node.chainage_m);
const baseZ = oldBase.map((node) => node.elevation_m);
return groundPvi.map((node) => {
const s = match(node.chainage_m);
return {
chainage_m: node.chainage_m,
elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6),
};
});
}
/**
* 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면
* 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬
* `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로.
*/
export function carryEdits(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldBase: ReadonlyArray<AlignmentNode>,
edits: Partial<AlignmentEdits> | null | undefined,
stations: ReadonlyArray<number>,
halfM: number,
tolM: number = SAME_LINE_TOL_M,
): AlignmentEdits {
const out: AlignmentEdits = { station_offsets: {}, curve_radii: {} };
if (!oldBase.length) return out;
const match = lineMatcher(oldXy, newXy, tolM);
const mapped: Array<{ t: number; s: number }> = [];
[...new Set(stations.filter(Number.isFinite).map((value) => roundTo(value, 3)))]
.sort((a, b) => a - b)
.forEach((t) => {
const s = match(t);
if (s !== null) mapped.push({ t, s });
});
const baseS = oldBase.map((node) => node.chainage_m);
const baseZ = oldBase.map((node) => node.elevation_m);
const pvi = resolvePvi({ basePvi: [...oldBase] } as AlignmentBase, edits?.station_offsets ?? {});
const pviS = pvi.map((node) => node.chainage_m);
const pviZ = pvi.map((node) => node.elevation_m);
const touched = (s: number): boolean => {
const on = pvi.find((node) => Math.abs(node.chainage_m - s) < ON_NODE_M);
if (on) return on.source === "user";
const next = pvi.findIndex((node) => node.chainage_m > s);
return next > 0 && (pvi[next - 1].source === "user" || pvi[next].source === "user");
};
mapped.forEach(({ t, s }) => {
if (touched(s))
out.station_offsets[chainageKey(t)] = roundTo(
interp(pviS, pviZ, s) - interp(baseS, baseZ, s),
6,
);
});
const gaps = new Map<string, number>();
Object.entries(edits?.curve_radii ?? {}).forEach(([key, value]) => {
const chainage = Number(key);
if (!Number.isFinite(chainage) || value == null) return;
let slot: string | null = null;
let best = Infinity;
mapped.forEach(({ t, s }) => {
const gap = roundTo(Math.abs(s - chainage), 3); // mm — 가운데 같은 거리면 앞 측점
if (gap <= halfM && gap < best) [slot, best] = [chainageKey(t), gap];
});
if (slot === null || best >= (gaps.get(slot) ?? Infinity)) return;
gaps.set(slot, best);
out.curve_radii[slot] = Number(value);
});
return out;
}
/**
* 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새
* 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262).
*/
export function carryNoCurveKeys(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldKeys: ReadonlyArray<string>,
groundKeys: ReadonlyArray<string>,
groundPvi: ReadonlyArray<AlignmentNode>,
tolM: number = SAME_LINE_TOL_M,
): string[] {
const match = lineMatcher(oldXy, newXy, tolM);
const old = oldKeys.map(Number);
const ground = new Set(groundKeys);
return groundPvi
.filter((node) => {
const s = match(node.chainage_m);
if (s === null) return ground.has(node.chainage_m.toFixed(3));
return old.some((value) => Math.abs(s - value) < ON_NODE_M);
})
.map((node) => node.chainage_m.toFixed(3));
}
+18 -2
View File
@@ -14,6 +14,7 @@ import {
type AlignmentNode,
type AlignmentPolicy,
} from "./B05_Profile_UI_Profile_Alignment";
import { initialNoCurveKeys } from "./B05_Profile_Profile_CurveLaw";
/** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */
export interface GroundSample {
@@ -83,6 +84,16 @@ export function passThrough(baseS: number[], target: number[], policy: Alignment
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
return groundFollowingPlan(input)?.pvi ?? null;
}
/**
* 첫 계획선 꼭짓점 + 초기값 직선 열쇠(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 =
* 지반고 그대로) · 파이썬 `design_ground_following_profile` 의 `no_curve_keys` 짝.
*/
export function groundFollowingPlan(
input: GroundFollowingInput,
): { pvi: AlignmentNode[]; noCurveKeys: string[] } | null {
const profile = groundProfile(input.samples);
if (!profile) return null;
const { chainage, ground } = profile;
@@ -103,9 +114,14 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
const target = nodeS.map((s) => interp(chainage, ground, s));
target[0] += input.startOffsetM ?? 0;
target[target.length - 1] += input.endOffsetM ?? 0;
const baseZ = passThrough(nodeS, target, input.policy);
return nodeS.map((s, index) => ({
const noCurveKeys = initialNoCurveKeys(
nodeS.map((s, index) => ({ chainage_m: s, elevation_m: target[index] })),
input.policy.default_curve_length_m,
);
const baseZ = passThrough(nodeS, target, { ...input.policy, no_curve_keys: noCurveKeys });
const pvi = nodeS.map((s, index) => ({
chainage_m: roundTo(s, 6),
elevation_m: roundTo(baseZ[index], 6),
}));
return { pvi, noCurveKeys };
}
@@ -0,0 +1,59 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Seat.ts
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로.
*
* 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes`
* (common_util_structure_station.ts)가 고름 · 거울 시험 = resources/tester/test_427_1_follow_seat.py.
* ========================================================================== */
import {
buildCurves,
evaluateAt,
type AlignmentBase,
type AlignmentNode,
} from "./B05_Profile_UI_Profile_Alignment";
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */
export function seatBornNodes(
base: Pick<AlignmentBase, "chainage" | "ground" | "policy">,
nodes: AlignmentNode[],
born: ReadonlyArray<number>,
): AlignmentNode[] {
if (!nodes.length || base.chainage.length < 2) return nodes;
const first = nodes[0].chainage_m;
const last = nodes[nodes.length - 1].chainage_m;
const fresh = born.filter(
(value) =>
first < value &&
value < last &&
nodes.every((node) => Math.abs(value - node.chainage_m) >= 0.006),
);
if (!fresh.length) return nodes;
const rows = [
...nodes.map((node) => ({
s: node.chainage_m,
z: node.elevation_m,
node: node as AlignmentNode | null,
})),
...fresh.map((s) => ({ s, z: evaluateAt(base.chainage, base.ground, [], s), node: null })),
].sort((a, b) => a.s - b.s);
const baseS = rows.map((row) => row.s);
const target = rows.map((row) => row.z);
let baseZ = [...target];
for (let round = 0; round < 20; round += 1) {
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
const curves = buildCurves(pvi, base.policy, {}, []);
const error = rows.map((row, index) =>
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
);
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
baseZ = baseZ.map((z, index) => z + error[index]);
}
return rows.map(
(row, index) =>
row.node ?? {
chainage_m: Number(row.s.toFixed(6)),
elevation_m: Number(baseZ[index].toFixed(6)),
},
);
}
@@ -24,6 +24,7 @@ import type {
PipelineStation,
} from "../../A00_Common/design/logic/design_pipeline_types";
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import { stationLabel } from "../../common_util/common_util_station";
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
@@ -121,7 +122,9 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] =>
? "ep"
: station.kind === "grid"
? "regular"
: "irregular";
: station.kind === "curve"
? "curve"
: "irregular";
/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */
function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
@@ -210,7 +213,8 @@ export function stationSection(
};
}
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
* (십자선 대체 높이도 초안 종단 · 413). */
export function draftSectionDetail(
saved: SectionDetailResponse,
ground: GroundOutput,
@@ -240,6 +244,7 @@ export function draftSectionDetail(
})),
stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head),
profile_alignment: profile,
design_profiles: [toDesignProfile(profile, saved.longitudinal.design_profiles?.[0])],
},
cross_sections: sections,
balloon_offsets: saved.balloon_offsets,
@@ -1,8 +1,8 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Pipeline_Follow.ts
* 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안
* (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라
* 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침).
* (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 관 고정 id(411-B · 옮겨 차례가 바뀌어도
* 그 관) · id 없는 관(막 놓은 관)끼리는 누가거리 차례 · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침).
* 순수 계산(DOM · fetch · 세션 없음).
* ========================================================================== */
@@ -10,6 +10,7 @@ interface Span {
chainage_m: number;
start_m?: number | null;
end_m?: number | null;
id?: string | null;
}
/** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */
@@ -19,9 +20,15 @@ export function followPipeReports<R extends Span>(
): R[] {
if (!pipes || pipes.length !== reports.length) return [...reports];
const byChainage = (a: Span, b: Span): number => a.chainage_m - b.chainage_m;
const to = [...pipes].sort(byChainage);
return [...reports].sort(byChainage).map((report, index) => {
const pipe = to[index];
const byId = new Map(pipes.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe]] : [])));
const paired = (report: R): boolean => Boolean(report.id && byId.has(report.id));
const loose = [...pipes].filter((pipe) => !reports.some((r) => r.id && r.id === pipe.id));
const to = loose.sort(byChainage);
const sorted = [...reports].sort(byChainage);
let next = 0;
return sorted.map((report) => {
const pipe = paired(report) ? byId.get(report.id!)! : to[next++];
if (!pipe) return report;
const chainage = Math.round(pipe.chainage_m * 100) / 100; // 알림 목록 자릿수(`buildPipeReports`)
const shift = chainage - report.chainage_m;
const moved = (own: number | null | undefined, from: number | null | undefined) =>
+69 -16
View File
@@ -4,9 +4,9 @@
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryProfileEdits` 의 edits 몫 · 옛 노선 `before.route` 가 있을 때만).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로
* 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫).
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`).
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
*
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
@@ -16,6 +16,7 @@ import type {
FollowPatch,
PipelineInputs,
RangePart,
RouteLine,
StageFn,
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
@@ -31,9 +32,16 @@ import {
type AlignmentBase,
type AlignmentEdits,
} from "./B05_Profile_UI_Profile_Alignment";
import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry";
import {
carryBase,
carryEdits,
carryNoCurveKeys,
changedSpan,
type XY,
} from "./B05_Profile_Grade_Carry";
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
import { groundFollowingPlan, groundProfile } from "./B05_Profile_Grade_Ground";
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
line.map(([x, y]) => ({ x, y }));
@@ -74,8 +82,17 @@ export function gradeRange(
};
}
/** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */
interface GradeRef {
line: XY[];
basePvi: AlignmentBase["basePvi"];
edits: AlignmentEdits;
}
/**
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 첫 계산(옛 노선 없음)은
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로
* 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도
* 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은
* 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 —
* 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 —
* 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남).
@@ -83,7 +100,11 @@ export function gradeRange(
export function createFollowStage(deps: {
savedLine(): Vertex[] | null;
saved?(): FollowPatch;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
let ref: GradeRef | null = null;
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
return (ctx) => {
if (!ctx.inputs.route && deps.saved) return deps.saved();
const before = ctx.before.route?.line ?? deps.savedLine();
@@ -101,12 +122,31 @@ export function createFollowStage(deps: {
undefined,
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
).map((moved) => moved.item);
const span = ctx.before.route ? changedSpan(xy(before), line) : null;
if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits;
const planline = ctx.before.planline;
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
ref =
alignment && planline
? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits }
: null;
if (ref && ctx.before.route) {
const span = changedSpan(ref.line, line);
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : [];
carried = patch.alignment = span
? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half)
: ref.edits;
}
return patch;
};
}
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
const gridOf = (route: RouteLine): number[] => {
const grid: number[] = [];
for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s);
return [...grid, route.lengthM];
};
export const followStage = createFollowStage({ savedLine: () => null });
export interface PlanlineDeps {
@@ -132,19 +172,32 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
const groundPvi =
groundFollowingBasePvi({
samples,
stations: ground.stations.map((station) => station.chainageM),
policy: saved.policy,
}) ?? [];
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선 열쇠 · 432)
const plan = groundFollowingPlan({
samples,
stations: ground.stations.map((station) => station.chainageM),
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
});
const groundPvi = plan?.pvi ?? [];
const savedLine = deps.savedLine();
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
return {
policy: saved.policy,
// 이어받은 구간 = 옛 정책(L · 직선 열쇠) · 바뀐 자리 새 꼭짓점만 새 열쇠(서버 `carry_policy` 짝)
policy: span
? {
...saved.policy,
no_curve_keys: carryNoCurveKeys(
xy(savedLine!),
xy(route.line),
saved.policy.no_curve_keys ?? [],
plan?.noCurveKeys ?? [],
groundPvi,
),
}
: saved.policy,
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: span ? carryProfileEdits(savedPvi, null, span, groundPvi).basePvi : savedPvi,
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((station) => ({
@@ -0,0 +1,70 @@
/* =============================================================================
* B05_Profile_Profile_CurveLaw.ts
* 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
*
* 짝: 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py`(`initial_no_curve_keys` ·
* `vertical_curve_shortfalls`) · config `FOREST_ROAD_PROFILE_ALIGNMENT` 의 `default_curve_length_m` ·
* `initial_straight_radius_m` — 거울 시험 `test_432_1_curve_law.py`.
*
* - 초기값에서만 기본 L 기준 R > 500 m(|대수차| < L/500) 꼭짓점은 곡선 없음 — 정책 `no_curve_keys`
* 로 얼려 두고, 사용자가 그 꼭짓점을 고치거나(source "user") R 을 넣으면 다시 기본 곡선.
* - 법정 최소 R · L 미달은 막지 않고 경고 글만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌.
* ========================================================================== */
import type {
AlignmentCurve,
AlignmentNode,
AlignmentPolicy,
} from "./B05_Profile_UI_Profile_Alignment";
/** 기본 종단곡선 길이 L(m) — config `default_curve_length_m` 짝(새 정책에 담겨 옴 · 옛 저장분은 저장값). */
export const DEFAULT_CURVE_LENGTH_M = 20;
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
export const INITIAL_STRAIGHT_RADIUS_M = 500;
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m 인 것의 열쇠(소수 3자리) — 파이썬 `initial_no_curve_keys`. */
export function initialNoCurveKeys(
nodes: ReadonlyArray<AlignmentNode>,
curveLengthM: number | null | undefined,
): string[] {
if (!curveLengthM || nodes.length < 3) return [];
const limit = curveLengthM / INITIAL_STRAIGHT_RADIUS_M;
const keys: string[] = [];
for (let index = 1; index < nodes.length - 1; index += 1) {
const [a, b, c] = [nodes[index - 1], nodes[index], nodes[index + 1]];
const spanIn = b.chainage_m - a.chainage_m;
const spanOut = c.chainage_m - b.chainage_m;
const gradeIn = (b.elevation_m - a.elevation_m) / (spanIn > 0 ? spanIn : 1);
const gradeOut = (c.elevation_m - b.elevation_m) / (spanOut > 0 ? spanOut : 1);
if (Math.abs(gradeOut - gradeIn) < limit) keys.push(b.chainage_m.toFixed(3));
}
return keys;
}
/** 초기값 직선 꼭짓점이라 곡선을 안 넣는가 — 사용자가 고친 꼭짓점 · R 지정이면 넣음. */
export function skipsInitialCurve(
policy: AlignmentPolicy,
key: string,
node: AlignmentNode & { source?: string },
hasRequested: boolean,
): boolean {
return !hasRequested && node.source !== "user" && (policy.no_curve_keys ?? []).includes(key);
}
/** 법정 최소 R · L 을 못 채운 종단곡선 경고 글 — 파이썬 `vertical_curve_shortfalls` 와 같은 글. */
export function verticalCurveShortfalls(
curves: ReadonlyArray<AlignmentCurve>,
policy: AlignmentPolicy,
): string[] {
const minR = policy.min_vertical_radius_m ?? 0;
const minL = policy.min_curve_length_m ?? 0;
if (minR <= 0 && minL <= 0) return [];
return curves
.filter((curve) => !curve.omitted && !curve.skip_allowed)
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
.map(
(curve) =>
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
`법정 최소 R ${minR.toFixed(0)}m · L ${minL.toFixed(0)}m 미달`,
);
}
@@ -0,0 +1,56 @@
/* =============================================================================
* B05_Profile_Profile_Straight.ts
* 종단 「직선인가」 판정 한 벌(426 · 분석 424 6장 · 10장) — 꼭짓점이 이웃 둘을 잇는 선에서
* `STRAIGHT_HEIGHT_M` 안이면 직선(꺾임 아님).
*
* 짝: `B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend` · `STRAIGHT_HEIGHT_M` —
* 거울 시험 `test_426_profile_straight.py`.
*
* 기울기 정확 비교(1e-9 · 1e-6)는 꼭짓점 높이를 소수 여섯째 자리로 끊은 값(±5e-7 m)이 측점
* 간격 1 m 안(구조물 측점)에서 기울기 차로 커져 직선을 꺾임으로 읽었다 — 높이로 잰다.
* 부르는 곳: 곡선 넣기(`buildCurves`) · ▲▼ 축(`pivotAxes`) · 직선 찾기(`detectStraightRun`) ·
* 편집 정리(`tidyEdits`) · 편집 측점 수. 표의 대수차 빈칸은 안전망으로 따로 둔다.
* ========================================================================== */
import type { AlignmentNode, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
/** 직선으로 볼 꼭짓점 높이 어긋남(m) — 파이썬 `STRAIGHT_HEIGHT_M` 과 같은 값. */
export const STRAIGHT_HEIGHT_M = 1e-5;
/** 대수차 · 좌우 마디 길이로 본 직선 — 꼭짓점이 이웃 잇는 선에서 떨어진 높이 = |Δ|·a·b/(a+b). */
export function isStraightBend(delta: number, spanLeft: number, spanRight: number): boolean {
const span = spanLeft + spanRight;
if (!(spanLeft > 0 && spanRight > 0)) return true;
return (Math.abs(delta) * spanLeft * spanRight) / span < STRAIGHT_HEIGHT_M;
}
/** 세 꼭짓점의 가운데가 직선 위인가. */
export function isStraightNode(
prev: AlignmentNode,
node: AlignmentNode,
next: AlignmentNode,
): boolean {
const spanLeft = node.chainage_m - prev.chainage_m;
const spanRight = next.chainage_m - node.chainage_m;
if (!(spanLeft > 0 && spanRight > 0)) return true;
const delta =
(next.elevation_m - node.elevation_m) / spanRight -
(node.elevation_m - prev.elevation_m) / spanLeft;
return isStraightBend(delta, spanLeft, spanRight);
}
/** index 번째 꼭짓점이 직선 위인가 — 시종점은 꺾임으로 본다(false). */
export function straightAt(pvi: ReadonlyArray<AlignmentNode>, index: number): boolean {
if (index <= 0 || index >= pvi.length - 1) return false;
return isStraightNode(pvi[index - 1], pvi[index], pvi[index + 1]);
}
/** 「편집 측점 N개」 — 높이를 고친 꼭짓점 중 실제로 꺾인 것(시종점은 오프셋이 있으면). */
export function bentEditCount(alignment: ProfileAlignment): number {
const last = alignment.pvi.length - 1;
return alignment.pvi.filter((node, index) => {
if (node.source !== "user") return false;
if (index === 0 || index === last) return true;
return !straightAt(alignment.pvi, index);
}).length;
}
@@ -0,0 +1,30 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts
* 평면 상태 · 범례 올림(408-2) — 뜬 종단 판이 지도 아래를 덮은 높이만큼 위로.
* 아래 줄 좁음(408-4) — 횡단 판 왼쪽 빈 폭의 반이 모자라면 잰 값 창을 상태 글판 위로 쌓음.
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Chrome`.
* ========================================================================== */
/** 상태 글판이 이 높이(px)는 지도 위에 남게 — 판을 끝까지 올려도 지도 밖으로 안 나감. */
const KEEP_PX = 120;
/** 올릴 높이(px) — `profileTop` = 종단 판 윗변(없음 · 접힘 · 창 분할 = null) · 지도 칸 아래 · 높이. */
export function infoLiftPx(
mapBottom: number,
mapHeight: number,
profileTop: number | null,
): number {
if (profileTop === null) return 0;
const cover = Math.max(0, mapBottom - profileTop);
return Math.round(Math.min(cover, Math.max(0, mapHeight - KEEP_PX)));
}
/** 아래 줄 반 폭이 이보다 좁으면 나란히 두지 않고 쌓음(px). */
const HALF_MIN_PX = 200;
/** 지도 칸 가장자리 · 두 글판 사이 · 횡단 판 앞 간격(px) — CSS `--spacing-12`. */
const GAP_PX = 12;
/** 상태 글판 · 잰 값 창을 나란히 못 두나 — `mapWidth` = 지도 칸 폭 · `sideOpen` = 횡단 판이 덮는 폭. */
export function bottomRowNarrow(mapWidth: number, sideOpen: number): boolean {
return (mapWidth - 3 * GAP_PX - sideOpen) / 2 < HALF_MIN_PX;
}
@@ -5,9 +5,11 @@
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
* ========================================================================== */
import { stationLabel } from "../../common_util/common_util_station";
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
/** 방향키 한 번(m) · Shift 배수. */
@@ -94,3 +96,24 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요.";
return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`;
}
/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 ·
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」).
* `handMoved` = 끌기 · 손잡이로 고른 IP 를 손이 옮김(430 · 놓을 때) — 고른 IP 는 「밀림」에 안 셈. */
export function fitNote(
before: ReadonlyArray<Vertex>,
after: ReadonlyArray<Vertex>,
picked: number,
offMcs: ReadonlyArray<number>,
stationM: number,
stepM: number,
handMoved = false,
): string {
const start = before[picked] ?? [0, 0];
const report = nudgeReport(before, after, picked, start, start);
const pushed = report.pushed + (!handMoved && report.movedM > SAME_M ? 1 : 0);
const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`);
const more = names.length > 3 ? ` 외 ${names.length - 3}` : "";
const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : "";
return ` · 함께 밀린 교점 ${pushed}곳${off}`;
}
@@ -14,16 +14,10 @@
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
* ========================================================================== */
import type { EditedCurve, EditedPolyline, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import {
buildEditedPolyline,
type EditedCurve,
type EditedPolyline,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
rebuildLine,
shortfallCrossed,
type NodeColumns,
} from "./B05_Profile_UI_RouteEdit_Edits";
@@ -47,10 +41,7 @@ type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curv
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline {
const radius = [...cols.curveRadius];
applyArcLocks(planned, cols.curveLock, cols.curveArc, radius);
applyCurveLimits(planned, cols.curveOn, radius, limits.limitRadiusM, limits.limitArcM);
return buildEditedPolyline(planned, cols.curveOn, radius, limits.minRadiusM);
return rebuildLine({ ...cols, planned, curveRadius: [...cols.curveRadius] }, limits);
}
/** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */
@@ -66,15 +57,25 @@ export function offStationCount(
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number {
if (!(intervalM > 0)) return 0;
const spans = curveSpans(line, curves);
return offStationMcs(line, curves, intervalM).length;
}
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
export function offStationMcs(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number[] {
if (!(intervalM > 0)) return [];
const hits = new Set<number>();
for (const [start, end] of spans) {
const off: number[] = [];
for (const [start, end] of curveSpans(line, curves)) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
else off.push(mc);
}
return spans.length - hits.size;
return off;
}
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
@@ -0,0 +1,47 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts
* 평면 구조물 측점 마커(399 · 요구 250-2) — 노선 따라 정수 m 자리 · 방향키 부호 · 고른 마커.
*
* · 끌기 · 방향키 = 노선 위 정수 m(시점 0 ~ 종점 내림 · 밖은 끝에 붙음) · 방향키 한 번 1 m(Shift 없음).
* · 방향키 부호 = 누른 화면 방향이 노선 앞쪽과 같은 쪽이면 + · 거의 직각이면 → · ↑ = +.
* · 고른 마커 = 공용 고름 구조물 id 와 같은 것 · id 없으면(관 고름) 그 누가거리 가까운 것.
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_StructureMarks`.
* ========================================================================== */
import type { ArrowKey } from "./B05_Profile_RouteEdit_KeyNudge";
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
/** 노선 위 정수 m 자리 — 0 ~ 종점(내림) 밖은 끝에 붙임. */
export function markChainage(rawM: number, totalM: number): number {
return Math.min(Math.max(Math.round(rawM), 0), Math.max(0, Math.floor(totalM)));
}
/** 방향키 한 번 — 지금 자리(정수로) ± 1 m. */
export const markStep = (chainageM: number, sign: 1 | -1, totalM: number): number =>
markChainage(Math.round(chainageM) + sign, totalM);
/** 방향키 부호 — `keyDir`(누른 화면 방향의 도면 벡터) · `tangent`(노선 앞쪽) 같은 쪽이면 +1. */
export function arrowSign(key: ArrowKey, keyDir: Vertex, tangent: Vertex): 1 | -1 {
const dot = keyDir[0] * tangent[0] + keyDir[1] * tangent[1];
const size = Math.hypot(keyDir[0], keyDir[1]) * Math.hypot(tangent[0], tangent[1]);
if (Math.abs(dot) > size * 0.2) return dot > 0 ? 1 : -1;
return key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
}
/** 고른 마커 id — 고름 구조물 id 와 같은 것 · id 없으면 그 누가거리 `tolM` 안 가장 가까운 것 · 없으면 null. */
export function pickedMarkId(
marks: ReadonlyArray<{ id: string; chainageM: number }>,
selection: { chainageM: number | null; structureId: string | null } | null,
tolM: number,
): string | null {
if (!selection || selection.chainageM === null) return null;
if (selection.structureId)
return marks.some((mark) => mark.id === selection.structureId) ? selection.structureId : null;
let best: string | null = null;
let bestGap = tolM;
for (const mark of marks) {
const gap = Math.abs(mark.chainageM - selection.chainageM);
if (gap < bestGap) [best, bestGap] = [mark.id, gap];
}
return best;
}
-158
View File
@@ -1,158 +0,0 @@
/* =============================================================================
* B05_Profile_RouteShift.ts
* 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점
* 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄.
*
* · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌)
* · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김)
* · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김)
* · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움
* · 높이 = 노면 그대로(364-7) — 옮기기 전 종단의 꼭짓점 · R 을 계획고 편집으로 되살림(`holdGrade`) ·
* 바뀐 구간 첫 계획선 맞춤(`Pipeline_Grade` planline)에 휩쓸리지 않게 · 지반만 새로
*
* 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음).
* ========================================================================== */
import {
buildAlignment,
chainageKey,
evaluateAt,
type AlignmentBase,
type AlignmentEdits,
type ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import { changedSpan } from "./B05_Profile_Grade_Carry";
import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame";
type Vertex = [number, number];
/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */
export const NODE_SNAP_M = 0.5;
/** 이보다 작은 높이 차는 보정 안 함(m). */
export const PIN_TOLERANCE_M = 5e-4;
/** 노면 그대로 견줌 횟수. */
const HOLD_PASSES = 4;
export type RouteShiftPlan =
| { kind: "move"; index: number; to: Vertex }
| { kind: "insert"; index: number; at: Vertex }
| { kind: "locked"; index: number };
export interface RouteShiftInput {
/** 노드(꺾임점). */
planned: ReadonlyArray<Vertex>;
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
line: ReadonlyArray<Vertex>;
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
/** 노드마다 고정. */
locked: ReadonlyArray<boolean>;
chainageM: number;
/** 좌(+) · 우(−) 이동(m). */
deltaM: number;
}
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
function nearestSegment(nodes: ReadonlyArray<Vertex>, point: Vertex): number {
let best = 0;
let bestGap = Infinity;
for (let i = 0; i < nodes.length - 1; i += 1) {
const [ax, ay] = nodes[i];
const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay];
const length2 = vx * vx + vy * vy || 1;
const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2));
const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]);
if (gap < bestGap) [best, bestGap] = [i, gap];
}
return best;
}
/** 노드 편집 고름 — 노선이 없으면 null. */
export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null {
const { planned, line, curves, locked, chainageM, deltaM } = input;
const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null;
if (!frame) return null;
const { origin, left } = frame;
const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM];
const move = (index: number): RouteShiftPlan =>
locked[index]
? { kind: "locked", index }
: { kind: "move", index, to: shifted(planned[index]) };
const near = planned.findIndex(
([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M,
);
if (near >= 0) return move(near);
const curve = curves.find((one) => {
const [span] = curveSpans(line, [one]);
return span && chainageM > span[0] && chainageM < span[1];
});
if (curve && planned[curve.node_first]) return move(curve.node_first);
return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) };
}
const xy = (line: ReadonlyArray<Vertex>) => line.map(([x, y]) => ({ x, y }));
/**
* 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤).
* [옛 → 새, 새 → 옛].
*/
function carryChainage(oldLine: ReadonlyArray<Vertex>, newLine: ReadonlyArray<Vertex>) {
const span = changedSpan(xy(oldLine), xy(newLine));
const shift = (end: number, delta: number) => (s: number) => (span && s >= end ? s + delta : s);
return [
shift(span?.old_end_m ?? 0, span?.delta_m ?? 0),
shift(span?.new_end_m ?? 0, -(span?.delta_m ?? 0)),
];
}
/**
* 노면 그대로(364-7) — 노선을 옮긴 뒤 새 기준(`base` · 바뀐 구간 첫 계획선) 위에 옮기기 전 종단(`before`)의
* 꼭짓점 · 종단곡선 R 을 계획고 편집으로 되살림. 두 꺾은선의 꼭짓점을 모두 맞추면 두 선이 같음 —
* 다른 꼭짓점 · 새 기준에 없는 옛 꼭짓점만 고정(바뀐 구간 밖은 이미 같아 편집 안 늚). 차가 없으면 null.
* ponytail: 새 기준에만 있는 꼭짓점(옛 선 위로 고정)이 옛 종단곡선 반쪽 한계를 줄일 수 있음 — 측점마다
* 꼭짓점인 기준(지금 첫 계획선)이면 옛 선과 꼭짓점이 같아 없음.
*/
export function holdGrade(
base: AlignmentBase,
edits: AlignmentEdits,
before: ProfileAlignment,
oldLine: ReadonlyArray<Vertex>,
newLine: ReadonlyArray<Vertex>,
): AlignmentEdits | null {
const [carry, back] = carryChainage(oldLine, newLine);
const oldS = before.pvi.map((node) => node.chainage_m);
const oldZ = before.pvi.map((node) => node.elevation_m);
const oldKeys = new Set(oldS.map((s) => chainageKey(carry(s))));
const stationS = base.stations.map((station) => station.chainage_m);
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const line = (s: number[], z: number[], at: number): number => evaluateAt(s, z, [], at);
const offsets = { ...edits.station_offsets };
const radii = { ...edits.curve_radii };
let changed = false;
// 고정한 꼭짓점이 이웃 측점 사이 선을 바꿈 — 다시 견줘 남은 측점까지
for (let pass = 0; pass < HOLD_PASSES; pass += 1) {
const now = buildAlignment(base, { ...edits, station_offsets: offsets }).pvi;
const nowS = now.map((node) => node.chainage_m);
const nowZ = now.map((node) => node.elevation_m);
const nowKeys = new Set(nowS.map(chainageKey));
let pinned = false;
for (const key of new Set([...oldS.map(carry), ...nowS, ...stationS].map(chainageKey))) {
const at = Number(key);
const z = line(oldS, oldZ, back(at));
const lost = oldKeys.has(key) && !nowKeys.has(key); // 옛 꺾임이 새 선에 없음
if (!lost && Math.abs(line(nowS, nowZ, at) - z) < PIN_TOLERANCE_M) continue;
offsets[key] = z - line(baseS, baseZ, at); // 반올림 안 함 — 옛 선 위 꼭짓점이 꺾임 안 만듦
pinned = true;
}
if (!pinned) break;
changed = true;
}
Object.entries(before.edits.curve_radii).forEach(([key, radius]) => {
const at = chainageKey(carry(Number(key)));
if (radii[at] !== undefined) return;
radii[at] = radius;
changed = true;
});
return changed ? { ...edits, station_offsets: offsets, curve_radii: radii } : null;
}
@@ -5,7 +5,7 @@
* 계약 = `A00_Common/design/logic/design_pipeline_types.ts` 머리).
*
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 측점 부분
* (격자 · 스냅 · 구조물 측점 병합 · 종류 · 틀 · 방위각) ↔ `buildStations` ·
* (격자 · 스냅 · 구조물 측점 병합 · 곡선 측점 · 종류 · 틀 · 방위각) ↔ `buildStations` ·
* `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `planRadii` · `curveWidenings` ·
* `designCurveWidenings` · `curve_widening_m` · `CURVE_WIDENING_TABLE_M` 등(config_system_design) ↔
* 같은 이름 상수 · `circumradius_2d`(Engine_Geometry) ↔ `circumradius`.
@@ -17,6 +17,7 @@
import {
curveSpans,
curveStations,
snapCurveStations,
stationFrameAt,
stationsAlong,
@@ -67,7 +68,8 @@ const round = (value: number, digits: number): number => Number(value.toFixed(di
export const stationText = (chainageM: number): string => chainageM.toFixed(2);
/** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:<id>` ·
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`.
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP` ·
* 곡선 = `curve:<누가거리>`(ref = 누가거리).
* 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */
export function stationKey(
kind: StationKind,
@@ -76,6 +78,7 @@ export function stationKey(
): string {
if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null);
if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1)
if (kind === "curve") return commonStationKey("curve", Number(ref), null);
const base = commonStationKey(kind, 0, null, String(ref));
// ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김
return role === "anchor" ? base : `${base}:${role}`;
@@ -308,7 +311,15 @@ export function buildStations(
role: station.role,
});
}
const chainages = [...new Set([...grid, ...planted.keys()])].sort((a, b) => a - b);
const withPlanted = [...new Set([...grid, ...planted.keys()])];
// 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음
const curved = curveStations(
withPlanted,
spans.map(([bc, ec]) => [bc, ec] as const),
total,
options.stationSnap ? options.stationSnapStep : null,
);
const chainages = [...withPlanted, ...curved].sort((a, b) => a - b);
const fromSpans = spans.length ? designCurveWidenings(chainages, spans) : null;
const measured = fromSpans ? null : planRadii(line, chainages, total);
@@ -329,6 +340,9 @@ export function buildStations(
} else if (mark) {
kind = mark.kind;
key = stationKey(mark.kind, mark.id, mark.role as "start" | "anchor" | "end");
} else if (curved.includes(chainage)) {
kind = "curve";
key = stationKey("curve", chainage);
}
return {
key,
@@ -1,8 +1,8 @@
/* =============================================================================
* B05_Profile_Structure_Follow.ts
* 노선 초안 동안 종단 알약 자리(PLAN 346-2 덧 3) — 계산 묶음 follow 가 좌표로 따라간 관 · 구조물의 새
* 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt`) 라 묶음 관 id
* (서버 고정 id)와 다르면 누가거리 차례로 짝.
* 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt` · 자리 장부)라
* 묶음 관 id(서버 고정 id)와 다르면 같은 자리(장부가 따라간 자리 = 그 관 · 411-B) → 남은 것끼리 누가거리 차례.
*
* 순수 — 화면 · fetch · 세션 없음.
* ========================================================================== */
@@ -22,6 +22,9 @@ interface Moved extends Placed {
structure_id?: string | null;
}
/** 같은 관으로 볼 자리 차이(m) — `PIPE_MATCH_M`(design_selection) 같은 값. */
const SAME_PIPE_M = 0.51;
const list = (value: unknown): Moved[] => (Array.isArray(value) ? (value as Moved[]) : []);
const at = (item: Placed): number => item.chainage_m ?? item.start_m ?? 0;
@@ -35,14 +38,21 @@ export function followedMarks<T extends Mark>(
for (const item of list(structures)) if (item.structure_id) byId.set(item.structure_id, item);
const movedPipes = list(pipes).filter((pipe) => typeof pipe.chainage_m === "number");
for (const pipe of movedPipes) if (pipe.id) byId.set(pipe.id, pipe);
// ponytail: 차례 짝 — 노선을 고쳐 관 차례가 뒤바뀌면 틀림 · 알약이 서버 관 id 를 들면 id 짝으로
const loose = marks
.filter((mark) => mark.structure_id?.startsWith("pipe-") && !byId.has(mark.structure_id))
.sort((a, b) => at(a) - at(b));
const ids = new Set(marks.map((mark) => mark.structure_id));
const free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b));
let free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b));
const paired = new Map<T, Moved>();
if (loose.length === free.length) loose.forEach((mark, index) => paired.set(mark, free[index]));
for (const mark of loose) {
const same = free.find((pipe) => Math.abs(at(pipe) - at(mark)) < SAME_PIPE_M);
if (!same) continue;
paired.set(mark, same);
free = free.filter((pipe) => pipe !== same);
}
// ponytail: 남은 것은 차례 짝 — 알약이 옛 자리인 채 노선 고쳐 관 차례가 뒤바뀌면 틀림
const rest = loose.filter((mark) => !paired.has(mark));
if (rest.length === free.length) rest.forEach((mark, index) => paired.set(mark, free[index]));
return marks.map((mark) => {
const moved = (mark.structure_id ? byId.get(mark.structure_id) : undefined) ?? paired.get(mark);
if (!moved) return mark;
@@ -14,6 +14,8 @@
* ========================================================================== */
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
import { isStraightBend } from "./B05_Profile_Profile_Straight";
import { skipsInitialCurve, verticalCurveShortfalls } from "./B05_Profile_Profile_CurveLaw";
export interface AlignmentPolicy {
station_interval_m: number;
@@ -34,6 +36,10 @@ export interface AlignmentPolicy {
min_plan_radius_m?: number;
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
hairpin_min_radius_m?: number;
/** 종단곡선 법정 최소 R · L(m) · 초기값 곡선 없는 꼭짓점 열쇠(432 · `_Profile_CurveLaw`). */
min_vertical_radius_m?: number;
min_curve_length_m?: number;
no_curve_keys?: string[];
}
export interface AlignmentNode {
@@ -260,7 +266,7 @@ function snapToStations(
}
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
function resolvePvi(
export function resolvePvi(
base: AlignmentBase,
offsets: Record<string, number>,
): Array<AlignmentNode & { source: "auto" | "user" }> {
@@ -305,7 +311,7 @@ export function buildCurves(
const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft;
const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight;
const delta = gradeOut - gradeIn;
if (Math.abs(delta) < 1e-9) continue;
if (isStraightBend(delta, spanLeft, spanRight)) continue; // 직선 판정 한 벌(426)
const chainage = pvi[index].chainage_m;
const key = chainageKey(chainage);
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
@@ -315,6 +321,7 @@ export function buildCurves(
const hasRequested = Number.isFinite(requested) && requested > 0;
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
if (onlyExplicit && !hasRequested) continue;
if (skipsInitialCurve(policy, key, pvi[index], hasRequested)) continue; // 초기값 직선(432)
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
? (policy.default_curve_length_m as number)
: policy.default_curve_radius_m * Math.abs(delta);
@@ -422,6 +429,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
grade_percent: length > 0 ? (height / length) * 100 : 0,
});
}
warnings.push(...verticalCurveShortfalls(curves, base.policy)); // 법정 R · L 미달(432)
curves.forEach((curve) => {
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);
@@ -10,9 +10,8 @@
* ========================================================================== */
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { straightAt } from "./B05_Profile_Profile_Straight";
/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */
const COLLINEAR_EPSILON = 1e-6;
const SAME_STATION_M = 1e-6;
export interface PivotAxes {
@@ -26,21 +25,17 @@ export interface PivotAxes {
/** 누른 점의 두 축과 사이 변화점 — 시종점 · 계획선 밖이면 null. */
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
const { pvi, segments } = alignment;
const { pvi } = alignment;
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
// 꼭짓점 아닌 자리 = 그 점을 품은 마디 하나가 좌우 마디
const right = index >= 0 ? index : pvi.findIndex((node) => node.chainage_m > chainageM);
if (right <= 0 || (index >= 0 && index >= pvi.length - 1)) return null;
const [leftSeg, rightSeg] = index >= 0 ? [index - 1, index] : [right - 1, right - 1];
const grade = (at: number): number => {
const segment = segments[at];
return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0;
};
const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON;
// 이웃끼리 — 꼭짓점이 직선 위(높이 기준 · 426)면 건넘
let from = leftSeg;
while (from > 0 && same(from - 1, leftSeg)) from -= 1;
while (from > 0 && straightAt(pvi, from)) from -= 1;
let to = rightSeg + 1;
while (to < pvi.length - 1 && same(to, rightSeg)) to += 1;
while (to < pvi.length - 1 && straightAt(pvi, to)) to += 1;
return {
fromM: pvi[from].chainage_m,
fromZ: pvi[from].elevation_m,
@@ -26,9 +26,8 @@ import {
chainageKey,
controlElevationAt,
} from "./B05_Profile_UI_Profile_Alignment";
import { isStraightNode, straightAt } from "./B05_Profile_Profile_Straight";
/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */
const COLLINEAR_EPSILON = 1e-6;
/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */
const SAME_STATION_M = 1e-6;
@@ -63,17 +62,10 @@ export function baseElevationAt(base: AlignmentBase, chainageM: number): number
return last.elevation_m;
}
/** 변화점 사이 기울기 (index 번째 구간). */
function gradeAt(alignment: ProfileAlignment, index: number): number {
const segment = alignment.segments[index];
if (!segment || segment.length_m <= 1e-12) return 0;
return segment.height_m / segment.length_m;
}
/**
* 그 자리를 지나는 **최대 직선 구간**을 찾는다.
*
* 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점
* 사이 꼭짓점이 직선 위(높이 기준 · 426)면 한 직선으로 본다. 전체 측점
* 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만
* 두 측점 이상으로 이어진 직선이 된다.
*/
@@ -89,18 +81,10 @@ export function detectStraightRun(
chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M,
);
if (index < 0) return null;
const grade = gradeAt(alignment, index);
let first = index;
let last = index;
while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) {
first -= 1;
}
while (
last < alignment.segments.length - 1 &&
Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON
) {
last += 1;
}
while (first > 0 && straightAt(alignment.pvi, first)) first -= 1;
while (last < alignment.segments.length - 1 && straightAt(alignment.pvi, last + 1)) last += 1;
// 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다.
if (last === first) return null;
const fromM = alignment.segments[first].from_m;
@@ -122,12 +106,7 @@ export function detectStraightRun(
* (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다.
*/
export function hasStraightRuns(alignment: ProfileAlignment): boolean {
for (let index = 1; index < alignment.segments.length; index += 1) {
if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) {
return true;
}
}
return false;
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
@@ -177,7 +156,47 @@ export function straightenBetween(
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates, inner.map(chainageKey));
return withOffsets(
edits,
[...updates, ...pinEnds(base, current, [fromM, toM])],
inner.map(chainageKey),
);
}
/**
* 범위 끝이 꼭짓점 아닌 측점(관 · 구조물 · 덧 · 곡선 측점)이면 지금 높이로 꼭짓점을 세운다(426) —
* 안 세우면 끝 높이가 옮긴 안쪽 점을 따라 바뀌어 직선이 다음 꼭짓점부터 시작했다.
*/
export function pinEnds(
base: AlignmentBase,
current: ProfileAlignment,
ends: number[],
): Array<[string, number]> {
return ends
.filter((at) => !current.pvi.some((node) => Math.abs(node.chainage_m - at) < SAME_STATION_M))
.map((at) => [chainageKey(at), controlElevationAt(current, at) - baseElevationAt(base, at)]);
}
/**
* 편집 정리(426) — 직선 위 꼭짓점의 기억 R 을 지우고, 0 으로 돌아와 모양을 안 바꾸는 오프셋을
* 지운다(자동 꼭짓점 자리 · 또는 직선 위). 모양은 그대로 — 적용 한 곳(`applyEdits`)이 부른다.
*/
export function tidyEdits(base: AlignmentBase, edits: AlignmentEdits): AlignmentEdits {
const current = buildAlignment(base, edits);
const pvi = current.pvi;
const straight = new Set(
pvi
.filter((_node, index) => straightAt(pvi, index))
.map((node) => chainageKey(node.chainage_m)),
);
const auto = new Set(base.basePvi.map((node) => chainageKey(node.chainage_m)));
const offsets = { ...current.edits.station_offsets };
const radii = { ...current.edits.curve_radii };
Object.keys(radii).forEach((key) => straight.has(key) && delete radii[key]);
Object.entries(offsets).forEach(([key, value]) => {
if (Math.abs(value) < 1e-6 && (auto.has(key) || straight.has(key))) delete offsets[key];
});
return { station_offsets: offsets, curve_radii: radii };
}
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
@@ -209,9 +228,8 @@ export function tiltStraightRun(
);
const startZ = controlElevationAt(current, run.fromM);
const endZ = controlElevationAt(current, run.toM);
const span = run.toM - run.fromM;
const pivotZ =
startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta;
// 지금 경첩 높이 + 걸음(426) — 처음 직선 위 높이로 다시 잡으면 ▲ 두 번째가 안 오르고 ▼ 가 두 칸
const pivotZ = controlElevationAt(current, pivot) + delta;
const updates: Array<[string, number]> = [
[chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)],
@@ -236,7 +254,16 @@ export function tiltStraightRun(
updates,
inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey),
);
if (deltaGrade < 1e-9) return next;
const straightPivot = isStraightNode(
{ chainage_m: run.fromM, elevation_m: startZ },
{ chainage_m: pivot, elevation_m: pivotZ },
{ chainage_m: run.toM, elevation_m: endZ },
);
if (straightPivot) {
const curveRadii = { ...next.curve_radii };
delete curveRadii[chainageKey(pivot)];
return { ...next, curve_radii: curveRadii };
}
return {
...next,
curve_radii: {
@@ -42,7 +42,7 @@ export interface EditedCurve {
}
/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */
const DUPLICATE_TOLERANCE_M = 0.5;
export const DUPLICATE_TOLERANCE_M = 0.5;
/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */
const ARC_STEP_DEG = 5.0;
/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면
@@ -18,12 +18,18 @@
* 다만 **내각 155° 이상은 뺀다**(별표2 Ⅰ.2.다.(1) 「내각 155도 이상 되는 장소는 곡선을
* 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로
* 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m).
*
* ⚠ 짝(430) — `flattenServerPlan` 이 읽는 넣은 값 `edits` ↔ `B05_Profile_Route_NodeEdits.py`(쓰기) ·
* 거울 시험 `test_430_route_edit_rules.py`.
* ========================================================================== */
import {
buildEditedPolyline,
DUPLICATE_TOLERANCE_M,
innerAngleDeg,
type EditedCurve,
type EditedNode,
type EditedPolyline,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
@@ -154,6 +160,97 @@ export function shortfallCrossed(before: readonly number[], after: readonly numb
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
/** 선을 짜는 값 — 기본 반지름 · 못 넘는 하한(R · L). */
export interface CurveLimits {
minRadiusM: number;
limitRadiusM: number;
limitArcM: number;
}
/** 편집 창 `markEdited` 의 셈 — 길이를 붙든 자리 R 다시 잡기 → 지정값 하한까지 끌어올림(`curveRadius`
* 를 고침) → 선. 교각이 바뀌었을 수 있어 R 부터 잡고 그린다. */
export function rebuildLine(
cols: Omit<NodeColumns, "apexLock">,
limits: CurveLimits,
): EditedPolyline {
applyArcLocks(cols.planned, cols.curveLock, cols.curveArc, cols.curveRadius);
applyCurveLimits(
cols.planned,
cols.curveOn,
cols.curveRadius,
limits.limitRadiusM,
limits.limitArcM,
);
return buildEditedPolyline(cols.planned, cols.curveOn, cols.curveRadius, limits.minRadiusM);
}
/** 하한 글 꼬리 — 「(곡선반지름 12m · 곡선 길이 20m)」. */
export function floorNote(limitRadiusM: number, limitArcM: number): string {
return `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""})`;
}
/** 자리 번호 셋까지 「IP3 · IP5 외 2」. */
const ipList = (seats: ReadonlyArray<number>): string =>
seats
.slice(0, 3)
.map((seat) => `IP${seat}`)
.join(" · ") + (seats.length > 3 ? ` 외 ${seats.length - 3}` : "");
/**
* 이번 편집이 하한을 **새로** 깼으면 까닭 한 줄 · 아니면 ""(430 · 모든 평면 조작이 `applyEdit` 한 곳에서).
* `before` = 지난 걸음 사진 · `nodes` = 지금 노드 요약(스냅 맞춤 전 — 맞춤은 스스로 하한을 지킴).
* 노드를 하나 끼우거나 뺐으면 처음 달라진 자리에서 줄을 맞춰 견줌(새 노드 = 지키던 자리) · 둘 넘게 다르면 안 견줌.
*/
export function floorStop(
before: NodeColumns,
planned: ReadonlyArray<Vertex>,
nodes: ReadonlyArray<EditedNode>,
limits: CurveLimits,
): string {
const gap = planned.length - before.planned.length;
if (Math.abs(gap) > 1) return "";
const built = rebuildLine({ ...before, curveRadius: [...before.curveRadius] }, limits);
const was = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
const now = curveShortfalls(nodes, limits.limitRadiusM, limits.limitArcM);
const differ = planned.findIndex(
(vertex, i) => vertex[0] !== before.planned[i]?.[0] || vertex[1] !== before.planned[i]?.[1],
);
const at = differ < 0 ? planned.length : differ;
const old = (i: number): number =>
(gap === 0 || i < at ? was[i] : gap > 0 ? (i === at ? 0 : was[i - 1]) : was[i + 1]) ?? 0;
const seats = now.flatMap((value, i) => (value > 1e-6 && old(i) <= 1e-6 ? [i] : []));
if (!seats.length) return "";
return `하한에 걸려 되돌렸습니다 — ${ipList(seats)}${floorNote(limits.limitRadiusM, limits.limitArcM)}.`;
}
/** 상태줄 표시(430) — 겹친 노드(앞 노드에서 0.5 m 안 · 선을 짤 때 버림 · 짝 `DUPLICATE_TOLERANCE_M`) ·
* 못 그린 곡선(켬인데 곡선이 없음 · 곧은 자리 · 겹친 노드는 뺌). */
export function routeFlags(
planned: ReadonlyArray<Vertex>,
curveOn: ReadonlyArray<boolean>,
curves: ReadonlyArray<EditedCurve>,
): { overlap: number[]; undrawn: number[] } {
const overlap: number[] = [];
let last: Vertex | null = null;
planned.forEach((vertex, i) => {
if (last && Math.hypot(vertex[0] - last[0], vertex[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
overlap.push(i);
else last = vertex;
});
const drawn = new Set(curves.map((curve) => curve.node_first));
const undrawn = planned.flatMap((vertex, i) =>
i === 0 ||
i === planned.length - 1 ||
curveOn[i] === false ||
drawn.has(i) ||
overlap.includes(i) ||
!(innerAngleDeg(planned[i - 1], vertex, planned[i + 1]) < 179.9)
? []
: [i],
);
return { overlap, undrawn };
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
@@ -170,6 +267,8 @@ export interface CurveSummaryInput {
fresh: boolean;
/** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
offStation?: number;
/** 겹친 노드 · 못 그린 곡선 자리(430 · `routeFlags`). */
flags?: { overlap: number[]; undrawn: number[] };
}
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
@@ -187,6 +286,7 @@ export function curveSummary(input: CurveSummaryInput): string {
]
.filter(Boolean)
.join(" · ");
const { overlap, undrawn } = input.flags ?? { overlap: [], undrawn: [] };
if (input.fresh) {
const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [저장] 때 반영` : "";
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
@@ -195,6 +295,8 @@ export function curveSummary(input: CurveSummaryInput): string {
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
`${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
`${overlap.length ? ` · 겹친 노드 ${ipList(overlap)}(0.5 m 안 · 선에서 빠짐)` : ""}` +
`${undrawn.length ? ` · 못 그린 곡선 ${ipList(undrawn)}` : ""}` +
`${edits ? ` · ${edits}` : ""}`
);
}
@@ -219,6 +321,15 @@ interface ServerNode {
violations?: string[];
}
/** 노드마다 넣은 편집값(430) — 곡선표 `edits` 칸 · 서버 `node_edits`(⚠ 짝 `B05_Profile_Route_NodeEdits.py`). */
export interface RouteNodeEdit {
curve: boolean;
/** 넣은 R(그린 R 아님 · 접선 자리에 눌렸어도 넣은 값) · null = 자동. */
radius_m: number | null;
lock: CurveLock;
arc_m: number | null;
}
/**
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
*
@@ -231,8 +342,15 @@ interface ServerNode {
* ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안
* 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집
* 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다.
*
* `edits`(430 · 노드 수와 같을 때만 서버가 줌)가 있으면 곡선 켬끔 · R · 고정 · 고정 길이는 **넣은 값**으로 —
* 그린 R 로 열면 접선 자리에 눌린 R 이 넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐.
*/
export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan {
export function flattenServerPlan(
nodes: ServerNode[],
curves: EditedCurve[],
edits?: ReadonlyArray<RouteNodeEdit> | null,
): FlattenedPlan {
const replaced = new Map<number, EditedCurve>();
const dropped = new Set<number>();
curves.forEach((curve) => {
@@ -263,11 +381,14 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
tangent_m: null,
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
});
out.curveOn.push(curve !== undefined);
const edit = edits?.[index];
out.curveOn.push(edit ? edit.curve : curve !== undefined);
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null);
out.curveArc.push(null);
out.curveRadius.push(
edit ? edit.radius_m : curve ? Math.round(curve.radius_m * 100) / 100 : null,
);
out.curveLock.push(edit?.lock ?? null);
out.curveArc.push(edit?.arc_m ?? null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
@@ -181,3 +181,24 @@ export function snapCurveStations(
}
return [...new Set(values)].sort((a, b) => a - b);
}
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이
* 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */
export function curveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
total: number,
stepM?: number | null,
): number[] {
const scale = mcScale(stepM);
const taken = [...stations];
const out: number[] = [];
for (const [start, end] of spans) {
const value = Math.floor(((start + end) / 2) * scale + 0.5) / scale;
if (!(value > 0 && value < total) || taken.some((other) => Math.abs(value - other) < 0.005))
continue;
taken.push(value);
out.push(value);
}
return out.sort((a, b) => a - b);
}

Some files were not shown because too many files have changed in this diff Show More