From 99cb8f0ae9e22428260c8fe73cef081d2421992a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 14:59:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(511-B=20B-5=20=EB=85=B8=EC=84=A0=20=C2=B7?= =?UTF-8?q?=20=EC=A2=8C=ED=91=9C=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EB=82=A8?= =?UTF-8?q?=EC=9D=80=20=EC=B8=A1=EC=A0=90=20=EA=B3=84=ED=9A=8D=20Node=20?= =?UTF-8?q?=C2=B7=20route=20=EC=A4=84=20=EC=97=B6):=20Router=5FReplan=2068?= =?UTF-8?q?8=20=E2=86=92=20612(=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=93=B0=EA=B8=B0=20=3D=20B05=5FProfile?= =?UTF-8?q?=5FRoute=5FPolyline=5FWrite.py)=20=C2=B7=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8(?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B0=88=EB=9E=98)=20=3D=20=EB=8A=98=20?= =?UTF-8?q?=EB=96=A0=20=EC=9E=88=EB=8A=94=20Node=20route.polyline(buildEdi?= =?UTF-8?q?tedPolyline=20+=20nodeEdits=20=C2=B7=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=EC=9D=80=20csv=204=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20as=5Fdict=20=EB=B0=98=EC=98=AC=EB=A6=BC?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EB=8B=A8=EC=88=9C=ED=99=94=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20Node=20=EB=AA=BB=20=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=3D=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=EC=A7=9D)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=20=EA=B8=80=20=EB=B0=98=EC=98=AC=EB=A6=BC=20?= =?UTF-8?q?=3D=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=EA=BC=B4(fixed1=20=C2=B7?= =?UTF-8?q?=20R=2012.25=20=E2=86=92=2012.2)=20=C2=B7=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=A2=8C=ED=91=9C=20=EB=A7=9E=EC=B6=A4(F1)=20=3D=20Node=20f?= =?UTF-8?q?acility.follow=20+=20fill(fillStructureCoordinates=20=C2=B7=20?= =?UTF-8?q?=EC=98=9B=20=EC=A2=8C=ED=91=9C=20=EC=A7=80=ED=82=B4=20=C2=B7=20?= =?UTF-8?q?0.1=20mm=20=C2=B7=20placeOnLine=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=9C=84=20=EC=9E=90=EB=A6=AC=EC=99=80=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20pointAtChainage)=20=C2=B7=20=EB=82=A8=EC=9D=80=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B3=84=ED=9A=8D(F3=20=C2=B7=20=EC=9D=B4=EC=9B=94?= =?UTF-8?q?=20=EC=97=B4=EC=87=A0=20=EC=A3=BC=EC=9D=B8=20=C2=B7=20=EC=98=9B?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EC=98=AE=EA=B8=B0=EA=B8=B0)=20=3D=20No?= =?UTF-8?q?de=20stations.plan=20=C2=B7=20=EB=93=B1=EB=A1=9D=EB=B6=80=20R1?= =?UTF-8?q?=20R3=20F3=20node:=20=C2=B7=20=ED=8C=90=20route=20=EC=A4=84=20?= =?UTF-8?q?=EC=97=B6(=E3=80=8C=EC=84=9C=EB=B2=84=E3=80=8D=20=3D=20?= =?UTF-8?q?=EC=9E=A0=EC=9E=A0=20=EB=92=A4=20=EC=84=9C=EB=B2=84=20route.pol?= =?UTF-8?q?yline=20=C2=B7=20=EB=AA=BB=20=EB=82=B4=EB=A9=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=85=88)=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=A0=84=EC=88=98=206=20=EB=B2=8C=20=3D?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=EA=B8=B8=20=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=200=20=EC=B0=A8=20=C2=B7=20=EC=95=8C=EB=A0=A4?= =?UTF-8?q?=EC=A7=84=20=EC=B0=A8=20=3D=20=EA=B5=90=EA=B0=81=20=EB=94=B1=20?= =?UTF-8?q?5=C2=B0=20=EB=B0=B0=EC=88=98=20=EC=9B=90=ED=98=B8=20=EC=A0=90?= =?UTF-8?q?=20=C2=B11(=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EC=8A=B9=EC=9D=B8)=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=ED=97=98=20test=5F511=5Fb5=5Froute=5Fnode=20?= =?UTF-8?q?=E2=80=94=20=EC=9A=94=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_page.ts | 2 + A00_Common/design/design_route_server.ts | 35 +++ A00_Common/design/logic/design_calc_node.ts | 8 +- .../design/logic/design_calc_node_follow.ts | 14 +- .../design/logic/design_calc_node_route.ts | 59 +++++ .../design/logic/design_calc_node_sections.ts | 22 +- .../design/logic/design_calc_registry.ts | 13 +- .../logic/design_calc_registry_facility.ts | 10 +- .../B05_Profile_Engine_Sections_Node.py | 44 +++- .../B05_Profile_Route_Polyline_Write.py | 184 +++++++++++++ B05_Profile/B05_Profile_Route_Save_Parts.py | 10 +- B05_Profile/B05_Profile_Router_Replan.py | 88 +------ B05_Profile/B05_Profile_Structures_Router.py | 13 +- .../logic/B05_Profile_Pipeline_RouteGround.ts | 30 ++- .../logic/B05_Profile_UI_RouteEdit_Curve.ts | 13 +- .../logic/B05_Profile_UI_RouteEdit_Edits.ts | 28 ++ B06_Section/B06_Section_Router_Calc.py | 2 +- common_util/common_util_calc_runner.py | 2 + common_util/common_util_calc_sites.py | 3 +- common_util/common_util_route_follow.py | 47 +++- common_util/common_util_route_follow.ts | 78 +++++- resources/master_template/calc_sites.json | 1 + resources/tester/test_309_2_calc_sites.py | 3 +- resources/tester/test_472_3_calc_registry.py | 2 + .../tester/test_511_b1_calc_node_stages.py | 5 +- resources/tester/test_511_b5_route_node.py | 245 ++++++++++++++++++ ui_template/ui_template_locale_m1.ts | 4 + 27 files changed, 827 insertions(+), 138 deletions(-) create mode 100644 A00_Common/design/design_route_server.ts create mode 100644 A00_Common/design/logic/design_calc_node_route.ts create mode 100644 B05_Profile/B05_Profile_Route_Polyline_Write.py create mode 100644 resources/tester/test_511_b5_route_node.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index bf82e8c2f..2d3784b03 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -28,6 +28,7 @@ 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, @@ -278,6 +279,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; diff --git a/A00_Common/design/design_route_server.ts b/A00_Common/design/design_route_server.ts new file mode 100644 index 000000000..66a06bcba --- /dev/null +++ b/A00_Common/design/design_route_server.ts @@ -0,0 +1,35 @@ +/* ============================================================================= + * A00_Common/design/design_route_server.ts + * 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node + * `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) · + * 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로). + * ========================================================================== */ + +import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; +import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import { calcOnServerNow } from "./design_calc_sites"; +import { calcOnServerOnly } from "./design_calc_server"; + +export function routeOnServer( + args: DraftArgs, + signal: AbortSignal, +): Promise | null { + if (!calcOnServerNow("route")) return null; + const [points, curveOn, curveRadius, minRadiusM] = args; + const route_polyline = { + points, + curve_flags: curveOn, + radii: curveRadius, + min_radius_m: minRadiusM, + }; + return calcOnServerOnly( + "route", + "route.polyline", + { + run: ["route.polyline"], + inputs: { route_polyline }, + pick: (results) => results["route.polyline"], + }, + signal, + ); +} diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 78ed19b3c..e3ce8c21d 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -17,7 +17,8 @@ * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } · * rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) · * stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) · - * stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) } } + * 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 묶음과 같은 차례). @@ -51,13 +52,15 @@ import { } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; +import { routeRunners, type RouteInputs } from "./design_calc_node_route"; import { sectionsRunners, type SectionsInputs } from "./design_calc_node_sections"; import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages"; import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs, SectionsInputs { +interface CalcInputs + extends FollowInputs, CarryInputs, TrialInputs, StagesInputs, SectionsInputs, RouteInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -113,6 +116,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A) ...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1) ...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2) + ...routeRunners(inputs), // 노선 폴리라인 · 곡선표(511-B B-5) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts index f6ce41a40..6433bb29a 100644 --- a/A00_Common/design/logic/design_calc_node_follow.ts +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -7,12 +7,15 @@ * base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`) * facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line · * max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`) + * — follow.fill { previous?: { structure_id: 항목 } · only_missing } 있으면 = 저장 좌표 맞춤(같은 F1 · + * 511-B B-5) → { xy: [{ x · y } | null(그대로)] · filled }(`fillStructureCoordinates` · items 에 structure_id) * facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } → * { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 } * ========================================================================== */ import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat"; import { + fillStructureCoordinates, followPlusRows, followStructures, type FollowingStructure, @@ -27,10 +30,11 @@ const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y })); export interface FollowInputs { follow?: { - items: FollowingStructure[]; + items: Array; line: Line; max_drift_m?: number; interval_m?: number | null; + fill?: { previous?: Record | null; only_missing?: boolean }; }; plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line }; seat?: { @@ -61,6 +65,14 @@ export function followRunners(inputs: FollowInputs): Record unknow "facility.follow": () => { const follow = inputs.follow; if (!follow) return null; + if (follow.fill) + return fillStructureCoordinates( + follow.items, + xyOf(follow.line), + follow.fill.previous ?? null, + follow.fill.only_missing ?? false, + follow.interval_m ?? null, + ); return followStructures( follow.items, xyOf(follow.line), diff --git a/A00_Common/design/logic/design_calc_node_route.ts b/A00_Common/design/logic/design_calc_node_route.ts new file mode 100644 index 000000000..af3bf7746 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_route.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_route.ts + * 계산 Node 입구(`design_calc_node.ts`)의 노선 몫(PLAN 511-B B-5) — 화면 노선 편집과 같은 TS. + * + * route.polyline 입력 route_polyline { points · curve_flags · radii · locks · arcs · min_radius_m } → + * { cleaned: 겹친 점(0.5 m) 뺀 노드 · nodes: 노드 요약(cleaned 자리) · vertices: 선 · + * curves: 곡선(node_first · last = cleaned 자리) · edits: 곡선표 편집표 } + * — 서버 노선 저장 `_write_planned_polyline`(편집 갈래 · 파이썬은 파일 꼴 반올림만) + * ========================================================================== */ + +import { + buildEditedPolyline, + DUPLICATE_TOLERANCE_M, + type Vertex, +} from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import { nodeEdits } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits"; + +export interface RouteInputs { + route_polyline?: { + points: Vertex[]; + curve_flags?: Array | null; + radii?: Array | null; + locks?: Array | null; + arcs?: Array | null; + min_radius_m: number; + }; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function routeRunners(inputs: RouteInputs): Record unknown> { + return { + "route.polyline": () => { + const at = inputs.route_polyline; + if (!at) throw new Error("노선 입력(route_polyline) 없음"); + const flags = at.curve_flags ?? null; + const radii = at.radii ?? null; + // 겹친 점을 편집값과 함께 거름 — 곡선 자리 번호 = 저장 노드 자리(파이썬 `build_planned_polyline`) + const cleaned: Vertex[] = []; + const on: boolean[] = []; + const forced: Array = []; + at.points.forEach((point, index) => { + const last = cleaned[cleaned.length - 1]; + if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M) + return; + cleaned.push(point); + on.push(flags && index < flags.length ? Boolean(flags[index]) : true); + forced.push(radii && index < radii.length ? radii[index] : null); + }); + const line = buildEditedPolyline(cleaned, on, forced, at.min_radius_m); + return { + cleaned, + nodes: line.nodes, + vertices: line.vertices, + curves: line.curves, + edits: nodeEdits(at.points, flags, radii, at.locks ?? null, at.arcs ?? null), + }; + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_sections.ts b/A00_Common/design/logic/design_calc_node_sections.ts index e9c6463bb..0251ab16a 100644 --- a/A00_Common/design/logic/design_calc_node_sections.ts +++ b/A00_Common/design/logic/design_calc_node_sections.ts @@ -8,11 +8,18 @@ * 노선 저장 옛 노선 열쇠 * 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 type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station"; +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"; @@ -35,6 +42,7 @@ export interface SectionsInputs { cross_half_width_m?: number; cross_sample_interval_m?: number; long_sample_interval_m?: number; + plan?: Array<{ pipes?: PlanPipe[]; structures?: PlanStructure[]; plus?: number[] }>; }; } @@ -62,6 +70,18 @@ export function sectionsRunners(inputs: SectionsInputs): Record un 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 아닌 지표면은 파이썬 길"); diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 70d415e33..85f8293f2 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -64,11 +64,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "route", ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#buildEditedPolyline`, - runner: `py:${U}common_util_route_polyline.py#build_planned_polyline`, - until: "472-4", + runner: "node:route.polyline", pair: `${U}common_util_route_polyline.py#build_planned_polyline`, - mirror: ["test_route_polyline_browser_mirror"], - note: "파이썬 짝은 추천 엔진 · B04 배수 · 노선 확인이 부름 — 짝 남음", + mirror: ["test_route_polyline_browser_mirror", "test_511_b5_route_node"], + note: "서버 노선 저장(편집 갈래 · `_write_planned_polyline`) = Node · 파이썬 짝은 첫 폴리라인(단순화) · 추천 엔진 · B04 배수 · 노선 열기가 부름 — 짝 남음 · 알려진 차 = 교각 딱 5° 배수면 원호 점 ±1(libm 끝자리 · 실데이터 0 차)", }, { id: "route.handle", @@ -92,10 +91,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "route", ts: `${B05L}B05_Profile_UI_RouteEdit_Edits.ts#applyCurveLimits`, - runner: `py:${B05}B05_Profile_Route_NodeEdits.py#node_edits`, - until: "472-4", + runner: "node:route.polyline", pair: `${B05}B05_Profile_Route_NodeEdits.py#node_edits`, - mirror: ["test_430_route_edit_rules"], + mirror: ["test_430_route_edit_rules", "test_511_b5_route_node"], + note: "곡선표 편집표 = Node `route.polyline` 의 `nodeEdits` · 파이썬 짝 = Node 못 돌릴 때", }, { id: "route.station_fit", diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index da11ddc46..f2deae4d2 100644 --- a/A00_Common/design/logic/design_calc_registry_facility.ts +++ b/A00_Common/design/logic/design_calc_registry_facility.ts @@ -30,8 +30,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [ "test_459_route_seat_mirror", "test_380_3_site_follow", "test_472_4_follow_node", + "test_511_b5_route_node", ], - note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS `placeOnLine` 과 규칙 다름)", + note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` = 같은 Node 입구 + fill(`fillStructureCoordinates` · 옛 좌표 지킴 · 0.1 mm · `placeOnLine` 노선 위 자리와 한 벌 · 511-B B-5)", }, { id: "facility.plus_follow", @@ -70,11 +71,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [ site: "browser_now", stage: "stations", ts: `${U}common_util_structure_station.ts#planStations`, - runner: `py:${U}common_util_structure_station.py#plan_stations`, - until: "472-4", + runner: "node:stations.plan", pair: `${U}common_util_structure_station.py#plan_stations`, - mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station"], - note: "파이썬 짝은 B07 · 배수가 넓게 부름", + mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station", "test_511_b5_route_node"], + note: "서버 측점 생성 = Node `stations.sections` · 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기) = Node `stations.plan` · 파이썬 짝은 B06 형상 얹기 · B07 · 배수 · Node 못 돌릴 때", }, { id: "facility.attach", diff --git a/B05_Profile/B05_Profile_Engine_Sections_Node.py b/B05_Profile/B05_Profile_Engine_Sections_Node.py index 22748259b..9e86ddb11 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Node.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Node.py @@ -37,6 +37,13 @@ def plan_pipe(pipe: Any) -> dict[str, Any]: } +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 @@ -62,10 +69,7 @@ def stations_input( "line": xy, "curves": design_curves, "pipes": [plan_pipe(pipe) for pipe in pipes], - "structures": [ - item.model_dump(mode="json") if hasattr(item, "model_dump") else dict(item) - for item in structures - ], + "structures": [plan_structure(item) for item in structures], "types": types, "definition": definition, "plus": [float(at) for at in plus], @@ -90,6 +94,38 @@ def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | 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, diff --git a/B05_Profile/B05_Profile_Route_Polyline_Write.py b/B05_Profile/B05_Profile_Route_Polyline_Write.py new file mode 100644 index 000000000..bf4cfbab1 --- /dev/null +++ b/B05_Profile/B05_Profile_Route_Polyline_Write.py @@ -0,0 +1,184 @@ +"""계획노선 폴리라인 파일 쓰기(`B05_Profile_Router_Replan` 에서 나눔 · PLAN 511-B B-5). + +노드 · 곡선 켬끔 · R(편집 갈래) → 선 · 노드 · 곡선표(+ 편집표) = 늘 떠 있는 Node `route.polyline` +(화면 `buildEditedPolyline` · `nodeEdits` 와 같은 TS) · 파이썬은 파일 꼴(csv 4자리 · 곡선 as_dict +반올림)만. 단순화 갈래(예상노선 첫 폴리라인) · Node 못 돌림 = 파이썬 `build_planned_polyline` · +`node_edits`(짝 · 로그 남김). +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Route_NodeEdits import node_edits +from common_util.common_util_route_geometry import write_route_csv +from common_util.common_util_route_polyline import ( + PlannedPolyline, + RouteCurve, + RouteNode, + build_planned_polyline, +) + +logger = logging.getLogger(__name__) + + +def _nodes_path(path: Path) -> Path: + """그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`.""" + return path.with_name(f"{path.stem}_nodes.csv") + + +def _curves_path(path: Path) -> Path: + """그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`. + + 왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가 + 잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터 + 어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다. + """ + return path.with_name(f"{path.stem}_curves.json") + + +def _read_curves(path: Path, key: str = "curves") -> list[dict]: + """저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록.""" + target = _curves_path(path) + if not target.is_file(): + return [] + try: + data = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target) + return [] + curves = data.get(key) if isinstance(data, dict) else None + return curves if isinstance(curves, list) else [] + + +def node_polyline( + points: list[tuple[float, float]], + radius_m: float, + curve_flags: list[bool] | None, + radii: list[float | None] | None, + locks: list[str | None] | None, + arcs: list[float | None] | None, +) -> tuple[PlannedPolyline, list[dict[str, Any]]] | None: + """편집 갈래 폴리라인 + 편집표 = Node `route.polyline` — 못 돌림 None.""" + from common_util.common_util_calc_runner import run_calc + + route = { + "points": [[float(x), float(y)] for x, y in points], + "curve_flags": curve_flags, + "radii": radii, + "locks": locks, + "arcs": arcs, + "min_radius_m": float(radius_m), + } + try: + out = (run_calc(["route.polyline"], {"route_polyline": route}) or {}).get("route.polyline") + except Exception: # noqa: BLE001 — 노선 저장은 파이썬 짝으로 이어 감 + logger.exception("Node 노선 폴리라인 실패 — 파이썬 길") + return None + if not out: + logger.warning("Node 노선 폴리라인 못 돌림 — 파이썬 길") + return None + # JSON 정수(100) → float — 파이썬 길과 같은 글(`100.0`)로 적힘 + nodes = [ + RouteNode( + x=float(x), + y=float(y), + inner_angle_deg=_num(info["inner_angle_deg"]), + radius_m=_num(info["radius_m"]), + tangent_m=_num(info["tangent_m"]), + violations=list(info["violations"]), + ) + for (x, y), info in zip(out["cleaned"], out["nodes"]) + ] + curves = [ + RouteCurve( + apex=_xy(curve["apex"]), + radius_m=float(curve["radius_m"]), + tangent_m=float(curve["tangent_m"]), + inner_angle_deg=float(curve["inner_angle_deg"]), + start=_xy(curve["start"]), + end=_xy(curve["end"]), + node_first=int(curve["node_first"]), + node_last=int(curve["node_last"]), + violations=list(curve["violations"]), + ) + for curve in out["curves"] + ] + vertices = [_xy(vertex) for vertex in out["vertices"]] + edits = [ + {**edit, "radius_m": _num(edit["radius_m"]), "arc_m": _num(edit["arc_m"])} + for edit in out["edits"] + ] + return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves), edits + + +def _num(value: Any) -> float | None: + return None if value is None else float(value) + + +def _xy(pair: list[float]) -> tuple[float, float]: + return float(pair[0]), float(pair[1]) + + +def _write_planned_polyline( + path: Path, + points: list[tuple[float, float]], + radius_m: float, + *, + simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, + station_snap: bool | None = None, + station_snap_step: float | None = None, + locks: list[str | None] | None = None, + arcs: list[float | None] | None = None, +) -> dict: + """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + + `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) · + `station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.). + `locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`). + + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 + 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 + 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. + """ + edited = curve_flags is not None or radii is not None + made = ( + node_polyline(points, radius_m, curve_flags, radii, locks, arcs) + if edited and not simplify + else None + ) + if made is not None: + result, edits = made + else: + result = build_planned_polyline( + points, + min_radius_m=radius_m, + simplify=simplify, + curve_flags=curve_flags, + radii=radii, + ) + edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None + write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) + write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) + table: dict[str, Any] = { + "min_radius_m": round(radius_m, 3), + "curves": [c.as_dict() for c in result.curves], + } + if station_snap is not None: + table["station_snap"] = bool(station_snap) + if station_snap and station_snap_step is not None: + table["station_snap_step"] = float(station_snap_step) + if edits is not None: + table["edits"] = edits + _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") + return { + "nodes": len(result.nodes), + "curves": result.curve_count, + "violations": result.violation_count, + "vertices": len(result.vertices), + } diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index 990222a25..14ab36cc9 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -120,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), ( diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index b1104b39e..e772dbac6 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -24,7 +24,6 @@ import asyncio import functools -import json import logging import time from pathlib import Path @@ -38,7 +37,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step -from B05_Profile.B05_Profile_Route_NodeEdits import node_edits +from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 + _curves_path, + _nodes_path, + _read_curves, + _write_planned_polyline, +) from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 RouteInitialCriteria, @@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: return grade_class, design_speed, terrain -def _nodes_path(path: Path) -> Path: - """그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`.""" - return path.with_name(f"{path.stem}_nodes.csv") - - -def _curves_path(path: Path) -> Path: - """그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`. - - 왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가 - 잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터 - 어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다. - """ - return path.with_name(f"{path.stem}_curves.json") - - -def _read_curves(path: Path, key: str = "curves") -> list[dict]: - """저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록.""" - target = _curves_path(path) - if not target.is_file(): - return [] - try: - data = json.loads(target.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target) - return [] - curves = data.get(key) if isinstance(data, dict) else None - return curves if isinstance(curves, list) else [] - - -def _write_planned_polyline( - path: Path, - points: list[tuple[float, float]], - radius_m: float, - *, - simplify: bool = True, - curve_flags: list[bool] | None = None, - radii: list[float | None] | None = None, - station_snap: bool | None = None, - station_snap_step: float | None = None, - locks: list[str | None] | None = None, - arcs: list[float | None] | None = None, -) -> dict: - """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. - - `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) · - `station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.). - `locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`). - - **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 - 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 - 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. - """ - result = build_planned_polyline( - points, - min_radius_m=radius_m, - simplify=simplify, - curve_flags=curve_flags, - radii=radii, - ) - write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) - write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) - table: dict[str, Any] = { - "min_radius_m": round(radius_m, 3), - "curves": [c.as_dict() for c in result.curves], - } - if station_snap is not None: - table["station_snap"] = bool(station_snap) - if station_snap and station_snap_step is not None: - table["station_snap_step"] = float(station_snap_step) - if curve_flags is not None or radii is not None: - table["edits"] = node_edits(points, curve_flags, radii, locks, arcs) - _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") - return { - "nodes": len(result.nodes), - "curves": result.curve_count, - "violations": result.violation_count, - "vertices": len(result.vertices), - } - - def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: """계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다. diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 020a16d9d..7a99051da 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest # 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음) # · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음 from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points + from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations from B05_Profile.B05_Profile_Plus_Stations import plus_chainages - planted = [ - float(station["chainage_m"]) - for station in plan_stations( - route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧) - existing, - plus_chainages(root), - ) - ] + # 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝 + group = (route_pipe_points(Path(root)), existing, plus_chainages(root)) + marks = (node_plan_stations([group]) or [plan_stations(*group)])[0] + planted = [float(station["chainage_m"]) for station in marks] legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index e18bb4642..dc749709b 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -42,8 +42,15 @@ export interface RouteStageDeps { minRadiusM(): number; /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */ savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null; + /** 계산 자리 route 「서버」(511-B B-5) — 서버 Node `route.polyline`(같은 `buildEditedPolyline`) 값 · + * null = 화면 셈 · 끊김 · 실패 = undefined(화면 셈으로). */ + remote?(args: DraftArgs, signal: AbortSignal): Promise | null; } +/** `buildEditedPolyline` 인자(곡선 길이 고정을 R 로 푼 뒤) — 화면 셈 · 서버 셈이 같은 값을 받음. */ +export type DraftArgs = Parameters; +type BuiltLine = Pick, "vertices" | "curves">; + /** 정점마다 누가거리 — 첫 점 0. */ export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine { const chainage = [0]; @@ -67,10 +74,19 @@ export function draftLine( draft: RouteDraft, minRadiusM: number, ): { line: Vertex[]; curves: DesignCurve[] } { + return shapedLine(buildEditedPolyline(...draftArgs(draft, minRadiusM))); +} + +/** 초안 → `buildEditedPolyline` 인자(길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음). */ +export function draftArgs(draft: RouteDraft, minRadiusM: number): DraftArgs { const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]); const radius = [...draft.curveRadius]; applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius); - const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM); + return [nodes, draft.curveOn, radius, minRadiusM]; +} + +/** 그린 선 → 계약 꼴 · 서버 저장 자릿수(화면 셈 · 서버 셈 한 벌). */ +function shapedLine(built: BuiltLine): { line: Vertex[]; curves: DesignCurve[] } { return { line: built.vertices.map(xy4), curves: built.curves.map(({ start, apex, end, radius_m }) => ({ @@ -85,9 +101,15 @@ export function draftLine( export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> { return (ctx) => { const draft = ctx.inputs.route; - const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); - if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다."); - return routeLineOf(drawn.line, drawn.curves); + const done = (drawn: { line: Vertex[]; curves: DesignCurve[] } | null) => { + if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다."); + return routeLineOf(drawn.line, drawn.curves); + }; + if (!draft) return done(deps.savedLine()); + const args = draftArgs(draft, deps.minRadiusM()); + const asked = deps.remote?.(args, ctx.signal); + const local = () => shapedLine(buildEditedPolyline(...args)); + return asked ? asked.then((built) => done(built ? shapedLine(built) : local())) : done(local()); }; } diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts index 20fe1dcae..79cb6abcc 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts @@ -52,6 +52,13 @@ const RAD_PER_DEG = Math.PI / 180; /** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ export const HAIRPIN_MIN_RADIUS_M = 10.0; +/** 짝: 파이썬 `f"{x:.1f}"` — 딱 반(x = 홀수/4 · 12.25)이면 짝수 쪽(`toFixed` 는 큰 쪽 · 위반 글이 갈림). */ +export function fixed1(x: number): string { + if (!Number.isInteger(x * 4) || Number.isInteger(x * 2)) return x.toFixed(1); + const floor = Math.floor(x * 10); + return ((floor % 2 === 0 ? floor : floor + 1) / 10).toFixed(1); +} + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { const dx1 = a2[0] - a1[0]; @@ -308,12 +315,10 @@ export function buildEditedPolyline( if (radius <= 0 || geometry === null) continue; if (radius < minRadiusM) { - node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + node.violations.push(`최소곡선반지름 미달(${fixed1(radius)} < ${fixed1(minRadiusM)}m)`); } if (radius < hairpinMinRadiusM) { - node.violations.push( - `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, - ); + node.violations.push(`배향곡선 하한 미달(${fixed1(radius)} < ${fixed1(hairpinMinRadiusM)}m)`); } node.radius_m = radius; node.tangent_m = tangent; diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts index 359bd14f1..3f3dee74b 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts @@ -330,6 +330,34 @@ export interface RouteNodeEdit { arc_m: number | null; } +/** 넣은 편집값 → 곡선표 `edits`(430) — 겹친 점(앞 노드 0.5 m 안)은 빼고 저장 노드와 같은 자리 · 칸이 짧으면 + * 켬 · 자동. ⚠ 짝 `B05_Profile_Route_NodeEdits.node_edits` · 서버 노선 저장 = Node `route.polyline`(511-B B-5). */ +export function nodeEdits( + points: ReadonlyArray, + flags: ReadonlyArray | null = null, + radii: ReadonlyArray | null = null, + locks: ReadonlyArray | null = null, + arcs: ReadonlyArray | null = null, +): RouteNodeEdit[] { + const at = (values: ReadonlyArray | null, index: number): T | null => + values && index < values.length ? values[index] : null; + const kept: Vertex[] = []; + const edits: RouteNodeEdit[] = []; + points.forEach((point, index) => { + const last = kept[kept.length - 1]; + if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M) return; + kept.push(point); + const lock = at(locks, index); + edits.push({ + curve: flags && index < flags.length ? Boolean(flags[index]) : true, + radius_m: at(radii, index), + lock: lock === "radius" || lock === "arc" ? lock : null, + arc_m: at(arcs, index), + }); + }); + return edits; +} + /** * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). * diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py index 0bd7c8a29..637637701 100644 --- a/B06_Section/B06_Section_Router_Calc.py +++ b/B06_Section/B06_Section_Router_Calc.py @@ -87,7 +87,7 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe problem = await _server_grid(project_id, inputs) if problem: return _error(400, problem) - if "stations.list" in run and isinstance(inputs.get("stations"), dict): + 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 diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 094033949..e5dd23a1e 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -46,6 +46,8 @@ NODE_IDS = frozenset( "stations.carry", "stations.list", "stations.sections", + "route.polyline", + "stations.plan", } ) # 창구(`/calc`)가 안 받는 id — 서버 파일 경로를 입력으로 받음(측점 생성 · 511-B B-2) diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index d9f416d4a..1eac2c427 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -31,7 +31,8 @@ GROUPS: tuple[dict[str, Any], ...] = ( }, {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, # 묶음 단계(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-3 ~ B-5 끝난 줄부터 엶 - {"key": "route", "nos": ["R1", "R3"], "switchable": False, "fixed": "both", "why": "pair_B5"}, + # 노선(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"], diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 019f89b44..0056e1a98 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -19,6 +19,7 @@ from __future__ import annotations +import logging import math from typing import Any, Iterable, Sequence @@ -33,6 +34,7 @@ from common_util.common_util_structure_station import ( ) from config.config_system import SECTION_STATION_INTERVAL_M, STRUCTURE_ROUTE_DRIFT_MAX_M +logger = logging.getLogger(__name__) _POSITION_KEYS = ("chainage_m", "start_m", "end_m") _FOLLOW_KEYS = ("placement", *_POSITION_KEYS, "x", "y") #: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝). @@ -96,8 +98,51 @@ def fill_structure_coordinates( `previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다 (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를 낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은 - 좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때. + 좌표(누가거리만 옮김)는 노선 위로. `only_missing` = 읽을 때. + 셈 = TS `fillStructureCoordinates`(Node `facility.follow` + fill · 511-B B-5) · Node 못 돌림 = + 파이썬 짝 `_fill_structure_coordinates_py`. """ + items = list(items) + line = _line(xy) + if line is None or not items: + return 0 + keys = ("structure_id", *_FOLLOW_KEYS) + rows = [{key: getattr(item, key, None) for key in keys} for item in items] + old = { + sid: {key: getattr(item, key, None) for key in keys} + for sid, item in (previous or {}).items() + } + results = run_calc( + ["facility.follow"], + { + "follow": { + "items": rows, + "line": [[x, y] for x, y in line.coords], + "interval_m": SECTION_STATION_INTERVAL_M, + "fill": {"previous": old, "only_missing": only_missing}, + } + }, + ) + if results is None: + logger.warning("구조물 좌표 맞춤 Node 못 돌림 — 파이썬 짝") + return _fill_structure_coordinates_py( + items, xy, previous=previous, only_missing=only_missing + ) + out = results["facility.follow"] + for item, at in zip(items, out["xy"], strict=True): + if at is not None: + item.x, item.y = float(at["x"]), float(at["y"]) + return int(out["filled"]) + + +def _fill_structure_coordinates_py( + items: Iterable[Any], + xy: Sequence[Sequence[float]], + *, + previous: dict[str, Any] | None = None, + only_missing: bool = False, +) -> int: + """`fill_structure_coordinates` 파이썬 짝(Node 못 돌릴 때 · 거울 시험 기준).""" line = _line(xy) if line is None: return 0 diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 9c5a8d063..cabbe3918 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -11,6 +11,8 @@ * - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`. * - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 · * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. + * - `fillStructureCoordinates` = 저장 때 좌표 맞춤 그 자체(옛 좌표 지킴 · 0.1 mm · 서버 Node `facility.fill_xy` · + * 파이썬 짝 = Node 못 돌릴 때) · 노선 위 자리 `pointAtChainage` 는 `placeOnLine` 과 한 벌(511-B B-5). * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). @@ -129,21 +131,73 @@ export function placeOnLine 0 && (walked + span >= item.chainage_m || index === line.length - 1)) { - const t = Math.min(1, Math.max(0, (item.chainage_m - walked) / span)); - return { ...item, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; - } - walked += span; - } - return item; + const at = pointAtChainage(line, item.chainage_m); + return at ? { ...item, ...at } : item; }); } +/** 누가거리의 노선 위 자리 — shapely `LineString.interpolate` 꼴(선분 길이 √ · 넘으면 끝점 · 음수 = 끝에서) + + * 0.1 mm(파이썬 `round(·, 4)` · F1 저장 좌표 맞춤과 같은 값 · 511-B B-5). 노선 없음(점 둘 미만 · 길이 0) = null. */ +export function pointAtChainage(line: ReadonlyArray, chainageM: number): XY | null { + const spans = line.slice(1).map((b, index) => { + const a = line[index]; + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); + }); + const length = spans.reduce((sum, span) => sum + span, 0); + if (!(length > 0)) return null; + const target = chainageM < 0 ? Math.max(0, length + chainageM) : chainageM; + // ponytail: toFixed = 딱 반(홀수/32 m)에서 큰 쪽 · 파이썬 round 는 짝수 쪽 — 실측 좌표엔 안 생김 + const round4 = (value: number) => Number(value.toFixed(4)); + let walked = 0; + for (let index = 0; index < spans.length; index += 1) { + if (walked + spans[index] > target) { + const a = line[index]; + const b = line[index + 1]; + const t = Math.max(0, (target - walked) / spans[index]); + return { x: round4((b.x - a.x) * t + a.x), y: round4((b.y - a.y) * t + a.y) }; + } + walked += spans[index]; + } + const end = line[line.length - 1]; + return { x: round4(end.x), y: round4(end.y) }; +} + +type Positioned = FollowingStructure & { structure_id?: string | null }; + +/** 저장 때 구조물 좌표 맞춤(F1 · 511-B B-5 · 파이썬 `fill_structure_coordinates` 같은 규칙 · 서버 Node + * `facility.fill_xy`) — 누가거리 없음 · `onlyMissing` 에 좌표 있음 = 그대로 · `previous`(저장 전 정본 · + * structure_id)와 자리 셋(기준 · 시작 · 끝)이 같고 옛 좌표 있음 = 옛 좌표 · 좌표가 그 누가거리를 낳음 = 그대로 · + * 그 밖 = 노선 위 자리(`pointAtChainage`). 돌려줌 = 항목마다 새 좌표(안 바뀜 null) · 채운 수. */ +export function fillStructureCoordinates( + items: ReadonlyArray, + line: ReadonlyArray, + previous: Readonly> | null = null, + onlyMissing = false, + intervalM: number | null = null, +): { xy: Array; filled: number } { + const total = line + .slice(1) + .reduce((sum, b, index) => sum + Math.hypot(b.x - line[index].x, b.y - line[index].y), 0); + if (line.length < 2 || !(total > 0)) return { xy: items.map(() => null), filled: 0 }; + const same = (a: Positioned, b: Positioned) => + (a.chainage_m ?? null) === (b.chainage_m ?? null) && + (a.start_m ?? null) === (b.start_m ?? null) && + (a.end_m ?? null) === (b.end_m ?? null); + let filled = 0; + const xy = items.map((item): XY | null => { + if (item.chainage_m == null) return null; + const placed = item.x != null && item.y != null; + if (onlyMissing && placed) return null; + const old = previous?.[item.structure_id ?? ""]; + if (old && same(old, item) && old.x != null) return { x: old.x, y: old.y! }; + if (placed && holdsStation(item, line, intervalM)) return null; + const at = pointAtChainage(line, item.chainage_m); + if (at) filled += 1; + return at; + }); + return { xy, filled }; +} + /** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순). * 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) · 따라가기만 했으면(땅 위 자리 = 정본 x · y · * 누가거리만 화면 계산 · 428) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로 diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index 29f2137ed..31d39fd8a 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -5,6 +5,7 @@ "corridor_mesh": "both", "quantity": "both", "carry": "both", + "route": "both", "stations": "both", "fixed_browser": "both" } diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index f809e25cf..484348f82 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -63,9 +63,10 @@ def test_묶음_여덟_줄_등록부_전부(): "corridor_mesh", "quantity", "carry", + "route", "stations", "fixed_browser", - } # 511-A 셋 더 · 511-B B-2 stations + } # 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": diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 8461439fc..39e309d86 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -50,6 +50,8 @@ NODE_IDS = { "stations.carry", "stations.list", "stations.sections", + "route.polyline", + "stations.plan", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py index 8608cf8eb..b0cd189b8 100644 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -3,7 +3,8 @@ 못박는 것 - 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 = 파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음. - - 판 = 단계 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」 · stations = B-2 가 엶) · F9 = 폼 기본값 · + - 판 = 단계 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」 · stations = B-2 · route = B-5 가 + 엶) · F9 = 폼 기본값 · 커밋한 판 그대로. - 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎 (측점 = 저장 상세 측점 0.01 m). @@ -76,7 +77,6 @@ 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"), "profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"), "cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"), @@ -92,6 +92,7 @@ def test_판_다섯_줄_잠금_기본값_그대로(): "corridor_mesh", "quantity", "carry", + "route", # B-5 = 엶(511-B B-5) "stations", # B-2 끝 = 엶(511-B B-2) "fixed_browser", } diff --git a/resources/tester/test_511_b5_route_node.py b/resources/tester/test_511_b5_route_node.py new file mode 100644 index 000000000..87d915390 --- /dev/null +++ b/resources/tester/test_511_b5_route_node.py @@ -0,0 +1,245 @@ +"""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] diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 9d9856e13..d080cfa5d 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -115,6 +115,10 @@ export const ui_locales_m1 = { "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", "Off = server calc after a pause · F10 = no browser call", ], + B01_CalcSites_Why_route: [ + "끄면 잠잠 뒤 서버 노선(같은 TS) · 노선 [저장] = 서버 Node", + "Off = server route after a pause (same TS) · route [Save] = server Node", + ], B01_CalcSites_Why_stations: [ "끄면 잠잠 뒤 서버 측점(같은 TS) · [저장] 측점 = 서버 Node · DTM 아닌 지표면은 파이썬", "Off = server stations after a pause (same TS) · [Save] stations = server Node · non-DTM surface = Python",