Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_3
This commit is contained in:
@@ -487,6 +487,7 @@ export async function mountProfileView(
|
||||
contourIntervalM: latestResponse.surface_params.contour_interval_m,
|
||||
smooth: latestResponse.surface_params.smooth,
|
||||
tools: [view3d.modal.button],
|
||||
recommend: panel.recommend,
|
||||
});
|
||||
|
||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*
|
||||
* GET /projects/{id}/route/terrain-sources → 설계 지형 · 격자 받을 자리(`grid`)
|
||||
* GET /projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(ETag · IndexedDB)
|
||||
* POST /projects/{id}/route/elevations → 격자를 못 쓸 때 계산 묶음 ground 단계의 서버 길
|
||||
*
|
||||
* 주소에 manifest 서명을 붙임 — 보관함은 옛 저장본을 먼저 내므로(`fetchCachedBytes`) 지형이
|
||||
* 바뀐 뒤 옛 격자로 한 번 그리는 일을 막음. DTM 아닌 표현 · 못 받음 = null → 서버 표본 길.
|
||||
@@ -12,6 +13,7 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
|
||||
|
||||
export interface DesignTerrainGrid {
|
||||
/** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */
|
||||
@@ -25,6 +27,8 @@ interface TerrainSourcesPayload {
|
||||
}
|
||||
|
||||
const loaded = new Map<string, Promise<DesignTerrainGrid | null>>();
|
||||
/** 받기가 끝난 격자 — 끝나기 전엔 칸이 없음(그동안 서버 길). */
|
||||
const ready = new Map<string, DesignTerrainGrid | null>();
|
||||
|
||||
async function download(projectId: string): Promise<DesignTerrainGrid | null> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/terrain-sources`, {
|
||||
@@ -45,11 +49,53 @@ async function download(projectId: string): Promise<DesignTerrainGrid | null> {
|
||||
export function loadDesignTerrainGrid(projectId: string): Promise<DesignTerrainGrid | null> {
|
||||
let pending = loaded.get(projectId);
|
||||
if (!pending) {
|
||||
pending = download(projectId).catch(() => {
|
||||
loaded.delete(projectId);
|
||||
return null;
|
||||
});
|
||||
pending = download(projectId).then(
|
||||
(value) => {
|
||||
ready.set(projectId, value);
|
||||
return value;
|
||||
},
|
||||
() => {
|
||||
loaded.delete(projectId);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
loaded.set(projectId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */
|
||||
export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null {
|
||||
if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId);
|
||||
return ready.get(projectId) ?? null;
|
||||
}
|
||||
|
||||
/** 한 번에 물을 수 있는 점 수 — 서버 `MAX_POINTS` 와 같은 값. */
|
||||
const ELEVATION_CHUNK = 4000;
|
||||
|
||||
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길 값은 mm 자리(응답 그대로). */
|
||||
export function groundStageDeps(projectId: string): Pick<GroundStageDeps, "grid" | "remote"> {
|
||||
return {
|
||||
grid: () => designTerrainGridNow(projectId),
|
||||
async remote(points, terrain, signal) {
|
||||
const chunks: Array<Array<[number, number]>> = [];
|
||||
for (let i = 0; i < points.length; i += ELEVATION_CHUNK) {
|
||||
chunks.push(points.slice(i, i + ELEVATION_CHUNK));
|
||||
}
|
||||
const answers = await Promise.all(
|
||||
chunks.map(async (chunk) => {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/elevations`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ points: chunk, terrain }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`지반고 요청 실패: HTTP ${response.status}`);
|
||||
return ((await response.json()) as { z: Array<number | null> }).z;
|
||||
}),
|
||||
);
|
||||
return answers.flat();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
parseOptional,
|
||||
type RoutePanelValues,
|
||||
} from "./B05_Profile_UI_Panel_Fields";
|
||||
import { createRecommendPart } from "./B05_Profile_UI_Panel_Recommend";
|
||||
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
import { createViewControls } from "./B05_Profile_UI_Panel_View";
|
||||
|
||||
@@ -71,6 +72,7 @@ interface PanelCallbacks {
|
||||
export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) {
|
||||
const { viewControls, setCorridorPending } = createViewControls(callbacks);
|
||||
const settings = createSettingsPart(callbacks);
|
||||
const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4)
|
||||
const {
|
||||
contourInterval,
|
||||
gradeClass,
|
||||
@@ -119,11 +121,13 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
|
||||
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
|
||||
// [확정]은 한 벌이 두 보기 몫을 모아 낸다.
|
||||
Object.assign(shared.parts, { settings });
|
||||
Object.assign(shared.parts, { settings, recommend });
|
||||
sharedPanel(shared);
|
||||
|
||||
return {
|
||||
viewControls,
|
||||
/** 「노선 추천」 칸 요소 — 노선 편집기(`_RouteEdit_Initial`)에 넘긴다. */
|
||||
recommend,
|
||||
/** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */
|
||||
structures: structures as StructuresSection,
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Recommend.ts
|
||||
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 ·
|
||||
* [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의
|
||||
* `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface RecommendPart {
|
||||
recommendButton: HTMLButtonElement;
|
||||
resetButton: HTMLButtonElement;
|
||||
nowLine: HTMLElement;
|
||||
/** 칸 몸 — 화면 순서대로. */
|
||||
nodes: HTMLElement[];
|
||||
}
|
||||
|
||||
export function createRecommendPart(): RecommendPart {
|
||||
const nowLine = document.createElement("span");
|
||||
nowLine.className = "b05-routeedit__rec-now";
|
||||
const actions = document.createElement("div");
|
||||
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>`;
|
||||
const recommendButton = actions.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
|
||||
const resetButton = actions.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
||||
return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] };
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Sections.ts
|
||||
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정.
|
||||
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 노선 추천.
|
||||
* 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
|
||||
*
|
||||
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역.
|
||||
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 노선 추천 > 아래 고정 영역.
|
||||
* 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) ·
|
||||
* 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문).
|
||||
* 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문) · 그 자리에
|
||||
* 「노선 추천」(345-4 · 편집 머리에서 옮김 · 재료 `_Panel_Recommend`).
|
||||
* 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다.
|
||||
* ========================================================================== */
|
||||
|
||||
import "../A00_Common/design/design_sections";
|
||||
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel";
|
||||
import type { RecommendPart } from "./B05_Profile_UI_Panel_Recommend";
|
||||
import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
|
||||
registerPanelSection({
|
||||
@@ -24,3 +26,15 @@ registerPanelSection({
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
|
||||
registerPanelSection({
|
||||
id: "route-recommend",
|
||||
title: "노선 추천",
|
||||
views: ["profile"],
|
||||
order: 30,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "노선 추천");
|
||||
frame.body.append(...part<RecommendPart>(ctx, "recommend").nodes);
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
routeLocksOf,
|
||||
writeRouteLocks,
|
||||
} from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip";
|
||||
import {
|
||||
applyArcLocks,
|
||||
@@ -104,6 +104,8 @@ export interface RouteEditOptions {
|
||||
smooth?: boolean;
|
||||
/** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */
|
||||
tools?: HTMLElement[];
|
||||
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
|
||||
recommend: InitialPanelParams["controls"];
|
||||
}
|
||||
|
||||
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
|
||||
@@ -115,7 +117,7 @@ export async function mountRouteEditor(
|
||||
host: RouteEditHost,
|
||||
projectId: string,
|
||||
onApplied: () => void | Promise<void>,
|
||||
options: RouteEditOptions = {},
|
||||
options: RouteEditOptions,
|
||||
): Promise<void> {
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome(parent, options.tools);
|
||||
@@ -269,8 +271,9 @@ export async function mountRouteEditor(
|
||||
};
|
||||
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
|
||||
const initial = createInitialPanel({
|
||||
overlay,
|
||||
controls: options.recommend,
|
||||
projectId,
|
||||
planned: () => planned,
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ import { createButton } from "@ui/ui_template_elements";
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
* 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다.
|
||||
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
|
||||
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
|
||||
@@ -53,11 +54,8 @@ export function createRouteEditChrome(
|
||||
<div class="b05-routeedit__box" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<h3>계획노선</h3>
|
||||
<span class="b05-routeedit__rec-now" data-rec-now></span>
|
||||
<span class="b05-routeedit__actions">
|
||||
<span data-slot="tools"></span>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
|
||||
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 종단 띠 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
@@ -79,8 +77,6 @@ export function createRouteEditChrome(
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
|
||||
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [노선 반영]에서">초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
|
||||
title="마지막 [노선 반영] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">반영 전으로</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial.ts
|
||||
* 계획노선 편집 창의 **노선 추천 이음**(계획서 20 · 23-2) — 도구 막대 [노선 추천] 단추 →
|
||||
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [확인]에 실을 지금 추천.
|
||||
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
|
||||
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [노선 반영]에 실을 지금 추천. 칸 요소는
|
||||
* 밖(`_Panel_Recommend`)에서 받는다.
|
||||
*
|
||||
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
|
||||
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
|
||||
@@ -42,8 +43,15 @@ import {
|
||||
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
|
||||
|
||||
export interface InitialPanelParams {
|
||||
overlay: HTMLElement;
|
||||
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
|
||||
controls: {
|
||||
recommendButton: HTMLButtonElement;
|
||||
resetButton: HTMLButtonElement;
|
||||
nowLine: HTMLElement;
|
||||
};
|
||||
projectId: string;
|
||||
/** 지금 편집기 노드 — 추천 창이 「지금 노선」으로 같이 그린다. */
|
||||
planned: () => Array<[number, number]>;
|
||||
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
|
||||
planMinRadiusM: () => number;
|
||||
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
|
||||
@@ -75,10 +83,8 @@ const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
|
||||
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
|
||||
|
||||
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const { overlay, projectId } = params;
|
||||
const recommendButton = overlay.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
|
||||
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
||||
const nowLine = overlay.querySelector<HTMLElement>("[data-rec-now]")!;
|
||||
const { projectId } = params;
|
||||
const { recommendButton, resetButton, nowLine } = params.controls;
|
||||
|
||||
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
|
||||
let openingChoice = readInitialChoice(projectId);
|
||||
@@ -146,7 +152,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
logic: (choice ?? base).logic,
|
||||
planMinRadiusM: params.planMinRadiusM(),
|
||||
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
||||
current: opening!.planned.map(([x, y]) => [x, y]),
|
||||
current: params.planned().map(([x, y]) => [x, y]),
|
||||
request,
|
||||
onApply: (picked) => {
|
||||
choice = picked;
|
||||
|
||||
@@ -557,7 +557,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
},
|
||||
});
|
||||
|
||||
// 편집 창 단축키(Ctrl+Z 등)가 뒤에서 노선을 되돌리지 않게 막는다. 칸 안의 글자 되돌리기는 그대로.
|
||||
// 본문 평면 · 종단도 단축키(Ctrl+Z 등 · 창에서 들음)가 뒤에서 되돌리지 않게 막는다 — 칸 안 글자
|
||||
// 되돌리기는 그대로(345-4 · 이제 모달은 이 창 하나).
|
||||
(bodyEl as HTMLElement | null)
|
||||
?.closest(".ui-modal__backdrop")
|
||||
?.addEventListener("keydown", (event) => {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* 노선 추천 모달(계획서 23-2) — 알약 넷 · 미리 보기 지도 · 비중 · 제약 칸 · 비교값.
|
||||
색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 계획노선 편집 창(`.b05-routeedit`) 위에 뜬다. */
|
||||
색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 종단 보기 화면 위에 뜬다(모달 하나 · 345-4). */
|
||||
|
||||
/* 편집 창 제목 옆 「현재 추천: ○○」. */
|
||||
/* 좌측 「노선 추천」 칸(345-4) — 「현재 추천: ○○」 + [노선 추천] [초기화]. */
|
||||
.b05-routeedit__rec-now {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */
|
||||
.b05-route__rec-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 곡선 라벨 패널 · 페이지 막들 위로 — 모달과 뒤 막이 함께 올라간다. */
|
||||
.ui-modal__backdrop.b05-routerec-backdrop {
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Pipeline_RouteGround.ts
|
||||
* 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`.
|
||||
*
|
||||
* route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝
|
||||
* `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로.
|
||||
* ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝
|
||||
* (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나
|
||||
* (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`).
|
||||
* 바뀐 범위 밖 측점 · 종단은 앞 출력 재사용(국부 연산) — 자리 · 축 · 지형이 같을 때만.
|
||||
*
|
||||
* 화면 · fetch · 세션 안 씀 — 격자 · 서버 길은 부르는 쪽이 넣음(`B05_Profile_Api_TerrainGrid.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
GroundOutput,
|
||||
GroundSample,
|
||||
PipelineStation,
|
||||
RouteDraft,
|
||||
RouteLine,
|
||||
StageFn,
|
||||
StationGround,
|
||||
Vertex,
|
||||
} from "../../A00_Common/design/design_pipeline_types";
|
||||
import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler";
|
||||
import { buildEditedPolyline } from "../B05_Profile_UI_RouteEdit_Curve";
|
||||
import { applyArcLocks } from "../B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
crossOffsets,
|
||||
longitudinalPoints,
|
||||
round6,
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
} from "./B05_Profile_Sections_Ground";
|
||||
|
||||
/* ── route ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface RouteStageDeps {
|
||||
/** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */
|
||||
minRadiusM(): number;
|
||||
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */
|
||||
savedLine(): Vertex[] | null;
|
||||
}
|
||||
|
||||
/** 정점마다 누가거리 — 첫 점 0. */
|
||||
export function routeLineOf(line: Vertex[]): RouteLine {
|
||||
const chainage = [0];
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
chainage.push(
|
||||
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
|
||||
);
|
||||
}
|
||||
return { line, chainage, lengthM: chainage[chainage.length - 1] };
|
||||
}
|
||||
|
||||
/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */
|
||||
export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] {
|
||||
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
|
||||
const radius = [...draft.curveRadius];
|
||||
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
|
||||
return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices;
|
||||
}
|
||||
|
||||
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
|
||||
return (ctx) => {
|
||||
const draft = ctx.inputs.route;
|
||||
const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
|
||||
if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(line);
|
||||
};
|
||||
}
|
||||
|
||||
/* ── ground ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface GroundStageDeps {
|
||||
/** 지금 쓸 수 있는 설계 지형 격자 — 아직 못 받았거나 없으면 null(그동안은 서버 길). */
|
||||
grid(): { grid: DtmGrid; design: "las" | "sheet" } | null;
|
||||
/** 서버 표본(`/route/elevations`) — 점 차례 그대로 · 무효 null · `signal` 로 끊김. */
|
||||
remote(
|
||||
points: Vertex[],
|
||||
terrain: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<number | null>>;
|
||||
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */
|
||||
crossHalfWidthM?(): number | null;
|
||||
}
|
||||
|
||||
const samePlace = (a: PipelineStation, b: PipelineStation): boolean =>
|
||||
a.x === b.x && a.y === b.y && a.left[0] === b.left[0] && a.left[1] === b.left[1];
|
||||
|
||||
const outside = (chainageM: number, range: { fromM: number; toM: number } | null): boolean =>
|
||||
range !== null && (chainageM < range.fromM || chainageM > range.toM);
|
||||
|
||||
export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
|
||||
/** 앞 출력이 쓴 지형 · 노선 · 반폭 — 다르면 재사용 안 함. */
|
||||
let last: { terrain: string; line: Vertex[] | null; halfWidth: number } | null = null;
|
||||
|
||||
return async (ctx) => {
|
||||
const stations = ctx.out.stations ?? [];
|
||||
const line = ctx.out.route?.line ?? null;
|
||||
const terrain = ctx.inputs.route?.terrain ?? null;
|
||||
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
|
||||
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M);
|
||||
const local = deps.grid();
|
||||
const useGrid = local !== null && (terrain === null || terrain === local.design);
|
||||
|
||||
const tag = terrain ?? "design";
|
||||
const reusable = ctx.prev !== null && last?.terrain === tag && last.halfWidth === halfWidth;
|
||||
const range = ctx.range;
|
||||
const prevByKey = new Map((reusable ? ctx.prev!.stations : []).map((s) => [s.key, s]));
|
||||
|
||||
// 다시 셀 측점 — 범위 안 · 앞 출력에 없음 · 자리나 축이 바뀜.
|
||||
const kept = stations.map((station) => {
|
||||
const before = prevByKey.get(station.key);
|
||||
return before?.ground && outside(station.chainageM, range) && samePlace(before, station)
|
||||
? before.ground
|
||||
: null;
|
||||
});
|
||||
|
||||
const long = line ? longitudinalPoints(line) : { chainage: [], xy: [] as Vertex[], lengthM: 0 };
|
||||
// 종단 — 노선이 그대로면 통째 · 범위 앞쪽은 같은 노선 위라 그대로.
|
||||
const prevLong = reusable ? ctx.prev!.longitudinal : null;
|
||||
const longKept: Array<number | null | undefined> = long.chainage.map((c, i) => {
|
||||
if (!prevLong || prevLong.chainageM[i] !== round6(c)) return undefined;
|
||||
if (last?.line === line || (range !== null && c < range.fromM)) return prevLong.z[i];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const points: Vertex[] = [];
|
||||
stations.forEach((station, i) => {
|
||||
if (kept[i]) return;
|
||||
for (const offset of offsets) {
|
||||
points.push([station.x + station.left[0] * offset, station.y + station.left[1] * offset]);
|
||||
}
|
||||
});
|
||||
long.xy.forEach((xy, i) => {
|
||||
if (longKept[i] === undefined) points.push(xy);
|
||||
});
|
||||
|
||||
let heights: Array<number | null>;
|
||||
if (useGrid) {
|
||||
heights = points.map(([x, y]) => {
|
||||
const z = heightAt(local!.grid, x, y);
|
||||
return Number.isNaN(z) ? null : round6(z);
|
||||
});
|
||||
} else {
|
||||
heights = points.length ? await deps.remote(points, terrain, ctx.signal) : [];
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const filled: PipelineStation[] = stations.map((station, i) => {
|
||||
if (kept[i]) return { ...station, ground: kept[i] };
|
||||
const zs = offsets.map(() => heights[cursor++] ?? null);
|
||||
return { ...station, ground: stationGround(offsets, zs) };
|
||||
});
|
||||
const z = long.chainage.map((_, i) =>
|
||||
longKept[i] === undefined ? (heights[cursor++] ?? null) : (longKept[i] as number | null),
|
||||
);
|
||||
last = { terrain: tag, line, halfWidth };
|
||||
const output: GroundOutput = {
|
||||
stations: filled,
|
||||
longitudinal: { chainageM: long.chainage.map(round6), z },
|
||||
};
|
||||
return output;
|
||||
};
|
||||
}
|
||||
|
||||
/** offset 열 표고 → 계약 꼴(중심 · 좌 · 우 · 중심에서 바깥 차례 · offsetM 양수). */
|
||||
function stationGround(offsets: number[], zs: Array<number | null>): StationGround {
|
||||
const left: GroundSample[] = [];
|
||||
const right: GroundSample[] = [];
|
||||
let centerZ: number | null = null;
|
||||
offsets.forEach((offset, i) => {
|
||||
if (Math.abs(offset) <= 1e-9) centerZ = zs[i];
|
||||
else if (offset > 0) left.push({ offsetM: round6(offset), z: zs[i] });
|
||||
else right.unshift({ offsetM: round6(-offset), z: zs[i] });
|
||||
});
|
||||
return { centerZ, left, right };
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export interface GroundOptions {
|
||||
}
|
||||
|
||||
/** 파이썬 `round(x, 6)` 자리. */
|
||||
const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
const round9 = (value: number): number => Math.round(value * 1e9) / 1e9;
|
||||
|
||||
function sample(grid: DtmGrid, x: number, y: number): GroundSample {
|
||||
@@ -100,7 +100,7 @@ function cleanLine(line: ReadonlyArray<Vertex>): {
|
||||
}
|
||||
|
||||
/** 횡단 offset 열 — `np.arange(-hw, hw + si/2, si)` · hw 넘는 것 버림 · 0 없으면 끼움. */
|
||||
function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
export function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
const count = Math.ceil((halfWidth + interval * 0.5 + halfWidth) / interval);
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
@@ -114,6 +114,33 @@ function crossOffsets(halfWidth: number, interval: number): number[] {
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/** 종단 표본 자리 — 누가거리(간격 · 끝점 포함) · 자리 = `_interpolate_xy`(축마다 `np.interp`). */
|
||||
export function longitudinalPoints(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
intervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
): { chainage: number[]; xy: Vertex[]; lengthM: number } {
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const chainage = stationsAlong(line, intervalM, true);
|
||||
const xy = chainage.map((c): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)]);
|
||||
return { chainage, xy, lengthM: along[along.length - 1] ?? 0 };
|
||||
}
|
||||
|
||||
/** 측점 자리 · 좌측 축 — 자리 = `_interpolate_xy` · 축 = 측점 틀 짝(`stationFrameAt`). 못 재면 뺌. */
|
||||
export function placeStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
stationsM: ReadonlyArray<number>,
|
||||
longIntervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
): Array<{ chainage: number; origin: Vertex; left: Vertex }> {
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const probe = Math.max(longIntervalM, 0.5);
|
||||
return stationsM.flatMap((chainage) => {
|
||||
const frame = stationFrameAt(line, chainage, probe);
|
||||
if (!frame) return [];
|
||||
const origin: Vertex = [npInterp(chainage, along, xs), npInterp(chainage, along, ys)];
|
||||
return [{ chainage, origin, left: frame.left }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 노선 지반선 — 종단 표본 · 측점마다 중심 · 좌측 축 · 횡단 표본. `stationsM` = 측점 누가거리. */
|
||||
export function sectionsGround(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
@@ -124,15 +151,12 @@ export function sectionsGround(
|
||||
const halfWidth = options.crossHalfWidthM ?? SECTION_CROSS_HALF_WIDTH_M;
|
||||
const interval = options.crossSampleIntervalM ?? SECTION_CROSS_SAMPLE_INTERVAL_M;
|
||||
const longInterval = options.longSampleIntervalM ?? SECTION_LONG_SAMPLE_INTERVAL_M;
|
||||
const probe = Math.max(longInterval, 0.5);
|
||||
|
||||
// 자리 = `_interpolate_xy`(축마다 `np.interp`) · 좌측 축만 측점 틀 짝(`stationFrameAt`).
|
||||
const { xs, ys, chainage: along } = cleanLine(line);
|
||||
const at = (c: number): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)];
|
||||
const samples: LongGroundSample[] = stationsAlong(line, longInterval, true).map((chainage) => {
|
||||
const [x, y] = at(chainage);
|
||||
return { chainage_m: round6(chainage), ...sample(grid, x, y) };
|
||||
});
|
||||
const long = longitudinalPoints(line, longInterval);
|
||||
const samples: LongGroundSample[] = long.chainage.map((chainage, i) => ({
|
||||
chainage_m: round6(chainage),
|
||||
...sample(grid, long.xy[i][0], long.xy[i][1]),
|
||||
}));
|
||||
|
||||
const offsets = crossOffsets(halfWidth, interval);
|
||||
let center = 0;
|
||||
@@ -141,11 +165,9 @@ export function sectionsGround(
|
||||
});
|
||||
|
||||
const stations: StationGround[] = [];
|
||||
for (const chainage of stationsM) {
|
||||
const frame = stationFrameAt(line, chainage, probe);
|
||||
if (!frame) continue;
|
||||
const [ox, oy] = at(chainage);
|
||||
const [lx, ly] = frame.left;
|
||||
for (const { chainage, origin, left } of placeStations(line, stationsM, longInterval)) {
|
||||
const [ox, oy] = origin;
|
||||
const [lx, ly] = left;
|
||||
const cross: CrossGroundPoint[] = [];
|
||||
const raw: number[] = [];
|
||||
for (const offset of offsets) {
|
||||
@@ -176,7 +198,7 @@ export function sectionsGround(
|
||||
samples: cross,
|
||||
});
|
||||
}
|
||||
return { length_m: round6(along[along.length - 1] ?? 0), samples, stations };
|
||||
return { length_m: round6(long.lengthM), samples, stations };
|
||||
}
|
||||
|
||||
/** `np.interp(x, xp, fp)` — xp 오름차순 · 범위 밖은 끝값 · 식 차례도 numpy 와 같음. */
|
||||
|
||||
@@ -176,6 +176,7 @@ const panel = design.createDesignPanel(
|
||||
parts: {
|
||||
structures: { root: node("section"), listRoot: node() },
|
||||
settings: { nodes: [text("a")] },
|
||||
recommend: { nodes: [text("rec")] },
|
||||
crossView: { row: text("cross-row") },
|
||||
standardSlot: text("std"),
|
||||
},
|
||||
|
||||
@@ -197,6 +197,7 @@ const built = panel.createDesignPanel(
|
||||
parts: {
|
||||
structures: sample(),
|
||||
settings: { nodes: [text("a"), text("b")] },
|
||||
recommend: { nodes: [text("rec")] },
|
||||
crossView: { row: text("cross-row") },
|
||||
standardSlot: text("std"),
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ def out():
|
||||
def test_B05_B06_첫_그림_접힘(out):
|
||||
assert out["profileFirst"] == {
|
||||
"페이지 설정": True,
|
||||
"노선 추천": False, # 345-4 단추 칸 — 펼친 채
|
||||
}
|
||||
assert out["crossFirst"]["B06_View_Title"] is True
|
||||
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""345-4 노선 추천 → 좌측 새 칸 「노선 추천」(지운 「계획노선」 자리) — [노선 추천] · 현재 추천 · [초기화].
|
||||
|
||||
· 칸 요소는 `_Panel_Recommend` 가 만들고 편집기(`_RouteEdit_Initial`)가 밖에서 받아 건다
|
||||
· 추천 창은 공용 모달 하나 · [적용] = 본문 평면에 되돌리기 한 칸(`restoreSnapshot(…, true)` → 이력 한 걸음)
|
||||
· 추천 창의 「지금 노선」은 본문 편집기 지금 노드
|
||||
칸 생김새 · 차례는 `test_design_view_parity` · `test_311_panel_folded` 가 봄(화면 확인은 브레인).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_칸_자리와_재료():
|
||||
sections = _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts")
|
||||
at = sections.index('id: "route-recommend"')
|
||||
block = sections[at : at + 300]
|
||||
assert 'title: "노선 추천"' in block and "order: 30," in block # 지운 계획노선 자리
|
||||
assert 'part<RecommendPart>(ctx, "recommend").nodes' in block
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Panel.ts")
|
||||
assert "const recommend = createRecommendPart();" in panel
|
||||
assert "Object.assign(shared.parts, { settings, recommend });" in panel
|
||||
view = _read("A00_Common/design/design_view_profile.ts")
|
||||
assert "recommend: panel.recommend," in view
|
||||
|
||||
|
||||
def test_편집기가_칸_요소를_밖에서_받음():
|
||||
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
|
||||
assert "overlay" not in initial
|
||||
assert "const { recommendButton, resetButton, nowLine } = params.controls;" in initial
|
||||
assert "current: params.planned().map(([x, y]) => [x, y])," in initial # 지금 노선
|
||||
main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts")
|
||||
assert "controls: options.recommend," in main
|
||||
assert "planned: () => planned," in main
|
||||
assert len(main.splitlines()) <= 700
|
||||
|
||||
|
||||
def test_적용은_본문_평면에_되돌리기_한_칸():
|
||||
main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts")
|
||||
assert "apply: (snapshot, message) => restoreSnapshot(snapshot, message, true)," in main
|
||||
restore = main[main.index("function restoreSnapshot(") :]
|
||||
restore = restore[: restore.index("\n }\n")]
|
||||
assert "applyEdit(message, record);" in restore
|
||||
apply_edit = main[main.index("function applyEdit(") :]
|
||||
assert "if (record) history?.commit(snapshotNow());" in apply_edit[:600]
|
||||
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
|
||||
on_apply = initial[initial.index("onApply: (picked) => {") :]
|
||||
assert on_apply[:500].count("params.apply(") == 1
|
||||
|
||||
|
||||
def test_추천_창은_공용_모달_하나():
|
||||
rec = _read("B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
assert rec.count("openModal(") == 1
|
||||
# 뒤 판(평면 · 종단도) 단축키가 창 안 Ctrl+Z 를 받지 않게 — 창 막에서 끊음
|
||||
assert '?.closest(".ui-modal__backdrop")' in rec and "key.stopPropagation();" in rec
|
||||
@@ -0,0 +1,290 @@
|
||||
"""PLAN 346-1 — 계산 묶음 route · ground 단계(`logic/B05_Profile_Pipeline_RouteGround.ts`).
|
||||
|
||||
route = 노선 초안 → 그린 노선(서버 `build_planned_polyline` 편집 갈래와 같은 선).
|
||||
ground = 측점 → 지반 채움 · 종단 지반선(서버 `generate_sections` 와 같은 표본).
|
||||
격자 없음 · 분석 지형 다름 = 서버 길 · 바뀐 범위 밖 재사용 · 노선 1,078 m 한 번 시간.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
||||
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_Pipeline_RouteGround.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
|
||||
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
|
||||
REAL_ROUTE = PROJECT / "B05_Profile/route/planned_route_initial.csv"
|
||||
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
|
||||
_TOL = 1.5e-6
|
||||
MIN_RADIUS_M = 12.0
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { parseDtmGrid, heightAt } from "./common_util/common_util_surface_sampler.js";
|
||||
import { placeStations } from "./B05_Profile/logic/B05_Profile_Sections_Ground.js";
|
||||
import {
|
||||
createRouteStage, createGroundStage,
|
||||
} from "./B05_Profile/logic/B05_Profile_Pipeline_RouteGround.js";
|
||||
const bytes = readFileSync(process.argv[2]);
|
||||
const grid = parseDtmGrid(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
|
||||
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
|
||||
const n = job.nodes.length;
|
||||
const draft = {
|
||||
rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null,
|
||||
};
|
||||
const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line });
|
||||
const ctxOf = (extra) => ({
|
||||
generation: 1, range: null, signal: new AbortController().signal,
|
||||
inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null },
|
||||
out: {}, before: {}, prev: null, ...extra,
|
||||
});
|
||||
const route = routeStage(ctxOf({ inputs: { route: draft } }));
|
||||
const saved = routeStage(ctxOf({}));
|
||||
let noRoute = null;
|
||||
try { createRouteStage({ minRadiusM: () => 12, savedLine: () => null })(ctxOf({})); }
|
||||
catch (error) { noRoute = String(error.message); }
|
||||
|
||||
const line = job.line;
|
||||
const stations = placeStations(line, job.stations).map((s, i) => ({
|
||||
key: `k${i}`, chainageM: s.chainage, x: s.origin[0], y: s.origin[1], azimuthDeg: 0,
|
||||
left: s.left, kind: "grid", id: null, ground: null,
|
||||
planRadiusM: null, curveOuterSide: null, curveWideningM: null,
|
||||
}));
|
||||
const lineOut = { line, chainage: [], lengthM: 0 };
|
||||
let remoteCalls = [];
|
||||
const remote = async (points, terrain) => {
|
||||
remoteCalls.push({ count: points.length, terrain });
|
||||
return points.map(([x, y]) => {
|
||||
const z = heightAt(grid, x, y);
|
||||
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
|
||||
});
|
||||
};
|
||||
let ready = null;
|
||||
const ground = createGroundStage({ grid: () => ready, remote });
|
||||
const run = (extra) => ground(ctxOf({ out: { route: lineOut, stations }, ...extra }));
|
||||
|
||||
const out = { route, saved, noRoute };
|
||||
out.serverPath = await run({}); // 격자 받기 전 = 서버 길
|
||||
out.serverCalls = remoteCalls; remoteCalls = [];
|
||||
ready = { grid, design: "las" };
|
||||
out.gridPath = await run({}); // 받은 뒤 = 브라우저 길
|
||||
out.gridCalls = remoteCalls.length;
|
||||
out.sheetPath = await run({ inputs: { route: { ...draft, terrain: "sheet" } } });
|
||||
out.sheetCalls = remoteCalls; remoteCalls = [];
|
||||
|
||||
// 범위 밖 재사용 — 서버 길로 앞 출력을 받고 범위 [fromM, ∞) 만 다시
|
||||
ready = null;
|
||||
const reuseStage = createGroundStage({ grid: () => ready, remote });
|
||||
const first = await reuseStage(ctxOf({ out: { route: lineOut, stations } }));
|
||||
remoteCalls = [];
|
||||
const moved = stations.map((s, i) => (i === 1 ? { ...s, x: s.x + 0.5 } : s));
|
||||
const second = await reuseStage(ctxOf({
|
||||
out: { route: lineOut, stations: moved }, prev: first,
|
||||
range: { sources: ["grade"], fromM: job.fromM, toM: Infinity, keys: [] },
|
||||
}));
|
||||
out.reuseCalls = remoteCalls;
|
||||
out.reuse = second;
|
||||
ready = { grid, design: "las" };
|
||||
out.movedFull = await createGroundStage({ grid: () => ready, remote })(
|
||||
ctxOf({ out: { route: lineOut, stations: moved } }));
|
||||
|
||||
const timing = createGroundStage({ grid: () => ready, remote });
|
||||
for (let i = 0; i < 5; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 20; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
|
||||
out.ms = (performance.now() - t0) / 20;
|
||||
writeFileSync(process.argv[4], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run_ts(tmp_path: Path, npz: Path, job: dict) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--rootDir",
|
||||
str(ROOT),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
grid = tmp_path / "grid.bin"
|
||||
grid.write_bytes(dtm_grid_bytes(npz))
|
||||
payload = tmp_path / "job.json"
|
||||
result = tmp_path / "out.json"
|
||||
payload.write_text(json.dumps(job), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(grid), str(payload), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _read_xy(path: Path) -> list[list[float]]:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return [[float(row["x"]), float(row["y"])] for row in csv.DictReader(handle)]
|
||||
|
||||
|
||||
def _contract_rows(station: dict) -> list[tuple[float, float | None]]:
|
||||
"""계약 꼴(중심 · 좌 · 우) → 서버 꼴 (offset, z) 오름차순."""
|
||||
ground = station["ground"]
|
||||
rows = [(-s["offsetM"], s["z"]) for s in ground["right"]]
|
||||
rows += [(0.0, ground["centerZ"])] + [(s["offsetM"], s["z"]) for s in ground["left"]]
|
||||
return sorted(rows)
|
||||
|
||||
|
||||
def _close(a, b, tol: float) -> bool:
|
||||
return (a is None and b is None) or (
|
||||
a is not None and b is not None and abs(float(a) - float(b)) <= tol
|
||||
)
|
||||
|
||||
|
||||
def _fake_case(tmp_path: Path) -> tuple[Path, list, list, float]:
|
||||
"""가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠 · 노선이 빈칸 · 격자 끝을 지나감."""
|
||||
rng = np.random.default_rng(3461)
|
||||
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
|
||||
y = (np.cumsum(rng.uniform(0.95, 1.1, 140)) + 2000.0).astype(np.float32)
|
||||
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
|
||||
z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + rng.normal(0, 0.2, xx.shape)
|
||||
mask = np.ones(z.shape, dtype=bool)
|
||||
mask[60:70, 100:115] = False
|
||||
npz = tmp_path / "dtm_fake.npz"
|
||||
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
|
||||
nodes = [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
|
||||
built = build_planned_polyline(
|
||||
[tuple(n) for n in nodes],
|
||||
min_radius_m=MIN_RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True] * 4,
|
||||
radii=[None] * 4,
|
||||
)
|
||||
return npz, [list(v) for v in built.vertices], nodes, 120.0
|
||||
|
||||
|
||||
def _real_case(_tmp_path: Path) -> tuple[Path, list, list, float]:
|
||||
if not (REAL_GRID.is_file() and REAL_ROUTE.is_file() and REAL_NODES.is_file()):
|
||||
pytest.skip("검증_sub8 없음")
|
||||
return REAL_GRID, _read_xy(REAL_ROUTE), _read_xy(REAL_NODES), 600.0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
@pytest.mark.parametrize("case", [_fake_case, _real_case], ids=["가짜", "검증_sub8"])
|
||||
def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(tmp_path: Path, case) -> None:
|
||||
npz, line, nodes, from_m = case(tmp_path)
|
||||
expected = core.generate_sections(
|
||||
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
|
||||
)
|
||||
stations = [s["chainage_m"] for s in expected["longitudinal"]["stations"]]
|
||||
job = {
|
||||
"line": line,
|
||||
"nodes": nodes,
|
||||
"minRadius": MIN_RADIUS_M,
|
||||
"stations": stations,
|
||||
"fromM": from_m,
|
||||
}
|
||||
out = _run_ts(tmp_path, npz, job)
|
||||
|
||||
# route — 서버 편집 갈래와 같은 선 · 누가거리 · 저장분 대체 · 노선 없음
|
||||
built = build_planned_polyline(
|
||||
[tuple(node) for node in nodes],
|
||||
min_radius_m=MIN_RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True] * len(nodes),
|
||||
radii=[None] * len(nodes),
|
||||
)
|
||||
route = out["route"]
|
||||
assert len(route["line"]) == len(built.vertices)
|
||||
assert (
|
||||
max(
|
||||
abs(a - b)
|
||||
for p, q in zip(route["line"], built.vertices, strict=True)
|
||||
for a, b in zip(p, q)
|
||||
)
|
||||
< 1e-9
|
||||
)
|
||||
steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T)
|
||||
assert abs(route["lengthM"] - float(steps.sum())) < 1e-6
|
||||
assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"])
|
||||
assert out["saved"]["line"] == line
|
||||
assert out["noRoute"]
|
||||
|
||||
# ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단)
|
||||
grid_path = out["gridPath"]
|
||||
assert out["gridCalls"] == 0
|
||||
assert len(grid_path["stations"]) == len(expected["cross_sections"])
|
||||
problems: list[str] = []
|
||||
for ts, py in zip(grid_path["stations"], expected["cross_sections"], strict=True):
|
||||
rows = _contract_rows(ts)
|
||||
assert len(rows) == len(py["samples"])
|
||||
for (offset, z), sample in zip(rows, py["samples"], strict=True):
|
||||
if not (
|
||||
_close(offset, sample["offset_m"], _TOL)
|
||||
and _close(z, sample["z"] if sample["valid"] else None, _TOL)
|
||||
):
|
||||
problems.append(f"측점 {py['chainage_m']} offset {sample['offset_m']}: {z}")
|
||||
if not _close(ts["ground"]["centerZ"], py["center_z"], _TOL):
|
||||
problems.append(f"측점 {py['chainage_m']} 중심 {ts['ground']['centerZ']}")
|
||||
long_py = expected["longitudinal"]["samples"]
|
||||
long_ts = grid_path["longitudinal"]
|
||||
assert len(long_ts["z"]) == len(long_py)
|
||||
for c, z, sample in zip(long_ts["chainageM"], long_ts["z"], long_py, strict=True):
|
||||
if not (_close(c, sample["chainage_m"], _TOL) and _close(z, sample["z"], _TOL)):
|
||||
problems.append(f"종단 {sample['chainage_m']}: {z}")
|
||||
assert not problems, "\n".join(problems[:30])
|
||||
|
||||
# 서버 길 — 격자 받기 전 · 분석 지형 다름 · 값은 mm 자리로 같음
|
||||
count = len(stations) * 81 + len(long_py)
|
||||
assert out["serverCalls"] == [{"count": count, "terrain": None}]
|
||||
assert out["sheetCalls"] == [{"count": count, "terrain": "sheet"}]
|
||||
for ts, gs in zip(out["serverPath"]["stations"], grid_path["stations"], strict=True):
|
||||
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(gs), strict=True):
|
||||
assert _close(a, b, 5e-4)
|
||||
|
||||
# 범위 밖 재사용 — 다시 묻는 것 = 범위 안 측점 + 범위 밖이어도 자리가 바뀐 측점 하나
|
||||
again = sum(1 for c in stations if c >= from_m) + 1
|
||||
assert out["reuseCalls"] == [{"count": again * 81, "terrain": None}]
|
||||
for ts, full in zip(out["reuse"]["stations"], out["movedFull"]["stations"], strict=True):
|
||||
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(full), strict=True):
|
||||
assert _close(a, b, 5e-4), f"재사용 값 다름 {ts['chainageM']}"
|
||||
assert out["reuse"]["longitudinal"]["z"] == out["serverPath"]["longitudinal"]["z"]
|
||||
|
||||
# 한 번 전체 ground(검증_sub8 = 노선 1,078 m · 측점 55) — 0.3 s 예산 안 넉넉히
|
||||
assert out["ms"] < 50, f"ground 단계 {out['ms']:.1f} ms"
|
||||
print(f"ground 단계 {out['ms']:.2f} ms · 측점 {len(stations)} · 종단 {len(long_py)}")
|
||||
@@ -167,13 +167,18 @@ def test_미리_보기_지도_선(results):
|
||||
|
||||
|
||||
def test_도구_막대는_노선_추천_하나만():
|
||||
"""편집 창 도구 막대 — [노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 ·
|
||||
「연 상태로」 없음. 그 묶음은 모달(`_Recommend`)로 옮겼다."""
|
||||
"""[노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 · 「연 상태로」 없음. 그 묶음은
|
||||
모달(`_Recommend`)로 옮겼다. 345-4 — [노선 추천] · [초기화] · 현재 추천은 편집 머리에서 좌측
|
||||
「노선 추천」 칸(`_Panel_Recommend`)으로 옮김."""
|
||||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||||
chrome = open(os.path.join(page, "B05_Profile_UI_RouteEdit_Chrome.ts"), encoding="utf-8").read()
|
||||
assert chrome.count('data-act="recommend"') == 1
|
||||
for gone in ('data-act="recommend"', 'data-act="reset"', "data-rec-now"):
|
||||
assert gone not in chrome
|
||||
assert "연 상태로" not in chrome and "history-reset" not in chrome
|
||||
assert chrome.index('data-act="recommend"') < chrome.index('data-act="contour-minor"')
|
||||
part = open(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"), encoding="utf-8").read()
|
||||
assert part.count('data-act="recommend" disabled') == 1 # 단추 하나
|
||||
assert part.count('data-act="reset" disabled') == 1
|
||||
assert part.index('data-act="recommend" disabled') < part.index('data-act="reset" disabled')
|
||||
initial = open(
|
||||
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
|
||||
).read()
|
||||
@@ -203,9 +208,10 @@ def test_노선_추천_다듬기_23_3():
|
||||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||||
|
||||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
rec = chrome.index('data-act="recommend"')
|
||||
divider = chrome.index("b05-routeedit__divider", rec)
|
||||
assert rec < divider < chrome.index('data-act="contour-minor"')
|
||||
# 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로
|
||||
tools = chrome.index('data-slot="tools"')
|
||||
divider = chrome.index("b05-routeedit__divider", tools)
|
||||
assert tools < divider < chrome.index('data-act="contour-minor"')
|
||||
assert chrome.count("b05-routeedit__divider") == 2
|
||||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||||
|
||||
@@ -59,8 +59,8 @@ def results():
|
||||
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
|
||||
assert results["sameNodes"] == {"cross": True, "back": True}
|
||||
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
|
||||
# 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 칸 지움)
|
||||
assert results["childCount"] == 1 + 2 + 2
|
||||
# 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 지움 · 345-4 노선 추천)
|
||||
assert results["childCount"] == 1 + 3 + 2
|
||||
|
||||
|
||||
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
|
||||
@@ -80,6 +80,7 @@ def test_보기마다_제_칸만_보인다(results):
|
||||
assert views["profile"]["shownTitles"] == [
|
||||
"구조물 배치",
|
||||
"페이지 설정",
|
||||
"노선 추천",
|
||||
]
|
||||
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
|
||||
assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]
|
||||
|
||||
Reference in New Issue
Block a user