merge(511-B 합치기): origin/sub_laptop_3(B-2 측점 · B-3 종단 · /calc 경로 막기 · B-5 route) 받음 — 공유 파일 충돌 한 번 풂
- 판 calc_sites.json = route · stations · profile · cross 모두 both · 열린 줄 = 네 줄 모두 · 잠긴 단계 줄 = follow(pair_B5) 하나
- Node 입구 design_calc_node.ts = 입력 꼴 · runner 표 양쪽(cross · sections · route · profile) · NODE_IDS = facility.attach · cross.enforce · stations.list · stations.sections · route.polyline · stations.plan
- /calc 창구 Router_Calc = 서버 측점 생성 거절 · 경로 칸 거절 · {model_id} 격자 서버가 정함(B-2 · 경로 막기) 뒤 단계 · 화면 길 서버 맥락(B-4) — 단계 격자 칸이 없을 때만 설계 지형 격자로 채움
- 시험 309_2 · 309_2_browser · 472_3 · 511_b1 = 양쪽 열린 줄 합 · 잠금 예시 = follow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* design_calc_profile.ts
|
||||
* 계산 자리 「서버」 종단 길(PLAN 511-B B-3) — 판(`design_calc_sites.ts`)이 profile 묶음을 server 로 둔 때
|
||||
* 묶음 planline 의 새 지반 첫 계획선 꼭짓점(P1 · `profile.base`)과 profile 단계 계획선 짓기(P2 ~ P4 · P8 ·
|
||||
* `profile.alignment`)를 서버 Node 가 같은 TS 로 셈(창구 `POST /projects/{id}/calc`). both 면 브라우저 그대로.
|
||||
* 서버 실패(끊김 밖) = 경고 한 줄 · 브라우저 값.
|
||||
* ========================================================================== */
|
||||
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { serverCalc } from "./design_calc_server";
|
||||
import type { StageFn } from "./logic/design_pipeline_types";
|
||||
import {
|
||||
groundFollowingBasePvi,
|
||||
groundProfile,
|
||||
type GroundFollowingInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { profileStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
emptyEdits,
|
||||
type AlignmentNode,
|
||||
type ProfileAlignment,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const KEY = "profile";
|
||||
const r6 = (value: number): number => Number(value.toFixed(6));
|
||||
|
||||
/** 서버 값 — 끊김은 그대로 던짐(묶음이 버림) · 그 밖 실패는 브라우저 값. */
|
||||
async function orLocal<T>(signal: AbortSignal, remote: () => Promise<T>, local: () => T) {
|
||||
try {
|
||||
return await remote();
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
console.warn("[calc-server] profile 실패 — 브라우저 값", error);
|
||||
return local();
|
||||
}
|
||||
}
|
||||
|
||||
/** planline `groundPvi` — 서버면 첫 계획선 꼭짓점 약속(0.000001 m · `groundFollowingBasePvi` 꼴) · 아니면 null
|
||||
* (브라우저 셈 · 단계 동기). 판은 앞 ground 단계가 이미 받음(`calcOnServerNow`). */
|
||||
export function groundPviAt(
|
||||
input: GroundFollowingInput,
|
||||
signal: AbortSignal,
|
||||
): Promise<AlignmentNode[] | null> | null {
|
||||
if (!calcOnServerNow(KEY)) return null;
|
||||
const local = () => groundFollowingBasePvi(input);
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
return orLocal(
|
||||
signal,
|
||||
async () => {
|
||||
const follow = {
|
||||
stations: input.stations,
|
||||
start_offset_m: input.startOffsetM ?? 0,
|
||||
end_offset_m: input.endOffsetM ?? 0,
|
||||
};
|
||||
const inputs = { profile: { ...profile, policy: input.policy, ground_following: follow } };
|
||||
const results = await serverCalc(KEY, ["profile.base"], inputs, signal);
|
||||
const nodes = results["profile.base"] as AlignmentNode[] | null;
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: r6(node.chainage_m),
|
||||
elevation_m: r6(node.elevation_m),
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
local,
|
||||
);
|
||||
}
|
||||
|
||||
/** profile 단계 — 첫 계획선 + 계획고 편집 → 종단(서버면 `profile.alignment` · 아니면 동기 그대로). */
|
||||
export const profileAt: StageFn<"profile"> = (ctx) => {
|
||||
const base = ctx.out.planline;
|
||||
if (!base || !calcOnServerNow(KEY)) return profileStage(ctx);
|
||||
return orLocal(
|
||||
ctx.signal,
|
||||
async () => {
|
||||
const profile = {
|
||||
chainage: base.chainage,
|
||||
ground: base.ground,
|
||||
policy: base.policy,
|
||||
stations: base.stations,
|
||||
base_pvi: base.basePvi,
|
||||
edits: ctx.inputs.alignment ?? emptyEdits(),
|
||||
only_explicit_curves: base.onlyExplicitCurves,
|
||||
};
|
||||
const results = await serverCalc(KEY, ["profile.alignment"], { profile }, ctx.signal);
|
||||
return results["profile.alignment"] as ProfileAlignment;
|
||||
},
|
||||
() => profileStage(ctx) as ProfileAlignment,
|
||||
);
|
||||
};
|
||||
@@ -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<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
/** 관 세션 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<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes));
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -4,50 +4,5 @@
|
||||
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// 계획선 측점 줄 = logic(511-B B-3 · 서버 Node 묶음도 같은 셈) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { listedBase } from "./logic/design_planted_base";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_route_server.ts
|
||||
* 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node
|
||||
* `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) ·
|
||||
* 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
|
||||
export function routeOnServer(
|
||||
args: DraftArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<EditedPolyline | undefined> | null {
|
||||
if (!calcOnServerNow("route")) return null;
|
||||
const [points, curveOn, curveRadius, minRadiusM] = args;
|
||||
const route_polyline = {
|
||||
points,
|
||||
curve_flags: curveOn,
|
||||
radii: curveRadius,
|
||||
min_radius_m: minRadiusM,
|
||||
};
|
||||
return calcOnServerOnly<EditedPolyline>(
|
||||
"route",
|
||||
"route.polyline",
|
||||
{
|
||||
run: ["route.polyline"],
|
||||
inputs: { route_polyline },
|
||||
pick: (results) => results["route.polyline"],
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_stations_server.ts
|
||||
* 계산 자리 stations 「서버」(PLAN 511-B B-2) — 묶음 stations 단계 측점 목록을 서버 Node
|
||||
* `stations.list`(같은 `buildStations`)로. 판이 both 면 null(화면 셈) · 서버 값만 · 잠잠 뒤 ·
|
||||
* 끊김 · 실패 = undefined(단계가 화면 셈으로). 구조물 정의는 서버 창구가 붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { plusStations } from "@util/common_util_structure_station";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import type { StationsArgs } from "./logic/design_pipeline_stations";
|
||||
import type { PipelineStation } from "./logic/design_pipeline_types";
|
||||
|
||||
export function stationsOnServer(
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<PipelineStation[] | undefined> | null {
|
||||
if (!calcOnServerNow("stations")) return null;
|
||||
const [line, curves, pipes, structures, options] = args;
|
||||
const stations = {
|
||||
line,
|
||||
curves,
|
||||
pipes,
|
||||
structures,
|
||||
types: options.types,
|
||||
plus: [...(options.plus ?? plusStations())],
|
||||
interval_m: options.intervalM,
|
||||
include_endpoint: options.includeEndpoint,
|
||||
station_snap: options.stationSnap ?? false,
|
||||
station_snap_step: options.stationSnapStep ?? null,
|
||||
};
|
||||
return calcOnServerOnly<PipelineStation[]>(
|
||||
"stations",
|
||||
"stations.list",
|
||||
{ run: ["stations.list"], inputs: { stations }, pick: (results) => results["stations.list"] },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -6,35 +6,10 @@
|
||||
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
// 측점 계획 → 종단 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { plannedIrregular } from "./logic/design_planted_base";
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||
|
||||
@@ -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<Parameters<typeof computeHaulPlan>[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<string, () => unknown> = {
|
||||
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
||||
...profileRunners(inputs), // 첫 계획선 · 계획선 짓기 · 계획고 옮김(511-B B-3)
|
||||
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
||||
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
|
||||
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
|
||||
...crossRunners(inputs), // 형상 얹기 · [저장] 횡단 한 바퀴(511-B B-4)
|
||||
...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2)
|
||||
...routeRunners(inputs), // 노선 폴리라인 · 곡선표(511-B B-5)
|
||||
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
|
||||
"structure.layouts": () => {
|
||||
const areas = structureAreaRows(sections);
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
* base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`)
|
||||
* facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line ·
|
||||
* max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`)
|
||||
* — follow.fill { previous?: { structure_id: 항목 } · only_missing } 있으면 = 저장 좌표 맞춤(같은 F1 ·
|
||||
* 511-B B-5) → { xy: [{ x · y } | null(그대로)] · filled }(`fillStructureCoordinates` · items 에 structure_id)
|
||||
* facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } →
|
||||
* { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 }
|
||||
* ========================================================================== */
|
||||
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import {
|
||||
fillStructureCoordinates,
|
||||
followPlusRows,
|
||||
followStructures,
|
||||
type FollowingStructure,
|
||||
@@ -27,10 +30,11 @@ const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface FollowInputs {
|
||||
follow?: {
|
||||
items: FollowingStructure[];
|
||||
items: Array<FollowingStructure & { structure_id?: string | null }>;
|
||||
line: Line;
|
||||
max_drift_m?: number;
|
||||
interval_m?: number | null;
|
||||
fill?: { previous?: Record<string, FollowingStructure> | null; only_missing?: boolean };
|
||||
};
|
||||
plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line };
|
||||
seat?: {
|
||||
@@ -61,6 +65,14 @@ export function followRunners(inputs: FollowInputs): Record<string, () => unknow
|
||||
"facility.follow": () => {
|
||||
const follow = inputs.follow;
|
||||
if (!follow) return null;
|
||||
if (follow.fill)
|
||||
return fillStructureCoordinates(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
follow.fill.previous ?? null,
|
||||
follow.fill.only_missing ?? false,
|
||||
follow.interval_m ?? null,
|
||||
);
|
||||
return followStructures(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_profile.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 종단 몫(PLAN 511-B B-3) — 첫 계획선 꼭짓점 · 계획선 짓기 ·
|
||||
* 노선 바뀜 계획고 옮김. 브라우저 묶음이 쓰는 같은 TS · 파이썬은 `B05_Profile_Engine_Grade_Node.py` 로
|
||||
* (늘 떠 있는 Node · 출력 = 반올림 전 값 · 정본 꼴 0.000001 m 는 파이썬이 적음).
|
||||
*
|
||||
* profile.base 입력 profile { chainage · ground(파이썬 `ground_profile`) · policy(`AlignmentPolicy.as_dict`) ·
|
||||
* ground_following { stations: [측점 m] · start_offset_m · end_offset_m } } →
|
||||
* 꼭짓점(`groundFollowingNodes` · 반올림 전) · 못 서는 땅 null(파이썬 2~4 순위)
|
||||
* profile.alignment 입력 profile { chainage · ground · stations: [{ station_id · chainage_m }] · policy ·
|
||||
* base_pvi(없으면 profile.base 출력) · edits · only_explicit_curves } → `buildAlignment`
|
||||
* profile.carry 입력 carry_profile { old_xy · new_xy · old_base · edits · stations · half_m ·
|
||||
* seat?{ ground_s · ground_z · policy · base_pvi · born } } →
|
||||
* { edits(`carryEdits`) · base_pvi(`seatRouteMoved` · 높이 고친 측점 = 옮긴 편집 열쇠) }
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
type AlignmentPolicy,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { groundFollowingNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { carryEdits } from "../../../B05_Profile/logic/B05_Profile_Grade_Carry";
|
||||
import { seatRouteMoved } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
|
||||
type Line = Array<[number, number]>;
|
||||
const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface ProfileInputs {
|
||||
profile?: {
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
policy: AlignmentPolicy;
|
||||
stations?: Array<{ station_id: string | null; chainage_m: number }>;
|
||||
base_pvi?: AlignmentNode[] | null;
|
||||
edits?: AlignmentEdits | null;
|
||||
only_explicit_curves?: boolean;
|
||||
ground_following?: { stations: number[]; start_offset_m?: number; end_offset_m?: number };
|
||||
};
|
||||
carry_profile?: {
|
||||
old_xy: Line;
|
||||
new_xy: Line;
|
||||
old_base: AlignmentNode[];
|
||||
edits?: Partial<AlignmentEdits> | null;
|
||||
stations: number[];
|
||||
half_m: number;
|
||||
seat?: {
|
||||
ground_s: number[];
|
||||
ground_z: number[];
|
||||
policy: AlignmentPolicy;
|
||||
base_pvi: AlignmentNode[];
|
||||
born: number[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음) — 계획선은 꼭짓점이 없으면 같은 요청의 첫 계획선 꼭짓점 위. */
|
||||
export function profileRunners(inputs: ProfileInputs): Record<string, () => unknown> {
|
||||
let nodes: AlignmentNode[] | null | undefined;
|
||||
const base = (): AlignmentNode[] | null => {
|
||||
const profile = inputs.profile;
|
||||
const follow = profile?.ground_following;
|
||||
if (nodes === undefined)
|
||||
nodes =
|
||||
profile && follow
|
||||
? groundFollowingNodes(profile.chainage, profile.ground, {
|
||||
stations: follow.stations,
|
||||
policy: profile.policy,
|
||||
startOffsetM: follow.start_offset_m ?? 0,
|
||||
endOffsetM: follow.end_offset_m ?? 0,
|
||||
})
|
||||
: null;
|
||||
return nodes;
|
||||
};
|
||||
return {
|
||||
"profile.base": base,
|
||||
"profile.alignment": () => {
|
||||
const profile = inputs.profile;
|
||||
const basePvi = profile?.base_pvi ?? base();
|
||||
if (!profile || !basePvi) return null;
|
||||
return buildAlignment(
|
||||
{
|
||||
policy: profile.policy,
|
||||
onlyExplicitCurves: profile.only_explicit_curves === true,
|
||||
basePvi,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: profile.stations ?? [],
|
||||
},
|
||||
profile.edits ?? emptyEdits(),
|
||||
);
|
||||
},
|
||||
"profile.carry": () => {
|
||||
const carry = inputs.carry_profile;
|
||||
if (!carry) return null;
|
||||
const edits = carryEdits(
|
||||
xyOf(carry.old_xy),
|
||||
xyOf(carry.new_xy),
|
||||
carry.old_base,
|
||||
carry.edits,
|
||||
carry.stations,
|
||||
carry.half_m,
|
||||
);
|
||||
const seat = carry.seat;
|
||||
if (!seat) return { edits, base_pvi: null };
|
||||
const held = Object.keys(edits.station_offsets).map(Number);
|
||||
const base = {
|
||||
chainage: seat.ground_s,
|
||||
ground: seat.ground_z,
|
||||
policy: seat.policy,
|
||||
basePvi: seat.base_pvi,
|
||||
};
|
||||
return { edits, base_pvi: seatRouteMoved(base, seat.born, held) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_route.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 노선 몫(PLAN 511-B B-5) — 화면 노선 편집과 같은 TS.
|
||||
*
|
||||
* route.polyline 입력 route_polyline { points · curve_flags · radii · locks · arcs · min_radius_m } →
|
||||
* { cleaned: 겹친 점(0.5 m) 뺀 노드 · nodes: 노드 요약(cleaned 자리) · vertices: 선 ·
|
||||
* curves: 곡선(node_first · last = cleaned 자리) · edits: 곡선표 편집표 }
|
||||
* — 서버 노선 저장 `_write_planned_polyline`(편집 갈래 · 파이썬은 파일 꼴 반올림만)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
DUPLICATE_TOLERANCE_M,
|
||||
type Vertex,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { nodeEdits } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
export interface RouteInputs {
|
||||
route_polyline?: {
|
||||
points: Vertex[];
|
||||
curve_flags?: Array<boolean | null> | null;
|
||||
radii?: Array<number | null> | null;
|
||||
locks?: Array<string | null> | null;
|
||||
arcs?: Array<number | null> | null;
|
||||
min_radius_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function routeRunners(inputs: RouteInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"route.polyline": () => {
|
||||
const at = inputs.route_polyline;
|
||||
if (!at) throw new Error("노선 입력(route_polyline) 없음");
|
||||
const flags = at.curve_flags ?? null;
|
||||
const radii = at.radii ?? null;
|
||||
// 겹친 점을 편집값과 함께 거름 — 곡선 자리 번호 = 저장 노드 자리(파이썬 `build_planned_polyline`)
|
||||
const cleaned: Vertex[] = [];
|
||||
const on: boolean[] = [];
|
||||
const forced: Array<number | null> = [];
|
||||
at.points.forEach((point, index) => {
|
||||
const last = cleaned[cleaned.length - 1];
|
||||
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
|
||||
return;
|
||||
cleaned.push(point);
|
||||
on.push(flags && index < flags.length ? Boolean(flags[index]) : true);
|
||||
forced.push(radii && index < radii.length ? radii[index] : null);
|
||||
});
|
||||
const line = buildEditedPolyline(cleaned, on, forced, at.min_radius_m);
|
||||
return {
|
||||
cleaned,
|
||||
nodes: line.nodes,
|
||||
vertices: line.vertices,
|
||||
curves: line.curves,
|
||||
edits: nodeEdits(at.points, flags, radii, at.locks ?? null, at.arcs ?? null),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_sections.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 측점 몫(PLAN 511-B B-2) — 화면 묶음 stations 단계와 같은 TS.
|
||||
*
|
||||
* stations.list 입력 stations { line · curves · pipes · structures · types · definition? · plus ·
|
||||
* interval_m · include_endpoint? · station_snap · station_snap_step } →
|
||||
* 측점 목록(`buildStations` · 열쇠 · 종류 · 반경 · 확폭 · 지반 null) — 계산 자리 「서버」 ·
|
||||
* 노선 저장 옛 노선 열쇠
|
||||
* stations.sections 같은 입력 + grid(DTM 격자 이진 경로) · 표본 간격 → 서버 `generate_sections` 꼴
|
||||
* (`generateSections`) — 서버 측점 생성 길만(창구 `/calc` 는 막음 · 경로 입력)
|
||||
* stations.plan 입력 stations { types · definition? · plan: [{ pipes · structures · plus }] } → 묶음마다
|
||||
* 측점 계획(`planStations`) — 서버 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기 · 511-B B-5)
|
||||
* definition = 구조물 정의(`{types, …정의}` · 화면 `fetchStructureTypes` 와 같은 꼴) — 심는 측점 · 카드 이름 표.
|
||||
* ========================================================================== */
|
||||
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import {
|
||||
planStations,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "@util/common_util_structure_station";
|
||||
import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { generateSections } from "../../../B05_Profile/logic/B05_Profile_Sections_Generate";
|
||||
import type { DesignCurve, Vertex } from "./design_pipeline_types";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface SectionsInputs {
|
||||
stations?: {
|
||||
line: Vertex[];
|
||||
curves?: DesignCurve[];
|
||||
pipes?: PlanPipe[];
|
||||
structures?: PlanStructure[];
|
||||
types: PlanType[];
|
||||
definition?: CatalogDefinition | null;
|
||||
plus?: number[];
|
||||
interval_m?: number;
|
||||
include_endpoint?: boolean;
|
||||
station_snap?: boolean;
|
||||
station_snap_step?: number | null;
|
||||
grid?: string;
|
||||
cross_half_width_m?: number;
|
||||
cross_sample_interval_m?: number;
|
||||
long_sample_interval_m?: number;
|
||||
plan?: Array<{ pipes?: PlanPipe[]; structures?: PlanStructure[]; plus?: number[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function sectionsRunners(inputs: SectionsInputs): Record<string, () => unknown> {
|
||||
const args = () => {
|
||||
const at = inputs.stations;
|
||||
if (!at) throw new Error("측점 입력(stations) 없음");
|
||||
if (at.definition !== undefined) useCatalogDefinition(at.definition);
|
||||
const options = {
|
||||
intervalM: at.interval_m,
|
||||
includeEndpoint: at.include_endpoint,
|
||||
stationSnap: at.station_snap ?? false,
|
||||
stationSnapStep: at.station_snap_step ?? null,
|
||||
types: at.types,
|
||||
plus: at.plus ?? [],
|
||||
crossHalfWidthM: at.cross_half_width_m,
|
||||
crossSampleIntervalM: at.cross_sample_interval_m,
|
||||
longSampleIntervalM: at.long_sample_interval_m,
|
||||
};
|
||||
return { at, options };
|
||||
};
|
||||
return {
|
||||
"stations.list": () => {
|
||||
const { at, options } = args();
|
||||
return buildStations(at.line, at.curves ?? [], at.pipes ?? [], at.structures ?? [], options);
|
||||
},
|
||||
"stations.plan": () => {
|
||||
const { at } = args();
|
||||
return (at.plan ?? []).map((group) =>
|
||||
planStations(
|
||||
group.pipes ?? [],
|
||||
group.structures ?? [],
|
||||
at.types,
|
||||
undefined,
|
||||
group.plus ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
"stations.sections": () => {
|
||||
const { at, options } = args();
|
||||
if (!at.grid) throw new Error("DTM 격자 없음 — DTM 아닌 지표면은 파이썬 길");
|
||||
const grid = gridAt(at.grid);
|
||||
return generateSections(
|
||||
at.line,
|
||||
at.curves ?? [],
|
||||
at.pipes ?? [],
|
||||
at.structures ?? [],
|
||||
grid,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
/* ── 횡단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -61,18 +61,27 @@ export function snapOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
|
||||
/** `buildStations` 인자 한 벌 — 화면 셈 · 서버 셈(계산 자리 「서버」)이 같은 값을 받음. */
|
||||
export type StationsArgs = Parameters<typeof buildStations>;
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선).
|
||||
* `remote` = 계산 자리 「서버」면 서버 Node 값(같은 `buildStations` · 511-B B-2) · null = 화면 셈 · 서버가
|
||||
* 못 내면(끊김 · 실패) 화면 셈. */
|
||||
export function createStationsStage(
|
||||
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
|
||||
types: () => ReadonlyArray<PlanType>;
|
||||
saved?: () => SavedSnap | null;
|
||||
remote?: (
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
) => Promise<PipelineStation[] | undefined> | null;
|
||||
},
|
||||
): StageFn<"stations"> {
|
||||
const { saved, ...rest } = options;
|
||||
const { saved, remote, ...rest } = options;
|
||||
return (ctx: StageContext<"stations">) => {
|
||||
const route = ctx.out.route;
|
||||
if (!route) return [];
|
||||
const next = buildStations(
|
||||
const args: StationsArgs = [
|
||||
route.line,
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
@@ -83,8 +92,11 @@ export function createStationsStage(
|
||||
...snapOptions(ctx.inputs.route, saved?.()),
|
||||
plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저)
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
];
|
||||
const done = (next: PipelineStation[] | undefined) =>
|
||||
reuseOutside(next ?? buildStations(...args), ctx.prev, ctx.range);
|
||||
const asked = remote?.(args, ctx.signal);
|
||||
return asked ? asked.then(done) : done(undefined); // 화면 셈 = 동기 그대로
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_planted_base.ts
|
||||
* 묶음 planline 의 저장 노선 종단 기준(PLAN 386-3) 셈 몫 — 화면 · 가게 · 세션 없는 순수(511-B B-3 · 서버 Node
|
||||
* 묶음 `design_calc_node_stages.ts` 도 같은 셈). 페이지 겉 = `design_pipeline_base.ts`(관 id = 세션 장부) ·
|
||||
* `design_profile_base.ts` · `design_structure_status.ts` 가 다시 내보냄.
|
||||
*
|
||||
* 종단도 `listed()`(design_profile)와 같은 셈 — 저장 상세 측점 + 구조물 · 관이 심는 측점(`listedBase`).
|
||||
* 심는 측점 = 종단 보기 구조물 칸(`syncCrossDrainStations`) · 횡단 보기(`planMarkStations`)와 같은
|
||||
* `planStations` → `plannedIrregular` — 보기가 종단도에 늦게 심어도 묶음은 다시 안 돎.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { toAlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Profile_Planted";
|
||||
import {
|
||||
followMovedNodes,
|
||||
oneStationList,
|
||||
planStations,
|
||||
type PlannedStation,
|
||||
type PlanType,
|
||||
} from "../../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import type { PipelineInputs } from "./design_pipeline_types";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준.
|
||||
* `ids` = 관에 관 id 붙임(페이지 = 세션 장부 · 서버 Node = 장부 사본 · 511-B). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
ids: (pipes: Array<{ chainage_m: number }>) => Array<{ chainage_m: number; id: string }>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = ids((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
|
||||
@@ -40,6 +40,13 @@ SOURCE_GLOBS = (
|
||||
"common_util/common_util_structure_station.py",
|
||||
"common_util/common_util_drainage_pipes.py",
|
||||
"config/config_system_design.py",
|
||||
# 측점 · 지반 = Node `stations.sections`(511-B B-2) — 묶음을 낳는 TS
|
||||
"B05_Profile/logic/B05_Profile_Sections_*.ts",
|
||||
"B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts",
|
||||
"common_util/common_util_structure_station.ts",
|
||||
"common_util/common_util_structure_catalog.ts",
|
||||
"common_util/common_util_surface_sampler.ts",
|
||||
"A00_Common/design/logic/design_calc_node_sections.ts",
|
||||
)
|
||||
# 직선 위 꼭짓점(438 직선화) 판정 — 앞뒤 기울기 차(비율)
|
||||
_STRAIGHT_EPS = 1e-5
|
||||
@@ -126,12 +133,19 @@ def initial_longitudinal(
|
||||
) -> dict[str, Any]:
|
||||
"""[적용] 체인이 남길 종단(측점 · 계획선) — 측점 생성 · 균형 얹기와 같은 조각."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import build_sections, resolve_extra_stations
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
build_sections,
|
||||
resolve_extra_stations,
|
||||
station_facilities,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import seat_base_pvi
|
||||
|
||||
polyline = [[x, y, 0.0] for x, y in line]
|
||||
pipes, structures, plus = facilities
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
planted = station_facilities(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, planted[0], structures=planted[1], plus=planted[2]
|
||||
)
|
||||
result, _summary = build_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
@@ -144,6 +158,7 @@ def initial_longitudinal(
|
||||
design_curves=curves,
|
||||
station_snap=True, # [적용] = 스냅 켬 · 측점 No.(`saveRecommendedRoute`)
|
||||
station_snap_step=None,
|
||||
facilities=planted,
|
||||
)
|
||||
longitudinal = result["longitudinal"]
|
||||
if target is not None and longitudinal.get("profile_alignment"):
|
||||
|
||||
@@ -34,6 +34,7 @@ from B05_Profile.B05_Profile_Engine_Sections_Names import ( # noqa: F401 —
|
||||
prune_stale_cross_files,
|
||||
valid_cross_names,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_sections
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
structure_type_map,
|
||||
@@ -257,6 +258,24 @@ def resolve_extra_stations(
|
||||
`xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459).
|
||||
`structures` · `plus` = 파일 대신 따라간 사본(476 추천 초기 종단 · 파일 안 씀).
|
||||
"""
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
*station_facilities(project_root, pipes, xy, structures=structures, plus=plus)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def station_facilities(
|
||||
project_root: Path,
|
||||
pipes: list[PipePoint],
|
||||
xy: Sequence[Sequence[float]] | None = None,
|
||||
*,
|
||||
structures: list[Any] | None = None,
|
||||
plus: list[float] | None = None,
|
||||
) -> tuple[list[PipePoint], list[Any], list[float]]:
|
||||
"""측점을 심는 관 · 구조물 · 추가 측점(노선 밖 뺌) — 파이썬 `plan_stations` ·
|
||||
Node 측점 생성이 같은 입력."""
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
from common_util.common_util_route_follow import route_outside
|
||||
|
||||
@@ -273,12 +292,7 @@ def resolve_extra_stations(
|
||||
for s in structures
|
||||
if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy)
|
||||
]
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
pipes, structures, plus_chainages(project_root) if plus is None else plus
|
||||
)
|
||||
)
|
||||
return pipes, structures, plus_chainages(project_root) if plus is None else list(plus)
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
@@ -408,21 +422,28 @@ def build_sections(
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
facilities: tuple[list[Any], list[Any], list[float]] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""측점 틀 · 지반 · 1차 계획선(438) · 포장 제안 — 파일 안 씀 · (결과, 계획선 요약).
|
||||
|
||||
종횡단 생성(`run_section_generation`)과 추천 후보 초기 종단(476 · `..._RouteInitial_Initial`)
|
||||
이 같은 셈 한 벌."""
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
replace(options or SectionGenerationOptions(), extra_stations=extras),
|
||||
source_snapshot=source_snapshot,
|
||||
crs=crs,
|
||||
design_curves=design_curves,
|
||||
station_snap=station_snap,
|
||||
station_snap_step=station_snap_step,
|
||||
)
|
||||
이 같은 셈 한 벌. 측점 · 지반 = 화면과 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`facilities` = `station_facilities`) · DTM 아닌 지표면 · Node 못 돌림 =
|
||||
파이썬 `generate_sections`."""
|
||||
generation = replace(options or SectionGenerationOptions(), extra_stations=extras)
|
||||
shared = {
|
||||
"crs": crs,
|
||||
"design_curves": design_curves,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
}
|
||||
result = (
|
||||
node_sections(
|
||||
polyline, sampler, facilities, generation, source_snapshot=source_snapshot, **shared
|
||||
)
|
||||
if facilities is not None
|
||||
else None
|
||||
) or generate_sections(polyline, sampler, generation, source_snapshot=source_snapshot, **shared)
|
||||
grade_summary = _append_design_profiles(
|
||||
result["longitudinal"],
|
||||
grade_options,
|
||||
@@ -475,10 +496,13 @@ def run_section_generation(
|
||||
# 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다.
|
||||
pipes = _load_pipe_points(project_root, polyline)
|
||||
try:
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline)
|
||||
facilities = station_facilities(project_root, pipes, polyline)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, facilities[0], structures=facilities[1], plus=facilities[2]
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)")
|
||||
extras = ()
|
||||
facilities, extras = ([], [], []), ()
|
||||
logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes))
|
||||
|
||||
result, grade_summary = build_sections(
|
||||
@@ -495,6 +519,7 @@ def run_section_generation(
|
||||
design_curves=_load_design_curves(project_root),
|
||||
station_snap=load_station_snap(project_root),
|
||||
station_snap_step=load_station_snap_step(project_root),
|
||||
facilities=facilities,
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts`
|
||||
(`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`.
|
||||
서버 측점 생성 길(`build_sections`)은 DTM 이면 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`B05_Profile_Engine_Sections_Node.py`) — 이 짝을 쓰는 곳 = DTM 아닌 지표면 · Node 못 돌림 ·
|
||||
구조물 측점만 뜨기(`sample_structure_sections`) · B06 곡선 측점 판정 · 추천 엔진(`_plan_radii` 등).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""서버 측점 생성 = 화면과 같은 TS(PLAN 511-B B-2 · 요구 320-4) — 늘 떠 있는 Node
|
||||
`stations.sections`.
|
||||
|
||||
`build_sections`(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단)가 부름 — 측점 자리 ·
|
||||
종류 · 열쇠 · 반경 · 확폭 · 지반 · 틀 · 상단측 =
|
||||
`B05_Profile/logic/B05_Profile_Sections_Generate.ts`
|
||||
(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source ·
|
||||
coordinate_reference · cad_exchange · options)만 붙임.
|
||||
|
||||
DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) ·
|
||||
Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김).
|
||||
측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SECTION_SCHEMA_VERSION,
|
||||
SectionGenerationOptions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plan_pipe(pipe: Any) -> dict[str, Any]:
|
||||
"""관 정본 → TS `PlanPipe`(누가거리 그대로 · 파이썬 `plan_stations` 가 읽는 칸)."""
|
||||
return {
|
||||
"id": getattr(pipe, "id", None),
|
||||
"chainage_m": float(pipe.chainage_m),
|
||||
"facility": getattr(pipe, "facility", None) or "pipe",
|
||||
"start_m": getattr(pipe, "start_m", None),
|
||||
"end_m": getattr(pipe, "end_m", None),
|
||||
"options": getattr(pipe, "options", None) or {},
|
||||
}
|
||||
|
||||
|
||||
def plan_structure(item: Any) -> dict[str, Any]:
|
||||
"""구조물 정본 → TS `PlanStructure`(JSON 꼴)."""
|
||||
if hasattr(item, "model_dump"):
|
||||
return item.model_dump(mode="json")
|
||||
return dict(item) if isinstance(item, dict) else dict(vars(item))
|
||||
|
||||
|
||||
def structure_table() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""구조물 종류(`/projects/structure-types` 꼴) · 정의(화면 `useCatalogDefinition` 꼴)."""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import _read_definition, _stamp
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
|
||||
|
||||
types = [item.model_dump(mode="json") for item in with_visible().types]
|
||||
return types, {"types": types, **_read_definition(*_stamp())}
|
||||
|
||||
|
||||
def stations_input(
|
||||
xy: list[list[float]],
|
||||
pipes: list[Any],
|
||||
structures: list[Any],
|
||||
plus: list[float],
|
||||
options: SectionGenerationOptions,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Node `stations.list` · `stations.sections` 입력(격자 빼고)."""
|
||||
types, definition = structure_table()
|
||||
return {
|
||||
"line": xy,
|
||||
"curves": design_curves,
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"types": types,
|
||||
"definition": definition,
|
||||
"plus": [float(at) for at in plus],
|
||||
"interval_m": options.station_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
}
|
||||
|
||||
|
||||
def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
"""측점 목록(`buildStations` · 열쇠 · 누가거리 · 종류) — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
answer = run_calc_served(["stations.list"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 목록 실패: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
return answer["results"]["stations.list"]
|
||||
|
||||
|
||||
def node_plan_stations(
|
||||
groups: list[tuple[list[Any], list[Any], list[float]]],
|
||||
) -> list[list[dict[str, Any]]] | None:
|
||||
"""(관 · 구조물 · 추가 측점) 묶음마다 측점 계획(`planStations` · 파이썬 `plan_stations` 짝) —
|
||||
한 Node 부름 · 못 돌림 None(부르는 쪽이 파이썬 짝)."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
types, definition = structure_table()
|
||||
plan = [
|
||||
{
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"plus": [float(at) for at in plus],
|
||||
}
|
||||
for pipes, structures, plus in groups
|
||||
]
|
||||
try:
|
||||
results = run_calc(
|
||||
["stations.plan"],
|
||||
{"stations": {"types": types, "definition": definition, "plan": plan}},
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 측점 계획 실패 — 파이썬 길")
|
||||
return None
|
||||
if results is None:
|
||||
logger.warning("Node 측점 계획 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
return results["stations.plan"]
|
||||
|
||||
|
||||
def node_sections(
|
||||
polyline: list[list[float]],
|
||||
sampler: Any,
|
||||
facilities: tuple[list[Any], list[Any], list[float]],
|
||||
options: SectionGenerationOptions,
|
||||
*,
|
||||
source_snapshot: dict[str, Any],
|
||||
crs: str | None,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""`generate_sections` 와 같은 꼴(+ 측점 `key`) — DTM 아님 · Node 못 돌림 = None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file
|
||||
|
||||
source = getattr(sampler, "source", None)
|
||||
if not source:
|
||||
return None
|
||||
options.validate()
|
||||
pipes, structures, plus = facilities
|
||||
stations = stations_input(
|
||||
[[float(p[0]), float(p[1])] for p in polyline],
|
||||
pipes,
|
||||
structures,
|
||||
plus,
|
||||
options,
|
||||
design_curves,
|
||||
station_snap,
|
||||
station_snap_step,
|
||||
)
|
||||
stations["grid"] = str(dtm_grid_file(source))
|
||||
answer = run_calc_served(["stations.sections"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 생성 실패 — 파이썬 길: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
out = answer["results"]["stations.sections"]
|
||||
return {
|
||||
"schema_version": SECTION_SCHEMA_VERSION,
|
||||
"status": "completed",
|
||||
"source": source_snapshot or {},
|
||||
"coordinate_reference": {
|
||||
"crs": crs,
|
||||
"world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"},
|
||||
"units": {"horizontal": "m", "vertical": "m", "angle": "degree"},
|
||||
},
|
||||
"cad_exchange": {
|
||||
"station_origin": "BP",
|
||||
"chainage_direction": "BP_to_EP",
|
||||
"cross_offset_sign": {"negative": "right", "positive": "left"},
|
||||
"cross_local_axes": {"x": "offset_m", "y": "elevation_m"},
|
||||
"recommended_drawing_datum_m": out["datum"],
|
||||
},
|
||||
"options": {
|
||||
"station_interval_m": options.station_interval_m,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"extra_stations": [list(entry) for entry in options.extra_stations],
|
||||
},
|
||||
"longitudinal": out["longitudinal"],
|
||||
"cross_sections": out["cross_sections"],
|
||||
"summary": out["summary"],
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""계획노선 폴리라인 파일 쓰기(`B05_Profile_Router_Replan` 에서 나눔 · PLAN 511-B B-5).
|
||||
|
||||
노드 · 곡선 켬끔 · R(편집 갈래) → 선 · 노드 · 곡선표(+ 편집표) = 늘 떠 있는 Node `route.polyline`
|
||||
(화면 `buildEditedPolyline` · `nodeEdits` 와 같은 TS) · 파이썬은 파일 꼴(csv 4자리 · 곡선 as_dict
|
||||
반올림)만. 단순화 갈래(예상노선 첫 폴리라인) · Node 못 돌림 = 파이썬 `build_planned_polyline` ·
|
||||
`node_edits`(짝 · 로그 남김).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
|
||||
from common_util.common_util_route_geometry import write_route_csv
|
||||
from common_util.common_util_route_polyline import (
|
||||
PlannedPolyline,
|
||||
RouteCurve,
|
||||
RouteNode,
|
||||
build_planned_polyline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def node_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
curve_flags: list[bool] | None,
|
||||
radii: list[float | None] | None,
|
||||
locks: list[str | None] | None,
|
||||
arcs: list[float | None] | None,
|
||||
) -> tuple[PlannedPolyline, list[dict[str, Any]]] | None:
|
||||
"""편집 갈래 폴리라인 + 편집표 = Node `route.polyline` — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
route = {
|
||||
"points": [[float(x), float(y)] for x, y in points],
|
||||
"curve_flags": curve_flags,
|
||||
"radii": radii,
|
||||
"locks": locks,
|
||||
"arcs": arcs,
|
||||
"min_radius_m": float(radius_m),
|
||||
}
|
||||
try:
|
||||
out = (run_calc(["route.polyline"], {"route_polyline": route}) or {}).get("route.polyline")
|
||||
except Exception: # noqa: BLE001 — 노선 저장은 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 노선 폴리라인 실패 — 파이썬 길")
|
||||
return None
|
||||
if not out:
|
||||
logger.warning("Node 노선 폴리라인 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
# JSON 정수(100) → float — 파이썬 길과 같은 글(`100.0`)로 적힘
|
||||
nodes = [
|
||||
RouteNode(
|
||||
x=float(x),
|
||||
y=float(y),
|
||||
inner_angle_deg=_num(info["inner_angle_deg"]),
|
||||
radius_m=_num(info["radius_m"]),
|
||||
tangent_m=_num(info["tangent_m"]),
|
||||
violations=list(info["violations"]),
|
||||
)
|
||||
for (x, y), info in zip(out["cleaned"], out["nodes"])
|
||||
]
|
||||
curves = [
|
||||
RouteCurve(
|
||||
apex=_xy(curve["apex"]),
|
||||
radius_m=float(curve["radius_m"]),
|
||||
tangent_m=float(curve["tangent_m"]),
|
||||
inner_angle_deg=float(curve["inner_angle_deg"]),
|
||||
start=_xy(curve["start"]),
|
||||
end=_xy(curve["end"]),
|
||||
node_first=int(curve["node_first"]),
|
||||
node_last=int(curve["node_last"]),
|
||||
violations=list(curve["violations"]),
|
||||
)
|
||||
for curve in out["curves"]
|
||||
]
|
||||
vertices = [_xy(vertex) for vertex in out["vertices"]]
|
||||
edits = [
|
||||
{**edit, "radius_m": _num(edit["radius_m"]), "arc_m": _num(edit["arc_m"])}
|
||||
for edit in out["edits"]
|
||||
]
|
||||
return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves), edits
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
def _xy(pair: list[float]) -> tuple[float, float]:
|
||||
return float(pair[0]), float(pair[1])
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
station_snap_step: float | None = None,
|
||||
locks: list[str | None] | None = None,
|
||||
arcs: list[float | None] | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
|
||||
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
|
||||
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
edited = curve_flags is not None or radii is not None
|
||||
made = (
|
||||
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
|
||||
if edited and not simplify
|
||||
else None
|
||||
)
|
||||
if made is not None:
|
||||
result, edits = made
|
||||
else:
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
if station_snap and station_snap_step is not None:
|
||||
table["station_snap_step"] = float(station_snap_step)
|
||||
if edits is not None:
|
||||
table["edits"] = edits
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
@@ -51,8 +51,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
keyed_stations,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
station_seats,
|
||||
vertices_of,
|
||||
@@ -255,12 +255,9 @@ async def _carry_profile(
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
|
||||
carry_base,
|
||||
carry_edits,
|
||||
carry_policy,
|
||||
changed_span,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy, changed_span
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
@@ -278,13 +275,22 @@ async def _carry_profile(
|
||||
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
|
||||
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
||||
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
|
||||
alignment["base_pvi"] = base
|
||||
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
|
||||
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
|
||||
held = [float(key) for key in (edits.get("station_offsets") or {})]
|
||||
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
|
||||
if (
|
||||
span is not None
|
||||
): # 편집 옮김 + 옮긴 시설 측점 427 꼭짓점 다시 세움 = Node 한 부름(459 · 511-B)
|
||||
samples = alignment.get("samples") or []
|
||||
seat = {
|
||||
"ground_s": [float(row["chainage_m"]) for row in samples],
|
||||
"ground_z": [float(row["ground_elevation_m"]) for row in samples],
|
||||
"policy": AlignmentPolicy.from_dict(alignment["policy"] or {}).as_dict(),
|
||||
"base_pvi": base,
|
||||
"born": route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy),
|
||||
}
|
||||
edits, alignment["base_pvi"] = await asyncio.to_thread(
|
||||
carry_seat, old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half, seat
|
||||
)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
@@ -331,8 +337,11 @@ async def _carry_cross(
|
||||
|
||||
new_long = _read_json(project_root / relative)
|
||||
_, new_structures = load_structures(str(project_root))
|
||||
old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m)
|
||||
new_keys = station_keys(
|
||||
# 열쇠 = Node 측점 출력(`key` · 511-B B-2) · 없으면(옛 파일 · DTM 아닌 지표면) 파이썬 열쇠
|
||||
old_keys = keyed_stations(old_long.get("stations") or []) or station_keys(
|
||||
old_long.get("stations") or [], old_pipes, old_structures, interval_m
|
||||
)
|
||||
new_keys = keyed_stations(new_long.get("stations") or []) or station_keys(
|
||||
new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m
|
||||
)
|
||||
designs = {
|
||||
|
||||
@@ -10,7 +10,8 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
같은 세류선 라벨 관(옮겼어도) · 지움 표지가 짝으로 잡은 교차점엔 안 더함(507-2 ·
|
||||
`common_util_stream_crossing`).
|
||||
- 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만.
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
- 측점 열쇠 = Node 측점 생성이 측점에 적은 `key`(511-B B-2 · `keyed_stations`) · 없으면(옛 파일 ·
|
||||
DTM 아닌 지표면) `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
|
||||
id 없으면 `at:<누가거리 0.01 m>`.
|
||||
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 + `ROUTE_CARRY_KEYS`(사면 쉬프트 ·
|
||||
@@ -119,15 +120,21 @@ def _facility_owners(
|
||||
) -> dict[float, tuple[str, str | None, str]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
|
||||
(`plan_stations` 이름 규칙)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
|
||||
# 측점 계획 = Node(`stations.plan` · 511-B B-5) · 못 돌림 = 파이썬 `plan_stations`
|
||||
groups = [([pipe], [], []) for pipe in pipes] + [([], [item], []) for item in structures]
|
||||
planned = node_plan_stations(groups) or [plan_stations(*group) for group in groups]
|
||||
marks_of = iter(planned)
|
||||
owners: dict[float, tuple[str, str | None, str]] = {}
|
||||
for pipe in pipes:
|
||||
for mark in plan_stations([pipe], []):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
|
||||
)
|
||||
for item in structures:
|
||||
for mark in plan_stations([], [item]):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
(
|
||||
@@ -176,6 +183,18 @@ def station_keys(
|
||||
return out
|
||||
|
||||
|
||||
def keyed_stations(stations: Sequence[dict[str, Any]]) -> dict[str, float] | None:
|
||||
"""측점이 지닌 열쇠(`key` · Node 측점 생성 출력 · 511-B B-2) → 누가거리 · 열쇠 없는 측점이 있으면
|
||||
None(옛 파일 · DTM 아닌 지표면 = `station_keys` 로 셈)."""
|
||||
rows = [row for row in stations if row.get("chainage_m") is not None]
|
||||
if not rows or not all(isinstance(row.get("key"), str) for row in rows):
|
||||
return None
|
||||
out: dict[str, float] = {}
|
||||
for row in rows:
|
||||
out.setdefault(row["key"], float(row["chainage_m"]))
|
||||
return out
|
||||
|
||||
|
||||
def station_seats(
|
||||
stations: Sequence[dict[str, Any]], keys: dict[str, float]
|
||||
) -> dict[str, tuple[float, float, tuple[float, float] | None] | None]:
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -38,7 +37,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
|
||||
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
|
||||
from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
_curves_path,
|
||||
_nodes_path,
|
||||
_read_curves,
|
||||
_write_planned_polyline,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
@@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
return grade_class, design_speed, terrain
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
station_snap_step: float | None = None,
|
||||
locks: list[str | None] | None = None,
|
||||
arcs: list[float | None] | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
|
||||
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
|
||||
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
if station_snap and station_snap_step is not None:
|
||||
table["station_snap_step"] = float(station_snap_step)
|
||||
if curve_flags is not None or radii is not None:
|
||||
table["edits"] = node_edits(points, curve_flags, radii, locks, arcs)
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
||||
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
|
||||
|
||||
|
||||
@@ -199,6 +199,32 @@ def _design_grid(
|
||||
}
|
||||
|
||||
|
||||
async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path:
|
||||
"""그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭).
|
||||
|
||||
경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀).
|
||||
"""
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
model_path = (root / str(row[1])).resolve()
|
||||
if not model_path.is_relative_to(root.resolve()):
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
raise LookupError("DTM 격자 파일이 없습니다.")
|
||||
return npz_path
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/grid", response_model=None)
|
||||
async def read_surface_grid(
|
||||
request: Request, project_id: UUID, model_id: int, smooth: bool = False
|
||||
@@ -209,26 +235,9 @@ async def read_surface_grid(
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자가 없는 지표면 모델입니다."},
|
||||
)
|
||||
model_path = Path(resolve_stored_project_path(stored)) / str(row[1])
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자 파일이 없습니다."},
|
||||
)
|
||||
try:
|
||||
npz_path = await dtm_grid_npz(connection, project_id, model_id, smooth)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
grid_path = await asyncio.to_thread(dtm_grid_file, npz_path)
|
||||
return cached_file_response(request, grid_path, "application/octet-stream")
|
||||
|
||||
@@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest
|
||||
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
|
||||
# · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음
|
||||
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
|
||||
planted = [
|
||||
float(station["chainage_m"])
|
||||
for station in plan_stations(
|
||||
route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧)
|
||||
existing,
|
||||
plus_chainages(root),
|
||||
)
|
||||
]
|
||||
# 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝
|
||||
group = (route_pipe_points(Path(root)), existing, plus_chainages(root))
|
||||
marks = (node_plan_stations([group]) or [plan_stations(*group)])[0]
|
||||
planted = [float(station["chainage_m"]) for station in marks]
|
||||
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationPartsLabel } from "../common_util/common_util_station";
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
@@ -26,6 +25,10 @@ import {
|
||||
} from "@config/config_frontend";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
export interface IrregularStation {
|
||||
id: string;
|
||||
/** 측점번호 X. */
|
||||
@@ -126,16 +129,6 @@ interface IrregularStationsCallbacks {
|
||||
onSelect: (station: IrregularStation | null) => void;
|
||||
}
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
// 법정 기준표 = logic(511-B B-3 · X5) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { PROFILE_CRITERIA, SPEED_CHOICES, gradeLimitOf } from "./logic/B05_Profile_Grade_Criteria";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
|
||||
@@ -32,44 +35,9 @@ export interface RoutePanelValues {
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
export const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: RoutePanelValues["designSpeed"],
|
||||
terrain: RoutePanelValues["terrainType"],
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
@@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
|
||||
export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { normalizedLongitudinal } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
|
||||
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station";
|
||||
import type { LongitudinalSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
// 최대 누가거리 · 그래프 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularGraphStations, maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
|
||||
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
|
||||
@@ -40,39 +37,6 @@ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL
|
||||
*/
|
||||
export const PROFILE_SPACING_MULTIPLIER = 1.5;
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
export function chainageMapper(
|
||||
data: LongitudinalSection,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Criteria.ts
|
||||
* 종단 법정 기준표(설계속도 × 지형 → 종단기울기 상한 · 종단곡선 반경) · 종류별 설계속도 — 화면
|
||||
* `B05_Profile_UI_Panel_Fields.ts` 에서 옮김(511-B B-3 · X5 · 순수 자료). 파이썬 표 =
|
||||
* `config_system_design.FOREST_ROAD_PROFILE_CRITERIA`(추천 · 폴백 · 기본값 해석 몫) · 거울 시험 =
|
||||
* resources/tester/test_442_2_grade_limit_mirror.py.
|
||||
* ========================================================================== */
|
||||
|
||||
export type DesignSpeed = 20 | 30 | 40;
|
||||
export type TerrainType = "normal" | "special";
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
DesignSpeed,
|
||||
{ grade: Record<TerrainType, number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: DesignSpeed,
|
||||
terrain: TerrainType,
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<DesignSpeed>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
@@ -110,11 +110,15 @@ function fitThrough(
|
||||
return baseZ;
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
const { chainage, ground } = profile;
|
||||
/**
|
||||
* 첫 계획선 꼭짓점(반올림 전) — 종단 지반(`groundProfile` 꼴) 위 측점마다 꼭짓점 · 서버 Node(511-B B-3)는
|
||||
* 이 값으로 선형을 짓고 파이썬이 정본 꼴(0.000001 m)로 적음(파이썬 1순위와 같은 순서).
|
||||
*/
|
||||
export function groundFollowingNodes(
|
||||
chainage: number[],
|
||||
ground: number[],
|
||||
input: Omit<GroundFollowingInput, "samples">,
|
||||
): AlignmentNode[] | null {
|
||||
const total = chainage[chainage.length - 1];
|
||||
if (!(total > 0)) return null;
|
||||
|
||||
@@ -133,8 +137,17 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
|
||||
target[0] += input.startOffsetM ?? 0;
|
||||
target[target.length - 1] += input.endOffsetM ?? 0;
|
||||
const baseZ = passThrough(nodeS, target, input.policy);
|
||||
return nodeS.map((s, index) => ({
|
||||
chainage_m: roundTo(s, 6),
|
||||
elevation_m: roundTo(baseZ[index], 6),
|
||||
}));
|
||||
return nodeS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
const nodes = profile && groundFollowingNodes(profile.chainage, profile.ground, input);
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: roundTo(node.chainage_m, 6),
|
||||
elevation_m: roundTo(node.elevation_m, 6),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
} from "./B05_Profile_Grade_Carry";
|
||||
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
|
||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||
import {
|
||||
groundFollowingBasePvi,
|
||||
groundProfile,
|
||||
type GroundFollowingInput,
|
||||
} from "./B05_Profile_Grade_Ground";
|
||||
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
|
||||
|
||||
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
|
||||
@@ -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<AlignmentBase["basePvi"] | null> | 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) ?? []) : []);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,15 @@ export interface RouteStageDeps {
|
||||
minRadiusM(): number;
|
||||
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */
|
||||
savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null;
|
||||
/** 계산 자리 route 「서버」(511-B B-5) — 서버 Node `route.polyline`(같은 `buildEditedPolyline`) 값 ·
|
||||
* null = 화면 셈 · 끊김 · 실패 = undefined(화면 셈으로). */
|
||||
remote?(args: DraftArgs, signal: AbortSignal): Promise<BuiltLine | undefined> | null;
|
||||
}
|
||||
|
||||
/** `buildEditedPolyline` 인자(곡선 길이 고정을 R 로 푼 뒤) — 화면 셈 · 서버 셈이 같은 값을 받음. */
|
||||
export type DraftArgs = Parameters<typeof buildEditedPolyline>;
|
||||
type BuiltLine = Pick<ReturnType<typeof buildEditedPolyline>, "vertices" | "curves">;
|
||||
|
||||
/** 정점마다 누가거리 — 첫 점 0. */
|
||||
export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine {
|
||||
const chainage = [0];
|
||||
@@ -67,10 +74,19 @@ export function draftLine(
|
||||
draft: RouteDraft,
|
||||
minRadiusM: number,
|
||||
): { line: Vertex[]; curves: DesignCurve[] } {
|
||||
return shapedLine(buildEditedPolyline(...draftArgs(draft, minRadiusM)));
|
||||
}
|
||||
|
||||
/** 초안 → `buildEditedPolyline` 인자(길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음). */
|
||||
export function draftArgs(draft: RouteDraft, minRadiusM: number): DraftArgs {
|
||||
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
|
||||
const radius = [...draft.curveRadius];
|
||||
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
|
||||
const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM);
|
||||
return [nodes, draft.curveOn, radius, minRadiusM];
|
||||
}
|
||||
|
||||
/** 그린 선 → 계약 꼴 · 서버 저장 자릿수(화면 셈 · 서버 셈 한 벌). */
|
||||
function shapedLine(built: BuiltLine): { line: Vertex[]; curves: DesignCurve[] } {
|
||||
return {
|
||||
line: built.vertices.map(xy4),
|
||||
curves: built.curves.map(({ start, apex, end, radius_m }) => ({
|
||||
@@ -85,9 +101,15 @@ export function draftLine(
|
||||
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
|
||||
return (ctx) => {
|
||||
const draft = ctx.inputs.route;
|
||||
const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
|
||||
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(drawn.line, drawn.curves);
|
||||
const done = (drawn: { line: Vertex[]; curves: DesignCurve[] } | null) => {
|
||||
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
|
||||
return routeLineOf(drawn.line, drawn.curves);
|
||||
};
|
||||
if (!draft) return done(deps.savedLine());
|
||||
const args = draftArgs(draft, deps.minRadiusM());
|
||||
const asked = deps.remote?.(args, ctx.signal);
|
||||
const local = () => shapedLine(buildEditedPolyline(...args));
|
||||
return asked ? asked.then((built) => done(built ? shapedLine(built) : local())) : done(local());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Profile_Planted.ts
|
||||
* 종단도에 심는 비정규 측점 → 그래프 측점 · 노선 최대 누가거리(511-B B-3) — 화면
|
||||
* `B05_Profile_UI_IrregularStations.ts` · `B05_Profile_UI_Profile_Layout.ts` 에서 옮김(순수 · 두 파일이
|
||||
* 다시 내보냄). 서버 Node 묶음 planline 저장 기준(`design_planted_base.ts`)이 화면 파일을 안 끌게.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../B05_Profile_UI_IrregularStations";
|
||||
import type { LongitudinalSection, SectionStation } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { stationPartsLabel } from "../../common_util/common_util_station";
|
||||
import { PLUS_OWNER_PREFIX } from "../../common_util/common_util_structure_station";
|
||||
import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Sections_Generate.ts
|
||||
* 측점 생성 한 벌(PLAN 511-B B-2) — 노선 + 관 · 구조물 · 추가 측점 + DTM 격자 → 서버 `generate_sections`
|
||||
* 꼴(종단 표본 · 측점 · 횡단 표본). 서버 측점 생성 길(`B05_Profile_Engine_Sections_Node.py` → Node
|
||||
* `stations.sections`)이 부름 — 화면 묶음과 같은 측점(`buildStations`) · 같은 지반(`sectionsGround`).
|
||||
*
|
||||
* 측점 = `buildStations`(열쇠 · 종류 · 반경 · 확폭) · 지반 · 틀 · 상단측 = `sectionsGround` · 이름 =
|
||||
* `plantedStations`(카드 이름) · 칸 차례 · 자릿수 = 파이썬 `generate_sections` 그대로 + 측점 열쇠 `key`.
|
||||
* 머리(schema_version · source · coordinate_reference · cad_exchange · options)는 파이썬이 붙임.
|
||||
* 순수 — 화면 · fetch · 세션 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DtmGrid } from "../../common_util/common_util_surface_sampler";
|
||||
import { stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import { formatStation } from "../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
buildStations,
|
||||
lineLength,
|
||||
plantedStations,
|
||||
SECTION_INCLUDE_ENDPOINT,
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
type StationsOptions,
|
||||
} from "./B05_Profile_Sections_Stations";
|
||||
import { sectionsGround, type GroundOptions } from "./B05_Profile_Sections_Ground";
|
||||
import type { PlanPipe, PlanStructure } from "../../common_util/common_util_structure_station";
|
||||
import type { DesignCurve, Vertex } from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
|
||||
export type GenerateOptions = Omit<StationsOptions, "registry"> & GroundOptions;
|
||||
|
||||
const KIND: Record<string, string> = {
|
||||
grid: "regular",
|
||||
curve: "curve",
|
||||
pipe: "irregular",
|
||||
structure: "irregular",
|
||||
};
|
||||
|
||||
/** 서버 `generate_sections` 꼴 몸통 — 종단(길이 · 표본 · 측점) · 횡단 · 요약 · 도면 기준고. */
|
||||
export function generateSections(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
grid: DtmGrid,
|
||||
options: GenerateOptions,
|
||||
) {
|
||||
const stations = buildStations(line, curves, pipes, structures, options);
|
||||
if (stations.length < 2) throw new Error("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다.");
|
||||
const gridM = stationsAlong(
|
||||
line,
|
||||
options.intervalM ?? SECTION_STATION_INTERVAL_M,
|
||||
options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT,
|
||||
);
|
||||
const planted = plantedStations(gridM, lineLength(line), pipes, structures, options);
|
||||
const chainages = stations.map((station) => station.chainageM);
|
||||
const ground = sectionsGround(line, chainages, grid, options);
|
||||
const crossSections = stations.map((station, index) => {
|
||||
const at = ground.stations[index];
|
||||
const mark = [...planted].find(([m]) => Math.abs(m - station.chainageM) < 1e-6)?.[1];
|
||||
const kind = index === 0 ? "bp" : station.kind === "end" ? "ep" : KIND[station.kind];
|
||||
const head: Record<string, unknown> = {
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: at.chainage_m,
|
||||
label: formatStation(station.chainageM) + (kind === "curve" ? " (MC)" : ""),
|
||||
kind,
|
||||
center_x: at.center_x,
|
||||
center_y: at.center_y,
|
||||
center_z: at.center_z,
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
curve_outer_side: station.curveOuterSide,
|
||||
curve_widening_m: station.curveWideningM,
|
||||
uphill_side: at.uphill_side,
|
||||
frame: {
|
||||
origin: { x: at.center_x, y: at.center_y, z: at.center_z },
|
||||
tangent_xy: at.tangent_xy,
|
||||
left_xy: at.left_xy,
|
||||
up_xyz: [0.0, 0.0, 1.0],
|
||||
},
|
||||
};
|
||||
if (mark) {
|
||||
head.structure = mark.title;
|
||||
if (gridM.some((m) => Math.abs(m - station.chainageM) < 1e-6)) head.grid = true;
|
||||
}
|
||||
head.key = station.key;
|
||||
return { head, samples: at.samples };
|
||||
});
|
||||
const z = ground.samples.flatMap((sample) => (sample.z === null ? [] : [sample.z]));
|
||||
return {
|
||||
longitudinal: {
|
||||
length_m: ground.length_m,
|
||||
samples: ground.samples,
|
||||
stations: crossSections.map(({ head }) => head),
|
||||
},
|
||||
cross_sections: crossSections.map(({ head, samples }) => ({ ...head, samples })),
|
||||
summary: {
|
||||
station_count: stations.length,
|
||||
cross_sample_count: crossSections.reduce((sum, { samples }) => sum + samples.length, 0),
|
||||
invalid_longitudinal_samples: ground.samples.filter((sample) => !sample.valid).length,
|
||||
invalid_cross_samples: crossSections.reduce(
|
||||
(sum, { samples }) => sum + samples.filter((sample) => !sample.valid).length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
datum: z.length ? Math.floor(Math.min(...z) / 10) * 10 : null,
|
||||
};
|
||||
}
|
||||
@@ -69,9 +69,9 @@ export interface GroundOptions {
|
||||
longSampleIntervalM?: number;
|
||||
}
|
||||
|
||||
/** 파이썬 `round(x, 6)` 자리. */
|
||||
export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
|
||||
const round9 = (value: number): number => Math.round(value * 1e9) / 1e9;
|
||||
/** 파이썬 `round(x, 6)` 자리 — 십진 자릿수 반올림(곱셈 반올림 오차 없음 · 511-B B-2). */
|
||||
export const round6 = (value: number): number => Number(value.toFixed(6));
|
||||
const round9 = (value: number): number => Number(value.toFixed(9));
|
||||
|
||||
function sample(grid: DtmGrid, x: number, y: number): GroundSample {
|
||||
const z = heightAt(grid, x, y);
|
||||
|
||||
@@ -239,31 +239,31 @@ export interface StationsOptions {
|
||||
}
|
||||
|
||||
/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */
|
||||
const lineLength = (line: ReadonlyArray<Vertex>): number =>
|
||||
export const lineLength = (line: ReadonlyArray<Vertex>): number =>
|
||||
line.reduce(
|
||||
(sum, vertex, index) =>
|
||||
index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0,
|
||||
0,
|
||||
);
|
||||
|
||||
/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */
|
||||
export function buildStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
/** 심는 측점 한 자리 — 주인 종류 · id · 역할 · 카드 이름(서버 측점 `structure` 칸). */
|
||||
export interface PlantedMark {
|
||||
kind: "pipe" | "structure";
|
||||
id: string;
|
||||
role: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). */
|
||||
export function plantedStations(
|
||||
grid: ReadonlyArray<number>,
|
||||
total: number,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
options: StationsOptions,
|
||||
): PipelineStation[] {
|
||||
if (line.length < 2) return [];
|
||||
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
|
||||
const total = lineLength(line);
|
||||
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
|
||||
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
|
||||
const spans = designSpans(line, curves);
|
||||
|
||||
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
|
||||
options: Pick<StationsOptions, "types" | "registry" | "plus">,
|
||||
): Map<number, PlantedMark> {
|
||||
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
|
||||
const planted = new Map<number, { kind: "pipe" | "structure"; id: string; role: string }>();
|
||||
const planted = new Map<number, PlantedMark>();
|
||||
for (const station of planStations(
|
||||
pipes,
|
||||
structures,
|
||||
@@ -280,8 +280,27 @@ export function buildStations(
|
||||
kind: pipeIds.has(owner) ? "pipe" : "structure",
|
||||
id: owner,
|
||||
role: station.role,
|
||||
title: station.title,
|
||||
});
|
||||
}
|
||||
return planted;
|
||||
}
|
||||
|
||||
/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */
|
||||
export function buildStations(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
options: StationsOptions,
|
||||
): PipelineStation[] {
|
||||
if (line.length < 2) return [];
|
||||
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
|
||||
const total = lineLength(line);
|
||||
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
|
||||
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
|
||||
const spans = designSpans(line, curves);
|
||||
const planted = plantedStations(grid, total, pipes, structures, options);
|
||||
const withPlanted = [...new Set([...grid, ...planted.keys()])];
|
||||
// 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음
|
||||
const curved = curveStations(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Alignment.ts
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산.
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산 — 구간 쉬프트 · R 입력은
|
||||
* `B05_Profile_UI_Profile_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3).
|
||||
*
|
||||
* 백엔드 `B05_Profile_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가
|
||||
* 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며,
|
||||
@@ -159,7 +160,7 @@ export function emptyEdits(): AlignmentEdits {
|
||||
}
|
||||
|
||||
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
|
||||
function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
export function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
if (!xs.length) return 0;
|
||||
if (value <= xs[0]) return ys[0];
|
||||
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
@@ -391,6 +392,14 @@ function trapezoidWeights(chainage: number[]): number[] {
|
||||
}
|
||||
|
||||
/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */
|
||||
/** `np.round(value, 6)` — 백만 배 · 짝수 쪽 반올림(rint) · 나눔(파이썬 표본 격자와 같은 값). */
|
||||
function npRound6(value: number): number {
|
||||
const scaled = value * 1e6;
|
||||
let rounded = Math.round(scaled);
|
||||
if (rounded - scaled === 0.5 && rounded % 2 !== 0) rounded -= 1; // Math.round = .5 위로
|
||||
return rounded / 1e6;
|
||||
}
|
||||
|
||||
function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
|
||||
return {
|
||||
station_offsets: edits?.station_offsets ?? {},
|
||||
@@ -437,7 +446,8 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
// 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의
|
||||
// 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼
|
||||
// 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다.
|
||||
const sampleChainages = new Set<number>(base.chainage.map((value) => Number(value.toFixed(3))));
|
||||
// 격자 자리 = 0.000001 m(파이썬 `np.round(merged, 6)` 와 같은 값 — 서버 Node 정본 · 511-B B-3).
|
||||
const sampleChainages = new Set<number>(base.chainage.map(npRound6));
|
||||
const first = base.chainage[0];
|
||||
const last = base.chainage[base.chainage.length - 1];
|
||||
for (const extra of [
|
||||
@@ -445,7 +455,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]),
|
||||
]) {
|
||||
if (extra >= first - 1e-6 && extra <= last + 1e-6) {
|
||||
sampleChainages.add(Number(extra.toFixed(3)));
|
||||
sampleChainages.add(npRound6(extra));
|
||||
}
|
||||
}
|
||||
const samples: AlignmentSample[] = [...sampleChainages]
|
||||
@@ -614,86 +624,6 @@ export function controlElevationAt(alignment: ProfileAlignment, chainageM: numbe
|
||||
return node ? node.elevation_m : planElevationAt(alignment, chainageM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
export function hasEdits(edits: AlignmentEdits): boolean {
|
||||
const safe = normalizeEdits(edits);
|
||||
return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
||||
* 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
interpolate,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentCurve,
|
||||
type AlignmentEdits,
|
||||
type AlignmentSegment,
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const FEEDBACK_PASSES = 4;
|
||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
@@ -52,6 +52,13 @@ const RAD_PER_DEG = Math.PI / 180;
|
||||
/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */
|
||||
export const HAIRPIN_MIN_RADIUS_M = 10.0;
|
||||
|
||||
/** 짝: 파이썬 `f"{x:.1f}"` — 딱 반(x = 홀수/4 · 12.25)이면 짝수 쪽(`toFixed` 는 큰 쪽 · 위반 글이 갈림). */
|
||||
export function fixed1(x: number): string {
|
||||
if (!Number.isInteger(x * 4) || Number.isInteger(x * 2)) return x.toFixed(1);
|
||||
const floor = Math.floor(x * 10);
|
||||
return ((floor % 2 === 0 ? floor : floor + 1) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
|
||||
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
|
||||
const dx1 = a2[0] - a1[0];
|
||||
@@ -308,12 +315,10 @@ export function buildEditedPolyline(
|
||||
if (radius <= 0 || geometry === null) continue;
|
||||
|
||||
if (radius < minRadiusM) {
|
||||
node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`);
|
||||
node.violations.push(`최소곡선반지름 미달(${fixed1(radius)} < ${fixed1(minRadiusM)}m)`);
|
||||
}
|
||||
if (radius < hairpinMinRadiusM) {
|
||||
node.violations.push(
|
||||
`배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`,
|
||||
);
|
||||
node.violations.push(`배향곡선 하한 미달(${fixed1(radius)} < ${fixed1(hairpinMinRadiusM)}m)`);
|
||||
}
|
||||
node.radius_m = radius;
|
||||
node.tangent_m = tangent;
|
||||
|
||||
@@ -330,6 +330,34 @@ export interface RouteNodeEdit {
|
||||
arc_m: number | null;
|
||||
}
|
||||
|
||||
/** 넣은 편집값 → 곡선표 `edits`(430) — 겹친 점(앞 노드 0.5 m 안)은 빼고 저장 노드와 같은 자리 · 칸이 짧으면
|
||||
* 켬 · 자동. ⚠ 짝 `B05_Profile_Route_NodeEdits.node_edits` · 서버 노선 저장 = Node `route.polyline`(511-B B-5). */
|
||||
export function nodeEdits(
|
||||
points: ReadonlyArray<Vertex>,
|
||||
flags: ReadonlyArray<boolean | null> | null = null,
|
||||
radii: ReadonlyArray<number | null> | null = null,
|
||||
locks: ReadonlyArray<string | null> | null = null,
|
||||
arcs: ReadonlyArray<number | null> | null = null,
|
||||
): RouteNodeEdit[] {
|
||||
const at = <T>(values: ReadonlyArray<T> | null, index: number): T | null =>
|
||||
values && index < values.length ? values[index] : null;
|
||||
const kept: Vertex[] = [];
|
||||
const edits: RouteNodeEdit[] = [];
|
||||
points.forEach((point, index) => {
|
||||
const last = kept[kept.length - 1];
|
||||
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M) return;
|
||||
kept.push(point);
|
||||
const lock = at(locks, index);
|
||||
edits.push({
|
||||
curve: flags && index < flags.length ? Boolean(flags[index]) : true,
|
||||
radius_m: at(radii, index),
|
||||
lock: lock === "radius" || lock === "arc" ? lock : null,
|
||||
arc_m: at(arcs, index),
|
||||
});
|
||||
});
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 아닌 지표면 = 브라우저)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<S extends FollowingStructure & { id?: string | null
|
||||
Math.abs(was.chainage_m - item.chainage_m) <= SAME_STATION_M
|
||||
)
|
||||
return { ...item, x: was.x, y: was.y };
|
||||
let walked = 0;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const a = line[index - 1];
|
||||
const b = line[index];
|
||||
const span = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (span > 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<XY>, 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<Positioned>,
|
||||
line: ReadonlyArray<XY>,
|
||||
previous: Readonly<Record<string, Positioned>> | null = null,
|
||||
onlyMissing = false,
|
||||
intervalM: number | null = null,
|
||||
): { xy: Array<XY | null>; 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) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로
|
||||
|
||||
@@ -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` 가 같은 값을 봄.
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"corridor_mesh": "both",
|
||||
"quantity": "both",
|
||||
"carry": "both",
|
||||
"route": "both",
|
||||
"stations": "both",
|
||||
"profile": "both",
|
||||
"cross": "both",
|
||||
"fixed_browser": "both"
|
||||
}
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 <id> [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))
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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` 가 꺼냄.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 길 ──────────────────────────────────
|
||||
|
||||
@@ -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_답_파일_길과_같음_죽어도_다시():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 판만 서버를 부름
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 # 격자 없음 = 그대로
|
||||
@@ -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():
|
||||
|
||||
@@ -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 아닌 지표면 = 브라우저",
|
||||
|
||||
Reference in New Issue
Block a user