diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 7ce332dd7..748436186 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -328,13 +328,15 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심 * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. * `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6) - * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */ + * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다 · `bbox` = route 의 노선 대신 편집 중 노선 + * bbox(사업지 m `x0,y0,x1,y1` · 508-2). */ export async function fetchCachedSheetLayer( projectId: string, layer: string, scope?: SheetScope, + bbox?: string, ): Promise { - const range = scope ? `&scope=${scope}` : ""; + const range = (scope ? `&scope=${scope}` : "") + (bbox ? `&bbox=${bbox}` : ""); return fetchCachedJson( projectId, `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`, diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 5462c2491..6f95b8585 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -106,8 +106,6 @@ export const STATE_REGISTRY = { "input-prefs": { bucket: "pref", scope: "global" }, /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ "b05-3d-projection": { bucket: "pref", scope: "global" }, - /** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */ - "drainage-mode": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", 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_page_data.ts b/A00_Common/design/design_page_data.ts index 74143063c..70ba17f67 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -69,17 +69,9 @@ const notify = (projectId: string): void => { busyListeners.get(projectId)?.forEach((fn) => fn(now)); }; -const launchedBy = new Map(); - -/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */ -export const pageLaunchedBy = (projectId: string, event: Event): boolean => - launchedBy.get(projectId) === event; - -/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */ +/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */ export function trackPageBusy(projectId: string, run: Promise | null): void { if (!run) return; - const event = (globalThis as { event?: Event }).event; - if (event) launchedBy.set(projectId, event); busy.set(projectId, (busy.get(projectId) ?? 0) + 1); notify(projectId); const done = (): void => { diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 1c8a2cf10..7e17175c6 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -129,3 +129,27 @@ export function followMovedPipe(projectId: string | null, fromM: number, toM: nu { partKey: picked.partKey }, ); } + +/** 우클릭 삭제의 두 길 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길. */ +export interface OwnerRemove { + pipe: (chainageM: number) => void; + structure: (structureId: string) => void; +} + +/** 종단 측점 하나를 지움(513-3 · 우클릭 「구조물 삭제」 종단 · 평면 공용) — 측점 id = 주인 · `주인:역할` + * (`plannedIrregular`) · 관 기준 측점 = 그 자리 · 관 구간 끝 = 관 자리 · 그 밖 = 구조물 id(좌측 칸 [삭제]와 같은 길). */ +export function removeStationOwner( + station: { id: string; chainage_m: number }, + pipe: boolean, + remove: OwnerRemove, +): void { + if (pipe) return remove.pipe(station.chainage_m); + removeMarkOwner(station.id.replace(/:(start|end)$/, ""), remove); +} + +/** 알약 하나를 지움(514-1 · 측점선 없는 구조물도) — 관 알약 = 관 세션 id 장부 자리 · 그 밖 = 구조물 id. */ +export function removeMarkOwner(structureId: string, remove: OwnerRemove): void { + const at = pipeChainageOf(structureId); + if (at !== null) remove.pipe(at); + else remove.structure(structureId); +} 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_profile_frame.ts b/A00_Common/design/design_profile_frame.ts index f014d74a0..7e3d82d70 100644 --- a/A00_Common/design/design_profile_frame.ts +++ b/A00_Common/design/design_profile_frame.ts @@ -10,6 +10,7 @@ import "../../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. import "../../B06_Section/B06_Section_UI_Style_Cross.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; export function createProfileFrame(view: string) { 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..fdcdab850 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -41,7 +41,6 @@ import type { DesignView } from "./design_dock"; import type { FacilityLane } from "./design_facility_lane"; import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; import { plantedBase } from "./design_pipeline_base"; -import { isUserPick } from "./design_user_pick"; import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { applyStructureAreaRows, @@ -59,6 +58,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"; @@ -223,6 +223,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { onIrregularSelect: toView(shared, "onIrregularSelect"), onStructureSelect: toView(shared, "onStructureSelect"), onStructureMarkMove: toView(shared, "onStructureMarkMove"), + onStructureMarkRemove: toView(shared, "onStructureMarkRemove"), onStructureTypeAdd: toView(shared, "onStructureTypeAdd"), structureGroups: () => sharedStructures(shared).pickGroups(), onBasinSelected: toView(shared, "onBasinSelected"), @@ -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) => { @@ -321,10 +331,8 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection { onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes), onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM), onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM), + // 고름은 내용만 — 좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 사용자가 둔 그대로(513-2) onListRelease: () => on().onListRelease?.(), - // 사용자 고름 = 「구조물 배치」 카드 폄 — 좌측 패널 열림 · 닫힘은 그대로(398-1 · 499-1) - onReveal: () => - isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed"), }, { includeDetail: () => shared.view() === "cross", projectId: shared.projectId }, ); @@ -403,9 +411,22 @@ async function alreadyConfirmed(projectId: string): Promise { /** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */ export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void { if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info"); - void alreadyConfirmed(shared.projectId).then((skip) => - skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route), - ); + const go = () => + alreadyConfirmed(shared.projectId).then((skip) => + skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route), + ); + void drainageFirst(shared).then((ready) => (ready ? go() : undefined)); +} + +/** 배수유역도가 낡은 채 B07 로 — 확인 창 → [업데이트] 끝난 뒤 감 · 취소 · 실패 = 머묾(요구 326-6). */ +async function drainageFirst(shared: DesignPageShared): Promise { + const side = shared.profile?.side; + if (!side?.stale()) return true; + if (!(await showConfirmDialog(L("Design_Drainage_Stale"), L("Design_Drainage_Stale_Go")))) + return false; + if (await side.update()) return true; + showToast(L("Design_Drainage_Stale_Failed"), "error"); + return false; } /** 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_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index afe825847..1bb82b400 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -25,6 +25,18 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; } +/** 배수유역도가 낡은 동안(요구 328-3) 설계유량 = 옛 유역 측점 값이라 「낡음」으로 가림 · 높이도 안 채움. + * 판정은 배수유역 판 하나(`B05_Profile_UI_Drainage_Live` 가 부름) · 바뀌면 열린 폼을 다시 그림. */ +let drainageStale = false; +// ponytail: 폼마다 하나씩 쌓임(페이지 다시 열 때만 · 떨어진 폼 다시 그림은 헛일) — 많아지면 폼 닫을 때 뺌 +const staleRepaints = new Set<() => void>(); +export const FORD_FLOW_STALE_HINT = "설계유량 낡음 — 배수유역도 [업데이트] 뒤 셈"; +export function setDrainageStale(next: boolean): void { + if (drainageStale === next) return; + drainageStale = next; + staleRepaints.forEach((repaint) => repaint()); +} + /** * 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3). * · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로. @@ -41,7 +53,8 @@ export function overflowSync( let pending = false; const values = () => form().state()[box]?.[0] ?? {}; const width = () => Number(values().width ?? ""); - const need = (): number | null => fordHeightM(flow(), width()); + const need = (): number | null => (drainageStale ? null : fordHeightM(flow(), width())); + staleRepaints.add(() => form()?.refresh()); return { arm: (fresh: boolean): void => { pending = fresh; @@ -67,7 +80,7 @@ export function overflowSync( if (c.id !== box) return []; const hint = document.createElement("p"); hint.className = "b05-structure__flow-hint"; - hint.textContent = fordHeightHint(flow(), width()); + hint.textContent = drainageStale ? FORD_FLOW_STALE_HINT : fordHeightHint(flow(), width()); return [hint]; }, }; 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/design_user_pick.ts b/A00_Common/design/design_user_pick.ts deleted file mode 100644 index 3d075da31..000000000 --- a/A00_Common/design/design_user_pick.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* ============================================================================= - * A00_Common/design/design_user_pick.ts - * 「사용자 고름」 표시 한 칸(PLAN 398-1) — 좌측 패널 저절로 펴기(09-04 「바깥에서 고르면 좌측 패널도」)는 - * 사용자가 직접 누른 고름에서만. - * - * 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D · - * 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶. - * 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름 - * (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기). - * 「계산 중」 동안 새로 누른 고름은 폄(505-2). - * 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄. - * ========================================================================== */ - -import { pageLaunchedBy } from "./design_page_data"; - -const PICK_EVENTS = new Set([ - "pointerdown", - "pointerup", - "mousedown", - "mouseup", - "click", - "dblclick", - "contextmenu", -]); - -/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */ -export function isUserPick(projectId: string | null): boolean { - const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지) - if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false; - return !projectId || !pageLaunchedBy(projectId, event); -} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index ccee54e26..a90c8a790 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -64,6 +64,7 @@ import { } from "../../B06_Section/B06_Section_UI_Page_Structures_Selection"; import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style_Cross.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index bf8e88b5e..58a2342b9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { pipeIdAt } from "./design_pipe_ids"; +import { pipeIdAt, removeMarkOwner, removeStationOwner } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPlusStations } from "../../common_util/common_util_structure_station"; @@ -172,6 +172,10 @@ export async function mountProfileView( } panel.structures.moveById(structureId, toChainage); }; + const removeOwner = { + pipe: (chainage: number) => profilePanel.drainage.removePipe(chainage), + structure: (structureId: string) => void panel.structures.removeById(structureId), + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -194,9 +198,10 @@ export async function mountProfileView( onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, - onStructureRemove: (station) => { - if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); - }, + // 우클릭 삭제 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길(513-3 · 알약 514-1) + onStructureRemove: (station) => + removeStationOwner(station, isPipeStation(station), removeOwner), + onStructureMarkRemove: (structureId) => removeMarkOwner(structureId, removeOwner), // 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18). onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), onStructureAdd: (chainage, type) => diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 5b6058c80..9f86cdfd4 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -17,6 +17,7 @@ import type { DesignPageHost } from "./design_page"; import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; import { createViewer3dModal } from "./design_view_profile_3d_modal"; import { pageRouteId } from "./design_page_route"; +import { calcOnServer } from "./design_calc_sites"; export interface Profile3dDeps { projectId: string; @@ -67,7 +68,7 @@ export function createProfile3d(deps: Profile3dDeps) { if (!detail || !latest?.route || routeId === null) return; const routePoints = latest.route_points ?? []; if (routePoints.length <= 1) return; - await refreshCorridor( + const fresh = await refreshCorridor( viewer, projectId, routeId, @@ -77,7 +78,7 @@ export function createProfile3d(deps: Profile3dDeps) { ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). deps.sections().renderStationLines(detail); - deps.sections().setCorridorPending(false); + deps.sections().setCorridorPending(!fresh); // D3 = 서버 짓기 실패 = 늦음 남김(508-2) } async function applyContours(interval: number): Promise { @@ -134,8 +135,10 @@ export function createProfile3d(deps: Profile3dDeps) { loadTerrain, visible: () => deps.host.visible(), // 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다. + // D3 = 서버면 [3D 업데이트]만 다시 지음(요구 324) — 열 때는 옛 모형 그대로. onOpen: () => { - if (state.corridorPending) void refreshOnce(); + if (state.corridorPending) + void calcOnServer("corridor_mesh").then((server) => void (server || refreshOnce())); }, }); 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/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index c56b848d1..cdd21d765 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -49,6 +49,7 @@ import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; import "./B01_Dashboard_UI_Style.css"; +import "./B01_Dashboard_UI_Style_Company.css"; export interface DashboardState { user: DashboardUser; diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index c25e72392..0430258c7 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -385,428 +385,3 @@ grid-template-columns: 1fr; } } - -/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13). - 라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라 - 그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */ -.b01-dashboard__address { - display: flex; - flex-direction: column; - gap: var(--spacing-8, 8px); -} - -.b01-dashboard__address-row { - display: flex; - align-items: center; - gap: var(--spacing-8, 8px); -} - -.b01-dashboard__address-row > .ui-field { - display: flex; - flex: 1 1 auto; - flex-direction: row; - align-items: center; - gap: var(--spacing-8, 8px); - min-width: 0; - margin: 0; -} - -.b01-dashboard__address .ui-field__label { - flex: 0 0 64px; - margin: 0; - white-space: nowrap; -} - -.b01-dashboard__address .ui-input { - flex: 1 1 auto; - min-width: 0; -} - -.b01-dashboard__address .ui-field__error { - display: none; -} - -.b01-dashboard__address-row > .ui-btn { - flex: 0 0 auto; -} - -.b01-dashboard__address-zip { - flex: 0 0 auto; - color: var(--color-text-muted, #666); - font-size: 0.85rem; - white-space: nowrap; -} - -.b01-dashboard__address-list { - display: flex; - flex-direction: column; - max-height: 220px; - overflow-y: auto; - overscroll-behavior: contain; - border: 1px solid var(--color-border); - border-radius: var(--radius-8, 8px); -} - -.b01-dashboard__address-item { - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--spacing-8, 8px); - border: 0; - border-bottom: 1px solid var(--color-border); - background: transparent; - color: inherit; - text-align: left; - cursor: pointer; -} - -.b01-dashboard__address-item:last-child { - border-bottom: 0; -} - -.b01-dashboard__address-item:hover, -.b01-dashboard__address-item:focus-visible { - background: var(--color-surface-hover, rgba(0, 0, 0, 0.04)); -} - -.b01-dashboard__address-item small { - color: var(--color-text-muted, #666); -} - -.b01-dashboard__map-zoom { - display: flex; - gap: var(--spacing-8, 8px); - margin-bottom: var(--spacing-8, 8px); -} - -/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */ -.b01-dashboard__map-frame { - position: relative; - width: 100%; - max-width: 384px; - aspect-ratio: 1; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-8, 8px); -} - -.b01-dashboard__map-grid { - display: grid; - grid-template-columns: repeat(3, 256px); - transform: scale(0.5); - transform-origin: top left; -} - -.b01-dashboard__map-marker { - position: absolute; - width: 12px; - height: 12px; - margin: -6px 0 0 -6px; - border: 2px solid var(--color-surface, #fff); - border-radius: 50%; - background: var(--color-danger, #d33); -} - -/* 프로젝트 참여자 고르기 (2026-09-06). */ -.b01-dashboard__members { - display: flex; - flex-direction: column; - gap: var(--spacing-4, 4px); - max-height: 160px; - overflow-y: auto; -} - -.b01-dashboard__member-row { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.b01-knowledge-chat { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 100; - display: flex; - flex-direction: column-reverse; - align-items: flex-end; - gap: 8px; -} - -.b01-knowledge-chat__toggle, -.b01-knowledge-chat__form button { - border: 0; - background: var(--color-primary); - color: var(--color-primary-text); - font: inherit; - cursor: pointer; -} - -.b01-knowledge-chat__toggle { - display: grid; - width: 58px; - height: 58px; - place-items: center; - border-radius: 50%; - box-shadow: 0 4px 16px #0004; -} - -.b01-knowledge-chat__toggle svg { - width: 30px; - height: 30px; -} - -.b01-knowledge-chat__panel { - display: flex; - flex-direction: column; - width: min(380px, calc(100vw - 32px)); - height: min(680px, calc(100dvh - 104px)); - border: 1px solid var(--color-border); - border-radius: 12px; - background: var(--color-surface, #fff); - box-shadow: 0 8px 28px #0003; - color: var(--color-text-body); - overflow: hidden; - overscroll-behavior: contain; -} - -.b01-knowledge-chat__panel[hidden] { - display: none; -} - -.b01-knowledge-chat__panel header { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-bottom: 1px solid var(--color-border); -} - -.b01-knowledge-chat__avatar { - display: grid; - width: 34px; - height: 34px; - place-items: center; - border-radius: 50%; - background: var(--color-primary); - color: var(--color-primary-text); -} - -.b01-knowledge-chat__avatar svg { - width: 21px; - height: 21px; -} - -.b01-knowledge-chat__close { - margin-left: auto; - border: 0; - background: transparent; - color: inherit; - font: inherit; - font-size: 26px; - cursor: pointer; -} - -.b01-knowledge-chat__messages { - display: flex; - flex: 1; - flex-direction: column; - gap: 12px; - min-height: 0; - padding: 16px 12px; - overflow-y: auto; - overscroll-behavior: contain; -} - -.b01-knowledge-chat__user, -.b01-knowledge-chat__reply { - max-width: 88%; - margin: 0; - padding: 10px 12px; - border-radius: 14px; - overflow-wrap: anywhere; - white-space: pre-wrap; -} - -.b01-knowledge-chat__user { - align-self: flex-end; - border-bottom-right-radius: 4px; - background: var(--color-primary); - color: var(--color-primary-text); -} - -.b01-knowledge-chat__reply { - align-self: flex-start; - border: 1px solid var(--color-border); - border-bottom-left-radius: 4px; - background: var(--color-surface-raised); -} - -.b01-knowledge-chat__reply details { - margin-top: 8px; - font-size: 0.9em; -} - -.b01-knowledge-chat__reply summary { - cursor: pointer; - overflow-wrap: anywhere; -} - -.b01-knowledge-chat__reply pre { - max-height: 220px; - overflow: auto; - white-space: pre-wrap; - font: inherit; -} - -.b01-knowledge-chat__sources { - display: grid; - gap: 6px; - margin-top: 12px; - padding-top: 10px; - border-top: 1px solid var(--color-border); -} - -.b01-knowledge-chat__sources > summary { - margin-bottom: 3px; - color: var(--color-text-secondary, #666); - font-size: 0.85em; -} - -.b01-knowledge-chat__source { - display: grid; - gap: 3px; - width: 100%; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-surface, #fff); - color: inherit; - text-align: left; - cursor: pointer; -} - -.b01-knowledge-chat__source span { - color: var(--color-text-secondary, #666); - font-size: 0.8em; - overflow-wrap: anywhere; -} - -.b01-knowledge-source-modal.ui-modal { - width: min(900px, 94vw); - --modal-max: 88vh; -} - -.b01-knowledge-source-modal__content { - max-height: calc(88vh - 100px); - overflow: auto; - overscroll-behavior: contain; - padding: 8px 16px; - line-height: 1.75; - overflow-wrap: anywhere; -} - -.b01-knowledge-source-modal__content h2, -.b01-knowledge-source-modal__content h3, -.b01-knowledge-source-modal__content h4, -.b01-knowledge-source-modal__content h5 { - margin: 1.4em 0 0.5em; - line-height: 1.4; -} - -.b01-knowledge-source-modal__content p, -.b01-knowledge-source-modal__content ul, -.b01-knowledge-source-modal__content ol { - margin: 0.6em 0 1em; -} - -.b01-knowledge-source-modal__content code { - padding: 0.1em 0.35em; - border-radius: 4px; - background: var(--color-surface); -} - -.b01-knowledge-source-modal__content .ui-markdown-table { - max-width: 100%; - overflow-x: auto; - overscroll-behavior: contain; -} - -.b01-knowledge-source-modal__content table { - width: 100%; - border-collapse: collapse; - font-size: 0.92em; -} - -.b01-knowledge-source-modal__content th, -.b01-knowledge-source-modal__content td { - padding: 8px 10px; - border: 1px solid var(--color-border); - text-align: left; - vertical-align: top; -} - -.b01-knowledge-source-modal__content th { - background: var(--color-surface); -} - -.b01-knowledge-chat__form { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 8px; - border-top: 1px solid var(--color-border); -} - -.b01-knowledge-chat__form textarea { - min-width: 0; - flex: 1; - min-height: 56px; - max-height: 140px; - padding: 8px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - resize: vertical; - overflow-y: auto; -} - -.b01-knowledge-chat__form textarea::placeholder { - color: var(--color-text-muted); -} - -.b01-knowledge-chat__form button { - padding: 9px 14px; - border-radius: 8px; -} - -.b01-knowledge-chat button:focus-visible, -.b01-knowledge-chat textarea:focus-visible { - outline: 2px solid var(--color-royal-amethyst, #6843a7); - outline-offset: 2px; -} - -/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */ -.b01-dashboard__calc-sites .b01-dashboard__table td { - white-space: normal; -} -.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2), -.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) { - text-align: center; -} -.b01-dashboard__calc-sites .b01-dashboard__calc-group td { - background: var(--color-surface); - font-weight: var(--font-weight-medium); -} -.b01-dashboard__calc-sites .b01-dashboard__calc-name { - padding-left: var(--spacing-16); -} -.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name { - padding-left: 0; -} -.b01-dashboard__calc-count, -.b01-dashboard__calc-note { - margin: 0 0 0.5rem; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Company.css b/B01_Dashboard/B01_Dashboard_UI_Style_Company.css new file mode 100644 index 000000000..c6c31bcb1 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Company.css @@ -0,0 +1,424 @@ +/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13). + 라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라 + 그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */ +.b01-dashboard__address { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); +} + +.b01-dashboard__address-row { + display: flex; + align-items: center; + gap: var(--spacing-8, 8px); +} + +.b01-dashboard__address-row > .ui-field { + display: flex; + flex: 1 1 auto; + flex-direction: row; + align-items: center; + gap: var(--spacing-8, 8px); + min-width: 0; + margin: 0; +} + +.b01-dashboard__address .ui-field__label { + flex: 0 0 64px; + margin: 0; + white-space: nowrap; +} + +.b01-dashboard__address .ui-input { + flex: 1 1 auto; + min-width: 0; +} + +.b01-dashboard__address .ui-field__error { + display: none; +} + +.b01-dashboard__address-row > .ui-btn { + flex: 0 0 auto; +} + +.b01-dashboard__address-zip { + flex: 0 0 auto; + color: var(--color-text-muted, #666); + font-size: 0.85rem; + white-space: nowrap; +} + +.b01-dashboard__address-list { + display: flex; + flex-direction: column; + max-height: 220px; + overflow-y: auto; + overscroll-behavior: contain; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 8px); +} + +.b01-dashboard__address-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8, 8px); + border: 0; + border-bottom: 1px solid var(--color-border); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.b01-dashboard__address-item:last-child { + border-bottom: 0; +} + +.b01-dashboard__address-item:hover, +.b01-dashboard__address-item:focus-visible { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.04)); +} + +.b01-dashboard__address-item small { + color: var(--color-text-muted, #666); +} + +.b01-dashboard__map-zoom { + display: flex; + gap: var(--spacing-8, 8px); + margin-bottom: var(--spacing-8, 8px); +} + +/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */ +.b01-dashboard__map-frame { + position: relative; + width: 100%; + max-width: 384px; + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 8px); +} + +.b01-dashboard__map-grid { + display: grid; + grid-template-columns: repeat(3, 256px); + transform: scale(0.5); + transform-origin: top left; +} + +.b01-dashboard__map-marker { + position: absolute; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border: 2px solid var(--color-surface, #fff); + border-radius: 50%; + background: var(--color-danger, #d33); +} + +/* 프로젝트 참여자 고르기 (2026-09-06). */ +.b01-dashboard__members { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + max-height: 160px; + overflow-y: auto; +} + +.b01-dashboard__member-row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b01-knowledge-chat { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 100; + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: 8px; +} + +.b01-knowledge-chat__toggle, +.b01-knowledge-chat__form button { + border: 0; + background: var(--color-primary); + color: var(--color-primary-text); + font: inherit; + cursor: pointer; +} + +.b01-knowledge-chat__toggle { + display: grid; + width: 58px; + height: 58px; + place-items: center; + border-radius: 50%; + box-shadow: 0 4px 16px #0004; +} + +.b01-knowledge-chat__toggle svg { + width: 30px; + height: 30px; +} + +.b01-knowledge-chat__panel { + display: flex; + flex-direction: column; + width: min(380px, calc(100vw - 32px)); + height: min(680px, calc(100dvh - 104px)); + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface, #fff); + box-shadow: 0 8px 28px #0003; + color: var(--color-text-body); + overflow: hidden; + overscroll-behavior: contain; +} + +.b01-knowledge-chat__panel[hidden] { + display: none; +} + +.b01-knowledge-chat__panel header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-border); +} + +.b01-knowledge-chat__avatar { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-primary-text); +} + +.b01-knowledge-chat__avatar svg { + width: 21px; + height: 21px; +} + +.b01-knowledge-chat__close { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 26px; + cursor: pointer; +} + +.b01-knowledge-chat__messages { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + padding: 16px 12px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.b01-knowledge-chat__user, +.b01-knowledge-chat__reply { + max-width: 88%; + margin: 0; + padding: 10px 12px; + border-radius: 14px; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.b01-knowledge-chat__user { + align-self: flex-end; + border-bottom-right-radius: 4px; + background: var(--color-primary); + color: var(--color-primary-text); +} + +.b01-knowledge-chat__reply { + align-self: flex-start; + border: 1px solid var(--color-border); + border-bottom-left-radius: 4px; + background: var(--color-surface-raised); +} + +.b01-knowledge-chat__reply details { + margin-top: 8px; + font-size: 0.9em; +} + +.b01-knowledge-chat__reply summary { + cursor: pointer; + overflow-wrap: anywhere; +} + +.b01-knowledge-chat__reply pre { + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + font: inherit; +} + +.b01-knowledge-chat__sources { + display: grid; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--color-border); +} + +.b01-knowledge-chat__sources > summary { + margin-bottom: 3px; + color: var(--color-text-secondary, #666); + font-size: 0.85em; +} + +.b01-knowledge-chat__source { + display: grid; + gap: 3px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface, #fff); + color: inherit; + text-align: left; + cursor: pointer; +} + +.b01-knowledge-chat__source span { + color: var(--color-text-secondary, #666); + font-size: 0.8em; + overflow-wrap: anywhere; +} + +.b01-knowledge-source-modal.ui-modal { + width: min(900px, 94vw); + --modal-max: 88vh; +} + +.b01-knowledge-source-modal__content { + max-height: calc(88vh - 100px); + overflow: auto; + overscroll-behavior: contain; + padding: 8px 16px; + line-height: 1.75; + overflow-wrap: anywhere; +} + +.b01-knowledge-source-modal__content h2, +.b01-knowledge-source-modal__content h3, +.b01-knowledge-source-modal__content h4, +.b01-knowledge-source-modal__content h5 { + margin: 1.4em 0 0.5em; + line-height: 1.4; +} + +.b01-knowledge-source-modal__content p, +.b01-knowledge-source-modal__content ul, +.b01-knowledge-source-modal__content ol { + margin: 0.6em 0 1em; +} + +.b01-knowledge-source-modal__content code { + padding: 0.1em 0.35em; + border-radius: 4px; + background: var(--color-surface); +} + +.b01-knowledge-source-modal__content .ui-markdown-table { + max-width: 100%; + overflow-x: auto; + overscroll-behavior: contain; +} + +.b01-knowledge-source-modal__content table { + width: 100%; + border-collapse: collapse; + font-size: 0.92em; +} + +.b01-knowledge-source-modal__content th, +.b01-knowledge-source-modal__content td { + padding: 8px 10px; + border: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} + +.b01-knowledge-source-modal__content th { + background: var(--color-surface); +} + +.b01-knowledge-chat__form { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px; + border-top: 1px solid var(--color-border); +} + +.b01-knowledge-chat__form textarea { + min-width: 0; + flex: 1; + min-height: 56px; + max-height: 140px; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + resize: vertical; + overflow-y: auto; +} + +.b01-knowledge-chat__form textarea::placeholder { + color: var(--color-text-muted); +} + +.b01-knowledge-chat__form button { + padding: 9px 14px; + border-radius: 8px; +} + +.b01-knowledge-chat button:focus-visible, +.b01-knowledge-chat textarea:focus-visible { + outline: 2px solid var(--color-royal-amethyst, #6843a7); + outline-offset: 2px; +} + +/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */ +.b01-dashboard__calc-sites .b01-dashboard__table td { + white-space: normal; +} +.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2), +.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) { + text-align: center; +} +.b01-dashboard__calc-sites .b01-dashboard__calc-group td { + background: var(--color-surface); + font-weight: var(--font-weight-medium); +} +.b01-dashboard__calc-sites .b01-dashboard__calc-name { + padding-left: var(--spacing-16); +} +.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name { + padding-left: 0; +} +.b01-dashboard__calc-count, +.b01-dashboard__calc-note { + margin: 0 0 0.5rem; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index cd9a87b93..6f97ac3b6 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -48,6 +48,7 @@ import { type UploadStatus, } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; +import "./B03_FileInput_UI_Style_Card.css"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 6719bdcd3..fb01e0657 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -348,367 +348,3 @@ grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: var(--spacing-12); } - -/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */ -.b03-file--busy { - pointer-events: none; -} - -.b03-file--busy .b03-file__card-select, -.b03-file--busy .b03-file__card-remove, -.b03-file--busy .b03-file__dropzone { - opacity: 0.5; -} - -/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */ -.b03-file__upload-spinner { - width: 14px; - height: 14px; - border-width: 2px; - border-color: rgb(255 255 255 / 40%); - border-top-color: var(--color-primary-text); -} - -/* Wiza 8px radius 카드 */ -/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) — - 파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */ -.b03-file__card { - padding: var(--spacing-12) var(--spacing-16); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - background: var(--color-canvas, #ffffff); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-8); - transition: - transform var(--transition-base, 0.2s), - border-color var(--transition-base, 0.2s), - box-shadow var(--transition-base, 0.2s); -} - -.b03-file__card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--color-royal-amethyst, #3e0079); -} - -.b03-file__card--selected { - border-color: var(--color-royal-amethyst, #3e0079); - background: var(--color-paper, #f6f7fa); -} - -.b03-file__card--uploading { - border-color: var(--color-royal-amethyst, #3e0079); - box-shadow: var(--shadow-lg-2); -} - -.b03-file__card--completed { - border-color: #10b981; /* Success Green */ -} - -.b03-file__card--error { - border-color: var(--color-danger, #dc2626); -} - -/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이 - 글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */ -/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로 - 내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */ -/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다 - (2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */ -.b03-file__card-header { - display: flex; - flex-wrap: nowrap; - gap: var(--spacing-8); - align-items: center; - min-width: 0; - max-width: 100%; -} - -.b03-file__card-header > * { - min-width: 0; -} - -.b03-file__card-icon { - flex: 0 0 auto; - width: 28px; - height: 28px; - border-radius: var(--radius-icons, 8px); - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--color-royal-amethyst, #3e0079); - background: var(--color-mist-violet, #edecff); - font-size: var(--text-body-sm, 14px); - margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */ -} - -.b03-file__card-heading { - min-width: 0; - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 1px; -} - -.b03-file__card-label { - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); - /* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.b03-file__card-ext, -.b03-file__file-size, -.b03-file__progress-info { - color: var(--color-slate, #615e6e); - font-size: var(--text-caption, 12px); -} - -.b03-file__card-badge-container { - display: flex; - align-items: center; - margin-left: auto; - margin-right: var(--spacing-4); -} - -.b03-file__card-remove { - width: 24px; - height: 24px; - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-buttons, 8px); - color: var(--color-slate, #615e6e); - background: var(--color-canvas, #ffffff); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-body-sm, 14px); - line-height: 1; - padding: 0; - margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */ - transition: all var(--transition-base, 0.2s); -} - -.b03-file__card-remove:hover { - color: var(--color-danger, #dc2626); - border-color: var(--color-danger, #dc2626); - background: #fef2f2; -} - -.b03-file__card-content { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */ -.b03-file__card--empty .b03-file__card-content { - display: none; -} - -/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */ -.b03-file__card-select { - flex: 0 0 auto; - min-height: 26px; - padding: 0 var(--spacing-8); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-buttons, 8px); - color: var(--color-deep-iris, #26114a); - background: var(--color-canvas, #ffffff); - font-weight: var(--font-weight-medium, 500); - font-size: var(--text-caption, 12px); - white-space: nowrap; - cursor: pointer; - transition: - background var(--transition-base, 0.2s), - border-color var(--transition-base, 0.2s); -} - -.b03-file__file-info { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.b03-file__file-name { - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); - overflow-wrap: anywhere; -} - -/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면 - 읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */ -.b03-file__progress-section { - display: none; - flex-direction: column; - gap: var(--spacing-8); -} - -.b03-file__card--uploading .b03-file__progress-section { - display: flex; -} - -.b03-file__progress-bar-container { - width: 100%; - height: 6px; - overflow: hidden; - border-radius: var(--radius-pills, 1440px); - background: var(--color-mist, #e6e2e3); -} - -.b03-file__progress-bar { - width: 0; - height: 100%; - border-radius: var(--radius-pills, 1440px); - background: var(--color-royal-amethyst, #3e0079); - transition: width var(--transition-base, 0.2s); -} - -.b03-file__progress-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.b03-file__error-message { - display: none; - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-cards, 8px); - color: var(--color-danger, #dc2626); - background: #fef2f2; - font-size: var(--text-caption, 12px); - border: 1px solid rgba(220, 38, 38, 0.15); -} - -.b03-file__card--empty .b03-file__file-info, -.b03-file__card--empty .b03-file__preview, -.b03-file__card--empty .b03-file__progress-section, -.b03-file__card--empty .b03-file__error-message { - display: none; -} - -/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형. - 값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */ -.b03-file__preview { - display: none; - margin-top: 6px; -} - -/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 - 파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */ -.b03-file__preview.is-visible { - display: flex; - grid-column: 1 / -1; - align-items: center; - justify-content: center; - width: 100%; - height: 120px; - overflow: hidden; - border: 1px solid var(--color-border, #dcdce6); - border-radius: var(--radius-cards, 8px); - background: var(--color-surface, #fff); -} - -.b03-file__preview svg { - display: block; - width: 100%; - height: 100%; -} - -/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */ -.b03-file__preview-cloud { - fill: none; - stroke: var(--color-text-muted, #5a5a72); - stroke-linecap: round; - stroke-opacity: 0.55; - stroke-width: 0.7; -} - -/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라 - 다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다 - (2026-09-04 사용자 지적). */ -.b03-file__preview-thumb { - max-width: 100%; - max-height: 100%; - border: 1px dashed var(--color-border, #9aa); - image-rendering: pixelated; - object-fit: contain; -} - -/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */ -.b03-file__preview-facts { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 8px; - text-align: center; -} - -.b03-file__preview-fact { - color: var(--color-text, #1c1c28); - font-size: var(--text-body, 15px); - font-weight: 600; -} - -.b03-file__preview-fact-sub { - color: var(--color-text-muted, #5a5a72); - font-size: var(--text-caption, 12px); -} - -.b03-file__preview-empty { - color: var(--color-text-muted, #8a8aa0); - font-size: var(--text-caption, 12px); -} - -.b03-file__preview-extent { - fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent); - stroke: var(--color-border, #9aa); - stroke-dasharray: 4 3; - stroke-width: 1; -} - -.b03-file__preview-route { - fill: none; - /* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서 - 붉은색으로 덮어써 경고로 쓴다. */ - stroke: var(--color-success, #16a34a); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.5; -} - -/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ -.b03-file__preview.is-warning .b03-file__preview-route { - stroke: var(--color-danger, #d33); -} - -.b03-file__preview.is-warning .b03-file__preview-extent { - stroke: var(--color-danger, #d33); - stroke-dasharray: 3 2; -} - -.b03-file__card--selected .b03-file__progress-section { - display: none; -} - -/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아 - 끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */ -.b03-file__card--uploading .b03-file__card-select { - display: none; -} - -.b03-file__card--error .b03-file__error-message { - display: block; -} - -.b03-file__card--error .b03-file__progress-section { - display: none; -} diff --git a/B03_FileInput/B03_FileInput_UI_Style_Card.css b/B03_FileInput/B03_FileInput_UI_Style_Card.css new file mode 100644 index 000000000..74cc766e2 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Style_Card.css @@ -0,0 +1,363 @@ +/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */ +.b03-file--busy { + pointer-events: none; +} + +.b03-file--busy .b03-file__card-select, +.b03-file--busy .b03-file__card-remove, +.b03-file--busy .b03-file__dropzone { + opacity: 0.5; +} + +/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */ +.b03-file__upload-spinner { + width: 14px; + height: 14px; + border-width: 2px; + border-color: rgb(255 255 255 / 40%); + border-top-color: var(--color-primary-text); +} + +/* Wiza 8px radius 카드 */ +/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) — + 파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */ +.b03-file__card { + padding: var(--spacing-12) var(--spacing-16); + border: 1px solid var(--color-mist, #e6e2e3); + border-radius: var(--radius-cards, 8px); + background: var(--color-canvas, #ffffff); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + transition: + transform var(--transition-base, 0.2s), + border-color var(--transition-base, 0.2s), + box-shadow var(--transition-base, 0.2s); +} + +.b03-file__card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + border-color: var(--color-royal-amethyst, #3e0079); +} + +.b03-file__card--selected { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-paper, #f6f7fa); +} + +.b03-file__card--uploading { + border-color: var(--color-royal-amethyst, #3e0079); + box-shadow: var(--shadow-lg-2); +} + +.b03-file__card--completed { + border-color: #10b981; /* Success Green */ +} + +.b03-file__card--error { + border-color: var(--color-danger, #dc2626); +} + +/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이 + 글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */ +/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로 + 내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */ +/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다 + (2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */ +.b03-file__card-header { + display: flex; + flex-wrap: nowrap; + gap: var(--spacing-8); + align-items: center; + min-width: 0; + max-width: 100%; +} + +.b03-file__card-header > * { + min-width: 0; +} + +.b03-file__card-icon { + flex: 0 0 auto; + width: 28px; + height: 28px; + border-radius: var(--radius-icons, 8px); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); + font-size: var(--text-body-sm, 14px); + margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */ +} + +.b03-file__card-heading { + min-width: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 1px; +} + +.b03-file__card-label { + color: var(--color-deep-iris, #26114a); + font-size: var(--text-body-sm, 14px); + font-weight: var(--font-weight-medium, 500); + /* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.b03-file__card-ext, +.b03-file__file-size, +.b03-file__progress-info { + color: var(--color-slate, #615e6e); + font-size: var(--text-caption, 12px); +} + +.b03-file__card-badge-container { + display: flex; + align-items: center; + margin-left: auto; + margin-right: var(--spacing-4); +} + +.b03-file__card-remove { + width: 24px; + height: 24px; + border: 1px solid var(--color-mist, #e6e2e3); + border-radius: var(--radius-buttons, 8px); + color: var(--color-slate, #615e6e); + background: var(--color-canvas, #ffffff); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-body-sm, 14px); + line-height: 1; + padding: 0; + margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */ + transition: all var(--transition-base, 0.2s); +} + +.b03-file__card-remove:hover { + color: var(--color-danger, #dc2626); + border-color: var(--color-danger, #dc2626); + background: #fef2f2; +} + +.b03-file__card-content { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */ +.b03-file__card--empty .b03-file__card-content { + display: none; +} + +/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */ +.b03-file__card-select { + flex: 0 0 auto; + min-height: 26px; + padding: 0 var(--spacing-8); + border: 1px solid var(--color-mist, #e6e2e3); + border-radius: var(--radius-buttons, 8px); + color: var(--color-deep-iris, #26114a); + background: var(--color-canvas, #ffffff); + font-weight: var(--font-weight-medium, 500); + font-size: var(--text-caption, 12px); + white-space: nowrap; + cursor: pointer; + transition: + background var(--transition-base, 0.2s), + border-color var(--transition-base, 0.2s); +} + +.b03-file__file-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.b03-file__file-name { + color: var(--color-deep-iris, #26114a); + font-size: var(--text-body-sm, 14px); + font-weight: var(--font-weight-medium, 500); + overflow-wrap: anywhere; +} + +/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면 + 읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */ +.b03-file__progress-section { + display: none; + flex-direction: column; + gap: var(--spacing-8); +} + +.b03-file__card--uploading .b03-file__progress-section { + display: flex; +} + +.b03-file__progress-bar-container { + width: 100%; + height: 6px; + overflow: hidden; + border-radius: var(--radius-pills, 1440px); + background: var(--color-mist, #e6e2e3); +} + +.b03-file__progress-bar { + width: 0; + height: 100%; + border-radius: var(--radius-pills, 1440px); + background: var(--color-royal-amethyst, #3e0079); + transition: width var(--transition-base, 0.2s); +} + +.b03-file__progress-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.b03-file__error-message { + display: none; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-cards, 8px); + color: var(--color-danger, #dc2626); + background: #fef2f2; + font-size: var(--text-caption, 12px); + border: 1px solid rgba(220, 38, 38, 0.15); +} + +.b03-file__card--empty .b03-file__file-info, +.b03-file__card--empty .b03-file__preview, +.b03-file__card--empty .b03-file__progress-section, +.b03-file__card--empty .b03-file__error-message { + display: none; +} + +/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형. + 값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */ +.b03-file__preview { + display: none; + margin-top: 6px; +} + +/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 + 파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */ +.b03-file__preview.is-visible { + display: flex; + grid-column: 1 / -1; + align-items: center; + justify-content: center; + width: 100%; + height: 120px; + overflow: hidden; + border: 1px solid var(--color-border, #dcdce6); + border-radius: var(--radius-cards, 8px); + background: var(--color-surface, #fff); +} + +.b03-file__preview svg { + display: block; + width: 100%; + height: 100%; +} + +/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */ +.b03-file__preview-cloud { + fill: none; + stroke: var(--color-text-muted, #5a5a72); + stroke-linecap: round; + stroke-opacity: 0.55; + stroke-width: 0.7; +} + +/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라 + 다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다 + (2026-09-04 사용자 지적). */ +.b03-file__preview-thumb { + max-width: 100%; + max-height: 100%; + border: 1px dashed var(--color-border, #9aa); + image-rendering: pixelated; + object-fit: contain; +} + +/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */ +.b03-file__preview-facts { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px; + text-align: center; +} + +.b03-file__preview-fact { + color: var(--color-text, #1c1c28); + font-size: var(--text-body, 15px); + font-weight: 600; +} + +.b03-file__preview-fact-sub { + color: var(--color-text-muted, #5a5a72); + font-size: var(--text-caption, 12px); +} + +.b03-file__preview-empty { + color: var(--color-text-muted, #8a8aa0); + font-size: var(--text-caption, 12px); +} + +.b03-file__preview-extent { + fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent); + stroke: var(--color-border, #9aa); + stroke-dasharray: 4 3; + stroke-width: 1; +} + +.b03-file__preview-route { + fill: none; + /* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서 + 붉은색으로 덮어써 경고로 쓴다. */ + stroke: var(--color-success, #16a34a); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; +} + +/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ +.b03-file__preview.is-warning .b03-file__preview-route { + stroke: var(--color-danger, #d33); +} + +.b03-file__preview.is-warning .b03-file__preview-extent { + stroke: var(--color-danger, #d33); + stroke-dasharray: 3 2; +} + +.b03-file__card--selected .b03-file__progress-section { + display: none; +} + +/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아 + 끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */ +.b03-file__card--uploading .b03-file__card-select { + display: none; +} + +.b03-file__card--error .b03-file__error-message { + display: block; +} + +.b03-file__card--error .b03-file__progress-section { + display: none; +} diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 6106be9bc..9b74f5d61 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)는 서버가 다시 계산하므로 뺀다. */ @@ -597,6 +599,8 @@ export interface DetailBasinResponse { stale?: string | null; /** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */ pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>; + /** 이 답을 셈한 노선(사업지 CRS m) — 배수유역도 노선선 · 관 자리가 유역과 짝(요구 326-6). */ + route_xy?: Array<[number, number]>; } let lastNote = { text: "", at: 0 }; @@ -622,16 +626,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_Engine_SheetClip.py b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py index cde050a88..92526711a 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py @@ -6,7 +6,8 @@ - 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠) - `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로 - 나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정). + 나가면 화면이 놓은 뒤 편집 중 노선 bbox(`route_bounds` · 508-2)로 다시 받는다 + (`clip_lonlat` 으로 판정). - `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2) + `DRAIN_SPARE_M`. - 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위) @@ -144,9 +145,25 @@ def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float, return points -def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None: - """범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None.""" - route = _bounds(_route_points(project_root)) +def parse_route_bbox(text: str | None) -> Rect | None: + """화면이 보낸 편집 중 노선 bbox(`x0,y0,x1,y1` m) — 숫자 넷 · 차례 맞음만 · 아니면 None.""" + try: + values = [float(part) for part in (text or "").split(",")] + except ValueError: + return None + if len(values) != 4 or not all(math.isfinite(v) for v in values): + return None + if values[0] > values[2] or values[1] > values[3]: + return None + return values[0], values[1], values[2], values[3] + + +def clip_rect_m( + project_root: Path, scope: str, meta: dict[str, Any], route_bounds: Rect | None = None +) -> Rect | None: + """범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None. + `route_bounds` = 저장 노선 대신 쓸 노선 bbox(편집 중 · 508-2).""" + route = route_bounds or _bounds(_route_points(project_root)) if route is None: return None if scope == "route": @@ -214,11 +231,18 @@ def _write_compact(path: Path, value: Any) -> None: raise -def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path: - """`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로.""" +def clipped_sheet( + project_root: Path, source: Path, scope: str, route_bounds: Rect | None = None +) -> Path: + """`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로. + 열쇠 = 사본 지문 + 범위 — 같은 범위면 노선을 어디서 재든 같은 저장본(다시 안 셈).""" try: meta = _read_meta(project_root / "B04_PreProcess" / "processed") - rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None + rect_m = ( + clip_rect_m(project_root, scope, meta, route_bounds) + if meta and scope in SCOPES + else None + ) if rect_m is None or meta is None: return source west, south = _to_lonlat(meta, rect_m[0], rect_m[1]) diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index eb85536ba..24928ccb5 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import copy +import dataclasses import hashlib import json import logging @@ -27,6 +28,7 @@ from uuid import UUID from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, drainage_dir from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, structure_type_map @@ -37,6 +39,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 +60,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 +75,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 +86,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 = [ @@ -124,14 +123,18 @@ def _rebuilt(detail: DrainageDetail) -> list[PipePoint]: def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageDetail: - """낡은 분석 — 세부 유역 없이 관 자리만(지금 노선 위) · 시설 속성은 넘겨받은 그대로.""" + """낡은 분석 — 마지막 [저장]의 옛 유역 그림(저장본 파일 · 그 노선) + 관 자리만 지금 노선 위 · + 저장본이 없으면 관 자리만(512-6 · 새로 연 화면도 옛 그림 · [업데이트] 로만 바꿈).""" + pipes = pipes_from_chainages(context.vertices, [point.chainage_m for point in points]) + _retag(pipes, points) + saved = _read_cache_file(context.stored_path) + if saved is not None: + return dataclasses.replace(saved["value"][0], pipes=pipes, pipe_candidates=[]) to_lonlat = context.to_lonlat - detail = DrainageDetail( + return DrainageDetail( route_lonlat=[list(to_lonlat(vertex.x, vertex.y)) for vertex in context.vertices], - pipes=pipes_from_chainages(context.vertices, [point.chainage_m for point in points]), + pipes=pipes, ) - _retag(detail.pipes, points) - return detail # 세부 유역 저장본(342-3 · 부록 4 R15) — 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 · @@ -141,14 +144,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"] @@ -171,6 +171,26 @@ def _cache_path(stored_path: str): return drainage_dir(stored_path) / DETAIL_CACHE_FILENAME +def _read_cache_file(stored_path: str) -> dict[str, Any] | None: + """[저장] 때 남긴 세부 유역 저장본 `{key, value: (detail, 관 목록)}` — 없거나 깨지면 None.""" + try: + with _cache_path(stored_path).open("rb") as file: + saved = pickle.load(file) + saved["value"][0].basins # 모양 확인 + return saved + except ( + OSError, + ValueError, + pickle.PickleError, + EOFError, + AttributeError, + KeyError, + TypeError, + IndexError, + ): + return None + + def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None: _DETAIL_CACHE.pop(key, None) _DETAIL_CACHE[key] = value @@ -179,20 +199,16 @@ 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: - with _cache_path(context.stored_path).open("rb") as file: - saved = pickle.load(file) - hit = saved["value"] if saved.get("key") == key else None - except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError): - hit = None + saved = _read_cache_file(context.stored_path) + hit = saved["value"] if saved is not None and saved.get("key") == key else 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 +304,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)) @@ -330,6 +334,12 @@ def _payload( payload["route_signature"] = route_signature(context.vertices) payload["pipe_signature"] = pipe_signature([pipe.chainage_m for pipe in detail.pipes]) payload["stale"] = STALE_MESSAGE if stale else None + if stale: # 노선선 = 옛 그림의 노선(512-6 · 저장본 없으면 지금 노선 그대로) + back = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True) + payload["route_xy"] = [ + [round(x, 3), round(y, 3)] + for x, y in (back.transform(lon, lat) for lon, lat in detail.route_lonlat) + ] return round_floats(payload, LONLAT_DIGITS) @@ -358,6 +368,8 @@ def _raw_payload( # ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어 # 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화). "route_lonlat": detail.route_lonlat, + # 이 답을 셈한 노선(사업지 CRS m) — B05 배수유역도 노선선 · 관 자리 · 유역이 늘 짝(요구 326-6) + "route_xy": [[round(v.x, 3), round(v.y, 3)] for v in context.vertices], # 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음). "main_polygon_lonlat": detail.basin_lonlat, "grid_cell_m": detail.grid_cell_m, @@ -480,16 +492,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 +510,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 +519,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 +553,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 +649,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_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index db067e4ab..42ad22b43 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -18,7 +18,10 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) -from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402 +from B04_PreProcess.B04_PreProcess_Engine_SheetClip import ( # noqa: E402 + clipped_sheet, + parse_route_bbox, +) # 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일) from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402 @@ -98,13 +101,18 @@ async def get_vworld_map( # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) async def get_project_geojson( - project_id: UUID, layer: str, request: Request, scope: str | None = None + project_id: UUID, + layer: str, + request: Request, + scope: str | None = None, + bbox: str | None = None, ) -> dict[str, Any] | Response | JSONResponse: """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. `scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6). + `bbox`(route 만 · 사업지 m `x0,y0,x1,y1`) = 저장 노선 대신 편집 중 노선 bbox(508-2). """ pool = get_db_pool() try: @@ -158,7 +166,10 @@ async def get_project_geojson( # 그대로고 뜻 없는 자리만 사라진다. display = await asyncio.to_thread(_display_copy, filepath) if scope: - display = await asyncio.to_thread(clipped_sheet, project_root, display, scope) + bounds = parse_route_bbox(bbox) if scope == "route" else None + display = await asyncio.to_thread( + clipped_sheet, project_root, display, scope, bounds + ) return cached_file_response(request, display, "application/geo+json") if layer == "등고선": 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..590182fdc 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -35,11 +35,18 @@ SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py", "B05_Profile/B05_Profile_Route_Save_Parts.py", "B05_Profile/B05_Profile_Plus_Stations.py", - "common_util/common_util_route_polyline.py", + "common_util/common_util_route_polyline*.py", "common_util/common_util_route_follow.py", "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_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 396cc8fc8..663a77c6c 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -47,7 +47,7 @@ SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial*.py", "B05_Profile/B05_Profile_Engine_Sections_Core.py", "B06_Section/B06_Section_Engine_Design*.py", - "common_util/common_util_route_polyline.py", + "common_util/common_util_route_polyline*.py", ) STORE_DIR = "B05_Profile/route_recommend" # 종류(갈래 · 지형) 마다 남길 벌 수 — 최근에 적은 것이 남음(꺼내기는 읽기만 · 505-1) 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_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 7e3c62b1c..136a0a038 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -157,15 +157,17 @@ async def compute_initial( forget_pool_on_standard(pool_key, inputs["standard"]) stored = await asyncio.to_thread(read_stored, project_root, key) if stored is None and not may_compute: - # 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧) - found = await asyncio.to_thread( - read_stored_any, - project_root, - pool_key, - lambda source: request_fingerprint( - route, terrain, streams, **inputs, source=source - )[0], - ) + # 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧 · 514-4) + found = None + for form in standard_forms(inputs["standard"]): + found = found or await asyncio.to_thread( + read_stored_any, + project_root, + pool_key, + lambda source, form=form: request_fingerprint( + route, terrain, streams, **{**inputs, "standard": form}, source=source + )[0], + ) if found is None: return JSONResponse( status_code=409, content={"status": "error", "message": NOT_PREPARED} @@ -249,6 +251,16 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None: return stored_standard_cross_section({"data": data if isinstance(data, dict) else None}) +def standard_forms(standard: dict[str, Any] | None) -> list[dict[str, Any] | None]: + """같은 뜻 표준 횡단면 꼴(지금 꼴 먼저) — 없음(None) = config 기본값 · 키 · 지문은 둘을 가름 · + [저장]은 손대지 않은 패널 기본값도 실어 보냄 → 프로젝트 접근은 두 꼴 저장본 다 꺼냄(514-4).""" + from config.config_system import STANDARD_CROSS_SECTION + + if standard is None: + return [None, STANDARD_CROSS_SECTION] + return [standard, None] if standard == STANDARD_CROSS_SECTION else [standard] + + async def precompute_defaults(project_id: UUID) -> None: """체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 × 고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만). diff --git a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py index 85309c903..aefeb5537 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py @@ -49,6 +49,7 @@ async def initial_profile_of( NOT_PREPARED, balanced_profile_of, project_standard, + standard_forms, ) from B06_Section.B06_Section_Repository import ( get_latest_section_options, @@ -113,10 +114,13 @@ async def initial_profile_of( "road": await replan._road_settings(project_id), } mark = ip.fingerprint(parts) - # 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧) - stored = await asyncio.to_thread( - ip.read_initial, project_root, key, mark, None if compute else parts - ) + # 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧) · 표준 두 꼴(514-4) + forms = standard_forms(parts["standard"]) + stored = None + for form in [None] if compute else [{**parts, "standard": f} for f in forms]: + stored = stored or await asyncio.to_thread( + ip.read_initial, project_root, key, mark, form + ) if stored is not None: return stored if not compute: 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_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index cd1ef7aed..7549b8d43 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -9,6 +9,8 @@ * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가 * 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 · * 올리지 않음. + * 4) 계산 자리 D3 = 서버(508-2 · 요구 324) — 낡은 저장본도 받아 옛 모형으로 세워 둠(빈 3D 아님) · + * [3D 업데이트]만 서버가 지금 입력으로 지어 바꿔 끼움(지문 같으면 그대로) · 짓기 실패 = 옛 모형 · 「늦음」 남김. * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). * ========================================================================== */ @@ -21,6 +23,7 @@ import { } from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; @@ -201,8 +204,29 @@ function withSections( return build; } +/** 낡은 저장본 그대로(열쇠 안 맞춤 · D3 = 서버 · 508-2) — 보관함에 안 담음(낡은 17MB 가 안 쌓이게). */ +async function fetchStaleStored( + projectId: string, + routeId: number, +): Promise { + try { + const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { + method: "GET", + }); + if (!response.ok) return null; + const payload = (await response.json()) as CorridorEnvelope; + if (payload?.version !== ENVELOPE_VERSION || !Array.isArray(payload.ribbons)) return null; + const build = deserialize(payload); + remember(keyOf(projectId, routeId), { hash: payload.hash, build }); + return build; + } catch { + return null; + } +} + /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). - * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ + * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). + * 돌려줌 = 3D 가 지금 입력과 맞나 — D3 = 서버 짓기 실패면 거짓 · 옛 모형 그대로(508-2). */ export async function refreshCorridor( viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, projectId: string, @@ -211,13 +235,18 @@ export async function refreshCorridor( routePoints: RoutePoint[], designSamples?: ProfileSamples, rebuild = true, -): Promise { - if (!routeId) return; +): Promise { + if (!routeId) return false; // [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1) if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0); + const keepOld = await calcOnServer("corridor_mesh"); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(withSections(build, detail))) - .catch(() => viewer.setCorridor(null)); + .then((build) => { + if (!build && keepOld) return false; + viewer.setCorridor(withSections(build, detail)); + return true; + }) + .catch(() => (keepOld ? false : (viewer.setCorridor(null), true))); } /** @@ -235,8 +264,13 @@ export function loadCorridorIfFresh( ): Promise { if (!routeId) return Promise.resolve(false); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) - .then((build) => { + .then(async (build) => { if (build) viewer.setCorridor(withSections(build, detail)); + // D3 = 서버 — 낡았으면 옛 저장본을 세워 둠(빈 3D 대신 · 「늦음」 표시는 부르는 쪽 · 508-2) + else if (await calcOnServer("corridor_mesh")) { + const stale = await fetchStaleStored(projectId, routeId); + if (stale) viewer.setCorridor(withSections(stale, detail)); + } return build !== null; }) .catch(() => false); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts index fe9cf0958..0f2629266 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -3,12 +3,14 @@ * 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드 * 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다. * 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다. + * 계산 자리 D3 = 서버면 워커 대신 서버 창구(`/calc` corridor.mesh · 같은 TS)가 지음(508-2 · [3D 업데이트] 때만). * ========================================================================== */ import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { deserialize, type CorridorEnvelope } from "./B05_Profile_UI_Corridor_Envelope"; import type { CorridorWorkerRequest, CorridorWorkerResponse, @@ -49,14 +51,40 @@ function getWorker(): Worker | null { return worker; } +/** 서버가 같은 입력으로 지음(508-2) — 계획선 샘플 = 넘긴 값(라이브 편집분) · 실패 = null(부르는 쪽이 옛 모형을 둠). */ +async function buildOnServer( + crossSections: CrossSection[], + routePoints: RoutePoint[], + designSamples?: Samples, +): Promise { + const detail = { + cross_sections: crossSections, + longitudinal: { design_profiles: [{ samples: designSamples }] }, + }; + try { + // 서버 갈래만 부름(both 묶음 · 워커 시험에 안 실림) + const { serverCalc } = await import("../A00_Common/design/design_calc_server"); + const results = await serverCalc("corridor.mesh", ["corridor.mesh"], { + detail, + route_points: routePoints, + }); + const envelope = results["corridor.mesh"] as CorridorEnvelope | null; + return envelope ? deserialize(envelope) : null; + } catch (error) { + console.warn("[corridor] 서버 짓기 실패", error); + return null; + } +} + /** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). - * 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */ + * 계산 자리 판(504-2) D3 = server 면 서버 창구가 지음(508-2 · 워커 안 띄움). */ export async function buildCorridorOffThread( crossSections: CrossSection[], routePoints: RoutePoint[], designSamples?: Samples, ): Promise { - if (await calcOnServer("corridor_mesh")) return null; + if (await calcOnServer("corridor_mesh")) + return buildOnServer(crossSections, routePoints, designSamples); const onMain = (): CorridorBuildResult | null => buildCorridor(crossSections, routePoints, designSamples); const target = getWorker(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index 7a82c1545..0aa64a118 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -18,10 +18,9 @@ export interface DrainageChrome { root: HTMLElement; panelHandle: ReturnType; layerButtons: HTMLElement; - analyzeButton: HTMLButtonElement; - deleteButton: HTMLButtonElement; - autoButton: HTMLButtonElement; - /** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */ + /** [업데이트](요구 326-6) — 지금 노선으로 처음부터 셈 · 배선은 `B05_Profile_UI_Drainage_Live`. */ + updateButton: HTMLButtonElement; + /** [자동 제안](510) — 후보 목록 · 배선은 패널(`B05_Profile_UI_Drainage_Propose`). */ proposeButton: HTMLButtonElement; viewport: HTMLElement; backgroundImage: HTMLImageElement; @@ -38,12 +37,6 @@ export interface DrainageChrome { export interface DrainageChromeLabels { title: string; - analyze: string; - analyzeTip: string; - deleteSelected: string; - deleteSelectedTip: string; - auto: string; - autoTip: string; imageAlt: string; statusNeedRoute: string; } @@ -77,32 +70,16 @@ export function createDrainageChrome( layerButtons.addEventListener("pointerdown", (event) => event.stopPropagation()); header.append(title); - // 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다. - // 격자 해석은 하지 않으므로 즉시 끝난다. - const analyzeButton = document.createElement("button"); - analyzeButton.type = "button"; - analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = labels.analyze; - analyzeButton.title = labels.analyzeTip; - // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; - deleteButton.textContent = labels.deleteSelected; - deleteButton.title = labels.deleteSelectedTip; - deleteButton.disabled = true; - // 배관 배치 초기화 — 편집한 배관 배치를 버리고 세류선 교차 자리만(초안 · 369). - const autoButton = document.createElement("button"); - autoButton.type = "button"; - autoButton.className = "b05-drainage__analyze b05-drainage__tool"; - autoButton.textContent = labels.auto; - autoButton.title = labels.autoTip; + const updateButton = document.createElement("button"); + updateButton.type = "button"; + updateButton.className = "b05-drainage__analyze b05-drainage__update"; + updateButton.textContent = "업데이트"; const proposeButton = document.createElement("button"); proposeButton.type = "button"; proposeButton.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__propose"; proposeButton.textContent = ui_locales.B05_Drainage_Btn_Propose[currentLanguageIndex]; proposeButton.title = ui_locales.B05_Drainage_Btn_Propose_Tip[currentLanguageIndex]; - header.append(analyzeButton, deleteButton, autoButton, proposeButton); + header.append(updateButton, proposeButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -160,9 +137,7 @@ export function createDrainageChrome( root, panelHandle, layerButtons, - analyzeButton, - deleteButton, - autoButton, + updateButton, proposeButton, viewport, backgroundImage, 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_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 4f4bc3896..8818be515 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -25,8 +25,7 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu"; /** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ const CLICK_SLOP_PX = 4; /** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */ -const HTML_CONTROLS = - "button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil"; +const HTML_CONTROLS = "button, input, select, textarea, a, label, [role='button']"; export interface DrainageInteractParams { viewport: HTMLElement; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 0a92686af..bd0520232 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -1,30 +1,26 @@ /* ============================================================================= * B05_Profile_UI_Drainage_Live.ts - * 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석 - * 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`. + * 배수유역 [업데이트](요구 326-6 · 347-2) — 묶음 drainage 단계 · 낡음 표시 · 구석 「계산 중」 · + * 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`. * * - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 — * 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음). - * - 노선 초안이 있으면 「낡음」 — 자동 = 흐림 없이 3 s 조용한 뒤 셈(구석 「계산 중」만) · 수동 = 칸만 - * 흐림 + 가운데 [업데이트]. 낡은 동안 제안 [적용]은 끔(두 모드 · 새 답이 오면 다시 켬). - * - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s. - * 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음. + * - 노선 초안이 있으면 「낡음」 — 저절로 셈하지 않음 · 옛 그림 그대로 · [업데이트] 단추에 표시만 + * (508-2 [3D 업데이트] 꼴). 낡은 동안 제안 [적용] · [자동 제안]은 끔(새 답이 오면 다시 켬). + * - [업데이트] = 늘 지금 노선(초안 · 저장 노선)으로 배수유역을 처음부터 셈 — 노선이 그대로여도. + * 뒤에서 조용히(덮개 · 잠금 없음) · 답이 오면 그림을 갈아 끼움. + * - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음. 묶음이 같은 노선 · 관으로 다시 + * 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음. * - 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 다시 받아(열 때와 같은 길 · 계산 아님) 그 답의 - * 낡음대로 — 떠 있던 초안용 답은 버림 · 자동도 3 s 셈 대신 저장분. - * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤. + * 낡음대로 — 떠 있던 초안용 답은 버림. + * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 낡음 표시. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. - * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. - * - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차 - * 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) · - * 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에 - * 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { openModal } from "@ui/ui_template_modal"; -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { setDrainageStale } from "../A00_Common/design/design_structure_ford_calc"; import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types"; import { computeDetailBasins, @@ -36,39 +32,36 @@ import { draftMakesStale, inputKey, liveRequestBody, - parseDrainageMode, - QUIET_MS, - type DrainageMode, } from "./logic/B05_Profile_Drainage_Live"; import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest"; -const MODE_KEY = "drainage-mode"; - export interface DrainageLiveDeps { - /** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */ + /** 패널 뿌리 — 머리([자동 제안]) · 지도(구석 「계산 중」)를 이 안에서 찾음. */ root: HTMLElement; + /** [업데이트] 단추(머리 줄 · `_Drainage_Chrome`). */ + updateButton: HTMLButtonElement; /** 제안 목록을 이 앞에 둠. */ basinList: HTMLElement; pipeRequest: () => DetailPipeInput[]; runner: DetailRunner; - /** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */ + /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */ rememberPipes: () => void; /** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */ loadSaved: () => Promise; + /** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(510). */ + onStale?: () => void; } export interface DrainageLive { /** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */ stage: StageFn<"drainage">; - /** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */ - refresh(): void; - /** 서버 답이 「낡음」 — 흐림 + [업데이트](자동이면 3 s 뒤 셈). 이미 낡음이면 그대로. */ + /** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */ + refresh(): Promise; + /** 서버 답이 「낡음」 — 낡음 표시. 이미 낡음이면 그대로. */ markStale(): void; - /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */ + /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청도 끊음). */ markFresh(): void; - /** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */ - resetToStreams(): void; readonly stale: boolean; suggest: DrainageSuggest; } @@ -76,7 +69,7 @@ export interface DrainageLive { /** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */ async function fetchLive( projectId: string, - body: ReturnType | (Proposal & { generation: number }), + body: ReturnType, signal: AbortSignal, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, { @@ -91,102 +84,76 @@ async function fetchLive( return payload; } -export function readDrainageMode(): DrainageMode { - return parseDrainageMode(readStateRaw(MODE_KEY)); -} - const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; -/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */ -const STALE_PREFIX = "배수유역 낡음"; - -/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */ -type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" }; +const UPDATE_LABEL = "업데이트"; +const UPDATE_TIP = + "지금 노선으로 배수유역을 처음부터 다시 셉니다 — 뒤에서 셈 · 끝나면 그림을 바꿈."; +const UPDATE_STALE_TIP = + "노선 · 관 자리가 바뀌어 배수유역도가 낡았습니다 — 누르면 지금 노선으로 다시 셈."; export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; const header = deps.root.querySelector(".b05-drainage__header") as HTMLElement; const propose = header.querySelector(".b05-drainage__propose"); - /** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */ - const draft = (request: (projectId: string) => Promise): void => { + const update = deps.updateButton; + const suggest = createDrainageSuggest(deps.pipeRequest, (points) => { const projectId = deps.runner.projectId(); if (!projectId) return; void deps.runner - .run(() => request(projectId)) + .run(() => computeDetailBasins(projectId, points)) .then((ok) => { - if (!ok) return; - deps.rememberPipes(); + if (ok) deps.rememberPipes(); }); - }; - const suggest = createDrainageSuggest(deps.pipeRequest, (points) => - draft((projectId) => computeDetailBasins(projectId, points)), - ); + }); deps.basinList.before(suggest.root); - let mode = readDrainageMode(); let route: unknown = null; let stale = false; /** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */ let drafted = false; /** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */ let savedStale = false; - /** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */ - let analysisStale = false; let generation = 0; /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ let key = ""; - let timer = 0; let inflight: AbortController | null = null; - const veil = document.createElement("div"); - veil.className = "b05-drainage__veil"; - veil.hidden = true; - const update = document.createElement("button"); - update.type = "button"; - update.className = "b05-drainage__analyze b05-drainage__update"; - update.textContent = "업데이트"; - update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다."; - veil.append(update); const busy = createProgressCircle({ size: 22, label: "계산 중" }); busy.root.classList.add("b05-drainage__busy"); busy.root.hidden = true; - viewport.append(veil, busy.root); - - const gear = document.createElement("button"); - gear.type = "button"; - gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear"; - gear.textContent = "⚙"; - gear.title = "배수유역 계산 설정 — 수동(기본) / 자동"; - header.append(gear); + viewport.append(busy.root); const paint = (): void => { - deps.root.classList.toggle("is-stale", stale && mode === "manual"); + deps.root.classList.toggle("is-stale", stale); + setDrainageStale(stale); // 관 카드 설계유량 가림(요구 328-3) + update.classList.toggle("is-pending", stale); + update.disabled = inflight !== null; + update.textContent = inflight ? "계산 중…" : UPDATE_LABEL; + update.title = stale ? UPDATE_STALE_TIP : UPDATE_TIP; suggest.setStale(stale); if (propose) { propose.disabled = stale; propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip"); } - veil.hidden = !stale || mode === "auto" || inflight !== null; busy.root.hidden = inflight === null; }; function cancel(): void { - window.clearTimeout(timer); - timer = 0; inflight?.abort(); inflight = null; } - /** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */ - function refresh(proposal?: Proposal): void { + /** 지금 노선으로 처음부터 셈 — 노선 초안이 있으면 그 노선 · 없으면 저장 노선. */ + function refresh(): Promise { const projectId = deps.runner.projectId(); - if (!projectId) return; + if (!projectId) return Promise.resolve(false); cancel(); const sent = { generation: ++generation, key }; const controller = new AbortController(); inflight = controller; paint(); - const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal }; - void deps.runner + const body = liveRequestBody(sent.generation, route, deps.pipeRequest()); + return deps.runner .run(async () => { const response = await fetchLive(projectId, body, controller.signal); // 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌. @@ -195,41 +162,23 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { return response; }, true) .then((applied) => { - if (inflight !== controller) return; + if (inflight !== controller) return applied; inflight = null; if (applied) stale = false; - if (applied && proposal) { - deps.rememberPipes(); - } paint(); + return applied; }); } - /** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(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); - } catch (error) { - if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error; - analysisStale = true; - queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음) - throw new DOMException("stale", "AbortError"); - } - }); - } - - /** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */ + /** 평면 · 관 자리 조작 — 떠 있는 요청을 끊고 낡음(저절로 셈하지 않음). */ function invalidate(): void { cancel(); stale = true; - if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); + deps.onStale?.(); paint(); } - /** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(흐림은 답이 올 때까지 그대로). */ + /** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(표시는 답이 올 때까지 그대로). */ function backToSaved(): void { cancel(); const sent = ++generation; @@ -238,82 +187,12 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { void deps.loadSaved().then((applied) => { if (sent !== generation) return; stale = !applied || savedStale; - if (stale && mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); paint(); }); } - function openSettings(): void { - openModal({ - title: "배수유역 계산", - closeLabel: "닫기", - mount(body, close) { - const choices: Array<[DrainageMode, string, string]> = [ - ["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"], - ["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"], - ]; - choices.forEach(([value, label, hint]) => { - const row = document.createElement("label"); - row.className = "b05-drainage__mode"; - const input = document.createElement("input"); - input.type = "radio"; - input.name = "b05-drainage-mode"; - input.checked = mode === value; - input.addEventListener("change", () => { - setMode(value); - close(); - }); - const text = document.createElement("span"); - text.textContent = `${label} — ${hint}`; - row.append(input, text); - body.append(row); - }); - }, - }); - } - - /** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */ - 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); - window.clearTimeout(timer); - timer = 0; - if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); - paint(); - } - - update.addEventListener("click", () => refresh()); - gear.addEventListener("click", openSettings); - propose?.addEventListener("click", openPropose); + update.addEventListener("click", () => void refresh()); + paint(); return { stage(ctx) { @@ -327,23 +206,19 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { else if (wasDraft) backToSaved(); return null; }, - refresh: () => refresh(), + refresh, suggest, markStale() { savedStale = true; - analysisStale = true; if (stale) return; stale = true; - if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); paint(); }, markFresh() { cancel(); stale = false; - analysisStale = false; paint(); }, - resetToStreams: () => proposeDraft({ points: null }), get stale() { return stale; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a947d28ca..35c48980e 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"; @@ -82,12 +83,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const chrome = createDrainageChrome( { title: L("B05_Drainage_Title"), - analyze: L("B05_Drainage_Btn_Analyze"), - analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"), - deleteSelected: L("B05_Drainage_Btn_DeleteSelected"), - deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"), - auto: L("B05_Drainage_Btn_Auto"), - autoTip: L("B05_Drainage_Btn_Auto_Tip"), imageAlt: L("B05_Drainage_ImageAlt"), statusNeedRoute: L("B05_Drainage_Status_NeedRoute"), }, @@ -101,9 +96,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra root, panelHandle, layerButtons, - analyzeButton, - deleteButton, - autoButton, + updateButton, + proposeButton, viewport, backgroundImage, canvas, @@ -243,7 +237,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, intervalSpans, - candidates: live.suggest.candidates(), }); updateImageTransform(); } @@ -258,10 +251,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); - /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ + /** 마커 선택 ↔ 유역 강조 동기화(편집 폼은 사이드 소관). */ function syncPipeSelection(notify = true): void { const index = pipeEditor.selected(); - deleteButton.disabled = index === null; const picked = index === null ? null : pipeEditor.pipes()[index]; selectBasin(basinIndexOfPipe(basins, picked ?? null), notify); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 @@ -302,12 +294,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra callbacks.onBasinSelected?.(picked ? picked.chainage_m : null); } - /** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다 - * — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */ + /** 응답을 화면 상태로 옮긴다. B04 지도와 같은 엔드포인트 · 같은 응답(2026-08-01 일원화). */ function apply(response: DetailBasinResponse): void { + // 낡은 답(노선 [저장] 뒤 등 · 세부 유역 없음) = 옛 그림 그대로 · 관만 받음 · [업데이트] 표시(326-6) + const keep = !!response.stale && basins.length > 0; + if (response.stale) live.markStale(); + if (keep) return void takePipes(response); basins = response.basins; live.suggest.update(response); - if (response.stale) live.markStale(); // 노선 [저장] 뒤 등 — 흐림 + [업데이트](자동이면 3 s 뒤) + // 노선선 = 이 답을 셈한 노선 — 유역 · 관 자리와 늘 짝(초안 노선 [업데이트] 뒤 빈 곳 · 326-6) + const answered = response.route_xy?.map(([x, y]) => ({ x, y })) ?? []; + if (answered.length) takeRoute(answered, false); mainBoundary = response.main_polygon_lonlat ?? []; upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; @@ -321,7 +318,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra maxStrength = built.maximum; legend.update(maxStrength, show.strength); maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0); - // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). + takePipes(response); + } + + /** 답의 관 목록(유역 없는 관 포함) · 시설 정보를 마커 · 폼에 올림(유역 그림은 그대로). */ + function takePipes(response: DetailBasinResponse): void { pipeEditor.setPipes( response.pipe_points.map((pipe) => ({ chainage_m: pipe.chainage_m, @@ -329,8 +330,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra id: pipe.id, })), ); - // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음). - const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits()); + // 시설 확장 정보는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이 먼저 · 낡은 답이면 뒤로 · 328-3). + const fordFilled = + !live.stale && fillFordHeights(response.pipe_points, response.basins, fordWaits()); facilityStore.replaceFromResponse(response.pipe_points); if (fordFilled) rememberPipes(); if (saved?.response === response) saved.request = JSON.stringify(pipeRequest()); @@ -349,7 +351,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra /** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */ const runner = createDetailRunner({ projectId: () => projectId, - analyzeButton, + updateButton, status, showProgress, apply, @@ -393,21 +395,54 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest()))); - // 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔). + // [업데이트](326-6 · 347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔). const live = createDrainageLive({ root, + updateButton, basinList, pipeRequest, runner, rememberPipes: () => (rememberPipes(), callbacks.onPipesDrafted?.()), // + 사진첩 한 걸음(369-4) loadSaved: () => run(loadSaved, true), // 초안 버림 = 저장분 조용히(349-0i) + onStale: () => propose.close(), }); - analyzeButton.addEventListener("click", () => void analyze()); - deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); - autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369) + // [자동 제안](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, + ), + ), + }); + basinList.before(propose.root); + proposeButton.addEventListener("click", () => void propose.open()); + + 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); + } + + let routeFromAnswer = false; // 노선선이 답에서 왔나 — 그 뒤 페이지 노선은 그림 안 바꿈(326-6) + + function takeRoute(points: ReadonlyArray, fit: boolean): void { + routeFromAnswer ||= !fit; + routePoints = points; + routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + // 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다. + strengthSamples = resampleRoute(points); + pipeEditor.setContext(meta, points); + if (routeLayer && fit) fitToRoute(); + scheduleDraw(); + } /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). - * * B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다 * (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다. * 노선이 없으면 배경 전체를 그대로 보여준다. */ @@ -596,13 +631,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 +639,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이 재계산을 돌리고, @@ -651,14 +681,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, setRoute(points) { - routePoints = points; - routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; - // 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다. - strengthSamples = resampleRoute(points); - pipeEditor.setContext(meta, points); - if (routeLayer) fitToRoute(); - scheduleDraw(); + if (!routeFromAnswer) takeRoute(points, true); // 페이지 노선 = 첫 답 전만 · 그 뒤 답의 노선(326-6) }, + stale: () => live.stale, + update: () => live.refresh(), dispose() { loadSequence += 1; if (frameHandle) { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index e30eba54a..4129e849d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,6 +52,10 @@ export interface DrainagePanel { removePipe: (chainageM: number) => void; /** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */ pipelineStage: StageFn<"drainage">; + /** 배수유역도가 낡았나(노선 · 관 자리가 바뀜 · 서버 「낡음」) — B06 [>] 확인 창(326-6). */ + stale: () => boolean; + /** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */ + update: () => Promise; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 042f952c1..5bef30fc6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -449,6 +449,7 @@ export async function fetchDrainageLayers( projectId: string, onProgress: (ratio: number) => void, scope: SheetScope | null = "drainage", + bbox?: string, ): Promise<{ meta: VWorldMeta; layers: Array; @@ -463,6 +464,7 @@ export async function fetchDrainageLayers( projectId, layer, scope ?? undefined, + bbox, ); return [layer, collection] as const; } catch { 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..a97bac2b7 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Propose.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Propose.ts + * 배수유역 칸 [자동 제안](510) — 서버가 후보를 셈(`post_pipe_proposals` · 세류선 교차 + 간격 보충 · + * 있는 관 · 지운 관 상태) · 화면은 목록만(점선 원 걷음 · 326-6) · 고른 것만 관 넣기 길(`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; +} + +export function createDrainagePropose(deps: { + projectId: () => string | null; + pipeRequest: () => DetailPipeInput[]; + /** 고른 후보를 관 넣기 길로(패널 `addPipe` 와 같은 길). */ + addPipes: (picks: PipeProposal[]) => 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(); + }; + + 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; + }; + + 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, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 706b7af6e..54395e6cb 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -70,8 +70,6 @@ export interface DrainageScene { stationIntervalM: number; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ intervalSpans: ReadonlyArray; - /** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */ - candidates: ReadonlyArray; } export function drawDrainageScene( @@ -170,20 +168,6 @@ export function drawDrainageScene( scene.maxHotspotArea, ); } - // 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음). - scene.candidates.forEach((chainage) => { - const sample = scene.strengthSamples[Math.round(chainage)]; - if (!sample) return; - const [x, y] = projector.toScreen(sample.x, sample.y); - context.save(); - context.setLineDash([3, 3]); - context.lineWidth = 2; - context.strokeStyle = "#1e88e5"; - context.beginPath(); - context.arc(x, y, 9, 0, Math.PI * 2); - context.stroke(); - context.restore(); - }); // 배관(관 매설) 마커 — 계획선 위. scene.pipeEditor.draw(context, view, scene.pipeColor); // 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Run.ts b/B05_Profile/B05_Profile_UI_Drainage_Run.ts index be63c32ef..b10a4de06 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Run.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Run.ts @@ -17,7 +17,7 @@ type StatusKey = export interface DetailRunnerDeps { projectId: () => string | null; - analyzeButton: HTMLButtonElement; + updateButton: HTMLButtonElement; status: HTMLElement; showProgress: (ratio: number | null, label: string | null) => void; apply: (response: DetailBasinResponse) => void; @@ -33,14 +33,14 @@ export interface DetailRunner { } export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner { - const { analyzeButton, status, showProgress, label } = deps; + const { updateButton, status, showProgress, label } = deps; let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 return { projectId: deps.projectId, async run(request, quiet = false) { if (!deps.projectId()) return false; if (!quiet) { - analyzeButton.disabled = true; + updateButton.disabled = true; status.hidden = false; status.textContent = label("B05_Drainage_Status_Analyzing"); showProgress(null, label("B05_Drainage_Status_Analyzing")); @@ -63,7 +63,7 @@ export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner { return false; } finally { if (seq === requestSeq) { - analyzeButton.disabled = false; + updateButton.disabled = false; showProgress(null, null); } } 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_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index f754a64f9..1997915b0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ @@ -81,7 +82,10 @@ export function createViewControls(callbacks: ViewControlCallbacks): { "3D 업데이트", () => { corridorRefresh.disabled = true; - corridorRefresh.textContent = "갱신 중…"; + // D3 = 서버 = 서버가 짓는 동안 옛 모형 그대로(508-2) + corridorRefresh.textContent = calcOnServerNow("corridor_mesh") + ? "서버에서 짓는 중…" + : "갱신 중…"; void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { corridorRefresh.disabled = false; corridorRefresh.textContent = "3D 업데이트"; 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_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index 9c71a8d0d..36bfb2404 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -48,6 +48,8 @@ export interface RouteProfilePanelCallbacks { onStructureSelect?: (structureId: string | null) => void; /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ onStructureMarkMove?: (structureId: string, toChainageM: number) => void; + /** 알약 · 평면 마커 우클릭으로 그 구조물(관 포함)을 지움(514-1). */ + onStructureMarkRemove?: (structureId: string) => void; /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ onStructureTypeAdd?: (chainageM: number, typeId: string) => void; /** 우클릭 군 · 종류 목록 = 사이드 「구조물 배치」 드롭다운 그대로(129-1 · B05 · B06 한 패널). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13880cf22..b1a92e3d2 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 { @@ -408,6 +408,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), onRemove: (station) => callbacks?.onStructureRemove?.(station), + onRemoveMark: (structureId) => callbacks?.onStructureMarkRemove?.(structureId), onRemovePlus: removePlusStation, onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), @@ -437,6 +438,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + onRemove: (structureId) => callbacks?.onStructureMarkRemove?.(structureId), // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) containerCount: (structure) => structure.type_id === "pipe" diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 0a777853b..aeaf3c66f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -11,11 +11,7 @@ import { pickPx } from "../A00_Common/touch_bridge"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { - irregularStationId, - isPipeStation, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; +import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance, @@ -60,6 +56,8 @@ export interface StructureLineOptions { maxChainageM: number; /** 선을 지울 때. */ onRemove: (station: IrregularStation) => void; + /** 알약(평면 마커)으로 구조물을 지울 때(514-1 · 측점선 없는 구조물도). */ + onRemoveMark?: (structureId: string) => void; /** 추가 측점을 지울 때(368-3 · 우클릭 「측점 삭제」). */ onRemovePlus?: (chainageM: number) => void; /** 빈 자리에서 우클릭해 배관을 넣을 때. */ @@ -72,16 +70,48 @@ export interface StructureLineOptions { onAddStructureType?: (chainageM: number, typeId: string) => void; } +/** 지금 선 종단 그래프의 메뉴 재료 — 평면 우클릭이 같은 것을 씀(513-1 · 종단도는 페이지에 한 벌). */ +let liveMenu: { host: HTMLElement; options: StructureLineOptions } | null = null; + +/** 종단 그래프 메뉴 재료 — 그래프가 아직 안 섰거나 떠났으면 null. */ +export function liveStructureMenu(): StructureLineOptions | null { + return liveMenu?.host.isConnected ? liveMenu.options : null; +} + +/** 우클릭 한 자리의 항목 — 가까운 측점이 있으면 삭제, 없으면 그 자리에 추가(종단 그래프 · 평면 공용 · 513-1). */ +export function structureMenuItemsAt( + options: StructureLineOptions, + chainageM: number, + near: IrregularStation | null, +): MapContextMenuItem[] { + if (near) { + if (near.id.startsWith(PLUS_OWNER_PREFIX)) + return [["측점 삭제", () => options.onRemovePlus?.(near.chainage_m)]]; + return [["구조물 삭제", () => options.onRemove(near)]]; // 관 · 세월교 · BOX 도 한 글(514-1) + } + const at = Number(Math.min(Math.max(chainageM, 0), options.maxChainageM).toFixed(2)); + // 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 + // 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를 + // 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다. + const groups = options.structureGroups?.() ?? []; + return groups.length + ? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId)) + : [ + ["배관 추가", () => options.onAddPipe(at)], + ...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ + `${type === "기타" ? "기타 구조물" : type} 추가`, + () => options.onAddStructure?.(at, type), + ]), + ]; +} + /** * 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도 - * 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다. + * 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(평면이 쓸 재료만 `liveMenu` 에 둠). */ export function mountStructureMenu(host: HTMLElement, options: StructureLineOptions): void { const menu = createMapContextMenu("b05-profile-chart"); - - function clamp(chainageM: number): number { - return Math.min(Math.max(chainageM, 0), options.maxChainageM); - } + liveMenu = { host, options }; // 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다. host.addEventListener("contextmenu", (event) => { @@ -91,40 +121,17 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti } const rect = host.getBoundingClientRect(); const localX = event.clientX - rect.left; - const chainage = clamp(options.chainageAt(localX)); // 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다. const near = options.stations .map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) })) .filter((entry) => entry.distance <= pickPx(GRAB_SLACK_PX)) .sort((left, right) => left.distance - right.distance)[0]; event.preventDefault(); - const at = Number(chainage.toFixed(2)); - // 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 - // 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를 - // 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다. - const groups = options.structureGroups?.() ?? []; - const addItems: MapContextMenuItem[] = groups.length - ? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId)) - : [ - ["배관 추가", () => options.onAddPipe(at)], - ...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ - `${type === "기타" ? "기타 구조물" : type} 추가`, - () => options.onAddStructure?.(at, type), - ]), - ]; - const plus = near?.station.id.startsWith(PLUS_OWNER_PREFIX); - menu.open(localX, event.clientY - rect.top, [ - ...(near - ? [ - (plus - ? ["측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)] - : [ - isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", - () => options.onRemove(near.station), - ]) as [string, () => void], - ] - : addItems), - ]); + menu.open( + localX, + event.clientY - rect.top, + structureMenuItemsAt(options, options.chainageAt(localX), near?.station ?? null), + ); }); host.addEventListener("pointerdown", (event) => { if (!menu.contains(event.target)) menu.close(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 7f137d4b3..689dec426 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -18,8 +18,13 @@ import type { AlignmentSegment, ProfileAlignment, } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers"; +import { + buildSelectedColumn, + displayStationLabel, + element, + isStraightCurve, +} from "./B05_Profile_UI_Profile_Table_Selected"; import { irregularStationId, isPipeStation, @@ -90,20 +95,6 @@ const CURVE_ROTATED_WIDTH_PX = 16; */ const SAME_STATION_TOLERANCE_M = 0.1; -/** - * 대수차가 이 값보다 작으면 **직선**으로 본다(%). - * - * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 - * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 - * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. - */ -const STRAIGHT_DELTA_PCT = 1e-4; - -/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ -function isStraightCurve(curve: AlignmentCurve): boolean { - return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; -} - /** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */ function isNearChainage(chainageM: number, list: ReadonlyArray): boolean { return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M); @@ -130,14 +121,6 @@ function fitFontSize(rowHeight: number, cellWidth: number): number { function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean { return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2; } - -function element(tag: string, className: string, text?: string): HTMLElement { - const node = document.createElement(tag); - node.className = className; - if (text !== undefined) node.textContent = text; - return node; -} - /** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void { node.style.left = `${centerX}px`; @@ -184,15 +167,6 @@ function createRow(className: string, label: string, unit: string): HTMLElement const SEGMENT_ROWS: SegmentRowSpec[] = [ { label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; - -/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */ -function displayStationLabel(chainageM: number, interval: number, stationOffset: number): string { - const base = stationLabel(chainageM, interval); // "X+YY.Y" - if (!stationOffset) return base; - const [number, remainder] = base.split("+"); - return `${Number(number) + stationOffset}+${remainder}`; -} - function buildStationRows( alignment: ProfileAlignment, interval: number, @@ -423,134 +397,6 @@ function buildCurveRows( return [lengthRow, radiusRow]; } -/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */ -function interpolateSample( - samples: ProfileAlignment["samples"], - chainageM: number, - key: "elevation_m" | "ground_elevation_m", -): number | null { - if (!samples.length) return null; - if (chainageM <= samples[0].chainage_m) return samples[0][key]; - const last = samples[samples.length - 1]; - if (chainageM >= last.chainage_m) return last[key]; - for (let index = 1; index < samples.length; index += 1) { - const current = samples[index]; - if (current.chainage_m < chainageM) continue; - const previous = samples[index - 1]; - const span = current.chainage_m - previous.chainage_m; - if (span <= 1e-9) return current[key]; - const ratio = (chainageM - previous.chainage_m) / span; - return previous[key] + (current[key] - previous[key]) * ratio; - } - return last[key]; -} - -/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */ -type CellValue = { text: string } | { left: string; right: string }; - -/** - * 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리· - * 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로 - * 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된 - * 중심을, 비정규 측점은 `x(chainage)`를 넘긴다. - */ -function buildSelectedColumn( - chainage: number, - alignment: ProfileAlignment, - centerX: number, - cellWidth: number, - interval: number, - display: { station: number; cumulative: number }, - onAdjustStation?: (chainageM: number, deltaM: number) => void, - isIrregular = false, - mc = false, -): HTMLElement { - const plan = interpolateSample(alignment.samples, chainage, "elevation_m"); - const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m"); - const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null; - const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null; - - // 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고, - // 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다. - const inside = alignment.segments.find( - (segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6, - ); - const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01); - const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01); - const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => { - if (inside) return { text: pick(inside).toFixed(digits) }; - const left = leftSeg ? pick(leftSeg).toFixed(digits) : ""; - const right = rightSeg ? pick(rightSeg).toFixed(digits) : ""; - return left && right ? { left, right } : { text: left || right || "" }; - }; - const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); - // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. - const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; - // 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. - // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변). - // 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. - const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [ - { value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S - { value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고 - { value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고 - { - value: { - text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""), - }, - modifier: mc ? "mc" : "", - }, // 측점 - { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L - { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R - ]; - const column = element( - "div", - `b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`, - ); - column.style.left = `${centerX}px`; - column.style.width = `${cellWidth}px`; - rows.forEach((row) => { - const cell = element( - "div", - `b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`, - ); - if (row.plan && onAdjustStation && plan !== null) { - // 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용). - const input = document.createElement("input"); - input.type = "number"; - input.step = "any"; - input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin"; - input.value = plan.toFixed(2); - input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`; - input.addEventListener("change", () => { - const target = Number.parseFloat(input.value); - if (Number.isFinite(target)) onAdjustStation(chainage, target - plan); - }); - cell.append(input); - } else if ("left" in row.value) { - // 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다). - cell.classList.add("is-split"); - cell.append( - element("span", "b05-profile-table__irregular-col-half", row.value.left), - element("span", "b05-profile-table__irregular-col-half", row.value.right), - ); - } else { - cell.textContent = row.value.text; - } - column.append(cell); - }); - // 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. - const cutFillText = - cut !== null - ? ` · 절토 ${cut.toFixed(2)}m` - : fill !== null - ? ` · 성토 ${fill.toFixed(2)}m` - : ""; - column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${( - chainage + display.cumulative - ).toFixed(2)}m${cutFillText}`; - return column; -} - export function createProfileTable(options: ProfileTableOptions): HTMLElement { const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options; const display = options.stationDisplay ?? { station: 0, cumulative: 0 }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts b/B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts new file mode 100644 index 000000000..f6edcfee2 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Table_Selected.ts + * 종단면도 하단 표 — 고른 측점의 **값 열 오버레이**(`buildSelectedColumn`)와 표 본체가 같이 쓰는 조각 + * (`element` · `displayStationLabel` · `isStraightCurve`). 표 본체는 `B05_Profile_UI_Profile_Table`. + * ========================================================================== */ + +import type { + AlignmentCurve, + AlignmentSegment, + ProfileAlignment, +} from "./logic/B05_Profile_UI_Profile_Alignment"; +import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; + +/** + * 대수차가 이 값보다 작으면 **직선**으로 본다(%). + * + * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 + * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 + * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. + */ +const STRAIGHT_DELTA_PCT = 1e-4; + +/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ +export function isStraightCurve(curve: AlignmentCurve): boolean { + return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; +} + +export function element(tag: string, className: string, text?: string): HTMLElement { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */ +export function displayStationLabel( + chainageM: number, + interval: number, + stationOffset: number, +): string { + const base = stationLabel(chainageM, interval); // "X+YY.Y" + if (!stationOffset) return base; + const [number, remainder] = base.split("+"); + return `${Number(number) + stationOffset}+${remainder}`; +} + +/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */ +function interpolateSample( + samples: ProfileAlignment["samples"], + chainageM: number, + key: "elevation_m" | "ground_elevation_m", +): number | null { + if (!samples.length) return null; + if (chainageM <= samples[0].chainage_m) return samples[0][key]; + const last = samples[samples.length - 1]; + if (chainageM >= last.chainage_m) return last[key]; + for (let index = 1; index < samples.length; index += 1) { + const current = samples[index]; + if (current.chainage_m < chainageM) continue; + const previous = samples[index - 1]; + const span = current.chainage_m - previous.chainage_m; + if (span <= 1e-9) return current[key]; + const ratio = (chainageM - previous.chainage_m) / span; + return previous[key] + (current[key] - previous[key]) * ratio; + } + return last[key]; +} + +/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */ +type CellValue = { text: string } | { left: string; right: string }; + +/** + * 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리· + * 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로 + * 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된 + * 중심을, 비정규 측점은 `x(chainage)`를 넘긴다. + */ +export function buildSelectedColumn( + chainage: number, + alignment: ProfileAlignment, + centerX: number, + cellWidth: number, + interval: number, + display: { station: number; cumulative: number }, + onAdjustStation?: (chainageM: number, deltaM: number) => void, + isIrregular = false, + mc = false, +): HTMLElement { + const plan = interpolateSample(alignment.samples, chainage, "elevation_m"); + const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m"); + const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null; + const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null; + + // 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고, + // 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다. + const inside = alignment.segments.find( + (segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6, + ); + const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01); + const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01); + const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => { + if (inside) return { text: pick(inside).toFixed(digits) }; + const left = leftSeg ? pick(leftSeg).toFixed(digits) : ""; + const right = rightSeg ? pick(rightSeg).toFixed(digits) : ""; + return left && right ? { left, right } : { text: left || right || "" }; + }; + const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); + // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. + const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; + // 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. + // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변). + // 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. + const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [ + { value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S + { value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고 + { value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고 + { + value: { + text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""), + }, + modifier: mc ? "mc" : "", + }, // 측점 + { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L + { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R + ]; + const column = element( + "div", + `b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`, + ); + column.style.left = `${centerX}px`; + column.style.width = `${cellWidth}px`; + rows.forEach((row) => { + const cell = element( + "div", + `b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`, + ); + if (row.plan && onAdjustStation && plan !== null) { + // 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용). + const input = document.createElement("input"); + input.type = "number"; + input.step = "any"; + input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin"; + input.value = plan.toFixed(2); + input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`; + input.addEventListener("change", () => { + const target = Number.parseFloat(input.value); + if (Number.isFinite(target)) onAdjustStation(chainage, target - plan); + }); + cell.append(input); + } else if ("left" in row.value) { + // 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다). + cell.classList.add("is-split"); + cell.append( + element("span", "b05-profile-table__irregular-col-half", row.value.left), + element("span", "b05-profile-table__irregular-col-half", row.value.right), + ); + } else { + cell.textContent = row.value.text; + } + column.append(cell); + }); + // 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. + const cutFillText = + cut !== null + ? ` · 절토 ${cut.toFixed(2)}m` + : fill !== null + ? ` · 성토 ${fill.toFixed(2)}m` + : ""; + column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${( + chainage + display.cumulative + ).toFixed(2)}m${cutFillText}`; + return column; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 86ad60483..85faf4c1d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -25,9 +25,12 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { showToast } from "@ui/ui_template_elements"; import { bindMinorContourToggle, + createSheetFollow, fetchRouteSheets, loadRouteEditBackground, + contourHintText, type RouteEditContours, + type SheetLayers, } from "./B05_Profile_UI_RouteEdit_Contour"; import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { @@ -37,7 +40,7 @@ import { } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve"; -import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; +import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; @@ -74,6 +77,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)보다 **넉넉히** 둔다 @@ -226,6 +230,8 @@ export async function mountRouteEditor( mapRect: computeMapRect(null, 0, 0), }; let closed = false; + /** 놓은 뒤 노선이 도엽 범위 밖이면 다시 받음(508-2 ③) — 자료를 읽은 뒤 섬. */ + let sheetFollow: ReturnType | null = null; /** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */ const rotation = createMapRotation({ overlay, @@ -250,6 +256,7 @@ export async function mountRouteEditor( unbindApply(); // 노선 저장 등록부에서 풂(445-2) viewMemory.flush(); closed = true; + sheetFollow?.dispose(); sizeWatch.disconnect(); themeWatch(); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다. @@ -366,19 +373,10 @@ export async function mountRouteEditor( nodeInfo = built.nodes; } - /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 - * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ + /** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */ const routeHead = (): string => - `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + - `계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + - `노드 ${planned.length}개`; - - /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ - const contourHint = (): string => { - if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; - const level = contours?.layer.features[pickedContour]?.labelValue ?? null; - return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; - }; + routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length); + const contourHint = (): string => contourHintText(contours, pickedContour); /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ const curveHint = (): string => @@ -434,7 +432,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(); @@ -442,6 +446,7 @@ export async function mountRouteEditor( draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); + sheetFollow?.poke(); } /** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */ @@ -600,7 +605,7 @@ export async function mountRouteEditor( try { const [plan, drainage, sources] = await Promise.all([ fetchRoutePlan(projectId), - fetchRouteSheets(projectId, () => plannedLine), + fetchRouteSheets(projectId, () => (planned.length ? planned : plannedLine)), // 띠 = 편집 노드 fetchTerrainSources(projectId).catch(() => null), ]); if (closed) return; @@ -635,13 +640,20 @@ export async function mountRouteEditor( terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`; // 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. // 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽). - const loadBackground = () => - loadRouteEditBackground(projectId, drainage, { - surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null, - intervalM: options.contourIntervalM ?? 1, - smooth: options.smooth ?? false, - }); + let sheets: SheetLayers = drainage; + const loadBackground = (reuse: RouteEditContours | null = null) => + loadRouteEditBackground( + projectId, + sheets, + { + surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null, + intervalM: options.contourIntervalM ?? 1, + smooth: options.smooth ?? false, + }, + reuse, + ); const useBackground = (background: Awaited>): void => { + sheets = background.sheets; otherSheets = background.otherSheets; contours = background.contours; pickedContour = contourLock.reattach(contours?.layer ?? null); @@ -660,6 +672,15 @@ export async function mountRouteEditor( }; useBackground(await loadBackground()); if (closed) return; + sheetFollow = createSheetFollow( + projectId, + () => sheets, + async (next) => { + sheets = next; + const background = await loadBackground(contours); + if (!closed) (useBackground(background), draw()); + }, + ); viewWanted = true; resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index dea91d8fc..00e895126 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -1,5 +1,5 @@ import { createButton } from "@ui/ui_template_elements"; -import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels"; +import { createSidePanels } from "@ui/ui_template_side_panels"; import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks"; import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift"; @@ -65,9 +65,6 @@ export interface RouteEditChrome { /** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */ const SIDE_HANDLE_PX = 32; -/** 횡단 판 몸 → 패널 묶음 — 고른 측점 횡단을 띄울 때 접힌 판을 폄(382-2 · `_Cross`). */ -export const sidePanelsOf = new WeakMap(); - // 글판 접기 기억(345-5 덧 3) — 페이지 동안만(모듈 값) · 다시 열어도 그대로. let hintOpen = false; let infoCollapsed = false; @@ -155,7 +152,7 @@ export function createRouteEditChrome( overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools); // 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 · - // 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 손잡이로 연 것만 기억 · 잰 값 창이 판을 비킴(--b05-side-open). + // 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 열림 · 닫힘은 손잡이만(513-2) · 잰 값 창이 판을 비킴(--b05-side-open). const side = document.createElement("div"); side.className = "b05-routeedit__side"; const setSideOpen = (): void => @@ -172,7 +169,6 @@ export function createRouteEditChrome( onChange: setSideOpen, onResize: setSideOpen, }); - sidePanelsOf.set(side, panels); overlay.querySelector(".b05-routeedit__side-wrap")!.append(panels.root); setSideOpen(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 0805c6e14..84796ff7d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -23,6 +23,7 @@ import { prepareMetricPolylines, } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { QUIET_MS } from "../A00_Common/design/design_calc_server"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render"; @@ -47,6 +48,13 @@ export interface RouteEditContours { minor: (() => PreparedLayer) | null; } +/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ +export function contourHintText(contours: RouteEditContours | null, picked: number): string { + if (picked < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; + const level = contours?.layer.features[picked]?.labelValue ?? null; + return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; +} + interface ContourResponse { contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>; } @@ -128,7 +136,21 @@ export function sheetCoversBand( ); } -type SheetLayers = { +/** 범위 눈금(m) — 서버 `SNAP_M` 과 같음 · 노선을 조금 옮겨도 같은 주소 · 같은 저장본. */ +const SHEET_SNAP_M = 50; + +/** 편집 중 노선 bbox(`x0,y0,x1,y1` m · 눈금 바깥 맞춤) — 서버가 띠 + 여유를 더해 자름(508-2). */ +export function routeBboxParam(line: ReadonlyArray): string { + const xs = line.map((point) => point[0]); + const ys = line.map((point) => point[1]); + const down = (value: number): number => Math.floor(value / SHEET_SNAP_M) * SHEET_SNAP_M; + const up = (value: number): number => Math.ceil(value / SHEET_SNAP_M) * SHEET_SNAP_M; + return [down(Math.min(...xs)), down(Math.min(...ys)), up(Math.max(...xs)), up(Math.max(...ys))] + .map(String) + .join(","); +} + +export type SheetLayers = { meta: VWorldMeta; layers: ReadonlyArray; /** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */ @@ -143,36 +165,84 @@ export async function fetchRouteSheets( return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line }; } +/** 지금 노선 띠를 덮는 도엽 — 덮으면 `clipped` 그대로 · 밖이면 지금 노선 범위로 잘라 다시 받음(508-2 ③). */ +export async function coveringSheets( + projectId: string, + clipped: SheetLayers, +): Promise { + const line = clipped.line?.() ?? []; + const covered = clipped.layers.every(([, collection]) => + sheetCoversBand(collection, clipped.meta, line, CONTOUR_BAND_M), + ); + if (covered) return clipped; + const bbox = routeBboxParam(line); + return { ...(await fetchDrainageLayers(projectId, () => {}, "route", bbox)), line: clipped.line }; +} + /** * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). * 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의 - * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다. + * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 지금 노선 범위로 다시 받는다(508-2). + * `reuse` = 앞 등고선 — LAS 것이면 그대로(도엽 범위와 상관없음 · 다시 안 읽음). */ export async function loadRouteEditBackground( projectId: string, clipped: SheetLayers, options: { surfaceModelId: number | null; intervalM: number; smooth: boolean }, -): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> { - // ponytail: 여는 때 · 지형 바꿀 때만 잼 — 편집 도중 여유 밖으로 끌면 다음 열 때 통째로 받음. - const drainage = clipped.layers.every(([, collection]) => - sheetCoversBand(collection, clipped.meta, clipped.line?.() ?? [], CONTOUR_BAND_M), - ) - ? clipped - : await fetchDrainageLayers(projectId, () => {}, null); + reuse: RouteEditContours | null = null, +): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> { + const drainage = await coveringSheets(projectId, clipped); const normalizer = createNormalizer(drainage.meta); const otherSheets = drainage.layers .filter(([layer]) => layer !== "도엽_등고선") .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) .filter((layer): layer is PreparedLayer => layer !== null); const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null; - const contours = await loadRouteEditContours( - projectId, - drainage.meta, - normalizer, - sheet, - options, - ); - return { otherSheets, contours }; + const contours = + reuse?.source === "las" + ? reuse + : await loadRouteEditContours(projectId, drainage.meta, normalizer, sheet, options); + return { otherSheets, contours, sheets: drainage }; +} + +/** + * 놓은 뒤(잠잠 `QUIET_MS`) 편집 노선 띠가 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아 + * 바꿔 끼움(508-2 ③) — 바꿔 끼우는 곳 = `swap` 한 자리. 받는 중 새 걸음은 끝난 뒤 다시 잼. + */ +export function createSheetFollow( + projectId: string, + current: () => SheetLayers | null, + swap: (next: SheetLayers) => Promise, +): { poke: () => void; dispose: () => void } { + let timer: number | undefined; + let running = false; + let again = false; + let disposed = false; + const check = async (): Promise => { + const sheets = current(); + if (!sheets || disposed) return; + if (running) return void (again = true); + running = true; + try { + const next = await coveringSheets(projectId, sheets); + if (next !== sheets && !disposed) await swap(next); + } catch { + /* 못 받으면 지금 바탕 그대로 — 다음 놓기에 다시 잼. */ + } finally { + running = false; + if (again && !disposed) ((again = false), void check()); + } + }; + return { + poke: () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void check(), QUIET_MS); + }, + dispose: () => { + disposed = true; + window.clearTimeout(timer); + }, + }; } /** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 6b637970b..2c4abe96f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -46,7 +46,6 @@ import { import { readStateRaw } from "../A00_Common/b_page_state"; import { pageRouteId } from "../A00_Common/design/design_page_route"; import { stationLabel as formatStation } from "../common_util/common_util_station"; -import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; import { routeCrossCard, @@ -320,13 +319,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind stopPick(); }; - /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄(`open`). */ - function showStation( - key: string | null, - chainageM: number, - structure = false, - open = true, - ): void { + /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 접힌 채 내용만(513-2). */ + function showStation(key: string | null, chainageM: number, structure = false): void { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. previous.show(null, interval()); @@ -334,12 +328,10 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind drawnRoute = pipe?.input("route") ?? null; shown = null; redraw(); - const panels = sidePanelsOf.get(params.side); - if (open && panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1) } /** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */ - function follow(selection: DesignSelection, open = true): void { + function follow(selection: DesignSelection): void { attach(); const at = selection.chainageM; if (at === null) { @@ -352,16 +344,16 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind } const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); const structure = !!selection.structureId || (!!station && !isGridStation(station)); - showStation(station?.key ?? null, station?.chainageM ?? at, structure, open); + showStation(station?.key ?? null, station?.chainageM ?? at, structure); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { // 평면 이름 = 눈금 누름(제가 이미 그림)만 거름 · 마커 고름 풂(값 없음)은 따라 비움(500-1) if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection); }); attach(); - // 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄 + // 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) const picked = readSelection(params.projectId); - if (picked) follow(picked, false); + if (picked) follow(picked); /** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */ const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => { 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..c5c0c2e11 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -13,6 +13,7 @@ import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProce import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys"; import { bindStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks"; +import { bindPlanStructureMenu } from "./B05_Profile_UI_RouteEdit_StructureMenu"; import { dragHandleTo as curveDragTo, type EditedCurve, @@ -33,6 +34,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 +190,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; @@ -203,6 +213,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); + bindPlanStructureMenu(params, nodeAt); // 우클릭 = 종단 그래프와 같은 구조물 메뉴(513-1) /** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */ const chooseAt = (px: number, py: number) => { const pick = pickAt(px, py); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index a58cbe090..8033af8c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -4,7 +4,7 @@ * 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 · * 못 지킨 구간 · 오른쪽 위 [적용] · [닫기]. * 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값. - * [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`). + * [재계산] = 저장본 없음(409) 안내 줄 옆 · 칸을 바꾼 뒤 종단 반영 오른쪽에만(508-2) — 서버 저장본을 지우고 새로 셈(`recompute`). * * 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은 * 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는 @@ -129,6 +129,8 @@ const CRITERIA_FIELD: Record< }; const TERRAIN_NAME: Record = { las: "라이다", sheet: "수치지형도" }; +/** 서버 `NOT_PREPARED`(409 · 505-1) 글 머리 — 이 글이면 [재계산]을 안내 줄 옆에. */ +const NOT_PREPARED_HEAD = "추천 저장본이 없습니다"; export interface RecommendParams { /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ @@ -184,6 +186,8 @@ export function openRecommendModal(params: RecommendParams): void { let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; + /** 마지막 셈이 저장본 없음(409 · 505-1) — [재계산]을 안내 줄 옆에 보임(508-2). */ + let missing = false; let closed = false; let seq = 0; /** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */ @@ -439,7 +443,10 @@ export function openRecommendModal(params: RecommendParams): void { input.disabled = busy; }); q("[data-target-row]").hidden = logic !== "gradeline"; - // 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421) + // 저장본 없음 = 안내 줄 옆 · 칸을 바꿈 = 종단 반영 오른쪽 · 그 밖엔 숨김(508-2) + rerun.hidden = !(missing || dirty); + if (missing) stateEl.after(rerun); + else profileToggle.after(rerun); rerun.disabled = busy || invalid.size > 0; terrainSelect.disabled = busy; profileToggle.disabled = busy; @@ -517,6 +524,7 @@ export function openRecommendModal(params: RecommendParams): void { const withProfile = profileOn; busy = true; dirty = false; + missing = false; preview = null; // 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4). const started = Date.now(); @@ -573,6 +581,7 @@ export function openRecommendModal(params: RecommendParams): void { // 서버 사유를 판에 남김 — 저장본 없음이면 「전처리를 다시 하면 생깁니다」(505-1) const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다."; stateEl.textContent = message; + missing = message.includes(NOT_PREPARED_HEAD); showToast(message, "error"); } finally { if (mine === seq) window.clearInterval(ticker); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 6aedb6f1f..2f1658c37 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -66,6 +66,16 @@ export function polylineLengthM(points: ReadonlyArray): number { return total; } +/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 + * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ +export const routeHeadText = ( + expected: ReadonlyArray, + line: ReadonlyArray, + nodeCount: number, +): string => + `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + + `계획노선 ${polylineLengthM(line).toFixed(1)}m · 노드 ${nodeCount}개`; + export interface RouteEditScene { view: ViewState; /** 사업지 좌표(m) → 캔버스 px. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index 00a6f244a..06c66cbf8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -258,6 +258,26 @@ function showBalloon( : `translate(-50%, calc(-100% - ${gap}px))`; } +/** 화면 자리에서 가장 가까운 마커 — 집기 거리 밖이면 null(누름 · 우클릭 삭제 514-1 공용). */ +export function planMarkAt( + canvas: HTMLCanvasElement, + line: ReadonlyArray, + toScreen: (point: Vertex) => [number, number], + px: number, + py: number, +): { mark: PlanMark; distance: number } | null { + let best: PlanMark | null = null; + let bestDistance = pickPx(MARK_HIT_PX); + for (const mark of planMarks(canvas)?.marks ?? []) { + const frame = stationFrameAt(line, mark.chainageM); + if (!frame) continue; + const [x, y] = toScreen(frame.origin); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; + } + return best ? { mark: best, distance: bestDistance } : null; +} + export interface StructureMarkInput { /** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */ press: (px: number, py: number) => boolean; @@ -288,20 +308,8 @@ export function bindStructureMarks( const state = params.state(); return state.plannedLine.length ? state.plannedLine : state.planned; }; - const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => { - const found = planMarks(canvas); - const line = lineNow(); - let best: PlanMark | null = null; - let bestDistance = pickPx(MARK_HIT_PX); - for (const mark of found?.marks ?? []) { - const frame = stationFrameAt(line, mark.chainageM); - if (!frame) continue; - const [x, y] = params.toScreen(frame.origin); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; - } - return best ? { mark: best, distance: bestDistance } : null; - }; + const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => + planMarkAt(canvas, lineNow(), params.toScreen, px, py); const say = (moving: Moving, done: boolean): void => { const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; const shift = moving.to - moving.from; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts new file mode 100644 index 000000000..a57aae7fa --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_StructureMenu.ts + * 평면 우클릭 구조물 메뉴(513-1 · 요구 327-1). + * + * · 메뉴 = 종단 그래프 우클릭과 같은 항목 · 같은 동작(`structureMenuItemsAt` · 재료 = 지금 선 종단 그래프). + * · 자리 = 누른 곳을 노선에 내린 누가거리 · 가까운 구조물 · 배관 측점이면 삭제 · 측점선 없는 마커도(514-1). + * · 노드 위 = 옛 동작 그대로(노드 삭제 · `bindNodeSplice`) · 노선에서 먼 곳 = 메뉴 없음. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { liveStructureMenu, structureMenuItemsAt } from "./B05_Profile_UI_Profile_Structures"; +import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; +import { planMarkAt } from "./B05_Profile_UI_RouteEdit_StructureMarks"; +import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; +import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; + +/** 노선 위로 볼 거리(px) — 이보다 멀면 구조물 메뉴를 안 띄움. */ +const ROUTE_HIT_PX = 12; +/** 구조물 · 배관 측점을 잡았다고 볼 거리(px) — 평면 마커 집기와 같은 값. */ +const STATION_HIT_PX = 10; + +/** 평면 캔버스에 구조물 우클릭 메뉴를 붙임 — 캔버스와 수명이 같음. */ +export function bindPlanStructureMenu( + params: RouteEditPointerParams, + nodeAt: (px: number, py: number) => number, +): void { + const { canvas, toScreen } = params; + const menu = createMapContextMenu("b05-profile-chart"); + canvas.parentElement?.append(menu.element); + + canvas.addEventListener("contextmenu", (event) => { + menu.close(); // 노선 밖 · 노드 위 우클릭이면 옛 메뉴도 닫힌 채 + const options = liveStructureMenu(); + const rect = canvas.getBoundingClientRect(); + const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + if (!options || nodeAt(px, py) >= 0) return; // 노드 삭제 몫 + const state = params.state(); + const line = state.plannedLine.length ? state.plannedLine : state.planned; + const hit = routePointAtScreen(line, toScreen, px, py, pickPx(ROUTE_HIT_PX)); + if (!hit) return; + const near = options.stations + .map((station) => { + const frame = stationFrameAt(line, station.chainage_m); + const [x, y] = frame ? toScreen(frame.origin) : [Infinity, Infinity]; + return { station, distance: Math.hypot(x - px, y - py) }; + }) + .filter((entry) => entry.distance <= pickPx(STATION_HIT_PX)) + .sort((left, right) => left.distance - right.distance)[0]; + const host = menu.element.parentElement?.getBoundingClientRect() ?? rect; + const mark = near ? null : planMarkAt(canvas, line, toScreen, px, py)?.mark; + menu.open( + event.clientX - host.left, + event.clientY - host.top, + mark + ? [["구조물 삭제", () => options.onRemoveMark?.(mark.id)]] + : structureMenuItemsAt(options, hit.chainageM, near?.station ?? null), + ); + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 87b64fd38..1f711a65c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -16,6 +16,7 @@ * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; +import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { structureAnchorM, type StructureInstance, @@ -73,6 +74,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; + /** 우클릭 「구조물 삭제」(514-1 · 측점선 없는 알약도) — 없으면 메뉴 없음. */ + onRemove?: (structureId: string) => void; /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } @@ -162,6 +165,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const structures = laneStructures(options.structures); + const menu = createMapContextMenu("b05-profile-chart"); // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. const texts = structurePillTexts(structures, typeMap); @@ -227,6 +231,16 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement options.onSelect(selected ? null : id); }); attachMarkDrag(mark, structure, options); + mark.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + const remove = options.onRemove; + if (!remove) return; + const rect = lane.getBoundingClientRect(); + menu.open(event.clientX - rect.left, event.clientY - rect.top, [ + ["구조물 삭제", () => remove(id)], + ]); + }); lane.append(mark); if (selected) @@ -257,6 +271,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement axisInner.textContent = "구조물"; axis.append(axisInner); lane.prepend(axis); + lane.append(menu.element); return lane; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index f87c0a3da..e6342c6ee 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -597,7 +597,6 @@ export function createStructuresSection( if (moved) callbacks.onPipeSelect(moved.chainage_m); // 고름(알약 · 측점선)도 따라감 }, selectPipeByChainage(chainageM) { - if (chainageM !== null) callbacks.onReveal?.(); if (chainageM === null) { // 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군· // 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시. @@ -621,7 +620,6 @@ export function createStructuresSection( return hit; }, selectById(structureId) { - if (structureId !== null) callbacks.onReveal?.(); if (structureId === null) { // 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지 // 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다. @@ -637,11 +635,9 @@ export function createStructuresSection( const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 - // 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 「구조물 배치」를 폄(좌측 패널은 안 엶 · 499-1). + // 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 고름만 바꿈(좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 그대로 · 513-2). // 등록부 기본값 뒤 필수 빈칸이 없으면 바로 추가 + 새 항목 고름 · 남으면 폼만(A군은 임시 배치까지). startNew(type.group, typeId); - callbacks.onReveal?.(); - root.classList.remove("is-collapsed"); positionConfirmed = true; // 우클릭은 측점이 확정돼 들어온다 — 옵션도 바로 연다. const placement = placementOf(typeId); const step = interval(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 2e4734543..6d8f825a3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -204,12 +204,6 @@ export interface StructuresCallbacks { /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ onPipeSelect: (chainageM: number | null) => void; - /** - * 바깥(3D·종단 그래프·배수유역도·횡단도)에서 고른 것이 폼에 실릴 때 — 좌측 패널이 - * 접혀 있으면 펼친다(2026-09-04 사용자: 선택하면 좌측 패널도 함께 활성화돼야 함). - * 목록에서 직접 고른 경우엔 이미 펼쳐져 있어 아무 일도 안 한다. - */ - onReveal?: () => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 다른 화면의 고름(횡단 카드)도 함께 끔(54-8). */ onListRelease?: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 2ca878278..1783176c1 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -173,7 +173,7 @@ cursor: default; } -/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +/* 머리 줄 둘째 단추([자동 제안]) — [업데이트] 우측에 나란히(auto 마진 해제). */ .b05-drainage__tool { margin-left: var(--spacing-4, 4px); } @@ -267,32 +267,21 @@ display: none; } -/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */ -.b05-drainage.is-stale .b05-drainage__canvas, -.b05-drainage.is-stale .b05-drainage__image, -.b05-drainage.is-stale .b05-drainage__summary, -.b05-drainage.is-stale .b05-drainage__basins, -.b05-drainage.is-stale .b05-drainage__suggest { - filter: blur(2px); - opacity: 0.6; +/* 배수유역 낡음(326-6) — 옛 그림 그대로 · [업데이트] 단추에 점([3D 업데이트] 꼴) · 구석 「계산 중」. */ +.b05-drainage__update.is-pending { + position: relative; + border-color: var(--color-warning, #d98a00); } -.b05-drainage__veil { +.b05-drainage__update.is-pending::after { + content: ""; position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.b05-drainage__veil[hidden] { - display: none; -} - -.b05-drainage__veil .b05-drainage__update { - margin-left: 0; /* 머리 줄 단추의 오른쪽 붙임(auto) 풂 — 흐린 칸 가운데 */ - pointer-events: auto; + top: 3px; + right: 3px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-warning, #d98a00); } .b05-drainage__busy { @@ -305,14 +294,6 @@ display: none; } -.b05-drainage__mode { - display: flex; - gap: var(--spacing-8); - align-items: center; - padding: var(--spacing-8) 0; - color: var(--color-text-body); -} - /* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */ .b05-drainage__suggest { flex: 0 0 auto; @@ -334,3 +315,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_Drainage_Live.ts b/B05_Profile/logic/B05_Profile_Drainage_Live.ts index caf190ffd..fc258c740 100644 --- a/B05_Profile/logic/B05_Profile_Drainage_Live.ts +++ b/B05_Profile/logic/B05_Profile_Drainage_Live.ts @@ -1,23 +1,14 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Drainage_Live.ts - * 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`. + * 배수유역 [업데이트](요구 326-6 · 347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`. * * - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음). * - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤. - * - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동. + * - 저절로 셈하지 않음 — [업데이트]를 누를 때만 지금 노선으로 처음부터 셈. * - 묶음이 같은 입력으로 다시 돌면(입력 지문 같음) 낡음 · 떠 있는 요청을 그대로 둠. * - 답 채택 = 보낸 차례 · 입력 지문이 지금과 같고 서버가 그만두지 않았을 때만(늦은 답 버림). * ========================================================================== */ -export type DrainageMode = "manual" | "auto"; - -/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */ -export const QUIET_MS = 3000; - -/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */ -export const parseDrainageMode = (raw: string | null | undefined): DrainageMode => - raw === "auto" ? "auto" : "manual"; - /** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */ export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined; 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/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 9487f6718..a2501148b 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -118,6 +118,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ? inside.reduce((best, at) => (Math.abs(at - anchor) < Math.abs(best - anchor) ? at : best)) : anchor; }; + /** 담당 유역 설계유량 — 배수유역도 지금 답(관 보고 `shared.lastPipes`)이 먼저 · 아직 없으면 저장 유역 + * ([업데이트] 뒤 B06 폼도 새 값 · 낡은 동안 가림은 `design_structure_ford_calc` · 요구 328-3). */ + const flowAt = ( + chainageM: number, + basins: ReadonlyArray<{ chainage_m: number; design_flow_m3s?: number | null }> = [], + ): number | null => { + const near = (entry: { chainage_m: number }) => + Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M; + const live = deps.shared.lastPipes; + return (live ? live.find(near) : basins.find(near))?.design_flow_m3s ?? null; + }; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). 알약 · 측점선 · 관 id 장부 = 페이지 * 시설 줄 한 벌(462 · 숨은 B05 평면 마커도 따라감) · `carryEdits` = 사용자 고침(불러옴은 false). */ const pushMarks = (carryEdits = true): void => { @@ -353,10 +364,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 담당 유역의 설계유량 — 물넘이·세월교 개략 단면(필요 수심 = 월류 높이 // 자동값)이 이 값에서 나온다. B05와 같은 규칙으로 유역을 측점으로 짝짓는다 // (2026-08-30 사용자: 월류 폭을 넣어도 높이가 자동 계산되지 않았다). - design_flow_m3s: - pipeResponse.basins.find( - (basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M, - )?.design_flow_m3s ?? null, + design_flow_m3s: flowAt(pipe.chainage_m, pipeResponse.basins), })); // 옛 소수 관 자리 = 정수 m 초안(요구 167 · B05 `snapOldStations` 와 같은 셈) — B06 을 바로 열어도 // 카드 · 측점이 B05 를 거친 것과 같음 · 정본은 [저장]에서 @@ -549,9 +557,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc entry.options = { ...entry.options, ford_height_m: height }; return true; }); - if (!filled.length) return; + // 설계유량도 새 답으로(요구 328-3) + const flowed = pipeFacilities.filter((entry) => { + const flow = flowAt(entry.chainage_m); + if (!deps.shared.lastPipes || flow === (entry.design_flow_m3s ?? null)) return false; + entry.design_flow_m3s = flow; + return true; + }); + if (!filled.length && !flowed.length) return; section.setPipeFacilities(pipeFacilities); - const open = filled.find((entry) => entry.chainage_m === currentChainageM); + const open = [...filled, ...flowed].find((entry) => entry.chainage_m === currentChainageM); if (open) section.selectPipeByChainage(open.chainage_m); }, releasePipe: () => { diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index b1a9bf0b2..9dbb8caca 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -24,6 +24,7 @@ import { } from "../A00_Common/design/design_selection"; import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; +import { removeMarkOwner, removeStationOwner } from "../A00_Common/design/design_pipe_ids"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { fetchLatestRoute, @@ -156,7 +157,20 @@ export function createSectionProfile( if (isPipeStation(station)) host.edit()?.movePipe(from, to); }, onStructureRemove: (station) => { - if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); + const edit = host.edit(); + if (edit) + removeStationOwner(station, isPipeStation(station), { + pipe: edit.removePipe, + structure: edit.removeStructure, + }); + }, + onStructureMarkRemove: (structureId) => { + const edit = host.edit(); + if (edit) + removeMarkOwner(structureId, { + pipe: edit.removePipe, + structure: edit.removeStructure, + }); }, onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), onStructureAdd: (chainage, type) => @@ -177,6 +191,7 @@ export function createSectionProfile( p.facility, p.options?.pipe_diameter_mm ?? null, p.options?.ford_height_m ?? null, + p.design_flow_m3s ?? null, // [업데이트] 뒤 설계유량(요구 328-3) ]), ); const changed = pipeSignature !== null && signature !== pipeSignature; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 1ac1dd830..45f1b3d25 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -367,377 +367,3 @@ html.is-bm-route .b06-section__notice-bar { color: var(--color-text-muted); background: var(--color-surface); } - -/* SVG 차트 색상은 테마 토큰만 사용한다. */ -.b06-chart__bg { - fill: var(--color-surface-raised); -} - -/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은 - 그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */ -.b06-chart__ywindow[transform] * { - vector-effect: non-scaling-stroke; -} - -.b06-chart__grid { - stroke: var(--color-border); - stroke-width: 1; -} - -.b06-chart__axis { - stroke: var(--color-text-secondary); - stroke-width: 1; -} - -.b06-chart__tick, -.b06-chart__station-label, -.b06-chart__axis-label { - fill: var(--color-text-secondary); - font-family: var(--font-body); -} - -.b06-chart__tick, -.b06-chart__station-label { - font-size: 10px; -} - -/* MC(곡선 가운데) 측점 = 사용자가 고칠 자리(507-3) — 한 단계 크게 · 굵게 · 경고색. 고름 빨강은 아래가 이김. */ -.b06-chart__station-label--mc { - font-size: 12px; - font-weight: var(--font-weight-bold); - fill: var(--color-warning); -} - -.b06-station--mc { - font-size: 1.15em; - font-weight: var(--font-weight-bold); - color: var(--color-warning); -} - -/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 · - 바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */ -.b06-chart__center-box { - fill: color-mix(in srgb, var(--b06-tag) 14%, var(--color-surface-raised)); - stroke: var(--b06-tag); - stroke-width: 1; -} - -.b06-chart__center-text { - fill: var(--b06-tag); - font-weight: var(--font-weight-medium); -} - -/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */ -.b06-chart__station-label--lifted { - paint-order: stroke; - stroke: var(--color-surface-raised); - stroke-width: 3px; - stroke-linejoin: round; - pointer-events: none; -} - -/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ -.b06-chart__structure-label { - font-size: 9px; - font-weight: var(--font-weight-medium); - fill: rgb(180 83 9); -} - -.b06-chart__axis-label { - fill: var(--color-text-body); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); -} - -.b06-chart__profile { - fill: none; - stroke: var(--color-chart-0); - stroke-width: 2.5; -} - -/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */ -.b06-chart__profile, -.b06-chart__design-profile { - pointer-events: none; -} - -.b06-chart__cross-profile { - fill: none; - stroke: var(--color-chart-1); - stroke-width: 2.4; -} - -/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */ -.b06-chart__design-profile { - fill: none; - stroke: var(--color-royal-amethyst); - stroke-width: 2.2; - stroke-dasharray: 8 4; -} - -/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */ -.b06-chart__band { - stroke: none; -} - -.b06-chart__band--cut { - fill: rgb(220 38 38 / 14%); -} - -.b06-chart__band--fill { - fill: rgb(37 99 235 / 14%); -} - -.b06-chart__balance-boundary { - stroke: var(--color-royal-amethyst); - stroke-width: 1.2; - stroke-dasharray: 3 3; - opacity: 0.55; -} - -.b06-chart__station { - cursor: pointer; -} - -/* 구조물 측점선은 끌어 옮길 수 있다 — 커서로 그 사실을 알린다(2026-08-01 사용자 지시). */ -.b06-chart__station--draggable { - cursor: ew-resize; - touch-action: none; -} - -.b06-chart__station-line { - stroke-width: 1.2; -} - -.b06-chart__station-hit { - stroke: transparent; - stroke-width: 14; - pointer-events: stroke; -} - -.b06-chart__station-line--bp, -.b06-chart__station-line--regular, -.b06-chart__station-line--curve { - stroke: var(--color-warning); -} - -.b06-chart__station-line--ep { - stroke: var(--color-accent); - stroke-dasharray: 4 3; -} - -/* 비정규 측점(구조물)은 종단면도에서도 자수정색 파선으로 구분한다(B05 종단 규칙과 정합). - 미선택 시 stroke 미정의로 세로선이 안 그려지던 문제 해소. */ -.b06-chart__station-line--irregular { - stroke: var(--color-royal-amethyst, rgb(139 92 246)); - stroke-width: 1.4; - stroke-dasharray: 5 3; -} - -/* 추가 측점(368-3) = 일반측점 색 점선 */ -.b06-chart__station-line--plus { - stroke: var(--color-warning); - stroke-dasharray: 5 3; -} - -.b06-chart__station-line--selected, -.b06-chart__center-marker { - stroke: var(--color-danger); - stroke-width: 2.5; -} - -/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */ -.b06-chart__center-marker--design { - stroke: var(--color-royal-amethyst); -} - -.b06-chart__station--selected .b06-chart__station-label, -.b06-chart__station-label--selected { - fill: var(--color-danger); - font-weight: var(--font-weight-bold); -} - -/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). - 한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */ -.b06-design { - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: var(--spacing-8) var(--spacing-16); - padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16); - border-bottom: 1px solid var(--color-border); - margin-bottom: var(--spacing-8); -} - -/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */ -.b06-design__more { - position: relative; - flex: 0 0 auto; - margin-left: auto; -} - -.b06-design__more-summary { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 22px; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: 0.9rem; - line-height: 1; - cursor: pointer; - list-style: none; -} - -.b06-design__more-summary::-webkit-details-marker { - display: none; -} - -.b06-design__more[open] .b06-design__more-summary { - color: var(--color-surface); - background: var(--color-royal-amethyst); - border-color: var(--color-royal-amethyst); -} - -.b06-design__more-panel { - position: absolute; - right: 0; - top: calc(100% + 4px); - z-index: 3; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface-raised); - box-shadow: var(--shadow-sm); -} - -.b06-design__seg { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.b06-design__seg-legend { - font-size: 0.72rem; - color: var(--color-text-secondary); - white-space: nowrap; -} - -/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */ -.b06-design__seg--disabled { - display: none; -} - -.b06-design__seg-buttons { - display: inline-flex; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - overflow: hidden; -} - -.b06-design__btn { - padding: 3px 8px; - font-size: 0.72rem; - color: var(--color-text-secondary); - background: var(--color-surface); - border: none; - border-left: 1px solid var(--color-border); - cursor: pointer; -} - -.b06-design__btn:first-child { - border-left: none; -} - -.b06-design__btn--active { - color: var(--color-surface); - background: var(--color-royal-amethyst); - font-weight: var(--font-weight-medium); -} - -.b06-design__areas { - display: inline-flex; - gap: var(--spacing-8); - margin-left: auto; - font-size: 0.72rem; - font-family: var(--font-mono); -} - -/* 암 경계선 제어(E-7) — 하단 정보 행 **왼쪽 맞춤**(318-2 · 중심고는 그래프 Y축 라벨 상자로 옮김). */ -.b06-cross-card__rockb { - flex: 0 0 auto; - border-radius: var(--radius-inputs); - padding: 1px 4px; -} - -/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */ -.b06-cross-card__linecell { - flex: 0 1 auto; - min-width: 0; -} - -.b06-cross-card__linecell.is-empty { - visibility: hidden; -} - -.b06-cross-card__linecell .b06-design__seg-buttons { - min-width: 0; -} - -.b06-cross-card__linecell .b06-design__rockb-readout { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.b06-cross-card__linecell .b06-design__rockb-btn { - flex: 0 0 auto; - border-left: none; -} - -.b06-cross-card__linecell - .b06-design__rockb-btn:not([hidden]) - ~ .b06-design__rockb-btn:not([hidden]) { - border-left: 1px solid var(--color-border); -} - -/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */ -.b06-cross-card__cutslope { - margin-left: auto; - flex: 0 0 auto; - border-radius: var(--radius-inputs); - padding: 1px 4px; -} - -.b06-design__cutslope-input { - width: 44px; - padding: 0 2px; - font-size: 0.72rem; - font-family: var(--font-mono); - text-align: right; - color: var(--color-text); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); -} - -/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ -.b06-design__cutslope-input::-webkit-outer-spin-button, -.b06-design__cutslope-input::-webkit-inner-spin-button { - margin: 0; - appearance: none; -} - -.b06-design__cutslope-unit { - padding: 0 2px 0 1px; - font-size: 0.72rem; - color: var(--color-text-secondary); - align-self: center; -} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Chart.css b/B06_Section/B06_Section_UI_Style_Cross_Chart.css new file mode 100644 index 000000000..7f447bcbd --- /dev/null +++ b/B06_Section/B06_Section_UI_Style_Cross_Chart.css @@ -0,0 +1,373 @@ +/* SVG 차트 색상은 테마 토큰만 사용한다. */ +.b06-chart__bg { + fill: var(--color-surface-raised); +} + +/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은 + 그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */ +.b06-chart__ywindow[transform] * { + vector-effect: non-scaling-stroke; +} + +.b06-chart__grid { + stroke: var(--color-border); + stroke-width: 1; +} + +.b06-chart__axis { + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b06-chart__tick, +.b06-chart__station-label, +.b06-chart__axis-label { + fill: var(--color-text-secondary); + font-family: var(--font-body); +} + +.b06-chart__tick, +.b06-chart__station-label { + font-size: 10px; +} + +/* MC(곡선 가운데) 측점 = 사용자가 고칠 자리(507-3) — 한 단계 크게 · 굵게 · 경고색. 고름 빨강은 아래가 이김. */ +.b06-chart__station-label--mc { + font-size: 12px; + font-weight: var(--font-weight-bold); + fill: var(--color-warning); +} + +.b06-station--mc { + font-size: 1.15em; + font-weight: var(--font-weight-bold); + color: var(--color-warning); +} + +/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 · + 바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */ +.b06-chart__center-box { + fill: color-mix(in srgb, var(--b06-tag) 14%, var(--color-surface-raised)); + stroke: var(--b06-tag); + stroke-width: 1; +} + +.b06-chart__center-text { + fill: var(--b06-tag); + font-weight: var(--font-weight-medium); +} + +/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */ +.b06-chart__station-label--lifted { + paint-order: stroke; + stroke: var(--color-surface-raised); + stroke-width: 3px; + stroke-linejoin: round; + pointer-events: none; +} + +/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ +.b06-chart__structure-label { + font-size: 9px; + font-weight: var(--font-weight-medium); + fill: rgb(180 83 9); +} + +.b06-chart__axis-label { + fill: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); +} + +.b06-chart__profile { + fill: none; + stroke: var(--color-chart-0); + stroke-width: 2.5; +} + +/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */ +.b06-chart__profile, +.b06-chart__design-profile { + pointer-events: none; +} + +.b06-chart__cross-profile { + fill: none; + stroke: var(--color-chart-1); + stroke-width: 2.4; +} + +/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */ +.b06-chart__design-profile { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 2.2; + stroke-dasharray: 8 4; +} + +/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */ +.b06-chart__band { + stroke: none; +} + +.b06-chart__band--cut { + fill: rgb(220 38 38 / 14%); +} + +.b06-chart__band--fill { + fill: rgb(37 99 235 / 14%); +} + +.b06-chart__balance-boundary { + stroke: var(--color-royal-amethyst); + stroke-width: 1.2; + stroke-dasharray: 3 3; + opacity: 0.55; +} + +.b06-chart__station { + cursor: pointer; +} + +/* 구조물 측점선은 끌어 옮길 수 있다 — 커서로 그 사실을 알린다(2026-08-01 사용자 지시). */ +.b06-chart__station--draggable { + cursor: ew-resize; + touch-action: none; +} + +.b06-chart__station-line { + stroke-width: 1.2; +} + +.b06-chart__station-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; +} + +.b06-chart__station-line--bp, +.b06-chart__station-line--regular, +.b06-chart__station-line--curve { + stroke: var(--color-warning); +} + +.b06-chart__station-line--ep { + stroke: var(--color-accent); + stroke-dasharray: 4 3; +} + +/* 비정규 측점(구조물)은 종단면도에서도 자수정색 파선으로 구분한다(B05 종단 규칙과 정합). + 미선택 시 stroke 미정의로 세로선이 안 그려지던 문제 해소. */ +.b06-chart__station-line--irregular { + stroke: var(--color-royal-amethyst, rgb(139 92 246)); + stroke-width: 1.4; + stroke-dasharray: 5 3; +} + +/* 추가 측점(368-3) = 일반측점 색 점선 */ +.b06-chart__station-line--plus { + stroke: var(--color-warning); + stroke-dasharray: 5 3; +} + +.b06-chart__station-line--selected, +.b06-chart__center-marker { + stroke: var(--color-danger); + stroke-width: 2.5; +} + +/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */ +.b06-chart__center-marker--design { + stroke: var(--color-royal-amethyst); +} + +.b06-chart__station--selected .b06-chart__station-label, +.b06-chart__station-label--selected { + fill: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). + 한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */ +.b06-design { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-8); +} + +/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */ +.b06-design__more { + position: relative; + flex: 0 0 auto; + margin-left: auto; +} + +.b06-design__more-summary { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: 0.9rem; + line-height: 1; + cursor: pointer; + list-style: none; +} + +.b06-design__more-summary::-webkit-details-marker { + display: none; +} + +.b06-design__more[open] .b06-design__more-summary { + color: var(--color-surface); + background: var(--color-royal-amethyst); + border-color: var(--color-royal-amethyst); +} + +.b06-design__more-panel { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 3; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + +.b06-design__seg { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b06-design__seg-legend { + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; +} + +/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */ +.b06-design__seg--disabled { + display: none; +} + +.b06-design__seg-buttons { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + overflow: hidden; +} + +.b06-design__btn { + padding: 3px 8px; + font-size: 0.72rem; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__btn:first-child { + border-left: none; +} + +.b06-design__btn--active { + color: var(--color-surface); + background: var(--color-royal-amethyst); + font-weight: var(--font-weight-medium); +} + +.b06-design__areas { + display: inline-flex; + gap: var(--spacing-8); + margin-left: auto; + font-size: 0.72rem; + font-family: var(--font-mono); +} + +/* 암 경계선 제어(E-7) — 하단 정보 행 **왼쪽 맞춤**(318-2 · 중심고는 그래프 Y축 라벨 상자로 옮김). */ +.b06-cross-card__rockb { + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */ +.b06-cross-card__linecell { + flex: 0 1 auto; + min-width: 0; +} + +.b06-cross-card__linecell.is-empty { + visibility: hidden; +} + +.b06-cross-card__linecell .b06-design__seg-buttons { + min-width: 0; +} + +.b06-cross-card__linecell .b06-design__rockb-readout { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b06-cross-card__linecell .b06-design__rockb-btn { + flex: 0 0 auto; + border-left: none; +} + +.b06-cross-card__linecell + .b06-design__rockb-btn:not([hidden]) + ~ .b06-design__rockb-btn:not([hidden]) { + border-left: 1px solid var(--color-border); +} + +/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */ +.b06-cross-card__cutslope { + margin-left: auto; + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +.b06-design__cutslope-input { + width: 44px; + padding: 0 2px; + font-size: 0.72rem; + font-family: var(--font-mono); + text-align: right; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); +} + +/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ +.b06-design__cutslope-input::-webkit-outer-spin-button, +.b06-design__cutslope-input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.b06-design__cutslope-unit { + padding: 0 2px 0 1px; + font-size: 0.72rem; + color: var(--color-text-secondary); + align-self: center; +} 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/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index ba4a78ba4..69fc10e89 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -25,6 +25,10 @@ export interface DesignDrawingItem { /** 계획평면도만 — 종류 id · 이름(PLAN 60장). */ plan_kind?: string | null; plan_kind_name?: string | null; + /** 입력 지문(508-3b) — 같으면 같은 그림 · 미리받기가 바뀐 도면만 다시 받음 */ + fingerprint?: string | null; + /** 횡단 장 — 측점마다 지문 */ + stations?: Record | null; } export interface CadDrawing { @@ -275,6 +279,8 @@ export interface PlanKind { layout: PlanKindLayout; /** 축척 분모 — 평면도 양식은 `scales` 중 하나(기본 1,200) · 유역도는 6,000 고정. */ scale: number; + /** 유역도 양식만 — 등고선 간격(m · `contour_intervals` 중 하나 · 기본 5). */ + contour_interval_m?: number; } export type PlanKindLayout = "plan" | "watershed"; export interface PlanMark { @@ -288,6 +294,9 @@ export interface PlanKindsState { scales: number[]; default_scale: number; watershed_scale: number; + /** 유역도 등고선 간격(m) 고름(서버 `BASIN_CONTOUR_INTERVAL_CHOICES`) · 기본. */ + contour_intervals: number[]; + default_contour_interval: number; kinds: PlanKind[]; marks: PlanMark[]; /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ @@ -304,10 +313,11 @@ export function addPlanKind( pieces: string[], layout: PlanKindLayout = "plan", scale?: number, + contourIntervalM?: number, ): Promise { return requestJson(`/projects/${projectId}/plan-kinds`, { method: "POST", - body: JSON.stringify({ name, pieces, layout, scale }), + body: JSON.stringify({ name, pieces, layout, scale, contour_interval_m: contourIntervalM }), }); } @@ -317,10 +327,11 @@ export function updatePlanKind( name: string, pieces: string[], scale?: number, + contourIntervalM?: number, ): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "PUT", - body: JSON.stringify({ name, pieces, scale }), + body: JSON.stringify({ name, pieces, scale, contour_interval_m: contourIntervalM }), }); } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index d1d53ed05..35f3c628e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -22,6 +22,7 @@ from shapely.geometry import LineString from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, + _POLY_NS, DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, @@ -117,10 +118,30 @@ def map_area_mm() -> tuple[float, float]: def _thinned(points: list[tuple[float, float]]) -> list[tuple[float, float]]: - """종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다).""" - if len(points) < 3: - return points - return list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords) + """종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다). + + 좌표는 1 µm 로 끊음 — 응답 글자 수만 줄고 그림은 같음(508-3c).""" + if len(points) >= 3: + points = list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords) + return [(round(x, 3), round(y, 3)) for x, y in points] + + +def _background_entity( + drawing_id: str, points: list[tuple[float, float]], layer_id: str, color: str, suffix: str +) -> dict[str, Any] | None: + """배경선 = 폴리선 하나 + 꼭짓점 목록(자식 Line · 선분마다 UUID 없음) — 유역도 응답 크기(514-3). + + CAD 앱 `PolyLineEntity.fromJson` 이 선분으로 펼침 · id 는 `polyline_entity` 와 같음.""" + if len(points) < 2: + return None + return { + "id": str(uuid5(_POLY_NS, f"{drawing_id}:{layer_id}{suffix}")), + "type": "PolyLine", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": {"points": [{"x": x, "y": y} for x, y in points]}, + } def _number(value: Any, fallback: float = 0.0) -> float: @@ -318,22 +339,22 @@ def build_watershed_drawing( entities: list[dict[str, Any]] = [] for index, line in enumerate(contours): - contour = polyline_entity( + contour = _background_entity( drawing_id, _thinned([paper(point) for point in line]), CONTOUR_LAYER_ID, CONTOUR_COLOR, - suffix=f":contour:{index}", + f":contour:{index}", ) if contour: entities.append(contour) for index, line in enumerate(streams): - stream = polyline_entity( + stream = _background_entity( drawing_id, _thinned([paper(point) for point in line]), STREAM_LAYER_ID, STREAM_COLOR, - suffix=f":stream:{index}", + f":stream:{index}", ) if stream: entities.append(stream) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 25d6bbc54..5994e55d2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -25,6 +25,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import ( BASIN_COLORS, _hatch_entity, _number_badge_entities, + _thinned, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size @@ -249,10 +250,13 @@ def _piece_entities( out: list[dict[str, Any]] = [] labels = labels if labels is not None else [] - def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None: + def line( + points: Any, suffix: str, width: int = 1, dash: list[int] | None = None, thin: bool = False + ) -> None: + on_paper = [paper(p) for p in points] entity = polyline_entity( drawing_id, - [paper(p) for p in points], + _thinned(on_paper) if thin else on_paper, layer, color, suffix=suffix, @@ -301,7 +305,11 @@ def _piece_entities( }, } ) - elif piece in ("contour", "stream", *_OUTLINE): + elif piece in ("contour", "stream"): + # 배경선 = 종이 해상도로 솎음(유역도와 같은 0.1 mm · 508-3c) + for index, points in enumerate(data.get("lines") or []): + line(points, f":{index}", thin=True) + elif piece in _OUTLINE: for index, points in enumerate(data.get("lines") or []): line(points, f":{index}") elif piece == "centerline": diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py index da64d98ee..757248cf8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -209,8 +209,8 @@ def _add_entity( space.add_circle(center, radius, dxfattribs=attribs) else: vertices = [xy for vertex in shape.get("points") or [] if (xy := _xy(vertex))] - if len(vertices) >= 2: - space.add_lwpolyline(vertices, close=True, dxfattribs=attribs) + if len(vertices) >= 2: # 꼭짓점 목록 폴리선(유역도 배경선 · 514-3) = 열린 선 + space.add_lwpolyline(vertices, close=kind != "PolyLine", dxfattribs=attribs) for child in entity.get("children") or []: _add_entity(space, child, layers, skipped) diff --git a/B07_DesignDetail/B07_DesignDetail_Prefetch.ts b/B07_DesignDetail/B07_DesignDetail_Prefetch.ts new file mode 100644 index 000000000..b2955f80f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Prefetch.ts @@ -0,0 +1,197 @@ +/* ============================================================================= + * B07_DesignDetail_Prefetch.ts + * 도면 미리받기 — **페이지 밖** 모듈(508-3b · 요구 325). + * + * 페이지를 옮겨도 받기는 이어 감 · B07 로 돌아오면 목록 지문(서버 `fingerprint`)이 바뀐 도면만 + * 버리고 다시 받음 · 차례 = 목록 차례 가벼운 것 → 무거운 것(평면도) 끝 · 사용자가 연 도면이 맨 앞 · + * B07 [저장] 중 쉼 · 실패 = 한 번 다시 → 그래도면 「다시 받기」(누르면 다시) · + * 담는 장 수 상한(`LIMIT` · 가까이 쓴 차례로 버림 — 버린 장은 열 때 다시 받음). + * 화면 · CAD 는 만지지 않음 — 상태 바뀜은 `onFetchState` 한 칸으로만 알림(페이지가 다시 세우면 갈아 낌). + * ========================================================================== */ + +import { + fetchDesignDrawing, + type DesignDrawingItem, + type DesignDrawingResponse, +} from "./B07_DesignDetail_Api_Fetch"; +import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; +import { isSaving } from "./B07_DesignDetail_UI_Save"; + +/** 담는 장 수 상한 — 유역도 한 장이 수십 MB 라 메모리를 막음. */ +export const LIMIT = 40; +/** 무거운 도면(평면도 · 유역도) — 차례 끝 */ +const HEAVY = new Set(["plan"]); + +export type FetchState = "none" | "loading" | "ready" | "error"; + +interface Slot { + fingerprint: string | null | undefined; + request?: Promise; + response?: DesignDrawingResponse; + /** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */ + failed?: boolean; + /** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */ + evicted?: boolean; + usedAt: number; +} + +let project: string | undefined; +let items: DesignDrawingItem[] = []; +const slots = new Map(); +/** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */ +let urgent = 0; +let pumping = false; +let tick = 0; +let notify: ((id: string, state: FetchState) => void) | undefined; + +const wait = (ms: number) => new Promise((done) => window.setTimeout(done, ms)); + +/** 상태 바뀜을 받을 곳 하나(페이지가 세울 때마다 갈아 낌). */ +export function onFetchState(listener: ((id: string, state: FetchState) => void) | undefined) { + notify = listener; +} + +export function fetchState(id: string): FetchState { + const slot = slots.get(id); + if (!slot) return "none"; + if (slot.response) return "ready"; + if (slot.request) return "loading"; + return slot.failed ? "error" : "none"; +} + +/** + * 목록을 맞춤 — 다른 프로젝트면 모두 버림 · 지문이 바뀐(또는 목록에서 빠진) 도면만 버림 · + * 돌리기는 `startPrefetch`(페이지가 첫 장을 고른 뒤). + */ +export function syncDrawings(projectId: string, list: DesignDrawingItem[]): void { + if (project !== projectId) { + slots.clear(); + project = projectId; + } + items = list; + const now = new Map(list.map((item) => [item.id, item])); + for (const [id, slot] of slots) { + const item = now.get(id); + // 지문 없음(서버가 못 셈) = 늘 다시 받음 + if (!item?.fingerprint || item.fingerprint !== slot.fingerprint) slots.delete(id); + } +} + +/** 미리받기를 (다시) 돌림 — 이미 돌면 그대로(맞춘 목록을 이어 받음). */ +export function startPrefetch(): void { + void pump(); +} + +/** 저장 · 다시 그리기 뒤 — 다음 열기는 서버에서 */ +export function forgetDrawing(id: string): void { + slots.delete(id); +} + +/** 도각을 고친 뒤 — 모두 버리고 다시 받음 */ +export function forgetAll(): void { + slots.clear(); + void pump(); +} + +/** 도면 하나(구조물까지 얹은 응답) — 겹쳐 부르면 같은 Promise · 실패한 장은 다시 받음. */ +function request(item: DesignDrawingItem, retries: number): Promise { + const known = slots.get(item.id); + if (known?.response) { + known.usedAt = ++tick; + return Promise.resolve(known.response); + } + if (known?.request) return known.request; + const projectId = project as string; + const slot: Slot = { fingerprint: item.fingerprint, usedAt: ++tick }; + slots.set(item.id, slot); + const once = async (): Promise => { + const response = await fetchDesignDrawing(projectId, item.id); + // 구조물(배수관 · 기슭막이 · 세월교 · BOX · 물넘이포장)은 B06 산식이 화면에 있어 여기서 얹음 + // — 저장본(확정)은 이미 담겨 있어 그대로 + if (item.kind === "cross" && !response.confirmed) { + await appendStructureEntities(projectId, response.route_id, response.drawing); + } + return response; + }; + slot.request = (async () => { + for (let attempt = 0; ; attempt++) { + try { + return await once(); + } catch (error) { + if (attempt >= retries) throw error; + } + } + })(); + notify?.(item.id, "loading"); + slot.request.then( + (response) => { + if (slots.get(item.id) !== slot) return; // 그 사이 지문이 바뀌어 버림 + slot.response = response; + slot.request = undefined; + trim(item.id); + notify?.(item.id, "ready"); + }, + () => { + if (slots.get(item.id) !== slot) return; + slot.request = undefined; + slot.failed = true; + notify?.(item.id, "error"); + }, + ); + return slot.request; +} + +/** 담은 장이 상한을 넘으면 가장 오래 안 쓴 것부터 버림(방금 받은 장 · 받는 중은 남김). */ +function trim(keep: string): void { + const held = [...slots].filter(([, slot]) => slot.response); + let excess = held.length - LIMIT; + for (const [id, slot] of held.sort((a, b) => a[1].usedAt - b[1].usedAt)) { + if (excess <= 0) break; + if (id === keep) continue; + slots.set(id, { fingerprint: slot.fingerprint, evicted: true, usedAt: slot.usedAt }); + notify?.(id, "none"); + excess--; + } +} + +/** + * 사용자가 연 도면 — 미리받기 차례를 기다리지 않고 바로 받음(미리받기는 그동안 쉼) · + * 못 받았던 장 · 버린 장도 다시 받음(「다시 받기」). + */ +export async function openDrawing(item: DesignDrawingItem): Promise { + const known = slots.get(item.id); + if (known && !known.response && !known.request) slots.delete(item.id); + urgent++; + try { + return await request(item, 1); + } finally { + urgent--; + } +} + +/** 다음에 미리 받을 도면 — 가벼운 것(목록 차례) 먼저 · 무거운 것 끝 · 이미 받음 · 실패 · 버림은 건너뜀. */ +function nextItem(): DesignDrawingItem | undefined { + const free = (item: DesignDrawingItem) => !slots.has(item.id); + return items.find((item) => !HEAVY.has(item.kind) && free(item)) ?? items.find(free); +} + +/** 한 번에 하나씩 · 사용자가 연 도면 · [저장]이 도는 동안 쉼 · 상한이 차면 멈춤. */ +async function pump(): Promise { + if (pumping) return; + pumping = true; + try { + for (;;) { + while (urgent > 0 || isSaving()) await wait(120); + const held = [...slots.values()].filter((slot) => slot.response || slot.request).length; + const item = held < LIMIT ? nextItem() : undefined; + if (!item || !project) return; + try { + await request(item, 1); + } catch { + /* 두 번 다 못 받음 — 「다시 받기」로 남기고 다음 장 */ + } + } + } finally { + pumping = false; + } +} diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f1960af31..5234dfcfb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -10,7 +10,7 @@ from uuid import UUID from aiomysql import DictCursor from fastapi import APIRouter -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -24,7 +24,6 @@ from B06_Section.B06_Section_Repository import ( get_cross_section_design, get_cross_section_designs, get_longitudinal_section, - get_project_standard_cross_section, merge_cross_section_design_by_round, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID @@ -51,6 +50,13 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( read_cross_layout, write_cross_layout, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Fingerprint import ( + drawing_fingerprints, + plan_copy_key, + read_plan_copy, + standard_cross_source, + write_plan_copy, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import STRUCTURE_FRAME_LAYER @@ -284,6 +290,23 @@ async def get_design_drawing_list( try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + # 도면마다 입력 지문(508-3b) — 화면 미리받기가 바뀐 도면만 다시 받음 + use_project_templates(project_root) + try: + prints = await drawing_fingerprints( + project_id, + route_id, + project_root, + longitudinal_path, + drawings, + await title_block_fields(project_id), + ) + except Exception: # 지문은 덤 — 못 세면 빈 지문(화면은 늘 다시 받음) · 목록은 그대로 + logger.warning("B07 도면 지문 실패: project_id=%s", project_id, exc_info=True) + prints = {} + for item in drawings: + item.fingerprint = prints.get(item.id, {}).get("fingerprint") + item.stations = prints.get(item.id, {}).get("stations") return DesignDrawingListResponse( project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass ) @@ -309,11 +332,13 @@ async def get_design_drawing( # 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트). use_project_templates(project_root) # 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다. - use_title_fields(await title_block_fields(project_id)) + title_fields = await title_block_fields(project_id) + use_title_fields(title_fields) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. design: dict[str, Any] | None = None source_design: Any = None overlays: dict[Any, Any] | None = None + plan_key: str | None = None cross_match = _CROSS_ID.fullmatch(drawing_id) if cross_match: pool = get_db_pool() @@ -334,19 +359,7 @@ async def get_design_drawing( source_design = ((section or {}).get("data") or {}).get("mass_haul") elif drawing_id == CROSS_STANDARD_ID: # 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값). - pool = get_db_pool() - async with pool.acquire() as connection: - async with connection.cursor() as cursor: - await cursor.execute( - "SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL", - (str(project_id),), - ) - row = await cursor.fetchone() - source_design = ( - await get_project_standard_cross_section(connection, int(row[0]), project_id) - if row - else None - ) + source_design = await standard_cross_source(project_id) elif DEVELOPMENT_ID.fullmatch(drawing_id): # 전개도 — 추가 흙막이 단 수 · 포장 빈 폭을 측점별 설계에서 읽음(377) source_design = await _designs_by_chainage(route_id) @@ -357,6 +370,16 @@ async def get_design_drawing( return JSONResponse(status_code=404, content={"status": "error", "message": reason}) longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) designs = await _designs_by_chainage(route_id) + # 파생 저장본(508-3c) — 지문이 같으면 그린 응답 그대로(확정 저장본은 manifest 길) + plan_key = await plan_copy_key( + project_id, route_id, project_root, longitudinal_path, drawing_id, + title_fields, context, designs, + ) # fmt: skip + stored = plan_key and await asyncio.to_thread( + read_plan_copy, project_root, drawing_id, plan_key + ) + if stored: + return Response(content=stored, media_type="application/json") source_design = await asyncio.to_thread( plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id ) @@ -368,7 +391,7 @@ async def get_design_drawing( source_design, overlays, ) - return DesignDrawingResponse( + response = DesignDrawingResponse( project_id=str(project_id), route_id=route_id, id=drawing_id, @@ -380,6 +403,11 @@ async def get_design_drawing( design=design, guide=_drawing_guide(drawing), ) + if plan_key and not confirmed: + body = response.model_dump_json().encode("utf-8") + await asyncio.to_thread(write_plan_copy, project_root, drawing_id, plan_key, body) + return Response(content=body, media_type="application/json") + return response except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) except FileNotFoundError as exc: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index 6e0b7078c..805e8478b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -28,6 +28,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( write_marks, ) from config.config_system import ( + BASIN_CONTOUR_INTERVAL_CHOICES, + BASIN_CONTOUR_INTERVAL_M, DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN, DRAWING_SCALE_PLAN_CHOICES, @@ -44,6 +46,8 @@ class PlanKindRequest(BaseModel): layout: str = "plan" # 축척 분모(`scales` 중 하나) — 없으면 추가는 1/1,200 · 업데이트는 지금 값 · 유역도 1/6,000. scale: int | None = None + # 유역도 등고선 간격(m · `contour_intervals` 중 하나) — 없으면 추가는 5 m · 업데이트는 지금 값. + contour_interval_m: int | None = None class PlanKindsOrderRequest(BaseModel): @@ -67,6 +71,8 @@ def _state(root: Any) -> dict[str, Any]: "scales": list(DRAWING_SCALE_PLAN_CHOICES), "default_scale": DRAWING_SCALE_PLAN, "watershed_scale": DRAWING_SCALE_BASIN, + "contour_intervals": list(BASIN_CONTOUR_INTERVAL_CHOICES), + "default_contour_interval": BASIN_CONTOUR_INTERVAL_M, "kinds": list_kinds(root), "marks": read_marks(root), } @@ -100,7 +106,13 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: try: root = await _project_root(project_id) await asyncio.to_thread( - add_kind, root, request.name, request.pieces, request.layout, request.scale + add_kind, + root, + request.name, + request.pieces, + request.layout, + request.scale, + request.contour_interval_m, ) return await asyncio.to_thread(_state, root) except ValueError as exc: @@ -120,11 +132,17 @@ async def put_plan_kinds_order(project_id: UUID, request: PlanKindsOrderRequest) @router.put("/{project_id}/plan-kinds/{kind_id}") async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any: - """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" + """종류 업데이트 — 이름 · 조각 · 축척 · 유역도 등고선 간격을 고친다(기본 넷도).""" try: root = await _project_root(project_id) await asyncio.to_thread( - update_kind, root, kind_id, request.name, request.pieces, request.scale + update_kind, + root, + kind_id, + request.name, + request.pieces, + request.scale, + request.contour_interval_m, ) return await asyncio.to_thread(_state, root) except ValueError as exc: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index bbcea06eb..82f38e41f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -25,7 +25,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_drawing_label, ) from common_util.common_util_drainage_pipes import detail_basins_path -from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN +from config.config_system import ( + BASIN_CONTOUR_INTERVAL_M, + DRAWING_SCALE_BASIN, + DRAWING_SCALE_PLAN, +) logger = logging.getLogger(__name__) @@ -188,7 +192,7 @@ def _too_far_from_route( @lru_cache(maxsize=8) def _metric_lines_cached( path_str: str, mtime_ns: int, crs: str -) -> tuple[tuple[np.ndarray, ...], np.ndarray]: +) -> tuple[tuple[np.ndarray, ...], np.ndarray, np.ndarray]: """도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다. 같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면 @@ -197,31 +201,44 @@ def _metric_lines_cached( 선은 (n, 2) 실수 배열로 쥔다 — 파이썬 튜플이면 등고선 한 벌이 수백 MB 를 붙든다. 둘째 값은 선마다 (min_x, min_y, max_x, max_y) — 도곽 밖 선을 자르기 전에 거른다. + 셋째 값은 선마다 높이(`등고수치` · 없으면 nan) — 유역도 등고선 간격 거르기. """ to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True) lines: list[np.ndarray] = [] + levels: list[float] = [] for feature in _geojson_features(Path(path_str)): + level = (feature.get("properties") or {}).get("등고수치") for part in _geometry_lines(feature.get("geometry")): if len(part) < 2: continue lonlat = np.array([(point[0], point[1]) for point in part], dtype=float) x, y = to_metric.transform(lonlat[:, 0], lonlat[:, 1]) lines.append(np.column_stack((x, y))) + levels.append(float(level) if isinstance(level, (int, float)) else math.nan) bounds = np.array( [(*line.min(axis=0), *line.max(axis=0)) for line in lines], dtype=float ).reshape(-1, 4) - return tuple(lines), bounds + return tuple(lines), bounds, np.array(levels, dtype=float) def _metric_lines( - path: Path, crs: str, box: tuple[float, float, float, float] | None = None + path: Path, + crs: str, + box: tuple[float, float, float, float] | None = None, + interval_m: float | None = None, ) -> list[list[tuple[float, float]]]: - """캐시된 배경 선 중 box 에 걸치는 것만 낸다. 파일이 없으면 빈 목록.""" + """캐시된 배경 선 중 box 에 걸치는 것만 낸다. 파일이 없으면 빈 목록. + + interval_m = 그 간격의 배수 높이 선만(높이 없는 선은 남김) · None = 전부. + """ if not path.is_file(): return [] - lines, bounds = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) + lines, bounds, levels = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) + keep = np.ones(len(lines), dtype=bool) + if interval_m: + keep = np.isnan(levels) | np.isclose(np.mod(levels, interval_m), 0.0) if box is None: - picked = range(len(lines)) + picked = np.flatnonzero(keep) else: min_x, min_y, max_x, max_y = box hit = ( @@ -230,7 +247,7 @@ def _metric_lines( & (bounds[:, 3] >= min_y) & (bounds[:, 1] <= max_y) ) - picked = np.flatnonzero(hit) + picked = np.flatnonzero(hit & keep) return [list(map(tuple, lines[index].tolist())) for index in picked] @@ -240,6 +257,7 @@ def map_background( scale: int, area_mm: tuple[float, float], extent_points: list[tuple[float, float]], + contour_interval_m: float | None = None, ) -> dict[str, list[list[tuple[float, float]]]]: """도엽 등고선·세류선을 사업지 좌표계로 읽어 **그 도면의 도곽 크기로 절취**한다. @@ -248,6 +266,7 @@ def map_background( `extent_points` 는 그 도면의 주제(노선·유역 등) 좌표다. 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다. + `contour_interval_m` = 등고선을 그 간격 배수 높이만(유역도 5 · 10 m) · None = 도엽 그대로. """ area_w_mm, area_h_mm = area_mm half_w_m = area_w_mm / 2.0 * scale / 1000.0 @@ -266,9 +285,12 @@ def map_background( sheet_dir = Path(project_root) / "B04_PreProcess" / "processed" background: dict[str, list[list[tuple[float, float]]]] = {} - for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): + for key, filename, interval in ( + ("contours", CONTOUR_FILE, contour_interval_m), + ("streams", STREAM_FILE, None), + ): lines: list[list[tuple[float, float]]] = [] - for line in _metric_lines(sheet_dir / filename, crs, box): + for line in _metric_lines(sheet_dir / filename, crs, box, interval): lines.extend(clip_line_to_box(line, box)) background[key] = lines return background @@ -490,9 +512,13 @@ def _hillshade_for_box( ) -def watershed_source(context: Any) -> dict[str, Any]: +def watershed_source( + context: Any, contour_interval_m: float = BASIN_CONTOUR_INTERVAL_M +) -> dict[str, Any]: """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. + 등고선 = 그 종류 간격(5 · 10 m) 배수 높이만 — 1 m 는 B05 · B06 전용. + 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — @@ -513,6 +539,7 @@ def watershed_source(context: Any) -> dict[str, Any]: DRAWING_SCALE_BASIN, map_area_mm(), [*route_xy, *(point for basin in basins for point in basin["ring"])], + contour_interval_m, ) return { diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py new file mode 100644 index 000000000..a18a03b23 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py @@ -0,0 +1,474 @@ +"""B07 도면 지문(508-3b) — 도면마다 입력 지문을 **그리기 없이** 싸게 센다. + +지문이 같으면 같은 그림 — 화면 미리받기가 바뀐 도면만 다시 받고(횡단 장 = 측점 지문 모음), +평면도 GET 이 서버 파생 저장본(`_derived/b07_plan_<도면 id>/`)을 그대로 낸다. +⚠ 입력 하나라도 빠지면 옛 그림 — 도면 조립(`_Router_Support._read_drawing`)이 읽는 것을 + 고치면 여기도. + +파일 도장 = 작은 파일은 **내용** 해시(B06 [저장]이 같은 내용으로 다시 쓰는 종단 · 횡단 파일) · +큰 파일(지형 · 모델)은 시각:크기. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from functools import cache, lru_cache +from pathlib import Path +from typing import Any +from uuid import UUID + +from B05_Profile.B05_Profile_Engine_Sections import cross_station, find_cross_file +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import LEGACY_PIECES +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import CROSS_SHEET_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Template import system_template_path +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _design_root, + _read_json, + _read_manifest, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + PLAN_KIND_ID, + WATERSHED_LAYOUT, + list_kinds, +) +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + file_stamp, + prune_derived, +) +from common_util.common_util_drainage_pipes import ( + detail_basins_path, + pipe_points_path_in, + route_signature, +) +from M02_MasterTemplete.M02_Template_Layers import project_dir + +logger = logging.getLogger(__name__) + +#: 판 번호 — 지문 짜임을 바꾸면 올림(옛 저장본 · 화면 기억을 한 번에 버림) +FINGERPRINT_VERSION = 1 +_CONTENT_MAX = 8 * 1024 * 1024 +_SLOPE_PIECES = {*LEGACY_PIECES["slope_outline"], "slope_ticks"} +_MASS_HAUL_ID = "mass_haul" +_STANDARD_ID = "cross_standard" + + +@lru_cache(maxsize=4096) +def _content(path_str: str, mtime_ns: int, size: int) -> str: + return hashlib.blake2b(Path(path_str).read_bytes(), digest_size=12).hexdigest() + + +def _canon(value: Any) -> Any: + """값 꼴만 다른 것은 같게 — 4.0 = 4(브라우저를 거친 저장이 정수로 바꿈) · 열쇠는 글.""" + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, dict): + return {str(_canon(key)): _canon(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_canon(item) for item in value] + return value + + +def _key(*parts: Any) -> str: + return derived_key(*_canon(list(parts))) + + +def _without_warnings(value: Any) -> Any: + """경고 글(`warnings`)은 B07 이 안 그림 — 다시 셈 · 글 꼴만 바뀌어도 종단 지문이 안 흔들리게.""" + if isinstance(value, dict): + return {k: _without_warnings(v) for k, v in value.items() if k != "warnings"} + if isinstance(value, list): + return [_without_warnings(item) for item in value] + return value + + +@lru_cache(maxsize=64) +def _longitudinal(path_str: str, mtime_ns: int, size: int) -> str: + data = json.loads(Path(path_str).read_text(encoding="utf-8")) + return _key(_without_warnings(data)) + + +def longitudinal_stamp_of(path: Path) -> str | None: + """종단 파일 지문 — 내용(경고 글 뺌 · 값 꼴 맞춤) · 없거나 깨졌으면 None.""" + try: + info = path.stat() + return _longitudinal(str(path), info.st_mtime_ns, info.st_size) + except (OSError, ValueError): + return None + + +def stamp(path: Path | str | None) -> str | None: + """파일 도장 — 8 MB 이하 = 내용 해시(시각 · 크기로 기억) · 넘으면 시각:크기 · 없으면 None.""" + if path is None: + return None + try: + info = Path(path).stat() + except OSError: + return None + if info.st_size > _CONTENT_MAX: + return file_stamp(Path(path)) + try: + return _content(str(path), info.st_mtime_ns, info.st_size) + except OSError: + return None + + +def tree_stamp(folder: Path, skip: tuple[str, ...] = ()) -> dict[str, str | None]: + """폴더 아래 파일 전부 {상대 경로: 도장}(없으면 빈 dict).""" + if not folder.is_dir(): + return {} + return { + path.relative_to(folder).as_posix(): stamp(path) + for path in sorted(folder.rglob("*")) + if path.is_file() and not path.name.endswith(".tmp") and path.name not in skip + } + + +@cache +def code_stamp() -> str: + """B07 엔진 코드 도장 — 배포 · 고침이 옛 저장본을 그대로 내지 않게(프로세스마다 한 번).""" + folder = Path(__file__).resolve().parent + return derived_key({path.name: file_stamp(path) for path in sorted(folder.glob("*.py"))}) + + +def _settings(project_root: Path) -> dict[str, Any]: + """B07 설정 파일(축척 · 장 배치 · 표식 · 평면도 종류) — manifest · 그린 결과 폴더는 뺌. + + 평면도 종류 = 읽은 종류 목록(기본 종류를 파일로 내려도 같음) · 유역도 등고선 간격은 뺌 — + 그 도면만 바뀜(`_plan_extra` 의 종류 칸). + """ + root = _design_root(project_root) + files = sorted(root.glob("*.json")) if root.is_dir() else [] + return { + "files": {path.name: stamp(path) for path in files if path.name != "manifest.json"}, + "plan_kinds": [ + {key: value for key, value in kind.items() if key != "contour_interval_m"} + for kind in list_kinds(project_root) + ], + } + + +def _facilities(project_root: Path) -> dict[str, str | None]: + """시설 정본 — 구조물 · 관 지점.""" + return { + "structures": stamp(structures_file_path(str(project_root))), + "pipes": stamp(pipe_points_path_in(project_root)), + } + + +def _plan_world(project_root: Path, context: Any, longitudinal_stamp: str | None) -> str: + """평면도 · 유역도가 함께 기대는 노선 · 지형 · 배경 — 종류 · 장마다 같음.""" + return _key( + route_signature(list(context.vertices)), + context.crs, + context.surface_params, + tree_stamp(project_root / "B03_FileInput" / "input"), + tree_stamp(project_root / "B04_PreProcess" / "processed"), + tree_stamp(project_root / "B04_PreProcess" / "models"), + tree_stamp(project_root / "B05_Profile" / "route"), + stamp(detail_basins_path(context.stored_path)), + _facilities(project_root), + longitudinal_stamp, + ) + + +def _neighbors(chainages: list[int | float], start: float, end: float) -> list[int | float]: + """구간 안 측점 + 앞뒤 하나(사면 선이 이웃 측점까지 읽음).""" + inside = [index for index, value in enumerate(chainages) if start <= value <= end] + if not inside: + return [] + return chainages[max(inside[0] - 1, 0) : inside[-1] + 2] + + +def compute_fingerprints( + project_root: Path, + longitudinal_path: Path, + drawings: list[Any], + sources: dict[str, Any], +) -> dict[str, dict[str, Any]]: + """{도면 id: {"fingerprint", ["stations"]}} — 동기 · 파일만 읽음(DB 값은 `sources`). + + `sources` = 라우터가 모은 DB · 문맥 값 — title(표제란) · designs({측점: 설계}) · + detail({측점: 종횡단 상세 칸}) · overlays({측점: 덧그림 범위}) · context(배수 문맥) · + mass_haul · standard · sheets(횡단 장 계획) · elevations({측점: 계획고}) · + cross_inputs(측점 → 장 입력 조각 함수). + 고르지 않은 종류에 쓰는 값은 없어도 됨. + """ + manifest = _read_manifest(project_root)["drawings"] + longitudinal_stamp = longitudinal_stamp_of(longitudinal_path) + templates = { + "project": tree_stamp(project_dir(project_root)), # 도각 · 구조물 사본 + "system": tree_stamp(system_template_path().parent), + } + common = _key( + FINGERPRINT_VERSION, + code_stamp(), + templates, + _settings(project_root), + sources.get("title") or {}, + ) + designs = sources.get("designs") or {} + out: dict[str, dict[str, Any]] = {} + sheets: dict[str, dict[str, Any]] = { + sheet["id"]: sheet for sheet in sources.get("sheets") or [] + } + kinds: dict[str, dict[str, Any]] | None = None + world: str | None = None + for item in drawings: + drawing_id = item.id + entry = manifest.get(drawing_id) or {} + saved = _design_root(project_root) / "drawings" / f"{drawing_id}.json" + head = (common, entry, stamp(saved) if entry.get("confirmed") else None) + extra: Any = None + stations: dict[str, str] | None = None + if drawing_id == _MASS_HAUL_ID: + extra = (longitudinal_stamp, sources.get("mass_haul")) + elif drawing_id == _STANDARD_ID: + extra = sources.get("standard") + elif item.kind == "longitudinal": + extra = longitudinal_stamp + elif item.kind == "structure": + extra = _facilities(project_root) + elif DEVELOPMENT_ID.fullmatch(drawing_id): + extra = (longitudinal_stamp, designs, _facilities(project_root)) + elif CROSS_SHEET_ID.fullmatch(drawing_id) and drawing_id in sheets: + sheet = sheets[drawing_id] + stations = { + str(chainage): _station_fingerprint(sources, chainage) + for chainage in sheet["chainages"] + } + extra = (sheet, sources.get("elevations"), stations) + elif PLAN_KIND_ID.fullmatch(drawing_id) and sources.get("context") is not None: + if kinds is None: + kinds = {kind["id"]: kind for kind in list_kinds(project_root)} + world = _plan_world(project_root, sources["context"], longitudinal_stamp) + extra = _plan_extra(project_root, longitudinal_path, drawing_id, kinds, world, designs) + out[drawing_id] = {"fingerprint": _key(*head, item.kind, extra)[:16]} + if stations is not None: + out[drawing_id]["stations"] = stations + return out + + +def _station_fingerprint(sources: dict[str, Any], chainage: int | float) -> str: + """측점 하나 — 장 입력 조각(원본 · 설계 · 계획선 · 표 · 글) · 상세 칸(화면 구조물) · 덧그림.""" + return _key( + sources["cross_inputs"](chainage), + (sources.get("detail") or {}).get(chainage), + (sources.get("overlays") or {}).get(chainage), + )[:16] + + +def _plan_extra( + project_root: Path, + longitudinal_path: Path, + drawing_id: str, + kinds: dict[str, dict[str, Any]], + world: str | None, + designs: dict[int | float, Any], +) -> Any: + """평면도 한 장 — 종류 정의 · 장 번호 + 노선 세계 + (사면 조각이면) 그 장 측점 설계 · 원본.""" + match = PLAN_KIND_ID.fullmatch(drawing_id) + kind = kinds.get(match.group(1)) if match else None + if kind is None: + return (world, None) + number = int(match.group(2)) if match.group(2) else 1 + if kind["layout"] == WATERSHED_LAYOUT or not _SLOPE_PIECES & set(kind["pieces"]): + return (world, kind, number) + longitudinal = _read_json(longitudinal_path) + stations = [ + (float(row["chainage_m"]), float(row.get("center_x", 0)), float(row.get("center_y", 0))) + for row in longitudinal.get("stations") or [] + if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float)) + ] + chunk = next( + (item for item in plan_chunks(stations, kind["scale"]) if item["number"] == number), None + ) + if chunk is None: + return (world, kind, number) + chainages = sorted({cross_station(value) for value, _x, _y in stations}) + near = _neighbors(chainages, float(chunk["start_m"]), float(chunk["end_m"])) + cross_dir = longitudinal_path.parent.parent / "cross_sections" + return ( + world, + kind, + number, + {str(c): (designs.get(c), stamp(find_cross_file(cross_dir, c))) for c in near}, + ) + + +#: 평면도 파생 저장본 종류 접두 — `_derived/b07_plan_<도면 id>/<지문>.json`(응답 그대로 · 최근 2벌) +PLAN_DERIVED = "b07_plan_" + + +def read_plan_copy(project_root: Path, drawing_id: str, key: str) -> bytes | None: + """지문이 같은 평면도 응답(JSON 글 그대로) — 없으면 None · 읽기만(지우지 않음).""" + path = derived_dir(project_root, PLAN_DERIVED + drawing_id) / f"{key}.json" + try: + body = path.read_bytes() + path.touch() # 최근에 쓴 것 = 정리 때 남김 + except OSError: + return None + return body or None + + +def write_plan_copy(project_root: Path, drawing_id: str, key: str, body: bytes) -> None: + """원자 쓰기 뒤 그 도면의 옛 지문을 정리(최근 2벌).""" + folder = derived_dir(project_root, PLAN_DERIVED + drawing_id) + folder.mkdir(parents=True, exist_ok=True) + temporary = folder / f".{key}.tmp" + temporary.write_bytes(body) + temporary.replace(folder / f"{key}.json") + prune_derived(project_root, PLAN_DERIVED + drawing_id) + + +async def plan_copy_key( + project_id: UUID, + route_id: int, + project_root: Path, + longitudinal_path: Path, + drawing_id: str, + title: dict[str, str], + context: Any, + designs: dict[int | float, Any], +) -> str | None: + """평면도 저장본 열쇠 = 그 장 지문 — 확정 저장본(manifest 길) · 못 셈 = None(저장본 안 씀).""" + from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem + + if _read_manifest(project_root)["drawings"].get(drawing_id, {}).get("confirmed"): + return None + item = DesignDrawingItem(id=drawing_id, kind="plan", label=drawing_id) + try: + prints = await drawing_fingerprints( + project_id, route_id, project_root, longitudinal_path, [item], title, + context=context, designs=designs, + ) # fmt: skip + except Exception: + logger.warning("B07 평면도 지문 실패 — 저장본 없이 그림: %s", drawing_id, exc_info=True) + return None + return prints[drawing_id]["fingerprint"] + + +async def standard_cross_source(project_id: UUID) -> Any: + """표준 횡단면 설정(B06) — GET 과 지문이 같은 값을 씀.""" + from B06_Section.B06_Section_Repository import get_project_standard_cross_section + from config.config_db import get_db_pool + + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row: + return None + return await get_project_standard_cross_section(connection, int(row[0]), project_id) + + +async def drawing_fingerprints( + project_id: UUID, + route_id: int, + project_root: Path, + longitudinal_path: Path, + drawings: list[Any], + title: dict[str, str], + context: Any = None, + designs: dict[int | float, Any] | None = None, +) -> dict[str, dict[str, Any]]: + """고른 도면만 지문 — 쓰는 종류의 DB · 문맥 값만 모아 `compute_fingerprints` 로. + + `context` · `designs` = 단건 GET 이 이미 읽은 값(다시 안 읽음). + """ + import asyncio + + from B06_Section.B06_Section_Repository import get_longitudinal_section + from B07_DesignDetail.B07_DesignDetail_Router import _designs_by_chainage + from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + _cross_section_input, + _cross_sheet_plan, + load_note_sources, + ) + from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents + from common_util.common_util_drainage_context import load_drainage_context + from config.config_db import get_db_pool + + ids = {item.id for item in drawings} + has_sheet = any(CROSS_SHEET_ID.fullmatch(drawing_id) for drawing_id in ids) + has_plan = any(PLAN_KIND_ID.fullmatch(drawing_id) for drawing_id in ids) + sources: dict[str, Any] = {"title": title, "designs": designs or {}} + + # DB 가 원격 — 서로 안 기다리는 것끼리 겹쳐 보냄(값은 같고 왕복만 겹침) + async def read_designs() -> None: + if designs is None and ( + has_sheet or has_plan or any(DEVELOPMENT_ID.fullmatch(i) for i in ids) + ): + sources["designs"] = await _designs_by_chainage(route_id) + + async def read_context() -> None: + if has_plan: + sources["context"] = context or (await load_drainage_context(project_id))[0] + + async def read_mass_haul() -> None: + if _MASS_HAUL_ID in ids: + pool = get_db_pool() + async with pool.acquire() as connection: + section = await get_longitudinal_section(connection, project_id, route_id) + sources["mass_haul"] = ((section or {}).get("data") or {}).get("mass_haul") + + async def read_standard() -> None: + if _STANDARD_ID in ids: + sources["standard"] = await standard_cross_source(project_id) + + async def read_cross() -> None: + if not has_sheet: + return + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(project_id, route_id) + dump = getattr(response, "model_dump", None) + rows = dump(mode="json").get("cross_sections") or [] if dump else [] + detail = {cross_station(row["chainage_m"]): row for row in rows if "chainage_m" in row} + sources["detail"] = detail + sources["overlays"] = await cross_overlay_extents( + project_id, route_id, response if dump else None + ) + sources["elevations"] = { + str(chainage): (row.get("design") or {}).get("design_elevation_m") + for chainage, row in detail.items() + } + + async def read_files() -> None: + if has_sheet: + sources["sheets"], sources["longitudinal"], sources["pipes"] = await asyncio.gather( + asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path), + asyncio.to_thread(_read_json, longitudinal_path), + asyncio.to_thread(load_note_sources, project_root), + ) + + await asyncio.gather( + read_designs(), + read_context(), + read_mass_haul(), + read_standard(), + read_cross(), + read_files(), + ) + if has_sheet: + sources["cross_inputs"] = lambda chainage: _cross_section_input( + longitudinal_path, + sources["longitudinal"], + chainage, + sources["designs"].get(chainage), + sources["overlays"].get(chainage), + sources["pipes"], + ) + return await asyncio.to_thread( + compute_fingerprints, project_root, longitudinal_path, drawings, sources + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py index 88fa28340..a5dc2713d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py @@ -52,12 +52,17 @@ def _extents_from(output: Any) -> dict[int | float, Extent]: return extents -async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[int | float, Extent]: - """{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다.""" +async def cross_overlay_extents( + project_id: UUID | str, route_id: int, response: Any = None +) -> dict[int | float, Extent]: + """{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다. + + `response` = 이미 받은 종횡단 상세(지문 · 508-3b) — 없으면 여기서 받음.""" from B06_Section.B06_Section_Router import get_section_detail try: - response = await get_section_detail(UUID(str(project_id)), route_id) + if response is None: + response = await get_section_detail(UUID(str(project_id)), route_id) dump = getattr(response, "model_dump", None) if dump is None: # JSONResponse = 실패 return {} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1905ec8da..a637630a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -2,6 +2,7 @@ 저장 자리(프로젝트 저장소 `B07_DesignDetail/`): plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout, scale} — 파일 하나씩 + (유역도 양식은 + contour_interval_m 등고선 간격 5 · 10 m) plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) plan_marks.json 사용자가 정한 대피소 · 사토장 자리 @@ -73,6 +74,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import ( + BASIN_CONTOUR_INTERVAL_CHOICES, + BASIN_CONTOUR_INTERVAL_M, DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN, DRAWING_SCALE_PLAN_CHOICES, @@ -109,6 +112,14 @@ def _scale(layout: str, value: Any) -> int: return value if value in DRAWING_SCALE_PLAN_CHOICES else DRAWING_SCALE_PLAN +def _contour(layout: str, value: Any) -> dict[str, int]: + """유역도 양식만 등고선 간격(m) 칸 — 없거나 목록 밖이면 5 m(옛 파일).""" + if layout != WATERSHED_LAYOUT: + return {} + chosen = value if value in BASIN_CONTOUR_INTERVAL_CHOICES else BASIN_CONTOUR_INTERVAL_M + return {"contour_interval_m": chosen} + + def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: layout = kind.get("layout", "plan") return { @@ -117,6 +128,7 @@ def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: "order": order, "layout": layout, "scale": _scale(layout, kind.get("scale")), + **_contour(layout, kind.get("contour_interval_m")), } @@ -149,8 +161,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: { "layout": "plan", **kind, - "pieces": list(kind["pieces"]), + # 조각 차례 = 파일로 내린 뒤와 같게(지문이 안 흔들림) + "pieces": [piece for piece in PIECE_IDS if piece in kind["pieces"]], "scale": _scale(kind.get("layout", "plan"), None), + **_contour(kind.get("layout", "plan"), None), } for kind in DEFAULT_KINDS ] @@ -178,6 +192,7 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: "pieces": pieces, "layout": layout, "scale": _scale(layout, document.get("scale")), + **_contour(layout, document.get("contour_interval_m")), }, ) ) @@ -221,15 +236,26 @@ def _checked_scale(layout: str, scale: int | None, current: int = DRAWING_SCALE_ return scale +def _checked_contour(contour: int | None, current: int = BASIN_CONTOUR_INTERVAL_M) -> int: + """고른 유역도 등고선 간격 — 없으면 지금 값 · 목록 밖이면 거절(1 m = B05 · B06 전용).""" + if contour is None: + return current + if contour not in BASIN_CONTOUR_INTERVAL_CHOICES: + raise ValueError("고를 수 없는 등고선 간격입니다.") + return contour + + def add_kind( project_root: Path, name: str, pieces: list[str], layout: str = "plan", scale: int | None = None, + contour: int | None = None, ) -> dict[str, Any]: text, chosen = _checked(name, pieces, layout) chosen_scale = _checked_scale(layout, scale) + chosen_contour = _checked_contour(contour) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") @@ -239,15 +265,22 @@ def add_kind( "pieces": chosen, "layout": layout, "scale": chosen_scale, + **_contour(layout, chosen_contour), } atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds))) return kind def update_kind( - project_root: Path, kind_id: str, name: str, pieces: list[str], scale: int | None = None + project_root: Path, + kind_id: str, + name: str, + pieces: list[str], + scale: int | None = None, + contour: int | None = None, ) -> dict[str, Any]: - """종류 이름 · 조각 · 축척을 고친다(차례 · 양식은 그대로). 그 종류 확정본은 풀어 다시 그린다.""" + """종류 이름 · 조각 · 축척 · 유역도 등고선 간격을 고친다(차례 · 양식은 그대로). + 그 종류 확정본은 풀어 다시 그린다.""" if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") kinds = _materialize(project_root) @@ -257,6 +290,9 @@ def update_kind( raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") text, chosen = _checked(name, pieces, current["layout"]) chosen_scale = _checked_scale(current["layout"], scale, current["scale"]) + chosen_contour = _checked_contour( + contour, current.get("contour_interval_m", BASIN_CONTOUR_INTERVAL_M) + ) if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") order = int(_read_json(path).get("order") or 0) @@ -266,6 +302,7 @@ def update_kind( "pieces": chosen, "layout": current["layout"], "scale": chosen_scale, + **_contour(current["layout"], chosen_contour), } atomic_write_json(path, _document(kind, order)) _release_kinds(project_root, {kind_id}) @@ -609,7 +646,11 @@ def plan_kind_source( raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.") if kind["layout"] == WATERSHED_LAYOUT: # 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장. - return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)} + return { + "layout": WATERSHED_LAYOUT, + "label": kind["name"], + **watershed_source(context, kind["contour_interval_m"]), + } number = int(match.group(2)) if match.group(2) else 1 pieces, box, chunk, total = build_pieces( context, longitudinal, longitudinal_path, designs, number, kind["pieces"], kind["scale"] diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index de5f4376e..f5838ff9a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -31,6 +31,9 @@ class DesignDrawingItem(BaseModel): # 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다. plan_kind: str | None = None plan_kind_name: str | None = None + # 입력 지문(508-3b) — 같으면 같은 그림 · 횡단 장은 측점마다(`_Router_Support_Fingerprint`) + fingerprint: str | None = None + stations: dict[str, str] | None = None class DesignDrawingListResponse(BaseModel): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 23f7a7fb2..b7d7a6199 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -39,7 +39,6 @@ import { completeDesignStage, exportDrawing, fetchCrossLayout, - fetchDesignDrawing, fetchDesignDrawingList, fetchFrameTemplate, importFrameTemplate, @@ -52,11 +51,18 @@ import { type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; -import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; +import { + fetchState, + forgetAll, + forgetDrawing, + onFetchState, + openDrawing, + startPrefetch, + syncDrawings, +} from "./B07_DesignDetail_Prefetch"; import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle"; import { hasRedrawable, - isSaving, recoveryScopeOf, redrawFromSource, resetDrawings, @@ -125,7 +131,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { title: L("B07_Design_Title"), // 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281). onChanged: (dirty) => { - if (frameEditor.isEditing()) return; + // 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음 + if (frameEditor.isEditing() || pending) return; cadDirty = dirty; if (dirty) tabs.markDirty(); }, @@ -209,57 +216,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { quantityTable: response.quantity_table ?? null, }); - /** - * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시). - * - * 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초, - * 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다. - * sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라 - * 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다. - */ - const drawingCache = new Map>(); - - /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ - const requestDrawing = (drawing: DesignDrawingItem): Promise => { - const cached = drawingCache.get(drawing.id); - if (cached) return cached; - const request = (async () => { - const response = await fetchDesignDrawing(projectId as string, drawing.id); - // 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어 - // 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다. - if (drawing.kind === "cross" && !response.confirmed) { - await appendStructureEntities(projectId as string, response.route_id, response.drawing); - } - return response; - })().catch((error) => { - drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다. - throw error; - }); - drawingCache.set(drawing.id, request); - return request; - }; - - /** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. - * - * ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리). - * 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이 - * **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다. - * 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다. - */ - const prefetchAllDrawings = async (): Promise => { - if (!projectId) return; - for (const item of drawings) { - // 클릭 · [저장]이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다. - while (loadInFlight || isSaving()) await new Promise((done) => window.setTimeout(done, 120)); - if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다 - try { - await requestDrawing(item); - } catch { - /* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */ - } - } - }; - /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { if (!cadDirty && !tabs.anyDirty()) return true; @@ -270,19 +226,29 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); }; - /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ - let loadInFlight = false; + /** + * 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은 + * 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘). + */ + let pending: { id: string; stash?: CadDrawing } | undefined; /** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */ const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => { - if (!projectId || loadInFlight) return; - loadInFlight = true; + if (!projectId) return; + const mine = { id: drawing.id, stash }; + pending = mine; + currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀 highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; - cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다 + const ready = fetchState(drawing.id) === "ready"; + cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음 + if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info"); try { - const response = await requestDrawing(drawing); + const response = await openDrawing(drawing); + if (pending !== mine) return; // 그 사이 다른 도면을 고름 + pending = undefined; + if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검 currentDrawing = drawing; currentIndex = index; cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 @@ -312,10 +278,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); } } catch (error) { - cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); - if (currentDrawing) highlightActive(currentDrawing.id); + if (pending !== mine) return; + const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; + cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`); } finally { - loadInFlight = false; if (button) button.dataset.loading = "false"; } }; @@ -326,16 +292,20 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { */ const tabs = createCadTabs({ features: { close: true }, - capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }), + // 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠 + capture: async () => + pending + ? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false } + : { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }, canLeave: () => { - if (!frameEditor.isEditing()) return !loadInFlight; + if (!frameEditor.isEditing()) return true; showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning"); return false; }, show: async (tab: CadTab) => { const index = drawings.findIndex((item) => item.id === tab.id); - if (index >= 0) - await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined); + // 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김) + if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined); }, }); const openTab = (drawing: DesignDrawingItem): Promise => @@ -343,6 +313,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const selectDrawing = (drawing: DesignDrawingItem) => { if (drawing === currentDrawing) return; + // 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음) + if (pending?.id === drawing.id && fetchState(drawing.id) === "error") { + void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash); + return; + } void openTab(drawing); }; @@ -358,7 +333,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { setCadClean: () => void (cadDirty = false), requestCad: requestCadDrawing, isFrameEditing: () => frameEditor.isEditing(), - forget: (id: string) => void drawingCache.delete(id), + forget: forgetDrawing, reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)), }; @@ -407,7 +382,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, - onSaved: () => drawingCache.clear(), + onSaved: forgetAll, currentDrawingInfo: () => currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); @@ -449,6 +424,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { folded, ); drawingListEl = drawingPanel; + // 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음) + const markFetch = (id: string, state: string): void => { + const button = findButton(id); + if (button) button.dataset.fetch = state; + }; + drawings.forEach((item) => markFetch(item.id, fetchState(item.id))); + onFetchState(markFetch); const actions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). @@ -544,6 +526,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. + // 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감) + if (projectId) syncDrawings(projectId, drawings); if (drawings.length > 0) { const crossSheets = drawings.filter(isCrossSheet); const reopenId = reopenCrossSheet @@ -555,9 +539,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 311 기억한 장이 없으면 표지 const cover = drawings.findIndex((item) => item.kind === "cover"); const first = reopen >= 0 ? reopen : Math.max(cover, 0); - void openTab(drawings[first]).then(() => { - // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. - void prefetchAllDrawings(); - }); + // 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼). + void openTab(drawings[first]).then(startPrefetch); } } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 8b8971d54..35ce74f8b 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -99,6 +99,15 @@ export function kindForm( // 축척 — 목록은 서버 `scales` 한 곳 · 유역도 양식은 1/6,000 고정(잠금). const scaleText = (value: number): string => `1/${value.toLocaleString("en-US")}`; const scale = createSelectField({ options: [], compact: true, ariaLabel: "축척" }); + // 등고선 간격 — 유역도 양식만(5 m 기본 · 10 m) · 1 m 는 B05 · B06 전용이라 없음. + const contour = createSelectField({ + options: state.contour_intervals.map((value) => ({ + value: String(value), + text: `등고선 ${value} m`, + })), + compact: true, + ariaLabel: "등고선 간격", + }); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; @@ -128,6 +137,8 @@ export function kindForm( ); } scale.select.disabled = watershed; + contour.root.hidden = !watershed; + contour.select.value = String(kind?.contour_interval_m ?? state.default_contour_interval); }; const show = (): void => { const kind = state.kinds.find((item) => item.id === pick.select.value); @@ -155,17 +166,18 @@ export function kindForm( const label = name.value.trim(); const chosen = layout.select.value as PlanKindLayout; const denominator = Number(scale.select.value); + const interval = chosen === "watershed" ? Number(contour.select.value) : undefined; void run( () => kindId - ? updatePlanKind(controls.projectId, kindId, label, pieces, denominator) - : addPlanKind(controls.projectId, label, pieces, chosen, denominator), + ? updatePlanKind(controls.projectId, kindId, label, pieces, denominator, interval) + : addPlanKind(controls.projectId, label, pieces, chosen, denominator, interval), controls, ); }); const actions = el("div", "b07-plan-tools__actions"); actions.append(action, remove); - form.append(pick.root, layout.root, scale.root, name, pieceList, actions); + form.append(pick.root, layout.root, scale.root, contour.root, name, pieceList, actions); return { root: form, pick: (kindId) => { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 552deb3d5..34b77dd09 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -542,3 +542,13 @@ html.is-bm-route .b07-cad-stack > .cad-tabs { justify-content: flex-end; gap: var(--spacing-8); } + +/* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */ +.b07-drawing-button[data-fetch="error"] { + border-left-color: var(--color-danger); +} + +.b07-drawing-button[data-fetch="error"] .b07-drawing-button__name::after { + content: " · 다시 받기"; + color: var(--color-danger); +} diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index c4b983701..7239e8173 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -431,480 +431,3 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans padding-top: 6px; padding-bottom: 6px; } -/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이 - 행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */ -.cad-layer-manager .layer > button > span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} -.cad-layer-manager .layer > button > div { - flex-shrink: 0; -} -/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */ -.cad-layer-manager .layer > button [data-size="small"] { - padding-left: 2px; - padding-right: 2px; - min-width: 0; -} -/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */ -.cad-layer-manager button[data-type="regular"] { - background: var(--cad-chrome-sunken); - color: var(--cad-accent); -} -.cad-layer-manager button[data-type="transparent"] { - background: transparent; - color: var(--cad-accent); -} -.cad-layer-manager button:hover { - background: var(--cad-hover); - color: var(--color-text); -} -.cad-layer-manager button[data-active="true"] { - background: var(--color-primary); - color: var(--color-primary-text); -} - -.cad-view-controls { - position: fixed; - z-index: 3; - right: 14px; - bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px); - display: flex; - align-items: center; - overflow: hidden; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: var(--cad-chrome-raised); - box-shadow: var(--shadow-lg); -} -/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */ -.cad-drop-hint { - position: fixed; - z-index: 2; - top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px); - right: 14px; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 2px; - white-space: nowrap; - padding: 2px 4px; - border: 0; - background: transparent; - color: var(--cad-text-muted); - font-size: 11px; - text-align: right; - opacity: 0.55; - cursor: pointer; -} -.cad-drop-hint:hover { - opacity: 1; -} -.cad-drop-hint__steps { - display: flex; - flex-direction: column; - align-items: flex-end; - color: var(--cad-text-dim); - font-size: 10px; -} -/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */ -body.cad-file-dragging::after { - content: ''; - position: fixed; - z-index: 50; - inset: 6px; - border: 2px dashed var(--cad-accent); - border-radius: 6px; - opacity: 0.6; - pointer-events: none; -} - -.cad-view-controls button { - width: 34px; - height: 32px; - border: 0; - border-right: 1px solid var(--cad-line); - background: transparent; - font-size: 17px; -} -.cad-view-controls button:hover { - background: var(--cad-hover); -} -.cad-view-controls__fit { - color: var(--cad-accent); - font-size: 15px; -} -.cad-view-controls span { - min-width: 48px; - color: var(--cad-text-dim); - font-size: 10px; - text-align: center; -} - -/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */ -.cad-command-area { - position: fixed; - z-index: 3; - right: 0; - bottom: var(--cad-status-height); - left: var(--cad-panel-width); - height: var(--cad-command-height); - display: flex; - flex-direction: column; - gap: 4px; - padding: 6px 10px; - background: var(--cad-chrome); - border-top: 1px solid var(--cad-line); -} -.cad-command-history { - display: flex; - flex-direction: column; - flex: 1; - justify-content: flex-end; - overflow: hidden; - color: var(--cad-text-muted); - font: 11px Consolas, monospace; -} -.cad-command-suggestions { - position: absolute; - z-index: 6; - bottom: 100%; - left: 10px; - display: flex; - flex-direction: column; - min-width: 220px; - padding: 4px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 4px; - box-shadow: var(--shadow-lg); -} -.cad-command-suggestions button { - padding: 4px 8px; - border: 0; - border-radius: 3px; - background: transparent; - font: 11px Consolas, monospace; - text-align: left; -} -.cad-command-suggestions button:hover { - background: var(--cad-hover); -} -.cad-prop--wide select { - min-width: 132px; -} -.cad-command-prompt { - max-width: 42%; - overflow: hidden; - color: var(--cad-text-dim); - font-size: 11px; - white-space: nowrap; - text-overflow: ellipsis; -} -.cad-command-area form { - display: flex; - align-items: center; - gap: 7px; - height: 28px; -} -.cad-command-area label { - color: var(--color-text); - font-size: 11px; - font-weight: 600; -} -.cad-command-area input { - flex: 1; - height: 26px; - padding: 0 8px; - border: 1px solid var(--cad-line); - border-radius: 2px; - outline: none; - background: var(--cad-chrome-sunken); - color: var(--cad-text); - font: 11px Consolas, monospace; -} -.cad-command-area input:focus { - border-color: var(--color-focus-ring); - box-shadow: 0 0 0 1px var(--color-focus-ring); -} - -.cad-statusbar { - position: fixed; - z-index: 3; - right: 0; - bottom: 0; - left: 0; - height: var(--cad-status-height); - display: flex; - align-items: center; - gap: 2px; - padding: 0 8px; - background: var(--cad-chrome); - border-top: 1px solid var(--cad-line); -} -.cad-statusbar button { - height: 21px; - padding: 0 8px; - border: 1px solid transparent; - border-radius: 2px; - background: transparent; - color: var(--cad-text-dim); - font-size: 10px; -} -.cad-statusbar button:hover { - background: var(--cad-hover); -} -.cad-statusbar button[data-active="true"] { - border-color: var(--cad-accent); - background: var(--color-primary); - color: var(--color-primary-text); -} -.cad-statusbar__hint { - margin-left: auto; - color: var(--cad-text-muted); - font-size: 10px; -} - -@media (max-width: 800px) { - :root { - --cad-panel-width: 200px; - } - .cad-statusbar__hint { - display: none; - } -} - -/* 특성 팔레트 — 값을 바로 고치는 입력들 */ -.cad-properties-editor { - display: flex; - flex-direction: column; - gap: 7px; -} -.cad-properties-editor__title { - color: var(--color-text); - font-size: 13px; - font-weight: 600; -} -.cad-properties-editor label { - display: grid; - grid-template-columns: 72px 1fr; - align-items: center; - gap: 8px; - font-size: 11px; - color: var(--cad-text-dim); -} -.cad-properties-editor select, -.cad-properties-editor input { - height: 24px; - min-width: 0; - padding: 0 4px; - border: 1px solid var(--cad-line); - border-radius: 3px; - background: var(--cad-chrome-sunken); - color: var(--cad-text); - font-size: 11px; -} -.cad-properties-editor__readout { - margin: 6px 0 0; - border-top: 1px solid var(--cad-line); -} -.cad-properties-editor__readout div { - display: grid; - grid-template-columns: 72px 1fr; - padding: 5px 0; - border-bottom: 1px solid var(--cad-line); - font-size: 11px; -} -.cad-properties-editor__readout dt { - color: var(--cad-text-dim); -} -.cad-properties-editor__readout dd { - margin: 0; - overflow: hidden; - color: var(--cad-text); - text-overflow: ellipsis; -} -.cad-properties__empty { - margin: 0; - color: var(--cad-text-muted); - font-size: 11px; -} - -/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */ -.cad-quick-properties { - position: fixed; - z-index: 5; - top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); - right: 14px; - width: 240px; - padding: 8px 10px 10px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 5px; - box-shadow: var(--shadow-lg); -} -.cad-quick-properties__header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 6px; - color: var(--cad-text-dim); - font-size: 11px; -} -.cad-quick-properties__header button { - border: 0; - background: transparent; - color: var(--cad-text-muted); - font-size: 12px; -} - -/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */ -.cad-block-library { - position: fixed; - z-index: 5; - top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); - right: 14px; - width: 240px; - padding: 8px 10px 10px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 5px; - box-shadow: var(--shadow-lg); -} -.cad-block-library__empty { - margin: 0; - color: var(--cad-text-muted); - font-size: 11px; - line-height: 1.5; -} -.cad-block-library__list { - max-height: 220px; - margin: 0; - padding: 0; - overflow-y: auto; - list-style: none; -} -.cad-block-library__list li { - display: flex; - align-items: center; - gap: 4px; -} -.cad-block-library__list li button { - border: 0; - background: transparent; - color: var(--cad-text); - font-size: 12px; -} -.cad-block-library__name { - flex: 1; - overflow: hidden; - padding: 3px 4px; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; -} -.cad-block-library__name:hover { - background: var(--cad-chrome-sunken); -} -.cad-block-library__actions { - display: flex; - gap: 6px; - margin-top: 8px; -} -.cad-block-library__actions button { - flex: 1; - padding: 4px 0; - border: 1px solid var(--cad-line); - border-radius: 3px; - background: transparent; - color: var(--cad-text-dim); - font-size: 11px; -} - -/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로 - 부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */ -.cad-frame-tokens { - position: fixed; - /* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */ - right: 12px; - bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px); - z-index: 3; - max-height: 46vh; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; - width: 236px; - padding: 8px; - border: 1px solid var(--cad-line); - border-radius: 6px; - background: var(--cad-chrome-raised); - box-shadow: var(--shadow-lg); - color: var(--cad-text); -} - -.cad-frame-tokens__header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 6px; - color: var(--cad-text-dim); - font-size: 11px; - line-height: 1.4; -} - -.cad-frame-tokens__fold { - flex: none; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: transparent; - color: var(--cad-text); - cursor: pointer; -} - -.cad-frame-tokens__group { - display: flex; - flex-wrap: wrap; - gap: 4px; - align-items: center; -} - -.cad-frame-tokens__title { - width: 100%; - color: var(--cad-text-dim); - font-size: 11px; -} - -.cad-frame-tokens__button { - padding: 3px 7px; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: var(--cad-chrome); - color: var(--cad-text); - font-size: 11px; - cursor: pointer; -} - -.cad-frame-tokens__button:hover { - background: var(--cad-accent-soft, var(--cad-chrome-raised)); -} - -/* 자리표 칸 크기 입력 (2026-09-06) */ -.cad-frame-tokens__size { - display: flex; - align-items: center; - gap: 4px; - color: var(--cad-text-dim); - font-size: 11px; -} - -.cad-frame-tokens__size input { - width: 64px; - padding: 2px 4px; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: var(--cad-chrome); - color: var(--cad-text); - font-size: 11px; -} diff --git a/B07_DesignDetail/openwebcad/src/App.tsx b/B07_DesignDetail/openwebcad/src/App.tsx index 370698bba..e5cbee69d 100644 --- a/B07_DesignDetail/openwebcad/src/App.tsx +++ b/B07_DesignDetail/openwebcad/src/App.tsx @@ -1,4 +1,5 @@ import './App.css'; +import './App_Layers.css'; import { ToastContainer } from 'react-toastify'; import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx'; import { Toolbar } from './components/Toolbar.tsx'; diff --git a/B07_DesignDetail/openwebcad/src/App_Layers.css b/B07_DesignDetail/openwebcad/src/App_Layers.css new file mode 100644 index 000000000..c46a6d7d1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/App_Layers.css @@ -0,0 +1,477 @@ +/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이 + 행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */ +.cad-layer-manager .layer > button > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.cad-layer-manager .layer > button > div { + flex-shrink: 0; +} +/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */ +.cad-layer-manager .layer > button [data-size="small"] { + padding-left: 2px; + padding-right: 2px; + min-width: 0; +} +/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */ +.cad-layer-manager button[data-type="regular"] { + background: var(--cad-chrome-sunken); + color: var(--cad-accent); +} +.cad-layer-manager button[data-type="transparent"] { + background: transparent; + color: var(--cad-accent); +} +.cad-layer-manager button:hover { + background: var(--cad-hover); + color: var(--color-text); +} +.cad-layer-manager button[data-active="true"] { + background: var(--color-primary); + color: var(--color-primary-text); +} + +.cad-view-controls { + position: fixed; + z-index: 3; + right: 14px; + bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px); + display: flex; + align-items: center; + overflow: hidden; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); +} +/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */ +.cad-drop-hint { + position: fixed; + z-index: 2; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px); + right: 14px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + white-space: nowrap; + padding: 2px 4px; + border: 0; + background: transparent; + color: var(--cad-text-muted); + font-size: 11px; + text-align: right; + opacity: 0.55; + cursor: pointer; +} +.cad-drop-hint:hover { + opacity: 1; +} +.cad-drop-hint__steps { + display: flex; + flex-direction: column; + align-items: flex-end; + color: var(--cad-text-dim); + font-size: 10px; +} +/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */ +body.cad-file-dragging::after { + content: ''; + position: fixed; + z-index: 50; + inset: 6px; + border: 2px dashed var(--cad-accent); + border-radius: 6px; + opacity: 0.6; + pointer-events: none; +} + +.cad-view-controls button { + width: 34px; + height: 32px; + border: 0; + border-right: 1px solid var(--cad-line); + background: transparent; + font-size: 17px; +} +.cad-view-controls button:hover { + background: var(--cad-hover); +} +.cad-view-controls__fit { + color: var(--cad-accent); + font-size: 15px; +} +.cad-view-controls span { + min-width: 48px; + color: var(--cad-text-dim); + font-size: 10px; + text-align: center; +} + +/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */ +.cad-command-area { + position: fixed; + z-index: 3; + right: 0; + bottom: var(--cad-status-height); + left: var(--cad-panel-width); + height: var(--cad-command-height); + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 10px; + background: var(--cad-chrome); + border-top: 1px solid var(--cad-line); +} +.cad-command-history { + display: flex; + flex-direction: column; + flex: 1; + justify-content: flex-end; + overflow: hidden; + color: var(--cad-text-muted); + font: 11px Consolas, monospace; +} +.cad-command-suggestions { + position: absolute; + z-index: 6; + bottom: 100%; + left: 10px; + display: flex; + flex-direction: column; + min-width: 220px; + padding: 4px; + background: var(--cad-chrome-raised); + border: 1px solid var(--cad-line); + border-radius: 4px; + box-shadow: var(--shadow-lg); +} +.cad-command-suggestions button { + padding: 4px 8px; + border: 0; + border-radius: 3px; + background: transparent; + font: 11px Consolas, monospace; + text-align: left; +} +.cad-command-suggestions button:hover { + background: var(--cad-hover); +} +.cad-prop--wide select { + min-width: 132px; +} +.cad-command-prompt { + max-width: 42%; + overflow: hidden; + color: var(--cad-text-dim); + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} +.cad-command-area form { + display: flex; + align-items: center; + gap: 7px; + height: 28px; +} +.cad-command-area label { + color: var(--color-text); + font-size: 11px; + font-weight: 600; +} +.cad-command-area input { + flex: 1; + height: 26px; + padding: 0 8px; + border: 1px solid var(--cad-line); + border-radius: 2px; + outline: none; + background: var(--cad-chrome-sunken); + color: var(--cad-text); + font: 11px Consolas, monospace; +} +.cad-command-area input:focus { + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); +} + +.cad-statusbar { + position: fixed; + z-index: 3; + right: 0; + bottom: 0; + left: 0; + height: var(--cad-status-height); + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + background: var(--cad-chrome); + border-top: 1px solid var(--cad-line); +} +.cad-statusbar button { + height: 21px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: var(--cad-text-dim); + font-size: 10px; +} +.cad-statusbar button:hover { + background: var(--cad-hover); +} +.cad-statusbar button[data-active="true"] { + border-color: var(--cad-accent); + background: var(--color-primary); + color: var(--color-primary-text); +} +.cad-statusbar__hint { + margin-left: auto; + color: var(--cad-text-muted); + font-size: 10px; +} + +@media (max-width: 800px) { + :root { + --cad-panel-width: 200px; + } + .cad-statusbar__hint { + display: none; + } +} + +/* 특성 팔레트 — 값을 바로 고치는 입력들 */ +.cad-properties-editor { + display: flex; + flex-direction: column; + gap: 7px; +} +.cad-properties-editor__title { + color: var(--color-text); + font-size: 13px; + font-weight: 600; +} +.cad-properties-editor label { + display: grid; + grid-template-columns: 72px 1fr; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--cad-text-dim); +} +.cad-properties-editor select, +.cad-properties-editor input { + height: 24px; + min-width: 0; + padding: 0 4px; + border: 1px solid var(--cad-line); + border-radius: 3px; + background: var(--cad-chrome-sunken); + color: var(--cad-text); + font-size: 11px; +} +.cad-properties-editor__readout { + margin: 6px 0 0; + border-top: 1px solid var(--cad-line); +} +.cad-properties-editor__readout div { + display: grid; + grid-template-columns: 72px 1fr; + padding: 5px 0; + border-bottom: 1px solid var(--cad-line); + font-size: 11px; +} +.cad-properties-editor__readout dt { + color: var(--cad-text-dim); +} +.cad-properties-editor__readout dd { + margin: 0; + overflow: hidden; + color: var(--cad-text); + text-overflow: ellipsis; +} +.cad-properties__empty { + margin: 0; + color: var(--cad-text-muted); + font-size: 11px; +} + +/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */ +.cad-quick-properties { + position: fixed; + z-index: 5; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); + right: 14px; + width: 240px; + padding: 8px 10px 10px; + background: var(--cad-chrome-raised); + border: 1px solid var(--cad-line); + border-radius: 5px; + box-shadow: var(--shadow-lg); +} +.cad-quick-properties__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + color: var(--cad-text-dim); + font-size: 11px; +} +.cad-quick-properties__header button { + border: 0; + background: transparent; + color: var(--cad-text-muted); + font-size: 12px; +} + +/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */ +.cad-block-library { + position: fixed; + z-index: 5; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); + right: 14px; + width: 240px; + padding: 8px 10px 10px; + background: var(--cad-chrome-raised); + border: 1px solid var(--cad-line); + border-radius: 5px; + box-shadow: var(--shadow-lg); +} +.cad-block-library__empty { + margin: 0; + color: var(--cad-text-muted); + font-size: 11px; + line-height: 1.5; +} +.cad-block-library__list { + max-height: 220px; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} +.cad-block-library__list li { + display: flex; + align-items: center; + gap: 4px; +} +.cad-block-library__list li button { + border: 0; + background: transparent; + color: var(--cad-text); + font-size: 12px; +} +.cad-block-library__name { + flex: 1; + overflow: hidden; + padding: 3px 4px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.cad-block-library__name:hover { + background: var(--cad-chrome-sunken); +} +.cad-block-library__actions { + display: flex; + gap: 6px; + margin-top: 8px; +} +.cad-block-library__actions button { + flex: 1; + padding: 4px 0; + border: 1px solid var(--cad-line); + border-radius: 3px; + background: transparent; + color: var(--cad-text-dim); + font-size: 11px; +} + +/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로 + 부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */ +.cad-frame-tokens { + position: fixed; + /* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */ + right: 12px; + bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px); + z-index: 3; + max-height: 46vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + width: 236px; + padding: 8px; + border: 1px solid var(--cad-line); + border-radius: 6px; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); + color: var(--cad-text); +} + +.cad-frame-tokens__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 6px; + color: var(--cad-text-dim); + font-size: 11px; + line-height: 1.4; +} + +.cad-frame-tokens__fold { + flex: none; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: transparent; + color: var(--cad-text); + cursor: pointer; +} + +.cad-frame-tokens__group { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.cad-frame-tokens__title { + width: 100%; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__button { + padding: 3px 7px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; + cursor: pointer; +} + +.cad-frame-tokens__button:hover { + background: var(--cad-accent-soft, var(--cad-chrome-raised)); +} + +/* 자리표 칸 크기 입력 (2026-09-06) */ +.cad-frame-tokens__size { + display: flex; + align-items: center; + gap: 4px; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__size input { + width: 64px; + padding: 2px 4px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; +} diff --git a/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts b/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts index 34a7c0350..af9e45afa 100644 --- a/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts @@ -1,5 +1,5 @@ import type * as Flatten from '@flatten-js/core'; -import { Box, type Point, type Segment } from '@flatten-js/core'; +import { Box, Point, type Segment } from '@flatten-js/core'; import { mapLimit } from 'blend-promise-utils'; import { compact, maxBy } from 'es-toolkit'; import { minBy } from 'es-toolkit/compat'; @@ -181,10 +181,20 @@ export class PolyLineEntity implements Entity { } ); - const polyLineEntity = new PolyLineEntity( - jsonEntity.layerId || getActiveLayerId(), - compact(entities) - ); + const layerId = jsonEntity.layerId || getActiveLayerId(); + // 514-3 가벼운 꼴 — 자식 대신 꼭짓점 목록(B07 유역도 배경선) · 선분으로 펼침 + const points = jsonEntity.children?.length ? [] : (jsonEntity.shapeData?.points ?? []); + for (let index = 1; index < points.length; index++) { + const start = points[index - 1]; + const end = points[index]; + const line = new LineEntity(layerId, new Point(start.x, start.y), new Point(end.x, end.y)); + line.lineColor = jsonEntity.lineColor; + line.lineWidth = jsonEntity.lineWidth; + line.lineDash = jsonEntity.lineDash; + entities.push(line); + } + + const polyLineEntity = new PolyLineEntity(layerId, compact(entities)); polyLineEntity.id = jsonEntity.id; polyLineEntity.lineColor = jsonEntity.lineColor; polyLineEntity.lineWidth = jsonEntity.lineWidth; @@ -193,4 +203,4 @@ export class PolyLineEntity implements Entity { } } -export type PolyLineJsonData = Record; +export type PolyLineJsonData = { points?: { x: number; y: number }[] }; diff --git a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts index dbbe75cd1..c2240f779 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts @@ -1,8 +1,18 @@ +import type { Layer } from '../App.types'; + +/** 도면층 목록 + 현재 층 — LAYMRG · LAYDEL 이 객체 변경과 함께 담는다 */ +export interface LayerSnapshot { + layers: Layer[]; + activeLayerId: string; +} + export interface UndoState { variable: StateVariable; // biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types value: any; + /** 이 단계가 도면층도 바꿨다면 전 · 후 — 되돌리면 before · 다시 하면 after 를 복원 */ + layerChange?: { before: LayerSnapshot; after: LayerSnapshot }; } export enum StateVariable { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index ad64ff267..57e005795 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -12,7 +12,12 @@ import { import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; import { bumpSceneVersion } from './helpers/scene-version'; -import { StateVariable, type UndoState, createStack } from './helpers/undo-stack'; +import { + type LayerSnapshot, + StateVariable, + type UndoState, + createStack, +} from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt'; @@ -626,20 +631,43 @@ export function resetUndoBaseline() { drawingDirty = false; } +export const getLayerSnapshot = (): LayerSnapshot => ({ layers, activeLayerId }); + +/** + * 방금 쌓은 객체 되돌리기 단계에 도면층 전 · 후를 붙인다(LAYMRG · LAYDEL) — + * Ctrl+Z 한 번에 객체와 도면층이 함께 돌아오고 Ctrl+Y 도 짝으로 간다. + * 맨 위 단계가 지금 객체 목록이 아니면(쌓이지 않음) 붙이지 않는다. + */ +export function attachLayerChangeToUndo(before: LayerSnapshot, after: LayerSnapshot) { + const top = undoStack.peek(); + if (top?.variable === StateVariable.entities && top.value === entities) { + top.layerChange = { before, after }; + } +} + +function applyLayerSnapshot(snapshot: LayerSnapshot) { + setLayers(snapshot.layers); + setActiveLayerId(snapshot.activeLayerId); +} + export function undo() { + const undone = undoStack.peek(); const undoState = undoStack.undo(); if (!undoState) return; updateStates(undoState); + if (undone !== undoState && undone?.layerChange) applyLayerSnapshot(undone.layerChange.before); drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function redo() { + const current = undoStack.peek(); const redoState = undoStack.redo(); if (!redoState) return; updateStates(redoState); + if (current !== redoState && redoState.layerChange) applyLayerSnapshot(redoState.layerChange.after); drawingDirty = true; notifyWindow(HtmlEvent.DRAWING_CHANGED); } diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts new file mode 100644 index 000000000..75d042686 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts @@ -0,0 +1,88 @@ +import { Point } from '@flatten-js/core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { createActor } from 'xstate'; +import type { Layer } from '../../App.types'; +import { LineEntity } from '../../entities/LineEntity.ts'; +import { + getActiveLayerId, + getEntities, + getLayers, + redo, + resetUndoBaseline, + setActiveLayerId, + setEntities, + setLayers, + setScreenCanvasDrawController, + undo, +} from '../../state.ts'; +import { ActorEvent } from '../tool.types.ts'; +import { layerDeleteToolStateMachine, layerMergeToolStateMachine } from './layer-tools.ts'; + +const layer = (id: string): Layer => ({ id, name: id, isVisible: true, isLocked: false }); + +/** 선 A(레이어 A · y=0) · 선 B(레이어 B · y=100) — 활성층은 A */ +function setup() { + const a = new LineEntity('A', new Point(0, 0), new Point(10, 0)); + const b = new LineEntity('B', new Point(0, 100), new Point(10, 100)); + setLayers([layer('A'), layer('B')]); + setActiveLayerId('A'); + setEntities([a, b]); + resetUndoBaseline(); +} + +const click = (actor: ReturnType, y: number) => + actor.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation: new Point(5, y), + screenMouseLocation: new Point(0, 0), + holdingCtrl: false, + holdingShift: false, + }); + +const layerOfEntities = () => getEntities().map((entity) => entity.layerId); +const layerIds = () => getLayers().map((l) => l.id); + +describe('LAYMRG · LAYDEL 되돌리기 (508-3a)', () => { + beforeAll(() => { + (globalThis as { window?: unknown }).window = { dispatchEvent: () => true }; + // 집기 반경만 필요 — 화면 배율 1 + setScreenCanvasDrawController({ getScreenScale: () => 1, getWorldMouseLocation: () => new Point(0, 0) } as never); + }); + + it('LAYMRG A→B: Ctrl+Z 한 번에 도형 레이어 · 레이어 목록 · 현재 층이 처음대로, Ctrl+Y 로 다시 합쳐짐', () => { + setup(); + const actor = createActor(layerMergeToolStateMachine).start(); + click(actor, 0); + click(actor, 100); + expect(layerOfEntities()).toEqual(['B', 'B']); + expect(layerIds()).toEqual(['B']); + expect(getActiveLayerId()).toBe('B'); + + undo(); + expect(layerOfEntities()).toEqual(['A', 'B']); + expect(layerIds()).toEqual(['A', 'B']); + expect(getActiveLayerId()).toBe('A'); + + redo(); + expect(layerOfEntities()).toEqual(['B', 'B']); + expect(layerIds()).toEqual(['B']); + expect(getActiveLayerId()).toBe('B'); + }); + + it('LAYDEL A: Ctrl+Z 한 번에 지운 도형 · 레이어 A 가 돌아오고, Ctrl+Y 로 다시 지워짐', () => { + setup(); + const actor = createActor(layerDeleteToolStateMachine).start(); + click(actor, 0); + expect(layerOfEntities()).toEqual(['B']); + expect(layerIds()).toEqual(['B']); + + undo(); + expect(layerOfEntities()).toEqual(['A', 'B']); + expect(layerIds()).toEqual(['A', 'B']); + expect(getActiveLayerId()).toBe('A'); + + redo(); + expect(layerOfEntities()).toEqual(['B']); + expect(layerIds()).toEqual(['B']); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts index e80a22ae0..6fd8c7b81 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts @@ -11,9 +11,11 @@ import { saveLayerState, } from '../../helpers/layer-history'; import { + attachLayerChangeToUndo, deleteEntities, getActiveLayerId, getEntities, + getLayerSnapshot, getLayers, setActiveLayerId, setLayers, @@ -29,6 +31,13 @@ function updateLayers(mutate: (layers: Layer[]) => Layer[]): void { setLayers(mutate(current.map((layer) => ({ ...layer })))); } +/** 객체 변경 + 도면층 변경을 되돌리기 한 단계로 묶는다(Ctrl+Z 한 번에 함께 · Ctrl+Y 도 짝) */ +function withLayerUndo(change: () => void): void { + const before = getLayerSnapshot(); + change(); + attachLayerChangeToUndo(before, getLayerSnapshot()); +} + const layerNameOf = (layerId: string): string => getLayers().find((layer) => layer.id === layerId)?.name ?? layerId; @@ -222,15 +231,17 @@ export const layerMergeToolStateMachine = createSequenceTool({ toast.warn('같은 레이어입니다.'); return; } - // 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) · 층 목록 = LAYERP - restyle( - getEntities().filter((entity) => entity.layerId === sourceId), - (entity) => { - entity.layerId = targetId; - } - ); - updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId)); - if (getActiveLayerId() === sourceId) setActiveLayerId(targetId); + // 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) + withLayerUndo(() => { + restyle( + getEntities().filter((entity) => entity.layerId === sourceId), + (entity) => { + entity.layerId = targetId; + } + ); + updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId)); + if (getActiveLayerId() === sourceId) setActiveLayerId(targetId); + }); toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`); }, }); @@ -246,9 +257,11 @@ export const layerDeleteToolStateMachine = createSequenceTool({ return; } const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId); - deleteEntities(doomed, true); - updateLayers((layers) => layers.filter((layer) => layer.id !== layerId)); - if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id); + withLayerUndo(() => { + deleteEntities(doomed, true); + updateLayers((layers) => layers.filter((layer) => layer.id !== layerId)); + if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id); + }); toast.success(`레이어와 객체 ${doomed.length}개를 삭제했습니다.`); }, }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 97c93a15f..e665d5969 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -16,6 +16,7 @@ * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; +import "../B01_Dashboard/B01_Dashboard_UI_Style_Company.css"; import { createButton, createInputField, diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py index 117ccfb53..d2503a454 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py @@ -320,7 +320,8 @@ class _Painter: vertices = [xy for v in shape.get("points") or [] if (xy := _xy(v))] if len(vertices) >= 2: self.stroke(entity) - self.line([*vertices, vertices[0]]) + # 꼭짓점 목록 폴리선(B07 유역도 배경선) = 열린 선 + self.line(vertices if kind == "PolyLine" else [*vertices, vertices[0]]) for child in entity.get("children") or []: self.entity(child) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index a4ec6adbc..ff1db8eec 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -21,7 +21,7 @@ const TEXT = { GroupName: ["그룹 이름", "Group name"], GroupNew: ["새 그룹 {value}", "New group {value}"], GroupUp: ["위로", "Up"], - AddKind: ["종류 더하기", "Add type"], + AddKind: ["종류 추가", "Add type"], AddKindNote: [ "본보기 종류를 그대로 복사한 새 종류(이름 「본보기 2」) — 옵션 · 그룹 · 반영 · 그리는 법이 같음 · 이름 · 반영은 고쳐서 [저장]", "New type copied from the template (name “template 2”) — same options, groups, reflect and drawing; edit name / reflect, then [Save]", 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_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 5ae0b51b4..c42709154 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -66,158 +66,32 @@ import { } from "./common_util_mass_haul_settle"; export { haulPlanPayload } from "./common_util_mass_haul_settle"; +import type { + BalanceStep, + CurvePoint, + HaulBand, + HaulBlock, + HaulDirection, + HaulEquipmentLimit, + HaulPlan, + HaulResidual, +} from "./common_util_mass_haul_balance_types"; +import { + applyCollectedStoneDeduction, + applyStructureSpoil, +} from "./common_util_mass_haul_balance_apply"; -/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */ -export interface HaulEquipmentLimit { - key: string; - max_distance_m: number | null; -} - -/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */ -export type HaulDirection = "forward" | "backward"; - -/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */ -export interface CurvePoint { - m: number; - v: number; -} - -/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */ -export interface HaulBand { - /** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */ - index: number; - /** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */ - equipment: string | null; - /** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */ - level_base_m3: number; - level_apex_m3: number; - /** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */ - volume_m3: number; - /** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */ - boundary_from_m: number; - boundary_to_m: number; - /** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */ - haul_distance_m: number; - haul_from_m: number; - haul_to_m: number; - /** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */ - ea_m3: number; - rr_m3: number; - br_m3: number; - /** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */ - outline: CurvePoint[]; -} - -export interface HaulBlock { - /** 1부터. */ - index: number; - /** 이 블록의 평형선 높이(누가토량 축, ㎥). */ - base_m3: number; - /** 평형선이 곡선을 자르는 두 점(누가거리 m). */ - from_m: number; - to_m: number; - /** 정점(극대/극소) 위치와 값. */ - apex_m: number; - apex_volume_m3: number; - /** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */ - volume_m3: number; - direction: HaulDirection; - /** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */ - bands: HaulBand[]; -} - -/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */ -export interface HaulResidual { - index: number; - kind: "spoil" | "borrow"; - from_m: number; - to_m: number; - volume_m3: number; - /** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */ - level_from_m3: number; - level_to_m3: number; - /** - * 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다. - * **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다. - */ - ea_m3: number; - rr_m3: number; - br_m3: number; - /** - * 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다. - * 나머지(`volume_m3 − natural_m3`)는 사토장으로 실어 내야 한다. - * - * 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가 - * 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서** - * 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0). - */ - natural_m3: number; -} - -/** - * 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인 - * 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다). - * - * 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면 - * 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다. - */ -export interface HaulTransfer { - index: number; - /** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */ - from_m: number; - to_m: number; - volume_m3: number; - /** 두 자리 사이 거리 = 이 운반의 `L`. */ - haul_distance_m: number; - /** 연결 수평선을 그릴 높이(누가토량 ㎥). */ - level_m3: number; - equipment: string | null; - ea_m3: number; - rr_m3: number; - br_m3: number; -} - -/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */ -export interface BalanceStep { - from_m: number; - to_m: number; - level_m3: number; -} - -export interface HaulPlan { - blocks: HaulBlock[]; - residuals: HaulResidual[]; - transfers: HaulTransfer[]; - steps: BalanceStep[]; - spoil_m3: number; - borrow_m3: number; - /** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */ - natural_spoil_m3: number; - /** - * 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 — - * 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09). - */ - collected_stone_deduction_m3: number | null; - /** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */ - collected_stone_deducted_m3: number; - /** - * 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고 - * `0` 은 「없음」이다(공제와 같은 태도). - */ - structure_spoil_m3: number | null; - /** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */ - structure_spoil_added_m3: number; - /** 블록 안에서 옮기는 양(㎥). */ - hauled_m3: number; - /** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */ - transferred_m3: number; - /** - * 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로 - * `hauled_m3 + transferred_m3`가 이 값과 같아야 한다. - * 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다. - */ - fill_total_m3: number; -} +export type { + BalanceStep, + CurvePoint, + HaulBand, + HaulBlock, + HaulDirection, + HaulEquipmentLimit, + HaulPlan, + HaulResidual, + HaulTransfer, +} from "./common_util_mass_haul_balance_types"; /** * **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 @@ -333,253 +207,6 @@ function bandOutline( * 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다. * 블록도 잔량도 안 나오면(평탄한 곡선) null. */ -/** - * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). - * - * 채집석 공제는 사토에서 한 번만 뺀다. - * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, - * 빼는 자리는 유토곡선의 사토뿐이다 — - * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. - * - * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) - * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** - * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. - * ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 - * 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다. - * ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다. - * - * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. - */ -function applyCollectedStoneDeduction( - residuals: HaulResidual[], - deduction: number | null, - byGround?: Record | null, - groundUnknownM3?: number | null, - conversion?: EarthworkConversion | null, -): number { - // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 - // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. - // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). - // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. - const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); - const amountToTake = converted === null ? deduction : converted; - if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0; - const spoils = residuals.filter((residual) => residual.kind === "spoil"); - let left = amountToTake; - const take = (residual: HaulResidual, amount: number): void => { - if (amount <= 0) return; - const before = residual.volume_m3; - const ratio = before > 0 ? (before - amount) / before : 0; - residual.volume_m3 = before - amount; - // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. - residual.ea_m3 *= ratio; - residual.rr_m3 *= ratio; - residual.br_m3 *= ratio; - left -= amount; - }; - // ① 실어 내는 몫부터 - for (const residual of spoils) { - if (left <= EPSILON) break; - take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); - } - // ② 모자라면 자연방토에서 - for (const residual of spoils) { - if (left <= EPSILON) break; - const amount = Math.min(residual.natural_m3, left); - residual.natural_m3 -= amount; - take(residual, amount); - } - return amountToTake - Math.max(left, 0); -} - -/** - * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). - * - * 구조물 잔토는 사토에 한 번만 더한다. - * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리). - * ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로 - * **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고, - * 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다). - * ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다. - * ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다. - * 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다. - * - * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. - */ -/** - * 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다). - * 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다. - */ -function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null { - const text = (ground ?? "").trim(); - if (!text) return null; - if (text === "토사" || text === "soil" || text === "ea") return "ea_m3"; - if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") { - return "rr_m3"; - } - if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3"; - return null; -} - -/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ -const GROUND_OF_BUCKET = { - ea_m3: "soil", - rr_m3: "ripping_rock", - br_m3: "blasting_rock", -} as const; - -/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ -const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const; - -/** - * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. - * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. - */ -function compactedStoneDeduction( - byGround: Record | null | undefined, - groundUnknownM3: number | null | undefined, - conversion: EarthworkConversion | null | undefined, -): number | null { - if (!byGround) return null; - const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter( - ([, value]) => Number.isFinite(value) && value > 0, - ); - const unknown = Number(groundUnknownM3 ?? 0); - const hasUnknown = Number.isFinite(unknown) && unknown > 0; - if (!entries.length && !hasUnknown) return null; - let total = hasUnknown ? unknown : 0; - for (const [ground, value] of entries) { - const factor = conversion?.[ground]?.compacted; - total += typeof factor === "number" && factor > 0 ? value * factor : value; - } - return total; -} - -/** - * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. - * - * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 - * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 - * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. - * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). - * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** - * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 - * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. - */ -function toCompacted( - naturalM3: number, - bucket: keyof typeof GROUND_OF_BUCKET | null, - conversion: EarthworkConversion | null | undefined, -): number { - if (!bucket || !conversion) return naturalM3; - const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; - return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; -} - -function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual { - // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰). - // ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다. - return { - index: 0, - kind: "spoil", - from_m: fromM, - to_m: toM, - volume_m3: volumeM3, - level_from_m3: 0, - level_to_m3: 0, - ea_m3: 0, - rr_m3: 0, - br_m3: 0, - natural_m3: 0, - }; -} - -/** - * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). - * - * 구조물 잔토는 사토에 한 번만 더한다. - * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. - * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 - * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 - * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — - * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. - * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. - * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. - * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, - * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` - * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. - * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). - */ -function applyStructureSpoil( - residuals: HaulResidual[], - amount: number | null, - points: Array<{ - chainage_m: number; - spoil_m3: number; - /** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */ - ground_type?: string | null; - ground_label?: string | null; - ground?: string | null; - }> | null, - conversion?: EarthworkConversion | null, -): number { - const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil"); - - // ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다. - if (points && points.length) { - let added = 0; - for (const point of points) { - const value = Number(point?.spoil_m3); - const chainage = Number(point?.chainage_m); - if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue; - const spoils = spoilsOf(); - const covering = spoils.find( - (residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9, - ); - // 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라 - // 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다. - const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label); - const target = covering ?? newSpoilResidual(chainage, chainage, 0); - if (!covering) residuals.push(target); - // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). - // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. - const placed = toCompacted(value, bucket, conversion); - target.volume_m3 += placed; - if (bucket) target[bucket] += placed; - added += placed; - } - return added; - } - - // ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선). - if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0; - const spoils = spoilsOf(); - const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0); - if (!spoils.length || total <= EPSILON) { - // 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로 - // 구간을 넓게 잡고, **물량은 보존**한다. - const froms = residuals.map((residual) => residual.from_m); - const tos = residuals.map((residual) => residual.to_m); - const fromM = froms.length ? Math.min(...froms) : 0; - const toM = tos.length ? Math.max(...tos) : 0; - residuals.push(newSpoilResidual(fromM, toM, amount)); - return amount; - } - let added = 0; - spoils.forEach((residual, index) => { - const share = - index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total); - residual.volume_m3 += share; - added += share; - }); - return added; -} export function computeHaulPlan( result: MassHaulResult, diff --git a/common_util/common_util_mass_haul_balance_apply.ts b/common_util/common_util_mass_haul_balance_apply.ts new file mode 100644 index 000000000..576211395 --- /dev/null +++ b/common_util/common_util_mass_haul_balance_apply.ts @@ -0,0 +1,257 @@ +/* ============================================================================= + * common_util_mass_haul_balance_apply.ts + * 토량 분배 뒤에 사토에 얹는 몫 — 구조물 잔토(더함) · 채집석 공제(뺌). + * 순서 = 잔토 먼저 · 공제 나중(`computeHaulPlan` 이 부른다). + * ========================================================================== */ + +import type { EarthworkConversion } from "./common_util_mass_haul_types"; +import type { HaulResidual } from "./common_util_mass_haul_balance_types"; +import { EPSILON } from "./common_util_mass_haul_curve"; + +/** + * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). + * + * 채집석 공제는 사토에서 한 번만 뺀다. + * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, + * 빼는 자리는 유토곡선의 사토뿐이다 — + * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. + * + * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) + * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** + * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. + * ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 + * 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다. + * ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다. + * + * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. + */ +export function applyCollectedStoneDeduction( + residuals: HaulResidual[], + deduction: number | null, + byGround?: Record | null, + groundUnknownM3?: number | null, + conversion?: EarthworkConversion | null, +): number { + // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 + // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. + // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). + // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. + const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); + const amountToTake = converted === null ? deduction : converted; + if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0; + const spoils = residuals.filter((residual) => residual.kind === "spoil"); + let left = amountToTake; + const take = (residual: HaulResidual, amount: number): void => { + if (amount <= 0) return; + const before = residual.volume_m3; + const ratio = before > 0 ? (before - amount) / before : 0; + residual.volume_m3 = before - amount; + // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. + residual.ea_m3 *= ratio; + residual.rr_m3 *= ratio; + residual.br_m3 *= ratio; + left -= amount; + }; + // ① 실어 내는 몫부터 + for (const residual of spoils) { + if (left <= EPSILON) break; + take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); + } + // ② 모자라면 자연방토에서 + for (const residual of spoils) { + if (left <= EPSILON) break; + const amount = Math.min(residual.natural_m3, left); + residual.natural_m3 -= amount; + take(residual, amount); + } + return amountToTake - Math.max(left, 0); +} + +/** + * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). + * + * 구조물 잔토는 사토에 한 번만 더한다. + * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, + * 더하는 자리는 유토곡선의 사토뿐이다. + * + * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리). + * ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로 + * **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고, + * 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다). + * ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다. + * ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다. + * 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다. + * + * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. + */ +/** + * 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다). + * 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다. + */ +function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null { + const text = (ground ?? "").trim(); + if (!text) return null; + if (text === "토사" || text === "soil" || text === "ea") return "ea_m3"; + if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") { + return "rr_m3"; + } + if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3"; + return null; +} + +/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ +const GROUND_OF_BUCKET = { + ea_m3: "soil", + rr_m3: "ripping_rock", + br_m3: "blasting_rock", +} as const; + +/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ +const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const; + +/** + * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. + * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. + */ +function compactedStoneDeduction( + byGround: Record | null | undefined, + groundUnknownM3: number | null | undefined, + conversion: EarthworkConversion | null | undefined, +): number | null { + if (!byGround) return null; + const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter( + ([, value]) => Number.isFinite(value) && value > 0, + ); + const unknown = Number(groundUnknownM3 ?? 0); + const hasUnknown = Number.isFinite(unknown) && unknown > 0; + if (!entries.length && !hasUnknown) return null; + let total = hasUnknown ? unknown : 0; + for (const [ground, value] of entries) { + const factor = conversion?.[ground]?.compacted; + total += typeof factor === "number" && factor > 0 ? value * factor : value; + } + return total; +} + +/** + * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. + * + * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 + * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 + * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. + * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). + * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** + * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 + * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. + */ +function toCompacted( + naturalM3: number, + bucket: keyof typeof GROUND_OF_BUCKET | null, + conversion: EarthworkConversion | null | undefined, +): number { + if (!bucket || !conversion) return naturalM3; + const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; + return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; +} + +function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual { + // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰). + // ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다. + return { + index: 0, + kind: "spoil", + from_m: fromM, + to_m: toM, + volume_m3: volumeM3, + level_from_m3: 0, + level_to_m3: 0, + ea_m3: 0, + rr_m3: 0, + br_m3: 0, + natural_m3: 0, + }; +} + +/** + * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). + * + * 구조물 잔토는 사토에 한 번만 더한다. + * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, + * 더하는 자리는 유토곡선의 사토뿐이다. + * + * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. + * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 + * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 + * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — + * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. + * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. + * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. + * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, + * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` + * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. + * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). + */ +export function applyStructureSpoil( + residuals: HaulResidual[], + amount: number | null, + points: Array<{ + chainage_m: number; + spoil_m3: number; + /** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */ + ground_type?: string | null; + ground_label?: string | null; + ground?: string | null; + }> | null, + conversion?: EarthworkConversion | null, +): number { + const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil"); + + // ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다. + if (points && points.length) { + let added = 0; + for (const point of points) { + const value = Number(point?.spoil_m3); + const chainage = Number(point?.chainage_m); + if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue; + const spoils = spoilsOf(); + const covering = spoils.find( + (residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9, + ); + // 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라 + // 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다. + const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label); + const target = covering ?? newSpoilResidual(chainage, chainage, 0); + if (!covering) residuals.push(target); + // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). + // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. + const placed = toCompacted(value, bucket, conversion); + target.volume_m3 += placed; + if (bucket) target[bucket] += placed; + added += placed; + } + return added; + } + + // ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선). + if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0; + const spoils = spoilsOf(); + const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0); + if (!spoils.length || total <= EPSILON) { + // 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로 + // 구간을 넓게 잡고, **물량은 보존**한다. + const froms = residuals.map((residual) => residual.from_m); + const tos = residuals.map((residual) => residual.to_m); + const fromM = froms.length ? Math.min(...froms) : 0; + const toM = tos.length ? Math.max(...tos) : 0; + residuals.push(newSpoilResidual(fromM, toM, amount)); + return amount; + } + let added = 0; + spoils.forEach((residual, index) => { + const share = + index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total); + residual.volume_m3 += share; + added += share; + }); + return added; +} diff --git a/common_util/common_util_mass_haul_balance_types.ts b/common_util/common_util_mass_haul_balance_types.ts new file mode 100644 index 000000000..0b7d69cf5 --- /dev/null +++ b/common_util/common_util_mass_haul_balance_types.ts @@ -0,0 +1,157 @@ +/* ============================================================================= + * common_util_mass_haul_balance_types.ts + * 토량 분배(평형선 · 운반 블록 · 장비 띠) 결과 타입 — 계산은 `common_util_mass_haul_balance`. + * 밖에서는 계속 `common_util_mass_haul_balance` 에서 가져온다(거기서 다시 내보낸다). + * ========================================================================== */ + +/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */ +export interface HaulEquipmentLimit { + key: string; + max_distance_m: number | null; +} + +/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */ +export type HaulDirection = "forward" | "backward"; + +/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */ +export interface CurvePoint { + m: number; + v: number; +} + +/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */ +export interface HaulBand { + /** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */ + index: number; + /** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */ + equipment: string | null; + /** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */ + level_base_m3: number; + level_apex_m3: number; + /** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */ + volume_m3: number; + /** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */ + boundary_from_m: number; + boundary_to_m: number; + /** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */ + haul_distance_m: number; + haul_from_m: number; + haul_to_m: number; + /** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */ + ea_m3: number; + rr_m3: number; + br_m3: number; + /** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */ + outline: CurvePoint[]; +} + +export interface HaulBlock { + /** 1부터. */ + index: number; + /** 이 블록의 평형선 높이(누가토량 축, ㎥). */ + base_m3: number; + /** 평형선이 곡선을 자르는 두 점(누가거리 m). */ + from_m: number; + to_m: number; + /** 정점(극대/극소) 위치와 값. */ + apex_m: number; + apex_volume_m3: number; + /** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */ + volume_m3: number; + direction: HaulDirection; + /** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */ + bands: HaulBand[]; +} + +/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */ +export interface HaulResidual { + index: number; + kind: "spoil" | "borrow"; + from_m: number; + to_m: number; + volume_m3: number; + /** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */ + level_from_m3: number; + level_to_m3: number; + /** + * 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다. + * **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다. + */ + ea_m3: number; + rr_m3: number; + br_m3: number; + /** + * 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다. + * 나머지(`volume_m3 − natural_m3`)는 사토장으로 실어 내야 한다. + * + * 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가 + * 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서** + * 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0). + */ + natural_m3: number; +} + +/** + * 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인 + * 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다). + * + * 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면 + * 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다. + */ +export interface HaulTransfer { + index: number; + /** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */ + from_m: number; + to_m: number; + volume_m3: number; + /** 두 자리 사이 거리 = 이 운반의 `L`. */ + haul_distance_m: number; + /** 연결 수평선을 그릴 높이(누가토량 ㎥). */ + level_m3: number; + equipment: string | null; + ea_m3: number; + rr_m3: number; + br_m3: number; +} + +/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */ +export interface BalanceStep { + from_m: number; + to_m: number; + level_m3: number; +} + +export interface HaulPlan { + blocks: HaulBlock[]; + residuals: HaulResidual[]; + transfers: HaulTransfer[]; + steps: BalanceStep[]; + spoil_m3: number; + borrow_m3: number; + /** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */ + natural_spoil_m3: number; + /** + * 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 — + * 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09). + */ + collected_stone_deduction_m3: number | null; + /** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */ + collected_stone_deducted_m3: number; + /** + * 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고 + * `0` 은 「없음」이다(공제와 같은 태도). + */ + structure_spoil_m3: number | null; + /** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */ + structure_spoil_added_m3: number; + /** 블록 안에서 옮기는 양(㎥). */ + hauled_m3: number; + /** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */ + transferred_m3: number; + /** + * 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로 + * `hauled_m3 + transferred_m3`가 이 값과 같아야 한다. + * 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다. + */ + fill_total_m3: number; +} 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_route_polyline.py b/common_util/common_util_route_polyline.py index 8224bc9d4..9172c407f 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -23,30 +23,28 @@ import math from dataclasses import dataclass, field from typing import Any -# 같은 자리로 볼 점 사이 거리(m) — 이보다 가까우면 뒤엣것을 버린다. 원본 점군에 중복· -# 미세 진동이 섞여 있으면 내각이 튀어 없는 곡선이 생긴다. -DUPLICATE_TOLERANCE_M = 0.5 - -# 꺾임점(IP)을 뽑는 단순화 허용오차(m). 예상노선은 격자 탐색이 낸 **조밀한 점군**이라 -# (용화 실측: 1,097m 에 331점 = 약 3.3m 간격) 점마다 곡선을 끼우면 접선 자리가 1.5m 밖에 -# 안 나와 반지름이 2~6m 로 뭉개진다. 격자 해상도(`ROUTE_GRID_RES_M` 2.0m)의 두 배로 잡아 -# 계단 모양만 걷어내고 실제 굴곡은 남긴다. -SIMPLIFY_TOLERANCE_M = 4.0 - -# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m). -# -# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고 -# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아 -# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다. -# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만** -# 골라 허용오차를 절반으로 낮춰 다시 뽑는다. -MAX_NODE_SPACING_M = 100.0 - -# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m). -MAX_REFINE_DEPTH = 3 - -# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다. -ARC_STEP_DEG = 5.0 +from common_util.common_util_route_polyline_geom import ( # noqa: F401 — 바탕은 나눈 파일 · 이름은 여기서 그대로 + DUPLICATE_TOLERANCE_M, + SIMPLIFY_TOLERANCE_M, + MAX_NODE_SPACING_M, + MAX_REFINE_DEPTH, + ARC_STEP_DEG, + _distance, + dedupe_points, + _perpendicular_distance, + _douglas_peucker, + simplify_to_nodes, + _inner_angle_deg, + _unit, + _node_indices, + _point_to_segment_m, + _corner_deviation_m, + _arc_geometry, + _fit_radius_m, + _arc_points, + _turn_sign, + _line_intersection, +) # 별표2 가 「곡선을 두지 않을 수 있다」고 하는 내각(도) — **지금은 쓰지 않는다.** # @@ -159,293 +157,6 @@ class PlannedPolyline: return sum(1 for node in self.nodes if node.violations) -def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: - return math.hypot(b[0] - a[0], b[1] - a[1]) - - -def dedupe_points( - points: list[tuple[float, float]], tolerance_m: float = DUPLICATE_TOLERANCE_M -) -> list[tuple[float, float]]: - """붙어 있는 점을 하나로 줄인다. 순서는 그대로 둔다.""" - cleaned: list[tuple[float, float]] = [] - for point in points: - if not cleaned or _distance(cleaned[-1], point) > tolerance_m: - cleaned.append(point) - return cleaned - - -def _perpendicular_distance( - point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] -) -> float: - """점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리.""" - dx, dy = end[0] - start[0], end[1] - start[1] - if dx == 0 and dy == 0: - return _distance(point, start) - return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot( - dx, dy - ) - - -def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]: - """남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다.""" - if len(points) < 3: - return list(range(len(points))) - keep = {0, len(points) - 1} - stack = [(0, len(points) - 1)] - while stack: - first, last = stack.pop() - if last <= first + 1: - continue - worst, worst_index = -1.0, first - for index in range(first + 1, last): - gap = _perpendicular_distance(points[index], points[first], points[last]) - if gap > worst: - worst, worst_index = gap, index - if worst > tolerance_m: - keep.add(worst_index) - stack.append((first, worst_index)) - stack.append((worst_index, last)) - return sorted(keep) - - -def simplify_to_nodes( - points: list[tuple[float, float]], - tolerance_m: float = SIMPLIFY_TOLERANCE_M, - max_spacing_m: float = MAX_NODE_SPACING_M, - depth: int = MAX_REFINE_DEPTH, -) -> list[tuple[float, float]]: - """조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기. - - 예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울 - 접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는 - 선에서 점을 걷어내므로 **모양은 그대로**다. - - 남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시 - 뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측). - """ - if len(points) < 3: - return list(points) - kept = _douglas_peucker(points, tolerance_m) - if depth <= 0 or max_spacing_m <= 0: - return [points[index] for index in kept] - - result: list[tuple[float, float]] = [points[kept[0]]] - for previous, current in zip(kept, kept[1:]): - if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1: - refined = simplify_to_nodes( - points[previous : current + 1], - tolerance_m / 2, - max_spacing_m, - depth - 1, - ) - result.extend(refined[1:]) - else: - result.append(points[current]) - return result - - -def _inner_angle_deg( - before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] -) -> float: - """세 점이 이루는 내각(도). 일직선이면 180.""" - ax, ay = before[0] - at[0], before[1] - at[1] - bx, by = after[0] - at[0], after[1] - at[1] - la, lb = math.hypot(ax, ay), math.hypot(bx, by) - if la <= 0 or lb <= 0: - return 180.0 - cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb))) - return math.degrees(math.acos(cosine)) - - -def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tuple[float, float]: - length = _distance(from_point, to_point) - if length <= 0: - return (0.0, 0.0) - return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length) - - -def _node_indices( - points: list[tuple[float, float]], nodes: list[tuple[float, float]] -) -> list[int] | None: - """노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다. - - 사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다). - """ - indices: list[int] = [] - cursor = 0 - for node in nodes: - while cursor < len(points) and points[cursor] != node: - cursor += 1 - if cursor >= len(points): - return None - indices.append(cursor) - cursor += 1 - return indices - - -def _point_to_segment_m( - point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] -) -> float: - """점에서 선분까지 거리(m).""" - dx, dy = end[0] - start[0], end[1] - start[1] - if dx == 0 and dy == 0: - return _distance(point, start) - ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy) - ratio = max(0.0, min(1.0, ratio)) - return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio)) - - -def _corner_deviation_m( - samples: list[tuple[float, float]], - before: tuple[float, float], - after: tuple[float, float], - start: tuple[float, float], - end: tuple[float, float], - center: tuple[float, float], - radius: float, -) -> float: - """예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m). - - 사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다. - 그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다. - - ⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지 - 않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을 - 가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이 - 골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다. - """ - if not samples: - return 0.0 - total = 0.0 - for sample in samples: - gap = min( - _point_to_segment_m(sample, before, start), - _point_to_segment_m(sample, end, after), - # 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워 - # 위 두 값이 이미 그것을 담는다. - abs(_distance(sample, center) - radius), - ) - total += gap - return total / len(samples) - - -def _arc_geometry( - before: tuple[float, float], - at: tuple[float, float], - after: tuple[float, float], - inner_deg: float, - radius: float, - half_tan: float, -) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None: - """반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None.""" - tangent = radius * half_tan - to_before = _unit(at, before) - to_after = _unit(at, after) - start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) - end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) - bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) - bisector_length = math.hypot(*bisector) - if bisector_length <= 1e-9: - return None - center_distance = radius / math.sin(math.radians(inner_deg) / 2) - center = ( - at[0] + bisector[0] / bisector_length * center_distance, - at[1] + bisector[1] / bisector_length * center_distance, - ) - return start, end, center - - -def _fit_radius_m( - samples: list[tuple[float, float]], - before: tuple[float, float], - at: tuple[float, float], - after: tuple[float, float], - inner_deg: float, - half_tan: float, - min_radius_m: float, - max_radius_m: float, - steps: int = 48, -) -> float: - """법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다. - - 왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존 - 예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만 - (중앙종거 M = R(1/cos(Δ/2) − 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은 - 반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다. - """ - if not samples or max_radius_m <= min_radius_m: - return min_radius_m - best_radius = min_radius_m - best_gap = float("inf") - for step in range(steps + 1): - # 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다. - ratio = step / steps - radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio - geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan) - if geometry is None: - continue - start, end, center = geometry - gap = _corner_deviation_m(samples, before, after, start, end, center, radius) - if gap < best_gap - 1e-9: - best_gap, best_radius = gap, radius - return best_radius - - -def _arc_points( - center: tuple[float, float], - start: tuple[float, float], - end: tuple[float, float], - clockwise: bool, -) -> list[tuple[float, float]]: - """중심과 두 끝점으로 원호 위 점을 찍는다(양 끝 포함하지 않음 — 부르는 쪽이 붙인다).""" - radius = _distance(center, start) - if radius <= 0: - return [] - start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) - end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) - sweep = end_angle - start_angle - if clockwise: - while sweep > 0: - sweep -= 2 * math.pi - else: - while sweep < 0: - sweep += 2 * math.pi - steps = max(1, int(abs(math.degrees(sweep)) / ARC_STEP_DEG)) - return [ - ( - center[0] + radius * math.cos(start_angle + sweep * step / steps), - center[1] + radius * math.sin(start_angle + sweep * step / steps), - ) - for step in range(1, steps) - ] - - -def _turn_sign( - before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] -) -> int: - """도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0.""" - cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) - if abs(cross) <= 1e-9: - return 0 - return 1 if cross > 0 else -1 - - -def _line_intersection( - a1: tuple[float, float], - a2: tuple[float, float], - b1: tuple[float, float], - b2: tuple[float, float], -) -> tuple[float, float] | None: - """두 **직선**(선분 아님)의 교차점. 나란하면 None.""" - dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1] - dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1] - denominator = dx1 * dy2 - dy1 * dx2 - if abs(denominator) <= 1e-12: - return None - t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator - return (a1[0] + dx1 * t, a1[1] + dy1 * t) - - def _curve_runs( cleaned: list[tuple[float, float]], ) -> list[tuple[int, int]]: diff --git a/common_util/common_util_route_polyline_geom.py b/common_util/common_util_route_polyline_geom.py new file mode 100644 index 000000000..9c794baa2 --- /dev/null +++ b/common_util/common_util_route_polyline_geom.py @@ -0,0 +1,318 @@ +"""예상노선 → 폴리라인 — 점 다듬기 · 꺾임점 뽑기 · 원호 맞춤 바탕 (`common_util_route_polyline` 에서 나눔 · 밖에서는 `common_util_route_polyline` 로 부름).""" + +from __future__ import annotations + +import math + + +# 같은 자리로 볼 점 사이 거리(m) — 이보다 가까우면 뒤엣것을 버린다. 원본 점군에 중복· +# 미세 진동이 섞여 있으면 내각이 튀어 없는 곡선이 생긴다. +DUPLICATE_TOLERANCE_M = 0.5 + +# 꺾임점(IP)을 뽑는 단순화 허용오차(m). 예상노선은 격자 탐색이 낸 **조밀한 점군**이라 +# (용화 실측: 1,097m 에 331점 = 약 3.3m 간격) 점마다 곡선을 끼우면 접선 자리가 1.5m 밖에 +# 안 나와 반지름이 2~6m 로 뭉개진다. 격자 해상도(`ROUTE_GRID_RES_M` 2.0m)의 두 배로 잡아 +# 계단 모양만 걷어내고 실제 굴곡은 남긴다. +SIMPLIFY_TOLERANCE_M = 4.0 + +# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m). +# +# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고 +# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아 +# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다. +# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만** +# 골라 허용오차를 절반으로 낮춰 다시 뽑는다. +MAX_NODE_SPACING_M = 100.0 + +# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m). +MAX_REFINE_DEPTH = 3 + +# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다. +ARC_STEP_DEG = 5.0 + + +def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: + return math.hypot(b[0] - a[0], b[1] - a[1]) + + +def dedupe_points( + points: list[tuple[float, float]], tolerance_m: float = DUPLICATE_TOLERANCE_M +) -> list[tuple[float, float]]: + """붙어 있는 점을 하나로 줄인다. 순서는 그대로 둔다.""" + cleaned: list[tuple[float, float]] = [] + for point in points: + if not cleaned or _distance(cleaned[-1], point) > tolerance_m: + cleaned.append(point) + return cleaned + + +def _perpendicular_distance( + point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] +) -> float: + """점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리.""" + dx, dy = end[0] - start[0], end[1] - start[1] + if dx == 0 and dy == 0: + return _distance(point, start) + return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot( + dx, dy + ) + + +def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]: + """남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다.""" + if len(points) < 3: + return list(range(len(points))) + keep = {0, len(points) - 1} + stack = [(0, len(points) - 1)] + while stack: + first, last = stack.pop() + if last <= first + 1: + continue + worst, worst_index = -1.0, first + for index in range(first + 1, last): + gap = _perpendicular_distance(points[index], points[first], points[last]) + if gap > worst: + worst, worst_index = gap, index + if worst > tolerance_m: + keep.add(worst_index) + stack.append((first, worst_index)) + stack.append((worst_index, last)) + return sorted(keep) + + +def simplify_to_nodes( + points: list[tuple[float, float]], + tolerance_m: float = SIMPLIFY_TOLERANCE_M, + max_spacing_m: float = MAX_NODE_SPACING_M, + depth: int = MAX_REFINE_DEPTH, +) -> list[tuple[float, float]]: + """조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기. + + 예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울 + 접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는 + 선에서 점을 걷어내므로 **모양은 그대로**다. + + 남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시 + 뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측). + """ + if len(points) < 3: + return list(points) + kept = _douglas_peucker(points, tolerance_m) + if depth <= 0 or max_spacing_m <= 0: + return [points[index] for index in kept] + + result: list[tuple[float, float]] = [points[kept[0]]] + for previous, current in zip(kept, kept[1:]): + if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1: + refined = simplify_to_nodes( + points[previous : current + 1], + tolerance_m / 2, + max_spacing_m, + depth - 1, + ) + result.extend(refined[1:]) + else: + result.append(points[current]) + return result + + +def _inner_angle_deg( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> float: + """세 점이 이루는 내각(도). 일직선이면 180.""" + ax, ay = before[0] - at[0], before[1] - at[1] + bx, by = after[0] - at[0], after[1] - at[1] + la, lb = math.hypot(ax, ay), math.hypot(bx, by) + if la <= 0 or lb <= 0: + return 180.0 + cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb))) + return math.degrees(math.acos(cosine)) + + +def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tuple[float, float]: + length = _distance(from_point, to_point) + if length <= 0: + return (0.0, 0.0) + return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length) + + +def _node_indices( + points: list[tuple[float, float]], nodes: list[tuple[float, float]] +) -> list[int] | None: + """노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다. + + 사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다). + """ + indices: list[int] = [] + cursor = 0 + for node in nodes: + while cursor < len(points) and points[cursor] != node: + cursor += 1 + if cursor >= len(points): + return None + indices.append(cursor) + cursor += 1 + return indices + + +def _point_to_segment_m( + point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] +) -> float: + """점에서 선분까지 거리(m).""" + dx, dy = end[0] - start[0], end[1] - start[1] + if dx == 0 and dy == 0: + return _distance(point, start) + ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy) + ratio = max(0.0, min(1.0, ratio)) + return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio)) + + +def _corner_deviation_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + after: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + center: tuple[float, float], + radius: float, +) -> float: + """예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m). + + 사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다. + 그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다. + + ⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지 + 않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을 + 가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이 + 골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다. + """ + if not samples: + return 0.0 + total = 0.0 + for sample in samples: + gap = min( + _point_to_segment_m(sample, before, start), + _point_to_segment_m(sample, end, after), + # 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워 + # 위 두 값이 이미 그것을 담는다. + abs(_distance(sample, center) - radius), + ) + total += gap + return total / len(samples) + + +def _arc_geometry( + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + radius: float, + half_tan: float, +) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None: + """반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None.""" + tangent = radius * half_tan + to_before = _unit(at, before) + to_after = _unit(at, after) + start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) + end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) + bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) + bisector_length = math.hypot(*bisector) + if bisector_length <= 1e-9: + return None + center_distance = radius / math.sin(math.radians(inner_deg) / 2) + center = ( + at[0] + bisector[0] / bisector_length * center_distance, + at[1] + bisector[1] / bisector_length * center_distance, + ) + return start, end, center + + +def _fit_radius_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + half_tan: float, + min_radius_m: float, + max_radius_m: float, + steps: int = 48, +) -> float: + """법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다. + + 왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존 + 예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만 + (중앙종거 M = R(1/cos(Δ/2) − 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은 + 반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다. + """ + if not samples or max_radius_m <= min_radius_m: + return min_radius_m + best_radius = min_radius_m + best_gap = float("inf") + for step in range(steps + 1): + # 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다. + ratio = step / steps + radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio + geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan) + if geometry is None: + continue + start, end, center = geometry + gap = _corner_deviation_m(samples, before, after, start, end, center, radius) + if gap < best_gap - 1e-9: + best_gap, best_radius = gap, radius + return best_radius + + +def _arc_points( + center: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + clockwise: bool, +) -> list[tuple[float, float]]: + """중심과 두 끝점으로 원호 위 점을 찍는다(양 끝 포함하지 않음 — 부르는 쪽이 붙인다).""" + radius = _distance(center, start) + if radius <= 0: + return [] + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + sweep = end_angle - start_angle + if clockwise: + while sweep > 0: + sweep -= 2 * math.pi + else: + while sweep < 0: + sweep += 2 * math.pi + steps = max(1, int(abs(math.degrees(sweep)) / ARC_STEP_DEG)) + return [ + ( + center[0] + radius * math.cos(start_angle + sweep * step / steps), + center[1] + radius * math.sin(start_angle + sweep * step / steps), + ) + for step in range(1, steps) + ] + + +def _turn_sign( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> int: + """도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0.""" + cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) + if abs(cross) <= 1e-9: + return 0 + return 1 if cross > 0 else -1 + + +def _line_intersection( + a1: tuple[float, float], + a2: tuple[float, float], + b1: tuple[float, float], + b2: tuple[float, float], +) -> tuple[float, float] | None: + """두 **직선**(선분 아님)의 교차점. 나란하면 None.""" + dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1] + dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1] + denominator = dx1 * dy2 - dy1 * dx2 + if abs(denominator) <= 1e-12: + return None + t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator + return (a1[0] + dx1 * t, a1[1] + dy1 * t) 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/config/config_system.py b/config/config_system.py index ed3852578..364da6e9d 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -182,6 +182,9 @@ DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm) +# 유역도 등고선 간격(m) — 기본 5 · 종류 옵션으로 10 만(1 m = B05 · B06 전용) +BASIN_CONTOUR_INTERVAL_M = 5 +BASIN_CONTOUR_INTERVAL_CHOICES = (5, 10) # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 diff --git a/docs/raw/요구_1022/사용자요구_1022.md b/docs/raw/요구_1022/사용자요구_1022.md index dab140a3f..5d3abc94b 100644 --- a/docs/raw/요구_1022/사용자요구_1022.md +++ b/docs/raw/요구_1022/사용자요구_1022.md @@ -3,3 +3,59 @@ ## 요구 318 (10-22 · 사용자) 1. 검증백업사본은 지우고 전역 깃 싱크 + +## 요구 319 (10-22 · 사용자) + +1. 먼저 서버 재실행 화면보고 내가 끝나지 않은 것의 진행여부를 결정하겠음 + +## 요구 320 (10-22 · 사용자 · 남은 일 결정) + +1. 배수유역의 범위 현재 유지. +2. 자동 관 지정은 b05에서 배수 유역을 기준으로 계산이 되게 되어있을텐데 지금 b05/b06의 경우 상당히 만히 바뀌었음 로직확인해봐야하고 추가 방식은 현재 세류선과 교차되는 지점을 제안할텐데 지점 선택의 로직을 가져가되 그 외 구조물생성과 평면 노선변경에 따른 따라오는 로직들은 현재방식을 사용할 필요있음. 자동 추가 버튼은 b05 페이지의 배수유역 영역에 업데이트 후 사용자가 선택할 수 있게. +3. 508-3 b07 캐드는 일단 보류 설명이 적음 +4. 계산자리와 관련하여 지금 내가 제어할 수 없음. 단계적으로 끝까지 진행. + +## 요구 321 (10-22 · 사용자 · 물음 답) + +1. 유토 배분(E2) 계산 자리 = 브라우저도 고를 수 있게(09-06 「배분 산식 브라우저 번들 밖」 확정을 풂 · 기본값은 서버). + +## 요구 322 (10-22 · 사용자) + +1. 옛서버 계산 묶음은 문제있을 수 있으니 tmp폴더에 별도 폴더로 저장 이후 내가 필요없다고 판단되는 시점에 삭제 + +## 요구 323 (10-22 · 사용자) + +1. 508-2진행. 508-3 다시 설명 + +## 요구 324 (10-22 · 사용자 · 508-2 물음) + +1. 508-2 사용자가 업데이트 버튼을 누를때만 변경되었으면 변걍된데이터를 아니면 그냥 둬둬 될텐데 이렇게 하는 이유는? + +## 요구 325 (10-22 · 사용자 · 508-3 결정) + +1. 레이어 합치기는 진행 +2. 미리받기는 하는게 좋아보임. 대신 무거운것들은 순서를 뒤에 배치하고 조용이 받기 진행. 이때 사용자가 이정 페이지로 가면 미리받기는 유지하다가 변경부로 돌아온 경우 변경된 내용만 조용히 업데이트. 만약 사용자가 아직 받기 전의 도면에 접근한다면 다운받고 있다고 알려줌. 다시 다른 페이지를 접근할수 있어야 하면 도면에 유지하고 있다면 그냥 다운로드 종료 후 보여주기. 오류 있는지 확인 후 진행. 필요에 따라서 그림하나하나의 내용에 로직내부에 상태값을 남겨서 변걍했는지 확인 절차가 있어야 할지도. 이때 횡단도는 여러도면인 경우에는 여러 상태값이 존재. 다시말해 횡단도 하나당 하나의 측점의 상태값 필요. 어떰? +3. 동의. 하지만 도면의 사이즈나 표기하는 영역에따라 최적화하여 가져 오면 좋겠음. + +## 요구 326 (10-22 · 사용자 · 508-3 뒤 결정 · 그림 `요구326_배수유역도.png`) + +1. B07의 배수유역은 1m를 사용하는 경우가 있음? 기본 5m만 있음. 그 이하는 없어도 되면 경우에 따라 10m도 있지만 일단은 5m 기본값으로 두고 사용자가 해당 도면의 옵션 모달창에서 10m 로 변경할 수 있게만 반영. (1m 등고선은ㅇ B05/B06외에는 사용하지 않음.) +2. 메인 합치기는 그냥 받어 상관없어. +3. 계획고 옮김에서 어디의 계획고를 말하는 거임? +4. 노선 공용 파일처럼 700 줄 제한은 적당히 그룹하여 나주면 됨. +5. 472-3은 뭔지 알려줘. +6. 배수유역도의 점선 원은 필요없는 정보임. 삭제. 자동 제안으로 가능할 것으로 보임. 배관배치 초기화, 배관석제, 유역재분할 버튼 필요없음. 대신 업데이트 버튼이 필요. 이 업데이트는 평면이 없더라고 다시 계산되게 변경, 그리고 노면이 변경이 되더라도 그냥 변경되지 않은 그림을 보여주게 함.(업데이트 버튼으로 그림 몰래 업데이트), 이때 노선이 변경이 되는 경우에 배수유역이 계산을 처음부터 해야함(지금은 노선 주변에 빈곳이 발생). 그리고 만약 업데이트 되지 않은 상태에서B06에서 B07로 넘어가려고 하면 확인 모달창에 메세지를 보여주고 자동업데이트 후 B07로 넘어감. +7. 검증 스크립트의 낡은 너리줄은 알아서 해. + +## 요구 327 (10-22 · 사용자 · 326 이어서) + +1. 평면도에도 종단도의 마우스 우클릭의 구조물 옵션이 보이고 추가 삭제가 되어야함. 공용로직 사용 +2. B05의 우측 패널이 측점을 선택하면 자동으로 열림. 패널 내부의 내용은 사용자에게 안보이더라도 업데이트 되고 패널은 현재 상태를 유지해 달라고 했는데 이게 반영안됨. (B05 ~ B06의 좌측 패널의 구조물 컨테이너가 여기에 해당 될 것 같은데 어떻게 되어 있는지 확인) + +## 요구 328 (10-22 · 사용자 · 512 · 513 뒤 결정) + +1. 알약만있는 구조도 동일하게 지울 수 있어야함. +2. 세월교 박스 우클릭 글 변경, 배관삭제도 동일하게 구조물 변경으로 일원화 +3. 유량 값 가림 좋음. +4. 유역도를 가볍게 하는건 B07에 사용할 기능, 고치는건 괜찮으나 B05와 B06에 영향을 주면 안됨. +5. 이전 4개 요청 반영 후 전역 깃 싱크 할 것. diff --git a/docs/raw/요구_1022/요구326_배수유역도.png b/docs/raw/요구_1022/요구326_배수유역도.png new file mode 100644 index 000000000..0c3c83f16 Binary files /dev/null and b/docs/raw/요구_1022/요구326_배수유역도.png differ diff --git a/docs/raw/조사/2026-10-22_요구320_계산자리_B단계.md b/docs/raw/조사/2026-10-22_요구320_계산자리_B단계.md new file mode 100644 index 000000000..7f121ee57 --- /dev/null +++ b/docs/raw/조사/2026-10-22_요구320_계산자리_B단계.md @@ -0,0 +1,128 @@ +# 요구 320-4 · PLAN 511-B 계산 자리 B단계 — 짝 모으기 실행 계획 (분석 · 코드 고침 없음) + +- 기준 = `main_laptop_1` `349c26606` · 서버 안 켬 · 시험 안 돌림 · 511-A 코드 아직 없음(sub_laptop_2 = 같은 커밋). +- 시간 = 앞 보고 H 실측 + `tmp/server.log` `[계측]` 줄 모음(노선 저장 68 회 · 추천 초기 종단 161 회 · 전처리 4 회) · 「추정」 = 안 잰 값. +- 앞 자료 = PLAN 472 · 511 · `2026-10-19_472_구조다시세움_목록.md` 4.5 · `2026-10-19_475_요구275_저장차_초기종단.md` 1장 · H(`2026-10-20_요구304_H_…`) · 309-B(`2026-10-21_요구309_B_…`). + +## 0. 결론 + +1. B단계 26 = 단계 다섯 줄(route 2 · follow 4 · stations 4 · profile 5 · cross 10) + F9 — 이미 Node 인 P6 · F1 · F2 · C1 · C2 · C5(일부)도 같은 단계 안 짝 때문에 잠긴 것이라 단계째 엶. +2. 바꿀 서버 자리는 넷으로 모임 — `build_sections`(측점 · 지반 · 첫 계획선 · 모든 체인) · `rebuild_alignment_profile`(종단 · 경고 · 일곱 곳) · 「기본 설계 길」 + G2 · 구간 · 형상 얹기(횡단) · `_write_planned_polyline` · 좌표 맞춤(노선 · 따라감). +3. 저장 값 = DTM 지표면이면 거의 안 바뀜(같은 선 0 차 · 성토 ≤ 1e-4 ㎡ · 475 1.2 실측) — 바뀌는 곳 = 비DTM 표현의 미교차 넓힘(G2) · golden KNOWN R7 · X2 줄(값 다시 뜸 승인) · F1 맞춤 규칙(TS 를 파이썬 규칙으로 맞추면 0). +4. 차례 = 511-A(상주 Node) → B-1 바탕(단계 Node 입구 · 판 다섯 줄) → B-2 측점 · 지반 / B-3 종단 / B-4 횡단 창 셋 동시 → B-5 노선 · 따라감 → B-6 정리 · 단계마다 그 줄 바로 엶. +5. 성능 = 체인 시간 거의 그대로(노선 저장 10.9 s 중 파이썬 셈 몫 ≈ 3.5 s ↔ Node 부름 4~6 번 · 상주면 더 빠름 · 전처리는 배수 96~98 %) · 막을 곳 = 되풀이 안 Node 부름(추천 엔진 · B07 · G2 걸음) · GET 상세 읽기마다 새 Node 프로세스. + +## 1. 서버 길 묶음(표에서 줄임말) + +- **측점 생성 길** = `build_sections`(`B05_Profile_Engine_Sections.py:398` → `generate_sections` `_Sections_Core.py:419` + `_append_design_profiles` `:284`) ← `run_section_generation`(`:447`) ← B03 체인(`B03_FileInput_Service_Chain.py:130 · 587`) · 노선 저장(`B05_Profile_Route_Save.py:543` = 노선 바뀐 [저장] · [확인] · 추천 [적용]) · B05 solve(`B05_Profile_Router.py:292`) · B06 /regenerate(`B06_Section_Router.py:431`) · 넓힘(`B06_Section_Router_Widen.py:125`) + 구조물 측점(`Engine_Sections.py:584` ← B05 확정 `B05_Profile_Router_Confirm.py:560` · [측점 만들기] `B06_Section_Router_Stations.py:262 · 525`) + 추천 초기 종단(`B05_Profile_Engine_InitialProfile.py:135`). +- **종단 다시 셈 길** = `rebuild_alignment_profile`(`B05_Profile_Engine_Grade_Profile.py:537`) ← PUT profile-alignment(`B05_Profile_Router.py:445`) · 페이지 설정(`B05_Profile_PageSettings.py:104`) · B05 확정(`B05_Profile_Router_Confirm.py:362`) · [저장] 경고(`B06_Section_Server_Calc_Prebuild.py:242`) · preview(`B06_Section_Router.py:538` · 옛 자료 폴백) · 추천 초기 종단(`InitialProfile.py:151`). +- **기본 설계 길** = 파이썬 `compute_cross_design` 이 남은 B05 · B06 자리 — `compute_default_designs`(`B06_Section_Router_Design.py:538` ← B03 체인 `Service_Chain.py:625` · 노선 저장 이월 `Route_Save.py:359` · B06 확정 `B06_Section_Router_Confirm.py:328 · 423` · [측점 만들기] `Router_Stations.py:541`) · `attach_default_designs`(GET 상세 `B06_Section_Router.py:355`) · `recompute_designs_for_alignment`(preview `:541`) · B05 확정 상단측 역반영(`B05_Profile_Router_Confirm.py:172`) · G2 안(`B06_Section_Engine_SampleExtend.py:169`). +- **472 밖 소비자(파이썬 짝 남김 · 거울 유지)** = 추천 엔진 되풀이(R1 `RouteInitial_Pool.py:156 · 161` · `_Station.py:565 · 605` · `_Radius.py:75 · 159` · `_Merge.py:294` · `RouteInitial.py:358` / C1 `RouteInitial_Profile.py:187` · CrossEarth) · B04 실시간 배수(`Watershed_Live.py:49`) · B07(`Router_Support.py:392` C1 · `Engine_Cad_Development.py:129` F5 · `IpTable.py:19` Sections_Core 조각) · B08(`Router_Summary.py:585` F5). + +## 2. 26 계산 표 + +| # | 파이썬 짝(파일:줄) | TS | 서버 부르는 곳 | Node | 거울 · 값 차 | 옮기면 저장 값 | 단계 | +|---|---|---|---|---|---|---|---| +| R1 | `common_util_route_polyline.py:579` build_planned_polyline | `logic/B05_Profile_UI_RouteEdit_Curve.ts` buildEditedPolyline | 노선 저장 `B05_Profile_Router_Replan.py:246` · 초기 종단 `InitialProfile.py:85` · 추천 · B04(1장) | 아님 | `test_route_polyline_browser_mirror` < 1e-9 · 선 4자리 짝(475-1) | 안 바뀜(같은 선 0 차) | B-5 · 추천 · B04 짝 남음 | +| R3 | `B05_Profile_Route_NodeEdits.py:21` node_edits | `logic/B05_Profile_UI_RouteEdit_Edits.ts` applyCurveLimits | `Router_Replan.py:264`(곡선표 edits 칸) 하나 | 아님 | `test_430_route_edit_rules` | 안 바뀜 | B-5 · 소비자 0 → 걷음 | +| R5 | `_Sections_Core.py:419` generate_sections(_interpolate_xy · _tangent_at) | `logic/B05_Profile_UI_RouteEdit_Frame.ts` stationFrameAt | 측점 생성 길 | 아님 | `test_b05_routeedit_frame_mirror` · `test_224_2` | 0 차(파이썬이 6자리 반올림으로 적으면) | B-2 | +| R6 | 같은 generate_sections(측점 · 곡선 측점 · 확폭) | `logic/B05_Profile_Sections_Stations.ts` buildStations | 측점 생성 길 | 아님 | `test_344_3_pipeline_stations_mirror` · `test_407` · golden KNOWN R7(성토 1.1e-4 ㎡ · 누가 0.011 ㎥ · 노선 저장 다시 뜸) | 0 차(475 1.2) · KNOWN R7 걷힘 | B-2 | +| R7 | `common_util_station_key.py:32` station_key | `common_util_station_key.ts` stationKey | `B05_Profile_Route_Save_Parts.py:142` station_keys ← `_carry_cross`(`Route_Save.py:307`) 하나 | 아님(R9 은 Node) | `test_344_3_station_key_mirror` | 안 바뀜(열쇠 글 같음) | B-2 · 소비자 0 → 걷음 | +| G2 | `B06_Section_Engine_SampleExtend.py:62` extend_unclosed(`:113` 묶음) | `logic/B05_Profile_Sections_Ground.ts` extendUnclosed | [저장] 재계산 `Server_Calc_Prebuild.py:227` 하나 · 안에서 파이썬 C1 걸음마다 | 아님 | `test_344_1_sections_ground_mirror` · 브라우저는 DTM 격자 없으면 안 넓힘(`B06_Section_Cross_Stage.ts:140`) | DTM 0 차 · **비DTM = 갈림**(결정 1) | B-4 | +| G4 | generate_sections uphill_side(`_Sections_Core.py:536-541`) | `logic/B05_Profile_Pipeline_Detail.ts:67` uphillSide | 측점 생성 길(종단 stations) · 기본 단면 모양 `Router_Design.py:320` | 아님 | `test_346_2_pipeline_cross_page` · X1 확인(472-7) | 안 바뀜 | B-2 값 · cross 줄로 엶 | +| P1 | `Grade_Profile.py:210` design_ground_following_profile base_pvi 몫 · 2~4 순위 폴백(scipy `Grade_Solver.py:11`) | `logic/B05_Profile_Grade_Ground.ts` groundFollowingBasePvi(1순위만 · 못 서면 null) | 측점 생성 길 `_append_design_profiles` | 아님 | `test_344_2_route_pairs` · `test_432_1` | 1순위 0 차(475) · 폴백 파이썬 남김(결정 4) | B-3 | +| P2 | `Grade_Profile.py:537` rebuild_alignment_profile → `Grade_Alignment.py:408` evaluate | `logic/B05_Profile_UI_Profile_Alignment.ts`(700 줄) buildAlignment | 종단 다시 셈 길 | 아님 | 426 · 432_1 · 213_1 · 438_1(1e-6) | 계획고 6자리 끝자리(→ 면적 1e-4 ㎡ · Prebuild `:236` 머리) — 파이썬이 같은 반올림으로 적으면 0 | B-3 | +| P3 | `Grade_CurveLaw.py:25` flat_bends | `logic/B05_Profile_Profile_CurveLaw.ts` flatBends | P1 · P2 안(`Grade_Profile.py:203 · 420 · 518`) | 아님 | `test_432_1_curve_law` | 안 바뀜 | B-3 | +| P4 | `Grade_Alignment.py:40` is_straight_bend | `logic/B05_Profile_Profile_Straight.ts` | P2 안(`Grade_Alignment.py:340`) | 아님 | 426 · `test_271_straighten_pick` | 안 바뀜 | B-3 | +| P5 | `Grade_Carry.py:149` carry_edits | `logic/B05_Profile_Grade_Carry.ts` carryEdits | `_carry_profile`(`Route_Save.py:281`) 하나 | 아님 | `test_400_profile_carry` · `349_0v` | 안 바뀜(D2 흠 = 저장 본문 몫 · H 4.4) | B-3 · 소비자 0 → 걷음 | +| P6 | 겉 `B05_Profile_Router_Confirm.py:216` _seat_born_nodes | `logic/B05_Profile_Grade_Seat.ts` | B05 확정 · `_carry_profile` | **Node**(472-4 ①) | 427_1 · 459 · 472_4_follow_node | — | follow 줄 엶만 | +| P8 | `B05_Profile_Engine_Grade.py:193` resolve_grade_options(표 FOREST_ROAD_PROFILE_CRITERIA) + 경고 = evaluate 안 | buildAlignment `:430` verticalCurveShortfalls · 표 `B05_Profile_UI_Panel_Fields.ts:57`(화면 파일) | 종단 다시 셈 길 · `Router.py:118` · `PageSettings.py:68` · `B06_Section_Router_Regenerate_Options.py:44` | 아님 | `test_442_2_grade_limit_mirror` · golden W0 | 경고 글 꼴 같음(W1 풀림) · 표 두 벌(X5) → 한 벌 | B-3 | +| C1 | `B06_Section_Engine_Design.py:195` compute_cross_design | `common_util_cross_design.ts` computeCrossDesign | [저장] · [확정] = Node · 기본 설계 길은 파이썬 | 일부(472-4 ②) | `test_b06_cross_design_mirror` · `178_2` · 알려진 차 = 동점 1e-4 m 13 점(승인) | 기본 설계 측점 점 높이 1e-4 m(이미 허용) · 면적 0 차 | B-4 · B07 · 추천 짝 남음 | +| C2 | `B06_Section_Engine_Areas.py:51` _split_cut_areas(C1 안) | `common_util_cross_design_areas.ts` splitCutAreas | 파이썬 C1 안 | 일부 | C1 거울 | 0 차 | B-4 | +| C3 | `Router_Design.py:166 · 270` 포장 · 세월교 보정 · 구간 `B06_Section_Router_Design_Ranges.py:34 · 60 · 73` · `Engine_Tilt.py:71` | `logic/B06_Section_Cross_Enforce.ts` facilityRanges | [저장] 재계산 `Prebuild.py:208-229` · `Router_Design.py:233-234` | 다시 세운 설계만 Node | `test_349_0n_cross_enforce_mirror` | 안 바뀜 | B-4 | +| C5 | `common_util_cross_berm.py:66` cut_profile_points(C1 안 · `Design_Geometry.py:260`) | `common_util_cross_berm.ts` cutProfilePoints | 파이썬 C1 안 | 일부 | `test_178_2_berm_fill_mirror` | 0 차 | B-4 | +| C6 | `Engine_Tilt.py:100` attach_underdrain_specs(`:95` attach_underdrains = 운영 부름 0 · 시험만) | `common_util_structure_underdrain.ts` | F4 안(`Engine_Attach.py:132`) | 아님 | `test_472_4_underdrain_mirror` · 알려진 차 = 옛 underdrain 칸(TS 걷음) | GET 상세 옛 칸 하나(읽기 값) | B-4 | +| F1 | 겉 `common_util_route_follow.py:124` · 좌표 맞춤 `:87` fill_structure_coordinates = 파이썬 | `common_util_route_follow.ts` followStructures · placeOnLine `:113` | 구조물 PUT `B05_Profile_Structures_Router.py:136 · 241 · 455` | 따라감 Node · 맞춤 파이썬 | 410 · 344_2 · 493_3 | 맞춤 규칙 다름(파이썬 = 자리 셋 같으면 옛 좌표 · 4자리 · TS = 반올림 없음) → TS 를 파이썬 규칙으로 맞추면 0 | B-5 | +| F2 | 겉 `B05_Profile_Plus_Stations.py:74` | `common_util_route_follow.ts` followPlusRows | `Route_Save.py:418` | **Node**(472-4 ①) | 459 · 368_2 | — | follow 줄 엶만 | +| F3 | `common_util_structure_station.py:121` plan_stations | `common_util_structure_station.ts` planStations | 측점 생성 길(`Engine_Sections.py:278`) · `Route_Save_Parts.py:124 · 130` · `Structures_Router.py:334` · `Engine_Attach.py:63`(B07 은 같은 이름 다른 함수 `Support_Basin.py:280`) | 아님 | `test_322_3_plan_mirror` · `234_1` | 안 바뀜 | B-2 · 남은 부름 B-4 · B-5 · 상수 · snap 은 배수가 씀(남김) | +| F4 | `B06_Section_Engine_Attach.py:109` attach_shapes · `:139` attach_saved_shapes | `common_util_structure_attach.ts` attachShapes | GET 상세 `B06_Section_Router.py:263 · 471` ← [저장] 재계산이 그 상세를 Node 에 넘김 | 아님 | `test_323_4_attach_mirror` | 안 바뀜(읽기 때 얹음) | B-4 · GET 은 상주 Node 뒤 | +| F5 | `B06_Section_Engine_Culvert.py:76` pipe_wall_height_m · `_Culvert_Sets.py` load_culvert_sets | `common_util_culvert_sets.ts`(668) | F4 안 · C3 구간 · B04 `Basins.py:33` · B07 · B08 | 아님 | `test_b06_culvert_sets_mirror` · `pipe_wall_height_mirror` | 안 바뀜 | B-4 · B04 · B07 · B08 짝 남음 | +| F6 | `Engine_Structures_Wall.py:82` wall_specs_from · 포장 짝 | `common_util_structure_walls.ts` · `_pavement.ts` | F4 안(`Engine_Attach.py:127`) · B07 `Development.py:24 · 37` | 아님 | walls · pavement mirror | 안 바뀜 | B-4 · B07 짝 남음 | +| F9 | `B04_PreProcess_Router_Basins.py:268` ford_height_m · B08 ford_depth | `design_structure_ford_calc.ts` fordHeightM(화면 파일 `B05_Profile_UI_Drainage_Facility_Fields` 끌림) | B04 자동 배치 새 세월교 빈 높이(`Basins.py:262`) · B08 수리표 | 아님 | `test_65_ford_defaults` · `73_11` | 높이 = 저장 입력 · 서버 다시 안 셈 | 재분류(결정 3) | + +- 472-4 「남김」 줄 까닭 → 자리 — F1 좌표 맞춤(규칙 다름 → B-5 에서 TS 를 파이썬 규칙으로) · R7 서버 열쇠(주인 F3 · 부르는 곳 `_carry_cross` → B-2 가 측점 출력 열쇠를 씀) · 「R7 알려진 차」(노선 저장 표본 다시 뜸 → B-2 뒤 걷힘). +- 472 덧 121.6 ㎥(초안 −18,216.5 ↔ [저장] −18,094.9) = 계산 짝 밖(세션 초안 넘김 두 길 · 475 2장 추정) — B단계로 안 풀림 · 따로 둠. + +## 3. 묶음 단위 · 고르는 단위 + +- 한 단계 = 한 함수가 여러 id 를 한 번에 셈(cross = C1 C2 C3 C5 C6 G2 G4 F4 · F5 F6 은 얹기 · 보정 안) → id 하나만 서버로 못 뺌 · **고르는 단위 = 단계**. +- 제안 판(잠금 `stages` 23 + `structure_pair` 3 → 아래로 · `common_util_calc_sites.py:21-58`): + +| 줄 | id | 서버 정본 자리(B 뒤) | 열 때 | +|---|---|---|---| +| route | R1 R3 | Node `route.polyline`(`_write_planned_polyline` 갈음) | B-5 | +| follow | P5 P6 F1 F2 | Node(이미 P6 F1 F2 · P5 B-3 · 좌표 맞춤 B-5) | B-5 | +| stations | R5 R6 R7 F3 | Node 측점(측점 생성 길) | B-2 | +| ground | G1 | 있음(서버 = 파이썬 표본 · DTM 이면 B-2 뒤 Node 격자도 됨) | 지금 | +| profile | P1 P2 P3 P4 P8 | Node 종단(planline + profile — 서버 정본이 한 함수라 같이) | B-3 | +| cross | G2 G4 C1 C2 C3 C5 C6 F4 F5 F6 | Node 횡단 · 형상 얹기 | B-4 | +| (F9) | F9 | 폼 기본값 도구 · B04 · B08 파이썬 | 재분류 | + +- 서버 단계 요청 = 묶음 입력(`input.*` · 세션에서 모은 `input.cross` 는 브라우저가 모아 보냄 — `design_pipeline_cross.ts` 가 세션을 읽음) · 서버가 정본 맥락(저장 노선 · 관 · 구조물 · 종류 표 · 저장 PVI · 격자 경로)을 붙임 · **앞 단계는 서버 Node 가 다시 셈(5 ms 안팎)** — 지반 표본(0.5~1.5 MB)을 올려 보내지 않음 · 이어진 서버 단계는 한 요청. +- 단계마다 local · remote 한 겹(선례 ground `B05_Profile_Pipeline_RouteGround.ts:100` remote · 판 `calcOnServer` `design_calc_sites.ts:59`). + +## 4. 단계 표(창 · 파일 · 완료 기준) + +| 단계 | 창 | 대상 | 파일(그 창 주인) | 할 일 | 완료 기준 | 여는 줄 | +|---|---|---|---|---|---|---| +| B-0 | sub2(511-A) | 바탕 | `common_util_node_bundle.py` · `common_util_calc_runner.py` · 계산 창구 | 상주 Node · `run_calc` 가 상주로 · 묶음 다시 짓기는 켤 때만 | 511-A 확인(거울 · 왕복 시간 표) | quantity · carry · fixed_browser | +| B-1 | 창 1 | 단계 Node 입구 | 새 `design_calc_node_stages.ts` · `design_calc_node.ts` · `design_pipeline_page.ts`(693 · 빼기만) · `common_util_calc_sites.py` · `B01_Dashboard_UI_CalcSites.ts` | 472-1 헬퍼 배선(`helper_472_1_browser.mjs`)을 정식 입구로 · 페이지 닫힘 중 순수 몫(follow `placed` · cross `crossWithContext` · `shapeStatus`)을 logic 으로 · Node 가 DTM `.bin` 읽음(경로 · 수정시각 기억 · 시트 격자 17 MB) · 판 다섯 줄(잠금) · 단계 local · remote 한 겹 | golden 고정 자료 Node 단계 출력 = 브라우저 길 0 차 · 472-3 등록부 · 309-2 판 시험 · typecheck | — | +| B-2 | 창 A | R5 R6 R7 F3 G4 | `B05_Profile_Engine_Sections.py`(644) · `_Sections_Core.py`(640) · `Route_Save_Parts.py` · `InitialProfile.py` · TS stations · Frame · key · structure_station · Pipeline_Detail | `generate_sections` 몫 → Node 측점(열쇠 · 상단측) · 지반 = DTM Node 격자 / 비DTM 파이썬 표본 뒤 Node · 파이썬은 정본 꼴 · 6자리 반올림 · 머리(cad_exchange · source)만 적음 · 이월 열쇠 = Node 출력 · 추천 지문(`SOURCE_GLOBS`)에 묶음 지문 | 거울 344_3 · 224_2 · 407 · 322_3 · 346_2 · 344_1 · golden server_route = browser(R7 · X2 줄 걷힘 → 값 다시 뜸) · 검증 프로젝트 노선 저장 전후 측점 · 누가토량 같음 · 「종횡단 한 번」 중앙 ≤ 1.6 s | stations | +| B-3 | 창 B | P1 P2 P3 P4 P5 P8 | `Grade_Profile.py` · `B05_Profile_Router.py` · `PageSettings.py` · `Route_Save.py`(`_carry_profile`) · Prebuild `refresh_alignment` · TS `UI_Profile_Alignment.ts`(700 → 먼저 나눔) · `UI_Panel_Fields.ts` 표 → logic | `rebuild_alignment_profile` 본체 → Node(계획선 · 경고 · 법칙 · 직선) · 1순위 base PVI → Node(null → 파이썬 2~4 순위) · `carry_edits` → Node(seat 와 한 부름) · 기준표 한 벌(X5) · `_append_design_profiles` 는 B-2 합친 뒤 | 거울 426 · 432_1 · 213_1 · 438_1 · 400 · 349_0v · 442_2 · 427_1 · golden 0 차(W0 글 꼴) · 종단 정본 해시 같음 · 「계획고 옮김」 중앙 ≤ 0.7 s | profile | +| B-4 | 창 C | G2 C1 C2 C3 C5 C6 F4 F5 F6 | `B06_Section_Router_Design.py`(620) · `_Ranges.py` · `_Engine_Design_Node.py` · `_SampleExtend.py` · `_Engine_Attach.py` · `_Engine_Tilt.py` · `_Server_Calc_Prebuild.py` · `B06_Section_Router.py`(GET) | 기본 설계 길 → Node `cross.design` 묶음 · G2 = 한 Node 부름 안 +5 m 되풀이(격자) · 구간 → facilityRanges · 얹기 → Node(재계산 = server-calc 부름 안 · GET = 상주 Node 뒤) | 거울 b06_cross_design(동점 13 점 그대로) · 178_2 · 349_0n · 472_4_underdrain · 323_4 · culvert · walls · golden server_save = browser · [저장] 재계산 중앙 ≤ 1.6 s · [확정] ≤ 4.0 s · GET 상세 + 0.05 s 안 | cross | +| B-5 | 창 A(B-2 뒤) | R1 R3 F1 맞춤 · F3 남음 | `Router_Replan.py`(688 → 먼저 나눔) · `Structures_Router.py` · `common_util_route_follow.py · .ts` · `Route_Save_Parts.py` | `_write_planned_polyline` → Node(선 · 곡선 · 편집표) · placeOnLine 을 파이썬 규칙으로 맞춘 뒤 좌표 맞춤 → Node · plan_stations 남은 부름 → Node 출력 | 거울 route_polyline · 430 · 410 · 493_3 · 344_2 · 곡선표 · 구조물 좌표 해시 같음 · 노선 저장 체인 총 중앙 ≤ 10.9 s | route · follow | +| B-6 | 아무 창 | 정리 | 등록부 둘 · `common_util_calc_runner.py` · golden | runner `node:` · `until` 지움 · NODE_IDS · KNOWN 빈 목록 · 소비자 0 짝 걷음(R3 · R7 · P5 · G2 · C3 구간 · C6 · F1 맞춤) · 남는 짝 머리에 「소비자 · 까닭」 | 고친 대상 시험 · 472-3 · 309-2 | — | + +- 겹침 — `B05_Profile_Router_Confirm.py`(B-2 `:560` · B-3 `:362` · B-4 `:172` 줄 다름 · 먼저 끝낸 창이 합침) · `Engine_Sections.py`(B-2 주인 · B-3 은 B-2 합친 뒤) · `Engine_Attach.py:63` F3 = B-4 몫. +- 700 줄 — `UI_Profile_Alignment.ts` 700 · `Router_Replan.py` 688 · `design_pipeline_page.ts` 693 · `B07_..._Router_Support.py` 693(안 만짐) → 그 단계 첫 커밋에서 나눔. +- 단계 문(472-1 그대로) — 기준값 두 길 · 고친 대상 시험 · 끌린 묶음 다시 지음(design-calc · server-calc · corridor) · 화면 = 검증 프로젝트 [저장] 전후 행 · 파일 해시. + +## 5. 성능 — 파이썬 대신 Node + +- 바탕 값(H 2.2) — Node 새 프로세스 기동 52 ms · 묶음 읽기까지 0.09~0.14 s · 상세 1.45 MB 넘김 포함 0.22~0.27 s · 같은 셈 TS = 측점 · 지반 · 첫 계획선 4.5~5 ms ↔ 파이썬 0.51~0.60 s(파일 쓰기 포함) · 횡단 57 측점 TS 18 ms ↔ 파이썬 0.10~0.17 s. +- 상주 Node(511-A 가정) — 부름당 = JSON 넘김(1.45 MB ≈ 파이썬 dumps 36 ms + Node 파싱 15 ms) + 셈 → 0.05~0.08 s · 작은 자료 0.01 s(추정). + +| 체인 | 지금(로그 중앙) | B 가 바꾸는 파이썬 몫 | 새 프로세스 Node | 상주 Node | +|---|---|---|---|---| +| 노선 저장(노선 바뀐 [저장] · [확인] · 추천 [적용]) | 10.9 s(68 회 · H 아홉 로그 10.5 · 「종횡단 한 번」 1.6 · 「계획고 옮김」 0.7 · 「횡단 이월」 1.2) | ≈ 3.5 s(셈 + 파일 · DB) | 부름 4~6 × 0.2~0.3 s 더함 · 파이썬 셈 0.5~0.8 s 덞 → +0.3~0.7 s(추정) | −0.4~0.7 s(추정) | +| B03 전처리(배수 · 관 · 측점) | 93~121 s(배수 96~98 % · 측점 재생성 1.6~3.2 s) | 측점 재생성 몫 | ±0.3 s(0.3 %) | 거의 0 | +| 추천 초기 종단 미리 셈 | 4.6 s(161 회 · 0.8~58 s) | build_sections 0.5~0.6 s | −0.2~+0.1 s | −0.4 s | +| [저장] 서버 재계산 | 1.6 s(H · 661 회) | 경고 0.04 · 보정 · 넓힘 0.2 · 상세 얹기 0.2 | 기존 Node 부름에 얹으면 ±0 · 따로면 +0.2 s | −0.1~0.2 s | +| GET 상세(F4 · C6 · 기본 설계) | 0.2 s(H 「상세 + DB」) | 얹기 · 기본 설계 | **읽기마다 +0.2~0.3 s** | +0.01~0.05 s | + +- 막을 곳: + 1. 되풀이 안 부름 — 추천 엔진(R1 일곱 자리 · 균형 종단 · 단면 곡선 C1 측점 × Δ) · B07 측점마다 C1 → 파이썬 짝 남김(Node 로 바꾸면 수백~수천 × 0.2 s). + 2. G2 걸음 — +5 m 마다 설계 · 표본 → 한 Node 부름 안(Node 가 격자 읽음) · 파이썬 ↔ Node 핑퐁 금지. + 3. GET 상세 — 상주 Node 전엔 F4 · C6 · attach_default_designs 파이썬 둠. + 4. 묶음 낡음 — 다시 짓기 0.4~0.5 s + npm 0.74 s 가 체인 중간에 안 일게(서버 켤 때 · 싱크 뒤 미리 지음 · 낡음 검사 35~41 ms). + 5. 큰 자료 — 측점 250(5 km)이면 상세 6 MB(추정) → 넘김 0.3~0.5 s · 격자는 경로만 넘기고 Node 가 읽음 · 결과는 파이썬이 파일에 적을 칸만. + 6. 추천 저장본 지문 — `InitialProfile.py:32` SOURCE_GLOBS 가 파이썬 파일만 봄 → TS 묶음 지문 더함(안 하면 TS 고쳐도 옛 저장본). +- 조작 중 「서버」 고름 = 왕복(5장 「네트워크 왕복 금지」의 시험용 예외 · 309-B 결정 6) · 끌기 0.1 s 는 상주 Node 로도 회선(23 ms~80 ms) 몫이 남음. + +## 6. C단계 메모 + +| id | 브라우저에서 못 도는 까닭 · 옮김 크기 | +|---|---| +| E2 유토 배분 | TS 는 이미 한 벌(`common_util_mass_haul_balance.ts` 842 + `_settle.ts` · Node `haul.plan`) · 막힌 까닭 = `import type` 로 묶음에서 뺌(값어치 A · 09-06 사용자 확정 · `B05_Profile_Api_HaulPlan.ts:3-8`) · 옮김 = 842 → 700 아래 나눔 + 판 both 때만 dynamic import + 배분 맥락(B08 채집석 · 구조물 잔토 · 장비 한계 = 지금 서버 `haul_inputs_for` DB 조회)을 페이지로 + 거울 · 새 150 줄 안팎 · 보안 결정 먼저 | +| D1 배수유역 | 래스터 흐름 · 유역 다각형(numpy 격자 · polygonize · `common_util_drainage_detail.py` 655 + B04 Watershed_*) · 유역 산정 54~100 s · DEM 수십 MB | +| D2 관 자리 제안 | D1 흐름 누적 · 세류선 래스터(`find_inflow_hotspots`) 위에서만 셈 — D1 없이 못 돎 | +| N1 노선 추천 | 파이썬 10 파일 3,891 줄 · scipy · 지형 · 세류 래스터 탐색 · 셈 6 s 중앙(최대 87 s) · 안에서 R1 · C1 짝 되풀이 · 값어치 A | +| G3 넓힘 | 셈이 아니라 저장 밖 DB 쓰기(`B06_Section_Router_Widen.py` · N10) + 모든 지표면 표현 파이썬 표본 — 미리 보일 값 없음 | + +## 사용자 결정 필요 + +1. 비DTM 지표면(tin · nurbs · implicit · meshfree · 기본은 dtm `config_system_terrain.py:165`) — (가) 그 프로젝트는 파이썬 길 남김 · 단계 줄 잠금 까닭 「DTM 아닌 지표면」 ★ (나) 격자로 구워 브라우저 · Node 한 벌(미교차 넓힘 · 표본 값 바뀜). +2. golden 값 파일 다시 뜸(B-2 끝 · KNOWN R7 · X2 걷힘 · `helper_472_1_dump.py` 승인 때만). +3. F9 — 재분류(폼 기본값 도구 · B04 · B08 파이썬 · 고를 것 없음) ★ / 서버 Node 로 열기(값어치 작음). +4. P1 폴백 2~4 순위(scipy) 파이썬 남김 ★ / TS 로 옮김(큼). +5. preview 창구(`/cross-design/preview` · 옛 자료 폴백) — B-3 · B-4 때 걷음 ★ / Node 로 바꿔 둠. 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/knowledge/original/_pipeline/gen_md.py b/resources/knowledge/original/_pipeline/gen_md.py index ef15eee5d..c512929d9 100644 --- a/resources/knowledge/original/_pipeline/gen_md.py +++ b/resources/knowledge/original/_pipeline/gen_md.py @@ -45,281 +45,7 @@ def norm(s): return re.sub(r"[\s\u00b7\u2027]", "", s) -ENTRIES = [ - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률", - "약칭 산림자원법", - [r"산림자원의조성및관리에관한법률(?!시행)", r"산림자원법(?!시행)"], - ), - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률 시행령", - "", - [r"산림자원의조성및관리에관한법률시행령", r"산림자원법시행령"], - ), - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률 시행규칙", - "별표1 타당성평가·별표2 시설기준 포함", - [r"산림자원의조성및관리에관한법률시행규칙", r"산림자원법시행규칙"], - ), - ( - "법률", - "산림청", - "산림기술 진흥 및 관리에 관한 법률", - "약칭 산림기술법", - [r"산림기술진흥및관리에관한법률(?!시행)", r"산림기술법(?!시행)"], - ), - ( - "법률", - "산림청", - "산림기술 진흥 및 관리에 관한 법률 시행령", - "", - [r"산림기술진흥및관리에관한법률시행령", r"산림기술법시행령"], - ), - ("법률", "산림청", "산림보호법", "", [r"산림보호법"]), - ("법률", "산림청", "산지관리법", "", [r"산지관리법"]), - ("법률", "환경부", "자연환경보전법", "시행령·시행규칙 포함", [r"자연환경보전법"]), - ("법률", "행정안전부", "자연재해대책법", "시행령·시행규칙 포함", [r"자연재해대책법"]), - ("법률", "환경부", "환경영향평가법", "시행령·시행규칙 포함", [r"환경영향평가법"]), - ( - "법률", - "산업통상자원부", - "국가균형발전특별법", - "시행령·시행규칙 포함", - [r"국가균형발전특별법"], - ), - ( - "법률", - "기획재정부", - "보조금 관리에 관한 법률", - "시행령·시행규칙 포함", - [r"보조금관리에관한법률", r"보조금에관한법률"], - ), - ( - "법률", - "기획재정부", - "국가를 당사자로 하는 계약에 관한 법률", - "약칭 국가계약법", - [r"국가를당사자로하는계약에관한법률(?!시행)", r"국가계약법(?!시행|률)", r"국가계약법률"], - ), - ( - "법률", - "기획재정부", - "국가를 당사자로 하는 계약에 관한 법률 시행령", - "", - [r"국가를당사자로하는계약에관한법률시행령", r"국가계약법시행령"], - ), - ( - "법률", - "행정안전부", - "지방자치단체를 당사자로 하는 계약에 관한 법률", - "약칭 지방계약법", - [r"지방자치단체를당사자로하는계약에관한법률(?!시행)", r"지방계약법(?!시행)"], - ), - ( - "법률", - "행정안전부", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "", - [r"지방자치단체를당사자로하는계약에관한법률시행령", r"지방계약법시행령"], - ), - ("법률", "국토교통부", "도로법", "", [r"(?", re.S) +_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)") +_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$") +#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …) +_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])") +_HOLD = "\x00" +#: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선). +#: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는 +#: `…` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄. +_PUA = re.compile("[\ue000-\uf8ff]") +_OFF_OPEN, _OFF_CLOSE = "\x01", "\x02" +_OFF_MARK = re.compile(r"") + + +def _same_han(ch: str) -> str: + """호환 한자(U+F900–FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림.""" + return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch + + +def book_of(path: Path) -> dict: + path = path.resolve() + for book in BOOKS.values(): + if path.is_relative_to(book["out"].resolve()): + return book + raise ValueError(f"본문/ 아래 파일이 아님: {path}") + + +def split_head(text: str) -> tuple[str, str]: + """(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지.""" + lines = text.split("\n") + end = lines.index("---", 1) + return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :]) + + +def md_lines(body: str) -> list[tuple[int, str]]: + """몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터. + + 쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌. + """ + body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body) + body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body) + out = [] + for number, line in enumerate(body.split("\n"), start=1): + if _TABLE_RULE.match(line): + continue + line = _IMAGE.sub("", line).replace("
", " ") + held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴 + line = _ESCAPED.sub(_HOLD, line) + line = re.sub(r"^\s*#+\s", "", line) # 제목 + line = re.sub(r"^\s*(>\s*)+", "", line) # 인용 + line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`) + for ch in held: + line = line.replace(_HOLD, ch, 1) + out.append((number, line)) + return out + + +#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과 +#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음. +_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S") +#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님. +#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨. +_NOT_REPEAT = re.compile( + r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작 + r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류 + r"|^\d{1,2}\.\s" # 1. 제목 + r"|^[가-힣]\.\s" # 가. 제목 + r"|^[①-⑳㉮-㉻]" # ① ㉮ + r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예] + r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■ +) + + +def _chars(row: str) -> collections.Counter: + """그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대).""" + kept, _ = _split_off([(0, row)]) + return collections.Counter(_tight(kept)[0]) + + +def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter: + """되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼. + + md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음. + 딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐. + """ + kept_pdf, _ = _split_off(rows) + kept_md, _ = _split_off(md_lines(body)) + return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0]) + + +def pdf_lines( + doc, head: dict[str, str], footer: re.Pattern, body: str = "" +) -> list[tuple[int, str]]: + """그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌. + + 쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌. + 셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤: + + ① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음). + 새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음. + ② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄. + ③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌. + """ + first, _, last = head["PDF쪽"].partition("~") + first, last = int(first), int(last or first) + start_page, start_row = locate(doc, head["시작표지"], first, slack=0) + end_page, end_row = last + 1, 0 + if head["끝표지"] and head["끝표지"] != "끝": + try: + end_page, end_row = locate(doc, head["끝표지"], last, slack=1) + except ValueError: + pass + raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가) + for number in range(start_page, min(end_page, last) + 1): + rows = [row.strip() for row in read_rows(doc[number - 1])] + if rows and _is_footer(doc[number - 1], rows[-1], footer): + rows[-1] = "" # 쪽 꼬리 + if rows and _side_number(doc[number - 1], rows[0]): + rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호 + fresh = number > start_page # 다음 쪽 맨 위 + for index, row in enumerate(rows): + if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row): + continue + if not row: + continue + if row == "→": # 한 쪽 안에서 표가 끊긴 자리 + fresh = True + continue + raw.append((number, row, fresh)) + fresh = False + budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None + gone, later = [False] * len(raw), [] + since_head, at = "", 0 + while at < len(raw): + number, row, fresh = raw[at] + if fresh and (run := _repeat_run(raw, at, since_head)): + chars = sum((_chars(raw[k][1]) for k in run), collections.Counter()) + if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌 + if budget is not None: + budget -= chars + for k in run: + gone[k] = True + at = run[-1] + 1 + continue + later.append(run) # 몫이 모자란 것은 뒤로 미룸 + if _ITEM_HEAD.match(row): + since_head = "" + since_head += re.sub(r"\s+", "", row) + at += 1 + for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리 + for k in run: + chars = _chars(raw[k][1]) + if chars - budget: + break + budget -= chars + gone[k] = True + return [(number, row) for (number, row, _), out in zip(raw, gone) if not out] + + +def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]: + """이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지.""" + run, trial = [], "" + for k in range(at, len(raw)): + _, row, fresh = raw[k] + if (k > at and fresh) or _NOT_REPEAT.match(row): + break + grown = trial + re.sub(r"\s+", "", row) + if not grown or grown not in since_head: + break + trial = grown + run.append(k) + return run + + +def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: + """(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄.""" + kept, off, inside = [], [], False + for at, line in lines: + keep, gone = [], [] + for ch in line: + if ch in (_OFF_OPEN, _OFF_CLOSE): + inside = ch == _OFF_OPEN + elif inside or _PUA.match(ch): + # 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」) + keep.append(" ") + gone.append(ch) + else: + keep.append(ch) + kept.append((at, "".join(keep))) + if "".join(gone).strip(): + off.append((at, "".join(gone))) + return kept, off + + +def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]: + """공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로.""" + text, where = [], [] + for at, line in lines: + for ch in re.sub(r"\s", "", line): + text.append(_same_han(ch)) + where.append(at) + return "".join(text), where + + +def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]: + counts, where = collections.Counter(), collections.defaultdict(list) + for at, line in lines: + for token in _NUM.findall(line): + counts[token] += 1 + where[token].append(at) + return counts, where + + +def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict: + """① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈.""" + pdf, pdf_off = _split_off(pdf) + md, md_off = _split_off(md) + pdf_n, pdf_at = _numbers(pdf) + md_n, md_at = _numbers(md) + pdf_t, pdf_where = _tight(pdf) + md_t, md_where = _tight(md) + missing_c = collections.Counter(pdf_t) - collections.Counter(md_t) + extra_c = collections.Counter(md_t) - collections.Counter(pdf_t) + spots = [] + if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만) + matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False) + for op, i1, i2, j1, j2 in matcher.get_opcodes(): + gone, added = pdf_t[i1:i2], md_t[j1:j2] + if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)): + continue + spots.append( + { + "pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None, + "md": md_where[min(j1, len(md_where) - 1)] if md_where else None, + "context": pdf_t[max(0, i1 - 8) : i1], + "pdf_only": gone, + "md_only": added, + } + ) + return { + "num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()}, + "num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()}, + "char_missing": missing_c, + "char_extra": extra_c, + "spots": spots, + "off_pdf": ( + sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off), + sorted({a for a, _ in pdf_off}), + ), + "off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off), + } + + +#: 자간 벌린 수 — md 주석 `` (규칙 3-13). +#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함. +_SPACED = re.compile(r"", re.S) +_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」") +#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것 +#: md 주석 `` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함. +_GLUED = re.compile(r"", re.S) +#: 기계검사 예외 — md 주석 `` (규칙 6장 · 머리 바로 아래 한 줄). +#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임. +#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음. +_EXEMPT = re.compile(r"") + + +def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok): + """(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만).""" + done, bad = 0, [] + for block in mark.findall(body): + for old, new in _SPACED_PAIR.findall(block): + hits = sum(line.count(old) for _, line in pdf) + if not hits or not ok(old, new): + bad.append(old) + continue + pdf = [(at, line.replace(old, new)) for at, line in pdf] + done += hits + return pdf, done, bad + + +def join_spaced_numbers(pdf: list[tuple[int, str]], body: str): + """(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임.""" + return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight) + + +def split_glued_numbers(pdf: list[tuple[int, str]], body: str): + """(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름.""" + return _rewrite( + pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued + ) + + +def verdict(result: dict) -> str: + counts = ( + sum(v for v, _ in result["num_missing"].values()), + sum(v for v, _ in result["num_extra"].values()), + sum(result["char_missing"].values()), + sum(result["char_extra"].values()), + ) + joined, bad = result.get("spaced", (0, [])) + split, bad_glued = result.get("glued", (0, [])) + clean = not any(counts) and not bad and not bad_glued + word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") else "불통") + line = ( + f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}" + ) + glyphs, pages = result.get("off_pdf", (0, [])) + if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게 + where = f"({', '.join(map(str, pages))}쪽)" if pages else "" + line += f" · 글자층 밖 글리프 {glyphs}개{where} · 받아씀 {result.get('off_md', 0)}자" + if joined: # ③ 이 그림으로 그 수를 꼭 보게 + line += f" · 자간 벌린 수 {joined}곳" + if bad: + line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad) + if split: # ③ 이 그림으로 칸마다 값을 꼭 보게 + line += f" · 글자층 붙은 수 {split}곳" + if bad_glued: + line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued) + return line + + +def passed(line: str) -> str: + """기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」.""" + return line.startswith(("통과", "예외")) + + +def fingerprint(text: str) -> str: + """확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌.""" + lines = text.replace("\r\n", "\n").split("\n") + kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))] + return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest() + + +def set_head(text: str, field: str, value: str) -> str: + head, body = split_head(text) + head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1) + return head + "\n" + body + + +def check_file(path: Path) -> tuple[dict, str]: + """(대조 결과, 한 줄 판정) — 파일은 안 고침.""" + import pymupdf + + _, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n")) + book = book_of(path) + with pymupdf.open(book["pdf"]) as doc: + pdf = pdf_lines(doc, read_head(path), book["footer"], body) + pdf, joined, bad = join_spaced_numbers(pdf, body) + pdf, split, bad_glued = split_glued_numbers(pdf, body) + result = compare(pdf, md_lines(body)) + result["spaced"] = (joined, bad) + result["glued"] = (split, bad_glued) + result["exempt"] = bool(_EXEMPT.search(body)) + return result, verdict(result) + + +def check(path: Path) -> int: + """검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음. + + ⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음. + """ + result, line = check_file(path) + print(f"{path.name}: {line}") + for token, (count, at) in sorted(result["num_missing"].items()): + print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)") + for token, (count, at) in sorted(result["num_extra"].items()): + print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)") + for spot in result["spots"][:40]: + print( + f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}" + f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]" + ) + text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line) + head = read_head(path) + if head.get("상태") == "확정" and passed(line) and not head.get("확정지문"): + text = set_head(text, "확정지문", fingerprint(text)) + path.write_text(text, encoding="utf-8", newline="\n") + return 0 if passed(line) else 1 diff --git a/resources/knowledge/original/_pipeline/pum_md_plan.py b/resources/knowledge/original/_pipeline/pum_md_plan.py new file mode 100644 index 000000000..7f4915d1a --- /dev/null +++ b/resources/knowledge/original/_pipeline/pum_md_plan.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +"""품셈 md 도구 — 차례 읽기 · 새 md 틀 만들기(plan) (`pum_md_tool` 에서 나눔 · 밖에서는 `pum_md_tool` 로 부름).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py + _NUM, + BOOKS, + COST, + HEAD_KEYS, + ROOT, + STATES, + _page_rows, + find_heading, + heading_pattern, + join_spaced, + key, + locate, + read_rows, + visual_text, +) + +_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"} +_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*") +_BAD_NAME = re.compile(r'[/:*?"<>|\\]') +_LEADER = re.compile(r"\s*[·․…]{3,}.*$") +#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽) +_EMPTY_PAGE_CHARS = 20 + + +@dataclass +class Entry: + """차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록.""" + + ident: str + name: str + page: int # 인쇄 쪽 + + +def toc_entries(doc, book: dict) -> list[Entry]: + """차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0).""" + first, last = book["toc"] + raw = [ + line for number in range(first - 1, last) for line in doc[number].get_text().splitlines() + ] + if book["toc_layout"] == "inline": + return parse_toc_inline(raw) + return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP]) + + +def parse_toc(raw: list[str]) -> list[Entry]: + lines = [ + t for t in (_LEADER.sub("", line).strip() for line in raw) if t + ] # 점선 뗌(「부록 ····」) + out, i = [], 0 + while i < len(lines): + if not _TOC_ID.fullmatch(lines[i]): + raise ValueError(f"차례 모양이 다름: {lines[i]!r}") + j, name = i + 1, [] + while j < len(lines) and not lines[j].isdigit(): + name.append(lines[j]) + j += 1 + if j == len(lines): + raise ValueError(f"차례 쪽 번호 없음: {lines[i]}") + out.append(Entry(lines[i], " ".join(name), int(lines[j]))) + i = j + 1 + return out + + +_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$") +_DIVISION = re.compile(r"^\S+부문$") + + +def parse_toc_inline(raw: list[str]) -> list[Entry]: + """건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄.""" + lines = [line.strip() for line in raw if line.strip()] + out, i = [], 0 + while i < len(lines): + line = lines[i] + if found := _INLINE.match(line): + out.append(Entry(found[1], found[2], int(found[3]))) + elif re.fullmatch(r"제\d+장", line): + out.append(Entry(line, lines[i + 1], int(lines[i + 2]))) + i += 2 + elif _DIVISION.match(line): + out.append(Entry(line, "", 0)) + elif not ( + line.replace(" ", "") == "목차" or line.isdigit() + ): # 쪽 머리 「목차」 · 차례 쪽 번호 + raise ValueError(f"차례 모양이 다름: {line!r}") + i += 1 + return out + + +def name_of(ident: str, title: str, dot: bool = True) -> str: + """머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」).""" + return title[heading_pattern(ident, dot).match(title).end() :].strip() + + +#: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김) +_REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$") + + +def file_label(ident: str, title: str, dot: bool = True) -> str: + return safe(_REVISION.sub("", name_of(ident, title, dot))) + + +def claimed_elsewhere(root: Path) -> set[str]: + """다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘.""" + import subprocess + + rel = root.relative_to(ROOT).as_posix() + refs = subprocess.run( + ["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], + capture_output=True, + text=True, + check=True, + ).stdout.split() + out: set[str] = set() + for ref in refs: + listed = subprocess.run( + [ + "git", + "-C", + str(ROOT), + "-c", + "core.quotepath=off", + "ls-tree", + "-r", + "--name-only", + ref, + "--", + rel, + ], + capture_output=True, + text=True, + encoding="utf-8", + ) + out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line) + return out # root 아래 상대 경로 + + +def safe(text: str) -> str: + # 글자층 밖 글리프(PUA)는 이름에서 뺌 + text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF) + return _BAD_NAME.sub("", " ".join(text.split())).replace(" ", "_") + + +def _span(a: int, b: int) -> str: + return str(a) if a == b else f"{a}~{b}" + + +def head_text(values: dict[str, str]) -> str: + return ( + "---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n" + ) + + +def read_head(path: Path) -> dict[str, str]: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0] != "---": + raise ValueError(f"머리 없음: {path}") + out = {} + for line in lines[1:]: + if line == "---": + return out + k, _, v = line.partition(":") + out[k.strip()] = v.strip() + raise ValueError(f"머리 끝 없음: {path}") + + +#: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리 +_SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_") +#: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장) +_MAX_PAGES = 40 + + +def _side_number(page, row: str) -> bool: + """가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음.""" + return page.rect.width > page.rect.height and row.isdigit() + + +def _is_footer(page, row: str, footer: re.Pattern) -> bool: + return bool(footer.match(row)) or _side_number(page, row) + + +def _body_rows(page, footer: re.Pattern) -> list[str]: + """쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌.""" + rows = [row.strip() for row in _page_rows(page)] + if rows and _side_number(page, rows[0]): + rows = rows[1:] + if rows and _is_footer(page, rows[-1], footer): + rows = rows[:-1] + return [row for row in rows if row != "→"] + + +def _blank(page, footer: re.Pattern) -> bool: + return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS + + +def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""): + """(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌.""" + offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"] + start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot) + if following is None: # 문서 맨 끝 + end, marker = doc.page_count, "끝" + else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목 + nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot) + end = nxt - 1 if at == 0 else nxt + for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음 + if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]): + end = page - 1 + break + while end > start and _blank(doc[end - 1], footer): + end -= 1 + return start, title, max(end, start), marker + + +def plan(book_name: str) -> list[Path]: + """장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다. + + **이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄). + 건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 장 번호가 1부터. + """ + import pymupdf + + book = BOOKS[book_name] + out, dot, footer = book["out"], book["dot"], book["footer"] + offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽 + have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")} + taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m} + folders = { + (m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel)) + } + written = [] + + def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None: + if f"{division}/{chapter_no}/{slot}" in taken: + return + # 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준 + drawn = any( + _blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1) + ) + path = out / division / folders[(division, chapter_no)] / file_name + path.parent.mkdir(parents=True, exist_ok=True) + values = {"문서": book["doc"], "절": title, "시작표지": title, "끝표지": marker} + values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)} + values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"} + path.write_text(head_text(values), encoding="utf-8") + written.append(path) + + with pymupdf.open(book["pdf"]) as doc: + entries = toc_entries(doc, book) + # 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌) + at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2] + bounds = [entries[i] for i in at] + division, divisions, chapter_no = "", 0, None + for n, entry in enumerate(bounds): + if _DIVISION.match(entry.ident): + divisions += 1 + division, chapter_no = f"{divisions:02d}_{entry.ident}", None + continue + if entry.ident.startswith("부록"): + continue + j = next( + (k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None + ) + following = bounds[j] if j is not None else None + cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문 + start, title, end, marker = _span_of(doc, book, entry, following, cover) + if entry.ident.startswith("제"): + chapter_no = int(entry.ident[1:-1]) + folders.setdefault( + (division, chapter_no), + f"제{chapter_no:02d}장_{file_label(entry.ident, title, dot)}", + ) + write( + division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc + ) + continue + if chapter_no is None: + continue + major, minor = entry.ident.split("-") + slot = f"{major}-{int(minor):02d}" + region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)] + items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident] + if end - start + 1 <= _MAX_PAGES or not items: + name = f"{slot}_{file_label(entry.ident, title, dot)}.md" + write(division, chapter_no, slot, name, start, title, end, marker, doc) + continue + # 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다 + head_end = _span_of(doc, book, entry, items[0]) + write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc) + for k, item in enumerate(items): + piece = _span_of( + doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover + ) + if k + 1 == len(items): # 마지막 항은 절 끝까지 + piece = (piece[0], piece[1], end, marker) + item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}" + name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md" + write(division, chapter_no, item_slot, name, *piece, doc) + return written diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py index afcfdb026..c7e78297e 100644 --- a/resources/knowledge/original/_pipeline/pum_md_tool.py +++ b/resources/knowledge/original/_pipeline/pum_md_tool.py @@ -15,12 +15,7 @@ from __future__ import annotations import collections -import difflib -import hashlib -import re import sys -import unicodedata -from dataclasses import dataclass from pathlib import Path from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py @@ -40,670 +35,73 @@ from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기· visual_text, ) -_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"} -_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*") -_BAD_NAME = re.compile(r'[/:*?"<>|\\]') -_LEADER = re.compile(r"\s*[·․…]{3,}.*$") -#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽) -_EMPTY_PAGE_CHARS = 20 - - -@dataclass -class Entry: - """차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록.""" - - ident: str - name: str - page: int # 인쇄 쪽 - - -def toc_entries(doc, book: dict) -> list[Entry]: - """차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0).""" - first, last = book["toc"] - raw = [ - line for number in range(first - 1, last) for line in doc[number].get_text().splitlines() - ] - if book["toc_layout"] == "inline": - return parse_toc_inline(raw) - return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP]) - - -def parse_toc(raw: list[str]) -> list[Entry]: - lines = [ - t for t in (_LEADER.sub("", line).strip() for line in raw) if t - ] # 점선 뗌(「부록 ····」) - out, i = [], 0 - while i < len(lines): - if not _TOC_ID.fullmatch(lines[i]): - raise ValueError(f"차례 모양이 다름: {lines[i]!r}") - j, name = i + 1, [] - while j < len(lines) and not lines[j].isdigit(): - name.append(lines[j]) - j += 1 - if j == len(lines): - raise ValueError(f"차례 쪽 번호 없음: {lines[i]}") - out.append(Entry(lines[i], " ".join(name), int(lines[j]))) - i = j + 1 - return out - - -_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$") -_DIVISION = re.compile(r"^\S+부문$") - - -def parse_toc_inline(raw: list[str]) -> list[Entry]: - """건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄.""" - lines = [line.strip() for line in raw if line.strip()] - out, i = [], 0 - while i < len(lines): - line = lines[i] - if found := _INLINE.match(line): - out.append(Entry(found[1], found[2], int(found[3]))) - elif re.fullmatch(r"제\d+장", line): - out.append(Entry(line, lines[i + 1], int(lines[i + 2]))) - i += 2 - elif _DIVISION.match(line): - out.append(Entry(line, "", 0)) - elif not ( - line.replace(" ", "") == "목차" or line.isdigit() - ): # 쪽 머리 「목차」 · 차례 쪽 번호 - raise ValueError(f"차례 모양이 다름: {line!r}") - i += 1 - return out - - -def name_of(ident: str, title: str, dot: bool = True) -> str: - """머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」).""" - return title[heading_pattern(ident, dot).match(title).end() :].strip() - - -#: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김) -_REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$") - - -def file_label(ident: str, title: str, dot: bool = True) -> str: - return safe(_REVISION.sub("", name_of(ident, title, dot))) - - -def claimed_elsewhere(root: Path) -> set[str]: - """다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘.""" - import subprocess - - rel = root.relative_to(ROOT).as_posix() - refs = subprocess.run( - ["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], - capture_output=True, - text=True, - check=True, - ).stdout.split() - out: set[str] = set() - for ref in refs: - listed = subprocess.run( - [ - "git", - "-C", - str(ROOT), - "-c", - "core.quotepath=off", - "ls-tree", - "-r", - "--name-only", - ref, - "--", - rel, - ], - capture_output=True, - text=True, - encoding="utf-8", - ) - out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line) - return out # root 아래 상대 경로 - - -def safe(text: str) -> str: - # 글자층 밖 글리프(PUA)는 이름에서 뺌 - text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF) - return _BAD_NAME.sub("", " ".join(text.split())).replace(" ", "_") - - -def _span(a: int, b: int) -> str: - return str(a) if a == b else f"{a}~{b}" - - -def head_text(values: dict[str, str]) -> str: - return ( - "---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n" - ) - - -def read_head(path: Path) -> dict[str, str]: - lines = path.read_text(encoding="utf-8").splitlines() - if not lines or lines[0] != "---": - raise ValueError(f"머리 없음: {path}") - out = {} - for line in lines[1:]: - if line == "---": - return out - k, _, v = line.partition(":") - out[k.strip()] = v.strip() - raise ValueError(f"머리 끝 없음: {path}") - - -#: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리 -_SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_") -#: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장) -_MAX_PAGES = 40 - - -def _side_number(page, row: str) -> bool: - """가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음.""" - return page.rect.width > page.rect.height and row.isdigit() - - -def _is_footer(page, row: str, footer: re.Pattern) -> bool: - return bool(footer.match(row)) or _side_number(page, row) - - -def _body_rows(page, footer: re.Pattern) -> list[str]: - """쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌.""" - rows = [row.strip() for row in _page_rows(page)] - if rows and _side_number(page, rows[0]): - rows = rows[1:] - if rows and _is_footer(page, rows[-1], footer): - rows = rows[:-1] - return [row for row in rows if row != "→"] - - -def _blank(page, footer: re.Pattern) -> bool: - return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS - - -def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""): - """(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌.""" - offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"] - start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot) - if following is None: # 문서 맨 끝 - end, marker = doc.page_count, "끝" - else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목 - nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot) - end = nxt - 1 if at == 0 else nxt - for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음 - if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]): - end = page - 1 - break - while end > start and _blank(doc[end - 1], footer): - end -= 1 - return start, title, max(end, start), marker - - -def plan(book_name: str) -> list[Path]: - """장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다. - - **이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄). - 건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 장 번호가 1부터. - """ - import pymupdf - - book = BOOKS[book_name] - out, dot, footer = book["out"], book["dot"], book["footer"] - offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽 - have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")} - taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m} - folders = { - (m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel)) - } - written = [] - - def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None: - if f"{division}/{chapter_no}/{slot}" in taken: - return - # 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준 - drawn = any( - _blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1) - ) - path = out / division / folders[(division, chapter_no)] / file_name - path.parent.mkdir(parents=True, exist_ok=True) - values = {"문서": book["doc"], "절": title, "시작표지": title, "끝표지": marker} - values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)} - values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"} - path.write_text(head_text(values), encoding="utf-8") - written.append(path) - - with pymupdf.open(book["pdf"]) as doc: - entries = toc_entries(doc, book) - # 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌) - at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2] - bounds = [entries[i] for i in at] - division, divisions, chapter_no = "", 0, None - for n, entry in enumerate(bounds): - if _DIVISION.match(entry.ident): - divisions += 1 - division, chapter_no = f"{divisions:02d}_{entry.ident}", None - continue - if entry.ident.startswith("부록"): - continue - j = next( - (k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None - ) - following = bounds[j] if j is not None else None - cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문 - start, title, end, marker = _span_of(doc, book, entry, following, cover) - if entry.ident.startswith("제"): - chapter_no = int(entry.ident[1:-1]) - folders.setdefault( - (division, chapter_no), - f"제{chapter_no:02d}장_{file_label(entry.ident, title, dot)}", - ) - write( - division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc - ) - continue - if chapter_no is None: - continue - major, minor = entry.ident.split("-") - slot = f"{major}-{int(minor):02d}" - region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)] - items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident] - if end - start + 1 <= _MAX_PAGES or not items: - name = f"{slot}_{file_label(entry.ident, title, dot)}.md" - write(division, chapter_no, slot, name, start, title, end, marker, doc) - continue - # 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다 - head_end = _span_of(doc, book, entry, items[0]) - write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc) - for k, item in enumerate(items): - piece = _span_of( - doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover - ) - if k + 1 == len(items): # 마지막 항은 절 끝까지 - piece = (piece[0], piece[1], end, marker) - item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}" - name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md" - write(division, chapter_no, item_slot, name, *piece, doc) - return written - - -# ── check ────────────────────────────────────────────────────────────── -#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님) -_COMMENT = re.compile(r"", re.S) -_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)") -_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$") -#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …) -_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])") -_HOLD = "\x00" -#: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선). -#: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는 -#: `…` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄. -_PUA = re.compile("[\ue000-\uf8ff]") -_OFF_OPEN, _OFF_CLOSE = "\x01", "\x02" -_OFF_MARK = re.compile(r"") - - -def _same_han(ch: str) -> str: - """호환 한자(U+F900–FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림.""" - return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch - - -def book_of(path: Path) -> dict: - path = path.resolve() - for book in BOOKS.values(): - if path.is_relative_to(book["out"].resolve()): - return book - raise ValueError(f"본문/ 아래 파일이 아님: {path}") - - -def split_head(text: str) -> tuple[str, str]: - """(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지.""" - lines = text.split("\n") - end = lines.index("---", 1) - return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :]) - - -def md_lines(body: str) -> list[tuple[int, str]]: - """몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터. - - 쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌. - """ - body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body) - body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body) - out = [] - for number, line in enumerate(body.split("\n"), start=1): - if _TABLE_RULE.match(line): - continue - line = _IMAGE.sub("", line).replace("
", " ") - held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴 - line = _ESCAPED.sub(_HOLD, line) - line = re.sub(r"^\s*#+\s", "", line) # 제목 - line = re.sub(r"^\s*(>\s*)+", "", line) # 인용 - line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`) - for ch in held: - line = line.replace(_HOLD, ch, 1) - out.append((number, line)) - return out - - -#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과 -#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음. -_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S") -#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님. -#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨. -_NOT_REPEAT = re.compile( - r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작 - r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류 - r"|^\d{1,2}\.\s" # 1. 제목 - r"|^[가-힣]\.\s" # 가. 제목 - r"|^[①-⑳㉮-㉻]" # ① ㉮ - r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예] - r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■ +from pum_md_plan import ( # noqa: E402,F401 — 차례 읽기 · 새 md 틀은 pum_md_plan.py + Entry, + _BAD_NAME, + _DIVISION, + _EMPTY_PAGE_CHARS, + _INLINE, + _LEADER, + _MAX_PAGES, + _REVISION, + _SLOT, + _TOC_ID, + _TOC_SKIP, + _blank, + _body_rows, + _is_footer, + _side_number, + _span, + _span_of, + claimed_elsewhere, + file_label, + head_text, + name_of, + parse_toc, + parse_toc_inline, + plan, + read_head, + safe, + toc_entries, +) +from pum_md_check import ( # noqa: E402,F401 — 기계검사는 pum_md_check.py + _COMMENT, + _ESCAPED, + _EXEMPT, + _GLUED, + _HOLD, + _IMAGE, + _ITEM_HEAD, + _NOT_REPEAT, + _OFF_CLOSE, + _OFF_MARK, + _OFF_OPEN, + _PUA, + _SPACED, + _SPACED_PAIR, + _TABLE_RULE, + _chars, + _numbers, + _repeat_budget, + _repeat_run, + _rewrite, + _same_han, + _split_off, + _tight, + book_of, + check, + check_file, + compare, + fingerprint, + join_spaced_numbers, + md_lines, + passed, + pdf_lines, + set_head, + split_glued_numbers, + split_head, + verdict, ) - - -def _chars(row: str) -> collections.Counter: - """그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대).""" - kept, _ = _split_off([(0, row)]) - return collections.Counter(_tight(kept)[0]) - - -def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter: - """되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼. - - md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음. - 딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐. - """ - kept_pdf, _ = _split_off(rows) - kept_md, _ = _split_off(md_lines(body)) - return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0]) - - -def pdf_lines( - doc, head: dict[str, str], footer: re.Pattern, body: str = "" -) -> list[tuple[int, str]]: - """그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌. - - 쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌. - 셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤: - - ① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음). - 새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음. - ② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄. - ③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌. - """ - first, _, last = head["PDF쪽"].partition("~") - first, last = int(first), int(last or first) - start_page, start_row = locate(doc, head["시작표지"], first, slack=0) - end_page, end_row = last + 1, 0 - if head["끝표지"] and head["끝표지"] != "끝": - try: - end_page, end_row = locate(doc, head["끝표지"], last, slack=1) - except ValueError: - pass - raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가) - for number in range(start_page, min(end_page, last) + 1): - rows = [row.strip() for row in read_rows(doc[number - 1])] - if rows and _is_footer(doc[number - 1], rows[-1], footer): - rows[-1] = "" # 쪽 꼬리 - if rows and _side_number(doc[number - 1], rows[0]): - rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호 - fresh = number > start_page # 다음 쪽 맨 위 - for index, row in enumerate(rows): - if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row): - continue - if not row: - continue - if row == "→": # 한 쪽 안에서 표가 끊긴 자리 - fresh = True - continue - raw.append((number, row, fresh)) - fresh = False - budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None - gone, later = [False] * len(raw), [] - since_head, at = "", 0 - while at < len(raw): - number, row, fresh = raw[at] - if fresh and (run := _repeat_run(raw, at, since_head)): - chars = sum((_chars(raw[k][1]) for k in run), collections.Counter()) - if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌 - if budget is not None: - budget -= chars - for k in run: - gone[k] = True - at = run[-1] + 1 - continue - later.append(run) # 몫이 모자란 것은 뒤로 미룸 - if _ITEM_HEAD.match(row): - since_head = "" - since_head += re.sub(r"\s+", "", row) - at += 1 - for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리 - for k in run: - chars = _chars(raw[k][1]) - if chars - budget: - break - budget -= chars - gone[k] = True - return [(number, row) for (number, row, _), out in zip(raw, gone) if not out] - - -def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]: - """이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지.""" - run, trial = [], "" - for k in range(at, len(raw)): - _, row, fresh = raw[k] - if (k > at and fresh) or _NOT_REPEAT.match(row): - break - grown = trial + re.sub(r"\s+", "", row) - if not grown or grown not in since_head: - break - trial = grown - run.append(k) - return run - - -def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: - """(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄.""" - kept, off, inside = [], [], False - for at, line in lines: - keep, gone = [], [] - for ch in line: - if ch in (_OFF_OPEN, _OFF_CLOSE): - inside = ch == _OFF_OPEN - elif inside or _PUA.match(ch): - # 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」) - keep.append(" ") - gone.append(ch) - else: - keep.append(ch) - kept.append((at, "".join(keep))) - if "".join(gone).strip(): - off.append((at, "".join(gone))) - return kept, off - - -def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]: - """공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로.""" - text, where = [], [] - for at, line in lines: - for ch in re.sub(r"\s", "", line): - text.append(_same_han(ch)) - where.append(at) - return "".join(text), where - - -def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]: - counts, where = collections.Counter(), collections.defaultdict(list) - for at, line in lines: - for token in _NUM.findall(line): - counts[token] += 1 - where[token].append(at) - return counts, where - - -def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict: - """① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈.""" - pdf, pdf_off = _split_off(pdf) - md, md_off = _split_off(md) - pdf_n, pdf_at = _numbers(pdf) - md_n, md_at = _numbers(md) - pdf_t, pdf_where = _tight(pdf) - md_t, md_where = _tight(md) - missing_c = collections.Counter(pdf_t) - collections.Counter(md_t) - extra_c = collections.Counter(md_t) - collections.Counter(pdf_t) - spots = [] - if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만) - matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False) - for op, i1, i2, j1, j2 in matcher.get_opcodes(): - gone, added = pdf_t[i1:i2], md_t[j1:j2] - if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)): - continue - spots.append( - { - "pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None, - "md": md_where[min(j1, len(md_where) - 1)] if md_where else None, - "context": pdf_t[max(0, i1 - 8) : i1], - "pdf_only": gone, - "md_only": added, - } - ) - return { - "num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()}, - "num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()}, - "char_missing": missing_c, - "char_extra": extra_c, - "spots": spots, - "off_pdf": ( - sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off), - sorted({a for a, _ in pdf_off}), - ), - "off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off), - } - - -#: 자간 벌린 수 — md 주석 `` (규칙 3-13). -#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함. -_SPACED = re.compile(r"", re.S) -_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」") -#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것 -#: md 주석 `` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함. -_GLUED = re.compile(r"", re.S) -#: 기계검사 예외 — md 주석 `` (규칙 6장 · 머리 바로 아래 한 줄). -#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임. -#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음. -_EXEMPT = re.compile(r"") - - -def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok): - """(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만).""" - done, bad = 0, [] - for block in mark.findall(body): - for old, new in _SPACED_PAIR.findall(block): - hits = sum(line.count(old) for _, line in pdf) - if not hits or not ok(old, new): - bad.append(old) - continue - pdf = [(at, line.replace(old, new)) for at, line in pdf] - done += hits - return pdf, done, bad - - -def join_spaced_numbers(pdf: list[tuple[int, str]], body: str): - """(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임.""" - return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight) - - -def split_glued_numbers(pdf: list[tuple[int, str]], body: str): - """(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름.""" - return _rewrite( - pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued - ) - - -def verdict(result: dict) -> str: - counts = ( - sum(v for v, _ in result["num_missing"].values()), - sum(v for v, _ in result["num_extra"].values()), - sum(result["char_missing"].values()), - sum(result["char_extra"].values()), - ) - joined, bad = result.get("spaced", (0, [])) - split, bad_glued = result.get("glued", (0, [])) - clean = not any(counts) and not bad and not bad_glued - word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") else "불통") - line = ( - f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}" - ) - glyphs, pages = result.get("off_pdf", (0, [])) - if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게 - where = f"({', '.join(map(str, pages))}쪽)" if pages else "" - line += f" · 글자층 밖 글리프 {glyphs}개{where} · 받아씀 {result.get('off_md', 0)}자" - if joined: # ③ 이 그림으로 그 수를 꼭 보게 - line += f" · 자간 벌린 수 {joined}곳" - if bad: - line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad) - if split: # ③ 이 그림으로 칸마다 값을 꼭 보게 - line += f" · 글자층 붙은 수 {split}곳" - if bad_glued: - line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued) - return line - - -def passed(line: str) -> str: - """기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」.""" - return line.startswith(("통과", "예외")) - - -def fingerprint(text: str) -> str: - """확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌.""" - lines = text.replace("\r\n", "\n").split("\n") - kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))] - return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest() - - -def set_head(text: str, field: str, value: str) -> str: - head, body = split_head(text) - head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1) - return head + "\n" + body - - -def check_file(path: Path) -> tuple[dict, str]: - """(대조 결과, 한 줄 판정) — 파일은 안 고침.""" - import pymupdf - - _, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n")) - book = book_of(path) - with pymupdf.open(book["pdf"]) as doc: - pdf = pdf_lines(doc, read_head(path), book["footer"], body) - pdf, joined, bad = join_spaced_numbers(pdf, body) - pdf, split, bad_glued = split_glued_numbers(pdf, body) - result = compare(pdf, md_lines(body)) - result["spaced"] = (joined, bad) - result["glued"] = (split, bad_glued) - result["exempt"] = bool(_EXEMPT.search(body)) - return result, verdict(result) - - -def check(path: Path) -> int: - """검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음. - - ⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음. - """ - result, line = check_file(path) - print(f"{path.name}: {line}") - for token, (count, at) in sorted(result["num_missing"].items()): - print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)") - for token, (count, at) in sorted(result["num_extra"].items()): - print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)") - for spot in result["spots"][:40]: - print( - f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}" - f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]" - ) - text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line) - head = read_head(path) - if head.get("상태") == "확정" and passed(line) and not head.get("확정지문"): - text = set_head(text, "확정지문", fingerprint(text)) - path.write_text(text, encoding="utf-8", newline="\n") - return 0 if passed(line) else 1 - # ── pages · status ───────────────────────────────────────────────────── PAGES_DIR = ROOT / "tmp/pum_pages" diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 56b7e5a34..4fdda195a 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -660,208 +660,6 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} -# ── 검사 ────────────────────────────────────────────────────────────── -def _choices(row: dict, name: str, master: Master): - """이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None.""" - for spec in row.get("입력", []): - if spec["이름"] == name: - return spec.get("고르기") - for step in row.get("중간", []): - if step["이름"] == name: - node = parse(step["식"]) - if node[0] == "field" and node[1][0] == "ref": - out = set() - for ref in _expand(row, node[1][1], master) or []: - try: - out.add(str(master.get(ref)["값"][node[2]])) - except (FormulaError, KeyError, TypeError): - pass - return sorted(out) - return None - - -def _expand(row: dict, template: str, master: Master): - """원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None.""" - holes = _HOLE.findall(template) - options = [_choices(row, h, master) for h in holes] - if any(o is None for o in options): - return None - return [ - fill(template, {h: v for h, v in zip(holes, combo)}) - for combo in itertools.product(*options) - ] - - -def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set): - for part in walk(node): - kind = part[0] - if kind == "name" and part[1] not in known: - out.append(f"{where} · 모르는 이름 「{part[1]}」") - if kind in ("ref", "find", "logic"): - for hole in _HOLE.findall(part[1]): - if hole not in known: - out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님") - if kind == "ref": - refs = _expand(row, part[1], master) or [] - for ref in refs: - try: - link = master.get(ref).get("연결") - except FormulaError as e: - out.append(f"{where} · {e}") - continue - if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known: - out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요") - if ( - isinstance(link, dict) - and link.get("계약종별") == "입력" - and TARIFF not in known - ): - out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요") - if kind == "field" and part[1][0] == "ref": - for ref in _expand(row, part[1][1], master) or []: - try: - value = master.get(ref).get("값") - except FormulaError: - continue - if not isinstance(value, dict) or part[2] not in value: - out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음") - if kind == "find": - if mk.id_of(part[1]) not in TABLE_IDS: - out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님") - continue - try: - table = master.get(part[1]) - except FormulaError as e: - out.append(f"{where} · {e}") - continue - for cond, _ in part[2]: - if cond not in table.get("조건", {}): - out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음") - if kind == "field" and part[1][0] == "find": - try: - table = master.get(part[1][1]) - except FormulaError: - continue - if part[2] not in table.get("값칸", {}): - out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음") - if kind == "logic": - calls.add(part[1]) - try: - target = master.logic(part[1]) - except FormulaError as e: - out.append(f"{where} · {e}") - continue - names = {s["이름"] for s in target.get("입력", [])} - given = {k for k, _ in part[2]} - for extra in sorted(given - names): - out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음") - for lack in sorted(names - given): - out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌") - if kind == "field" and part[1][0] == "logic": - try: - target = master.logic(part[1][1]) - except FormulaError: - continue - if "결과" in target: - out.append( - f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀" - ) - elif part[2] not in BUNDLE: - out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") - - -def check_logic(master: Master, row: dict) -> tuple[list[str], set]: - """(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력.""" - out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}" - known = {s["이름"] for s in row.get("입력", [])} - - def one(text, where, names): - try: - _check_node(row, parse(text), names, master, where, out, calls) - except FormulaError as e: - out.append(f"{where} · 식 못 읽음: {e}") - - for step in row.get("중간", []): - one(step["식"], f"{key} 중간 {step['이름']}", known) - known = known | {step["이름"]} - if "결과" in row: - one(row["결과"]["식"], f"{key} 결과", known) - for item in row.get("호표", []): - where = f"{key} 호표 {item.get('이름', '')}" - one(item["수량"], where + " 수량", known) - if item.get("종류") not in ("인력", "재료", "기계", "로직"): - out.append(f"{where} · 종류 「{item.get('종류')}」") - if item.get("비목") not in (*COST_ITEMS, None) or ( - item.get("종류") != "로직" and "비목" not in item - ): - out.append(f"{where} · 비목 「{item.get('비목')}」") - if isinstance(item["요소"], dict): # 재료 고르기 조건 - if item.get("종류") != "재료": - out.append(f"{where} · 고르기 조건은 종류 「재료」 만") - # 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄 - for value in item["요소"].values(): - for hole in _HOLE.findall(value) if isinstance(value, str) else []: - if hole not in known: - out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님") - out += check_pick(master, where + " 요소", item["요소"]) - continue - one(item["요소"], where + " 요소", known) - group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) - if item.get("종류") != "로직" and group != item.get("종류"): - out.append(f"{where} · 요소 그룹이 종류와 다름") - names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만) - for extra in row.get("덧줄", []): - one(extra["식"], f"{key} 덧줄 {extra['이름']}", names) - names = names | {extra["이름"]} - if extra.get("비목") not in COST_ITEMS: - out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") - out += _check_cut(row, key) - return out, calls - - -def _check_cut(row: dict, key: str) -> list[str]: - """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" - rule = row.get("끝수") - if rule is None or isinstance(rule, str): - return [] - if not isinstance(rule, dict): - return [f"{key} 끝수 · 묶음도 글자도 아님"] - out = [] - unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} - if unknown: - out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") - on = rule.get("대상") - if on not in ROUND_ON: - out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") - elif ("결과" in row) != (on == "결과"): - out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") - if rule.get("방법") not in ROUND_WAYS: - out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") - place = rule.get("자리", 0) - if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: - out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") - return out - - -def find_loops(graph: dict[str, set]) -> list[list[str]]: - """로직 부르기의 돌고 도는 길.""" - loops, state = [], {} - - def visit(node, path): - state[node] = 1 - for nxt in sorted(graph.get(node, ())): - if state.get(nxt) == 1: - loops.append(path[path.index(nxt) :] + [nxt]) - elif nxt not in state: - visit(nxt, path + [nxt]) - state[node] = 2 - - for node in sorted(graph): - if node not in state: - visit(node, [node]) - return loops - - # 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음 from master_material import ( # noqa: E402, F401 LINKED, @@ -878,3 +676,11 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 + _check_cut, + _check_node, + _choices, + _expand, + check_logic, + find_loops, +) diff --git a/resources/master_data/scripts/master_formula_check.py b/resources/master_data/scripts/master_formula_check.py new file mode 100644 index 000000000..25aec63f8 --- /dev/null +++ b/resources/master_data/scripts/master_formula_check.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +"""마스터 로직 식 — 검사 (`master_formula` 에서 나눔 · 밖에서는 `master_formula` 로 부름). + +`master_formula` 끝에서만 읽힘 — 먼저 읽으면 서로 부르다 막힘. +""" + +from __future__ import annotations + +import itertools +from decimal import Decimal + +import master_keys as mk +from master_formula import ( + _HOLE, + BUNDLE, + COST_ITEMS, + ROUND_ON, + ROUND_WAYS, + TABLE_IDS, + FormulaError, + fill, + parse, + walk, +) +from master_material import REGION, TARIFF, check_pick + + +# ── 검사 ────────────────────────────────────────────────────────────── +def _choices(row: dict, name: str, master: Master): + """이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None.""" + for spec in row.get("입력", []): + if spec["이름"] == name: + return spec.get("고르기") + for step in row.get("중간", []): + if step["이름"] == name: + node = parse(step["식"]) + if node[0] == "field" and node[1][0] == "ref": + out = set() + for ref in _expand(row, node[1][1], master) or []: + try: + out.add(str(master.get(ref)["값"][node[2]])) + except (FormulaError, KeyError, TypeError): + pass + return sorted(out) + return None + + +def _expand(row: dict, template: str, master: Master): + """원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None.""" + holes = _HOLE.findall(template) + options = [_choices(row, h, master) for h in holes] + if any(o is None for o in options): + return None + return [ + fill(template, {h: v for h, v in zip(holes, combo)}) + for combo in itertools.product(*options) + ] + + +def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set): + for part in walk(node): + kind = part[0] + if kind == "name" and part[1] not in known: + out.append(f"{where} · 모르는 이름 「{part[1]}」") + if kind in ("ref", "find", "logic"): + for hole in _HOLE.findall(part[1]): + if hole not in known: + out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님") + if kind == "ref": + refs = _expand(row, part[1], master) or [] + for ref in refs: + try: + link = master.get(ref).get("연결") + except FormulaError as e: + out.append(f"{where} · {e}") + continue + if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known: + out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요") + if ( + isinstance(link, dict) + and link.get("계약종별") == "입력" + and TARIFF not in known + ): + out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요") + if kind == "field" and part[1][0] == "ref": + for ref in _expand(row, part[1][1], master) or []: + try: + value = master.get(ref).get("값") + except FormulaError: + continue + if not isinstance(value, dict) or part[2] not in value: + out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음") + if kind == "find": + if mk.id_of(part[1]) not in TABLE_IDS: + out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님") + continue + try: + table = master.get(part[1]) + except FormulaError as e: + out.append(f"{where} · {e}") + continue + for cond, _ in part[2]: + if cond not in table.get("조건", {}): + out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음") + if kind == "field" and part[1][0] == "find": + try: + table = master.get(part[1][1]) + except FormulaError: + continue + if part[2] not in table.get("값칸", {}): + out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음") + if kind == "logic": + calls.add(part[1]) + try: + target = master.logic(part[1]) + except FormulaError as e: + out.append(f"{where} · {e}") + continue + names = {s["이름"] for s in target.get("입력", [])} + given = {k for k, _ in part[2]} + for extra in sorted(given - names): + out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음") + for lack in sorted(names - given): + out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌") + if kind == "field" and part[1][0] == "logic": + try: + target = master.logic(part[1][1]) + except FormulaError: + continue + if "결과" in target: + out.append( + f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀" + ) + elif part[2] not in BUNDLE: + out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") + + +def check_logic(master: Master, row: dict) -> tuple[list[str], set]: + """(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력.""" + out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}" + known = {s["이름"] for s in row.get("입력", [])} + + def one(text, where, names): + try: + _check_node(row, parse(text), names, master, where, out, calls) + except FormulaError as e: + out.append(f"{where} · 식 못 읽음: {e}") + + for step in row.get("중간", []): + one(step["식"], f"{key} 중간 {step['이름']}", known) + known = known | {step["이름"]} + if "결과" in row: + one(row["결과"]["식"], f"{key} 결과", known) + for item in row.get("호표", []): + where = f"{key} 호표 {item.get('이름', '')}" + one(item["수량"], where + " 수량", known) + if item.get("종류") not in ("인력", "재료", "기계", "로직"): + out.append(f"{where} · 종류 「{item.get('종류')}」") + if item.get("비목") not in (*COST_ITEMS, None) or ( + item.get("종류") != "로직" and "비목" not in item + ): + out.append(f"{where} · 비목 「{item.get('비목')}」") + if isinstance(item["요소"], dict): # 재료 고르기 조건 + if item.get("종류") != "재료": + out.append(f"{where} · 고르기 조건은 종류 「재료」 만") + # 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄 + for value in item["요소"].values(): + for hole in _HOLE.findall(value) if isinstance(value, str) else []: + if hole not in known: + out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님") + out += check_pick(master, where + " 요소", item["요소"]) + continue + one(item["요소"], where + " 요소", known) + group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) + if item.get("종류") != "로직" and group != item.get("종류"): + out.append(f"{where} · 요소 그룹이 종류와 다름") + names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만) + for extra in row.get("덧줄", []): + one(extra["식"], f"{key} 덧줄 {extra['이름']}", names) + names = names | {extra["이름"]} + if extra.get("비목") not in COST_ITEMS: + out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") + out += _check_cut(row, key) + return out, calls + + +def _check_cut(row: dict, key: str) -> list[str]: + """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" + rule = row.get("끝수") + if rule is None or isinstance(rule, str): + return [] + if not isinstance(rule, dict): + return [f"{key} 끝수 · 묶음도 글자도 아님"] + out = [] + unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} + if unknown: + out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") + on = rule.get("대상") + if on not in ROUND_ON: + out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") + elif ("결과" in row) != (on == "결과"): + out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") + if rule.get("방법") not in ROUND_WAYS: + out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") + place = rule.get("자리", 0) + if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: + out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") + return out + + +def find_loops(graph: dict[str, set]) -> list[list[str]]: + """로직 부르기의 돌고 도는 길.""" + loops, state = [], {} + + def visit(node, path): + state[node] = 1 + for nxt in sorted(graph.get(node, ())): + if state.get(nxt) == 1: + loops.append(path[path.index(nxt) :] + [nxt]) + elif nxt not in state: + visit(nxt, path + [nxt]) + state[node] = 2 + + for node in sorted(graph): + if node not in state: + visit(node, [node]) + return loops 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/cad/polyline-points-514-3.test.ts b/resources/tester/cad/polyline-points-514-3.test.ts new file mode 100644 index 000000000..326706261 --- /dev/null +++ b/resources/tester/cad/polyline-points-514-3.test.ts @@ -0,0 +1,49 @@ +/** + * 514-3 — 가벼운 폴리선(자식 대신 꼭짓점 목록 · B07 유역도 배경선)을 CAD 가 선분으로 펼치는지. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config + */ +import { describe, expect, it } from "vitest"; +import { EntityName, type JsonEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; +import type { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity"; +import { + PolyLineEntity, + type PolyLineJsonData, +} from "../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity"; + +const light: JsonEntity = { + id: "p1", + type: EntityName.PolyLine, + lineColor: "#6b7684", + lineWidth: 1, + layerId: "b08-basin-contour", + shapeData: { points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 2 }] }, +}; + +describe("514-3 가벼운 폴리선", () => { + it("꼭짓점 n 개 = 선분 n-1 개 · 색 · 층 · id 는 부모 것", async () => { + const poly = await PolyLineEntity.fromJson(light); + expect(poly.id).toBe("p1"); + expect(poly.numberOfSegments()).toBe(2); + const lines = poly.getEntities() as LineEntity[]; + expect(lines.map((line) => [line.lineColor, line.layerId])).toEqual([ + ["#6b7684", "b08-basin-contour"], + ["#6b7684", "b08-basin-contour"], + ]); + expect(lines[1].getStartPoint().y).toBe(0); + expect(lines[1].getEndPoint().y).toBe(2); + }); + + it("다시 내보내면 옛 꼴(자식 선분) — 편집 저장 길 그대로", async () => { + const json = await (await PolyLineEntity.fromJson(light)).toJson(); + expect(json?.children?.length).toBe(2); + expect(json?.shapeData).toBeNull(); + const again = await PolyLineEntity.fromJson(json as JsonEntity); + expect(again.numberOfSegments()).toBe(2); + }); + + it("꼭짓점 하나 · 빈 값 = 선분 없음", async () => { + const one = await PolyLineEntity.fromJson({ ...light, shapeData: { points: [{ x: 0, y: 0 }] } }); + const none = await PolyLineEntity.fromJson({ ...light, shapeData: null }); + expect([one.numberOfSegments(), none.numberOfSegments()]).toEqual([0, 0]); + }); +}); 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_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 1477ec432..84f24c7ce 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -159,6 +159,8 @@ const state = { scales: [1000, 1200], default_scale: 1200, watershed_scale: 6000, + contour_intervals: [5, 10], // 512-1 유역도 등고선 간격(서버 `_state` 와 같은 칸) + default_contour_interval: 5, kinds: [ { id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 }, { id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 }, 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_347_2_drainage_live.cjs b/resources/tester/helper_347_2_drainage_live.cjs index 22dc10d7c..3ac337c17 100644 --- a/resources/tester/helper_347_2_drainage_live.cjs +++ b/resources/tester/helper_347_2_drainage_live.cjs @@ -34,12 +34,7 @@ const applied = sug.applySuggestions(pipes, suggestions); process.stdout.write( JSON.stringify({ - quietMs: live.QUIET_MS, - modes: [ - live.parseDrainageMode(null), - live.parseDrainageMode("auto"), - live.parseDrainageMode("x"), - ], + noAutoMode: live.QUIET_MS === undefined && live.parseDrainageMode === undefined, // 326-6 stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })], accept: [ live.acceptLive({ status: "success", generation: 3 }, at(3), at(3)), diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs index 9946d7eaa..eb44b6f09 100644 --- a/resources/tester/helper_349_drainage_live_flow.cjs +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -1,4 +1,4 @@ -/* 349 배수유역 자동/수동 흐름 헬퍼 — 화면 파일 `B05_Profile_UI_Drainage_Live.ts` · `_Run.ts` · +/* 349 · 326-6 배수유역 [업데이트] 흐름 헬퍼 — 화면 파일 `B05_Profile_UI_Drainage_Live.ts` · `_Run.ts` · * `_Suggest.ts` 를 그 자리에서 트랜스파일해 가짜 DOM · 시계 · fetch 로 돌리고 단계별 상태를 JSON 으로 냄. */ const fs = require("fs"); @@ -86,21 +86,10 @@ const flush = async () => { /* ---- 모듈 읽기(그 자리 트랜스파일 · 바깥 모듈은 가짜) ---- */ const state = new Map(); const detailCalls = []; -const detailAutos = []; // [자동 제안](369) 방식 — 없으면 null let records = 0; // 사진첩 한 걸음(369) -let detailStale = false; // detail-basins 가 409 낡음으로 답함(369 — 화면이 몰랐던 낡음) -let modeRow = 1; // ⚙ · [자동 제안] 창에서 고를 줄 — ⚙ 0 수동 · 1 자동 / 제안 0 전체 · 1 지정 포함 const stubs = { "@config/config_frontend": { API_BASE_URL: "/api" }, "@ui/ui_template_progress": { createProgressCircle: () => ({ root: el("progress") }) }, - // ⚙ 창 — 둘째 줄(자동) 라디오를 고름 - "@ui/ui_template_modal": { - openModal: (options) => { - const body = el("modal"); - options.mount(body, () => undefined); - body.children[modeRow].children[0].fire("change"); - }, - }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_target, key) => [key, key] }), @@ -110,10 +99,8 @@ const stubs = { writeStateRaw: (key, value) => state.set(key, value), }, "../B04_PreProcess/B04_PreProcess_Api_Fetch": { - computeDetailBasins: async (projectId, points, auto) => { + computeDetailBasins: async (projectId, points) => { detailCalls.push(points); - detailAutos.push(auto ?? null); - if (detailStale) throw new Error("배수유역 낡음 — 노선이 분석 때와 다릅니다."); return { basins: [], pipe_points: points ?? [], stale: null }; }, }, @@ -145,6 +132,7 @@ async function main() { const header = el("header"); const root = el("root"); const propose = el("button"); + const update = el("button"); header.querySelector = (selector) => (selector === ".b05-drainage__propose" ? propose : null); root.querySelector = (selector) => selector === ".b05-drainage__viewport" @@ -163,7 +151,7 @@ async function main() { const status = el("status"); const runner = createDetailRunner({ projectId: () => "p1", - analyzeButton: el("button"), + updateButton: update, status, showProgress: () => undefined, apply: (response) => { @@ -177,6 +165,7 @@ async function main() { }); live = createDrainageLive({ root, + updateButton: update, basinList: el("list"), pipeRequest: () => pipes, runner, @@ -188,13 +177,12 @@ async function main() { return runner.run(() => new Promise((resolve) => (savedAnswer = resolve)), true); }, }); - const [veil, busy] = viewport.children; - const update = veil.children[0]; - const gear = header.children[0]; + const [busy] = viewport.children; const look = () => ({ stale: live.stale, - blur: root.classList.contains("is-stale"), - veil: !veil.hidden, + pending: update.classList.contains("is-pending"), + off: update.disabled, + label: update.textContent, busy: !busy.hidden, fetches: calls.length, applyOff: live.suggest.root.children[0].children[1].disabled, @@ -221,11 +209,14 @@ async function main() { ...extra, }); - // 수동 — 꼭짓점 옮김 = 흐림 + [업데이트] · 판 번호만 바뀐 같은 노선 = 그대로 + out.start = look(); + // 꼭짓점 옮김 = 낡음 표시만 · 저절로 셈 없음 · 판 번호만 바뀐 같은 노선 = 그대로 stage(draft(1, 50)); out.moved = look(); stage(draft(2, 50)); out.sameAgain = look(); + advance(60000); + out.noAuto = look(); // [업데이트] → 요청 하나 · 묶음이 같은 입력으로 다시 돌아도 답을 받음 update.fire("click"); out.sent = { ...look(), body: calls[0].body }; @@ -244,7 +235,7 @@ async function main() { await flush(); out.applied = { points: detailCalls[0], remembered }; - // 다시 옮김 → 요청 → 그 사이 또 옮김 = 앞 요청 끊김 · 다시 흐림 + // 다시 옮김 → 요청 → 그 사이 또 옮김 = 앞 요청 끊김 · 다시 낡음 stage(draft(4, 80)); update.fire("click"); stage(draft(5, 90)); @@ -253,64 +244,36 @@ async function main() { live.suggest.root.children[0].children[1].fire("click"); await flush(); out.staleApply = detailCalls.length; - update.fire("click"); + // [업데이트] 약속 — 답이 화면에 오르면 참(B06 [>] 확인 창 뒤 이동 · 326-6) + const done = live.refresh(); answer(calls[2]); + out.promised = await done; await flush(); out.fresh = look(); + // 낡지 않아도 [업데이트] = 다시 셈(노선 · 관 그대로 · 326-6) + update.fire("click"); + out.again = { ...look(), route: calls[3].body.route }; + answer(calls[3]); + await flush(); + out.againDone = look(); - // 노선 [저장] 뒤 — 서버 답이 「낡음」 = 흐림 + [업데이트](수동) + // 노선 [저장] 뒤 — 서버 답이 「낡음」 = 낡음 표시 · 저절로 셈 없음 stage(null); await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" })); out.savedStale = { ...look(), status: status.textContent }; - - // ⚙ 자동 — 바꾸는 순간 3 s 뒤 셈 · 흐림 막 · [업데이트] 없음 - gear.fire("click"); - out.autoSwitch = look(); - advance(2999); - out.auto2999 = look(); - advance(1); - out.auto3000 = { ...look(), route: calls[3].body.route }; - answer(calls[3]); - await flush(); - out.autoDone = look(); - // 자동 · 꼭짓점 옮김 = 조용히 3 s 뒤 · 그 사이 또 옮기면 처음부터 - stage(draft(6, 120)); - advance(2000); - stage(draft(7, 130)); - advance(2000); - out.autoRestart = look(); - advance(1000); + advance(60000); + out.savedStaleNoAuto = look(); + update.fire("click"); answer(calls[4]); await flush(); - out.autoMoved = { ...look(), mode: state.get("drainage-mode") }; - // 자동 · 저장 뒤 낡음 = 3 s 뒤 조용히 - await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" })); - out.autoSavedStale = look(); - advance(3000); - answer(calls[5]); - await flush(); - out.autoSavedDone = look(); + out.savedDone = look(); - // 349-0i 되돌리기 — 자동: 끌기 → 낡음 → Ctrl+Z(초안 없어짐) = 3 s 셈 대신 저장분 - // 저장분 답에도 관경 제안 하나 — [적용] 켜짐은 낡음 · 관경/시설 제안 유무로 갈림(369) + // 되돌리기 — 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고 저장분 · 표시는 답 올 때까지 그대로 const savedOk = { basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }], pipe_points: pipes, stale: null, }; - stage(draft(20, 300)); - advance(1000); - stage(null); - out.autoUndoWaiting = { ...look(), savedLoads }; - advance(5000); - out.autoUndoNoFetch = look(); - savedAnswer(savedOk); - await flush(); - out.autoUndo = { ...look(), savedLoads }; - - // 수동: 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고 저장분 · 흐림은 답 올 때까지 - modeRow = 0; - gear.fire("click"); stage(draft(21, 310)); update.fire("click"); const draftCall = calls[calls.length - 1]; @@ -319,76 +282,17 @@ async function main() { savedAnswer(savedOk); await flush(); out.undo = { ...look(), savedLoads }; - // 저장분 자체가 낡음 = 흐림 · [업데이트] 그대로 + // 저장분 자체가 낡음 = 낡음 표시 그대로 stage(draft(22, 320)); stage(null); savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }); await flush(); - out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") }; - // 369 [자동 제안] — 낡은 동안 끔 · ① all · ② add · [배관 배치 초기화] = 관 목록 없이(세류선만) + out.undoSavedStale = { ...look(), savedLoads }; + // [자동 제안] — 낡은 동안 끔 · 새 답이면 켬 out.proposeStale = { off: propose.disabled, title: propose.title }; - const beforeStale = detailCalls.length; - propose.fire("click"); - await flush(); - out.proposeStale.calls = detailCalls.length - beforeStale; live.markFresh(); out.proposeFresh = { off: propose.disabled, title: propose.title }; - 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(); - await flush(); - out.propose = { - autos: detailAutos.slice(n0), - points: detailCalls.slice(n0).map((points) => (points === null ? null : points.length)), - remembered: remembered - remembered0, - records: records - records0, - }; - // 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시 - detailStale = true; - modeRow = 0; - const fallbackFrom = calls.length; - propose.fire("click"); - await flush(); - const fallbackCall = calls[calls.length - 1]; - out.proposeFallback = { - sent: calls.length - fallbackFrom, - auto: fallbackCall.body.auto, - status: status.textContent, - }; - answer(fallbackCall); - await flush(); - detailStale = false; - // 저장 분석이 옛 노선(서버 낡음 답) → [업데이트] 뒤 [자동 제안] = 지금 노선으로 셈 길에 방식 실음 - await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" })); - update.fire("click"); - 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, - remembered: remembered - remembered0, - records: records - records0, - }; + out.records = records; // 입력 지문 — 판 번호 · 관 차례는 안 봄 · 꼭짓점 · 관 자리(cm) · id 는 봄 const { inputKey } = load(path.join(B05, "logic", "B05_Profile_Drainage_Live.ts")); const a = { id: "a1", chainage_m: 100 }; 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_508_2_corridor_server.cjs b/resources/tester/helper_508_2_corridor_server.cjs new file mode 100644 index 000000000..479a8a426 --- /dev/null +++ b/resources/tester/helper_508_2_corridor_server.cjs @@ -0,0 +1,171 @@ +/* 508-2 ① 3D 길 모형 계산 자리 D3 = 서버 — 낡은 저장본 = 옛 모형 세움 · [3D 업데이트] = 서버 짓기 → 바꿔 끼움 · + * 같은 입력 = 다시 안 지음 · 서버 실패 = 옛 모형 그대로 · both = 지금 그대로(옛 모형 안 받음 · 브라우저 짓기). + * 서버 창구(`serverCalc`)는 같은 TS(`buildCorridor` + `serialize`)로 흉내 · 저장본 GET 은 fetch 흉내. + * 측점 꼴 = helper_345_4_corridor_worker.cjs 와 같음. 결과는 JSON 한 벌. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const state = { server: true, fail: false, calls: [], gets: [] }; +globalThis.window = globalThis; +const STUBS = { + design_calc_sites: { + calcOnServer: async (key) => key === "corridor_mesh" && state.server, + calcOnServerNow: (key) => key === "corridor_mesh" && state.server, + }, + design_calc_server: { + QUIET_MS: 30, + serverCalc: async (key, run, inputs) => { + state.calls.push({ + key, + run, + samples: inputs.detail.longitudinal.design_profiles[0].samples, + }); + if (state.fail) throw new Error("HTTP 503"); + const { detail, route_points } = inputs; + const build = buildCorridor( + detail.cross_sections, + route_points, + detail.longitudinal.design_profiles[0].samples, + ); + return { "corridor.mesh": build ? serialize(build, "srv") : null }; + }, + }, + design_pipeline_page: { reconcilePagePipeline: async () => 0 }, + b_asset_cache: { + readCachedBytes: async () => null, + writeCachedBytes: async () => undefined, + purgeAssetsWithPrefix: async () => undefined, + }, +}; +const { loadTs } = makeLoader({ + root: ROOT, + stubs: STUBS, + rewrite: (js) => js.replace(/import\.meta\.url/g, '"file:///x.js"'), +}); +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { serialize, corridorHash } = loadTs(B05("B05_Profile_UI_Corridor_Envelope")); +const corridor = loadTs(B05("B05_Profile_UI_Corridor")); + +const HALF = 3; +const ROAD_Z = 100; +function station(chainage, cx, groundZ, ratio, roadZ = ROAD_Z) { + const w = Math.abs(groundZ - roadZ) * ratio; + const pts = [ + [-40, groundZ], + [-(HALF + w), groundZ], + [-HALF, roadZ], + [HALF, roadZ], + [HALF + w, groundZ], + [40, groundZ], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: cx, + center_y: 0, + center_z: groundZ, + frame: { left_xy: [0, 1] }, + samples, + design: { + design_elevation_m: roadZ, + section_mode: groundZ < roadZ ? "both_fill" : "both_cut", + road_edges: { + left: { offset_m: HALF, elevation_m: roadZ }, + right: { offset_m: -HALF, elevation_m: roadZ }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: roadZ }, + right: { offset_m: -2.5, elevation_m: roadZ }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} +const detailOf = (roadZ) => ({ + cross_sections: [ + station(0, 0, 96, 1.5, roadZ), + station(20, 20, 104, 1.0, roadZ), + station(40, 40, 96, 1.5, roadZ), + ], + longitudinal: { design_profiles: [] }, +}); +const route = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; +const samplesOf = (z) => [0, 20, 40].map((c) => ({ chainage_m: c, elevation_m: z })); +const oldDetail = detailOf(100); +const oldEnvelope = serialize( + buildCorridor(oldDetail.cross_sections, route, samplesOf(100)), + corridorHash(oldDetail, route), +); + +// 저장본 GET 흉내 — 열쇠가 다르면 stale · 열쇠 없으면 저장본 그대로(옛 모형) +globalThis.fetch = async (url) => { + state.gets.push(String(url)); + const hash = new URL(String(url)).searchParams.get("hash"); + const body = + hash && hash !== oldEnvelope.hash ? { status: "stale", hash: oldEnvelope.hash } : oldEnvelope; + return { ok: true, text: async () => JSON.stringify(body), json: async () => body }; +}; + +const viewer = () => { + const shown = []; + return { shown, setCorridor: (build) => shown.push(build ? build.ribbons.length : null) }; +}; +const zOf = (build) => build.ribbons[0].positions[2]; + +(async () => { + const out = {}; + const newDetail = detailOf(101); // 계획고 1 m 올린 초안 — 열쇠 다름 + // ── D3 = 서버 ── + let v = viewer(); + let lastBuild = null; + const spy = { setCorridor: (build) => ((lastBuild = build), v.setCorridor(build)) }; + out.serverFresh = await corridor.loadCorridorIfFresh(spy, "p", 7, newDetail, route); + out.serverEntryShown = v.shown.slice(); + out.serverEntryOldZ = lastBuild ? zOf(lastBuild) : null; + out.serverStaleGet = state.gets.some((url) => !url.includes("hash=")); + out.refreshOk = await corridor.refreshCorridor(spy, "p", 7, newDetail, route, samplesOf(101)); + out.refreshCalls = state.calls.length; + out.refreshSamples = state.calls[0]?.samples?.[0]?.elevation_m ?? null; + out.refreshNewZ = lastBuild ? zOf(lastBuild) : null; + out.refreshShown = v.shown.slice(); + out.againOk = await corridor.refreshCorridor(spy, "p", 7, newDetail, route, samplesOf(101)); + out.againCalls = state.calls.length; // 같은 입력 = 다시 안 지음 + state.fail = true; + const before = v.shown.length; + out.failOk = await corridor.refreshCorridor(spy, "p", 7, detailOf(102), route, samplesOf(102)); + out.failSetCalls = v.shown.length - before; // 0 = 옛 모형 그대로 + out.failZ = zOf(lastBuild); + // ── D3 = both(기본) — 지금 그대로 ── + state.server = false; + state.fail = false; + state.calls = []; + state.gets = []; + v = viewer(); + out.bothFresh = await corridor.loadCorridorIfFresh(v, "p", 8, detailOf(103), route); + out.bothEntryShown = v.shown.slice(); + out.bothStaleGet = state.gets.some((url) => !url.includes("hash=")); + out.bothRefreshOk = await corridor.refreshCorridor( + v, + "p", + 8, + detailOf(103), + route, + samplesOf(103), + ); + out.bothCalls = state.calls.length; + out.bothShown = v.shown.slice(); + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_508_2_sheet_follow.cjs b/resources/tester/helper_508_2_sheet_follow.cjs new file mode 100644 index 000000000..1962a0b45 --- /dev/null +++ b/resources/tester/helper_508_2_sheet_follow.cjs @@ -0,0 +1,102 @@ +/* 508-2 ③ 노선 편집 — 놓은 뒤(잠잠) 노선 띠가 잘라 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아 바꿔 끼움. + * `fetchDrainageLayers` 는 가짜(부른 범위 기록 · 서버처럼 bbox + 600 m 를 clip_lonlat 로) · 잠잠 = 20 ms. + * 메타 = 509-1 헬퍼와 같음(1 m = 0.00001 도). 결과는 JSON 한 벌. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = globalThis; +const META = { + x_min: 0, + y_min: 0, + width_meters: 10000, + height_meters: 10000, + lon_min: 127, + lon_max: 127.1, + lat_min: 37, + lat_max: 37.1, +}; +const lon = (x) => 127 + x * 1e-5; +const lat = (y) => 37 + y * 1e-5; +const clipOf = ([x0, y0, x1, y1]) => [lon(x0 - 600), lat(y0 - 600), lon(x1 + 600), lat(y1 + 600)]; +const calls = []; +const STUBS = { + b_asset_cache: {}, + B04_PreProcess_UI_MapRender: { createNormalizer: () => ({}) }, + B04_PreProcess_UI_MapRender_Prepare: { prepareLayer: () => ({ features: [] }) }, + B05_Profile_UI_Drainage_Parts: { + fetchDrainageLayers: async (_projectId, _progress, scope, bbox) => { + calls.push({ scope, bbox: bbox ?? null }); + const rect = bbox.split(",").map(Number); + return { meta: META, layers: [["도엽_등고선", { features: [], clip_lonlat: clipOf(rect) }]] }; + }, + }, + B05_Profile_UI_RouteEdit_Render: { CONTOUR_BAND_M: 300 }, + design_calc_server: { QUIET_MS: 20 }, +}; +const { load } = makeLoader({ root: ROOT, stubs: STUBS }); +const contour = load("B05_Profile", "B05_Profile_UI_RouteEdit_Contour.ts"); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let line = [ + [5000, 5000], + [5500, 5200], +]; +// 저장 노선으로 자른 첫 도엽(509-1 서버와 같은 범위) +let sheets = { + meta: META, + layers: [["도엽_등고선", { features: [], clip_lonlat: clipOf([5000, 5000, 5500, 5200]) }]], + line: () => line, +}; +const swaps = []; + +(async () => { + const out = {}; + out.bbox = contour.routeBboxParam([ + [5012.3, 4987.1], + [5499.9, 5200.0], + ]); + const follow = contour.createSheetFollow( + "p", + () => sheets, + async (next) => { + swaps.push(next.layers[0][1].clip_lonlat); + sheets = next; + }, + ); + // 범위 안 끌기 — 안 받음 + line = line.map(([x, y]) => [x + 250, y - 250]); + follow.poke(); + await sleep(60); + out.insideCalls = calls.length; + // 범위 밖으로 크게 끌며 여러 걸음 — 잠잠 뒤 한 번만 받음 · 지금 노선 범위 + for (const dx of [400, 800, 1200]) { + line = [ + [5000 + dx, 5000], + [5500 + dx, 5200], + ]; + follow.poke(); + await sleep(5); + } + out.beforeQuiet = calls.length; + await sleep(60); + out.outsideCalls = calls.slice(); + out.swaps = swaps.length; + out.swappedCovers = contour.sheetCoversBand(sheets.layers[0][1], META, line, 300); + out.lineKept = sheets.line === undefined ? false : sheets.line() === line; + // 새 범위 안에서 다시 놓음 — 안 받음 + line = line.map(([x, y]) => [x + 100, y]); + follow.poke(); + await sleep(60); + out.againCalls = calls.length; + // 여는 때(coveringSheets) — 덮으면 같은 것 그대로 + out.coveringSame = (await contour.coveringSheets("p", sheets)) === sheets; + // 떠난 뒤 — 안 받음 + line = line.map(([x, y]) => [x + 3000, y]); + follow.poke(); + follow.dispose(); + await sleep(60); + out.disposedCalls = calls.length; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_510_propose.cjs b/resources/tester/helper_510_propose.cjs new file mode 100644 index 000000000..539268144 --- /dev/null +++ b/resources/tester/helper_510_propose.cjs @@ -0,0 +1,97 @@ +/* 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])), + }); + 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, + noCircles: propose.candidates === undefined, // 점선 원 걷음(326-6) + }; + 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 }; + + // 관 속성 보관소 — 응답 라벨이 다음 요청에 실림(라벨만 있는 맨 배관도 보관) + 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/helper_513_1_plan_structure_menu.cjs b/resources/tester/helper_513_1_plan_structure_menu.cjs new file mode 100644 index 000000000..b45259649 --- /dev/null +++ b/resources/tester/helper_513_1_plan_structure_menu.cjs @@ -0,0 +1,130 @@ +/* 513-1 B05 평면 우클릭 구조물 메뉴(요구 327-1) — `_Profile_Structures` · `_RouteEdit_StructureMenu` 를 Node 로 + * 돌려 JSON 한 줄(`test_513_1_plan_structure_menu.py` 가 판정). 메뉴 판 · 측점 판정은 가짜 · 항목 짓기 · 노선 셈은 진짜. + * node helper_513_1_plan_structure_menu.cjs + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); +const opens = []; +const calls = []; + +/** 가짜 메뉴 — 연 자리 · 항목만 적음. */ +const ui = () => ({ + createMapContextMenu: () => ({ + element: { parentElement: null }, + open: (x, y, items) => opens.push({ x, y, items }), + close() {}, + contains: () => false, + }), +}); +const STUBS = { + "../A00_Common/touch_bridge": { pickPx: (v) => v }, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irr:${id}`, + isPipeStation: (station) => station.origin === "pipe", + }, + "./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_UI_RouteEdit_StructureMarks": { planMarkAt: () => null }, // 마커 없음(514-1 몫) +}; +const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS, ui }); +const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts")); +const plan = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMenu.ts")); + +/** 듣기만 적는 가짜 요소. */ +function fakeElement(connected = true) { + const listeners = {}; + return { + isConnected: connected, + listeners, + addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), + getBoundingClientRect: () => ({ left: 0, top: 0 }), + append(child) { + child.parentElement = this; + }, + parentElement: null, + fire(type, clientX, clientY) { + let prevented = false; + (listeners[type] || []).forEach((fn) => + fn({ clientX, clientY, target: null, preventDefault: () => (prevented = true) }), + ); + return prevented; + }, + }; +} + +// 노선 0 → 100 m 직선 · 화면 2 px/m · 배관 측점 40 m · 추가 측점 70 m +const PLUS = loadTs( + path.join(__dirname, "..", "..", "common_util", "common_util_structure_station.ts"), +).PLUS_OWNER_PREFIX; +const stations = [ + { id: "pipe-1", chainage_m: 40, origin: "pipe" }, + { id: `${PLUS}x`, chainage_m: 70 }, +]; +const options = { + stations, + x: (m) => m * 2, + chainageAt: (px) => px / 2, + maxChainageM: 100, + onRemove: (station) => calls.push(["remove", station.id]), + onRemovePlus: (m) => calls.push(["removePlus", m]), + onAddPipe: (m) => calls.push(["addPipe", m]), + structureGroups: () => [{ label: "A군", types: [{ type: { name: "배수관", type_id: "pipe" } }] }], + onAddStructureType: (m, typeId) => calls.push(["addType", m, typeId]), +}; +const labels = (items) => + items.map((item) => + Array.isArray(item) ? item[0] : [item.label, item.children.map((c) => c[0])], + ); +const pickFirst = (items) => { + const first = items[0]; + (Array.isArray(first) ? first[1] : first.children[0][1])(); +}; + +const out = {}; +out.liveBefore = shared.liveStructureMenu() === null; + +// 종단 그래프 — 빈 자리 · 배관 위 +const host = fakeElement(); +shared.mountStructureMenu(host, options); +host.fire("contextmenu", 120, 5); // 60 m +out.profileEmpty = labels(opens.at(-1).items); +pickFirst(opens.at(-1).items); +host.fire("contextmenu", 81, 5); // 40.5 m — 배관 +out.profilePipe = labels(opens.at(-1).items); + +// 평면 — 같은 재료 +const wrap = fakeElement(); +const canvas = fakeElement(); +canvas.parentElement = wrap; +const line = [ + [0, 0], + [100, 0], +]; +const params = { + canvas, + toScreen: (v) => [v[0] * 2, v[1] * 2], + unrotate: (x, y) => [x, y], + state: () => ({ plannedLine: line, planned: line }), +}; +const nodeAt = (px, py) => (Math.hypot(px, py) <= 6 ? 0 : Math.hypot(px - 200, py) <= 6 ? 1 : -1); +plan.bindPlanStructureMenu(params, nodeAt); +const planAt = (x, y) => { + const before = opens.length; + canvas.fire("contextmenu", x, y); + return opens.length > before ? labels(opens.at(-1).items) : null; +}; +out.planEmpty = planAt(120, 8); // 60 m · 노선에서 8 px +pickFirst(opens.at(-1).items); +out.planPipe = planAt(82, 6); // 40 m 배관 근처 +pickFirst(opens.at(-1).items); +out.planPlus = planAt(141, -4); // 70 m 추가 측점 +out.planMenuAt = [opens.at(-1).x, opens.at(-1).y]; // 누른 화면 자리(캔버스 그릇 기준) +pickFirst(opens.at(-1).items); +out.planFar = planAt(120, 40); // 노선에서 40 px +out.planNode = planAt(2, 1); // 시점 노드 위 = 노드 삭제 몫 +host.isConnected = false; // 종단 그래프가 떠남 +out.planGone = planAt(120, 8); +out.calls = calls; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_513_3_remove_station_owner.cjs b/resources/tester/helper_513_3_remove_station_owner.cjs new file mode 100644 index 000000000..c1fdc5d9d --- /dev/null +++ b/resources/tester/helper_513_3_remove_station_owner.cjs @@ -0,0 +1,38 @@ +/* 513-3 우클릭 「구조물 삭제」 관 아닌 구조물도(요구 327-1) — `design_pipe_ids.removeStationOwner` 를 Node 로 + * 돌려 JSON 한 줄(`test_513_3_remove_station_owner.py` 가 판정). 관 장부 · 고름은 가짜 · 주인 가르기는 진짜. + * node helper_513_3_remove_station_owner.cjs + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const BOOK = { next: 4, ids: [{ id: "pipe-s3", at: 55 }] }; +const STUBS = { + "../b_page_state": { readState: () => BOOK, writeState() {} }, + "./design_selection": { + PIPE_MATCH_M: 0.51, + isNear: () => false, + nearestWithin: () => null, + readSelection: () => null, + selectDesign() {}, + }, + common_util_pick_tag: { setPipeTagSource() {} }, +}; +const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS }); +const ids = loadTs(path.join(__dirname, "..", "..", "A00_Common", "design", "design_pipe_ids.ts")); +ids.usePipeIds("p1"); + +const calls = []; +const remove = { + pipe: (m) => calls.push(["pipe", m]), + structure: (id) => calls.push(["structure", id]), +}; +const cases = [ + [{ id: "pipe-s1", chainage_m: 40 }, true], // 관 기준 측점 + [{ id: "wall-7", chainage_m: 80 }, false], // 구조물 기준 측점 + [{ id: "wall-8:start", chainage_m: 100 }, false], // 구간 구조물 시작 + [{ id: "wall-8:end", chainage_m: 130 }, false], // 구간 구조물 끝 + [{ id: "pipe-s3:end", chainage_m: 60 }, false], // 관 구간 끝 = 장부 자리 + [{ id: "pipe-12.50:start", chainage_m: 10 }, false], // 옛 자리 이름 관 +]; +for (const [station, pipe] of cases) ids.removeStationOwner(station, pipe, remove); +process.stdout.write(JSON.stringify({ calls })); diff --git a/resources/tester/helper_514_1_mark_remove.cjs b/resources/tester/helper_514_1_mark_remove.cjs new file mode 100644 index 000000000..d1220e539 --- /dev/null +++ b/resources/tester/helper_514_1_mark_remove.cjs @@ -0,0 +1,119 @@ +/* 514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1) — `design_pipe_ids.removeMarkOwner` · + * `_RouteEdit_StructureMenu` 를 Node 로 돌려 JSON 한 줄(`test_514_1_mark_remove.py` 가 판정). + * 관 장부 · 메뉴 판 · 마커 집기는 가짜 · 주인 가르기 · 평면 항목 짓기는 진짜. + * node helper_514_1_mark_remove.cjs + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const B05 = path.join(ROOT, "B05_Profile"); +const opens = []; +const calls = []; +const ui = () => ({ + createMapContextMenu: () => ({ + element: { parentElement: null }, + open: (x, y, items) => opens.push({ x, y, items }), + close() {}, + contains: () => false, + }), +}); +// 평면 마커 = 측점선 없는 대피로 20 m(화면 40 px) +const MARK = { id: "evac-1", chainageM: 20 }; +const STUBS = { + "../b_page_state": { + readState: () => ({ next: 4, ids: [{ id: "pipe-s3", at: 55 }] }), + writeState() {}, + }, + "./design_selection": { + PIPE_MATCH_M: 0.51, + isNear: () => false, + nearestWithin: () => null, + readSelection: () => null, + selectDesign() {}, + }, + common_util_pick_tag: { setPipeTagSource() {} }, + "../A00_Common/touch_bridge": { pickPx: (v) => v }, + "./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `irr:${id}` }, + "./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_UI_RouteEdit_StructureMarks": { + planMarkAt: (_c, _l, toScreen, px, py) => { + const [x, y] = toScreen([MARK.chainageM, 0]); + const distance = Math.hypot(x - px, y - py); + return distance <= 10 ? { mark: MARK, distance } : null; + }, + }, +}; +const { loadTs } = makeLoader({ root: ROOT, stubs: STUBS, ui }); +const ids = loadTs(path.join(ROOT, "A00_Common", "design", "design_pipe_ids.ts")); +ids.usePipeIds("p1"); +const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts")); +const plan = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMenu.ts")); + +const out = {}; +// 알약 주인 가르기 — 관 세션 id · 옛 자리 이름 관 = 관 지점 · 그 밖 = 구조물 id +const remove = { + pipe: (m) => calls.push(["pipe", m]), + structure: (id) => calls.push(["structure", id]), +}; +["pipe-s3", "pipe-12.50", "evac-1", "cross-2"].forEach((id) => ids.removeMarkOwner(id, remove)); +out.owners = calls.splice(0); + +function fakeElement() { + const listeners = {}; + return { + isConnected: true, + addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), + getBoundingClientRect: () => ({ left: 0, top: 0 }), + append(child) { + child.parentElement = this; + }, + parentElement: null, + fire(type, clientX, clientY) { + (listeners[type] || []).forEach((fn) => + fn({ clientX, clientY, target: null, preventDefault() {} }), + ); + }, + }; +} +// 종단 그래프 = 재료(관 측점 40 m 하나) +const host = fakeElement(); +shared.mountStructureMenu(host, { + stations: [{ id: "pipe-1", chainage_m: 40, origin: "pipe" }], + x: (m) => m * 2, + chainageAt: (px) => px / 2, + maxChainageM: 100, + onRemove: (station) => calls.push(["remove", station.id]), + onRemoveMark: (id) => calls.push(["removeMark", id]), + onAddPipe: () => {}, + structureGroups: () => [{ label: "A군", types: [{ type: { name: "배수관", type_id: "pipe" } }] }], + onAddStructureType: () => {}, +}); +const canvas = fakeElement(); +canvas.parentElement = fakeElement(); +const line = [ + [0, 0], + [100, 0], +]; +plan.bindPlanStructureMenu( + { + canvas, + toScreen: (v) => [v[0] * 2, v[1] * 2], + unrotate: (x, y) => [x, y], + state: () => ({ plannedLine: line, planned: line }), + }, + () => -1, +); +const planAt = (x, y) => { + canvas.fire("contextmenu", x, y); + const items = opens.at(-1).items; + return items.map((item) => (Array.isArray(item) ? item[0] : item.label)); +}; +out.planMark = planAt(41, 3); // 대피로 마커 +opens.at(-1).items[0][1](); +out.planPipe = planAt(81, 2); // 관 측점선 = 측점 먼저 +opens.at(-1).items[0][1](); +out.planEmpty = planAt(140, 2); // 빈 자리 = 추가 메뉴 +out.calls = calls; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_514_2_stale_flow.cjs b/resources/tester/helper_514_2_stale_flow.cjs new file mode 100644 index 000000000..cb34fa0cf --- /dev/null +++ b/resources/tester/helper_514_2_stale_flow.cjs @@ -0,0 +1,62 @@ +/* 514-2 낡은 유량 가림 헬퍼 — 진짜 `design_structure_ford_calc.ts` 를 Node 로 돌려 JSON 한 벌로(요구 328-3). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; +const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); + +globalThis.document = { createElement: () => ({ textContent: "", className: "" }) }; +const state = { ford: [{ width: "10", height: "" }] }; +let refreshed = 0; +const form = { + state: () => state, + setValue: (c, i, v) => (state[c][0][i] = v), + refresh: () => (refreshed += 1), +}; +const sync = calc.overflowSync( + () => form, + "ford", + () => 2.54, + () => undefined, +); +const hint = () => sync.fields({ id: "ford" })[0].textContent; +const edit = (item, value) => { + state.ford[0][item] = value; + sync.onChange({ id: "ford" }, { id: item }); + return state.ford[0].height; +}; + +const fresh = { hint: hint(), height: edit("width", "10") }; +calc.setDrainageStale(true); +state.ford[0].height = ""; +const stale = { refreshed, hint: hint(), height: edit("width", "8") }; +sync.arm(true); +sync.fill(); +stale.afterFill = state.ford[0].height; +let before = refreshed; +calc.setDrainageStale(true); // 같은 값 = 다시 안 그림 +stale.repaintSame = refreshed - before; +before = refreshed; +calc.setDrainageStale(false); +const back = { repaint: refreshed - before, hint: hint() }; +sync.fill(); +back.afterFill = state.ford[0].height; + +process.stdout.write(JSON.stringify({ fresh, stale, back })); diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index aa9e8abba..e4c0b724e 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -67,8 +67,7 @@ let stations = [ ]; let footprint = null; let route = { v: 1 }; -// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내 -// 393-1 — 측점 눈금이 연 판은 기억 안 함(remember 거짓) +// 패널 묶음 흉내 — 513-2 고름은 판을 안 엶(now 그대로 null) const panels = { now: null, current: () => panels.now, @@ -186,7 +185,7 @@ async function run() { out.afterEdit = cross.watched(); out.cardsAfterEdit = cards.slice(); - // 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄 + // 평면 눈금 클릭 = 공용 고름에 적음 · 접힌 판은 그대로(513-2) const heard = []; pick.subscribeSelection("p-1", (selection, from) => heard.push({ at: selection.chainageM, from }), @@ -201,7 +200,7 @@ async function run() { watched: cross.watched(), }; - // 다른 보기 고름 — 일반측점 = 그 측점(판 폄) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음 + // 다른 보기 고름 — 일반측점 = 그 측점(판 그대로) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음 panels.now = null; pick.selectDesign("p-1", { chainageM: 60, structureId: null }, { from: "b06-card" }); out.otherGrid = { watched: cross.watched(), panel: panels.now }; 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_116_right_click_add.py b/resources/tester/test_116_right_click_add.py index 5a36e3b40..179fa1de6 100644 --- a/resources/tester/test_116_right_click_add.py +++ b/resources/tester/test_116_right_click_add.py @@ -81,12 +81,11 @@ def _add_at_body() -> str: return src[start : src.index(" moveById(", start)] -def test_addAt_은_새로_놓기_패널_폄_후_판정(): +def test_addAt_은_새로_놓기_후_판정_카드_열림_그대로(): body = _add_at_body() + assert "is-collapsed" not in body # 「구조물 배치」 카드 열림 · 닫힘 안 건드림(513-2) order = [ body.index("startNew(type.group, typeId)"), - body.index("callbacks.onReveal?.()"), - body.index('root.classList.remove("is-collapsed")'), body.index("addsAtOnce(optionInputs)"), body.index("finishAdd("), body.index("commitTempPipe(true)"), # 폼만 갈래 — A군 임시 배치 그대로(우클릭만 · 291-1) 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_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index 9cd4645bc..5d57a1643 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -16,7 +16,7 @@ SPOTS = { "B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'], "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"], "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'], - "ui_template/ui_template_locale_b2.ts": [ + "ui_template/ui_template_locale_b5.ts": [ # B08~B11 칸 = b5(512-2 나눔) "수량 원천: 수기 입력(직접비)", "수량 원천: 수기 입력 공종", ], 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_179_2_ditch_row_compact.py b/resources/tester/test_179_2_ditch_row_compact.py index c1c0866ba..1a175923d 100644 --- a/resources/tester/test_179_2_ditch_row_compact.py +++ b/resources/tester/test_179_2_ditch_row_compact.py @@ -12,7 +12,7 @@ def _src(rel: str) -> str: def test_막힌_묶음은_숨김(): - css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + _src("B06_Section/B06_Section_UI_Style_Cross_Chart.css") body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1) assert "display: none;" in body assert "opacity" not in body 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_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index a29f2c90f..de24be33b 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -107,7 +107,9 @@ def test_다시_나눔은_세션_초안으로_계산만_저장_안_함(): load = _body(PANEL, "const loadSaved = ", "const analyze = ") assert "readPendingPipes(projectId as string)" in load assert "computeDetailBasins(projectId as string, pending)" in load - analyze = _body(PANEL, "const analyze = ", "analyzeButton.addEventListener") + analyze = _body( + PANEL, "const analyze = ", "const live = createDrainageLive(" + ) # 326-6 [유역 재분할] 걷음 assert "computeDetailBasins(" in analyze run = RUN[RUN.index("async run(") :] assert "saveDetailPipePoints" not in run + load + analyze diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index b835aaa8e..ab8693852 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") @@ -234,10 +249,10 @@ def test_연결_자리_cross_격자_그대로(): assert "calc_sites" not in page and "calcOnServer" not in page worker = _text("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") body = worker.split("export async function buildCorridorOffThread(")[1] - assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( - "getWorker()" - ) - assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts") + # 508-2 — D3 = 서버면 워커 대신 서버 창구가 지음(옛 기대 = 안 지음 null) + assert 'if (await calcOnServer("corridor_mesh"))' in body + assert body.index("return buildOnServer(") < body.index("getWorker()") + 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_318_cross_card.py b/resources/tester/test_318_cross_card.py index 2ba716448..65300ab7a 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -182,7 +182,9 @@ def _rule(css: str, selector: str) -> str: def test_css_layout() -> None: - css = _src("B06_Section", "B06_Section_UI_Style_Cross.css") + css = _src("B06_Section", "B06_Section_UI_Style_Cross.css") + _src( + "B06_Section", "B06_Section_UI_Style_Cross_Chart.css" + ) assert "align-items: start" in _rule(css, ".b06-section__grid") # 카드 = 내용 높이 rock = _rule(css, ".b06-cross-card__rockb") assert "position: absolute" not in rock and "translateX" not in rock # 왼쪽 맞춤 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_4_corridor_worker.py b/resources/tester/test_345_4_corridor_worker.py index 4a378e427..6c039d498 100644 --- a/resources/tester/test_345_4_corridor_worker.py +++ b/resources/tester/test_345_4_corridor_worker.py @@ -62,9 +62,10 @@ def test_저장본_먼저_초안만_워커(): def test_모달_열_때_낡았으면_다시_짓기(): view3d = _read("A00_Common/design/design_view_profile_3d.ts") - assert "if (state.corridorPending) void refreshOnce();" in view3d + # 508-2 — both 면 열 때 다시 짓기 그대로 · D3 = 서버면 [3D 업데이트]만(요구 324) + assert "void (server || refreshOnce())" in view3d and "if (state.corridorPending)" in view3d assert "onCorridorRefresh: () => refreshOnce()," in view3d - assert "deps.sections().setCorridorPending(false);" in view3d + assert "deps.sections().setCorridorPending(!fresh);" in view3d # 서버 짓기 실패 = 늦음 sections = _read("A00_Common/design/design_view_profile_sections.ts") assert ".then((fresh) => setCorridorPending(!fresh));" in sections assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로 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_347_2_drainage_live_ui.py b/resources/tester/test_347_2_drainage_live_ui.py index ef94e14fd..d2678aec8 100644 --- a/resources/tester/test_347_2_drainage_live_ui.py +++ b/resources/tester/test_347_2_drainage_live_ui.py @@ -1,6 +1,6 @@ """347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일). -- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음. +- 자동 모드 없음(요구 326-6 · [업데이트]만) · 노선 초안이 있을 때만 낡음. - 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림. - 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) · [적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음). @@ -30,8 +30,7 @@ def r(): def test_mode_and_stale(r): - assert r["quietMs"] == 3000 - assert r["modes"] == ["manual", "auto", "manual"] + assert r["noAutoMode"] assert r["stale"] == [False, True] 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..b74d18a13 100644 --- a/resources/tester/test_349_drainage_live_flow.py +++ b/resources/tester/test_349_drainage_live_flow.py @@ -1,13 +1,12 @@ -"""349 배수유역 자동/수동 흐름 — 화면 파일(`B05_Profile_UI_Drainage_Live` · `_Run` · `_Suggest`)을 +"""349 · 326-6 배수유역 [업데이트] 흐름 — 화면 파일(`B05_Profile_UI_Drainage_Live` · `_Run` · `_Suggest`)을 가짜 DOM · 시계 · fetch 로 돌림(`helper_349_drainage_live_flow.cjs`). -- 수동: 꼭짓점 옮김 = 흐림 + [업데이트] · [업데이트] 뒤 묶음이 같은 노선 · 관으로 다시 돌아도 답을 받음 - (입력 지문) · 답이 오면 흐림 · 「계산 중」 사라짐 · 제안 「제안 N건」 · [적용]. -- 요청 중 또 옮기면 앞 요청 끊고 다시 흐림. -- 노선 [저장] 뒤 서버 답이 「낡음」 = 수동은 흐림 + [업데이트] · 자동은 3 s 뒤 조용히. -- ⚙ 자동: 흐림 없음 · 3 s 조용한 뒤 셈 · 그 사이 또 옮기면 처음부터 · [업데이트] 막 없음(349-0e). -- 낡은 동안 제안 [적용] 끔(두 모드) · 새 답이 오면 다시 켬(349-0e). -- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음 · 자동도 3 s 셈 대신(349-0i). +- 꼭짓점 옮김 = 옛 그림 그대로 · [업데이트] 단추에 낡음 점만 · 저절로 셈 없음(시계 60 s 흘려도). +- [업데이트] = 뒤에서 조용히 셈(단추만 「계산 중…」 · 구석 표시) · 묶음이 같은 입력으로 다시 돌아도 답을 받음. +- 낡지 않아도 [업데이트] = 다시 셈(326-6) · 약속은 답이 오르면 참(B06 [>] 확인 창 뒤 이동). +- 요청 중 또 옮기면 앞 요청 끊고 다시 낡음 · 낡은 동안 제안 [적용] · [자동 제안] 끔. +- 노선 [저장] 뒤 서버 답이 「낡음」 = 낡음 표시 · 저절로 셈 없음. +- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음(349-0i). """ import json @@ -33,124 +32,91 @@ def r(): STALE_TITLE = "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" +IDLE = "업데이트" +BUSY = "계산 중…" -def state(stale, veil, busy, fetches, auto=False): +def state(stale, busy, fetches): return { "stale": stale, - "blur": stale and not auto, # 자동 = 흐림 없음 - "veil": veil, + "pending": stale, # 낡음 = 단추 점(흐림 · 덮개 없음) + "off": busy, # 셈 동안 단추만 잠금 + "label": BUSY if busy else IDLE, "busy": busy, "fetches": fetches, "applyOff": stale, } -def look(row): - return {k: v for k, v in row.items() if k != "applyTitle"} +def look(row, *skip): + return {k: v for k, v in row.items() if k not in ("applyTitle", *skip)} -def test_manual_update_answer_lands_after_same_rerun(r): - assert look(r["moved"]) == state(True, True, False, 0) - assert look(r["sameAgain"]) == state(True, True, False, 0) # 판 번호만 바뀜 — 다시 안 흐림 - assert r["sent"]["fetches"] == 1 and r["sent"]["busy"] and not r["sent"]["veil"] +def test_moved_keeps_old_picture_no_auto(r): + assert look(r["start"]) == {**state(False, False, 0), "applyOff": True} # 제안 없음 + assert look(r["moved"]) == state(True, False, 0) + assert look(r["sameAgain"]) == state(True, False, 0) # 판 번호만 바뀜 + assert look(r["noAuto"]) == state(True, False, 0) # 60 s 지나도 셈 없음 + + +def test_update_answer_lands_after_same_rerun(r): + assert look(r["sent"], "body") == state(True, True, 1) + assert r["sent"]["body"]["route"]["rev"] == 2 # 지금 초안 노선으로 rerun = r["rerunWhileBusy"] assert not rerun["aborted"] and rerun["busy"] # 같은 입력 다시 돎 — 요청 그대로 - assert look(r["answered"]) == {**state(False, False, False, 1), "applied": 1} + assert look(r["answered"], "applied") == state(False, False, 1) + assert r["answered"]["applied"] == 1 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) + assert 200 not in points assert r["applied"]["remembered"] == 1 - - -def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r): - assert r["proposeStale"] == { - "off": True, - "title": "B05_Drainage_Btn_Propose_Stale", - "calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음 - } - assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"} - assert r["propose"] == { - "autos": ["all", "add", None], - "points": [1, 1, None], # ① ② = 지금 관 목록 함께 · 초기화 = 목록 없이(세류선만) - "remembered": 3, # 셋 다 초안 - "records": 3, # 답마다 사진첩 한 걸음 - } - # 저장 분석이 옛 노선 — 지금 노선으로 셈 길(detail-basins 안 부름) · 같은 초안 · 한 걸음 - assert r["proposeLive"] == { - "auto": "all", - "points": 1, - "resetPoints": None, - "resetAuto": None, - "detailCalls": 0, - "remembered": 6, # 앞 「409 → 다시」 한 번 포함 - "records": 6, - } - fallback = r["proposeFallback"] - assert fallback["sent"] == 1 and fallback["auto"] == "all" - assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시) + assert r["records"] == 1 # [적용] 한 번 = 사진첩 한 걸음 def test_move_while_busy_cancels(r): moved = r["movedWhileBusy"] - assert moved["aborted"] and moved["veil"] and not moved["busy"] - assert look(r["fresh"]) == state(False, False, False, 3) + assert moved["aborted"] and look(moved, "aborted") == state(True, False, 2) + assert r["promised"] is True + assert look(r["fresh"]) == state(False, False, 3) + + +def test_update_always_recomputes(r): + assert look(r["again"], "route") == {**state(False, True, 4), "applyOff": False} + assert look(r["againDone"]) == state(False, False, 4) def test_stale_blocks_apply(r): - assert r["movedWhileBusy"]["applyOff"] assert r["movedWhileBusy"]["applyTitle"] == STALE_TITLE assert r["staleApply"] == 1 # 눌러도 옛 제안이 안 들어감(앞 [적용] 한 번뿐) - assert not r["fresh"]["applyOff"] and r["fresh"]["applyTitle"] != STALE_TITLE + assert r["fresh"]["applyTitle"] != STALE_TITLE + assert r["proposeStale"] == {"off": True, "title": "B05_Drainage_Btn_Propose_Stale"} + assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"} -def test_saved_stale_shows_update(r): - assert {k: v for k, v in look(r["savedStale"]).items() if k != "status"} == state( - True, True, False, 3 - ) +def test_saved_stale_marks_only(r): + assert look(r["savedStale"], "status") == state(True, False, 4) assert r["savedStale"]["status"] == "배수유역 낡음" - - -def test_auto_mode(r): - assert look(r["autoSwitch"]) == state( - True, False, False, 3, True - ) # 자동 — 흐림 없음 · [업데이트] 없음 - assert r["auto2999"]["fetches"] == 3 - assert r["auto3000"]["fetches"] == 4 and r["auto3000"]["route"] is None - assert r["auto3000"]["busy"] and not r["auto3000"]["blur"] # 계산 중 = 구석 표시만 - assert look(r["autoDone"]) == state(False, False, False, 4) - assert look(r["autoRestart"]) == state( - True, False, False, 4, True - ) # 2 s + 2 s — 처음부터 다시 3 s - assert look(r["autoMoved"]) == {**state(False, False, False, 5), "mode": "auto"} - assert look(r["autoSavedStale"]) == state(True, False, False, 5, True) - assert look(r["autoSavedDone"]) == state(False, False, False, 6) + assert look(r["savedStaleNoAuto"]) == state(True, False, 4) + assert look(r["savedDone"]) == state(False, False, 5) def test_undo_back_to_saved(r): - # 자동 — 끌기 뒤 1 s 에 되돌리기: 3 s 셈 없음 · 저장분 하나 · 답 오면 낡음 풂 - assert look(r["autoUndoWaiting"]) == {**state(True, False, False, 6, True), "savedLoads": 2} - assert r["autoUndoNoFetch"]["fetches"] == 6 - assert look(r["autoUndo"]) == {**state(False, False, False, 6), "savedLoads": 2} - # 수동 — [업데이트] 요청 중 되돌리기: 초안용 요청 끊음 · 저장분 답 올 때까지 흐림 그대로 waiting = r["undoWaiting"] - assert waiting["aborted"] and waiting["stale"] and waiting["blur"] and not waiting["busy"] - assert look(r["undo"]) == {**state(False, False, False, 7), "savedLoads": 3} - # 저장분 자체가 낡음 = 흐림 · [업데이트] · [적용] 꺼짐 그대로 - assert look(r["undoSavedStale"]) == { - **state(True, True, False, 7), - "savedLoads": 4, - "mode": "manual", - } + assert waiting["aborted"] and look(waiting, "aborted", "savedLoads") == state(True, False, 6) + assert look(r["undo"], "savedLoads") == state(False, False, 6) + assert r["undo"]["savedLoads"] == 2 + assert look(r["undoSavedStale"], "savedLoads") == state(True, False, 6) + assert r["undoSavedStale"]["savedLoads"] == 3 def test_input_key(r): - assert look(r["keys"]) == { + assert r["keys"] == { "rev": True, "order": True, "node": False, 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_359_3_b05_drainage_btn_noun.py b/resources/tester/test_359_3_b05_drainage_btn_noun.py index 2f2e29e60..1a3ed4de8 100644 --- a/resources/tester/test_359_3_b05_drainage_btn_noun.py +++ b/resources/tester/test_359_3_b05_drainage_btn_noun.py @@ -7,7 +7,9 @@ LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_loca def test_drainage_buttons_are_nouns(): text = LOCALE.read_text(encoding="utf-8") - assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text - assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text + # [유역 재분할] · [관 삭제] · [배관 배치 초기화] 걷음 → [업데이트] 하나(요구 326-6) + assert "B05_Drainage_Btn_Analyze" not in text + assert "B05_Drainage_Btn_DeleteSelected" not in text + assert "B05_Drainage_Btn_Auto:" not in text assert "유역 다시 나누기" not in text assert "선택한 관 삭제" not in text 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_368_3_cross_modal.py b/resources/tester/test_368_3_cross_modal.py index 3ea4faadc..8e4f1128c 100644 --- a/resources/tester/test_368_3_cross_modal.py +++ b/resources/tester/test_368_3_cross_modal.py @@ -16,10 +16,13 @@ def test_추가_측점_선_점선_끌기_없음() -> None: graph = _read("B06_Section/B06_Section_UI_Longitudinal.ts") assert 'station.kind === "irregular" && !station.plus' in graph assert 'station.plus ? "plus" : station.kind' in graph - css = _read("B06_Section/B06_Section_UI_Style_Cross.css") + css = _read("B06_Section/B06_Section_UI_Style_Cross.css") + _read( + "B06_Section/B06_Section_UI_Style_Cross_Chart.css" + ) rule = css[css.index(".b06-chart__station-line--plus") :].split("}")[0] assert "var(--color-warning)" in rule and "stroke-dasharray: 5 3" in rule - assert "PLUS_OWNER_PREFIX" in _read("B05_Profile/B05_Profile_UI_Profile_Layout.ts") + # 주인 앞머리는 공용 `common_util_structure_station.ts` 로 옮겨짐(Layout.ts 에는 없음) + assert 'PLUS_OWNER_PREFIX = "plus-"' in _read("common_util/common_util_structure_station.ts") def test_누름_작은_창_우클릭_삭제() -> None: @@ -27,7 +30,7 @@ def test_누름_작은_창_우클릭_삭제() -> None: assert "crossPickFromEvent(chartWrap, event" in render and "openCrossModal(" in render assert "onRemovePlus: removePlusStation" in render menu = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts") - assert '"측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)' in menu + assert '"측점 삭제", () => options.onRemovePlus?.(near.chainage_m)' in menu modal = _read("B05_Profile/B05_Profile_UI_Profile_CrossModal.ts") assert "previewStructureSections(projectId(), [m])" in modal assert "add.disabled = taken" in modal # 같은 m 막음 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_370_3_rock_cell.py b/resources/tester/test_370_3_rock_cell.py index 1dd9ca9cc..439d74441 100644 --- a/resources/tester/test_370_3_rock_cell.py +++ b/resources/tester/test_370_3_rock_cell.py @@ -91,5 +91,7 @@ def test_배선_떠_있는_선_조정창_걷음() -> None: assert "buildRockBoundaryControl" not in chrome # 479 — 그림 없는 암 카드도 빈 칸 detail = (B06 / "B06_Section_UI_Cross_Detail_Pick.ts").read_text(encoding="utf-8") assert ".b06-chart__design-hit:not(.b06-chart__rock-hit)" in detail # 상세 표 강조에 안 섞임 - css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + ( + B06 / "B06_Section_UI_Style_Cross_Chart.css" + ).read_text(encoding="utf-8") assert ".b06-cross-card__linecell.is-empty {" in css and "text-overflow: ellipsis;" in css diff --git a/resources/tester/test_383_3_rerun_row.py b/resources/tester/test_383_3_rerun_row.py index e67a07a16..859aef478 100644 --- a/resources/tester/test_383_3_rerun_row.py +++ b/resources/tester/test_383_3_rerun_row.py @@ -1,4 +1,4 @@ -"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 보임 · 늘 켜짐(421). +"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 켜짐(421) · 보임 = 저장본 없음 · 칸 바꿈 때만(508-2). ./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q """ @@ -10,10 +10,10 @@ ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" -def test_재계산_한_줄_늘_보임_늘_켜짐() -> None: +def test_재계산_한_줄_늘_켜짐() -> None: ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8") assert re.search(r'
]*data-act="rerun">재계산', ts) - assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts + assert "rerun.hidden = !(missing || dirty);" in ts # 508-2 — 저장본 없음(409) · 칸 바꿈 때만 assert "rerun.before(profileToggle)" in ts # 종단 반영 토글이 같은 줄 왼쪽(421) assert "rerun.disabled = busy || invalid.size > 0;" in ts css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8") diff --git a/resources/tester/test_398_1_reveal_user_pick.py b/resources/tester/test_398_1_reveal_user_pick.py index 61bff9e99..02af90667 100644 --- a/resources/tester/test_398_1_reveal_user_pick.py +++ b/resources/tester/test_398_1_reveal_user_pick.py @@ -1,26 +1,16 @@ -"""PLAN 398-1 sub7 — 교점 옮기면 좌측 패널 열림(요구 248). +"""PLAN 398-1 sub7 — 교점 옮기면 좌측 패널 열림(요구 248) · 513-2 로 뒤집음. 못박는 것 - - 좌측 패널 저절로 펴기는 사용자 고름만 — 진짜 누름(click · pointerup · contextmenu) 안 = 1. - - 다시 칠하기 · 누가거리 따라가기 · 되돌리기 · 묶음 다시 돎 = 0 — 누름 밖(배수유역 다시 나눔 응답) · - 방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례를 띄운 그 누름(교점 놓음 · 곡선 지우기 · 되돌리기) · - 「계산 중」 동안 새 누름 = 1(505-2). - - 뿌리 한 곳 — 폼(`selectPipeByChainage` · `selectById` · `addAt`)은 그대로 펴기를 부르고 `design_shared` - onReveal 하나가 `isUserPick` 으로 거름. - - 499-1 뒤집음 — 펴기 = 「구조물 배치」 카드만 · 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) · + - 513-2 — 고름은 내용만 · 좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 사용자가 둔 그대로 + (옛 onReveal · `isUserPick` 펴기 걷음 · 우클릭 추가도 카드 안 폄). + - 499-1 그대로 — 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) · 패널을 열 때 고른 줄 · 강조 상자로 한 번 밂 · B06 평면 겹침 기준 = main 전체. - -짝: `helper_398_1_user_pick.cjs`(진짜 `design_user_pick` · `design_page_data`). """ from __future__ import annotations -import json -import subprocess from pathlib import Path -import pytest - HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] @@ -29,54 +19,15 @@ def _read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") -@pytest.fixture(scope="module") -def r() -> dict: - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_398_1_user_pick.cjs")], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun", "busyPick"]) -def test_사용자_고름은_폄(r, way): - assert r[way] == 1 # busyPick = 「계산 중」 동안 새 누름(505-2) - - -@pytest.mark.parametrize("way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint"]) -def test_다시_칠하기는_안_폄(r, way): - assert r[way] == 0 - - -def test_뿌리_한_곳(): +def test_513_2_고름은_카드_열림_안_건드림(): shared = _read("A00_Common/design/design_shared.ts") - assert ( - 'isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed")' - in shared - ) + assert "onReveal" not in shared and "isUserPick" not in shared for rel in ("A00_Common/design/design_shared.ts", "A00_Common/design/design_page.ts"): assert "revealLeft" not in _read(rel) and "setOptionsOpen(" not in _read(rel) # 499-1 panel = _read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") - assert panel.count("callbacks.onReveal?.()") == 3 # 고름 둘 · 우클릭 추가 — 부르는 쪽은 안 막음 - found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일 - ["git", "grep", "-l", "isUserPick(", "--", "*.ts"], - cwd=ROOT, - capture_output=True, - text=True, - encoding="utf-8", - ) - users = set(found.stdout.split()) | { - "A00_Common/design/design_user_pick.ts", - "A00_Common/design/design_shared.ts", - } # 새 파일(추적 전) - assert sorted(users) == [ - "A00_Common/design/design_shared.ts", - "A00_Common/design/design_user_pick.ts", - ] + assert "onReveal" not in panel + assert 'root.classList.remove("is-collapsed")' not in panel + assert not (ROOT / "A00_Common/design/design_user_pick.ts").exists() def test_열_때_고른_줄로_밂(): 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_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py index 4171c874f..aa855855a 100644 --- a/resources/tester/test_409_mark_top_deselect.py +++ b/resources/tester/test_409_mark_top_deselect.py @@ -124,9 +124,6 @@ def test_눈금_클릭은_폼_그대로(got2) -> None: def test_폼_닫기는_패널_안_폄() -> None: panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") head = panel[panel.index(" selectById(structureId) {") :] - assert head.index("if (structureId !== null) callbacks.onReveal?.();") < head.index( - "resetForm();" - ) assert "is-collapsed" not in head[: head.index(" addAt(")] # 접힘 · 펼침 안 건드림 pick = _read("A00_Common", "design", "design_pick_profile.ts") assert "from === PLAN_PICK_FROM && selection.chainageM === null" in pick 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..4812d13dd 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( @@ -71,6 +80,7 @@ SESSION_PULLS = ( "ui_template/ui_template_locale_b2.ts", "ui_template/ui_template_locale_b3.ts", "ui_template/ui_template_locale_b4.ts", + "ui_template/ui_template_locale_b5.ts", "ui_template/ui_template_locale_common.ts", "ui_template/ui_template_locale_m1.ts", "ui_template/ui_template_locale_m2.ts", @@ -106,6 +116,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 +267,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 +307,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 +329,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 +341,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_505_3_cross_card_text.py b/resources/tester/test_505_3_cross_card_text.py index 05ecc02e1..a734832fb 100644 --- a/resources/tester/test_505_3_cross_card_text.py +++ b/resources/tester/test_505_3_cross_card_text.py @@ -71,7 +71,9 @@ def test_곡선_판_가둠_도구줄_횡단판_종단판() -> None: def test_지반선_계획선_누름_통과() -> None: - css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + _read( + "B06_Section", "B06_Section_UI_Style_Cross_Chart.css" + ) assert ".b06-chart__profile,\n.b06-chart__design-profile {\n pointer-events: none;" in css diff --git a/resources/tester/test_506_3_b07_cad_cleanup.py b/resources/tester/test_506_3_b07_cad_cleanup.py index 747e63775..f7fba88a9 100644 --- a/resources/tester/test_506_3_b07_cad_cleanup.py +++ b/resources/tester/test_506_3_b07_cad_cleanup.py @@ -46,7 +46,7 @@ def test_6b_host_save_클래스_없음(): def test_4_2_안_쓰는_글_없음(): - locale = _read(ROOT / "ui_template" / "ui_template_locale_b2.ts") + locale = _read(ROOT / "ui_template" / "ui_template_locale_b5.ts") # B08 칸 = b5(512-2 나눔) for key in ( "B08_Quantity_Side_Pending", "B08_Quantity_Btn_Confirm", diff --git a/resources/tester/test_506_3_b07_save_path.py b/resources/tester/test_506_3_b07_save_path.py index 540eb301d..507957121 100644 --- a/resources/tester/test_506_3_b07_save_path.py +++ b/resources/tester/test_506_3_b07_save_path.py @@ -56,8 +56,9 @@ def test_1a_한도(): def test_1a_미리받기_쉼(): - page = _read("B07_DesignDetail_UI_Page.ts") - assert "while (loadInFlight || isSaving())" in page + # 508-3b 미리받기 = 페이지 밖 모듈 · 연 도면 받는 중(urgent) · [저장] 중 쉼 + prefetch = _read("B07_DesignDetail_Prefetch.ts") + assert "while (urgent > 0 || isSaving())" in prefetch def test_1a_저장_판정(out): diff --git a/resources/tester/test_507_3_mc_label.py b/resources/tester/test_507_3_mc_label.py index 02b8d94ca..6d73c4e6f 100644 --- a/resources/tester/test_507_3_mc_label.py +++ b/resources/tester/test_507_3_mc_label.py @@ -65,7 +65,7 @@ def test_b08_b09_earthwork_station_mc() -> None: def test_b05_b06_screen_mc_emphasis() -> None: - css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + _src("B06_Section/B06_Section_UI_Style_Cross_Chart.css") assert "header strong.b06-station--mc {" in css # 카드 머리 strong 규칙을 이김 for rule in ("\n.b06-chart__station-label--mc {", "\n.b06-station--mc {"): body = css[css.index(rule) : css.index("}", css.index(rule))] @@ -108,7 +108,8 @@ def test_b05_profile_table_station_mc() -> None: # 칸 글 · 굵게 · 경고색 = B06 공용 규칙 · 칸 폭 판정(`longest`)은 (MC) 없는 글 그대로 assert 'spec.cell(stationIndex) + (mc ? " (MC)" : "")' in table assert '${mc ? " b06-station--mc" : ""}' in table - assert 'display.station) + (mc ? " (MC)" : "")' in table # 고른 측점 값 열 + selected = _src("B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts") + assert 'display.station) + (mc ? " (MC)" : "")' in selected # 고른 측점 값 열 render = _src("B05_Profile/B05_Profile_UI_Profile_Render.ts") assert 'station.kind === "curve"' in render css = _src("B05_Profile/B05_Profile_UI_Style_Table.css") diff --git a/resources/tester/test_508_2_browser.py b/resources/tester/test_508_2_browser.py new file mode 100644 index 000000000..68cc6b2e2 --- /dev/null +++ b/resources/tester/test_508_2_browser.py @@ -0,0 +1,389 @@ +"""PLAN 508-2 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. + +① 판 D3 = 서버 · 저장 3D 모형 열쇠를 낡게(머리 hash 만 바꾼 사본 · 끝나면 바이트 되돌림) + → 들어오면 옛 모형이 섬 · [3D 업데이트] 「늦음」 · 3D 창을 열어도 안 지음 + · [3D 업데이트] = /calc corridor.mesh 한 번 → 새 모형 · 늦음 꺼짐 · 한 번 더 = /calc 0(같은 입력) +② 추천 노선 창 — 저장본 있는 갈래 = [재계산] 숨김 · 저장본 없음(409) = 안내 줄 옆 [재계산] + · 제약 칸 바꿈 = 종단 반영 오른쪽 [재계산] · [재계산] · [적용]은 안 누름 +③ 노선 편집 — 교각점을 범위(저장 노선 띠 300 + 여유 300 m) 밖으로 끌어 놓으면 잠잠 뒤 bbox 로 + 잘린 도엽 다시 받음 · 받는 양 · 밝음 · 어두움 한 장씩(`tmp/5082/`) · 측점 스냅 끔은 세션만 +- 저장 안 함(세션 초안만) · 판 = 파일 바이트 되돌림 · 시험 브라우저 표식 시스템 로그 행만 지움 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_browser.py -q -s +""" + +from __future__ import annotations + +import json +import re +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_505_3_cross_card_screen import JS_NODES # noqa: E402 +from test_511_a_calc_sites_browser import _UP, _started # noqa: E402 + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) +MARK = "AisloTest-508-2" +SHOTS = ROOT / "tmp" / "5082" + +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +JS_CORRIDOR = r"""() => { + const s = window.__corridorScene; + const g = s && s.scene.getObjectByName('corridor'); + if (!g) return null; + g.userData.mark ??= Math.random().toString(36).slice(2); + return { mark: g.userData.mark, children: g.children.length }; +}""" +JS_PENDING = r"""() => { + const b = [...document.querySelectorAll('button')] + .find((x) => /3D 업데이트|짓는 중|갱신 중/.test(x.textContent)); + return b ? { text: b.textContent.trim(), pending: b.classList.contains('is-pending') } : null; +}""" +JS_BUILT = ( + "() => ![...document.querySelectorAll('button')].some((b) => /짓는 중/.test(b.textContent))" +) +JS_IDLE = ( + "() => !/셈하는 중/.test(document.querySelector('.b05-routerec [data-state]').textContent)" +) +JS_SHOWN = ( + "() => /미리 보기/.test(document.querySelector('.b05-routerec [data-state]').textContent)" +) +JS_REC = r"""() => { + const m = document.querySelector('.b05-routerec'); + const r = m?.querySelector('[data-act="rerun"]'); + return m && { + state: m.querySelector('[data-state]').textContent, + shown: !!r && !r.hidden, + beside: !!r && r.previousElementSibling?.matches('[data-state]'), + inRow: !!r && !!r.closest('.b05-routerec__row'), + }; +}""" + + +def _project_root() -> Path: + import asyncio + + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + 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: + return await get_project_storage_relative_path(connection, PROJECT) + finally: + await close_db_pool() + + return Path(resolve_stored_project_path(asyncio.run(run()))) + + +# 플레이라이트 고리 밖에서 한 번(그 안에서는 asyncio.run 못 씀) +PROJECT_ROOT = _project_root() if _UP and _UP == _started(API) else None + + +@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")) + SHOTS.mkdir(parents=True, exist_ok=True) + 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_logs() + + +def _drop_logs() -> None: + import asyncio + + 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()) + + +# 계정 화면 취향(서버 user_ui_prefs) — 새 문맥이어도 남음 · 끌기 시험이 건드리는 것 +VIEW_PREFS = (f"aislo:pref:routeedit-view:{PROJECT}", "aislo:pref:profile-collapsed") + + +def _prefs(email: str, values: dict[str, str | None] | None = None) -> dict[str, str | None]: + """`VIEW_PREFS` 값 읽기 · `values` 있으면 그 키만 되씀(None = 지움 · 다른 키 그대로).""" + import asyncio + import concurrent.futures + + 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("SELECT id FROM users WHERE email = %s", (email,)) + user = (await cursor.fetchone())[0] + for key, value in (values or {}).items(): + path = f'$."{key}"' + if value is None: + sql, args = "JSON_REMOVE(prefs, %s)", (path,) + else: + sql, args = "JSON_SET(prefs, %s, %s)", (path, value) + await cursor.execute( + f"UPDATE user_ui_prefs SET prefs = {sql} WHERE user_id = %s", (*args, user) + ) + await connection.commit() + await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user,)) + row = await cursor.fetchone() + prefs = json.loads(row[0]) if row else {} + return {key: prefs.get(key) for key in VIEW_PREFS} + finally: + await close_db_pool() + + # 플레이라이트 고리 안 — asyncio.run 은 딴 실에서 + with concurrent.futures.ThreadPoolExecutor(1) as pool: + return pool.submit(asyncio.run, run()).result() + + +def _board(corridor: str | None) -> None: + board = json.loads(BOARD.read_text("utf-8")) + board["sites"]["corridor_mesh"] = corridor or "both" + BOARD.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _open(context, on_response=None): + page = context.new_page() + page.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}');") + if on_response: + 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) + return page + + +def test_3D_서버_낡음_옛_모형_단추로_바꿔_끼움(ctx): + root = PROJECT_ROOT + route_id = max( + int(p.stem.split("_")[1]) + for p in (root / "B05_Profile" / "corridor").glob("corridor_*.json") + ) + stored = root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json" + original = stored.read_bytes() + head = original[:4096] + found = re.search(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"', head) + assert found + stale = original.replace(found.group(0), b'"hash":"0badbeef"', 1) + calls: list[str] = [] + + def on_response(res): + if res.url.endswith("/calc") and res.request.method == "POST": + body = json.loads(res.request.post_data) + calls.append("+".join(body["run"])) + + _board("server") + stored.write_bytes(stale) + try: + page = _open(ctx, on_response) + page.get_by_role("button", name="3D", exact=True).first.click() + page.wait_for_timeout(6000) + entry = {"model": page.evaluate(JS_CORRIDOR), "button": page.evaluate(JS_PENDING)} + opened_calls = list(calls) + page.screenshot(path=str(SHOTS / "d3_stale_entry.png")) + button = page.locator("button", has_text="3D 업데이트").first + button.click() + page.wait_for_timeout(300) + during = page.evaluate(JS_PENDING) + page.wait_for_function(JS_BUILT, timeout=120000) + page.wait_for_timeout(1000) + after = { + "model": page.evaluate(JS_CORRIDOR), + "button": page.evaluate(JS_PENDING), + "source": page.evaluate("() => window.__corridorSource?.source ?? null"), + } + page.screenshot(path=str(SHOTS / "d3_rebuilt.png")) + built_calls = list(calls) + page.locator("button", has_text="3D 업데이트").first.click() + page.wait_for_timeout(4000) + again_calls = list(calls) + page.close() + finally: + stored.write_bytes(original) + _board(None) + print("entry", entry, "during", during, "after", after) + print("calls", opened_calls, built_calls, again_calls) + assert entry["model"] and entry["model"]["children"] > 0 # 옛 모형(빈 3D 아님) + assert entry["button"]["pending"] is True + assert "corridor.mesh" not in opened_calls # 창 열 때 안 지음 + assert during["text"] == "서버에서 짓는 중…" + assert built_calls.count("corridor.mesh") == 1 + assert after["model"]["mark"] != entry["model"]["mark"] # 바꿔 끼움 + assert after["button"]["pending"] is False and after["source"] == "built" + assert again_calls.count("corridor.mesh") == 1 # 같은 입력 = 다시 안 지음 + assert stored.read_bytes() == original + + +def test_추천_창_재계산_409_때만(ctx): + page = _open(ctx) + page.get_by_text("노선 추천", exact=True).first.click() + page.wait_for_timeout(800) + page.locator("button", has_text="노선 추천").last.click() + page.wait_for_selector(".b05-routerec", timeout=30000) + seen = [] + for logic in ("follow", "gradeline", "earthwork", "gentle", "weighted"): + page.click(f'.b05-routerec [data-logic="{logic}"]') + page.wait_for_function(JS_IDLE, timeout=120000) + seen.append({"logic": logic, **page.evaluate(JS_REC)}) + terrain = page.locator('.b05-routerec [data-act="terrain"]') + if terrain.is_visible(): + other = "sheet" if terrain.input_value() == "las" else "las" + terrain.select_option(other) + page.wait_for_function(JS_IDLE, timeout=120000) + seen.append({"logic": f"terrain:{other}", **page.evaluate(JS_REC)}) + print(json.dumps(seen, ensure_ascii=False, indent=1)) + missing = [s for s in seen if "저장본이 없습니다" in s["state"]] + if missing: + page.screenshot(path=str(SHOTS / "rec_409_rerun.png")) + # 제약 칸 바꿈(저장본 있는 갈래로 돌아가서) — 종단 반영 오른쪽 + stored = [s for s in seen if s["state"].startswith("미리 보기")] + if stored: + if terrain.is_visible(): + terrain.select_option("las") # 지형 되돌림 — 마지막 갈래(비중) 라이다 저장본 + else: + page.click(f'.b05-routerec [data-logic="{stored[0]["logic"]}"]') + page.wait_for_function(JS_SHOWN, timeout=120000) + field = page.locator(".b05-routerec__criteria input").first + field.fill("7") + page.wait_for_timeout(300) + dirty = page.evaluate(JS_REC) + page.screenshot(path=str(SHOTS / "rec_dirty_rerun.png")) + page.close() + print(json.dumps(seen, ensure_ascii=False, indent=1)) + for s in seen: + if s["state"].startswith("미리 보기"): + assert s["shown"] is False, s + if "저장본이 없습니다" in s["state"]: + assert s["shown"] and s["beside"] and not s["inRow"], s + assert stored, "저장본 있는 갈래 없음" + assert dirty["shown"] and dirty["inRow"] and not dirty["beside"], dirty + if not missing: + pytest.skip("저장본 없는 갈래 없음 — 409 줄은 단위 시험 몫") + + +def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): + got: list[dict] = [] + + def on_response(res): + if "/geojson?" in res.url and "scope=route" in res.url: + got.append({"bbox": "bbox=" in res.url, "status": res.status, "bytes": len(res.body())}) + + # 새 문맥 — 앞 시험 페이지가 남긴 보던 화면 · 보관함 없이(축척 · 받기가 늘 같게) + fresh_ctx = ctx.browser.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + # 보던 화면은 계정(서버)에도 남음 — 지난 판 끌기 · 줄임이 쌓여 시작 화면이 매번 달랐음(514-4) + kept = _prefs(cred["email"]) + _prefs(cred["email"], {VIEW_PREFS[0]: None}) + try: + _drag_and_fetch(fresh_ctx, cred, got, on_response) + finally: + fresh_ctx.close() + _prefs(cred["email"], kept) # 계정 화면 취향 되돌림 + + +def _drag_and_fetch(fresh_ctx, cred, got, on_response) -> None: + login = {k: cred[k] for k in ("email", "password")} + assert fresh_ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + page = _open(fresh_ctx, on_response) + handle = page.locator(".ui-workflow-overlay__handle--bottom", has_text="종단").first + for _ in range(6): # 종단 접음 — 평면 넓게(배치가 늦게 되살아나면 다시 접음) + if handle.bounding_box()["y"] > 900: + break + handle.click() + page.wait_for_timeout(2500) + page.mouse.move(960, 500) + for _ in range(3): # 줄여 봄 — 오른쪽 끝 교각점을 범위 밖까지 끌 자리 + page.mouse.wheel(0, -300) + page.wait_for_timeout(300) + first = [g for g in got if not g["bbox"]] # 앞 시험이 담아 두었으면 보관함 — 0 일 수 있음 + nodes = [] + for _ in range(20): # 접은 뒤 다시 그릴 때까지(사진 한 장 = 그림 한 번 내보냄) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + nodes = page.evaluate(JS_NODES) + if nodes: + break + page.wait_for_timeout(500) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + assert nodes, "교각점 못 찾음" + page.get_by_role("button", name="측점 스냅", exact=True).click() # 끔(세션만) — 큰 끌기 막음 풂 + page.wait_for_timeout(500) + during = None + drops = 0 + for _ in range(4): # 끌어 놓기 · 화면을 왼쪽으로 옮김 — 범위 밖까지 + node = max(nodes, key=lambda n: n["x"]) # 오른쪽 끝 교각점 — 오른쪽 바깥으로 + page.mouse.move(node["x"], node["y"]) + page.mouse.down() + page.mouse.move(node["x"] + 150, node["y"], steps=8) + page.wait_for_timeout(600) + if during is None: + during = sum(g["bbox"] for g in got) # 끄는 동안(잠잠 시간보다 오래 쥠) + page.mouse.move(1590, node["y"], steps=8) + page.mouse.up() + drops += 1 + page.wait_for_timeout(4000) + if any(g["bbox"] for g in got): + # 둘째 도엽(수 MB)은 늦게 옴 — 바쁜 서버(싱크 때 시험 묶음)면 4 초 안에 하나만(514-4) + for _ in range(60): + if sum(g["bbox"] for g in got) >= 2: + break + page.wait_for_timeout(500) + page.wait_for_timeout(1500) # 더 오는 것(다시 받기 두 번)도 잡히게 + break + page.mouse.move(1500, 900) + page.mouse.down(button="middle") + page.mouse.move(900, 900, steps=10) + page.mouse.up(button="middle") + page.wait_for_timeout(800) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + nodes = page.evaluate(JS_NODES) + assert nodes, "옮긴 뒤 교각점 못 찾음" + status = page.locator(".b05-routeedit__status").first.text_content() + after = list(got) + page.screenshot(path=str(SHOTS / "sheet_follow_light.png")) + page.evaluate("() => document.documentElement.setAttribute('data-theme', 'dark')") + page.wait_for_timeout(1500) + page.screenshot(path=str(SHOTS / "sheet_follow_dark.png")) + page.evaluate("() => document.documentElement.setAttribute('data-theme', 'light')") + page.close() + fresh = [g for g in after if g["bbox"]] + print("first", first, "fresh", fresh, "drops", drops, "status", status) + assert during == 0 # 끄는 동안 = 안 받음 + assert len(fresh) == 2 and all(g["bbox"] and g["status"] == 200 for g in fresh) + assert all(g["bytes"] < 5_000_000 for g in fresh) # 통째 사본(36.8 MB) 아님 diff --git a/resources/tester/test_508_2_corridor_server.py b/resources/tester/test_508_2_corridor_server.py new file mode 100644 index 000000000..4c7e87351 --- /dev/null +++ b/resources/tester/test_508_2_corridor_server.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""508-2 ① 3D 길 모형 계산 자리 D3 = 서버(요구 323 · 324). + +- 들어올 때 저장본이 낡았으면 옛 저장본을 세워 둠(빈 3D 아님 · 「늦음」 표시) +- [3D 업데이트] = 서버 창구(`/calc` corridor.mesh · 같은 TS)가 지금 입력(라이브 계획선 샘플)으로 + 지어 바꿔 끼움 +- 같은 입력이면 다시 안 지음 · 서버 실패 = 옛 모형 그대로 · 거짓(늦음 남김) +- 3D 창을 열 때 자동으로 안 지음(서버) · 기본 both = 지금 그대로(옛 저장본 안 받음 · 브라우저 짓기) + +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_corridor_server.py -q +""" + +import json +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _run() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_508_2_corridor_server.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_서버_낡음_옛_모형_뒤_바꿔_끼움() -> None: + out = _run() + assert out["serverFresh"] is False # 늦음 표시 + assert out["serverEntryShown"] == [9] and out["serverEntryOldZ"] == 100 # 옛 모형 + assert out["serverStaleGet"] is True + assert out["refreshOk"] is True and out["refreshCalls"] == 1 + assert out["refreshSamples"] == 101 # 라이브 계획선 샘플을 서버에 실음 + assert out["refreshNewZ"] == 101 and out["refreshShown"] == [9, 9] + assert out["againOk"] is True and out["againCalls"] == 1 # 같은 입력 = 다시 안 지음 + assert out["failOk"] is False and out["failSetCalls"] == 0 and out["failZ"] == 101 + + +def test_both_는_지금_그대로() -> None: + out = _run() + assert out["bothFresh"] is False and out["bothEntryShown"] == [] + assert out["bothStaleGet"] is False + assert out["bothRefreshOk"] is True and out["bothCalls"] == 0 and out["bothShown"] == [9] + + +def test_창_열_때_서버면_자동_안_지음_단추_글() -> None: + three = (ROOT / "A00_Common/design/design_view_profile_3d.ts").read_text(encoding="utf-8") + assert 'calcOnServer("corridor_mesh").then((server) => void (server || refreshOnce()))' in three + assert "setCorridorPending(!fresh)" in three + view = (ROOT / "B05_Profile/B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") + assert '? "서버에서 짓는 중…"' in view and 'calcOnServerNow("corridor_mesh")' in view diff --git a/resources/tester/test_508_2_recommend_rerun.py b/resources/tester/test_508_2_recommend_rerun.py new file mode 100644 index 000000000..dcc124d27 --- /dev/null +++ b/resources/tester/test_508_2_recommend_rerun.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""508-2 ② B05 추천 노선 창 [재계산] — 저장본 없음(409) 안내 줄 옆 · 칸(제약 · 비중) 바꾼 뒤만 보임. + +505-1 · 509-5 규칙 그대로 — 접근 = 저장본만 · 사용자가 누른 셈만 그 자리. + +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_recommend_rerun.py -q +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +REC = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Recommend.ts" + + +def test_보임_규칙() -> None: + ts = REC.read_text(encoding="utf-8") + assert "rerun.hidden = !(missing || dirty);" in ts + assert "if (missing) stateEl.after(rerun);" in ts # 안내 줄(상태 줄) 옆 + assert "else profileToggle.after(rerun);" in ts # 칸 바꿈 = 종단 반영 오른쪽(지금처럼) + run = ts.split("async function run(recompute = false)")[1].split("pills.forEach")[0] + assert "missing = false;" in run.split("try {")[0] # 새 셈마다 지움 + assert "missing = message.includes(NOT_PREPARED_HEAD);" in run + assert 'rerun.addEventListener("click", () => void run(true));' in ts + + +def test_안내_글_머리_서버와_같음() -> None: + ts = REC.read_text(encoding="utf-8") + head = re.search(r'const NOT_PREPARED_HEAD = "([^"]+)";', ts).group(1) + py = (ROOT / "B05_Profile" / "B05_Profile_Router_RouteInitial.py").read_text(encoding="utf-8") + message = re.search(r'^NOT_PREPARED = "([^"]+)"', py, re.M).group(1) + assert message.startswith(head) diff --git a/resources/tester/test_508_2_sheet_follow.py b/resources/tester/test_508_2_sheet_follow.py new file mode 100644 index 000000000..97b43cb3d --- /dev/null +++ b/resources/tester/test_508_2_sheet_follow.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""508-2 ③ 노선 편집 평면 도엽 등고선 — 편집 중 노선을 범위(저장 노선 띠 300 + 여유 300 m) 밖으로 +끌어 놓으면 잠잠 뒤 그 초안 노선 범위로 서버에 잘린 등고선을 다시 받아 바꿔 끼움(통째 사본 안 받음). + +- 서버 `bbox`(route 만 · 사업지 m) = 저장 노선 대신 그 bbox 로 범위 · 열쇠 = 사본 지문 + 범위 · + 같은 범위 = 다시 안 셈 +- 화면 `createSheetFollow` — 범위 안 = 안 받음 · 밖 = 잠잠 뒤 한 번 · 지금 노선 범위 · + 떠난 뒤 = 안 받음 +- 배수유역도 범위(509-6)는 그대로(bbox 안 받음) + +임시 폴더 사업지(509-1 과 같음) — 공유 storage · DB 안 씀. +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_sheet_follow.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest +from test_509_1_sheet_clip_route import META, project # noqa: F401 — 같은 사업지 + +from B04_PreProcess import B04_PreProcess_Engine_SheetClip as clip + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def test_bbox_읽기() -> None: + assert clip.parse_route_bbox("6200,5000,6700,5200") == (6200, 5000, 6700, 5200) + for bad in (None, "", "1,2,3", "a,b,c,d", "5,0,1,0", "nan,0,1,1", "0,0,inf,1"): + assert clip.parse_route_bbox(bad) is None + + +def test_편집_노선_범위로_자름_같은_범위_다시_안_셈(project) -> None: # noqa: F811 + root, source = project + saved = clip.clipped_sheet(root, source, "route") + moved = clip.clipped_sheet(root, source, "route", (6200, 5000, 6700, 5200)) + assert moved != saved and moved.parent == saved.parent + data = json.loads(moved.read_text(encoding="utf-8")) + assert data["clip_lonlat"] == pytest.approx([127.056, 37.044, 127.073, 37.058]) + stamp = moved.stat().st_mtime_ns + again = clip.clipped_sheet(root, source, "route", (6210, 5010, 6690, 5190)) # 같은 눈금 + assert again == moved and again.stat().st_mtime_ns == stamp + # 저장 노선 bbox 를 그대로 보내면 저장 노선 범위와 같은 저장본 + assert clip.clipped_sheet(root, source, "route", (5000, 5000, 5500, 5200)) == saved + + +def test_라우터_bbox_는_route_만() -> None: + text = (ROOT / "B04_PreProcess" / "B04_PreProcess_Router_GIS.py").read_text(encoding="utf-8") + assert 'bounds = parse_route_bbox(bbox) if scope == "route" else None' in text + assert "clipped_sheet, project_root, display, scope, bounds" in text + + +def test_화면_놓은_뒤_다시_받음() -> None: + proc = subprocess.run( + ["node", str(HERE / "helper_508_2_sheet_follow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout.strip().splitlines()[-1]) + assert out["bbox"] == "5000,4950,5500,5200" # 50 m 눈금 바깥 맞춤 + assert out["insideCalls"] == 0 # 범위 안 끌기 = 안 받음 + assert out["beforeQuiet"] == 0 # 끄는 동안 = 안 받음 + assert out["outsideCalls"] == [{"scope": "route", "bbox": "6200,5000,6700,5200"}] # 한 번 + assert out["swaps"] == 1 and out["swappedCovers"] is True and out["lineKept"] is True + assert out["againCalls"] == 1 and out["coveringSame"] is True + assert out["disposedCalls"] == 1 + + +def test_여는_때도_통째_사본_안_받음() -> None: + ts = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Contour.ts").read_text(encoding="utf-8") + assert "fetchDrainageLayers(projectId, () => {}, null)" not in ts + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert len(edit.splitlines()) <= 700 + assert "sheetFollow?.poke();" in edit and "sheetFollow?.dispose();" in edit diff --git a/resources/tester/test_508_3_b07_fingerprint.py b/resources/tester/test_508_3_b07_fingerprint.py new file mode 100644 index 000000000..177c7c7d6 --- /dev/null +++ b/resources/tester/test_508_3_b07_fingerprint.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""508-3b · c B07 도면 지문 · 평면도 파생 저장본(임시 폴더 · DB · 실제 마스터 안 씀). + +- 지문 = 입력이 바뀔 때만 바뀜(같은 내용으로 다시 쓴 종단 = 그대로) +- 횡단 측점 하나 바꾸면 그 장만 · 그 장 측점 지문도 그 측점만 +- B07 설정 · 도각 · 그 도면 manifest 칸 = 그 범위만 바뀜 +- 평면도 저장본 열쇠 = 지문 · 읽기는 안 지움 · 최근 2벌 · 해상도(종이 0.1 mm 솎음 · 1 µm 끊음) + + ./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_fingerprint.py -q +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail import B07_DesignDetail_Router_Support_Fingerprint as fp # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _thinned # noqa: E402 + +SHEETS = [ + {"id": "cross_s00000m", "number": 1, "chainages": [0, 20], "grid": [4, 4]}, + {"id": "cross_s00040m", "number": 2, "chainages": [40, 60], "grid": [4, 4]}, +] + + +def _item(drawing_id: str, kind: str) -> SimpleNamespace: + return SimpleNamespace(id=drawing_id, kind=kind) + + +ITEMS = [ + _item("cover", "cover"), + _item("longitudinal_1", "longitudinal"), + _item("cross_s00000m", "cross"), + _item("cross_s00040m", "cross"), + _item("mass_haul", "mass_haul"), +] + + +@pytest.fixture() +def project(tmp_path: Path) -> dict: + root = tmp_path / "proj" + longitudinal = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal.parent.mkdir(parents=True) + longitudinal.write_text(json.dumps({"stations": [{"chainage_m": 0}]}), encoding="utf-8") + (root / "B07_DesignDetail").mkdir() + (root / "B07_DesignDetail" / "drawing_scales.json").write_text("{}", encoding="utf-8") + (root / "templates" / "drawing").mkdir(parents=True) + (root / "templates" / "drawing" / "00_template_A1.json").write_text("{}", encoding="utf-8") + designs = {c: {"cut_slope_ratio": 0.4, "at": c} for c in (0, 20, 40, 60)} + sources = { + "title": {"공사명": "시험"}, + "designs": designs, + "detail": {c: {"chainage_m": c, "design": designs[c]} for c in designs}, + "overlays": {}, + "elevations": {str(c): 100.0 for c in designs}, + "sheets": SHEETS, + "mass_haul": {"points": [1, 2]}, + "cross_inputs": lambda c: {"chainage": c, "design": sources["designs"].get(c)}, + } + return {"root": root, "longitudinal": longitudinal, "sources": sources} + + +def _prints(project: dict) -> dict: + return fp.compute_fingerprints( + project["root"], project["longitudinal"], ITEMS, project["sources"] + ) + + +def _changed(before: dict, after: dict) -> set[str]: + return {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]} + + +def test_같은_입력_같은_지문(project): + first = _prints(project) + assert first == _prints(project) + assert all(len(value["fingerprint"]) == 16 for value in first.values()) + assert set(first["cross_s00000m"]["stations"]) == {"0", "20"} + + +def test_같은_내용으로_다시_쓴_종단은_그대로(project): + before = _prints(project) + path: Path = project["longitudinal"] + text = path.read_text(encoding="utf-8") + time.sleep(0.02) + path.write_text(text, encoding="utf-8") # B06 [저장]이 같은 내용으로 다시 씀 + os.utime(path, None) + assert _changed(before, _prints(project)) == set() + path.write_text(json.dumps({"stations": [{"chainage_m": 0}, {"chainage_m": 20}]})) + assert _changed(before, _prints(project)) == {"longitudinal_1", "mass_haul"} + + +def test_횡단_측점_하나_바꾸면_그_장만(project): + before = _prints(project) + sources = project["sources"] + sources["designs"] = {**sources["designs"], 40: {"cut_slope_ratio": 0.5, "at": 40}} + sources["detail"] = {**sources["detail"], 40: {"chainage_m": 40, "design": {"x": 1}}} + after = _prints(project) + assert _changed(before, after) == {"cross_s00040m"} + stations_before = before["cross_s00040m"]["stations"] + stations_after = after["cross_s00040m"]["stations"] + assert stations_before["60"] == stations_after["60"] + assert stations_before["40"] != stations_after["40"] + + +def test_덧그림_범위만_바뀌어도_그_장(project): + before = _prints(project) + project["sources"]["overlays"] = {20: (-5.0, 5.0, 90.0, 110.0)} + assert _changed(before, _prints(project)) == {"cross_s00000m"} + + +def test_설정_도각_manifest_범위(project): + root: Path = project["root"] + before = _prints(project) + (root / "B07_DesignDetail" / "drawing_scales.json").write_text('{"cross": {"x": 200}}') + after = _prints(project) + assert _changed(before, after) == {item.id for item in ITEMS} # 설정 = 모든 도면 + + (root / "templates" / "drawing" / "00_template_A1.json").write_text('{"a": 1}') + again = _prints(project) + assert _changed(after, again) == {item.id for item in ITEMS} # 도각 = 모든 도면 + + manifest = {"drawings": {"cover": {"number": 1}}} + (root / "B07_DesignDetail" / "manifest.json").write_text(json.dumps(manifest)) + last = _prints(project) + assert _changed(again, last) == {"cover"} # 그 도면 칸만 · manifest 파일은 설정이 아님 + + # 그린 결과 폴더(plan_pieces)는 입력 아님 + (root / "B07_DesignDetail" / "plan_pieces" / "1").mkdir(parents=True) + (root / "B07_DesignDetail" / "plan_pieces" / "1" / "contour.json").write_text("[]") + assert _changed(last, _prints(project)) == set() + + +def test_DB_유토만_바뀌면_토적도만(project): + before = _prints(project) + project["sources"]["mass_haul"] = {"points": [1, 3]} + assert _changed(before, _prints(project)) == {"mass_haul"} + + +def test_파일_도장_내용_대_시각(tmp_path, monkeypatch): + small = tmp_path / "a.json" + small.write_text("1") + first = fp.stamp(small) + os.utime(small, (1, 1)) + assert fp.stamp(small) == first # 작은 파일 = 내용 + monkeypatch.setattr(fp, "_CONTENT_MAX", 0) + assert fp.stamp(small) == "1000000000:1" # 큰 파일 = 시각:크기 + assert fp.stamp(tmp_path / "없음") is None + + +def test_평면도_저장본_열쇠_읽기는_안_지움(tmp_path): + root = tmp_path / "proj" + assert fp.read_plan_copy(root, "plan_terrain", "k1") is None + fp.write_plan_copy(root, "plan_terrain", "k1", b'{"a":1}') + fp.write_plan_copy(root, "plan_route", "r1", b'{"r":1}') + folder = root / "_derived" / "b07_plan_plan_terrain" + assert fp.read_plan_copy(root, "plan_terrain", "k1") == b'{"a":1}' + assert fp.read_plan_copy(root, "plan_terrain", "다른") is None # 지문 다름 = 없음 + assert (folder / "k1.json").is_file() # 읽기 · 빗나감 모두 안 지움 + time.sleep(0.02) + fp.write_plan_copy(root, "plan_terrain", "k2", b'{"a":2}') + time.sleep(0.02) + fp.read_plan_copy(root, "plan_terrain", "k1") # 꺼낸 것 = 최근 + time.sleep(0.02) + fp.write_plan_copy(root, "plan_terrain", "k3", b'{"a":3}') + assert sorted(p.name for p in folder.glob("*.json")) == ["k1.json", "k3.json"] # 최근 2벌 + assert (root / "_derived" / "b07_plan_plan_route" / "r1.json").is_file() # 다른 도면 그대로 + assert not list(folder.glob("*.tmp")) + + +def test_해상도_종이_솎음(tmp_path): + # 종이 mm 직선 위 점 100 개 + 0.05 mm 흔들림 = 양 끝만 · 좌표 1 µm + points = [(i * 0.5 + 0.0000123, (0.05 if i % 2 else 0.0)) for i in range(100)] + thinned = _thinned(points) + assert len(thinned) == 2 + assert all(round(v, 3) == v for xy in thinned for v in xy) + bend = [(0.0, 0.0), (10.0, 0.5), (20.0, 0.0)] # 0.5 mm 꺾임은 남김 + assert len(_thinned(bend)) == 3 + + +def test_평면도_등고선_솎음_적용(): + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing + + scale = 1200 + line = [(i * 0.6, 0.00001 * (i % 2)) for i in range(200)] # 실좌표 m — 종이에선 곧은 선 + box = (0.0, -10.0, 120.0, 10.0) + pieces = {"contour": {"lines": [line]}, "centerline": {"lines": [line]}} + drawing = build_plan_kind_drawing("plan_t", "지형", box, pieces, scale) + lines = { + entity["layerId"]: entity + for entity in drawing["entities"] + if entity.get("type") == "PolyLine" + } + contour = next(value for key, value in lines.items() if "contour" in key) + center = next(value for key, value in lines.items() if "center" in key) + assert len(contour["children"]) == 1 # 배경선 = 솎음 + assert len(center["children"]) == 199 # 주제선(중심선)은 그대로 + + +def test_값_꼴_경고_글만_바뀐_저장은_그대로(project): + # 옛 판 자료의 첫 B06 [저장] = 4.0 → 4 · 경고 글 꼴만 바뀜(2026-10-22 실측) — 그림은 같음 + path: Path = project["longitudinal"] + path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}], "warnings": ["a"]})) + sources = project["sources"] + sources["designs"] = {c: {**d, "width": 4.0} for c, d in sources["designs"].items()} + sources["detail"] = {c: {"chainage_m": c, "design": d} for c, d in sources["designs"].items()} + before = _prints(project) + path.write_text(json.dumps({"stations": [{"chainage_m": 0}], "warnings": ["a", "b"]})) + sources["designs"] = {c: {**d, "width": 4} for c, d in sources["designs"].items()} + sources["detail"] = { + c: {"chainage_m": float(c), "design": d} for c, d in sources["designs"].items() + } + assert _changed(before, _prints(project)) == set() diff --git a/resources/tester/test_508_3_b07_prefetch_browser.py b/resources/tester/test_508_3_b07_prefetch_browser.py new file mode 100644 index 000000000..e10943b2c --- /dev/null +++ b/resources/tester/test_508_3_b07_prefetch_browser.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +"""508-3b · c 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만. + +- B07 열기 → 미리받기 차례 = 가벼운 것 먼저 · 평면도(무거운 것) 끝 +- B06 으로 가 첫 측점 깎기 비탈 고치고 [저장] → B07 돌아옴 → 지문 바뀐 도면만 다시 받음 + (횡단 = 고친 측점 든 장 하나 · 측점 지문도 그 측점만) · 페이지 옮겨도 받은 것은 그대로 +- 안 받은 평면도(유역도 · 저장본 지움) 열기 → 「받는 중」 → 받은 뒤 보임 +- 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음 +- 못 받은 도면 = 「다시 받기」 → 누르면 다시 받음(밝음 · 어두움 한 장씩 · tmp/shots_508_3) +- 단계 행 · projects · 노선 · 종 · 횡단 행 · B05 노선 · B06 · B07 storage 를 먼저 떠 두고 끝에 되돌림 + + AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_prefetch_browser.py -q -s +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import sys +import time +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from helper_verify_project import own_ports # noqa: E402 + +BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄 +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +SHOTS = ROOT / "tmp" / "shots_508_3" +STAGE_COLS = ( + "state", + "progress_percent", + "params", + "message", + "started_at", + "completed_at", + "updated_at", +) +KEEP_DIRS = ("B05_Profile/route", "B06_Section", "B07_DesignDetail") + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +pytestmark = pytest.mark.skipif( + os.environ.get("AISLO_LIVE_DB") != "1" or not _up(), + reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · DB 행을 씀)", +) + + +async def _keep(mode: str, held: dict) -> None: + """단계 · projects · routes · 종 · 횡단 행 + storage 폴더 떠 두기(save) · 되돌리기(restore).""" + import aiomysql + + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + conn = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, + charset="utf8mb4", autocommit=False, + ) # fmt: skip + try: + async with conn.cursor(aiomysql.DictCursor) as cur: + if mode == "save": + await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,)) + held["root"] = Path( + resolve_stored_project_path((await cur.fetchone())["storage_path"]) + ) + for name, sql in ( + ("stages", "SELECT * FROM project_workflow_stages WHERE project_id=%s"), + ("project", "SELECT status, updated_at FROM projects WHERE id=%s"), + ("routes", "SELECT id, status FROM routes WHERE project_id=%s"), + ("long", "SELECT id, data, status FROM longitudinal_sections WHERE project_id=%s"), + ("cross", "SELECT * FROM cross_sections WHERE project_id=%s"), + ): # fmt: skip + await cur.execute(sql, (PROJECT,)) + held[name] = await cur.fetchall() + held["copy"] = ROOT / "tmp" / f"pt_508_3_keep_{int(time.time())}" + for rel in KEEP_DIRS: + shutil.copytree(held["root"] / rel, held["copy"] / rel) + return + for row in held["stages"]: + await cur.execute( + f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in STAGE_COLS)} " + "WHERE id=%s", + (*[row[k] for k in STAGE_COLS], row["id"]), + ) + project = held["project"][0] + await cur.execute( + "UPDATE projects SET status=%s, updated_at=%s WHERE id=%s", + (project["status"], project["updated_at"], PROJECT), + ) + for row in held["routes"]: + await cur.execute( + "UPDATE routes SET status=%s WHERE id=%s", (row["status"], row["id"]) + ) + for row in held["long"]: + await cur.execute( + "UPDATE longitudinal_sections SET data=%s, status=%s WHERE id=%s", + (row["data"], row["status"], row["id"]), + ) + await cur.execute("DELETE FROM cross_sections WHERE project_id=%s", (PROJECT,)) + for row in held["cross"]: + cols = list(row) + await cur.execute( + f"INSERT INTO cross_sections ({', '.join(cols)}) " + f"VALUES ({', '.join(['%s'] * len(cols))})", + [row[c] for c in cols], + ) + await conn.commit() + for rel in KEEP_DIRS: + shutil.rmtree(held["root"] / rel) + shutil.copytree(held["copy"] / rel, held["root"] / rel) + finally: + conn.close() + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + held: dict = {} + asyncio.run(_keep("save", held)) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 950}) + 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}" # 비밀번호는 안 찍음 + context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") + context.held = held # type: ignore[attr-defined] + yield context + chrome.close() + finally: + asyncio.run(_keep("restore", held)) + print("되돌림 사본 자리", held.get("copy")) + + +def _list(ctx) -> dict: + body = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/design-drawings", timeout=120000) + return {item["id"]: item for item in body.json()["drawings"]} + + +def _watch(page) -> list[tuple[float, str]]: + gets: list[tuple[float, str]] = [] + page.on( + "requestfinished", + lambda q: ( + q.method == "GET" + and "/design-drawings/" in q.url + and gets.append((time.time(), q.url.rsplit("/", 1)[-1])) + ), + ) + return gets + + +def _settle(page, gets: list, want: set[str], start: int = 0, timeout_s: float = 240) -> None: + """`start` 뒤로 원하는 도면을 다 받고 5 s 동안 새 GET 이 없을 때까지.""" + end = time.time() + timeout_s + while time.time() < end: + got = {name for _t, name in gets[start:]} + quiet = len(gets) == start or time.time() - gets[-1][0] > 5 + if want <= got and quiet: + return + page.wait_for_timeout(500) + raise AssertionError(f"못 받음 {sorted(want - {name for _t, name in gets[start:]})}") + + +def _b07(page) -> None: + page.wait_for_selector(".b07-drawing-button[data-active='true']", timeout=90000) + + +def _open_group(page, kind: str) -> None: + """접힌 목록 묶음을 폄.""" + group = page.locator(f".b07-drawing-group[data-kind='{kind}']") + if "is-collapsed" in (group.get_attribute("class") or ""): + group.locator(".ui-collapsible__title").first.click() + + +def _cut_slope_and_save(page, value: str) -> str: + """B06 첫 횡단 카드 깎기 비탈 → [저장] · 카드 머리 글을 돌려줌.""" + box = page.locator(".b06-design__cutslope-input").first + box.wait_for(timeout=120000) + page.wait_for_timeout(3000) + title = page.locator("article.b06-cross-card").first.inner_text().split("\n")[0] + box.fill(value) + box.press("Enter") + page.wait_for_timeout(800) + with page.expect_response( + lambda r: "/design/save" in r.url and r.request.method == "POST", timeout=180000 + ) as saved: + page.locator("button.ui-btn", has_text="저장").last.click() + assert saved.value.ok, f"B06 저장 실패 {saved.value.status}" + page.wait_for_timeout(2000) + return title + + +def test_b07_미리받기_지문_화면(ctx): + root: Path = ctx.held["root"] + SHOTS.mkdir(parents=True, exist_ok=True) + page = ctx.new_page() + gets = _watch(page) + # 옛 판 자료는 첫 B06 [저장]이 모든 측점 설계에 새 칸을 붙임(한 번만 · 실제 자료 바뀜) — 먼저 한 번 + page.goto(f"{BASE}/#/b06-section", wait_until="domcontentloaded") + _cut_slope_and_save(page, "0.45") + before = _list(ctx) + mark = len(gets) + page.evaluate("location.hash = '#/b07-design-detail'") + _b07(page) + _settle(page, gets, set(before), mark) + order = [name for _t, name in gets[mark:]] + heavy = [i for i, name in enumerate(order) if before[name]["kind"] == "plan"] + light = [i for i, name in enumerate(order) if before[name]["kind"] != "plan"] + print("미리받기 차례", order) + assert len(order) == len(set(order)) == len(before) # 한 장 한 번 + assert max(light) < min(heavy) # 무거운 것 끝 + + # B06 으로 가 측점 하나 고치고 [저장] → B07 로 돌아옴(같은 창 · 페이지 밖 모듈 그대로) + page.evaluate("location.hash = '#/b06-section'") + card = _cut_slope_and_save(page, "0.55") + after = _list(ctx) + changed = {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]} + sheets = {key for key in changed if before[key]["kind"] == "cross"} + stations = { + (key, c) + for key in sheets + for c, value in (after[key]["stations"] or {}).items() + if before[key]["stations"].get(c) != value + } + print(f"고친 카드 「{card}」 · 지문 바뀐 도면 {sorted(changed)} · 측점 {sorted(stations)}") + assert len(sheets) == 1 and len(stations) == 1 # 그 장만 · 그 측점만 + mark = len(gets) + page.evaluate("location.hash = '#/b07-design-detail'") + _b07(page) + _settle(page, gets, changed, mark) + again = [name for _t, name in gets[mark:]] + print("돌아와 다시 받음", again) + assert set(again) == changed and len(again) == len(changed) + + # 되돌림(화면 조작으로 바꾼 값 = 원래 값) — 행 · 파일은 끝에 통째로 되돌림 + page.evaluate("location.hash = '#/b06-section'") + _cut_slope_and_save(page, "0.40") + page.close() + + # 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음 + shutil.rmtree(root / "_derived" / "b07_plan_plan_watershed", ignore_errors=True) + shutil.rmtree(root / "_derived" / "b07_plan_plan_terrain", ignore_errors=True) + files = {p for p in root.rglob("*") if p.is_file() and "B05_Profile" not in p.parts} + url = f"{BASE}/api/projects/{PROJECT}/design-drawings/plan_terrain" + spent = [] + for _ in range(2): + start = time.time() + assert ctx.request.get(url, timeout=120000).ok + spent.append(time.time() - start) + print(f"평면도(지형) 첫 열기 {spent[0]:.2f} s → 둘째(저장본) {spent[1]:.2f} s") + assert spent[1] < spent[0] + gone = {p for p in files if not p.exists()} + assert not gone, f"GET 이 지움 {sorted(map(str, gone))[:5]}" + assert list((root / "_derived" / "b07_plan_plan_terrain").glob("*.json")) + + # 안 받은 평면도(유역도 · 저장본 없음) 열기 → 「받는 중」 → 받은 뒤 보임 + page = ctx.new_page() + page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") + _b07(page) + _open_group(page, "plan") + start = time.time() + page.locator(".b07-drawing-button[data-drawing-id='plan_watershed']").click() + page.wait_for_selector(".ui-toast:has-text('받는 중')", timeout=10000) + page.wait_for_function( + "() => document.querySelector('.b07-drawing-button[data-drawing-id=plan_watershed]')" + "?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'", + timeout=120000, + ) + print(f"유역도 받는 중 → 보임 {time.time() - start:.2f} s") + page.screenshot(path=str(SHOTS / "watershed_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + page.wait_for_timeout(500) + page.screenshot(path=str(SHOTS / "watershed_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.close() + + +def test_b07_다시_받기(ctx): + page = ctx.new_page() + target = "mass_haul" + fails = {"n": 0} + + def fail(route): + fails["n"] += 1 + route.abort() + + page.route(f"**/design-drawings/{target}", fail) + page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") + _b07(page) + button = page.locator(f".b07-drawing-button[data-drawing-id='{target}']") + page.wait_for_function( + f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')" + "?.dataset.fetch === 'error'", + timeout=120000, + ) + print("미리받기 실패 횟수(한 번 다시 포함)", fails["n"]) + assert fails["n"] == 2 + text = page.evaluate( + f"getComputedStyle(document.querySelector('.b07-drawing-button[data-drawing-id={target}]" + " .b07-drawing-button__name'), '::after').content" + ) + assert "다시 받기" in text + panel = button.locator("xpath=..") + panel.screenshot(path=str(SHOTS / "retry_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + panel.screenshot(path=str(SHOTS / "retry_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.unroute(f"**/design-drawings/{target}") + button.click() + page.wait_for_function( + f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')" + "?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'", + timeout=60000, + ) + page.screenshot(path=str(SHOTS / "retry_after.png")) + page.close() 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..9b1011e17 --- /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["noCircles"] # 배수유역도 점선 원 없음(326-6) + assert got["added"] == [[320, "S4"], [420, None]] # 고른 둘만 + assert got["afterClose"] == {"hidden": True} + 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_512_1_b07_basin_contour.py b/resources/tester/test_512_1_b07_basin_contour.py new file mode 100644 index 000000000..964981e51 --- /dev/null +++ b/resources/tester/test_512_1_b07_basin_contour.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""512-1 B07 유역도 등고선 간격 5 m 기본 · 종류 옵션 10 m(임시 폴더 · DB · 실제 마스터 안 씀). + +- 배경선 = 간격 배수 높이만(높이 없는 선은 남김 · None = 전부) +- 유역도 종류 = contour_interval_m 5 기본 · 10 고름 · 1 거절 · 평면도 양식엔 칸 없음 +- 지문 = 간격 바꾸면 유역도만 · 기본 종류를 파일로 내려도 그대로 · 같은 값 = 같은 지문 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_512_1_b07_basin_contour.py -q +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail import B07_DesignDetail_Router_Support_Fingerprint as fp # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _metric_lines # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( # noqa: E402 + list_kinds, + update_kind, +) + + +def _line(level: float | None, x: float) -> dict: + props = {} if level is None else {"등고수치": level} + coords = [[x, 0.0], [x, 1.0]] + return {"properties": props, "geometry": {"type": "LineString", "coordinates": coords}} + + +def test_간격_배수_높이만(tmp_path): + path = tmp_path / "contour.geojson" + features = [_line(775.0, 0), _line(780.0, 1), _line(785.0, 2), _line(None, 3)] + path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), "utf-8") + xs = lambda lines: [line[0][0] for line in lines] # noqa: E731 + assert xs(_metric_lines(path, "EPSG:4326")) == [0, 1, 2, 3] + assert xs(_metric_lines(path, "EPSG:4326", None, 5)) == [0, 1, 2, 3] + assert xs(_metric_lines(path, "EPSG:4326", None, 10)) == [1, 3] + assert xs(_metric_lines(path, "EPSG:4326", (-1, -1, 1.5, 2), 10)) == [1] + + +def _watershed(root: Path) -> dict: + return next(kind for kind in list_kinds(root) if kind["layout"] == "watershed") + + +def test_유역도_종류_간격(tmp_path): + root = tmp_path / "proj" + assert _watershed(root)["contour_interval_m"] == 5 + assert all("contour_interval_m" not in k for k in list_kinds(root) if k["layout"] == "plan") + kind = _watershed(root) + update_kind(root, kind["id"], kind["name"], [], None, 10) + assert _watershed(root)["contour_interval_m"] == 10 + update_kind(root, kind["id"], kind["name"], []) # 간격 안 보냄 = 지금 값 + assert _watershed(root)["contour_interval_m"] == 10 + with pytest.raises(ValueError): + update_kind(root, kind["id"], kind["name"], [], None, 1) + + +def test_지문_유역도만(tmp_path, monkeypatch): + monkeypatch.setattr(fp, "detail_basins_path", lambda stored: tmp_path / "basins.geojson") + root = tmp_path / "proj" + longitudinal = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal.parent.mkdir(parents=True) + longitudinal.write_text(json.dumps({"stations": [{"chainage_m": 0}]}), encoding="utf-8") + context = SimpleNamespace( + vertices=[SimpleNamespace(x=0.0, y=0.0), SimpleNamespace(x=10.0, y=0.0)], + crs="EPSG:5186", + surface_params={}, + stored_path="x", + ) + items = [ + SimpleNamespace(id="cover", kind="cover"), + SimpleNamespace(id="plan_route", kind="plan"), + SimpleNamespace(id="plan_watershed", kind="plan"), + ] + sources = {"title": {}, "designs": {}, "context": context} + + def prints() -> dict: + found = fp.compute_fingerprints(root, longitudinal, items, sources) + return {key: value["fingerprint"] for key, value in found.items()} + + def changed(before: dict, after: dict) -> set: + return {key for key in before if before[key] != after[key]} + + first = prints() + kind = _watershed(root) + update_kind(root, kind["id"], kind["name"], [], None, 5) # 기본 종류를 파일로 내림 · 같은 값 + assert changed(first, prints()) == set() + update_kind(root, kind["id"], kind["name"], [], None, 10) + ten = prints() + assert changed(first, ten) == {"plan_watershed"} + update_kind(root, kind["id"], kind["name"], [], None, 5) + assert prints() == first diff --git a/resources/tester/test_512_2_drainage_update.py b/resources/tester/test_512_2_drainage_update.py new file mode 100644 index 000000000..e09aecb64 --- /dev/null +++ b/resources/tester/test_512_2_drainage_update.py @@ -0,0 +1,68 @@ +"""512-2(요구 326-6) 배수유역도 [업데이트] — 점선 원 · 단추 셋 걷음 · 노선선 = 답의 노선 · B06 [>] 확인 창. + +- 서버 답 `route_xy` = 그 답을 셈한 노선(사업지 m) — 화면 노선선 · 관 자리 · 유역이 늘 짝 + (초안 노선 [업데이트] 뒤 저장 노선을 그려 생기던 「노선 곁 빈 곳」). +- 낡은 답(세부 유역 없음)이 와도 그림이 있으면 옛 그림 그대로 · 관만 받음. +- 흐름(낡음 · 늘 다시 셈 · 저절로 셈 없음)은 test_349 · 화면 실측은 PLAN 512-2. +""" + +from pathlib import Path +from uuid import uuid4 + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_context import DrainageContext +from common_util.common_util_drainage_detail import DrainageDetail, pipes_from_chainages +from common_util.common_util_route_geometry import RouteVertex + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_답에_셈한_노선이_실림(): + vertices = [RouteVertex(10.0, 20.0, 100.0, 0.0), RouteVertex(310.1234, 20.0, 90.0, 300.1234)] + context = DrainageContext(stored_path="x", project_root=ROOT, vertices=vertices) + detail = DrainageDetail(route_lonlat=[], pipes=pipes_from_chainages(vertices, [100.0])) + points = basins.carry_facility_attributes(basins._rebuilt(detail), []) + payload = basins._payload(uuid4(), context, detail, points, False) + assert payload["route_xy"] == [[10.0, 20.0], [310.123, 20.0]] + + +def test_단추는_업데이트_자동제안_둘(): + chrome = _read(B05 / "B05_Profile_UI_Drainage_Chrome.ts") + assert "header.append(updateButton, proposeButton);" in chrome + for gone in ("analyzeButton", "deleteButton", "autoButton"): + assert gone not in chrome + locale = _read(ROOT / "ui_template" / "ui_template_locale_b2.ts") + assert "B05_Drainage_Btn_Auto:" not in locale and "B05_Drainage_Btn_Analyze" not in locale + + +def test_점선_원_없음(): + render = _read(B05 / "B05_Profile_UI_Drainage_Render.ts") + assert "candidates" not in render and "setLineDash" not in render + assert "candidates" not in _read(B05 / "B05_Profile_UI_Drainage_Propose.ts") + + +def test_노선선은_답의_노선_낡은_답은_옛_그림(): + panel = _read(B05 / "B05_Profile_UI_Drainage_Panel.ts") + apply = panel[panel.index("function apply(") : panel.index("function takePipes(")] + assert apply.index("if (keep) return void takePipes(response);") < apply.index("basins = ") + assert "const keep = !!response.stale && basins.length > 0;" in apply + assert "takeRoute(answered, false)" in apply + assert "if (!routeFromAnswer) takeRoute(points, true);" in panel # 페이지 노선 = 첫 답 전만 + + +def test_B06_다음은_낡으면_확인_뒤_업데이트(): + shared = _read(ROOT / "A00_Common" / "design" / "design_shared.ts") + go = shared[shared.index("export function confirmAndGo(") :] + go = go[: go.index("\n}\n", go.index("async function drainageFirst("))] + assert "void drainageFirst(shared).then((ready) => (ready ? go() : undefined));" in go + first = go[go.index("async function drainageFirst(") :] + assert ( + first.index("side?.stale()") + < first.index("showConfirmDialog(") + < first.index("side.update()") + ) diff --git a/resources/tester/test_512_6_drainage_saved_old.py b/resources/tester/test_512_6_drainage_saved_old.py new file mode 100644 index 000000000..ed15a22dd --- /dev/null +++ b/resources/tester/test_512_6_drainage_saved_old.py @@ -0,0 +1,72 @@ +"""512-6(요구 326-6) 노선 [저장] 뒤 새로 연 B05 = 저장된 옛 유역 그림 + 낡음. + +- 낡은 분석(노선 지문 다름)이면 마지막 [저장]의 세부 유역 저장본(`04_detail_cache.pkl`)으로 + 옛 유역 · 옛 노선선을 돌려줌 · 관 자리만 지금 노선 위 · 저장본 없으면 관 자리만(옛 꼴). +- 화면은 열 때 그림이 없으니 그 답을 그대로 그림 + 낡음 점([업데이트] 로만 바꿈 · test_512_2). +""" + +import pickle +from pathlib import Path +from uuid import uuid4 + +from pyproj import Transformer + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_context import DrainageContext +from common_util.common_util_drainage_detail import DrainageDetail, WatershedBasin +from common_util.common_util_route_geometry import RouteVertex + +CRS = "EPSG:5186" +TO = Transformer.from_crs(CRS, "EPSG:4326", always_xy=True) + + +def _context(tmp_path: Path, monkeypatch, shift: float) -> DrainageContext: + monkeypatch.setattr(basins, "drainage_dir", lambda _stored: tmp_path) + vertices = [ + RouteVertex(200000.0 + shift, 500000.0, 100.0, 0.0), + RouteVertex(200300.0 + shift, 500000.0, 90.0, 300.0), + ] + return DrainageContext( + stored_path="x", + project_root=tmp_path, + vertices=vertices, + crs=CRS, + to_lonlat=lambda x, y: TO.transform(x, y), + ) + + +def test_저장본_있으면_옛_그림과_옛_노선(tmp_path, monkeypatch): + old = DrainageDetail( + route_lonlat=[ + list(TO.transform(200000.0, 500000.0)), + list(TO.transform(200300.0, 500000.0)), + ], + basins=[WatershedBasin(index=1, chainage_m=120.0, outlet_x=200120.0, outlet_y=500000.0)], + ) + with (tmp_path / basins.DETAIL_CACHE_FILENAME).open("wb") as file: + pickle.dump({"key": "옛", "value": (old, [])}, file) + context = _context(tmp_path, monkeypatch, shift=17.0) # 노선 17 m 옮김 + points = [basins.PipePoint(chainage_m=150.0, source="user")] + detail = basins._build_stale(context, points) + assert [basin.chainage_m for basin in detail.basins] == [120.0] + assert detail.pipe_candidates == [] + assert abs(detail.pipes[0].x - 200167.0) < 1e-6 # 관 = 지금 노선 위 + payload = basins._payload(uuid4(), context, detail, points, True, stale=True) + assert payload["stale"] and len(payload["basins"]) == 1 + xy = payload["route_xy"] + assert abs(xy[0][0] - 200000.0) < 0.01 and abs(xy[1][0] - 200300.0) < 0.01 # 옛 노선 + + +def test_저장본_없으면_관_자리만(tmp_path, monkeypatch): + context = _context(tmp_path, monkeypatch, shift=17.0) + points = [basins.PipePoint(chainage_m=150.0, source="user")] + detail = basins._build_stale(context, points) + assert detail.basins == [] + payload = basins._payload(uuid4(), context, detail, points, True, stale=True) + assert abs(payload["route_xy"][0][0] - 200017.0) < 0.01 # 지금 노선 + + +def test_깨진_저장본은_없음과_같음(tmp_path, monkeypatch): + (tmp_path / basins.DETAIL_CACHE_FILENAME).write_bytes(b"broken") + monkeypatch.setattr(basins, "drainage_dir", lambda _stored: tmp_path) + assert basins._read_cache_file("x") is None diff --git a/resources/tester/test_513_1_plan_structure_menu.py b/resources/tester/test_513_1_plan_structure_menu.py new file mode 100644 index 000000000..283b2471a --- /dev/null +++ b/resources/tester/test_513_1_plan_structure_menu.py @@ -0,0 +1,63 @@ +"""513-1 B05 평면 우클릭 구조물 메뉴(요구 327-1) — 종단 그래프 우클릭과 같은 항목 · 같은 동작(공용 +`structureMenuItemsAt`) · 자리 = 노선에 내린 누가거리 · 가까운 배관 · 측점 = 삭제 · 노선 밖 · 노드 위 = 메뉴 없음. + +TS 셈은 `helper_513_1_plan_structure_menu.cjs`(Node · 항목 짓기 · 노선 셈은 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_513_1_plan_structure_menu.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_513_1_plan_structure_menu.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_평면_메뉴는_종단_메뉴와_같음(got) -> None: + assert got["liveBefore"] is True # 종단 그래프가 서기 전 = 재료 없음 + assert got["profileEmpty"] == got["planEmpty"] == [["A군", ["배수관"]]] + assert got["profilePipe"] == got["planPipe"] == ["구조물 삭제"] # 글 하나(514-1) + assert got["planPlus"] == ["측점 삭제"] + assert got["planMenuAt"] == [141, -4] + + +def test_같은_동작_같은_자리(got) -> None: + assert got["calls"] == [ + ["addType", 60, "pipe"], # 종단 60 m + ["addType", 60, "pipe"], # 평면 60 m(노선에 내린 자리) + ["remove", "pipe-1"], + ["removePlus", 70], + ] + + +def test_노선_밖_노드_위_떠난_종단은_메뉴_없음(got) -> None: + assert got["planFar"] is None + assert got["planNode"] is None # 노드 삭제(옛 동작) 몫 + assert got["planGone"] is None + + +def test_배선() -> None: + pointer = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text( + encoding="utf-8" + ) + assert "bindPlanStructureMenu(params, nodeAt);" in pointer + viewer = (ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Structures.ts").read_text( + encoding="utf-8" + ) + assert "structureMenuItemsAt(options, options.chainageAt(localX)" in viewer diff --git a/resources/tester/test_513_3_remove_station_owner.py b/resources/tester/test_513_3_remove_station_owner.py new file mode 100644 index 000000000..84769522c --- /dev/null +++ b/resources/tester/test_513_3_remove_station_owner.py @@ -0,0 +1,44 @@ +"""513-3 종단 · 평면 우클릭 「구조물 삭제」 관 아닌 구조물도(요구 327-1) — 측점 주인을 갈라 관 = 관 지점 정본 · +그 밖 = 좌측 칸 [삭제]와 같은 `removeById`(공용 `removeStationOwner`). + +TS 셈은 `helper_513_3_remove_station_owner.cjs`(Node · 주인 가르기는 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_513_3_remove_station_owner.py -q +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_513_3_remove_station_owner.cjs") + + +def test_주인대로_지움() -> None: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["calls"] == [ + ["pipe", 40], + ["structure", "wall-7"], + ["structure", "wall-8"], + ["structure", "wall-8"], + ["pipe", 55], + ["pipe", 12.5], + ] + + +def test_두_보기_배선() -> None: + view = (ROOT / "A00_Common" / "design" / "design_view_profile.ts").read_text(encoding="utf-8") + assert "removeStationOwner(station, isPipeStation(station)" in view + assert "panel.structures.removeById(structureId)" in view + cross = (ROOT / "B06_Section" / "B06_Section_UI_Section_View_Profile.ts").read_text( + encoding="utf-8" + ) + assert "removeStationOwner(station, isPipeStation(station)" in cross + assert "structure: edit.removeStructure" in cross diff --git a/resources/tester/test_514_1_mark_remove.py b/resources/tester/test_514_1_mark_remove.py new file mode 100644 index 000000000..49101a43d --- /dev/null +++ b/resources/tester/test_514_1_mark_remove.py @@ -0,0 +1,65 @@ +"""514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1 · 328-2) — 측점선 없는 구조물(대피로 · 노출형 횡단수로)도 +알약 · 평면 마커 우클릭으로 지움 · 513-3 과 같은 주인 가르기(`removeMarkOwner`) · 삭제 글 = 「구조물 삭제」 하나. + +TS 셈은 `helper_514_1_mark_remove.cjs`(Node · 주인 가르기 · 평면 항목 짓기는 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_514_1_mark_remove.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_514_1_mark_remove.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_알약_주인대로_지움(got) -> None: + assert got["owners"] == [ + ["pipe", 55], # 관 세션 id = 장부 자리 + ["pipe", 12.5], # 옛 자리 이름 관 + ["structure", "evac-1"], + ["structure", "cross-2"], + ] + + +def test_평면_마커_우클릭(got) -> None: + assert got["planMark"] == ["구조물 삭제"] + assert got["planPipe"] == ["구조물 삭제"] # 관도 같은 글 + assert got["planEmpty"] == ["A군"] + assert got["calls"] == [["removeMark", "evac-1"], ["remove", "pipe-1"]] + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +def test_배선() -> None: + marks = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts") + assert 'mark.addEventListener("contextmenu"' in marks + assert '["구조물 삭제", () => remove(id)]' in marks + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert render.count("callbacks?.onStructureMarkRemove?.(structureId)") == 2 # 알약 · 평면 + assert "배관 삭제" not in _read("B05_Profile", "B05_Profile_UI_Profile_Structures.ts") + assert 'toView(shared, "onStructureMarkRemove")' in _read( + "A00_Common", "design", "design_shared.ts" + ) + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "removeMarkOwner(structureId, removeOwner)" in view + cross = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "removeMarkOwner(structureId, {" in cross diff --git a/resources/tester/test_514_2_stale_flow.py b/resources/tester/test_514_2_stale_flow.py new file mode 100644 index 000000000..af9726aab --- /dev/null +++ b/resources/tester/test_514_2_stale_flow.py @@ -0,0 +1,60 @@ +"""514-2 배수유역도 낡은 동안 관 카드 설계유량 「낡음」 가림(요구 328-3). + +판정 한 곳 = 배수유역 판 `live.stale` → `setDrainageStale` · 세월교 · 물넘이 칸 안내 · 높이 자동값이 그것을 봄. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_514_2_stale_flow.cjs") +STALE_HINT = "설계유량 낡음 — 배수유역도 [업데이트] 뒤 셈" + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def run() -> dict: + out = subprocess.run(["node", str(HELPER)], capture_output=True, check=True, cwd=ROOT).stdout + return json.loads(out.decode("utf-8")) + + +def test_안_낡으면_값(run): + assert run["fresh"]["hint"].startswith("필요 수심 ") + assert run["fresh"]["height"] != "" + + +def test_낡으면_가림_높이_안_채움(run): + stale = run["stale"] + assert stale["refreshed"] == 1 # 열린 폼 다시 그림 + assert stale["hint"] == STALE_HINT + assert stale["height"] == "" and stale["afterFill"] == "" + assert stale["repaintSame"] == 0 + + +def test_업데이트_뒤_값(run): + back = run["back"] + assert back["repaint"] == 1 + assert back["hint"].startswith("필요 수심 ") + assert back["afterFill"] != "" # 기다리던 새 시설 높이 = 그때 채움 + + +def test_판정은_배수유역_판_한_곳(): + live = _read("B05_Profile", "B05_Profile_UI_Drainage_Live.ts") + assert "setDrainageStale(stale);" in live + panel = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + assert "!live.stale && fillFordHeights(" in " ".join(panel.split()) + + +def test_B06_폼도_업데이트_뒤_새_유량(): + b06 = " ".join(_read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts").split()) + assert "design_flow_m3s: flowAt(pipe.chainage_m, pipeResponse.basins)," in b06 + assert "const live = deps.shared.lastPipes;" in b06 + assert "entry.design_flow_m3s = flow;" in b06 # adoptFordHeights 가 새 답 유량을 받음 + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "p.design_flow_m3s ?? null," in view # 유량만 바뀌어도 pipesChanged diff --git a/resources/tester/test_514_3_b07_basin_light.py b/resources/tester/test_514_3_b07_basin_light.py new file mode 100644 index 000000000..03e0e66e3 --- /dev/null +++ b/resources/tester/test_514_3_b07_basin_light.py @@ -0,0 +1,50 @@ +"""514-3 — B07 유역도 배경선(등고선 · 계류) = 폴리선 하나 + 꼭짓점 목록(자식 Line 없음). + +CAD 읽기(꼭짓점 → 선분)는 vitest `cad/polyline-points-514-3`. +""" + +import io + +from ezdxf import recover + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import ( + CONTOUR_LAYER_ID, + ROUTE_LAYER_ID, + STREAM_LAYER_ID, + build_watershed_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf + +ROUTE = [(0.0, 0.0), (300.0, 0.0)] +CONTOUR = [(0.0, 50.0), (100.0, 80.0), (200.0, 40.0), (300.0, 90.0)] +STREAM = [(50.0, -100.0), (60.0, 100.0)] + + +def _drawing() -> dict: + return build_watershed_drawing("plan_watershed", ROUTE, [], [CONTOUR], [STREAM]) + + +def _on(drawing: dict, layer: str) -> list[dict]: + return [e for e in drawing["entities"] if e.get("layerId") == layer] + + +def test_background_is_point_list_polyline() -> None: + drawing = _drawing() + (contour,) = _on(drawing, CONTOUR_LAYER_ID) + (stream,) = _on(drawing, STREAM_LAYER_ID) + for entity in (contour, stream): + assert entity["type"] == "PolyLine" and "children" not in entity + assert all(set(p) == {"x", "y"} for p in entity["shapeData"]["points"]) + assert len(contour["shapeData"]["points"]) == 4 + assert len(stream["shapeData"]["points"]) == 2 + # 주제선(노선)은 옛 꼴 그대로 + (route,) = _on(drawing, ROUTE_LAYER_ID) + assert len(route["children"]) == 1 + + +def test_dxf_keeps_background_open() -> None: + data, _skipped = drawing_to_dxf(_drawing()) + doc, _auditor = recover.read(io.BytesIO(data)) + polylines = [p for p in doc.modelspace().query("LWPOLYLINE") if p.dxf.layer.endswith("contour")] + assert len(polylines) == 1 + assert not polylines[0].closed and len(polylines[0]) == 4 diff --git a/resources/tester/test_514_4_recommend_standard_default.py b/resources/tester/test_514_4_recommend_standard_default.py new file mode 100644 index 000000000..24ed55060 --- /dev/null +++ b/resources/tester/test_514_4_recommend_standard_default.py @@ -0,0 +1,52 @@ +"""PLAN 514-4 — 추천 저장본 접근 = 표준 횡단면 「없음」과 config 기본값을 같은 뜻으로. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_514_4_recommend_standard_default.py -q + +전처리 때 표준 없음(None)으로 셈 → B05 · B06 [저장]이 손대지 않은 패널 기본값을 실음 → 화면은 +그 저장본을 꺼냄(409 아님 · 셈 0) · 반대 꼴도 · 사용자가 고친 표준은 옛 저장본을 안 끌어옴. +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TESTER = Path(__file__).resolve().parent +for path in (ROOT, TESTER): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_224_3_recommend_store import _ask, wired # noqa: E402, F401 — 가짜 세계 fixture + +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + + +def _chain_then_screen(wired, before, after) -> list[dict]: # noqa: F811 + wired.standard = before + for mode in DEFAULT_MODES: + _ask(True, mode=mode) + count = wired.count + wired.standard = after + answers = [_ask(False, mode=mode) for mode in DEFAULT_MODES] + assert wired.count == count # 접근 = 셈 0 + return answers + + +def test_전처리_없음_저장_뒤_기본값이면_꺼냄(wired) -> None: # noqa: F811 + answers = _chain_then_screen(wired, None, copy.deepcopy(STANDARD_CROSS_SECTION)) + assert all(isinstance(a, dict) and a["stored"] is True for a in answers), answers + + +def test_전처리_기본값_뒤_없음이어도_꺼냄(wired) -> None: # noqa: F811 + answers = _chain_then_screen(wired, copy.deepcopy(STANDARD_CROSS_SECTION), None) + assert all(isinstance(a, dict) and a["stored"] is True for a in answers), answers + + +def test_고친_표준은_옛_저장본_안_끌어옴(wired) -> None: # noqa: F811 + custom = copy.deepcopy(STANDARD_CROSS_SECTION) + custom["soil"]["road_width_m"] = 4.0 + answers = _chain_then_screen(wired, None, custom) + assert all(getattr(a, "status_code", None) == 409 for a in answers), answers 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_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index a3cbe238e..f6d2dff87 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -53,12 +53,12 @@ def test_겉틀은_본문_칸(): assert "follow: false" in chrome and 'data-act="side-toggle"' not in chrome -def test_393_1_횡단_판_기본_접힘_자동_열림은_기억_안_함(): +def test_393_1_횡단_판_기본_접힘_측점_고름은_판을_열지_않음(): + # 513-2 — 측점 고름 = 내용만 갱신 · 열림 · 닫힘은 손잡이(누름)만 · 자동 열림(393-1) 걷음 chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts") assert "defaultOpen: null," in chrome and "innerWidth >= 1500" not in chrome - # 측점 눈금 → 열되 기억 안 함 · 손잡이(누름)만 기억 cross = _read("B05_Profile_UI_RouteEdit_Cross.ts") - assert 'panels.open("cross", false)' in cross + assert "panels.open(" not in cross and "sidePanelsOf" not in cross side = (ROOT / "ui_template" / "ui_template_side_panels.ts").read_text(encoding="utf-8") assert "function open(id: string | null, remember = true): void {" in side assert "JSON.stringify({ open: kept, width })" in side diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 7b7744f6d..e2a9dab5b 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -72,18 +72,18 @@ def test_노선_고친_뒤_같은_열쇠_측점의_새_발자국(results): } -def test_평면_눈금_클릭은_공용_고름에_적고_판을_편다(results): +def test_평면_눈금_클릭은_공용_고름에_적고_판은_그대로(results): click = results["planClick"] assert click["selection"] == {"chainageM": 40, "structureId": None} assert click["heard"] == [{"at": 40, "from": "b05-plan"}] - assert click["panel"] == "cross" + assert click["panel"] is None # 접힌 판은 접힌 채 내용만(513-2) assert click["watched"]["chainageM"] == 40 def test_다른_보기_일반측점_고름은_평면_판(results): assert results["otherGrid"]["watched"]["chainageM"] == 60 assert "structure" not in results["otherGrid"]["watched"] - assert results["otherGrid"]["panel"] == "cross" + assert results["otherGrid"]["panel"] is None # 513-2 def test_관_구조물_고름은_마름모만(results): @@ -101,9 +101,10 @@ def test_393_판_그림은_B06_카드_사본_노선_고치면_전후(results): assert results["cardsAfterEdit"] == ["b05-prev-s100", "b05-cur-s102"] -def test_393_1_눈금이_연_판은_기억_안_함(results): - assert results["planClick"]["panel"] == "cross" - assert results["planClick"]["remember"] is False +def test_513_2_고름은_판_열림_안_건드림(results): + # 393-1 「눈금이 판을 엶(기억 안 함)」 뒤집음 — 열림 · 닫힘은 손잡이만 + assert results["planClick"]["panel"] is None + assert "remember" not in results["planClick"] def test_393_3_판_칸은_그래프만(): 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_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index 7f2827860..9829d848e 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -379,6 +379,7 @@ def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: "pieces": [], "layout": "watershed", "scale": 6000, + "contour_interval_m": 5, # 512-1 유역도 등고선 간격 칸(기본 5 m) } # 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장 stations = [ diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index d5ba48fce..bcffbcd31 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -225,7 +225,7 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수(): src = ROOT / "B07_DesignDetail/openwebcad/src" ribbon = (src / "ribbon/Ribbon.tsx").read_text("utf-8") quick = (src / "components/QuickAccessBar.tsx").read_text("utf-8") - css = (src / "App.css").read_text("utf-8") + css = (src / "App.css").read_text("utf-8") + (src / "App_Layers.css").read_text("utf-8") assert '' in ribbon assert ribbon.index("cad-ribbon-tabs__slot") < ribbon.index("") flat = " ".join(ribbon.split()) # 줄바꿈 · 들여쓰기 무관 @@ -238,7 +238,10 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수(): assert "--cad-title-height: 0px;" in css and ".cad-titlebar" not in css assert ".cad-qtable" not in css and not (src / "components/QuantityPanel.tsx").exists() assert "QuantityPanel" not in (src / "App.tsx").read_text("utf-8") - assert "html .cad-ribbon-tabs {" in (src / "integration/host-burger.css").read_text("utf-8") + # 508-1f — ☰ 자리 비움이 탭 줄 + 리본 도구 줄 한 묶음(같은 변수) + burger = (src / "integration/host-burger.css").read_text("utf-8") + assert "html .cad-ribbon-tabs,\nhtml .cad-ribbon-panels {" in burger + assert "var(--host-burger-pad, 0px)" in burger def test_여러_장_문서_장_도번_백업_칸(got): 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_m01_draft.py b/resources/tester/test_m01_draft.py index 16f3199fb..4d3152271 100644 --- a/resources/tester/test_m01_draft.py +++ b/resources/tester/test_m01_draft.py @@ -56,6 +56,9 @@ LOOP = """가 = 나 + 1 @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: for path in REAL.glob("*.json"): + # 진짜 폴더에 남은 자체 로직 파일은 안 복사 — 「파일에 안 씀」 판정이 흐려짐 + if path.name == make.OWN: + continue if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, tmp_path / path.name) monkeypatch.setattr(store, "FOLDER", tmp_path) 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/resources/tester/test_modal_top_and_add_word.py b/resources/tester/test_modal_top_and_add_word.py index 4b719fe42..89dc80eb1 100644 --- a/resources/tester/test_modal_top_and_add_word.py +++ b/resources/tester/test_modal_top_and_add_word.py @@ -53,7 +53,9 @@ def test_따로_둔_모달도_위_고정(): m01 = _text("M01_MasterData/M01_MasterData_UI_Style.css") assert "align-items: flex-start;" in _block(m01, ".m01-procure") assert "margin-top: 10vh;" in _block(m01, ".m01-procure__box") - b01 = _text("B01_Dashboard/B01_Dashboard_UI_Style.css") + b01 = _text("B01_Dashboard/B01_Dashboard_UI_Style.css") + _text( + "B01_Dashboard/B01_Dashboard_UI_Style_Company.css" + ) assert "align-items: flex-start;" in _block(b01, ".b01-dashboard__modal") diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 70e00ff94..d4b2a19f8 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -105,8 +105,9 @@ export interface CadLoadExtra { export interface CadHost { /** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */ element: HTMLElement; - /** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */ - beginLoading: () => void; + /** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. + * `watch` 거짓 = 아직 서버에서 받는 중 — 표시만 · 시계 없음(받은 뒤 다시 부름). */ + beginLoading: (watch?: boolean) => void; /** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */ fail: (detail: string) => void; /** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다. @@ -353,10 +354,12 @@ export function createCadHost { + beginLoading: (watch = true) => { element.dataset.loading = "true"; element.dataset.error = ""; // 앞선 실패 표시를 지운다 - startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다 + // 저쪽이 말이 없으면 여기서 끊는다 · 거짓 = 아직 서버에서 받는 중(시계 없음 · 508-3b) + if (watch) startLoadWatchdog(); + else stopLoadWatchdog(); }, fail: (detail) => { stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다 diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a285a8a60..51b1be1e4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -12,7 +12,8 @@ * - ui_template_locale_common.ts : 공통 (액션/상태/폼/네비게이션/워크플로우/앱 셸) * - ui_template_locale_a.ts : A 그룹 (로그인 전 A01~A09) * - ui_template_locale_b1.ts : B 그룹 전반부 (B01~B04) - * - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B11) + * - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B07) + * - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11) * * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ @@ -24,6 +25,7 @@ import { ui_locales_a } from "./ui_template_locale_a"; import { ui_locales_b1 } from "./ui_template_locale_b1"; import { ui_locales_b2 } from "./ui_template_locale_b2"; import { ui_locales_b3 } from "./ui_template_locale_b3"; +import { ui_locales_b5 } from "./ui_template_locale_b5"; import { ui_locales_m1 } from "./ui_template_locale_m1"; import { ui_locales_m2 } from "./ui_template_locale_m2"; @@ -60,6 +62,7 @@ export const ui_locales = { ...ui_locales_b1, ...ui_locales_b2, ...ui_locales_b3, + ...ui_locales_b5, ...ui_locales_m1, ...ui_locales_m2, } as const; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 42187a7d1..4cada153b 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1,6 +1,6 @@ /* ============================================================================= * ui_template_locale_b2.ts - * 다국어 사전 — B 그룹 후반부 (B05_Profile ~ B11_Status) + * 다국어 사전 — B 그룹 후반부 (B05_Profile ~ B07_DesignDetail) · B08~B11 은 `ui_template_locale_b5.ts` * * 워크플로우 진행 단계 라벨은 ui_template_locale_common.ts 의 WF_Step_* 키를 * 재사용한다 (중복 등록 방지). @@ -30,23 +30,6 @@ export const ui_locales_b2 = { "유역 안쪽 상류 세류망을 굵게 강조합니다.", "Emphasize the upstream stream network inside the basin.", ], - // 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다 - // (2026-08-19 사용자 지시 4). - B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"], - B05_Drainage_Btn_Analyze_Tip: [ - "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관을 더하지 않습니다 — 간격 보충은 [자동 제안]. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", - "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts without adding any — use [Auto suggest] to fill spacing gaps. Run the analysis in B04 first if none exists.", - ], - B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"], - B05_Drainage_Btn_DeleteSelected_Tip: [ - "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", - "Removes the culvert selected on the map. Select a culvert marker first.", - ], - B05_Drainage_Btn_Auto: ["배관 배치 초기화", "Reset culvert layout"], - B05_Drainage_Btn_Auto_Tip: [ - "손으로 넣거나 옮기거나 지운 배관을 모두 버리고, 세류선(계곡)이 노선과 만나는 자리에만 배관을 둡니다. 유역 구획도 함께 다시 나뉩니다. 초안이라 [저장] 때 남습니다.", - "Discards every manual culvert edit and keeps culverts only where streams cross the route; sub-basins are re-split as well. This is a draft — it is kept on [Save].", - ], B05_Drainage_Menu_Add: ["배수관 추가", "Add culvert"], B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"], B05_Drainage_Layer_Hotspots: ["집중유역", "Inflow hotspots"], @@ -563,466 +546,17 @@ export const ui_locales_b2 = { "이 장은 측점 여러 개를 담습니다. 지반·계획 정보는 측점 도면에서 봅니다.", "This sheet holds several stations. Ground and plan details live on each station drawing.", ], - - /* --- B08_Quantity 수량 산출 --- */ - B08_Quantity_Title: ["수량산출", "Quantity"], - B08_Quantity_Tab_Earthwork: ["토공", "Earthwork"], - B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], - B08_Quantity_Grid_Empty: [ - "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", - "No cross-section areas yet. Finish the cross-section design first.", - ], - B08_Quantity_Grid_Failed: [ - "토적표를 불러오지 못했습니다.", - "Failed to load the earthwork table.", - ], - B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], - B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], - B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"], - B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], - B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], - B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"], - B08_Quantity_Side_Method_Label: ["시공법", "Method"], - B08_Quantity_Method_Unset: ["안 정함", "Not set"], - B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], - B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], - B08_Quantity_Placing_NotApplied: [ - "콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", - "The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.", - ], - B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], - B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"], - B08_Quantity_Side_TopsoilTarget_Label: ["표토제거 대상", "Topsoil removal target"], - B08_Quantity_TopsoilTarget_RoadAndCut: [ - "노면 + 절토 사면 (기본 · 법 문언)", - "Road surface + cut slope (default · law)", - ], - B08_Quantity_TopsoilTarget_RoadOnly: ["노면만", "Road surface only"], - B08_Quantity_Side_TopsoilTarget_Hint: [ - "기본 근거: 산림자원법 시행규칙 별표2 Ⅰ.2.차.(6) 「노면·절토대상지에 있는 … 표토는 전량 제거한 후 … 운반하고 쌓아두어야 한다」 — 성토 사면은 「제거·정리」라 대상 밖. 노면만: 실무 영월 한 건(1m당 3.34㎡)이 노면만 셈 — 한 건이라 기본으로 삼지 않고 고를 수 있게 둠(면적 약 3배 차이)", - "Default basis: the forest-road rule names road surface and cut areas; fill slopes are only cleared. Road-only follows one field case (Yeongwol, 3.34 ㎡ per m) — selectable, not the default (about 3× area difference)", - ], - B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"], - // ── 확정 3차(2026-09-09) 로 정한 값들 — **화면에 칸으로 세우고 근거를 보인다.** - // 사용자 지시: 「값을 코드에 박고 끝내지 말 것 · 대신 페이지에 남길 것」. - B08_Quantity_Side_Structure: ["구조물·사토", "Structures & Spoil"], - B08_Quantity_Side_BenchCut_Label: ["층따기 길이(m)", "Bench cut length (m)"], - B08_Quantity_Side_BenchCut_Hint: [ - "면적 × 이 값 = ㎥ — 비우면 층따기 줄이 막힙니다(확정 2차 ①)", - "Area × this = ㎥ — blank keeps the bench-cut row blocked", - ], - B08_Quantity_Side_BenchCut_Suggest: [ - "층따기 표준치수 — 최소 높이 토사 500 mm 이상 · 암반 400 mm 이상 · 폭 1 m 이상(기계 작업을 할 때는 3 m 이상). 근거: KDS 44 30 00 도로토공 2.3.4 (4) ② 「기초지반의 종류에 따른 층따기의 표준치수」", - "Bench cut standard dimensions — minimum height: soil ≥ 500 mm · rock ≥ 400 mm · width ≥ 1 m (≥ 3 m for machine work). Basis: KDS 44 30 00 Road Earthwork 2.3.4 (4) ②", - ], - B08_Quantity_Side_Rubble_Label: ["기초잡석 두께(m)", "Rubble base thickness (m)"], - B08_Quantity_Side_Rubble_Hint: [ - "폭은 버림 폭과 같음(KCS 34 50 05) · 두께 0.2m 는 사용자 확정 3차 ② — 품셈 12-25 는 두께를 정하지 않음", - "Width equals the blinding width (KCS 34 50 05); 0.2 m thickness is a user decision — the standard gives none", - ], - B08_Quantity_Side_SpoilDistance_Label: ["사토장까지 거리(m)", "Distance to spoil site (m)"], - B08_Quantity_Side_SpoilDistance_Hint: [ - "사토장을 노선에 놓으면 그 측점까지 거리가 계산되어 이 값은 안 씁니다 — 안 놓았을 때의 대체값입니다", - "Used only until a spoil site is placed on the route — then the distance is computed from that station", - ], - B08_Quantity_Side_Water_Label: ["구조물터파기 용수", "Structure trench water"], - B08_Quantity_Water_Dry: ["육상(용수 없음)", "Dry"], - B08_Quantity_Water_Wet: ["용수", "Wet"], - B08_Quantity_Water_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], - B08_Quantity_Side_Water_Hint: [ - "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", - "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", - ], - B08_Quantity_Side_SeedSpray_Label: ["초류종자살포 비탈면 토질", "Seed spray slope soil"], - B08_Quantity_SeedSpray_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], - B08_Quantity_Side_SeedSpray_Hint: [ - "품셈 5-24 씨앗뿜어붙이기가 5-24-1 기계/일반 · 5-24-2 기계/마사토 둘로 갈림 — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다", - "Pumsem 5-24 splits into general / decomposed-granite soil — no suggestion; unset leaves the bill row with an input reason", - ], - B08_Quantity_Side_RootRemoval_Label: ["제근 굴착기 크기", "Root removal excavator size"], - B08_Quantity_RootRemoval_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], - B08_Quantity_RootRemoval_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"], - B08_Quantity_RootRemoval_FillSuggested: ["제안값 넣기", "Fill suggested"], - B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"], - B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], - B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (30–60 ㎥/ha)"], - B08_Quantity_StandVolume_Mid: ["중림(60~90㎥/㏊)", "Mid (60–90 ㎥/ha)"], - B08_Quantity_StandVolume_High: ["밀림(90㎥/㏊ 이상)", "High (90+ ㎥/ha)"], - B08_Quantity_Side_StandVolume_Hint: [ - "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", - "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", - ], - B08_Quantity_Side_Chipping: ["임목파쇄", "Wood Chipping"], - B08_Quantity_Chipping_Label: ["임목파쇄를 셀 것인가", "Count wood chipping"], - B08_Quantity_Chipping_Off: ["안 셈(기본)", "No (default)"], - B08_Quantity_Chipping_On: ["셈", "Yes"], - B08_Quantity_Chipping_Volume: ["파쇄 부피(㎥)", "Chipping volume (㎥)"], - B08_Quantity_Side_Chipping_Hint: [ - "기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)", - "Off by default — turn on per site. Volume must be entered for the row to carry a quantity", - ], - B08_Quantity_Side_TreeWaste: ["임목폐기물", "Tree Waste"], - B08_Quantity_TreeWaste_Stems: ["표본지 1,000㎡당 본수", "Stems per 1,000 ㎡ (sample plot)"], - B08_Quantity_TreeWaste_Dbh: ["평균 흉고직경(㎝)", "Mean DBH (cm)"], - B08_Quantity_TreeWaste_Height: ["평균 수고(m)", "Mean tree height (m)"], - B08_Quantity_TreeWaste_UnitWeight: ["단위체적중량(㎏/㎥)", "Unit weight (kg/㎥)"], - B08_Quantity_Side_TreeWaste_Hint: [ - "현장 조사값 넷을 넣으면 준비공 「임목폐기물 처리」가 톤으로 섭니다. 산식: 지상부 WA = 0.5 × 3.14 × (흉고직경/2)² × 수고 × (1+0.3) × 단위체적중량 × 본수 · 본수 = 1,000㎡당 본수 × 벌목·지장목제거 면적 — 환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2. 단위체적중량 참고: 소나무·침엽수 1,210~1,250 · 단풍·산벚 1,250~1,300 · 느티 1,300~1,340 · 상수리·졸참 1,340 이상(조경설계요람). 기본값 없음 — 수종·임분마다 다름", - "Enter the four field-survey values to raise the tree-waste row in tonnes. WA = 0.5 × 3.14 × (DBH/2)² × height × 1.3 × unit weight × stems (road guideline appendix 1, KICT 2012). No defaults — values differ by species and stand", - ], - B08_Quantity_TreeWaste_RootMethod: ["뿌리부 산정법", "Root estimate"], - B08_Quantity_TreeWaste_RootRatio: [ - "분배비 WA × 15/85 (기본)", - "Share ratio WA × 15/85 (default)", - ], - B08_Quantity_TreeWaste_RootBall: ["뿌리분 체적 × 1,300㎏/㎥", "Root-ball volume × 1,300 kg/㎥"], - B08_Quantity_TreeWaste_RootBallDiameter: [ - "뿌리분 직경 D(m, 흉고직경 아님) — 체적법만", - "Root-ball diameter D (m, not DBH) — volume method", - ], - B08_Quantity_TreeWaste_Root_Hint: [ - "환경친화적인 도로건설 지침 부록1 이 두 방법을 둡니다. 분배비(기본): 뿌리 15 : 지상 85(일반 수목류, 한국건설기술연구원 2012). 뿌리분 체적: V = πD²(D/2 + RH2/3) = 0.3927D³ · 뿌리분 중량 = V × 1,300㎏/㎥ × 본수. ⚠ 지침은 D 를 「뿌리분의 직경(DBH, m)」으로 적어 흉고직경인지 뿌리분 직경인지 갈리지 않음 — 흉고직경으로 두지 않고 따로 받습니다. 참고: 실무 실정보고(한 현장)는 뿌리 14 : 86 · 0.65톤/㎥ 를 씀", - "Road guideline appendix 1 gives two methods: share ratio 15:85 (default), or root-ball volume 0.3927·D³ × 1,300 kg/㎥ × stems. The guideline labels D ambiguously, so it is entered separately", - ], - B08_Quantity_TreeWaste_Price: ["처리단가(원/톤) — 수동", "Disposal price (KRW/t) — manual"], - B08_Quantity_TreeWaste_Price_Hint: [ - "⚠ 공표 단가 없음(한국건설자원협회 중간처리단가 미확보) — 넣으면 금액이 서되 「미확정」으로 셉니다(빨간 테두리). 비목은 경비 — 원가계산서 폐기물처리비 줄로 가고 일반관리비·이윤 밑수에 듭니다(예정가격작성기준 제19조③18호)", - "No published price — a manual price raises the amount as unconfirmed (red outline). Cost category is expense, inside the overhead and profit bases", - ], - B08_Quantity_TreeWaste_Separate: ["분리발주", "Separate disposal contract"], - B08_Quantity_TreeWaste_Separate_Off: ["분리발주 아님(기본)", "No (default)"], - B08_Quantity_TreeWaste_Separate_On: ["분리발주", "Yes"], - B08_Quantity_TreeWaste_Separate_Hint: [ - "⚠ 임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 · 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물) — 기본 꺼짐으로 원가계산서 경비에 듭니다. 이 칸은 다른 건설폐기물(위탁처리 100톤 이상 분리발주 대상)이 생길 때 쓰는 칸 — 켜면 총원가 밖에 서고 총공사비에만 더합니다. 5톤 이상은 배출자 신고 대상(폐기물관리법 시행령 제2조8호)", - "Tree waste is not subject to separate ordering (Ministry of Environment reply; classified as business general waste) — off by default, inside the expense category. Use this switch for other construction waste (100 t or more). When on, it sits outside total cost. 5 t or more requires a discharge report", - ], - B08_Quantity_Side_FaceDressing: ["면고르기", "Face dressing"], - B08_Quantity_FaceDressing_Cut: ["절토면 토질", "Cut face soil"], - B08_Quantity_FaceDressing_Fill: ["성토면 시공·토질", "Fill face method·soil"], - B08_Quantity_FaceDressing_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], - B08_Quantity_FaceDressing_FillArea: ["성토면 면적(㎡)", "Fill face area (㎡)"], - B08_Quantity_FaceDressing_CutArea: ["절토면 면적(㎡)", "Cut face area (㎡)"], - B08_Quantity_FaceDressing_Area_Placeholder: ["파종 면적 그대로", "Same as seeding area"], - B08_Quantity_FaceDressing_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"], - B08_Quantity_FaceDressing_FillSuggested: ["제안값 넣기", "Fill suggested"], - B08_Quantity_Side_FaceDressing_Hint: [ - "품셈 9-19-1 원문 표 두 벌(절토면 토질 6 · 성토면 시공·토질) — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다. 면적은 비우면 초류종자살포 면적(× 반영률) 그대로 · 넣으면 그 값으로 덮어씀", - "Standard estimate 9-19-1 tables (cut face soil · fill face method/soil) — no suggested value; unset leaves the bill row with an input reason. Area left blank uses the seeding area (× ratio); a value overrides it", - ], - B08_Quantity_Side_SubgradeCompaction: ["노체다짐", "Subgrade Compaction"], - B08_Quantity_SubgradeCompaction_Label: ["노체다짐을 셀 것인가", "Count subgrade compaction"], - B08_Quantity_Side_SubgradeCompaction_Hint: [ - "기본은 안 셉니다 — 근거: 산림품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용한다」. 켜면 토공집계에 「노체다짐」 줄이 성토량(㎥)으로 섭니다. [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 이미 셉니다", - "Off by default — forest standard 9-16-2 note ⑤ applies it only to large fills needing layer compaction. When on, a compaction row equal to the fill volume is added; the excavator of the note ③ pairing is already in the fill (spreading) row", - ], - B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"], - B08_Quantity_Side_Frame_Hint: [ - "⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)", - "Pre-filled values are field observations, not a legal standard — the standard only says “per design quantity”", - ], - B08_Quantity_Frame_Square: ["각재 50×50 (㎥/개소)", "Square timber (㎥/ea)"], - B08_Quantity_Frame_Board: ["판재 T12 (㎥/개소)", "Board T12 (㎥/ea)"], - B08_Quantity_Frame_Nail: ["못 (㎏/개소)", "Nails (㎏/ea)"], - B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], - B08_Quantity_Side_Ancillary_Hint: [ - "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", - "Counts are design input — the source sets no spacing rule, so no formula is used", - ], - B08_Quantity_Ancillary_national_point_sign: ["국가지점번호판(개소)", "National point signs"], - B08_Quantity_Ancillary_guide_sign: ["임도 안내판(개소)", "Guide signs"], - B08_Quantity_Ancillary_gate: ["차단기(개소)", "Gates"], - B08_Quantity_Ancillary_site_container: ["가설창고(개소)", "Site containers"], - B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"], - B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"], - B08_Quantity_Side_TopsoilHaul_Hint: [ - "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다. 표토제거는 노면 + 절토 사면 ㎡ 로 서고, 두께는 운반 부피(㎡ × 두께)에만 씁니다. 제거 품(9-15-2)에 20m 압토가 들어 있어 운반은 20m 를 넘는 몫만 셉니다", - "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row. Removal counts road surface + cut slope in ㎡; thickness only sizes the haul volume. The removal rate already includes a 20 m push, so only the excess is hauled", - ], - B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], - B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], - B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"], - B08_Quantity_Placing_Current: ["적용 중", "In use"], - B08_Quantity_Placing_Default_Tag: ["기본값 — 확인 필요", "default — needs review"], - B08_Quantity_Placing_Hint: ["참고 단가(B09 산출):", "Reference unit price (from B09):"], - B08_Quantity_Placing_Ready: ["레디믹스트", "Ready-mixed"], - B08_Quantity_Placing_Machine: ["기계비빔", "Machine-mixed"], - B08_Quantity_Placing_Hand: ["인력비빔", "Hand-mixed"], - B08_Quantity_Placing_Default_Notice: [ - "⚠ 기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값입니다. 타설 방식에 따라 단가가 갈립니다.", - "⚠ Using the default (ready-mixed) — not yet decided. Unit prices differ by method.", - ], - B08_Quantity_Material_Failed: [ - "자재총괄을 불러오지 못했습니다.", - "Failed to load the material summary.", - ], - B08_Quantity_Haul_Missing: [ - "운반계획이 아직 없습니다. 노선설계에서 [확정]을 누르면 만들어집니다.", - "No haul plan yet. Press [Confirm] on the profile design to build it.", - ], - B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], - B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], - /* 반영률 항목 이름 — 서버 키를 사람이 읽는 말로. 거창 실무 시트 문구를 따른다. */ - B08_Quantity_Ratio_FillCompaction: ["성토면다짐", "Fill slope compaction"], - B08_Quantity_Ratio_SeedFill: ["초류종자살포(성토면)", "Seed spray (fill)"], - B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"], - B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"], - B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], - B08_Quantity_Side_RockRatios: [ - "암 갈래 구성비(%) — 암 총량 기준", - "Rock class split (%) of rock volume", - ], - B08_Quantity_Btn_Save: ["저장", "Save"], - B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], - B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], - B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."], - B08_Quantity_Side_Method: ["산출법", "Method"], - B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], - B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], - B08_Quantity_Factor_Reach: [ - "이 계수는 유토곡선·운반표·기초단가에도 같이 닿습니다.", - "These factors also feed the mass-haul curve, haul table and basis units.", - ], - B08_Quantity_Factor_Default: ["기본값", "Default"], - B08_Quantity_Side_HaulLimits: ["운반장비 거리 경계", "Haul equipment distance limits"], - B08_Quantity_Side_DozerLimit_Label: ["도쟈 한계거리(m)", "Dozer haul limit (m)"], - B08_Quantity_Side_FreeHaul: ["종무대(바꾸지 않음)", "Free haul (fixed)"], - B08_Quantity_Side_HaulLimits_Reach: [ - "유토곡선 운반 배분(무대·도쟈·덤프)에 닿습니다 — 바꾼 뒤 종횡단에서 다시 저장해야 운반표가 따라옵니다.", - "Feeds the mass-haul split (free/dozer/dump) — re-save the profile/cross sections for the haul table to follow.", - ], - B08_Quantity_Factor_Range: ["품셈 범위", "Standard range"], - B08_Quantity_Factor_OutOfRange: [ - "품셈 범위 밖입니다 — 사유를 적어 주세요(토질시험 값일 수 있습니다).", - "Outside the standard range — please note why (it may be a soil-test value).", - ], - B08_Quantity_Factor_Reason: ["사유", "Reason"], - B08_Quantity_Factor_Pumsem: ["품셈 체적변화율(C)", "Standard volume-change (C)"], - - /* --- B09_Estimation 원가계산 --- */ - B09_Estimation_Title: ["원가계산", "Cost Estimate"], - B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], - B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], - B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"], - B09_Estimation_PB_Empty: [ - "설계내역서를 먼저 불러오면 단가산출서가 섭니다.", - "Load the bill first and the price-basis sheets appear.", - ], - B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], - B09_Estimation_PB_Ref: ["참조", "Refers to"], - B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"], - B09_Estimation_Mat_Owner: [ - "관급 자재 — 총원가 밖 별도 표기", - "Owner-supplied — listed outside the total cost", - ], - B09_Estimation_Mat_Unknown: [ - "관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다", - "Supply type undecided — excluded from both totals", - ], - B09_Estimation_Mat_None: [ - "이 프로젝트에는 자재 줄이 없습니다 — 구조물이 없거나 자재가 안 나온 상태입니다.", - "This project has no material rows yet.", - ], - B09_Estimation_Mat_Empty: [ - "설계내역서를 먼저 불러오면 자재대가 섭니다.", - "Load the bill first and the material sheet appears.", - ], - B09_Estimation_Boq_Precision: [ - "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", - "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", - ], - B09_Estimation_Boq_Excluded: [ - "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", - "Check rows — quantity only, never priced", - ], - B09_Estimation_Boq_NotOurs: [ - "여기서 세지 않는 줄 (다른 표에 있거나 이 노선에 없음)", - "Counted elsewhere or not present on this route", - ], - B09_Estimation_Boq_NeedsInput: [ - "입력하면 풀리는 것 (설계 화면에서 값을 고르면 금액이 섭니다)", - "Waiting on input — pick the value on the design screen and the amount appears", - ], - B09_Estimation_Boq_NeedsWork: [ - "우리가 만들어야 하는 것 (원단위·전개식 없음)", - "Needs build — unit data or formula is missing", - ], - B09_Estimation_Boq_Missing: [ - "금액을 못 세운 줄 — 0 으로 안 채웁니다", - "Rows without an amount — shown as-is, not zero-filled", - ], - B09_Estimation_Boq_Materials: ["자재 (별도 벌)", "Materials (separate set)"], - B09_Estimation_Boq_NoMaterialPrice: [ - "사급 자재 단가가 아직 없어 자재비가 빠져 있습니다 — 지금 합계는 모자란 값입니다.", - "Contractor-supplied material prices are missing, so material cost is absent — this total is short.", - ], - B09_Estimation_Boq_Load: ["B08 수량 불러오기", "Load B08 quantities"], - B09_Estimation_Boq_Failed: [ - "B08 인계 자료를 받지 못했습니다.", - "Could not load the B08 handoff.", - ], - B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], - B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], - B09_Estimation_Tab_Machine: ["중기", "Equipment"], - B09_Estimation_Tab_Duration: ["공사기간", "Duration"], - B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], - B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], - B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"], - B09_Estimation_Tab_BasisSheet: ["산출기초", "Calculation Basis"], - B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], - B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], - B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], - B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"], - B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"], - B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"], - B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"], - B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"], - B09_Estimation_Field_WorkType: ["공종", "Work Type"], - B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"], - B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"], - B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"], - B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"], - B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"], - B09_Estimation_Btn_Confirm: ["확정", "Confirm"], - B09_Estimation_Col_Item: ["비목", "Item"], - B09_Estimation_Col_Amount: ["금액", "Amount"], - B09_Estimation_Col_Rate: ["요율", "Rate"], - B09_Estimation_Col_Basis: ["산출근거", "Basis"], - B09_Estimation_Col_Note: ["비고", "Note"], - B09_Estimation_Adopted: ["채택", "Adopted"], - B09_Estimation_NotAdopted: ["미채택", "Not adopted"], - B09_Estimation_Suggest_Adjust: [ - "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", - "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", - ], - B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], - B09_Estimation_Confirm_Success: [ - "원가계산 단계를 확정했습니다.", - "Cost estimate stage confirmed.", - ], - B09_Estimation_Confirm_Failed: [ - "원가계산 단계 확정에 실패했습니다.", - "Failed to confirm the cost estimate stage.", - ], - B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], - B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], - B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], - B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], - B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], - B09_Estimation_Col_Name: ["명칭", "Name"], - B09_Estimation_Col_Spec: ["규격", "Spec"], - B09_Estimation_Col_Unit: ["단위", "Unit"], - B09_Estimation_Col_Qty: ["수량", "Qty"], - B09_Estimation_Col_Source: ["원천", "Source"], - B09_Estimation_Col_Material: ["재료비", "Material"], - B09_Estimation_Col_Labor: ["노무비", "Labor"], - B09_Estimation_Col_Expense: ["경비", "Expense"], - B09_Estimation_Col_Total: ["합계", "Total"], - B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], - B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], - B09_Estimation_UP_RoundGap: [ - "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", - "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", - ], - B09_Estimation_Group_Quantity: ["수량", "Quantities"], - B09_Estimation_Field_Quantities: [ - "공종별 수량 (한 줄에 「공종코드=수량」)", - 'Quantities (one "code=qty" per line)', - ], - B09_Estimation_Src_Manual: ["수량 원천: 수기 입력(직접비)", "Source: manual direct costs"], - B09_Estimation_Src_Quantities: [ - "수량 원천: 수기 입력 공종 수량 × 일위대가", - "Source: manual quantities × unit prices", - ], - B09_Estimation_Missing_UP: [ - "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", - "Quantities without a unit price — excluded from the total:", - ], - B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], - - /* --- B10_Payment 결재 --- */ - B10_Payment_Title: ["결재", "Payment"], - B10_Payment_Subtitle: [ - "세금계산서 발행과 계좌 입금 절차를 확인하세요.", - "Review the tax invoice and bank transfer process.", - ], - B10_Payment_Invoice_Title: ["세금계산서 발행 요청", "Tax Invoice Request"], - B10_Payment_Invoice_Description: [ - "사업자 정보와 발행 금액은 견적 확정 후 연결됩니다.", - "Business details and the invoice amount will be linked after the estimate is finalized.", - ], - B10_Payment_Invoice_Request: ["발행 요청", "Request Invoice"], - B10_Payment_Invoice_Status: ["요청 전", "Not Requested"], - B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"], - B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"], - B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"], - B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"], - B10_Payment_Deposit_Note: [ - "입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.", - "Design documents and DWG downloads are enabled after the deposit is confirmed.", - ], - - /* --- B11_Status 상태 출력 --- */ - B11_Status_Title: ["처리 상태", "Status"], - B11_Status_Subtitle: [ - "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", - "Check payment and document status, and download results.", - ], - // 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재) - B11_Loading_Title: ["자료 준비 중", "Preparing data"], - B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."], - B11_Loading_Message: [ - "작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.", - "Loading the 3D surface and contours so the workspace opens instantly.", - ], - B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"], - B11_Loading_NoProject: [ - "프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.", - "No project selected. Choose a project on the dashboard first.", - ], - B11_Loading_Failed: [ - "자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.", - "Could not prepare the data. The analysis result or storage path may be broken. Please contact support.", - ], - B11_Loading_NoSurface: [ - "아직 확정된 지표면이 없습니다. 필수 파일을 올리면 지표면 분석이 자동으로 진행되고, 끝나면 이 화면이 자료를 준비합니다.", - "No confirmed surface yet. Upload the required files — the surface analysis runs automatically, and this screen prepares the data once it finishes.", - ], - B11_Loading_Btn_FileInput: ["파일 입력", "File input"], - B11_Loading_Btn_Continue: ["계속", "Continue"], - B11_Loading_Btn_Dashboard: ["대시보드", "Dashboard"], - B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], - B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], - B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], - B11_Status_Step_Deposit: ["입금 확인", "Deposit Confirmed"], - B11_Status_Step_Complete: ["완료", "Complete"], - B11_Status_NotStarted: ["시작 전", "Not Started"], - B11_Status_Download_Title: ["결과물 다운로드", "Result Downloads"], - B11_Status_Download_Description: [ - "입금 확인이 완료되면 설계문서와 DWG 다운로드가 활성화됩니다.", - "Design document and DWG downloads are enabled after deposit confirmation.", - ], B06_Std_Note_Rock: ["암", "Rock"], 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", @@ -1039,4 +573,14 @@ export const ui_locales_b2 = { ], Design_Route_Moved_Go: ["B07 로", "Go to B07"], Design_Route_Moved_Stay: ["머묾", "Stay"], + /* 배수유역도 낡은 채 B06 [>](요구 326-6) — 확인 → [업데이트] 뒤 이동 */ + Design_Drainage_Stale: [ + "배수유역도가 지금 노선 · 관 자리보다 낡았음 — 배수유역을 다시 셈(업데이트)한 뒤 B07 로 넘어감.", + "The drainage map is older than the current route and culverts — it will be recomputed (Update) before going to B07.", + ], + Design_Drainage_Stale_Go: ["업데이트 뒤 이동", "Update and go"], + Design_Drainage_Stale_Failed: [ + "배수유역 업데이트 실패 — 머묾.", + "Drainage update failed — staying.", + ], } as const satisfies Record; diff --git a/ui_template/ui_template_locale_b5.ts b/ui_template/ui_template_locale_b5.ts new file mode 100644 index 000000000..bd4c5f6ed --- /dev/null +++ b/ui_template/ui_template_locale_b5.ts @@ -0,0 +1,449 @@ +/* ============================================================================= + * ui_template_locale_b5.ts + * 다국어 사전 — B 그룹 끝 (B08_Quantity ~ B11_Status) + * + * b2 가 700줄을 넘어 B08~B11 칸을 그대로 뗌. 신규 문구는 해당 섹션 최하단에 추가. + * 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_b5 = { + /* --- B08_Quantity 수량 산출 --- */ + B08_Quantity_Title: ["수량산출", "Quantity"], + B08_Quantity_Tab_Earthwork: ["토공", "Earthwork"], + B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], + B08_Quantity_Grid_Empty: [ + "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", + "No cross-section areas yet. Finish the cross-section design first.", + ], + B08_Quantity_Grid_Failed: [ + "토적표를 불러오지 못했습니다.", + "Failed to load the earthwork table.", + ], + B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], + B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], + B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"], + B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], + B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], + B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"], + B08_Quantity_Side_Method_Label: ["시공법", "Method"], + B08_Quantity_Method_Unset: ["안 정함", "Not set"], + B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], + B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], + B08_Quantity_Placing_NotApplied: [ + "콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", + "The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.", + ], + B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], + B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"], + B08_Quantity_Side_TopsoilTarget_Label: ["표토제거 대상", "Topsoil removal target"], + B08_Quantity_TopsoilTarget_RoadAndCut: [ + "노면 + 절토 사면 (기본 · 법 문언)", + "Road surface + cut slope (default · law)", + ], + B08_Quantity_TopsoilTarget_RoadOnly: ["노면만", "Road surface only"], + B08_Quantity_Side_TopsoilTarget_Hint: [ + "기본 근거: 산림자원법 시행규칙 별표2 Ⅰ.2.차.(6) 「노면·절토대상지에 있는 … 표토는 전량 제거한 후 … 운반하고 쌓아두어야 한다」 — 성토 사면은 「제거·정리」라 대상 밖. 노면만: 실무 영월 한 건(1m당 3.34㎡)이 노면만 셈 — 한 건이라 기본으로 삼지 않고 고를 수 있게 둠(면적 약 3배 차이)", + "Default basis: the forest-road rule names road surface and cut areas; fill slopes are only cleared. Road-only follows one field case (Yeongwol, 3.34 ㎡ per m) — selectable, not the default (about 3× area difference)", + ], + B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"], + // ── 확정 3차(2026-09-09) 로 정한 값들 — **화면에 칸으로 세우고 근거를 보인다.** + // 사용자 지시: 「값을 코드에 박고 끝내지 말 것 · 대신 페이지에 남길 것」. + B08_Quantity_Side_Structure: ["구조물·사토", "Structures & Spoil"], + B08_Quantity_Side_BenchCut_Label: ["층따기 길이(m)", "Bench cut length (m)"], + B08_Quantity_Side_BenchCut_Hint: [ + "면적 × 이 값 = ㎥ — 비우면 층따기 줄이 막힙니다(확정 2차 ①)", + "Area × this = ㎥ — blank keeps the bench-cut row blocked", + ], + B08_Quantity_Side_BenchCut_Suggest: [ + "층따기 표준치수 — 최소 높이 토사 500 mm 이상 · 암반 400 mm 이상 · 폭 1 m 이상(기계 작업을 할 때는 3 m 이상). 근거: KDS 44 30 00 도로토공 2.3.4 (4) ② 「기초지반의 종류에 따른 층따기의 표준치수」", + "Bench cut standard dimensions — minimum height: soil ≥ 500 mm · rock ≥ 400 mm · width ≥ 1 m (≥ 3 m for machine work). Basis: KDS 44 30 00 Road Earthwork 2.3.4 (4) ②", + ], + B08_Quantity_Side_Rubble_Label: ["기초잡석 두께(m)", "Rubble base thickness (m)"], + B08_Quantity_Side_Rubble_Hint: [ + "폭은 버림 폭과 같음(KCS 34 50 05) · 두께 0.2m 는 사용자 확정 3차 ② — 품셈 12-25 는 두께를 정하지 않음", + "Width equals the blinding width (KCS 34 50 05); 0.2 m thickness is a user decision — the standard gives none", + ], + B08_Quantity_Side_SpoilDistance_Label: ["사토장까지 거리(m)", "Distance to spoil site (m)"], + B08_Quantity_Side_SpoilDistance_Hint: [ + "사토장을 노선에 놓으면 그 측점까지 거리가 계산되어 이 값은 안 씁니다 — 안 놓았을 때의 대체값입니다", + "Used only until a spoil site is placed on the route — then the distance is computed from that station", + ], + B08_Quantity_Side_Water_Label: ["구조물터파기 용수", "Structure trench water"], + B08_Quantity_Water_Dry: ["육상(용수 없음)", "Dry"], + B08_Quantity_Water_Wet: ["용수", "Wet"], + B08_Quantity_Water_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], + B08_Quantity_Side_Water_Hint: [ + "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", + "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", + ], + B08_Quantity_Side_SeedSpray_Label: ["초류종자살포 비탈면 토질", "Seed spray slope soil"], + B08_Quantity_SeedSpray_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], + B08_Quantity_Side_SeedSpray_Hint: [ + "품셈 5-24 씨앗뿜어붙이기가 5-24-1 기계/일반 · 5-24-2 기계/마사토 둘로 갈림 — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다", + "Pumsem 5-24 splits into general / decomposed-granite soil — no suggestion; unset leaves the bill row with an input reason", + ], + B08_Quantity_Side_RootRemoval_Label: ["제근 굴착기 크기", "Root removal excavator size"], + B08_Quantity_RootRemoval_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], + B08_Quantity_RootRemoval_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"], + B08_Quantity_RootRemoval_FillSuggested: ["제안값 넣기", "Fill suggested"], + B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"], + B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], + B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (30–60 ㎥/ha)"], + B08_Quantity_StandVolume_Mid: ["중림(60~90㎥/㏊)", "Mid (60–90 ㎥/ha)"], + B08_Quantity_StandVolume_High: ["밀림(90㎥/㏊ 이상)", "High (90+ ㎥/ha)"], + B08_Quantity_Side_StandVolume_Hint: [ + "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", + "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", + ], + B08_Quantity_Side_Chipping: ["임목파쇄", "Wood Chipping"], + B08_Quantity_Chipping_Label: ["임목파쇄를 셀 것인가", "Count wood chipping"], + B08_Quantity_Chipping_Off: ["안 셈(기본)", "No (default)"], + B08_Quantity_Chipping_On: ["셈", "Yes"], + B08_Quantity_Chipping_Volume: ["파쇄 부피(㎥)", "Chipping volume (㎥)"], + B08_Quantity_Side_Chipping_Hint: [ + "기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)", + "Off by default — turn on per site. Volume must be entered for the row to carry a quantity", + ], + B08_Quantity_Side_TreeWaste: ["임목폐기물", "Tree Waste"], + B08_Quantity_TreeWaste_Stems: ["표본지 1,000㎡당 본수", "Stems per 1,000 ㎡ (sample plot)"], + B08_Quantity_TreeWaste_Dbh: ["평균 흉고직경(㎝)", "Mean DBH (cm)"], + B08_Quantity_TreeWaste_Height: ["평균 수고(m)", "Mean tree height (m)"], + B08_Quantity_TreeWaste_UnitWeight: ["단위체적중량(㎏/㎥)", "Unit weight (kg/㎥)"], + B08_Quantity_Side_TreeWaste_Hint: [ + "현장 조사값 넷을 넣으면 준비공 「임목폐기물 처리」가 톤으로 섭니다. 산식: 지상부 WA = 0.5 × 3.14 × (흉고직경/2)² × 수고 × (1+0.3) × 단위체적중량 × 본수 · 본수 = 1,000㎡당 본수 × 벌목·지장목제거 면적 — 환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2. 단위체적중량 참고: 소나무·침엽수 1,210~1,250 · 단풍·산벚 1,250~1,300 · 느티 1,300~1,340 · 상수리·졸참 1,340 이상(조경설계요람). 기본값 없음 — 수종·임분마다 다름", + "Enter the four field-survey values to raise the tree-waste row in tonnes. WA = 0.5 × 3.14 × (DBH/2)² × height × 1.3 × unit weight × stems (road guideline appendix 1, KICT 2012). No defaults — values differ by species and stand", + ], + B08_Quantity_TreeWaste_RootMethod: ["뿌리부 산정법", "Root estimate"], + B08_Quantity_TreeWaste_RootRatio: [ + "분배비 WA × 15/85 (기본)", + "Share ratio WA × 15/85 (default)", + ], + B08_Quantity_TreeWaste_RootBall: ["뿌리분 체적 × 1,300㎏/㎥", "Root-ball volume × 1,300 kg/㎥"], + B08_Quantity_TreeWaste_RootBallDiameter: [ + "뿌리분 직경 D(m, 흉고직경 아님) — 체적법만", + "Root-ball diameter D (m, not DBH) — volume method", + ], + B08_Quantity_TreeWaste_Root_Hint: [ + "환경친화적인 도로건설 지침 부록1 이 두 방법을 둡니다. 분배비(기본): 뿌리 15 : 지상 85(일반 수목류, 한국건설기술연구원 2012). 뿌리분 체적: V = πD²(D/2 + RH2/3) = 0.3927D³ · 뿌리분 중량 = V × 1,300㎏/㎥ × 본수. ⚠ 지침은 D 를 「뿌리분의 직경(DBH, m)」으로 적어 흉고직경인지 뿌리분 직경인지 갈리지 않음 — 흉고직경으로 두지 않고 따로 받습니다. 참고: 실무 실정보고(한 현장)는 뿌리 14 : 86 · 0.65톤/㎥ 를 씀", + "Road guideline appendix 1 gives two methods: share ratio 15:85 (default), or root-ball volume 0.3927·D³ × 1,300 kg/㎥ × stems. The guideline labels D ambiguously, so it is entered separately", + ], + B08_Quantity_TreeWaste_Price: ["처리단가(원/톤) — 수동", "Disposal price (KRW/t) — manual"], + B08_Quantity_TreeWaste_Price_Hint: [ + "⚠ 공표 단가 없음(한국건설자원협회 중간처리단가 미확보) — 넣으면 금액이 서되 「미확정」으로 셉니다(빨간 테두리). 비목은 경비 — 원가계산서 폐기물처리비 줄로 가고 일반관리비·이윤 밑수에 듭니다(예정가격작성기준 제19조③18호)", + "No published price — a manual price raises the amount as unconfirmed (red outline). Cost category is expense, inside the overhead and profit bases", + ], + B08_Quantity_TreeWaste_Separate: ["분리발주", "Separate disposal contract"], + B08_Quantity_TreeWaste_Separate_Off: ["분리발주 아님(기본)", "No (default)"], + B08_Quantity_TreeWaste_Separate_On: ["분리발주", "Yes"], + B08_Quantity_TreeWaste_Separate_Hint: [ + "⚠ 임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 · 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물) — 기본 꺼짐으로 원가계산서 경비에 듭니다. 이 칸은 다른 건설폐기물(위탁처리 100톤 이상 분리발주 대상)이 생길 때 쓰는 칸 — 켜면 총원가 밖에 서고 총공사비에만 더합니다. 5톤 이상은 배출자 신고 대상(폐기물관리법 시행령 제2조8호)", + "Tree waste is not subject to separate ordering (Ministry of Environment reply; classified as business general waste) — off by default, inside the expense category. Use this switch for other construction waste (100 t or more). When on, it sits outside total cost. 5 t or more requires a discharge report", + ], + B08_Quantity_Side_FaceDressing: ["면고르기", "Face dressing"], + B08_Quantity_FaceDressing_Cut: ["절토면 토질", "Cut face soil"], + B08_Quantity_FaceDressing_Fill: ["성토면 시공·토질", "Fill face method·soil"], + B08_Quantity_FaceDressing_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], + B08_Quantity_FaceDressing_FillArea: ["성토면 면적(㎡)", "Fill face area (㎡)"], + B08_Quantity_FaceDressing_CutArea: ["절토면 면적(㎡)", "Cut face area (㎡)"], + B08_Quantity_FaceDressing_Area_Placeholder: ["파종 면적 그대로", "Same as seeding area"], + B08_Quantity_FaceDressing_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"], + B08_Quantity_FaceDressing_FillSuggested: ["제안값 넣기", "Fill suggested"], + B08_Quantity_Side_FaceDressing_Hint: [ + "품셈 9-19-1 원문 표 두 벌(절토면 토질 6 · 성토면 시공·토질) — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다. 면적은 비우면 초류종자살포 면적(× 반영률) 그대로 · 넣으면 그 값으로 덮어씀", + "Standard estimate 9-19-1 tables (cut face soil · fill face method/soil) — no suggested value; unset leaves the bill row with an input reason. Area left blank uses the seeding area (× ratio); a value overrides it", + ], + B08_Quantity_Side_SubgradeCompaction: ["노체다짐", "Subgrade Compaction"], + B08_Quantity_SubgradeCompaction_Label: ["노체다짐을 셀 것인가", "Count subgrade compaction"], + B08_Quantity_Side_SubgradeCompaction_Hint: [ + "기본은 안 셉니다 — 근거: 산림품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용한다」. 켜면 토공집계에 「노체다짐」 줄이 성토량(㎥)으로 섭니다. [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 이미 셉니다", + "Off by default — forest standard 9-16-2 note ⑤ applies it only to large fills needing layer compaction. When on, a compaction row equal to the fill volume is added; the excavator of the note ③ pairing is already in the fill (spreading) row", + ], + B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"], + B08_Quantity_Side_Frame_Hint: [ + "⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)", + "Pre-filled values are field observations, not a legal standard — the standard only says “per design quantity”", + ], + B08_Quantity_Frame_Square: ["각재 50×50 (㎥/개소)", "Square timber (㎥/ea)"], + B08_Quantity_Frame_Board: ["판재 T12 (㎥/개소)", "Board T12 (㎥/ea)"], + B08_Quantity_Frame_Nail: ["못 (㎏/개소)", "Nails (㎏/ea)"], + B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], + B08_Quantity_Side_Ancillary_Hint: [ + "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", + "Counts are design input — the source sets no spacing rule, so no formula is used", + ], + B08_Quantity_Ancillary_national_point_sign: ["국가지점번호판(개소)", "National point signs"], + B08_Quantity_Ancillary_guide_sign: ["임도 안내판(개소)", "Guide signs"], + B08_Quantity_Ancillary_gate: ["차단기(개소)", "Gates"], + B08_Quantity_Ancillary_site_container: ["가설창고(개소)", "Site containers"], + B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"], + B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"], + B08_Quantity_Side_TopsoilHaul_Hint: [ + "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다. 표토제거는 노면 + 절토 사면 ㎡ 로 서고, 두께는 운반 부피(㎡ × 두께)에만 씁니다. 제거 품(9-15-2)에 20m 압토가 들어 있어 운반은 20m 를 넘는 몫만 셉니다", + "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row. Removal counts road surface + cut slope in ㎡; thickness only sizes the haul volume. The removal rate already includes a 20 m push, so only the excess is hauled", + ], + B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], + B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], + B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"], + B08_Quantity_Placing_Current: ["적용 중", "In use"], + B08_Quantity_Placing_Default_Tag: ["기본값 — 확인 필요", "default — needs review"], + B08_Quantity_Placing_Hint: ["참고 단가(B09 산출):", "Reference unit price (from B09):"], + B08_Quantity_Placing_Ready: ["레디믹스트", "Ready-mixed"], + B08_Quantity_Placing_Machine: ["기계비빔", "Machine-mixed"], + B08_Quantity_Placing_Hand: ["인력비빔", "Hand-mixed"], + B08_Quantity_Placing_Default_Notice: [ + "⚠ 기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값입니다. 타설 방식에 따라 단가가 갈립니다.", + "⚠ Using the default (ready-mixed) — not yet decided. Unit prices differ by method.", + ], + B08_Quantity_Material_Failed: [ + "자재총괄을 불러오지 못했습니다.", + "Failed to load the material summary.", + ], + B08_Quantity_Haul_Missing: [ + "운반계획이 아직 없습니다. 노선설계에서 [확정]을 누르면 만들어집니다.", + "No haul plan yet. Press [Confirm] on the profile design to build it.", + ], + B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], + B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], + /* 반영률 항목 이름 — 서버 키를 사람이 읽는 말로. 거창 실무 시트 문구를 따른다. */ + B08_Quantity_Ratio_FillCompaction: ["성토면다짐", "Fill slope compaction"], + B08_Quantity_Ratio_SeedFill: ["초류종자살포(성토면)", "Seed spray (fill)"], + B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"], + B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"], + B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], + B08_Quantity_Side_RockRatios: [ + "암 갈래 구성비(%) — 암 총량 기준", + "Rock class split (%) of rock volume", + ], + B08_Quantity_Btn_Save: ["저장", "Save"], + B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], + B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."], + B08_Quantity_Side_Method: ["산출법", "Method"], + B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], + B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], + B08_Quantity_Factor_Reach: [ + "이 계수는 유토곡선·운반표·기초단가에도 같이 닿습니다.", + "These factors also feed the mass-haul curve, haul table and basis units.", + ], + B08_Quantity_Factor_Default: ["기본값", "Default"], + B08_Quantity_Side_HaulLimits: ["운반장비 거리 경계", "Haul equipment distance limits"], + B08_Quantity_Side_DozerLimit_Label: ["도쟈 한계거리(m)", "Dozer haul limit (m)"], + B08_Quantity_Side_FreeHaul: ["종무대(바꾸지 않음)", "Free haul (fixed)"], + B08_Quantity_Side_HaulLimits_Reach: [ + "유토곡선 운반 배분(무대·도쟈·덤프)에 닿습니다 — 바꾼 뒤 종횡단에서 다시 저장해야 운반표가 따라옵니다.", + "Feeds the mass-haul split (free/dozer/dump) — re-save the profile/cross sections for the haul table to follow.", + ], + B08_Quantity_Factor_Range: ["품셈 범위", "Standard range"], + B08_Quantity_Factor_OutOfRange: [ + "품셈 범위 밖입니다 — 사유를 적어 주세요(토질시험 값일 수 있습니다).", + "Outside the standard range — please note why (it may be a soil-test value).", + ], + B08_Quantity_Factor_Reason: ["사유", "Reason"], + B08_Quantity_Factor_Pumsem: ["품셈 체적변화율(C)", "Standard volume-change (C)"], + + /* --- B09_Estimation 원가계산 --- */ + B09_Estimation_Title: ["원가계산", "Cost Estimate"], + B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], + B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], + B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"], + B09_Estimation_PB_Empty: [ + "설계내역서를 먼저 불러오면 단가산출서가 섭니다.", + "Load the bill first and the price-basis sheets appear.", + ], + B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], + B09_Estimation_PB_Ref: ["참조", "Refers to"], + B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"], + B09_Estimation_Mat_Owner: [ + "관급 자재 — 총원가 밖 별도 표기", + "Owner-supplied — listed outside the total cost", + ], + B09_Estimation_Mat_Unknown: [ + "관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다", + "Supply type undecided — excluded from both totals", + ], + B09_Estimation_Mat_None: [ + "이 프로젝트에는 자재 줄이 없습니다 — 구조물이 없거나 자재가 안 나온 상태입니다.", + "This project has no material rows yet.", + ], + B09_Estimation_Mat_Empty: [ + "설계내역서를 먼저 불러오면 자재대가 섭니다.", + "Load the bill first and the material sheet appears.", + ], + B09_Estimation_Boq_Precision: [ + "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", + "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", + ], + B09_Estimation_Boq_Excluded: [ + "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", + "Check rows — quantity only, never priced", + ], + B09_Estimation_Boq_NotOurs: [ + "여기서 세지 않는 줄 (다른 표에 있거나 이 노선에 없음)", + "Counted elsewhere or not present on this route", + ], + B09_Estimation_Boq_NeedsInput: [ + "입력하면 풀리는 것 (설계 화면에서 값을 고르면 금액이 섭니다)", + "Waiting on input — pick the value on the design screen and the amount appears", + ], + B09_Estimation_Boq_NeedsWork: [ + "우리가 만들어야 하는 것 (원단위·전개식 없음)", + "Needs build — unit data or formula is missing", + ], + B09_Estimation_Boq_Missing: [ + "금액을 못 세운 줄 — 0 으로 안 채웁니다", + "Rows without an amount — shown as-is, not zero-filled", + ], + B09_Estimation_Boq_Materials: ["자재 (별도 벌)", "Materials (separate set)"], + B09_Estimation_Boq_NoMaterialPrice: [ + "사급 자재 단가가 아직 없어 자재비가 빠져 있습니다 — 지금 합계는 모자란 값입니다.", + "Contractor-supplied material prices are missing, so material cost is absent — this total is short.", + ], + B09_Estimation_Boq_Load: ["B08 수량 불러오기", "Load B08 quantities"], + B09_Estimation_Boq_Failed: [ + "B08 인계 자료를 받지 못했습니다.", + "Could not load the B08 handoff.", + ], + B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], + B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], + B09_Estimation_Tab_Machine: ["중기", "Equipment"], + B09_Estimation_Tab_Duration: ["공사기간", "Duration"], + B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], + B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], + B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"], + B09_Estimation_Tab_BasisSheet: ["산출기초", "Calculation Basis"], + B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], + B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], + B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], + B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"], + B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"], + B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"], + B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"], + B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"], + B09_Estimation_Field_WorkType: ["공종", "Work Type"], + B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"], + B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"], + B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"], + B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"], + B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"], + B09_Estimation_Btn_Confirm: ["확정", "Confirm"], + B09_Estimation_Col_Item: ["비목", "Item"], + B09_Estimation_Col_Amount: ["금액", "Amount"], + B09_Estimation_Col_Rate: ["요율", "Rate"], + B09_Estimation_Col_Basis: ["산출근거", "Basis"], + B09_Estimation_Col_Note: ["비고", "Note"], + B09_Estimation_Adopted: ["채택", "Adopted"], + B09_Estimation_NotAdopted: ["미채택", "Not adopted"], + B09_Estimation_Suggest_Adjust: [ + "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", + "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", + ], + B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], + B09_Estimation_Confirm_Success: [ + "원가계산 단계를 확정했습니다.", + "Cost estimate stage confirmed.", + ], + B09_Estimation_Confirm_Failed: [ + "원가계산 단계 확정에 실패했습니다.", + "Failed to confirm the cost estimate stage.", + ], + B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], + B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], + B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], + B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], + B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], + B09_Estimation_Col_Name: ["명칭", "Name"], + B09_Estimation_Col_Spec: ["규격", "Spec"], + B09_Estimation_Col_Unit: ["단위", "Unit"], + B09_Estimation_Col_Qty: ["수량", "Qty"], + B09_Estimation_Col_Source: ["원천", "Source"], + B09_Estimation_Col_Material: ["재료비", "Material"], + B09_Estimation_Col_Labor: ["노무비", "Labor"], + B09_Estimation_Col_Expense: ["경비", "Expense"], + B09_Estimation_Col_Total: ["합계", "Total"], + B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], + B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_RoundGap: [ + "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", + "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", + ], + B09_Estimation_Group_Quantity: ["수량", "Quantities"], + B09_Estimation_Field_Quantities: [ + "공종별 수량 (한 줄에 「공종코드=수량」)", + 'Quantities (one "code=qty" per line)', + ], + B09_Estimation_Src_Manual: ["수량 원천: 수기 입력(직접비)", "Source: manual direct costs"], + B09_Estimation_Src_Quantities: [ + "수량 원천: 수기 입력 공종 수량 × 일위대가", + "Source: manual quantities × unit prices", + ], + B09_Estimation_Missing_UP: [ + "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", + "Quantities without a unit price — excluded from the total:", + ], + B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], + + /* --- B10_Payment 결재 --- */ + B10_Payment_Title: ["결재", "Payment"], + B10_Payment_Subtitle: [ + "세금계산서 발행과 계좌 입금 절차를 확인하세요.", + "Review the tax invoice and bank transfer process.", + ], + B10_Payment_Invoice_Title: ["세금계산서 발행 요청", "Tax Invoice Request"], + B10_Payment_Invoice_Description: [ + "사업자 정보와 발행 금액은 견적 확정 후 연결됩니다.", + "Business details and the invoice amount will be linked after the estimate is finalized.", + ], + B10_Payment_Invoice_Request: ["발행 요청", "Request Invoice"], + B10_Payment_Invoice_Status: ["요청 전", "Not Requested"], + B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"], + B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"], + B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"], + B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"], + B10_Payment_Deposit_Note: [ + "입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.", + "Design documents and DWG downloads are enabled after the deposit is confirmed.", + ], + + /* --- B11_Status 상태 출력 --- */ + B11_Status_Title: ["처리 상태", "Status"], + B11_Status_Subtitle: [ + "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", + "Check payment and document status, and download results.", + ], + // 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재) + B11_Loading_Title: ["자료 준비 중", "Preparing data"], + B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."], + B11_Loading_Message: [ + "작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.", + "Loading the 3D surface and contours so the workspace opens instantly.", + ], + B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"], + B11_Loading_NoProject: [ + "프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.", + "No project selected. Choose a project on the dashboard first.", + ], + B11_Loading_Failed: [ + "자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.", + "Could not prepare the data. The analysis result or storage path may be broken. Please contact support.", + ], + B11_Loading_NoSurface: [ + "아직 확정된 지표면이 없습니다. 필수 파일을 올리면 지표면 분석이 자동으로 진행되고, 끝나면 이 화면이 자료를 준비합니다.", + "No confirmed surface yet. Upload the required files — the surface analysis runs automatically, and this screen prepares the data once it finishes.", + ], + B11_Loading_Btn_FileInput: ["파일 입력", "File input"], + B11_Loading_Btn_Continue: ["계속", "Continue"], + B11_Loading_Btn_Dashboard: ["대시보드", "Dashboard"], + B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], + B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], + B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], + B11_Status_Step_Deposit: ["입금 확인", "Deposit Confirmed"], + B11_Status_Step_Complete: ["완료", "Complete"], + B11_Status_NotStarted: ["시작 전", "Not Started"], + B11_Status_Download_Title: ["결과물 다운로드", "Result Downloads"], + B11_Status_Download_Description: [ + "입금 확인이 완료되면 설계문서와 DWG 다운로드가 활성화됩니다.", + "Design document and DWG downloads are enabled after deposit confirmation.", + ], +} as const satisfies Record; 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"],