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_pipeline_base.ts b/A00_Common/design/design_pipeline_base.ts index 0a67c0a40..2491b96d7 100644 --- a/A00_Common/design/design_pipeline_base.ts +++ b/A00_Common/design/design_pipeline_base.ts @@ -8,34 +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, - /** 관 세션 id — 없으면 세션 장부(`withPipeIds`) · 서버 Node 는 장부 사본(511-B). */ - pipeId?: (chainageM: number) => string, ): AlignmentBase | null { - const detail = source.detail ?? null; - const stored = detail && readAlignment(detail.longitudinal); - if (!detail || !stored) return source.base; - const list = (inputs.pipes ?? []) as Array<{ chainage_m: number }>; - const pipes = pipeId - ? list.map((pipe) => ({ ...pipe, id: pipeId(pipe.chainage_m) })) - : withPipeIds(list); - 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_page.ts b/A00_Common/design/design_pipeline_page.ts index 786b9bf63..170bc3d1e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -27,6 +27,8 @@ 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 type { ChangeSource, FollowPatch, @@ -87,9 +89,9 @@ import { createFollowStage, createPlanlineStage, gradeRange, - profileStage, routeRange, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +import { groundPviAt, profileAt } from "./design_calc_profile"; import { draftSectionDetail, shapeStatus, @@ -291,6 +293,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; @@ -307,14 +310,16 @@ function createPagePipeline( 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) => { diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index 4bf4bcfa7..9a11d5618 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -4,50 +4,5 @@ * 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음. * ========================================================================== */ -import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; -import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; -import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; -import { - irregularGraphStations, - maxChainageOf, -} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; -import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; -import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; -import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat"; - -/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이 - * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) · - * 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */ -export function listedBase( - storedBase: AlignmentBase | null, - detail: SectionDetailResponse | null, - irregularStations: IrregularStation[] | null, -): AlignmentBase | null { - if (!storedBase || !detail || !irregularStations) return storedBase; - const server = detail.longitudinal.stations; - const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal)); - const list = oneStationList(server, planted); - const after = new Set(list.map((station) => station.chainage_m.toFixed(3))); - const before = new Set(server.map((station) => station.chainage_m.toFixed(3))); - const gone = server - .filter((station) => station.kind === "irregular" && !station.grid) - .map((station) => station.chainage_m) - .filter((chainage) => !after.has(chainage.toFixed(3))); - const born = list - .map((station) => station.chainage_m) - .filter((chainage) => !before.has(chainage.toFixed(3))); - const moved = followMovedNodes( - storedBase.basePvi, - gone, - born, - Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}), - ); - return { - ...storedBase, - basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held), - stations: list.map((station) => ({ - station_id: station.station_id, - chainage_m: station.chainage_m, - })), - }; -} +// 계획선 측점 줄 = logic(511-B B-3 · 서버 Node 묶음도 같은 셈) — 부르는 쪽은 그대로 여기서 받음 +export { listedBase } from "./logic/design_planted_base"; diff --git a/A00_Common/design/design_route_server.ts b/A00_Common/design/design_route_server.ts new file mode 100644 index 000000000..66a06bcba --- /dev/null +++ b/A00_Common/design/design_route_server.ts @@ -0,0 +1,35 @@ +/* ============================================================================= + * A00_Common/design/design_route_server.ts + * 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node + * `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) · + * 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로). + * ========================================================================== */ + +import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; +import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import { calcOnServerNow } from "./design_calc_sites"; +import { calcOnServerOnly } from "./design_calc_server"; + +export function routeOnServer( + args: DraftArgs, + signal: AbortSignal, +): Promise | null { + if (!calcOnServerNow("route")) return null; + const [points, curveOn, curveRadius, minRadiusM] = args; + const route_polyline = { + points, + curve_flags: curveOn, + radii: curveRadius, + min_radius_m: minRadiusM, + }; + return calcOnServerOnly( + "route", + "route.polyline", + { + run: ["route.polyline"], + inputs: { route_polyline }, + pick: (results) => results["route.polyline"], + }, + signal, + ); +} diff --git a/A00_Common/design/design_stations_server.ts b/A00_Common/design/design_stations_server.ts new file mode 100644 index 000000000..03ca39ab0 --- /dev/null +++ b/A00_Common/design/design_stations_server.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * A00_Common/design/design_stations_server.ts + * 계산 자리 stations 「서버」(PLAN 511-B B-2) — 묶음 stations 단계 측점 목록을 서버 Node + * `stations.list`(같은 `buildStations`)로. 판이 both 면 null(화면 셈) · 서버 값만 · 잠잠 뒤 · + * 끊김 · 실패 = undefined(단계가 화면 셈으로). 구조물 정의는 서버 창구가 붙임. + * ========================================================================== */ + +import { plusStations } from "@util/common_util_structure_station"; +import { calcOnServerNow } from "./design_calc_sites"; +import { calcOnServerOnly } from "./design_calc_server"; +import type { StationsArgs } from "./logic/design_pipeline_stations"; +import type { PipelineStation } from "./logic/design_pipeline_types"; + +export function stationsOnServer( + args: StationsArgs, + signal: AbortSignal, +): Promise | null { + if (!calcOnServerNow("stations")) return null; + const [line, curves, pipes, structures, options] = args; + const stations = { + line, + curves, + pipes, + structures, + types: options.types, + plus: [...(options.plus ?? plusStations())], + interval_m: options.intervalM, + include_endpoint: options.includeEndpoint, + station_snap: options.stationSnap ?? false, + station_snap_step: options.stationSnapStep ?? null, + }; + return calcOnServerOnly( + "stations", + "stations.list", + { run: ["stations.list"], inputs: { stations }, pick: (results) => results["stations.list"] }, + signal, + ); +} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 67b16f8a7..28937013d 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -6,35 +6,10 @@ * 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함). * ========================================================================== */ -import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; -import type { PlannedStation } from "../../common_util/common_util_structure_station"; +// 측점 계획 → 종단 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음 +export { plannedIrregular } from "./logic/design_planted_base"; import { useDesignStore, type DesignStoreValues } from "./design_store"; -/** - * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` · - * 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 = - * 「배관」 꼴(관 끌기 · 지우기 길). - */ -export function plannedIrregular( - planned: ReadonlyArray, - intervalM: number, -): IrregularStation[] { - const interval = intervalM > 0 ? intervalM : 20; - return planned.map((station) => { - const owner = station.owners[0]; - const pipe = owner.startsWith("pipe-") && station.role === "anchor"; - const number = Math.floor(station.chainage_m / interval); - return { - id: station.role === "anchor" ? owner : `${owner}:${station.role}`, - station: number, - remainder: station.chainage_m - number * interval, - chainage_m: station.chainage_m, - structure: station.title, - ...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}), - }; - }); -} - /** * 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 · * 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 8477b25b8..e1717038e 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -16,8 +16,11 @@ * chainage_m? }] · facilities?: { pipes · structures }(시설 구간 · 511-B B-4) } · * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } · * 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) } } + * 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 묶음과 같은 차례). @@ -52,12 +55,24 @@ import { 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 { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs, CrossInputs, TrialInputs, StagesInputs { +interface CalcInputs + extends + FollowInputs, + ProfileInputs, + CarryInputs, + CrossInputs, + TrialInputs, + StagesInputs, + SectionsInputs, + RouteInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -110,10 +125,13 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { 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); 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 index c394c75d1..df21c873b 100644 --- a/A00_Common/design/logic/design_calc_node_stages.ts +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -43,7 +43,7 @@ import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile import type { 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_pipeline_base"; +import { plantedBase } from "./design_planted_base"; import { createDesignPipeline } from "./design_pipeline"; import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs"; import { createStationsStage, snapOptions } from "./design_pipeline_stations"; @@ -152,7 +152,10 @@ async function runStages( remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")), }), planline: createPlanlineStage({ - savedBase: (at) => plantedBase({ base: null, detail }, at, types, book.at), + 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), }), diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 17e250d21..5e7877080 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -64,11 +64,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 +91,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 +120,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 +146,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 +164,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", @@ -245,10 +250,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 +265,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 +279,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 +299,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 +312,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 +325,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 +367,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)", }, /* ── 횡단 ─────────────────────────────────────────────────────────────── */ { diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index 6ff6ef648..1cbdb0d53 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", 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/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 56c5d8784..9d0690dd5 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -10,7 +10,7 @@ (측점 스냅이 뒤 교점을 밀어도 이어받음). - 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · - 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 @@ -153,58 +149,18 @@ def carry_edits( 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`)라 계획고가 그대로. + `half_m` 안 가장 가까운 같은 자리 측점. 셈 = TS `carryEdits` 한 벌(서버 Node `profile.carry` · + 511-B B-3) — 노선 [저장]은 꼭짓점 세움과 한 부름(`carry_seat`). """ - out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}} + from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat + 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 + return {"station_offsets": {}, "curve_radii": {}} + return carry_seat(old_xy, new_xy, old_base, edits, stations, half_m)[0] def carry_policy( 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..ff6b4f6a6 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -48,6 +48,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 +276,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 +554,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 +567,14 @@ def rebuild_alignment_profile( min_curve_length_m=policy.min_curve_length_m or float(criteria.get("min_curve_length_m") or 0.0), ) - alignment = build_alignment( - base_s=base_s, - base_z=base_z, - chainage=chainage, - ground=ground, - stations=list(longitudinal.get("stations") or []), - policy=policy, - edits=edits, + # 선형 = 서버 Node(511-B B-3 · TS `buildAlignment`) · 정본 꼴은 `alignment_record` + alignment = node_alignment( + chainage, + ground, + list(longitudinal.get("stations") or []), + policy.as_dict(), + edits, + base_pvi=base, ) options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index 7ecc75a21..b9b035e71 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -40,6 +40,13 @@ SOURCE_GLOBS = ( "common_util/common_util_structure_station.py", "common_util/common_util_drainage_pipes.py", "config/config_system_design.py", + # 측점 · 지반 = Node `stations.sections`(511-B B-2) — 묶음을 낳는 TS + "B05_Profile/logic/B05_Profile_Sections_*.ts", + "B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts", + "common_util/common_util_structure_station.ts", + "common_util/common_util_structure_catalog.ts", + "common_util/common_util_surface_sampler.ts", + "A00_Common/design/logic/design_calc_node_sections.ts", ) # 직선 위 꼭짓점(438 직선화) 판정 — 앞뒤 기울기 차(비율) _STRAIGHT_EPS = 1e-5 @@ -126,12 +133,19 @@ def initial_longitudinal( ) -> dict[str, Any]: """[적용] 체인이 남길 종단(측점 · 계획선) — 측점 생성 · 균형 얹기와 같은 조각.""" from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile - from B05_Profile.B05_Profile_Engine_Sections import build_sections, resolve_extra_stations + from B05_Profile.B05_Profile_Engine_Sections import ( + build_sections, + resolve_extra_stations, + station_facilities, + ) from B05_Profile.B05_Profile_Router_RouteInitial import seat_base_pvi polyline = [[x, y, 0.0] for x, y in line] pipes, structures, plus = facilities - extras = resolve_extra_stations(project_root, pipes, polyline, structures=structures, plus=plus) + planted = station_facilities(project_root, pipes, polyline, structures=structures, plus=plus) + extras = resolve_extra_stations( + project_root, planted[0], structures=planted[1], plus=planted[2] + ) result, _summary = build_sections( polyline, sampler, @@ -144,6 +158,7 @@ def initial_longitudinal( design_curves=curves, station_snap=True, # [적용] = 스냅 켬 · 측점 No.(`saveRecommendedRoute`) station_snap_step=None, + facilities=planted, ) longitudinal = result["longitudinal"] if target is not None and longitudinal.get("profile_alignment"): diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 4c91bf6dc..31884c6ba 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -34,6 +34,7 @@ from B05_Profile.B05_Profile_Engine_Sections_Names import ( # noqa: F401 — prune_stale_cross_files, valid_cross_names, ) +from B05_Profile.B05_Profile_Engine_Sections_Node import node_sections from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import ( structure_type_map, @@ -257,6 +258,24 @@ def resolve_extra_stations( `xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459). `structures` · `plus` = 파일 대신 따라간 사본(476 추천 초기 종단 · 파일 안 씀). """ + return tuple( + (float(station["chainage_m"]), str(station["title"])) + for station in plan_stations( + *station_facilities(project_root, pipes, xy, structures=structures, plus=plus) + ) + ) + + +def station_facilities( + project_root: Path, + pipes: list[PipePoint], + xy: Sequence[Sequence[float]] | None = None, + *, + structures: list[Any] | None = None, + plus: list[float] | None = None, +) -> tuple[list[PipePoint], list[Any], list[float]]: + """측점을 심는 관 · 구조물 · 추가 측점(노선 밖 뺌) — 파이썬 `plan_stations` · + Node 측점 생성이 같은 입력.""" from B05_Profile.B05_Profile_Plus_Stations import plus_chainages from common_util.common_util_route_follow import route_outside @@ -273,12 +292,7 @@ def resolve_extra_stations( for s in structures if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy) ] - return tuple( - (float(station["chainage_m"]), str(station["title"])) - for station in plan_stations( - pipes, structures, plus_chainages(project_root) if plus is None else plus - ) - ) + return pipes, structures, plus_chainages(project_root) if plus is None else list(plus) def _append_design_profiles( @@ -408,21 +422,28 @@ def build_sections( design_curves: list[dict[str, Any]], station_snap: bool, station_snap_step: float | None, + facilities: tuple[list[Any], list[Any], list[float]] | None = None, ) -> tuple[dict[str, Any], dict[str, Any] | None]: """측점 틀 · 지반 · 1차 계획선(438) · 포장 제안 — 파일 안 씀 · (결과, 계획선 요약). 종횡단 생성(`run_section_generation`)과 추천 후보 초기 종단(476 · `..._RouteInitial_Initial`) - 이 같은 셈 한 벌.""" - result = generate_sections( - polyline, - sampler, - replace(options or SectionGenerationOptions(), extra_stations=extras), - source_snapshot=source_snapshot, - crs=crs, - design_curves=design_curves, - station_snap=station_snap, - station_snap_step=station_snap_step, - ) + 이 같은 셈 한 벌. 측점 · 지반 = 화면과 같은 TS(Node `stations.sections` · 511-B B-2 · + `facilities` = `station_facilities`) · DTM 아닌 지표면 · Node 못 돌림 = + 파이썬 `generate_sections`.""" + generation = replace(options or SectionGenerationOptions(), extra_stations=extras) + shared = { + "crs": crs, + "design_curves": design_curves, + "station_snap": station_snap, + "station_snap_step": station_snap_step, + } + result = ( + node_sections( + polyline, sampler, facilities, generation, source_snapshot=source_snapshot, **shared + ) + if facilities is not None + else None + ) or generate_sections(polyline, sampler, generation, source_snapshot=source_snapshot, **shared) grade_summary = _append_design_profiles( result["longitudinal"], grade_options, @@ -475,10 +496,13 @@ def run_section_generation( # 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다. pipes = _load_pipe_points(project_root, polyline) try: - extras = resolve_extra_stations(project_root, pipes, polyline) + facilities = station_facilities(project_root, pipes, polyline) + extras = resolve_extra_stations( + project_root, facilities[0], structures=facilities[1], plus=facilities[2] + ) except Exception: logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)") - extras = () + facilities, extras = ([], [], []), () logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes)) result, grade_summary = build_sections( @@ -495,6 +519,7 @@ def run_section_generation( design_curves=_load_design_curves(project_root), station_snap=load_station_snap(project_root), station_snap_step=load_station_snap_step(project_root), + facilities=facilities, ) stage_root = project_root / _STAGE_SUBDIR diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 8cc6afa33..203ef2d40 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -16,6 +16,9 @@ (`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` (`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`. +서버 측점 생성 길(`build_sections`)은 DTM 이면 같은 TS(Node `stations.sections` · 511-B B-2 · +`B05_Profile_Engine_Sections_Node.py`) — 이 짝을 쓰는 곳 = DTM 아닌 지표면 · Node 못 돌림 · +구조물 측점만 뜨기(`sample_structure_sections`) · B06 곡선 측점 판정 · 추천 엔진(`_plan_radii` 등). """ import math diff --git a/B05_Profile/B05_Profile_Engine_Sections_Node.py b/B05_Profile/B05_Profile_Engine_Sections_Node.py new file mode 100644 index 000000000..9e86ddb11 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_Sections_Node.py @@ -0,0 +1,193 @@ +"""서버 측점 생성 = 화면과 같은 TS(PLAN 511-B B-2 · 요구 320-4) — 늘 떠 있는 Node +`stations.sections`. + +`build_sections`(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단)가 부름 — 측점 자리 · +종류 · 열쇠 · 반경 · 확폭 · 지반 · 틀 · 상단측 = +`B05_Profile/logic/B05_Profile_Sections_Generate.ts` +(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source · +coordinate_reference · cad_exchange · options)만 붙임. + +DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) · +Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김). +측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + SECTION_SCHEMA_VERSION, + SectionGenerationOptions, +) + +logger = logging.getLogger(__name__) + + +def plan_pipe(pipe: Any) -> dict[str, Any]: + """관 정본 → TS `PlanPipe`(누가거리 그대로 · 파이썬 `plan_stations` 가 읽는 칸).""" + return { + "id": getattr(pipe, "id", None), + "chainage_m": float(pipe.chainage_m), + "facility": getattr(pipe, "facility", None) or "pipe", + "start_m": getattr(pipe, "start_m", None), + "end_m": getattr(pipe, "end_m", None), + "options": getattr(pipe, "options", None) or {}, + } + + +def plan_structure(item: Any) -> dict[str, Any]: + """구조물 정본 → TS `PlanStructure`(JSON 꼴).""" + if hasattr(item, "model_dump"): + return item.model_dump(mode="json") + return dict(item) if isinstance(item, dict) else dict(vars(item)) + + +def structure_table() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """구조물 종류(`/projects/structure-types` 꼴) · 정의(화면 `useCatalogDefinition` 꼴).""" + from B05_Profile.B05_Profile_Structures_Schema import _read_definition, _stamp + from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + + types = [item.model_dump(mode="json") for item in with_visible().types] + return types, {"types": types, **_read_definition(*_stamp())} + + +def stations_input( + xy: list[list[float]], + pipes: list[Any], + structures: list[Any], + plus: list[float], + options: SectionGenerationOptions, + design_curves: list[dict[str, Any]], + station_snap: bool, + station_snap_step: float | None, +) -> dict[str, Any]: + """Node `stations.list` · `stations.sections` 입력(격자 빼고).""" + types, definition = structure_table() + return { + "line": xy, + "curves": design_curves, + "pipes": [plan_pipe(pipe) for pipe in pipes], + "structures": [plan_structure(item) for item in structures], + "types": types, + "definition": definition, + "plus": [float(at) for at in plus], + "interval_m": options.station_interval_m, + "include_endpoint": options.include_endpoint, + "station_snap": station_snap, + "station_snap_step": station_snap_step, + "cross_half_width_m": options.cross_half_width_m, + "cross_sample_interval_m": options.cross_sample_interval_m, + "long_sample_interval_m": options.long_sample_interval_m, + } + + +def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None: + """측점 목록(`buildStations` · 열쇠 · 누가거리 · 종류) — 못 돌림 None.""" + from common_util.common_util_calc_runner import run_calc_served + + answer = run_calc_served(["stations.list"], {"stations": stations}) + if not answer or "error" in answer: + logger.warning("Node 측점 목록 실패: %s", (answer or {}).get("error")) + return None + return answer["results"]["stations.list"] + + +def node_plan_stations( + groups: list[tuple[list[Any], list[Any], list[float]]], +) -> list[list[dict[str, Any]]] | None: + """(관 · 구조물 · 추가 측점) 묶음마다 측점 계획(`planStations` · 파이썬 `plan_stations` 짝) — + 한 Node 부름 · 못 돌림 None(부르는 쪽이 파이썬 짝).""" + from common_util.common_util_calc_runner import run_calc + + if not groups: + return [] + types, definition = structure_table() + plan = [ + { + "pipes": [plan_pipe(pipe) for pipe in pipes], + "structures": [plan_structure(item) for item in structures], + "plus": [float(at) for at in plus], + } + for pipes, structures, plus in groups + ] + try: + results = run_calc( + ["stations.plan"], + {"stations": {"types": types, "definition": definition, "plan": plan}}, + ) + except Exception: # noqa: BLE001 — 파이썬 짝으로 이어 감 + logger.exception("Node 측점 계획 실패 — 파이썬 길") + return None + if results is None: + logger.warning("Node 측점 계획 못 돌림 — 파이썬 길") + return None + return results["stations.plan"] + + +def node_sections( + polyline: list[list[float]], + sampler: Any, + facilities: tuple[list[Any], list[Any], list[float]], + options: SectionGenerationOptions, + *, + source_snapshot: dict[str, Any], + crs: str | None, + design_curves: list[dict[str, Any]], + station_snap: bool, + station_snap_step: float | None, +) -> dict[str, Any] | None: + """`generate_sections` 와 같은 꼴(+ 측점 `key`) — DTM 아님 · Node 못 돌림 = None.""" + from common_util.common_util_calc_runner import run_calc_served + from common_util.common_util_surface_sampler import dtm_grid_file + + source = getattr(sampler, "source", None) + if not source: + return None + options.validate() + pipes, structures, plus = facilities + stations = stations_input( + [[float(p[0]), float(p[1])] for p in polyline], + pipes, + structures, + plus, + options, + design_curves, + station_snap, + station_snap_step, + ) + stations["grid"] = str(dtm_grid_file(source)) + answer = run_calc_served(["stations.sections"], {"stations": stations}) + if not answer or "error" in answer: + logger.warning("Node 측점 생성 실패 — 파이썬 길: %s", (answer or {}).get("error")) + return None + out = answer["results"]["stations.sections"] + return { + "schema_version": SECTION_SCHEMA_VERSION, + "status": "completed", + "source": source_snapshot or {}, + "coordinate_reference": { + "crs": crs, + "world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"}, + "units": {"horizontal": "m", "vertical": "m", "angle": "degree"}, + }, + "cad_exchange": { + "station_origin": "BP", + "chainage_direction": "BP_to_EP", + "cross_offset_sign": {"negative": "right", "positive": "left"}, + "cross_local_axes": {"x": "offset_m", "y": "elevation_m"}, + "recommended_drawing_datum_m": out["datum"], + }, + "options": { + "station_interval_m": options.station_interval_m, + "cross_half_width_m": options.cross_half_width_m, + "cross_sample_interval_m": options.cross_sample_interval_m, + "long_sample_interval_m": options.long_sample_interval_m, + "include_endpoint": options.include_endpoint, + "extra_stations": [list(entry) for entry in options.extra_stations], + }, + "longitudinal": out["longitudinal"], + "cross_sections": out["cross_sections"], + "summary": out["summary"], + } diff --git a/B05_Profile/B05_Profile_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..14ab36cc9 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]: diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index b1104b39e..e772dbac6 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -24,7 +24,6 @@ import asyncio import functools -import json import logging import time from pathlib import Path @@ -38,7 +37,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step -from B05_Profile.B05_Profile_Route_NodeEdits import node_edits +from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 + _curves_path, + _nodes_path, + _read_curves, + _write_planned_polyline, +) from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 RouteInitialCriteria, @@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: return grade_class, design_speed, terrain -def _nodes_path(path: Path) -> Path: - """그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`.""" - return path.with_name(f"{path.stem}_nodes.csv") - - -def _curves_path(path: Path) -> Path: - """그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`. - - 왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가 - 잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터 - 어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다. - """ - return path.with_name(f"{path.stem}_curves.json") - - -def _read_curves(path: Path, key: str = "curves") -> list[dict]: - """저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록.""" - target = _curves_path(path) - if not target.is_file(): - return [] - try: - data = json.loads(target.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target) - return [] - curves = data.get(key) if isinstance(data, dict) else None - return curves if isinstance(curves, list) else [] - - -def _write_planned_polyline( - path: Path, - points: list[tuple[float, float]], - radius_m: float, - *, - simplify: bool = True, - curve_flags: list[bool] | None = None, - radii: list[float | None] | None = None, - station_snap: bool | None = None, - station_snap_step: float | None = None, - locks: list[str | None] | None = None, - arcs: list[float | None] | None = None, -) -> dict: - """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. - - `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) · - `station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.). - `locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`). - - **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 - 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 - 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. - """ - result = build_planned_polyline( - points, - min_radius_m=radius_m, - simplify=simplify, - curve_flags=curve_flags, - radii=radii, - ) - write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) - write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) - table: dict[str, Any] = { - "min_radius_m": round(radius_m, 3), - "curves": [c.as_dict() for c in result.curves], - } - if station_snap is not None: - table["station_snap"] = bool(station_snap) - if station_snap and station_snap_step is not None: - table["station_snap_step"] = float(station_snap_step) - if curve_flags is not None or radii is not None: - table["edits"] = node_edits(points, curve_flags, radii, locks, arcs) - _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") - return { - "nodes": len(result.nodes), - "curves": result.curve_count, - "violations": result.violation_count, - "vertices": len(result.vertices), - } - - def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: """계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다. diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 27faaa26c..ee918e151 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -199,6 +199,32 @@ def _design_grid( } +async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path: + """그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭). + + 경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀). + """ + stored = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT model_type, model_file_path FROM surface_models" + " WHERE id = %s AND project_id = %s", + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not stored or not row or row[0] != "dtm" or not row[1]: + raise LookupError("DTM 격자가 없는 지표면 모델입니다.") + root = Path(resolve_stored_project_path(stored)) + model_path = (root / str(row[1])).resolve() + if not model_path.is_relative_to(root.resolve()): + raise LookupError("DTM 격자가 없는 지표면 모델입니다.") + smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz") + npz_path = smooth_path if smooth and smooth_path.is_file() else model_path + if not npz_path.is_file(): + raise LookupError("DTM 격자 파일이 없습니다.") + return npz_path + + @router.get("/{project_id}/surface/models/{model_id}/grid", response_model=None) async def read_surface_grid( request: Request, project_id: UUID, model_id: int, smooth: bool = False @@ -209,26 +235,9 @@ async def read_surface_grid( """ pool = get_db_pool() async with pool.acquire() as connection: - stored = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - "SELECT model_type, model_file_path FROM surface_models" - " WHERE id = %s AND project_id = %s", - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not stored or not row or row[0] != "dtm" or not row[1]: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "DTM 격자가 없는 지표면 모델입니다."}, - ) - model_path = Path(resolve_stored_project_path(stored)) / str(row[1]) - smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz") - npz_path = smooth_path if smooth and smooth_path.is_file() else model_path - if not npz_path.is_file(): - return JSONResponse( - status_code=404, - content={"status": "error", "message": "DTM 격자 파일이 없습니다."}, - ) + try: + npz_path = await dtm_grid_npz(connection, project_id, model_id, smooth) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) grid_path = await asyncio.to_thread(dtm_grid_file, npz_path) return cached_file_response(request, grid_path, "application/octet-stream") diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 020a16d9d..7a99051da 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest # 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음) # · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음 from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points + from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations from B05_Profile.B05_Profile_Plus_Stations import plus_chainages - planted = [ - float(station["chainage_m"]) - for station in plan_stations( - route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧) - existing, - plus_chainages(root), - ) - ] + # 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝 + group = (route_pipe_points(Path(root)), existing, plus_chainages(root)) + marks = (node_plan_stations([group]) or [plan_stations(*group)])[0] + planted = [float(station["chainage_m"]) for station in marks] legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index a425b540d..1286d9d34 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -10,7 +10,6 @@ * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다. * ========================================================================== */ -import { stationPartsLabel } from "../common_util/common_util_station"; import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog"; import { ESCAPE_ROUTE_DEFAULT_WIDTH_M, @@ -26,6 +25,10 @@ import { } from "@config/config_frontend"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음 +import { irregularLabel } from "./logic/B05_Profile_Profile_Planted"; +export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted"; + export interface IrregularStation { id: string; /** 측점번호 X. */ @@ -126,16 +129,6 @@ interface IrregularStationsCallbacks { onSelect: (station: IrregularStation | null) => void; } -/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */ -export function irregularStationId(id: string): string { - return `irregular:${id}`; -} - -/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */ -export function irregularLabel(station: IrregularStation): string { - return stationPartsLabel(station.station, station.remainder); -} - function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 9fabc1037..066ca8a4a 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -7,6 +7,9 @@ import { type ButtonVariant, createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +// 법정 기준표 = logic(511-B B-3 · X5) — 부르는 쪽은 그대로 여기서 받음 +export { PROFILE_CRITERIA, SPEED_CHOICES, gradeLimitOf } from "./logic/B05_Profile_Grade_Criteria"; + export interface RoutePanelValues { contourInterval: number; /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 @@ -32,44 +35,9 @@ export interface RoutePanelValues { stationDisplay: { station: number; cumulative: number }; } -/** - * 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값. - * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. - * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. - */ /** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */ export const STATION_INTERVAL_M = 20; -/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통, - * 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다 - * (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와 - * 실제 적용값이 어긋나지 않는다. - * - * ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인): - * ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선· - * 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침). - * ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은 - * 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는 - * 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다. - * ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다. - * 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4). - * 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */ -export const PROFILE_CRITERIA: Record< - RoutePanelValues["designSpeed"], - { grade: Record; radius: number } -> = { - 40: { grade: { normal: 7, special: 10 }, radius: 450 }, - 30: { grade: { normal: 8, special: 12 }, radius: 250 }, - 20: { grade: { normal: 9, special: 14 }, radius: 100 }, -}; - -/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */ -export const gradeLimitOf = ( - speed: RoutePanelValues["designSpeed"], - terrain: RoutePanelValues["terrainType"], - typed: number | null, -): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]); - /** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ export const ROAD_TYPE_LABELS: Record = { trunk: "간선임도", @@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record = { branch: "작업임도(구 지선)", }; -/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ -export const SPEED_CHOICES: Record> = { - trunk: [20, 30, 40], - fire: [20, 30, 40], - work: [20], - branch: [20], -}; - export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; export function L(key: keyof typeof ui_locales): string { diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index f5cdbfcd3..c372280a5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -8,15 +8,12 @@ * ========================================================================== */ import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; -import { normalizedLongitudinal } from "./logic/B05_Profile_UI_Profile_Data"; import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table"; -import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; -import { - irregularLabel, - irregularStationId, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; -import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station"; +import type { LongitudinalSection } from "../B06_Section/B06_Section_Api_Fetch"; + +// 최대 누가거리 · 그래프 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음 +import { maxChainageOf } from "./logic/B05_Profile_Profile_Planted"; +export { irregularGraphStations, maxChainageOf } from "./logic/B05_Profile_Profile_Planted"; /** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서 * 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */ @@ -40,39 +37,6 @@ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL */ export const PROFILE_SPACING_MULTIPLIER = 1.5; -/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */ -export function maxChainageOf(data: LongitudinalSection): number { - const samples = normalizedLongitudinal(data).samples.filter( - (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), - ); - return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); -} - -/** - * 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만 - * 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외. - */ -export function irregularGraphStations( - list: IrregularStation[], - maxChainage: number, -): SectionStation[] { - return list - .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) - .map((entry) => ({ - station_id: irregularStationId(entry.id), - chainage_m: entry.chainage_m, - label: irregularLabel(entry), - kind: "irregular" as const, - // 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음 - ...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}), - center_z: null, - azimuth_deg: null, - center_x: 0, - center_y: 0, - frame: { left_xy: [0, 0] as [number, number] }, - })); -} - /** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ export function chainageMapper( data: LongitudinalSection, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13880cf22..ffa7410d0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -23,11 +23,11 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch import { normalizedLongitudinal, toDesignProfile } from "./logic/B05_Profile_UI_Profile_Data"; import { adjustStation, - setCurveRadius, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "./logic/B05_Profile_UI_Profile_Alignment"; +import { setCurveRadius } from "./logic/B05_Profile_UI_Profile_Alignment_Edit"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; import { diff --git a/B05_Profile/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..c2e79a615 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -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 }> => @@ -295,6 +299,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 +321,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 +347,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_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py index a7780ada9..6ee07bea6 100644 --- a/B06_Section/B06_Section_Router_Calc.py +++ b/B06_Section/B06_Section_Router_Calc.py @@ -5,6 +5,7 @@ 브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름 (`design_calc_server.ts`). 권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. +파일 경로 = 서버가 프로젝트 id 로 정함(그 storage 안 DTM 격자) · 입력에 경로 칸 = 400. 실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · 정본 저장 길은 이 창구를 안 씀. 단계(`stage.*`) · 화면 길(`cross.refresh`) = 서버가 DTM 격자 경로 · 구조물 정의를 채움 — @@ -19,7 +20,13 @@ from uuid import UUID from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from common_util.common_util_calc_runner import PAGE_IDS, STAGE_IDS, run_calc_served +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"]) @@ -31,6 +38,38 @@ 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") @@ -51,8 +90,18 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe 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) + 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: @@ -77,16 +126,19 @@ async def _server_context(project_id: UUID, inputs: dict) -> None: ) 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) - inputs["definition"] = load_definition() - if isinstance(inputs.get("stages"), dict): - inputs["stages"]["grid"] = ( - {"path": str(grid), "design": design_terrain(params)} if grid else None - ) + 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/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 96657f539..9ff01ea8d 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -38,6 +38,9 @@ NODE_IDS = frozenset( "excavation.trench", "corridor.mesh", "profile.seat", + "profile.base", + "profile.alignment", + "profile.carry", "facility.plus_follow", "facility.follow", "cross.design", @@ -46,8 +49,14 @@ NODE_IDS = frozenset( "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( { diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index b51b69843..5ea83e911 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -31,8 +31,9 @@ GROUPS: tuple[dict[str, Any], ...] = ( "why": "quantity", }, {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, - # 묶음 단계 다섯(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-2 ~ B-5 끝난 줄부터 엶 - {"key": "route", "nos": ["R1", "R3"], "switchable": False, "fixed": "both", "why": "pair_B5"}, + # 묶음 단계(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}, { "key": "follow", "nos": ["P5", "P6", "F1", "F2"], @@ -40,19 +41,21 @@ GROUPS: tuple[dict[str, Any], ...] = ( "fixed": "both", "why": "pair_B5", }, + # 측점(511-B B-2 · 서버 측점 생성 = Node `stations.sections` · 조작 중 「서버」 = `stations.list`) { "key": "stations", "nos": ["R5", "R6", "R7", "F3"], - "switchable": False, - "fixed": "both", - "why": "pair_B2", + "switchable": True, + "fixed": None, + "why": None, }, + # 종단 = 짝 모음 끝(B-3 · 서버 Node `profile.base` · `profile.alignment`) — 엶 { "key": "profile", "nos": ["P1", "P2", "P3", "P4", "P8"], - "switchable": False, - "fixed": "both", - "why": "pair_B3", + "switchable": True, + "fixed": None, + "why": None, }, # cross = 짝 모음 끝(B-4) — server = 묶음 cross 를 서버 Node 가 같은 TS 로(DTM 아닌 지표면 = 브라우저) { diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 019f89b44..0056e1a98 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -19,6 +19,7 @@ from __future__ import annotations +import logging import math from typing import Any, Iterable, Sequence @@ -33,6 +34,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 +98,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_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/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index b40c9a101..9934379fa 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -5,6 +5,9 @@ "corridor_mesh": "both", "quantity": "both", "carry": "both", + "route": "both", + "stations": "both", + "profile": "both", "cross": "both", "fixed_browser": "both" } 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_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..d4fd974c8 100644 --- a/resources/tester/helper_472_1_paths.py +++ b/resources/tester/helper_472_1_paths.py @@ -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] @@ -179,7 +181,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 +227,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"] 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_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index 8e883105b..8bd6ee21e 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -63,9 +63,12 @@ def test_묶음_여덟_줄_등록부_전부(): "corridor_mesh", "quantity", "carry", + "route", + "stations", + "profile", # 511-B B-3 엶 "cross", # 511-B B-4 끝에 엶 "fixed_browser", - } # 511-A 셋 더 + } # 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["fixed"] == "browser": @@ -156,7 +159,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client): def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" - for bad in ({"profile": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + for bad in ({"follow": "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 == [] diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index c79724ed6..d301cae7e 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -150,12 +150,14 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): calcs = [row for row in rows if not row["head"]] assert len(heads) == 12 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 폼 기본값 whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} - for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3}.items(): + for no, n in {"F2": 5}.items(): assert whys[no] == f"짝 모으는 중 · B-{n}", no 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]] opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 + opened += ["R1", "R3", "R5", "R6", "R7", "F3"] # 511-B B-5 노선 · 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 += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 assert [name.split(" · ")[0] for name in editable] == opened 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_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 7c711a90e..6fc53c51b 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -42,6 +42,9 @@ NODE_IDS = { "excavation.trench", "corridor.mesh", "profile.seat", + "profile.base", + "profile.alignment", + "profile.carry", "facility.plus_follow", "facility.follow", "cross.design", @@ -50,6 +53,10 @@ NODE_IDS = { "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( @@ -108,23 +115,8 @@ GEOMETRY_PULLS = ( # 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀 "B06_Section/B06_Section_Route_Carry_Patch.ts", ) -# 묶음 단계 입구(511-B B-1 · `design_calc_node_stages.ts`) — planline 저장 기준 -# (`plantedBase`)이 종단도 측점 표 · 관 세션 id 파일을 끎(모듈 맨 위 DOM · 세션 없음) -# — B-3 이 logic 으로 옮기면 빈 목록. -STAGE_PULLS = ( - "A00_Common/b_page_state.ts", - "A00_Common/design/design_pipe_ids.ts", - "A00_Common/design/design_pipeline_base.ts", - "A00_Common/design/design_profile_base.ts", - "A00_Common/design/design_selection.ts", - "A00_Common/design/design_store.ts", - "A00_Common/design/design_structure_status.ts", - "B05_Profile/B05_Profile_UI_IrregularStations.ts", - "B05_Profile/B05_Profile_UI_Profile_Layout.ts", - "B05_Profile/B05_Profile_UI_Profile_Table.ts", - "B06_Section/B06_Section_UI_Section_Common.ts", -) -KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) | set(STAGE_PULLS) +# 묶음 단계 입구(511-B B-1) planline 저장 기준(`plantedBase`) 끌림 11 = B-3 이 logic 으로 옮겨 걷음 +KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) def _node(*args: str, timeout: int = 120) -> str: diff --git a/resources/tester/test_472_3_calc_runner.py b/resources/tester/test_472_3_calc_runner.py index 9e223bc18..75e718f8b 100644 --- a/resources/tester/test_472_3_calc_runner.py +++ b/resources/tester/test_472_3_calc_runner.py @@ -69,8 +69,8 @@ def test_실행기_값_server_calc_와_같음() -> None: 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_511_a_calc_server.py b/resources/tester/test_511_a_calc_server.py index 3054674f7..01600c49d 100644 --- a/resources/tester/test_511_a_calc_server.py +++ b/resources/tester/test_511_a_calc_server.py @@ -114,8 +114,8 @@ def test_본문_상한_꼴_모르는_id(client, monkeypatch): 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": ["stations.list"], "inputs": {}}) - assert bad.status_code == 400 and "stations.list" in bad.json()["message"] + bad = http.post(url, json={"run": ["ground.widen"], "inputs": {}}) + assert bad.status_code == 400 and "ground.widen" in bad.json()["message"] def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시(): diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py index 233e04cf5..ad619ac94 100644 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -3,7 +3,8 @@ 못박는 것 - 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 = 파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음. - - 판 = 단계 다섯 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」) · F9 = 폼 기본값 · + - 판 = 단계 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」 · stations = B-2 · route = B-5 가 + 엶) · F9 = 폼 기본값 · 커밋한 판 그대로. - 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎 (측점 = 저장 상세 측점 0.01 m). @@ -76,10 +77,7 @@ def test_단계_id_표_같음_등록부_밖(): def test_판_다섯_줄_잠금_기본값_그대로(): by_key = {g["key"]: g for g in calc_sites.GROUPS} expected = { - "route": (["R1", "R3"], "pair_B5"), "follow": (["P5", "P6", "F1", "F2"], "pair_B5"), - "stations": (["R5", "R6", "R7", "F3"], "pair_B2"), - "profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"), } for key, (nos, why) in expected.items(): group = by_key[key] @@ -96,6 +94,9 @@ def test_판_다섯_줄_잠금_기본값_그대로(): "corridor_mesh", "quantity", "carry", + "route", # B-5 = 엶(511-B B-5) + "stations", # B-2 끝 = 엶(511-B B-2) + "profile", # B-3 엶(511-B · test_511_b3_profile_node) "cross", "fixed_browser", } @@ -107,7 +108,7 @@ def test_판_다섯_줄_잠금_기본값_그대로(): ).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") - for n in (2, 3, 4, 5): + for n in (4, 5): assert f'B01_CalcSites_Why_pair_B{n}: ["짝 모으는 중 · B-{n}"' in locale 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..630b272f4 --- /dev/null +++ b/resources/tester/test_511_b3_profile_node.py @@ -0,0 +1,207 @@ +# -*- 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_Carry import carry_edits # noqa: E402 +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 한 부름 · + 편집만 부르는 겉(`carry_edits`)과 같은 편집.""" + 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_edits(old_xy, new_xy, base, edits, stations, 10.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) + assert carry_edits(old_xy, new_xy, [], edits, stations, 10.0) == { + "station_offsets": {}, + "curve_radii": {}, + } 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..8d7b9f9c3 --- /dev/null +++ b/resources/tester/test_511_b5_route_node.py @@ -0,0 +1,321 @@ +"""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 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") 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_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/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index e30a90e35..cce74f0d4 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -115,8 +115,18 @@ export const ui_locales_m1 = { "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", "Off = server calc after a pause · F10 = no browser call", ], - B01_CalcSites_Why_pair_B2: ["짝 모으는 중 · B-2", "Merging pairs · B-2"], - B01_CalcSites_Why_pair_B3: ["짝 모으는 중 · B-3", "Merging pairs · B-3"], + B01_CalcSites_Why_route: [ + "끄면 잠잠 뒤 서버 노선(같은 TS) · 노선 [저장] = 서버 Node", + "Off = server route after a pause (same TS) · route [Save] = 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_pair_B4: ["짝 모으는 중 · B-4", "Merging pairs · B-4"], B01_CalcSites_Why_cross: [ "끄면 횡단을 서버 Node 가 셈(잠잠 뒤 · 왕복) · DTM 아닌 지표면 = 브라우저",