Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_4

This commit is contained in:
2026-10-22 16:50:09 +09:00
213 changed files with 9168 additions and 2535 deletions
+91
View File
@@ -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,
);
};
+236
View File
@@ -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 };
}
+21 -3
View File
@@ -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;
+16 -1
View File
@@ -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")])
+52
View File
@@ -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;
}
+1 -1
View File
@@ -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)
+5 -16
View File
@@ -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,
);
}
+42 -46
View File
@@ -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?.();
+2 -47
View File
@@ -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";
+35
View File
@@ -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,
);
}
+12 -2
View File
@@ -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,
);
}
+2 -27
View File
@@ -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` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
+119 -72
View File
@@ -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[])))]),
);
}
+53 -56
View File
@@ -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);
}
+4 -4
View File
@@ -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) }),
]),
];
}
+4 -4
View File
@@ -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 }),
});
}
+59 -41
View File
@@ -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(
+39 -10
View File
@@ -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() {
+2
View File
@@ -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
+2 -67
View File
@@ -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"]
+26 -21
View File
@@ -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"):
+44 -19
View File
@@ -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),
}
+23 -14
View File
@@ -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 = {
+22 -23
View File
@@ -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))
+15 -7
View File
@@ -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).
+6 -82
View File
@@ -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:
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
+30 -21
View File
@@ -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")
+5 -8
View File
@@ -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,
};
+12 -40
View File
@@ -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) {
+31 -10
View File
@@ -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)),
};
}
+6 -12
View File
@@ -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";
+3 -43
View File
@@ -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 {
+5 -41
View File
@@ -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,
+32 -16
View File
@@ -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) => {
+1 -1
View File
@@ -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 {
+8 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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],
};
+22 -9
View File
@@ -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
);
}
+64 -36
View File
@@ -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).
*
-62
View File
@@ -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>;
+108 -350
View File
@@ -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;
}
+2
View File
@@ -8,6 +8,8 @@
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
남는 까닭(511-B B-6) — `compute_cross_design` 안 몫 — 그 짝과 같은 까닭(B07 · 추천 · DTM 아닌 지표면
· Node 못 부를 때).
"""
+53 -6
View File
@@ -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
+2
View File
@@ -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
+63 -6
View File
@@ -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:
+2 -5
View File
@@ -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:
+1 -1
View File
@@ -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 => {
-110
View File
@@ -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": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
)
+150
View File
@@ -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
+45 -146
View File
@@ -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
+25 -11
View File
@@ -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(
+2 -49
View File
@@ -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)
+4 -2
View File
@@ -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