diff --git a/A00_Common/design/design_calc_profile.ts b/A00_Common/design/design_calc_profile.ts new file mode 100644 index 000000000..2a5ce57f3 --- /dev/null +++ b/A00_Common/design/design_calc_profile.ts @@ -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(signal: AbortSignal, remote: () => Promise, 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 | 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, + ); +}; diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts new file mode 100644 index 000000000..c0fc466a2 --- /dev/null +++ b/A00_Common/design/design_calc_server.ts @@ -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; + 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 { + 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, + signal?: AbortSignal, + extra: Partial = {}, +): Promise> { + 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; + 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(); + +/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */ +export async function afterQuiet( + slot: string, + task: (signal: AbortSignal) => Promise, + outer?: AbortSignal, +): Promise { + 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; + pick: (results: Record) => 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( + 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 = { 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( + key: string, + slot: string, + request: CalcRequest, + outer?: AbortSignal, +): Promise { + 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(); +const asked = new Map(); + +/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */ +export function calcFromServer( + 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 }; +} diff --git a/A00_Common/design/design_calc_sites.ts b/A00_Common/design/design_calc_sites.ts index 7ecd81f8b..c678ad044 100644 --- a/A00_Common/design/design_calc_sites.ts +++ b/A00_Common/design/design_calc_sites.ts @@ -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; } export interface CalcSitesBoard { @@ -43,11 +47,15 @@ export const saveCalcSites = (sites: Record): Promise> | null = null; +let loaded: Record = {}; +let project = ""; -/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */ -export function openCalcSites(): Promise> { +/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */ +export function openCalcSites(projectId = project): Promise> { + project = projectId; + loaded = {}; opened = fetchCalcSites().then( - (board) => board.sites, + (board) => (loaded = board.sites), () => ({}), ); return opened; @@ -57,3 +65,13 @@ export function openCalcSites(): Promise> { export async function calcOnServer(key: string): Promise { return (await (opened ?? openCalcSites()))[key] === "server"; } + +/** 판에 both 일 때만 브라우저 셈 — 못 받음 · 받기 전 = 아님(기본 server 인 haul 줄). */ +export async function calcInBrowser(key: string): Promise { + return (await (opened ?? openCalcSites()))[key] === "both"; +} + +/** 동기 자리용 — 받기 전 = both. */ +export const calcOnServerNow = (key: string): boolean => loaded[key] === "server"; + +export const calcProjectId = (): string => project; diff --git a/A00_Common/design/design_facility_lane.ts b/A00_Common/design/design_facility_lane.ts index f9faa06d1..5c9bf6970 100644 --- a/A00_Common/design/design_facility_lane.ts +++ b/A00_Common/design/design_facility_lane.ts @@ -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("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("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")]) diff --git a/A00_Common/design/design_follow_server.ts b/A00_Common/design/design_follow_server.ts new file mode 100644 index 000000000..b3c7b437f --- /dev/null +++ b/A00_Common/design/design_follow_server.ts @@ -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, + 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; +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 4f0515c18..77b29b225 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -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) diff --git a/A00_Common/design/design_pipeline_base.ts b/A00_Common/design/design_pipeline_base.ts index bcf843930..2491b96d7 100644 --- a/A00_Common/design/design_pipeline_base.ts +++ b/A00_Common/design/design_pipeline_base.ts @@ -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, types: ReadonlyArray, ): 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[1]; - const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 - return listedBase(toAlignmentBase(stored), detail, planted); + return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes)); } diff --git a/A00_Common/design/design_pipeline_cross_server.ts b/A00_Common/design/design_pipeline_cross_server.ts new file mode 100644 index 000000000..97c0b6f73 --- /dev/null +++ b/A00_Common/design/design_pipeline_cross_server.ts @@ -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, + "input" | "grid" | "pipe_ids" +>; + +/** 서버 단계 답 · 끊김 · 실패 = undefined(부르는 쪽이 브라우저) · server 아님 = null. */ +export function crossOnServer( + projectId: string, + ctx: StageContext<"cross">, + context: () => CrossStageContext | null, +): Promise | null { + if (!calcOnServerNow("cross") || !designTerrainGridNow(projectId)) return null; + const saved = context(); + if (!saved) return null; + // ponytail: 저장 상세(1.4 MB)를 부름마다 올림 — 시험용 길 · 느리면 서버가 정본 맥락을 붙임 + return calcOnServerOnly( + "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, + ); +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index cb2021ab2..f5531df3f 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -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(); 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?.(); diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index 4bf4bcfa7..9a11d5618 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -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"; diff --git a/A00_Common/design/design_route_server.ts b/A00_Common/design/design_route_server.ts new file mode 100644 index 000000000..66a06bcba --- /dev/null +++ b/A00_Common/design/design_route_server.ts @@ -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 | 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( + "route", + "route.polyline", + { + run: ["route.polyline"], + inputs: { route_polyline }, + pick: (results) => results["route.polyline"], + }, + signal, + ); +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 2e538b2f0..904ec427d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -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) => { diff --git a/A00_Common/design/design_stations_server.ts b/A00_Common/design/design_stations_server.ts new file mode 100644 index 000000000..03ca39ab0 --- /dev/null +++ b/A00_Common/design/design_stations_server.ts @@ -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 | 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( + "stations", + "stations.list", + { run: ["stations.list"], inputs: { stations }, pick: (results) => results["stations.list"] }, + signal, + ); +} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 67b16f8a7..28937013d 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -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, - 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` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 8c353b06a..8d8f6ddc4 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -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[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; + /** 시설 구간을 얹을 누가거리(`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 = { - 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; - 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, - standard: CrossDesignOptions["standard"], -): CrossDesignOptions { - const options: Record = { 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(); + 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 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), + ), + ), // 두 벌 — 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 = {}; - 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 { + 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 = 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 })); diff --git a/A00_Common/design/logic/design_calc_node_cross.ts b/A00_Common/design/logic/design_calc_node_cross.ts new file mode 100644 index 000000000..34d129ad8 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_cross.ts @@ -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 unknown> { + return { + "facility.attach": () => { + const attach = inputs.attach; + if (!attach) throw new Error("형상 얹기 입력(attach) 없음"); + const sections = attach.sections.map((section) => ({ ...section })) as Array< + Record + >; + 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), + }; + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts index f6ce41a40..6433bb29a 100644 --- a/A00_Common/design/logic/design_calc_node_follow.ts +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -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; line: Line; max_drift_m?: number; interval_m?: number | null; + fill?: { previous?: Record | 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 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), diff --git a/A00_Common/design/logic/design_calc_node_profile.ts b/A00_Common/design/logic/design_calc_node_profile.ts new file mode 100644 index 000000000..834190246 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_profile.ts @@ -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 | 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 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) }; + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_route.ts b/A00_Common/design/logic/design_calc_node_route.ts new file mode 100644 index 000000000..af3bf7746 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_route.ts @@ -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 | null; + radii?: Array | null; + locks?: Array | null; + arcs?: Array | null; + min_radius_m: number; + }; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function routeRunners(inputs: RouteInputs): Record 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 = []; + 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), + }; + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_sections.ts b/A00_Common/design/logic/design_calc_node_sections.ts new file mode 100644 index 000000000..0251ab16a --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_sections.ts @@ -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 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, + ); + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts new file mode 100644 index 000000000..bec708668 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -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[0]; + interval_m?: number; + grid?: { path: string; design: "las" | "sheet" } | null; + input: Partial; + /** 구조물 정의(서버 창구가 붙임 · 화면 `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, +): Promise> { + 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; + } finally { + pipe.dispose(); + } +} + +/** route · follow 두 단계만 — 묶음 첫 바퀴와 같은 맥락(앞 출력 없음 = 저장분에서 · 페이지 「다시 열기」 꼴). */ +async function followOnly( + table: PipelineStages, + inputs: PipelineInputs, +): Promise> { + const none = Object.fromEntries( + [...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]), + ) as StageContext<"route">["out"]; + const at = (out: StageContext["out"]): StageContext => ({ + 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; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */ +export function stageRunners(inputs: StagesInputs): Record Promise> { + let run: Promise> | 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])]), + ); +} diff --git a/A00_Common/design/logic/design_calc_node_trial.ts b/A00_Common/design/logic/design_calc_node_trial.ts new file mode 100644 index 000000000..d22ead5fa --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_trial.ts @@ -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; +} + +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 = { + "masshaul.series": computeMassHaulSeries as Call, + "stations.carry_keyed": ((states: Record, 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 unknown> { + return Object.fromEntries( + TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]), + ); +} diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 79b0e5d37..be41eea05 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -18,7 +18,6 @@ * ts — TS 셈 자리 `<경로>#<함수>` · 없으면 null * runner — 실행기: `node:`(서버 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", diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index a0169494c..7415c29cc 100644 --- a/A00_Common/design/logic/design_calc_registry_facility.ts +++ b/A00_Common/design/logic/design_calc_registry_facility.ts @@ -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", diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index ed7f04fff..1cd512706 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -83,6 +83,8 @@ export interface DesignPipeline { value: unknown, part?: RangePart | null, ): Promise | null; + /** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */ + refresh(source: ChangeSource): Promise | null; /** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */ subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void; output(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); diff --git a/A00_Common/design/logic/design_pipeline_stage_inputs.ts b/A00_Common/design/logic/design_pipeline_stage_inputs.ts new file mode 100644 index 000000000..695d67fcc --- /dev/null +++ b/A00_Common/design/logic/design_pipeline_stage_inputs.ts @@ -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, + ), + }, +}); diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 053a4d96b..873d1d92a 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -61,18 +61,27 @@ export function snapOptions( }; } -/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */ +/** `buildStations` 인자 한 벌 — 화면 셈 · 서버 셈(계산 자리 「서버」)이 같은 값을 받음. */ +export type StationsArgs = Parameters; + +/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). + * `remote` = 계산 자리 「서버」면 서버 Node 값(같은 `buildStations` · 511-B B-2) · null = 화면 셈 · 서버가 + * 못 내면(끊김 · 실패) 화면 셈. */ export function createStationsStage( options: Omit & { types: () => ReadonlyArray; saved?: () => SavedSnap | null; + remote?: ( + args: StationsArgs, + signal: AbortSignal, + ) => Promise | 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); // 화면 셈 = 동기 그대로 }; } diff --git a/A00_Common/design/logic/design_planted_base.ts b/A00_Common/design/logic/design_planted_base.ts new file mode 100644 index 000000000..e721c6033 --- /dev/null +++ b/A00_Common/design/logic/design_planted_base.ts @@ -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, + 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, + types: ReadonlyArray, + 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[1]; + const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 + return listedBase(toAlignmentBase(stored), detail, planted); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts index e73d2d505..e86e11a01 100644 --- a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts +++ b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts @@ -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) }), ]), ]; } diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 6106be9bc..5f27a3034 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -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 | null; } /** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ @@ -622,16 +624,14 @@ export async function fetchDetailPipePoints(projectId: string): Promise { return requestJson(`/projects/${projectId}/drainage/detail-basins`, { method: "POST", - body: JSON.stringify(auto ? { points, auto } : { points }), + body: JSON.stringify({ points }), }); } diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index eb85536ba..a79e155a1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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} diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py index 1ec330489..2bd8f3510 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py @@ -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( diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index 78fe2810d..cfcab7693 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -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 { + 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 { + async function send( + result: unknown, + ctx: HaulPlanContext | null, + seq: number, + key: string, + ): Promise { 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() { diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index 706bc2635..21f4d1fda 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -9,6 +9,8 @@ 절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가 나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로 반환한다. +남는 까닭(511-B B-6) — 기준표 · 설계 속도 = 서버 종단 입력(페이지 설정 · 재생성 옵션) · 추천 엔진 · +4순위 폴백(`design_grade_line`). """ import math diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 3744807b0..3cb9cfa7f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 56c5d8784..5eb8d852c 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -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]], diff --git a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py index 0090a970f..83dc7196f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py +++ b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Grade_Node.py b/B05_Profile/B05_Profile_Engine_Grade_Node.py new file mode 100644 index 000000000..58d057803 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_Grade_Node.py @@ -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"] diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 0c3ff91fe..2d481033b 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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), diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index 7ecc75a21..b9b035e71 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -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"): diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 4c91bf6dc..31884c6ba 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 8cc6afa33..203ef2d40 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Sections_Node.py b/B05_Profile/B05_Profile_Engine_Sections_Node.py new file mode 100644 index 000000000..9e86ddb11 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_Sections_Node.py @@ -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"], + } diff --git a/B05_Profile/B05_Profile_Footprint_Server.ts b/B05_Profile/B05_Profile_Footprint_Server.ts new file mode 100644 index 000000000..831a8676a --- /dev/null +++ b/B05_Profile/B05_Profile_Footprint_Server.ts @@ -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 { + if (!calcOnServerNow("fixed_browser")) + return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]); + const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]); + return calcOnServerOnly("fixed_browser", "D4", asked, ctx.signal).then( + (lines) => lines ?? footprintStage(ctx), + ); +} diff --git a/B05_Profile/B05_Profile_Route_NodeEdits.py b/B05_Profile/B05_Profile_Route_NodeEdits.py index 56f658e2a..5664ef935 100644 --- a/B05_Profile/B05_Profile_Route_NodeEdits.py +++ b/B05_Profile/B05_Profile_Route_NodeEdits.py @@ -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 diff --git a/B05_Profile/B05_Profile_Route_Polyline_Write.py b/B05_Profile/B05_Profile_Route_Polyline_Write.py new file mode 100644 index 000000000..bf4cfbab1 --- /dev/null +++ b/B05_Profile/B05_Profile_Route_Polyline_Write.py @@ -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), + } diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 388f627ff..9b1ca473c 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -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 = { diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index eee34ca91..18736c8d5 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -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:` · 구조물 `structure:` · 시종점 `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)) diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 53ab71b6c..2a7aa7ec0 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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). diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index b1104b39e..e772dbac6 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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: """계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다. diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 27faaa26c..ee918e151 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -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") diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 020a16d9d..7a99051da 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -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) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index 7a82c1545..2bf510e3d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index f2cf3a0dc..14e7ebcea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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; - summary_item?: SummaryItem | null; - group_items?: GroupItems | null; - }>, - ): void { + replaceFromResponse(points: ReadonlyArray>): 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, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 0a92686af..e3a4116aa 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -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; + /** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(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(".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) { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a947d28ca..76b1b7ef4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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이 재계산을 돌리고, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Propose.ts b/B05_Profile/B05_Profile_UI_Drainage_Propose.ts new file mode 100644 index 000000000..469ba638e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Propose.ts @@ -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; + 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(); + 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)), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts index 379702fa1..6ba8725a4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts @@ -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), }; } diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index a425b540d..1286d9d34 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 9fabc1037..066ca8a4a 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -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; 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 = { trunk: "간선임도", @@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record = { branch: "작업임도(구 지선)", }; -/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ -export const SPEED_CHOICES: Record> = { - 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 { diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index f5cdbfcd3..c372280a5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 87aa9f811..33f9f2a81 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -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 = [ + 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("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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index fdcc2d9ec..428d5e7b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -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 = [ + base, + ctx.edits(), + fromM, + toM, + ctx.savedRadii(), + ]; + if (calcOnServerNow("fixed_browser")) { + const asked = trialRequest("profile.tools", args); + void calcOnServerOnly>( + "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) => { diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13880cf22..ffa7410d0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 86ad60483..328e5c691 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index a0c2a7fc1..daacecf8d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -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( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 40edb8fbd..dae66c034 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 2ca878278..a3f553295 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -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; +} diff --git a/B05_Profile/logic/B05_Profile_Grade_Carry.ts b/B05_Profile/logic/B05_Profile_Grade_Carry.ts index cb8f6a18b..2c0a7bc4b 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Carry.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Carry.ts @@ -182,8 +182,8 @@ export function carryBase( /** * 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면 - * 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬 - * `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로. + * 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(서버 = + * Node `profile.carry` · 파이썬 짝 없음). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로. */ export function carryEdits( oldXy: ReadonlyArray, diff --git a/B05_Profile/logic/B05_Profile_Grade_Criteria.ts b/B05_Profile/logic/B05_Profile_Grade_Criteria.ts new file mode 100644 index 000000000..77de876da --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Grade_Criteria.ts @@ -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; 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> = { + trunk: [20, 30, 40], + fire: [20, 30, 40], + work: [20], + branch: [20], +}; diff --git a/B05_Profile/logic/B05_Profile_Grade_Ground.ts b/B05_Profile/logic/B05_Profile_Grade_Ground.ts index 01e724e0a..f5c73f0c6 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Ground.ts @@ -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, +): 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 + ); } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index b7084ba0d..be68ffe06 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -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): Array<{ x: number; y: number }> => @@ -180,6 +184,24 @@ const plusAt = new WeakMap(); /** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */ const followedAt = new WeakMap>(); +/** 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(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, born: ReadonlyArray, @@ -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 | 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) ?? []) : []); }; } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index e18bb4642..dc749709b 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -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 | null; } +/** `buildEditedPolyline` 인자(곡선 길이 고정을 R 로 푼 뒤) — 화면 셈 · 서버 셈이 같은 값을 받음. */ +export type DraftArgs = Parameters; +type BuiltLine = Pick, "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()); }; } diff --git a/B05_Profile/logic/B05_Profile_Profile_Planted.ts b/B05_Profile/logic/B05_Profile_Profile_Planted.ts new file mode 100644 index 000000000..4e34ffc1f --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Profile_Planted.ts @@ -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] }, + })); +} diff --git a/B05_Profile/logic/B05_Profile_Sections_Generate.ts b/B05_Profile/logic/B05_Profile_Sections_Generate.ts new file mode 100644 index 000000000..ed045ec1c --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Sections_Generate.ts @@ -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 & GroundOptions; + +const KIND: Record = { + grid: "regular", + curve: "curve", + pipe: "irregular", + structure: "irregular", +}; + +/** 서버 `generate_sections` 꼴 몸통 — 종단(길이 · 표본 · 측점) · 횡단 · 요약 · 도면 기준고. */ +export function generateSections( + line: ReadonlyArray, + curves: ReadonlyArray, + pipes: ReadonlyArray, + structures: ReadonlyArray, + 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 = { + 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, + }; +} diff --git a/B05_Profile/logic/B05_Profile_Sections_Ground.ts b/B05_Profile/logic/B05_Profile_Sections_Ground.ts index b0b81e635..2b9fba094 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Ground.ts @@ -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); diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index bd95a988c..4d7f85ecc 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -239,31 +239,31 @@ export interface StationsOptions { } /** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */ -const lineLength = (line: ReadonlyArray): number => +export const lineLength = (line: ReadonlyArray): 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, - curves: ReadonlyArray, +/** 심는 측점 한 자리 — 주인 종류 · id · 역할 · 카드 이름(서버 측점 `structure` 칸). */ +export interface PlantedMark { + kind: "pipe" | "structure"; + id: string; + role: string; + title: string; +} + +/** 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). */ +export function plantedStations( + grid: ReadonlyArray, + total: number, pipes: ReadonlyArray, structures: ReadonlyArray, - 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, +): Map { const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`)); - const planted = new Map(); + const planted = new Map(); 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, + curves: ReadonlyArray, + pipes: ReadonlyArray, + structures: ReadonlyArray, + 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( diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index c26217af1..e7d6f1919 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -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(base.chainage.map((value) => Number(value.toFixed(3)))); + // 격자 자리 = 0.000001 m(파이썬 `np.round(merged, 6)` 와 같은 값 — 서버 Node 정본 · 511-B B-3). + const sampleChainages = new Set(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; diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts new file mode 100644 index 000000000..5877c0d9c --- /dev/null +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts @@ -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 }; +} diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts index 20fe1dcae..79cb6abcc 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts @@ -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; diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts index 359bd14f1..3f3dee74b 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts @@ -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, + flags: ReadonlyArray | null = null, + radii: ReadonlyArray | null = null, + locks: ReadonlyArray | null = null, + arcs: ReadonlyArray | null = null, +): RouteNodeEdit[] { + const at = (values: ReadonlyArray | 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). * diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 42f2182c0..5832adb4a 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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; - curve_radii: Record; -} - -/** - * 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다. - * 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB). - */ -export interface CrossDesignPreviewResponse { - status: string; - /** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */ - designs: Array<{ chainage_m: number; design: Partial }>; -} - -/** - * 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음). - * - * B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다. - * 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의 - * 임시저장·확정이 맡는다. - */ -export async function previewCrossDesigns( - projectId: string, - routeId: number, - edits: ProfileAlignmentEdits, - standardCrossSection?: StandardCrossSection, - options?: { - /** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */ - fullDesigns?: boolean; - /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ - rockBoundaryOffsets?: Record; - /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ - berms?: Record< - string, - { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } - >; - /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ - cutSlopeRatios?: Record; - /** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */ - designChoices?: Record; - }, -): Promise { - return requestJson( - `/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; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index e7c0b586d..bc5b86ae0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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): boolean { - return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); -} - -function preserveUserFields( - next: NonNullable, - previous: CrossSection["design"], -): NonNullable { - if (!previous) return next; - const merged = { ...next } as Record; - const source = previous as unknown as Record; - for (const key of PRESERVED_KEYS) { - if (source[key] !== undefined) merged[key] = source[key]; - } - return merged as unknown as NonNullable; -} - /** * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리. * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다 @@ -170,13 +137,30 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise [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>( + "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 { const raw = readRockBoundarySession(projectId, routeId) ?? {}; const offsets = new Map(); @@ -206,65 +186,6 @@ function readRockOffsets(projectId: string, routeId: number): Map>> { - const spans = readBermSpans(projectId, routeId); - const out: Record>> = {}; - 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; - const sides: Partial> = {}; - 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; - 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, 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(); - -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("berm", projectId, input.routeId); - const bermSpans = Array.isArray(bermCopy) ? bermCopy : null; - const bermDepthAt = bermDepthReader(projectId, input.routeId); - const bermAt = (chainageM: number, design: Record): 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): 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, 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("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, - 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 { - 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; +} + +/** 계산 자리 cross = server — 모은 입력 · 상세를 서버 Node(`cross.refresh` · 같은 셈)에 넘기고 답을 제자리에 + * 얹음 · 입력 못 모음 = 셈 없음(true) · 못 받음 = false(브라우저가 셈). */ +async function refreshOnServer(input: CrossRefreshInput): Promise { + const inputs = gatherRefresh(input); + if (!inputs) return true; + const { detail } = input; + const answer = await calcOnServerOnly("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, - 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; } diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index 62220348c..1ac554e12 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -8,6 +8,8 @@ `_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이 넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다. +남는 까닭(511-B B-6) — `compute_cross_design` 안 몫 — 그 짝과 같은 까닭(B07 · 추천 · DTM 아닌 지표면 +· Node 못 부를 때). """ diff --git a/B06_Section/B06_Section_Engine_Attach.py b/B06_Section/B06_Section_Engine_Attach.py index b76e642e5..a1f6b4ce7 100644 --- a/B06_Section/B06_Section_Engine_Attach.py +++ b/B06_Section/B06_Section_Engine_Attach.py @@ -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 diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 47b03b31a..bd0611827 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -18,6 +18,7 @@ B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 ⚠ 아래 세 상수는 **임도 확정값이 아니다**. 지식DB에 임도 기준이 없어 교차 참조로 채택했고(2026-08-19 사용자 확정), 임도 기준이 확보되면 그 값으로 교체한다. +남는 까닭(511-B B-6) — B04 · B07 · B08 파이썬 소비자(관 벽 높이 · 세트). """ from __future__ import annotations diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 9a25e6a31..e4d89bc54 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -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 diff --git a/B06_Section/B06_Section_Engine_Design_Node.py b/B06_Section/B06_Section_Engine_Design_Node.py index 2f67bc54a..f56ae204f 100644 --- a/B06_Section/B06_Section_Engine_Design_Node.py +++ b/B06_Section/B06_Section_Engine_Design_Node.py @@ -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"]] diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 4b69f70ce..66a02fe02 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -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 diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 88607f211..a427db9dd 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -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: diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index 9e78a1546..da897bb6e 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -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: diff --git a/B06_Section/B06_Section_Route_Carry_Patch.ts b/B06_Section/B06_Section_Route_Carry_Patch.ts index 643eeffa0..e365281ef 100644 --- a/B06_Section/B06_Section_Route_Carry_Patch.ts +++ b/B06_Section/B06_Section_Route_Carry_Patch.ts @@ -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 => { diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 76195d937..18b0648c4 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."}, - ) diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py new file mode 100644 index 000000000..9644af226 --- /dev/null +++ b/B06_Section/B06_Section_Router_Calc.py @@ -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 diff --git a/B06_Section/B06_Section_Router_Calc_Context.py b/B06_Section/B06_Section_Router_Calc_Context.py new file mode 100644 index 000000000..543714a3d --- /dev/null +++ b/B06_Section/B06_Section_Router_Calc_Context.py @@ -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 diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 44500ae1a..19096659e 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -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 diff --git a/B06_Section/B06_Section_Router_Design_Ranges.py b/B06_Section/B06_Section_Router_Design_Ranges.py index 9f2d7f426..d8b590504 100644 --- a/B06_Section/B06_Section_Router_Design_Ranges.py +++ b/B06_Section/B06_Section_Router_Design_Ranges.py @@ -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 diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 947de6450..536ecedf6 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -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( diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 6587430e6..733a79f43 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 4fdce6dd3..548bc6fd1 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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 }; diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts deleted file mode 100644 index 8d584b5c8..000000000 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* ============================================================================= - * B06_Section_Server_Calc_Node.ts - * 브라우저에서만 돌던 횡단 계산을 **서버가 한 번 돌리는** 진입점 — 구조물 폐회로 - * 절·성토 면적 + 그것을 쌓아 만든 유토곡선. - * - * 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 이 두 값은 지금까지 브라우저에서만 - * 나왔다. 사용자가 B06 을 한 번도 안 열면 값이 없고, 저장·확정 뒤 서버가 다시 계산하면 - * 구조물을 모르는 표준값으로 되돌아갔다. 코리도(`B05_Profile_Corridor_Node.ts`)와 같은 - * 방식으로 **브라우저가 쓰는 코드를 서버가 그대로 실행**한다 — 계산을 두 벌로 짜지 않는다. - * - * 순서가 중요하다: 면적 보정을 **먼저** 얹고 그 위에서 유토곡선을 쌓는다. 화면도 같은 - * 순서다(카드를 그리며 면적을 고친 뒤 유토곡선을 낸다). - * - * 실행: node <번들> <입력.json> <출력.json> - * 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion, - * natural_spoil_min_ground_slope, haul_equipment_limits } } - * 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null, - * extra_walls: [{ chainage_m, extra_walls: [선 다단 벽…] }] } - * — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다. - * — extra_walls 는 관 주인 측점마다(빈 목록 포함) · B08 이 다단 기슭막이 줄을 세움(④). - * 끝 코드: 0 성공 / 2 인자 오류 - * ========================================================================== */ - -import { readFileSync, writeFileSync } from "node:fs"; -import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; -import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; -import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { - applyStructureAreaRows, - extraWallRows, - structureAreaRows, -} from "./B06_Section_Structure_Layouts"; - -interface ServerCalcInput { - detail?: SectionDetailResponse; - /** - * 유토 **배분만** 낼 때 쓰는 입력 — 브라우저가 누가토량(`computeMassHaul`)까지 내고 - * 그 결과를 보내면 여기서 배분·운반거리만 얹어 돌려준다(2026-09-06). - * 배분 코드를 브라우저 번들에서 빼기 위한 길이라, 이 갈래는 `detail` 을 안 받는다. - */ - haul_plan_for?: Parameters[0]; - context?: { - earthwork_conversion?: Parameters[1]; - natural_spoil_min_ground_slope?: number | null; - haul_equipment_limits?: Parameters[1]; - /** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */ - collected_stone_deduction_m3?: number | null; - /** 갈래별 채집석(㎥) — 벽 입적(자연 축)이라 곡선 쪽이 ×C 해 다짐 축에 맞춘다. */ - collected_stone_by_ground_m3?: Record | null; - /** 갈래를 못 가른 채집석(㎥) — 계수가 없어 환산하지 않는다. */ - collected_stone_ground_unknown_m3?: number | null; - /** 구조물 터파기 잔토(㎥, 양수) — B08 이 낸다. 사토에 **더한다**. */ - structure_spoil_m3?: number | null; - /** 측점별 잔토 — 오면 **그 자리**에 얹는다(운반거리가 맞다). */ - structure_spoil_points?: Array<{ - chainage_m: number; - spoil_m3: number; - /** 그 터파기의 토질 — B08 이 이미 판정한 값(품셈 9-13). `null` 이면 「지반 모름」. */ - ground_type?: string | null; - ground_label?: string | null; - ground?: string | null; - }> | null; - }; -} - -const [inputPath, outputPath] = process.argv.slice(2); -if (!inputPath || !outputPath) { - console.error("사용법: node <번들> <입력.json> <출력.json>"); - process.exit(2); -} - -const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput; - -// 배분만 내는 갈래 — 화면이 편집을 멈추면 조용히 물어보는 자리(유토곡선 배경 선반입). -if (input.haul_plan_for) { - // **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야 - // 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다. - const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, { - collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, - collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null, - collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null, - structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, - structure_spoil_points: input.context?.structure_spoil_points ?? null, - // 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다. - conversion: input.context?.earthwork_conversion ?? null, - // 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다). - drawing: true, - }); - writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); - process.exit(0); -} - -const sections: CrossSection[] = input.detail?.cross_sections ?? []; - -const areas = structureAreaRows(sections); -// 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다. -applyStructureAreaRows(sections, areas); - -// 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존). -const conversion = input.context?.earthwork_conversion; -const result = conversion - ? computeMassHaul( - sections, - conversion, - input.context?.natural_spoil_min_ground_slope ?? undefined, - ) - : null; -// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06). -// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은 -// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①). -const planFor = (drawing: boolean) => - result - ? computeHaulPlan(result, input.context?.haul_equipment_limits, { - collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, - collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null, - collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null, - structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, - structure_spoil_points: input.context?.structure_spoil_points ?? null, - conversion: conversion ?? null, - drawing, - }) - : null; -const plan = planFor(false); -const drawingPlan = planFor(true); -const massHaul = result - ? massHaulPayload(result, { - ...(plan ? { haul_plan: haulPlanPayload(plan) } : {}), - ...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}), - }) - : null; - -// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움. -const extraWalls = extraWallRows(sections); - -writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls })); diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 0b8d11742..843b6392c 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -13,8 +13,9 @@ **보안**이다 — 저장 경로가 브라우저 계산이면 유토 배분·운반거리 코드가 번들에 남아야 해서 화면에서 안 그려도 뺄 수 없다. 대가는 저장 대기 850ms → 약 980ms(Node 131ms). -**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`) -으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데 +**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 늘 떠 있는 Node(`design_calc_node.ts` · +`structure.layouts` · `masshaul.cumulative` · `haul.plan` · 511-C)로 그대로 돌린다. +파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데 수량은 저렇다」가 생긴다. 실패는 던진다 — 부르는 쪽([저장] · [확정])이 `recomputed: false` 로 화면에 알린다(388-2). @@ -38,7 +39,9 @@ from B06_Section.B06_Section_Repository import ( merge_longitudinal_section_data, ) from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_design_batches -from common_util.common_util_node_bundle import bundle_stale, run_bundle_json +from common_util.common_util_calc_runner import BUNDLE as NODE_BUNDLE +from common_util.common_util_calc_runner import run_calc, run_calc_served +from common_util.common_util_node_bundle import bundle_stale from common_util.common_util_project_settings import ( haul_equipment_limits, mixed_conversion_factors, @@ -55,9 +58,8 @@ from config.config_system import ( logger = logging.getLogger(__name__) -ROOT = Path(__file__).resolve().parents[1] -BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" -_NPM_SCRIPT = "build:server-calc" +# 곡선부 확폭 입력 칸(파이썬 `curve_widening_args` · TS `SaveSection`) +CURVE_KEYS = ("plan_radius_m", "curve_outer_side", "curve_widening_m") # 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다. # ⚠ **TS 쪽 `STRUCTURE_ROW_KEYS` 와 짝이다.** 한쪽만 늘리면 Node 가 값을 내도 여기서 조용히 # 버려진다(2026-09-08 관 길이를 더하며 실제로 걸린 자리). 시험이 두 목록을 대조한다. @@ -87,16 +89,37 @@ def _node_fingerprint(node_input: dict[str, Any]) -> str: json.dumps(node_input, sort_keys=True, default=str).encode("utf-8"), usedforsecurity=False, ) - digest.update(str(BUNDLE.stat().st_mtime_ns if BUNDLE.is_file() else 0).encode()) + digest.update(str(NODE_BUNDLE.stat().st_mtime_ns if NODE_BUNDLE.is_file() else 0).encode()) return digest.hexdigest() +_NODE_IDS = ("structure.layouts", "masshaul.cumulative", "haul.plan") + + +def _run_node(node_input: dict[str, Any]) -> dict[str, Any] | None: + """늘 떠 있는 Node(못 부르면 새 프로세스) 셋 → [저장] 답 꼴 + `{areas, mass_haul, extra_walls}`.""" + answer = run_calc_served(_NODE_IDS, node_input) + results = answer.get("results") if isinstance(answer, dict) else None + if not isinstance(results, dict): + results = run_calc(_NODE_IDS, node_input) + if not isinstance(results, dict): + return None + layouts = results.get("structure.layouts") or {} + cumulative = results.get("masshaul.cumulative") + return { + "areas": layouts.get("areas"), + "mass_haul": {**cumulative, **(results.get("haul.plan") or {})} if cumulative else None, + "extra_walls": layouts.get("extra_walls"), + } + + def _node_output(node_input: dict[str, Any]) -> Any: """같은 입력이면 지난 결과 · 아니면 Node(면적 · 유토곡선)를 돌려 기억한다.""" key = _node_fingerprint(node_input) - if key in _NODE_CACHE and not bundle_stale(BUNDLE): + if key in _NODE_CACHE and not bundle_stale(NODE_BUNDLE): return _NODE_CACHE[key] - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, node_input) + output = _run_node(node_input) if output is None: # 실패는 기억하지 않음 — 다음 [저장]이 다시 셈 return None if len(_NODE_CACHE) >= _NODE_CACHE_LIMIT: @@ -229,6 +252,105 @@ def _enforce_stored_designs( enforce_ford_surface_drops(longitudinal, sections, project_root, standard) +def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path | None: + """설계 지형이 DTM 이면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와 + 같은 npz) · DTM 아닌 지표면(tin · nurbs · …) · 못 엶 = None(파이썬 길).""" + from common_util.common_util_surface_sampler import dtm_grid_file + + params = params or {} + if params.get("method") != "dtm" or not params.get("source_filter"): + return None + models = project_root / "B04_PreProcess" / "models" + smooth = models / f"dtm_{params['source_filter']}_smooth.npz" + npz = ( + smooth + if params.get("smooth") and smooth.exists() + else smooth.with_name(f"dtm_{params['source_filter']}.npz") + ) + try: + return dtm_grid_file(npz) if npz.is_file() else None + except (OSError, ValueError, KeyError) as exc: + logger.warning("서버 재계산: DTM 격자 이진을 못 만듦 — 파이썬 길 · %s", exc) + return None + + +def _enforce_on_python( + longitudinal: dict[str, Any], + sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None, + surface: dict[str, Any] | None, +) -> None: + """파이썬 길(DTM 아닌 지표면 · Node 못 부름) — 설계선 → (미교차면 표본기) → 넓힘 · + 포장 · 하강.""" + from B06_Section.B06_Section_Router_Design import enforce_design_lines + + # 모든 측점 설계선을 저장 입력으로 먼저 다시 냄(139-6 · 노견 확장 · 측구 · 지반유형) — 미교차 + # 판정도 새 설계로 해야 넓힘 지표면을 연다. + enforce_design_lines(longitudinal, sections, project_root, standard) + # 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀). + unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections) + sampler = _surface_sampler(project_root, surface) if unclosed else None + _enforce_stored_designs(longitudinal, sections, project_root, standard, sampler) + + +def _enforce_on_node( + longitudinal: dict[str, Any], + sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None, + grid: Path, +) -> bool: + """Node `cross.enforce` 한 부름(TS `B06_Section_Cross_Save.enforceSavedCross`) — 측점 설계 · + 넓힌 표본을 자리에서 갈고 넓힌 표본은 횡단 파일에 남김. 못 부르면 False(파이썬 길).""" + from B05_Profile.B05_Profile_Engine_Sections import find_cross_file + from B05_Profile.B05_Profile_Structures_Schema import load_definition + from B06_Section.B06_Section_Engine_Design_Node import facility_inputs, full_standard + from common_util.common_util_calc_runner import run_calc + from common_util.common_util_json import atomic_write_json + from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + + keys = ("chainage_m", "design", "samples", "frame", *CURVE_KEYS) + profiles = longitudinal.get("design_profiles") or [] + results = run_calc( + ["cross.enforce"], + { + "definition": load_definition(), + "cross_enforce": { + "profile": (profiles[0] or {}).get("samples") or [] if profiles else [], + "sections": [{k: s.get(k) for k in keys if k in s} for s in sections], + "standard": full_standard(standard), + "rockDefaultM": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + **facility_inputs(project_root), + "grid": str(grid), + }, + }, + ) + if results is None: + logger.warning("서버 재계산: Node 횡단 한 바퀴 실패 — 파이썬 길") + return False + cross_dir = project_root / "B06_Section" / "cross_sections" + for section, row in zip(sections, results["cross.enforce"], strict=True): + if not row: + continue + section["design"] = row["design"] + if row.get("samples"): + section["samples"] = row["samples"] + cross_path = find_cross_file(cross_dir, float(section.get("chainage_m") or 0.0)) + if cross_path.is_file(): + stored = json.loads(cross_path.read_text(encoding="utf-8")) + atomic_write_json(cross_path, {**stored, "samples": row["samples"]}) + info = row.get("extended") or {} + logger.info( + "미교차 샘플 넓힘: 측점 %.3f 좌 +%sm 우 +%sm 지표면 끝 %s", + float(section.get("chainage_m") or 0.0), + info.get("left_m"), + info.get("right_m"), + info.get("surface_end"), + ) + return True + + def refresh_alignment(longitudinal: dict[str, Any]) -> dict[str, Any] | None: """종단 경고를 저장 입력(base_pvi · 편집 · 정책 · 기준)으로 지금 코드가 다시 셈 — 옛 코드가 쓴 경고가 남지 않게(472 덧 · 요구 272). 기하(계획고 · 곡선 · 표본 · 위반)는 저장값 그대로 — @@ -324,24 +446,26 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: standard = stored_standard_cross_section(longitudinal_row) # 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다. before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections] - # 모든 측점 설계선을 저장 입력으로 먼저 다시 냄(139-6 · 노견 확장 · 측구 · 지반유형) — 미교차 - # 판정도 새 설계로 해야 넓힘 지표면을 연다. - from B06_Section.B06_Section_Router_Design import enforce_design_lines - - await asyncio.to_thread( - enforce_design_lines, detail.get("longitudinal") or {}, sections, project_root, standard - ) - # 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀). - unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections) - sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None - await asyncio.to_thread( - _enforce_stored_designs, + # 횡단 한 바퀴(설계선 → 미교차 넓힘 → 포장 → 세월교 하강) — DTM 이면 Node 한 부름 + # (화면과 같은 TS · 511-B B-4) · DTM 아닌 지표면 · Node 못 부름 = 파이썬 길 + grid = await asyncio.to_thread(design_grid_file, project_root, surface) + on_node = grid is not None and await asyncio.to_thread( + _enforce_on_node, detail.get("longitudinal") or {}, sections, project_root, standard, - sampler, + grid, ) + if not on_node: + await asyncio.to_thread( + _enforce_on_python, + detail.get("longitudinal") or {}, + sections, + project_root, + standard, + surface, + ) fixed = [ item for index, item in enumerate(sections) diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 532cef832..78b91350d 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -22,6 +22,7 @@ import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; import { afterAnySwap } from "./B06_Section_Swap_Bus"; import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import { chainageKey, looseChainage, type StationKeyRole } from "@util/common_util_station_key"; import { inferStationInterval } from "@util/common_util_svg"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -253,12 +254,22 @@ export function carryStationDrafts( projectId: string, fromRouteId: number, toRouteId: number, - { quiet = false }: { quiet?: boolean } = {}, + { quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number } = {}, ): number { - const { states, outside } = carryKeyedStates( - (name) => readRaw(name, projectId, fromRouteId), - lists.get(scopeOf(projectId, fromRouteId)), - lists.get(scopeOf(projectId, toRouteId)), + // 계산 자리 carry = server 면 잠잠 뒤 같은 입력 서버 셈 → 다르면 그 칸을 서버 값으로(applyTo · 511-A) + const read = Object.fromEntries( + KEYED_STATES.map((name) => [name, readRaw(name, projectId, fromRouteId)]), + ) as Record; + const { states, outside } = calcChecked( + "carry", + "stations.carry_keyed", + (from: typeof read, before?: Keyed[], after?: Keyed[]) => + carryKeyedStates((name) => from[name], before, after), + [read, lists.get(scopeOf(projectId, fromRouteId)), lists.get(scopeOf(projectId, toRouteId))], + { + apply: (server) => + KEYED_STATES.forEach((name) => writeState(name, server.states[name], projectId, applyTo)), + }, ); for (const name of KEYED_STATES) writeState(name, states[name], projectId, toRouteId); if (outside.size && !quiet) @@ -275,7 +286,7 @@ const NEXT_ROUTE = -1; */ export function carryStationKeys(projectId: string, routeId: number, next: Keyed[]): number { lists.set(scopeOf(projectId, NEXT_ROUTE), next); - const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE); + const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId }); for (const name of KEYED_STATES) { writeState(name, readRaw(name, projectId, NEXT_ROUTE), projectId, routeId); writeState(name, null, projectId, NEXT_ROUTE); diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md index 1243b4846..2eddefab5 100644 --- a/B06_Section/CLAUDE.md +++ b/B06_Section/CLAUDE.md @@ -19,7 +19,7 @@ 서버 Node 묶음: -- 입구 `B06_Section_Server_Calc_Node.ts`(package.json `build:server-calc`) · 끌어감 = `UI_Cross_Culvert*` · `_Box_Geom` · `_Ford_Geom` · `_Wall*` · `_Revetment` · `_Lean` · `_Excavation` · `_Structure_Cut` · `_Body_Guard` · `Structure_Layouts` · `UI_Section_Common` +- [저장] 면적 · 유토곡선 = A00 `design_calc_node`(`build:design-calc` · `structure.layouts` · `masshaul.cumulative` · `haul.plan`) · 끌어감 = `UI_Cross_Culvert*` · `_Box_Geom` · `_Ford_Geom` · `_Wall*` · `_Revetment` · `_Lean` · `_Excavation` · `_Structure_Cut` · `_Body_Guard` · `Structure_Layouts` · `UI_Section_Common` - `build:corridor` · `build:b07-extent` 도 `UI_Cross_*` 구조물 그림을 끌어감 — 고치면 해당 build 다시 지어 시험 - `build:design-calc`(A00 `design_calc_node`)가 `Route_Carry_Patch` · `logic/Route_Carry` · `logic/Design_Keys` 를 끌어감(노선 바뀐 [저장] 넘김) — 고치면 다시 지어 `test_472_6_route_save` - 입구 경로를 시험이 씀(`test_b06_ground_unlock.py` · `test_b06_height_unlock.py` 등) · 묶음이 끄는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 diff --git a/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts new file mode 100644 index 000000000..4a01067a2 --- /dev/null +++ b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts @@ -0,0 +1,303 @@ +/* ============================================================================= + * B06_Section_Cross_Refresh_Calc.ts + * 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은 + * 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node + * (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값. + * 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`. + * 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽). + * ========================================================================== */ + +import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans"; +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 { computeCrossDesign } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { DtmGrid } from "@util/common_util_surface_sampler"; +import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch"; +import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session"; +import { + planElevationAt, + type ProfileAlignment, +} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys"; + +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; +type SideValues = Partial>; + +/** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */ +export interface RefreshInputs { + /** 화면이 쥔 계획선(측점 한 벌 · 342-1). */ + alignment: ProfileAlignment; + standard: StandardCrossSectionSpec; + /** 암 경계 세션 오프셋 · 기본 오프셋(config). */ + rockOffsets: Array<[number, number]>; + rockDefault: number | null; + /** 측점별 암 절토 경사(0 = 표준값). */ + cutSlopes: Array<[number, number]>; + /** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */ + bermSpans: BermSpan[] | null; + bermDepths: Array<[number, SideValues]>; + /** 카드 선택(지반 · 측구 · 2단 경사 …). */ + choices: Array<[number, CrossDesignChoice]>; + shoulderExtends: Array<[number, SideValues]>; + /** 저장 정본 설계(세션 고름 ?? 정본). */ + canon: Array<[number, unknown]>; +} + +/** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */ +export type RefreshMemo = WeakMap< + CrossSection, + { samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] } +>; + +/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; + +/** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */ +export function rockKey(chainageM: number): number { + return Math.round(chainageM * 100) / 100; +} + +/** + * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. + * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. + */ +export function pavedBySpecs(section: Pick): boolean { + return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); +} + +function preserveUserFields( + next: NonNullable, + previous: CrossSection["design"], +): NonNullable { + if (!previous) return next; + const merged = { ...next } as Record; + const source = previous as unknown as Record; + for (const key of PRESERVED_KEYS) { + if (source[key] !== undefined) merged[key] = source[key]; + } + return merged as unknown as NonNullable; +} + +/** 저장분 소단 제원(`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; + 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(session: Map) { + return (chainageM: number, design: Record, 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); + }; +} + +/** + * 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리. + * 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌). + */ +export function refreshDesigns( + detail: SectionDetailResponse, + inputs: RefreshInputs, + grid: DtmGrid | null, + memos?: RefreshMemo, +): number[] { + const { alignment, standard } = inputs; + 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 = new Map(inputs.rockOffsets); + const rockDefault = inputs.rockDefault; + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = new Map(inputs.cutSlopes); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm` + // (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467). + const bermSpans = inputs.bermSpans; + const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths)); + const bermAt = (chainageM: number, design: Record): 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): 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 = new Map(inputs.choices); + const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); + // 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로 + const shoulderExtends = new Map(inputs.shoulderExtends); + const extendAt = (chainageM: number, design: Record, 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(받은 뒤 다시 셈이 넓힘 · 지문에 실음) + // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로 + // (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계 + const canon = new Map(inputs.canon); + 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, + 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; +} diff --git a/B06_Section/logic/B06_Section_Cross_Save.ts b/B06_Section/logic/B06_Section_Cross_Save.ts new file mode 100644 index 000000000..b9cf7a1d8 --- /dev/null +++ b/B06_Section/logic/B06_Section_Cross_Save.ts @@ -0,0 +1,337 @@ +/* ============================================================================= + * B06_Section_Cross_Save.ts + * [저장] · [확정] 서버 횡단 한 바퀴(PLAN 511-B B-4 · 서버 Node `cross.enforce`) — 저장 입력으로 모든 측점 설계선 + * 다시 냄 → 미교차 넓힘(DTM 격자) → 포장 구간 → 세월교 노면 하강. 차례 · 승계 · 입력 = 파이썬 그대로. + * + * ⚠ ① 짝 — `B06_Section_Router_Design.py` `enforce_design_lines` · `enforce_pavement_ranges` · + * `enforce_ford_surface_drops` + `B06_Section_Engine_SampleExtend.py` `extend_unclosed_sections` + * (서버 `_enforce_stored_designs` 차례 · DTM 아닌 지표면은 파이썬 길) · 저장분 입력 읽기 + * (`stored_cut_slope` · `berm_from` · `stored_shoulder_extend` · `stored_tilt` · `curve_widening_args`) · + * 계획고 = `design_elevation_from_longitudinal`. 거울 시험 `test_511_b4_cross_save_mirror.py`. + * crossOptions = 파이썬 `compute_cross_design` 키 → TS 짝 키(Node `cross.design` 도 이것). + * 순수 — 화면 · fetch · 세션 없음. + * ========================================================================== */ + +import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; +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 { 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 type { DtmGrid } from "@util/common_util_surface_sampler"; +import { + extendUnclosed, + type CrossGroundPoint, + type ExtendInfo, + type UnclosedDesign, +} from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys"; +import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce"; + +type Raw = Record; + +/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */ +const CROSS_OPTION_KEYS: Record = { + 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; + 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, + }; +} + +/** 파이썬 키 입력 → 엔진 입력(모르는 키 = 던짐). */ +export function crossOptions( + raw: Raw, + standard: CrossDesignOptions["standard"], +): CrossDesignOptions { + const options: Raw = { 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; +} + +const isNum = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +/** 파이썬 `stored_cut_slope` — 측점별 암 절토 경사 · 없거나 0 = null. */ +const storedCutSlope = (design: Raw): number | null => + isNum(design.cut_slope_ratio_user) && design.cut_slope_ratio_user > 0 + ? design.cut_slope_ratio_user + : null; + +/** 파이썬 `berm_from` — 소단 제원 dict + 쪽마다 옮긴 깊이 → BermSpec(python 키). */ +function bermFrom(spec: unknown, sides: Raw): Raw | null { + if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null; + const own = (side: string): number | null => { + const value = sides[`berm_depth_${side}_m`]; + return isNum(value) ? Math.max(value, 0) : null; + }; + const s = spec as Raw; + const field = (key: string, fallback: number): number => Number(s[key] ?? fallback); + const out = { + width_m: field("width_m", BERM_DEFAULT_WIDTH_M), + interval_m: field("interval_m", BERM_DEFAULT_INTERVAL_M), + slope_deg: field("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: Math.max(field("depth_m", BERM_DEFAULT_DEPTH_M), 0), + }; + if (!Object.values(out).every(Number.isFinite)) return null; // 파이썬 TypeError · ValueError + return { ...out, depth_left_m: own("left"), depth_right_m: own("right") }; +} + +const storedBerm = (design: Raw): Raw | null => bermFrom(design.berm, design); + +/** 파이썬 `stored_shoulder_extend` — 없거나 음수 = 0. */ +const storedShoulder = (design: Raw): Raw => + Object.fromEntries( + (["left", "right"] as const).map((side) => { + const value = design[`shoulder_extend_${side}_m`]; + return [`shoulder_extend_${side}_m`, isNum(value) ? Math.max(value, 0) : 0]; + }), + ); + +/** 파이썬 `stored_tilt`. */ +const storedTilt = (design: Raw): { tilted: boolean; tilt_pct: number | null } => ({ + tilted: Boolean(design.tilted), + tilt_pct: isNum(design.tilt_pct) ? design.tilt_pct : null, +}); + +/** 파이썬 `curve_widening_args`. */ +const curveArgs = (section: Raw): Raw => ({ + plan_radius_m: section.plan_radius_m ?? null, + curve_outer_side: section.curve_outer_side ?? null, + curve_widening_m: section.curve_widening_m ?? null, +}); + +/** 파이썬 `design_elevation_from_longitudinal` — 계획선 표본 선형 보간 · 범위 밖 끝값 · 없으면 null. */ +export function elevationAt( + samples: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>, + chainageM: number, +): number | null { + const rows = samples.filter((s) => isNum(s.elevation_m) && isNum(s.chainage_m)) as Array<{ + chainage_m: number; + elevation_m: number; + }>; + if (!rows.length) return null; + if (chainageM <= rows[0].chainage_m) return rows[0].elevation_m; + const last = rows[rows.length - 1]; + if (chainageM >= last.chainage_m) return last.elevation_m; + for (let index = 1; index < rows.length; index += 1) { + const [previous, current] = [rows[index - 1], rows[index]]; + if (chainageM > current.chainage_m) continue; + const span = current.chainage_m - previous.chainage_m; + if (span <= 0) return current.elevation_m; + const ratio = (chainageM - previous.chainage_m) / span; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + } + return last.elevation_m; +} + +/** 저장분 공통 입력(파이썬 넷이 같은 꼴로 모음) — 포장 · 하강 · 기울임은 부르는 자리가 덮음. */ +function storedOptions(design: Raw, section: Raw, rockDefaultM: number): Raw { + return { + ground_type: String(design.ground_type || "ripping_rock"), + section_mode: String(design.section_mode || "left_cut"), + ditch_side: design.ditch_side ?? null, + ditch_type: String(design.ditch_type || "standard"), + paved: Boolean(design.paved), + rock_boundary_offset_m: + "rock_boundary_offset_m" in design ? design.rock_boundary_offset_m : rockDefaultM, + two_stage_slope: Boolean(design.two_stage_slope), + cut_slope_ratio: storedCutSlope(design), + ditch_enabled: design.ditch_enabled ?? null, + ditch_choice: design.ditch_choice ?? null, + berm: storedBerm(design), + ...curveArgs(section), + ...storedShoulder(design), + }; +} + +/** 측점 하나(서버 상세 꼴) — 쓰는 칸만. */ +export interface SaveSection { + chainage_m: number; + design?: Raw | null; + samples?: CrossGroundSample[]; + frame?: { origin?: { x: number; y: number }; left_xy?: [number, number] }; + plan_radius_m?: number | null; + curve_outer_side?: string | null; + curve_widening_m?: number | null; +} + +export interface SaveCrossInput { + /** 종단 계획선 표본(`design_profiles[0].samples`). */ + profile: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>; + sections: SaveSection[]; + standard: CrossDesignOptions["standard"]; + rockDefaultM: number; + pipes: readonly CulvertPipePoint[]; + /** 구조물 정본 — null = 못 읽음(소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음). */ + structures: readonly FacilityStructure[] | null; +} + +/** 측점마다 결과 — 설계 없는 측점 null · 넓혔으면 표본 · 넓힘 기록. */ +export interface SaveCrossRow { + design: Raw; + samples?: CrossGroundSample[]; + extended?: ExtendInfo; +} + +/** [저장] 횡단 한 바퀴 — 파이썬 `_recompute` 의 설계선 · 넓힘 · 포장 · 하강 넷(차례 그대로 · 제자리 아님). */ +export function enforceSavedCross( + input: SaveCrossInput, + grid: () => DtmGrid | null, +): Array { + const ranges = facilityRanges(input.pipes, input.structures ?? []); + const spans = input.structures + ? bermSpansFromStructures(input.structures as Parameters[0]) + : null; + const rows = input.sections.map((section): SaveCrossRow | null => + section.design && typeof section.design === "object" ? { design: section.design } : null, + ); + const elevation = (section: SaveSection) => elevationAt(input.profile, section.chainage_m); + const compute = (samples: CrossGroundSample[] | undefined, z: number | null, raw: Raw) => { + try { + return computeCrossDesign( + samples ?? [], + z, + crossOptions(raw, input.standard), + ) as unknown as Raw; + } catch { + return null; // 파이썬 ValueError · Node { error } 자리 — 저장분 그대로 + } + }; + /** 다시 세운 설계에 사용자 값 · 상태 승계(파이썬 `apply_designs`). */ + const keep = (next: Raw, from: Raw): Raw => { + for (const key of ["status", "pavement_suggested", ...USER_TOUCHED_KEYS]) + if (from[key] !== undefined && from[key] !== null) next[key] = from[key]; + return next; + }; + const at = (section: SaveSection) => facilityAt(ranges, section.chainage_m); + const sectionRaw = (section: SaveSection) => section as unknown as Raw; + + // ① 모든 측점 설계선(`enforce_design_lines`) — 소단 = 구조물 정본 구간 · 하강 · 기울임 = 지금 시설 + input.sections.forEach((section, index) => { + const row = rows[index]; + if (!row) return; + const design = spans + ? { ...row.design, berm: bermSpecAt(spans, section.chainage_m) } + : row.design; + const facility = at(section); + const next = compute(section.samples, elevation(section), { + ...storedOptions(design, sectionRaw(section), input.rockDefaultM), + surface_drop_m: facility.surfaceDropM, + tilted: facility.tilted, + tilt_pct: facility.tiltPct, + }); + if (next) row.design = keep(next, design); + }); + // ② 미교차 넓힘(`extend_unclosed_sections`) — 열린 쪽만 +5 m · DTM 격자 · 넓힌 표본으로 다시 셈 + const open = rows.some((row) => row?.design.slope_unclosed); + const map = open ? grid() : null; + input.sections.forEach((section, index) => { + const row = rows[index]; + const origin = section.frame?.origin; + const left = section.frame?.left_xy; + if (!map || !row?.design.slope_unclosed || !origin || !left) return; + const design = row.design; + const z = elevation(section); + const raw = { + ...storedOptions(design, sectionRaw(section), input.rockDefaultM), + surface_drop_m: at(section).surfaceDropM, + ...storedTilt(design), + }; + let wide; + try { + wide = extendUnclosed( + (section.samples ?? []) as Array>, + [origin.x, origin.y], + left, + (samples) => { + const next = compute(samples as CrossGroundSample[], z, raw); + if (!next) throw new Error("넓힘 셈 못 함"); + return next as unknown as UnclosedDesign; + }, + map, + ); + } catch { + return; // 파이썬 ValueError · KeyError — 그 측점 그대로 + } + if (!wide || (!wide.info.left_m && !wide.info.right_m)) return; + const samples = wide.samples as CrossGroundSample[]; + const next = compute(samples, z, raw); + if (!next) return; + rows[index] = { design: keep(next, design), samples, extended: wide.info }; + }); + const samplesOf = (index: number) => rows[index]?.samples ?? input.sections[index].samples; + // ③ 포장 구간(`enforce_pavement_ranges`) — 포장 · 기울임이 저장분과 다른 측점만 + input.sections.forEach((section, index) => { + const row = rows[index]; + if (!row) return; + const design = row.design; + const facility = at(section); + const tilt = storedTilt(design); + if ( + Boolean(design.paved) === facility.paved && + tilt.tilted === facility.tilted && + tilt.tilt_pct === facility.tiltPct + ) + return; + const next = compute(samplesOf(index), elevation(section), { + ...storedOptions(design, sectionRaw(section), input.rockDefaultM), + paved: facility.paved, + surface_drop_m: facility.surfaceDropM, + tilted: facility.tilted, + tilt_pct: facility.tiltPct, + }); + if (next) row.design = keep(next, design); + }); + // ④ 세월교 노면 하강(`enforce_ford_surface_drops`) — 하강이 저장분과 다른 측점만 + input.sections.forEach((section, index) => { + const row = rows[index]; + if (!row) return; + const design = row.design; + const wanted = at(section).surfaceDropM; + if (Math.abs(wanted - Number(design.surface_drop_m || 0)) < 1e-6) return; + const next = compute(samplesOf(index), elevation(section), { + ...storedOptions(design, sectionRaw(section), input.rockDefaultM), + surface_drop_m: wanted, + ...storedTilt(design), + }); + if (next) row.design = keep(next, design); + }); + return rows; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 4c11b847a..68d6700e7 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -4,15 +4,29 @@ (`build:design-calc`). 부름은 `common_util_node_bundle.run_bundle_json` 한 길 · 계산 없음. `NODE_IDS` = 등록부에서 실행기가 `node:` 인 줄(같은지 시험 `test_472_3_calc_runner.py`). 입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`). +`run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) — +늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 · +시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) · 화면 길 `PAGE_IDS`(B-4) · +유토 배분 화면 꼴 `VIEW_IDS`(511-C) 도 받음. """ from __future__ import annotations +import atexit +import json +import subprocess +import threading +import time from collections.abc import Sequence from pathlib import Path from typing import Any -from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_node_bundle import ( + build_bundle, + bundle_stale, + node_env, + run_bundle_json, +) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js" @@ -25,14 +39,47 @@ NODE_IDS = frozenset( "excavation.trench", "corridor.mesh", "profile.seat", + "profile.base", + "profile.alignment", + "profile.carry", "facility.plus_follow", "facility.follow", "cross.design", "cross.berm_spans", "facility.rebase", "stations.carry", + "facility.attach", + "cross.enforce", + "stations.list", + "stations.sections", + "route.polyline", + "stations.plan", } ) +# 창구(`/calc`)가 안 받는 id — 서버 파일 경로를 입력으로 받음(측점 생성 · 511-B B-2) +SERVER_ONLY_IDS = frozenset({"stations.sections"}) +# 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로 +TRIAL_IDS = frozenset( + { + "masshaul.series", + "stations.carry_keyed", + "route.handle", + "route.station_fit", + "stations.swap", + "profile.tools", + "cross.fit", + "footprint.plan", + } +) +# 묶음 단계(`design_calc_node_stages.ts` `STAGE_IDS` 와 같음 · 511-B) — 한 요청 = 묶음 한 바퀴 +STAGE_IDS = frozenset( + {"stage.route", "stage.follow", "stage.stations", "stage.profile", "stage.cross"} +) +# 화면 길(`design_calc_node_cross.ts` `PAGE_IDS` · 511-B B-4) — cross = server 저장 노선 다시 셈 +PAGE_IDS = frozenset({"cross.refresh"}) +# 유토 배분 화면 꼴(`design_calc_node.ts` `haul.view` · /haul-plan 창구 · 511-C) +VIEW_IDS = frozenset({"haul.view"}) +SERVE_TIMEOUT_S = 60 def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: @@ -40,5 +87,78 @@ def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | Non unknown = [item for item in ids if item not in NODE_IDS] if unknown: raise ValueError(f"Node 실행기 아닌 id: {', '.join(unknown)}") - output = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs}) - return output.get("results") if isinstance(output, dict) else None + # 늘 떠 있는 Node(511-B B-4 · 부름당 새 프로세스 0.1~0.2 s 덞) · 못 띄우면 새 프로세스 한 번 + answer = _SERVED.call({"run": list(ids), "inputs": inputs}) + if answer is None: + answer = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs}) + return answer.get("results") if isinstance(answer, dict) else None + + +class _Served: + """늘 떠 있는 Node 하나 — 한 번에 한 요청(잠금) · 답 없음 · 죽음 = 끄고 다음 부름에 다시.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.proc: subprocess.Popen[str] | None = None + self.stamp = 0 + + def _start(self) -> subprocess.Popen[str]: + self.stop() + self.proc = subprocess.Popen( # noqa: S603 — 고정 실행 파일 · 입력은 표준 입력 한 줄 + ["node", str(BUNDLE), "--serve"], + cwd=str(ROOT), + env=node_env(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + self.stamp = BUNDLE.stat().st_mtime_ns + return self.proc + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + self.proc.kill() + self.proc = None + + def call(self, payload: dict[str, Any]) -> dict[str, Any] | None: + line = json.dumps(payload, default=float) + "\n" + with self.lock: + if bundle_stale(BUNDLE) and not build_bundle(NPM_SCRIPT): + return None + for _ in range(2): # 죽은 프로세스에 쓴 첫 부름 = 다시 띄워 한 번 더 + proc = self.proc + if not proc or proc.poll() is not None or BUNDLE.stat().st_mtime_ns != self.stamp: + proc = self._start() + timer = threading.Timer(SERVE_TIMEOUT_S, proc.kill) + timer.start() + try: + proc.stdin.write(line) # type: ignore[union-attr] + proc.stdin.flush() # type: ignore[union-attr] + answer = proc.stdout.readline() # type: ignore[union-attr] + except OSError: + answer = "" + finally: + timer.cancel() + if answer: + return json.loads(answer) + self.stop() + return None + + +_SERVED = _Served() +atexit.register(_SERVED.stop) + + +def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: + """창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None.""" + unknown = [ + item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS | PAGE_IDS | VIEW_IDS + ] + if unknown: + raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}") + t0 = time.perf_counter() + answer = _SERVED.call({"run": list(ids), "inputs": inputs}) + if answer is not None: + answer["wall_ms"] = round((time.perf_counter() - t0) * 1000, 2) + return answer diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index cc84fe57d..a8dce730d 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -1,9 +1,13 @@ """계산 자리 판(PLAN 504-2 · 요구 309-2) — 시스템 관리자 「계산 자리」 창이 읽고 쓰는 판 하나. 판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`. -both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길. -판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. -묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. +both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길 +(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구 · +cross = 511-B B-4 묶음 cross 단계 · 저장 노선 다시 셈). +판이 없거나 깨지면 기본값(줄 `default` · 없으면 both = 등록부 그대로 · haul = server). +haul(E2 · 511-C · 요구 321) both = 브라우저가 배분 모듈을 그때만 불러 셈 · server = /haul-plan. +[저장] · [확정] 서버 정본 길은 판을 안 읽음. +묶음 13 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. """ from __future__ import annotations @@ -24,54 +28,90 @@ GROUPS: tuple[dict[str, Any], ...] = ( { "key": "quantity", "nos": ["E1", "F7", "F8", "C4"], - "switchable": False, - "fixed": "both", - "why": "later", + "switchable": True, + "fixed": None, + "why": "quantity", }, - {"key": "carry", "nos": ["R9", "F10"], "switchable": False, "fixed": "both", "why": "later"}, + {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, + # 묶음 단계(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-3 ~ B-5 끝난 줄부터 엶 + # 노선(511-B B-5 · 노선 저장 폴리라인 = Node `route.polyline` · 조작 중 「서버」 = 같은 id) + {"key": "route", "nos": ["R1", "R3"], "switchable": True, "fixed": None, "why": None}, + # 따라감(511-B B-5 · 서버 정본 Node · 조작 중 「서버」 = 잠잠 뒤 창구 맥락 + route · follow 두 단계) { - "key": "stages", - "nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"] - + ["P8", "C1", "C2", "C3", "C5", "C6", "F1", "F2", "F3", "F4"], - "switchable": False, - "fixed": "both", - "why": "pair_later", + "key": "follow", + "nos": ["P5", "P6", "F1", "F2"], + "switchable": True, + "fixed": None, + "why": None, }, + # 측점(511-B B-2 · 서버 측점 생성 = Node `stations.sections` · 조작 중 「서버」 = `stations.list`) { - "key": "structure_pair", - "nos": ["F5", "F6", "F9"], - "switchable": False, - "fixed": "both", - "why": "pair", + "key": "stations", + "nos": ["R5", "R6", "R7", "F3"], + "switchable": True, + "fixed": None, + "why": None, }, + # 종단 = 짝 모음 끝(B-3 · 서버 Node `profile.base` · `profile.alignment`) — 엶 + { + "key": "profile", + "nos": ["P1", "P2", "P3", "P4", "P8"], + "switchable": True, + "fixed": None, + "why": None, + }, + # cross = 짝 모음 끝(B-4) — server = 묶음 cross 를 서버 Node 가 같은 TS 로(DTM 아닌 지표면 = 브라우저) + { + "key": "cross", + "nos": ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], + "switchable": True, + "fixed": None, + "why": "cross", + }, + # 폼 기본값 도구(B04 · B08 파이썬 · 고를 것 없음) + {"key": "form_default", "nos": ["F9"], "switchable": False, "fixed": "both", "why": None}, + # 유토 배분 — 기본 server(지금 /haul-plan 그대로) · both = 브라우저 셈(511-C) + { + "key": "haul", + "nos": ["E2"], + "switchable": True, + "fixed": None, + "why": "haul", + "default": "server", + }, + # 서버만 — 줄마다 까닭(`whys` · 511-C) { "key": "fixed_server", - "nos": ["E2", "G3", "D1", "D2", "N1"], + "nos": ["G3", "D1", "D2", "N1"], "switchable": False, "fixed": "server", "why": "server", + "whys": {n: f"server_{n}" for n in ("G3", "D1", "D2", "N1")}, }, { "key": "fixed_browser", "nos": ["R2", "R4", "R8", "P7", "C7", "D4"], - "switchable": False, + "switchable": True, "fixed": "browser", "why": "browser", }, ) -SWITCHABLE = frozenset(g["key"] for g in GROUPS if g["switchable"]) +# 판 줄 차례 = 묶음 차례(파일 늘 같은 꼴) +ORDER = tuple(g["key"] for g in GROUPS if g["switchable"]) +SWITCHABLE = frozenset(ORDER) LOCKED = frozenset(g["key"] for g in GROUPS if not g["switchable"]) +DEFAULTS = {g["key"]: g.get("default", "both") for g in GROUPS if g["switchable"]} def read_sites() -> dict[str, Any]: - """판 — 없음 · 깨짐 · 모르는 값 = 그 줄 기본값(both).""" + """판 — 없음 · 깨짐 · 모르는 값 = 그 줄 기본값(`DEFAULTS`).""" try: raw = json.loads(PATH.read_text(encoding="utf-8")) except (OSError, ValueError): raw = {} stored = raw.get("sites") if isinstance(raw, dict) else None stored = stored if isinstance(stored, dict) else {} - sites = {key: stored.get(key) if stored.get(key) in VALUES else "both" for key in SWITCHABLE} + sites = {k: stored.get(k) if stored.get(k) in VALUES else DEFAULTS[k] for k in ORDER} version = raw.get("version") if isinstance(raw, dict) else None return {"version": version if isinstance(version, int) else 0, "sites": sites} diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index d69e9ad4d..47dbabc59 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -20,6 +20,8 @@ 그래서 지식DB 에도 적지 않는다(사용자 지시). · 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** · 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음. +남는 까닭(511-B B-6) — `compute_cross_design` 안 몫 — 그 짝과 같은 까닭(B07 · 추천 · DTM 아닌 지표면 +· Node 못 부를 때). """ import logging diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index ac93ca754..bd883621d 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -172,25 +172,20 @@ def build_detail( confirmed_chainages: list[float] | None = None, routing: RoadRouting | None = None, upstream_lonlat: list[list[list[float]]] | None = None, - auto: str | None = None, ) -> DrainageDetail | None: """B04 분석 결과를 읽어 관 자리를 정하고 세부유역을 나눈다. `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집 · 빈 목록 = 관 0개), None 이면 B04의 기본 관(세류선 교차)만. 어느 쪽이든 격자 해석은 하지 않는다. - `auto`([자동 제안] · 369) — None = 위 그대로 · "all" = 기본 관 + 최대 간격 보충으로 다시 - (확정 목록 무시) · "add" = 확정 목록 + 빈 구간 보충. 관 자리 후보는 어느 길이든 셈. + 관 자리 후보(`pipe_candidates`)는 어느 길이든 셈 — [자동 제안](510)이 이 후보를 씀. `routing` · `upstream_lonlat` 을 주면 파일 대신 그것(「지금 노선으로 셈」 메모리 결과 · 347-2). """ routing = routing or read_road_routing(directory) if routing is None or len(vertices) < 2: return None - if confirmed_chainages is not None and auto != "all": + if confirmed_chainages is not None: pipes = pipes_from_chainages(vertices, confirmed_chainages) - if auto == "add": - pipes = [*pipes, *suggest_pipe_positions(vertices, pipes, routing.strength_curve)] - pipes.sort(key=lambda pipe: pipe.chainage_m) else: # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. base = [ @@ -203,7 +198,7 @@ def build_detail( for pipe in routing.base_pipes ] # 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만 - placed = place_pipes(vertices, base, routing.strength_curve, fill=auto is not None) + placed = place_pipes(vertices, base, routing.strength_curve, fill=False) pipes = snap_candidates(placed, vertices) if confirmed_chainages: kept = pipes_from_chainages(vertices, confirmed_chainages) diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 0895ce5bf..b31b98b98 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -500,6 +500,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) point.summary_item = ref.summary_item point.group_items = ref.group_items point.id = ref.id + point.stream_label = ref.stream_label # 화면이 실어 온 라벨(510 · 없으면 저장 때 id 로) # 땅 위 자리도 — 버리면 저장이 노선 위 측점 자리로 채워 정본 좌표를 덮음(493-4) point.x, point.y = ref.x, ref.y return ensure_pipe_ids(base) diff --git a/common_util/common_util_excavation.py b/common_util/common_util_excavation.py deleted file mode 100644 index 61cfffe2c..000000000 --- a/common_util/common_util_excavation.py +++ /dev/null @@ -1,126 +0,0 @@ -"""터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.** - -⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_excavation.ts`. - 거울 시험: `resources/tester/test_excavation_mirror.py` (고칠 때 같이 돌릴 것). - 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다. - -① 관 — **법정 시방서에 표가 있다** - `KCS 44 40 10 배수관(23.01) §3.2(4) 그림 3.2-1`. - 「설계도서에 따로 표기되지 않았을 때에는 그림 3.2-1 터파기 기준 폭에 따라야 한다」 - ⇒ 기본값으로 쓰고 사용자가 덮어쓰는 구조가 원문과 맞다. - ⚠ **관경 + 2b 로 계산하지 않는다** — 표가 관 두께·작업여유를 이미 담고 있다 - (Ø800 은 800+2×300=1400 이 아니라 **1600**). - 여유폭 b 는 같은 그림의 값: 보통지반 0.30 m · 연약지반 0.50 m(한쪽). - -② 벽(돌쌓기·기슭막이) — **법정 근거 없음. 실무 정본 식이 있다** - 법령·교본은 「설계도서 지시 폭·기울기·깊이」까지만 정한다 - (지식DB `01_임도/02_상세설계/구조물/구조물_기초_토공.md` §1 — 전수 확인). - 실무 정본 `original/구조물도/기슭막이/04.구조도(기슭막이).xls` 7탭: - - 터파기 = 기초 0.5 × (0.7 + 0.2) × 1.0 = 0.45 (버림만 있는 탭은 0.1 × 0.7 = 0.07) - + 비탈 H × (평균두께 + 0.2) × 1.0 - - ⇒ **여유폭은 「+0.2」**(평균두께에 더함), **기초는 깊이 0.5 · 폭 0.9**. - ⇒ 정본 탭 제목이 **「기초유 / 기초버림」**이다 — 기초를 안 두는 것이 아니라 - **버림으로 얕게 두는 것**이라 뜻이 다르다. 그 축이 곧 **0.45 냐 0.07 이냐**를 가른다. - ⚠ **비탈을 주지 않는다 — 정본이 수직으로 센다.** 기울기 항이 식에 아예 없다. - 임의로 기울기를 주면 그림과 물량이 어긋난다. - -⚠ 값을 지어내지 않는다 — 여기 있는 수는 전부 위 두 출처의 것이고, 사용자가 덮어쓰면 - 그 값이 이긴다. 화면에는 **근거의 급**(법정 표 / 실무 식)을 노티스로 드러낸다. -""" - -from __future__ import annotations - -#: 관경(㎜) → 터파기 폭(㎜). `KCS 44 40 10` 그림 3.2-1 표 그대로. -PIPE_TRENCH_WIDTH_MM: dict[int, int] = { - 300: 700, - 350: 800, - 400: 900, - 450: 1000, - 500: 1100, - 600: 1200, - 700: 1400, - 800: 1600, - 900: 1700, - 1000: 1800, - 1100: 2000, - 1200: 2100, - 1300: 2200, - 1400: 2400, - 1500: 2500, -} - -#: 한쪽 여유폭(m) — 같은 그림. 표에 없는 관경을 사용자가 넣었을 때의 안내용이다. -PIPE_TRENCH_CLEARANCE_NORMAL_M = 0.30 -PIPE_TRENCH_CLEARANCE_SOFT_M = 0.50 - -#: 벽 터파기 — 실무 정본 식의 수(위 문서 참조). -WALL_TRENCH_CLEARANCE_M = 0.2 # 평균두께에 더하는 여유 -WALL_FOUNDATION_DEPTH_M = 0.5 # 기초 깊이 -WALL_FOUNDATION_WIDTH_M = 0.9 # 기초 폭 (0.7 + 0.2) -WALL_BLINDING_DEPTH_M = 0.1 # 기초 없이 버림만 있을 때의 깊이 -WALL_BLINDING_WIDTH_M = 0.7 # 그때의 폭 (여유폭 0.0 — 버림 탭에는 여유가 없다) - -#: 근거의 급 — 화면 노티스가 이 값을 그대로 쓴다. -BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값" -#: ⭐ 2026-09-09 **사용자 확정 5차 1번** — 「비탈 터파기를 셀 것인가」에 **「지금 이대로」**로 답이 -#: 왔다(기초 + 비탈 두 줄). 그래서 문구에서 **「확인 후 쓸 것」을 걷는다** — 확인이 끝났다. -#: ⚠ 「법정 근거 없음」은 그대로 둔다(사실이 안 바뀜) — 근거의 급은 여전히 실무 정본이다. -#: ⚠ 소광리 07-구조도는 **비탈 몫을 안 적는다**(설계사무소 간 차이) — 그래도 우리는 정본 -#: 04.구조도(기슭막이)의 두 줄을 따른다는 것이 이 확정의 뜻이다. -BASIS_WALL = ( - "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) — 기초 + 비탈 두 줄." - " **2026-09-09 사용자 확정 5차 1번으로 「지금 이대로」 확정**" -) - - -def pipe_trench_width_m(diameter_mm: float | int | None) -> float | None: - """관경(㎜)에 대응하는 터파기 폭(m). 표에 없으면 `None`(지어내지 않는다).""" - if diameter_mm is None: - return None - try: - key = int(round(float(diameter_mm))) - except (TypeError, ValueError): - return None - width = PIPE_TRENCH_WIDTH_MM.get(key) - return None if width is None else width / 1000.0 - - -def wall_trench_area_m2( - height_m: float | None, - average_thickness_m: float | None, - *, - has_foundation: bool, -) -> float | None: - """벽 터파기 단면적(㎡/m). 높이나 평균두께가 없으면 `None`. - - 실무 정본 식 그대로: `기초분 + H × (평균두께 + 0.2)`. - `has_foundation=False` 면 기초분이 **버림 몫(0.1 × 0.7)**으로 줄어든다. - """ - if height_m is None or average_thickness_m is None: - return None - try: - height = float(height_m) - thickness = float(average_thickness_m) - except (TypeError, ValueError): - return None - if height <= 0 or thickness <= 0: - return None - base = ( - WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M - if has_foundation - else WALL_BLINDING_DEPTH_M * WALL_BLINDING_WIDTH_M - ) - return base + height * (thickness + WALL_TRENCH_CLEARANCE_M) - - -def wall_trench_width_m(average_thickness_m: float | None) -> float | None: - """벽 터파기 폭(m) — 평균두께 + 여유 0.2. 값이 없으면 `None`.""" - if average_thickness_m is None: - return None - try: - thickness = float(average_thickness_m) - except (TypeError, ValueError): - return None - return None if thickness <= 0 else thickness + WALL_TRENCH_CLEARANCE_M diff --git a/common_util/common_util_excavation.ts b/common_util/common_util_excavation.ts index bdd181175..350be22d4 100644 --- a/common_util/common_util_excavation.ts +++ b/common_util/common_util_excavation.ts @@ -2,9 +2,8 @@ * common_util_excavation.ts * 터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.** * - * ⚠⚠ 파이썬 짝과 **한 벌**이다 — `common_util/common_util_excavation.py`. - * 거울 시험: `resources/tester/test_excavation_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것. - * 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽 수가 같아야 한다. + * 한 벌 — 화면(B06 횡단도)이 그리고 서버(B08 수량 · Node `excavation.trench`)가 셈. + * 파이썬 짝 = 운영 부름 0 이라 걷음(511-B B-6) · 시험 `resources/tester/test_excavation_mirror.py`. * * ① 관 — 법정 시방서에 표가 있다: `KCS 44 40 10 배수관(23.01) §3.2(4) 그림 3.2-1`. * 「설계도서에 따로 표기되지 않았을 때에는 … 기준 폭에 따라야 한다」 diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 019f89b44..38aebbefd 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -15,10 +15,13 @@ (관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음 판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py` · 끌기 여러 걸음 화면 자리 = [저장] 한 번 자리(관 · 구조물 · 추가 측점) = `test_493_3_follow_screen_save.py`. +남는 까닭(511-B B-6) — 따라감 = Node `facility.follow` · 좌표 맞춤 파이썬 짝 = Node 못 돌림 때 · +`route_outside` 는 본 길. """ from __future__ import annotations +import logging import math from typing import Any, Iterable, Sequence @@ -33,6 +36,7 @@ from common_util.common_util_structure_station import ( ) from config.config_system import SECTION_STATION_INTERVAL_M, STRUCTURE_ROUTE_DRIFT_MAX_M +logger = logging.getLogger(__name__) _POSITION_KEYS = ("chainage_m", "start_m", "end_m") _FOLLOW_KEYS = ("placement", *_POSITION_KEYS, "x", "y") #: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝). @@ -96,8 +100,51 @@ def fill_structure_coordinates( `previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다 (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를 낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은 - 좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때. + 좌표(누가거리만 옮김)는 노선 위로. `only_missing` = 읽을 때. + 셈 = TS `fillStructureCoordinates`(Node `facility.follow` + fill · 511-B B-5) · Node 못 돌림 = + 파이썬 짝 `_fill_structure_coordinates_py`. """ + items = list(items) + line = _line(xy) + if line is None or not items: + return 0 + keys = ("structure_id", *_FOLLOW_KEYS) + rows = [{key: getattr(item, key, None) for key in keys} for item in items] + old = { + sid: {key: getattr(item, key, None) for key in keys} + for sid, item in (previous or {}).items() + } + results = run_calc( + ["facility.follow"], + { + "follow": { + "items": rows, + "line": [[x, y] for x, y in line.coords], + "interval_m": SECTION_STATION_INTERVAL_M, + "fill": {"previous": old, "only_missing": only_missing}, + } + }, + ) + if results is None: + logger.warning("구조물 좌표 맞춤 Node 못 돌림 — 파이썬 짝") + return _fill_structure_coordinates_py( + items, xy, previous=previous, only_missing=only_missing + ) + out = results["facility.follow"] + for item, at in zip(items, out["xy"], strict=True): + if at is not None: + item.x, item.y = float(at["x"]), float(at["y"]) + return int(out["filled"]) + + +def _fill_structure_coordinates_py( + items: Iterable[Any], + xy: Sequence[Sequence[float]], + *, + previous: dict[str, Any] | None = None, + only_missing: bool = False, +) -> int: + """`fill_structure_coordinates` 파이썬 짝(Node 못 돌릴 때 · 거울 시험 기준).""" line = _line(xy) if line is None: return 0 diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 9c5a8d063..cabbe3918 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -11,6 +11,8 @@ * - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`. * - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 · * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. + * - `fillStructureCoordinates` = 저장 때 좌표 맞춤 그 자체(옛 좌표 지킴 · 0.1 mm · 서버 Node `facility.fill_xy` · + * 파이썬 짝 = Node 못 돌릴 때) · 노선 위 자리 `pointAtChainage` 는 `placeOnLine` 과 한 벌(511-B B-5). * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). @@ -129,21 +131,73 @@ export function placeOnLine 0 && (walked + span >= item.chainage_m || index === line.length - 1)) { - const t = Math.min(1, Math.max(0, (item.chainage_m - walked) / span)); - return { ...item, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; - } - walked += span; - } - return item; + const at = pointAtChainage(line, item.chainage_m); + return at ? { ...item, ...at } : item; }); } +/** 누가거리의 노선 위 자리 — shapely `LineString.interpolate` 꼴(선분 길이 √ · 넘으면 끝점 · 음수 = 끝에서) + + * 0.1 mm(파이썬 `round(·, 4)` · F1 저장 좌표 맞춤과 같은 값 · 511-B B-5). 노선 없음(점 둘 미만 · 길이 0) = null. */ +export function pointAtChainage(line: ReadonlyArray, chainageM: number): XY | null { + const spans = line.slice(1).map((b, index) => { + const a = line[index]; + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); + }); + const length = spans.reduce((sum, span) => sum + span, 0); + if (!(length > 0)) return null; + const target = chainageM < 0 ? Math.max(0, length + chainageM) : chainageM; + // ponytail: toFixed = 딱 반(홀수/32 m)에서 큰 쪽 · 파이썬 round 는 짝수 쪽 — 실측 좌표엔 안 생김 + const round4 = (value: number) => Number(value.toFixed(4)); + let walked = 0; + for (let index = 0; index < spans.length; index += 1) { + if (walked + spans[index] > target) { + const a = line[index]; + const b = line[index + 1]; + const t = Math.max(0, (target - walked) / spans[index]); + return { x: round4((b.x - a.x) * t + a.x), y: round4((b.y - a.y) * t + a.y) }; + } + walked += spans[index]; + } + const end = line[line.length - 1]; + return { x: round4(end.x), y: round4(end.y) }; +} + +type Positioned = FollowingStructure & { structure_id?: string | null }; + +/** 저장 때 구조물 좌표 맞춤(F1 · 511-B B-5 · 파이썬 `fill_structure_coordinates` 같은 규칙 · 서버 Node + * `facility.fill_xy`) — 누가거리 없음 · `onlyMissing` 에 좌표 있음 = 그대로 · `previous`(저장 전 정본 · + * structure_id)와 자리 셋(기준 · 시작 · 끝)이 같고 옛 좌표 있음 = 옛 좌표 · 좌표가 그 누가거리를 낳음 = 그대로 · + * 그 밖 = 노선 위 자리(`pointAtChainage`). 돌려줌 = 항목마다 새 좌표(안 바뀜 null) · 채운 수. */ +export function fillStructureCoordinates( + items: ReadonlyArray, + line: ReadonlyArray, + previous: Readonly> | null = null, + onlyMissing = false, + intervalM: number | null = null, +): { xy: Array; filled: number } { + const total = line + .slice(1) + .reduce((sum, b, index) => sum + Math.hypot(b.x - line[index].x, b.y - line[index].y), 0); + if (line.length < 2 || !(total > 0)) return { xy: items.map(() => null), filled: 0 }; + const same = (a: Positioned, b: Positioned) => + (a.chainage_m ?? null) === (b.chainage_m ?? null) && + (a.start_m ?? null) === (b.start_m ?? null) && + (a.end_m ?? null) === (b.end_m ?? null); + let filled = 0; + const xy = items.map((item): XY | null => { + if (item.chainage_m == null) return null; + const placed = item.x != null && item.y != null; + if (onlyMissing && placed) return null; + const old = previous?.[item.structure_id ?? ""]; + if (old && same(old, item) && old.x != null) return { x: old.x, y: old.y! }; + if (placed && holdsStation(item, line, intervalM)) return null; + const at = pointAtChainage(line, item.chainage_m); + if (at) filled += 1; + return at; + }); + return { xy, filled }; +} + /** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순). * 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) · 따라가기만 했으면(땅 위 자리 = 정본 x · y · * 누가거리만 화면 계산 · 428) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로 diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index 36427cf00..0806853f7 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -4,19 +4,17 @@ 시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>`` · 곡선 측점(MC · 252-2) = ``curve:<누가거리 0.01 m>``. 구간형 구조물 시작 · 끝 측점 = ``structure::start`` · ``:end``(기준 측점은 ``structure:``). -한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``). +한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(TS ``dedupeStationKeys``). MC 가 움직여 곡선 열쇠가 바뀌면 넘길 때 가까운 새 곡선 측점으로 이음(``curve_moves`` · 432-3). ⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. +남는 까닭 = DTM 아닌 지표면 · Node 못 돌림 때 노선 저장 이월 열쇠(``station_keys``). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. """ from __future__ import annotations import math -import re - -_LOOSE = re.compile(r"^-?\d+(\.\d+)?$") def _half_up(value: float) -> int: @@ -65,23 +63,6 @@ def station_key( return loose -def dedupe_station_keys(keys: list[str], chainages: list[float]) -> list[str]: - """한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>`` — 열쇠마다 측점 하나.""" - seen: set[str] = set() - out: list[str] = [] - for key, chainage in zip(keys, chainages, strict=True): - unique = f"at:{chainage_key(chainage)}" if key in seen else key - seen.add(unique) - out.append(unique) - return out - - -def loose_chainage(key: str) -> float | None: - """옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None.""" - text = key[3:] if key.startswith("at:") else key - return float(text) if _LOOSE.match(text) else None - - #: 옮긴 곡선 측점으로 보는 거리(m) — 옛 MC 자리에서 이 안의 새 MC. CURVE_NEAR_M = 10.0 diff --git a/common_util/common_util_stream_crossing.py b/common_util/common_util_stream_crossing.py index 0b194c40e..094d3e19a 100644 --- a/common_util/common_util_stream_crossing.py +++ b/common_util/common_util_stream_crossing.py @@ -119,8 +119,8 @@ def _gap(label: dict[str, Any], x: float, y: float) -> float: return math.hypot(float(label["x"]) - x, float(label["y"]) - y) -def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) -> set[int]: - """라벨마다 같은 세류선 교차점 중 라벨 좌표와 가장 가까운 것 하나 — 잡힌 교차점 번호. +def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) -> dict[int, int]: + """라벨마다 같은 세류선 교차점 중 라벨 좌표와 가장 가까운 것 하나 — {교차점 번호: 라벨 번호}. 가까운 짝부터 하나씩(라벨 하나 · 교차점 하나) · 거리 한계 없음. """ @@ -132,12 +132,12 @@ def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) if getattr(crossing, "stream_id", None) == label["stream"] ) used_labels: set[int] = set() - used: set[int] = set() + used: dict[int, int] = {} for _, li, ci in pairs: if li in used_labels or ci in used: continue used_labels.add(li) - used.add(ci) + used[ci] = li return used @@ -220,3 +220,62 @@ def keep_labels( continue if mark not in dismissed: dismissed.append(mark) + # 지운 자동 관을 [자동 제안]으로 다시 넣음(510) — 그 지움 표지는 걷음 + held = [point.stream_label for point in points if point.stream_label] + dismissed[:] = [mark for mark in dismissed if mark not in held] + + +def propose_pipes( + vertices: Sequence[RouteVertex], + crossings: Sequence[Any], + pipes: Sequence[Any], + dismissed: Sequence[dict[str, Any]], + strength_curve: Any = None, +) -> list[dict[str, Any]]: + """[자동 제안] 후보(510) — 세류선 교차(`_base_pipes` · 라벨) + 간격 보충(흐름 세기 · 분석이 + 지금 노선일 때) · 관 목록엔 안 넣음(사용자가 골라 관 넣기 길로). + + 상태 = held(라벨 짝 관 · 교차 5 m 안 관) · dismissed(지움 표지 짝 · 라벨 = 그 표지) · new. + 짝 잡기 = 노선 [저장](`add_crossing_pipes`)과 같은 `claim_crossings`(관 라벨 + 지움 표지 함께). + 간격 보충 = 지금 관 + 교차 후보(새 · 지운 관) 사이 최대 간격 넘는 구간(suggest_pipe_positions). + """ + from common_util.common_util_drainage_detail import suggest_pipe_positions + from common_util.common_util_route_geometry import interpolate_vertex + from common_util.common_util_structure_station import snap_structure_station + + total = float(vertices[-1].chainage_m) + held = [pipe.stream_label for pipe in pipes if pipe.stream_label] + marks = [mark for mark in dismissed if "stream" in mark] + claimed = claim_crossings([*held, *marks], crossings) + labels = crossing_labels(crossings) + taken = [float(pipe.chainage_m) for pipe in pipes] + found: list[dict[str, Any]] = [] + for index, crossing in enumerate(crossings): + at = snap_structure_station(float(crossing.chainage_m), [total]) + label, state = labels[index], "new" + if index in claimed: + pick = claimed[index] + state = "held" if pick < len(held) else "dismissed" + if state == "dismissed": + label = marks[pick - len(held)] + elif any(abs(other - at) < DRAINAGE_PIPE_MIN_SPACING_M for other in taken): + state = "held" + found.append( + { + "chainage_m": at, + "kind": "stream", + "state": state, + "stream_label": dict(label), + "stream_name": getattr(crossing, "stream_name", None), + } + ) + if strength_curve is not None: + base = sorted({*taken, *(item["chainage_m"] for item in found if item["state"] != "held")}) + placed = [ + StructureCandidate(chainage_m=c, x=xy[0], y=xy[1], reason="confirmed") + for c in base + for xy in [interpolate_vertex(list(vertices), c)] + ] + for pipe in suggest_pipe_positions(list(vertices), placed, strength_curve): + found.append({"chainage_m": float(pipe.chainage_m), "kind": "spacing", "state": "new"}) + return sorted(found, key=lambda item: item["chainage_m"]) diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 9382b262c..f5bc4944e 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -8,6 +8,7 @@ TS 짝 = `common_util/common_util_structure_station.ts` 측점 계획 `plan_stations` ↔ `planStations` · 322-3 · 추가 측점 `plus` 368-2). 거울 시험 = `resources/tester/test_234_1_structure_station.py`. 일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀. +남는 까닭(511-B B-6) — B07 · 배수(snap · 일반 측점) 파이썬 소비자 · 측점 생성 Node 못 돌림 때. """ from __future__ import annotations diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index d8b9c44a2..32d86f06f 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -131,7 +131,9 @@ class DtmGridSampler: # ponytail: 지표면 4벌(sheet 격자 한 벌 약 17 MB) — 서버 프로세스 하나 전제 · 늘면 maxsize 를 셈. @lru_cache(maxsize=4) def _open_dtm(path: str, _mtime_ns: int, _size: int) -> DtmGridSampler: - return DtmGridSampler.from_npz(path) + sampler = DtmGridSampler.from_npz(path) + sampler.source = path # npz 경로 — 서버 측점 생성이 같은 격자를 Node 에 넘김(511-B B-2) + return sampler #: 브라우저로 보내는 격자 꼴(`dtm_grid_bytes`) 머리 — TS `parseDtmGrid` 가 같은 값을 봄. diff --git a/main.py b/main.py index 3740dd697..d9042e54b 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router +from B06_Section.B06_Section_Router_Calc import router as b06_calc_router from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router @@ -477,6 +478,7 @@ app.include_router(b06_design_save_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b06_section_cross_table_router, dependencies=protected_with_company) +app.include_router(b06_calc_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) diff --git a/package.json b/package.json index 74658cf6e..44e4b3b76 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,8 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:design-calc && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:design-calc && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", - "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index f88037ca8..10709416a 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -2,6 +2,14 @@ "version": 1, "sites": { "ground_sample": "both", - "corridor_mesh": "both" + "corridor_mesh": "both", + "quantity": "both", + "carry": "both", + "route": "both", + "follow": "both", + "stations": "both", + "profile": "both", + "cross": "both", + "fixed_browser": "both" } } diff --git a/resources/tester/fixtures/haul_511_c.json b/resources/tester/fixtures/haul_511_c.json new file mode 100644 index 000000000..7d52dd7f7 --- /dev/null +++ b/resources/tester/fixtures/haul_511_c.json @@ -0,0 +1 @@ +{"483":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":2091.02,"ripping_rock":5776.64,"blasting_rock":0},"cut_compacted_m3":8611.7,"fill_compacted_m3":20716.74,"final_cumulative_m3":-12105.04,"surplus_m3":0,"shortage_m3":12105.04,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-18.6,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":-369.38,"cumulative_volume_m3":-369.38,"cut_soil_m3":21.85,"cut_rock_m3":20.93,"cut_rr_m3":20.93,"cut_br_m3":0,"net_area_m2":-18.34,"fill_m3":413.42},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":-274.03,"cumulative_volume_m3":-643.41,"cut_soil_m3":33.73,"cut_rock_m3":43.29,"cut_rr_m3":43.29,"cut_br_m3":0,"net_area_m2":-9.06,"fill_m3":354.83},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":-115.69,"cumulative_volume_m3":-759.11,"cut_soil_m3":37.64,"cut_rock_m3":67.38,"cut_rr_m3":67.38,"cut_br_m3":0,"net_area_m2":-2.51,"fill_m3":228.07},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":-207.19,"cumulative_volume_m3":-966.29,"cut_soil_m3":39.79,"cut_rock_m3":82.09,"cut_rr_m3":82.09,"cut_br_m3":0,"net_area_m2":-18.21,"fill_m3":338.63},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":-633.05,"cumulative_volume_m3":-1599.34,"cut_soil_m3":42.35,"cut_rock_m3":92.72,"cut_rr_m3":92.72,"cut_br_m3":0,"net_area_m2":-45.09,"fill_m3":779.18},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":-498.94,"cumulative_volume_m3":-2098.28,"cut_soil_m3":46.28,"cut_rock_m3":109.16,"cut_rr_m3":109.16,"cut_br_m3":0,"net_area_m2":-4.8,"fill_m3":667.76},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":-259.65,"cumulative_volume_m3":-2357.93,"cut_soil_m3":33.87,"cut_rock_m3":64.11,"cut_rr_m3":64.11,"cut_br_m3":0,"net_area_m2":-21.16,"fill_m3":364.82},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":-244.39,"cumulative_volume_m3":-2602.32,"cut_soil_m3":26.78,"cut_rock_m3":31.16,"cut_rr_m3":31.16,"cut_br_m3":0,"net_area_m2":-3.27,"fill_m3":304.78},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":-18,"cumulative_volume_m3":-2620.32,"cut_soil_m3":33.02,"cut_rock_m3":40.08,"cut_rr_m3":40.08,"cut_br_m3":0,"net_area_m2":1.47,"fill_m3":94.42},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":12.51,"cumulative_volume_m3":-2607.81,"cut_soil_m3":31.5,"cut_rock_m3":32.68,"cut_rr_m3":32.68,"cut_br_m3":0,"net_area_m2":-0.22,"fill_m3":53.91},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":41.35,"cumulative_volume_m3":-2566.47,"cut_soil_m3":36.53,"cut_rock_m3":51.91,"cut_rr_m3":51.91,"cut_br_m3":0,"net_area_m2":4.36,"fill_m3":52.01},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":289.03,"cumulative_volume_m3":-2277.44,"cut_soil_m3":58.42,"cut_rock_m3":219.33,"cut_rr_m3":219.33,"cut_br_m3":0,"net_area_m2":24.55,"fill_m3":19.08},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":-238.44,"cumulative_volume_m3":-2515.88,"cut_soil_m3":61.87,"cut_rock_m3":252.02,"cut_rr_m3":252.02,"cut_br_m3":0,"net_area_m2":-48.39,"fill_m3":587.74},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-1858.09,"cumulative_volume_m3":-4373.97,"cut_soil_m3":44.49,"cut_rock_m3":107.89,"cut_rr_m3":107.89,"cut_br_m3":0,"net_area_m2":-137.42,"fill_m3":2023.82},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-1429.07,"cumulative_volume_m3":-5803.04,"cut_soil_m3":33.94,"cut_rock_m3":52.71,"cut_rr_m3":52.71,"cut_br_m3":0,"net_area_m2":-5.49,"fill_m3":1521.02},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":-109.85,"cumulative_volume_m3":-5912.89,"cut_soil_m3":25.59,"cut_rock_m3":20.9,"cut_rr_m3":20.9,"cut_br_m3":0,"net_area_m2":-5.5,"fill_m3":157.23},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":-87.85,"cumulative_volume_m3":-6000.74,"cut_soil_m3":27.32,"cut_rock_m3":27.87,"cut_rr_m3":27.87,"cut_br_m3":0,"net_area_m2":-3.29,"fill_m3":144.91},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-85.8,"cumulative_volume_m3":-6086.55,"cut_soil_m3":32.32,"cut_rock_m3":40.02,"cut_rr_m3":40.02,"cut_br_m3":0,"net_area_m2":-5.29,"fill_m3":161.51},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":-103.06,"cumulative_volume_m3":-6189.61,"cut_soil_m3":30.52,"cut_rock_m3":33.82,"cut_rr_m3":33.82,"cut_br_m3":0,"net_area_m2":-5.01,"fill_m3":169.93},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-42.48,"cumulative_volume_m3":-6232.09,"cut_soil_m3":32.53,"cut_rock_m3":36.82,"cut_rr_m3":36.82,"cut_br_m3":0,"net_area_m2":0.77,"fill_m3":114.66},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":9.02,"cumulative_volume_m3":-6223.07,"cut_soil_m3":33.06,"cut_rock_m3":32.79,"cut_rr_m3":32.79,"cut_br_m3":0,"net_area_m2":0.14,"fill_m3":58.93},{"station_id":"station_000000425000","chainage_m":425,"net_volume_m3":1.7,"cumulative_volume_m3":-6221.37,"cut_soil_m3":7.79,"cut_rock_m3":6.02,"cut_rr_m3":6.02,"cut_br_m3":0,"net_area_m2":0.55,"fill_m3":12.32},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":5.4,"cumulative_volume_m3":-6215.97,"cut_soil_m3":21.82,"cut_rock_m3":15.92,"cut_rr_m3":15.92,"cut_br_m3":0,"net_area_m2":0.17,"fill_m3":32.79},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":19.24,"cumulative_volume_m3":-6196.73,"cut_soil_m3":32.37,"cut_rock_m3":33.87,"cut_rr_m3":33.87,"cut_br_m3":0,"net_area_m2":1.75,"fill_m3":49.36},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":-81.94,"cumulative_volume_m3":-6278.67,"cut_soil_m3":30.89,"cut_rock_m3":35.15,"cut_rr_m3":35.15,"cut_br_m3":0,"net_area_m2":-9.94,"fill_m3":150.69},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":-110.69,"cumulative_volume_m3":-6389.36,"cut_soil_m3":25.58,"cut_rock_m3":22.41,"cut_rr_m3":22.41,"cut_br_m3":0,"net_area_m2":-1.13,"fill_m3":159.82},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":-29.5,"cumulative_volume_m3":-6418.86,"cut_soil_m3":29.99,"cut_rock_m3":32.13,"cut_rr_m3":32.13,"cut_br_m3":0,"net_area_m2":-1.82,"fill_m3":93.92},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-49.64,"cumulative_volume_m3":-6468.5,"cut_soil_m3":31.54,"cut_rock_m3":34.97,"cut_rr_m3":34.97,"cut_br_m3":0,"net_area_m2":-3.14,"fill_m3":118.75},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":-42.55,"cumulative_volume_m3":-6511.05,"cut_soil_m3":31.36,"cut_rock_m3":34.09,"cut_rr_m3":34.09,"cut_br_m3":0,"net_area_m2":-1.12,"fill_m3":110.48},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":-30.35,"cumulative_volume_m3":-6541.4,"cut_soil_m3":26.84,"cut_rock_m3":25.13,"cut_rr_m3":25.13,"cut_br_m3":0,"net_area_m2":-1.92,"fill_m3":83.78},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":-32.67,"cumulative_volume_m3":-6574.07,"cut_soil_m3":20.67,"cut_rock_m3":10.64,"cut_rr_m3":10.64,"cut_br_m3":0,"net_area_m2":-1.35,"fill_m3":63.66},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":-21.07,"cumulative_volume_m3":-6595.14,"cut_soil_m3":18.95,"cut_rock_m3":6.65,"cut_rr_m3":6.65,"cut_br_m3":0,"net_area_m2":-0.76,"fill_m3":45.87},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":-2.41,"cumulative_volume_m3":-6597.54,"cut_soil_m3":20.68,"cut_rock_m3":7.49,"cut_rr_m3":7.49,"cut_br_m3":0,"net_area_m2":0.52,"fill_m3":29.75},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":-8.28,"cumulative_volume_m3":-6605.82,"cut_soil_m3":26.36,"cut_rock_m3":21.06,"cut_rr_m3":21.06,"cut_br_m3":0,"net_area_m2":-1.35,"fill_m3":56.54},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":-18.75,"cumulative_volume_m3":-6624.57,"cut_soil_m3":29.59,"cut_rock_m3":30.57,"cut_rr_m3":30.57,"cut_br_m3":0,"net_area_m2":-0.53,"fill_m3":80.99},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-16.5,"cumulative_volume_m3":-6641.07,"cut_soil_m3":30.24,"cut_rock_m3":31.98,"cut_rr_m3":31.98,"cut_br_m3":0,"net_area_m2":-1.12,"fill_m3":80.97},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-18.2,"cumulative_volume_m3":-6659.27,"cut_soil_m3":30.65,"cut_rock_m3":33.63,"cut_rr_m3":33.63,"cut_br_m3":0,"net_area_m2":-0.7,"fill_m3":84.97},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":-62.21,"cumulative_volume_m3":-6721.48,"cut_soil_m3":26.82,"cut_rock_m3":24.68,"cut_rr_m3":24.68,"cut_br_m3":0,"net_area_m2":-5.52,"fill_m3":115.09},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-85.12,"cumulative_volume_m3":-6806.6,"cut_soil_m3":27.13,"cut_rock_m3":25.34,"cut_rr_m3":25.34,"cut_br_m3":0,"net_area_m2":-2.99,"fill_m3":139.06},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":-81.7,"cumulative_volume_m3":-6888.3,"cut_soil_m3":29.96,"cut_rock_m3":31.27,"cut_rr_m3":31.27,"cut_br_m3":0,"net_area_m2":-5.18,"fill_m3":145.09},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":-652.25,"cumulative_volume_m3":-7540.55,"cut_soil_m3":27.68,"cut_rock_m3":26.25,"cut_rr_m3":26.25,"cut_br_m3":0,"net_area_m2":-60.05,"fill_m3":707.75},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":-824.37,"cumulative_volume_m3":-8364.92,"cut_soil_m3":34.79,"cut_rock_m3":55.49,"cut_rr_m3":55.49,"cut_br_m3":0,"net_area_m2":-22.39,"fill_m3":920.32},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":-540.49,"cumulative_volume_m3":-8905.41,"cut_soil_m3":43.25,"cut_rock_m3":91.33,"cut_rr_m3":91.33,"cut_br_m3":0,"net_area_m2":-31.66,"fill_m3":685.81},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":-1182.55,"cumulative_volume_m3":-10087.95,"cut_soil_m3":34.64,"cut_rock_m3":60.11,"cut_rr_m3":60.11,"cut_br_m3":0,"net_area_m2":-86.6,"fill_m3":1283.75},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-1740.44,"cumulative_volume_m3":-11828.39,"cut_soil_m3":31.58,"cut_rock_m3":49.63,"cut_rr_m3":49.63,"cut_br_m3":0,"net_area_m2":-87.45,"fill_m3":1826.68},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":-1005.05,"cumulative_volume_m3":-12833.44,"cut_soil_m3":34.66,"cut_rock_m3":56.98,"cut_rr_m3":56.98,"cut_br_m3":0,"net_area_m2":-13.06,"fill_m3":1102.63},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":-322.66,"cumulative_volume_m3":-13156.1,"cut_soil_m3":32.9,"cut_rock_m3":43.54,"cut_rr_m3":43.54,"cut_br_m3":0,"net_area_m2":-19.21,"fill_m3":402.99},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":-886.78,"cumulative_volume_m3":-14042.88,"cut_soil_m3":28.84,"cut_rock_m3":33.41,"cut_rr_m3":33.41,"cut_br_m3":0,"net_area_m2":-69.47,"fill_m3":951.66},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":-869.46,"cumulative_volume_m3":-14912.34,"cut_soil_m3":26.07,"cut_rock_m3":20.82,"cut_rr_m3":20.82,"cut_br_m3":0,"net_area_m2":-17.48,"fill_m3":917.18},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":-239.19,"cumulative_volume_m3":-15151.54,"cut_soil_m3":25.2,"cut_rock_m3":17.84,"cut_rr_m3":17.84,"cut_br_m3":0,"net_area_m2":-6.44,"fill_m3":282.66},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-102.27,"cumulative_volume_m3":-15253.81,"cut_soil_m3":23.16,"cut_rock_m3":17.02,"cut_rr_m3":17.02,"cut_br_m3":0,"net_area_m2":-3.79,"fill_m3":142.94},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-96.82,"cumulative_volume_m3":-15350.63,"cut_soil_m3":35.53,"cut_rock_m3":59.86,"cut_rr_m3":59.86,"cut_br_m3":0,"net_area_m2":-5.9,"fill_m3":198.52},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":1095.58,"cumulative_volume_m3":-14255.04,"cut_soil_m3":94.44,"cut_rock_m3":986.1,"cut_rr_m3":986.1,"cut_br_m3":0,"net_area_m2":115.45,"fill_m3":138.21},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":1869.54,"cumulative_volume_m3":-12385.5,"cut_soil_m3":126.5,"cut_rock_m3":1507.04,"cut_rr_m3":1507.04,"cut_br_m3":0,"net_area_m2":71.5,"fill_m3":0},{"station_id":"station_000001080000","chainage_m":1080,"net_volume_m3":790.62,"cumulative_volume_m3":-11594.88,"cut_soil_m3":81.97,"cut_rock_m3":626.13,"cut_rr_m3":626.13,"cut_br_m3":0,"net_area_m2":7.56,"fill_m3":12.59},{"station_id":"station_000001100000","chainage_m":1100,"net_volume_m3":60.74,"cumulative_volume_m3":-11534.14,"cut_soil_m3":42.78,"cut_rock_m3":76.89,"cut_rr_m3":76.89,"cut_br_m3":0,"net_area_m2":-1.49,"fill_m3":67.34},{"station_id":"station_000001120000","chainage_m":1120,"net_volume_m3":-14.51,"cumulative_volume_m3":-11548.65,"cut_soil_m3":33.56,"cut_rock_m3":43.95,"cut_rr_m3":43.95,"cut_br_m3":0,"net_area_m2":0.04,"fill_m3":95.92},{"station_id":"station_000001140000","chainage_m":1140,"net_volume_m3":-27.26,"cumulative_volume_m3":-11575.91,"cut_soil_m3":31.18,"cut_rock_m3":36.53,"cut_rr_m3":36.53,"cut_br_m3":0,"net_area_m2":-2.76,"fill_m3":97.88},{"station_id":"station_000001160000","chainage_m":1160,"net_volume_m3":-62.35,"cumulative_volume_m3":-11638.25,"cut_soil_m3":29.57,"cut_rock_m3":32.89,"cut_rr_m3":32.89,"cut_br_m3":0,"net_area_m2":-3.47,"fill_m3":127.27},{"station_id":"station_000001172990","chainage_m":1172.99,"net_volume_m3":-466.79,"cumulative_volume_m3":-12105.04,"cut_soil_m3":10.09,"cut_rock_m3":12.19,"cut_rr_m3":12.19,"cut_br_m3":0,"net_area_m2":-68.39,"fill_m3":490.08}]},"513":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":2051.38,"ripping_rock":3871.2,"blasting_rock":0},"cut_compacted_m3":6356.19,"fill_compacted_m3":23514.75,"final_cumulative_m3":-17158.56,"surplus_m3":0,"shortage_m3":17158.56,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":0.12,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":-34.67,"cumulative_volume_m3":-34.67,"cut_soil_m3":17,"cut_rock_m3":15.42,"cut_rr_m3":15.42,"cut_br_m3":0,"net_area_m2":-3.59,"fill_m3":67.94},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":-29.06,"cumulative_volume_m3":-63.74,"cut_soil_m3":29.94,"cut_rock_m3":36.23,"cut_rr_m3":36.23,"cut_br_m3":0,"net_area_m2":0.68,"fill_m3":98.21},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":-9.89,"cumulative_volume_m3":-73.63,"cut_soil_m3":31.75,"cut_rock_m3":46.56,"cut_rr_m3":46.56,"cut_br_m3":0,"net_area_m2":-1.67,"fill_m3":92.71},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":23.36,"cumulative_volume_m3":-50.27,"cut_soil_m3":48.1,"cut_rock_m3":105.69,"cut_rr_m3":105.69,"cut_br_m3":0,"net_area_m2":4.01,"fill_m3":143.06},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":83.27,"cumulative_volume_m3":33,"cut_soil_m3":50.96,"cut_rock_m3":131.59,"cut_rr_m3":131.59,"cut_br_m3":0,"net_area_m2":4.32,"fill_m3":115.9},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":34.05,"cumulative_volume_m3":67.05,"cut_soil_m3":27.87,"cut_rock_m3":55.43,"cut_rr_m3":55.43,"cut_br_m3":0,"net_area_m2":-0.91,"fill_m3":55.61},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":6.78,"cumulative_volume_m3":73.83,"cut_soil_m3":31.43,"cut_rock_m3":16.93,"cut_rr_m3":16.93,"cut_br_m3":0,"net_area_m2":1.59,"fill_m3":41.23},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":7.93,"cumulative_volume_m3":81.76,"cut_soil_m3":33.23,"cut_rock_m3":19.38,"cut_rr_m3":19.38,"cut_br_m3":0,"net_area_m2":-0.8,"fill_m3":44.56},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":67.19,"cumulative_volume_m3":148.95,"cut_soil_m3":38.83,"cut_rock_m3":81.98,"cut_rr_m3":81.98,"cut_br_m3":0,"net_area_m2":7.52,"fill_m3":63.27},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":92.04,"cumulative_volume_m3":240.99,"cut_soil_m3":43.81,"cut_rock_m3":95.62,"cut_rr_m3":95.62,"cut_br_m3":0,"net_area_m2":1.69,"fill_m3":58.79},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":139.74,"cumulative_volume_m3":380.73,"cut_soil_m3":48.98,"cut_rock_m3":132.87,"cut_rr_m3":132.87,"cut_br_m3":0,"net_area_m2":12.29,"fill_m3":59.14},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":-104.03,"cumulative_volume_m3":276.7,"cut_soil_m3":52.27,"cut_rock_m3":148.99,"cut_rr_m3":148.99,"cut_br_m3":0,"net_area_m2":-22.69,"fill_m3":324.64},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":-550.34,"cumulative_volume_m3":-273.65,"cut_soil_m3":26.52,"cut_rock_m3":37.9,"cut_rr_m3":37.9,"cut_br_m3":0,"net_area_m2":-32.34,"fill_m3":618.36},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-780.96,"cumulative_volume_m3":-1054.61,"cut_soil_m3":29.71,"cut_rock_m3":74,"cut_rr_m3":74,"cut_br_m3":0,"net_area_m2":-45.75,"fill_m3":893.91},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-605.27,"cumulative_volume_m3":-1659.87,"cut_soil_m3":40.22,"cut_rock_m3":94.48,"cut_rr_m3":94.48,"cut_br_m3":0,"net_area_m2":-14.77,"fill_m3":751.53},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":-142.22,"cumulative_volume_m3":-1802.09,"cut_soil_m3":45.78,"cut_rock_m3":74.18,"cut_rr_m3":74.18,"cut_br_m3":0,"net_area_m2":0.55,"fill_m3":269.83},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":37.42,"cumulative_volume_m3":-1764.67,"cut_soil_m3":45.1,"cut_rock_m3":84,"cut_rr_m3":84,"cut_br_m3":0,"net_area_m2":3.19,"fill_m3":101.03},{"station_id":"station_000000351000","chainage_m":351,"net_volume_m3":26.08,"cumulative_volume_m3":-1738.58,"cut_soil_m3":19.84,"cut_rock_m3":32.73,"cut_rr_m3":32.73,"cut_br_m3":0,"net_area_m2":1.55,"fill_m3":29.91},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":31.23,"cumulative_volume_m3":-1707.35,"cut_soil_m3":19.85,"cut_rock_m3":36.46,"cut_rr_m3":36.46,"cut_br_m3":0,"net_area_m2":5.39,"fill_m3":29.11},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":19.77,"cumulative_volume_m3":-1687.58,"cut_soil_m3":41.81,"cut_rock_m3":82.77,"cut_rr_m3":82.77,"cut_br_m3":0,"net_area_m2":-3.41,"fill_m3":114.29},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":53.67,"cumulative_volume_m3":-1633.91,"cut_soil_m3":44.87,"cut_rock_m3":95.08,"cut_rr_m3":95.08,"cut_br_m3":0,"net_area_m2":8.78,"fill_m3":97.47},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":123.17,"cumulative_volume_m3":-1510.74,"cut_soil_m3":44.25,"cut_rock_m3":87.67,"cut_rr_m3":87.67,"cut_br_m3":0,"net_area_m2":3.54,"fill_m3":18.79},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":69.9,"cumulative_volume_m3":-1440.84,"cut_soil_m3":40.8,"cut_rock_m3":54.58,"cut_rr_m3":54.58,"cut_br_m3":0,"net_area_m2":3.45,"fill_m3":30.41},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":-83.62,"cumulative_volume_m3":-1524.46,"cut_soil_m3":40.31,"cut_rock_m3":45.9,"cut_rr_m3":45.9,"cut_br_m3":0,"net_area_m2":-11.81,"fill_m3":173.37},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":-1194.12,"cumulative_volume_m3":-2718.59,"cut_soil_m3":35.25,"cut_rock_m3":65.61,"cut_rr_m3":65.61,"cut_br_m3":0,"net_area_m2":-107.6,"fill_m3":1302.29},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":-1944.94,"cumulative_volume_m3":-4663.53,"cut_soil_m3":41.09,"cut_rock_m3":96.63,"cut_rr_m3":96.63,"cut_br_m3":0,"net_area_m2":-86.9,"fill_m3":2094.5},{"station_id":"station_000000502000","chainage_m":502,"net_volume_m3":-205.89,"cumulative_volume_m3":-4869.41,"cut_soil_m3":4.06,"cut_rock_m3":6.13,"cut_rr_m3":6.13,"cut_br_m3":0,"net_area_m2":-118.99,"fill_m3":216.68},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":-1421.38,"cumulative_volume_m3":-6290.8,"cut_soil_m3":29.02,"cut_rock_m3":21.1,"cut_rr_m3":21.1,"cut_br_m3":0,"net_area_m2":-38.94,"fill_m3":1472.09},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-517.42,"cumulative_volume_m3":-6808.22,"cut_soil_m3":21.57,"cut_rock_m3":11.28,"cut_rr_m3":11.28,"cut_br_m3":0,"net_area_m2":-12.8,"fill_m3":549.98},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":-155.19,"cumulative_volume_m3":-6963.41,"cut_soil_m3":16.33,"cut_rock_m3":7.09,"cut_rr_m3":7.09,"cut_br_m3":0,"net_area_m2":-2.72,"fill_m3":178.15},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":-21.92,"cumulative_volume_m3":-6985.32,"cut_soil_m3":19.38,"cut_rock_m3":11.29,"cut_rr_m3":11.29,"cut_br_m3":0,"net_area_m2":0.53,"fill_m3":52.51},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":54.31,"cumulative_volume_m3":-6931.01,"cut_soil_m3":41.77,"cut_rock_m3":63.23,"cut_rr_m3":63.23,"cut_br_m3":0,"net_area_m2":4.9,"fill_m3":56.94},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":45.54,"cumulative_volume_m3":-6885.47,"cut_soil_m3":45.42,"cut_rock_m3":75.77,"cut_rr_m3":75.77,"cut_br_m3":0,"net_area_m2":-0.35,"fill_m3":83.61},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":122.68,"cumulative_volume_m3":-6762.79,"cut_soil_m3":49.07,"cut_rock_m3":113.84,"cut_rr_m3":113.84,"cut_br_m3":0,"net_area_m2":12.62,"fill_m3":54.1},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":113.59,"cumulative_volume_m3":-6649.2,"cut_soil_m3":49.76,"cut_rock_m3":114.9,"cut_rr_m3":114.9,"cut_br_m3":0,"net_area_m2":-1.26,"fill_m3":65.05},{"station_id":"station_000000681000","chainage_m":681,"net_volume_m3":-127.33,"cumulative_volume_m3":-6776.53,"cut_soil_m3":32.98,"cut_rock_m3":31.99,"cut_rr_m3":31.99,"cut_br_m3":0,"net_area_m2":-10.87,"fill_m3":194.28},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-840.67,"cumulative_volume_m3":-7617.2,"cut_soil_m3":29.33,"cut_rock_m3":30.44,"cut_rr_m3":30.44,"cut_br_m3":0,"net_area_m2":-77.62,"fill_m3":902.53},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-882.72,"cumulative_volume_m3":-8499.92,"cut_soil_m3":35.46,"cut_rock_m3":63.13,"cut_rr_m3":63.13,"cut_br_m3":0,"net_area_m2":-10.65,"fill_m3":988.18},{"station_id":"station_000000741000","chainage_m":741,"net_volume_m3":-180.52,"cumulative_volume_m3":-8680.45,"cut_soil_m3":56.42,"cut_rock_m3":149.13,"cut_rr_m3":149.13,"cut_br_m3":0,"net_area_m2":-6.55,"fill_m3":405.04},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-129.16,"cumulative_volume_m3":-8809.6,"cut_soil_m3":47.84,"cut_rock_m3":111.92,"cut_rr_m3":111.92,"cut_br_m3":0,"net_area_m2":-7.05,"fill_m3":302.6},{"station_id":"station_000000781000","chainage_m":781,"net_volume_m3":-180.21,"cumulative_volume_m3":-8989.81,"cut_soil_m3":32.82,"cut_rock_m3":47.43,"cut_rr_m3":47.43,"cut_br_m3":0,"net_area_m2":-10.11,"fill_m3":265},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":-1537.88,"cumulative_volume_m3":-10527.69,"cut_soil_m3":31.66,"cut_rock_m3":52.03,"cut_rr_m3":52.03,"cut_br_m3":0,"net_area_m2":-151.77,"fill_m3":1626.99},{"station_id":"station_000000821000","chainage_m":821,"net_volume_m3":-1840.85,"cumulative_volume_m3":-12368.53,"cut_soil_m3":38.85,"cut_rock_m3":57.23,"cut_rr_m3":57.23,"cut_br_m3":0,"net_area_m2":-23.55,"fill_m3":1942.49},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":-783,"cumulative_volume_m3":-13151.53,"cut_soil_m3":36.5,"cut_rock_m3":80.64,"cut_rr_m3":80.64,"cut_br_m3":0,"net_area_m2":-58.87,"fill_m3":909.8},{"station_id":"station_000000861000","chainage_m":861,"net_volume_m3":-1181.94,"cumulative_volume_m3":-14333.47,"cut_soil_m3":40.94,"cut_rock_m3":104.93,"cut_rr_m3":104.93,"cut_br_m3":0,"net_area_m2":-53.69,"fill_m3":1341.03},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-607.16,"cumulative_volume_m3":-14940.64,"cut_soil_m3":34.4,"cut_rock_m3":63.49,"cut_rr_m3":63.49,"cut_br_m3":0,"net_area_m2":-10.22,"fill_m3":712.08},{"station_id":"station_000000901000","chainage_m":901,"net_volume_m3":57.24,"cumulative_volume_m3":-14883.4,"cut_soil_m3":53.75,"cut_rock_m3":144.15,"cut_rr_m3":144.15,"cut_br_m3":0,"net_area_m2":15.67,"fill_m3":159.07},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":179.2,"cumulative_volume_m3":-14704.2,"cut_soil_m3":49.34,"cut_rock_m3":138.4,"cut_rr_m3":138.4,"cut_br_m3":0,"net_area_m2":3.19,"fill_m3":26.44},{"station_id":"station_000000941000","chainage_m":941,"net_volume_m3":41.98,"cumulative_volume_m3":-14662.22,"cut_soil_m3":49.08,"cut_rock_m3":105.64,"cut_rr_m3":105.64,"cut_br_m3":0,"net_area_m2":0.8,"fill_m3":125.26},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":8.79,"cumulative_volume_m3":-14653.42,"cut_soil_m3":46.27,"cut_rock_m3":121.29,"cut_rr_m3":121.29,"cut_br_m3":0,"net_area_m2":0.12,"fill_m3":174.16},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":-81.59,"cumulative_volume_m3":-14735.01,"cut_soil_m3":37.28,"cut_rock_m3":83.07,"cut_rr_m3":83.07,"cut_br_m3":0,"net_area_m2":-8.28,"fill_m3":211.91},{"station_id":"station_000001001000","chainage_m":1001,"net_volume_m3":-1175.69,"cumulative_volume_m3":-15910.7,"cut_soil_m3":34.96,"cut_rock_m3":51.49,"cut_rr_m3":51.49,"cut_br_m3":0,"net_area_m2":-103.69,"fill_m3":1267.14},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-950.93,"cumulative_volume_m3":-16861.62,"cut_soil_m3":32.86,"cut_rock_m3":55.92,"cut_rr_m3":55.92,"cut_br_m3":0,"net_area_m2":3.59,"fill_m3":1045.64},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":-40.25,"cumulative_volume_m3":-16901.88,"cut_soil_m3":35.02,"cut_rock_m3":55.12,"cut_rr_m3":55.12,"cut_br_m3":0,"net_area_m2":-7.62,"fill_m3":135.99},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":-180.55,"cumulative_volume_m3":-17082.43,"cut_soil_m3":23.58,"cut_rock_m3":22.92,"cut_rr_m3":22.92,"cut_br_m3":0,"net_area_m2":-10.44,"fill_m3":228.47},{"station_id":"station_000001080000","chainage_m":1080,"net_volume_m3":-77.47,"cumulative_volume_m3":-17159.9,"cut_soil_m3":24.64,"cut_rock_m3":25.81,"cut_rr_m3":25.81,"cut_br_m3":0,"net_area_m2":2.69,"fill_m3":129.72},{"station_id":"station_000001080936","chainage_m":1080.94,"net_volume_m3":1.34,"cumulative_volume_m3":-17158.56,"cut_soil_m3":1.45,"cut_rock_m3":1.71,"cut_rr_m3":1.71,"cut_br_m3":0,"net_area_m2":0.17,"fill_m3":1.96}]},"592":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":1906.46,"ripping_rock":2958.09,"blasting_rock":0},"cut_compacted_m3":5161.99,"fill_compacted_m3":20255.44,"final_cumulative_m3":-15093.45,"surplus_m3":0,"shortage_m3":15093.45,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-0.17,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":-175.73,"cumulative_volume_m3":-175.73,"cut_soil_m3":20.28,"cut_rock_m3":24.12,"cut_rr_m3":24.12,"cut_br_m3":0,"net_area_m2":-17.41,"fill_m3":222.08},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":-507.4,"cumulative_volume_m3":-683.13,"cut_soil_m3":29.55,"cut_rock_m3":28.33,"cut_rr_m3":28.33,"cut_br_m3":0,"net_area_m2":-33.33,"fill_m3":566.99},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":-349.19,"cumulative_volume_m3":-1032.33,"cut_soil_m3":29.81,"cut_rock_m3":35.11,"cut_rr_m3":35.11,"cut_br_m3":0,"net_area_m2":-1.59,"fill_m3":416.93},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":-225.46,"cumulative_volume_m3":-1257.79,"cut_soil_m3":35.37,"cut_rock_m3":58.86,"cut_rr_m3":58.86,"cut_br_m3":0,"net_area_m2":-20.96,"fill_m3":325.86},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":-138.95,"cumulative_volume_m3":-1396.74,"cut_soil_m3":39.88,"cut_rock_m3":114.02,"cut_rr_m3":114.02,"cut_br_m3":0,"net_area_m2":7.07,"fill_m3":307.67},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":-210.34,"cumulative_volume_m3":-1607.08,"cut_soil_m3":35.32,"cut_rock_m3":93.76,"cut_rr_m3":93.76,"cut_br_m3":0,"net_area_m2":-28.1,"fill_m3":351.36},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":-281.15,"cumulative_volume_m3":-1888.23,"cut_soil_m3":28.19,"cut_rock_m3":22.2,"cut_rr_m3":22.2,"cut_br_m3":0,"net_area_m2":-0.02,"fill_m3":332.39},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":-51.01,"cumulative_volume_m3":-1939.24,"cut_soil_m3":20.82,"cut_rock_m3":15.6,"cut_rr_m3":15.6,"cut_br_m3":0,"net_area_m2":-5.09,"fill_m3":87.92},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":-81.44,"cumulative_volume_m3":-2020.68,"cut_soil_m3":20.3,"cut_rock_m3":13.08,"cut_rr_m3":13.08,"cut_br_m3":0,"net_area_m2":-3.06,"fill_m3":114.95},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":-25.75,"cumulative_volume_m3":-2046.43,"cut_soil_m3":26,"cut_rock_m3":17.48,"cut_rr_m3":17.48,"cut_br_m3":0,"net_area_m2":0.48,"fill_m3":69.52},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":-148.81,"cumulative_volume_m3":-2195.25,"cut_soil_m3":23.43,"cut_rock_m3":7.04,"cut_rr_m3":7.04,"cut_br_m3":0,"net_area_m2":-15.36,"fill_m3":178.1},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":-322.38,"cumulative_volume_m3":-2517.62,"cut_soil_m3":23.97,"cut_rock_m3":13.48,"cut_rr_m3":13.48,"cut_br_m3":0,"net_area_m2":-16.87,"fill_m3":359.65},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":-104.21,"cumulative_volume_m3":-2621.84,"cut_soil_m3":34.8,"cut_rock_m3":85.2,"cut_rr_m3":85.2,"cut_br_m3":0,"net_area_m2":6.45,"fill_m3":234.78},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-271.27,"cumulative_volume_m3":-2893.1,"cut_soil_m3":40.71,"cut_rock_m3":99.94,"cut_rr_m3":99.94,"cut_br_m3":0,"net_area_m2":-33.58,"fill_m3":424.34},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-616.51,"cumulative_volume_m3":-3509.61,"cut_soil_m3":37.89,"cut_rock_m3":74.89,"cut_rr_m3":74.89,"cut_br_m3":0,"net_area_m2":-28.07,"fill_m3":737.86},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":-254.69,"cumulative_volume_m3":-3764.3,"cut_soil_m3":39.04,"cut_rock_m3":90.1,"cut_rr_m3":90.1,"cut_br_m3":0,"net_area_m2":2.6,"fill_m3":394.79},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":11.08,"cumulative_volume_m3":-3753.21,"cut_soil_m3":38.72,"cut_rock_m3":69.8,"cut_rr_m3":69.8,"cut_br_m3":0,"net_area_m2":-1.49,"fill_m3":105.09},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-17.89,"cumulative_volume_m3":-3771.11,"cut_soil_m3":36.42,"cut_rock_m3":48.66,"cut_rr_m3":48.66,"cut_br_m3":0,"net_area_m2":-0.29,"fill_m3":107.37},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":9.64,"cumulative_volume_m3":-3761.46,"cut_soil_m3":32.05,"cut_rock_m3":31.56,"cut_rr_m3":31.56,"cut_br_m3":0,"net_area_m2":1.26,"fill_m3":55.97},{"station_id":"station_000000389000","chainage_m":389,"net_volume_m3":2.21,"cumulative_volume_m3":-3759.25,"cut_soil_m3":10.85,"cut_rock_m3":7.65,"cut_rr_m3":7.65,"cut_br_m3":0,"net_area_m2":-0.77,"fill_m3":16.47},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-10.63,"cumulative_volume_m3":-3769.89,"cut_soil_m3":12.8,"cut_rock_m3":14.82,"cut_rr_m3":14.82,"cut_br_m3":0,"net_area_m2":-1.17,"fill_m3":39.42},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":-83.39,"cumulative_volume_m3":-3853.28,"cut_soil_m3":29.08,"cut_rock_m3":39.15,"cut_rr_m3":39.15,"cut_br_m3":0,"net_area_m2":-7.17,"fill_m3":155.17},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":-174.8,"cumulative_volume_m3":-4028.08,"cut_soil_m3":33.49,"cut_rock_m3":45.64,"cut_rr_m3":45.64,"cut_br_m3":0,"net_area_m2":-10.31,"fill_m3":258.11},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":-52.07,"cumulative_volume_m3":-4080.15,"cut_soil_m3":35.92,"cut_rock_m3":66.92,"cut_rr_m3":66.92,"cut_br_m3":0,"net_area_m2":5.1,"fill_m3":162.37},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":22.53,"cumulative_volume_m3":-4057.62,"cut_soil_m3":33.67,"cut_rock_m3":64.25,"cut_rr_m3":64.25,"cut_br_m3":0,"net_area_m2":-2.85,"fill_m3":82.63},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":-30.66,"cumulative_volume_m3":-4088.28,"cut_soil_m3":32.19,"cut_rock_m3":42.89,"cut_rr_m3":42.89,"cut_br_m3":0,"net_area_m2":-0.22,"fill_m3":109.59},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":-49.99,"cumulative_volume_m3":-4138.27,"cut_soil_m3":26.52,"cut_rock_m3":22.49,"cut_rr_m3":22.49,"cut_br_m3":0,"net_area_m2":-4.78,"fill_m3":100.05},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-24.8,"cumulative_volume_m3":-4163.07,"cut_soil_m3":26.84,"cut_rock_m3":18.4,"cut_rr_m3":18.4,"cut_br_m3":0,"net_area_m2":2.3,"fill_m3":70.4},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":30.6,"cumulative_volume_m3":-4132.47,"cut_soil_m3":27.61,"cut_rock_m3":19.21,"cut_rr_m3":19.21,"cut_br_m3":0,"net_area_m2":0.76,"fill_m3":16.63},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":6.8,"cumulative_volume_m3":-4125.67,"cut_soil_m3":25.47,"cut_rock_m3":13.96,"cut_rr_m3":13.96,"cut_br_m3":0,"net_area_m2":-0.08,"fill_m3":32.38},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":-38.62,"cumulative_volume_m3":-4164.29,"cut_soil_m3":26.13,"cut_rock_m3":20.95,"cut_rr_m3":20.95,"cut_br_m3":0,"net_area_m2":-3.78,"fill_m3":86.54},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":-5.38,"cumulative_volume_m3":-4169.67,"cut_soil_m3":30.35,"cut_rock_m3":63.51,"cut_rr_m3":63.51,"cut_br_m3":0,"net_area_m2":3.24,"fill_m3":106.69},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":25.73,"cumulative_volume_m3":-4143.93,"cut_soil_m3":34.52,"cut_rock_m3":68.08,"cut_rr_m3":68.08,"cut_br_m3":0,"net_area_m2":-0.67,"fill_m3":84.65},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":-21.63,"cumulative_volume_m3":-4165.57,"cut_soil_m3":32.09,"cut_rock_m3":38.59,"cut_rr_m3":38.59,"cut_br_m3":0,"net_area_m2":-1.49,"fill_m3":95.46},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":0.21,"cumulative_volume_m3":-4165.36,"cut_soil_m3":30.97,"cut_rock_m3":40.04,"cut_rr_m3":40.04,"cut_br_m3":0,"net_area_m2":1.51,"fill_m3":74.32},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-41.16,"cumulative_volume_m3":-4206.52,"cut_soil_m3":30.04,"cut_rock_m3":33.54,"cut_rr_m3":33.54,"cut_br_m3":0,"net_area_m2":-5.63,"fill_m3":107.27},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-534.3,"cumulative_volume_m3":-4740.82,"cut_soil_m3":40.14,"cut_rock_m3":71.71,"cut_rr_m3":71.71,"cut_br_m3":0,"net_area_m2":-47.8,"fill_m3":653.97},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":-1737.81,"cumulative_volume_m3":-6478.64,"cut_soil_m3":44.15,"cut_rock_m3":83.28,"cut_rr_m3":83.28,"cut_br_m3":0,"net_area_m2":-125.98,"fill_m3":1874.57},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-1329.34,"cumulative_volume_m3":-7807.98,"cut_soil_m3":41.92,"cut_rock_m3":77.5,"cut_rr_m3":77.5,"cut_br_m3":0,"net_area_m2":-6.95,"fill_m3":1457.36},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":-346.18,"cumulative_volume_m3":-8154.16,"cut_soil_m3":45.09,"cut_rock_m3":100.2,"cut_rr_m3":100.2,"cut_br_m3":0,"net_area_m2":-27.66,"fill_m3":503.49},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":-355.54,"cumulative_volume_m3":-8509.7,"cut_soil_m3":39.59,"cut_rock_m3":70.45,"cut_rr_m3":70.45,"cut_br_m3":0,"net_area_m2":-7.89,"fill_m3":473.24},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":-152.56,"cumulative_volume_m3":-8662.26,"cut_soil_m3":31.69,"cut_rock_m3":47.91,"cut_rr_m3":47.91,"cut_br_m3":0,"net_area_m2":-7.37,"fill_m3":236.9},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":-97.38,"cumulative_volume_m3":-8759.64,"cut_soil_m3":28.98,"cut_rock_m3":46.67,"cut_rr_m3":46.67,"cut_br_m3":0,"net_area_m2":-2.37,"fill_m3":177.84},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":-17.18,"cumulative_volume_m3":-8776.82,"cut_soil_m3":28.62,"cut_rock_m3":30.05,"cut_rr_m3":30.05,"cut_br_m3":0,"net_area_m2":0.65,"fill_m3":77.94},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-515.88,"cumulative_volume_m3":-9292.7,"cut_soil_m3":24.85,"cut_rock_m3":14.07,"cut_rr_m3":14.07,"cut_br_m3":0,"net_area_m2":-52.24,"fill_m3":554.64},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":-1445.46,"cumulative_volume_m3":-10738.16,"cut_soil_m3":31.01,"cut_rock_m3":49.51,"cut_rr_m3":49.51,"cut_br_m3":0,"net_area_m2":-92.3,"fill_m3":1531.05},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":-884.55,"cumulative_volume_m3":-11622.71,"cut_soil_m3":37.19,"cut_rock_m3":73.52,"cut_rr_m3":73.52,"cut_br_m3":0,"net_area_m2":3.85,"fill_m3":1003.67},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":53.7,"cumulative_volume_m3":-11569.01,"cut_soil_m3":32.03,"cut_rock_m3":43.48,"cut_rr_m3":43.48,"cut_br_m3":0,"net_area_m2":1.52,"fill_m3":25.78},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":21.43,"cumulative_volume_m3":-11547.57,"cut_soil_m3":25.44,"cut_rock_m3":24.16,"cut_rr_m3":24.16,"cut_br_m3":0,"net_area_m2":0.62,"fill_m3":29.61},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":-17.29,"cumulative_volume_m3":-11564.86,"cut_soil_m3":28.11,"cut_rock_m3":33.96,"cut_rr_m3":33.96,"cut_br_m3":0,"net_area_m2":-2.35,"fill_m3":82.15},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-198.3,"cumulative_volume_m3":-11763.16,"cut_soil_m3":35.44,"cut_rock_m3":44.69,"cut_rr_m3":44.69,"cut_br_m3":0,"net_area_m2":-17.48,"fill_m3":282.27},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-196.98,"cumulative_volume_m3":-11960.15,"cut_soil_m3":35.13,"cut_rock_m3":45.87,"cut_rr_m3":45.87,"cut_br_m3":0,"net_area_m2":-2.22,"fill_m3":282.04},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":-3.07,"cumulative_volume_m3":-11963.22,"cut_soil_m3":33.48,"cut_rock_m3":69.27,"cut_rr_m3":69.27,"cut_br_m3":0,"net_area_m2":1.91,"fill_m3":113.9},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":-544.21,"cumulative_volume_m3":-12507.43,"cut_soil_m3":31.95,"cut_rock_m3":68.41,"cut_rr_m3":68.41,"cut_br_m3":0,"net_area_m2":-56.33,"fill_m3":652.67},{"station_id":"station_000001080000","chainage_m":1080,"net_volume_m3":-543.61,"cumulative_volume_m3":-13051.03,"cut_soil_m3":32.99,"cut_rock_m3":55.87,"cut_rr_m3":55.87,"cut_br_m3":0,"net_area_m2":1.97,"fill_m3":638.38},{"station_id":"station_000001100000","chainage_m":1100,"net_volume_m3":-34.37,"cumulative_volume_m3":-13085.4,"cut_soil_m3":35.02,"cut_rock_m3":68.7,"cut_rr_m3":68.7,"cut_br_m3":0,"net_area_m2":-5.41,"fill_m3":145.92},{"station_id":"station_000001120000","chainage_m":1120,"net_volume_m3":-705.55,"cumulative_volume_m3":-13790.95,"cut_soil_m3":32.65,"cut_rock_m3":55.57,"cut_rr_m3":55.57,"cut_br_m3":0,"net_area_m2":-65.15,"fill_m3":799.67},{"station_id":"station_000001140000","chainage_m":1140,"net_volume_m3":-953.09,"cumulative_volume_m3":-14744.04,"cut_soil_m3":29.86,"cut_rock_m3":28.61,"cut_rr_m3":28.61,"cut_br_m3":0,"net_area_m2":-30.16,"fill_m3":1013.3},{"station_id":"station_000001160000","chainage_m":1160,"net_volume_m3":-264.65,"cumulative_volume_m3":-15008.69,"cut_soil_m3":39.22,"cut_rock_m3":93.83,"cut_rr_m3":93.83,"cut_br_m3":0,"net_area_m2":3.7,"fill_m3":409.25},{"station_id":"station_000001180000","chainage_m":1180,"net_volume_m3":-48.75,"cumulative_volume_m3":-15057.43,"cut_soil_m3":38.42,"cut_rock_m3":88.47,"cut_rr_m3":88.47,"cut_br_m3":0,"net_area_m2":-8.57,"fill_m3":186.39},{"station_id":"station_000001188804","chainage_m":1188.8,"net_volume_m3":-36.01,"cumulative_volume_m3":-15093.45,"cut_soil_m3":12.41,"cut_rock_m3":9.01,"cut_rr_m3":9.01,"cut_br_m3":0,"net_area_m2":0.39,"fill_m3":57.68}]},"761":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":3387.67,"ripping_rock":29842.3,"blasting_rock":0},"cut_compacted_m3":37815.18,"fill_compacted_m3":36824.14,"final_cumulative_m3":991.04,"surplus_m3":991.04,"shortage_m3":0,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-0.17,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":166.99,"cumulative_volume_m3":166.99,"cut_soil_m3":36.36,"cut_rock_m3":119.55,"cut_rr_m3":119.55,"cut_br_m3":0,"net_area_m2":16.87,"fill_m3":5.01},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":567.31,"cumulative_volume_m3":734.29,"cut_soil_m3":77.42,"cut_rock_m3":427.69,"cut_rr_m3":427.69,"cut_br_m3":0,"net_area_m2":39.87,"fill_m3":0.63},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":890.48,"cumulative_volume_m3":1624.78,"cut_soil_m3":92.83,"cut_rock_m3":692.65,"cut_rr_m3":692.65,"cut_br_m3":0,"net_area_m2":49.18,"fill_m3":0},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":1359.94,"cumulative_volume_m3":2984.71,"cut_soil_m3":109.06,"cut_rock_m3":1083.07,"cut_rr_m3":1083.07,"cut_br_m3":0,"net_area_m2":86.81,"fill_m3":0},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":1882.58,"cumulative_volume_m3":4867.29,"cut_soil_m3":125.68,"cut_rock_m3":1518.85,"cut_rr_m3":1518.85,"cut_br_m3":0,"net_area_m2":101.45,"fill_m3":0},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":2358.88,"cumulative_volume_m3":7226.17,"cut_soil_m3":139.8,"cut_rock_m3":1916.79,"cut_rr_m3":1916.79,"cut_br_m3":0,"net_area_m2":134.44,"fill_m3":0},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":2676.44,"cumulative_volume_m3":9902.62,"cut_soil_m3":147.69,"cut_rock_m3":2183.28,"cut_rr_m3":2183.28,"cut_br_m3":0,"net_area_m2":133.2,"fill_m3":0},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":2623.86,"cumulative_volume_m3":12526.47,"cut_soil_m3":143.36,"cut_rock_m3":2141.49,"cut_rr_m3":2141.49,"cut_br_m3":0,"net_area_m2":129.18,"fill_m3":0},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":2349.16,"cumulative_volume_m3":14875.64,"cut_soil_m3":136.06,"cut_rock_m3":1911.34,"cut_rr_m3":1911.34,"cut_br_m3":0,"net_area_m2":105.73,"fill_m3":0},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":2056.4,"cumulative_volume_m3":16932.04,"cut_soil_m3":129.27,"cut_rock_m3":1665.29,"cut_rr_m3":1665.29,"cut_br_m3":0,"net_area_m2":99.91,"fill_m3":0},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":1958.15,"cumulative_volume_m3":18890.19,"cut_soil_m3":127.64,"cut_rock_m3":1582.21,"cut_rr_m3":1582.21,"cut_br_m3":0,"net_area_m2":95.91,"fill_m3":0},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":2300.65,"cumulative_volume_m3":21190.84,"cut_soil_m3":138.38,"cut_rock_m3":1867.9,"cut_rr_m3":1867.9,"cut_br_m3":0,"net_area_m2":134.16,"fill_m3":0},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":3003.85,"cumulative_volume_m3":24194.69,"cut_soil_m3":155.49,"cut_rock_m3":2458.3,"cut_rr_m3":2458.3,"cut_br_m3":0,"net_area_m2":166.23,"fill_m3":0},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":3125.84,"cumulative_volume_m3":27320.53,"cut_soil_m3":155.19,"cut_rock_m3":2563.23,"cut_rr_m3":2563.23,"cut_br_m3":0,"net_area_m2":146.35,"fill_m3":0},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":1827.82,"cumulative_volume_m3":29148.35,"cut_soil_m3":117.21,"cut_rock_m3":1478.39,"cut_rr_m3":1478.39,"cut_br_m3":0,"net_area_m2":36.43,"fill_m3":0},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":-459.7,"cumulative_volume_m3":28688.65,"cut_soil_m3":43.46,"cut_rock_m3":279.11,"cut_rr_m3":279.11,"cut_br_m3":0,"net_area_m2":-82.4,"fill_m3":823.97},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":-5494.19,"cumulative_volume_m3":23194.46,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-467.02,"fill_m3":5494.19},{"station_id":"station_000000359000","chainage_m":359,"net_volume_m3":-8772.77,"cumulative_volume_m3":14421.69,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-456.43,"fill_m3":8772.77},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-465.49,"cumulative_volume_m3":13956.2,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-474.55,"fill_m3":465.49},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":-6828.32,"cumulative_volume_m3":7127.88,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-208.28,"fill_m3":6828.32},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-2001.61,"cumulative_volume_m3":5126.26,"cut_soil_m3":29.27,"cut_rock_m3":47.07,"cut_rr_m3":47.07,"cut_br_m3":0,"net_area_m2":8.12,"fill_m3":2082.78},{"station_id":"station_000000404000","chainage_m":404,"net_volume_m3":28.32,"cumulative_volume_m3":5154.59,"cut_soil_m3":11.19,"cut_rock_m3":15.76,"cut_rr_m3":15.76,"cut_br_m3":0,"net_area_m2":6.04,"fill_m3":0.11},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":892.03,"cumulative_volume_m3":6046.62,"cut_soil_m3":74.76,"cut_rock_m3":708.32,"cut_rr_m3":708.32,"cut_br_m3":0,"net_area_m2":105.46,"fill_m3":0.45},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":1983.63,"cumulative_volume_m3":8030.25,"cut_soil_m3":131.4,"cut_rock_m3":1601.18,"cut_rr_m3":1601.18,"cut_br_m3":0,"net_area_m2":92.9,"fill_m3":0},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":1096.24,"cumulative_volume_m3":9126.49,"cut_soil_m3":98.44,"cut_rock_m3":864.93,"cut_rr_m3":864.93,"cut_br_m3":0,"net_area_m2":16.72,"fill_m3":0},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":197.99,"cumulative_volume_m3":9324.47,"cut_soil_m3":55.57,"cut_rock_m3":147.23,"cut_rr_m3":147.23,"cut_br_m3":0,"net_area_m2":3.08,"fill_m3":23.55},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":46.02,"cumulative_volume_m3":9370.5,"cut_soil_m3":36.56,"cut_rock_m3":45.56,"cut_rr_m3":45.56,"cut_br_m3":0,"net_area_m2":1.53,"fill_m3":39.96},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":18.68,"cumulative_volume_m3":9389.18,"cut_soil_m3":22.92,"cut_rock_m3":17.26,"cut_rr_m3":17.26,"cut_br_m3":0,"net_area_m2":0.34,"fill_m3":22.06},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-5.63,"cumulative_volume_m3":9383.54,"cut_soil_m3":18.97,"cut_rock_m3":8.98,"cut_rr_m3":8.98,"cut_br_m3":0,"net_area_m2":-0.91,"fill_m3":33.16},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":-120.39,"cumulative_volume_m3":9263.15,"cut_soil_m3":16.53,"cut_rock_m3":9.99,"cut_rr_m3":9.99,"cut_br_m3":0,"net_area_m2":-11.13,"fill_m3":146.91},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":-117.69,"cumulative_volume_m3":9145.46,"cut_soil_m3":23.82,"cut_rock_m3":21.13,"cut_rr_m3":21.13,"cut_br_m3":0,"net_area_m2":-0.64,"fill_m3":163.75},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":-11.38,"cumulative_volume_m3":9134.08,"cut_soil_m3":36.1,"cut_rock_m3":42.89,"cut_rr_m3":42.89,"cut_br_m3":0,"net_area_m2":-0.5,"fill_m3":93.83},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":101.73,"cumulative_volume_m3":9235.81,"cut_soil_m3":46.54,"cut_rock_m3":117.46,"cut_rr_m3":117.46,"cut_br_m3":0,"net_area_m2":10.67,"fill_m3":77},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":144.66,"cumulative_volume_m3":9380.47,"cut_soil_m3":49.39,"cut_rock_m3":118.89,"cut_rr_m3":118.89,"cut_br_m3":0,"net_area_m2":3.79,"fill_m3":38.3},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":-13.74,"cumulative_volume_m3":9366.73,"cut_soil_m3":39.14,"cut_rock_m3":64.38,"cut_rr_m3":64.38,"cut_br_m3":0,"net_area_m2":-5.17,"fill_m3":123.96},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":-242.22,"cumulative_volume_m3":9124.51,"cut_soil_m3":37.85,"cut_rock_m3":86.65,"cut_rr_m3":86.65,"cut_br_m3":0,"net_area_m2":-19.06,"fill_m3":377.23},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-1984.13,"cumulative_volume_m3":7140.38,"cut_soil_m3":35.73,"cut_rock_m3":71.99,"cut_rr_m3":71.99,"cut_br_m3":0,"net_area_m2":-179.36,"fill_m3":2100.16},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-2032.1,"cumulative_volume_m3":5108.28,"cut_soil_m3":35.61,"cut_rock_m3":39.52,"cut_rr_m3":39.52,"cut_br_m3":0,"net_area_m2":-23.85,"fill_m3":2110.19},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":-411.83,"cumulative_volume_m3":4696.45,"cut_soil_m3":40.82,"cut_rock_m3":48.27,"cut_rr_m3":48.27,"cut_br_m3":0,"net_area_m2":-17.33,"fill_m3":504.81},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-133.24,"cumulative_volume_m3":4563.22,"cut_soil_m3":42.53,"cut_rock_m3":90.73,"cut_rr_m3":90.73,"cut_br_m3":0,"net_area_m2":4.01,"fill_m3":277.22},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":119.93,"cumulative_volume_m3":4683.15,"cut_soil_m3":47.96,"cut_rock_m3":130.88,"cut_rr_m3":130.88,"cut_br_m3":0,"net_area_m2":7.99,"fill_m3":75.71},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":378.99,"cumulative_volume_m3":5062.14,"cut_soil_m3":71.22,"cut_rock_m3":295.38,"cut_rr_m3":295.38,"cut_br_m3":0,"net_area_m2":29.91,"fill_m3":29.23},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":143.49,"cumulative_volume_m3":5205.63,"cut_soil_m3":64.52,"cut_rock_m3":269.63,"cut_rr_m3":269.63,"cut_br_m3":0,"net_area_m2":-15.56,"fill_m3":228.7},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":-1116.45,"cumulative_volume_m3":4089.18,"cut_soil_m3":36.8,"cut_rock_m3":71.94,"cut_rr_m3":71.94,"cut_br_m3":0,"net_area_m2":-96.08,"fill_m3":1233.38},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":-1020.55,"cumulative_volume_m3":3068.63,"cut_soil_m3":30.1,"cut_rock_m3":37.09,"cut_rr_m3":37.09,"cut_br_m3":0,"net_area_m2":-5.97,"fill_m3":1090.85},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-43.1,"cumulative_volume_m3":3025.53,"cut_soil_m3":32.88,"cut_rock_m3":44.51,"cut_rr_m3":44.51,"cut_br_m3":0,"net_area_m2":1.66,"fill_m3":124.55},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":101.61,"cumulative_volume_m3":3127.14,"cut_soil_m3":44.33,"cut_rock_m3":97.93,"cut_rr_m3":97.93,"cut_br_m3":0,"net_area_m2":8.5,"fill_m3":52.38},{"station_id":"station_000000918000","chainage_m":918,"net_volume_m3":147.34,"cumulative_volume_m3":3274.48,"cut_soil_m3":43.78,"cut_rock_m3":125.18,"cut_rr_m3":125.18,"cut_br_m3":0,"net_area_m2":7.87,"fill_m3":37.9},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":6.8,"cumulative_volume_m3":3281.28,"cut_soil_m3":4.42,"cut_rock_m3":9.88,"cut_rr_m3":9.88,"cut_br_m3":0,"net_area_m2":-1.07,"fill_m3":8.69},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":-462.95,"cumulative_volume_m3":2818.33,"cut_soil_m3":20.53,"cut_rock_m3":25.36,"cut_rr_m3":25.36,"cut_br_m3":0,"net_area_m2":-45.22,"fill_m3":510.97},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":-453.35,"cumulative_volume_m3":2364.98,"cut_soil_m3":6.8,"cut_rock_m3":2.58,"cut_rr_m3":2.58,"cut_br_m3":0,"net_area_m2":-0.11,"fill_m3":462.47},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":181.94,"cumulative_volume_m3":2546.91,"cut_soil_m3":40.34,"cut_rock_m3":133.79,"cut_rr_m3":133.79,"cut_br_m3":0,"net_area_m2":18.31,"fill_m3":10.24},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-375.69,"cumulative_volume_m3":2171.22,"cut_soil_m3":56.03,"cut_rock_m3":192.21,"cut_rr_m3":192.21,"cut_br_m3":0,"net_area_m2":-55.88,"fill_m3":650.04},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-379.94,"cumulative_volume_m3":1791.28,"cut_soil_m3":57.81,"cut_rock_m3":187.18,"cut_rr_m3":187.18,"cut_br_m3":0,"net_area_m2":17.88,"fill_m3":650.04},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":-72.52,"cumulative_volume_m3":1718.76,"cut_soil_m3":46.45,"cut_rock_m3":139.03,"cut_rr_m3":139.03,"cut_br_m3":0,"net_area_m2":-25.13,"fill_m3":276.29},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":-539.63,"cumulative_volume_m3":1179.13,"cut_soil_m3":14.19,"cut_rock_m3":12.9,"cut_rr_m3":12.9,"cut_br_m3":0,"net_area_m2":-28.83,"fill_m3":567.42},{"station_id":"station_000001073274","chainage_m":1073.27,"net_volume_m3":-188.09,"cumulative_volume_m3":991.04,"cut_soil_m3":11.47,"cut_rock_m3":9.5,"cut_rr_m3":9.5,"cut_br_m3":0,"net_area_m2":0.49,"fill_m3":209.48}]},"762":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":2660.16,"ripping_rock":16008.35,"blasting_rock":0},"cut_compacted_m3":21043.87,"fill_compacted_m3":13874.21,"final_cumulative_m3":7169.66,"surplus_m3":7169.66,"shortage_m3":0,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-0.17,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":166.99,"cumulative_volume_m3":166.99,"cut_soil_m3":36.36,"cut_rock_m3":119.55,"cut_rr_m3":119.55,"cut_br_m3":0,"net_area_m2":16.87,"fill_m3":5.01},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":567.31,"cumulative_volume_m3":734.29,"cut_soil_m3":77.42,"cut_rock_m3":427.69,"cut_rr_m3":427.69,"cut_br_m3":0,"net_area_m2":39.87,"fill_m3":0.63},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":890.48,"cumulative_volume_m3":1624.78,"cut_soil_m3":92.83,"cut_rock_m3":692.65,"cut_rr_m3":692.65,"cut_br_m3":0,"net_area_m2":49.18,"fill_m3":0},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":1359.94,"cumulative_volume_m3":2984.71,"cut_soil_m3":109.06,"cut_rock_m3":1083.07,"cut_rr_m3":1083.07,"cut_br_m3":0,"net_area_m2":86.81,"fill_m3":0},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":1882.58,"cumulative_volume_m3":4867.29,"cut_soil_m3":125.68,"cut_rock_m3":1518.85,"cut_rr_m3":1518.85,"cut_br_m3":0,"net_area_m2":101.45,"fill_m3":0},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":2358.88,"cumulative_volume_m3":7226.17,"cut_soil_m3":139.8,"cut_rock_m3":1916.79,"cut_rr_m3":1916.79,"cut_br_m3":0,"net_area_m2":134.44,"fill_m3":0},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":2676.44,"cumulative_volume_m3":9902.62,"cut_soil_m3":147.69,"cut_rock_m3":2183.28,"cut_rr_m3":2183.28,"cut_br_m3":0,"net_area_m2":133.2,"fill_m3":0},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":2623.66,"cumulative_volume_m3":12526.27,"cut_soil_m3":143.36,"cut_rock_m3":2141.32,"cut_rr_m3":2141.32,"cut_br_m3":0,"net_area_m2":129.16,"fill_m3":0},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":2222.05,"cumulative_volume_m3":14748.32,"cut_soil_m3":132.24,"cut_rock_m3":1805.17,"cut_rr_m3":1805.17,"cut_br_m3":0,"net_area_m2":93.04,"fill_m3":0},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":998.02,"cumulative_volume_m3":15746.34,"cut_soil_m3":83.49,"cut_rock_m3":799.13,"cut_rr_m3":799.13,"cut_br_m3":0,"net_area_m2":6.76,"fill_m3":8.12},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":16.07,"cumulative_volume_m3":15762.41,"cut_soil_m3":36.61,"cut_rock_m3":64.46,"cut_rr_m3":64.46,"cut_br_m3":0,"net_area_m2":-5.15,"fill_m3":91.97},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":-28.51,"cumulative_volume_m3":15733.9,"cut_soil_m3":37.16,"cut_rock_m3":69.1,"cut_rr_m3":69.1,"cut_br_m3":0,"net_area_m2":2.3,"fill_m3":142.44},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":-120.84,"cumulative_volume_m3":15613.06,"cut_soil_m3":38.26,"cut_rock_m3":81.02,"cut_rr_m3":81.02,"cut_br_m3":0,"net_area_m2":-14.39,"fill_m3":249.67},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-405.51,"cumulative_volume_m3":15207.55,"cut_soil_m3":31.98,"cut_rock_m3":49.33,"cut_rr_m3":49.33,"cut_br_m3":0,"net_area_m2":-26.16,"fill_m3":491.76},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-241.16,"cumulative_volume_m3":14966.38,"cut_soil_m3":32.04,"cut_rock_m3":39.56,"cut_rr_m3":39.56,"cut_br_m3":0,"net_area_m2":2.05,"fill_m3":316.09},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":58.08,"cumulative_volume_m3":15024.47,"cut_soil_m3":32.16,"cut_rock_m3":47.78,"cut_rr_m3":47.78,"cut_br_m3":0,"net_area_m2":3.76,"fill_m3":26.52},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":16.18,"cumulative_volume_m3":15040.65,"cut_soil_m3":29.04,"cut_rock_m3":40.13,"cut_rr_m3":40.13,"cut_br_m3":0,"net_area_m2":-2.14,"fill_m3":56.7},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-3.49,"cumulative_volume_m3":15037.16,"cut_soil_m3":28.01,"cut_rock_m3":27.6,"cut_rr_m3":27.6,"cut_br_m3":0,"net_area_m2":1.79,"fill_m3":60.85},{"station_id":"station_000000361000","chainage_m":361,"net_volume_m3":1.08,"cumulative_volume_m3":15038.24,"cut_soil_m3":1.18,"cut_rock_m3":1.22,"cut_rr_m3":1.22,"cut_br_m3":0,"net_area_m2":0.37,"fill_m3":1.41},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":-1726.45,"cumulative_volume_m3":13311.79,"cut_soil_m3":8.55,"cut_rock_m3":6.93,"cut_rr_m3":6.93,"cut_br_m3":0,"net_area_m2":-182.11,"fill_m3":1742.23},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-1816.24,"cumulative_volume_m3":11495.55,"cut_soil_m3":16.7,"cut_rock_m3":4.53,"cut_rr_m3":4.53,"cut_br_m3":0,"net_area_m2":0.48,"fill_m3":1836.55},{"station_id":"station_000000405000","chainage_m":405,"net_volume_m3":16.38,"cumulative_volume_m3":11511.93,"cut_soil_m3":10.99,"cut_rock_m3":9.7,"cut_rr_m3":9.7,"cut_br_m3":0,"net_area_m2":6.07,"fill_m3":4.8},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":131.09,"cumulative_volume_m3":11643.02,"cut_soil_m3":39.19,"cut_rock_m3":93.71,"cut_rr_m3":93.71,"cut_br_m3":0,"net_area_m2":11.41,"fill_m3":13.36},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":185.31,"cumulative_volume_m3":11828.32,"cut_soil_m3":46.87,"cut_rock_m3":146.73,"cut_rr_m3":146.73,"cut_br_m3":0,"net_area_m2":7.12,"fill_m3":27.81},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":54.37,"cumulative_volume_m3":11882.69,"cut_soil_m3":35.62,"cut_rock_m3":63.7,"cut_rr_m3":63.7,"cut_br_m3":0,"net_area_m2":-1.68,"fill_m3":51.9},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":-41.91,"cumulative_volume_m3":11840.78,"cut_soil_m3":28.91,"cut_rock_m3":19.86,"cut_rr_m3":19.86,"cut_br_m3":0,"net_area_m2":-2.51,"fill_m3":91.06},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":-32.72,"cumulative_volume_m3":11808.07,"cut_soil_m3":29.15,"cut_rock_m3":26.44,"cut_rr_m3":26.44,"cut_br_m3":0,"net_area_m2":-0.77,"fill_m3":89.75},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":11.93,"cumulative_volume_m3":11820,"cut_soil_m3":29.8,"cut_rock_m3":19.57,"cut_rr_m3":19.57,"cut_br_m3":0,"net_area_m2":1.96,"fill_m3":37.69},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":19.24,"cumulative_volume_m3":11839.23,"cut_soil_m3":27.3,"cut_rock_m3":13.78,"cut_rr_m3":13.78,"cut_br_m3":0,"net_area_m2":-0.03,"fill_m3":21.39},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":-106.86,"cumulative_volume_m3":11732.37,"cut_soil_m3":18.05,"cut_rock_m3":11.81,"cut_rr_m3":11.81,"cut_br_m3":0,"net_area_m2":-10.65,"fill_m3":136.86},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":-119.35,"cumulative_volume_m3":11613.03,"cut_soil_m3":22.2,"cut_rock_m3":21.86,"cut_rr_m3":21.86,"cut_br_m3":0,"net_area_m2":-1.28,"fill_m3":164.8},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":-32.9,"cumulative_volume_m3":11580.13,"cut_soil_m3":31.07,"cut_rock_m3":37.07,"cut_rr_m3":37.07,"cut_br_m3":0,"net_area_m2":-2.01,"fill_m3":104.05},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":77.35,"cumulative_volume_m3":11657.47,"cut_soil_m3":43.21,"cut_rock_m3":101.65,"cut_rr_m3":101.65,"cut_br_m3":0,"net_area_m2":9.74,"fill_m3":79.96},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":118.35,"cumulative_volume_m3":11775.82,"cut_soil_m3":47.13,"cut_rock_m3":106.25,"cut_rr_m3":106.25,"cut_br_m3":0,"net_area_m2":2.09,"fill_m3":47.84},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":-64.12,"cumulative_volume_m3":11711.7,"cut_soil_m3":34.14,"cut_rock_m3":52.46,"cut_rr_m3":52.46,"cut_br_m3":0,"net_area_m2":-8.51,"fill_m3":155.95},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":-106.18,"cumulative_volume_m3":11605.53,"cut_soil_m3":40.54,"cut_rock_m3":92.9,"cut_rr_m3":92.9,"cut_br_m3":0,"net_area_m2":-2.11,"fill_m3":250.89},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-64.8,"cumulative_volume_m3":11540.72,"cut_soil_m3":48.38,"cut_rock_m3":102.83,"cut_rr_m3":102.83,"cut_br_m3":0,"net_area_m2":-4.37,"fill_m3":228.15},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-277.94,"cumulative_volume_m3":11262.78,"cut_soil_m3":39.77,"cut_rock_m3":49.26,"cut_rr_m3":49.26,"cut_br_m3":0,"net_area_m2":-23.43,"fill_m3":371.13},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":-506.68,"cumulative_volume_m3":10756.1,"cut_soil_m3":34.75,"cut_rock_m3":24.16,"cut_rr_m3":24.16,"cut_br_m3":0,"net_area_m2":-27.24,"fill_m3":566.11},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-284.39,"cumulative_volume_m3":10471.71,"cut_soil_m3":34.88,"cut_rock_m3":53.72,"cut_rr_m3":53.72,"cut_br_m3":0,"net_area_m2":-1.2,"fill_m3":378.36},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":52.4,"cumulative_volume_m3":10524.11,"cut_soil_m3":43.68,"cut_rock_m3":109.54,"cut_rr_m3":109.54,"cut_br_m3":0,"net_area_m2":6.44,"fill_m3":114.52},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":384.68,"cumulative_volume_m3":10908.8,"cut_soil_m3":71.33,"cut_rock_m3":310.21,"cut_rr_m3":310.21,"cut_br_m3":0,"net_area_m2":32.03,"fill_m3":40.9},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":240.83,"cumulative_volume_m3":11149.63,"cut_soil_m3":68.39,"cut_rock_m3":300.08,"cut_rr_m3":300.08,"cut_br_m3":0,"net_area_m2":-7.95,"fill_m3":170.32},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":-990.53,"cumulative_volume_m3":10159.1,"cut_soil_m3":41.56,"cut_rock_m3":90.13,"cut_rr_m3":90.13,"cut_br_m3":0,"net_area_m2":-91.1,"fill_m3":1132.94},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":-937.11,"cumulative_volume_m3":9221.99,"cut_soil_m3":32.38,"cut_rock_m3":44.45,"cut_rr_m3":44.45,"cut_br_m3":0,"net_area_m2":-2.61,"fill_m3":1018.02},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-8.23,"cumulative_volume_m3":9213.76,"cut_soil_m3":33.69,"cut_rock_m3":46.83,"cut_rr_m3":46.83,"cut_br_m3":0,"net_area_m2":1.78,"fill_m3":93.1},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":104.09,"cumulative_volume_m3":9317.85,"cut_soil_m3":44.41,"cut_rock_m3":99.21,"cut_rr_m3":99.21,"cut_br_m3":0,"net_area_m2":8.63,"fill_m3":51.47},{"station_id":"station_000000918000","chainage_m":918,"net_volume_m3":149.61,"cumulative_volume_m3":9467.46,"cut_soil_m3":43.81,"cut_rock_m3":126.95,"cut_rr_m3":126.95,"cut_br_m3":0,"net_area_m2":8,"fill_m3":37.71},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":7.13,"cumulative_volume_m3":9474.58,"cut_soil_m3":4.5,"cut_rock_m3":10.03,"cut_rr_m3":10.03,"cut_br_m3":0,"net_area_m2":-0.87,"fill_m3":8.61},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":-459.16,"cumulative_volume_m3":9015.42,"cut_soil_m3":21.34,"cut_rock_m3":25.79,"cut_rr_m3":25.79,"cut_br_m3":0,"net_area_m2":-45.04,"fill_m3":508.42},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":-451.42,"cumulative_volume_m3":8563.99,"cut_soil_m3":6.85,"cut_rock_m3":2.62,"cut_rr_m3":2.62,"cut_br_m3":0,"net_area_m2":-0.1,"fill_m3":460.64},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":182.55,"cumulative_volume_m3":8746.54,"cut_soil_m3":40.37,"cut_rock_m3":134.26,"cut_rr_m3":134.26,"cut_br_m3":0,"net_area_m2":18.35,"fill_m3":10.2},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-380.78,"cumulative_volume_m3":8365.76,"cut_soil_m3":56,"cut_rock_m3":192.29,"cut_rr_m3":192.29,"cut_br_m3":0,"net_area_m2":-56.43,"fill_m3":655.19},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-384.83,"cumulative_volume_m3":7980.94,"cut_soil_m3":57.81,"cut_rock_m3":187.41,"cut_rr_m3":187.41,"cut_br_m3":0,"net_area_m2":17.95,"fill_m3":655.19},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":-75.89,"cumulative_volume_m3":7905.05,"cut_soil_m3":46.67,"cut_rock_m3":139.56,"cut_rr_m3":139.56,"cut_br_m3":0,"net_area_m2":-25.54,"fill_m3":280.48},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":-545.55,"cumulative_volume_m3":7359.5,"cut_soil_m3":14.36,"cut_rock_m3":12.84,"cut_rr_m3":12.84,"cut_br_m3":0,"net_area_m2":-29.02,"fill_m3":573.43},{"station_id":"station_000001073309","chainage_m":1073.31,"net_volume_m3":-189.84,"cumulative_volume_m3":7169.66,"cut_soil_m3":11.47,"cut_rock_m3":9.52,"cut_rr_m3":9.52,"cut_br_m3":0,"net_area_m2":0.49,"fill_m3":211.25}]},"819":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":1998.76,"ripping_rock":3780.29,"blasting_rock":0},"cut_compacted_m3":6202.92,"fill_compacted_m3":14651.75,"final_cumulative_m3":-8448.83,"surplus_m3":0,"shortage_m3":8448.83,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-0.17,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":-60.3,"cumulative_volume_m3":-60.3,"cut_soil_m3":26.03,"cut_rock_m3":41.37,"cut_rr_m3":41.37,"cut_br_m3":0,"net_area_m2":-5.86,"fill_m3":131.92},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":-0.51,"cumulative_volume_m3":-60.81,"cut_soil_m3":50.58,"cut_rock_m3":107.12,"cut_rr_m3":107.12,"cut_br_m3":0,"net_area_m2":5.81,"fill_m3":170.82},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":14.63,"cumulative_volume_m3":-46.18,"cut_soil_m3":46.22,"cut_rock_m3":90.15,"cut_rr_m3":90.15,"cut_br_m3":0,"net_area_m2":-4.35,"fill_m3":131.99},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":-272.62,"cumulative_volume_m3":-318.81,"cut_soil_m3":33.65,"cut_rock_m3":42.08,"cut_rr_m3":42.08,"cut_br_m3":0,"net_area_m2":-22.91,"fill_m3":351.94},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":-411.23,"cumulative_volume_m3":-730.04,"cut_soil_m3":34.44,"cut_rock_m3":87.84,"cut_rr_m3":87.84,"cut_br_m3":0,"net_area_m2":-18.21,"fill_m3":544.56},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":-174.28,"cumulative_volume_m3":-904.32,"cut_soil_m3":37.42,"cut_rock_m3":93.65,"cut_rr_m3":93.65,"cut_br_m3":0,"net_area_m2":0.78,"fill_m3":317.07},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":-13.62,"cumulative_volume_m3":-917.94,"cut_soil_m3":31.22,"cut_rock_m3":32.14,"cut_rr_m3":32.14,"cut_br_m3":0,"net_area_m2":-2.14,"fill_m3":79.15},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":33.52,"cumulative_volume_m3":-884.42,"cut_soil_m3":37.16,"cut_rock_m3":45.39,"cut_rr_m3":45.39,"cut_br_m3":0,"net_area_m2":5.5,"fill_m3":52.8},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":106.99,"cumulative_volume_m3":-777.42,"cut_soil_m3":46.41,"cut_rock_m3":65.44,"cut_rr_m3":65.44,"cut_br_m3":0,"net_area_m2":5.2,"fill_m3":11.01},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":70.86,"cumulative_volume_m3":-706.57,"cut_soil_m3":37.66,"cut_rock_m3":38.17,"cut_rr_m3":38.17,"cut_br_m3":0,"net_area_m2":1.88,"fill_m3":7.51},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":69.26,"cumulative_volume_m3":-637.31,"cut_soil_m3":34.52,"cut_rock_m3":51.93,"cut_rr_m3":51.93,"cut_br_m3":0,"net_area_m2":5.04,"fill_m3":22.32},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":13.77,"cumulative_volume_m3":-623.54,"cut_soil_m3":32.14,"cut_rock_m3":59.59,"cut_rr_m3":59.59,"cut_br_m3":0,"net_area_m2":-3.66,"fill_m3":84.58},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":-270.91,"cumulative_volume_m3":-894.45,"cut_soil_m3":25.4,"cut_rock_m3":26.52,"cut_rr_m3":26.52,"cut_br_m3":0,"net_area_m2":-23.43,"fill_m3":324.66},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-357.98,"cumulative_volume_m3":-1252.43,"cut_soil_m3":26.86,"cut_rock_m3":22.43,"cut_rr_m3":22.43,"cut_br_m3":0,"net_area_m2":-12.37,"fill_m3":408.29},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-133.52,"cumulative_volume_m3":-1385.95,"cut_soil_m3":27.32,"cut_rock_m3":22.07,"cut_rr_m3":22.07,"cut_br_m3":0,"net_area_m2":-0.98,"fill_m3":183.82},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":64.57,"cumulative_volume_m3":-1321.38,"cut_soil_m3":33.16,"cut_rock_m3":57.32,"cut_rr_m3":57.32,"cut_br_m3":0,"net_area_m2":7.44,"fill_m3":32.05},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":61.54,"cumulative_volume_m3":-1259.85,"cut_soil_m3":29.1,"cut_rock_m3":51.57,"cut_rr_m3":51.57,"cut_br_m3":0,"net_area_m2":-1.28,"fill_m3":24.73},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-90.31,"cumulative_volume_m3":-1350.16,"cut_soil_m3":22.02,"cut_rock_m3":7.27,"cut_rr_m3":7.27,"cut_br_m3":0,"net_area_m2":-7.75,"fill_m3":118.6},{"station_id":"station_000000374000","chainage_m":374,"net_volume_m3":-59.31,"cumulative_volume_m3":-1409.47,"cut_soil_m3":14.68,"cut_rock_m3":6.23,"cut_rr_m3":6.23,"cut_br_m3":0,"net_area_m2":-0.73,"fill_m3":79.79},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":-0.44,"cumulative_volume_m3":-1409.92,"cut_soil_m3":6.88,"cut_rock_m3":4.5,"cut_rr_m3":4.5,"cut_br_m3":0,"net_area_m2":0.58,"fill_m3":11.88},{"station_id":"station_000000385000","chainage_m":385,"net_volume_m3":-0.42,"cumulative_volume_m3":-1410.34,"cut_soil_m3":5.82,"cut_rock_m3":4.55,"cut_rr_m3":4.55,"cut_br_m3":0,"net_area_m2":-0.75,"fill_m3":10.96},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-23.08,"cumulative_volume_m3":-1433.41,"cut_soil_m3":14.13,"cut_rock_m3":6.84,"cut_rr_m3":6.84,"cut_br_m3":0,"net_area_m2":-2.33,"fill_m3":43.76},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":-29.13,"cumulative_volume_m3":-1462.55,"cut_soil_m3":23.79,"cut_rock_m3":9.13,"cut_rr_m3":9.13,"cut_br_m3":0,"net_area_m2":-0.58,"fill_m3":61.18},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":-42.33,"cumulative_volume_m3":-1504.88,"cut_soil_m3":22.81,"cut_rock_m3":10.49,"cut_rr_m3":10.49,"cut_br_m3":0,"net_area_m2":-3.65,"fill_m3":75.08},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":-86.93,"cumulative_volume_m3":-1591.81,"cut_soil_m3":27.44,"cut_rock_m3":22.84,"cut_rr_m3":22.84,"cut_br_m3":0,"net_area_m2":-5.04,"fill_m3":138.24},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":-13.78,"cumulative_volume_m3":-1605.59,"cut_soil_m3":39.45,"cut_rock_m3":60.04,"cut_rr_m3":60.04,"cut_br_m3":0,"net_area_m2":3.66,"fill_m3":119.24},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":36.8,"cumulative_volume_m3":-1568.79,"cut_soil_m3":37.16,"cut_rock_m3":63.14,"cut_rr_m3":63.14,"cut_br_m3":0,"net_area_m2":0.02,"fill_m3":70.19},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":-37.78,"cumulative_volume_m3":-1606.58,"cut_soil_m3":28.83,"cut_rock_m3":34.26,"cut_rr_m3":34.26,"cut_br_m3":0,"net_area_m2":-3.79,"fill_m3":103.65},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-60.16,"cumulative_volume_m3":-1666.74,"cut_soil_m3":26.42,"cut_rock_m3":19.42,"cut_rr_m3":19.42,"cut_br_m3":0,"net_area_m2":-2.22,"fill_m3":106.56},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":-14.04,"cumulative_volume_m3":-1680.78,"cut_soil_m3":22.62,"cut_rock_m3":11.94,"cut_rr_m3":11.94,"cut_br_m3":0,"net_area_m2":0.82,"fill_m3":48.32},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":-10.97,"cumulative_volume_m3":-1691.75,"cut_soil_m3":22.66,"cut_rock_m3":7.9,"cut_rr_m3":7.9,"cut_br_m3":0,"net_area_m2":-1.91,"fill_m3":40.57},{"station_id":"station_000000599000","chainage_m":599,"net_volume_m3":-49.1,"cumulative_volume_m3":-1740.85,"cut_soil_m3":25.86,"cut_rock_m3":13.76,"cut_rr_m3":13.76,"cut_br_m3":0,"net_area_m2":-3.25,"fill_m3":88.4},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":-3.16,"cumulative_volume_m3":-1744.01,"cut_soil_m3":1.26,"cut_rock_m3":0.93,"cut_rr_m3":0.93,"cut_br_m3":0,"net_area_m2":-3.06,"fill_m3":5.38},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":-0.68,"cumulative_volume_m3":-1744.69,"cut_soil_m3":31.17,"cut_rock_m3":36.22,"cut_rr_m3":36.22,"cut_br_m3":0,"net_area_m2":2.99,"fill_m3":70.93},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":44.19,"cumulative_volume_m3":-1700.5,"cut_soil_m3":41.38,"cut_rock_m3":49.93,"cut_rr_m3":49.93,"cut_br_m3":0,"net_area_m2":1.42,"fill_m3":51.22},{"station_id":"station_000000656000","chainage_m":656,"net_volume_m3":85.57,"cumulative_volume_m3":-1614.94,"cut_soil_m3":38.63,"cut_rock_m3":87.68,"cut_rr_m3":87.68,"cut_br_m3":0,"net_area_m2":9.27,"fill_m3":51.35},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":30.82,"cumulative_volume_m3":-1584.12,"cut_soil_m3":10.76,"cut_rock_m3":29,"cut_rr_m3":29,"cut_br_m3":0,"net_area_m2":6.14,"fill_m3":12.65},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":97.7,"cumulative_volume_m3":-1486.42,"cut_soil_m3":51.21,"cut_rock_m3":80.66,"cut_rr_m3":80.66,"cut_br_m3":0,"net_area_m2":3.63,"fill_m3":42.36},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":261.55,"cumulative_volume_m3":-1224.87,"cut_soil_m3":63.4,"cut_rock_m3":185.83,"cut_rr_m3":185.83,"cut_br_m3":0,"net_area_m2":22.52,"fill_m3":12.01},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":-133.08,"cumulative_volume_m3":-1357.96,"cut_soil_m3":66.39,"cut_rock_m3":233.98,"cut_rr_m3":233.98,"cut_br_m3":0,"net_area_m2":-35.83,"fill_m3":465.42},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":-1353.12,"cumulative_volume_m3":-2711.07,"cut_soil_m3":47.98,"cut_rock_m3":129.51,"cut_rr_m3":129.51,"cut_br_m3":0,"net_area_m2":-99.48,"fill_m3":1547.18},{"station_id":"station_000000759000","chainage_m":759,"net_volume_m3":-1129.36,"cumulative_volume_m3":-3840.43,"cut_soil_m3":38.08,"cut_rock_m3":88.25,"cut_rr_m3":88.25,"cut_br_m3":0,"net_area_m2":-19.4,"fill_m3":1266.44},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-13.94,"cumulative_volume_m3":-3854.37,"cut_soil_m3":2.22,"cut_rock_m3":4.34,"cut_rr_m3":4.34,"cut_br_m3":0,"net_area_m2":-8.47,"fill_m3":20.99},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":-228.3,"cumulative_volume_m3":-4082.67,"cut_soil_m3":45.88,"cut_rock_m3":97.18,"cut_rr_m3":97.18,"cut_br_m3":0,"net_area_m2":-14.36,"fill_m3":382.8},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":-298.42,"cumulative_volume_m3":-4381.09,"cut_soil_m3":35,"cut_rock_m3":57.55,"cut_rr_m3":57.55,"cut_br_m3":0,"net_area_m2":-15.48,"fill_m3":396.96},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":-229.16,"cumulative_volume_m3":-4610.25,"cut_soil_m3":34.03,"cut_rock_m3":49.96,"cut_rr_m3":49.96,"cut_br_m3":0,"net_area_m2":-7.43,"fill_m3":317.99},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":31.24,"cumulative_volume_m3":-4579.01,"cut_soil_m3":54.69,"cut_rock_m3":151.94,"cut_rr_m3":151.94,"cut_br_m3":0,"net_area_m2":10.55,"fill_m3":194.99},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":-37.03,"cumulative_volume_m3":-4616.04,"cut_soil_m3":55.04,"cut_rock_m3":169.02,"cut_rr_m3":169.02,"cut_br_m3":0,"net_area_m2":-14.26,"fill_m3":283.48},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":-905.57,"cumulative_volume_m3":-5521.61,"cut_soil_m3":39.08,"cut_rock_m3":91.48,"cut_rr_m3":91.48,"cut_br_m3":0,"net_area_m2":-76.3,"fill_m3":1047.31},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":-735.51,"cumulative_volume_m3":-6257.12,"cut_soil_m3":36.13,"cut_rock_m3":63.33,"cut_rr_m3":63.33,"cut_br_m3":0,"net_area_m2":2.75,"fill_m3":841.81},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":7.14,"cumulative_volume_m3":-6249.98,"cut_soil_m3":33.28,"cut_rock_m3":47.88,"cut_rr_m3":47.88,"cut_br_m3":0,"net_area_m2":-2.03,"fill_m3":78.59},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":21.16,"cumulative_volume_m3":-6228.81,"cut_soil_m3":37.53,"cut_rock_m3":76.87,"cut_rr_m3":76.87,"cut_br_m3":0,"net_area_m2":4.15,"fill_m3":102.16},{"station_id":"station_000000958000","chainage_m":958,"net_volume_m3":95.12,"cumulative_volume_m3":-6133.69,"cut_soil_m3":40.64,"cut_rock_m3":108.96,"cut_rr_m3":108.96,"cut_br_m3":0,"net_area_m2":6.42,"fill_m3":68.4},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":5.04,"cumulative_volume_m3":-6128.65,"cut_soil_m3":4.55,"cut_rock_m3":8.99,"cut_rr_m3":8.99,"cut_br_m3":0,"net_area_m2":-1.38,"fill_m3":9.53},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":-444.5,"cumulative_volume_m3":-6573.15,"cut_soil_m3":21.83,"cut_rock_m3":23.79,"cut_rr_m3":23.79,"cut_br_m3":0,"net_area_m2":-43.07,"fill_m3":491.86},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-427.95,"cumulative_volume_m3":-7001.1,"cut_soil_m3":8.35,"cut_rock_m3":4.05,"cut_rr_m3":4.05,"cut_br_m3":0,"net_area_m2":0.27,"fill_m3":440.19},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":190.96,"cumulative_volume_m3":-6810.14,"cut_soil_m3":42.36,"cut_rock_m3":139.34,"cut_rr_m3":139.34,"cut_br_m3":0,"net_area_m2":18.82,"fill_m3":9.5},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":-370.1,"cumulative_volume_m3":-7180.24,"cut_soil_m3":56.57,"cut_rock_m3":197.17,"cut_rr_m3":197.17,"cut_br_m3":0,"net_area_m2":-55.83,"fill_m3":650.71},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":-380.33,"cumulative_volume_m3":-7560.57,"cut_soil_m3":57.86,"cut_rock_m3":187.39,"cut_rr_m3":187.39,"cut_br_m3":0,"net_area_m2":17.8,"fill_m3":650.71},{"station_id":"station_000001080000","chainage_m":1080,"net_volume_m3":-121.08,"cumulative_volume_m3":-7681.65,"cut_soil_m3":47.05,"cut_rock_m3":138.92,"cut_rr_m3":138.92,"cut_br_m3":0,"net_area_m2":-29.91,"fill_m3":325.26},{"station_id":"station_000001100000","chainage_m":1100,"net_volume_m3":-582.13,"cumulative_volume_m3":-8263.78,"cut_soil_m3":14.96,"cut_rock_m3":13.52,"cut_rr_m3":13.52,"cut_br_m3":0,"net_area_m2":-28.31,"fill_m3":611.35},{"station_id":"station_000001113304","chainage_m":1113.3,"net_volume_m3":-185.06,"cumulative_volume_m3":-8448.83,"cut_soil_m3":11.6,"cut_rock_m3":9.54,"cut_rr_m3":9.54,"cut_br_m3":0,"net_area_m2":0.49,"fill_m3":206.61}]},"826":{"basis":"compacted","conversion":{"soil":{"loose":1.25,"compacted":0.9},"ripping_rock":{"loose":1.35,"compacted":1.1649999999999998},"blasting_rock":{"loose":1.6,"compacted":1.1649999999999998}},"cut_natural_m3":{"soil":2427.23,"ripping_rock":7048.19,"blasting_rock":0},"cut_compacted_m3":10395.65,"fill_compacted_m3":12078.35,"final_cumulative_m3":-1682.7,"surplus_m3":0,"shortage_m3":1682.7,"points":[{"station_id":"station_000000000000","chainage_m":0,"net_volume_m3":0,"cumulative_volume_m3":0,"cut_soil_m3":0,"cut_rock_m3":0,"cut_rr_m3":0,"cut_br_m3":0,"net_area_m2":-0.17,"fill_m3":0},{"station_id":"station_000000020000","chainage_m":20,"net_volume_m3":-60.35,"cumulative_volume_m3":-60.35,"cut_soil_m3":26.03,"cut_rock_m3":41.36,"cut_rr_m3":41.36,"cut_br_m3":0,"net_area_m2":-5.87,"fill_m3":131.96},{"station_id":"station_000000040000","chainage_m":40,"net_volume_m3":-50.6,"cumulative_volume_m3":-110.95,"cut_soil_m3":47.23,"cut_rock_m3":97.8,"cut_rr_m3":97.8,"cut_br_m3":0,"net_area_m2":0.81,"fill_m3":207.04},{"station_id":"station_000000060000","chainage_m":60,"net_volume_m3":-56.11,"cumulative_volume_m3":-167.06,"cut_soil_m3":39.02,"cut_rock_m3":75.86,"cut_rr_m3":75.86,"cut_br_m3":0,"net_area_m2":-6.42,"fill_m3":179.61},{"station_id":"station_000000080000","chainage_m":80,"net_volume_m3":31.24,"cumulative_volume_m3":-135.82,"cut_soil_m3":44.38,"cut_rock_m3":78.5,"cut_rr_m3":78.5,"cut_br_m3":0,"net_area_m2":9.54,"fill_m3":100.15},{"station_id":"station_000000100000","chainage_m":100,"net_volume_m3":-18.79,"cumulative_volume_m3":-154.61,"cut_soil_m3":44.58,"cut_rock_m3":101.25,"cut_rr_m3":101.25,"cut_br_m3":0,"net_area_m2":-11.42,"fill_m3":176.87},{"station_id":"station_000000120000","chainage_m":120,"net_volume_m3":-28.28,"cumulative_volume_m3":-182.89,"cut_soil_m3":38.85,"cut_rock_m3":127.24,"cut_rr_m3":127.24,"cut_br_m3":0,"net_area_m2":8.6,"fill_m3":211.47},{"station_id":"station_000000140000","chainage_m":140,"net_volume_m3":195.42,"cumulative_volume_m3":12.54,"cut_soil_m3":53.39,"cut_rock_m3":156.2,"cut_rr_m3":156.2,"cut_br_m3":0,"net_area_m2":10.95,"fill_m3":34.6},{"station_id":"station_000000160000","chainage_m":160,"net_volume_m3":215.46,"cumulative_volume_m3":228,"cut_soil_m3":63.03,"cut_rock_m3":136.26,"cut_rr_m3":136.26,"cut_br_m3":0,"net_area_m2":10.6,"fill_m3":0},{"station_id":"station_000000180000","chainage_m":180,"net_volume_m3":121.19,"cumulative_volume_m3":349.19,"cut_soil_m3":51.08,"cut_rock_m3":86.14,"cut_rr_m3":86.14,"cut_br_m3":0,"net_area_m2":1.52,"fill_m3":25.13},{"station_id":"station_000000200000","chainage_m":200,"net_volume_m3":29.91,"cumulative_volume_m3":379.1,"cut_soil_m3":39.02,"cut_rock_m3":34.26,"cut_rr_m3":34.26,"cut_br_m3":0,"net_area_m2":1.47,"fill_m3":45.12},{"station_id":"station_000000220000","chainage_m":220,"net_volume_m3":8.21,"cumulative_volume_m3":387.31,"cut_soil_m3":36.92,"cut_rock_m3":41.58,"cut_rr_m3":41.58,"cut_br_m3":0,"net_area_m2":-0.65,"fill_m3":73.47},{"station_id":"station_000000240000","chainage_m":240,"net_volume_m3":155.23,"cumulative_volume_m3":542.54,"cut_soil_m3":44.43,"cut_rock_m3":147.83,"cut_rr_m3":147.83,"cut_br_m3":0,"net_area_m2":16.17,"fill_m3":56.97},{"station_id":"station_000000260000","chainage_m":260,"net_volume_m3":50.12,"cumulative_volume_m3":592.66,"cut_soil_m3":45.69,"cut_rock_m3":138.79,"cut_rr_m3":138.79,"cut_br_m3":0,"net_area_m2":-11.16,"fill_m3":152.69},{"station_id":"station_000000280000","chainage_m":280,"net_volume_m3":-446.26,"cumulative_volume_m3":146.4,"cut_soil_m3":33.83,"cut_rock_m3":50.3,"cut_rr_m3":50.3,"cut_br_m3":0,"net_area_m2":-33.46,"fill_m3":535.31},{"station_id":"station_000000300000","chainage_m":300,"net_volume_m3":-513.37,"cumulative_volume_m3":-366.97,"cut_soil_m3":31.33,"cut_rock_m3":45.09,"cut_rr_m3":45.09,"cut_br_m3":0,"net_area_m2":-17.87,"fill_m3":594.11},{"station_id":"station_000000320000","chainage_m":320,"net_volume_m3":-623.84,"cumulative_volume_m3":-990.81,"cut_soil_m3":30.26,"cut_rock_m3":44.9,"cut_rr_m3":44.9,"cut_br_m3":0,"net_area_m2":-44.51,"fill_m3":703.38},{"station_id":"station_000000340000","chainage_m":340,"net_volume_m3":-478.82,"cumulative_volume_m3":-1469.64,"cut_soil_m3":32.42,"cut_rock_m3":52.54,"cut_rr_m3":52.54,"cut_br_m3":0,"net_area_m2":-3.37,"fill_m3":569.22},{"station_id":"station_000000360000","chainage_m":360,"net_volume_m3":-63.57,"cumulative_volume_m3":-1533.2,"cut_soil_m3":31.57,"cut_rock_m3":34.29,"cut_rr_m3":34.29,"cut_br_m3":0,"net_area_m2":-2.99,"fill_m3":131.92},{"station_id":"station_000000380000","chainage_m":380,"net_volume_m3":-32.39,"cumulative_volume_m3":-1565.59,"cut_soil_m3":28.74,"cut_rock_m3":24.33,"cut_rr_m3":24.33,"cut_br_m3":0,"net_area_m2":-0.25,"fill_m3":86.59},{"station_id":"station_000000385000","chainage_m":385,"net_volume_m3":-4.04,"cumulative_volume_m3":-1569.63,"cut_soil_m3":4.03,"cut_rock_m3":2.94,"cut_rr_m3":2.94,"cut_br_m3":0,"net_area_m2":-1.36,"fill_m3":11.1},{"station_id":"station_000000400000","chainage_m":400,"net_volume_m3":-5.16,"cumulative_volume_m3":-1574.79,"cut_soil_m3":12.56,"cut_rock_m3":11.44,"cut_rr_m3":11.44,"cut_br_m3":0,"net_area_m2":0.68,"fill_m3":29.79},{"station_id":"station_000000420000","chainage_m":420,"net_volume_m3":-44.48,"cumulative_volume_m3":-1619.27,"cut_soil_m3":28.63,"cut_rock_m3":31.33,"cut_rr_m3":31.33,"cut_br_m3":0,"net_area_m2":-5.12,"fill_m3":106.75},{"station_id":"station_000000440000","chainage_m":440,"net_volume_m3":-55.35,"cumulative_volume_m3":-1674.63,"cut_soil_m3":31.58,"cut_rock_m3":44.63,"cut_rr_m3":44.63,"cut_br_m3":0,"net_area_m2":-0.41,"fill_m3":135.77},{"station_id":"station_000000460000","chainage_m":460,"net_volume_m3":-65.95,"cumulative_volume_m3":-1740.58,"cut_soil_m3":29.98,"cut_rock_m3":36.99,"cut_rr_m3":36.99,"cut_br_m3":0,"net_area_m2":-6.18,"fill_m3":136.04},{"station_id":"station_000000480000","chainage_m":480,"net_volume_m3":-45.3,"cumulative_volume_m3":-1785.88,"cut_soil_m3":29.13,"cut_rock_m3":38.44,"cut_rr_m3":38.44,"cut_br_m3":0,"net_area_m2":1.65,"fill_m3":116.3},{"station_id":"station_000000500000","chainage_m":500,"net_volume_m3":-19.66,"cumulative_volume_m3":-1805.54,"cut_soil_m3":26.9,"cut_rock_m3":37.37,"cut_rr_m3":37.37,"cut_br_m3":0,"net_area_m2":-3.62,"fill_m3":87.39},{"station_id":"station_000000520000","chainage_m":520,"net_volume_m3":-119.09,"cumulative_volume_m3":-1924.62,"cut_soil_m3":14.67,"cut_rock_m3":7.91,"cut_rr_m3":7.91,"cut_br_m3":0,"net_area_m2":-8.29,"fill_m3":141.5},{"station_id":"station_000000540000","chainage_m":540,"net_volume_m3":-126.28,"cumulative_volume_m3":-2050.91,"cut_soil_m3":14.82,"cut_rock_m3":7.49,"cut_rr_m3":7.49,"cut_br_m3":0,"net_area_m2":-4.34,"fill_m3":148.34},{"station_id":"station_000000560000","chainage_m":560,"net_volume_m3":65.29,"cumulative_volume_m3":-1985.62,"cut_soil_m3":44.19,"cut_rock_m3":74.39,"cut_rr_m3":74.39,"cut_br_m3":0,"net_area_m2":10.87,"fill_m3":61.14},{"station_id":"station_000000580000","chainage_m":580,"net_volume_m3":242.78,"cumulative_volume_m3":-1742.83,"cut_soil_m3":66.52,"cut_rock_m3":157.01,"cut_rr_m3":157.01,"cut_br_m3":0,"net_area_m2":13.41,"fill_m3":0},{"station_id":"station_000000600000","chainage_m":600,"net_volume_m3":82.99,"cumulative_volume_m3":-1659.84,"cut_soil_m3":45.93,"cut_rock_m3":98.93,"cut_rr_m3":98.93,"cut_br_m3":0,"net_area_m2":-5.11,"fill_m3":73.6},{"station_id":"station_000000620000","chainage_m":620,"net_volume_m3":-131.92,"cumulative_volume_m3":-1791.76,"cut_soil_m3":20.67,"cut_rock_m3":10.53,"cut_rr_m3":10.53,"cut_br_m3":0,"net_area_m2":-8.08,"fill_m3":162.79},{"station_id":"station_000000640000","chainage_m":640,"net_volume_m3":-121.07,"cumulative_volume_m3":-1912.83,"cut_soil_m3":20.8,"cut_rock_m3":17.97,"cut_rr_m3":17.97,"cut_br_m3":0,"net_area_m2":-4.03,"fill_m3":160.72},{"station_id":"station_000000660000","chainage_m":660,"net_volume_m3":-39.29,"cumulative_volume_m3":-1952.12,"cut_soil_m3":32.4,"cut_rock_m3":41.61,"cut_rr_m3":41.61,"cut_br_m3":0,"net_area_m2":0.1,"fill_m3":116.92},{"station_id":"station_000000680000","chainage_m":680,"net_volume_m3":-14.3,"cumulative_volume_m3":-1966.42,"cut_soil_m3":35.3,"cut_rock_m3":45.96,"cut_rr_m3":45.96,"cut_br_m3":0,"net_area_m2":-1.53,"fill_m3":99.61},{"station_id":"station_000000700000","chainage_m":700,"net_volume_m3":-71.74,"cumulative_volume_m3":-2038.16,"cut_soil_m3":29.9,"cut_rock_m3":33.84,"cut_rr_m3":33.84,"cut_br_m3":0,"net_area_m2":-5.65,"fill_m3":138.07},{"station_id":"station_000000720000","chainage_m":720,"net_volume_m3":402.28,"cumulative_volume_m3":-1635.88,"cut_soil_m3":61.44,"cut_rock_m3":369.81,"cut_rr_m3":369.81,"cut_br_m3":0,"net_area_m2":45.88,"fill_m3":83.85},{"station_id":"station_000000740000","chainage_m":740,"net_volume_m3":650.4,"cumulative_volume_m3":-985.48,"cut_soil_m3":87.05,"cut_rock_m3":546.45,"cut_rr_m3":546.45,"cut_br_m3":0,"net_area_m2":19.16,"fill_m3":64.56},{"station_id":"station_000000760000","chainage_m":760,"net_volume_m3":-16.44,"cumulative_volume_m3":-1001.92,"cut_soil_m3":73.88,"cut_rock_m3":330.92,"cut_rr_m3":330.92,"cut_br_m3":0,"net_area_m2":-20.81,"fill_m3":468.45},{"station_id":"station_000000780000","chainage_m":780,"net_volume_m3":-280.07,"cumulative_volume_m3":-1281.99,"cut_soil_m3":57.48,"cut_rock_m3":186.36,"cut_rr_m3":186.36,"cut_br_m3":0,"net_area_m2":-7.2,"fill_m3":548.91},{"station_id":"station_000000800000","chainage_m":800,"net_volume_m3":-286.75,"cumulative_volume_m3":-1568.73,"cut_soil_m3":42.11,"cut_rock_m3":82.07,"cut_rr_m3":82.07,"cut_br_m3":0,"net_area_m2":-21.48,"fill_m3":420.25},{"station_id":"station_000000820000","chainage_m":820,"net_volume_m3":-362.28,"cumulative_volume_m3":-1931.01,"cut_soil_m3":35.3,"cut_rock_m3":59,"cut_rr_m3":59,"cut_br_m3":0,"net_area_m2":-14.75,"fill_m3":462.78},{"station_id":"station_000000840000","chainage_m":840,"net_volume_m3":166.24,"cumulative_volume_m3":-1764.77,"cut_soil_m3":60.69,"cut_rock_m3":256.79,"cut_rr_m3":256.79,"cut_br_m3":0,"net_area_m2":31.37,"fill_m3":187.54},{"station_id":"station_000000860000","chainage_m":860,"net_volume_m3":599.56,"cumulative_volume_m3":-1165.22,"cut_soil_m3":89.41,"cut_rock_m3":445.57,"cut_rr_m3":445.57,"cut_br_m3":0,"net_area_m2":28.58,"fill_m3":0},{"station_id":"station_000000880000","chainage_m":880,"net_volume_m3":385.02,"cumulative_volume_m3":-780.2,"cut_soil_m3":76.97,"cut_rock_m3":316.6,"cut_rr_m3":316.6,"cut_br_m3":0,"net_area_m2":9.92,"fill_m3":53.09},{"station_id":"station_000000900000","chainage_m":900,"net_volume_m3":-308.04,"cumulative_volume_m3":-1088.23,"cut_soil_m3":59.25,"cut_rock_m3":210.02,"cut_rr_m3":210.02,"cut_br_m3":0,"net_area_m2":-40.72,"fill_m3":606.04},{"station_id":"station_000000920000","chainage_m":920,"net_volume_m3":106.99,"cumulative_volume_m3":-981.24,"cut_soil_m3":78.01,"cut_rock_m3":506.21,"cut_rr_m3":506.21,"cut_br_m3":0,"net_area_m2":51.42,"fill_m3":552.96},{"station_id":"station_000000940000","chainage_m":940,"net_volume_m3":671.72,"cumulative_volume_m3":-309.52,"cut_soil_m3":81.63,"cut_rock_m3":516.42,"cut_rr_m3":516.42,"cut_br_m3":0,"net_area_m2":15.75,"fill_m3":3.37},{"station_id":"station_000000960000","chainage_m":960,"net_volume_m3":170.62,"cumulative_volume_m3":-138.9,"cut_soil_m3":56.94,"cut_rock_m3":150.93,"cut_rr_m3":150.93,"cut_br_m3":0,"net_area_m2":1.31,"fill_m3":56.46},{"station_id":"station_000000980000","chainage_m":980,"net_volume_m3":-104.1,"cumulative_volume_m3":-243,"cut_soil_m3":33.73,"cut_rock_m3":44.72,"cut_rr_m3":44.72,"cut_br_m3":0,"net_area_m2":-11.72,"fill_m3":186.56},{"station_id":"station_000001000000","chainage_m":1000,"net_volume_m3":-552.75,"cumulative_volume_m3":-795.75,"cut_soil_m3":7.8,"cut_rock_m3":7.92,"cut_rr_m3":7.92,"cut_br_m3":0,"net_area_m2":-43.55,"fill_m3":568.99},{"station_id":"station_000001020000","chainage_m":1020,"net_volume_m3":-422.84,"cumulative_volume_m3":-1218.59,"cut_soil_m3":12.17,"cut_rock_m3":6.59,"cut_rr_m3":6.59,"cut_br_m3":0,"net_area_m2":1.27,"fill_m3":441.47},{"station_id":"station_000001040000","chainage_m":1040,"net_volume_m3":190.35,"cumulative_volume_m3":-1028.24,"cut_soil_m3":47.18,"cut_rock_m3":132.06,"cut_rr_m3":132.06,"cut_br_m3":0,"net_area_m2":17.77,"fill_m3":5.96},{"station_id":"station_000001060000","chainage_m":1060,"net_volume_m3":137.74,"cumulative_volume_m3":-890.51,"cut_soil_m3":67.53,"cut_rock_m3":226.43,"cut_rr_m3":226.43,"cut_br_m3":0,"net_area_m2":-3.99,"fill_m3":186.83},{"station_id":"station_000001080000","chainage_m":1080,"net_volume_m3":154.83,"cumulative_volume_m3":-735.67,"cut_soil_m3":69.02,"cut_rock_m3":239.96,"cut_rr_m3":239.96,"cut_br_m3":0,"net_area_m2":19.48,"fill_m3":186.83},{"station_id":"station_000001100000","chainage_m":1100,"net_volume_m3":-127.83,"cumulative_volume_m3":-863.5,"cut_soil_m3":53.4,"cut_rock_m3":164.78,"cut_rr_m3":164.78,"cut_br_m3":0,"net_area_m2":-32.26,"fill_m3":367.86},{"station_id":"station_000001120000","chainage_m":1120,"net_volume_m3":-621.7,"cumulative_volume_m3":-1485.2,"cut_soil_m3":16.9,"cut_rock_m3":25.78,"cut_rr_m3":25.78,"cut_br_m3":0,"net_area_m2":-29.91,"fill_m3":666.95},{"station_id":"station_000001133426","chainage_m":1133.43,"net_volume_m3":-197.49,"cumulative_volume_m3":-1682.7,"cut_soil_m3":9.55,"cut_rock_m3":9.57,"cut_rr_m3":9.57,"cut_br_m3":0,"net_area_m2":0.49,"fill_m3":217.23}]}} diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index 7a8431b8f..731741bb7 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -58,6 +58,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 · 이 흠과 무관 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/helper_266_card_toggle.mjs b/resources/tester/helper_266_card_toggle.mjs index 33f07986e..477d5aec0 100644 --- a/resources/tester/helper_266_card_toggle.mjs +++ b/resources/tester/helper_266_card_toggle.mjs @@ -136,7 +136,7 @@ async function toggles(projectId, shift) { const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b", 0.002) }; -/* 서버 폴백(선형 저장분 없음) — 카드 선택이 요청에 실림 · 열쇠 = 측점 자리 */ +/* 선형 저장분 없음 — 옛 서버 폴백 걷음(511-B B-4) · 부름 0 */ { const projectId = "p266c"; m.writeState("std-cross", job.standard, projectId); @@ -149,7 +149,7 @@ const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b", ditch_choice: false, }); await m.refreshSavedCross({ projectId, routeId: R, detail, edits: EDITS }, [], () => undefined); - out.server = { at, choices: previews.at(-1)?.design_choices ?? null }; + out.server = { at, calls: previews.length }; } process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_271_profile_settings.cjs b/resources/tester/helper_271_profile_settings.cjs index 53cdfe39b..74264b478 100644 --- a/resources/tester/helper_271_profile_settings.cjs +++ b/resources/tester/helper_271_profile_settings.cjs @@ -29,6 +29,7 @@ const REAL = new Set( "B05_Profile/B05_Profile_Api_PageSettings.ts", "B05_Profile/B05_Profile_UI_Page_Latest.ts", "B05_Profile/B05_Profile_UI_Panel_Fields.ts", + "B05_Profile/logic/B05_Profile_Grade_Criteria.ts", // 법정 기준표(511-B B-3 · 화면에서 옮김) ].map((rel) => path.join(ROOT, rel)), ); const anything = () => diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index 1c4604c72..6124da24d 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -69,6 +69,7 @@ function load(target) { cache.set(full, box.exports); const need = (spec) => { if (spec.startsWith("@ui/")) return { showToast: () => {} }; + if (spec === "@config/config_frontend") return { API_BASE_URL: "/api" }; const alias = spec.startsWith("@util/") ? path.join(ROOT, "common_util", spec.slice(6)) : null; const base = alias ?? path.join(path.dirname(full), spec); const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 84e9ea937..2a4465501 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -27,7 +27,16 @@ const session = memoryStorage(); globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; globalThis.fetch = async () => ({ ok: false }); -const STUBS = {}; +// 계산 자리 「서버」 길(511-A) — 판 both 그대로 +const CALC_STUBS = { + design_calc_sites: { calcOnServerNow: () => false, calcProjectId: () => "" }, + design_calc_server: { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, +}; +const STUBS = { ...CALC_STUBS }; const { load, loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs index 9946d7eaa..9e0306e01 100644 --- a/resources/tester/helper_349_drainage_live_flow.cjs +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -325,7 +325,7 @@ async function main() { savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }); await flush(); out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") }; - // 369 [자동 제안] — 낡은 동안 끔 · ① all · ② add · [배관 배치 초기화] = 관 목록 없이(세류선만) + // [자동 제안] — 낡은 동안 끔 · 누름은 패널 후보 목록(510 · 여기 안 셈) · [배관 배치 초기화] = 관 목록 없이(세류선만) out.proposeStale = { off: propose.disabled, title: propose.title }; const beforeStale = detailCalls.length; propose.fire("click"); @@ -336,10 +336,6 @@ async function main() { const n0 = detailCalls.length; const remembered0 = remembered; const records0 = records; - modeRow = 0; - propose.fire("click"); - await flush(); - modeRow = 1; propose.fire("click"); await flush(); live.resetToStreams(); @@ -352,14 +348,13 @@ async function main() { }; // 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시 detailStale = true; - modeRow = 0; const fallbackFrom = calls.length; - propose.fire("click"); + live.resetToStreams(); await flush(); const fallbackCall = calls[calls.length - 1]; out.proposeFallback = { sent: calls.length - fallbackFrom, - auto: fallbackCall.body.auto, + points: fallbackCall.body.points, status: status.textContent, }; answer(fallbackCall); @@ -371,18 +366,11 @@ async function main() { answer(calls[calls.length - 1]); await flush(); const viaDetail = detailCalls.length; - modeRow = 0; - propose.fire("click"); - const liveCall = calls[calls.length - 1]; - answer(liveCall); - await flush(); live.resetToStreams(); const resetCall = calls[calls.length - 1]; answer(resetCall); await flush(); out.proposeLive = { - auto: liveCall.body.auto, - points: liveCall.body.points.length, resetPoints: resetCall.body.points, resetAuto: resetCall.body.auto ?? null, detailCalls: detailCalls.length - viaDetail, diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index d816d89e1..d6ea93bf6 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -189,7 +189,7 @@ async function run() { out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0)); // ── ④ 기준표 ── - const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {}); + const fields = load(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Grade_Criteria.ts"), {}); out.criteria = fields.PROFILE_CRITERIA; out.speedChoices = fields.SPEED_CHOICES; diff --git a/resources/tester/helper_472_1_paths.py b/resources/tester/helper_472_1_paths.py index 9540479a3..4093c6c7c 100644 --- a/resources/tester/helper_472_1_paths.py +++ b/resources/tester/helper_472_1_paths.py @@ -2,7 +2,7 @@ stored GET 상세(정본 그대로) → 누가토량(서버 Node 묶음 `computeMassHaul`) server_save [저장] 서버 재계산 — 정본 상세 → 종단 경고 다시 셈(`refresh_alignment` · 472 덧) → - `enforce_design_lines` → `_enforce_stored_designs` → server-calc + `enforce_design_lines` → `_enforce_stored_designs` → 서버 Node(`_run_node`) server_route 노선 저장 체인(같은 노선 · DB 없이) — `run_section_generation`(고정 geojson · npz) → `_carry_profile` 같은 노선 갈래(옛 base_pvi · 편집) → `_apply_alignment_edits` → 측점 열쇠 이월(`carry_cross_inputs`) → `compute_default_designs` → 상세 읽기 → @@ -97,7 +97,7 @@ def values_of( def _keys(longitudinal: dict[str, Any], sections: list[dict[str, Any]], root: Path) -> list: """서버 측점 열쇠(`station_keys` · 노선 저장 이월이 쓰는 한 벌) — 상세 측점 차례.""" - from B05_Profile.B05_Profile_Route_Save_Parts import station_keys + from B05_Profile.B05_Profile_Route_Save_Parts import keyed_stations, station_keys from B05_Profile.B05_Profile_Structures_Repository import load_structures from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file @@ -107,7 +107,9 @@ def _keys(longitudinal: dict[str, Any], sections: list[dict[str, Any]], root: Pa (longitudinal.get("profile_alignment") or {}).get("policy", {}).get("station_interval_m") or 20.0 ) - found = station_keys(longitudinal.get("stations") or [], pipes, structures, interval) + found = keyed_stations(longitudinal.get("stations") or []) or station_keys( + longitudinal.get("stations") or [], pipes, structures, interval + ) by_m = {round(at, 3): key for key, at in found.items()} return [by_m.get(round(float(s["chainage_m"]), 3)) for s in sections] @@ -129,12 +131,10 @@ def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]: from B06_Section.B06_Section_Router_Design import enforce_design_lines from B06_Section.B06_Section_Server_Calc_Prebuild import ( _AREA_KEYS, - _NPM_SCRIPT, - BUNDLE, _enforce_stored_designs, + _run_node, refresh_alignment, ) - from common_util.common_util_node_bundle import run_bundle_json from common_util.common_util_surface_sampler import build_surface_sampler longitudinal = detail["longitudinal"] @@ -149,9 +149,9 @@ def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]: detail = json.loads( json.dumps(detail, default=str) ) # 응답 직렬화와 같은 꼴(`model_dump(json)`) - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": detail, "context": _context()}) + output = _run_node({"detail": detail, "context": _context()}) if not isinstance(output, dict): - raise RuntimeError("server-calc 묶음 실패") + raise RuntimeError("서버 Node(_run_node) 실패") sections = detail["cross_sections"] for row in output.get("areas") or []: for section in sections: @@ -179,7 +179,11 @@ def server_route(root: Path) -> dict[str, Any]: from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_policy from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions - from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs, station_keys + from B05_Profile.B05_Profile_Route_Save_Parts import ( + carry_cross_inputs, + keyed_stations, + station_keys, + ) from B05_Profile.B05_Profile_Router import _apply_alignment_edits from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS @@ -221,7 +225,9 @@ def server_route(root: Path) -> dict[str, Any]: _, structures = load_structures(str(root)) interval = float(options["station_interval_m"]) old_keys = station_keys(saved["longitudinal"]["stations"], pipes, structures, interval) - new_keys = station_keys(new_long.get("stations") or [], pipes, structures, interval) + new_keys = keyed_stations(new_long.get("stations") or []) or station_keys( + new_long.get("stations") or [], pipes, structures, interval + ) designs = { round(float(s["chainage_m"]), 3): s["design"] for s in saved["cross_sections"] @@ -253,13 +259,12 @@ def server_route(root: Path) -> dict[str, Any]: def stored(root: Path) -> dict[str, Any]: """정본 그대로 — GET 상세 면적 · 서버 Node 묶음과 같은 누가토량(화면 배지 값).""" - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node detail = read("detail.json") alignment = detail["longitudinal"].get("profile_alignment") or {} keys = _keys(detail["longitudinal"], detail["cross_sections"], root) - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": detail, "context": _context()}) + output = _run_node({"detail": detail, "context": _context()}) haul = (output or {}).get("mass_haul") return values_of( detail["cross_sections"], diff --git a/resources/tester/helper_472_d_b05_pipe_undo.cjs b/resources/tester/helper_472_d_b05_pipe_undo.cjs index 01d041c54..a08a66131 100644 --- a/resources/tester/helper_472_d_b05_pipe_undo.cjs +++ b/resources/tester/helper_472_d_b05_pipe_undo.cjs @@ -51,6 +51,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/helper_475_1_moved.py b/resources/tester/helper_475_1_moved.py index c48b63d44..ca7d883fd 100644 --- a/resources/tester/helper_475_1_moved.py +++ b/resources/tester/helper_475_1_moved.py @@ -10,22 +10,43 @@ from __future__ import annotations import json +from collections.abc import Sequence from pathlib import Path from typing import Any import helper_472_1_paths as P +def seat_route_moved( + alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = () +) -> list[dict[str, Any]]: + """옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459) — 서버 [저장] 길(Node + `carry_seat` 의 seat) 앞뒤 그대로: 그 자리 옛 꼭짓점을 걷고 `_seat_born_nodes`. + 파이썬 짝(`Route_Save_Parts.seat_route_moved`) = 운영 부름 0 이라 걷음(511-B B-6) — 거울 기준만 + 여기(`test_459_route_seat_mirror` · `test_475_1_route_digits`).""" + 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)) + + def server_moved(root: Path, move: dict[str, Any]) -> dict[str, Any]: from B05_Profile.B05_Profile_Engine import _route_geojson from B05_Profile.B05_Profile_Engine_AsPlanned import _vertex_z from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions from B05_Profile.B05_Profile_Engine_Grade_Carry import ( carry_base, - carry_edits, carry_policy, changed_span, ) + from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations @@ -33,7 +54,6 @@ def server_moved(root: Path, move: dict[str, Any]) -> dict[str, Any]: carry_cross_inputs, follow_pipes, route_moved_stations, - seat_route_moved, station_keys, vertices_of, ) @@ -125,9 +145,9 @@ def server_moved(root: Path, move: dict[str, Any]) -> dict[str, Any]: stations = [float(r["chainage_m"]) for r in longitudinal.get("stations") or []] half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2 base = carry_base(old_line, points, old_alignment["base_pvi"], alignment["base_pvi"]) - edits = carry_edits( + edits = carry_seat( old_line, points, old_alignment["base_pvi"], old_alignment.get("edits"), stations, half - ) + )[0] alignment["policy"] = carry_policy(old_line, points, old_alignment, alignment) alignment["base_pvi"] = base if span is not None: diff --git a/resources/tester/helper_510_propose.cjs b/resources/tester/helper_510_propose.cjs new file mode 100644 index 000000000..6f0676ebf --- /dev/null +++ b/resources/tester/helper_510_propose.cjs @@ -0,0 +1,98 @@ +/* 510-1 [자동 제안] 후보 목록 — 진짜 `B05_Profile_UI_Drainage_Propose.ts` · 관 속성 보관소를 가짜 DOM · fetch 로. + * node helper_510_propose.cjs (짝 = `test_510_auto_pipe_propose.py`) */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +/* ---- 가짜 DOM(쓰는 것만) ---- */ +function el(tag) { + const listeners = {}; + const node = { + tag, + children: [], + hidden: false, + disabled: false, + checked: false, + textContent: "", + append(...items) { + items.forEach((item) => + node.children.push(typeof item === "string" ? { text: item } : item), + ); + }, + replaceChildren(...items) { + node.children = []; + node.append(...items); + }, + addEventListener(type, fn) { + (listeners[type] ??= []).push(fn); + }, + fire(type) { + (listeners[type] ?? []).forEach((fn) => fn()); + }, + }; + return node; +} +globalThis.document = { createElement: el }; + +const sent = []; +globalThis.fetch = async (url, init) => { + sent.push({ url, body: JSON.parse(init.body) }); + return { + ok: true, + json: async () => ({ + spacing: true, + proposals: [ + { chainage_m: 50, kind: "stream", state: "dismissed", stream_label: { stream: "S1", order: 1 } }, + { chainage_m: 150, kind: "stream", state: "held", stream_label: { stream: "S2", order: 1 } }, + { + chainage_m: 320, + kind: "stream", + state: "new", + stream_label: { stream: "S4", order: 1 }, + stream_name: "작은골", + }, + { chainage_m: 420, kind: "spacing", state: "new" }, + ], + }), + }; +}; + +const { load } = makeLoader({ root: ROOT }); +const { createDrainagePropose } = load("B05_Profile", "B05_Profile_UI_Drainage_Propose.ts"); +const { createFacilityStore } = load("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"); + +(async () => { + const added = []; + const propose = createDrainagePropose({ + projectId: () => "p1", + pipeRequest: () => [{ chainage_m: 150, source: "user" }], + addPipes: (picks) => + picks.forEach((item) => added.push([item.chainage_m, item.stream_label?.stream ?? null])), + redraw: () => undefined, + }); + await propose.open(); + const [head, list] = propose.root.children; + const [title, add] = head.children; + const boxes = list.children.map((row) => row.children[0].children[0]); + const out = { + sent: { url: sent[0].url, points: sent[0].body.points.length }, + rows: list.children.map((row) => row.children[0].children[1].text.trim()), + title: title.textContent, + addOff: add.disabled, + candidates: propose.candidates(), + }; + boxes[1].checked = true; + boxes[1].fire("change"); + boxes[2].checked = true; + boxes[2].fire("change"); + add.fire("click"); + out.added = added; + out.afterClose = { hidden: propose.root.hidden, candidates: propose.candidates() }; + + // 관 속성 보관소 — 응답 라벨이 다음 요청에 실림(라벨만 있는 맨 배관도 보관) + const store = createFacilityStore(); + store.replaceFromResponse([{ chainage_m: 320, stream_label: { stream: "S4", order: 1 } }]); + out.carried = store.attach([{ chainage_m: 320, source: "user" }])[0].stream_label ?? null; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_511_a_browser.cjs b/resources/tester/helper_511_a_browser.cjs new file mode 100644 index 000000000..6b8b71fb9 --- /dev/null +++ b/resources/tester/helper_511_a_browser.cjs @@ -0,0 +1,90 @@ +/* PLAN 511-A 브라우저 길 헬퍼 — `design_calc_server.ts` 를 그 자리에서 트랜스파일 · 판(`design_calc_sites`)과 + * fetch 를 가짜로 끼워 판 따라 길 고름 · 잠잠 · 끊음 · 대조 · 기록을 잼. 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_511_a_calc_browser.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const board = {}; +const fetched = []; +let answer = (body) => ({ [body.run[0]]: body.inputs.args?.[body.run[0]]?.[0] ?? null }); +globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + fetched.push({ url, run: body.run, at: Date.now() }); + if (init.signal?.aborted) throw new DOMException("끊김", "AbortError"); + return { + ok: true, + status: 200, + json: async () => ({ results: answer(body), ms: 1, wall_ms: 2 }), + }; +}; +const plain = (value) => JSON.parse(JSON.stringify(value)); +const { loadTs } = makeLoader({ + root: ROOT, + stubs: { + design_calc_sites: { + calcOnServerNow: (key) => board[key] === "server", + calcProjectId: () => "p1", + }, + design_calc_node_trial: { plain }, + }, +}); +const mod = loadTs(path.join(ROOT, "A00_Common", "design", "design_calc_server.ts")); +const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const echo = (value) => value; + +(async () => { + const out = {}; + // 1 판 both — 브라우저 값 · 서버 부름 0 · 브라우저 ms 기록 + out.both = mod.calcChecked("quantity", "cross.fit", echo, [5]); + await tick(500); + out.bothFetched = fetched.length; + out.local = globalThis.__aisloCalcServer.local["cross.fit"].length; + + // 2 판 server — 브라우저 값 바로 · 잠잠 뒤 한 번(빠른 둘째 부름이 첫째를 끊음) · 같음 + board.quantity = "server"; + let applied = 0; + mod.calcChecked("quantity", "cross.fit", echo, [6], { apply: () => (applied += 1) }); + out.server = mod.calcChecked("quantity", "cross.fit", echo, [7], { apply: () => (applied += 1) }); + out.beforeQuiet = fetched.length; + await tick(500); + out.afterQuiet = fetched.length; + const calls = globalThis.__aisloCalcServer.calls; + out.same = calls[calls.length - 1].same; + out.applied = applied; + + // 3 서버 값이 다름 — apply(서버 값) + answer = () => ({ "cross.fit": 99 }); + let got = null; + mod.calcChecked("quantity", "cross.fit", echo, [8], { apply: (v) => (got = v) }); + await tick(500); + out.diffApplied = got; + out.diffSame = calls[calls.length - 1].same; + + // 4 그리기 자리 — 처음 undefined · 답 오면 redraw · 같은 입력 = fresh + answer = (body) => ({ "masshaul.series": ["s", body.inputs.args["masshaul.series"][0]] }); + let redraws = 0; + const first = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + await tick(500); + const second = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + const third = mod.calcFromServer("quantity", "masshaul.series", [2], () => (redraws += 1)); + out.draw = { first, second, third, redraws }; + await tick(500); + + // 5 비동기 자리 — outer 끊음 = undefined · 부름 0 + const before = fetched.length; + const outer = new AbortController(); + const pending = mod.calcOnServerOnly("quantity", "slot", mod.areasRequest([]), outer.signal); + outer.abort(); + out.aborted = (await pending) === undefined && fetched.length === before; + + // 6 왕복 흉내 — delayMs 만큼 늦음 + globalThis.__aisloCalcServer.delayMs = 80; + answer = () => ({ "structure.layouts": { areas: [1] } }); + const t0 = Date.now(); + out.onlyValue = await mod.calcOnServerOnly("quantity", "slot2", mod.areasRequest([])); + out.delayed = Date.now() - t0 >= 400 + 80; + out.roundMs = calls[calls.length - 1].roundMs; + out.url = fetched[fetched.length - 1].url; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_511_a_carry.ts b/resources/tester/helper_511_a_carry.ts new file mode 100644 index 000000000..d052fae2e --- /dev/null +++ b/resources/tester/helper_511_a_carry.ts @@ -0,0 +1,10 @@ +/* PLAN 511-A 거울 — R9 브라우저 자리(`carryStationDrafts`)가 부르는 꼴 그대로(읽기 = 칸 이름 → 값 표). */ +import { + carryKeyedStates, + type Keyed, + type KeyedState, + type Raw, +} from "../../B06_Section/logic/B06_Section_Route_Carry"; + +export const carryWith = (from: Record, before?: Keyed[], after?: Keyed[]) => + carryKeyedStates((name) => from[name], before, after); diff --git a/resources/tester/helper_511_a_mirror.cjs b/resources/tester/helper_511_a_mirror.cjs new file mode 100644 index 000000000..a855da9fc --- /dev/null +++ b/resources/tester/helper_511_a_mirror.cjs @@ -0,0 +1,21 @@ +/* PLAN 511-A 거울 헬퍼 — 브라우저 자리가 부르는 TS 함수를 그 자리에서 트랜스파일해 Node 로 바로 부름. + * 입력 = argv[2] JSON `[{ mod: 뿌리 기준 경로, fn: export 이름, args: [...] }]` · 출력 = 표준출력 JSON 목록 + * (Map · Set → 목록 = 서버 `plain` 과 같은 꼴). 짝 = `test_511_a_calc_server.py`(서버 창구 답과 견줌). */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; +const { loadTs } = makeLoader({ root: ROOT }); + +const plain = (value) => + JSON.parse( + JSON.stringify(value, (_key, item) => + item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item, + ) ?? "null", + ); + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = job.map(({ mod, fn, args }) => plain(loadTs(path.join(ROOT, mod))[fn](...args))); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/run_511_b5_route_save_timing.py b/resources/tester/run_511_b5_route_save_timing.py new file mode 100644 index 000000000..e47c6e40e --- /dev/null +++ b/resources/tester/run_511_b5_route_save_timing.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""511-B B-5 노선 저장 체인 시간 · 값 — 검증 프로젝트의 지금 노드 · 편집표 그대로 저장(수동 실행). + +같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값 안 건드림(`as_initial` 끔). +갈래 = 파이썬 짝(Node 폴리라인 · 좌표 맞춤 끔) / Node 를 번갈아 N 번 · 저장 뒤 정본 파일 해시 +(노선 csv · 노드 · 곡선표 · 구조물 · 관 · 추가 측점 · 종단 · 횡단)가 처음과 같은지. +쓰는 법: `./venv/Scripts/python.exe resources/tester/run_511_b5_route_save_timing.py [N]` +""" + +import asyncio +import hashlib +import json +import logging +import statistics +import sys +import time +from pathlib import Path +from uuid import UUID + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from config.config_db import close_db_pool, init_db_pool # noqa: E402 + +CANON = ( + "B05_Profile/route/planned_route.csv", + "B05_Profile/route/planned_route_nodes.csv", + "B05_Profile/route/planned_route_curves.json", + "B05_Profile/route/route_main.geojson", + "B05_Profile/route/structures.json", + "B05_Profile/route/plus_stations.json", + "B04_PreProcess/drainage/edits/pipe_points.json", +) + + +def _hashes(root: Path) -> dict[str, str]: + files = [root / name for name in CANON] + files += sorted((root / "B06_Section").rglob("*.json")) + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()[:16] + for path in files + if path.is_file() + } + + +async def _save(project_id: UUID, python: bool) -> float: + from B05_Profile import B05_Profile_Route_Polyline_Write as writer + from B05_Profile import B05_Profile_Router_Replan as replan + from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest + from common_util import common_util_route_follow as follow + + plan = await replan.read_route_plan(project_id) + edits = plan["node_edits"] + body = RouteSaveRequest( + vertices=[ + {"x": n["x"], "y": n["y"], "curve": e["curve"], "radius_m": e["radius_m"]} + for n, e in zip(plan["nodes"], edits, strict=True) + ], + station_snap=bool(plan.get("station_snap")), + station_snap_step=plan.get("station_snap_step"), + ) + real = (writer.node_polyline, follow.fill_structure_coordinates) + if python: + writer.node_polyline = lambda *a: None + follow.fill_structure_coordinates = follow._fill_structure_coordinates_py + try: + started = time.perf_counter() + result = await replan.replan_route(project_id, body) + seconds = time.perf_counter() - started + finally: + writer.node_polyline, follow.fill_structure_coordinates = real + status = getattr(result, "status_code", 200) + assert status == 200, getattr(result, "body", result) + return seconds + + +async def main(project_id: UUID, rounds: int) -> None: + from B05_Profile import B05_Profile_Router_Replan as replan + + logging.basicConfig(level=logging.WARNING, format="%(message)s") + await init_db_pool() + try: + root, _ = await replan._project_paths(project_id) + first = _hashes(root) + times: dict[str, list[float]] = {"python": [], "node": []} + for _ in range(rounds): + for kind in ("python", "node"): + times[kind].append(await _save(project_id, kind == "python")) + after = _hashes(root) + changed = sorted( + k for k in first.keys() | after.keys() if first.get(k) != after.get(k) + ) + print(f"{kind}: {times[kind][-1]:.2f} s · 바뀐 정본 {len(changed)} {changed[:5]}") + print( + json.dumps( + { + k: { + "median_s": round(statistics.median(v), 2), + "runs": [round(x, 2) for x in v], + } + for k, v in times.items() + }, + ensure_ascii=False, + ) + ) + finally: + from common_util.common_util_calc_runner import _SERVED + + _SERVED.stop() + await close_db_pool() + + +if __name__ == "__main__": + asyncio.run(main(UUID(sys.argv[1]), int(sys.argv[2]) if len(sys.argv) > 2 else 3)) diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py index 875c4e906..f8d0f10da 100644 --- a/resources/tester/test_110_b08_bed_slope.py +++ b/resources/tester/test_110_b08_bed_slope.py @@ -101,10 +101,9 @@ def test_패치가_받는다() -> None: def _server_rows(sections: list[dict]) -> dict[float, dict]: - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + output = _run_node({"detail": {"cross_sections": sections}}) assert output, "서버 Node 번들 실행 실패" return {round(r["chainage_m"], 3): r for r in output["areas"]} diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index ca621d888..870c4e11d 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -288,15 +288,14 @@ def test_패치가_박스_길이를_받는다(): @needs_node def test_서버가_박스_본체_길이를_주인_측점에만(): - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) sections = data["cross_sections"] box_at = next(s for s in sections if s.get("box")) neighbour = next(s for s in sections if s["chainage_m"] == 340) neighbour["box"] = copy.deepcopy(box_at["box"]) - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + output = _run_node({"detail": {"cross_sections": sections}}) rows = {round(r["chainage_m"], 3): r for r in output["areas"]} assert rows[352.14]["box_length_m"] > 0 assert "box_length_m" not in rows[340] diff --git a/resources/tester/test_126_ford_right_click.py b/resources/tester/test_126_ford_right_click.py index 2a19f85ab..f4dae5398 100644 --- a/resources/tester/test_126_ford_right_click.py +++ b/resources/tester/test_126_ford_right_click.py @@ -102,7 +102,9 @@ def test_배수유역_패널_배선(): apply.index("callbacks.onPipesChanged?.("), ] assert order == sorted(order) - add = src[src.index(" addPipe(chainageM, attributes) {") : src.index(" removePipe(")] + # 510 — 패널 addPipe 를 함수로(자동 제안 [넣기]도 같은 길) · 옮김은 updatePipeFacility 그대로 + add = src[src.index(" function addPipe(chainageM: number, attributes?: FacilityAttributes): void {") : src.index(" /** 노선 전체가")] + add += src[src.index(" updatePipeFacility(") : src.index(" removePipe(")] assert "waitsFordHeight(attributes?.facility, attributes?.options)" in add assert add.index("fordWaits().add(chainageM)") < add.index( "pipeEditor.addAtChainage(chainageM)" diff --git a/resources/tester/test_131_b08_material_fix.py b/resources/tester/test_131_b08_material_fix.py index 5f96af366..dd109183e 100644 --- a/resources/tester/test_131_b08_material_fix.py +++ b/resources/tester/test_131_b08_material_fix.py @@ -145,15 +145,14 @@ def test_세월교_관_길이_없으면_빈칸_알림(tmp_path): @needs_node def test_서버가_세월교_관_길이를_주인_측점에만(): - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) sections = data["cross_sections"] ford_at = next(s for s in sections if s.get("ford")) neighbour = next(s for s in sections if s["chainage_m"] == 180) neighbour["ford"] = copy.deepcopy(ford_at["ford"]) - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + output = _run_node({"detail": {"cross_sections": sections}}) rows = {round(r["chainage_m"], 3): r for r in output["areas"]} length = rows[173.087]["ford_pipe_length_m"] assert length > 0 and length == int(length) # 1 m 올림 diff --git a/resources/tester/test_139_6_save_redesign.py b/resources/tester/test_139_6_save_redesign.py index bf884c31f..11fa2d4b2 100644 --- a/resources/tester/test_139_6_save_redesign.py +++ b/resources/tester/test_139_6_save_redesign.py @@ -69,4 +69,5 @@ def test_서버_재계산이_먼저_돌고_미교차_판정은_그_뒤() -> None src = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text( encoding="utf-8" ) - assert src.index("enforce_design_lines, detail.get") < src.index("unclosed = any(") + # 파이썬 길(DTM 아닌 지표면) — 설계선 먼저 · Node 길 차례 = `test_511_b4_cross_save_mirror` + assert src.index("enforce_design_lines(longitudinal, sections") < src.index("unclosed = any(") diff --git a/resources/tester/test_178_1_slope_wall_guard.py b/resources/tester/test_178_1_slope_wall_guard.py index b743055ad..d16520092 100644 --- a/resources/tester/test_178_1_slope_wall_guard.py +++ b/resources/tester/test_178_1_slope_wall_guard.py @@ -5,12 +5,11 @@ ⇒ 관 링크는 있으나 관 벽이 안 서는 측점에서 카드엔 흙막이가 서고 서버 면적 · B07 엔 없음. 고침: `ownWallsDrawAt` 한 벌을 세 곳이 함께 씀. 서버 면적은 같은 TS 를 Node 로 돌리므로 -번들을 새로 빌드해 그 결과로 판정한다. +[저장] 재계산 Node 입구(`_run_node`) 결과로 판정한다. """ import copy import json -import subprocess from pathlib import Path import pytest @@ -32,36 +31,17 @@ SOIL_GUARD = { @pytest.fixture(scope="module") -def bundle(tmp_path_factory) -> Path: +def bundle(): + """서버 면적 = [저장] 재계산 Node 입구(`_run_node` · 옛 server-calc 묶음 = 511-B B-6 걷음).""" if not VITE.is_file(): pytest.skip("프론트엔드 의존성 미설치") - out = tmp_path_factory.mktemp("server_calc") - subprocess.run( # noqa: S603 — 고정 실행 파일 - [ - "node", - str(VITE), - "build", - "--configLoader", - "runner", - "--ssr", - "../B06_Section/B06_Section_Server_Calc_Node.ts", - "--outDir", - str(out), - "--logLevel", - "error", - ], - cwd=str(ROOT), - check=True, - capture_output=True, - ) - return out / "B06_Section_Server_Calc_Node.js" + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node + + return _run_node -def _areas(bundle: Path, sections: list, tmp_path: Path) -> dict[float, tuple]: - src, dst = tmp_path / "in.json", tmp_path / "out.json" - src.write_text(json.dumps({"detail": {"cross_sections": sections}}), encoding="utf-8") - subprocess.run(["node", str(bundle), str(src), str(dst)], check=True, cwd=str(ROOT)) # noqa: S603 - rows = json.loads(dst.read_text(encoding="utf-8"))["areas"] +def _areas(bundle, sections: list, tmp_path: Path) -> dict[float, tuple]: + rows = bundle({"detail": {"cross_sections": sections}})["areas"] return {row["chainage_m"]: (row["cut_area_m2"], row["fill_area_m2"]) for row in rows} diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index daf1ae1de..7064bb727 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -205,5 +205,10 @@ def test_좌측_패널_칸_구간_깊이_나름(): # 소단 구간 셈은 472-4 C4 에서 `common_util_berm_spans.ts` 로 옮김 source = (ROOT / "common_util" / "common_util_berm_spans.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_178_3_slope_tiers.py b/resources/tester/test_178_3_slope_tiers.py index 4e3831625..837f10397 100644 --- a/resources/tester/test_178_3_slope_tiers.py +++ b/resources/tester/test_178_3_slope_tiers.py @@ -77,7 +77,7 @@ def test_명세_저장_왕복(out): def test_서버_저장_뒤_벽_제원(tmp_path): - from B05_Profile.B05_Profile_Structures_Repository import save_structures + from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures from B06_Section import B06_Section_Engine_Structures_Wall as wall items = [ @@ -99,7 +99,8 @@ def test_서버_저장_뒤_벽_제원(tmp_path): ), ] save_structures(str(tmp_path), items, base_revision=0) - got = {spec["type_id"]: spec for spec in wall.load_wall_structures(tmp_path)} + stored = load_structures(str(tmp_path))[1] + got = {spec["type_id"]: spec for spec in wall.wall_specs_from(stored)} assert got["soil_guard"]["tiers"] == 3 assert got["soil_guard"]["extras"] == [ {"form": "콘크리트", "height_m": 2}, diff --git a/resources/tester/test_266_card_toggle_mismatch.py b/resources/tester/test_266_card_toggle_mismatch.py index 30e03293d..fa3afc410 100644 --- a/resources/tester/test_266_card_toggle_mismatch.py +++ b/resources/tester/test_266_card_toggle_mismatch.py @@ -5,7 +5,7 @@ 복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜. - 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침. - 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4). - - 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음. + - 선형 저장분 없음 = 서버 폴백 없음(511-B B-4 · `/cross-design/preview` 걷음). 짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` · 지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`. """ @@ -29,7 +29,6 @@ for path in (ROOT, HERE): from test_346_2_pipeline_cross_page import _fake, _line, _sections # noqa: E402 -from B06_Section.B06_Section_Router import _with_design_choices # noqa: E402 from common_util.common_util_surface_sampler import DtmGridSampler # noqa: E402 from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402 from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 @@ -121,19 +120,6 @@ def test_측점_어긋남_카드_자료는_고침_돌려줄_값만_null(r): _check_toggles(r["mismatch"], None) -def test_서버_폴백에_카드_선택_실음(r): - key = f"{r['server']['at']:.3f}" - choice = (r["server"]["choices"] or {}).get(key) - assert choice is not None, r["server"] - assert choice["ground_type"] == "soil" - assert choice["two_stage_slope"] is True - assert choice["ditch_choice"] is False - - -def test_서버_카드_선택이_저장분_위에_얹힘(): - stored = [{"chainage_m": 40.0, "design": {"ground_type": "ripping_rock", "berm": {"w": 1}}}] - rows = _with_design_choices(stored, {"40.000": {"ground_type": "soil"}, "60.123": {"x": 1}}) - by_key = {round(float(r["chainage_m"]), 3): r["design"] for r in rows} # 서버 읽는 꼴 - assert by_key[40.0] == {"ground_type": "soil", "berm": {"w": 1}} - assert by_key[60.123] == {"x": 1} - assert _with_design_choices(stored, None) == stored +def test_선형_저장분_없음_서버_폴백_없음(r): + # 511-B B-4 — 옛 데이터 서버 프리뷰(`/cross-design/preview`) 걷음 · 부름 0 + assert r["server"]["calls"] == 0, r["server"] diff --git a/resources/tester/test_271_straighten_pick.py b/resources/tester/test_271_straighten_pick.py index fc75e60e5..e5fbab5fd 100644 --- a/resources/tester/test_271_straighten_pick.py +++ b/resources/tester/test_271_straighten_pick.py @@ -172,7 +172,12 @@ def test_배선(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") assert "tools.handleStationPick(station ?? chainageM)" in panel assert "straightSubRun(alignment, fromM, toM)" in panel - assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel + # 511-A — 인자 한 벌(계산 자리 길이 함수째 받음) + args = ( + "Parameters = [\n base,\n ctx.edits(),\n" + " fromM,\n toM,\n ctx.savedRadii()," + ) + assert args in panel assert "autoRadii: ctx.savedRadii" in panel assert 'window.addEventListener("keydown", onEscape, true)' in panel assert "createRangeBandView(" in panel diff --git a/resources/tester/test_272_choice_undo.py b/resources/tester/test_272_choice_undo.py index fda0524ed..55cbf0e60 100644 --- a/resources/tester/test_272_choice_undo.py +++ b/resources/tester/test_272_choice_undo.py @@ -107,5 +107,10 @@ def test_단추가_메모리_설계를_안_덮음(): sync.index("async function handleDesignChange") : sync.index("function recomputeIfRock") ] assert "target.design" not in body - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") - assert "savedDesigns(projectId, input.routeId)" in refresh + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) + assert "savedDesigns(projectId, routeId)" in refresh diff --git a/resources/tester/test_280_4_fit.py b/resources/tester/test_280_4_fit.py index 1c2f821cd..25b652fa6 100644 --- a/resources/tester/test_280_4_fit.py +++ b/resources/tester/test_280_4_fit.py @@ -77,7 +77,12 @@ def test_끄는_중_미리보기_놓을_때_같음(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit + # 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit assert "stationM: stationIntervalM" in edit assert len(edit.splitlines()) <= 695 diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py index 1131348f5..444f9615c 100644 --- a/resources/tester/test_286_1_ford_pavement_design.py +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -137,7 +137,10 @@ def test_노면은_표고_낮은_쪽으로(): def test_저장_뒤_서버가_물넘이_측구를_맞춤(): router = _read("B06_Section", "B06_Section_Router_Design.py") assert "stored_tilt(design) == tilt" in router - refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + refresh = _read( + "B06_Section", "B06_Section_Cross_Refresh.ts" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts") assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py index c048d4bf6..73ea796cd 100644 --- a/resources/tester/test_288_underdrain_cross.py +++ b/resources/tester/test_288_underdrain_cross.py @@ -3,7 +3,7 @@ 못박는 것 - 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 표준 경사로 지대 낮은 쪽 한 경사 (물넘이 286 · 287 길 — 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted` · 암거 경사는 - 노면에 안 감 371-3) · 제원 얹기 서버 `attach_underdrains` ↔ TS `attachUnderdrainSpecs` + 노면에 안 감 371-3) · 제원 얹기 서버 `attach_underdrain_specs` ↔ TS `attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림. - 371-3 암거 경사 = 형상 하단 경계 — 높은 쪽 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림. @@ -97,7 +97,8 @@ def test_맹암거_자리_기울기_서버(project): def test_제원_얹기_서버_TS_같은_값(project, ts): sections = [dict(s) for s in SECTIONS] - assert tilt.attach_underdrains(project, sections) == 2 == ts["attached"] + attached = tilt.attach_underdrain_specs(sections, tilt.load_underdrains(project)) + assert attached == 2 == ts["attached"] assert [s.get("underdrain") for s in sections] == ts["sections"] assert ts["sections"][1] == { "structure_id": "ud-1", @@ -175,7 +176,12 @@ def test_횡단도_B07_이_맹암거를_그림(): encoding="utf-8" ) assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07 - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh diff --git a/resources/tester/test_291_1_add_only_on_add.py b/resources/tester/test_291_1_add_only_on_add.py index 7510271b5..e7c34be34 100644 --- a/resources/tester/test_291_1_add_only_on_add.py +++ b/resources/tester/test_291_1_add_only_on_add.py @@ -60,7 +60,8 @@ def test_고르던_항목만_실시간_반영(): def test_새_세월교_물넘이_월류_높이는_추가_뒤_채움(): drainage = _read("B05_Profile_UI_Drainage_Panel.ts") - body = _body(drainage, " addPipe(chainageM, attributes) {", " updatePipeFacility(") + # 510 — 패널 addPipe 를 함수로(자동 제안 [넣기]도 같은 길) + body = _body(drainage, " function addPipe(chainageM: number, attributes?: FacilityAttributes): void {", " /** 노선 전체가") assert "fordWaits().add(chainageM)" in body diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index b835aaa8e..da098f250 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -1,7 +1,7 @@ """PLAN 504-2 (요구 309-2 · 310) — 계산 자리 판 · 시스템 관리자 창구 · G1 · D3 연결. 못박는 것 - - 묶음 8 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. + - 묶음 12 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. - 커밋한 판 = 기본값(전부 both = 등록부 그대로) · 판 없음 · 깨짐 = 기본값. - GET = 로그인 · PUT = 시스템 관리자만(ADMIN · USER 403) · 잠금 · 모르는 줄 · 값 = 400 · 바뀐 줄마다 시스템 로그 CALC_SITE_CHANGE 한 줄 · 같은 값 = 판 · 로그 그대로. @@ -56,34 +56,48 @@ def test_묶음_여덟_줄_등록부_전부(): sites = _registry_sites() assert len(sites) == 45 nos = [no for group in calc_sites.GROUPS for no in group["nos"]] - assert len(calc_sites.GROUPS) == 8 + assert len(calc_sites.GROUPS) == 13 # 511-B 단계 다섯 · F9 폼 기본값 · 511-C haul assert len(nos) == len(set(nos)) and set(nos) == set(sites) - assert calc_sites.SWITCHABLE == {"ground_sample", "corridor_mesh"} + assert calc_sites.SWITCHABLE == { + "ground_sample", + "corridor_mesh", + "quantity", + "carry", + "route", + "follow", + "stations", + "profile", # 511-B B-3 엶 + "cross", # 511-B B-4 끝에 엶 + "haul", # 511-C E2 엶(기본 server) + "fixed_browser", + } # 511-A 셋 더 · 511-B B-2 stations · B-5 route for group in calc_sites.GROUPS: got = {sites[no] for no in group["nos"]} - if group["switchable"] or group["fixed"] == "both": - assert got == {"browser_now"}, group["key"] - elif group["fixed"] == "server": + if group["fixed"] == "browser": + assert got == {"browser_only"}, group["key"] + elif group["fixed"] == "server" or group.get("default") == "server": assert got <= {"server_canon", "server_only"}, group["key"] else: - assert got == {"browser_only"}, group["key"] + assert got == {"browser_now"}, group["key"] by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS} assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"] def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch): committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8")) - assert committed["sites"] == {"ground_sample": "both", "corridor_mesh": "both"} - assert set(calc_sites.read_sites()["sites"].values()) == {"both"} + assert calc_sites.DEFAULTS == {**dict.fromkeys(calc_sites.SWITCHABLE, "both"), "haul": "server"} + assert set(committed["sites"].items()) <= set(calc_sites.DEFAULTS.items()) + assert calc_sites.read_sites()["sites"] == calc_sites.DEFAULTS # 판 파일 haul 없음 = server monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json") - assert calc_sites.read_sites() == { - "version": 0, - "sites": {"ground_sample": "both", "corridor_mesh": "both"}, - } + assert calc_sites.read_sites() == {"version": 0, "sites": calc_sites.DEFAULTS} broken = tmp_path / "broken.json" broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8") monkeypatch.setattr(calc_sites, "PATH", broken) - assert set(calc_sites.read_sites()["sites"].values()) == {"both"} + assert calc_sites.read_sites()["sites"] == calc_sites.DEFAULTS + odd = tmp_path / "odd.json" + odd.write_text('{"sites": {"haul": "x", "quantity": "server"}}', "utf-8") + monkeypatch.setattr(calc_sites, "PATH", odd) + assert calc_sites.read_sites()["sites"] == {**calc_sites.DEFAULTS, "quantity": "server"} class _Cursor: @@ -149,7 +163,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client): def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" - for bad in ({"stages": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + for bad in ({"form_default": "server"}, {"fixed_server": "both"}, {"nope": "server"}): assert http.put(url, json={"sites": bad}).status_code == 400, bad assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 assert log == [] @@ -157,7 +171,8 @@ def test_잠금_모르는_줄_400_감사_한_줄(client): done = http.put(url, json={"sites": {"ground_sample": "server"}}) assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"] saved = json.loads(board.read_text("utf-8")) - assert saved == {"version": 2, "sites": {"ground_sample": "server", "corridor_mesh": "both"}} + sites = {**calc_sites.DEFAULTS, "ground_sample": "server"} + assert saved == {"version": 2, "sites": sites} inserts = [args for sql, args in log if "system_audit_logs" in sql] assert len(inserts) == 1 assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server") @@ -237,7 +252,7 @@ def test_연결_자리_cross_격자_그대로(): assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( "getWorker()" ) - assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts") + assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts") # 저장 길은 판을 안 읽음 for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel) diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index fce9ca2c9..cf4e1cc5d 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -143,18 +143,28 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): rows = page.eval_on_selector_all( ".b01-dashboard__calc-sites tbody tr", "(trs) => trs.map((tr) => ({ head: tr.classList.contains('b01-dashboard__calc-group')," - " name: tr.cells[0].textContent.trim()," + " name: tr.cells[0].textContent.trim(), why: tr.cells[3].textContent.trim()," " boxes: [...tr.querySelectorAll('input')].map((i) => [i.checked, i.disabled]) }))", ) heads = [row for row in rows if row["head"]] calcs = [row for row in rows if not row["head"]] - assert len(heads) == 8 and len(calcs) == 45 + assert len(heads) == 13 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 · 511-C haul + whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} + assert not any(why.startswith("짝 모으는 중") for why in whys.values()) # 단계 줄 모두 엶 + assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶 assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] - assert [name.split(" · ")[0] for name in editable] == ["G1", "D3"] # 줄도 둘만 켬 + opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 + opened += ["R1", "R3", "P5", "P6", "F1", "F2"] # 511-B B-5 노선 · 따라감 + opened += ["R5", "R6", "R7", "F3"] # 511-B B-2 측점 + opened += ["P1", "P2", "P3", "P4", "P8"] # 511-B B-3 종단 엶 + opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4 + opened += ["E2"] # 511-C 유토 배분 엶(기본 server = 체크 꺼짐) + opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 + assert [name.split(" · ")[0] for name in editable] == opened assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label assert all(row["boxes"][1][1] for row in calcs) # 서버 정본 칸은 늘 잠금 - assert "지금 서버 0 줄" in dialog.inner_text() + assert "지금 서버 1 줄" in dialog.inner_text() # haul 기본 server(511-C) SHOTS.mkdir(parents=True, exist_ok=True) dialog.screenshot(path=str(SHOTS / "light.png")) page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") @@ -162,7 +172,7 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") page.click("input[data-calc-site='ground_sample']") - assert "지금 서버 1 줄" in dialog.inner_text() + assert "지금 서버 2 줄" in dialog.inner_text() assert not page.is_checked("input[data-calc-no='G1']") # 머리 따라 줄도 꺼짐 page.locator(".b01-dashboard__calc-sites .ui-modal__actions button").click() page.wait_for_selector(".b01-dashboard__calc-sites", state="detached", timeout=30000) diff --git a/resources/tester/test_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py index f83c0f428..83ceb81ce 100644 --- a/resources/tester/test_312_empty_pipes.py +++ b/resources/tester/test_312_empty_pipes.py @@ -148,7 +148,7 @@ def test_칸_없음_파일_없음_DELETE_노선_바뀜은_자동_배치(api) -> def test_빈_목록과_None_을_가르는_자리() -> None: src = _read("common_util/common_util_drainage_detail.py") - assert 'if confirmed_chainages is not None and auto != "all":' in src + assert "if confirmed_chainages is not None:" in src router = _read("B04_PreProcess/B04_PreProcess_Router_Basins.py") assert "points = requested if requested is not None else stored" in router assert "requested or stored" not in router diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 41be0ff25..af3627548 100644 --- a/resources/tester/test_314_stale_structure.py +++ b/resources/tester/test_314_stale_structure.py @@ -72,7 +72,10 @@ def test_structure_change_reconciles_and_ford_reattach() -> None: view = _read("A00_Common", "design", "design_view_cross.ts") body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0] assert "void reconcileStaleDesigns();" in body - refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + refresh = _read( + "B06_Section", "B06_Section_Cross_Refresh.ts" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts") assert "Number(section.ford?.overflow_depth_m) > 0" in refresh # 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 · # 동작 시험 = test_323_4_attach_mirror) diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py index 46fcbd6c3..bcf895892 100644 --- a/resources/tester/test_316_defaults.py +++ b/resources/tester/test_316_defaults.py @@ -77,7 +77,7 @@ def test_ts_defaults_off_and_stored_kept(): ) # 브라우저 재계산 인자 — 세션 선택 → 저장값 → 꺼짐 assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( - "B06_Section/B06_Section_Cross_Refresh.ts" + "B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts" # 511-B B-4 셈 몫 ) # 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만 assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts") diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py index de282db89..425dd7f99 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -22,12 +22,12 @@ from pathlib import Path import pytest from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations -from B05_Profile.B05_Profile_Structures_Repository import structures_file_path +from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path from B05_Profile.B05_Profile_Structures_Schema import load_definition from B06_Section.B06_Section_Engine_Culvert import pipe_points_file from B06_Section.B06_Section_Engine_Culvert_Sets import attach_culvert_sets -from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures -from B06_Section.B06_Section_Engine_Tilt import attach_underdrains +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from +from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, load_underdrains from common_util.common_util_drainage_pipes import parse_pipe_points from common_util.common_util_structure_pavement import attach_pavement_structures from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible @@ -139,9 +139,9 @@ def _python_row(sample: dict, root: Path, plain: dict) -> dict: sections.sort(key=lambda s: s["chainage_m"]) attach_culvert_sets(root, sections) relabel_pipe_stations(root, sections) - attach_wall_structures(root, sections) + attach_wall_specs(sections, wall_specs_from(load_structures(str(root))[1])) attach_pavement_structures(root, sections) - attach_underdrains(root, sections) + attach_underdrain_specs(sections, load_underdrains(root)) return { "station_py": [f"{label}@{at:.2f}" for at, label in extras], "cards_py": { diff --git a/resources/tester/test_342_1_haul_plan_key.py b/resources/tester/test_342_1_haul_plan_key.py index 9a916d7e1..9a84a1d3c 100644 --- a/resources/tester/test_342_1_haul_plan_key.py +++ b/resources/tester/test_342_1_haul_plan_key.py @@ -23,15 +23,15 @@ def _patch(monkeypatch, outputs: list) -> list: async def _none(_project_id): return {} - def _run(_bundle, _script, payload): - calls.append(payload) + def _run(result, context): + calls.append(result) return outputs.pop(0) monkeypatch.setattr(router, "haul_inputs_for", _none) monkeypatch.setattr(router, "conversion_factors_for", _none) monkeypatch.setattr(router, "haul_limits_for", _none) monkeypatch.setattr(router, "_mass_haul_context", lambda *_: {"limits": []}) - monkeypatch.setattr(router, "run_bundle_json", _run) + monkeypatch.setattr(router, "_run_view", _run) router._KEPT.clear() return calls diff --git a/resources/tester/test_342_2_confirm_fingerprint.py b/resources/tester/test_342_2_confirm_fingerprint.py index 7b5580067..d653aded7 100644 --- a/resources/tester/test_342_2_confirm_fingerprint.py +++ b/resources/tester/test_342_2_confirm_fingerprint.py @@ -48,12 +48,12 @@ def node(monkeypatch, tmp_path): bundle.write_text("//", encoding="utf-8") calls: list = [] - def fake_run(path, script, payload): + def fake_run(payload): calls.append(payload) return {"areas": [], "mass_haul": {"total": len(calls)}} - monkeypatch.setattr(prebuild, "BUNDLE", bundle) - monkeypatch.setattr(prebuild, "run_bundle_json", fake_run) + monkeypatch.setattr(prebuild, "NODE_BUNDLE", bundle) + monkeypatch.setattr(prebuild, "_run_node", fake_run) monkeypatch.setattr(prebuild, "bundle_stale", lambda path: False) monkeypatch.setattr(prebuild, "_NODE_CACHE", {}) return SimpleNamespace(calls=calls, bundle=bundle) @@ -105,7 +105,7 @@ def test_TS_가_번들보다_새로우면_다시(node, monkeypatch): def test_실패는_기억_안_함(node, monkeypatch): - monkeypatch.setattr(prebuild, "run_bundle_json", lambda *a: node.calls.append(a) or None) + monkeypatch.setattr(prebuild, "_run_node", lambda *a: node.calls.append(a) or None) assert prebuild._node_output(_input()) is None assert prebuild._node_output(_input()) is None assert len(node.calls) == 2 @@ -241,13 +241,13 @@ def test_같은_입력_두_번이면_Node_0_쓰기_0(node, monkeypatch, tmp_path written_haul.append(data_patch) stored["data"]["mass_haul"] = data_patch["mass_haul"] - def fake_run(path, script, payload): + def fake_run(payload): node.calls.append(payload) # Node 에 간 입력이 곧 지문 입력 — 다른 입력 길 없음 assert set(payload) == {"detail", "context"} return {"areas": [{"chainage_m": 0.0, "cut_area_m2": 5.0}], "mass_haul": {"total": 9.0}} - monkeypatch.setattr(prebuild, "run_bundle_json", fake_run) + monkeypatch.setattr(prebuild, "_run_node", fake_run) monkeypatch.setattr(b06_router, "get_section_detail", detail) monkeypatch.setattr(prebuild, "run_with_connection", run_with) monkeypatch.setattr(prebuild, "resolve_stored_project_path", lambda path: str(tmp_path)) diff --git a/resources/tester/test_342_3_detail_cache.py b/resources/tester/test_342_3_detail_cache.py index 661d712aa..8ea80935b 100644 --- a/resources/tester/test_342_3_detail_cache.py +++ b/resources/tester/test_342_3_detail_cache.py @@ -24,7 +24,7 @@ def setup(tmp_path, monkeypatch): basins._DETAIL_CACHE.clear() calls = SimpleNamespace(count=0) - def build(stored_path, context, points, auto=None): + def build(stored_path, context, points): calls.count += 1 chainages = [p.chainage_m for p in points] if points else [50.0] return DrainageDetail(), [PipePoint(chainage_m=c, source="stream") for c in chainages] diff --git a/resources/tester/test_342_3_drainage_stale.py b/resources/tester/test_342_3_drainage_stale.py index c252f6c35..18d096861 100644 --- a/resources/tester/test_342_3_drainage_stale.py +++ b/resources/tester/test_342_3_drainage_stale.py @@ -52,13 +52,13 @@ def _patch(monkeypatch, analysis_signature, stored): monkeypatch.setattr(basins, "load_pipe_points", lambda *args: stored) built = SimpleNamespace(called=False) - def build(stored_path, context, points, auto=None): + def build(stored_path, context, points): built.called = True return SimpleNamespace(pipes=[], basins=["유역"]), points or [] monkeypatch.setattr(basins, "_build", build) monkeypatch.setattr( - basins, "_build_cached", lambda context, points, auto=None: build("x", context, points) + basins, "_build_cached", lambda context, points: build("x", context, points) ) return built diff --git a/resources/tester/test_344_2_route_pairs.py b/resources/tester/test_344_2_route_pairs.py index 3c140cfaf..0868d368f 100644 --- a/resources/tester/test_344_2_route_pairs.py +++ b/resources/tester/test_344_2_route_pairs.py @@ -34,10 +34,10 @@ from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 carry_base, - carry_edits, changed_span, line_matcher, ) +from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, ) @@ -58,6 +58,11 @@ pytestmark = pytest.mark.skipif( # ── 입력 만들기 ─────────────────────────────────────────────────────────────── +def carry_edits(*args): + """파이썬 짝 걷음(511-B B-6) — 서버 = Node `carry_seat` 의 편집 몫.""" + return carry_seat(*args)[0] + + def _longitudinal(total: float, *, wave: float, holes: tuple[int, ...] = ()) -> dict: samples = [] for index in range(int(total) + 1): diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py index 4f2316b76..4c01f5aa5 100644 --- a/resources/tester/test_344_3_station_key_mirror.py +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -14,12 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from common_util.common_util_station_key import ( # noqa: E402 - chainage_key, - dedupe_station_keys, - loose_chainage, - station_key, -) +from common_util.common_util_station_key import chainage_key, station_key # noqa: E402 TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts" TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript" @@ -53,6 +48,9 @@ CASES = [ ] DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006]) LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] +# 파이썬 `loose_chainage` · `dedupe_station_keys` = 운영 부름 0 → 걷음(511-B B-6) · 걷기 전 값 +LOOSE_WANT = [85.05, 85.05, None, None, -1.5, None] +DEDUPE_WANT = ["grid:1", "structure:st-1", "at:20.00", "at:25.01"] SCRIPT = r""" const ts = require(process.argv[1]); @@ -92,8 +90,8 @@ def test_파이썬과_TS_열쇠_같음(): out = json.loads(proc.stdout) assert out["keys"] == [station_key(*case) for case in CASES] assert out["chainage"] == [chainage_key(case[1]) for case in CASES] - assert out["loose"] == [loose_chainage(key) for key in LOOSE] - assert out["dedupe"] == dedupe_station_keys(*DEDUPE) + assert out["loose"] == LOOSE_WANT + assert out["dedupe"] == DEDUPE_WANT def test_열쇠_규칙(): @@ -102,10 +100,8 @@ def test_열쇠_규칙(): assert station_key("regular", 50.0, 20.0) == "grid:3" assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3" assert station_key("irregular", 85.054, 20.0) == "at:85.05" - assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None # 5파 — 구간형 시작 · 끝 · 기준은 그대로 · 원래 격자 번호 · 같은 열쇠 둘째는 at: assert station_key("structure", 30.0, 20.0, "st-9", role="start") == "structure:st-9:start" assert station_key("structure", 33.0, 20.0, "st-9") == "structure:st-9" assert station_key("grid", 41.3, 20.0, "2") == "grid:2" assert station_key("end", 99.0, 20.0) == "end:EP" - assert dedupe_station_keys(*DEDUPE) == ["grid:1", "structure:st-1", "at:20.00", "at:25.01"] diff --git a/resources/tester/test_345_5b_plan_visible_area.py b/resources/tester/test_345_5b_plan_visible_area.py index 917a9765e..f89937d91 100644 --- a/resources/tester/test_345_5b_plan_visible_area.py +++ b/resources/tester/test_345_5b_plan_visible_area.py @@ -115,7 +115,7 @@ def test_판은_덮이기만_따라_안_옮김() -> None: css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8") assert "--b05-routeedit-cover" not in css bar = (B05 / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text(encoding="utf-8") - assert "bottom: rect.bottom - 8," in bar + assert "bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8," in bar # 505-3 assert "coveredPx" not in bar and "onCoverChange" not in bar # 칸 크기가 바뀌어도 보던 가운데 · 배율 유지(다시 맞추지 않음) editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index c265c56df..7c6957578 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -132,7 +132,7 @@ def test_노선_반영_단추_없앰_배선(): def test_넘김_알림은_결과_한_줄에_합침(): """346-3 덧 — [저장] 뒤 「노선 밖 고침」 두 줄 → 한 줄 · 넘김은 조용히 수만 · 기본은 알림 그대로.""" keys = _read("B06_Section/B06_Section_Station_Keys.ts") - assert "{ quiet = false }: { quiet?: boolean } = {}," in keys + assert "{ quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number }" in keys assert "if (outside.size && !quiet)" in keys patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts") assert "outside: keyed.outside.size + chain.outside.size" in patch diff --git a/resources/tester/test_349_0v_route_save_span.py b/resources/tester/test_349_0v_route_save_span.py index 3de52f75d..b683b8ab0 100644 --- a/resources/tester/test_349_0v_route_save_span.py +++ b/resources/tester/test_349_0v_route_save_span.py @@ -3,7 +3,7 @@ 화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드 초안) → `changedSpan` → `carryBase` · `carryEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 -CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · `carry_edits`(400). +CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · Node `carry_seat`(400). 두 길이 같은 구간 · 같은 계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음). """ @@ -26,9 +26,9 @@ if str(ROOT) not in sys.path: from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 carry_base, - carry_edits, changed_span, ) +from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "typescript").is_dir(), @@ -44,6 +44,11 @@ CURVE_ON = [True] * len(OLD_NODES) RADII: list[float | None] = [None] * len(OLD_NODES) +def carry_edits(*args): + """파이썬 짝 걷음(511-B B-6) — 서버 = Node `carry_seat` 의 편집 몫.""" + return carry_seat(*args)[0] + + def _csv_line(path: Path, nodes) -> list[list[float]]: replan._write_planned_polyline( path, list(nodes), RADIUS_M, simplify=False, curve_flags=CURVE_ON, radii=RADII diff --git a/resources/tester/test_349_drainage_live_flow.py b/resources/tester/test_349_drainage_live_flow.py index 16ccc91f3..753d70263 100644 --- a/resources/tester/test_349_drainage_live_flow.py +++ b/resources/tester/test_349_drainage_live_flow.py @@ -60,8 +60,9 @@ def test_manual_update_answer_lands_after_same_rerun(r): def test_suggestions_and_apply(r): - assert r["suggest"]["shown"] and r["suggest"]["title"] == "제안 2건" - assert len(r["suggest"]["rows"]) == 2 + # 관 자리 후보(200)는 제안 목록에 안 올림 — [자동 제안] 목록 몫(510) + assert r["suggest"]["shown"] and r["suggest"]["title"] == "제안 1건" + assert len(r["suggest"]["rows"]) == 1 points = {p["chainage_m"]: p for p in r["applied"]["points"]} assert points[100]["options"]["pipe_diameter_mm"] == 1200 assert 200 not in points # 관 자리 후보는 [적용]이 안 더함 — [자동 제안] 몫(369) @@ -75,24 +76,23 @@ def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r): "calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음 } assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"} + # [자동 제안] 누름 = 패널 후보 목록(510 · test_510) — 여기선 초기화만 초안 assert r["propose"] == { - "autos": ["all", "add", None], - "points": [1, 1, None], # ① ② = 지금 관 목록 함께 · 초기화 = 목록 없이(세류선만) - "remembered": 3, # 셋 다 초안 - "records": 3, # 답마다 사진첩 한 걸음 + "autos": [None], + "points": [None], # 초기화 = 목록 없이(세류선만) + "remembered": 1, + "records": 1, # 답마다 사진첩 한 걸음 } # 저장 분석이 옛 노선 — 지금 노선으로 셈 길(detail-basins 안 부름) · 같은 초안 · 한 걸음 assert r["proposeLive"] == { - "auto": "all", - "points": 1, "resetPoints": None, "resetAuto": None, "detailCalls": 0, - "remembered": 6, # 앞 「409 → 다시」 한 번 포함 - "records": 6, + "remembered": 3, # 앞 「409 → 다시」 한 번 포함 + "records": 3, } fallback = r["proposeFallback"] - assert fallback["sent"] == 1 and fallback["auto"] == "all" + assert fallback["sent"] == 1 and fallback["points"] is None assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시) diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py index 3f178e535..bbb79185d 100644 --- a/resources/tester/test_354_2_masshaul_cross_only.py +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -48,6 +48,8 @@ def test_기준_횡단만_계산(r): def test_그리기는_횡단만_부름(): mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S) + # 511-A — 부름 인자 한 벌(계산 자리 길이 함수째 받음) + calls += re.findall(r"Parameters = \[([^;]*?)\];", mass, re.S) assert calls and all('["cross"]' in call for call in calls) diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py index 0154535bc..2c05b11f6 100644 --- a/resources/tester/test_366_3_station_fit.py +++ b/resources/tester/test_366_3_station_fit.py @@ -89,7 +89,12 @@ def test_시작_자리는_고친_꺾임점_앞(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit # 280-4 + # 280-4 · 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit # 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤 at = edit.index("function restoreSnapshot(") assert "applyEdit(message, record, false);" in edit[at : at + 700] diff --git a/resources/tester/test_369_drainage_auto.py b/resources/tester/test_369_drainage_auto.py index e342a71ea..d8a5e1d59 100644 --- a/resources/tester/test_369_drainage_auto.py +++ b/resources/tester/test_369_drainage_auto.py @@ -1,10 +1,7 @@ -"""369 배수 구조물 자리 — 기본 = 세류선 교차만 · [자동 제안] 두 방식(all · add). +"""369 배수 구조물 자리 — 기본 = 세류선 교차만 · 목록 있으면 그대로 · 낡은 분석은 관 자리만. -- 기본(auto 없음) = 세류선 교차 관만 · 100 m 보충 없음 · 관 자리 후보는 셈. -- all = 세류선 + 100 m 보충으로 다시(지금 목록 무시) · 같은 자리(0.05 m) 관은 속성 · id 유지 · - 나머지 = 새 관(등록부 기본값). -- add = 지금 목록 그대로 + 빈 구간 보충 · 더한 관 = 새 관(등록부 기본값). -- 틀린 auto = 400 · 저장본 열쇠에 auto. +- 목록 없음 = 세류선 교차 관만 · 100 m 보충 없음 · 관 자리 후보는 셈. +- 목록 있음 = 그 자리 그대로(보충은 후보만) · [자동 제안] = 510 (`test_510_auto_pipe_propose.py`). """ import asyncio @@ -17,7 +14,6 @@ from B04_PreProcess import B04_PreProcess_Router_Basins as basins from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util.common_util_drainage_context import DrainageContext from common_util.common_util_drainage_detail import RoadRouting, build_detail -from common_util.common_util_drainage_pipes import PipePoint from common_util.common_util_route_geometry import RouteVertex, StructureCandidate VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)] @@ -41,8 +37,8 @@ def _routing() -> RoadRouting: ) -def _detail(tmp_path, chainages=None, auto=None): - return build_detail(tmp_path / "없음", VERTICES, chainages, _routing(), [], auto) +def _detail(tmp_path, chainages=None): + return build_detail(tmp_path / "없음", VERTICES, chainages, _routing(), []) def test_기본은_세류선_교차만(tmp_path): @@ -51,84 +47,19 @@ def test_기본은_세류선_교차만(tmp_path): assert len(detail.pipe_candidates) == 2 # 0~150 · 150~300 이 100 m 넘음 — 제안만 -def test_all_은_세류선_더하기_100m_보충_지금_목록_무시(tmp_path): - detail = _detail(tmp_path, [10.0, 150.02], "all") - reasons = [p.reason for p in detail.pipes] - places = [p.chainage_m for p in detail.pipes] - assert reasons.count("stream") == 1 and reasons.count("spacing") == 2 - assert 150.02 in places and 10.0 not in places # 세류선 150 m 0.5 m 안의 지금 관 = 그 자리 - gaps = np.diff([0.0, *places, 300.0]) - assert gaps.max() <= 100.0 and not detail.pipe_candidates - - -def test_add_는_지금_목록_더하기_빈_구간_보충(tmp_path): - detail = _detail(tmp_path, [20.0], "add") - places = [p.chainage_m for p in detail.pipes] - assert 20.0 in places and len(places) == 3 # 20 ~ 300 = 280 m → 보충 둘 - assert np.diff([0.0, *places, 300.0]).max() <= 100.0 - # auto 없음 = 목록 그대로(보충은 후보만) +def test_목록은_그대로_보충은_후보만(tmp_path): plain = _detail(tmp_path, [20.0]) assert [p.chainage_m for p in plain.pipes] == [20.0] and len(plain.pipe_candidates) == 2 -def _build(tmp_path, monkeypatch, points, auto): - monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path / "없음") - return basins._build("x", CONTEXT, points, _routing(), [], auto=auto) - - -def test_all_같은_자리_관은_속성_id_유지_새_관은_기본값(tmp_path, monkeypatch): - kept = PipePoint( - chainage_m=150.3, # 세류선 150 m 에서 0.3 m — 노선 따라감으로 어긋난 같은 관 - source="user", - facility="ford_bridge", - options={"ford_width_m": 4.0}, - id="keep", - ) - gone = PipePoint(chainage_m=10.0, source="user", options={"pipe_diameter_mm": 600}, id="gone") - _, carried = _build(tmp_path, monkeypatch, [gone, kept], "all") - ford = [p for p in carried if p.id == "keep"] - assert len(ford) == 1 and ford[0].facility == "ford_bridge" and ford[0].source == "user" - assert ford[0].chainage_m == 150.3 # 그 자리 그대로 - assert all(p.id != "gone" for p in carried) - fresh = [p for p in carried if p.id != "keep"] - assert len(fresh) == 2 and all(p.source == "spacing" for p in fresh) - assert all((p.options or {}).get("pipe_diameter_mm") for p in fresh) # 등록부 관경 - - -def test_add_더한_관은_기본값_지금_관은_그대로(tmp_path, monkeypatch): - mine = PipePoint(chainage_m=20.0, source="user", id="mine") - _, carried = _build(tmp_path, monkeypatch, [mine], "add") - assert [p.id for p in carried].count("mine") == 1 - assert next(p for p in carried if p.id == "mine").options is None # 옛 빈칸 = 미확정 유지 - added = [p for p in carried if p.id != "mine"] - assert len(added) == 2 and all(p.source == "spacing" for p in added) - assert all((p.options or {}).get("pipe_diameter_mm") for p in added) - - -def test_틀린_auto_는_400_열쇠에_auto(tmp_path, monkeypatch): - async def load(project_id): - return CONTEXT, "" - - monkeypatch.setattr(basins, "load_drainage_context", load) - monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path) - result = asyncio.run(basins._resolve(uuid4(), {"auto": "spacing"}, use_stored=False)) - assert result.status_code == 400 - points = [PipePoint(chainage_m=20.0)] - keys = {basins._detail_key(CONTEXT, points, auto) for auto in basins.AUTO_MODES} - assert len(keys) == 3 - - -def test_낡은_분석에_auto_는_409(monkeypatch): +def test_낡은_분석은_관_자리만_낡음으로(monkeypatch): async def load(project_id): return CONTEXT, "" monkeypatch.setattr(basins, "load_drainage_context", load) monkeypatch.setattr(basins, "analysis_route_signature", lambda stored: "옛 노선") points = [{"chainage_m": 20.0}] - for auto in ("all", "add"): - result = asyncio.run( - basins._resolve(uuid4(), {"points": points, "auto": auto}, use_stored=False) - ) - assert result.status_code == 409 # 말없이 그대로 돌려주지 않음 — 낡음 까닭 kept = asyncio.run(basins._resolve(uuid4(), {"points": points}, use_stored=False)) - assert kept[-1] is True # auto 없음 = 관 자리만 낡음으로(그대로) + assert kept[-1] is True # 관 목록 있음 = 관 자리만 낡음으로 + none = asyncio.run(basins._resolve(uuid4(), {}, use_stored=False)) + assert none.status_code == 409 # 목록 없음 = 낡음 까닭 diff --git a/resources/tester/test_400_profile_carry.py b/resources/tester/test_400_profile_carry.py index b0f9021dd..615313b38 100644 --- a/resources/tester/test_400_profile_carry.py +++ b/resources/tester/test_400_profile_carry.py @@ -2,7 +2,7 @@ """400 평면 고친 뒤 종단 편집 이어받기 — 같은 자리(옆 1 m · 방향 30° 안)는 옛 계획고를 새 측점 위에. 거울(파이썬 = 서버 [저장] 정본 · TS = 브라우저 즉시 · 같은 값): - `B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · carry_edits ↔ + `B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · Node carry_seat(편집 · 파이썬 carry_edits 걷음) ↔ `logic/B05_Profile_Grade_Carry.ts` lineMatcher · carryBase · carryEdits. 묶음 follow(`logic/B05_Profile_Pipeline_Grade.ts`) — 걸음 수와 무관(기준 한 벌에서 한 번) · 되돌린 노선 = 옛 편집. @@ -28,9 +28,9 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 SAME_LINE_TOL_M, carry_base, - carry_edits, line_matcher, ) +from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "typescript").is_dir(), @@ -44,6 +44,11 @@ HALF = INTERVAL / 2 # ── 입력 만들기 ─────────────────────────────────────────────────────────────── +def carry_edits(*args): + """파이썬 짝 걷음(511-B B-6) — 서버 = Node `carry_seat` 의 편집 몫.""" + return carry_seat(*args)[0] + + def _dense(nodes: list[tuple[float, float]], step: float = 2.0) -> list[list[float]]: """꺾은선을 `step` m 안팎 점으로(그린 노선처럼 정점이 많게).""" out = [list(nodes[0])] diff --git a/resources/tester/test_438_1_initial_straighten.py b/resources/tester/test_438_1_initial_straighten.py index c142b4cfa..8d5690c81 100644 --- a/resources/tester/test_438_1_initial_straighten.py +++ b/resources/tester/test_438_1_initial_straighten.py @@ -236,7 +236,11 @@ def test_부르는_곳(): return (ROOT / rel).read_text(encoding="utf-8") profile = read("B05_Profile/B05_Profile_Engine_Grade_Profile.py") - assert "base_z = straight_pass_through(base_s, base_z, policy)" in profile + # 1순위 첫 계획선 = 서버 Node(511-B B-3 · TS `groundFollowingNodes` → `passThrough`) + assert "ground_following={" in profile + assert "const baseZ = passThrough(nodeS, target, input.policy);" in read( + "B05_Profile/logic/B05_Profile_Grade_Ground.ts" + ) assert "straight_pass_through(base_s, target, policy, lambda z: _hold(" in read( "B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py" ) diff --git a/resources/tester/test_442_2_grade_limit_mirror.py b/resources/tester/test_442_2_grade_limit_mirror.py index 9dce7a895..74e9bfaf9 100644 --- a/resources/tester/test_442_2_grade_limit_mirror.py +++ b/resources/tester/test_442_2_grade_limit_mirror.py @@ -1,6 +1,7 @@ """PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수. -TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙 +TS `logic/B05_Profile_Grade_Criteria.ts`(511-B B-3 · 화면 `_UI_Panel_Fields.ts` 에서 옮김) +PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙 (고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬 `FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 × 지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄. diff --git a/resources/tester/test_459_route_seat_mirror.py b/resources/tester/test_459_route_seat_mirror.py index 2127afb86..e35076c18 100644 --- a/resources/tester/test_459_route_seat_mirror.py +++ b/resources/tester/test_459_route_seat_mirror.py @@ -27,6 +27,8 @@ ROOT = HERE.parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from helper_475_1_moved import seat_route_moved # noqa: E402 + from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 AlignmentPolicy, @@ -42,10 +44,7 @@ from B05_Profile.B05_Profile_Plus_Stations import ( # noqa: E402 plus_chainages, save_plus_stations, ) -from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 - route_moved_stations, - seat_route_moved, -) +from B05_Profile.B05_Profile_Route_Save_Parts import route_moved_stations # noqa: E402 from B05_Profile.B05_Profile_Router_Confirm import _seat_born_nodes # noqa: E402 from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 from common_util.common_util_route_follow import route_outside # noqa: E402 diff --git a/resources/tester/test_472_1_golden.py b/resources/tester/test_472_1_golden.py index 79ae0e1b1..347099ec7 100644 --- a/resources/tester/test_472_1_golden.py +++ b/resources/tester/test_472_1_golden.py @@ -8,7 +8,8 @@ 면적(절 · 성 · 토사 · 암 · 측구) · 층따기 밑수 · 단면 모양 · 누가토량(측점별 · 끝값) · 관 길이 · 종단 경고 · 기울기 위반 수. - 두 길 사이 = 「알려진 차」(`KNOWN`)만 · 그 밖 차는 실패 · 안 나타난 알려진 차도 실패 - (목록에서 지움 — 472-4 가 짝을 하나 옮길 때마다 한 줄씩 지워 마지막에 빈 목록). + (목록에서 지움). 짝 모으기(511-B) 끝 — 남은 줄은 계산 짝이 아닌 까닭(표본 출처 · 입력 선 + 자릿수 · 저장 때 코드) · 줄마다 `why`. - 화면 참값 — 정본 누가토량 −17,570.70 ㎥ · 측점 57(465 기준값). 이 고정 자료가 안 거치는 짝인데 값이 갈리는 자리 — X1 단면 모양 기본값(설계 없는 새 측점 없음) · X3 면적 두 갈래(구조물 = 관 351 하나 · 두 길 다 Node TS 로 셈) · X4 · X6(노선 안 바뀜). @@ -58,7 +59,8 @@ KNOWN: dict[tuple[str, str], list[Known]] = { Known( "R7", {"fill_m2": 0.00011, "cumulative_m3": 0.011}, - "다시 뜬 표본 마지막 자리 — grid:37 성토 1e-4 ㎡ · 누가토량 0.01 ㎥ 넷(끝값 같음)", + "지반 표본 출처(G1) — 정본 = 10-17 파이썬 표본 · 노선 저장 = Node 격자 표본 · " + "grid:37 성토 1e-4 ㎡ · 누가토량 0.01 ㎥ 넷(끝값 같음 · 기준값 다시 뜸 안 함 B-2)", ), Known( "W0", diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 4572d363f..c7addd471 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -11,8 +11,8 @@ Node 입구 = `A00_Common/design/logic/design_calc_node.ts`(`build:design-calc` - 서버 쪽(server_canon · server_only) TS 가 브라우저 import 그래프에 없음 · 등록부는 안 끔. - Node 입구 도달 집합 = logic · common_util · 자료만 + 「알려진 끌림」(`KNOWN_PULLS`) — 그 밖은 실패 · 안 나타난 알려진 끌림도 실패(목록에서 지움 — 472-2 2-B · 472-4 가 하나씩 걷어 빈 목록). - - Node 입구 값 = 기존 묶음 값(고정 자료 `fixtures/golden_472` · server-calc 면적 · 다단 벽 · - 누가토량 · 배분 · corridor 3D) · 터파기 = 파이썬 짝. + - Node 입구 값(고정 자료 `fixtures/golden_472` · 면적 · 다단 벽 · 누가토량 끝값 · 배분 두 벌 · + corridor 3D = corridor 묶음) · 터파기 = 걷기 전 파이썬 값. ./venv/Scripts/python.exe -m pytest resources/tester/test_472_3_calc_registry.py -q """ @@ -42,12 +42,21 @@ NODE_IDS = { "excavation.trench", "corridor.mesh", "profile.seat", + "profile.base", + "profile.alignment", + "profile.carry", "facility.plus_follow", "facility.follow", "cross.design", "cross.berm_spans", "facility.rebase", "stations.carry", + "facility.attach", # 511-B B-4 — GET 형상 얹기 + "cross.enforce", # 511-B B-4 — [저장] 횡단 한 바퀴 + "stations.list", + "stations.sections", + "route.polyline", + "stations.plan", } pytestmark = pytest.mark.skipif( @@ -106,6 +115,7 @@ GEOMETRY_PULLS = ( # 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀 "B06_Section/B06_Section_Route_Carry_Patch.ts", ) +# 묶음 단계 입구(511-B B-1) planline 저장 기준(`plantedBase`) 끌림 11 = B-3 이 logic 으로 옮겨 걷음 KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) @@ -256,7 +266,16 @@ def test_서버_쪽_코드_브라우저_밖(loaded, reach) -> None: if row["site"] in ("server_canon", "server_only") and row["ts"] ] assert server # 유토 배분(common_util_mass_haul_balance) - assert [path for path in server if path in browser] == [] + # 511-C(요구 321) — 유토 배분은 판 haul = both 때만 dynamic import(첫 묶음 밖) · 정적 import 0 + lazy = "common_util/common_util_mass_haul_balance.ts" + assert [path for path in server if path in browser] == [lazy] + static = re.compile( + r'^import\s+(?!type\s)[^;]*?from\s+"@util/common_util_mass_haul_balance"', re.M + ) + ts_files = [p for p in browser if p.endswith(".ts")] + assert [p for p in ts_files if static.search((ROOT / p).read_text(encoding="utf-8"))] == [] + haul_api = (ROOT / "B05_Profile" / "B05_Profile_Api_HaulPlan.ts").read_text(encoding="utf-8") + assert 'await import("@util/common_util_mass_haul_balance")' in haul_api assert NODE_ENTRY not in browser assert reach[REGISTRY] == sorted(REGISTRY_PARTS) # 순수 자료 — 제 몫 파일만(나눔 · 함수 안 끔) @@ -287,19 +306,12 @@ def golden_run() -> dict: return out["results"] -def test_server_calc_묶음과_같음(golden_run) -> None: - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT - from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE as SC_BUNDLE - from common_util.common_util_node_bundle import run_bundle_json - - sc = run_bundle_json( - SC_BUNDLE, _NPM_SCRIPT, {"detail": _fixture("detail.json"), "context": _context()} - ) +def test_Node_입구_정본_값(golden_run) -> None: + # 옛 server-calc 묶음 걷음(511-B B-6) — 값은 472-1 기준값 시험(`stored` · `server_save`) layouts = golden_run["structure.layouts"] - assert layouts["areas"] == sc["areas"] and sc["areas"] - assert layouts["extra_walls"] == sc["extra_walls"] + assert layouts["areas"] and isinstance(layouts["extra_walls"], list) merged = {**golden_run["masshaul.cumulative"], **golden_run["haul.plan"]} - assert merged == sc["mass_haul"] + assert merged["haul_plan"] and merged["haul_plan_drawing"] assert merged["final_cumulative_m3"] == -17570.7 # 472-1 화면 참값 @@ -316,9 +328,8 @@ def test_corridor_묶음과_같음() -> None: assert co and out["results"]["corridor.mesh"] == co -def test_터파기_파이썬_짝과_같음() -> None: - from common_util import common_util_excavation as py - +def test_터파기_값() -> None: + # 파이썬 짝 걷음(511-B B-6 · 운영 부름 0) — 기대값 = 걷기 전 파이썬 값 pipes = [{"diameter_mm": d} for d in (300, 800, 1500, 250, None)] walls = [ {"height_m": 2.0, "average_thickness_m": 0.5, "has_foundation": True}, @@ -329,15 +340,11 @@ def test_터파기_파이썬_짝과_같음() -> None: {"run": ["excavation.trench"], "inputs": {"excavation": {"pipes": pipes, "walls": walls}}} ) got = out["results"]["excavation.trench"] - assert [p["width_m"] for p in got["pipes"]] == [ - py.pipe_trench_width_m(p["diameter_mm"]) for p in pipes - ] - for row, wall in zip(got["walls"], walls, strict=True): - assert row["width_m"] == py.wall_trench_width_m(wall["average_thickness_m"]) - want = py.wall_trench_area_m2( - wall["height_m"], wall["average_thickness_m"], has_foundation=wall["has_foundation"] - ) - assert row["area_m2"] == pytest.approx(want) if want is not None else row["area_m2"] is None + assert [p["width_m"] for p in got["pipes"]] == [0.7, 1.6, 2.5, None, None] + assert [w["width_m"] for w in got["walls"]] == pytest.approx([0.7, 0.55, 0.6], abs=1e-12) + assert got["walls"][0]["area_m2"] == pytest.approx(1.85) + assert got["walls"][1]["area_m2"] == pytest.approx(0.73) + assert got["walls"][2]["area_m2"] is None def test_모르는_id_는_실패() -> None: diff --git a/resources/tester/test_472_3_calc_runner.py b/resources/tester/test_472_3_calc_runner.py index 9e223bc18..c14630c66 100644 --- a/resources/tester/test_472_3_calc_runner.py +++ b/resources/tester/test_472_3_calc_runner.py @@ -2,8 +2,8 @@ 못박는 것 - `NODE_IDS` = 등록부 `node:` 줄 = 입구 표(`available`). - - 고정 자료 `fixtures/golden_472` 로 `run_calc` 값 = [저장] 재계산이 부르는 server-calc 묶음 값 - (`Server_Calc_Prebuild` BUNDLE · 면적 · 다단 벽 · 누가토량 · 배분 · 끝값 −17,570.7). + - 고정 자료 `fixtures/golden_472` 로 늘 떠 있는 Node `run_calc` 값 = 같은 묶음 새 프로세스 값 + (면적 · 다단 벽 · 누가토량 · 배분 · 끝값 −17,570.7). - Node 실행기 아닌 id(파이썬 · 브라우저 실행기)는 ValueError. ./venv/Scripts/python.exe -m pytest resources/tester/test_472_3_calc_runner.py -q @@ -50,27 +50,20 @@ def test_NODE_IDS_등록부_입구와_같음() -> None: assert set(out["available"]) == runner.NODE_IDS -def test_실행기_값_server_calc_와_같음() -> None: - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json - - sc = run_bundle_json( - BUNDLE, _NPM_SCRIPT, {"detail": _fixture("detail.json"), "context": _context()} - ) - got = runner.run_calc( - ["structure.layouts", "masshaul.cumulative", "haul.plan"], - {"detail": _fixture("detail.json"), "context": _context()}, - ) - assert got["structure.layouts"]["areas"] == sc["areas"] and sc["areas"] - assert got["structure.layouts"]["extra_walls"] == sc["extra_walls"] +def test_늘_떠_있는_Node_새_프로세스와_같음() -> None: + # 옛 server-calc 묶음 걷음(511-B B-6) — 늘 떠 있는 Node(`run_calc`) = 같은 묶음 새 프로세스 + ids = ["structure.layouts", "masshaul.cumulative", "haul.plan"] + inputs = {"detail": _fixture("detail.json"), "context": _context()} + got = runner.run_calc(ids, inputs) + fresh = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": ids, "inputs": inputs}) + assert got == fresh["results"] and got["structure.layouts"]["areas"] merged = {**got["masshaul.cumulative"], **got["haul.plan"]} - assert merged == sc["mass_haul"] assert merged["final_cumulative_m3"] == -17570.7 def test_Node_실행기_아닌_id_는_ValueError() -> None: - with pytest.raises(ValueError, match="stations.list"): - runner.run_calc(["masshaul.cumulative", "stations.list"], {}) + with pytest.raises(ValueError, match="ground.widen"): + runner.run_calc(["masshaul.cumulative", "ground.widen"], {}) # ── 472-4 ② — 횡단 설계선 · 소단 구간 Node 길 ────────────────────────────────── diff --git a/resources/tester/test_472_4_underdrain_mirror.py b/resources/tester/test_472_4_underdrain_mirror.py index 947c16d13..15a905487 100644 --- a/resources/tester/test_472_4_underdrain_mirror.py +++ b/resources/tester/test_472_4_underdrain_mirror.py @@ -5,7 +5,8 @@ - 구조물 목록 → 제원(높이 · 경사 · 빈 값 · 0 · 음수 · 글 · 맹암거 아닌 종류) 두 쪽 같음. - 기준 측점(±0.02 m) 얹기 · 얹은 수 같음. - 알려진 차 하나 — 옛 `underdrain` 이 남은 측점: TS 는 걷음 · 파이썬은 둠 - (서버는 파일에서 새로 읽어 옛 값 없음 · Node 로 옮길 때 한 벌). + (서버 GET = Node `facility.attach` = TS · 파이썬 짝은 Node 못 부를 때만 · 그 길은 정본 파일을 + 새로 읽어 옛 값 없음). ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_underdrain_mirror.py -q """ diff --git a/resources/tester/test_475_3_seat_carry.py b/resources/tester/test_475_3_seat_carry.py index c23b0bf56..d5c233ec9 100644 --- a/resources/tester/test_475_3_seat_carry.py +++ b/resources/tester/test_475_3_seat_carry.py @@ -201,13 +201,13 @@ def test_기본_설계가_넘긴_손_단면_유형으로_셈(tmp_path, monkeypat import B06_Section.B06_Section_Router_Design as design_module seen: list[tuple[str, object]] = [] - real = design_module.compute_cross_design + real = design_module.compute_cross_designs # 511-B B-4 — 측점 묶음 Node 한 번 - def spy(*args, **kwargs): - seen.append((kwargs["section_mode"], kwargs.get("ditch_side"))) - return real(*args, **kwargs) + def spy(jobs, *args, **kwargs): + seen.extend((options["section_mode"], options.get("ditch_side")) for *_, options in jobs) + return real(jobs, *args, **kwargs) - monkeypatch.setattr(design_module, "compute_cross_design", spy) + monkeypatch.setattr(design_module, "compute_cross_designs", spy) route = tmp_path / "routes" / "r1" (route / "longitudinal").mkdir(parents=True) (route / "cross_sections").mkdir() diff --git a/resources/tester/test_510_auto_pipe_propose.py b/resources/tester/test_510_auto_pipe_propose.py new file mode 100644 index 000000000..d2d34de73 --- /dev/null +++ b/resources/tester/test_510_auto_pipe_propose.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +"""510-1 [자동 제안] = B05 배수유역 영역 후보 → 사용자가 골라 넣음(요구 320-2). + +못박는 것 + - 후보 = 지금 노선 × 세류선 교차(5 m 솎기 · 정수 m · 라벨) + 간격 보충(흐름 세기 · 분석 있을 때만). + - 상태 = 라벨 짝 관 · 교차 5 m 안 관 = held · 지움 표지 짝 = dismissed(라벨 = 그 표지) · 나머지 new. + - 고른 교차 후보 = 라벨 실은 관 → 계산 왕복(carry) · 저장(keep_labels) 지나도 라벨 · 지운 관을 다시 + 넣으면 지움 표지 걷음. + - 넣은 라벨 관 = 노선 바뀐 [저장] 따라감 · 새 관 안 더함(겹침 없음). + - 화면: 아무것도 미리 안 고름 · [넣기] = 고른 것만 · held 는 목록 밖 · 관 속성 보관소가 라벨 왕복. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of # noqa: E402 +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PipePoint, + carry_facility_attributes, + parse_pipe_points, +) +from common_util.common_util_stream_crossing import keep_labels, propose_pipes # noqa: E402 + +HERE = Path(__file__).resolve().parent +TSC = ROOT / "config" / "node_modules" / "typescript" + + +def _stream(ufid: str, x: float) -> dict: + return { + "type": "Feature", + "properties": {"UFID": ufid}, + "geometry": {"type": "LineString", "coordinates": [[x, -50.0], [x, 120.0]]}, + } + + +LINE = [[0.0, 0.0], [300.0, 0.0], [600.0, 0.0]] +STREAMS = [_stream("S1", 50.4), _stream("S2", 150.0), _stream("S3", 250.0), _stream("S4", 320.0)] + + +def _case(): + vertices = vertices_of(LINE) + crossings = _base_pipes(vertices, STREAMS) + s2 = {"stream": "S2", "order": 1, "x": 150.0, "y": 0.0} + pipes = [ + PipePoint(chainage_m=150.0, source="user", stream_label=s2, id="p15000"), + PipePoint(chainage_m=252.0, source="user", id="p25200"), # 라벨 없음 · S3 교차 5 m 안 + ] + dismissed = [{"stream": "S1", "order": 1, "x": 50.4, "y": 0.0}] + return vertices, crossings, pipes, dismissed + + +def test_후보_교차_상태_라벨(): + vertices, crossings, pipes, dismissed = _case() + found = propose_pipes(vertices, crossings, pipes, dismissed) + assert [(f["chainage_m"], f["kind"], f["state"]) for f in found] == [ + (50.0, "stream", "dismissed"), # 정수 m + (150.0, "stream", "held"), + (250.0, "stream", "held"), + (320.0, "stream", "new"), + ] + assert found[0]["stream_label"] == dismissed[0] # 지운 관 = 그 표지 그대로(다시 넣으면 걷음) + assert found[3]["stream_label"] == {"stream": "S4", "order": 1, "x": 320.0, "y": 0.0} + + +def test_간격_보충_분석_있을_때만(): + vertices, crossings, pipes, dismissed = _case() + curve = np.ones(601) + found = propose_pipes(vertices, crossings, pipes, dismissed, curve) + spacing = [f for f in found if f["kind"] == "spacing"] + assert spacing and all(f["state"] == "new" and "stream_label" not in f for f in spacing) + # 지금 관 + 교차 후보(새 · 지운 관) 사이 최대 간격(100 m) 넘는 구간만 · 320 뒤 280 m · + # 지운 교차(50) 곁에 간격 보충이 붙지 않음(화면 실측 = 지운 385 옆 378) + taken = [50.0, 150.0, 252.0, 320.0] + assert all(min(abs(f["chainage_m"] - t) for t in taken) > 5.0 for f in spacing) + assert any(f["chainage_m"] > 320.0 for f in spacing) + assert not [f for f in propose_pipes(vertices, crossings, pipes, dismissed) if f["kind"] == "spacing"] + + +def test_고른_라벨_관_계산_왕복_저장_지나도_라벨(): + label = {"stream": "S4", "order": 1, "x": 320.0, "y": 0.0} + request = parse_pipe_points([{"chainage_m": 320.0, "source": "user", "stream_label": label}]) + rebuilt = carry_facility_attributes([PipePoint(chainage_m=320.0, source="user")], request) + assert rebuilt[0].stream_label == label # 고치기 전 = 계산 왕복에서 사라짐 + dismissed: list = [] + keep_labels(rebuilt, [], dismissed) + assert rebuilt[0].stream_label == label and dismissed == [] + + +def test_지운_관_다시_넣으면_지움_표지_걷음(): + mark = {"stream": "S1", "order": 1, "x": 50.4, "y": 0.0} + other = {"stream": "S9", "order": 1, "x": 9.0, "y": 0.0} + dismissed = [mark, other] + keep_labels([PipePoint(chainage_m=50.0, stream_label=dict(mark))], [], dismissed) + assert dismissed == [other] + + +# 507-2 시험과 같은 꼴 — 앞 노선을 북으로 옮겨 세류선 교차가 30 m 옮음 +OLD = [[0.0, 0.0], [100.0, 0.0], [200.0, 0.0], [300.0, 0.0], [400.0, 0.0]] +NEW1 = [[0.0, 0.0], [100.0, 0.0], [200.0, 60.0], [300.0, 0.0], [400.0, 0.0]] +NEW2 = [[0.0, 0.0], [100.0, 0.0], [200.0, 90.0], [300.0, 0.0], [400.0, 0.0]] +ONE = [_stream("S1", 150.0)] + + +def _follow(old, old_xy, new_xy): + points = [{"x": x, "y": y} for x, y in new_xy] + pairs, crossing, _ = save.followed_pipes(old, old_xy, points, ONE, []) + return [pipe for _, pipe in pairs], crossing + + +def test_넣은_라벨_관_노선_바꿈_따라감_겹침_없음(): + [cand] = propose_pipes(vertices_of(OLD), _base_pipes(vertices_of(OLD), ONE), [], []) + assert cand["state"] == "new" + picked = PipePoint( + chainage_m=cand["chainage_m"], source="user", x=150.0, y=0.0, + stream_label=cand["stream_label"], id="p15000", + ) # fmt: skip + pipes, crossing = _follow([picked], OLD, NEW1) + assert crossing == 0 and len(pipes) == 1 and pipes[0].stream_label == cand["stream_label"] + again = parse_pipe_points([pipe.as_dict() for pipe in pipes]) + pipes2, crossing2 = _follow(again, NEW1, NEW2) + assert crossing2 == 0 and len(pipes2) == 1 + # 새 노선에서 다시 물으면 = 있는 관(held) · 후보 아님 + [again_cand] = propose_pipes(vertices_of(NEW2), _base_pipes(vertices_of(NEW2), ONE), pipes2, []) + assert again_cand["state"] == "held" + + +@pytest.mark.skipif(shutil.which("node") is None or not TSC.is_dir(), reason="node · typescript 없음") +def test_화면_고른_것만_넣음_라벨_왕복(): + run = subprocess.run( + ["node", str(HERE / "helper_510_propose.cjs")], + capture_output=True, text=True, encoding="utf-8", timeout=60, check=True, + ) # fmt: skip + got = json.loads(run.stdout) + assert got["sent"] == {"url": "http://fake/projects/p1/drainage/pipe-proposals", "points": 1} + assert got["rows"] == [ + "측점 50 m — 세류선 교차 · 지운 관", + "측점 320 m — 세류선 교차(작은골)", + "측점 420 m — 간격 보충(흐름이 모이는 자리)", + ] # held 는 목록 밖 + assert got["title"] == "자동 제안 3건 · 있는 관 1 뺌" + assert got["addOff"] is True and got["candidates"] == [50, 320, 420] + assert got["added"] == [[320, "S4"], [420, None]] # 고른 둘만 + assert got["afterClose"] == {"hidden": True, "candidates": []} + assert got["carried"] == {"stream": "S4", "order": 1} # 응답 → 보관소 → 요청 diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py new file mode 100644 index 000000000..965c8f299 --- /dev/null +++ b/resources/tester/test_511_a_calc_browser.py @@ -0,0 +1,94 @@ +"""PLAN 511-A (요구 320-4) — 브라우저 「서버」 길(`design_calc_server.ts`) · 판 따라 길 · 자리. + +못박는 것 + - 판 both = 브라우저 값 · 서버 부름 0(넣어도 바뀌는 것 0) · server = 잠잠 400 ms 뒤 한 번 + (앞 부름 끊음). + - 대조 = 같으면 그대로 · 다르면 apply(서버 값) · 그리기 자리 = 답 오면 redraw · 같은 입력 = fresh. + - 끊긴 부름 = undefined · 왕복 흉내 delayMs · 창구 주소 = /projects/{id}/calc. + - 자리마다 판 열쇠 · [저장] · [확정] 길은 서버 길을 안 부름. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_browser.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 없음") +def test_판_따라_길_고름_잠잠_대조_기록(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_browser.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["both"] == 5 and out["bothFetched"] == 0 and out["local"] == 1 + assert out["server"] == 7 and out["beforeQuiet"] == 0 and out["afterQuiet"] == 1 + assert out["same"] is True and out["applied"] == 0 + assert out["diffApplied"] == 99 and out["diffSame"] is False + draw = out["draw"] + assert draw["first"] == {"fresh": False} and draw["redraws"] == 1 + assert draw["second"] == {"value": ["s", 1], "fresh": True} + assert draw["third"] == {"value": ["s", 1], "fresh": False} # 새 입력 = 앞 답 + 다시 물음 + assert out["aborted"] is True + assert out["onlyValue"] == [1] and out["delayed"] and out["roundMs"] >= 80 + assert out["url"] == "http://fake/projects/p1/calc" # API_BASE_URL(가짜) + 창구 + + +def test_자리마다_판_열쇠_저장_길_안_부름(): + refresh = _text("B06_Section/B06_Section_Cross_Refresh.ts") + assert 'calcOnServerNow("quantity")' in refresh and '"F7.saved"' in refresh + shared = _text("A00_Common/design/design_shared.ts") + assert 'calcChecked("quantity", "structure.layouts"' in shared + assert 'calcChecked("quantity", "cross.berm_spans"' in shared + lane = _text("A00_Common/design/design_facility_lane.ts") + assert '"C4.copy", bermRequest(structures)' in lane + mass = _text("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts") + assert 'calcFromServer("quantity", "masshaul.series"' in mass + assert "openCalcSites(projectId)" in _text("A00_Common/design/design_page.ts") + for rel in ( + "A00_Common/design/design_save.ts", + "A00_Common/design/design_save_api.ts", + "A00_Common/design/design_reset.ts", + ): + assert "design_calc_server" not in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert len(page.splitlines()) <= 699 and "design_calc_server" not in page # 연결 = 다른 파일 + + +def test_이월_R9_자리_판_열쇠(): + keys = _text("B06_Section/B06_Section_Station_Keys.ts") + assert '"carry",\n "stations.carry_keyed",' in keys + assert "carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId })" in keys + + +def test_고정_브라우저_자리_판_열쇠(): + sites = { + "B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts": '"fixed_browser", "route.handle"', + "B05_Profile/B05_Profile_UI_RouteEdit.ts": '"fixed_browser", "route.station_fit"', + "B06_Section/B06_Section_Section_Store.ts": '"fixed_browser", "stations.swap"', + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts": '"fixed_browser",\n "P7",', + "B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts": '"fixed_browser", "cross.fit"', + "B05_Profile/B05_Profile_Footprint_Server.ts": '"fixed_browser", "D4"', + } + for rel, needle in sites.items(): + assert needle in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert "footprint: footprintStageSited," in page diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py new file mode 100644 index 000000000..70177c631 --- /dev/null +++ b/resources/tester/test_511_a_calc_mirror.py @@ -0,0 +1,213 @@ +"""PLAN 511-A (요구 320-4) — 묶음마다 서버 창구 답 = 브라우저 자리 TS 답(거울). + +브라우저 쪽 = `helper_511_a_mirror.cjs` 가 그 자리 함수를 바로 부름 · 서버 쪽 = 늘 떠 있는 Node +(`run_calc_served` · 창구와 같은 본문 꼴 — `design_calc_server.ts` `areasRequest` · `bermRequest` · +시험 길 args). +고정 자료 = golden_472 · 272 소단 사례 · 손 자료(공유 DB · 실제 마스터 안 씀). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_mirror.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="node · 프론트엔드 의존성 없음", +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _browser(tmp_path: Path, calls: list[dict]) -> list: + job = tmp_path / "job.json" + job.write_text(json.dumps(calls, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_mirror.cjs"), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _served(run: list[str], inputs: dict) -> dict: + answer = runner.run_calc_served(run, inputs) + assert answer and "results" in answer, answer + return answer["results"] + + +def _trial(id_: str, args: list): + return _served([id_], {"args": {id_: args}})[id_] + + +# ── ③ 토량 · 구조물 셈(E1 · F7 · C4 · F8 = 브라우저 부름 없음) ─────────────────────── + + +def test_quantity_F7_C4_E1_서버_브라우저_같음(tmp_path): + from test_272_berm_source import STRUCTURE_CASES + + detail = _fixture("detail.json") + sections = detail["cross_sections"] + conv = _fixture("context.json")["earthwork_conversion"] + long_ = detail["longitudinal"] + calls = [ + {"mod": "B06_Section/B06_Section_Structure_Layouts.ts", "fn": "structureAreaRows", + "args": [sections]}, + {"mod": "common_util/common_util_mass_haul.ts", "fn": "computeMassHaulSeries", + "args": [long_, sections, conv, None, ["cross"]]}, + ] + [ + {"mod": "common_util/common_util_berm_spans.ts", "fn": "bermSpansFromStructures", + "args": [case]} + for case in STRUCTURE_CASES + ] # fmt: skip + browser = _browser(tmp_path, calls) + areas = _served(["structure.layouts"], {"detail": {"cross_sections": sections}}) + assert areas["structure.layouts"]["areas"] == browser[0] and browser[0] + series = _trial("masshaul.series", [long_, sections, conv, None, ["cross"]]) + assert series == browser[1] and series[0]["result"]["final_cumulative_m3"] + for index, case in enumerate(STRUCTURE_CASES): + got = _served(["cross.berm_spans"], {"berm": {"structures": case, "chainages": []}}) + assert got["cross.berm_spans"]["spans"] == browser[2 + index], index + + +# ── ④ 이월 · 다시 얹기(R9 · F10 = 브라우저 부름 없음) ────────────────────────────────── + + +def test_carry_R9_서버_브라우저_같음(tmp_path): + seat = {"x": 10.0, "y": 5.0, "left": [0.0, 1.0]} + states = { + "crossdesign": {"grid:1": {"ditch_type": "U"}, "20.00": {"paved": True}}, + "shoulderext": {"grid:2": 0.5, "@at": {"grid:2": 40.0}, "@seat": {"grid:2": seat}}, + "bermdepth": {"at:55.00": 1.0}, + "rockb": {"30.00": -2.5}, + "cutslope": {}, + } + before = [ + {"key": "grid:1", "chainageM": 20.0, "seat": seat}, + {"key": "grid:2", "chainageM": 40.0}, + {"key": "end:EP", "chainageM": 60.0}, + ] + after = [ + {"key": "grid:1", "chainageM": 22.0}, + {"key": "grid:2", "chainageM": 41.0, "seat": seat}, + {"key": "end:EP", "chainageM": 58.0}, + ] + empty = dict.fromkeys(states, {}) # 브라우저 읽기 = 빈 칸도 {}(readRaw) + cases = [[states, before, after], [states, before, None], [empty, None, after]] + browser = _browser( + tmp_path, + [ + {"mod": "resources/tester/helper_511_a_carry.ts", "fn": "carryWith", "args": case} + for case in cases + ], + ) + for index, case in enumerate(cases): + assert _trial("stations.carry_keyed", case) == browser[index], index + assert browser[0]["states"]["crossdesign"] # 넘김이 실제로 일어남 + + +# ── ⑧ 고정 브라우저(시험용 · R2 R4 C7 대조 · P7 서버 값 · R8 · D4 = 화면 검증) ────────────────── + +_ROUTE = [[0, 0], [93, 7], [171, 61], [262, 48], [337, 112], [431, 101], [503, 160]] +_LIMITS = {"minRadiusM": 30, "limitRadiusM": 12, "limitArcM": 0, "stationM": 20, "intervalM": 10} + + +def _columns(planned): + n = len(planned) + return { + "planned": [list(p) for p in planned], + "curveOn": [True] * n, + "curveRadius": [None] * n, + "curveLock": [None] * n, + "curveArc": [None] * n, + "apexLock": [i in (0, n - 1) for i in range(n)], + } + + +def _profile_base(): + import math + + stations = [i * 20 for i in range(16)] + ground = [round(100 + 0.03 * s + 0.6 * math.sin(s / 13), 6) for s in stations] + policy = { + "station_interval_m": 20, "curve_radius_ratio": 1, "curve_tangent_max_ratio": 0.5, + "curve_skip_legal_exception": False, "default_curve_length_m": 20, + "default_curve_radius_m": 300, "balance_tolerance_percent": 10, "edit_step_m": 0.1, + "grade_violation_policy": "warn", "max_grade_pct": 14, "curve_skip_delta_pct": 0, + "paved": True, "min_vertical_radius_m": 100, "min_curve_length_m": 20, + } # fmt: skip + return { + "policy": policy, + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in zip(stations, ground)], + "chainage": stations, + "ground": ground, + "stations": [{"station_id": f"S{s}", "chainage_m": s} for s in stations], + } + + +def test_fixed_browser_R2_R4_P7_C7_서버_브라우저_같음(tmp_path): + pushed = [list(p) for p in _ROUTE] + pushed[2] = [174.0, 58.0] + base = _profile_base() + empty = {"station_offsets": {}, "curve_radii": {}} + saved = {"40.000": 5330, "200.000": 1084} + sections = _fixture("detail.json")["cross_sections"][:12] + cases = { + "route.handle": [ + [[0, 0], [100, 0], [100, 100], "end", [105, 60]], + [[0, 0], [100, 0], [100, 100], "start", [60, -4]], + [[0, 0], [100, 0], [200, 0], "end", [150, 0]], # 직선 = null + ], + "route.station_fit": [ + [_columns(_ROUTE), _columns(pushed), _LIMITS], + [None, _columns(_ROUTE), {**_LIMITS, "intervalM": 20}], + ], + "profile.tools": [ + [base, empty, 40, 200, {}], + [base, {"station_offsets": {}, "curve_radii": dict(saved)}, 40, 200, saved], + [base, empty, 0, 300, {}], + ], + "cross.fit": [[section] for section in sections], + } + b05 = "B05_Profile/logic/B05_Profile_" + where = { + "route.handle": (f"{b05}UI_RouteEdit_Curve.ts", "dragHandleTo"), + "route.station_fit": (f"{b05}RouteEdit_StationFit.ts", "fitAfter"), + "profile.tools": (f"{b05}UI_Profile_Straighten.ts", "straightenBetween"), + "cross.fit": ("B06_Section/logic/B06_Section_UI_Cross_Fit.ts", "toeOffsets"), + } + flat = [(id_, args) for id_, rows in cases.items() for args in rows] + browser = _browser( + tmp_path, + [{"mod": where[id_][0], "fn": where[id_][1], "args": args} for id_, args in flat], + ) + for (id_, args), want in zip(flat, browser): + assert _trial(id_, args) == want, id_ + assert browser[0] and browser[2] is None # 직선 = null + toes = [want for (id_, _), want in zip(flat, browser) if id_ == "cross.fit"] + assert any(toe["left"] is not None for toe in toes) diff --git a/resources/tester/test_511_a_calc_server.py b/resources/tester/test_511_a_calc_server.py new file mode 100644 index 000000000..01600c49d --- /dev/null +++ b/resources/tester/test_511_a_calc_server.py @@ -0,0 +1,139 @@ +"""PLAN 511-A (요구 320-4) — 계산 자리 「서버」 창구 `POST /api/projects/{id}/calc` · 떠 있는 Node. + +못박는 것 + - 창구 = 프로젝트 권한(다른 회사 403 · 시스템 관리자 통과) · 본문 상한 413 · 모르는 id · 꼴 400. + - 늘 떠 있는 Node 답 = 파일 길(`run_calc`) 답 · 죽여도 다음 부름에 다시 띄움. + - 시험 길 id(`TRIAL_IDS`) = TS `design_calc_node_trial.ts` 표 · `available` 에 안 섞임. + - 공유 DB 안 씀(가짜 풀) · 실제 마스터 안 씀(고정 자료 golden_472). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_server.py -q +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402 +from common_util import common_util_auth as auth # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "golden_472" +TRIAL_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_trial.ts" +PROJECT = "11111111-2222-3333-4444-555555555555" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _inputs() -> dict: + ctx = _fixture("context.json") + keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope") + return {"detail": _fixture("detail.json"), "context": {k: ctx[k] for k in keys}} + + +class _Cursor: + async def execute(self, sql, args=None): + self.args = args + + async def fetchone(self): + return (1,) if self.args == (PROJECT,) else None # 회사 1 프로젝트 하나 + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Conn(_Cursor): + def cursor(self): + return _Cursor() + + +class _Pool: + def acquire(self): + return _Conn() + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool()) + app = FastAPI() + app.include_router( + calc_router.router, + dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)], + ) + session = {"role": "USER", "company_id": 1, "user_id": 7} + app.dependency_overrides[auth.verify_session] = lambda: dict(session) + return TestClient(app), session + + +def test_main_프로젝트_권한으로_붙음(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b06_calc_router, dependencies=protected_with_company)" in main + assert "note_api_call(session_id)" in main # 호출량 = 미들웨어(/api 전부) + + +def test_권한_다른_회사_403_관리자_통과(client): + http, session = client + body = {"run": ["cross.fit"], "inputs": {"args": {"cross.fit": [{}]}}} + ok = http.post(f"/api/projects/{PROJECT}/calc", json=body) + assert ok.status_code == 200, ok.text + assert ok.json()["results"] == {"cross.fit": {"left": None, "right": None}} + session["company_id"] = 2 + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 403 + missing = "00000000-0000-0000-0000-000000000000" + assert http.post(f"/api/projects/{missing}/calc", json=body).status_code == 404 + session["role"] = "SYSTEM_ADMIN" + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 200 + + +def test_본문_상한_꼴_모르는_id(client, monkeypatch): + http, _ = client + url = f"/api/projects/{PROJECT}/calc" + monkeypatch.setattr(calc_router, "MAX_BODY", 100) + big = {"run": ["cross.fit"], "inputs": {"pad": "x" * 200}} + assert http.post(url, json=big).status_code == 413 + monkeypatch.setattr(calc_router, "MAX_BODY", 8 * 1024 * 1024) + assert http.post(url, content=b"{nope").status_code == 400 + assert http.post(url, json={"run": "cross.fit"}).status_code == 400 + assert http.post(url, json={"run": ["cross.fit"], "inputs": []}).status_code == 400 + bad = http.post(url, json={"run": ["ground.widen"], "inputs": {}}) + assert bad.status_code == 400 and "ground.widen" in bad.json()["message"] + + +def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시(): + ids = ["structure.layouts", "masshaul.cumulative", "haul.plan", "excavation.trench"] + want = runner.run_calc(ids, _inputs()) + got = runner.run_calc_served(ids, _inputs()) + assert got["results"] == want and want["masshaul.cumulative"]["final_cumulative_m3"] + assert got["ms"] >= 0 and got["wall_ms"] >= got["ms"] + runner._SERVED.proc.kill() + runner._SERVED.proc.wait() + again = runner.run_calc_served(["structure.layouts"], _inputs()) + assert again["results"]["structure.layouts"] == want["structure.layouts"] + + +def test_시험_길_id_TS_표와_같음_available_밖(): + src = TRIAL_TS.read_text(encoding="utf-8") + table = src.split("const TRIAL: Record = {")[1].split("\n};")[0] + assert set(re.findall(r'^ "([a-z_.]+)":', table, re.M)) == runner.TRIAL_IDS + out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) + assert set(out["available"]) == runner.NODE_IDS + assert not runner.NODE_IDS & runner.TRIAL_IDS diff --git a/resources/tester/test_511_a_calc_sites_browser.py b/resources/tester/test_511_a_calc_sites_browser.py new file mode 100644 index 000000000..5d8cad785 --- /dev/null +++ b/resources/tester/test_511_a_calc_sites_browser.py @@ -0,0 +1,235 @@ +"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2. + +- 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만 +- 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다 + 그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로) +- 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움 +- 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms) + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s +""" + +from __future__ import annotations + +import asyncio +import json +import statistics +import sys +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 +from test_505_3_cross_card_screen import JS_NODES # noqa: E402 + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +MARK = "AisloTest-511-A" +TIMES = ROOT / "tmp" / "511a_times.json" +GROUP_IDS = { + "quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"}, + "carry": {"stations.carry_keyed"}, + "fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"} + | {"cross.fit", "footprint.plan"}, +} + + +def _started(base: str) -> str | None: + try: + with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: + return json.loads(res.read()).get("started_at") + except Exception: + return None + + +_UP = _started(BASE) if BASE else None +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +JS_GRADE = r"""async () => { + const url = performance.getEntriesByType('resource').map((e) => e.name) + .find((n) => /design_pipeline_page\.ts/.test(n)); + const m = await import(url); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = m.pagePipeline(pid); + const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} }; + const st = pipe.output('ground').stations; + const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3); + m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets, + [key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } }); +}""" + +# 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지 +JS_MIRROR = r"""async (calls) => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => + x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); + const where = { + 'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'], + 'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'], + 'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'], + 'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'], + 'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'], + 'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'], + 'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'], + 'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'], + 'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'], + 'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'], + }; + const out = []; + for (const { run, inputs, results } of calls) { + for (const id of run) { + const fn = (await mod(where[id][0]))[where[id][1]]; + const args = inputs.args?.[id]; + const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections) + : id === 'cross.berm_spans' ? fn(inputs.berm.structures) + : id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null }) + : id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2]) + : fn(...args); + const theirs = id === 'structure.layouts' ? results[id].areas + : id === 'cross.berm_spans' ? results[id].spans : results[id]; + out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) }); + } + } + return out; +}""" + + +def _drop_test_logs() -> None: + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) + await connection.commit() + finally: + await close_db_pool() + + asyncio.run(run()) + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + committed = BOARD.read_bytes() + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + try: + yield context + finally: + for key in GROUP_IDS: + _put(context, key, "both") + chrome.close() + finally: + BOARD.write_bytes(committed) + _drop_test_logs() + + +def _put(context, key: str, value: str) -> None: + r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}}) + assert r.ok, r.status + + +def _session(context, delay: int = 0): + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + calls: list[dict] = [] + + def on_response(res): + if res.url.endswith("/calc") and res.request.method == "POST": + calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]}) + + page.on("response", on_response) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + seed = "{ calls: [], local: {}, delayMs: 0 }" + page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay) + return page, calls + + +def _act(page) -> dict: + page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) + page.wait_for_timeout(3500) + nodes = page.evaluate(JS_NODES) + assert len(nodes) >= 3, "지도에서 교각점을 못 찾음" + node = nodes[len(nodes) // 2] + page.mouse.move(node["x"], node["y"]) + page.mouse.down() + page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안) + page.mouse.up() + page.wait_for_timeout(5000) + return page.evaluate("() => window.__aisloCalcServer") + + +def _median(values: list[float]) -> float | None: + values = [v for v in values if v is not None] + return round(statistics.median(values), 2) if values else None + + +def test_기본값_서버_부름_0(ctx): + page, calls = _session(ctx) + log = _act(page) + page.close() + assert calls == [] and log["calls"] == [] + assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"]) + assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"]) + TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8") + + +@pytest.mark.parametrize("group", list(GROUP_IDS)) +def test_묶음_서버_요청_값_같음_시간(ctx, group): + _put(ctx, group, "server") + table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {} + try: + for delay in (0, 30, 80): + page, calls = _session(ctx, delay) + log = _act(page) + mirror = page.evaluate(JS_MIRROR, calls) + page.close() + sent = {id_ for call in calls for id_ in call["run"]} + assert sent and sent <= GROUP_IDS[group], (group, sent) + assert mirror and all(row["same"] for row in mirror), (group, mirror) + assert all(c["same"] is not False for c in log["calls"]), log["calls"] + rows = {} + for call in log["calls"]: + rows.setdefault(call["id"], []).append(call) + table[f"{group}@{delay}"] = { + id_: { + "n": len(group_calls), + "round_ms": _median([c["roundMs"] for c in group_calls]), + "node_ms": _median([c["nodeMs"] for c in group_calls]), + "wall_ms": _median([c["wallMs"] for c in group_calls]), + "kb": round(_median([c["bytes"] for c in group_calls]) / 1024), + } + for id_, group_calls in rows.items() + } + print(group, delay, table[f"{group}@{delay}"]) + finally: + _put(ctx, group, "both") + TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8") + assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both" diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py new file mode 100644 index 000000000..ac3af1462 --- /dev/null +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -0,0 +1,363 @@ +"""PLAN 511-B B-1 (요구 320-4) — 묶음 단계 Node 입구 · 판 다섯 줄. + +못박는 것 + - 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 = + 파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음. + - 판 = 단계 줄(B-2 ~ B-5 가 엶 · 기본 both · 옛 까닭 「짝 모으는 중 · B-n」 = B-6 걷음) · + F9 = 폼 기본값 · + 커밋한 판 그대로. + - 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎 + (측점 = 저장 상세 측점 0.01 m). + - 화면(검증_sub2 · 이 폴더 서버 · Playwright) — 페이지 묶음 입력 · 저장 기준을 떠서 Node + 단계에 넣으면 단계 출력 = 페이지 단계 출력 0 차 — 노선 초안 둔 채 다시 열기 = 다섯 · + 저장 노선 = 넷(횡단은 페이지가 다시 셈 창구 = 파이썬 저장 설계 · B-4 몫 · 열쇠만 같음). + 저장 안 함 · 세션만. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b1_calc_node_stages.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 + +from common_util import common_util_calc_runner as runner # noqa: E402 +from common_util import common_util_calc_sites as calc_sites # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" +STAGES_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_stages.ts" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +CAPTURE = ROOT / "tmp" / "511b1_capture.json" +STAGES = ("route", "follow", "stations", "profile", "cross") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _stages(stages: dict) -> dict: + """다섯 단계를 한 요청으로(늘 떠 있는 Node) → 단계 이름 → 출력.""" + answer = runner.run_calc_served([f"stage.{n}" for n in STAGES], {"stages": stages}) + assert answer is not None and "error" not in answer, answer + return {n: answer["results"][f"stage.{n}"] for n in STAGES} + + +def test_단계_id_표_같음_등록부_밖(): + names = re.search(r"STAGE_NAMES = \[([^\]]+)\]", STAGES_TS.read_text(encoding="utf-8")) + ts = {f"stage.{n}" for n in re.findall(r'"(\w+)"', names.group(1))} + assert ts == runner.STAGE_IDS == {f"stage.{n}" for n in STAGES} + out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) + assert set(out["available"]) == runner.NODE_IDS # 단계 id 는 등록부 밖 + with pytest.raises(ValueError, match="stage.ground"): + runner.run_calc_served(["stage.ground"], {}) + bad = runner.run_calc_served(["stage.route"], {}) + assert "단계 입력" in bad["error"] # 입력 없음 = 그 줄 오류(프로세스는 삶) + + +def test_판_다섯_줄_잠금_기본값_그대로(): + by_key = {g["key"]: g for g in calc_sites.GROUPS} + expected = {} + for key, (nos, why) in expected.items(): + group = by_key[key] + assert group["nos"] == nos and group["why"] == why, key + assert group["switchable"] is False and group["fixed"] == "both", key + # cross = B-4 끝에 엶(511-B B-4 · 기본값 both 그대로) + cross = by_key["cross"] + assert cross["nos"] == ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] + assert cross["switchable"] is True and cross["fixed"] is None and cross["why"] == "cross" + assert by_key["form_default"]["nos"] == ["F9"] and by_key["form_default"]["fixed"] == "both" + assert "stages" not in by_key and "structure_pair" not in by_key + assert set(calc_sites.SWITCHABLE) == { + "ground_sample", + "corridor_mesh", + "quantity", + "carry", + "route", # B-5 = 엶(511-B B-5) + "follow", # B-5 = 엶(511-B B-5) + "stations", # B-2 끝 = 엶(511-B B-2) + "profile", # B-3 엶(511-B · test_511_b3_profile_node) + "cross", + "haul", # 511-C E2 엶(기본 server · 판 파일 바뀜 0) + "fixed_browser", + } + committed = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["git", "show", "HEAD:resources/master_template/calc_sites.json"], + cwd=ROOT, + capture_output=True, + check=True, + ).stdout + assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n") + locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8") + assert "짝 모으는 중" not in locale # B-6 — 단계 줄 모두 엶 · 옛 까닭 글 걷음 + + +def test_페이지_빼기만_같은_맞춤_함수(): + page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + assert len(page.splitlines()) <= 693 # 늘리지 않음(B-1 전 693) + assert ( + "placedFollowInputs(ctx, savedLine(), saved)" in page and "contextCross(ctx, status" in page + ) + assert "placed(" not in page and "crossWithContext(" not in page + stages = STAGES_TS.read_text(encoding="utf-8") + assert "placedFollowInputs(ctx" in stages and "contextCross(ctx" in stages + + +def test_고정_자료_Node_단계_저장_노선_한_바퀴(tmp_path): + from common_util.common_util_surface_sampler import dtm_grid_bytes + + grid = tmp_path / "grid.bin" + grid.write_bytes( + dtm_grid_bytes(FIXTURE / "project" / "B04_PreProcess/models/dtm_classification_smooth.npz") + ) + detail = _fixture("detail.json") + stages = { + "plan": _fixture("plan.json"), + "detail": detail, + "types": _fixture("structure_types.json")["types"], + "saved_pipes": [ + {k: v for k, v in p.items() if k != "lonlat"} + for p in _fixture("pipes.json")["pipe_points"] + ], + "saved_structures": _fixture("structures.json")["structures"], + "interval_m": 20, + "grid": {"path": str(grid), "design": "las"}, + "input": {"plusStations": _fixture("plus_stations.json")["chainages"]}, + } + out = _stages(stages) + assert out["route"]["line"] == [[x, y] for x, y in stages["plan"]["planned"]] + # 끝점 = 노선 길이(파이썬 저장 상세는 0.6 mm 다름 · 차례 뒤바뀜 = B-2 몫) → 0.01 m · 차례 없이 + saved = sorted(round(s["chainage_m"], 2) for s in detail["cross_sections"]) + assert [round(s["chainageM"], 2) for s in out["stations"]] == saved + assert len(out["profile"]["pvi"]) >= 2 and out["cross"] == {"byKey": {}} # 횡단 입력 없음 + + +# ── 화면 — 페이지 단계 출력 대 Node 단계 출력(검증_sub2) ────────────────────────────── + +API, BASE = own_ports() + + +def _started(base: str) -> str | None: + try: + with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: + return json.loads(res.read()).get("started_at") + except Exception: + return None + + +_UP = _started(BASE) if BASE else None +screen = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +# 페이지 묶음 입력(`inputsNow` 꼴 · 가게 null = 저장분) · 저장 기준 · 다섯 단계 출력을 뜸 +JS_CAPTURE = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => + x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const data = await mod(/design_page_data\.ts/); + const routeId = data.pageRouteId(pid); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const types = await (await mod(/B05_Profile_Api_Structures\.ts/)).fetchStructureTypes(true); + const list = await data.pageStructures(pid); + const pipes = await data.pagePipes(pid); + const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); + const store = (await mod(/design_store\.ts/)).useDesignStore(pid, null); + const edit = await mod(/B05_Profile_UI_Profile_Edit\.ts/); + const prof = await mod(/B05_Profile_UI_Profile_Data\.ts/); + const plus = await mod(/B05_Profile_Api_PlusStations\.ts/); + const state = await mod(/b_page_state\.ts/); + const route = store.get('routeDraft'); + const savedEdits = edit.readAlignmentDraft(routeId) + ?? prof.readAlignment(detail.longitudinal)?.edits ?? null; + const terrain = await (await fetch(`/api/projects/${pid}/route/terrain-sources`, + { credentials: 'include' })).json(); + return plain({ + routeId, + terrain: { design: terrain.design, grid: terrain.grid }, + stages: { + plan, detail, types, + saved_pipes: (pipes?.pipe_points ?? []).map(({ lonlat: _l, ...p }) => p), + saved_structures: list?.structures ?? [], + saved_edits: savedEdits, + saved_plus: plus.savedPlusRows(pid), + interval_m: 20, + before_draft: route ? (state.readState('route-draft-hold', pid)?.facilities ?? null) : null, + pipe_ids: state.readState('pipe-ids', pid), + input: { + route, + alignment: route ? (edit.readAlignmentDraft(0) || savedEdits) : pipe.input('alignment'), + cross: pipe.input('cross'), + pipes: store.get('pipes'), + structures: store.get('structures'), + plusStations: plus.plusStationsOf(pid), + }, + }, + outputs: Object.fromEntries(['route', 'follow', 'stations', 'profile', 'cross'] + .map((n) => [n, pipe.output(n)])), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + }); +}""" + +# 노선 초안 — 가운데 교각점 하나 옮김(편집기 다리 · 세션 칸 route-draft) +JS_DRAFT = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => import(urls.find((n) => re.test(n))); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) + .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); + const nodes = flat.planned.map(([x, y]) => [x, y]); + const at = Math.floor(nodes.length / 2); + nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4]; + page.routeDraftBridge(pid, null).edited({ + nodes, + curveOn: flat.curveOn.map((on) => on !== false), + curveRadius: flat.curveRadius.map((r) => r ?? null), + curveLock: flat.curveLock ?? nodes.map(() => null), + curveArc: flat.curveArc ?? nodes.map(() => null), + stationSnap: plan.station_snap ?? false, + stationSnapStep: plan.station_snap_step ?? null, + }); + await page.reconcilePagePipeline(pid); + await page.pagePipeline(pid).settled(); + return at; +}""" + + +def _grid_path(terrain: dict) -> dict | None: + """페이지가 받은 격자와 같은 파일(`read_surface_grid` 고르기) — DB 읽기만.""" + info = terrain.get("grid") + if not info: + return None + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_surface_sampler import dtm_grid_file + from config.config_db import close_db_pool, init_db_pool + + async def lookup(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, PROJECT) + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT model_file_path FROM surface_models" + " WHERE id = %s AND project_id = %s", + (info["model_id"], PROJECT), + ) + row = await cursor.fetchone() + return stored, row[0] + finally: + await close_db_pool() + + stored, rel = asyncio.run(lookup()) + model = Path(resolve_stored_project_path(stored)) / rel + smooth = model.with_name(f"{model.stem}_smooth.npz") + npz = smooth if info["smooth"] and smooth.is_file() else model + return {"path": str(dtm_grid_file(npz)), "design": terrain.get("design") or "las"} + + +@pytest.fixture(scope="module") +def captured(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots["saved"] = page.evaluate(JS_CAPTURE) + page.evaluate(JS_DRAFT) + page.wait_for_timeout(3000) + page.reload(wait_until="domcontentloaded") # 초안 둔 채 다시 열기 = 처음부터 한 바퀴 + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots["draft"] = page.evaluate(JS_CAPTURE) + page.close() # 세션 초안은 창과 함께 버림(저장 안 함) + finally: + chrome.close() + CAPTURE.write_text(json.dumps(shots, ensure_ascii=False), encoding="utf-8") + return shots + + +def _diff(a, b, path="") -> str | None: + """첫 다른 자리(0 차 — 수도 정확히 같아야 함).""" + if type(a) is not type(b) and not (isinstance(a, (int, float)) and isinstance(b, (int, float))): + return f"{path}: {type(a).__name__} ≠ {type(b).__name__}" + if isinstance(a, dict): + for k in sorted(set(a) | set(b)): + if k not in a or k not in b: + return f"{path}.{k}: 한쪽만" + hit = _diff(a[k], b[k], f"{path}.{k}") + if hit: + return hit + return None + if isinstance(a, list): + if len(a) != len(b): + return f"{path}: 길이 {len(a)} ≠ {len(b)}" + for i, (x, y) in enumerate(zip(a, b)): + hit = _diff(x, y, f"{path}[{i}]") + if hit: + return hit + return None + return None if a == b else f"{path}: {a!r} ≠ {b!r}" + + +@screen +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_화면_Node_단계_출력_페이지와_0차(captured, case): + shot = captured[case] + assert shot["runs"] == ["done"], shot["runs"] + assert (shot["stages"]["input"]["route"] is not None) == (case == "draft") + stages = {**shot["stages"], "grid": _grid_path(shot["terrain"])} + out = _stages(stages) + # 저장 노선 횡단 = 페이지는 다시 셈 창구(저장 상세 설계 · 파이썬 짝 · 칸 더 많음) + # — 엔진 대조는 B-4 몫 + names = STAGES if case == "draft" else STAGES[:-1] + for name in names: + hit = _diff(out[name], shot["outputs"][name], name) + assert hit is None, hit + assert len(out["stations"]) > 10 and out["cross"]["byKey"] + assert set(out["cross"]["byKey"]) == set(shot["outputs"]["cross"]["byKey"]) # 측점 열쇠는 같음 diff --git a/resources/tester/test_511_b2_stations_node.py b/resources/tester/test_511_b2_stations_node.py new file mode 100644 index 000000000..7d461361a --- /dev/null +++ b/resources/tester/test_511_b2_stations_node.py @@ -0,0 +1,321 @@ +"""PLAN 511-B B-2 (요구 320-4) — 서버 측점 생성 = 화면과 같은 TS(Node `stations.sections`). + +못박는 것 + - 고정 자료(`fixtures/golden_472`) 노선 · 관 · 구조물 · 추가 측점 — `build_sections` + Node 길 = 파이썬 `generate_sections` 길 0 차(칸 · 차례 · 값 · 측점 열쇠 `key` 만 더함) — + 곡선표 있음 · 없음 · 스냅 10 m · 측점 간격 10 m · 끝 측점 뺌. + - DTM 아닌 지표면 = 파이썬 길(Node 안 부름 · 열쇠 없음). + - 열쇠 = Node `stations.list`(화면 묶음 stations 단계 `buildStations`) 열쇠. + - 창구(`/calc`)는 `stations.sections`(서버 파일 경로 입력)를 안 받음. + - 등록부 R5 · R6 · R7 = node:stations.list · G4 = node:stations.sections(472-4 until 없음). + - 공유 DB · 실제 storage 안 씀(고정 자료 사본). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b2_stations_node.py -q +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import helper_472_1_paths as paths # noqa: E402 + +from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402 +from B05_Profile import B05_Profile_Engine_Sections_Node as node # noqa: E402 +from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 +from common_util.common_util_surface_sampler import ( # noqa: E402 + CallableSurfaceSampler, + build_surface_sampler, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def project(tmp_path_factory): + root = paths.stage_project(tmp_path_factory.mktemp("b2") / "project") + summary = paths.read("summary.json")["longitudinal"]["data"] + options = SectionGenerationOptions( + **{k: v for k, v in summary["options"].items() if k != "extra_stations"} + ) + polyline = sections._load_route_polyline(root, paths.ROUTE) + pipes = sections._load_pipe_points(root, polyline) + facilities = sections.station_facilities(root, pipes, polyline) + extras = sections.resolve_extra_stations( + root, facilities[0], structures=facilities[1], plus=facilities[2] + ) + return { + "root": root, + "options": options, + "polyline": polyline, + "pipes": pipes, + "facilities": facilities, + "extras": extras, + "sampler": build_surface_sampler(root / "B04_PreProcess/models", *paths.SURFACE), + "curves": sections._load_design_curves(root), + } + + +def _both(project, sampler=None, **change): + kw = { + "options": project["options"], + "grade_options": None, + "source_snapshot": {"filter": "classification"}, + "crs": "EPSG:5186", + "design_curves": project["curves"], + "station_snap": sections.load_station_snap(project["root"]), + "station_snap_step": sections.load_station_snap_step(project["root"]), + **change, + } + args = (project["polyline"], sampler or project["sampler"], project["pipes"], project["extras"]) + python, _ = sections.build_sections(*args, **kw) + noded, _ = sections.build_sections(*args, facilities=project["facilities"], **kw) + return python, noded + + +def _without_key(value): + if isinstance(value, dict): + return {k: _without_key(v) for k, v in value.items() if k != "key"} + if isinstance(value, list): + return [_without_key(v) for v in value] + return value + + +def _first_diff(a, b, path=""): + """첫 다른 자리(값 · 칸 차례) — 큰 글 비교를 pytest 에 안 넘김. 수 = 같은 값(0 ↔ 0.0).""" + if isinstance(a, dict) and isinstance(b, dict): + if list(a) != list(b): + return f"{path}: 칸 {list(a)} != {list(b)}" + return next((d for k in a if (d := _first_diff(a[k], b[k], f"{path}.{k}"))), None) + if isinstance(a, list) and isinstance(b, list): + if len(a) != len(b): + return f"{path}: 길이 {len(a)} != {len(b)}" + return next( + (d for i, (x, y) in enumerate(zip(a, b)) if (d := _first_diff(x, y, f"{path}[{i}]"))), + None, + ) + return None if a == b else f"{path}: {a!r} != {b!r}" + + +def _same(python, noded): + hit = _first_diff(python, _without_key(noded)) # 칸 차례 · 자릿수까지 + assert hit is None, hit + keys = [s.get("key") for s in noded["longitudinal"]["stations"]] + assert all(keys) and keys == [s["key"] for s in noded["cross_sections"]] + return keys + + +@pytest.mark.parametrize( + "case", + ["as_saved", "no_curves", "snap10", "interval10", "no_endpoint"], +) +def test_Node_길_파이썬_길_0차(project, case): + change = { + "as_saved": {}, + "no_curves": {"design_curves": []}, + "snap10": {"station_snap": True, "station_snap_step": 10.0}, + "interval10": {"options": replace(project["options"], station_interval_m=10.0)}, + "no_endpoint": {"options": replace(project["options"], include_endpoint=False)}, + }[case] + python, noded = _both(project, **change) + keys = _same(python, noded) + assert len(keys) == len(set(keys)) > 10 + if case == "as_saved": + assert len(keys) == 57 and keys[0] == "end:BP" and keys[-1] == "end:EP" + kinds = {s["kind"] for s in python["longitudinal"]["stations"]} + assert {"bp", "ep", "regular", "irregular"} <= kinds + assert any(k.startswith("pipe:") for k in keys) + + +def test_열쇠_Node_측점_목록과_같음(project): + _, noded = _both(project) + opts = project["options"] + stations = node.stations_input( + [[p[0], p[1]] for p in project["polyline"]], + *project["facilities"], + opts, + project["curves"], + sections.load_station_snap(project["root"]), + sections.load_station_snap_step(project["root"]), + ) + listed = node.node_stations(stations) + assert [s["key"] for s in listed] == [s["key"] for s in noded["longitudinal"]["stations"]] + assert [s["chainageM"] for s in listed] == [ + s["chainage_m"] for s in noded["longitudinal"]["stations"] + ] + + +def test_DTM_아닌_지표면_파이썬_길(project, monkeypatch): + called = [] + monkeypatch.setattr(runner, "run_calc_served", lambda *a: called.append(a)) + flat = CallableSurfaceSampler(lambda xy: np.full(len(xy), 100.0)) + python, noded = _both(project, sampler=flat) + assert called == [] and python == noded + assert "key" not in noded["longitudinal"]["stations"][0] + + +def test_Node_못_돌림_파이썬_길(project, monkeypatch): + monkeypatch.setattr(runner, "run_calc_served", lambda *a: None) + python, noded = _both(project) + assert python == noded + + +def test_창구는_서버_측점_생성_안_받음(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from B06_Section import B06_Section_Router_Calc as calc_router + + app = FastAPI() + app.include_router(calc_router.router) + http = TestClient(app) + url = "/api/projects/11111111-2222-3333-4444-555555555555/calc" + body = {"run": ["stations.sections"], "inputs": {"stations": {"grid": "C:/x"}}} + bad = http.post(url, json=body) + assert bad.status_code == 400 and "창구 밖" in bad.json()["message"] + assert runner.SERVER_ONLY_IDS <= runner.NODE_IDS + + +def test_등록부_측점_줄_Node(): + src = (ROOT / "A00_Common/design/logic/design_calc_registry.ts").read_text(encoding="utf-8") + for no, run in ( + ("R5", "stations.list"), + ("R6", "stations.list"), + ("R7", "stations.list"), + ("G4", "stations.sections"), + ): + row = src.split(f'no: "{no}"')[1].split("\n },")[0] + assert f'runner: "node:{run}"' in row and "until" not in row, no + + +def test_이월_열쇠_Node_출력_없으면_파이썬(project): + from B05_Profile.B05_Profile_Route_Save_Parts import keyed_stations, station_keys + + _, noded = _both(project) + stations = noded["longitudinal"]["stations"] + keyed = keyed_stations(stations) + pipes, structures, _ = project["facilities"] + assert keyed == station_keys(stations, pipes, structures, 20.0) # 고정 자료 = 같은 열쇠 + assert keyed_stations([{k: v for k, v in s.items() if k != "key"} for s in stations]) is None + assert keyed_stations([*stations, {"chainage_m": 1.0}]) is None # 섞이면(구조물 측점 덧) 파이썬 + route_save = (ROOT / "B05_Profile/B05_Profile_Route_Save.py").read_text(encoding="utf-8") + assert route_save.count("keyed_stations(") == 2 + + +def test_창구_측점_목록에_서버_정의_붙임(monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from B06_Section import B06_Section_Router_Calc as calc_router + + seen = [] + monkeypatch.setattr( + calc_router, "run_calc_served", lambda run, inputs: seen.append(inputs) or {"results": {}} + ) + app = FastAPI() + app.include_router(calc_router.router) + url = "/api/projects/11111111-2222-3333-4444-555555555555/calc" + body = {"run": ["stations.list"], "inputs": {"stations": {"line": [], "definition": {"x": 1}}}} + assert TestClient(app).post(url, json=body).status_code == 200 + definition = seen[0]["stations"]["definition"] + assert definition != {"x": 1} and definition["types"] # 브라우저가 보낸 정의 안 씀 + + +def test_판_stations_줄_열림_기본_both(): + from common_util import common_util_calc_sites as calc_sites + + group = next(g for g in calc_sites.GROUPS if g["key"] == "stations") + assert group["switchable"] and group["fixed"] is None + assert calc_sites.read_sites()["sites"]["stations"] == "both" + page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + assert "remote: stationsOnServer" in page + + +# ── 화면 — 계산 자리 stations 「서버」 측점 = 화면 셈 측점(검증_sub3 · Playwright · 저장 안 함) ── + +VERIFY = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" # 검증_sub3(OWNERS.md) + +JS_STATIONS = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stations.list'); + return { stations: JSON.parse(JSON.stringify(pipe.output('stations'))), + calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome) }; +}""" + + +@pytest.fixture(scope="module") +def screens(): + from test_511_b1_calc_node_stages import BASE, JS_DRAFT, _UP, _started + + if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): + pytest.skip("이 폴더 서버 없음") + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + for mode in ("both", "server"): + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post( + f"{BASE}/api/auth/login/request", data=login, timeout=60000 + ) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 stations = server + + def board(route): + body = route.fetch().json() + body["sites"]["stations"] = "server" + route.fulfill(json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots[mode] = {"saved": page.evaluate(JS_STATIONS)} + page.evaluate(JS_DRAFT) # 교각점 하나 옮김(세션 초안 · 창과 함께 버림) + page.wait_for_timeout(3000) + shots[mode]["draft"] = page.evaluate(JS_STATIONS) + context.close() + finally: + chrome.close() + (ROOT / "tmp" / "511b2_screens.json").write_text(json.dumps(shots), encoding="utf-8") + return shots + + +import json # noqa: E402 + + +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_화면_서버_측점_화면_셈과_같음(screens, case): + both, server = screens["both"][case], screens["server"][case] + assert both["runs"] == server["runs"] == ["done"] + assert len(both["stations"]) > 10 + hit = _first_diff(both["stations"], server["stations"]) + assert hit is None, hit + assert both["calls"] == [] and server["calls"] # server 판만 서버를 부름 diff --git a/resources/tester/test_511_b3_profile_node.py b/resources/tester/test_511_b3_profile_node.py new file mode 100644 index 000000000..c80f2720d --- /dev/null +++ b/resources/tester/test_511_b3_profile_node.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +"""511-B B-3 종단 짝 모으기 — 종단 다시 셈 · 첫 계획선 · 계획고 옮김 = 서버 Node(TS 한 벌). + +옛 파이썬 길(`build_alignment` · `straight_pass_through` — 2~4 순위 폴백 몫으로 남음)로 지은 +정본과 새 Node 길(`rebuild_alignment_profile` · `design_ground_following_profile` 겉 → +`profile.alignment` · `profile.base` → `alignment_record`)이 낸 정본의 **JSON 글이 같음** +(칸 · 차례 · 실수형 · −0.0 · 반올림). +사용자 R(측점 밖 곡선 시종점) · 측점 0.006 m 안 편집 키 · 법정 R 미달 경고 · 시종점 오프셋 포함. + +./venv/Scripts/python.exe -m pytest resources/tester/test_511_b3_profile_node.py -q +""" + +from __future__ import annotations + +import copy +import json +import math +import sys +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade import ( # noqa: E402 + ground_profile, + legal_plan_radius_min_m, # noqa: E402 + resolve_grade_options, +) +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, +) +from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, + infer_station_interval, + rebuild_alignment_profile, + straight_pass_through, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _ground(s: float, phase: float) -> float: + return 300.0 + 0.06 * s + 1.4 * math.sin(s / 37.0 + phase) + 0.2 * math.sin(s / 7.3) + + +def _longitudinal(phase: float, length: float = 400.0) -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": _ground(float(s), phase), "valid": True} + for s in np.arange(0.0, length + 1e-9, 1.0) + ] + marks = sorted({*np.arange(0.0, length + 1e-9, 20.0).tolist(), 133.37, 251.2, 318.06}) + stations = [ + {"station_id": f"s{index}", "chainage_m": float(value)} for index, value in enumerate(marks) + ] + return {"samples": samples, "stations": stations, "length_m": length, "design_profiles": []} + + +def _options(): + return resolve_grade_options("trunk", terrain_type="special", design_speed_kph=30) + + +def _old_ground_following(longitudinal: dict, options, edits: dict | None) -> dict: + """옛 1순위 몸통(B-3 전 `design_ground_following_profile`) — 파이썬 꼭짓점 · 파이썬 선형.""" + chainage, ground = ground_profile(longitudinal) + total = float(chainage[-1]) + stations = list(longitudinal["stations"]) + policy = AlignmentPolicy.from_config( + station_interval_m=infer_station_interval(stations), + max_grade_pct=options.max_grade_pct, + curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, + paved=options.paved, + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), + min_vertical_radius_m=options.min_vertical_radius_m, + min_curve_length_m=options.min_curve_length_m, + ) + node_s = sorted( + {round(float(row["chainage_m"]), 3) for row in stations if 0 <= row["chainage_m"] <= total} + ) + if node_s[-1] < total: + node_s.append(round(total, 3)) + base_s = np.array(node_s, dtype=np.float64) + base_z = np.interp(base_s, chainage, ground) + base_z[0] += options.start_elevation_offset_m + base_z[-1] += options.end_elevation_offset_m + base_z = straight_pass_through(base_s, base_z, policy) + return build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=stations, + policy=policy, + edits=dict(edits or {}), + ) + + +def _old_rebuild(longitudinal: dict, edits: dict | None) -> dict: + """옛 `rebuild_alignment_profile` 몸통 — 저장 꼭짓점 · 파이썬 선형.""" + stored = longitudinal["profile_alignment"] + chainage, ground = ground_profile(longitudinal) + base = stored["base_pvi"] + criteria = (longitudinal.get("design_profiles") or [{}])[0].get("criteria") or {} + policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) + policy = replace( + policy, + min_vertical_radius_m=policy.min_vertical_radius_m + or float(criteria.get("min_vertical_radius_m") or 0.0), + min_curve_length_m=policy.min_curve_length_m + or float(criteria.get("min_curve_length_m") or 0.0), + ) + return build_alignment( + base_s=np.array([float(n["chainage_m"]) for n in base], dtype=np.float64), + base_z=np.array([float(n["elevation_m"]) for n in base], dtype=np.float64), + chainage=chainage, + ground=ground, + stations=list(longitudinal["stations"]), + policy=policy, + edits=edits, + ) + + +def _text(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +EDITS = { + "station_offsets": {"60.000": 0.8, "133.370": -0.45, "251.204": 0.3, "300.000": None}, + "curve_radii": {"100.000": 350.0, "251.200": 77.7, "60.000": 1234.5}, +} + + +@pytest.mark.parametrize("phase", [0.0, 0.7, 2.1]) +def test_첫_계획선_옛_파이썬과_정본_글_같음(phase: float) -> None: + longitudinal = _longitudinal(phase) + options = _options() + old = _old_ground_following(longitudinal, options, None) + new, profile = design_ground_following_profile(longitudinal, options) + assert _text(new) == _text(old) + assert profile["samples"] == new["samples"] + + +@pytest.mark.parametrize("phase", [0.0, 0.7, 2.1]) +def test_다시_셈_옛_파이썬과_정본_글_같음(phase: float) -> None: + longitudinal = _longitudinal(phase) + alignment, profile = design_ground_following_profile(longitudinal, _options()) + longitudinal["profile_alignment"] = alignment + longitudinal["design_profiles"] = [profile] + for edits in (None, {}, copy.deepcopy(EDITS)): + old = _old_rebuild(longitudinal, copy.deepcopy(edits)) + new, entry = rebuild_alignment_profile(longitudinal, copy.deepcopy(edits)) + assert _text(new) == _text(old) + assert entry["summary"]["warnings"] == old["warnings"] + # 사용자 R 77.7 m = 측점 밖 곡선 시종점 · 법정 R 미달 경고(432) + assert any(abs(s["chainage_m"] * 1000 % 1) > 1e-6 for s in new["samples"]) + assert any("종단곡선" in text for text in new["warnings"]) + + +def test_시종점_오프셋_첫_계획선_같음() -> None: + longitudinal = _longitudinal(1.3) + options = replace(_options(), start_elevation_offset_m=0.6, end_elevation_offset_m=-0.4) + old = _old_ground_following(longitudinal, options, EDITS) + new, _profile = design_ground_following_profile(longitudinal, options, edits=EDITS) + assert _text(new) == _text(old) + + +def test_저장_선형_없으면_ValueError() -> None: + with pytest.raises(ValueError): + rebuild_alignment_profile(_longitudinal(0.0), {}) + + +def test_계획고_옮김_꼭짓점_세움_한_부름() -> None: + """노선 [저장] 몫 — 편집 옮김(`carryEdits`)과 꼭짓점 세움(`seatRouteMoved`)이 Node 한 부름 · + 편집만 부른 값(seat 없음)과 같은 편집.""" + old_xy = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]] + new_xy = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]] + longitudinal = _longitudinal(0.4) + alignment, _profile = design_ground_following_profile(longitudinal, _options()) + base = alignment["base_pvi"] + edits = {"station_offsets": {"60.000": 0.5, "120.000": -0.3}, "curve_radii": {"120.000": 400}} + stations = [row["chainage_m"] for row in longitudinal["stations"]] + seat = { + "ground_s": [row["chainage_m"] for row in alignment["samples"]], + "ground_z": [row["ground_elevation_m"] for row in alignment["samples"]], + "policy": AlignmentPolicy.from_dict(alignment["policy"]).as_dict(), + "base_pvi": base, + "born": [251.2], + } + moved, seated = carry_seat(old_xy, new_xy, base, edits, stations, 10.0, seat) + assert moved == carry_seat(old_xy, new_xy, base, edits, stations, 10.0)[0] + assert set(moved["station_offsets"]) == {"60.000", "120.000"} + assert moved["curve_radii"] == {"120.000": 400.0} + assert all(isinstance(v, float) for v in moved["station_offsets"].values()) + assert any(abs(node["chainage_m"] - 251.2) < 1e-6 for node in seated) diff --git a/resources/tester/test_511_b4_cross_save_mirror.py b/resources/tester/test_511_b4_cross_save_mirror.py new file mode 100644 index 000000000..aa77912ea --- /dev/null +++ b/resources/tester/test_511_b4_cross_save_mirror.py @@ -0,0 +1,251 @@ +"""PLAN 511-B B-4 (요구 320-4) — [저장] 횡단 한 바퀴 파이썬 길 = Node 길(`cross.enforce`) · 형상 얹기 Node. + +못박는 것 + - 가짜 지형(DTM · 빈칸 = 지표면 끝) · 관 지점(세월교 · 물넘이포장 둘 · 배관) · 구조물(포장 · 맹암거 · 소단 · + 사토장)로 파이썬 길(`_enforce_on_python` = 설계선 → 표본기 넓힘 → 포장 → 하강)과 Node 길 + (`_enforce_on_node` · TS `B06_Section_Cross_Save.enforceSavedCross`)의 측점 설계 · 넓힌 표본 0 차. + - 그 사이 넓힘 · 지표면 끝 · 포장 켬 · 끔 · 기울임 · 세월교 하강 · 소단 · 사용자 값 승계가 실제로 일어남. + - 구조물 정본을 못 읽으면 두 길 다 소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음. + - DTM 아닌 지표면(tin) = 격자 없음(파이썬 길) · 형상 얹기 = Node(`facility.attach`) 부름. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_save_mirror.py -q +""" + +from __future__ import annotations + +import copy +import json +import math +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_349_0n_cross_enforce_mirror import PIPES # noqa: E402 + +import B05_Profile.B05_Profile_Engine_Sections as sections_mod # noqa: E402 +import B05_Profile.B05_Profile_Structures_Repository as structures_repo # noqa: E402 +import B06_Section.B06_Section_Engine_Tilt as tilt_mod # noqa: E402 +import B06_Section.B06_Section_Router_Design_Ranges as ranges_mod # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + +SURFACE = {"method": "dtm", "source_filter": "classification", "smooth": False} +STRUCTURES = [ + StructureInstance( + structure_id="pv1", type_id="pavement_concrete", placement="interval", start_m=40, end_m=80 + ), + StructureInstance( + structure_id="ud1", + type_id="cross_underdrain", + placement="point", + chainage_m=160, + options={"culvert_slope_pct": 2, "culvert_height_m": 0.5}, + ), + StructureInstance( + structure_id="bm1", + type_id="berm", + placement="interval", + start_m=180, + end_m=230, + options={"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0}, + ), + StructureInstance( + structure_id="sp1", + type_id="spoil_bank", + placement="site", + chainage_m=335, + options={"capacity_m3": 300, "side": "자동(성토 쪽)"}, + ), +] +OFFSETS = [round(-20 + 0.5 * i, 1) for i in range(81)] + + +def _plan(chainage: float) -> float: + return round(100.5 + 0.6 * math.sin(chainage / 53) + 0.002 * chainage, 4) + + +def _npz(root: Path) -> None: + """DTM 격자 — y = 노선 왼쪽(+) · −42 m 아래 빈칸(지표면 끝) · x 180~240 은 −30 m 아래도.""" + models = root / "B04_PreProcess" / "models" + models.mkdir(parents=True) + x = np.arange(-10.0, 431.0, 1.0) + y = np.arange(-80.0, 81.0, 1.0) + xx, yy = np.meshgrid(x, y) + # 가로 기울기 0.68 ~ 0.78(반폭 20 m 에서 안 닫히는 측점 · 닫히는 거리 갈림 · 지표면 끝) + z = 100 + (0.73 + 0.05 * np.sin(xx / 40)) * yy + 0.8 * np.sin(xx / 37) + 0.3 * np.cos(yy / 3) + valid = ~((yy < -42) | ((xx >= 180) & (xx <= 240) & (yy < -30))) + np.savez(models / f"dtm_{SURFACE['source_filter']}.npz", x=x, y=y, z=z, valid_mask=valid) + + +def _sections(root: Path) -> tuple[dict, list[dict]]: + sampler = DtmGridSampler.from_npz( + root / "B04_PreProcess" / "models" / f"dtm_{SURFACE['source_filter']}.npz" + ) + chainages = sorted({20.0 * i for i in range(21)} | {p["chainage_m"] for p in PIPES}) + longitudinal = { + "design_profiles": [ + {"samples": [{"chainage_m": c, "elevation_m": _plan(c)} for c in chainages]} + ] + } + out = [] + for index, c in enumerate(chainages): + xy = np.array([[c, o] for o in OFFSETS]) + z, _valid = sampler.sample_xy(xy) + samples = [ + { + "offset_m": o, + "x": c, + "y": o, + "z": round(float(e), 6), + "elevation_m": round(float(e), 6), + "valid": True, + } + for o, e in zip(OFFSETS, z) + ] + # 저장분 = 옛 입력(포장 · 기울임 · 하강은 지금 시설과 다르게) + 사용자 값 + options = dict( + ground_type="soil" if index % 3 == 0 else "ripping_rock", + section_mode="left_cut" if index % 2 else "right_cut", + ditch_type="standard", + paved=index % 4 == 0, + standard=STANDARD_CROSS_SECTION, + surface_drop_m=0.0, + shoulder_extend_left_m=0.3 if index % 5 == 0 else 0.0, + ) + design = compute_cross_design(samples, _plan(c), **options) + design.update(status="provisional", pavement_suggested=False, structure_note="메모") + if index % 5 == 0: + design["shoulder_extend_left_m"] = 0.3 + if index % 7 == 0: + design["cut_slope_ratio_user"] = 0.8 + if index % 6 == 0: + design["berm"] = {"width_m": 1.5, "interval_m": 5.0, "slope_deg": 0.0, "depth_m": 2.0} + design["berm_depth_left_m"] = 0.5 + out.append( + { + "chainage_m": c, + "center_x": c, + "center_y": 0.0, + "frame": {"origin": {"x": c, "y": 0.0}, "left_xy": [0.0, 1.0]}, + "plan_radius_m": 60.0 if index == 4 else None, + "curve_outer_side": "left" if index == 4 else None, + "curve_widening_m": 0.5 if index == 4 else None, + "samples": samples, + "design": design, + } + ) + return longitudinal, out + + +def _facilities(monkeypatch, readable: bool = True) -> None: + pipes = parse_pipe_points(PIPES) + + def structures(_root): + if not readable: + raise OSError("구조물 정본 못 읽음(시험)") + return 0, STRUCTURES + + monkeypatch.setattr(sections_mod, "route_pipe_points", lambda *_a, **_k: pipes) + for module in (structures_repo, ranges_mod, tilt_mod): + monkeypatch.setattr(module, "load_structures", structures) + + +def _both(tmp_path: Path, monkeypatch, readable: bool = True): + root = tmp_path / "p" + _npz(root) + _facilities(monkeypatch, readable) + longitudinal, sections = _sections(root) + py, node = copy.deepcopy(sections), copy.deepcopy(sections) + prebuild._enforce_on_python(longitudinal, py, root, None, SURFACE) + grid = prebuild.design_grid_file(root, SURFACE) + assert grid is not None and grid.is_file() + assert prebuild._enforce_on_node(longitudinal, node, root, None, grid) + return sections, py, node + + +def _same(py: list[dict], node: list[dict]) -> None: + """값 0 차(수는 값으로 — Node 를 거친 저장분 칸은 5.0 이 5 로 옴).""" + for left, right in zip(py, node, strict=True): + assert left["samples"] == right["samples"], left["chainage_m"] + assert left["design"] == right["design"], left["chainage_m"] + + +def test_저장_횡단_한_바퀴_파이썬_길_Node_길_0차(tmp_path, monkeypatch): + before, py, node = _both(tmp_path, monkeypatch) + _same(py, node) + by = {s["chainage_m"]: s for s in node} + widened = [s["chainage_m"] for s in node if len(s["samples"]) > len(OFFSETS)] + assert len(widened) >= 5, widened # 넓힘이 실제로 돎 + assert any(s["design"].get("slope_unclosed") for s in node) # 지표면 끝까지 넓혀도 안 닫힘 + assert ( + by[60.0]["design"]["paved"] is True and by[100.0]["design"]["paved"] is False + ) # 포장 켬 · 끔 + assert by[160.0]["design"]["tilted"] is True # 맹암거 자리 기울임 + assert by[250.0]["design"]["paved"] is True and by[250.0]["design"]["tilted"] is True # 물넘이 + assert by[110.0]["design"]["surface_drop_m"] > 0 # 세월교 하강 + assert by[200.0]["design"]["berm"]["width_m"] == 1.0 # 소단 = 구조물 정본 구간 + assert ( + "berm" not in by[120.0]["design"] or by[120.0]["design"]["berm"] is None + ) # 구간 밖 = 없음 + assert all(s["design"]["structure_note"] == "메모" for s in node) # 사용자 값 승계 + assert by[0.0]["design"]["shoulder_extend_left_m"] == 0.3 + assert any(a["design"] != b["design"] for a, b in zip(before, node)) + + +def test_구조물_정본_못_읽음_소단_저장분_그대로(tmp_path, monkeypatch): + before, py, node = _both(tmp_path, monkeypatch, readable=False) + _same(py, node) + by = {s["chainage_m"]: s for s in node} + saved = {s["chainage_m"]: s for s in before} + assert by[0.0]["design"]["berm"] == saved[0.0]["design"]["berm"] # 저장분 소단 + assert by[60.0]["design"]["paved"] is False # 포장 구간 없음(물넘이 밖) + assert not by[160.0]["design"].get("tilted") # 맹암거 구간 없음(칸 없음) + + +def test_DTM_아닌_지표면은_격자_없음(tmp_path): + root = tmp_path / "p" + _npz(root) + assert prebuild.design_grid_file(root, {**SURFACE, "method": "tin"}) is None + assert prebuild.design_grid_file(root, {**SURFACE, "source_filter": "없음"}) is None + + +def test_형상_얹기는_Node(monkeypatch): + from B06_Section import B06_Section_Engine_Attach as attach_mod + from common_util import common_util_calc_runner as runner + + calls = [] + real = runner.run_calc + monkeypatch.setattr( + runner, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs) + ) + sections = [ + {"chainage_m": float(c), "center_x": float(c), "center_y": 0.0} for c in range(0, 401, 10) + ] + misses = attach_mod.attach_shapes(sections, parse_pipe_points(PIPES), STRUCTURES) + assert calls == [["facility.attach"]] + assert misses == {"outside": [], "far": []} + keys = {k for s in sections for k in s} - {"chainage_m", "center_x", "center_y"} + assert {"ford", "ford_pavement", "culvert", "pavement", "underdrain"} <= keys + # 파이썬 짝(Node 못 부를 때)과 같음 + py = [ + {"chainage_m": s["chainage_m"], "center_x": s["center_x"], "center_y": 0.0} + for s in sections + ] + attach_mod._attach_python(py, parse_pipe_points(PIPES), STRUCTURES) + assert json.loads(json.dumps(py)) == json.loads(json.dumps(sections)) diff --git a/resources/tester/test_511_b4_cross_server_browser.py b/resources/tester/test_511_b4_cross_server_browser.py new file mode 100644 index 000000000..b2fa3c5d3 --- /dev/null +++ b/resources/tester/test_511_b4_cross_server_browser.py @@ -0,0 +1,128 @@ +"""PLAN 511-B B-4 (요구 320-4) — 계산 자리 cross = server 화면 길(검증_sub2 · 이 폴더 서버 · Playwright). + +못박는 것 + - 판 both 와 server(판 응답만 바꿈 · 판 파일 안 씀)에서 페이지 묶음 cross 출력 · 저장 상세 설계(구조물 폐회로 + 면적 층까지)가 0 차 — 저장 노선(`cross.refresh` · 다시 셈 창구) · 노선 초안 둔 채 다시 열기(`stage.cross`). + - server 일 때만 그 id 를 서버에 묻고 · both 면 /calc 0. + - 왕복 시간(`__aisloCalcServer.calls`)을 남김(PLAN 시간 줄). +저장 안 함 · 세션만(노선 초안은 창과 함께 버림). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_server_browser.py -q +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_511_b1_calc_node_stages import BASE, JS_DRAFT, PROJECT, _diff, screen # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) +OUT = ROOT / "tmp" / "511b4_capture.json" + +# 묶음 cross 출력 · 저장 상세 설계(면적 · 구조물 층) · 서버 부름 기록 +JS_STATE = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const data = await mod(/design_page_data\.ts/); + const routeId = data.pageRouteId(pid); + const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); + const shown = page.pipelineDetail(pid) ?? detail; + const calls = (window.__aisloCalcServer?.calls ?? []).map((c) => ({ + id: c.id, roundMs: c.roundMs, nodeMs: c.nodeMs, wallMs: c.wallMs, bytes: c.bytes, + })); + return JSON.parse(JSON.stringify({ + cross: pipe.output('cross'), + designs: (shown?.cross_sections ?? []).map((s) => ({ + chainage_m: s.chainage_m, design: s.design, samples: (s.samples ?? []).length, + })), + calls, + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + })); +}""" + + +def _open(play, cred: dict, cross: str) -> tuple: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + assert context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + + def board(route): # 판 응답만 바꿈(판 파일 안 씀) + response = route.fetch() + body = response.json() + body["sites"]["cross"] = cross + route.fulfill(response=response, json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + return chrome, page + + +def _shot(page) -> dict: + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + return page.evaluate(JS_STATE) + + +@pytest.fixture(scope="module") +def shots(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + out = {} + with sync.sync_playwright() as play: + for cross in ("both", "server"): + chrome, page = _open(play, cred, cross) + try: + out[f"saved_{cross}"] = _shot(page) + page.evaluate(JS_DRAFT) # 노선 초안(가운데 교각점 옮김 · 세션만) + page.wait_for_timeout(3000) + page.reload(wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + out[f"draft_{cross}"] = page.evaluate(JS_STATE) + finally: + chrome.close() # 세션 초안은 창과 함께 버림 + OUT.write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") + return out + + +@screen +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_cross_server_화면_값_브라우저와_0차(shots, case): + both, server = shots[f"{case}_both"], shots[f"{case}_server"] + assert both["runs"] == ["done"] and server["runs"] == ["done"], (both["runs"], server["runs"]) + assert both["cross"]["byKey"], "cross 출력 없음" + hit = _diff(server["cross"], both["cross"], "cross") + assert hit is None, hit + hit = _diff(server["designs"], both["designs"], "designs") + assert hit is None, hit + # 부름 — both = /calc 없음 · server = 그 길 id + assert not [c for c in both["calls"] if "cross" in c["id"]] + want = "cross.refresh" if case == "saved" else "stage.cross" + assert any(c["id"] == want for c in server["calls"]), server["calls"] diff --git a/resources/tester/test_511_b5_route_node.py b/resources/tester/test_511_b5_route_node.py new file mode 100644 index 000000000..cbcc3b58a --- /dev/null +++ b/resources/tester/test_511_b5_route_node.py @@ -0,0 +1,519 @@ +"""PLAN 511-B B-5 (요구 320-4) — 노선 · 따라감 서버 몫 = 화면과 같은 TS(늘 떠 있는 Node). + +못박는 것 + - 노선 저장 폴리라인 파일(선 csv · 노드 csv · 곡선표 + 편집표) — Node `route.polyline` 길 = + 파이썬 길 바이트 같음(거울 CASES · 위반 글 딱 반 R 12.25 · 12.75 · 겹친 점 · 짧은 칸 · 고정 · + 고정 길이 · 알려진 차 `KNOWN_ARC` = 직각 원호 점 ±1) · + 검증_sub3 저장 노선(노드 · 편집표) 다시 씀 = 저장된 곡선표 · 노드 그대로. + - 저장 좌표 맞춤(F1) — Node `facility.follow` + fill = 파이썬 짝 + `_fill_structure_coordinates_py`(좌표 없음 · 측점 낳음 · 낡은 좌표 · 옛 좌표 지킴 · 자리 바뀜 · + site · 읽을 때 · 노선 밖). + - 남은 측점 계획(F3) — Node `stations.plan` = 파이썬 `plan_stations`(322-3 CASES · 묶음마다). + - 공유 DB 안 씀 · storage 읽기만(검증_sub3 · 없으면 건너뜀) · 쓰기는 tmp. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b5_route_node.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for folder in (ROOT, HERE): + if str(folder) not in sys.path: + sys.path.insert(0, str(folder)) + +from B05_Profile import B05_Profile_Route_Polyline_Write as writer # noqa: E402 +from common_util import common_util_route_follow as follow # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + +SUB3 = ( + ROOT / "storage" / "1" / "3" / "12953f5a-5ba1-42ad-a427-8ac857a58bc2" / "B05_Profile" / "route" +) +FIXTURE = HERE / "fixtures" / "golden_472" +NAMES = ("planned_route.csv", "planned_route_nodes.csv", "planned_route_curves.json") +# 알려진 차(브레인 승인 · 511-B B-5) — 교각 딱 5° 배수(합성 직각)면 원호 칸 수가 libm 끝자리로 갈림 +# (파이썬 89.99999999999992° → 17 칸 · Node 90.0° → 18 칸) · 원호 점 ±1 · 실데이터 저장 노선 0 차 +KNOWN_ARC = { + "직각 한 번 · R 자동", + "R 못박음 30m", + "R 이 접선 자리보다 커서 눌림", + "겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함", + "딱 반 R(위반 글 짝수 쪽)", +} + + +def _files(folder: Path, monkeypatch, node: bool, args: dict) -> tuple[dict, dict]: + folder.mkdir(parents=True, exist_ok=True) + with monkeypatch.context() as patch: + if not node: + patch.setattr(writer, "node_polyline", lambda *a: None) + else: # Node 못 돌리면 파이썬으로 새는 것을 막음 + made = writer.node_polyline + patch.setattr(writer, "node_polyline", lambda *a: made(*a) or pytest.fail("Node 없음")) + summary = writer._write_planned_polyline(folder / NAMES[0], simplify=False, **args) + return summary, {name: (folder / name).read_bytes() for name in NAMES} + + +def _cases() -> list[tuple[str, dict]]: + import test_route_polyline_browser_mirror as mirror + + out = [ + ( + name, + { + "points": points, + "radius_m": mirror.MIN_RADIUS_M, + "curve_flags": flags, + "radii": radii, + }, + ) + for name, points, flags, radii in mirror.CASES + ] + corner = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)] + out += [ + ( + "딱 반 R(위반 글 짝수 쪽)", + { + "points": corner, + "radius_m": 15.0, + "curve_flags": [True] * 4, + "radii": [None, 12.25, 12.75, None], + }, + ), + ( + "겹친 점 · 짧은 칸 · 고정 · 고정 길이", + { + "points": [(0.0, 0.0), (80.0, 0.0), (80.3, 0.1), (120.0, 60.0), (200.0, 60.0)], + "radius_m": 12.0, + "curve_flags": [True, True, True, False], + "radii": [None, 30.0], + "locks": [None, "radius", "arc", "bad", None], + "arcs": [None, None, 20.5, None], + }, + ), + ( + "점 둘", + { + "points": [(0.0, 0.0), (10.0, 0.0)], + "radius_m": 12.0, + "curve_flags": [True, True], + "radii": [None, None], + }, + ), + ] + return out + + +@pytest.mark.parametrize("name,args", _cases(), ids=[name for name, _ in _cases()]) +def test_노선_폴리라인_Node_파이썬_바이트_같음(tmp_path, monkeypatch, name, args): + (py_sum, py), (node_sum, node) = ( + _files(tmp_path / mode, monkeypatch, mode == "node", args) for mode in ("py", "node") + ) + if name in KNOWN_ARC and node[NAMES[0]] != py[NAMES[0]]: + # 알려진 차 — 선 csv 원호 점 수만 ±1 · 노드 · 곡선표 · 편집표는 같음 + assert abs(node_sum["vertices"] - py_sum["vertices"]) == 1, name + node, py = ({k: v for k, v in f.items() if k != NAMES[0]} for f in (node, py)) + node_sum, py_sum = ({**s, "vertices": 0} for s in (node_sum, py_sum)) + assert (node_sum, node) == (py_sum, py), name + + +def test_딱_반_R_위반_글은_파이썬_꼴(tmp_path, monkeypatch): + args = dict(_cases())["딱 반 R(위반 글 짝수 쪽)"] + _, files = _files(tmp_path, monkeypatch, True, args) + text = files[NAMES[2]].decode("utf-8") + assert "최소곡선반지름 미달(12.2 < 15.0m)" in text and "(12.8 < 15.0m)" in text + + +@pytest.mark.skipif(not (SUB3 / "planned_route_nodes.csv").is_file(), reason="검증_sub3 노선 없음") +def test_검증_sub3_저장_노선_다시_씀_같음(tmp_path, monkeypatch): + from common_util.common_util_route_geometry import read_planned_route_csv + + table = json.loads((SUB3 / NAMES[2]).read_text(encoding="utf-8")) + nodes = [(v.x, v.y) for v in read_planned_route_csv(SUB3 / NAMES[1]).vertices] + edits = table["edits"] + args = { + "points": nodes, + "radius_m": table["min_radius_m"], + "curve_flags": [e["curve"] for e in edits], + "radii": [e["radius_m"] for e in edits], + "locks": [e["lock"] for e in edits], + "arcs": [e["arc_m"] for e in edits], + "station_snap": table.get("station_snap"), + "station_snap_step": table.get("station_snap_step"), + } + py = _files(tmp_path / "py", monkeypatch, False, args) + node = _files(tmp_path / "node", monkeypatch, True, args) + assert node == py + again = json.loads(node[1][NAMES[2]]) + assert again["curves"] == table["curves"] and again["edits"] == edits + assert node[1][NAMES[1]] == (SUB3 / NAMES[1]).read_bytes() + + +def _line() -> list[list[float]]: + plan = json.loads((FIXTURE / "plan.json").read_text(encoding="utf-8")) + return plan["planned"] + + +def _items(line: list[list[float]]) -> tuple[list[SimpleNamespace], dict]: + on = follow.LineString(line) + + def at(m: float) -> tuple[float, float]: + p = on.interpolate(m) + return round(p.x, 4), round(p.y, 4) + + def s(sid, chainage, x=None, y=None, placement="point", **span): + return SimpleNamespace( + structure_id=sid, + placement=placement, + chainage_m=chainage, + start_m=span.get("start_m"), + end_m=span.get("end_m"), + x=x, + y=y, + ) + + hold = at(150.0) + items = [ + s("bare", 120.0), + s("holds", 150.0, *hold), + s("stale", 210.0, *at(180.0)), + s("kept", 260.0, 1.0, 2.0), + s("moved", 300.0, 3.0, 4.0), + s("site", 340.0, *at(341.3), placement="site"), + s("span", 380.0, start_m=370.0, end_m=395.0), + s("none", None, 5.0, 6.0), + s("beyond", 99999.0), + s("prev-bare", 420.0), + ] + previous = { + "kept": s("kept", 260.0, 208000.5, 367800.25), + "moved": s("moved", 290.0, 9.0, 9.0), + "prev-bare": s("prev-bare", 420.0, 7.0, 8.0), + } + return items, previous + + +@pytest.mark.parametrize("only_missing", [False, True]) +def test_좌표_맞춤_Node_파이썬_같음(only_missing): + line = _line() + results = [] + for fill in (follow.fill_structure_coordinates, follow._fill_structure_coordinates_py): + items, previous = _items(line) + count = fill(items, line, previous=previous, only_missing=only_missing) + results.append((count, [(item.x, item.y) for item in items])) + assert results[0] == results[1] + assert results[0][0] > 0 + + +def test_좌표_맞춤_Node_길이_실제로_탐(monkeypatch): + called = [] + real = follow.run_calc + monkeypatch.setattr(follow, "run_calc", lambda *a: called.append(a[0]) or real(*a)) + items, previous = _items(_line()) + follow.fill_structure_coordinates(items, _line(), previous=previous) + assert called == [["facility.follow"]] + + +def test_측점_계획_Node_파이썬_같음(): + import test_322_3_plan_mirror as plan_mirror + + from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations + from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + from common_util.common_util_drainage_pipes import parse_pipe_points + from common_util.common_util_structure_station import plan_stations + + groups = [ + ( + parse_pipe_points(case["pipes"]), + [StructureInstance.model_validate(s) for s in case["structures"]], + list(case.get("plus", ())), + ) + for case in plan_mirror.CASES.values() + ] + node = node_plan_stations(groups) + assert node is not None + assert node == [plan_stations(*group) for group in groups] + + +# ── 화면 — 계산 자리 route 「서버」 노선 = 화면 셈 노선(검증_sub3 · Playwright · 저장 안 함) ── + +VERIFY = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" # 검증_sub3(OWNERS.md) + +JS_ROUTE = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'route.polyline'); + const plain = (v) => JSON.parse(JSON.stringify(v ?? null)); + return { route: plain(pipe.output('route')), stations: plain(pipe.output('stations')), + calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, same: c.same })), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome) }; +}""" + + +@pytest.fixture(scope="module") +def screens(): + from test_511_b1_calc_node_stages import _UP, BASE, JS_DRAFT, _started + + if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): + pytest.skip("이 폴더 서버 없음") + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + for mode in ("both", "server"): + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post( + f"{BASE}/api/auth/login/request", data=login, timeout=60000 + ) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 route = server + + def board(route): + body = route.fetch().json() + body["sites"]["route"] = "server" + route.fulfill(json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + shots[mode] = {"saved": page.evaluate(JS_ROUTE)} + page.evaluate(JS_DRAFT) # 교각점 하나 옮김(세션 초안 · 창과 함께 버림) + page.wait_for_timeout(3000) + shots[mode]["draft"] = page.evaluate(JS_ROUTE) + context.close() + finally: + chrome.close() + (ROOT / "tmp" / "511b5_screens.json").write_text(json.dumps(shots), encoding="utf-8") + return shots + + +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_화면_서버_노선_화면_셈과_같음(screens, case): + both, server = screens["both"][case], screens["server"][case] + assert both["runs"] == server["runs"] == ["done"] + assert len(both["route"]["line"]) > 10 + assert both["route"] == server["route"] and both["stations"] == server["stations"] + assert both["calls"] == [] + # 저장 노선(초안 없음) = 서버 안 부름 · 초안 = server 판만 서버 노선 + assert bool(server["calls"]) is (case == "draft") + + +# ── follow 「서버」 — 창구 맥락 · Node route · follow 두 단계 ───────────────────────── + + +def test_창구_follow_맥락_브라우저_정본칸_버림(monkeypatch): + from B06_Section import B06_Section_Router_Calc_Context as ctx + + async def context(_pid): + return {"plan": {"planned": "서버"}, "saved_pipes": ["서버"], "longitudinal": None} + + monkeypatch.setattr(ctx, "follow_context", context) + inputs = { + "stages": { + "until": "follow", + "input": {"route": 1}, + "saved_edits": None, + "plan": {"planned": "브라우저"}, + "saved_pipes": ["브라우저"], + "grid": {"path": "C:/x"}, + "detail": {"x": 1}, + } + } + assert asyncio.run(ctx.attach_follow_context("p", inputs)) is None + assert inputs["stages"] == { + "until": "follow", + "input": {"route": 1}, + "saved_edits": None, + "plan": {"planned": "서버"}, + "saved_pipes": ["서버"], + "longitudinal": None, + } + other = {"stages": {"plan": "브라우저"}} # until 없음 = 그대로(B-1 단계 길) + assert asyncio.run(ctx.attach_follow_context("p", other)) is None and other["stages"] == { + "plan": "브라우저" + } + + +def _golden_stages() -> dict: + from common_util.common_util_surface_sampler import dtm_grid_bytes + + def fixture(name): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + plan = fixture("plan.json") + nodes = [[n["x"], n["y"]] for n in plan["nodes"]] + at = len(nodes) // 2 + nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4] # 교각점 하나 옮긴 노선 초안 + draft = { + "nodes": nodes, + "curveOn": [n["radius_m"] is not None for n in plan["nodes"]], + "curveRadius": [None] * len(nodes), + "curveLock": [None] * len(nodes), + "curveArc": [None] * len(nodes), + "stationSnap": bool(plan.get("station_snap")), + "stationSnapStep": plan.get("station_snap_step"), + } + grid = ROOT / "tmp" / "511b5_grid.bin" + if not grid.is_file(): + grid.write_bytes( + dtm_grid_bytes(FIXTURE / "project/B04_PreProcess/models/dtm_classification_smooth.npz") + ) + return { + "plan": plan, + "detail": fixture("detail.json"), + "types": fixture("structure_types.json")["types"], + "saved_pipes": [ + {k: v for k, v in p.items() if k != "lonlat"} + for p in fixture("pipes.json")["pipe_points"] + ], + "saved_structures": fixture("structures.json")["structures"], + "interval_m": 20, + "grid": {"path": str(grid), "design": "las"}, + "input": {"route": draft, "plusStations": fixture("plus_stations.json")["chainages"]}, + } + + +def test_Node_follow_두_단계_묶음_첫_바퀴와_같음(): + from common_util import common_util_calc_runner as runner + + stages = _golden_stages() + full = runner.run_calc_served(["stage.route", "stage.follow"], {"stages": stages}) + only = runner.run_calc_served( + ["stage.route", "stage.follow"], {"stages": {**stages, "until": "follow", "grid": None}} + ) + assert full and only and "error" not in full and "error" not in only, (full, only) + assert only["results"]["stage.route"] == full["results"]["stage.route"] + got = only["results"]["stage.follow"] + assert got["patch"] == full["results"]["stage.follow"] + assert got["patch"]["pipes"] and got["followed"] # 관 따라감 · 따라가기만 한 자리 곁 자료 + + +# ── 화면 — follow 「서버」 끌기 여러 걸음 뒤 놓음 = 화면 셈(검증_sub3 · Playwright · 저장 안 함) ── + +JS_DRAG = r"""async (steps) => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => import(urls.find((n) => re.test(n))); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) + .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); + const bridge = page.routeDraftBridge(pid, null); + const at = Math.floor(flat.planned.length / 2); + for (let k = 1; k <= steps; k += 1) { // 끌기 걸음 — 걸음마다 묶음이 돎(잠잠 안 기다림) + const nodes = flat.planned.map(([x, y]) => [x, y]); + nodes[at] = [nodes[at][0] + 2 * k, nodes[at][1] + 1.5 * k]; + bridge.edited({ + nodes, + curveOn: flat.curveOn.map((on) => on !== false), + curveRadius: flat.curveRadius.map((r) => r ?? null), + curveLock: flat.curveLock ?? nodes.map(() => null), + curveArc: flat.curveArc ?? nodes.map(() => null), + stationSnap: plan.station_snap ?? false, + stationSnapStep: plan.station_snap_step ?? null, + }); + await new Promise((r) => setTimeout(r, 60)); + } + await page.pagePipeline(pid).settled(); +}""" + +JS_FOLLOW = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const plain = (v) => JSON.parse(JSON.stringify(v ?? null)); + const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stage.follow'); + const planline = pipe.output('planline'); + return { + follow: plain(pipe.output('follow')), + inputs: plain(['pipes', 'structures', 'plusStations', 'alignment'] + .map((k) => [k, pipe.input(k)])), + base_pvi: plain(planline?.basePvi), pvi: plain(pipe.output('profile')?.pvi), + calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + }; +}""" + + +@pytest.fixture(scope="module") +def drags(): + from test_511_b1_calc_node_stages import _UP, BASE, _started + + if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): + pytest.skip("이 폴더 서버 없음") + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + for mode in ("both", "server"): + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post( + f"{BASE}/api/auth/login/request", data=login, timeout=60000 + ) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 follow = server + + def board(route): + body = route.fetch().json() + body["sites"]["follow"] = "server" + route.fulfill(json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + page.evaluate(JS_DRAG, 4) # 끌기 네 걸음(세션 초안 · 창과 함께 버림) + page.wait_for_timeout(4000) # 잠잠 400 ms · 서버 · 다시 돎 + shots[mode] = page.evaluate(JS_FOLLOW) + context.close() + finally: + chrome.close() + (ROOT / "tmp" / "511b5_drags.json").write_text(json.dumps(shots), encoding="utf-8") + return shots + + +def test_화면_follow_서버_끌기_뒤_화면_셈과_같음(drags): + both, server = drags["both"], drags["server"] + assert both["runs"] == server["runs"] == ["done"] + assert both["calls"] == [] and server["calls"] # server 판만 놓은 뒤 서버 follow + # 계획고 옮김 = 끌기 중 화면 셈이 입력에 옮겨 둔 값 그대로(서버 follow 는 저장분에서 새로 셈 · 옮김 + # 기준(앞 노선 · 앞 첫 계획선) 없음 → patch 에 alignment 안 냄) — 입력 · 종단은 같아야 함 + drop = lambda patch: {k: v for k, v in patch.items() if k != "alignment"} # noqa: E731 + assert drop(server["follow"]) == drop(both["follow"]) + for key in ("inputs", "base_pvi", "pvi"): + assert server[key] == both[key], key diff --git a/resources/tester/test_511_b_calc_path_guard.py b/resources/tester/test_511_b_calc_path_guard.py new file mode 100644 index 000000000..d4658e56a --- /dev/null +++ b/resources/tester/test_511_b_calc_path_guard.py @@ -0,0 +1,157 @@ +"""PLAN 511-B (요구 320-4) — 계산 창구 `/calc` 가 브라우저 파일 경로를 안 씀. + +못박는 것 + - 단계 격자 경로 칸(`stages.grid.path`) · 측점 격자 칸(`stations.grid`) = 400 · Node 안 부름. + - 격자 = `{model_id, smooth, design}` → 서버가 그 프로젝트 storage 안 DTM 격자 경로로 갈음. + - `dtm_grid_npz` — 다른 프로젝트 모델 · storage 밖 상대 경로(../) · 절대 경로 = LookupError. + - 공유 DB 안 씀(가짜 풀 · 가짜 연결) · 실제 storage 안 씀(tmp_path). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b_calc_path_guard.py -q +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Router_Terrain as terrain # noqa: E402 +from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402 +from common_util import common_util_auth as auth # noqa: E402 +from common_util import common_util_surface_sampler as sampler # noqa: E402 + +PROJECT = "11111111-2222-3333-4444-555555555555" +OTHER = "99999999-2222-3333-4444-555555555555" + + +class _Cursor: + rows: dict = {} + + async def execute(self, sql, args=None): + self.args = args + + async def fetchone(self): + if self.args == (PROJECT,): + return (1,) # 권한 — 회사 1 + return self.rows.get(self.args) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Conn(_Cursor): + def cursor(self): + return _Cursor() + + async def __aenter__(self): + return self + + +class _Pool: + def acquire(self): + return _Conn() + + +@pytest.fixture +def storage(tmp_path, monkeypatch): + """프로젝트 둘의 storage(tmp) — 모델 행 = (id, project) → (종류, 상대 경로).""" + roots = {PROJECT: tmp_path / "p1", OTHER: tmp_path / "p2"} + for root in roots.values(): + (root / "models").mkdir(parents=True) + (roots[PROJECT] / "models" / "dtm_las.npz").write_bytes(b"x") + (roots[OTHER] / "models" / "dtm_las.npz").write_bytes(b"x") + + async def stored(_conn, project_id): + return str(project_id) + + monkeypatch.setattr(terrain, "get_project_storage_relative_path", stored) + monkeypatch.setattr(terrain, "resolve_stored_project_path", lambda pid: str(roots[pid])) + _Cursor.rows = { + (7, PROJECT): ("dtm", "models/dtm_las.npz"), + (8, OTHER): ("dtm", "models/dtm_las.npz"), + (9, PROJECT): ("dtm", "../p2/models/dtm_las.npz"), + (10, PROJECT): ("dtm", str(roots[OTHER] / "models" / "dtm_las.npz")), + } + return roots + + +def _npz(model_id: int, project: str = PROJECT) -> Path: + from uuid import UUID + + return asyncio.run(terrain.dtm_grid_npz(_Conn(), UUID(project), model_id, False)) + + +def test_dtm_grid_npz_storage_안만(storage): + assert _npz(7) == (storage[PROJECT] / "models" / "dtm_las.npz").resolve() + for model_id in (8, 9, 10): # 다른 프로젝트 모델 · ../ · 절대 경로 + with pytest.raises(LookupError): + _npz(model_id) + + +@pytest.fixture +def client(monkeypatch, storage): + monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(calc_router, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(sampler, "dtm_grid_file", lambda npz: Path(npz).with_suffix(".bin")) + seen: list = [] + + def fake_run(run, inputs): + seen.append(inputs) + return {"results": {}, "ms": 0} + + monkeypatch.setattr(calc_router, "run_calc_served", fake_run) + app = FastAPI() + app.include_router( + calc_router.router, + dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)], + ) + app.dependency_overrides[auth.verify_session] = lambda: { + "role": "USER", + "company_id": 1, + "user_id": 7, + } + return TestClient(app), seen + + +def _post(http, inputs, run=("stage.route",)): + return http.post(f"/api/projects/{PROJECT}/calc", json={"run": list(run), "inputs": inputs}) + + +def test_경로_칸_거절(client, storage): + http, seen = client + other = str(storage[OTHER] / "models" / "dtm_las.bin") + bad = [ + {"stages": {"grid": {"path": other, "design": "las"}}}, + {"stages": {"grid": {"path": "../../p2/models/dtm_las.bin"}}}, + {"stages": {"grid": {"model_id": 7, "path": other}}}, + {"stages": {"grid": "C:/Windows/win.ini"}}, + {"stages": {"grid": {"model_id": "7"}}}, + {"stages": {"grid": {"model_id": 7, "design": "../x"}}}, + {"stations": {"grid": other, "line": []}}, + ] + for inputs in bad: + res = _post(http, inputs, ("stations.list",) if "stations" in inputs else ("stage.route",)) + assert res.status_code == 400, inputs + for model_id in (8, 9, 10): # 다른 프로젝트 · ../ · 절대 경로 모델 = 거절 + assert _post(http, {"stages": {"grid": {"model_id": model_id}}}).status_code == 400 + assert seen == [] # Node 안 부름 + + +def test_격자_서버가_정함(client, storage): + http, seen = client + res = _post(http, {"stages": {"grid": {"model_id": 7, "smooth": False, "design": "sheet"}}}) + assert res.status_code == 200, res.text + want = (storage[PROJECT] / "models" / "dtm_las.bin").resolve() + assert seen[-1]["stages"]["grid"] == {"path": str(want), "design": "sheet"} + assert _post(http, {"stages": {"grid": None}}).status_code == 200 # 격자 없음 = 그대로 diff --git a/resources/tester/test_511_c_haul_browser.py b/resources/tester/test_511_c_haul_browser.py new file mode 100644 index 000000000..0c295546a --- /dev/null +++ b/resources/tester/test_511_c_haul_browser.py @@ -0,0 +1,157 @@ +"""PLAN 511-C 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. + +- 판 haul 기본(server) = B05 계획고 ▲ 뒤 /haul-plan 요청 · 배분 모듈 안 받음(첫 묶음 밖) +- 판 haul both = /haul-plan 0 · 배분 모듈을 그때 받아 셈 · 유토곡선(배분 balloon 포함) 그림 + = server 판과 같음 +- 계산 자리 창 = E2 열린 줄(기본 꺼짐) · 서버만 G3 D1 D2 N1 줄마다 다른 까닭 +- 저장 안 함(세션 초안만) · 판 = 파일 바이트 되돌림 · 시험 브라우저 표식 시스템 로그 행만 지움 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_c_haul_browser.py -q -s +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 +from test_511_a_calc_sites_browser import ( # noqa: E402, F401 + _UP, + JS_GRADE, # noqa: E402 + _started, +) + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) +MARK = "AisloTest-511-C" +CARD = "계산 자리" + +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +# 그림 글 — 그릴 때마다 새로 짓는 clipPath 이름만 한 글자로(값 아님) +JS_CHART = r"""() => { + const chart = document.querySelector('.b05-profile__masshaul-chart'); + return chart ? chart.innerHTML.replace(/mass-clip-[a-z0-9]+/g, 'mass-clip') : null; +}""" +JS_LOADED = r"""() => performance.getEntriesByType('resource').map((e) => e.name) + .some((n) => /common_util_mass_haul_balance\.ts/.test(n))""" + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + committed = BOARD.read_bytes() + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + try: + yield context + finally: + chrome.close() + finally: + BOARD.write_bytes(committed) + _drop_test_logs() + + +def _drop_test_logs() -> None: + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) + await connection.commit() + finally: + await close_db_pool() + + asyncio.run(run()) + + +def _board(haul: str | None) -> None: + board = json.loads(BOARD.read_text("utf-8")) + board["sites"].pop("haul", None) + if haul: + board["sites"]["haul"] = haul + BOARD.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _run(context) -> dict: + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + posts: list[dict] = [] + + def on_response(res): + if res.url.endswith("/haul-plan") and res.request.method == "POST": + posts.append(res.json()) + + page.on("response", on_response) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + page.click(".ui-workflow-overlay__handle--bottom") # 종단 펼침 + page.wait_for_timeout(2000) + if page.evaluate(JS_CHART) is None: + page.click(".b05-profile__masshaul-handle") # 유토곡선 펼침 + page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) — 새 누가토량 → 배분 다시 + page.wait_for_timeout(5000) + out = {"posts": posts, "chart": page.evaluate(JS_CHART), "loaded": page.evaluate(JS_LOADED)} + page.close() + return out + + +def test_haul_server_both_그림_같음(ctx): + _board(None) # 판 파일에 haul 없음 = 기본 server + server = _run(ctx) + _board("both") + both = _run(ctx) + _board(None) + assert server["posts"] and all(p["haul_plan"] for p in server["posts"]) + assert any(p["haul_plan"]["transfers"] for p in server["posts"]) + assert not server["loaded"] # 기본값 = 배분 모듈 안 받음 + assert both["posts"] == [] and both["loaded"] + assert server["chart"] and "balloon" in server["chart"] + assert both["chart"] == server["chart"] + print("posts", len(server["posts"]), "chart bytes", len(server["chart"])) + + +def test_계산_자리_창_haul_엶_서버만_까닭(ctx): + page = ctx.new_page() + page.goto(f"{BASE}/#/b01-account", wait_until="domcontentloaded") + page.wait_for_selector(".b01-dashboard__setting-card", timeout=60000) + page.get_by_text(CARD, exact=True).click() + page.wait_for_selector(".b01-dashboard__calc-sites", timeout=30000) + rows = page.eval_on_selector_all( + ".b01-dashboard__calc-sites tbody tr", + "(trs) => trs.map((tr) => ({ name: tr.cells[0].textContent.trim()," + " why: tr.cells[3].textContent.trim()," + " boxes: [...tr.querySelectorAll('input')].map((i) => [i.checked, i.disabled]) }))", + ) + page.close() + calcs = {row["name"].split(" · ")[0]: row for row in rows if " · " in row["name"]} + assert calcs["E2"]["boxes"] == [[False, False], [True, True]] # 열림 · 기본 server(꺼짐) + whys = {no: calcs[no]["why"] for no in ("G3", "D1", "D2", "N1")} + for no, word in {"G3": "DB", "D1": "래스터", "D2": "D1", "N1": "지형"}.items(): + assert word in whys[no] and calcs[no]["boxes"][0] == [False, True], (no, whys[no]) + assert len(set(whys.values())) == 4 + print(whys) diff --git a/resources/tester/test_511_c_haul_node.py b/resources/tester/test_511_c_haul_node.py new file mode 100644 index 000000000..361782939 --- /dev/null +++ b/resources/tester/test_511_c_haul_node.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""511-C (요구 320-4) — 유토 배분 E2 = 늘 떠 있는 Node(TS 한 벌) = 브라우저 자리 TS 0 차. + +못박는 것 + - /haul-plan 창구 새 길(`_run_view` → 시험 길 `haul.view`) + = 브라우저 자리 함수(`computeHaulPlan` 바로 부름) — 프로젝트 일곱 + (저장 유토곡선 · DB 에서 한 번 뜬 `fixtures/haul_511_c.json`) + × 문맥(기본 · 장비 경계 없음 · 구조물 잔토 · 채집석) × 그림 · 수량. + - 끝 경우 — 측점 하나(배분 없음 null) · 둘 · 전부 0 · 전부 절토 · 전부 성토. + - [저장] 정본 새 길(`Server_Calc_Prebuild._node_output` → 늘 떠 있는 Node 셋) = 브라우저 자리 + TS(`structureAreaRows` · `extraWallRows`) · 유토곡선 끝값 · 배분 두 벌 — golden_472 · 문맥 셋 + (옛 server-calc 묶음 = 511-B B-6 걷음). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_c_haul_node.py -q +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Router_HaulPlan as haul_router # noqa: E402 +from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402 + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="node · 프론트엔드 의존성 없음", +) + +PROJECTS = json.loads((HERE / "fixtures" / "haul_511_c.json").read_text(encoding="utf-8")) +GOLDEN = HERE / "fixtures" / "golden_472" +SPOIL_POINTS = [ + {"chainage_m": 40.0, "spoil_m3": 35.5, "ground_type": "soil"}, + {"chainage_m": 260.0, "spoil_m3": 12.25, "ground_type": "ripping_rock"}, + {"chainage_m": 600.0, "spoil_m3": 8.0, "ground_type": None}, +] + + +def _result(payload: dict) -> dict: + """저장 유토곡선 → 누가토량 결과 꼴(최소 · 최대 누계는 측점에서).""" + cums = [p["cumulative_volume_m3"] for p in payload["points"]] + return { + **payload, + "min_cumulative_m3": min(cums, default=0.0), + "max_cumulative_m3": max(cums, default=0.0), + } + + +def _contexts() -> dict[str, dict]: + base = prebuild._mass_haul_context() + return { + "기본": base, + "장비 경계 없음": {**base, "haul_equipment_limits": None}, + "구조물 잔토": { + **base, + "structure_spoil_points": SPOIL_POINTS, + "structure_spoil_m3": 55.75, + }, + "채집석": { + **base, + "collected_stone_deduction_m3": 120.0, + "collected_stone_by_ground_m3": {"soil": 40.0, "blasting_rock": 30.0}, + "collected_stone_ground_unknown_m3": 5.0, + }, + } + + +def _options(context: dict, drawing: bool) -> dict: + keys = ( + "collected_stone_deduction_m3", + "collected_stone_by_ground_m3", + "collected_stone_ground_unknown_m3", + "structure_spoil_m3", + "structure_spoil_points", + ) + return { + **{k: context.get(k) for k in keys}, + "conversion": context.get("earthwork_conversion"), + "drawing": drawing, + } + + +def _browser(tmp_path: Path, calls: list[dict]) -> list: + job = tmp_path / "job.json" + job.write_text(json.dumps(calls, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_mirror.cjs"), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _edge_results() -> dict[str, dict]: + conv = PROJECTS["483"]["conversion"] + + def make(nets: list[float]) -> dict: + points, cum = [], 0.0 + for index, net in enumerate(nets): + cum += net + points.append( + { + "station_id": f"s{index}", + "chainage_m": index * 20.0, + "net_volume_m3": net, + "cumulative_volume_m3": cum, + "cut_soil_m3": max(net, 0.0), + "cut_rock_m3": 0.0, + "cut_rr_m3": 0.0, + "cut_br_m3": 0.0, + "net_area_m2": net / 20.0, + "fill_m3": max(-net, 0.0), + } + ) + return _result({"points": points, "conversion": conv}) + + return { + "측점 하나": make([0.0]), + "측점 둘": make([0.0, 50.0]), + "전부 0": make([0.0] * 6), + "전부 절토": make([0.0, 30.0, 45.0, 10.0, 80.0]), + "전부 성토": make([0.0, -30.0, -45.0, -10.0, -80.0]), + "오르내림": make([0.0, 120.0, -200.0, 90.0, -15.0, 60.0, -140.0, 70.0]), + } + + +def test_창구_새_길_브라우저_같음(tmp_path): + cases = [ + (f"{rid}·{name}", _result(payload), context) + for rid, payload in PROJECTS.items() + for name, context in _contexts().items() + ] + [(f"끝·{name}", result, _contexts()["기본"]) for name, result in _edge_results().items()] + calls = [ + { + "mod": "common_util/common_util_mass_haul_balance.ts", + "fn": "computeHaulPlan", + "args": [result, context.get("haul_equipment_limits"), _options(context, drawing)], + } + for _, result, context in cases + for drawing in (True, False) + ] + browser = _browser(tmp_path, calls) + checked = 0 + for index, (label, result, context) in enumerate(cases): + new = haul_router._run_view(result, context) + assert new is not None, label + assert new["haul_plan"] == browser[2 * index], label + quantity = haul_router.run_calc_served( + ["haul.view"], + { + "args": { + "haul.view": [ + result, + context.get("haul_equipment_limits"), + _options(context, False), + ] + } + }, + )["results"]["haul.view"] + assert quantity == browser[2 * index + 1], label + checked += 1 + assert checked == len(PROJECTS) * 4 + 6 + assert browser[2 * len(PROJECTS) * 4] is None # 측점 하나 = 배분 없음 + assert any(item and item["transfers"] for item in browser) # 빈 배분만 견준 것 아님 + + +@pytest.mark.parametrize("name", ["기본", "구조물 잔토", "채집석"]) +def test_저장_정본_새_길_브라우저_같음(name, monkeypatch, tmp_path): + monkeypatch.setattr(prebuild, "_NODE_CACHE", {}) + detail = json.loads((GOLDEN / "detail.json").read_text(encoding="utf-8")) + node_input = {"detail": detail, "context": _contexts()[name]} + new = prebuild._node_output(copy.deepcopy(node_input)) + layouts = "B06_Section/B06_Section_Structure_Layouts.ts" + sections = detail["cross_sections"] + areas, walls = _browser( + tmp_path, + [ + {"mod": layouts, "fn": "structureAreaRows", "args": [sections]}, + {"mod": layouts, "fn": "extraWallRows", "args": [sections]}, + ], + ) + assert new["areas"] == areas and areas + assert new["extra_walls"] == walls + assert new["mass_haul"]["haul_plan"] and new["mass_haul"]["haul_plan_drawing"] diff --git a/resources/tester/test_73_4_body_guard.py b/resources/tester/test_73_4_body_guard.py index 2633298f4..52b15889c 100644 --- a/resources/tester/test_73_4_body_guard.py +++ b/resources/tester/test_73_4_body_guard.py @@ -69,11 +69,10 @@ def test_설계선_트림은_흙막이_쪽(screen: dict) -> None: def test_서버_면적이_화면과_같음(screen: dict) -> None: - from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE - from common_util.common_util_node_bundle import run_bundle_json + from B06_Section.B06_Section_Server_Calc_Prebuild import _run_node sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"] - output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + output = _run_node({"detail": {"cross_sections": sections}}) assert output, "서버 Node 번들 실행 실패" server = {round(r["chainage_m"], 2): r for r in output["areas"]} for row in screen["areas"]: diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index d594affeb..786eefdb3 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -78,7 +78,10 @@ def test_source_guard_sits_on_the_single_apply_path(): """ # 강제 여부 = 페이지 설정 한 칸을 그 자리에서 읽음(472-5 · 보기마다 사본 없음) assert "const enforce = enforceMinCoverOf(projectId);" in PANEL - assert "if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;" in PANEL + assert ( + "if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;" + in PANEL + ) assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐. assert "export function blocksMinCover(" in GUARD assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다. @@ -96,9 +99,11 @@ def test_source_old_diameter_formula_removed(): assert "minCoverTargets: () => minCoverTargets" in PANEL -ALIGNMENT = ( - PROJECT_ROOT / "B05_Profile" / "logic" / "B05_Profile_UI_Profile_Alignment.ts" -).read_text(encoding="utf-8") +# 구간 쉬프트 · R 입력 = `_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3) — 두 파일을 한 글로 봄 +ALIGNMENT = "".join( + (PROJECT_ROOT / "B05_Profile" / "logic" / name).read_text(encoding="utf-8") + for name in ("B05_Profile_UI_Profile_Alignment.ts", "B05_Profile_UI_Profile_Alignment_Edit.ts") +) def test_shift_moves_hinges_not_fixed_ends(): diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py index 40f5bbdce..467b5b0e3 100644 --- a/resources/tester/test_b05_structures_router.py +++ b/resources/tester/test_b05_structures_router.py @@ -32,8 +32,9 @@ def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, rou monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate) async def fake_saved_irregular(project_id, root, route_id): - # 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌 - return _migrate_payload()["stations"] + # 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌 (legacy, standing) + legacy = _migrate_payload()["stations"] + return legacy, [float(item["chainage_m"]) for item in legacy] monkeypatch.setattr(router_module, "_route_length", fake_route_length) monkeypatch.setattr(router_module, "_saved_irregular_stations", fake_saved_irregular) @@ -306,7 +307,7 @@ def test_migrate_keeps_existing_structures(client, tmp_path): def test_migrate_empty_list_is_noop(client, monkeypatch): async def none_saved(project_id, root, route_id): - return [] + return [], [] monkeypatch.setattr(router_module, "_saved_irregular_stations", none_saved) response = client.post( diff --git a/resources/tester/test_b06_cut_slope_user_input.py b/resources/tester/test_b06_cut_slope_user_input.py index 7dd605cc9..bbf90889e 100644 --- a/resources/tester/test_b06_cut_slope_user_input.py +++ b/resources/tester/test_b06_cut_slope_user_input.py @@ -103,7 +103,10 @@ def test_계산_전에_넘어간다(): """ router = _ROUTER_DESIGN.read_text(encoding="utf-8") assert router.count("cut_slope_ratio=") >= 3, "재계산 경로 중 값을 안 넘기는 곳이 있음" - refresh = _REFRESH.read_text(encoding="utf-8") + refresh = _REFRESH.read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (_B06 / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(encoding="utf-8") assert "cutSlopeRatio: cutSlopeAt(" in refresh, "브라우저 재계산이 값을 안 넘김" # 계산이 끝난 뒤 베껴 붙이는 꼴이면 안 된다. assert not re.search(r"design\.cut_slope_ratio\s*=", refresh), "계산 뒤에 값만 덮어쓰고 있음" diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py index 68e99c78d..e7ac45b6b 100644 --- a/resources/tester/test_b06_design_key_split.py +++ b/resources/tester/test_b06_design_key_split.py @@ -38,7 +38,7 @@ _COMPUTED_FIELDS = { "cut_soil_area_m2", "cut_rock_area_m2", # 배수관 연장(m, 2026-09-08) — 사용자 값이 아니라 **기하가 낸 값**이다. 면적 넷과 **같은 - # 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` → + # 다리**(`STRUCTURE_ROW_KEYS` → `design_calc_node` `structure.layouts` → # `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다. "pipe_length_m", # B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리. diff --git a/resources/tester/test_b06_ditch_choice.py b/resources/tester/test_b06_ditch_choice.py index fa0edf202..ac62f50b2 100644 --- a/resources/tester/test_b06_ditch_choice.py +++ b/resources/tester/test_b06_ditch_choice.py @@ -108,7 +108,7 @@ def test_선택이_저장_patch_까지_실린다() -> None: for name in ( "B06_Section_Cross_Design_Session.ts", # 캐시 칸 "B06_Section_UI_Page_Design_Sync.ts", # 캐시에 쓰는 자리 - "B06_Section_Cross_Refresh.ts", # 브라우저 재계산이 읽는 자리 + "logic/B06_Section_Cross_Refresh_Calc.ts", # 브라우저 재계산이 읽는 자리(511-B B-4 셈 몫) "B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch ): assert "ditch_choice" in _reads(name), f"{name} 에서 측구 선택이 빠졌다" diff --git a/resources/tester/test_b06_ground_unlock.py b/resources/tester/test_b06_ground_unlock.py index 2f1a3b575..f2dc4f33a 100644 --- a/resources/tester/test_b06_ground_unlock.py +++ b/resources/tester/test_b06_ground_unlock.py @@ -1,7 +1,7 @@ """원지반 잠금 풀기(54-45) — 흙막이 자리 · 다단 · 높이를 원지반이 막지 않음. 남는 규칙: 벽 윗면 노면 위 금지(d ≥ 0) · 관 보호공 흙막이 m 단위 자리(관 길이 정수). -서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄. +서버 면적은 같은 TS 를 Node 로 돌림(`design_calc_node.ts` `structure.layouts`) — 번들도 같은 판인지 봄. """ import json @@ -14,7 +14,7 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] B06 = PROJECT_ROOT / "B06_Section" TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" -BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" +BUNDLE = PROJECT_ROOT / "config" / "design_calc_node" / "design_calc_node.js" _RUNNER = """ import { writeFileSync } from "node:fs"; @@ -101,5 +101,5 @@ def test_화면_서버_같은_판() -> None: ), "추가 흙막이 매몰 자리 금지가 남음" bundle = BUNDLE.read_text(encoding="utf-8") assert "outletReceivable" not in bundle and "requireCrossing" not in bundle, ( - "서버 번들이 옛 판 — npm run build:server-calc" + "서버 번들이 옛 판 — npm run build:design-calc" ) diff --git a/resources/tester/test_b06_height_unlock.py b/resources/tester/test_b06_height_unlock.py index 43b2752d0..fe5941507 100644 --- a/resources/tester/test_b06_height_unlock.py +++ b/resources/tester/test_b06_height_unlock.py @@ -1,7 +1,7 @@ """높이 제약 풀기(54-51) — 관 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽 · 집수정 높이를 사용자 값 그대로. 남는 규칙: 그림이 안 서는 값(순수 높이 0 이하)만 0.1 에서 멈춤 · 토스트 없음. -서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄. +서버 면적은 같은 TS 를 Node 로 돌림(`design_calc_node.ts` `structure.layouts`) — 번들도 같은 판인지 봄. """ import json @@ -14,7 +14,7 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] B06 = PROJECT_ROOT / "B06_Section" TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" -BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" +BUNDLE = PROJECT_ROOT / "config" / "design_calc_node" / "design_calc_node.js" _RUNNER = """ import { writeFileSync } from "node:fs"; @@ -92,5 +92,5 @@ def test_높이_토스트_없음_서버_같은_판() -> None: panel = (B06 / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(encoding="utf-8") assert "Math.min(\n 2," not in panel, "집수정 내공 2m 상한이 남음" bundle = BUNDLE.read_text(encoding="utf-8") - assert "EXTRA_WALL_MIN_HEIGHT_M" not in bundle, "서버 번들이 옛 판 — npm run build:server-calc" - assert "WALL_MIN_PURE_HEIGHT_M" in bundle, "서버 번들이 옛 판 — npm run build:server-calc" + assert "EXTRA_WALL_MIN_HEIGHT_M" not in bundle, "서버 번들이 옛 판 — npm run build:design-calc" + assert "WALL_MIN_PURE_HEIGHT_M" in bundle, "서버 번들이 옛 판 — npm run build:design-calc" diff --git a/resources/tester/test_b06_pipe_length.py b/resources/tester/test_b06_pipe_length.py index 3e7832c62..009458f02 100644 --- a/resources/tester/test_b06_pipe_length.py +++ b/resources/tester/test_b06_pipe_length.py @@ -3,7 +3,7 @@ 무슨 일이었나 — 관 길이는 횡단 기하가 **m 단위 올림까지** 끝낸 값인데(`CulvertLayout.pipe.lengthM`) **정본(`design`)에 없었다.** 그래서 수량(B08)이 관 11개를 보고도 **연장을 못 내** 배수관 공종이 막혀 있었다. B08 창은 「계산을 서버로 옮기든가 저장 때 남기든가」 둘로 갈래를 냈는데, 실제로는 -**그 계산이 이미 서버 Node 로 돌고 있었다**(`B06_Section_Server_Calc_Node` ← +**그 계산이 이미 서버 Node 로 돌고 있었다**(`design_calc_node` `structure.layouts` ← `Server_Calc_Prebuild.recompute_server_side`, [저장]·[확정] 때). 그래서 **그 다리에 값 한 줄만** 더 실었다 — 계산을 두 벌로 짜지 않는다(CLAUDE.md 5장). diff --git a/resources/tester/test_b06_server_calc_node.mjs b/resources/tester/test_b06_server_calc_node.mjs index ef17d0ffd..ff6238985 100644 --- a/resources/tester/test_b06_server_calc_node.mjs +++ b/resources/tester/test_b06_server_calc_node.mjs @@ -13,7 +13,8 @@ import { join, resolve } from "node:path"; import assert from "node:assert/strict"; const ROOT = resolve(import.meta.dirname, "../.."); -const BUNDLE = join(ROOT, "config/server_calc_node/B06_Section_Server_Calc_Node.js"); +// 옛 server-calc 묶음 걷음(511-B B-6) — 서버 정본 = 등록부 Node 입구 `structure.layouts` +const BUNDLE = join(ROOT, "config/design_calc_node/design_calc_node.js"); /** 평지 위 표준 횡단 한 측점 — 구조물 없음. */ function plainSection(chainage) { @@ -30,7 +31,10 @@ function plainSection(chainage) { { offset_m: -3, elevation_m: 100 }, { offset_m: 3, elevation_m: 100 }, ], - road_edges: { left: { offset_m: -3, elevation_m: 100 }, right: { offset_m: 3, elevation_m: 100 } }, + road_edges: { + left: { offset_m: -3, elevation_m: 100 }, + right: { offset_m: 3, elevation_m: 100 }, + }, cut_area_m2: 1.23, fill_area_m2: 4.56, }, @@ -42,10 +46,13 @@ const input = join(work, "input.json"); const output = join(work, "output.json"); writeFileSync( input, - JSON.stringify({ detail: { cross_sections: [plainSection(0), plainSection(20)] } }), + JSON.stringify({ + run: ["structure.layouts"], + inputs: { detail: { cross_sections: [plainSection(0), plainSection(20)] } }, + }), ); execFileSync("node", [BUNDLE, input, output], { cwd: ROOT }); -const { areas: rows } = JSON.parse(readFileSync(output, "utf8")); +const { areas: rows } = JSON.parse(readFileSync(output, "utf8")).results["structure.layouts"]; assert.ok(Array.isArray(rows), "결과는 배열이어야 한다"); assert.equal(rows.length, 0, `구조물 없는 측점은 결과에 없어야 한다 (받은 수: ${rows.length})`); diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index fd758950e..19957114d 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -82,7 +82,6 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections })); def _python_result() -> dict: from B06_Section.B06_Section_Engine_Structures_Wall import ( _wall_form, - attach_wall_structures, tier_extras, ) @@ -126,7 +125,6 @@ def _python_result() -> dict: if spec["start_m"] - 0.02 <= section["chainage_m"] <= spec["end_m"] + 0.02: section["revetment"] = spec break - assert callable(attach_wall_structures) return {"specs": specs, "sections": sections} diff --git a/resources/tester/test_b06_user_touched_keys.py b/resources/tester/test_b06_user_touched_keys.py index bf048edfa..757325d32 100644 --- a/resources/tester/test_b06_user_touched_keys.py +++ b/resources/tester/test_b06_user_touched_keys.py @@ -12,7 +12,8 @@ from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS ROOT = Path(__file__).resolve().parents[2] TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6) -REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts" +# 셈 몫(PRESERVED_KEYS) = logic 으로 나눔(511-B B-4) +REFRESH = ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts" # 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다. _CALLER_ADDED = {"status", "pavement_suggested"} diff --git a/resources/tester/test_b06_wall_shape_unified.py b/resources/tester/test_b06_wall_shape_unified.py index 1f56901bd..e79c94898 100644 --- a/resources/tester/test_b06_wall_shape_unified.py +++ b/resources/tester/test_b06_wall_shape_unified.py @@ -5,7 +5,7 @@ ② 형태별 높이 한도(메 2.0 · 찰 3.0 · 콘크리트 5.0) · 원지반 맞춤 자동 자르기 없음. ③ 벽 윗면은 노면 위로 올라오지 않음(넘으면 노면에서 멈춤). -서버 면적은 같은 TS 를 Node 로 돌리므로(`B06_Section_Server_Calc_Node.ts`) 이 함수가 곧 서버 기하. +서버 면적은 같은 TS 를 Node 로 돌리므로(`design_calc_node.ts` `structure.layouts`) 이 함수가 곧 서버 기하. """ import json diff --git a/resources/tester/test_excavation_mirror.py b/resources/tester/test_excavation_mirror.py index 415cbdf66..d2ca6a07b 100644 --- a/resources/tester/test_excavation_mirror.py +++ b/resources/tester/test_excavation_mirror.py @@ -1,7 +1,8 @@ -"""터파기 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09). +"""터파기 단면 — TS 한 벌(`common_util_excavation.ts`)이 법정 표 · 실무 정본 값을 냄 (2026-09-09). -화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽이 갈리면 -「그림은 이런데 수량은 저렇다」가 된다. +화면(B06 횡단도)이 그리고 서버(B08 수량 · Node `excavation.trench`)가 세는 값. +파이썬 짝(`common_util_excavation.py`)은 운영 부름 0 이라 걷음(511-B B-6) — 기대값 = 걷기 전 +파이썬 값 그대로. ⚠ 근거의 급이 다른 두 갈래를 함께 잠근다 — 관은 **법정 표**(KCS 44 40 10 그림 3.2-1), 벽은 **실무 정본 식**(구조도 기슭막이 xls). @@ -12,24 +13,19 @@ from __future__ import annotations import json import re import subprocess -import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from common_util.common_util_excavation import ( # noqa: E402 - PIPE_TRENCH_WIDTH_MM, - WALL_FOUNDATION_DEPTH_M, - WALL_FOUNDATION_WIDTH_M, - WALL_TRENCH_CLEARANCE_M, - pipe_trench_width_m, - wall_trench_area_m2, - wall_trench_width_m, -) +# 걷기 전 파이썬 짝 값(511-B B-6) +PIPE_TABLE = { + 300: 700, 350: 800, 400: 900, 450: 1000, 500: 1100, 600: 1200, 700: 1400, 800: 1600, + 900: 1700, 1000: 1800, 1100: 2000, 1200: 2100, 1300: 2200, 1400: 2400, 1500: 2500, +} # fmt: skip +WALL_FOUNDATION_DEPTH_M = 0.5 +WALL_FOUNDATION_WIDTH_M = 0.9 +WALL_TRENCH_CLEARANCE_M = 0.2 TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" @@ -37,6 +33,9 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { PIPE_TRENCH_WIDTH_MM, + WALL_FOUNDATION_DEPTH_M, + WALL_FOUNDATION_WIDTH_M, + WALL_TRENCH_CLEARANCE_M, pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM, @@ -49,6 +48,8 @@ const out = { wallBlindingOnly: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.7, false)), wallWidth: [0.7, 1.05, 0].map((t) => wallTrenchWidthM(t)), nulls: [pipeTrenchWidthM(null), wallTrenchAreaM2(null, 0.7, true), wallTrenchAreaM2(1, null, true)], + practice: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.83, true)), + consts: [WALL_FOUNDATION_DEPTH_M, WALL_FOUNDATION_WIDTH_M, WALL_TRENCH_CLEARANCE_M], }; writeFileSync(process.argv[2], JSON.stringify(out)); """ @@ -92,50 +93,51 @@ def _ts_values(tmp_path: Path) -> dict: return json.loads(result.read_text(encoding="utf-8")) -@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") -def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None: - ts = _ts_values(tmp_path) +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + return _ts_values(tmp_path_factory.mktemp("excavation")) - assert {int(k): v for k, v in ts["table"].items()} == PIPE_TRENCH_WIDTH_MM - assert ts["pipe"] == [pipe_trench_width_m(d) for d in (300, 800, 1000, 1200, 1500, 250, 1600)] - assert ts["wallWithFoundation"] == [ - wall_trench_area_m2(h, 0.7, has_foundation=True) for h in (1.0, 2.0, 3.0) - ] - assert ts["wallBlindingOnly"] == [ - wall_trench_area_m2(h, 0.7, has_foundation=False) for h in (1.0, 2.0, 3.0) - ] - assert ts["wallWidth"] == [wall_trench_width_m(t) for t in (0.7, 1.05, 0)] + +def test_걷기_전_파이썬_값과_같다(ts: dict) -> None: + assert {int(k): v for k, v in ts["table"].items()} == PIPE_TABLE + assert ts["pipe"] == [0.7, 1.6, 1.8, 2.1, 2.5, None, None] + assert ts["wallWithFoundation"] == pytest.approx([1.35, 2.25, 3.15], abs=1e-12) + assert ts["wallBlindingOnly"] == pytest.approx([0.97, 1.87, 2.77], abs=1e-12) + assert ts["wallWidth"][:2] == pytest.approx([0.9, 1.25], abs=1e-12) + assert ts["wallWidth"][2] is None assert ts["nulls"] == [None, None, None] + assert ts["consts"] == [ + WALL_FOUNDATION_DEPTH_M, + WALL_FOUNDATION_WIDTH_M, + WALL_TRENCH_CLEARANCE_M, + ] -def test_관은_표를_그대로_쓴다() -> None: - """⚠ 관경 + 2b 로 계산하지 않는다 — 표가 관 두께·작업여유를 담고 있다.""" - assert pipe_trench_width_m(800) == 1.6 - assert pipe_trench_width_m(800) != 0.8 + 2 * 0.3 - for diameter in (800, 1000, 1200, 1500): - assert pipe_trench_width_m(diameter) is not None, diameter +def test_관은_표를_그대로_쓴다(ts: dict) -> None: + """⚠ 관경 + 2b 로 계산하지 않는다 — 표가 관 두께·작업여유를 담고 있다(800 → 1.6).""" + width_800 = ts["pipe"][1] + assert width_800 == 1.6 and width_800 != 0.8 + 2 * 0.3 + assert all(width is not None for width in ts["pipe"][1:5]) # 800 · 1000 · 1200 · 1500 -def test_표에_없는_관경은_지어내지_않는다() -> None: - assert pipe_trench_width_m(250) is None - assert pipe_trench_width_m(1600) is None +def test_표에_없는_관경은_지어내지_않는다(ts: dict) -> None: + assert ts["pipe"][5:] == [None, None] # 250 · 1600 -def test_실무_정본_수치와_맞는다() -> None: +def test_실무_정본_수치와_맞는다(ts: dict) -> None: """xls 7탭: H=1.0 → 1.03+0.45 = 1.48 · H=2.0 → 2.51 · H=3.0 → 3.54 (평균두께 0.83).""" - thickness = 0.83 - for height, expected in ((1.0, 1.48), (2.0, 2.51), (3.0, 3.54)): - area = wall_trench_area_m2(height, thickness, has_foundation=True) - assert area == pytest.approx(expected, abs=0.01), height + for area, expected in zip(ts["practice"], (1.48, 2.51, 3.54), strict=True): + assert area == pytest.approx(expected, abs=0.01) -def test_기초_유무가_기초분을_가른다() -> None: - with_base = wall_trench_area_m2(2.0, 0.7, has_foundation=True) - without = wall_trench_area_m2(2.0, 0.7, has_foundation=False) +def test_기초_유무가_기초분을_가른다(ts: dict) -> None: + with_base, without = ts["wallWithFoundation"][1], ts["wallBlindingOnly"][1] # H 2.0 · 0.7 assert with_base - without == pytest.approx( WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M - 0.1 * 0.7 ) -def test_여유폭은_평균두께에_더한다() -> None: - assert wall_trench_width_m(0.7) == pytest.approx(0.7 + WALL_TRENCH_CLEARANCE_M) +def test_여유폭은_평균두께에_더한다(ts: dict) -> None: + assert ts["wallWidth"][0] == pytest.approx(0.7 + WALL_TRENCH_CLEARANCE_M) diff --git a/resources/tester/test_haul_plan_quantity_unpruned.py b/resources/tester/test_haul_plan_quantity_unpruned.py index 5277f45a6..259e4e3f8 100644 --- a/resources/tester/test_haul_plan_quantity_unpruned.py +++ b/resources/tester/test_haul_plan_quantity_unpruned.py @@ -132,11 +132,16 @@ def test_수량_계획은_작은_봉우리도_운반으로_센다(tmp_path: Path def test_서버_정본은_수량과_그림을_따로_둔다() -> None: - source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text( + # 옛 server-calc 묶음 걷음(511-B B-6) — 저장 정본 = 등록부 Node 입구 · 화면 선반입 = /haul-plan + source = (PROJECT_ROOT / "A00_Common" / "design" / "logic" / "design_calc_node.ts").read_text( + encoding="utf-8" + ) + view = (PROJECT_ROOT / "B06_Section" / "B06_Section_Router_HaulPlan.py").read_text( encoding="utf-8" ) # ③ 화면 선반입(그림)은 거른 계획, 저장 정본은 둘 다. - assert "drawing: true" in source + assert 'options["drawing"] = True' in view + assert "planFor(false)" in source and "planFor(true)" in source assert "haul_plan_drawing" in source diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index 2c4af4fe5..93fc804a7 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -46,7 +46,7 @@ def test_b06_context_uses_workflow_route(): def test_cross_refresh_is_single_entry(): """횡단 재계산은 한 창구뿐 — 두 화면이 같은 인자를 실어 보낸다.""" assert "export async function refreshCrossDesigns" in REFRESH - assert "readStandardCrossSession(projectId)" in REFRESH + assert "effectiveStandardCross(projectId)" in REFRESH # 옛 서버 폴백 걷음(511-B B-4) assert "readRockBoundarySession(projectId, routeId)" in REFRESH # 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross · 387-4) — 화면은 묶음에 부탁 shared = _read("A00_Common/design/design_shared.ts") @@ -77,7 +77,7 @@ def test_cross_refresh_lays_structure_areas_once(): 유토 뱃지가 갈렸다(실측 B05 −14,144.5 · B06 −12,533.2㎥).""" body = REFRESH[REFRESH.index("export async function refreshCrossDesigns") :] body = body[: body.index("\n}\n")] - assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body + assert "applyStructureAreaRows(" in body and "structureAreaRows" in body # 511-A 함수째 넘김 assert "applyStructureAreaRows(" not in B05_PREVIEW # 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4) assert "applyStructureAreaRows(" not in B06_PAGE diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 42187a7d1..499da23d1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1005,24 +1005,13 @@ export const ui_locales_b2 = { B06_Std_Note_Paved: ["포장", "Paved"], B05_Drainage_Btn_Propose: ["자동 제안", "Auto suggest"], B05_Drainage_Btn_Propose_Tip: [ - "관 자리를 프로그램이 정합니다(세류선 교차 + 최대 간격 보충) — 두 방식 중 고름. 초안이라 [저장] 때 남습니다.", - "Let the program place culverts (stream crossings + max spacing rule) — choose one of two ways. This is a draft — it is kept on [Save].", + "관 자리 후보를 셉니다(지금 노선 × 세류선 교차 + 최대 간격 보충) — 고른 것만 관으로 넣음. 초안이라 [저장] 때 남습니다.", + "Find culvert candidates (current route × stream crossings + max spacing rule) — only the ones you pick are added. This is a draft — it is kept on [Save].", ], B05_Drainage_Btn_Propose_Stale: [ "노선 · 관 자리가 바뀌어 배수유역이 낡음 — [업데이트] 뒤 자동 제안", "Drainage basins are out of date — run [Update] first", ], - B05_Drainage_Propose_Title: ["관 자리 자동 제안", "Auto-suggest culvert positions"], - B05_Drainage_Propose_All: ["전체 자동", "Fully automatic"], - B05_Drainage_Propose_All_Hint: [ - "세류선 교차 + 최대 간격 규칙으로 관 자리를 새로 정함(같은 자리 관은 시설 · 관경 유지)", - "Re-place every culvert by stream crossings + max spacing (culverts at the same spot keep their facility and size)", - ], - B05_Drainage_Propose_Add: ["지정 포함 추가", "Add to my culverts"], - B05_Drainage_Propose_Add_Hint: [ - "지금 관은 그대로 두고 간격이 먼 구간에만 관을 더함", - "Keep the current culverts and add culverts only where the spacing is too long", - ], Design_Pipeline_Unstable: [ "계산 불안정 — 입력과 결과가 세 바퀴 안에 안 맞음", "Calculation unstable — inputs and results did not settle in three rounds", diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 8c93a946c..04c28d9a3 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -91,11 +91,13 @@ export const ui_locales_m1 = { B01_CalcSites_corridor_mesh: ["3D 길 모형", "3D corridor mesh"], B01_CalcSites_quantity: ["토량 · 구조물 셈", "Earthwork · structure calc"], B01_CalcSites_carry: ["이월 · 다시 얹기", "Carry · rebase"], - B01_CalcSites_stages: [ - "노선 · 측점 · 종단 · 따라감 · 횡단 단계", - "Route · station · profile · follow · cross stages", - ], - B01_CalcSites_structure_pair: ["구조물 짝", "Structure pairs"], + B01_CalcSites_route: ["노선 단계", "Route stage"], + B01_CalcSites_follow: ["따라감 단계", "Follow stage"], + B01_CalcSites_stations: ["측점 단계", "Station stage"], + B01_CalcSites_profile: ["종단 단계", "Profile stage"], + B01_CalcSites_cross: ["횡단 단계", "Cross stage"], + B01_CalcSites_form_default: ["폼 기본값 도구", "Form default tool"], + B01_CalcSites_haul: ["유토 배분", "Haul plan"], B01_CalcSites_fixed_server: ["고정 서버", "Fixed server"], B01_CalcSites_fixed_browser: ["고정 브라우저", "Fixed browser"], B01_CalcSites_Why_ground_sample: [ @@ -106,22 +108,65 @@ export const ui_locales_m1 = { "끄면 미리보기 안 지음 · [저장] · [확정] 때 서버가 지은 모형만", "Off = no preview · only the model the server built on [Save] · [Confirm]", ], - B01_CalcSites_Why_later: ["서버 계산 창구 뒤(2단계)", "After the server calc endpoint (step 2)"], - B01_CalcSites_Why_pair_later: [ - "파이썬 짝 섞임 · 짝 모으기(472-4) 뒤", - "Mixed Python pairs · after pair merge (472-4)", + B01_CalcSites_Why_quantity: [ + "끄면 서버 창구가 셈(조작 중 왕복 · 느림) · F8 = 브라우저 부름 없음(판만)", + "Off = server endpoint calcs (round trip while editing · slower) · F8 = no browser call", ], - B01_CalcSites_Why_pair: [ - "파이썬 정본 짝 유지 · TS 는 미리보기", - "Python canon pair · TS = preview", + B01_CalcSites_Why_carry: [ + "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", + "Off = server calc after a pause · F10 = no browser call", + ], + B01_CalcSites_Why_route: [ + "끄면 잠잠 뒤 서버 노선(같은 TS) · 노선 [저장] = 서버 Node", + "Off = server route after a pause (same TS) · route [Save] = server Node", + ], + B01_CalcSites_Why_follow: [ + "끄면 끌기 중 화면 셈 · 놓은 뒤 서버 따라감(같은 TS) · [저장] 따라감 = 서버 Node", + "Off = browser while dragging · server follow after release (same TS) · [Save] follow = server Node", + ], + B01_CalcSites_Why_stations: [ + "끄면 잠잠 뒤 서버 측점(같은 TS) · [저장] 측점 = 서버 Node · DTM 아닌 지표면은 파이썬", + "Off = server stations after a pause (same TS) · [Save] stations = server Node · non-DTM surface = Python", + ], + B01_CalcSites_Why_profile: [ + "끄면 첫 계획선 · 계획선 짓기를 서버 Node 가 셈(조작 중 왕복)", + "Off = server Node builds the first grade line · grade line (round trip while editing)", + ], + B01_CalcSites_Why_cross: [ + "끄면 횡단을 서버 Node 가 셈(잠잠 뒤 · 왕복) · DTM 아닌 지표면 = 브라우저", + "Off = server Node calcs cross sections (after a pause · round trip) · non-DTM surface = browser", + ], + B01_CalcSites_Why_form_default: [ + "폼 기본값 도구 — 입력 칸 기본값 · 서버 다시 안 셈(B04 · B08 은 파이썬)", + "Form default tool — input default · server never recalcs (B04 · B08 = Python)", ], B01_CalcSites_Why_server: [ "서버 단독(GIS · 래스터 · 값어치)", "Server only (GIS · raster · value)", ], + B01_CalcSites_Why_haul: [ + "켜면 브라우저가 배분 모듈을 그때 받아 셈 · 끄면(기본) 잠잠 뒤 서버 /haul-plan", + "On = browser loads the haul module then calcs · off (default) = server /haul-plan after a pause", + ], + B01_CalcSites_Why_server_G3: [ + "셈이 아니라 DB 씀(넓힘 저장) · 미리 보일 값 없음", + "Writes the DB (widening save), not a calc · nothing to preview", + ], + B01_CalcSites_Why_server_D1: [ + "래스터 흐름 · 유역 다각형(GIS 라이브러리) · DEM 수십 MB", + "Raster flow · basin polygons (GIS libraries) · DEM tens of MB", + ], + B01_CalcSites_Why_server_D2: [ + "배수유역(D1) 흐름 누적 래스터 위에서만 셈", + "Runs only on the D1 flow-accumulation raster", + ], + B01_CalcSites_Why_server_N1: [ + "큰 지형 파일 · 세류 래스터 탐색(scipy) · 6~87 s", + "Large terrain file · stream raster search (scipy) · 6–87 s", + ], B01_CalcSites_Why_browser: [ - "입력 도구 · 그림 — 서버 셈 없음", - "Input tools · drawing — no server calc", + "시험용 — 끄면 서버로 돌림(느림) · P7 · D4 = 서버 값 · 끌기 도구(R2 · R4 · R8 · C7) = 대조만", + "Test only — off = server (slow) · P7 · D4 = server value · drag tools (R2 · R4 · R8 · C7) = compare only", ], B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"], B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"],