Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_4
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* design_calc_profile.ts
|
||||
* 계산 자리 「서버」 종단 길(PLAN 511-B B-3) — 판(`design_calc_sites.ts`)이 profile 묶음을 server 로 둔 때
|
||||
* 묶음 planline 의 새 지반 첫 계획선 꼭짓점(P1 · `profile.base`)과 profile 단계 계획선 짓기(P2 ~ P4 · P8 ·
|
||||
* `profile.alignment`)를 서버 Node 가 같은 TS 로 셈(창구 `POST /projects/{id}/calc`). both 면 브라우저 그대로.
|
||||
* 서버 실패(끊김 밖) = 경고 한 줄 · 브라우저 값.
|
||||
* ========================================================================== */
|
||||
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { serverCalc } from "./design_calc_server";
|
||||
import type { StageFn } from "./logic/design_pipeline_types";
|
||||
import {
|
||||
groundFollowingBasePvi,
|
||||
groundProfile,
|
||||
type GroundFollowingInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { profileStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
emptyEdits,
|
||||
type AlignmentNode,
|
||||
type ProfileAlignment,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const KEY = "profile";
|
||||
const r6 = (value: number): number => Number(value.toFixed(6));
|
||||
|
||||
/** 서버 값 — 끊김은 그대로 던짐(묶음이 버림) · 그 밖 실패는 브라우저 값. */
|
||||
async function orLocal<T>(signal: AbortSignal, remote: () => Promise<T>, local: () => T) {
|
||||
try {
|
||||
return await remote();
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
console.warn("[calc-server] profile 실패 — 브라우저 값", error);
|
||||
return local();
|
||||
}
|
||||
}
|
||||
|
||||
/** planline `groundPvi` — 서버면 첫 계획선 꼭짓점 약속(0.000001 m · `groundFollowingBasePvi` 꼴) · 아니면 null
|
||||
* (브라우저 셈 · 단계 동기). 판은 앞 ground 단계가 이미 받음(`calcOnServerNow`). */
|
||||
export function groundPviAt(
|
||||
input: GroundFollowingInput,
|
||||
signal: AbortSignal,
|
||||
): Promise<AlignmentNode[] | null> | null {
|
||||
if (!calcOnServerNow(KEY)) return null;
|
||||
const local = () => groundFollowingBasePvi(input);
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
return orLocal(
|
||||
signal,
|
||||
async () => {
|
||||
const follow = {
|
||||
stations: input.stations,
|
||||
start_offset_m: input.startOffsetM ?? 0,
|
||||
end_offset_m: input.endOffsetM ?? 0,
|
||||
};
|
||||
const inputs = { profile: { ...profile, policy: input.policy, ground_following: follow } };
|
||||
const results = await serverCalc(KEY, ["profile.base"], inputs, signal);
|
||||
const nodes = results["profile.base"] as AlignmentNode[] | null;
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: r6(node.chainage_m),
|
||||
elevation_m: r6(node.elevation_m),
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
local,
|
||||
);
|
||||
}
|
||||
|
||||
/** profile 단계 — 첫 계획선 + 계획고 편집 → 종단(서버면 `profile.alignment` · 아니면 동기 그대로). */
|
||||
export const profileAt: StageFn<"profile"> = (ctx) => {
|
||||
const base = ctx.out.planline;
|
||||
if (!base || !calcOnServerNow(KEY)) return profileStage(ctx);
|
||||
return orLocal(
|
||||
ctx.signal,
|
||||
async () => {
|
||||
const profile = {
|
||||
chainage: base.chainage,
|
||||
ground: base.ground,
|
||||
policy: base.policy,
|
||||
stations: base.stations,
|
||||
base_pvi: base.basePvi,
|
||||
edits: ctx.inputs.alignment ?? emptyEdits(),
|
||||
only_explicit_curves: base.onlyExplicitCurves,
|
||||
};
|
||||
const results = await serverCalc(KEY, ["profile.alignment"], { profile }, ctx.signal);
|
||||
return results["profile.alignment"] as ProfileAlignment;
|
||||
},
|
||||
() => profileStage(ctx) as ProfileAlignment,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* design_calc_server.ts
|
||||
* 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버
|
||||
* 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로.
|
||||
* serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록)
|
||||
* afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림)
|
||||
* calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply
|
||||
* calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined)
|
||||
* calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw
|
||||
* 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용.
|
||||
* 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { calcOnServerNow, calcProjectId } from "./design_calc_sites";
|
||||
import { plain } from "./logic/design_calc_node_trial";
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */
|
||||
export const QUIET_MS = 400;
|
||||
const LOG_SIZE = 200;
|
||||
const LOCAL_SIZE = 20;
|
||||
|
||||
export interface CalcServerCall {
|
||||
key: string;
|
||||
id: string;
|
||||
/** 브라우저 셈 ms(같이 셈한 자리만). */
|
||||
browserMs: number | null;
|
||||
/** 보내기 → 답(흉내 지연 포함). */
|
||||
roundMs: number;
|
||||
/** Node 셈 · 파이썬 ↔ Node(창구 답). */
|
||||
nodeMs: number | null;
|
||||
wallMs: number | null;
|
||||
bytes: number;
|
||||
/** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */
|
||||
same: boolean | null;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
interface CalcServerLog {
|
||||
calls: CalcServerCall[];
|
||||
local: Record<string, number[]>;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
const log = (): CalcServerLog =>
|
||||
((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= {
|
||||
calls: [],
|
||||
local: {},
|
||||
delayMs: 0,
|
||||
});
|
||||
|
||||
const now = (): number => performance.now();
|
||||
const r2 = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new DOMException("끊김", "AbortError"));
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("끊김", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function record(call: CalcServerCall): void {
|
||||
const calls = log().calls;
|
||||
calls.push(call);
|
||||
if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE);
|
||||
}
|
||||
|
||||
/** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */
|
||||
export async function serverCalc(
|
||||
key: string,
|
||||
run: string[],
|
||||
inputs: Record<string, unknown> | string,
|
||||
signal?: AbortSignal,
|
||||
extra: Partial<CalcServerCall> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs });
|
||||
const t0 = now();
|
||||
if (log().delayMs > 0) await wait(log().delayMs, signal);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
results?: Record<string, unknown>;
|
||||
ms?: number;
|
||||
wall_ms?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`);
|
||||
// extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움
|
||||
record(
|
||||
Object.assign(extra, {
|
||||
key,
|
||||
id: run.join("+"),
|
||||
browserMs: null,
|
||||
roundMs: r2(now() - t0),
|
||||
nodeMs: data.ms ?? null,
|
||||
wallMs: data.wall_ms ?? null,
|
||||
bytes: body.length,
|
||||
same: null,
|
||||
applied: true,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
const quiet = new Map<string, AbortController>();
|
||||
|
||||
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
|
||||
export async function afterQuiet<T>(
|
||||
slot: string,
|
||||
task: (signal: AbortSignal) => Promise<T>,
|
||||
outer?: AbortSignal,
|
||||
): Promise<T | undefined> {
|
||||
quiet.get(slot)?.abort();
|
||||
const controller = new AbortController();
|
||||
quiet.set(slot, controller);
|
||||
outer?.addEventListener("abort", () => controller.abort());
|
||||
try {
|
||||
await wait(QUIET_MS, controller.signal);
|
||||
return await task(controller.signal);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (quiet.get(slot) === controller) quiet.delete(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */
|
||||
export interface CalcRequest {
|
||||
run: string[];
|
||||
inputs: Record<string, unknown>;
|
||||
pick: (results: Record<string, unknown>) => unknown;
|
||||
}
|
||||
|
||||
/** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */
|
||||
export const areasRequest = (rows: unknown[]): CalcRequest => ({
|
||||
run: ["structure.layouts"],
|
||||
inputs: { detail: { cross_sections: rows } },
|
||||
pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas,
|
||||
});
|
||||
export const bermRequest = (structures: unknown[]): CalcRequest => ({
|
||||
run: ["cross.berm_spans"],
|
||||
inputs: { berm: { structures, chainages: [] } },
|
||||
pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans,
|
||||
});
|
||||
|
||||
export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({
|
||||
run: [id],
|
||||
inputs: { args: { [id]: args } },
|
||||
pick: (results) => results[id],
|
||||
});
|
||||
|
||||
/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
|
||||
export function calcChecked<A extends unknown[], R>(
|
||||
key: string,
|
||||
id: string,
|
||||
fn: (...args: A) => R,
|
||||
args: A,
|
||||
options: { apply?: (value: R) => void; request?: CalcRequest } = {},
|
||||
): R {
|
||||
const t0 = now();
|
||||
const value = fn(...args);
|
||||
const browserMs = r2(now() - t0);
|
||||
const local = (log().local[id] ??= []);
|
||||
local.push(browserMs);
|
||||
if (local.length > LOCAL_SIZE) local.shift();
|
||||
if (!calcOnServerNow(key)) return value;
|
||||
const request = options.request ?? trialRequest(id, args);
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const mine = JSON.stringify(plain(value));
|
||||
const extra: Partial<CalcServerCall> = { browserMs, same: null, applied: false };
|
||||
void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then(
|
||||
(results) => {
|
||||
if (!results) return;
|
||||
const theirs = request.pick(results);
|
||||
extra.same = JSON.stringify(theirs) === mine;
|
||||
if (!extra.same && options.apply) {
|
||||
options.apply(theirs as R);
|
||||
extra.applied = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */
|
||||
export async function calcOnServerOnly<R>(
|
||||
key: string,
|
||||
slot: string,
|
||||
request: CalcRequest,
|
||||
outer?: AbortSignal,
|
||||
): Promise<R | undefined> {
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const results = await afterQuiet(
|
||||
slot,
|
||||
(signal) => serverCalc(key, request.run, body, signal),
|
||||
outer,
|
||||
);
|
||||
return results ? (request.pick(results) as R) : undefined;
|
||||
}
|
||||
|
||||
const held = new Map<string, { body: string; value: unknown }>();
|
||||
const asked = new Map<string, string>();
|
||||
|
||||
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
|
||||
export function calcFromServer<R>(
|
||||
key: string,
|
||||
id: string,
|
||||
args: unknown[],
|
||||
redraw: () => void,
|
||||
): { value: R | undefined; fresh: boolean } {
|
||||
const request = trialRequest(id, args);
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const hold = held.get(id);
|
||||
if (hold?.body === body) return { value: hold.value as R, fresh: true };
|
||||
if (asked.get(id) !== body) {
|
||||
asked.set(id, body);
|
||||
void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => {
|
||||
if (asked.get(id) === body) asked.delete(id);
|
||||
if (!results) return;
|
||||
held.set(id, { body, value: results[id] });
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
return { value: hold?.value as R | undefined, fresh: false };
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
* 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both.
|
||||
* ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`)
|
||||
* corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`)
|
||||
* quantity · carry · fixed_browser server = 조작 중 서버 창구(511-A · `design_calc_server.ts`)
|
||||
* haul(E2) 기본 server = /haul-plan · both = 브라우저가 배분 모듈을 그때 불러 셈(511-C · `B05_Profile_Api_HaulPlan.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -17,6 +19,8 @@ export interface CalcSiteGroup {
|
||||
switchable: boolean;
|
||||
fixed: "both" | "server" | "browser" | null;
|
||||
why: string | null;
|
||||
/** 줄마다 까닭 열쇠(번호 → 열쇠) — 없으면 `why`. */
|
||||
whys?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CalcSitesBoard {
|
||||
@@ -43,11 +47,15 @@ export const saveCalcSites = (sites: Record<string, CalcSiteValue>): Promise<Cal
|
||||
request({ method: "PUT", body: JSON.stringify({ sites }) });
|
||||
|
||||
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
|
||||
let loaded: Record<string, CalcSiteValue> = {};
|
||||
let project = "";
|
||||
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
|
||||
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */
|
||||
export function openCalcSites(projectId = project): Promise<Record<string, CalcSiteValue>> {
|
||||
project = projectId;
|
||||
loaded = {};
|
||||
opened = fetchCalcSites().then(
|
||||
(board) => board.sites,
|
||||
(board) => (loaded = board.sites),
|
||||
() => ({}),
|
||||
);
|
||||
return opened;
|
||||
@@ -57,3 +65,13 @@ export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
export async function calcOnServer(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "server";
|
||||
}
|
||||
|
||||
/** 판에 both 일 때만 브라우저 셈 — 못 받음 · 받기 전 = 아님(기본 server 인 haul 줄). */
|
||||
export async function calcInBrowser(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "both";
|
||||
}
|
||||
|
||||
/** 동기 자리용 — 받기 전 = both. */
|
||||
export const calcOnServerNow = (key: string): boolean => loaded[key] === "server";
|
||||
|
||||
export const calcProjectId = (): string => project;
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type BermSpan,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { bermRequest, calcChecked, calcOnServerOnly } from "./design_calc_server";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { planStations } from "../../common_util/common_util_structure_station";
|
||||
import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
|
||||
@@ -155,8 +157,21 @@ export function createFacilityLane(
|
||||
function syncBerm(): void {
|
||||
const routeId = pageRouteId(projectId);
|
||||
if (!structuresKnown || routeId === null) return;
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만(511-A) · 아니면 브라우저 셈
|
||||
if (calcOnServerNow("quantity")) {
|
||||
void calcOnServerOnly<BermSpan[]>("quantity", "C4.copy", bermRequest(structures)).then(
|
||||
(next) => next && putBerm(routeId, next),
|
||||
);
|
||||
return;
|
||||
}
|
||||
putBerm(
|
||||
routeId,
|
||||
calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [structures]),
|
||||
);
|
||||
}
|
||||
|
||||
function putBerm(routeId: number, next: BermSpan[]): void {
|
||||
const copy = readState<BermSpan[]>("berm", projectId, routeId);
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return;
|
||||
writeBermSpans(projectId, routeId, next);
|
||||
void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")])
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_follow_server.ts
|
||||
* 계산 자리 follow 「서버」(PLAN 511-B B-5) — 끌기 도구 꼴: 조작 중 = 화면 셈(미리보기) · 잠잠 뒤 서버 Node
|
||||
* route · follow 두 단계(`stage.follow` · until follow · 저장 정본 맥락은 창구가 프로젝트 id 로 붙임)를 묻고
|
||||
* 답이 오면 묶음을 노선부터 다시 돌림 → 같은 노선 초안이면 서버 값(곁 자료 다시 붙임)으로 바꿈.
|
||||
* 판이 both 면 null(화면 셈 그대로). 열쇠 = 노선 초안 + 저장 노선 계획고 편집(follow 는 노선에만 기댐).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import {
|
||||
withFollowSides,
|
||||
type FollowSides,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { afterQuiet, serverCalc } from "./design_calc_server";
|
||||
import type { FollowPatch, PipelineInputs } from "./logic/design_pipeline_types";
|
||||
|
||||
const ID = "stage.follow";
|
||||
let held: { key: string; patch: FollowPatch } | null = null;
|
||||
let asked: string | null = null;
|
||||
|
||||
/** 서버 follow 값 — 같은 열쇠 답이 있으면 그 값 · 없으면 null(화면 셈) + 잠잠 뒤 묻고 `redraw`. */
|
||||
export function followFromServer(
|
||||
projectId: string,
|
||||
inputs: Readonly<PipelineInputs>,
|
||||
before: unknown,
|
||||
savedEdits: unknown,
|
||||
redraw: () => void,
|
||||
): FollowPatch | null {
|
||||
if (!calcOnServerNow("follow") || !inputs.route) return null;
|
||||
const key = JSON.stringify([inputs.route, savedEdits]);
|
||||
if (held?.key === key) return held.patch;
|
||||
if (asked === key) return null;
|
||||
asked = key;
|
||||
const { route, alignment, pipes, structures, plusStations } = inputs;
|
||||
const stages = {
|
||||
until: "follow",
|
||||
input: { route, alignment, pipes, structures, plusStations },
|
||||
before_draft: before,
|
||||
pipe_ids: readState("pipe-ids", projectId),
|
||||
saved_edits: savedEdits,
|
||||
};
|
||||
const body = JSON.stringify({ run: [ID], inputs: { stages } });
|
||||
void afterQuiet(ID, (signal) => serverCalc("follow", [ID], body, signal)).then((results) => {
|
||||
if (asked === key) asked = null;
|
||||
const got = results?.[ID] as ({ patch: FollowPatch } & FollowSides) | undefined;
|
||||
if (!got) return;
|
||||
held = { key, patch: withFollowSides(got.patch, got) };
|
||||
redraw();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
|
||||
void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
|
||||
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
|
||||
|
||||
@@ -8,29 +8,18 @@
|
||||
* 종류 = 구조물 정의 전부(보기와 같음). 화면 모듈을 끌어 묶음 대신 페이지(`design_shared`)가 옵션으로 넘김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { listedBase } from "./design_profile_base";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { withPipeIds } from "./design_pipe_ids";
|
||||
import { planStations, type PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { PipelineInputs } from "./logic/design_pipeline_types";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
toAlignmentBase,
|
||||
type AlignmentBase,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { plantedBase as plantedBaseWith } from "./logic/design_planted_base";
|
||||
import type { PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준. */
|
||||
/** 저장 상세 + 지금 입력이 심는 측점 = 저장 노선 종단 기준(셈 = logic) · 관 id = 세션 장부(`withPipeIds`). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = withPipeIds((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pipeline_cross_server.ts
|
||||
* 계산 자리 cross = server 의 묶음 cross 단계(PLAN 511-B B-4) — 엔진 길(노선 초안 · 저장 상세 없음)을 서버 Node
|
||||
* 단계(`stage.cross` · `design_calc_node_stages.ts`)가 같은 단계 함수 · 같은 붙임으로 셈. 저장 노선 다시 셈은
|
||||
* `B06_Section_Cross_Refresh` 의 `cross.refresh`. 판 both · DTM 격자 없음(DTM 아닌 지표면 · 받기 전) = null
|
||||
* (브라우저). 격자 경로 · 구조물 정의 = 서버 창구가 채움(`B06_Section_Router_Calc.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import { designTerrainGridNow } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import type { StagesInputs } from "./logic/design_calc_node_stages";
|
||||
import type { CrossOutput, StageContext } from "./logic/design_pipeline_types";
|
||||
|
||||
/** 단계 입력 중 페이지 저장 맥락(묶음 입력 · 격자 · 관 세션 장부 뺌). */
|
||||
export type CrossStageContext = Omit<
|
||||
NonNullable<StagesInputs["stages"]>,
|
||||
"input" | "grid" | "pipe_ids"
|
||||
>;
|
||||
|
||||
/** 서버 단계 답 · 끊김 · 실패 = undefined(부르는 쪽이 브라우저) · server 아님 = null. */
|
||||
export function crossOnServer(
|
||||
projectId: string,
|
||||
ctx: StageContext<"cross">,
|
||||
context: () => CrossStageContext | null,
|
||||
): Promise<CrossOutput | undefined> | null {
|
||||
if (!calcOnServerNow("cross") || !designTerrainGridNow(projectId)) return null;
|
||||
const saved = context();
|
||||
if (!saved) return null;
|
||||
// ponytail: 저장 상세(1.4 MB)를 부름마다 올림 — 시험용 길 · 느리면 서버가 정본 맥락을 붙임
|
||||
return calcOnServerOnly<CrossOutput>(
|
||||
"cross",
|
||||
"C.stage",
|
||||
{
|
||||
run: ["stage.cross"],
|
||||
inputs: {
|
||||
stages: { ...saved, pipe_ids: readState("pipe-ids", projectId), input: { ...ctx.inputs } },
|
||||
},
|
||||
pick: (results) => results["stage.cross"],
|
||||
},
|
||||
ctx.signal,
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import { publishPagePipeline } from "./design_pipeline_page_publish";
|
||||
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
|
||||
export { pipelineWork } from "./logic/design_pipeline";
|
||||
import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations";
|
||||
import { stationsOnServer } from "./design_stations_server";
|
||||
import { routeOnServer } from "./design_route_server";
|
||||
import { followFromServer } from "./design_follow_server";
|
||||
import type {
|
||||
ChangeSource,
|
||||
FollowPatch,
|
||||
@@ -40,9 +43,9 @@ import type {
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
import {
|
||||
followStructures,
|
||||
placedOnLine as placed,
|
||||
type FollowingStructure,
|
||||
} from "../../common_util/common_util_route_follow";
|
||||
import { contextCross, placedFollowInputs } from "./logic/design_pipeline_stage_inputs";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import {
|
||||
@@ -87,20 +90,16 @@ import {
|
||||
createFollowStage,
|
||||
createPlanlineStage,
|
||||
gradeRange,
|
||||
profileStage,
|
||||
routeRange,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { groundPviAt, profileAt } from "./design_calc_profile";
|
||||
import {
|
||||
crossWithContext,
|
||||
draftSectionDetail,
|
||||
shapeStatus,
|
||||
type CrossInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
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 { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server";
|
||||
import { crossOnServer, type CrossStageContext } from "./design_pipeline_cross_server";
|
||||
import type {
|
||||
PlanPipe,
|
||||
PlanStructure,
|
||||
@@ -275,6 +274,19 @@ function createPagePipeline(
|
||||
{ intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
/** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */
|
||||
const serverContext = (): CrossStageContext | null =>
|
||||
plan && {
|
||||
plan,
|
||||
detail: savedSource().detail ?? null,
|
||||
types,
|
||||
saved_pipes: savedPipes,
|
||||
saved_structures: savedStructures,
|
||||
saved_edits: savedEdits(),
|
||||
saved_plus: savedPlusRows(projectId),
|
||||
before_draft: beforeDraft,
|
||||
interval_m: options.stationIntervalM,
|
||||
};
|
||||
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
|
||||
const savedOut = new WeakSet<object>();
|
||||
const pipe = createDesignPipeline({
|
||||
@@ -282,6 +294,7 @@ function createPagePipeline(
|
||||
route: createRouteStage({
|
||||
minRadiusM: () => plan?.min_radius_m ?? 0,
|
||||
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
|
||||
remote: routeOnServer, // 계산 자리 route 「서버」(511-B B-5)
|
||||
}),
|
||||
follow: (ctx) => {
|
||||
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
|
||||
@@ -291,73 +304,56 @@ function createPagePipeline(
|
||||
const { pipes, structures, plusStations } = inputsNow();
|
||||
return { alignment: kept, pipes, structures, plusStations };
|
||||
}
|
||||
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, frame(pipes, savedPipes), [
|
||||
...(savedPipes as FollowingStructure[]),
|
||||
...followed,
|
||||
]),
|
||||
structures: placed(
|
||||
structures,
|
||||
frame(structures, savedStructures),
|
||||
[],
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
),
|
||||
};
|
||||
return followStage({ ...ctx, inputs });
|
||||
// 계산 자리 follow 「서버」(511-B B-5) — 끌기 중 화면 셈 · 잠잠 뒤 서버 값으로 바꿈
|
||||
const before = beforeDraft ?? facilitiesNow();
|
||||
const remote = followFromServer(projectId, ctx.inputs, before, savedEdits(), () =>
|
||||
pipe.refresh("route"),
|
||||
);
|
||||
const saved = { pipes: savedPipes, structures: savedStructures };
|
||||
return (
|
||||
remote ?? followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) })
|
||||
);
|
||||
},
|
||||
stations: createStationsStage({
|
||||
intervalM: options.stationIntervalM,
|
||||
types: () => enabled,
|
||||
saved: () => plan,
|
||||
remote: stationsOnServer, // 계산 자리 stations 「서버」(511-B B-2)
|
||||
}),
|
||||
ground: createGroundStage(groundDeps),
|
||||
planline: createPlanlineStage({
|
||||
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
|
||||
savedLine,
|
||||
storedPvi,
|
||||
groundPvi: groundPviAt, // 계산 자리 「서버」 = 서버 Node(511-B B-3)
|
||||
}),
|
||||
profile: profileStage,
|
||||
profile: profileAt,
|
||||
// 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은
|
||||
// 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침)
|
||||
cross: (ctx) => {
|
||||
const stations = ctx.out.ground?.stations ?? [];
|
||||
const status = statusOf(ctx.inputs.pipes, ctx.inputs.structures);
|
||||
const engine = () =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null),
|
||||
prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithContext(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
stations,
|
||||
statusOf(ctx.inputs.pipes, ctx.inputs.structures),
|
||||
options.bermAt?.(ctx.inputs.structures) ?? null,
|
||||
),
|
||||
},
|
||||
});
|
||||
// 계산 자리 cross = server — 엔진 길도 서버 Node 단계(같은 TS · 511-B B-4) · 못 받음 = 브라우저
|
||||
const remote = () =>
|
||||
crossOnServer(projectId, ctx, serverContext)?.then((o) => o ?? engine());
|
||||
const { detail = null, routeId } = savedSource();
|
||||
const saved =
|
||||
!ctx.inputs.route && detail && typeof routeId === "number"
|
||||
? options.savedCross?.(detail, routeId, stations)
|
||||
: null;
|
||||
if (!saved) return engine();
|
||||
if (!saved) return remote() ?? engine();
|
||||
return saved.then((out) => {
|
||||
if (!out) return engine();
|
||||
if (!out) return remote() ?? engine();
|
||||
savedOut.add(out);
|
||||
return out;
|
||||
});
|
||||
},
|
||||
footprint: footprintStage,
|
||||
footprint: footprintStageSited, // 계산 자리 판(511-A)
|
||||
drainage: link.side?.pipelineStage,
|
||||
corridor: (ctx) => {
|
||||
if (ctx.inputs.route) options.onCorridorStale?.();
|
||||
|
||||
@@ -4,50 +4,5 @@
|
||||
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
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` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// 계획선 측점 줄 = logic(511-B B-3 · 서버 Node 묶음도 같은 셈) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { listedBase } from "./logic/design_planted_base";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_route_server.ts
|
||||
* 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node
|
||||
* `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) ·
|
||||
* 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
|
||||
export function routeOnServer(
|
||||
args: DraftArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<EditedPolyline | undefined> | null {
|
||||
if (!calcOnServerNow("route")) return null;
|
||||
const [points, curveOn, curveRadius, minRadiusM] = args;
|
||||
const route_polyline = {
|
||||
points,
|
||||
curve_flags: curveOn,
|
||||
radii: curveRadius,
|
||||
min_radius_m: minRadiusM,
|
||||
};
|
||||
return calcOnServerOnly<EditedPolyline>(
|
||||
"route",
|
||||
"route.polyline",
|
||||
{
|
||||
run: ["route.polyline"],
|
||||
inputs: { route_polyline },
|
||||
pick: (results) => results["route.polyline"],
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type CrossRefreshInput,
|
||||
} from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { areasRequest, bermRequest, calcChecked } from "./design_calc_server";
|
||||
import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types";
|
||||
@@ -254,7 +255,11 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
|
||||
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
|
||||
// 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로)
|
||||
bermAt: (structures) => {
|
||||
const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []);
|
||||
const list = Array.isArray(structures) ? structures : [];
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 대조만(초안 미리보기 · 511-A)
|
||||
const spans = calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [list], {
|
||||
request: bermRequest(list),
|
||||
});
|
||||
return (chainageM) => bermSpecAt(spans, chainageM);
|
||||
},
|
||||
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그
|
||||
@@ -277,7 +282,12 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
|
||||
const routeId = routeIdOf(shared);
|
||||
if (routeId !== null) withDraftAdjusts(detail, projectId, routeId);
|
||||
const rows = detail.cross_sections;
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows));
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값 대조 · 다르면 얹고 다시 그림(511-A)
|
||||
const areas = calcChecked("quantity", "structure.layouts", structureAreaRows, [rows], {
|
||||
request: areasRequest(rows),
|
||||
apply: (server) => (applyStructureAreaRows(rows, server), profile.redraw()),
|
||||
});
|
||||
applyStructureAreaRows(rows, areas);
|
||||
},
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved, seed) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_stations_server.ts
|
||||
* 계산 자리 stations 「서버」(PLAN 511-B B-2) — 묶음 stations 단계 측점 목록을 서버 Node
|
||||
* `stations.list`(같은 `buildStations`)로. 판이 both 면 null(화면 셈) · 서버 값만 · 잠잠 뒤 ·
|
||||
* 끊김 · 실패 = undefined(단계가 화면 셈으로). 구조물 정의는 서버 창구가 붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { plusStations } from "@util/common_util_structure_station";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import type { StationsArgs } from "./logic/design_pipeline_stations";
|
||||
import type { PipelineStation } from "./logic/design_pipeline_types";
|
||||
|
||||
export function stationsOnServer(
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<PipelineStation[] | undefined> | null {
|
||||
if (!calcOnServerNow("stations")) return null;
|
||||
const [line, curves, pipes, structures, options] = args;
|
||||
const stations = {
|
||||
line,
|
||||
curves,
|
||||
pipes,
|
||||
structures,
|
||||
types: options.types,
|
||||
plus: [...(options.plus ?? plusStations())],
|
||||
interval_m: options.intervalM,
|
||||
include_endpoint: options.includeEndpoint,
|
||||
station_snap: options.stationSnap ?? false,
|
||||
station_snap_step: options.stationSnapStep ?? null,
|
||||
};
|
||||
return calcOnServerOnly<PipelineStation[]>(
|
||||
"stations",
|
||||
"stations.list",
|
||||
{ run: ["stations.list"], inputs: { stations }, pick: (results) => results["stations.list"] },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -6,35 +6,10 @@
|
||||
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
// 측점 계획 → 종단 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { plannedIrregular } from "./logic/design_planted_base";
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||
|
||||
@@ -5,26 +5,34 @@
|
||||
* 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음.
|
||||
*
|
||||
* 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`)
|
||||
* node <묶음> --serve 늘 떠 있음 — 표준 입력 한 줄 = 요청 JSON · 표준 출력 한 줄 = 출력 JSON(511-A)
|
||||
* 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 ·
|
||||
* context?: { earthwork_conversion · natural_spoil_min_ground_slope ·
|
||||
* haul_equipment_limits · 채집석 · 구조물 잔토(B08) } ·
|
||||
* excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m ·
|
||||
* has_foundation }] } ·
|
||||
* cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) ·
|
||||
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
|
||||
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 ·
|
||||
* chainage_m? }] · facilities?: { pipes · structures }(시설 구간 · 511-B B-4) } ·
|
||||
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
|
||||
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) } }
|
||||
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
|
||||
* profile? · carry_profile?(종단 · `design_calc_node_profile.ts` · 511-B B-3) ·
|
||||
* definition? · attach? · cross_enforce?(형상 얹기 · [저장] 횡단 · `design_calc_node_cross.ts` · 511-B B-4) ·
|
||||
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) ·
|
||||
* stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) ·
|
||||
* route_polyline?(노선 · `design_calc_node_route.ts` · 511-B B-5) } }
|
||||
* 출력 { results: { [id]: 값 }, available: [id…] }
|
||||
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
|
||||
* server-calc 묶음과 같은 차례).
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id
|
||||
* [저장] 재계산과 같은 차례).
|
||||
* — 시험 길 id(`design_calc_node_trial.ts`) · 단계 id(`stage.*`)도 돌지만 `available` 에는 없음.
|
||||
* 끝 코드: 0 성공 / 1 셈 실패 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error })
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design";
|
||||
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
|
||||
import { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import type { MassHaulResult } from "@util/common_util_mass_haul";
|
||||
@@ -33,17 +41,38 @@ import type { RoutePoint } from "../../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { buildCorridor } from "../../../B05_Profile/B05_Profile_UI_Corridor_Build";
|
||||
import { corridorHash, serialize } from "../../../B05_Profile/B05_Profile_UI_Corridor_Envelope";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
facilityAt,
|
||||
facilityRanges,
|
||||
type FacilityStructure,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Enforce";
|
||||
import { crossOptions } from "../../../B06_Section/logic/B06_Section_Cross_Save";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
extraWallRows,
|
||||
structureAreaRows,
|
||||
} from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
||||
import { crossRunners, PAGE_IDS, useDefinition, type CrossInputs } from "./design_calc_node_cross";
|
||||
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
||||
import { profileRunners, type ProfileInputs } from "./design_calc_node_profile";
|
||||
import { routeRunners, type RouteInputs } from "./design_calc_node_route";
|
||||
import { sectionsRunners, type SectionsInputs } from "./design_calc_node_sections";
|
||||
import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages";
|
||||
import { plain, TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
|
||||
|
||||
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
|
||||
|
||||
interface CalcInputs extends FollowInputs, CarryInputs {
|
||||
interface CalcInputs
|
||||
extends
|
||||
FollowInputs,
|
||||
ProfileInputs,
|
||||
CarryInputs,
|
||||
CrossInputs,
|
||||
TrialInputs,
|
||||
StagesInputs,
|
||||
SectionsInputs,
|
||||
RouteInputs {
|
||||
detail?: SectionDetailResponse;
|
||||
route_points?: RoutePoint[];
|
||||
context?: Partial<
|
||||
@@ -78,69 +107,31 @@ interface CalcInputs extends FollowInputs, CarryInputs {
|
||||
samples: CrossGroundSample[];
|
||||
design_elevation_m: number | null;
|
||||
options: Record<string, unknown>;
|
||||
/** 시설 구간을 얹을 누가거리(`facilities` 와 함께 · 511-B B-4) */
|
||||
chainage_m?: number;
|
||||
}>;
|
||||
/** 관 지점 · 구조물 정본 → 측점마다 포장 · 기울임 · 세월교 하강(`facilityRanges` · C3) */
|
||||
facilities?: { pipes: CulvertPipePoint[]; structures: FacilityStructure[] | null };
|
||||
};
|
||||
}
|
||||
|
||||
/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */
|
||||
const CROSS_OPTION_KEYS: Record<string, keyof CrossDesignOptions> = {
|
||||
ground_type: "groundType",
|
||||
section_mode: "sectionMode",
|
||||
ditch_side: "ditchSide",
|
||||
ditch_type: "ditchType",
|
||||
paved: "paved",
|
||||
rock_boundary_offset_m: "rockBoundaryOffsetM",
|
||||
two_stage_slope: "twoStageSlope",
|
||||
cut_slope_ratio: "cutSlopeRatio",
|
||||
ditch_enabled: "ditchEnabled",
|
||||
ditch_choice: "ditchChoice",
|
||||
surface_drop_m: "surfaceDropM",
|
||||
plan_radius_m: "planRadiusM",
|
||||
curve_outer_side: "curveOuterSide",
|
||||
curve_widening_m: "curveWideningM",
|
||||
shoulder_extend_left_m: "shoulderExtendLeftM",
|
||||
shoulder_extend_right_m: "shoulderExtendRightM",
|
||||
tilted: "tilted",
|
||||
tilt_pct: "tiltPct",
|
||||
};
|
||||
|
||||
/** 파이썬 BermSpec(NamedTuple → dict) → TS BermSpec. */
|
||||
function bermOf(value: unknown): BermSpec | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const berm = value as Record<string, number | null>;
|
||||
return {
|
||||
widthM: berm.width_m as number,
|
||||
intervalM: berm.interval_m as number,
|
||||
slopeDeg: berm.slope_deg as number,
|
||||
depthM: berm.depth_m as number,
|
||||
depthLeftM: berm.depth_left_m ?? null,
|
||||
depthRightM: berm.depth_right_m ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function crossOptions(
|
||||
raw: Record<string, unknown>,
|
||||
standard: CrossDesignOptions["standard"],
|
||||
): CrossDesignOptions {
|
||||
const options: Record<string, unknown> = { standard };
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === "berm") options.berm = bermOf(value);
|
||||
else if (key in CROSS_OPTION_KEYS) options[CROSS_OPTION_KEYS[key]] = value;
|
||||
else throw new Error(`모르는 횡단 설계 키: ${key}`);
|
||||
}
|
||||
return options as unknown as CrossDesignOptions;
|
||||
}
|
||||
|
||||
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */
|
||||
function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */
|
||||
async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const memo = new Map<string, unknown>();
|
||||
useDefinition(inputs.definition); // 구조물 표 = 서버 정의(M02 반영 · 511-B B-4)
|
||||
const sections = inputs.detail?.cross_sections ?? [];
|
||||
const context = inputs.context ?? {};
|
||||
let massHaul: MassHaulResult | null = null;
|
||||
|
||||
const RUNNERS: Record<string, () => unknown> = {
|
||||
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
||||
...profileRunners(inputs), // 첫 계획선 · 계획선 짓기 · 계획고 옮김(511-B B-3)
|
||||
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
||||
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
|
||||
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
|
||||
...crossRunners(inputs), // 형상 얹기 · [저장] 횡단 한 바퀴(511-B B-4)
|
||||
...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2)
|
||||
...routeRunners(inputs), // 노선 폴리라인 · 곡선표(511-B B-5)
|
||||
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
|
||||
"structure.layouts": () => {
|
||||
const areas = structureAreaRows(sections);
|
||||
@@ -155,6 +146,14 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
: null;
|
||||
return massHaul ? massHaulPayload(massHaul) : null;
|
||||
},
|
||||
// 유토 배분 화면 꼴(/haul-plan 창구 · 511-C) — 인자 그대로(args) · 등록부 밖. 시험 길 표에 두면
|
||||
// 브라우저가 끄는 파일(`plain`)이라 배분 모듈이 첫 묶음에 실림 — 그래서 이 입구에만.
|
||||
"haul.view": () =>
|
||||
plain(
|
||||
computeHaulPlan(
|
||||
...((inputs.args?.["haul.view"] ?? []) as Parameters<typeof computeHaulPlan>),
|
||||
),
|
||||
),
|
||||
// 두 벌 — haul_plan = 거르지 않은 수량 정본(B08) · haul_plan_drawing = 잔진동 거른 그림.
|
||||
"haul.plan": () => {
|
||||
need("masshaul.cumulative");
|
||||
@@ -198,9 +197,15 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
|
||||
"cross.design": () => {
|
||||
const batch = inputs.cross_design;
|
||||
const ranges = batch?.facilities
|
||||
? facilityRanges(batch.facilities.pipes, batch.facilities.structures ?? [])
|
||||
: null;
|
||||
return (batch?.stations ?? []).map((station) => {
|
||||
try {
|
||||
const options = crossOptions(station.options, batch!.standard);
|
||||
const options = {
|
||||
...crossOptions(station.options, batch!.standard),
|
||||
...(ranges && station.chainage_m != null ? facilityAt(ranges, station.chainage_m) : {}),
|
||||
};
|
||||
return computeCrossDesign(station.samples, station.design_elevation_m, options);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -225,22 +230,64 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
|
||||
const unknown = run.filter((id) => !(id in RUNNERS));
|
||||
const results: Record<string, unknown> = {};
|
||||
if (!unknown.length) for (const id of run) results[id] = need(id);
|
||||
return { results, unknown, available: Object.keys(RUNNERS) };
|
||||
if (!unknown.length) for (const id of run) results[id] = await need(id);
|
||||
const outside = [...TRIAL_IDS, ...STAGE_IDS, ...PAGE_IDS, "haul.view"];
|
||||
const available = Object.keys(RUNNERS).filter((id) => !outside.includes(id));
|
||||
return { results, unknown, available };
|
||||
}
|
||||
|
||||
type CalcRequest = { run?: string[]; inputs?: CalcInputs };
|
||||
|
||||
/** 한 줄 요청 → 한 줄 답 · 실패는 그 줄 { error }(프로세스는 삶). */
|
||||
async function answerLine(line: string): Promise<unknown> {
|
||||
try {
|
||||
const request = JSON.parse(line) as CalcRequest;
|
||||
const t0 = performance.now();
|
||||
const { results, unknown } = await runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
const ms = Math.round((performance.now() - t0) * 100) / 100;
|
||||
return unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 받은 차례대로(단계 id 는 기다림). */
|
||||
function serve(): void {
|
||||
let buffer = "";
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) {
|
||||
const line = buffer.slice(0, at);
|
||||
buffer = buffer.slice(at + 1);
|
||||
queue = queue.then(async () => {
|
||||
process.stdout.write(`${JSON.stringify(await answerLine(line))}\n`);
|
||||
});
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => void queue.then(() => process.exit(0)));
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json>");
|
||||
if (inputPath === "--serve") serve();
|
||||
else if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json> | node <묶음> --serve");
|
||||
process.exit(2);
|
||||
} else {
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest;
|
||||
void runDesignCalc(request.run ?? [], request.inputs ?? {}).then(
|
||||
({ results, unknown, available }) => {
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
process.exit(0); // 묶음 타이머를 기다리지 않음
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
}
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
run?: string[];
|
||||
inputs?: CalcInputs;
|
||||
};
|
||||
const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_cross.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 횡단 몫(PLAN 511-B B-4) — 서버 GET · [저장] 이 부르는 횡단 셈을
|
||||
* 브라우저와 같은 TS 로(파이썬 짝을 걷는 자리).
|
||||
*
|
||||
* facility.attach 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고 · `attachShapes` · F4 F5 F6 C6)
|
||||
* 입력 attach { sections: [{ chainage_m · center_x · center_y }] · pipes · structures · types }
|
||||
* 출력 { shapes: 측점마다 그림 자료 칸만 · misses: { outside · far } }
|
||||
* cross.enforce [저장] 횡단 한 바퀴(`enforceSavedCross` · 설계선 → 미교차 넓힘 → 포장 → 세월교 하강 · G2 C3)
|
||||
* 입력 cross_enforce = `SaveCrossInput` + grid(DTM 격자 이진 경로 · 넓힘 때만 읽음)
|
||||
* 출력 측점마다 { design · samples?(넓힘) · extended? } | null
|
||||
* cross.refresh 계산 자리 cross = server 의 저장 노선 횡단 다시 셈(`refreshDesigns` + 구조물 폐회로 면적)
|
||||
* 입력 cross_refresh { detail · inputs: `RefreshInputs` · grid(서버 창구가 채움) }
|
||||
* 출력 { design_profiles · sections: [{ design · samples?(넓힘) }] · rows: 구조물 면적 줄 }
|
||||
* — 등록부 밖(`PAGE_IDS` · 파이썬 `calc_runner.PAGE_IDS`) · 화면 cross 단계 길
|
||||
* 구조물 표 = 서버 정의(`definition` · M02 반영) — 받으면 갈아 끼움(파이썬 `catalog_row` 와 같은 표).
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachShapes, SHAPE_KEYS, type AttachType } from "@util/common_util_structure_attach";
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import type { PlanPipe, PlanStructure } from "@util/common_util_structure_station";
|
||||
import {
|
||||
enforceSavedCross,
|
||||
type SaveCrossInput,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Save";
|
||||
import {
|
||||
refreshDesigns,
|
||||
type RefreshInputs,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Refresh_Calc";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { structureAreaRows } from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface CrossInputs {
|
||||
definition?: CatalogDefinition | null;
|
||||
attach?: {
|
||||
sections: Array<{ chainage_m: number; center_x?: number | null; center_y?: number | null }>;
|
||||
pipes: PlanPipe[];
|
||||
structures: PlanStructure[];
|
||||
types: AttachType[];
|
||||
};
|
||||
cross_enforce?: SaveCrossInput & { grid?: string | null };
|
||||
cross_refresh?: { detail: SectionDetailResponse; inputs: RefreshInputs; grid?: string | null };
|
||||
}
|
||||
|
||||
/** 등록부 밖 화면 길 id(`available` 에 안 넣음 · 파이썬 `PAGE_IDS` 와 같음). */
|
||||
export const PAGE_IDS = ["cross.refresh"];
|
||||
|
||||
let defined = "";
|
||||
|
||||
/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node · 요청마다 입구가 부름). */
|
||||
export function useDefinition(definition: CatalogDefinition | null | undefined): void {
|
||||
if (!definition) return;
|
||||
const key = JSON.stringify(definition);
|
||||
if (key === defined) return;
|
||||
useCatalogDefinition(definition);
|
||||
defined = key;
|
||||
}
|
||||
|
||||
const SHAPE_FIELDS = [...SHAPE_KEYS, "blank"];
|
||||
|
||||
export function crossRunners(inputs: CrossInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"facility.attach": () => {
|
||||
const attach = inputs.attach;
|
||||
if (!attach) throw new Error("형상 얹기 입력(attach) 없음");
|
||||
const sections = attach.sections.map((section) => ({ ...section })) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const misses = attachShapes(sections, attach.pipes, attach.structures, attach.types);
|
||||
const shapes = sections.map((section) =>
|
||||
Object.fromEntries(
|
||||
SHAPE_FIELDS.filter((key) => key in section).map((k) => [k, section[k]]),
|
||||
),
|
||||
);
|
||||
return { shapes, misses };
|
||||
},
|
||||
"cross.enforce": () => {
|
||||
const input = inputs.cross_enforce;
|
||||
if (!input) throw new Error("횡단 한 바퀴 입력(cross_enforce) 없음");
|
||||
return enforceSavedCross(input, () => (input.grid ? gridAt(input.grid) : null));
|
||||
},
|
||||
"cross.refresh": () => {
|
||||
const job = inputs.cross_refresh;
|
||||
if (!job) throw new Error("횡단 다시 셈 입력(cross_refresh) 없음");
|
||||
const { detail } = job;
|
||||
const before = detail.cross_sections.map((section) => section.samples);
|
||||
refreshDesigns(detail, job.inputs, job.grid ? gridAt(job.grid) : null);
|
||||
return {
|
||||
design_profiles: detail.longitudinal.design_profiles,
|
||||
sections: detail.cross_sections.map((section, index) => ({
|
||||
design: section.design,
|
||||
...(section.samples !== before[index] ? { samples: section.samples } : {}),
|
||||
})),
|
||||
rows: structureAreaRows(detail.cross_sections),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,15 @@
|
||||
* base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`)
|
||||
* facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line ·
|
||||
* max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`)
|
||||
* — follow.fill { previous?: { structure_id: 항목 } · only_missing } 있으면 = 저장 좌표 맞춤(같은 F1 ·
|
||||
* 511-B B-5) → { xy: [{ x · y } | null(그대로)] · filled }(`fillStructureCoordinates` · items 에 structure_id)
|
||||
* facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } →
|
||||
* { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 }
|
||||
* ========================================================================== */
|
||||
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import {
|
||||
fillStructureCoordinates,
|
||||
followPlusRows,
|
||||
followStructures,
|
||||
type FollowingStructure,
|
||||
@@ -27,10 +30,11 @@ const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface FollowInputs {
|
||||
follow?: {
|
||||
items: FollowingStructure[];
|
||||
items: Array<FollowingStructure & { structure_id?: string | null }>;
|
||||
line: Line;
|
||||
max_drift_m?: number;
|
||||
interval_m?: number | null;
|
||||
fill?: { previous?: Record<string, FollowingStructure> | null; only_missing?: boolean };
|
||||
};
|
||||
plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line };
|
||||
seat?: {
|
||||
@@ -61,6 +65,14 @@ export function followRunners(inputs: FollowInputs): Record<string, () => unknow
|
||||
"facility.follow": () => {
|
||||
const follow = inputs.follow;
|
||||
if (!follow) return null;
|
||||
if (follow.fill)
|
||||
return fillStructureCoordinates(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
follow.fill.previous ?? null,
|
||||
follow.fill.only_missing ?? false,
|
||||
follow.interval_m ?? null,
|
||||
);
|
||||
return followStructures(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_profile.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 종단 몫(PLAN 511-B B-3) — 첫 계획선 꼭짓점 · 계획선 짓기 ·
|
||||
* 노선 바뀜 계획고 옮김. 브라우저 묶음이 쓰는 같은 TS · 파이썬은 `B05_Profile_Engine_Grade_Node.py` 로
|
||||
* (늘 떠 있는 Node · 출력 = 반올림 전 값 · 정본 꼴 0.000001 m 는 파이썬이 적음).
|
||||
*
|
||||
* profile.base 입력 profile { chainage · ground(파이썬 `ground_profile`) · policy(`AlignmentPolicy.as_dict`) ·
|
||||
* ground_following { stations: [측점 m] · start_offset_m · end_offset_m } } →
|
||||
* 꼭짓점(`groundFollowingNodes` · 반올림 전) · 못 서는 땅 null(파이썬 2~4 순위)
|
||||
* profile.alignment 입력 profile { chainage · ground · stations: [{ station_id · chainage_m }] · policy ·
|
||||
* base_pvi(없으면 profile.base 출력) · edits · only_explicit_curves } → `buildAlignment`
|
||||
* profile.carry 입력 carry_profile { old_xy · new_xy · old_base · edits · stations · half_m ·
|
||||
* seat?{ ground_s · ground_z · policy · base_pvi · born } } →
|
||||
* { edits(`carryEdits`) · base_pvi(`seatRouteMoved` · 높이 고친 측점 = 옮긴 편집 열쇠) }
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
type AlignmentPolicy,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { groundFollowingNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { carryEdits } from "../../../B05_Profile/logic/B05_Profile_Grade_Carry";
|
||||
import { seatRouteMoved } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
|
||||
type Line = Array<[number, number]>;
|
||||
const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface ProfileInputs {
|
||||
profile?: {
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
policy: AlignmentPolicy;
|
||||
stations?: Array<{ station_id: string | null; chainage_m: number }>;
|
||||
base_pvi?: AlignmentNode[] | null;
|
||||
edits?: AlignmentEdits | null;
|
||||
only_explicit_curves?: boolean;
|
||||
ground_following?: { stations: number[]; start_offset_m?: number; end_offset_m?: number };
|
||||
};
|
||||
carry_profile?: {
|
||||
old_xy: Line;
|
||||
new_xy: Line;
|
||||
old_base: AlignmentNode[];
|
||||
edits?: Partial<AlignmentEdits> | null;
|
||||
stations: number[];
|
||||
half_m: number;
|
||||
seat?: {
|
||||
ground_s: number[];
|
||||
ground_z: number[];
|
||||
policy: AlignmentPolicy;
|
||||
base_pvi: AlignmentNode[];
|
||||
born: number[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음) — 계획선은 꼭짓점이 없으면 같은 요청의 첫 계획선 꼭짓점 위. */
|
||||
export function profileRunners(inputs: ProfileInputs): Record<string, () => unknown> {
|
||||
let nodes: AlignmentNode[] | null | undefined;
|
||||
const base = (): AlignmentNode[] | null => {
|
||||
const profile = inputs.profile;
|
||||
const follow = profile?.ground_following;
|
||||
if (nodes === undefined)
|
||||
nodes =
|
||||
profile && follow
|
||||
? groundFollowingNodes(profile.chainage, profile.ground, {
|
||||
stations: follow.stations,
|
||||
policy: profile.policy,
|
||||
startOffsetM: follow.start_offset_m ?? 0,
|
||||
endOffsetM: follow.end_offset_m ?? 0,
|
||||
})
|
||||
: null;
|
||||
return nodes;
|
||||
};
|
||||
return {
|
||||
"profile.base": base,
|
||||
"profile.alignment": () => {
|
||||
const profile = inputs.profile;
|
||||
const basePvi = profile?.base_pvi ?? base();
|
||||
if (!profile || !basePvi) return null;
|
||||
return buildAlignment(
|
||||
{
|
||||
policy: profile.policy,
|
||||
onlyExplicitCurves: profile.only_explicit_curves === true,
|
||||
basePvi,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: profile.stations ?? [],
|
||||
},
|
||||
profile.edits ?? emptyEdits(),
|
||||
);
|
||||
},
|
||||
"profile.carry": () => {
|
||||
const carry = inputs.carry_profile;
|
||||
if (!carry) return null;
|
||||
const edits = carryEdits(
|
||||
xyOf(carry.old_xy),
|
||||
xyOf(carry.new_xy),
|
||||
carry.old_base,
|
||||
carry.edits,
|
||||
carry.stations,
|
||||
carry.half_m,
|
||||
);
|
||||
const seat = carry.seat;
|
||||
if (!seat) return { edits, base_pvi: null };
|
||||
const held = Object.keys(edits.station_offsets).map(Number);
|
||||
const base = {
|
||||
chainage: seat.ground_s,
|
||||
ground: seat.ground_z,
|
||||
policy: seat.policy,
|
||||
basePvi: seat.base_pvi,
|
||||
};
|
||||
return { edits, base_pvi: seatRouteMoved(base, seat.born, held) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_route.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 노선 몫(PLAN 511-B B-5) — 화면 노선 편집과 같은 TS.
|
||||
*
|
||||
* route.polyline 입력 route_polyline { points · curve_flags · radii · locks · arcs · min_radius_m } →
|
||||
* { cleaned: 겹친 점(0.5 m) 뺀 노드 · nodes: 노드 요약(cleaned 자리) · vertices: 선 ·
|
||||
* curves: 곡선(node_first · last = cleaned 자리) · edits: 곡선표 편집표 }
|
||||
* — 서버 노선 저장 `_write_planned_polyline`(편집 갈래 · 파이썬은 파일 꼴 반올림만)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
DUPLICATE_TOLERANCE_M,
|
||||
type Vertex,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { nodeEdits } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
export interface RouteInputs {
|
||||
route_polyline?: {
|
||||
points: Vertex[];
|
||||
curve_flags?: Array<boolean | null> | null;
|
||||
radii?: Array<number | null> | null;
|
||||
locks?: Array<string | null> | null;
|
||||
arcs?: Array<number | null> | null;
|
||||
min_radius_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function routeRunners(inputs: RouteInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"route.polyline": () => {
|
||||
const at = inputs.route_polyline;
|
||||
if (!at) throw new Error("노선 입력(route_polyline) 없음");
|
||||
const flags = at.curve_flags ?? null;
|
||||
const radii = at.radii ?? null;
|
||||
// 겹친 점을 편집값과 함께 거름 — 곡선 자리 번호 = 저장 노드 자리(파이썬 `build_planned_polyline`)
|
||||
const cleaned: Vertex[] = [];
|
||||
const on: boolean[] = [];
|
||||
const forced: Array<number | null> = [];
|
||||
at.points.forEach((point, index) => {
|
||||
const last = cleaned[cleaned.length - 1];
|
||||
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
|
||||
return;
|
||||
cleaned.push(point);
|
||||
on.push(flags && index < flags.length ? Boolean(flags[index]) : true);
|
||||
forced.push(radii && index < radii.length ? radii[index] : null);
|
||||
});
|
||||
const line = buildEditedPolyline(cleaned, on, forced, at.min_radius_m);
|
||||
return {
|
||||
cleaned,
|
||||
nodes: line.nodes,
|
||||
vertices: line.vertices,
|
||||
curves: line.curves,
|
||||
edits: nodeEdits(at.points, flags, radii, at.locks ?? null, at.arcs ?? null),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_sections.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 측점 몫(PLAN 511-B B-2) — 화면 묶음 stations 단계와 같은 TS.
|
||||
*
|
||||
* stations.list 입력 stations { line · curves · pipes · structures · types · definition? · plus ·
|
||||
* interval_m · include_endpoint? · station_snap · station_snap_step } →
|
||||
* 측점 목록(`buildStations` · 열쇠 · 종류 · 반경 · 확폭 · 지반 null) — 계산 자리 「서버」 ·
|
||||
* 노선 저장 옛 노선 열쇠
|
||||
* stations.sections 같은 입력 + grid(DTM 격자 이진 경로) · 표본 간격 → 서버 `generate_sections` 꼴
|
||||
* (`generateSections`) — 서버 측점 생성 길만(창구 `/calc` 는 막음 · 경로 입력)
|
||||
* stations.plan 입력 stations { types · definition? · plan: [{ pipes · structures · plus }] } → 묶음마다
|
||||
* 측점 계획(`planStations`) — 서버 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기 · 511-B B-5)
|
||||
* definition = 구조물 정의(`{types, …정의}` · 화면 `fetchStructureTypes` 와 같은 꼴) — 심는 측점 · 카드 이름 표.
|
||||
* ========================================================================== */
|
||||
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import {
|
||||
planStations,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "@util/common_util_structure_station";
|
||||
import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { generateSections } from "../../../B05_Profile/logic/B05_Profile_Sections_Generate";
|
||||
import type { DesignCurve, Vertex } from "./design_pipeline_types";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface SectionsInputs {
|
||||
stations?: {
|
||||
line: Vertex[];
|
||||
curves?: DesignCurve[];
|
||||
pipes?: PlanPipe[];
|
||||
structures?: PlanStructure[];
|
||||
types: PlanType[];
|
||||
definition?: CatalogDefinition | null;
|
||||
plus?: number[];
|
||||
interval_m?: number;
|
||||
include_endpoint?: boolean;
|
||||
station_snap?: boolean;
|
||||
station_snap_step?: number | null;
|
||||
grid?: string;
|
||||
cross_half_width_m?: number;
|
||||
cross_sample_interval_m?: number;
|
||||
long_sample_interval_m?: number;
|
||||
plan?: Array<{ pipes?: PlanPipe[]; structures?: PlanStructure[]; plus?: number[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function sectionsRunners(inputs: SectionsInputs): Record<string, () => unknown> {
|
||||
const args = () => {
|
||||
const at = inputs.stations;
|
||||
if (!at) throw new Error("측점 입력(stations) 없음");
|
||||
if (at.definition !== undefined) useCatalogDefinition(at.definition);
|
||||
const options = {
|
||||
intervalM: at.interval_m,
|
||||
includeEndpoint: at.include_endpoint,
|
||||
stationSnap: at.station_snap ?? false,
|
||||
stationSnapStep: at.station_snap_step ?? null,
|
||||
types: at.types,
|
||||
plus: at.plus ?? [],
|
||||
crossHalfWidthM: at.cross_half_width_m,
|
||||
crossSampleIntervalM: at.cross_sample_interval_m,
|
||||
longSampleIntervalM: at.long_sample_interval_m,
|
||||
};
|
||||
return { at, options };
|
||||
};
|
||||
return {
|
||||
"stations.list": () => {
|
||||
const { at, options } = args();
|
||||
return buildStations(at.line, at.curves ?? [], at.pipes ?? [], at.structures ?? [], options);
|
||||
},
|
||||
"stations.plan": () => {
|
||||
const { at } = args();
|
||||
return (at.plan ?? []).map((group) =>
|
||||
planStations(
|
||||
group.pipes ?? [],
|
||||
group.structures ?? [],
|
||||
at.types,
|
||||
undefined,
|
||||
group.plus ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
"stations.sections": () => {
|
||||
const { at, options } = args();
|
||||
if (!at.grid) throw new Error("DTM 격자 없음 — DTM 아닌 지표면은 파이썬 길");
|
||||
const grid = gridAt(at.grid);
|
||||
return generateSections(
|
||||
at.line,
|
||||
at.curves ?? [],
|
||||
at.pipes ?? [],
|
||||
at.structures ?? [],
|
||||
grid,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_stages.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 단계 몫(PLAN 511-B B-1) — 페이지 묶음 단계(노선 · 따라감 · 측점 ·
|
||||
* 종단 · 횡단)를 서버 Node 가 **같은 단계 함수 · 같은 붙임**으로 셈. 한 요청 = 묶음 한 바퀴(앞 단계도 다시 셈) ·
|
||||
* id 여럿이면 그 바퀴 하나에서 꺼냄. 등록부 `node:` 밖(파이썬 `STAGE_IDS`) — 서버 정본 길은 B-2 ~ B-5 몫.
|
||||
*
|
||||
* 입력 stages { plan: 저장 노선(planned · curves · min_radius_m · station_snap · station_snap_step) ·
|
||||
* detail: 저장 상세(종단 기준 · 저장 PVI) · types: 구조물 정의 전부 · saved_pipes · saved_structures ·
|
||||
* saved_edits(노선 초안 없을 때 계획고 · 없으면 input.alignment) · saved_plus(추가 측점 정본 좌표) ·
|
||||
* before_draft(노선 초안 전 관 · 구조물 초안 · null 칸 = 저장분 · 없으면 input) ·
|
||||
* pipe_ids(관 세션 장부 `pipe-ids` 사본 · 종단 심는 측점 id · 없으면 새 차례) ·
|
||||
* interval_m · grid: { path: `dtm_grid_file` 이진 · design: las | sheet } ·
|
||||
* input: 묶음 입력(`PipelineInputs` · pipes · structures null = 저장분) }
|
||||
* 출력 stage.<단계> = 그 단계 출력(JSON 꼴 · `plain`)
|
||||
* 지반 = DTM 격자만(경로 · 수정 시각 기억) · 격자 없음 · 서버 표본 길 = 실패(DTM 아닌 지표면은 파이썬 길).
|
||||
* 붙임 = 페이지 `design_pipeline_page.ts` 와 같음 — 다른 점: 저장 노선 횡단도 엔진(페이지는 다시 셈 창구) ·
|
||||
* 소단 = 브라우저 값(계산 자리 대조 없음) · 되돌리기 사진 · 가게 메아리 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
|
||||
import type { FollowingStructure } from "@util/common_util_route_follow";
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station";
|
||||
import { parseDtmGrid, type DtmGrid } from "@util/common_util_surface_sampler";
|
||||
import type { RoutePlanResponse } from "../../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import {
|
||||
createFollowStage,
|
||||
createPlanlineStage,
|
||||
followSides,
|
||||
profileStage,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { shapeStatus } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import {
|
||||
createGroundStage,
|
||||
createRouteStage,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import type {
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
} from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch";
|
||||
import { plantedBase } from "./design_planted_base";
|
||||
import { AFTER_STAGES, BROWSER_STAGES, createDesignPipeline } from "./design_pipeline";
|
||||
import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs";
|
||||
import { createStationsStage, snapOptions } from "./design_pipeline_stations";
|
||||
import type {
|
||||
PipelineInputs,
|
||||
PipelineStages,
|
||||
StageContext,
|
||||
StageName,
|
||||
Vertex,
|
||||
} from "./design_pipeline_types";
|
||||
import { plain } from "./design_calc_node_trial";
|
||||
|
||||
export interface StagesInputs {
|
||||
stages?: {
|
||||
plan: Pick<
|
||||
RoutePlanResponse,
|
||||
"planned" | "curves" | "min_radius_m" | "station_snap" | "station_snap_step"
|
||||
>;
|
||||
detail?: SectionDetailResponse | null;
|
||||
types: PlanType[];
|
||||
saved_pipes: PlanPipe[];
|
||||
saved_structures: PlanStructure[];
|
||||
saved_edits?: AlignmentEdits | null;
|
||||
saved_plus?: FollowingStructure[] | null;
|
||||
before_draft?: { pipes: unknown; structures: unknown } | null;
|
||||
pipe_ids?: Parameters<typeof pipeBook>[0];
|
||||
interval_m?: number;
|
||||
grid?: { path: string; design: "las" | "sheet" } | null;
|
||||
input: Partial<PipelineInputs>;
|
||||
/** 구조물 정의(서버 창구가 붙임 · 화면 `useCatalogDefinition` 꼴) — 측점 · 카드 이름 표. */
|
||||
definition?: CatalogDefinition | null;
|
||||
/** 저장 종단(계획고 편집 = `readAlignment(·).edits` · 세션 초안 `saved_edits` 없을 때 · 창구가 붙임). */
|
||||
longitudinal?: LongitudinalSection | null;
|
||||
/** "follow" = route · follow 두 단계만(격자 · 상세 안 씀 · 계산 자리 follow 「서버」 · 511-B B-5) —
|
||||
* stage.follow = { patch · plus · followed }(곁 자료 = 페이지가 다시 붙임). */
|
||||
until?: "follow";
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력으로 내는 단계 다섯(판 줄과 같은 이름 · ground · planline 은 안에서만). */
|
||||
export const STAGE_NAMES = ["route", "follow", "stations", "profile", "cross"] as const;
|
||||
export const STAGE_IDS = STAGE_NAMES.map((name) => `stage.${name}`);
|
||||
|
||||
let gridMemo: { path: string; mtime: number; grid: DtmGrid } | null = null;
|
||||
|
||||
/** DTM 격자 이진 — 같은 경로 · 같은 수정 시각이면 앞에 읽은 것(늘 떠 있는 Node). */
|
||||
export function gridAt(path: string): DtmGrid {
|
||||
const mtime = statSync(path).mtimeMs;
|
||||
if (gridMemo?.path !== path || gridMemo.mtime !== mtime) {
|
||||
const bytes = readFileSync(path);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
gridMemo = { path, mtime, grid: parseDtmGrid(buffer as ArrayBuffer) };
|
||||
}
|
||||
return gridMemo.grid;
|
||||
}
|
||||
|
||||
async function runStages(
|
||||
stages: NonNullable<StagesInputs["stages"]>,
|
||||
): Promise<Record<StageName, unknown>> {
|
||||
const { plan, detail = null, types } = stages;
|
||||
const savedPipes = stages.saved_pipes;
|
||||
const savedStructures = stages.saved_structures;
|
||||
const enabled = types.filter((type) => (type as { enabled?: boolean }).enabled !== false);
|
||||
const grid = stages.grid ? { grid: gridAt(stages.grid.path), design: stages.grid.design } : null;
|
||||
const savedLine = (): Vertex[] => plan.planned.map(([x, y]): Vertex => [x, y]);
|
||||
const savedCurves = () =>
|
||||
(plan.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m }));
|
||||
const inputs: PipelineInputs = {
|
||||
route: stages.input.route ?? null,
|
||||
alignment: stages.input.alignment ?? null,
|
||||
cross: stages.input.cross ?? null,
|
||||
pipes: stages.input.pipes ?? savedPipes, // 저장분 = 같은 객체(자리 맞춤이 초안과 가름)
|
||||
structures: stages.input.structures ?? savedStructures,
|
||||
plusStations: stages.input.plusStations ?? null,
|
||||
};
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (stages.definition) useCatalogDefinition(stages.definition);
|
||||
// 페이지 `savedEdits` 짝 — 세션 초안 없으면 저장 종단 편집 · 없으면 빈 편집(창구가 longitudinal 을 붙인 때)
|
||||
const storedEdits = () =>
|
||||
stages.longitudinal === undefined
|
||||
? null
|
||||
: ((stages.longitudinal && readAlignment(stages.longitudinal)?.edits) ?? emptyEdits());
|
||||
const book = pipeBook(stages.pipe_ids ?? null);
|
||||
// 따라감 기준(페이지 `beforeDraft ?? 가게`) — 초안 전 관 · 구조물(null = 저장분)
|
||||
const before = stages.before_draft ?? stages.input;
|
||||
const followStage = createFollowStage({
|
||||
savedLine,
|
||||
savedPlus: () => stages.saved_plus ?? [],
|
||||
saved: () => ({
|
||||
pipes: before.pipes ?? savedPipes,
|
||||
structures: before.structures ?? savedStructures,
|
||||
alignment: stages.saved_edits ?? storedEdits() ?? inputs.alignment ?? emptyEdits(),
|
||||
}),
|
||||
stationsAt: (route, patch, at) =>
|
||||
buildStations(
|
||||
route.line,
|
||||
route.curves,
|
||||
(patch.pipes ?? at.pipes ?? []) as PlanPipe[],
|
||||
(patch.structures ?? at.structures ?? []) as PlanStructure[],
|
||||
{ intervalM: stages.interval_m, types: enabled, ...snapOptions(at.route, plan) },
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
const crossStage = createCrossStage({ grid: () => grid });
|
||||
const table: PipelineStages = {
|
||||
route: createRouteStage({
|
||||
minRadiusM: () => plan.min_radius_m ?? 0,
|
||||
savedLine: () => ({ line: savedLine(), curves: savedCurves() }),
|
||||
}),
|
||||
follow: (ctx) =>
|
||||
followStage({
|
||||
...ctx,
|
||||
inputs: placedFollowInputs(ctx, savedLine(), {
|
||||
pipes: savedPipes,
|
||||
structures: savedStructures,
|
||||
}),
|
||||
}),
|
||||
stations: createStationsStage({
|
||||
intervalM: stages.interval_m,
|
||||
types: () => enabled,
|
||||
saved: () => plan,
|
||||
}),
|
||||
ground: createGroundStage({
|
||||
grid: () => grid,
|
||||
remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")),
|
||||
}),
|
||||
planline: createPlanlineStage({
|
||||
savedBase: (at) =>
|
||||
plantedBase({ base: null, detail }, at, types, (pipes) =>
|
||||
pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })),
|
||||
),
|
||||
savedLine,
|
||||
storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null),
|
||||
}),
|
||||
profile: profileStage,
|
||||
cross: (ctx) => {
|
||||
const status = shapeStatus(
|
||||
ctx.inputs.pipes ?? savedPipes,
|
||||
ctx.inputs.structures ?? savedStructures,
|
||||
types,
|
||||
);
|
||||
const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : [];
|
||||
const spans = bermSpansFromStructures(list);
|
||||
return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at)));
|
||||
},
|
||||
};
|
||||
if (stages.until === "follow") return followOnly(table, inputs);
|
||||
const pipe = createDesignPipeline({ stages: table });
|
||||
try {
|
||||
const record = await pipe.start(inputs);
|
||||
await pipe.settled();
|
||||
if (record.outcome !== "done") throw new Error(`단계 묶음 ${record.outcome}`);
|
||||
return Object.fromEntries(
|
||||
STAGE_NAMES.map((name) => [name, plain(pipe.output(name))]),
|
||||
) as Record<StageName, unknown>;
|
||||
} finally {
|
||||
pipe.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** route · follow 두 단계만 — 묶음 첫 바퀴와 같은 맥락(앞 출력 없음 = 저장분에서 · 페이지 「다시 열기」 꼴). */
|
||||
async function followOnly(
|
||||
table: PipelineStages,
|
||||
inputs: PipelineInputs,
|
||||
): Promise<Record<StageName, unknown>> {
|
||||
const none = Object.fromEntries(
|
||||
[...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]),
|
||||
) as StageContext<"route">["out"];
|
||||
const at = <K extends StageName>(out: StageContext<K>["out"]): StageContext<K> => ({
|
||||
generation: 1,
|
||||
range: null,
|
||||
signal: new AbortController().signal,
|
||||
inputs,
|
||||
out,
|
||||
before: none,
|
||||
prev: null,
|
||||
});
|
||||
const route = await table.route!(at<"route">(none));
|
||||
const follow = await table.follow!(at<"follow">({ ...none, route }));
|
||||
const out = { route: plain(route), follow: { patch: plain(follow), ...followSides(follow) } };
|
||||
return { stations: null, profile: null, cross: null, ...out } as Record<StageName, unknown>;
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */
|
||||
export function stageRunners(inputs: StagesInputs): Record<string, () => Promise<unknown>> {
|
||||
let run: Promise<Record<StageName, unknown>> | null = null;
|
||||
const once = () => {
|
||||
if (!inputs.stages) throw new Error("단계 입력(stages) 없음");
|
||||
return (run ??= runStages(inputs.stages));
|
||||
};
|
||||
return Object.fromEntries(
|
||||
STAGE_NAMES.map((name) => [`stage.${name}`, () => once().then((out) => out[name])]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_trial.ts
|
||||
* 계산 자리 「서버」 시험 길(PLAN 511-A) — 조작 중 브라우저가 부르는 함수를 서버 Node 가 **같은 TS 로** 부름.
|
||||
* 등록부 `node:` 실행기 밖(파이썬 `TRIAL_IDS` 와 같은 표) · 입력 = `inputs.args[id]`(그 함수 인자 그대로 · JSON).
|
||||
* masshaul.series E1 유토곡선 그리기(`computeMassHaulSeries`)
|
||||
* stations.carry_keyed R9 측점 열쇠 넘김(`carryKeyedStates` · 읽기 = 칸 이름 → 값 표)
|
||||
* route.handle R2 · route.station_fit R4 · stations.swap R8 · profile.tools P7 · cross.fit C7 · footprint.plan D4
|
||||
* Map · Set 은 목록으로(JSON) — 브라우저 대조도 같은 꼴(`design_calc_server.ts` `plain`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||
import { fitAfter } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
|
||||
import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
carryKeyedStates,
|
||||
type KeyedState,
|
||||
type Raw,
|
||||
} from "../../../B06_Section/logic/B06_Section_Route_Carry";
|
||||
import { swapCrossSections } from "../../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
import { toeOffsets } from "../../../B06_Section/logic/B06_Section_UI_Cross_Fit";
|
||||
import type { StageContext } from "./design_pipeline_types";
|
||||
|
||||
export interface TrialInputs {
|
||||
args?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
type Call = (...args: never[]) => unknown;
|
||||
|
||||
/** Map · Set → 목록 — JSON 으로 오가는 꼴. */
|
||||
export function plain(value: unknown): unknown {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const TRIAL: Record<string, Call> = {
|
||||
"masshaul.series": computeMassHaulSeries as Call,
|
||||
"stations.carry_keyed": ((states: Record<string, Raw>, from, to) =>
|
||||
carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call,
|
||||
"route.handle": dragHandleTo as Call,
|
||||
"route.station_fit": fitAfter as Call, // 편집 한 걸음 뒤 맞춤(fitCurveStations 를 부름)
|
||||
"stations.swap": swapCrossSections as Call,
|
||||
"profile.tools": straightenBetween as Call,
|
||||
"cross.fit": toeOffsets as Call,
|
||||
// 발자국 단계 — 앞 결과 없이 전 구간(브라우저 범위 밖 재사용과 같은 값)
|
||||
"footprint.plan": ((out: StageContext<"footprint">["out"]) =>
|
||||
footprintStage({ out, range: null, prev: null } as StageContext<"footprint">)) as Call,
|
||||
};
|
||||
|
||||
export const TRIAL_IDS = Object.keys(TRIAL);
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음). */
|
||||
export function trialRunners(inputs: TrialInputs): Record<string, () => unknown> {
|
||||
return Object.fromEntries(
|
||||
TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]),
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
* ts — TS 셈 자리 `<경로>#<함수>` · 없으면 null
|
||||
* runner — 실행기: `node:<id>`(서버 Node 입구) · `py:<경로>#<함수>`(지금 파이썬) ·
|
||||
* `browser:<경로>#<함수>`(browser_only 만)
|
||||
* until — "472-4" = 파이썬 실행기는 472-4 짝 모으기 동안만(그 뒤 node:)
|
||||
* pair — 파이썬 짝 `<경로>#<함수>` · 짝 아니면 null
|
||||
* mirror — 거울 · 단위 시험(`resources/tester` 파일 이름 · .py 뺌)
|
||||
*
|
||||
@@ -41,7 +40,6 @@ export interface CalcEntry {
|
||||
stage: StageName | null;
|
||||
ts: string | null;
|
||||
runner: string;
|
||||
until?: "472-4";
|
||||
pair: string | null;
|
||||
mirror: string[];
|
||||
note?: string;
|
||||
@@ -64,11 +62,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "route",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#buildEditedPolyline`,
|
||||
runner: `py:${U}common_util_route_polyline.py#build_planned_polyline`,
|
||||
until: "472-4",
|
||||
runner: "node:route.polyline",
|
||||
pair: `${U}common_util_route_polyline.py#build_planned_polyline`,
|
||||
mirror: ["test_route_polyline_browser_mirror"],
|
||||
note: "파이썬 짝은 추천 엔진 · B04 배수 · 노선 확인이 부름 — 짝 남음",
|
||||
mirror: ["test_route_polyline_browser_mirror", "test_511_b5_route_node"],
|
||||
note: "서버 노선 저장(편집 갈래 · `_write_planned_polyline`) = Node · 파이썬 짝은 첫 폴리라인(단순화) · 추천 엔진 · B04 배수 · 노선 열기가 부름 — 짝 남음 · 알려진 차 = 교각 딱 5° 배수면 원호 점 ±1(libm 끝자리 · 실데이터 0 차)",
|
||||
},
|
||||
{
|
||||
id: "route.handle",
|
||||
@@ -92,10 +89,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "route",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Edits.ts#applyCurveLimits`,
|
||||
runner: `py:${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
|
||||
until: "472-4",
|
||||
runner: "node:route.polyline",
|
||||
pair: `${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
|
||||
mirror: ["test_430_route_edit_rules"],
|
||||
mirror: ["test_430_route_edit_rules", "test_511_b5_route_node"],
|
||||
note: "곡선표 편집표 = Node `route.polyline` 의 `nodeEdits` · 파이썬 짝 = Node 못 돌릴 때",
|
||||
},
|
||||
{
|
||||
id: "route.station_fit",
|
||||
@@ -121,10 +118,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Frame.ts#stationFrameAt`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_b05_routeedit_frame_mirror", "test_224_2_station_snap"],
|
||||
mirror: [
|
||||
"test_b05_routeedit_frame_mirror",
|
||||
"test_224_2_station_snap",
|
||||
"test_511_b2_stations_node",
|
||||
],
|
||||
note: "서버 측점 생성 = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · 추천 엔진 · B06 측점 맞춤",
|
||||
},
|
||||
{
|
||||
id: "stations.list",
|
||||
@@ -143,11 +144,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${B05L}B05_Profile_Sections_Stations.ts#buildStations`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_344_3_pipeline_stations_mirror", "test_407_curve_station"],
|
||||
note: "파이썬 짝은 B03 체인 · B07 IP 표 · 추천도 부름",
|
||||
mirror: [
|
||||
"test_344_3_pipeline_stations_mirror",
|
||||
"test_407_curve_station",
|
||||
"test_511_b2_stations_node",
|
||||
],
|
||||
note: "서버 측점 생성 길(B03 체인 · 노선 저장 · 재생성 · 추천 초기 종단) = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · B07 IP 표 · 추천 엔진",
|
||||
},
|
||||
{
|
||||
id: "stations.key",
|
||||
@@ -158,11 +162,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${U}common_util_station_key.ts#stationKey`,
|
||||
runner: `py:${U}common_util_station_key.py#station_key`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${U}common_util_station_key.py#station_key`,
|
||||
mirror: ["test_344_3_station_key_mirror"],
|
||||
note: "서버 열쇠(Route_Save_Parts.station_keys)는 주인 = 파이썬 plan_stations(F3 · 카탈로그) · 부르는 곳 = 노선 저장 이월(③) — ③ · F3 와 같이 Node 로",
|
||||
mirror: ["test_344_3_station_key_mirror", "test_511_b2_stations_node"],
|
||||
note: "노선 저장 이월 열쇠 = Node 측점 출력(`key` · 옛 노선 = `stations.list`) · 파이썬 짝 = 옛 열쇠 꼴 읽기(at: · 누가거리)",
|
||||
},
|
||||
{
|
||||
id: "stations.swap",
|
||||
@@ -203,10 +206,9 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
stage: "ground",
|
||||
ts: `${U}common_util_surface_sampler.ts#heightAt`,
|
||||
runner: `py:${U}common_util_surface_sampler.py#build_surface_sampler`,
|
||||
until: "472-4",
|
||||
pair: `${U}common_util_surface_sampler.py#build_surface_sampler`,
|
||||
mirror: ["test_344_1_surface_sampler_mirror"],
|
||||
note: "X2 — 브라우저 격자 .bin · 서버 npz(값 협의)",
|
||||
mirror: ["test_344_1_surface_sampler_mirror", "test_511_b2_stations_node"],
|
||||
note: "서버 측점 생성(DTM) = Node `stations.sections` 격자 표본 · 파이썬 표본기 남김 = DTM 아닌 지표면 · 조작 중 「서버」 표본 창구 · B04 · B07 · 추천 · X2 = 브라우저 격자 .bin · 서버 npz(값 협의)",
|
||||
},
|
||||
{
|
||||
id: "ground.extend",
|
||||
@@ -217,10 +219,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B05L}B05_Profile_Sections_Ground.ts#extendUnclosed`,
|
||||
runner: `py:${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
|
||||
until: "472-4",
|
||||
runner: "node:cross.enforce",
|
||||
pair: `${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
|
||||
mirror: ["test_344_1_sections_ground_mirror"],
|
||||
mirror: ["test_344_1_sections_ground_mirror", "test_511_b4_cross_save_mirror"],
|
||||
note: "[저장] = Node cross.enforce 안(DTM 격자 · 511-B B-4) · DTM 아닌 지표면 = 파이썬 짝(표본기)",
|
||||
},
|
||||
{
|
||||
id: "ground.widen",
|
||||
@@ -245,10 +247,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B05L}B05_Profile_Pipeline_Detail.ts#uphillSide`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.sections",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_346_2_pipeline_cross_page"],
|
||||
mirror: ["test_346_2_pipeline_cross_page", "test_511_b2_stations_node"],
|
||||
note: "서버 측점 생성(DTM) = Node `stations.sections` 의 상단측 · DTM 아닌 지표면 = 파이썬 짝",
|
||||
},
|
||||
/* ── 종단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -260,11 +262,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "planline",
|
||||
ts: `${B05L}B05_Profile_Grade_Ground.ts#groundFollowingBasePvi`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law"],
|
||||
note: "파이썬 짝은 추천 엔진(RouteInitial_Profile)도 씀 — 짝 남음",
|
||||
runner: "node:profile.base",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#straight_pass_through`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law", "test_511_b3_profile_node"],
|
||||
note: "1순위 = Node(511-B B-3) · 못 서면 파이썬 2~4 순위(관 정착 · 직선 분할 · 정함) · 파이썬 짝 = 추천 엔진(RouteInitial_Profile) 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.alignment",
|
||||
@@ -275,15 +276,16 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_UI_Profile_Alignment.ts#buildAlignment`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#build_alignment`,
|
||||
mirror: [
|
||||
"test_426_profile_straight",
|
||||
"test_432_1_curve_law",
|
||||
"test_213_1_hidden_station_pvi",
|
||||
"test_438_1_initial_straighten",
|
||||
"test_511_b3_profile_node",
|
||||
],
|
||||
note: "정본 꼴(0.000001 m) = 파이썬 `Engine_Grade_Node.alignment_record` · 파이썬 짝 = 2~4 순위 폴백 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.curve_law",
|
||||
@@ -294,8 +296,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#flatBends`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
mirror: ["test_432_1_curve_law"],
|
||||
},
|
||||
@@ -308,8 +309,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_Straight.ts#isStraightBend`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
mirror: ["test_426_profile_straight", "test_271_straighten_pick"],
|
||||
},
|
||||
@@ -322,10 +322,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "follow",
|
||||
ts: `${B05L}B05_Profile_Grade_Carry.ts#carryEdits`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span"],
|
||||
runner: "node:profile.carry",
|
||||
pair: null,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span", "test_511_b3_profile_node"],
|
||||
note: "노선 [저장] = 꼭짓점 세움(`seatRouteMoved`)과 Node 한 부름(`Engine_Grade_Node.carry_seat`)",
|
||||
},
|
||||
{
|
||||
id: "profile.seat",
|
||||
@@ -364,11 +364,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: null,
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#verticalCurveShortfalls`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
mirror: ["test_442_2_grade_limit_mirror"],
|
||||
note: "X5 — 기준표 두 벌(화면 PROFILE_CRITERIA · 서버 FOREST_ROAD_PROFILE_CRITERIA)",
|
||||
note: "경고 = 계획선 짓기 안 · 기준표 TS = `logic/B05_Profile_Grade_Criteria.ts`(X5 · 파이썬 표는 추천 · 폴백 몫 · 거울 442_2)",
|
||||
},
|
||||
/* ── 횡단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -408,11 +407,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B06L}B06_Section_Cross_Enforce.ts#facilityRanges`,
|
||||
runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`,
|
||||
until: "472-4",
|
||||
runner: "node:cross.enforce",
|
||||
pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`,
|
||||
note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design",
|
||||
mirror: ["test_349_0n_cross_enforce_mirror"],
|
||||
note: "[저장] 횡단 한 바퀴 = Node cross.enforce · 기본 설계 구간 = cross.design facilities(511-B B-4) · 파이썬 짝 = DTM 아닌 지표면 · Node 못 부를 때",
|
||||
mirror: ["test_349_0n_cross_enforce_mirror", "test_511_b4_cross_save_mirror"],
|
||||
},
|
||||
{
|
||||
id: "cross.berm_spans",
|
||||
@@ -451,11 +449,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${U}common_util_structure_underdrain.ts#attachUnderdrainSpecs`,
|
||||
runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
|
||||
until: "472-4",
|
||||
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
|
||||
runner: "node:facility.attach",
|
||||
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrain_specs`,
|
||||
mirror: ["test_472_4_underdrain_mirror"],
|
||||
note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
|
||||
note: "얹기 = GET 상세 Node facility.attach 안(511-B B-4) · 파이썬 짝 = Node 못 부를 때(`_attach_python`) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
|
||||
},
|
||||
{
|
||||
id: "cross.fit",
|
||||
|
||||
@@ -30,8 +30,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
"test_459_route_seat_mirror",
|
||||
"test_380_3_site_follow",
|
||||
"test_472_4_follow_node",
|
||||
"test_511_b5_route_node",
|
||||
],
|
||||
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS `placeOnLine` 과 규칙 다름)",
|
||||
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` = 같은 Node 입구 + fill(`fillStructureCoordinates` · 옛 좌표 지킴 · 0.1 mm · `placeOnLine` 노선 위 자리와 한 벌 · 511-B B-5)",
|
||||
},
|
||||
{
|
||||
id: "facility.plus_follow",
|
||||
@@ -70,11 +71,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${U}common_util_structure_station.ts#planStations`,
|
||||
runner: `py:${U}common_util_structure_station.py#plan_stations`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.plan",
|
||||
pair: `${U}common_util_structure_station.py#plan_stations`,
|
||||
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station"],
|
||||
note: "파이썬 짝은 B07 · 배수가 넓게 부름",
|
||||
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station", "test_511_b5_route_node"],
|
||||
note: "서버 측점 생성 = Node `stations.sections` · 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기) = Node `stations.plan` · 파이썬 짝은 B06 형상 얹기 · B07 · 배수 · Node 못 돌릴 때",
|
||||
},
|
||||
{
|
||||
id: "facility.attach",
|
||||
@@ -85,10 +85,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${U}common_util_structure_attach.ts#attachShapes`,
|
||||
runner: `py:${B06}B06_Section_Engine_Attach.py#attach_shapes`,
|
||||
until: "472-4",
|
||||
runner: "node:facility.attach",
|
||||
pair: `${B06}B06_Section_Engine_Attach.py#attach_shapes`,
|
||||
mirror: ["test_323_4_attach_mirror"],
|
||||
note: "GET 상세 · 재생성 = Node facility.attach(511-B B-4) · 파이썬 짝 = Node 못 부를 때",
|
||||
},
|
||||
{
|
||||
id: "facility.culvert_sets",
|
||||
@@ -141,9 +141,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
stage: null,
|
||||
ts: `${U}common_util_excavation.ts#wallTrenchAreaM2`,
|
||||
runner: "node:excavation.trench",
|
||||
pair: `${U}common_util_excavation.py#wall_trench_area_m2`,
|
||||
pair: null,
|
||||
mirror: ["test_excavation_mirror"],
|
||||
note: "파이썬 짝 운영 부름 0 — 걷음 후보(472-7)",
|
||||
note: "서버 = Node 한 벌 · 파이썬 짝 운영 부름 0 → 걷음(511-B B-6)",
|
||||
},
|
||||
{
|
||||
id: "facility.ford",
|
||||
@@ -185,8 +185,8 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
ts: `${U}common_util_mass_haul_balance.ts#computeHaulPlan`,
|
||||
runner: "node:haul.plan",
|
||||
pair: null,
|
||||
mirror: [],
|
||||
note: "브라우저 번들 밖(/haul-plan 요청) · 브라우저 실행 여부 협의",
|
||||
mirror: ["test_511_c_haul_node"],
|
||||
note: "기본 /haul-plan(늘 떠 있는 Node haul.view) · 판 haul both = 브라우저 dynamic import(511-C)",
|
||||
},
|
||||
/* ── 배수 · 3D · 추천 ────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -212,10 +212,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "server_only",
|
||||
stage: null,
|
||||
ts: null,
|
||||
runner: `py:${U}common_util_drainage_detail.py#suggest_pipe_positions`,
|
||||
runner: `py:${U}common_util_stream_crossing.py#propose_pipes`,
|
||||
pair: null,
|
||||
mirror: [],
|
||||
note: "TS 는 모으기만(B05_Profile/logic/B05_Profile_Drainage_Suggest.ts)",
|
||||
note: "[자동 제안](510) — TS 는 후보 그리기 · 고름만(B05_Profile/B05_Profile_UI_Drainage_Propose.ts)",
|
||||
},
|
||||
{
|
||||
id: "corridor.mesh",
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface DesignPipeline {
|
||||
value: unknown,
|
||||
part?: RangePart | null,
|
||||
): Promise<PipelineRunRecord> | null;
|
||||
/** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */
|
||||
refresh(source: ChangeSource): Promise<PipelineRunRecord> | null;
|
||||
/** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */
|
||||
subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void;
|
||||
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
||||
@@ -353,6 +355,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
||||
: { sources: [source], fromM: 0, toM: Infinity, keys: [] };
|
||||
return begin(range);
|
||||
},
|
||||
refresh(source) {
|
||||
if (busy) return null;
|
||||
inputVer[SOURCE_INPUT[source]] += 1;
|
||||
return begin({ sources: [source], fromM: 0, toM: Infinity, keys: [] });
|
||||
},
|
||||
subscribe(fn, name = "알림") {
|
||||
listeners.set(fn, name);
|
||||
return () => listeners.delete(fn);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_pipeline_stage_inputs.ts
|
||||
* 페이지 묶음 follow · cross 단계 입력 맞춤(PLAN 511-B B-1) — 페이지(`design_pipeline_page.ts`)와 서버 Node
|
||||
* 단계 입구(`design_calc_node_stages.ts`)가 같은 함수로 단계에 넣음. 화면 · 가게 · 세션 안 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
placedOnLine as placed,
|
||||
type FollowingStructure,
|
||||
} from "../../../common_util/common_util_route_follow";
|
||||
import {
|
||||
crossWithContext,
|
||||
type BermAt,
|
||||
type CrossInput,
|
||||
type ShapeStatus,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import type { PipelineInputs, StageContext, Vertex } from "./design_pipeline_types";
|
||||
|
||||
/** follow 입력 관 · 구조물 자리 — 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의
|
||||
* 앞 노선 위(410). 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 초안 값(저장분과 다른 객체) = 이미 초안
|
||||
* 노선 자리(앞 따라감이 씀). */
|
||||
export function placedFollowInputs(
|
||||
ctx: StageContext<"follow">,
|
||||
savedLine: Vertex[] | null,
|
||||
saved: { pipes: unknown; structures: unknown },
|
||||
): PipelineInputs {
|
||||
const line = ctx.before.route?.line ?? savedLine ?? [];
|
||||
const frame = (list: unknown, stored: unknown) =>
|
||||
!ctx.before.route && ctx.inputs.route && list !== stored ? (ctx.out.route?.line ?? line) : line;
|
||||
const { pipes, structures } = ctx.inputs;
|
||||
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
|
||||
return {
|
||||
...ctx.inputs,
|
||||
pipes: placed(pipes, frame(pipes, saved.pipes), [
|
||||
...(saved.pipes as FollowingStructure[]),
|
||||
...followed,
|
||||
]),
|
||||
structures: placed(
|
||||
structures,
|
||||
frame(structures, saved.structures),
|
||||
[],
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음(365-5 · 457). */
|
||||
export const contextCross = (
|
||||
ctx: StageContext<"cross">,
|
||||
status: ShapeStatus | null,
|
||||
bermAt: BermAt | null,
|
||||
): StageContext<"cross"> => ({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithContext(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
status,
|
||||
bermAt,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -61,18 +61,27 @@ export function snapOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
|
||||
/** `buildStations` 인자 한 벌 — 화면 셈 · 서버 셈(계산 자리 「서버」)이 같은 값을 받음. */
|
||||
export type StationsArgs = Parameters<typeof buildStations>;
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선).
|
||||
* `remote` = 계산 자리 「서버」면 서버 Node 값(같은 `buildStations` · 511-B B-2) · null = 화면 셈 · 서버가
|
||||
* 못 내면(끊김 · 실패) 화면 셈. */
|
||||
export function createStationsStage(
|
||||
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
|
||||
types: () => ReadonlyArray<PlanType>;
|
||||
saved?: () => SavedSnap | null;
|
||||
remote?: (
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
) => Promise<PipelineStation[] | undefined> | null;
|
||||
},
|
||||
): StageFn<"stations"> {
|
||||
const { saved, ...rest } = options;
|
||||
const { saved, remote, ...rest } = options;
|
||||
return (ctx: StageContext<"stations">) => {
|
||||
const route = ctx.out.route;
|
||||
if (!route) return [];
|
||||
const next = buildStations(
|
||||
const args: StationsArgs = [
|
||||
route.line,
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
@@ -83,8 +92,11 @@ export function createStationsStage(
|
||||
...snapOptions(ctx.inputs.route, saved?.()),
|
||||
plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저)
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
];
|
||||
const done = (next: PipelineStation[] | undefined) =>
|
||||
reuseOutside(next ?? buildStations(...args), ctx.prev, ctx.range);
|
||||
const asked = remote?.(args, ctx.signal);
|
||||
return asked ? asked.then(done) : done(undefined); // 화면 셈 = 동기 그대로
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_planted_base.ts
|
||||
* 묶음 planline 의 저장 노선 종단 기준(PLAN 386-3) 셈 몫 — 화면 · 가게 · 세션 없는 순수(511-B B-3 · 서버 Node
|
||||
* 묶음 `design_calc_node_stages.ts` 도 같은 셈). 페이지 겉 = `design_pipeline_base.ts`(관 id = 세션 장부) ·
|
||||
* `design_profile_base.ts` · `design_structure_status.ts` 가 다시 내보냄.
|
||||
*
|
||||
* 종단도 `listed()`(design_profile)와 같은 셈 — 저장 상세 측점 + 구조물 · 관이 심는 측점(`listedBase`).
|
||||
* 심는 측점 = 종단 보기 구조물 칸(`syncCrossDrainStations`) · 횡단 보기(`planMarkStations`)와 같은
|
||||
* `planStations` → `plannedIrregular` — 보기가 종단도에 늦게 심어도 묶음은 다시 안 돎.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { toAlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Profile_Planted";
|
||||
import {
|
||||
followMovedNodes,
|
||||
oneStationList,
|
||||
planStations,
|
||||
type PlannedStation,
|
||||
type PlanType,
|
||||
} 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";
|
||||
import type { PipelineInputs } from "./design_pipeline_types";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준.
|
||||
* `ids` = 관에 관 id 붙임(페이지 = 세션 장부 · 서버 Node = 장부 사본 · 511-B). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
ids: (pipes: Array<{ chainage_m: number }>) => Array<{ chainage_m: number; id: string }>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = ids((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_CalcSites.ts
|
||||
* 「계산 자리」 창(PLAN 504-2 · 504-3 · 요구 309-2 · 312) — 시스템 관리자만(카드 = `settingLinks` · 쓰기 = 서버
|
||||
* `require_system_admin`). 묶음 8 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중
|
||||
* `require_system_admin`). 묶음 12 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중
|
||||
* 브라우저 셈」 · 「[저장] 서버 정본」 늘 켬 · 잠금). 묶음 머리 체크 = 그 묶음 전부.
|
||||
* 바꿀 수 있는 줄 = 서버 판 `groups[].switchable`(판 열쇠 = 묶음) · 나머지는 잠긴 체크 + 까닭 글.
|
||||
* 고침은 창 안 사본에만 · [저장] 때만 바뀐 줄을 PUT(선례 `app_settings.ts`).
|
||||
@@ -40,7 +40,7 @@ function groupRows(
|
||||
changed: () => void,
|
||||
): HTMLElement[][] {
|
||||
const server = (): HTMLInputElement => check(group.fixed !== "browser", true);
|
||||
const why = K(`Why_${group.why ?? group.key}`);
|
||||
const why = (no: string): string => K(`Why_${group.whys?.[no] ?? group.why ?? group.key}`);
|
||||
const head = el("span", {
|
||||
children: [el("strong", { text: K(group.key) }), ` (${group.nos.length})`],
|
||||
});
|
||||
@@ -48,7 +48,7 @@ function groupRows(
|
||||
const browser = (): HTMLInputElement => check(group.fixed !== "server", true);
|
||||
return [
|
||||
[head, browser(), server(), el("span")],
|
||||
...group.nos.map((no) => [calcName(no), browser(), server(), el("span", { text: why })]),
|
||||
...group.nos.map((no) => [calcName(no), browser(), server(), el("span", { text: why(no) })]),
|
||||
];
|
||||
}
|
||||
const boxes = [group.key, ...group.nos].map((id, index) => {
|
||||
@@ -70,7 +70,7 @@ function groupRows(
|
||||
calcName(no),
|
||||
boxes[index + 1],
|
||||
server(),
|
||||
el("span", { text: why }),
|
||||
el("span", { text: why(no) }),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -525,6 +525,8 @@ export interface DetailPipePoint {
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
/** 자동 세류선 관 라벨(507-2 · `{stream, order, x, y}`) — 서버 정본 · 화면은 실어 나르기만(510). */
|
||||
stream_label?: Record<string, string | number> | null;
|
||||
}
|
||||
|
||||
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
|
||||
@@ -622,16 +624,14 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
|
||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
*
|
||||
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369) · `auto`([자동 제안]) = "all" 전체 자동
|
||||
* (같은 자리 관은 속성 유지) · "add" 지금 관 + 빈 구간 보충. */
|
||||
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369). */
|
||||
export async function computeDetailBasins(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[] | null,
|
||||
auto?: "all" | "add",
|
||||
): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(auto ? { points, auto } : { points }),
|
||||
body: JSON.stringify({ points }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from common_util.common_util_drainage_detail import (
|
||||
DrainageDetail,
|
||||
build_detail,
|
||||
pipes_from_chainages,
|
||||
read_road_routing,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_USER,
|
||||
@@ -57,7 +58,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_route_geometry import StructureCandidate
|
||||
from common_util.common_util_stream_crossing import keep_labels
|
||||
from common_util.common_util_stream_crossing import keep_labels, propose_pipes
|
||||
from config.config_system import (
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
@@ -72,8 +73,6 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
|
||||
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
|
||||
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
|
||||
# 머리 「배수유역 낡음」 = 화면 `STALE_PREFIX`(B05 Drainage_Live · 369) 짝.
|
||||
# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add
|
||||
AUTO_MODES = (None, "all", "add")
|
||||
STALE_MESSAGE = (
|
||||
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
|
||||
)
|
||||
@@ -85,22 +84,20 @@ def _build(
|
||||
points: list[PipePoint] | None,
|
||||
routing: Any = None,
|
||||
upstream_lonlat: list | None = None,
|
||||
auto: str | None = None,
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
||||
|
||||
`points`가 None 이면 B04 기본 관(세류선 교차)만. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기
|
||||
있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로
|
||||
두면 전부 "수동"이 된다. `auto`([자동 제안] · 369) = "all" 자리 새로 · "add" 빈 구간 보충 —
|
||||
같은 자리(0.05 m) 관만 속성 · id 를 이어받고 나머지는 새 관(등록부 기본값).
|
||||
두면 전부 "수동"이 된다. 원본에 없던 자리의 새 관은 등록부 기본값을 받는다.
|
||||
"""
|
||||
directory = drainage_dir(stored_path)
|
||||
chainages = [point.chainage_m for point in points] if points is not None else None
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto)
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat)
|
||||
if detail is None:
|
||||
return None, []
|
||||
if points is not None:
|
||||
_retag(detail.pipes, points, keep_new=auto is not None)
|
||||
_retag(detail.pipes, points)
|
||||
rebuilt = _rebuilt(detail)
|
||||
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
||||
fresh = [
|
||||
@@ -141,14 +138,11 @@ _DETAIL_CACHE_SIZE = 4
|
||||
DETAIL_CACHE_FILENAME = "04_detail_cache.pkl"
|
||||
|
||||
|
||||
def _detail_key(
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
|
||||
) -> str:
|
||||
def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str:
|
||||
directory = drainage_dir(context.stored_path)
|
||||
digest = hashlib.sha1(usedforsecurity=False)
|
||||
listed = None if points is None else [point.as_dict() for point in points]
|
||||
# auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감)
|
||||
digest.update(json.dumps([listed, auto], sort_keys=True, ensure_ascii=False).encode())
|
||||
digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode())
|
||||
for vertex in context.vertices:
|
||||
digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode())
|
||||
routing = STAGES["road_routing"]
|
||||
@@ -179,10 +173,10 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
|
||||
|
||||
|
||||
def _build_cached(
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
|
||||
context: DrainageContext, points: list[PipePoint] | None
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로)."""
|
||||
key = _detail_key(context, points, auto)
|
||||
key = _detail_key(context, points)
|
||||
hit = _DETAIL_CACHE.get(key)
|
||||
if hit is None:
|
||||
try:
|
||||
@@ -192,7 +186,7 @@ def _build_cached(
|
||||
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
|
||||
hit = None
|
||||
if hit is None:
|
||||
detail, carried = _build(context.stored_path, context, points, auto=auto)
|
||||
detail, carried = _build(context.stored_path, context, points)
|
||||
if detail is None:
|
||||
return None, []
|
||||
hit = (detail, carried)
|
||||
@@ -288,20 +282,8 @@ def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None:
|
||||
return math.ceil(depth * 100 - 1e-9) / 100
|
||||
|
||||
|
||||
def _retag(
|
||||
pipes: list[StructureCandidate], points: list[PipePoint], keep_new: bool = False
|
||||
) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.
|
||||
|
||||
`keep_new`([자동 제안]) = 같은 자리(0.05 m) 관만 · 새로 놓인 관은 계산기 사유
|
||||
(stream · spacing) 그대로.
|
||||
"""
|
||||
if keep_new:
|
||||
picks = carry_indices([pipe.chainage_m for pipe in pipes], [p.chainage_m for p in points])
|
||||
for pipe, pick in zip(pipes, picks):
|
||||
if pick is not None:
|
||||
pipe.reason = points[pick].source
|
||||
return
|
||||
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
||||
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
|
||||
for pipe in pipes:
|
||||
source = by_chainage.get(round(pipe.chainage_m, 2))
|
||||
@@ -480,16 +462,11 @@ async def _resolve(
|
||||
돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음.
|
||||
|
||||
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
|
||||
요청 `auto`([자동 제안] · 369) = "all" · "add" — `_build` 참고 · 없으면 None.
|
||||
"""
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
|
||||
auto = (payload or {}).get("auto")
|
||||
if auto not in AUTO_MODES:
|
||||
message = f"auto 는 {', '.join(m for m in AUTO_MODES if m)} 중 하나입니다."
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": message})
|
||||
try:
|
||||
raw = (payload or {}).get("points")
|
||||
requested = None if raw is None else parse_pipe_points(raw)
|
||||
@@ -503,7 +480,7 @@ async def _resolve(
|
||||
saved = requested is None and stored is not None
|
||||
|
||||
if analysis_route_signature(context.stored_path) != signature:
|
||||
if points is None or auto is not None: # 자동 자리 = 지금 노선 분석이 있어야(369)
|
||||
if points is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "stale", "message": STALE_MESSAGE}
|
||||
)
|
||||
@@ -512,11 +489,9 @@ async def _resolve(
|
||||
|
||||
# [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만
|
||||
if fresh:
|
||||
result = await asyncio.to_thread(
|
||||
lambda: _build(context.stored_path, context, points, auto=auto)
|
||||
)
|
||||
result = await asyncio.to_thread(lambda: _build(context.stored_path, context, points))
|
||||
else:
|
||||
result = await asyncio.to_thread(_build_cached, context, points, auto)
|
||||
result = await asyncio.to_thread(_build_cached, context, points)
|
||||
detail, resolved = result
|
||||
if detail is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
||||
@@ -548,7 +523,6 @@ async def post_detail_basins(
|
||||
|
||||
`points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 —
|
||||
[배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
||||
`auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충.
|
||||
"""
|
||||
resolved = await _resolve(project_id, payload, use_stored=False)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
@@ -645,3 +619,47 @@ async def put_pipe_points(
|
||||
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
|
||||
result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/pipe-proposals", response_model=None)
|
||||
async def post_pipe_proposals(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""[자동 제안](510) — 지금 노선 × 세류선 교차 + 간격 보충 후보와 상태 · 아무것도 안 씀.
|
||||
|
||||
`points` = 화면 관 목록(없으면 저장분) · 화면에서 지운 자동 관도 지움 표지로 봄(`keep_labels`).
|
||||
간격 보충은 분석이 지금 노선일 때만(`spacing` 거짓 = 교차만).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
path = pipe_points_path(context.stored_path)
|
||||
stored = read_pipe_points_file(path)
|
||||
try:
|
||||
raw = (payload or {}).get("points")
|
||||
points = (
|
||||
load_pipe_points(
|
||||
context.stored_path, route_signature(context.vertices), context.vertices
|
||||
)
|
||||
or []
|
||||
if raw is None
|
||||
else parse_pipe_points(raw)
|
||||
)
|
||||
except ValueError as error:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
dismissed = read_dismissed(path)
|
||||
keep_labels(points, stored, dismissed)
|
||||
prepared = await _prepare(project_id)
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"]
|
||||
fresh = analysis_route_signature(context.stored_path) == route_signature(context.vertices)
|
||||
routing = read_road_routing(drainage_dir(context.stored_path)) if fresh else None
|
||||
curve = routing.strength_curve if routing is not None else None
|
||||
proposals = await asyncio.to_thread(
|
||||
lambda: propose_pipes(
|
||||
context.vertices, _base_pipes(context.vertices, streams), points, dismissed, curve
|
||||
)
|
||||
)
|
||||
return {"status": "success", "proposals": proposals, "spacing": curve is not None}
|
||||
|
||||
@@ -22,7 +22,7 @@ from fastapi.responses import JSONResponse
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStopped
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import AUTO_MODES, _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router
|
||||
from common_util.common_util_drainage_context import _open_sampler, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import RoadRouting
|
||||
@@ -79,7 +79,7 @@ async def post_drainage_live(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""몸통 = {generation, route: RouteDraft | null(저장 노선),
|
||||
points: 관 목록 | null(세류선 교차만), auto: [자동 제안] 방식(all · add · 369) | 없음}."""
|
||||
points: 관 목록 | null(세류선 교차만)}."""
|
||||
body = payload or {}
|
||||
generation = int(body.get("generation") or 0)
|
||||
key = str(project_id)
|
||||
@@ -88,9 +88,6 @@ async def post_drainage_live(
|
||||
def superseded() -> bool:
|
||||
return _LATEST_GENERATION.get(key) != generation
|
||||
|
||||
auto = body.get("auto")
|
||||
if auto not in AUTO_MODES:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": "auto 틀림"})
|
||||
try:
|
||||
raw = body.get("points")
|
||||
points = None if raw is None else parse_pipe_points(raw)
|
||||
@@ -143,7 +140,7 @@ async def post_drainage_live(
|
||||
[list(to_lonlat(x, y)) for x, y in line.coords] for line in preview.region.split.upstream
|
||||
]
|
||||
detail, carried = await asyncio.to_thread(
|
||||
lambda: _build(context.stored_path, live, points, routing, upstream, auto=auto)
|
||||
lambda: _build(context.stored_path, live, points, routing, upstream)
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* B05_Profile_Api_HaulPlan.ts
|
||||
* 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리.
|
||||
*
|
||||
* 왜 서버인가(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
|
||||
* 남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), 그 결과를
|
||||
* 여기로 보내 배분을 받아 쥔다. 계산은 여전히 한 벌이다 — 서버가 같은 TS 를 Node 로 돈다.
|
||||
* 기본은 서버(계산 자리 판 haul = server) — 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`),
|
||||
* 그 결과를 여기로 보내 배분을 받아 쥔다. 판 haul = both(요구 321 · 511-C)면 배분 모듈을 **그때만**
|
||||
* dynamic import 해 브라우저가 셈(기본값이면 안 받음). 계산은 한 벌 — 서버도 같은 TS 를 Node 로 돈다.
|
||||
*
|
||||
* **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는
|
||||
* 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택).
|
||||
@@ -12,11 +12,30 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import type { MassHaulResult } from "@util/common_util_mass_haul";
|
||||
import type { EarthworkConversion, HaulEquipmentLimit } from "@util/common_util_mass_haul_types";
|
||||
import { calcInBrowser } from "../A00_Common/design/design_calc_sites";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
* `import type` 이라 배분 모듈이 첫 묶음에 안 실림(판 both 때만 따로 받음). */
|
||||
export type HaulPlanPayload = HaulPlan | null;
|
||||
|
||||
/** 브라우저 셈 문맥 — 서버 `_mass_haul_context` 와 같은 값(sections/context 가 같은 함수로 냄). */
|
||||
export interface HaulPlanContext {
|
||||
conversion: EarthworkConversion;
|
||||
haulLimits?: HaulEquipmentLimit[];
|
||||
}
|
||||
|
||||
/** 판 haul = both — 서버 /haul-plan(`_run_view`)과 같은 인자로 같은 함수.
|
||||
* ponytail: 채집석 · 구조물 잔토는 서버 `haul_inputs_for` 가 늘 빈 값이라 null — 값 내기 시작하면 sections/context 로 실어 옴. */
|
||||
async function browserHaulPlan(result: unknown, ctx: HaulPlanContext): Promise<HaulPlanPayload> {
|
||||
const { computeHaulPlan } = await import("@util/common_util_mass_haul_balance");
|
||||
return computeHaulPlan(result as MassHaulResult, ctx.haulLimits, {
|
||||
conversion: ctx.conversion,
|
||||
drawing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */
|
||||
const SETTLE_MS = 400;
|
||||
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
|
||||
@@ -85,8 +104,8 @@ async function postHaulPlan(
|
||||
}
|
||||
|
||||
export interface HaulPlanPrefetch {
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||
schedule: (result: unknown) => void;
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다(판 both = 브라우저 셈). */
|
||||
schedule: (result: unknown, ctx?: HaulPlanContext | null) => void;
|
||||
/** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */
|
||||
current: () => HaulPlanPayload;
|
||||
/** 화면을 떠날 때 예약을 지운다. */
|
||||
@@ -106,6 +125,7 @@ export function createHaulPlanPrefetch(
|
||||
let sequence = 0;
|
||||
let plan: HaulPlanPayload = null;
|
||||
let pending: unknown = null;
|
||||
let pendingCtx: HaulPlanContext | null = null;
|
||||
/** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */
|
||||
let sentKey: string | null = null;
|
||||
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
||||
@@ -116,10 +136,18 @@ export function createHaulPlanPrefetch(
|
||||
if (seq === sequence) sentKey = null;
|
||||
}
|
||||
|
||||
async function send(result: unknown, seq: number, key: string): Promise<void> {
|
||||
async function send(
|
||||
result: unknown,
|
||||
ctx: HaulPlanContext | null,
|
||||
seq: number,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
const route = routeId();
|
||||
if (!route) return forget(seq);
|
||||
const got = await postHaulPlan(projectId, route, result, key);
|
||||
const got =
|
||||
ctx && (await calcInBrowser("haul"))
|
||||
? await browserHaulPlan(result, ctx).catch(() => undefined)
|
||||
: await postHaulPlan(projectId, route, result, key);
|
||||
if (got === undefined) return forget(seq);
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
@@ -136,12 +164,13 @@ export function createHaulPlanPrefetch(
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(result) {
|
||||
schedule(result, ctx) {
|
||||
if (applying) return; // 응답을 반영하려고 다시 그리는 중이다.
|
||||
const key = keyOf(result);
|
||||
if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다.
|
||||
sentKey = key;
|
||||
pending = result;
|
||||
pendingCtx = ctx ?? null;
|
||||
sequence += 1;
|
||||
const seq = sequence;
|
||||
window.clearTimeout(timer);
|
||||
@@ -149,7 +178,7 @@ export function createHaulPlanPrefetch(
|
||||
const route = routeId();
|
||||
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
|
||||
if (known !== undefined) return void (plan = known);
|
||||
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
|
||||
timer = window.setTimeout(() => void send(pending, pendingCtx, seq, key), SETTLE_MS);
|
||||
},
|
||||
current: () => plan,
|
||||
dispose() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가
|
||||
나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로
|
||||
반환한다.
|
||||
남는 까닭(511-B B-6) — 기준표 · 설계 속도 = 서버 종단 입력(페이지 설정 · 재생성 옵션) · 추천 엔진 ·
|
||||
4순위 폴백(`design_grade_line`).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -18,6 +18,8 @@ scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구
|
||||
(거울 시험 `test_426_profile_straight.py`).
|
||||
경고 글 짝: `build_alignment` warnings ↔ TS `buildAlignment` warnings — 같은 목록
|
||||
(거울 시험 `test_472_save_warnings.py`).
|
||||
남는 까닭(511-B B-6) — 계획선 짓기 정본 = Node `profile.alignment` · 이 짝 = 2~4 순위 폴백 · 추천
|
||||
엔진(`pass_through`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -187,11 +189,6 @@ class AlignmentPolicy:
|
||||
}
|
||||
|
||||
|
||||
def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
"""종단곡선을 빼고 변화점 직선만으로 본 표고(탄젠트 표고)."""
|
||||
return np.interp(targets, pvi_s, pvi_z)
|
||||
|
||||
|
||||
#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이.
|
||||
#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝.
|
||||
SAME_STATION_SNAP_M = 0.006
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
「노선 그대로?」 · 범위.
|
||||
- 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
|
||||
(측점 스냅이 뒤 교점을 밀어도 이어받음).
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(Node `carry_seat`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
|
||||
R 은 가까운 측점.
|
||||
R 은 가까운 측점. 편집 셈 = TS `carryEdits`(서버 Node · 511-B B-3) — 파이썬 짝 없음.
|
||||
|
||||
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
|
||||
`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`.
|
||||
@@ -25,14 +25,10 @@ from typing import Any
|
||||
|
||||
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
|
||||
@@ -146,67 +142,6 @@ def carry_base(
|
||||
return out
|
||||
|
||||
|
||||
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_policy(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대.
|
||||
|
||||
짝: TS `logic/B05_Profile_Profile_CurveLaw.ts` — 거울 시험 `test_432_1_curve_law.py`.
|
||||
남는 까닭(511-B B-6) — 종단곡선 법칙 정본 = Node `profile.alignment` 안 · 이 짝 = 추천 엔진 첫
|
||||
계획선 · 2~4 순위 폴백.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""종단 계획선 셈 = 서버 Node(PLAN 511-B B-3) — 파이썬은 입력을 모으고 정본 꼴만 적음.
|
||||
|
||||
셈 = TS 한 벌(`design_calc_node_profile.ts` → `B05_Profile/logic/` 의
|
||||
`B05_Profile_UI_Profile_Alignment.ts` `buildAlignment` · `B05_Profile_Grade_Ground.ts`
|
||||
`groundFollowingNodes` · `B05_Profile_Grade_Carry.ts` `carryEdits` · `B05_Profile_Pipeline_Grade.ts`
|
||||
`seatRouteMoved`) · 늘 떠 있는 Node(`run_calc_served`).
|
||||
Node 출력은 반올림 전 값 — 정본 꼴(0.000001 m · 칸 차례 · 실수형)은 `alignment_record` 가 적음
|
||||
(옛 파이썬 `build_alignment` 출력과 같은 글). 파이썬 `build_alignment` 은 1순위 못 선 땅의
|
||||
2~4 순위 폴백(관 정착 · 직선 분할)만 씀. 시험 = `resources/tester/test_511_b3_profile_node.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ALIGNMENT_SCHEMA_VERSION
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
|
||||
def run_profile(ids: list[str], inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""종단 id 를 한 요청으로 · Node 못 돌림 · 셈 실패는 RuntimeError(파이썬 짝 없음)."""
|
||||
answer = run_calc_served(ids, inputs)
|
||||
if not answer or "results" not in answer:
|
||||
reason = (answer or {}).get("error") or "Node 응답 없음"
|
||||
raise RuntimeError(f"종단 계산 Node 실패({', '.join(ids)}) — {reason}")
|
||||
return answer["results"]
|
||||
|
||||
|
||||
def _r6(value: Any) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def alignment_record(
|
||||
raw: dict[str, Any], policy: dict[str, Any], *, only_explicit_curves: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Node `buildAlignment` 출력 → 저장 정본 꼴(옛 `build_alignment` 반환 · 칸 · 차례 · 반올림)."""
|
||||
segments = [
|
||||
{
|
||||
"index": int(row["index"]),
|
||||
"from_m": _r6(row["from_m"]),
|
||||
"to_m": _r6(row["to_m"]),
|
||||
"length_m": _r6(row["length_m"]),
|
||||
"height_m": _r6(row["height_m"]),
|
||||
"grade_percent": _r6(row["grade_percent"]),
|
||||
}
|
||||
for row in raw["segments"]
|
||||
]
|
||||
pvi = []
|
||||
for index, row in enumerate(raw["pvi"]):
|
||||
pvi.append(
|
||||
{
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"elevation_m": _r6(row["elevation_m"]),
|
||||
"source": row["source"],
|
||||
"kind": row["kind"],
|
||||
"grade_in_pct": _r6(segments[index - 1]["grade_percent"]) if index else None,
|
||||
"grade_out_pct": (
|
||||
_r6(segments[index]["grade_percent"]) if index < len(segments) else None
|
||||
),
|
||||
"curve_l_m": None if row["curve_l_m"] is None else _r6(row["curve_l_m"]),
|
||||
"curve_r_m": None if row["curve_r_m"] is None else _r6(row["curve_r_m"]),
|
||||
}
|
||||
)
|
||||
max_grade = float(policy["max_grade_pct"])
|
||||
stations = []
|
||||
for row in raw["stations"]:
|
||||
difference = float(row["ground_elevation_m"]) - float(row["plan_elevation_m"])
|
||||
stations.append(
|
||||
{
|
||||
"station_id": row["station_id"],
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"distance_m": _r6(row["distance_m"]),
|
||||
"ground_elevation_m": _r6(row["ground_elevation_m"]),
|
||||
"plan_elevation_m": _r6(row["plan_elevation_m"]),
|
||||
"cut_m": _r6(max(difference, 0.0)),
|
||||
"fill_m": _r6(max(-difference, 0.0)),
|
||||
}
|
||||
)
|
||||
balance = raw["balance"]
|
||||
cut, fill = float(balance["cut_area_m2"]), float(balance["fill_area_m2"])
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"policy": policy,
|
||||
"only_explicit_curves": bool(only_explicit_curves),
|
||||
"base_pvi": [
|
||||
{"chainage_m": _r6(node["chainage_m"]), "elevation_m": _r6(node["elevation_m"])}
|
||||
for node in raw["base_pvi"]
|
||||
],
|
||||
"edits": {
|
||||
name: {str(key): float(value) for key, value in raw["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
},
|
||||
"pvi": pvi,
|
||||
"segments": segments,
|
||||
"curves": [
|
||||
{
|
||||
"pvi_index": int(curve["pvi_index"]),
|
||||
**{
|
||||
name: _r6(curve[name])
|
||||
for name in (
|
||||
"chainage_m",
|
||||
"bvc_m",
|
||||
"evc_m",
|
||||
"bvc_elevation_m",
|
||||
"evc_elevation_m",
|
||||
"l_m",
|
||||
"r_m",
|
||||
"k",
|
||||
"delta_pct",
|
||||
"middle_ordinate_m",
|
||||
)
|
||||
},
|
||||
"omitted": bool(curve["omitted"]),
|
||||
"skip_allowed": bool(curve["skip_allowed"]),
|
||||
"omit_reason": curve["omit_reason"],
|
||||
}
|
||||
for curve in raw["curves"]
|
||||
],
|
||||
"stations": stations,
|
||||
"samples": [
|
||||
{
|
||||
"chainage_m": _r6(sample["chainage_m"]),
|
||||
"elevation_m": _r6(sample["elevation_m"]),
|
||||
"ground_elevation_m": _r6(sample["ground_elevation_m"]),
|
||||
"difference_m": _r6(
|
||||
float(sample["elevation_m"]) - float(sample["ground_elevation_m"])
|
||||
),
|
||||
}
|
||||
for sample in raw["samples"]
|
||||
],
|
||||
"balance": {
|
||||
"cut_area_m2": _r6(cut),
|
||||
"fill_area_m2": _r6(fill),
|
||||
"net_area_m2": _r6(fill - cut),
|
||||
"imbalance_percent": _r6(balance["imbalance_percent"]),
|
||||
"tolerance_percent": policy["balance_tolerance_percent"],
|
||||
"within_tolerance": bool(balance["within_tolerance"]),
|
||||
},
|
||||
"violations": [
|
||||
{
|
||||
"segment_index": row["index"],
|
||||
"type": "grade_over",
|
||||
"value": row["grade_percent"],
|
||||
"limit": policy["max_grade_pct"],
|
||||
}
|
||||
for row in segments
|
||||
if abs(row["grade_percent"]) > max_grade + 1e-6
|
||||
],
|
||||
"warnings": list(raw["warnings"]),
|
||||
}
|
||||
|
||||
|
||||
def node_alignment(
|
||||
chainage: Any,
|
||||
ground: Any,
|
||||
stations: list[dict[str, Any]],
|
||||
policy: dict[str, Any],
|
||||
edits: dict[str, Any] | None,
|
||||
*,
|
||||
base_pvi: list[dict[str, Any]] | None = None,
|
||||
ground_following: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""계획선 정본 — 꼭짓점(`base_pvi`) 또는 첫 계획선(`ground_following`) 위에 편집을 얹음.
|
||||
|
||||
첫 계획선이 못 서는 땅이면 None(부르는 쪽 2~4 순위). 편집 None 값 · 글 값은 옛 파이썬처럼 거름.
|
||||
"""
|
||||
edits = edits or {}
|
||||
clean = {
|
||||
name: {
|
||||
str(key): float(value)
|
||||
for key, value in (edits.get(name) or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
profile: dict[str, Any] = {
|
||||
"chainage": [float(value) for value in chainage],
|
||||
"ground": [float(value) for value in ground],
|
||||
"policy": policy,
|
||||
"stations": [
|
||||
{"station_id": row.get("station_id"), "chainage_m": float(row["chainage_m"])}
|
||||
for row in stations
|
||||
],
|
||||
"edits": clean,
|
||||
}
|
||||
if base_pvi is not None:
|
||||
profile["base_pvi"] = [
|
||||
{"chainage_m": float(node["chainage_m"]), "elevation_m": float(node["elevation_m"])}
|
||||
for node in base_pvi
|
||||
]
|
||||
if ground_following is not None:
|
||||
profile["ground_following"] = ground_following
|
||||
raw = run_profile(["profile.alignment"], {"profile": profile})["profile.alignment"]
|
||||
return None if raw is None else alignment_record(raw, policy)
|
||||
|
||||
|
||||
def carry_seat(
|
||||
old_xy: list[Any],
|
||||
new_xy: list[Any],
|
||||
old_base: list[dict[str, Any]],
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
seat: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, dict[str, float]], list[dict[str, Any]] | None]:
|
||||
"""노선 바뀜 계획고 옮김 + 옮긴 시설 꼭짓점 세움 = Node 한 부름(`profile.carry`).
|
||||
|
||||
`seat` = { ground_s · ground_z · policy · base_pvi · born } · 없으면 꼭짓점 None.
|
||||
"""
|
||||
carry = {
|
||||
"old_xy": [[float(x), float(y)] for x, y in old_xy],
|
||||
"new_xy": [[float(x), float(y)] for x, y in new_xy],
|
||||
"old_base": old_base,
|
||||
"edits": edits or {},
|
||||
"stations": [float(value) for value in stations],
|
||||
"half_m": float(half_m),
|
||||
"seat": seat,
|
||||
}
|
||||
out = run_profile(["profile.carry"], {"carry_profile": carry})["profile.carry"]
|
||||
moved = {
|
||||
name: {str(key): float(value) for key, value in out["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
return moved, out["base_pvi"]
|
||||
@@ -24,6 +24,8 @@
|
||||
`B05_Profile/logic/B05_Profile_Grade_Ground.ts` `groundFollowingBasePvi` · 노선 바뀜 때 계획고 옮김은
|
||||
`B05_Profile_Engine_Grade_Carry.py` ↔ `logic/B05_Profile_Grade_Carry.ts` · 거울 시험 =
|
||||
`resources/tester/test_344_2_route_pairs.py`.
|
||||
남는 까닭(511-B B-6) — 1순위 첫 계획선 · 종단 다시 셈 = Node(`Engine_Grade_Node`) · 이 짝 = 2~4 순위
|
||||
폴백 · 추천 엔진 · 정본 꼴 입력.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
@@ -48,6 +50,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends, straighten_held
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import node_alignment
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
||||
grade_limits,
|
||||
integration_weights,
|
||||
@@ -275,18 +278,22 @@ def design_ground_following_profile(
|
||||
if note:
|
||||
warnings.append(note)
|
||||
|
||||
# 초기값 직선화(438) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
|
||||
base_z = straight_pass_through(base_s, base_z, policy)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
# 꼭짓점(초기값 직선화 438 · 원지반 지남) · 선형 = 서버 Node 한 부름(511-B B-3 · TS
|
||||
# `groundFollowingNodes` → `buildAlignment`) · 못 서는 땅이면 ValueError → 2~4 순위 폴백
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
stations,
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
ground_following={
|
||||
"stations": [float(value) for value in base_s],
|
||||
"start_offset_m": float(options.start_elevation_offset_m),
|
||||
"end_offset_m": float(options.end_elevation_offset_m),
|
||||
},
|
||||
)
|
||||
if alignment is None:
|
||||
raise ValueError("첫 계획선 꼭짓점을 세울 수 없는 종단입니다.")
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
return alignment, _profile_entry(
|
||||
@@ -549,8 +556,6 @@ def rebuild_alignment_profile(
|
||||
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
base = stored["base_pvi"]
|
||||
base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64)
|
||||
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
@@ -564,14 +569,14 @@ def rebuild_alignment_profile(
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
# 선형 = 서버 Node(511-B B-3 · TS `buildAlignment`) · 정본 꼴은 `alignment_record`
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
list(longitudinal.get("stations") or []),
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
base_pvi=base,
|
||||
)
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
|
||||
@@ -40,6 +40,13 @@ SOURCE_GLOBS = (
|
||||
"common_util/common_util_structure_station.py",
|
||||
"common_util/common_util_drainage_pipes.py",
|
||||
"config/config_system_design.py",
|
||||
# 측점 · 지반 = Node `stations.sections`(511-B B-2) — 묶음을 낳는 TS
|
||||
"B05_Profile/logic/B05_Profile_Sections_*.ts",
|
||||
"B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts",
|
||||
"common_util/common_util_structure_station.ts",
|
||||
"common_util/common_util_structure_catalog.ts",
|
||||
"common_util/common_util_surface_sampler.ts",
|
||||
"A00_Common/design/logic/design_calc_node_sections.ts",
|
||||
)
|
||||
# 직선 위 꼭짓점(438 직선화) 판정 — 앞뒤 기울기 차(비율)
|
||||
_STRAIGHT_EPS = 1e-5
|
||||
@@ -126,12 +133,19 @@ def initial_longitudinal(
|
||||
) -> dict[str, Any]:
|
||||
"""[적용] 체인이 남길 종단(측점 · 계획선) — 측점 생성 · 균형 얹기와 같은 조각."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import build_sections, resolve_extra_stations
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
build_sections,
|
||||
resolve_extra_stations,
|
||||
station_facilities,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import seat_base_pvi
|
||||
|
||||
polyline = [[x, y, 0.0] for x, y in line]
|
||||
pipes, structures, plus = facilities
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
planted = station_facilities(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, planted[0], structures=planted[1], plus=planted[2]
|
||||
)
|
||||
result, _summary = build_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
@@ -144,6 +158,7 @@ def initial_longitudinal(
|
||||
design_curves=curves,
|
||||
station_snap=True, # [적용] = 스냅 켬 · 측점 No.(`saveRecommendedRoute`)
|
||||
station_snap_step=None,
|
||||
facilities=planted,
|
||||
)
|
||||
longitudinal = result["longitudinal"]
|
||||
if target is not None and longitudinal.get("profile_alignment"):
|
||||
|
||||
@@ -34,6 +34,7 @@ from B05_Profile.B05_Profile_Engine_Sections_Names import ( # noqa: F401 —
|
||||
prune_stale_cross_files,
|
||||
valid_cross_names,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_sections
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
structure_type_map,
|
||||
@@ -257,6 +258,24 @@ def resolve_extra_stations(
|
||||
`xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459).
|
||||
`structures` · `plus` = 파일 대신 따라간 사본(476 추천 초기 종단 · 파일 안 씀).
|
||||
"""
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
*station_facilities(project_root, pipes, xy, structures=structures, plus=plus)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def station_facilities(
|
||||
project_root: Path,
|
||||
pipes: list[PipePoint],
|
||||
xy: Sequence[Sequence[float]] | None = None,
|
||||
*,
|
||||
structures: list[Any] | None = None,
|
||||
plus: list[float] | None = None,
|
||||
) -> tuple[list[PipePoint], list[Any], list[float]]:
|
||||
"""측점을 심는 관 · 구조물 · 추가 측점(노선 밖 뺌) — 파이썬 `plan_stations` ·
|
||||
Node 측점 생성이 같은 입력."""
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
from common_util.common_util_route_follow import route_outside
|
||||
|
||||
@@ -273,12 +292,7 @@ def resolve_extra_stations(
|
||||
for s in structures
|
||||
if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy)
|
||||
]
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
pipes, structures, plus_chainages(project_root) if plus is None else plus
|
||||
)
|
||||
)
|
||||
return pipes, structures, plus_chainages(project_root) if plus is None else list(plus)
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
@@ -408,21 +422,28 @@ def build_sections(
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
facilities: tuple[list[Any], list[Any], list[float]] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""측점 틀 · 지반 · 1차 계획선(438) · 포장 제안 — 파일 안 씀 · (결과, 계획선 요약).
|
||||
|
||||
종횡단 생성(`run_section_generation`)과 추천 후보 초기 종단(476 · `..._RouteInitial_Initial`)
|
||||
이 같은 셈 한 벌."""
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
replace(options or SectionGenerationOptions(), extra_stations=extras),
|
||||
source_snapshot=source_snapshot,
|
||||
crs=crs,
|
||||
design_curves=design_curves,
|
||||
station_snap=station_snap,
|
||||
station_snap_step=station_snap_step,
|
||||
)
|
||||
이 같은 셈 한 벌. 측점 · 지반 = 화면과 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`facilities` = `station_facilities`) · DTM 아닌 지표면 · Node 못 돌림 =
|
||||
파이썬 `generate_sections`."""
|
||||
generation = replace(options or SectionGenerationOptions(), extra_stations=extras)
|
||||
shared = {
|
||||
"crs": crs,
|
||||
"design_curves": design_curves,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
}
|
||||
result = (
|
||||
node_sections(
|
||||
polyline, sampler, facilities, generation, source_snapshot=source_snapshot, **shared
|
||||
)
|
||||
if facilities is not None
|
||||
else None
|
||||
) or generate_sections(polyline, sampler, generation, source_snapshot=source_snapshot, **shared)
|
||||
grade_summary = _append_design_profiles(
|
||||
result["longitudinal"],
|
||||
grade_options,
|
||||
@@ -475,10 +496,13 @@ def run_section_generation(
|
||||
# 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다.
|
||||
pipes = _load_pipe_points(project_root, polyline)
|
||||
try:
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline)
|
||||
facilities = station_facilities(project_root, pipes, polyline)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, facilities[0], structures=facilities[1], plus=facilities[2]
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)")
|
||||
extras = ()
|
||||
facilities, extras = ([], [], []), ()
|
||||
logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes))
|
||||
|
||||
result, grade_summary = build_sections(
|
||||
@@ -495,6 +519,7 @@ def run_section_generation(
|
||||
design_curves=_load_design_curves(project_root),
|
||||
station_snap=load_station_snap(project_root),
|
||||
station_snap_step=load_station_snap_step(project_root),
|
||||
facilities=facilities,
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts`
|
||||
(`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`.
|
||||
서버 측점 생성 길(`build_sections`)은 DTM 이면 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`B05_Profile_Engine_Sections_Node.py`) — 이 짝을 쓰는 곳 = DTM 아닌 지표면 · Node 못 돌림 ·
|
||||
구조물 측점만 뜨기(`sample_structure_sections`) · B06 곡선 측점 판정 · 추천 엔진(`_plan_radii` 등).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""서버 측점 생성 = 화면과 같은 TS(PLAN 511-B B-2 · 요구 320-4) — 늘 떠 있는 Node
|
||||
`stations.sections`.
|
||||
|
||||
`build_sections`(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단)가 부름 — 측점 자리 ·
|
||||
종류 · 열쇠 · 반경 · 확폭 · 지반 · 틀 · 상단측 =
|
||||
`B05_Profile/logic/B05_Profile_Sections_Generate.ts`
|
||||
(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source ·
|
||||
coordinate_reference · cad_exchange · options)만 붙임.
|
||||
|
||||
DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) ·
|
||||
Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김).
|
||||
측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SECTION_SCHEMA_VERSION,
|
||||
SectionGenerationOptions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plan_pipe(pipe: Any) -> dict[str, Any]:
|
||||
"""관 정본 → TS `PlanPipe`(누가거리 그대로 · 파이썬 `plan_stations` 가 읽는 칸)."""
|
||||
return {
|
||||
"id": getattr(pipe, "id", None),
|
||||
"chainage_m": float(pipe.chainage_m),
|
||||
"facility": getattr(pipe, "facility", None) or "pipe",
|
||||
"start_m": getattr(pipe, "start_m", None),
|
||||
"end_m": getattr(pipe, "end_m", None),
|
||||
"options": getattr(pipe, "options", None) or {},
|
||||
}
|
||||
|
||||
|
||||
def plan_structure(item: Any) -> dict[str, Any]:
|
||||
"""구조물 정본 → TS `PlanStructure`(JSON 꼴)."""
|
||||
if hasattr(item, "model_dump"):
|
||||
return item.model_dump(mode="json")
|
||||
return dict(item) if isinstance(item, dict) else dict(vars(item))
|
||||
|
||||
|
||||
def structure_table() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""구조물 종류(`/projects/structure-types` 꼴) · 정의(화면 `useCatalogDefinition` 꼴)."""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import _read_definition, _stamp
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
|
||||
|
||||
types = [item.model_dump(mode="json") for item in with_visible().types]
|
||||
return types, {"types": types, **_read_definition(*_stamp())}
|
||||
|
||||
|
||||
def stations_input(
|
||||
xy: list[list[float]],
|
||||
pipes: list[Any],
|
||||
structures: list[Any],
|
||||
plus: list[float],
|
||||
options: SectionGenerationOptions,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Node `stations.list` · `stations.sections` 입력(격자 빼고)."""
|
||||
types, definition = structure_table()
|
||||
return {
|
||||
"line": xy,
|
||||
"curves": design_curves,
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"types": types,
|
||||
"definition": definition,
|
||||
"plus": [float(at) for at in plus],
|
||||
"interval_m": options.station_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
}
|
||||
|
||||
|
||||
def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
"""측점 목록(`buildStations` · 열쇠 · 누가거리 · 종류) — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
answer = run_calc_served(["stations.list"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 목록 실패: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
return answer["results"]["stations.list"]
|
||||
|
||||
|
||||
def node_plan_stations(
|
||||
groups: list[tuple[list[Any], list[Any], list[float]]],
|
||||
) -> list[list[dict[str, Any]]] | None:
|
||||
"""(관 · 구조물 · 추가 측점) 묶음마다 측점 계획(`planStations` · 파이썬 `plan_stations` 짝) —
|
||||
한 Node 부름 · 못 돌림 None(부르는 쪽이 파이썬 짝)."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
types, definition = structure_table()
|
||||
plan = [
|
||||
{
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"plus": [float(at) for at in plus],
|
||||
}
|
||||
for pipes, structures, plus in groups
|
||||
]
|
||||
try:
|
||||
results = run_calc(
|
||||
["stations.plan"],
|
||||
{"stations": {"types": types, "definition": definition, "plan": plan}},
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 측점 계획 실패 — 파이썬 길")
|
||||
return None
|
||||
if results is None:
|
||||
logger.warning("Node 측점 계획 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
return results["stations.plan"]
|
||||
|
||||
|
||||
def node_sections(
|
||||
polyline: list[list[float]],
|
||||
sampler: Any,
|
||||
facilities: tuple[list[Any], list[Any], list[float]],
|
||||
options: SectionGenerationOptions,
|
||||
*,
|
||||
source_snapshot: dict[str, Any],
|
||||
crs: str | None,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""`generate_sections` 와 같은 꼴(+ 측점 `key`) — DTM 아님 · Node 못 돌림 = None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file
|
||||
|
||||
source = getattr(sampler, "source", None)
|
||||
if not source:
|
||||
return None
|
||||
options.validate()
|
||||
pipes, structures, plus = facilities
|
||||
stations = stations_input(
|
||||
[[float(p[0]), float(p[1])] for p in polyline],
|
||||
pipes,
|
||||
structures,
|
||||
plus,
|
||||
options,
|
||||
design_curves,
|
||||
station_snap,
|
||||
station_snap_step,
|
||||
)
|
||||
stations["grid"] = str(dtm_grid_file(source))
|
||||
answer = run_calc_served(["stations.sections"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 생성 실패 — 파이썬 길: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
out = answer["results"]["stations.sections"]
|
||||
return {
|
||||
"schema_version": SECTION_SCHEMA_VERSION,
|
||||
"status": "completed",
|
||||
"source": source_snapshot or {},
|
||||
"coordinate_reference": {
|
||||
"crs": crs,
|
||||
"world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"},
|
||||
"units": {"horizontal": "m", "vertical": "m", "angle": "degree"},
|
||||
},
|
||||
"cad_exchange": {
|
||||
"station_origin": "BP",
|
||||
"chainage_direction": "BP_to_EP",
|
||||
"cross_offset_sign": {"negative": "right", "positive": "left"},
|
||||
"cross_local_axes": {"x": "offset_m", "y": "elevation_m"},
|
||||
"recommended_drawing_datum_m": out["datum"],
|
||||
},
|
||||
"options": {
|
||||
"station_interval_m": options.station_interval_m,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"extra_stations": [list(entry) for entry in options.extra_stations],
|
||||
},
|
||||
"longitudinal": out["longitudinal"],
|
||||
"cross_sections": out["cross_sections"],
|
||||
"summary": out["summary"],
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/B05_Profile_Footprint_Server.ts
|
||||
* 계산 묶음 footprint 단계(D4) + 계산 자리 「서버」 길(PLAN 511-A) — 판 fixed_browser = server 면 잠잠 뒤 서버
|
||||
* (`footprint.plan` · 같은 `footprintStage` 전 구간)가 셈 · 끊긴 차례는 묶음이 버림 · 못 받으면 브라우저 셈.
|
||||
* 판 both = 지금 그대로(같은 틱 · 브라우저 셈).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
import { footprintStage } from "./logic/B05_Profile_Footprint_Stage";
|
||||
import type { FootprintLines } from "./logic/B05_Profile_PlanFootprint";
|
||||
|
||||
/** 단계가 읽는 몫만 — 측점(지반) · 측점 횡단 detail · 노선 선. */
|
||||
export function footprintInputs(ctx: StageContext<"footprint">): unknown {
|
||||
const byKey = ctx.out.cross?.byKey ?? {};
|
||||
const route = ctx.out.route;
|
||||
return {
|
||||
ground: { stations: ctx.out.ground?.stations ?? [] },
|
||||
cross: {
|
||||
byKey: Object.fromEntries(Object.entries(byKey).map(([k, v]) => [k, { detail: v.detail }])),
|
||||
},
|
||||
route: route ? { line: route.line, chainage: route.chainage, lengthM: route.lengthM } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function footprintStageSited(
|
||||
ctx: StageContext<"footprint">,
|
||||
): FootprintLines | Promise<FootprintLines> {
|
||||
if (!calcOnServerNow("fixed_browser"))
|
||||
return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]);
|
||||
const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]);
|
||||
return calcOnServerOnly<FootprintLines>("fixed_browser", "D4", asked, ctx.signal).then(
|
||||
(lines) => lines ?? footprintStage(ctx),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
곡선표 `curves` 는 **그린** R(접선 자리에 눌린 값)만 있어, 그것으로 다시 열면 눌린 R 이
|
||||
넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐 — 넣은 값을 따로 둠. 자리 = 저장 노드
|
||||
(앞 노드에서 0.5 m 안인 점은 `build_planned_polyline` 과 같이 거름).
|
||||
남는 까닭(511-B B-6) — 노선 저장 편집표 = Node `route.polyline` · 이 짝 = 단순화 갈래 · Node 못 돌림
|
||||
때.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""계획노선 폴리라인 파일 쓰기(`B05_Profile_Router_Replan` 에서 나눔 · PLAN 511-B B-5).
|
||||
|
||||
노드 · 곡선 켬끔 · R(편집 갈래) → 선 · 노드 · 곡선표(+ 편집표) = 늘 떠 있는 Node `route.polyline`
|
||||
(화면 `buildEditedPolyline` · `nodeEdits` 와 같은 TS) · 파이썬은 파일 꼴(csv 4자리 · 곡선 as_dict
|
||||
반올림)만. 단순화 갈래(예상노선 첫 폴리라인) · Node 못 돌림 = 파이썬 `build_planned_polyline` ·
|
||||
`node_edits`(짝 · 로그 남김).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
|
||||
from common_util.common_util_route_geometry import write_route_csv
|
||||
from common_util.common_util_route_polyline import (
|
||||
PlannedPolyline,
|
||||
RouteCurve,
|
||||
RouteNode,
|
||||
build_planned_polyline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def node_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
curve_flags: list[bool] | None,
|
||||
radii: list[float | None] | None,
|
||||
locks: list[str | None] | None,
|
||||
arcs: list[float | None] | None,
|
||||
) -> tuple[PlannedPolyline, list[dict[str, Any]]] | None:
|
||||
"""편집 갈래 폴리라인 + 편집표 = Node `route.polyline` — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
route = {
|
||||
"points": [[float(x), float(y)] for x, y in points],
|
||||
"curve_flags": curve_flags,
|
||||
"radii": radii,
|
||||
"locks": locks,
|
||||
"arcs": arcs,
|
||||
"min_radius_m": float(radius_m),
|
||||
}
|
||||
try:
|
||||
out = (run_calc(["route.polyline"], {"route_polyline": route}) or {}).get("route.polyline")
|
||||
except Exception: # noqa: BLE001 — 노선 저장은 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 노선 폴리라인 실패 — 파이썬 길")
|
||||
return None
|
||||
if not out:
|
||||
logger.warning("Node 노선 폴리라인 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
# JSON 정수(100) → float — 파이썬 길과 같은 글(`100.0`)로 적힘
|
||||
nodes = [
|
||||
RouteNode(
|
||||
x=float(x),
|
||||
y=float(y),
|
||||
inner_angle_deg=_num(info["inner_angle_deg"]),
|
||||
radius_m=_num(info["radius_m"]),
|
||||
tangent_m=_num(info["tangent_m"]),
|
||||
violations=list(info["violations"]),
|
||||
)
|
||||
for (x, y), info in zip(out["cleaned"], out["nodes"])
|
||||
]
|
||||
curves = [
|
||||
RouteCurve(
|
||||
apex=_xy(curve["apex"]),
|
||||
radius_m=float(curve["radius_m"]),
|
||||
tangent_m=float(curve["tangent_m"]),
|
||||
inner_angle_deg=float(curve["inner_angle_deg"]),
|
||||
start=_xy(curve["start"]),
|
||||
end=_xy(curve["end"]),
|
||||
node_first=int(curve["node_first"]),
|
||||
node_last=int(curve["node_last"]),
|
||||
violations=list(curve["violations"]),
|
||||
)
|
||||
for curve in out["curves"]
|
||||
]
|
||||
vertices = [_xy(vertex) for vertex in out["vertices"]]
|
||||
edits = [
|
||||
{**edit, "radius_m": _num(edit["radius_m"]), "arc_m": _num(edit["arc_m"])}
|
||||
for edit in out["edits"]
|
||||
]
|
||||
return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves), edits
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
def _xy(pair: list[float]) -> tuple[float, float]:
|
||||
return float(pair[0]), float(pair[1])
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
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`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
edited = curve_flags is not None or radii is not None
|
||||
made = (
|
||||
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
|
||||
if edited and not simplify
|
||||
else None
|
||||
)
|
||||
if made is not None:
|
||||
result, edits = made
|
||||
else:
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
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 edits is not None:
|
||||
table["edits"] = edits
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
@@ -51,8 +51,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
keyed_stations,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
station_seats,
|
||||
vertices_of,
|
||||
@@ -255,12 +255,9 @@ async def _carry_profile(
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""같은 자리(옆 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_Engine_Grade_Alignment import AlignmentPolicy
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy, changed_span
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
|
||||
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
|
||||
@@ -278,13 +275,22 @@ async def _carry_profile(
|
||||
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
|
||||
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
|
||||
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
|
||||
held = [float(key) for key in (edits.get("station_offsets") or {})]
|
||||
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
|
||||
if (
|
||||
span is not None
|
||||
): # 편집 옮김 + 옮긴 시설 측점 427 꼭짓점 다시 세움 = Node 한 부름(459 · 511-B)
|
||||
samples = alignment.get("samples") or []
|
||||
seat = {
|
||||
"ground_s": [float(row["chainage_m"]) for row in samples],
|
||||
"ground_z": [float(row["ground_elevation_m"]) for row in samples],
|
||||
"policy": AlignmentPolicy.from_dict(alignment["policy"] or {}).as_dict(),
|
||||
"base_pvi": base,
|
||||
"born": route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy),
|
||||
}
|
||||
edits, alignment["base_pvi"] = await asyncio.to_thread(
|
||||
carry_seat, old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half, seat
|
||||
)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
@@ -331,8 +337,11 @@ async def _carry_cross(
|
||||
|
||||
new_long = _read_json(project_root / relative)
|
||||
_, new_structures = load_structures(str(project_root))
|
||||
old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m)
|
||||
new_keys = station_keys(
|
||||
# 열쇠 = Node 측점 출력(`key` · 511-B B-2) · 없으면(옛 파일 · DTM 아닌 지표면) 파이썬 열쇠
|
||||
old_keys = keyed_stations(old_long.get("stations") or []) or station_keys(
|
||||
old_long.get("stations") or [], old_pipes, old_structures, interval_m
|
||||
)
|
||||
new_keys = keyed_stations(new_long.get("stations") or []) or station_keys(
|
||||
new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m
|
||||
)
|
||||
designs = {
|
||||
|
||||
@@ -10,7 +10,8 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
같은 세류선 라벨 관(옮겼어도) · 지움 표지가 짝으로 잡은 교차점엔 안 더함(507-2 ·
|
||||
`common_util_stream_crossing`).
|
||||
- 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만.
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
- 측점 열쇠 = Node 측점 생성이 측점에 적은 `key`(511-B B-2 · `keyed_stations`) · 없으면(옛 파일 ·
|
||||
DTM 아닌 지표면) `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` 일곱 + `ROUTE_CARRY_KEYS`(사면 쉬프트 ·
|
||||
@@ -119,15 +120,21 @@ def _facility_owners(
|
||||
) -> dict[float, tuple[str, str | None, str]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
|
||||
(`plan_stations` 이름 규칙)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
|
||||
# 측점 계획 = Node(`stations.plan` · 511-B B-5) · 못 돌림 = 파이썬 `plan_stations`
|
||||
groups = [([pipe], [], []) for pipe in pipes] + [([], [item], []) for item in structures]
|
||||
planned = node_plan_stations(groups) or [plan_stations(*group) for group in groups]
|
||||
marks_of = iter(planned)
|
||||
owners: dict[float, tuple[str, str | None, str]] = {}
|
||||
for pipe in pipes:
|
||||
for mark in plan_stations([pipe], []):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
|
||||
)
|
||||
for item in structures:
|
||||
for mark in plan_stations([], [item]):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
(
|
||||
@@ -176,6 +183,18 @@ def station_keys(
|
||||
return out
|
||||
|
||||
|
||||
def keyed_stations(stations: Sequence[dict[str, Any]]) -> dict[str, float] | None:
|
||||
"""측점이 지닌 열쇠(`key` · Node 측점 생성 출력 · 511-B B-2) → 누가거리 · 열쇠 없는 측점이 있으면
|
||||
None(옛 파일 · DTM 아닌 지표면 = `station_keys` 로 셈)."""
|
||||
rows = [row for row in stations if row.get("chainage_m") is not None]
|
||||
if not rows or not all(isinstance(row.get("key"), str) for row in rows):
|
||||
return None
|
||||
out: dict[str, float] = {}
|
||||
for row in rows:
|
||||
out.setdefault(row["key"], float(row["chainage_m"]))
|
||||
return out
|
||||
|
||||
|
||||
def station_seats(
|
||||
stations: Sequence[dict[str, Any]], keys: dict[str, float]
|
||||
) -> dict[str, tuple[float, float, tuple[float, float] | None] | None]:
|
||||
@@ -248,23 +267,3 @@ def route_moved_stations(
|
||||
for station in stations
|
||||
if station.get("structure") and match(float(station["chainage_m"])) is None
|
||||
)
|
||||
|
||||
|
||||
def seat_route_moved(
|
||||
alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459 · 요구 270) — 그 자리
|
||||
옛 꼭짓점(옮겨 붙은 지반 · 옛 높이)을 걷고 원지반 지남 · 이웃 1 mm 맞춤 · 균형 종단이면
|
||||
옮기기 전 계획선 높이(`_seat_born_nodes`). `alignment` = 새 지반 표본 · 정책 · 옮겨 붙인
|
||||
`base_pvi` · `held` = 높이 고친 측점. TS 짝 = `seatRouteMoved`."""
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _SAME_STATION_M, _seat_born_nodes
|
||||
|
||||
base = alignment.get("base_pvi") or []
|
||||
if not born:
|
||||
return base
|
||||
kept = [
|
||||
node
|
||||
for node in base
|
||||
if all(abs(float(node["chainage_m"]) - value) >= _SAME_STATION_M for value in born)
|
||||
]
|
||||
return _seat_born_nodes(alignment, kept, list(born), list(held))
|
||||
|
||||
@@ -110,11 +110,11 @@ async def sync_uphill_overrides_into_designs(
|
||||
"""
|
||||
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Design_Node import DesignJob, compute_cross_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 (
|
||||
@@ -164,20 +164,17 @@ async def sync_uphill_overrides_into_designs(
|
||||
longitudinal_json = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
||||
preloaded = (longitudinal_path, longitudinal_json, pavement_suggestions(longitudinal_json))
|
||||
ford_drops = ford_surface_drops(root)
|
||||
out: list[tuple[float, dict[str, Any]]] = []
|
||||
picked: list[tuple[float, dict[str, Any], bool, DesignJob]] = []
|
||||
for chainage, design, next_mode, side in jobs:
|
||||
samples, design_elevation, pavement_suggested, cross_record = read_cross_design_inputs(
|
||||
root, longitudinal_file_path, float(chainage), preloaded
|
||||
)
|
||||
next_design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type", "soil")),
|
||||
section_mode=next_mode,
|
||||
ditch_side=side,
|
||||
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 ⑤)
|
||||
@@ -189,13 +186,24 @@ async def sync_uphill_overrides_into_designs(
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||
)
|
||||
picked.append(
|
||||
(float(chainage), design, pavement_suggested, (samples, design_elevation, options))
|
||||
)
|
||||
# 측점 묶음 Node 한 번(TS 짝 · 511-B B-4) · 못 세운 측점 = 저장분 그대로(옛 ValueError 자리)
|
||||
results = compute_cross_designs([job for *_rest, job in picked], stored_standard)
|
||||
out: list[tuple[float, dict[str, Any]]] = []
|
||||
for (chainage, design, pavement_suggested, _job), next_design in zip(
|
||||
picked, results, strict=True
|
||||
):
|
||||
if next_design is None:
|
||||
continue
|
||||
next_design["status"] = design.get("status", "provisional")
|
||||
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
||||
# 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `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))
|
||||
out.append((chainage, next_design))
|
||||
return out
|
||||
|
||||
# 쓰기도 한 문장으로 — 행마다 내면 원격 DB 왕복이 측점 수만큼 난다(건당 24.5ms).
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -38,7 +37,12 @@ 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_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
_curves_path,
|
||||
_nodes_path,
|
||||
_read_curves,
|
||||
_write_planned_polyline,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
@@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
return grade_class, design_speed, terrain
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
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`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
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),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
||||
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
|
||||
|
||||
|
||||
@@ -199,6 +199,32 @@ def _design_grid(
|
||||
}
|
||||
|
||||
|
||||
async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path:
|
||||
"""그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭).
|
||||
|
||||
경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀).
|
||||
"""
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
model_path = (root / str(row[1])).resolve()
|
||||
if not model_path.is_relative_to(root.resolve()):
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
raise LookupError("DTM 격자 파일이 없습니다.")
|
||||
return npz_path
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/grid", response_model=None)
|
||||
async def read_surface_grid(
|
||||
request: Request, project_id: UUID, model_id: int, smooth: bool = False
|
||||
@@ -209,26 +235,9 @@ async def read_surface_grid(
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자가 없는 지표면 모델입니다."},
|
||||
)
|
||||
model_path = Path(resolve_stored_project_path(stored)) / str(row[1])
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자 파일이 없습니다."},
|
||||
)
|
||||
try:
|
||||
npz_path = await dtm_grid_npz(connection, project_id, model_id, smooth)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
grid_path = await asyncio.to_thread(dtm_grid_file, npz_path)
|
||||
return cached_file_response(request, grid_path, "application/octet-stream")
|
||||
|
||||
@@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest
|
||||
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
|
||||
# · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음
|
||||
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
|
||||
planted = [
|
||||
float(station["chainage_m"])
|
||||
for station in plan_stations(
|
||||
route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧)
|
||||
existing,
|
||||
plus_chainages(root),
|
||||
)
|
||||
]
|
||||
# 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝
|
||||
group = (route_pipe_points(Path(root)), existing, plus_chainages(root))
|
||||
marks = (node_plan_stations([group]) or [plan_stations(*group)])[0]
|
||||
planted = [float(station["chainage_m"]) for station in marks]
|
||||
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface DrainageChrome {
|
||||
analyzeButton: HTMLButtonElement;
|
||||
deleteButton: HTMLButtonElement;
|
||||
autoButton: HTMLButtonElement;
|
||||
/** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */
|
||||
/** [자동 제안](510) — 후보 목록 · 배선은 패널(`B05_Profile_UI_Drainage_Propose`). */
|
||||
proposeButton: HTMLButtonElement;
|
||||
viewport: HTMLElement;
|
||||
backgroundImage: HTMLImageElement;
|
||||
|
||||
@@ -94,11 +94,13 @@ export interface FacilityAttributes {
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
/** 자동 세류선 관 라벨(507-2) — [자동 제안]으로 넣은 관 · 응답 · 초안 왕복에 실어 나름(510). */
|
||||
stream_label?: DetailPipeInput["stream_label"];
|
||||
}
|
||||
|
||||
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
||||
function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
|
||||
if (hasLinks(attributes) || attributes.stream_label) return true; // 집계 항목 · 라벨만 있어도
|
||||
if (attributes.facility !== "pipe") return true;
|
||||
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
@@ -134,17 +136,7 @@ export function createFacilityStore() {
|
||||
|
||||
return {
|
||||
/** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */
|
||||
replaceFromResponse(
|
||||
points: ReadonlyArray<{
|
||||
chainage_m: number;
|
||||
facility?: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
summary_item?: SummaryItem | null;
|
||||
group_items?: GroupItems | null;
|
||||
}>,
|
||||
): void {
|
||||
replaceFromResponse(points: ReadonlyArray<Omit<DetailPipeInput, "source">>): void {
|
||||
entries = points
|
||||
.map((point) => {
|
||||
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
|
||||
@@ -156,6 +148,7 @@ export function createFacilityStore() {
|
||||
start_m: point.start_m,
|
||||
end_m: point.end_m,
|
||||
options: point.options,
|
||||
...(point.stream_label ? { stream_label: point.stream_label } : {}),
|
||||
...linkFields(point),
|
||||
} as FacilityAttributes,
|
||||
};
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤.
|
||||
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
||||
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
||||
* - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차
|
||||
* 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) ·
|
||||
* 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에
|
||||
* 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409).
|
||||
* - [배관 배치 초기화] = 세류선 교차 관만 · 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은
|
||||
* 답도 앞 걸음에 안 묻힘) · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길로
|
||||
* (저장 분석으로 셈 = 옛 노선 자리 · 409).
|
||||
* - [자동 제안](510 · 후보 목록 = `B05_Profile_UI_Drainage_Propose`) — 낡은 동안 끔 · 낡으면 목록 닫음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -56,6 +56,8 @@ export interface DrainageLiveDeps {
|
||||
rememberPipes: () => void;
|
||||
/** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */
|
||||
loadSaved: () => Promise<boolean>;
|
||||
/** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(510). */
|
||||
onStale?: () => void;
|
||||
}
|
||||
|
||||
export interface DrainageLive {
|
||||
@@ -100,8 +102,8 @@ const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLangu
|
||||
/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */
|
||||
const STALE_PREFIX = "배수유역 낡음";
|
||||
|
||||
/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
|
||||
type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" };
|
||||
/** [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
|
||||
type Proposal = { points: DetailPipeInput[] | null };
|
||||
|
||||
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
||||
@@ -129,7 +131,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
let drafted = false;
|
||||
/** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */
|
||||
let savedStale = false;
|
||||
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */
|
||||
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [배관 배치 초기화]는 지금 노선으로 셈. */
|
||||
let analysisStale = false;
|
||||
let generation = 0;
|
||||
/** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */
|
||||
@@ -205,13 +207,13 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) ·
|
||||
/** [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) ·
|
||||
* 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */
|
||||
function proposeDraft(proposal: Proposal): void {
|
||||
if (analysisStale || draftMakesStale(route)) return refresh(proposal);
|
||||
draft(async (projectId) => {
|
||||
try {
|
||||
return await computeDetailBasins(projectId, proposal.points, proposal.auto);
|
||||
return await computeDetailBasins(projectId, proposal.points);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error;
|
||||
analysisStale = true;
|
||||
@@ -225,6 +227,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
function invalidate(): void {
|
||||
cancel();
|
||||
stale = true;
|
||||
deps.onStale?.();
|
||||
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
@@ -272,36 +275,6 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */
|
||||
function openPropose(): void {
|
||||
if (stale) return;
|
||||
openModal({
|
||||
title: L("B05_Drainage_Propose_Title"),
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<["all" | "add", string, string]> = [
|
||||
["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")],
|
||||
["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")],
|
||||
];
|
||||
choices.forEach(([value, label, hint], order) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-propose";
|
||||
input.addEventListener("change", () => {
|
||||
close();
|
||||
proposeDraft({ points: deps.pipeRequest(), auto: value });
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${order === 0 ? "①" : "②"} ${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMode(next: DrainageMode): void {
|
||||
mode = next;
|
||||
writeStateRaw(MODE_KEY, next);
|
||||
@@ -313,7 +286,6 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
|
||||
update.addEventListener("click", () => refresh());
|
||||
gear.addEventListener("click", openSettings);
|
||||
propose?.addEventListener("click", openPropose);
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { resampleRoute } from "../B04_PreProcess/logic/B04_PreProcess_UI_RouteSa
|
||||
import { batched, createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./logic/B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
@@ -38,6 +38,7 @@ import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
|
||||
import { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
@@ -104,6 +105,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
canvas,
|
||||
@@ -243,7 +245,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
candidates: live.suggest.candidates(),
|
||||
candidates: propose.candidates(),
|
||||
});
|
||||
updateImageTransform();
|
||||
}
|
||||
@@ -401,11 +403,35 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
runner,
|
||||
rememberPipes: () => (rememberPipes(), callbacks.onPipesDrafted?.()), // + 사진첩 한 걸음(369-4)
|
||||
loadSaved: () => run(loadSaved, true), // 초안 버림 = 저장분 조용히(349-0i)
|
||||
onStale: () => propose.close(),
|
||||
});
|
||||
// [자동 제안](510) — 서버 후보 · 고른 것만 관 넣기 길(세류선 교차 = 라벨 실음)
|
||||
const propose = createDrainagePropose({
|
||||
projectId: () => projectId,
|
||||
pipeRequest,
|
||||
addPipes: (picks) =>
|
||||
picks.forEach((item) =>
|
||||
addPipe(
|
||||
item.chainage_m,
|
||||
item.stream_label ? { facility: "pipe", stream_label: item.stream_label } : undefined,
|
||||
),
|
||||
),
|
||||
redraw: scheduleDraw,
|
||||
});
|
||||
basinList.before(propose.root);
|
||||
proposeButton.addEventListener("click", () => void propose.open());
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369)
|
||||
|
||||
function addPipe(chainageM: number, attributes?: FacilityAttributes): void {
|
||||
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||
facilityStore.set(chainageM, attributes ?? null);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
}
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
|
||||
*
|
||||
* B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다
|
||||
@@ -596,13 +622,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
intervalSpans = spans;
|
||||
scheduleDraw();
|
||||
},
|
||||
addPipe(chainageM, attributes) {
|
||||
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||
facilityStore.set(chainageM, attributes ?? null);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
},
|
||||
addPipe,
|
||||
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
||||
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
|
||||
if (Math.abs(toChainageM - fromChainageM) > 0.005)
|
||||
@@ -610,7 +630,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
|
||||
const previous = facilityStore.get(fromChainageM);
|
||||
facilityStore.set(fromChainageM, null);
|
||||
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
||||
const label = previous?.stream_label ? { stream_label: previous.stream_label } : {}; // 510
|
||||
facilityStore.set(toChainageM, { ...mergeFacilityOptions(previous, attributes), ...label });
|
||||
if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);
|
||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
||||
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Propose.ts
|
||||
* 배수유역 칸 [자동 제안](510) — 서버가 후보를 셈(`post_pipe_proposals` · 세류선 교차 + 간격 보충 ·
|
||||
* 있는 관 · 지운 관 상태) · 화면은 목록 + 배수유역도 점선 원 · 고른 것만 관 넣기 길(`addPipe`)로.
|
||||
*
|
||||
* - 있는 관(라벨 짝 · 교차 5 m 안 관) = 목록에서 뺌(머리 글에 수만) · 지운 관 = 「지운 관」 표시 ·
|
||||
* 고르면 다시 넣음(서버가 지움 표지 걷음).
|
||||
* - 아무것도 미리 안 고름 · [넣기] = 고른 것 · 세류선 교차 = 라벨 실음(노선 바뀐 [저장] 따라감 · 507-2).
|
||||
* - 노선 · 관이 바뀌면(배수유역 낡음) 목록 닫음 — 옛 노선 후보가 새 노선에 안 들어가게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
export interface PipeProposal {
|
||||
chainage_m: number;
|
||||
kind: "stream" | "spacing";
|
||||
state: "new" | "held" | "dismissed";
|
||||
stream_label?: DetailPipeInput["stream_label"];
|
||||
stream_name?: string | null;
|
||||
}
|
||||
|
||||
interface ProposalResponse {
|
||||
proposals: PipeProposal[];
|
||||
/** 간격 보충까지 셈(분석이 지금 노선) — 거짓 = 세류선 교차만. */
|
||||
spacing: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 후보 한 줄 글. */
|
||||
export function proposalText(item: PipeProposal): string {
|
||||
const at = `측점 ${Math.round(item.chainage_m)} m`;
|
||||
const what =
|
||||
item.kind === "stream"
|
||||
? `세류선 교차${item.stream_name ? `(${item.stream_name})` : ""}`
|
||||
: "간격 보충(흐름이 모이는 자리)";
|
||||
return `${at} — ${what}${item.state === "dismissed" ? " · 지운 관" : ""}`;
|
||||
}
|
||||
|
||||
export interface DrainagePropose {
|
||||
root: HTMLElement;
|
||||
/** 서버에 후보를 물어 목록을 엶. */
|
||||
open(): Promise<void>;
|
||||
close(): void;
|
||||
/** 목록에 오른 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainagePropose(deps: {
|
||||
projectId: () => string | null;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
/** 고른 후보를 관 넣기 길로(패널 `addPipe` 와 같은 길). */
|
||||
addPipes: (picks: PipeProposal[]) => void;
|
||||
redraw: () => void;
|
||||
}): DrainagePropose {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-drainage__suggest b05-drainage__proposals";
|
||||
root.hidden = true;
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-drainage__suggest-head";
|
||||
const title = document.createElement("span");
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "b05-drainage__analyze";
|
||||
add.textContent = "넣기";
|
||||
add.title = "고른 후보만 관으로 넣습니다 — 초안이라 [저장] 때 남습니다.";
|
||||
const shut = document.createElement("button");
|
||||
shut.type = "button";
|
||||
shut.className = "b05-drainage__analyze";
|
||||
shut.textContent = "닫기";
|
||||
head.append(title, add, shut);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-drainage__suggest-list b05-drainage__proposals-list";
|
||||
root.append(head, list);
|
||||
let shown: PipeProposal[] = [];
|
||||
const picked = new Set<number>();
|
||||
let sequence = 0;
|
||||
|
||||
const close = (): void => {
|
||||
sequence += 1;
|
||||
root.hidden = true;
|
||||
shown = [];
|
||||
picked.clear();
|
||||
list.replaceChildren();
|
||||
deps.redraw();
|
||||
};
|
||||
|
||||
const render = (response: ProposalResponse): void => {
|
||||
shown = response.proposals.filter((item) => item.state !== "held");
|
||||
const held = response.proposals.length - shown.length;
|
||||
title.textContent =
|
||||
`자동 제안 ${shown.length}건` +
|
||||
(held ? ` · 있는 관 ${held} 뺌` : "") +
|
||||
(response.spacing ? "" : " · 배수유역 낡음 — 세류선 교차만");
|
||||
list.replaceChildren(
|
||||
...shown.map((item, index) => {
|
||||
const row = document.createElement("li");
|
||||
const label = document.createElement("label");
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) picked.add(index);
|
||||
else picked.delete(index);
|
||||
add.disabled = picked.size === 0;
|
||||
});
|
||||
label.append(box, ` ${proposalText(item)}`);
|
||||
row.append(label);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
add.disabled = true;
|
||||
root.hidden = false;
|
||||
deps.redraw();
|
||||
};
|
||||
|
||||
add.addEventListener("click", () => {
|
||||
const picks = shown.filter((_, index) => picked.has(index));
|
||||
close();
|
||||
if (picks.length) deps.addPipes(picks);
|
||||
});
|
||||
shut.addEventListener("click", close);
|
||||
|
||||
return {
|
||||
root,
|
||||
async open() {
|
||||
const projectId = deps.projectId();
|
||||
if (!projectId) return;
|
||||
const sent = ++sequence;
|
||||
title.textContent = "자동 제안 — 후보 셈 중";
|
||||
root.hidden = false;
|
||||
list.replaceChildren();
|
||||
add.disabled = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/drainage/pipe-proposals`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ points: deps.pipeRequest() }),
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as ProposalResponse;
|
||||
if (sent !== sequence) return;
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
render(payload);
|
||||
} catch (error) {
|
||||
if (sent !== sequence) return;
|
||||
title.textContent = `자동 제안 실패 — ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
},
|
||||
close,
|
||||
candidates: () => (root.hidden ? [] : shown.map((item) => item.chainage_m)),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Suggest.ts
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관경 · 시설만
|
||||
* 관 초안으로(관 자리 = [자동 제안] · 369).
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류를 보여 주고 [적용] 한 번에 관 초안으로.
|
||||
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
|
||||
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
|
||||
* 관 자리 후보 = [자동 제안] 목록(510 · `B05_Profile_UI_Drainage_Propose`) — 여기 안 올림(두 목록 겹침).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
@@ -27,8 +26,7 @@ const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
|
||||
/** 제안 한 줄 글. */
|
||||
export function suggestionText(item: Suggestion): string {
|
||||
const at = `측점 ${Math.round(item.chainageM)} m`;
|
||||
if (item.kind === "position")
|
||||
return `${at} — 관 자리 후보(흐름이 모이는 오목 자리 · [자동 제안])`;
|
||||
if (item.kind === "position") return at; // 모으지 않음(510 · [자동 제안] 목록)
|
||||
if (item.kind === "facility")
|
||||
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
|
||||
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
|
||||
@@ -39,8 +37,6 @@ export interface DrainageSuggest {
|
||||
update(response: DetailBasinResponse): void;
|
||||
/** 노선 · 관이 바뀌어 낡음 — [적용] 끔(옛 노선 기준 제안이 새 노선에 안 들어가게). */
|
||||
setStale(stale: boolean): void;
|
||||
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainageSuggest(
|
||||
@@ -66,9 +62,9 @@ export function createDrainageSuggest(
|
||||
root.append(head, list);
|
||||
let suggestions: Suggestion[] = [];
|
||||
let stale = false;
|
||||
/** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때(관 자리 후보만이면 할 일 없음). */
|
||||
/** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때. */
|
||||
const paintApply = (): void => {
|
||||
apply.disabled = stale || !suggestions.some((item) => item.kind !== "position");
|
||||
apply.disabled = stale || suggestions.length === 0;
|
||||
apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle;
|
||||
};
|
||||
|
||||
@@ -82,7 +78,7 @@ export function createDrainageSuggest(
|
||||
update(response) {
|
||||
suggestions = response.stale
|
||||
? []
|
||||
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
|
||||
: collectSuggestions(response.pipe_points, response.basins, []);
|
||||
root.hidden = suggestions.length === 0;
|
||||
title.textContent = `제안 ${suggestions.length}건`;
|
||||
paintApply();
|
||||
@@ -98,7 +94,5 @@ export function createDrainageSuggest(
|
||||
stale = next;
|
||||
paintApply();
|
||||
},
|
||||
candidates: () =>
|
||||
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationPartsLabel } from "../common_util/common_util_station";
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
@@ -26,6 +25,10 @@ import {
|
||||
} from "@config/config_frontend";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
export interface IrregularStation {
|
||||
id: string;
|
||||
/** 측점번호 X. */
|
||||
@@ -126,16 +129,6 @@ interface IrregularStationsCallbacks {
|
||||
onSelect: (station: IrregularStation | null) => void;
|
||||
}
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
// 법정 기준표 = logic(511-B B-3 · X5) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { PROFILE_CRITERIA, SPEED_CHOICES, gradeLimitOf } from "./logic/B05_Profile_Grade_Criteria";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
|
||||
@@ -32,44 +35,9 @@ export interface RoutePanelValues {
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
export const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: RoutePanelValues["designSpeed"],
|
||||
terrain: RoutePanelValues["terrainType"],
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
@@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
|
||||
export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { normalizedLongitudinal } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
|
||||
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station";
|
||||
import type { LongitudinalSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
// 최대 누가거리 · 그래프 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularGraphStations, maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
|
||||
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
|
||||
@@ -40,39 +37,6 @@ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL
|
||||
*/
|
||||
export const PROFILE_SPACING_MULTIPLIER = 1.5;
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
export function chainageMapper(
|
||||
data: LongitudinalSection,
|
||||
|
||||
@@ -52,6 +52,8 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import { calcChecked, calcFromServer } from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
@@ -307,7 +309,7 @@ export function createRouteMassHaulPanel(
|
||||
// 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06).
|
||||
// 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면
|
||||
// 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다).
|
||||
haulPrefetch?.schedule(picked.result);
|
||||
haulPrefetch?.schedule(picked.result, context);
|
||||
onSummary(badgeValuesFrom(picked.result));
|
||||
}
|
||||
|
||||
@@ -317,15 +319,30 @@ export function createRouteMassHaulPanel(
|
||||
function reportSummary(params: RouteMassHaulDrawParams): void {
|
||||
if (!onSummary) return;
|
||||
if (!context || params.pendingRecalc) return;
|
||||
emitSummary(
|
||||
computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
),
|
||||
const series = crossSeries(params, context).series;
|
||||
if (series) emitSummary(series);
|
||||
}
|
||||
|
||||
/** 횡단 기준 누가토량 — 계산 자리 quantity = server 면 서버 값(잠잠 뒤 · 그 사이 앞 값 · 511-A). */
|
||||
function crossSeries(
|
||||
params: RouteMassHaulDrawParams,
|
||||
ctx: RouteMassHaulContext,
|
||||
): { series: MassHaulSeries[] | undefined; fresh: boolean } {
|
||||
const args: Parameters<typeof computeMassHaulSeries> = [
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
ctx.conversion,
|
||||
ctx.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
];
|
||||
if (!calcOnServerNow("quantity")) {
|
||||
const series = calcChecked("quantity", "masshaul.series", computeMassHaulSeries, args);
|
||||
return { series, fresh: true };
|
||||
}
|
||||
const got = calcFromServer<MassHaulSeries[]>("quantity", "masshaul.series", args, () =>
|
||||
draw(params),
|
||||
);
|
||||
return { series: got.value, fresh: got.fresh };
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
@@ -371,13 +388,12 @@ export function createRouteMassHaulPanel(
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
const { series, fresh } = crossSeries(params, context);
|
||||
if (!fresh) {
|
||||
pendingChip.textContent = "서버 셈 중…";
|
||||
pendingChip.hidden = !drawnOnce;
|
||||
}
|
||||
if (!series) return note("서버가 유토곡선을 셈하는 중입니다.");
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
|
||||
@@ -40,6 +40,12 @@ import { createRangeBandView } from "../A00_Common/design/design_profile_range_b
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
@@ -141,7 +147,22 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
tools.handleRunPick(sub);
|
||||
return;
|
||||
}
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 값으로 얹음(511-A)
|
||||
const args: Parameters<typeof straightenBetween> = [
|
||||
base,
|
||||
ctx.edits(),
|
||||
fromM,
|
||||
toM,
|
||||
ctx.savedRadii(),
|
||||
];
|
||||
if (calcOnServerNow("fixed_browser")) {
|
||||
const asked = trialRequest("profile.tools", args);
|
||||
void calcOnServerOnly<ReturnType<typeof straightenBetween>>(
|
||||
"fixed_browser",
|
||||
"P7",
|
||||
asked,
|
||||
).then((edits) => edits && ctx.applyEdits(edits));
|
||||
} else ctx.applyEdits(calcChecked("fixed_browser", "profile.tools", straightenBetween, args));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
|
||||
@@ -23,11 +23,11 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
adjustStation,
|
||||
setCurveRadius,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { setCurveRadius } from "./logic/B05_Profile_UI_Profile_Alignment_Edit";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight";
|
||||
import {
|
||||
|
||||
@@ -74,6 +74,7 @@ import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -434,7 +435,13 @@ export async function mountRouteEditor(
|
||||
markEdited();
|
||||
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
|
||||
if (stop && before) return restoreSnapshot(before, stop, false);
|
||||
if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits());
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
if (stationSnap && fitting)
|
||||
planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [
|
||||
before,
|
||||
snapshotNow(),
|
||||
fitLimits(),
|
||||
]);
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
|
||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fillSlopeLengths, toeOffsets } from "../B06_Section/logic/B06_Section_UI_Cross_Fit";
|
||||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||||
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
|
||||
@@ -113,7 +114,10 @@ export function buildCrossSvg(
|
||||
const ground = preview.samples
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
const toes = preview.design
|
||||
? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)])
|
||||
: { left: null, right: null };
|
||||
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||||
const design = clipToToes(
|
||||
(preview.design?.design_line ?? []).map(
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RouteEditPick,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
import {
|
||||
curveShortfalls,
|
||||
floorNote,
|
||||
@@ -188,7 +189,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
const before = planned[node - 1];
|
||||
const after = planned[node + 1];
|
||||
if (!before || !after) return null;
|
||||
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
|
||||
// 계산 자리 fixed_browser = server 면 놓은 뒤(잠잠) 서버 대조만(끌기 도구 · 511-A)
|
||||
const apex: Vertex = [curve.apex[0], curve.apex[1]];
|
||||
return calcChecked("fixed_browser", "route.handle", curveDragTo, [
|
||||
before,
|
||||
apex,
|
||||
after,
|
||||
which,
|
||||
to,
|
||||
]);
|
||||
}
|
||||
|
||||
let dragNode = -1;
|
||||
|
||||
@@ -334,3 +334,18 @@
|
||||
padding-left: 1.2em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* [자동 제안] 후보 목록(510) — 고름 칸 · 목록 위 [넣기] · [닫기]. */
|
||||
.b05-drainage__proposals-list {
|
||||
max-height: 9em;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.b05-drainage__proposals .b05-drainage__suggest-head {
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-drainage__proposals .b05-drainage__suggest-head > span {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ export function carryBase(
|
||||
|
||||
/**
|
||||
* 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면
|
||||
* 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬
|
||||
* `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로.
|
||||
* 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(서버 =
|
||||
* Node `profile.carry` · 파이썬 짝 없음). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로.
|
||||
*/
|
||||
export function carryEdits(
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Criteria.ts
|
||||
* 종단 법정 기준표(설계속도 × 지형 → 종단기울기 상한 · 종단곡선 반경) · 종류별 설계속도 — 화면
|
||||
* `B05_Profile_UI_Panel_Fields.ts` 에서 옮김(511-B B-3 · X5 · 순수 자료). 파이썬 표 =
|
||||
* `config_system_design.FOREST_ROAD_PROFILE_CRITERIA`(추천 · 폴백 · 기본값 해석 몫) · 거울 시험 =
|
||||
* resources/tester/test_442_2_grade_limit_mirror.py.
|
||||
* ========================================================================== */
|
||||
|
||||
export type DesignSpeed = 20 | 30 | 40;
|
||||
export type TerrainType = "normal" | "special";
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
DesignSpeed,
|
||||
{ grade: Record<TerrainType, number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: DesignSpeed,
|
||||
terrain: TerrainType,
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<DesignSpeed>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
@@ -110,11 +110,15 @@ function fitThrough(
|
||||
return baseZ;
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
const { chainage, ground } = profile;
|
||||
/**
|
||||
* 첫 계획선 꼭짓점(반올림 전) — 종단 지반(`groundProfile` 꼴) 위 측점마다 꼭짓점 · 서버 Node(511-B B-3)는
|
||||
* 이 값으로 선형을 짓고 파이썬이 정본 꼴(0.000001 m)로 적음(파이썬 1순위와 같은 순서).
|
||||
*/
|
||||
export function groundFollowingNodes(
|
||||
chainage: number[],
|
||||
ground: number[],
|
||||
input: Omit<GroundFollowingInput, "samples">,
|
||||
): AlignmentNode[] | null {
|
||||
const total = chainage[chainage.length - 1];
|
||||
if (!(total > 0)) return null;
|
||||
|
||||
@@ -133,8 +137,17 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
|
||||
target[0] += input.startOffsetM ?? 0;
|
||||
target[target.length - 1] += input.endOffsetM ?? 0;
|
||||
const baseZ = passThrough(nodeS, target, input.policy);
|
||||
return nodeS.map((s, index) => ({
|
||||
chainage_m: roundTo(s, 6),
|
||||
elevation_m: roundTo(baseZ[index], 6),
|
||||
}));
|
||||
return nodeS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
const nodes = profile && groundFollowingNodes(profile.chainage, profile.ground, input);
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: roundTo(node.chainage_m, 6),
|
||||
elevation_m: roundTo(node.elevation_m, 6),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
|
||||
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
|
||||
* `route_moved_stations` · `seat_route_moved` · 거울 시험 test_459_route_seat_mirror.py).
|
||||
* `route_moved_stations` · 서버 세움 = Node `profile.carry` · 거울 시험 test_459_route_seat_mirror.py).
|
||||
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
|
||||
*
|
||||
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
} from "./B05_Profile_Grade_Carry";
|
||||
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
|
||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||
import {
|
||||
groundFollowingBasePvi,
|
||||
groundProfile,
|
||||
type GroundFollowingInput,
|
||||
} 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 }> =>
|
||||
@@ -180,6 +184,24 @@ const plusAt = new WeakMap<object, FollowingStructure[]>();
|
||||
/** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */
|
||||
const followedAt = new WeakMap<object, ReadonlyMap<string, number[]>>();
|
||||
|
||||
/** follow 출력 곁 자료(추가 측점 땅 자리 · 따라가기만 한 자리) — JSON 꼴(서버 follow · 511-B B-5). */
|
||||
export interface FollowSides {
|
||||
plus: FollowingStructure[] | null;
|
||||
followed: Array<[string, number[]]> | null;
|
||||
}
|
||||
|
||||
export function followSides(patch: object): FollowSides {
|
||||
const followed = followedAt.get(patch);
|
||||
return { plus: plusAt.get(patch) ?? null, followed: followed ? [...followed] : null };
|
||||
}
|
||||
|
||||
/** 서버가 낸 follow 출력에 곁 자료를 다시 붙임 — 다음 걸음 · planline 이 화면 셈과 같은 값을 봄. */
|
||||
export function withFollowSides<T extends object>(patch: T, sides: FollowSides): T {
|
||||
if (sides.plus) plusAt.set(patch, sides.plus);
|
||||
if (sides.followed) followedAt.set(patch, new Map(sides.followed));
|
||||
return patch;
|
||||
}
|
||||
|
||||
type Facility = FollowingStructure & { id?: string | null; structure_id?: string | null };
|
||||
|
||||
/** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의
|
||||
@@ -266,7 +288,7 @@ export function routeMovedStations(
|
||||
}
|
||||
|
||||
/** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 ·
|
||||
* 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 파이썬 `seat_route_moved` 짝. */
|
||||
* 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 서버 = Node `profile.carry` 의 seat(거울 기준 = 시험 헬퍼 `seat_route_moved`). */
|
||||
export function seatRouteMoved(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
|
||||
born: ReadonlyArray<number>,
|
||||
@@ -295,6 +317,12 @@ export interface PlanlineDeps {
|
||||
savedLine(): Vertex[] | null;
|
||||
/** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */
|
||||
storedPvi?(): AlignmentBase["basePvi"] | null;
|
||||
/** 바뀐 자리 새 지반 첫 계획선 꼭짓점 — 계산 자리 「서버」 면 서버 Node 약속(511-B B-3) · null = 브라우저
|
||||
* `groundFollowingBasePvi`(단계 동기 그대로). */
|
||||
groundPvi?(
|
||||
input: GroundFollowingInput,
|
||||
signal: AbortSignal,
|
||||
): Promise<AlignmentBase["basePvi"] | null> | null;
|
||||
}
|
||||
|
||||
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
@@ -311,13 +339,6 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
}));
|
||||
const profile = groundProfile(samples);
|
||||
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
|
||||
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438)
|
||||
const pviAt = (stations: number[]) =>
|
||||
groundFollowingBasePvi({
|
||||
samples,
|
||||
stations,
|
||||
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
|
||||
}) ?? [];
|
||||
const savedLine = deps.savedLine();
|
||||
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
||||
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
|
||||
@@ -344,33 +365,40 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
...ground.stations.filter((station) => !born.includes(station.chainageM)),
|
||||
...gone.map((chainageM) => ({ chainageM, kind: "structure" as const, id: null })),
|
||||
];
|
||||
const carried = span
|
||||
? carryBase(
|
||||
xy(savedLine!),
|
||||
xy(route.line),
|
||||
savedPvi,
|
||||
pviAt(homeStations.map((station) => station.chainageM)),
|
||||
)
|
||||
: savedPvi;
|
||||
const home = span
|
||||
? seatRouteMoved(
|
||||
baseOf(carried),
|
||||
routeMovedStations(homeStations, xy(savedLine!), xy(route.line)),
|
||||
edited.map(Number),
|
||||
)
|
||||
: carried;
|
||||
const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null;
|
||||
return {
|
||||
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
|
||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||
basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: ground.stations.map((station) => ({
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
})),
|
||||
const finish = (groundPvi: AlignmentBase["basePvi"]): AlignmentBase => {
|
||||
const carried = span
|
||||
? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi)
|
||||
: savedPvi;
|
||||
const home = span
|
||||
? seatRouteMoved(
|
||||
baseOf(carried),
|
||||
routeMovedStations(homeStations, xy(savedLine!), xy(route.line)),
|
||||
edited.map(Number),
|
||||
)
|
||||
: carried;
|
||||
const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null;
|
||||
return {
|
||||
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
|
||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||
basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: ground.stations.map((station) => ({
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
})),
|
||||
};
|
||||
};
|
||||
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) · 계산 자리
|
||||
// 「서버」 면 서버 Node(`groundPvi` · 511-B B-3)
|
||||
const input = {
|
||||
samples,
|
||||
stations: homeStations.map((station) => station.chainageM),
|
||||
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
|
||||
};
|
||||
const remote = span ? deps.groundPvi?.(input, ctx.signal) : null;
|
||||
if (remote) return remote.then((nodes) => finish(nodes ?? []));
|
||||
return finish(span ? (groundFollowingBasePvi(input) ?? []) : []);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,15 @@ export interface RouteStageDeps {
|
||||
minRadiusM(): number;
|
||||
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */
|
||||
savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null;
|
||||
/** 계산 자리 route 「서버」(511-B B-5) — 서버 Node `route.polyline`(같은 `buildEditedPolyline`) 값 ·
|
||||
* null = 화면 셈 · 끊김 · 실패 = undefined(화면 셈으로). */
|
||||
remote?(args: DraftArgs, signal: AbortSignal): Promise<BuiltLine | undefined> | null;
|
||||
}
|
||||
|
||||
/** `buildEditedPolyline` 인자(곡선 길이 고정을 R 로 푼 뒤) — 화면 셈 · 서버 셈이 같은 값을 받음. */
|
||||
export type DraftArgs = Parameters<typeof buildEditedPolyline>;
|
||||
type BuiltLine = Pick<ReturnType<typeof buildEditedPolyline>, "vertices" | "curves">;
|
||||
|
||||
/** 정점마다 누가거리 — 첫 점 0. */
|
||||
export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine {
|
||||
const chainage = [0];
|
||||
@@ -67,10 +74,19 @@ export function draftLine(
|
||||
draft: RouteDraft,
|
||||
minRadiusM: number,
|
||||
): { line: Vertex[]; curves: DesignCurve[] } {
|
||||
return shapedLine(buildEditedPolyline(...draftArgs(draft, minRadiusM)));
|
||||
}
|
||||
|
||||
/** 초안 → `buildEditedPolyline` 인자(길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음). */
|
||||
export function draftArgs(draft: RouteDraft, minRadiusM: number): DraftArgs {
|
||||
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
|
||||
const radius = [...draft.curveRadius];
|
||||
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
|
||||
const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM);
|
||||
return [nodes, draft.curveOn, radius, minRadiusM];
|
||||
}
|
||||
|
||||
/** 그린 선 → 계약 꼴 · 서버 저장 자릿수(화면 셈 · 서버 셈 한 벌). */
|
||||
function shapedLine(built: BuiltLine): { line: Vertex[]; curves: DesignCurve[] } {
|
||||
return {
|
||||
line: built.vertices.map(xy4),
|
||||
curves: built.curves.map(({ start, apex, end, radius_m }) => ({
|
||||
@@ -85,9 +101,15 @@ export function draftLine(
|
||||
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
|
||||
return (ctx) => {
|
||||
const draft = ctx.inputs.route;
|
||||
const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
|
||||
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(drawn.line, drawn.curves);
|
||||
const done = (drawn: { line: Vertex[]; curves: DesignCurve[] } | null) => {
|
||||
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(drawn.line, drawn.curves);
|
||||
};
|
||||
if (!draft) return done(deps.savedLine());
|
||||
const args = draftArgs(draft, deps.minRadiusM());
|
||||
const asked = deps.remote?.(args, ctx.signal);
|
||||
const local = () => shapedLine(buildEditedPolyline(...args));
|
||||
return asked ? asked.then((built) => done(built ? shapedLine(built) : local())) : done(local());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Profile_Planted.ts
|
||||
* 종단도에 심는 비정규 측점 → 그래프 측점 · 노선 최대 누가거리(511-B B-3) — 화면
|
||||
* `B05_Profile_UI_IrregularStations.ts` · `B05_Profile_UI_Profile_Layout.ts` 에서 옮김(순수 · 두 파일이
|
||||
* 다시 내보냄). 서버 Node 묶음 planline 저장 기준(`design_planted_base.ts`)이 화면 파일을 안 끌게.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../B05_Profile_UI_IrregularStations";
|
||||
import type { LongitudinalSection, SectionStation } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { stationPartsLabel } from "../../common_util/common_util_station";
|
||||
import { PLUS_OWNER_PREFIX } from "../../common_util/common_util_structure_station";
|
||||
import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Sections_Generate.ts
|
||||
* 측점 생성 한 벌(PLAN 511-B B-2) — 노선 + 관 · 구조물 · 추가 측점 + DTM 격자 → 서버 `generate_sections`
|
||||
* 꼴(종단 표본 · 측점 · 횡단 표본). 서버 측점 생성 길(`B05_Profile_Engine_Sections_Node.py` → Node
|
||||
* `stations.sections`)이 부름 — 화면 묶음과 같은 측점(`buildStations`) · 같은 지반(`sectionsGround`).
|
||||
*
|
||||
* 측점 = `buildStations`(열쇠 · 종류 · 반경 · 확폭) · 지반 · 틀 · 상단측 = `sectionsGround` · 이름 =
|
||||
* `plantedStations`(카드 이름) · 칸 차례 · 자릿수 = 파이썬 `generate_sections` 그대로 + 측점 열쇠 `key`.
|
||||
* 머리(schema_version · source · coordinate_reference · cad_exchange · options)는 파이썬이 붙임.
|
||||
* 순수 — 화면 · fetch · 세션 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DtmGrid } from "../../common_util/common_util_surface_sampler";
|
||||
import { stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import { formatStation } from "../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
buildStations,
|
||||
lineLength,
|
||||
plantedStations,
|
||||
SECTION_INCLUDE_ENDPOINT,
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
type StationsOptions,
|
||||
} from "./B05_Profile_Sections_Stations";
|
||||
import { sectionsGround, type GroundOptions } from "./B05_Profile_Sections_Ground";
|
||||
import type { PlanPipe, PlanStructure } from "../../common_util/common_util_structure_station";
|
||||
import type { DesignCurve, Vertex } from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
|
||||
export type GenerateOptions = Omit<StationsOptions, "registry"> & GroundOptions;
|
||||
|
||||
const KIND: Record<string, string> = {
|
||||
grid: "regular",
|
||||
curve: "curve",
|
||||
pipe: "irregular",
|
||||
structure: "irregular",
|
||||
};
|
||||
|
||||
/** 서버 `generate_sections` 꼴 몸통 — 종단(길이 · 표본 · 측점) · 횡단 · 요약 · 도면 기준고. */
|
||||
export function generateSections(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
grid: DtmGrid,
|
||||
options: GenerateOptions,
|
||||
) {
|
||||
const stations = buildStations(line, curves, pipes, structures, options);
|
||||
if (stations.length < 2) throw new Error("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다.");
|
||||
const gridM = stationsAlong(
|
||||
line,
|
||||
options.intervalM ?? SECTION_STATION_INTERVAL_M,
|
||||
options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT,
|
||||
);
|
||||
const planted = plantedStations(gridM, lineLength(line), pipes, structures, options);
|
||||
const chainages = stations.map((station) => station.chainageM);
|
||||
const ground = sectionsGround(line, chainages, grid, options);
|
||||
const crossSections = stations.map((station, index) => {
|
||||
const at = ground.stations[index];
|
||||
const mark = [...planted].find(([m]) => Math.abs(m - station.chainageM) < 1e-6)?.[1];
|
||||
const kind = index === 0 ? "bp" : station.kind === "end" ? "ep" : KIND[station.kind];
|
||||
const head: Record<string, unknown> = {
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: at.chainage_m,
|
||||
label: formatStation(station.chainageM) + (kind === "curve" ? " (MC)" : ""),
|
||||
kind,
|
||||
center_x: at.center_x,
|
||||
center_y: at.center_y,
|
||||
center_z: at.center_z,
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
curve_outer_side: station.curveOuterSide,
|
||||
curve_widening_m: station.curveWideningM,
|
||||
uphill_side: at.uphill_side,
|
||||
frame: {
|
||||
origin: { x: at.center_x, y: at.center_y, z: at.center_z },
|
||||
tangent_xy: at.tangent_xy,
|
||||
left_xy: at.left_xy,
|
||||
up_xyz: [0.0, 0.0, 1.0],
|
||||
},
|
||||
};
|
||||
if (mark) {
|
||||
head.structure = mark.title;
|
||||
if (gridM.some((m) => Math.abs(m - station.chainageM) < 1e-6)) head.grid = true;
|
||||
}
|
||||
head.key = station.key;
|
||||
return { head, samples: at.samples };
|
||||
});
|
||||
const z = ground.samples.flatMap((sample) => (sample.z === null ? [] : [sample.z]));
|
||||
return {
|
||||
longitudinal: {
|
||||
length_m: ground.length_m,
|
||||
samples: ground.samples,
|
||||
stations: crossSections.map(({ head }) => head),
|
||||
},
|
||||
cross_sections: crossSections.map(({ head, samples }) => ({ ...head, samples })),
|
||||
summary: {
|
||||
station_count: stations.length,
|
||||
cross_sample_count: crossSections.reduce((sum, { samples }) => sum + samples.length, 0),
|
||||
invalid_longitudinal_samples: ground.samples.filter((sample) => !sample.valid).length,
|
||||
invalid_cross_samples: crossSections.reduce(
|
||||
(sum, { samples }) => sum + samples.filter((sample) => !sample.valid).length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
datum: z.length ? Math.floor(Math.min(...z) / 10) * 10 : null,
|
||||
};
|
||||
}
|
||||
@@ -69,9 +69,9 @@ export interface GroundOptions {
|
||||
longSampleIntervalM?: number;
|
||||
}
|
||||
|
||||
/** 파이썬 `round(x, 6)` 자리. */
|
||||
export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
const round9 = (value: number): number => Math.round(value * 1e9) / 1e9;
|
||||
/** 파이썬 `round(x, 6)` 자리 — 십진 자릿수 반올림(곱셈 반올림 오차 없음 · 511-B B-2). */
|
||||
export const round6 = (value: number): number => Number(value.toFixed(6));
|
||||
const round9 = (value: number): number => Number(value.toFixed(9));
|
||||
|
||||
function sample(grid: DtmGrid, x: number, y: number): GroundSample {
|
||||
const z = heightAt(grid, x, y);
|
||||
|
||||
@@ -239,31 +239,31 @@ export interface StationsOptions {
|
||||
}
|
||||
|
||||
/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */
|
||||
const lineLength = (line: ReadonlyArray<Vertex>): number =>
|
||||
export const lineLength = (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,
|
||||
);
|
||||
|
||||
/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */
|
||||
export function buildStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
/** 심는 측점 한 자리 — 주인 종류 · id · 역할 · 카드 이름(서버 측점 `structure` 칸). */
|
||||
export interface PlantedMark {
|
||||
kind: "pipe" | "structure";
|
||||
id: string;
|
||||
role: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). */
|
||||
export function plantedStations(
|
||||
grid: ReadonlyArray<number>,
|
||||
total: number,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
options: StationsOptions,
|
||||
): PipelineStation[] {
|
||||
if (line.length < 2) return [];
|
||||
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
|
||||
const total = lineLength(line);
|
||||
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
|
||||
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
|
||||
const spans = designSpans(line, curves);
|
||||
|
||||
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
|
||||
options: Pick<StationsOptions, "types" | "registry" | "plus">,
|
||||
): Map<number, PlantedMark> {
|
||||
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
|
||||
const planted = new Map<number, { kind: "pipe" | "structure"; id: string; role: string }>();
|
||||
const planted = new Map<number, PlantedMark>();
|
||||
for (const station of planStations(
|
||||
pipes,
|
||||
structures,
|
||||
@@ -280,8 +280,27 @@ export function buildStations(
|
||||
kind: pipeIds.has(owner) ? "pipe" : "structure",
|
||||
id: owner,
|
||||
role: station.role,
|
||||
title: station.title,
|
||||
});
|
||||
}
|
||||
return planted;
|
||||
}
|
||||
|
||||
/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */
|
||||
export function buildStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
options: StationsOptions,
|
||||
): PipelineStation[] {
|
||||
if (line.length < 2) return [];
|
||||
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
|
||||
const total = lineLength(line);
|
||||
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
|
||||
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
|
||||
const spans = designSpans(line, curves);
|
||||
const planted = plantedStations(grid, total, pipes, structures, options);
|
||||
const withPlanted = [...new Set([...grid, ...planted.keys()])];
|
||||
// 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음
|
||||
const curved = curveStations(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Alignment.ts
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산.
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산 — 구간 쉬프트 · R 입력은
|
||||
* `B05_Profile_UI_Profile_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3).
|
||||
*
|
||||
* 백엔드 `B05_Profile_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가
|
||||
* 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며,
|
||||
@@ -159,7 +160,7 @@ export function emptyEdits(): AlignmentEdits {
|
||||
}
|
||||
|
||||
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
|
||||
function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
export function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
if (!xs.length) return 0;
|
||||
if (value <= xs[0]) return ys[0];
|
||||
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
@@ -391,6 +392,14 @@ function trapezoidWeights(chainage: number[]): number[] {
|
||||
}
|
||||
|
||||
/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */
|
||||
/** `np.round(value, 6)` — 백만 배 · 짝수 쪽 반올림(rint) · 나눔(파이썬 표본 격자와 같은 값). */
|
||||
function npRound6(value: number): number {
|
||||
const scaled = value * 1e6;
|
||||
let rounded = Math.round(scaled);
|
||||
if (rounded - scaled === 0.5 && rounded % 2 !== 0) rounded -= 1; // Math.round = .5 위로
|
||||
return rounded / 1e6;
|
||||
}
|
||||
|
||||
function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
|
||||
return {
|
||||
station_offsets: edits?.station_offsets ?? {},
|
||||
@@ -437,7 +446,8 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
// 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의
|
||||
// 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼
|
||||
// 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다.
|
||||
const sampleChainages = new Set<number>(base.chainage.map((value) => Number(value.toFixed(3))));
|
||||
// 격자 자리 = 0.000001 m(파이썬 `np.round(merged, 6)` 와 같은 값 — 서버 Node 정본 · 511-B B-3).
|
||||
const sampleChainages = new Set<number>(base.chainage.map(npRound6));
|
||||
const first = base.chainage[0];
|
||||
const last = base.chainage[base.chainage.length - 1];
|
||||
for (const extra of [
|
||||
@@ -445,7 +455,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]),
|
||||
]) {
|
||||
if (extra >= first - 1e-6 && extra <= last + 1e-6) {
|
||||
sampleChainages.add(Number(extra.toFixed(3)));
|
||||
sampleChainages.add(npRound6(extra));
|
||||
}
|
||||
}
|
||||
const samples: AlignmentSample[] = [...sampleChainages]
|
||||
@@ -614,86 +624,6 @@ export function controlElevationAt(alignment: ProfileAlignment, chainageM: numbe
|
||||
return node ? node.elevation_m : planElevationAt(alignment, chainageM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
export function hasEdits(edits: AlignmentEdits): boolean {
|
||||
const safe = normalizeEdits(edits);
|
||||
return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
||||
* 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
interpolate,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentCurve,
|
||||
type AlignmentEdits,
|
||||
type AlignmentSegment,
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const FEEDBACK_PASSES = 4;
|
||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
@@ -52,6 +52,13 @@ const RAD_PER_DEG = Math.PI / 180;
|
||||
/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */
|
||||
export const HAIRPIN_MIN_RADIUS_M = 10.0;
|
||||
|
||||
/** 짝: 파이썬 `f"{x:.1f}"` — 딱 반(x = 홀수/4 · 12.25)이면 짝수 쪽(`toFixed` 는 큰 쪽 · 위반 글이 갈림). */
|
||||
export function fixed1(x: number): string {
|
||||
if (!Number.isInteger(x * 4) || Number.isInteger(x * 2)) return x.toFixed(1);
|
||||
const floor = Math.floor(x * 10);
|
||||
return ((floor % 2 === 0 ? floor : floor + 1) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
|
||||
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
|
||||
const dx1 = a2[0] - a1[0];
|
||||
@@ -308,12 +315,10 @@ export function buildEditedPolyline(
|
||||
if (radius <= 0 || geometry === null) continue;
|
||||
|
||||
if (radius < minRadiusM) {
|
||||
node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`);
|
||||
node.violations.push(`최소곡선반지름 미달(${fixed1(radius)} < ${fixed1(minRadiusM)}m)`);
|
||||
}
|
||||
if (radius < hairpinMinRadiusM) {
|
||||
node.violations.push(
|
||||
`배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`,
|
||||
);
|
||||
node.violations.push(`배향곡선 하한 미달(${fixed1(radius)} < ${fixed1(hairpinMinRadiusM)}m)`);
|
||||
}
|
||||
node.radius_m = radius;
|
||||
node.tangent_m = tangent;
|
||||
|
||||
@@ -330,6 +330,34 @@ export interface RouteNodeEdit {
|
||||
arc_m: number | null;
|
||||
}
|
||||
|
||||
/** 넣은 편집값 → 곡선표 `edits`(430) — 겹친 점(앞 노드 0.5 m 안)은 빼고 저장 노드와 같은 자리 · 칸이 짧으면
|
||||
* 켬 · 자동. ⚠ 짝 `B05_Profile_Route_NodeEdits.node_edits` · 서버 노선 저장 = Node `route.polyline`(511-B B-5). */
|
||||
export function nodeEdits(
|
||||
points: ReadonlyArray<Vertex>,
|
||||
flags: ReadonlyArray<boolean | null> | null = null,
|
||||
radii: ReadonlyArray<number | null> | null = null,
|
||||
locks: ReadonlyArray<string | null> | null = null,
|
||||
arcs: ReadonlyArray<number | null> | null = null,
|
||||
): RouteNodeEdit[] {
|
||||
const at = <T>(values: ReadonlyArray<T> | null, index: number): T | null =>
|
||||
values && index < values.length ? values[index] : null;
|
||||
const kept: Vertex[] = [];
|
||||
const edits: RouteNodeEdit[] = [];
|
||||
points.forEach((point, index) => {
|
||||
const last = kept[kept.length - 1];
|
||||
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M) return;
|
||||
kept.push(point);
|
||||
const lock = at(locks, index);
|
||||
edits.push({
|
||||
curve: flags && index < flags.length ? Boolean(flags[index]) : true,
|
||||
radius_m: at(radii, index),
|
||||
lock: lock === "radius" || lock === "arc" ? lock : null,
|
||||
arc_m: at(arcs, index),
|
||||
});
|
||||
});
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
|
||||
*
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session";
|
||||
import type {
|
||||
CrossDesign,
|
||||
CrossSection,
|
||||
@@ -239,67 +238,6 @@ export async function getCompanyStandard(
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */
|
||||
export interface ProfileAlignmentEdits {
|
||||
station_offsets: Record<string, number>;
|
||||
curve_radii: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다.
|
||||
* 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB).
|
||||
*/
|
||||
export interface CrossDesignPreviewResponse {
|
||||
status: string;
|
||||
/** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */
|
||||
designs: Array<{ chainage_m: number; design: Partial<CrossDesign> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음).
|
||||
*
|
||||
* B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다.
|
||||
* 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의
|
||||
* 임시저장·확정이 맡는다.
|
||||
*/
|
||||
export async function previewCrossDesigns(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
edits: ProfileAlignmentEdits,
|
||||
standardCrossSection?: StandardCrossSection,
|
||||
options?: {
|
||||
/** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */
|
||||
fullDesigns?: boolean;
|
||||
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
|
||||
rockBoundaryOffsets?: Record<string, number>;
|
||||
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
|
||||
berms?: Record<
|
||||
string,
|
||||
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number }
|
||||
>;
|
||||
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
|
||||
cutSlopeRatios?: Record<string, number>;
|
||||
/** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */
|
||||
designChoices?: Record<string, CrossDesignChoice>;
|
||||
},
|
||||
): Promise<CrossDesignPreviewResponse> {
|
||||
return requestJson<CrossDesignPreviewResponse>(
|
||||
`/projects/${projectId}/sections/${routeId}/cross-design/preview`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...edits,
|
||||
standard_cross_section: standardCrossSection ?? null,
|
||||
full_designs: options?.fullDesigns ?? false,
|
||||
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
|
||||
berms: options?.berms ?? null,
|
||||
cut_slope_ratios: options?.cutSlopeRatios ?? null,
|
||||
design_choices: options?.designChoices ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
|
||||
export interface CrossQuantitySheet {
|
||||
quantity_table: Record<string, number | null>;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py`
|
||||
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
|
||||
* 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다.
|
||||
* 옛 데이터 서버 프리뷰(`/cross-design/preview`)는 걷음(511-B B-4) — 선형 저장분이 없으면 셈 없음.
|
||||
*
|
||||
* ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진
|
||||
* (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다.
|
||||
@@ -24,24 +24,10 @@
|
||||
* 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
bermSpecAt,
|
||||
readBermSpans,
|
||||
readCutSlopeSession,
|
||||
readRockBoundarySession,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
type BermSpec,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
|
||||
import { savedDesigns } from "./B06_Section_Section_Canon";
|
||||
@@ -50,15 +36,9 @@ import {
|
||||
readBermDepths,
|
||||
readShoulderExtends,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
readStandardCrossSession,
|
||||
readStoredStandard,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
@@ -66,9 +46,25 @@ import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile
|
||||
import { useStationKeys } from "./B06_Section_Station_Keys";
|
||||
import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
areasRequest,
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
import { withPipeOptions } from "./B06_Section_Section_Adjusts";
|
||||
import { extendUnclosed } from "../B05_Profile/logic/B05_Profile_Sections_Ground";
|
||||
import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import {
|
||||
pavedBySpecs,
|
||||
refreshDesigns,
|
||||
rockKey,
|
||||
storedBermSpec,
|
||||
type RefreshInputs,
|
||||
type RefreshMemo,
|
||||
} from "./logic/B06_Section_Cross_Refresh_Calc";
|
||||
|
||||
/** 포장 판정 · 저장분 소단 읽기 — 셈 몫(logic)으로 옮김 · 부르는 곳 그대로(다시 내보냄). */
|
||||
export { pavedBySpecs, storedBermSpec };
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -84,40 +80,11 @@ export interface CrossRefreshInput {
|
||||
edits: CrossRefreshEdits;
|
||||
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
|
||||
alignment?: ProfileAlignment | null;
|
||||
/**
|
||||
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
||||
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
||||
*/
|
||||
shouldApply?: () => boolean;
|
||||
}
|
||||
|
||||
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
|
||||
export { USER_TOUCHED_KEYS };
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
|
||||
|
||||
/**
|
||||
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
|
||||
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
|
||||
*/
|
||||
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
|
||||
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
|
||||
}
|
||||
|
||||
function preserveUserFields(
|
||||
next: NonNullable<CrossSection["design"]>,
|
||||
previous: CrossSection["design"],
|
||||
): NonNullable<CrossSection["design"]> {
|
||||
if (!previous) return next;
|
||||
const merged = { ...next } as Record<string, unknown>;
|
||||
const source = previous as unknown as Record<string, unknown>;
|
||||
for (const key of PRESERVED_KEYS) {
|
||||
if (source[key] !== undefined) merged[key] = source[key];
|
||||
}
|
||||
return merged as unknown as NonNullable<CrossSection["design"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
|
||||
* 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
|
||||
@@ -170,13 +137,30 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
|
||||
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
|
||||
await withPipeOptions(input.detail, input.projectId, true); // 관 옵션을 고친 뒤도 면적이 따라옴(387-4b)
|
||||
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
|
||||
if (!refreshLocally(input)) await refreshFromServer(input);
|
||||
// 계산 자리 cross = server(DTM 격자 있음) — 서버 Node 가 같은 셈 · 구조물 층까지(511-B B-4) · 못 받음 = 브라우저
|
||||
const remote =
|
||||
calcOnServerNow("cross") && designTerrainGridNow(input.projectId) !== null
|
||||
? await refreshOnServer(input)
|
||||
: false;
|
||||
if (remote) {
|
||||
return sections
|
||||
.filter((section) => JSON.stringify(section.design) !== before.get(section))
|
||||
.map((section) => section.chainage_m);
|
||||
}
|
||||
refreshLocally(input); // 선형 저장분 · 표준값이 없으면 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
|
||||
// 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
|
||||
// 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점).
|
||||
applyStructureAreaRows(
|
||||
input.detail.cross_sections,
|
||||
structureAreaRows(input.detail.cross_sections),
|
||||
);
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만 · 밀린 부름은 안 얹음(새 부름이 얹음 · 511-A)
|
||||
const rows = calcOnServerNow("quantity")
|
||||
? await calcOnServerOnly<ReturnType<typeof structureAreaRows>>(
|
||||
"quantity",
|
||||
"F7.saved",
|
||||
areasRequest(input.detail.cross_sections),
|
||||
)
|
||||
: calcChecked("quantity", "structure.layouts", structureAreaRows, [
|
||||
input.detail.cross_sections,
|
||||
]);
|
||||
if (rows) applyStructureAreaRows(input.detail.cross_sections, rows);
|
||||
return sections
|
||||
.filter((section) => JSON.stringify(section.design) !== before.get(section))
|
||||
.map((section) => section.chainage_m);
|
||||
@@ -190,10 +174,6 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
|
||||
* 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측
|
||||
* 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다.
|
||||
*/
|
||||
function rockKey(chainageM: number): number {
|
||||
return Math.round(chainageM * 100) / 100;
|
||||
}
|
||||
|
||||
function readRockOffsets(projectId: string, routeId: number): Map<number, number> {
|
||||
const raw = readRockBoundarySession(projectId, routeId) ?? {};
|
||||
const offsets = new Map<number, number>();
|
||||
@@ -206,65 +186,6 @@ function readRockOffsets(projectId: string, routeId: number): Map<number, number
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다.
|
||||
* 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다.
|
||||
*/
|
||||
function bermPayload(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
): Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> {
|
||||
const spans = readBermSpans(projectId, routeId);
|
||||
const out: Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> = {};
|
||||
if (!spans.length) return out;
|
||||
const depthAt = bermDepthReader(projectId, routeId);
|
||||
for (const section of detail.cross_sections) {
|
||||
const spec = bermSpecAt(spans, section.chainage_m);
|
||||
if (!spec) continue;
|
||||
const design = (section.design ?? {}) as Record<string, unknown>;
|
||||
const sides: Partial<Record<`berm_depth_${Side}_m`, number>> = {};
|
||||
for (const side of SIDES) {
|
||||
const own = depthAt(section.chainage_m, design, side);
|
||||
if (own !== null) sides[`berm_depth_${side}_m`] = own;
|
||||
}
|
||||
out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
|
||||
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
|
||||
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
|
||||
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
|
||||
const spec = berm as Record<string, unknown>;
|
||||
const value = (key: string, fallback: number): number => {
|
||||
const raw = spec[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
|
||||
};
|
||||
const out = {
|
||||
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
|
||||
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
|
||||
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
||||
};
|
||||
return Object.values(out).every(Number.isFinite) ? out : null;
|
||||
}
|
||||
|
||||
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
||||
function bermDepthReader(projectId: string, routeId: number) {
|
||||
const session = readBermDepths(projectId, routeId);
|
||||
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
|
||||
const own = session.get(rockKey(chainageM))?.[side];
|
||||
const stored = design[`berm_depth_${side}_m`];
|
||||
const value = own ?? (typeof stored === "number" ? stored : null);
|
||||
return value === null ? null : Math.max(value, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
||||
*
|
||||
@@ -289,13 +210,8 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
|
||||
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
|
||||
* `null` 을 돌려 서버 폴백으로 넘긴다.
|
||||
*/
|
||||
/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */
|
||||
const memos = new WeakMap<
|
||||
CrossSection,
|
||||
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
|
||||
>();
|
||||
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
/** 세션 · 가게에서 셈 입력을 모음(`RefreshInputs`) — 선형 저장분 · 표준값이 없으면 null(셈 없음). */
|
||||
function gatherRefresh(input: CrossRefreshInput): RefreshInputs | null {
|
||||
const { projectId, detail, edits } = input;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
|
||||
@@ -308,231 +224,73 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
curve_radii: edits.curve_radii ?? {},
|
||||
})
|
||||
: null);
|
||||
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
|
||||
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
||||
const standardKey = JSON.stringify(standard);
|
||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
||||
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
||||
detail.longitudinal.design_profiles = [
|
||||
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
||||
];
|
||||
|
||||
const rockOffsets = readRockOffsets(projectId, input.routeId);
|
||||
const rockDefault = readRockBoundaryDefault(projectId);
|
||||
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
||||
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
||||
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
|
||||
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
|
||||
const bermCopy = readState<BermSpan[]>("berm", projectId, input.routeId);
|
||||
const bermSpans = Array.isArray(bermCopy) ? bermCopy : null;
|
||||
const bermDepthAt = bermDepthReader(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
||||
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
|
||||
return spec
|
||||
? {
|
||||
widthM: spec.width_m,
|
||||
intervalM: spec.interval_m,
|
||||
slopeDeg: spec.slope_deg,
|
||||
depthM: spec.depth_m,
|
||||
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
|
||||
depthLeftM: bermDepthAt(chainageM, design, "left"),
|
||||
depthRightM: bermDepthAt(chainageM, design, "right"),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
||||
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
|
||||
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
|
||||
const session = cutSlopeRatios.get(rockKey(chainageM));
|
||||
if (session !== undefined) return session > 0 ? session : null;
|
||||
const stored = design.cut_slope_ratio_user;
|
||||
return typeof stored === "number" && stored > 0 ? stored : null;
|
||||
};
|
||||
const choices = crossDesignChoices(projectId, input.routeId);
|
||||
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
||||
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
|
||||
const shoulderExtends = readShoulderExtends(projectId, input.routeId);
|
||||
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
|
||||
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
|
||||
const stored = design[`shoulder_extend_${side}_m`];
|
||||
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
|
||||
};
|
||||
|
||||
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
|
||||
const grid = designTerrainGridNow(projectId)?.grid ?? null;
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
|
||||
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
|
||||
const canon = new Map(
|
||||
(savedDesigns(projectId, input.routeId) ?? []).map((row) => [
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 받은 뒤 다시 셈
|
||||
const { routeId } = input;
|
||||
// 소단 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`(파이썬 `load_berm_spans` 못 읽음 →
|
||||
// 저장분 · `stored_berm` 짝 · 467)
|
||||
const bermCopy = readState<BermSpan[]>("berm", projectId, routeId);
|
||||
return {
|
||||
alignment,
|
||||
standard,
|
||||
rockOffsets: [...readRockOffsets(projectId, routeId)],
|
||||
rockDefault: readRockBoundaryDefault(projectId),
|
||||
// 측점별 암 절토 경사 · 소단 · 카드 선택 · 노견 확장 = 세션에만 있는 값 — **계산 전에** 실음(2026-09-07 · 3-9)
|
||||
cutSlopes: [...readCutSlopeRatios(projectId, routeId)],
|
||||
bermSpans: Array.isArray(bermCopy) ? bermCopy : null,
|
||||
bermDepths: [...readBermDepths(projectId, routeId)],
|
||||
choices: [...crossDesignChoices(projectId, routeId)],
|
||||
shoulderExtends: [...readShoulderExtends(projectId, routeId)],
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계(467 덧)
|
||||
canon: (savedDesigns(projectId, routeId) ?? []).map((row) => [
|
||||
rockKey(row.chainage_m),
|
||||
row.design,
|
||||
]),
|
||||
);
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const previous = section.design;
|
||||
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
||||
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
||||
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
||||
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
|
||||
const storedOffset = design.rock_boundary_offset_m;
|
||||
const rockBoundaryOffsetM =
|
||||
typeof sessionOffset === "number"
|
||||
? sessionOffset
|
||||
: typeof storedOffset === "number"
|
||||
? storedOffset
|
||||
: rockDefault;
|
||||
const choice = choiceAt(section.chainage_m);
|
||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
||||
const elevation = planElevationAt(alignment, section.chainage_m);
|
||||
const options: CrossDesignOptions = {
|
||||
groundType:
|
||||
choice?.ground_type ??
|
||||
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
|
||||
sectionMode:
|
||||
choice?.section_mode ??
|
||||
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
|
||||
ditchSide:
|
||||
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
|
||||
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
||||
ditchType:
|
||||
choice?.ditch_type ??
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
||||
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
|
||||
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
|
||||
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
|
||||
ditchChoice: choice
|
||||
? (choice.ditch_choice ?? null)
|
||||
: typeof design.ditch_choice === "boolean"
|
||||
? design.ditch_choice
|
||||
: null,
|
||||
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
||||
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
|
||||
surfaceDropM:
|
||||
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
|
||||
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
|
||||
planRadiusM: section.plan_radius_m ?? null,
|
||||
curveOuterSide:
|
||||
section.curve_outer_side === "left" || section.curve_outer_side === "right"
|
||||
? section.curve_outer_side
|
||||
: null,
|
||||
curveWideningM: section.curve_widening_m ?? null,
|
||||
shoulderExtendLeftM: extendLeft,
|
||||
shoulderExtendRightM: extendRight,
|
||||
};
|
||||
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
|
||||
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
|
||||
const samples = section.samples ?? [];
|
||||
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`;
|
||||
const memo = memos.get(section);
|
||||
if (
|
||||
memo?.samples === section.samples &&
|
||||
memo.fingerprint === fingerprint &&
|
||||
memo.design === previous
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(samples, elevation, options);
|
||||
// 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음)
|
||||
const wide =
|
||||
grid && next.slope_unclosed
|
||||
? extendUnclosed(
|
||||
samples,
|
||||
[section.center_x, section.center_y],
|
||||
section.frame.left_xy,
|
||||
(more) => computeCrossDesign(more as typeof samples, elevation, options),
|
||||
grid,
|
||||
)
|
||||
: null;
|
||||
if (wide && (wide.info.left_m || wide.info.right_m)) {
|
||||
section.samples = wide.samples as typeof samples;
|
||||
next = computeCrossDesign(section.samples, elevation, options);
|
||||
}
|
||||
} catch {
|
||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
section.design = preserveUserFields(
|
||||
next as unknown as NonNullable<CrossSection["design"]>,
|
||||
previous,
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
|
||||
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
|
||||
for (const side of SIDES) {
|
||||
const own = next[`berm_depth_${side}_m`];
|
||||
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
||||
else delete section.design[`berm_depth_${side}_m`];
|
||||
}
|
||||
if (next.berm) section.design.berm = next.berm;
|
||||
else delete section.design.berm;
|
||||
memos.set(section, { samples: section.samples, fingerprint, design: section.design });
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
}
|
||||
|
||||
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
|
||||
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
||||
const { projectId, routeId, detail, edits, shouldApply } = input;
|
||||
const choices = crossDesignChoices(projectId, routeId);
|
||||
const response = await previewCrossDesigns(
|
||||
projectId,
|
||||
routeId,
|
||||
edits,
|
||||
// 세션 고침 → 저장분(세션에 안 베낌 · 272) — 둘 다 없으면 서버 config
|
||||
readStandardCrossSession(projectId) ?? readStoredStandard(projectId) ?? undefined,
|
||||
{
|
||||
fullDesigns: true,
|
||||
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
||||
berms: bermPayload(projectId, routeId, detail),
|
||||
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
|
||||
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
|
||||
cutSlopeRatios: readCutSlopeSession(projectId, routeId),
|
||||
// 카드 단추 선택(지반 · 측구 · 복합경사)도 — 안 실으면 폴백에서만 단추가 안 먹음(439) · 열쇠 = 측점 자리
|
||||
designChoices: Object.fromEntries(
|
||||
detail.cross_sections.flatMap((section) => {
|
||||
const choice = choices.get(rockKey(section.chainage_m));
|
||||
return choice ? [[section.chainage_m.toFixed(3), choice]] : [];
|
||||
}),
|
||||
),
|
||||
/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */
|
||||
const memos: RefreshMemo = new WeakMap();
|
||||
|
||||
/** 브라우저 안에서 전 측점을 다시 셈(셈 = `logic/B06_Section_Cross_Refresh_Calc`) · 입력 못 모음 = null. */
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const inputs = gatherRefresh(input);
|
||||
if (!inputs) return null;
|
||||
const grid = designTerrainGridNow(input.projectId)?.grid ?? null; // 미교차 넓힘 격자(받기 전 = null)
|
||||
return refreshDesigns(input.detail, inputs, grid, memos);
|
||||
}
|
||||
|
||||
/** 서버 답(`cross.refresh`) — 계획선 표본 · 측점마다 설계(넓혔으면 표본) · 구조물 폐회로 면적 줄. */
|
||||
interface RefreshAnswer {
|
||||
design_profiles: SectionDetailResponse["longitudinal"]["design_profiles"];
|
||||
sections: Array<{ design: CrossSection["design"]; samples?: CrossSection["samples"] }>;
|
||||
rows: ReturnType<typeof structureAreaRows>;
|
||||
}
|
||||
|
||||
/** 계산 자리 cross = server — 모은 입력 · 상세를 서버 Node(`cross.refresh` · 같은 셈)에 넘기고 답을 제자리에
|
||||
* 얹음 · 입력 못 모음 = 셈 없음(true) · 못 받음 = false(브라우저가 셈). */
|
||||
async function refreshOnServer(input: CrossRefreshInput): Promise<boolean> {
|
||||
const inputs = gatherRefresh(input);
|
||||
if (!inputs) return true;
|
||||
const { detail } = input;
|
||||
const answer = await calcOnServerOnly<RefreshAnswer>("cross", "C.saved", {
|
||||
run: ["cross.refresh"],
|
||||
inputs: {
|
||||
cross_refresh: {
|
||||
detail: { longitudinal: detail.longitudinal, cross_sections: detail.cross_sections },
|
||||
inputs,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (shouldApply && !shouldApply()) return [];
|
||||
const designByChainage = new Map(
|
||||
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
|
||||
);
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const next = designByChainage.get(section.chainage_m.toFixed(3));
|
||||
if (!next) continue;
|
||||
section.design = preserveUserFields(
|
||||
next as NonNullable<CrossSection["design"]>,
|
||||
section.design,
|
||||
);
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
pick: (results) => results["cross.refresh"],
|
||||
});
|
||||
if (!answer || answer.sections.length !== detail.cross_sections.length) return false;
|
||||
detail.longitudinal.design_profiles = answer.design_profiles;
|
||||
answer.sections.forEach((row, index) => {
|
||||
const section = detail.cross_sections[index];
|
||||
section.design = row.design;
|
||||
if (row.samples) section.samples = row.samples;
|
||||
});
|
||||
applyStructureAreaRows(detail.cross_sections, answer.rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
|
||||
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
|
||||
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
|
||||
남는 까닭(511-B B-6) — `compute_cross_design` 안 몫 — 그 짝과 같은 까닭(B07 · 추천 · DTM 아닌 지표면
|
||||
· Node 못 부를 때).
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 →
|
||||
빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀.
|
||||
셈 = 늘 떠 있는 Node `facility.attach`(화면과 같은 TS · 511-B B-4) ·
|
||||
파이썬 몫(`_attach_python`)은 Node 를 못 부를 때만.
|
||||
TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) ·
|
||||
거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
|
||||
"""
|
||||
@@ -106,14 +108,10 @@ def _outside_route(pipe: Any, sections: list[dict[str, Any]]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def attach_shapes(
|
||||
def _attach_python(
|
||||
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
|
||||
) -> dict[str, list[float]]:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) ·
|
||||
시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`)."""
|
||||
for section in sections:
|
||||
for key in (*SHAPE_KEYS, "blank"):
|
||||
section.pop(key, None)
|
||||
"""파이썬 짝(TS `attachShapes` · Node 를 못 부를 때만)."""
|
||||
misses: dict[str, list[float]] = {"outside": [], "far": []}
|
||||
kept = []
|
||||
for pipe in pipes:
|
||||
@@ -131,6 +129,55 @@ def attach_shapes(
|
||||
)
|
||||
attach_underdrain_specs(sections, underdrains_from(structures))
|
||||
_attach_blanks(sections, pipes, structures)
|
||||
return misses
|
||||
|
||||
|
||||
def _attach_node(
|
||||
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
|
||||
) -> dict[str, list[float]] | None:
|
||||
"""늘 떠 있는 Node `facility.attach`(511-B B-4) — 측점 자리만 보내고 그림 자료 칸만 받음 ·
|
||||
못 부르면 None."""
|
||||
from dataclasses import asdict
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
stubs = [
|
||||
{key: section.get(key) for key in ("chainage_m", "center_x", "center_y")}
|
||||
for section in sections
|
||||
]
|
||||
results = run_calc(
|
||||
["facility.attach"],
|
||||
{
|
||||
"definition": load_definition(),
|
||||
"attach": {
|
||||
"sections": stubs,
|
||||
"pipes": [asdict(pipe) for pipe in pipes],
|
||||
"structures": [item.model_dump(mode="json") for item in structures],
|
||||
"types": [item.model_dump(mode="json") for item in load_structure_types()],
|
||||
},
|
||||
},
|
||||
)
|
||||
if results is None:
|
||||
return None
|
||||
out = results["facility.attach"]
|
||||
for section, shape in zip(sections, out["shapes"], strict=True):
|
||||
section.update(shape)
|
||||
return out["misses"]
|
||||
|
||||
|
||||
def attach_shapes(
|
||||
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
|
||||
) -> dict[str, list[float]]:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) ·
|
||||
시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`)."""
|
||||
for section in sections:
|
||||
for key in (*SHAPE_KEYS, "blank"):
|
||||
section.pop(key, None)
|
||||
misses = _attach_node(sections, pipes, structures)
|
||||
if misses is None:
|
||||
logger.warning("Node 형상 얹기 실패 — 파이썬 짝으로 얹음(%d 측점)", len(sections))
|
||||
misses = _attach_python(sections, pipes, structures)
|
||||
if misses["outside"] or misses["far"]:
|
||||
logger.warning("B06 시설 그림 안 얹음 — 노선 밖 %s · 측점 멂 %s", *misses.values())
|
||||
return misses
|
||||
|
||||
@@ -18,6 +18,7 @@ B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을
|
||||
|
||||
⚠ 아래 세 상수는 **임도 확정값이 아니다**. 지식DB에 임도 기준이 없어 교차 참조로
|
||||
채택했고(2026-08-19 사용자 확정), 임도 기준이 확보되면 그 값으로 교체한다.
|
||||
남는 까닭(511-B B-6) — B04 · B07 · B08 파이썬 소비자(관 벽 높이 · 세트).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
|
||||
좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우.
|
||||
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
|
||||
남는 까닭(511-B B-6) — [저장] · [확정] · 기본 설계 = Node `cross.design` · 이 짝 = B07 · 추천 엔진
|
||||
되풀이 · DTM 아닌 지표면 · Node 못 부를 때.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -52,10 +52,61 @@ def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] |
|
||||
return None
|
||||
|
||||
|
||||
# 시설 구간(포장 · 기울임 · 세월교 하강) = TS `facilityRanges` — (프로젝트 뿌리, 측점 누가거리)
|
||||
Facilities = tuple[Path, list[float]]
|
||||
|
||||
|
||||
def facility_inputs(project_root: Path) -> dict[str, list[dict[str, Any]] | None]:
|
||||
"""TS `facilityRanges` 입력 — 관 = 지금 노선에 얹은 관 지점(`route_pipe_points`) · 구조물 정본.
|
||||
|
||||
못 읽은 관 = 빈 목록 · 구조물 = None(포장 · 맹암거 구간 없음 · 소단 = 저장분 그대로 · 파이썬
|
||||
`pavement_ranges` · `berm_specs_at` 의 비치명 태도 그대로)."""
|
||||
from dataclasses import asdict
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
|
||||
try:
|
||||
pipes = [asdict(point) for point in route_pipe_points(project_root)]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 관 지점을 읽지 못했습니다 (시설 구간 없음으로 본다)")
|
||||
pipes = []
|
||||
structures: list[dict[str, Any]] | None
|
||||
try:
|
||||
structures = [s.model_dump(mode="json") for s in load_structures(str(project_root))[1]]
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("B06 구조물 정본을 읽지 못했습니다 (포장 · 맹암거 구간 없음으로 본다)")
|
||||
structures = None
|
||||
return {"pipes": pipes, "structures": structures}
|
||||
|
||||
|
||||
def _python_facility(root: Path) -> Any:
|
||||
"""Node 를 못 부를 때 — 파이썬 구간 짝(`Router_Design_Ranges` · `Engine_Tilt`)."""
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Router_Design_Ranges import (
|
||||
ford_drop_at,
|
||||
ford_surface_drops,
|
||||
paved_at,
|
||||
pavement_ranges,
|
||||
)
|
||||
|
||||
paved, tilts, drops = pavement_ranges(root), tilt_ranges(root), ford_surface_drops(root)
|
||||
return lambda c: {
|
||||
"paved": paved_at(c, paved),
|
||||
"surface_drop_m": ford_drop_at(c, drops),
|
||||
**tilt_args(c, tilts),
|
||||
}
|
||||
|
||||
|
||||
def compute_cross_designs(
|
||||
jobs: list[DesignJob], standard: dict[str, Any] | None
|
||||
jobs: list[DesignJob],
|
||||
standard: dict[str, Any] | None,
|
||||
facilities: Facilities | None = None,
|
||||
) -> list[dict[str, Any] | None]:
|
||||
"""측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리)."""
|
||||
"""측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리).
|
||||
|
||||
`facilities` = (뿌리, 누가거리) 면 포장 · 기울임 · 세월교 하강을 TS 구간이 측점마다 얹음
|
||||
(511-B B-4 · C3)."""
|
||||
if not jobs:
|
||||
return []
|
||||
stations = [
|
||||
@@ -69,12 +120,18 @@ def compute_cross_designs(
|
||||
}
|
||||
for samples, elevation, options in jobs
|
||||
]
|
||||
results = run_calc(
|
||||
["cross.design"],
|
||||
{"cross_design": {"standard": full_standard(standard), "stations": stations}},
|
||||
)
|
||||
batch: dict[str, Any] = {"standard": full_standard(standard), "stations": stations}
|
||||
if facilities is not None:
|
||||
root, chainages = facilities
|
||||
batch["facilities"] = facility_inputs(root)
|
||||
for station, chainage in zip(stations, chainages, strict=True):
|
||||
station["chainage_m"] = chainage
|
||||
results = run_calc(["cross.design"], {"cross_design": batch})
|
||||
if results is None:
|
||||
logger.warning("Node 횡단 설계 실패 — 파이썬 짝으로 셈(%d 측점)", len(jobs))
|
||||
if facilities is not None:
|
||||
at = _python_facility(facilities[0])
|
||||
jobs = [(s, z, {**o, **at(c)}) for (s, z, o), c in zip(jobs, facilities[1])]
|
||||
return [_python(job, standard) for job in jobs]
|
||||
return [None if "error" in row else row for row in results["cross.design"]]
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이
|
||||
같은 지반을 본다(한 벌).
|
||||
⚠ 파이썬·TS 짝 — `extend_unclosed` ↔ `B05_Profile_Sections_Ground.ts`(B05 logic) `extendUnclosed`.
|
||||
남는 까닭(511-B B-6) — [저장] 넓힘 = DTM 이면 Node `cross.enforce` 안 · 이 짝 = DTM 아닌 지표면 ·
|
||||
Node 못 부를 때.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
**짝**: `common_util/common_util_structure_walls.ts` — 브라우저는 아직 저장하지 않은
|
||||
목록으로 같은 제원을 만들어야 해서 한 벌을 더 둔다. 거울 테스트
|
||||
`tmp/tests/test_b06_structure_walls_mirror.py` 가 같은 값이 나오는지 대조한다.
|
||||
남는 까닭(511-B B-6) — B07 단 추가(`tier_extras`) · 형상 얹기 · 소단 구간 Node 못 부를 때.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -67,18 +68,6 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록.
|
||||
|
||||
소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9).
|
||||
"""
|
||||
try:
|
||||
return wall_specs_from(load_structures(str(project_root))[1])
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
||||
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
||||
return []
|
||||
|
||||
|
||||
def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
"""구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
types = structure_type_map()
|
||||
@@ -185,19 +174,6 @@ def berm_span_at(
|
||||
return None
|
||||
|
||||
|
||||
def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다.
|
||||
|
||||
이미 관 정본이 얹은 세트(`culvert`·`revetment`)가 있는 측점은 **건드리지 않는다** —
|
||||
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
|
||||
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
|
||||
"""
|
||||
attached = attach_wall_specs(cross_sections, load_wall_structures(project_root))
|
||||
if attached:
|
||||
logger.info("B06 구조물 벽 %d개 측점에 얹음", attached)
|
||||
return attached
|
||||
|
||||
|
||||
def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int:
|
||||
"""구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다."""
|
||||
if not walls:
|
||||
|
||||
@@ -9,6 +9,8 @@ tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 = 표준 횡단
|
||||
판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`).
|
||||
짝(349-0n): `tilt_ranges` · `tilt_args` ↔ `B06_Section/logic/B06_Section_Cross_Enforce.ts`
|
||||
(거울 시험 `test_349_0n_cross_enforce_mirror.py`).
|
||||
남는 까닭(511-B B-6) — 얹기 = GET 형상 Node `facility.attach` · 이 짝 = Node 못 부를
|
||||
때(`_attach_python`) · 파이썬 횡단 설계 폴백 기울기.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -92,11 +94,6 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
|
||||
return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None}
|
||||
|
||||
|
||||
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
|
||||
return attach_underdrain_specs(cross_sections, load_underdrains(project_root))
|
||||
|
||||
|
||||
def attach_underdrain_specs(
|
||||
cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]]
|
||||
) -> int:
|
||||
|
||||
@@ -308,7 +308,7 @@ export interface RouteCarryInput {
|
||||
}
|
||||
|
||||
/** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */
|
||||
function pipeBook(book: RouteCarryInput["pipe_ids"]) {
|
||||
export function pipeBook(book: RouteCarryInput["pipe_ids"]) {
|
||||
const ids = [...(book?.ids ?? [])];
|
||||
let next = book?.next ?? 1;
|
||||
const near = (at: number): string | null => {
|
||||
|
||||
@@ -14,7 +14,6 @@ from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
cross_files_in_order,
|
||||
relabel_pipe_stations,
|
||||
@@ -35,15 +34,9 @@ from B06_Section.B06_Section_Repository import (
|
||||
insert_cross_sections,
|
||||
list_recent_company_projects,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
attach_default_designs as _attach_default_designs,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
recompute_designs_for_alignment as _recompute_designs_for_alignment,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
stored_standard_cross_section as _stored_standard_cross_section,
|
||||
)
|
||||
@@ -55,8 +48,6 @@ from B06_Section.B06_Section_Schema import (
|
||||
CompanyStandardListResponse,
|
||||
CompanyStandardProject,
|
||||
CompanyStandardResponse,
|
||||
CrossDesignPreviewRequest,
|
||||
CrossDesignPreviewResponse,
|
||||
HaulEquipmentLimit,
|
||||
SectionContextResponse,
|
||||
SectionDetailResponse,
|
||||
@@ -482,104 +473,3 @@ async def regenerate_route_sections(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 재생성 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
def _with_design_choices(
|
||||
designs: list[dict[str, Any]], choices: dict[str, dict[str, Any]] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""카드 선택 세션값을 저장분 위에 얹은 행 목록(439) — 뒤 행이 같은 측점 앞 행을 덮음."""
|
||||
stored = {round(float(r["chainage_m"]), 3): r.get("design") or {} for r in designs}
|
||||
picked = {round(float(key), 3): choice for key, choice in (choices or {}).items()}
|
||||
return designs + [
|
||||
{"chainage_m": key, "design": {**stored.get(key, {}), **choice}}
|
||||
for key, choice in picked.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/sections/{route_id}/cross-design/preview",
|
||||
response_model=CrossDesignPreviewResponse,
|
||||
)
|
||||
async def preview_cross_designs(
|
||||
project_id: UUID, route_id: int, request: CrossDesignPreviewRequest
|
||||
) -> CrossDesignPreviewResponse | JSONResponse:
|
||||
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
|
||||
|
||||
⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은
|
||||
브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이
|
||||
서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는
|
||||
선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다
|
||||
(`B06_Section_Cross_Refresh.refreshCrossDesigns`).
|
||||
|
||||
영속화는 각 페이지의 임시저장·확정이 맡는다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal_row = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not longitudinal_row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
designs = await get_cross_section_designs(connection, route_id)
|
||||
source = await get_route_generation_source(connection, project_id, route_id)
|
||||
designs = _with_design_choices(designs, request.design_choices)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
detail = await asyncio.to_thread(
|
||||
_read_section_detail,
|
||||
project_root,
|
||||
str(longitudinal_row["longitudinal_file_path"]),
|
||||
(source or {}).get("route_data_path"),
|
||||
)
|
||||
|
||||
def rebuild() -> None:
|
||||
alignment, profile = rebuild_alignment_profile(detail["longitudinal"], request.edits())
|
||||
detail["longitudinal"]["profile_alignment"] = alignment
|
||||
detail["longitudinal"]["design_profiles"] = [profile]
|
||||
_recompute_designs_for_alignment(
|
||||
detail["longitudinal"],
|
||||
detail["cross_sections"],
|
||||
designs,
|
||||
request.standard_cross_section,
|
||||
request.rock_boundary_offsets,
|
||||
project_root,
|
||||
request.berms,
|
||||
request.cut_slope_ratios,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(rebuild)
|
||||
# 계획고를 끄는 동안 오가는 값이라 기본은 **유토곡선이 실제로 읽는 필드만** 싣는다.
|
||||
# 지반선 샘플·설계선 좌표(design_line)·선형 구조를 다 담으면 1MB가 넘어 편집이 굼떠진다
|
||||
# (2026-08-03 사용자 지적). 계획선 자체는 화면이 이미 같은 규칙으로 계산해 들고 있다.
|
||||
# `full_designs`(B06 진입 시 stale 일괄 재계산)일 때만 설계 전체를 1회 싣는다 —
|
||||
# 측점별 순차 호출 N번보다 왕복 1번이 훨씬 싸다(2026-08-04 사용자 확인).
|
||||
return CrossDesignPreviewResponse(
|
||||
designs=[
|
||||
{
|
||||
"chainage_m": section.get("chainage_m"),
|
||||
"design": section["design"]
|
||||
if request.full_designs
|
||||
else {
|
||||
key: section["design"].get(key)
|
||||
for key in _PREVIEW_DESIGN_FIELDS
|
||||
if key in section["design"]
|
||||
},
|
||||
}
|
||||
for section in detail["cross_sections"]
|
||||
if section.get("design")
|
||||
],
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B06 횡단 설계 프리뷰 실패: project_id=%s route_id=%s", project_id, route_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""계산 자리 「서버」 창구(PLAN 511-A · 요구 320-4) — 조작 중 계산을 서버가 같은 TS 로.
|
||||
|
||||
POST /api/projects/{id}/calc {run: [id…], inputs: {…}} → {results, ms, wall_ms}
|
||||
|
||||
브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름
|
||||
(`design_calc_server.ts`).
|
||||
권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`.
|
||||
파일 경로 = 서버가 프로젝트 id 로 정함(그 storage 안 DTM 격자) · 입력에 경로 칸 = 400 ·
|
||||
follow 「서버」 맥락(저장 노선 · 관 · 구조물 …) = 서버가 모음(`B06_Section_Router_Calc_Context`).
|
||||
실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) ·
|
||||
정본 저장 길은 이 창구를 안 씀.
|
||||
단계(`stage.*`) · 화면 길(`cross.refresh`) = 서버가 DTM 격자 경로 · 구조물 정의를 채움 —
|
||||
화면이 보낸 경로는 버림(파일 경로를 화면에 맡기지 않음 · DTM 아닌 지표면 = 격자 없음 · 511-B B-4).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B06_Section.B06_Section_Router_Calc_Context import attach_follow_context
|
||||
from common_util.common_util_calc_runner import (
|
||||
PAGE_IDS,
|
||||
SERVER_ONLY_IDS,
|
||||
STAGE_IDS,
|
||||
run_calc_served,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"])
|
||||
|
||||
#: 본문 상한 — 종횡단 상세 한 벌(1.3~1.5 MB) 여유 · 넘으면 413.
|
||||
MAX_BODY = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _error(status: int, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": message})
|
||||
|
||||
|
||||
async def _server_grid(project_id: UUID, inputs: dict) -> str | None:
|
||||
"""파일 경로는 브라우저 값을 안 씀 — 단계 격자 = `{model_id, smooth, design}` → 이 프로젝트
|
||||
storage 안 DTM 격자 경로(서버가 정함) · 경로 칸(`stages.grid.path` · `stations.grid`) = 거절."""
|
||||
stations = inputs.get("stations")
|
||||
if isinstance(stations, dict) and "grid" in stations:
|
||||
return "격자 경로는 서버가 정함"
|
||||
stages = inputs.get("stages")
|
||||
grid = stages.get("grid") if isinstance(stages, dict) else None
|
||||
if grid is None:
|
||||
return None
|
||||
if not isinstance(grid, dict) or "path" in grid:
|
||||
return "격자 경로는 서버가 정함 — {model_id, smooth, design}"
|
||||
model_id, design = grid.get("model_id"), grid.get("design", "las")
|
||||
if (
|
||||
not isinstance(model_id, int)
|
||||
or isinstance(model_id, bool)
|
||||
or design not in ("las", "sheet")
|
||||
):
|
||||
return "격자 = {model_id: 정수, smooth, design: las | sheet}"
|
||||
from B05_Profile.B05_Profile_Router_Terrain import dtm_grid_npz
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file
|
||||
|
||||
async with get_db_pool().acquire() as connection:
|
||||
try:
|
||||
npz = await dtm_grid_npz(connection, project_id, model_id, bool(grid.get("smooth")))
|
||||
except LookupError as exc:
|
||||
return str(exc)
|
||||
path = await asyncio.to_thread(dtm_grid_file, npz)
|
||||
stages["grid"] = {"path": str(path), "design": design}
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{project_id}/calc", response_model=None)
|
||||
async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONResponse:
|
||||
declared = request.headers.get("content-length")
|
||||
if declared and declared.isdigit() and int(declared) > MAX_BODY:
|
||||
return _error(413, "계산 입력이 너무 큼")
|
||||
body = b""
|
||||
async for chunk in request.stream():
|
||||
body += chunk
|
||||
if len(body) > MAX_BODY:
|
||||
return _error(413, "계산 입력이 너무 큼")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except ValueError:
|
||||
return _error(400, "JSON 아님")
|
||||
run = payload.get("run") if isinstance(payload, dict) else None
|
||||
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else None
|
||||
if not isinstance(run, list) or not all(isinstance(i, str) for i in run):
|
||||
return _error(400, "run = id 목록")
|
||||
if not isinstance(inputs, dict):
|
||||
return _error(400, "inputs = 객체")
|
||||
if SERVER_ONLY_IDS.intersection(run):
|
||||
return _error(400, "서버 측점 생성은 창구 밖")
|
||||
problem = await _server_grid(project_id, inputs)
|
||||
if problem:
|
||||
return _error(400, problem)
|
||||
if set(run) & (STAGE_IDS | PAGE_IDS):
|
||||
await _server_context(project_id, inputs)
|
||||
# follow 「서버」 = 저장 정본 맥락은 서버가 모음(브라우저 정본 칸 버림 · 511-B B-5)
|
||||
problem = await attach_follow_context(project_id, inputs)
|
||||
if problem:
|
||||
return _error(409, problem)
|
||||
if {"stations.list", "stations.plan"} & set(run) and isinstance(inputs.get("stations"), dict):
|
||||
# 심는 측점 · 카드 이름 표 = 서버 구조물 정의(서버 측점 생성과 같은 표 · 511-B B-2)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table
|
||||
|
||||
inputs["stations"]["definition"] = (await asyncio.to_thread(structure_table))[1]
|
||||
try:
|
||||
answer = await asyncio.to_thread(run_calc_served, run, inputs)
|
||||
except ValueError as exc:
|
||||
return _error(400, str(exc))
|
||||
if answer is None:
|
||||
return _error(503, "서버 계산을 못 돌림")
|
||||
if "error" in answer:
|
||||
return _error(422, str(answer["error"]))
|
||||
return {"status": "success", "project_id": str(project_id), **answer}
|
||||
|
||||
|
||||
async def _server_context(project_id: UUID, inputs: dict) -> None:
|
||||
"""단계 · 화면 길 입력에 서버 맥락을 채움 — DTM 격자 이진 경로(없으면 None) ·
|
||||
구조물 정의(M02 반영)."""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import design_grid_file
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
design_terrain,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
inputs["definition"] = load_definition()
|
||||
stages = inputs.get("stages")
|
||||
# 단계 격자 = 화면이 `{model_id…}` 로 보낸 때는 `_server_grid` 가 정함 — 칸이 없을 때만 채움
|
||||
fill_stages = isinstance(stages, dict) and "grid" not in stages
|
||||
if not fill_stages and not isinstance(inputs.get("cross_refresh"), dict):
|
||||
return
|
||||
stored, params = await asyncio.gather(
|
||||
run_with_connection(get_project_storage_relative_path, project_id),
|
||||
run_with_connection(get_surface_confirmation_params, str(project_id)),
|
||||
)
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
grid = await asyncio.to_thread(design_grid_file, root, params)
|
||||
if fill_stages:
|
||||
stages["grid"] = {"path": str(grid), "design": design_terrain(params)} if grid else None
|
||||
if isinstance(inputs.get("cross_refresh"), dict):
|
||||
inputs["cross_refresh"]["grid"] = str(grid) if grid else None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""계산 창구 `/calc` 의 서버 맥락(PLAN 511-B B-5) — follow 「서버」(`stage.follow` · until
|
||||
follow)에 붙이는 저장 정본을 프로젝트 id 로 서버가 모음. 브라우저가 보낸 정본 칸은 버림(경로
|
||||
막기와 같은 원칙).
|
||||
|
||||
plan · types · definition · saved_pipes · saved_structures · saved_plus · interval_m ·
|
||||
longitudinal
|
||||
= 화면이 받는 값과 같은 길(GET 노선 계획 · 구조물 종류 · 정의 · 관 지점 · 구조물 · 추가 측점 ·
|
||||
측점 간격 기본 · 최신 노선 저장 종단) — 정본 쓰기 없음(관 지점 = 화면 GET 과 같은 파생 저장본만).
|
||||
브라우저 몫 = `BROWSER_KEYS`(묶음 입력 · 초안 전 시설 · 관 세션 장부 · 세션 계획고 초안).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
BROWSER_KEYS = frozenset({"input", "before_draft", "pipe_ids", "saved_edits", "until"})
|
||||
PLAN_KEYS = ("planned", "curves", "min_radius_m", "station_snap", "station_snap_step")
|
||||
|
||||
|
||||
async def follow_context(project_id: UUID) -> dict[str, Any] | str:
|
||||
"""follow 두 단계 맥락 — 못 모으면 까닭 글."""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table
|
||||
from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations
|
||||
from B05_Profile.B05_Profile_Router_Replan import _project_paths, read_route_plan
|
||||
from B05_Profile.B05_Profile_Structures_Router import read_structures
|
||||
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return "프로젝트 저장 경로 없음"
|
||||
root = paths[0]
|
||||
plan, pipes, structures, (types, definition) = await asyncio.gather(
|
||||
read_route_plan(project_id),
|
||||
get_pipe_points(project_id),
|
||||
read_structures(project_id),
|
||||
asyncio.to_thread(structure_table),
|
||||
)
|
||||
for name, got in (("노선", plan), ("관", pipes)):
|
||||
if not isinstance(got, dict):
|
||||
return f"{name} 정본을 못 읽음"
|
||||
if not hasattr(structures, "model_dump"):
|
||||
return "구조물 정본을 못 읽음"
|
||||
return {
|
||||
"plan": {key: plan.get(key) for key in PLAN_KEYS},
|
||||
"types": types,
|
||||
"definition": definition,
|
||||
"saved_pipes": [
|
||||
{k: v for k, v in pipe.items() if k != "lonlat"} for pipe in pipes["pipe_points"]
|
||||
],
|
||||
"saved_structures": structures.model_dump(mode="json")["structures"],
|
||||
"saved_plus": await asyncio.to_thread(load_plus_stations, root),
|
||||
"interval_m": SectionGenerationOptions().station_interval_m,
|
||||
"longitudinal": await _saved_longitudinal(project_id, root),
|
||||
}
|
||||
|
||||
|
||||
async def _saved_longitudinal(project_id: UUID, root: Path) -> dict[str, Any] | None:
|
||||
"""최신 노선의 저장 종단(화면 상세 `longitudinal`) — 없거나 못 읽으면 None."""
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
async with get_db_pool().acquire() as connection:
|
||||
route = await get_latest_route(connection, project_id)
|
||||
row = route and await get_longitudinal_section(connection, project_id, int(route["id"]))
|
||||
rel = (row or {}).get("longitudinal_file_path")
|
||||
if not rel:
|
||||
return None
|
||||
path = (root / str(rel)).resolve()
|
||||
if not path.is_relative_to(root.resolve()) or not path.is_file():
|
||||
return None
|
||||
data = json.loads(await asyncio.to_thread(path.read_text, encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
async def attach_follow_context(project_id: UUID, inputs: dict[str, Any]) -> str | None:
|
||||
"""`inputs.stages`(until follow) — 브라우저 몫만 남기고 서버 맥락을 붙임 · 못 붙이면 까닭 글."""
|
||||
stages = inputs.get("stages")
|
||||
if not isinstance(stages, dict) or stages.get("until") != "follow":
|
||||
return None
|
||||
context = await follow_context(project_id)
|
||||
if isinstance(context, str):
|
||||
return context
|
||||
inputs["stages"] = {
|
||||
**{k: v for k, v in stages.items() if k in BROWSER_KEYS},
|
||||
**context,
|
||||
}
|
||||
return None
|
||||
@@ -1,6 +1,8 @@
|
||||
"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.
|
||||
|
||||
포장 · 세월교 구간 읽기 · 판정(짝 349-0n) = `B06_Section_Router_Design_Ranges.py` · 다시 내보냄.
|
||||
남는 까닭(511-B B-6) — [저장] 횡단 보정 = DTM 이면 Node `cross.enforce` · 이 파이썬 길(`enforce_*`)
|
||||
= DTM 아닌 지표면 · Node 못 부를 때 · 확정 승계 칸 읽기는 본 길.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,7 +13,6 @@ from typing import Any
|
||||
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
ROUTE_CARRY_KEYS,
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
@@ -42,20 +43,6 @@ from common_util.common_util_cross_berm import (
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
|
||||
PREVIEW_DESIGN_FIELDS = (
|
||||
"ground_type",
|
||||
"roadbed_width_m",
|
||||
"cut_area_m2",
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"cut_rock_kind",
|
||||
"fill_area_m2",
|
||||
"fill_ground_slope",
|
||||
"design_elevation_m",
|
||||
"slope_unclosed",
|
||||
)
|
||||
|
||||
|
||||
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
|
||||
#
|
||||
# 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`·
|
||||
@@ -375,34 +362,37 @@ def attach_default_designs(
|
||||
project_root: Path | None = None,
|
||||
standard: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""설계 없는 측점 = 기본값 프리뷰 — Node 묶음 한 번(TS 짝 · 시설 구간 TS · 511-B B-4)."""
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(project_root) if project_root else []
|
||||
tilts = tilt_ranges(project_root)
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
jobs: list[tuple[dict[str, Any], float, DesignJob]] = []
|
||||
for section in cross_sections:
|
||||
if section.get("design"):
|
||||
continue
|
||||
try:
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
suggested = pavement.get(round(chainage, 3), False)
|
||||
design = compute_cross_design(
|
||||
section.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, chainage),
|
||||
ground_type="ripping_rock",
|
||||
section_mode=modes.get(round(chainage, 3), "left_cut"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
||||
paved=paved_at(chainage, paved_ranges),
|
||||
standard=standard,
|
||||
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
**curve_widening_args(section),
|
||||
**tilt_args(chainage, tilts),
|
||||
)
|
||||
design.update(status="provisional", pavement_suggested=suggested)
|
||||
section["design"] = design
|
||||
except (ValueError, KeyError):
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
options = dict(
|
||||
ground_type="ripping_rock",
|
||||
section_mode=modes.get(round(chainage, 3), "left_cut"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28) · 구간 = TS
|
||||
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
|
||||
jobs.append((section, chainage, (section.get("samples", []), elevation, options)))
|
||||
if not jobs:
|
||||
return
|
||||
designs = compute_cross_designs(
|
||||
[job for *_rest, job in jobs],
|
||||
standard,
|
||||
(project_root, [c for _s, c, _j in jobs]) if project_root else None,
|
||||
)
|
||||
for (section, chainage, _job), design in zip(jobs, designs, strict=True):
|
||||
if design is None:
|
||||
continue
|
||||
design.update(
|
||||
status="provisional", pavement_suggested=pavement.get(round(chainage, 3), False)
|
||||
)
|
||||
section["design"] = design
|
||||
|
||||
|
||||
def stored_cut_slope(stored: dict[str, Any]) -> float | None:
|
||||
@@ -445,96 +435,6 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||
return berm_from(stored.get("berm"), stored)
|
||||
|
||||
|
||||
def recompute_designs_for_alignment(
|
||||
longitudinal: dict[str, Any],
|
||||
cross_sections: list[dict[str, Any]],
|
||||
stored_designs: list[dict[str, Any]],
|
||||
standard: dict[str, Any] | None,
|
||||
rock_boundary_offsets: dict[str, float] | None = None,
|
||||
project_root: Path | None = None,
|
||||
berms: dict[str, dict[str, float]] | None = None,
|
||||
cut_slope_ratios: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(project_root) if project_root else []
|
||||
tilts = tilt_ranges(project_root)
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
stored_by_chainage = {
|
||||
round(float(record["chainage_m"]), 3): (record.get("design") or {})
|
||||
for record in stored_designs
|
||||
}
|
||||
# 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다.
|
||||
session_cut_slopes: dict[float, float] = {}
|
||||
for raw_key, ratio in (cut_slope_ratios or {}).items():
|
||||
try:
|
||||
session_cut_slopes[round(float(raw_key), 3)] = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
session_offsets: dict[float, float] = {}
|
||||
for raw_key, offset in (rock_boundary_offsets or {}).items():
|
||||
try:
|
||||
session_offsets[round(float(raw_key), 3)] = float(offset)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로).
|
||||
session_berms: dict[float, BermSpec] = {}
|
||||
for raw_key, spec in (berms or {}).items():
|
||||
try:
|
||||
parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1)
|
||||
if parsed is not None:
|
||||
session_berms[round(float(raw_key), 3)] = parsed
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for section in cross_sections:
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
key = round(chainage, 3)
|
||||
stored = stored_by_chainage.get(key) or {}
|
||||
suggested = pavement.get(key, False)
|
||||
try:
|
||||
design = compute_cross_design(
|
||||
section.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, chainage),
|
||||
ground_type=str(stored.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
|
||||
ditch_side=stored.get("ditch_side"),
|
||||
ditch_type=str(stored.get("ditch_type") or "standard"),
|
||||
paved=paved_at(chainage, paved_ranges),
|
||||
standard=standard,
|
||||
rock_boundary_offset_m=session_offsets.get(
|
||||
key,
|
||||
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
|
||||
),
|
||||
two_stage_slope=bool(stored.get("two_stage_slope", False)),
|
||||
cut_slope_ratio=(
|
||||
(session_cut_slopes[key] or None)
|
||||
if key in session_cut_slopes
|
||||
else stored_cut_slope(stored)
|
||||
),
|
||||
ditch_enabled=stored.get("ditch_enabled"),
|
||||
ditch_choice=stored.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=session_berms.get(key) or stored_berm(stored),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(stored), # 노견 확장(139-6)
|
||||
**tilt_args(chainage, tilts),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
design.update(status="provisional", pavement_suggested=suggested)
|
||||
if stored.get("display_half_width_m") is not None:
|
||||
design["display_half_width_m"] = stored["display_half_width_m"]
|
||||
if stored.get("inlet_structure") is not None:
|
||||
design["inlet_structure"] = stored["inlet_structure"]
|
||||
if stored.get("basin_adjust") is not None:
|
||||
design["basin_adjust"] = stored["basin_adjust"]
|
||||
if stored.get("ford_adjust") is not None:
|
||||
design["ford_adjust"] = stored["ford_adjust"]
|
||||
if stored.get("box_adjust") is not None:
|
||||
design["box_adjust"] = stored["box_adjust"]
|
||||
section["design"] = design
|
||||
|
||||
|
||||
def compute_default_designs(
|
||||
project_root: Path,
|
||||
longitudinal_file_path: str,
|
||||
@@ -562,10 +462,7 @@ def compute_default_designs(
|
||||
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
||||
default_modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(root)
|
||||
tilts = tilt_ranges(root)
|
||||
ford_drops = ford_surface_drops(root)
|
||||
results: list[tuple[float, dict[str, Any]]] = []
|
||||
entries: list[tuple[float, dict[str, Any], DesignJob]] = []
|
||||
for chainage_m in chainages:
|
||||
cross = (crosses or {}).get(round(chainage_m, 3))
|
||||
cross_path = find_cross_file(cross_dir, chainage_m)
|
||||
@@ -577,12 +474,9 @@ def compute_default_designs(
|
||||
samples = cross.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
continue
|
||||
suggested = pavement.get(round(chainage_m, 3), False)
|
||||
user = (carried or {}).get(round(chainage_m, 3)) or {}
|
||||
rock_offset = user.get("rock_boundary_offset_m")
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
||||
options = dict(
|
||||
ground_type=str(user.get("ground_type") or "ripping_rock"),
|
||||
# 손 상단측(같은 자리로 넘긴 단면 유형 · 측구 방향 · 요구 275-3) · 없으면 상단측
|
||||
section_mode=user.get("section_mode")
|
||||
@@ -594,27 +488,32 @@ def compute_default_designs(
|
||||
),
|
||||
ditch_side=user.get("ditch_side"),
|
||||
ditch_type=str(user.get("ditch_type") or "standard"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
||||
paved=paved_at(chainage_m, paved_ranges),
|
||||
standard=standard,
|
||||
# 포장 · 기울임 · 세월교 하강 = 구간 · 관 지점(TS `facilityRanges` · 511-B B-4)
|
||||
rock_boundary_offset_m=(
|
||||
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
|
||||
),
|
||||
two_stage_slope=bool(user.get("two_stage_slope", False)),
|
||||
cut_slope_ratio=stored_cut_slope(user),
|
||||
ditch_choice=user.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
|
||||
berm=stored_berm(user),
|
||||
**curve_widening_args(cross),
|
||||
**stored_shoulder_extend(user), # 노선 저장이 넘긴 사면 쉬프트(417)
|
||||
**tilt_args(chainage_m, tilts),
|
||||
)
|
||||
for key in ("berm", "cut_slope_ratio_user", *ROUTE_CARRY_KEYS):
|
||||
if user.get(key) is not None:
|
||||
design[key] = user[key]
|
||||
design["status"] = "provisional"
|
||||
design["pavement_suggested"] = suggested
|
||||
results.append((chainage_m, design))
|
||||
elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
|
||||
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
||||
continue
|
||||
entries.append((chainage_m, user, (samples, elevation, options)))
|
||||
designs = compute_cross_designs(
|
||||
[job for *_rest, job in entries], standard, (root, [c for c, _u, _j in entries])
|
||||
)
|
||||
results: list[tuple[float, dict[str, Any]]] = []
|
||||
for (chainage_m, user, _job), design in zip(entries, designs, strict=True):
|
||||
if design is None:
|
||||
continue # 계획고 부재 등 — 조용히 건너뜀(확정을 막지 않음)
|
||||
for key in ("berm", "cut_slope_ratio_user", *ROUTE_CARRY_KEYS):
|
||||
if user.get(key) is not None:
|
||||
design[key] = user[key]
|
||||
design["status"] = "provisional"
|
||||
design["pavement_suggested"] = pavement.get(round(chainage_m, 3), False)
|
||||
results.append((chainage_m, design))
|
||||
return results
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔
|
||||
`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`.
|
||||
부르는 쪽은 `B06_Section_Router_Design` 이름으로 씀(다시 내보냄 · 시험이 그 이름을 갈아 끼움).
|
||||
남는 까닭(511-B B-6) — 포장 제안 · 세월교 하강 = B05 확정 본 길 · 구간 = 기본 설계 Node 못 부를
|
||||
때(`_python_facility`).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
멈추면 그 결과를 여기로 보내 배분을 받아 쥔다. 그래서 유토곡선 패널을 펼치는 순간이
|
||||
즉시가 된다(미리 받아 뒀으므로).
|
||||
|
||||
**계산은 한 벌이다** — 화면이 쓰던 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)으로
|
||||
그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과 화면 값이 갈린다.
|
||||
**계산은 한 벌이다** — 화면이 쓰던 TS(`computeHaulPlan`)를 늘 떠 있는 Node 시험 길 `haul.view`
|
||||
(`design_calc_node_trial.ts` · 511-C)로 그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과
|
||||
화면 값이 갈린다.
|
||||
|
||||
정본을 만들지 않는다 — 이 응답은 **표시 전용**이다. 저장 정본은 [저장]·[확정] 뒤
|
||||
`recompute_server_side` 가 따로 낸다.
|
||||
@@ -28,21 +29,19 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
BUNDLE,
|
||||
_mass_haul_context,
|
||||
conversion_factors_for,
|
||||
haul_inputs_for,
|
||||
haul_limits_for,
|
||||
)
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
from common_util.common_util_derived_store import read_derived, write_derived
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
_NPM_SCRIPT = "build:server-calc"
|
||||
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
||||
_MAX_POINTS = 5000
|
||||
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
|
||||
@@ -64,6 +63,26 @@ def _keep(key: str, plan: Any) -> None:
|
||||
_KEPT.popitem(last=False)
|
||||
|
||||
|
||||
def _run_view(result: dict[str, Any], context: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""화면 꼴 배분(그림용 · 잔진동 거름) — 브라우저 `computeHaulPlan` 과 같은 인자."""
|
||||
options = {
|
||||
key: context.get(key)
|
||||
for key in (
|
||||
"collected_stone_deduction_m3",
|
||||
"collected_stone_by_ground_m3",
|
||||
"collected_stone_ground_unknown_m3",
|
||||
"structure_spoil_m3",
|
||||
"structure_spoil_points",
|
||||
)
|
||||
}
|
||||
options["conversion"] = context.get("earthwork_conversion")
|
||||
options["drawing"] = True
|
||||
args = [result, context.get("haul_equipment_limits"), options]
|
||||
answer = run_calc_served(["haul.view"], {"args": {"haul.view": args}})
|
||||
results = answer.get("results") if isinstance(answer, dict) else None
|
||||
return {"haul_plan": results["haul.view"]} if isinstance(results, dict) else None
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
|
||||
async def compute_haul_plan(
|
||||
project_id: UUID,
|
||||
@@ -114,12 +133,7 @@ async def compute_haul_plan(
|
||||
_keep(key, stored["value"])
|
||||
return JSONResponse(content={"status": "success", "haul_plan": stored["value"]})
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
BUNDLE,
|
||||
_NPM_SCRIPT,
|
||||
{"haul_plan_for": result, "context": context},
|
||||
)
|
||||
output = await asyncio.to_thread(_run_view, result, context)
|
||||
except Exception:
|
||||
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
|
||||
return JSONResponse(
|
||||
|
||||
@@ -228,14 +228,14 @@ class CrossSectionPatch(BaseModel):
|
||||
# 「안 보냄」이라 병합에서 걷힌다 — 「자동」을 patch 로 되돌릴 길은 아직 없다.
|
||||
ditch_choice: bool | None = None
|
||||
# 구조물이 선 측점의 폐회로 절·성토 면적(㎡) · 아래 관 길이 묶음 — 옛 화면 호환으로 받기만 하고
|
||||
# 저장에서 걷음(388-2 · `_AREA_KEYS`) · 정본은 서버가 Node 로 셈(`B06_Section_Server_Calc_Node.ts`).
|
||||
# 저장에서 걷음(388-2 · `_AREA_KEYS`) · 정본은 서버가 Node 로 셈(`design_calc_node.ts` `structure.layouts`).
|
||||
cut_area_m2: float | None = Field(default=None, ge=0)
|
||||
fill_area_m2: float | None = Field(default=None, ge=0)
|
||||
cut_soil_area_m2: float | None = Field(default=None, ge=0)
|
||||
cut_rock_area_m2: float | None = Field(default=None, ge=0)
|
||||
# 배수관 연장(m) — 브라우저 기하가 **m 단위 올림까지** 끝낸 값. 수량(B08)이 배수관
|
||||
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
|
||||
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
|
||||
# 돌려 내므로 한 벌이다(`design_calc_node` `structure.layouts`).
|
||||
pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
|
||||
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
|
||||
@@ -367,50 +367,3 @@ class SectionDetailResponse(BaseModel):
|
||||
# longitudinal_sections.data.mass_haul.balloon_offsets에 확정 시점에 저장된 값이며,
|
||||
# 브라우저가 바뀌어도 같은 자리에 뜨도록 진입 시 프론트 캐시의 씨앗으로 내려보낸다.
|
||||
balloon_offsets: dict[str, list[float]] | None = None
|
||||
|
||||
|
||||
class CrossDesignPreviewRequest(BaseModel):
|
||||
"""계획선 편집 델타로 **전 측점 횡단 설계를 다시 계산**해 달라는 요청(저장 없음).
|
||||
|
||||
B05에서 계획고를 끌면 측점별 횡단 단면적이 함께 바뀌어야 횡단 기준 유토곡선이
|
||||
따라 움직인다. 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다.
|
||||
사용자가 이미 지정한 지반유형·단면유형·측구·암 경계는 그대로 유지하고
|
||||
**계획고만 새 선형으로 바꿔** 재계산한다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
station_offsets: dict[str, float] = Field(default_factory=dict)
|
||||
curve_radii: dict[str, float] = Field(default_factory=dict)
|
||||
# B06 설정 패널 편집값(있으면 그 표준단면으로 계산).
|
||||
standard_cross_section: dict[str, Any] | None = None
|
||||
# True면 설계 전체(설계선 좌표 포함)를 돌려준다 — B06 진입 시 stale 설계 일괄 재계산용.
|
||||
# B05 편집 프리뷰(기본 False)는 유토곡선 필드만 받아 응답을 가볍게 유지한다.
|
||||
full_designs: bool = False
|
||||
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
|
||||
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
|
||||
rock_boundary_offsets: dict[str, float] | None = None
|
||||
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로,
|
||||
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
|
||||
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
|
||||
berms: dict[str, dict[str, float]] | None = None
|
||||
# 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을
|
||||
# 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다.
|
||||
cut_slope_ratios: dict[str, float] | None = None
|
||||
# 측점별 카드 선택(chainage 키 → 지반 · 단면 · 측구 · 복합경사). 위와 같은 성격 — 저장분 위에 얹음(439).
|
||||
design_choices: dict[str, dict[str, Any]] | None = None
|
||||
|
||||
def edits(self) -> dict[str, Any]:
|
||||
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
||||
|
||||
|
||||
class CrossDesignPreviewResponse(BaseModel):
|
||||
"""계획선 편집 프리뷰 결과 — **바뀌는 것만** 담는다.
|
||||
|
||||
계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 설계 필드만** 담는다.
|
||||
지반선 샘플·설계선 좌표·선형 구조까지 실으면 1MB가 넘어 편집이 굼떠지고
|
||||
(2026-08-03 사용자 지적), 계획선 자체는 화면이 같은 규칙으로 이미 계산해 들고 있다.
|
||||
"""
|
||||
|
||||
status: str = "ok"
|
||||
designs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap";
|
||||
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
|
||||
import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
|
||||
export { onAnySwap } from "./B06_Section_Swap_Bus";
|
||||
|
||||
@@ -264,14 +265,15 @@ export async function swapSectionStations(
|
||||
const interval = stationIntervalOf(current);
|
||||
const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval);
|
||||
if (sameStationList(current.cross_sections, oldKeys, stations)) return null;
|
||||
const swap = swapCrossSections(
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
const swap = calcChecked("fixed_browser", "stations.swap", swapCrossSections, [
|
||||
current,
|
||||
stations,
|
||||
oldKeys,
|
||||
interval,
|
||||
useDesignStore(projectId, null).get("routeDraft") !== null,
|
||||
savedDesigns(projectId, routeId) ?? [],
|
||||
); // 초안 없으면 정본 표본(467)
|
||||
]); // 초안 없으면 정본 표본(467)
|
||||
// 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o)
|
||||
for (const section of swap.detail.cross_sections)
|
||||
if (section.design) section.design = { ...section.design };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user