Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
|
||||
@@ -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); // 다 나감 — 뒤처리는 새 상세로 그림
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
|
||||
|
||||
@@ -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)을 합쳐서 평가한다. 격자만 쓰면
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + 글」 포함)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", "닫기");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` 면 비킴) · 횡단 조정창으로 안 감
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: "거의 같은 노선",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user