diff --git a/A00_Common/design/design_pipeline_facilities.ts b/A00_Common/design/design_pipeline_facilities.ts index 7df48aef7..8aa74f311 100644 --- a/A00_Common/design/design_pipeline_facilities.ts +++ b/A00_Common/design/design_pipeline_facilities.ts @@ -1,6 +1,6 @@ /* ============================================================================= * A00_Common/design/design_pipeline_facilities.ts - * 계산 묶음 관 · 구조물 먹이기(PLAN 415) — 가게 관 · 구조물 초안이 바뀌면 묶음에 넣음(비면 저장분). + * 계산 묶음 관 · 구조물 · 추가 측점 먹이기(PLAN 415 · 429 ⑨) — 가게 초안이 바뀌면 묶음에 넣음(비면 저장분). * · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync` * (저장 목록 다시 읽기 · 입력 맞춤) 한 번. * · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은 @@ -12,10 +12,11 @@ import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History" import type { useDesignStore } from "./design_store"; import type { ChangeSource } from "./logic/design_pipeline_types"; -type Key = "pipes" | "structures"; +type Key = "pipes" | "structures" | "plusStations"; const FACILITIES: ReadonlyArray<{ source: ChangeSource; key: Key }> = [ { source: "pipe", key: "pipes" }, { source: "structure", key: "structures" }, + { source: "plus", key: "plusStations" }, ]; export interface FacilityFeed { diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 94759324e..3103c59f7 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -58,6 +58,7 @@ import { readAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { plusStationsOf } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; import { analysisTerrain, @@ -533,6 +534,7 @@ function createPagePipeline( cross: crossNow()?.input ?? null, pipes: store.get("pipes") ?? savedPipes, structures: store.get("structures") ?? savedStructures, + plusStations: plusStationsOf(projectId), }; }; const start = (): void => { @@ -625,7 +627,12 @@ function createPagePipeline( store, ready: () => started && !echo, gone: () => disposed, - saved: (key) => (key === "pipes" ? savedPipes : savedStructures), + saved: (key) => + key === "pipes" + ? savedPipes + : key === "structures" + ? savedStructures + : plusStationsOf(projectId), input: (key) => pipe.input(key), routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null, feed: (source, value) => void feed(source, value), diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 45cf43496..8cbb093ff 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -71,6 +71,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard"; import { listedBase } from "./design_profile_base"; +import { tidyEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import * as pagePipeline from "./design_pipeline_page"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; @@ -303,6 +304,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */ function applyEdits(next: AlignmentEdits): boolean { if (!base || !alignment) return false; + next = tidyEdits(base, next); // 직선 위 R · 0 오프셋 지움 · 모양 그대로(426) const candidate = buildAlignment(base, next); if ( base.policy.grade_violation_policy === "block" && diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index a1fb71aec..b5dd3183b 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -9,6 +9,8 @@ * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). + * 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄). + * 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④). * * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, @@ -86,12 +88,22 @@ export function savingDesign(projectId: string | null): boolean { return projectId !== null && saving.has(projectId); } -/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ -function reportOnly(label: string): (error: unknown) => void { - return (error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${label} 저장에 실패했습니다.${detail}`, "error"); - }; +/** 차례 이름 — 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게 · + * 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ +const STEP_LABEL: Record = { + route: "노선", + pipes: "배수관", + structures: "구조물", + plusStations: "추가 측점", + alignment: "계획선", + culvertOptions: "암거 옵션", + uphill: "상단측", + cross: "횡단", +}; + +function stepFailed(step: DesignSaveStep, error: unknown): Error { + const detail = error instanceof Error ? ` ${error.message}` : ""; + return new Error(`${STEP_LABEL[step]} 차례에서 멈췄습니다(뒤 차례 안 보냄).${detail}`); } /** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */ @@ -128,15 +140,7 @@ async function flushAlignmentDraft( if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); } -/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ -const TOLERANT: Partial> = { - pipes: "배수관", - structures: "구조물", - plusStations: "추가 측점", - alignment: "계획선", -}; - -/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ +/** 차례마다 기본 창구. */ function defaultStep( step: Exclude, projectId: string, @@ -153,12 +157,9 @@ function defaultStep( case "alignment": return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": - return () => - flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch( - () => undefined, - ); + return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)); case "uphill": - return () => flushUphillOverrides(projectId).catch(() => undefined); + return () => flushUphillOverrides(projectId); } } @@ -201,35 +202,38 @@ export async function saveDesign( try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { - if (step === "route") { - const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 - if (!body) continue; - const before = await savedPipesIfDraft(ctx.projectId); - routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) - if (routeSaved.route_id != null) routeId = routeSaved.route_id; - if (before) await rebasePendingPipes(ctx.projectId, before); - continue; - } - if (step === "cross") { - if (!routeSaved) await ctx.cross(confirm); - else { - const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); - showToast(routeSaveSummary(routeSaved, outside), "info"); + try { + if (step === "route") { + const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 + if (!body) continue; + const before = await savedPipesIfDraft(ctx.projectId); + routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) + if (routeSaved.route_id != null) routeId = routeSaved.route_id; + if (before) await rebasePendingPipes(ctx.projectId, before); + continue; } - continue; + if (step === "cross") { + if (!routeSaved) await ctx.cross(confirm); + else { + const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); + showToast(routeSaveSummary(routeSaved, outside), "info"); + } + continue; + } + // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. + const own = routeSaved ? undefined : ctx.steps?.[step]; + const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); + const run = + step === "pipes" + ? async () => { + await base(); + await own?.(); + } + : (own ?? base); + await run(); + } catch (error) { + throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨) } - // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. - const own = routeSaved ? undefined : ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); - const run = - step === "pipes" - ? async () => { - await base(); - await own?.(); - } - : (own ?? base); - const label = TOLERANT[step]; - await (label ? run().catch(reportOnly(label)) : run()); } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index 1dea2fe29..970dc5384 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -50,7 +50,7 @@ type Dep = StageName | `input.${InputKey}`; export const STAGE_DEPS: Record = { route: ["input.route"], follow: ["route"], - stations: ["route", "input.pipes", "input.structures"], + stations: ["route", "input.pipes", "input.structures", "input.plusStations"], ground: ["stations"], planline: ["ground"], profile: ["planline", "input.alignment"], @@ -152,6 +152,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig cross: null, pipes: null, structures: null, + plusStations: null, }; const inputVer: Record = { route: 0, @@ -159,6 +160,7 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig cross: 0, pipes: 0, structures: 0, + plusStations: 0, }; const out = emptyOuts(); const outVer = {} as Record; diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index aa8b9045b..217735186 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -5,7 +5,8 @@ * 계산 묶음 본체 = `design_pipeline.ts`. * * 흐름 — 한 방향: - * 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물) + * 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물) · + * plusStations(추가 측점 · 429 ⑨) * ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림 * 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄) * route → follow → stations → ground → planline → profile → cross → footprint → masshaul @@ -24,7 +25,7 @@ * | 단계 | 주인 | deps | 출력 | * | route | sub8 | input.route | RouteLine | * | follow | sub1 | route | FollowPatch(입력 고침) | - * | stations | sub7 | route · input.pipes · input.structures | PipelineStation[](지반 null) | + * | stations | sub7 | route · input.pipes · input.structures · input.plusStations | PipelineStation[](지반 null) | * | ground | sub8 | stations | GroundOutput | * | planline | sub1 | ground | AlignmentBase(첫 계획선) | * | profile | sub1 | planline · input.alignment | ProfileAlignment | @@ -89,12 +90,14 @@ export interface PipelineInputs { pipes: unknown; /** 구조물 목록(`StructureInstance[]`) — 위와 같음. */ structures: unknown; + /** 추가 측점(정수 m · 초안 ?? 저장분) — 바뀌면 stations 부터(429 ⑨) · 셈은 `plusStations()` 가 같은 값을 읽음. */ + plusStations?: ReadonlyArray | null; } export type InputKey = keyof PipelineInputs; -/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물. */ -export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure"; +/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물 · 추가 측점. */ +export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure" | "plus"; /** 출처 → 입력 칸. */ export const SOURCE_INPUT: Record = { @@ -103,6 +106,7 @@ export const SOURCE_INPUT: Record = { cross: "cross", pipe: "pipes", structure: "structures", + plus: "plusStations", }; /** diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index c658fca63..77d0ae4a9 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -27,7 +27,9 @@ import { import { baseElevationAt, defaultCurveLength, + pinEnds, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; +import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight"; /** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */ const SAME_M = 1e-6; @@ -131,7 +133,8 @@ export function buildFreeEdits( const toM = path[path.length - 1].chainage_m; if (toM - fromM <= SAME_M) return edits; const inner = new Map(); - buildAlignment(base, edits).pvi.forEach((node) => { + const current = buildAlignment(base, edits); + current.pvi.forEach((node) => { if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) { inner.set(chainageKey(node.chainage_m), node.chainage_m); } @@ -141,6 +144,10 @@ export function buildFreeEdits( .forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m)); const offsets = { ...edits.station_offsets }; const radii = { ...edits.curve_radii }; + // 끝이 꼭짓점 아닌 측점이면 지금 높이로 꼭짓점(426) — 직선화와 같음 + pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => { + offsets[key] = Number(value.toFixed(6)); + }); inner.forEach((chainage, key) => { const target = elevationOnPath(path, chainage); offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6)); @@ -150,7 +157,7 @@ export function buildFreeEdits( const gradeIn = gradePct(path[index - 1], path[index]) / 100; const gradeOut = gradePct(path[index], path[index + 1]) / 100; const deltaGrade = Math.abs(gradeOut - gradeIn); - if (deltaGrade < 1e-9) continue; + if (isStraightNode(path[index - 1], path[index], path[index + 1])) continue; radii[chainageKey(path[index].chainage_m)] = Number( (defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6), ); diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index fe5ca67a0..95a8a85ec 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -19,6 +19,7 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; +import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits"; /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; @@ -121,6 +122,8 @@ export interface RoutePlanResponse { station_snap_step?: number | null; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; + /** 노드마다 넣은 편집값(430) — 넣은 R · 고정 · 고정 길이 · 켬끔 · 노드 수와 다르거나 옛 노선이면 null. */ + node_edits?: RouteNodeEdit[] | null; } export interface RouteReplanResponse { diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index d5e0f1223..9cba2cb6f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -13,6 +13,9 @@ 수치 최적화(직선 분할 DP·표고 결정)는 [[B05_Profile_Engine_Grade_Solver]]가, 기준 해석과 오케스트레이션은 [[B05_Profile_Engine_Grade]]가 담당한다. 이 모듈은 scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다. + +직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts` +(거울 시험 `test_426_profile_straight.py`). """ from dataclasses import dataclass @@ -27,6 +30,17 @@ ALIGNMENT_SCHEMA_VERSION = 1 _KEY_DECIMALS = 3 +# 직선으로 볼 꼭짓점 높이 어긋남(m) — TS `STRAIGHT_HEIGHT_M` 과 같은 값(426). +STRAIGHT_HEIGHT_M = 1e-5 + + +def is_straight_bend(delta: float, span_left: float, span_right: float) -> bool: + """꼭짓점이 이웃 둘 잇는 선에서 `STRAIGHT_HEIGHT_M` 안이면 직선 — 높이 = |Δ|·a·b/(a+b).""" + if not (span_left > 0 and span_right > 0): + return True + return abs(delta) * span_left * span_right / (span_left + span_right) < STRAIGHT_HEIGHT_M + + def chainage_key(value: float) -> str: """chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙).""" return f"{float(value):.{_KEY_DECIMALS}f}" @@ -303,8 +317,8 @@ def build_curves( delta = grade_out - grade_in chainage = float(pvi_s[index]) key = chainage_key(chainage) - if abs(delta) < 1e-9: - continue + if is_straight_bend(delta, float(spans[index - 1]), float(spans[index])): + continue # 직선 판정 한 벌(426) if only_explicit and overrides.get(key) is None: continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. diff --git a/B05_Profile/B05_Profile_Route_NodeEdits.py b/B05_Profile/B05_Profile_Route_NodeEdits.py new file mode 100644 index 000000000..56f658e2a --- /dev/null +++ b/B05_Profile/B05_Profile_Route_NodeEdits.py @@ -0,0 +1,48 @@ +"""노드마다 넣은 편집값(430) — 곡선 켬끔 · 넣은 R · 고정(R | 길이) · 고정 길이 → 곡선표 `edits`. + +⚠ 짝: `logic/B05_Profile_UI_RouteEdit_Edits.ts` `flattenServerPlan`(`RouteNodeEdit`)이 읽음 · +거울 시험 `resources/tester/test_430_route_edit_rules.py`. + +곡선표 `curves` 는 **그린** R(접선 자리에 눌린 값)만 있어, 그것으로 다시 열면 눌린 R 이 +넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐 — 넣은 값을 따로 둠. 자리 = 저장 노드 +(앞 노드에서 0.5 m 안인 점은 `build_planned_polyline` 과 같이 거름). +""" + +from __future__ import annotations + +import math +from typing import Any + +from common_util.common_util_route_polyline import DUPLICATE_TOLERANCE_M + +_LOCKS = ("radius", "arc") + + +def node_edits( + points: list[tuple[float, float]], + flags: list[bool] | None, + radii: list[float | None] | None, + locks: list[str | None] | None = None, + arcs: list[float | None] | None = None, +) -> list[dict[str, Any]]: + """점마다의 편집값 — 겹친 점은 빼고 저장 노드와 같은 자리로. 칸이 짧으면 기본값(켬 · 자동).""" + + def at(values: list | None, index: int, default: Any = None) -> Any: + return values[index] if values is not None and index < len(values) else default + + kept: list[tuple[float, float]] = [] + edits: list[dict[str, Any]] = [] + for index, point in enumerate(points): + if kept and math.dist(kept[-1], point) <= DUPLICATE_TOLERANCE_M: + continue + kept.append(point) + lock = at(locks, index) + edits.append( + { + "curve": bool(at(flags, index, True)), + "radius_m": at(radii, index), + "lock": lock if lock in _LOCKS else None, + "arc_m": at(arcs, index), + } + ) + return edits diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 7fdbfaadb..fb6e58d74 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -118,11 +118,14 @@ async def sync_uphill_overrides_into_designs( from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Router_Design import ( + USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, pavement_suggestions, read_cross_design_inputs, resolve_longitudinal_path, + stored_berm, + stored_cut_slope, ) if not overrides: @@ -172,23 +175,26 @@ async def sync_uphill_overrides_into_designs( ground_type=str(design.get("ground_type", "soil")), section_mode=next_mode, ditch_side=side, - ditch_type=str(design.get("ditch_type", "standard")), + ditch_type=str(design.get("ditch_type") or "standard"), # 측구 끔 = None 저장분 paved=bool(design.get("paved", False)), standard=stored_standard, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), two_stage_slope=bool(design.get("two_stage_slope", False)), + cut_slope_ratio=stored_cut_slope(design), # 측점별 깎기 경사(429 ⑤) ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), surface_drop_m=ford_drop_at(float(chainage), ford_drops), + berm=stored_berm(design), # 소단 · 쪽마다 옮긴 깊이(429 ⑤) **curve_widening_args(cross_record), **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) - # 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다. - if design.get("display_half_width_m") is not None: - next_design["display_half_width_m"] = design["display_half_width_m"] + # 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `enforce_design_lines` 와 같음 + for key in USER_TOUCHED_KEYS: + if design.get(key) is not None: + next_design[key] = design[key] out.append((float(chainage), next_design)) return out diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 45e184e66..5383aee86 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -42,6 +42,7 @@ 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_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 RouteInitialCriteria, @@ -211,8 +212,8 @@ def _curves_path(path: Path) -> Path: return path.with_name(f"{path.stem}_curves.json") -def _read_curves(path: Path) -> list[dict]: - """저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트).""" +def _read_curves(path: Path, key: str = "curves") -> list[dict]: + """저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록.""" target = _curves_path(path) if not target.is_file(): return [] @@ -221,7 +222,7 @@ def _read_curves(path: Path) -> list[dict]: except (OSError, json.JSONDecodeError): logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target) return [] - curves = data.get("curves") if isinstance(data, dict) else None + curves = data.get(key) if isinstance(data, dict) else None return curves if isinstance(curves, list) else [] @@ -235,11 +236,14 @@ def _write_planned_polyline( 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`). **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 @@ -262,6 +266,8 @@ def _write_planned_polyline( 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), @@ -507,6 +513,13 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: # 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다. simplify=not saved_nodes, ) + saved_edits = await asyncio.to_thread( + _read_curves, + planned_route_working_path(project_root) + if working + else planned_route_initial_path(project_root), + "edits", + ) return { "status": "success", "project_id": str(project_id), @@ -527,6 +540,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "station_snap": await asyncio.to_thread(load_station_snap, project_root), "station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root), "current_initial": await asyncio.to_thread(read_initial_choice, project_root), + # 넣은 R · 고정 · 고정 길이(430) — 저장 노드와 자리가 같을 때만(옛 곡선표 = null) + "node_edits": saved_edits + if saved_nodes and len(saved_edits) == len(outline.nodes) + else None, } @@ -561,6 +578,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An nodes = [(vertex.x, vertex.y) for vertex in request.vertices] curve_flags = [bool(vertex.curve) for vertex in request.vertices] radii = [vertex.radius_m for vertex in request.vertices] + draft = getattr(body, "draft", None) # R · 길이 고정(430) — 초안에만 있음 # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). # 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음. @@ -576,6 +594,8 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An radii=radii, station_snap=request.station_snap, station_snap_step=request.station_snap_step, + locks=list(draft.curveLock) if draft else None, + arcs=list(draft.curveArc) if draft else None, ), working_path, nodes, diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 03013d8e5..5cbe53030 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -16,6 +16,7 @@ import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; +import { bentEditCount } from "./logic/B05_Profile_Profile_Straight"; export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -99,7 +100,7 @@ export function renderBalanceBar(params: BalanceBarParams): void { if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 // `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다. - const editedCount = Object.keys(alignment.edits.station_offsets).length; + const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426) if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { const item = document.createElement("span"); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index eeccad1c3..d24425bc4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -39,11 +39,7 @@ import { type AnalysisTerrain, } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; -import { - buildEditedPolyline, - type EditedCurve, - type EditedNode, -} from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; @@ -69,14 +65,7 @@ import { writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial"; -import { - applyArcLocks, - applyCurveLimits, - curveSummary, - flattenServerPlan, - spliceNode, - type CurveLock, -} from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits"; import { bindHistoryControls, createRouteEditHistory, @@ -147,7 +136,7 @@ export async function mountRouteEditor( let curveRadius: Array = []; /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ - let curveLock: CurveLock[] = []; + let curveLock: edits.CurveLock[] = []; let curveArc: Array = []; /** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점 * (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. @@ -161,6 +150,7 @@ export async function mountRouteEditor( const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => { if (toggle) setStationSnap(!stationSnap); if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음 + tellDraft(); // 끔 · 간격 바꿈도 계산 줄에 바로(430) draw(); }); const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo); @@ -361,12 +351,11 @@ export async function mountRouteEditor( * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { - // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. - applyArcLocks(planned, curveLock, curveArc, curveRadius); - // 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④). - // 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정). - applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM); - const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + // 길이 고정 R → 지정값 하한(R·L · 내각 155° 이상은 R 만) 끌어올림 → 선(셈은 `_Edits` · 0-9 ④). + const built = edits.rebuildLine( + { planned, curveOn, curveRadius, curveLock, curveArc }, + fitLimits(), + ); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; @@ -388,7 +377,7 @@ export async function mountRouteEditor( /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ const curveHint = (): string => - curveSummary({ + edits.curveSummary({ nodeCount: planned.length, curveOn, curveRadius, @@ -399,6 +388,7 @@ export async function mountRouteEditor( minRadiusM, fresh: nodeInfo.length === 0, offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0, + flags: edits.routeFlags(planned, curveOn, curveInfo), // 겹친 노드 · 못 그린 곡선(430) }); // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── @@ -432,9 +422,14 @@ export async function mountRouteEditor( const syncCurveBar = curveBar.sync; /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. - * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */ + * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). + * 하한 검사 한 곳(430) — 지난 걸음이 지키던 하한을 새로 깨면 그 걸음으로 되돌리고 까닭을 상태줄에. */ function applyEdit(message: string, record = true, fitting = true): void { - const from = fit.fitStart(history?.current?.(), snapshotNow()); + const before = history?.current?.(); + markEdited(); + const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : ""; + if (stop && before) return restoreSnapshot(before, stop, false); + const from = fit.fitStart(before, snapshotNow()); if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). @@ -445,6 +440,13 @@ export async function mountRouteEditor( historyControls.sync(); } + /** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */ + function tellDraft(): void { + if (!history) return; + const dirty = history.isDirty() || pickedTerrain() !== designTerrain || snap.changed(); + options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null); + } + /** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */ function fitLimits(): fit.FitLimits { return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() }; @@ -525,10 +527,10 @@ export async function mountRouteEditor( routeHead, curveHint, contourHint, - commit: () => { - if (stationSnap) return applyEdit(snap.text()); // 놓을 때(366-3) - history?.commit(snapshotNow()); - historyControls.sync(); + commit: (index) => { + const before = history?.current?.()?.planned ?? []; // 놓을 때 맞춤(366-3) · 하한 · 보고(430) + applyEdit(stationSnap ? snap.text() : "옮겼습니다."); + if (stationSnap && index !== undefined) curveBar.noteFit(snap.text(), before, index); }, snapStepM: snap, fitDrag: (index) => { @@ -545,7 +547,7 @@ export async function mountRouteEditor( segmentAt: pointer.segmentAt, nodeAt: pointer.nodeAt, insert: (segment, px, py) => { - spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. + edits.spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }, @@ -553,7 +555,7 @@ export async function mountRouteEditor( if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); if (apexLock[index]) return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error"); - spliceNode(snapshotNow(), index); + edits.spliceNode(snapshotNow(), index); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }, @@ -577,7 +579,7 @@ export async function mountRouteEditor( overlay, busy, projectId, - nodes: () => ({ planned, curveOn, curveRadius }), + nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(), @@ -611,7 +613,7 @@ export async function mountRouteEditor( limitArcM = plan.limit_curve_length_m ?? 0; setStationSnap(snap.load(plan)); // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). - const flat = flattenServerPlan(nodes, plan.curves ?? []); + const flat = edits.flattenServerPlan(nodes, plan.curves ?? [], plan.node_edits); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다. ({ nodes: nodeInfo, curves: curveInfo } = flat); @@ -666,9 +668,7 @@ export async function mountRouteEditor( let terrainTicket = 0; terrainSelect.addEventListener("change", () => { setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las"); - // 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3). - const dirty = history?.isDirty() || pickedTerrain() !== designTerrain; - options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null); + tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3). const mine = ++terrainTicket; void loadBackground().then((background) => { if (closed || mine !== terrainTicket) return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index c006134a3..0a78128db 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -39,7 +39,10 @@ import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep"; type Vertex = [number, number]; type RouteSettings = SnapSetting & { terrain: string | null }; -type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; +type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array } & { + curveLock?: RouteDraft["curveLock"]; + curveArc?: Array; +}; export interface RouteApplyParams { /** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */ @@ -72,15 +75,15 @@ interface RouteSource { const sources = new Map(); -/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */ +/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(R · 길이 고정도 실음 — 서버가 곡선표 `edits` 에 둠 · 430). */ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft { return { rev: 0, nodes: nodes.planned.map(([x, y]): Vertex => [x, y]), curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false), curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null), - curveLock: nodes.planned.map(() => null), - curveArc: nodes.planned.map(() => null), + curveLock: nodes.planned.map((_, index) => nodes.curveLock?.[index] ?? null), + curveArc: nodes.planned.map((_, index) => nodes.curveArc?.[index] ?? null), stationSnap: settings.stationSnap, stationSnapStep: settings.stationSnapStep ?? null, terrain: settings.terrain, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 6cace6057..42ab19b74 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -10,8 +10,10 @@ * 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고). * ========================================================================== */ +import { showToast } from "@ui/ui_template_elements"; import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { + CURVE_OPTIONAL_INNER_ANGLE_DEG, curveOptionalAt, deflectionRad, radiusFloorM, @@ -63,6 +65,8 @@ export interface CurveBar { label: CurveLabel; /** 고른 자리에 맞춰 패널을 옮겨 그린다. */ sync: () => void; + /** 끌기 · 손잡이 놓을 때 맞춤 결과를 상태줄 `message` 뒤에(430 · 스냅 끔이면 그대로). */ + noteFit: (message: string, before: Vertex[], picked: number) => void; } export function createCurveBar(params: CurveBarParams): CurveBar { @@ -120,10 +124,17 @@ export function createCurveBar(params: CurveBarParams): CurveBar { params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`); }, onCurveOn: (on) => { - const { picked, curveOn } = params.state(); + const { picked, curveOn, nodeInfo } = params.state(); if (picked < 0) return; curveOn[picked] = on; - params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + // 내각 155° 미만 = 곡선을 둬야 하는 자리(별표2 Ⅰ.2.다.(1)) — 막지 않고 경고(430). + const inner = nodeInfo[picked]?.inner_angle_deg ?? null; + const warn = + !on && inner !== null && !curveOptionalAt(inner) + ? ` — 내각 ${inner.toFixed(1)}° < ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 곡선 자리(경고)` + : ""; + params.applyEdit(on ? "곡선을 넣었습니다." : `곡선을 지웠습니다${warn}.`); + if (warn) showToast(`곡선을 지웠습니다${warn}.`, "warning", 5000); }, }, params.container, @@ -133,7 +144,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { * 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. * 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */ function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void { - const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state(); + const { picked, curveRadius, curveInfo, nodeInfo } = params.state(); const status = params.container.querySelector(".b05-routeedit__status"); if (!status) return; const asked = curveRadius[picked]; @@ -144,17 +155,29 @@ export function createCurveBar(params: CurveBarParams): CurveBar { asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05 ? message.replace(/\.$/, "") : `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`; - const tail = snap - ? fitNote( - before, - planned, - picked, - offStationMcs(line, curveInfo, snap.stepM), - snap.stationM, - snap.stepM, - ) - : ""; - status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`); + status.textContent = (status.textContent ?? "").replace( + message, + `${head}${fitTail(before, picked, false)}.`, + ); + } + + /** 맞춤이 민 교점 · 못 맞춘 곡선 꼬리 — 스냅 끔이면 ""(`hand` = 고른 IP 를 손이 옮김). */ + function fitTail(before: Vertex[], picked: number, hand: boolean): string { + const { curveInfo, planned, line, snap } = params.state(); + if (!snap) return ""; + const off = offStationMcs(line, curveInfo, snap.stepM); + return fitNote(before, planned, picked, off, snap.stationM, snap.stepM, hand); + } + + /** 끌기 · 손잡이 놓을 때(430) — 상태줄 `message` 뒤에 맞춤 결과(`before` = 누르기 전 교점). */ + function noteFit(message: string, before: Vertex[], picked: number): void { + const status = params.container.querySelector(".b05-routeedit__status"); + const tail = fitTail(before, picked, true); + if (status && tail) + status.textContent = (status.textContent ?? "").replace( + message, + `${message.replace(/\.$/, "")}${tail}.`, + ); } /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ @@ -238,5 +261,5 @@ export function createCurveBar(params: CurveBarParams): CurveBar { }); } - return { label, sync }; + return { label, sync, noteFit }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 358f29407..45f975742 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -34,6 +34,7 @@ import { import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; import { curveShortfalls, + floorNote, radiusFloorM, shortfallCrossed, type CurveLock, @@ -98,8 +99,8 @@ export interface RouteEditPointerParams { routeHead: () => string; curveHint: () => string; contourHint: () => string; - /** 끌기 한 번을 되돌리기에 쌓는다. */ - commit: () => void; + /** 끌기 한 번을 되돌리기에 쌓는다 — `index` = 끈 교각점(430 · 놓을 때 맞춤 보고 · 방향키는 안 줌). */ + commit: (index?: number) => void; /** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */ snapStepM: () => number; /** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 · @@ -308,6 +309,9 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const node = dragHandle.node; const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { + // 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430). + const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); + const kept = [state.planned[node], state.curveRadius[node], state.curveOn[node]] as const; state.planned[node] = moved.apex; // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). // 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다. @@ -319,8 +323,21 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP params.setPicked(node); // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. - dragMoved = true; params.markEdited(); + const after = params.state(); + if ( + shortfallCrossed( + before, + curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM), + ) + ) { + [state.planned[node], state.curveRadius[node], state.curveOn[node]] = kept; + params.markEdited(); + status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`; + params.draw(); + return; + } + dragMoved = true; params.syncCurveBar(); status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`; params.draw(); @@ -350,9 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP state.planned[dragNode] = previous; params.markEdited(); fitPreview(state.planned); - status.textContent = - `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` + - `(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`; + status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`; params.draw(); return; } @@ -395,7 +410,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 // 눌러야 한다. 놓는 순간에만 쌓는다. if (dragMoved) { - params.commit(); + params.commit(dragHandle?.node ?? dragNode); // 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲). // 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초). void params.crossPreview.refresh(); diff --git a/B05_Profile/logic/B05_Profile_Profile_Straight.ts b/B05_Profile/logic/B05_Profile_Profile_Straight.ts new file mode 100644 index 000000000..26ec4f71e --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Profile_Straight.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * B05_Profile_Profile_Straight.ts + * 종단 「직선인가」 판정 한 벌(426 · 분석 424 6장 · 10장) — 꼭짓점이 이웃 둘을 잇는 선에서 + * `STRAIGHT_HEIGHT_M` 안이면 직선(꺾임 아님). + * + * 짝: `B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend` · `STRAIGHT_HEIGHT_M` — + * 거울 시험 `test_426_profile_straight.py`. + * + * 기울기 정확 비교(1e-9 · 1e-6)는 꼭짓점 높이를 소수 여섯째 자리로 끊은 값(±5e-7 m)이 측점 + * 간격 1 m 안(구조물 측점)에서 기울기 차로 커져 직선을 꺾임으로 읽었다 — 높이로 잰다. + * 부르는 곳: 곡선 넣기(`buildCurves`) · ▲▼ 축(`pivotAxes`) · 직선 찾기(`detectStraightRun`) · + * 편집 정리(`tidyEdits`) · 편집 측점 수. 표의 대수차 빈칸은 안전망으로 따로 둔다. + * ========================================================================== */ + +import type { AlignmentNode, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + +/** 직선으로 볼 꼭짓점 높이 어긋남(m) — 파이썬 `STRAIGHT_HEIGHT_M` 과 같은 값. */ +export const STRAIGHT_HEIGHT_M = 1e-5; + +/** 대수차 · 좌우 마디 길이로 본 직선 — 꼭짓점이 이웃 잇는 선에서 떨어진 높이 = |Δ|·a·b/(a+b). */ +export function isStraightBend(delta: number, spanLeft: number, spanRight: number): boolean { + const span = spanLeft + spanRight; + if (!(spanLeft > 0 && spanRight > 0)) return true; + return (Math.abs(delta) * spanLeft * spanRight) / span < STRAIGHT_HEIGHT_M; +} + +/** 세 꼭짓점의 가운데가 직선 위인가. */ +export function isStraightNode( + prev: AlignmentNode, + node: AlignmentNode, + next: AlignmentNode, +): boolean { + const spanLeft = node.chainage_m - prev.chainage_m; + const spanRight = next.chainage_m - node.chainage_m; + if (!(spanLeft > 0 && spanRight > 0)) return true; + const delta = + (next.elevation_m - node.elevation_m) / spanRight - + (node.elevation_m - prev.elevation_m) / spanLeft; + return isStraightBend(delta, spanLeft, spanRight); +} + +/** index 번째 꼭짓점이 직선 위인가 — 시종점은 꺾임으로 본다(false). */ +export function straightAt(pvi: ReadonlyArray, index: number): boolean { + if (index <= 0 || index >= pvi.length - 1) return false; + return isStraightNode(pvi[index - 1], pvi[index], pvi[index + 1]); +} + +/** 「편집 측점 N개」 — 높이를 고친 꼭짓점 중 실제로 꺾인 것(시종점은 오프셋이 있으면). */ +export function bentEditCount(alignment: ProfileAlignment): number { + const last = alignment.pvi.length - 1; + return alignment.pvi.filter((node, index) => { + if (node.source !== "user") return false; + if (index === 0 || index === last) return true; + return !straightAt(alignment.pvi, index); + }).length; +} diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts index 37756e05f..7454daeb4 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts @@ -98,7 +98,8 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string { } /** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 · - * 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */ + * 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). + * `handMoved` = 끌기 · 손잡이로 고른 IP 를 손이 옮김(430 · 놓을 때) — 고른 IP 는 「밀림」에 안 셈. */ export function fitNote( before: ReadonlyArray, after: ReadonlyArray, @@ -106,10 +107,11 @@ export function fitNote( offMcs: ReadonlyArray, stationM: number, stepM: number, + handMoved = false, ): string { const start = before[picked] ?? [0, 0]; const report = nudgeReport(before, after, picked, start, start); - const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0); + const pushed = report.pushed + (!handMoved && report.movedM > SAME_M ? 1 : 0); const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`); const more = names.length > 3 ? ` 외 ${names.length - 3}` : ""; const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : ""; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index aee19d122..8a9fbd34c 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -14,16 +14,10 @@ * 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`. * ========================================================================== */ +import type { EditedCurve, EditedPolyline, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; import { - buildEditedPolyline, - type EditedCurve, - type EditedPolyline, - type Vertex, -} from "./B05_Profile_UI_RouteEdit_Curve"; -import { - applyArcLocks, - applyCurveLimits, curveShortfalls, + rebuildLine, shortfallCrossed, type NodeColumns, } from "./B05_Profile_UI_RouteEdit_Edits"; @@ -47,10 +41,7 @@ type Columns = Pick Math.abs(node.chainage_m - chainageM) < SAME_STATION_M); // 꼭짓점 아닌 자리 = 그 점을 품은 마디 하나가 좌우 마디 const right = index >= 0 ? index : pvi.findIndex((node) => node.chainage_m > chainageM); if (right <= 0 || (index >= 0 && index >= pvi.length - 1)) return null; const [leftSeg, rightSeg] = index >= 0 ? [index - 1, index] : [right - 1, right - 1]; - const grade = (at: number): number => { - const segment = segments[at]; - return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0; - }; - const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON; + // 이웃끼리 — 꼭짓점이 직선 위(높이 기준 · 426)면 건넘 let from = leftSeg; - while (from > 0 && same(from - 1, leftSeg)) from -= 1; + while (from > 0 && straightAt(pvi, from)) from -= 1; let to = rightSeg + 1; - while (to < pvi.length - 1 && same(to, rightSeg)) to += 1; + while (to < pvi.length - 1 && straightAt(pvi, to)) to += 1; return { fromM: pvi[from].chainage_m, fromZ: pvi[from].elevation_m, diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts index 74bc054e7..843024404 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts @@ -26,9 +26,8 @@ import { chainageKey, controlElevationAt, } from "./B05_Profile_UI_Profile_Alignment"; +import { isStraightNode, straightAt } from "./B05_Profile_Profile_Straight"; -/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */ -const COLLINEAR_EPSILON = 1e-6; /** 두 chainage를 같은 측점으로 볼 허용 오차(m). */ const SAME_STATION_M = 1e-6; @@ -63,17 +62,10 @@ export function baseElevationAt(base: AlignmentBase, chainageM: number): number return last.elevation_m; } -/** 변화점 사이 기울기 (index 번째 구간). */ -function gradeAt(alignment: ProfileAlignment, index: number): number { - const segment = alignment.segments[index]; - if (!segment || segment.length_m <= 1e-12) return 0; - return segment.height_m / segment.length_m; -} - /** * 그 자리를 지나는 **최대 직선 구간**을 찾는다. * - * 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점 + * 사이 꼭짓점이 직선 위(높이 기준 · 426)면 한 직선으로 본다. 전체 측점 * 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만 * 두 측점 이상으로 이어진 직선이 된다. */ @@ -89,18 +81,10 @@ export function detectStraightRun( chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M, ); if (index < 0) return null; - const grade = gradeAt(alignment, index); let first = index; let last = index; - while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) { - first -= 1; - } - while ( - last < alignment.segments.length - 1 && - Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON - ) { - last += 1; - } + while (first > 0 && straightAt(alignment.pvi, first)) first -= 1; + while (last < alignment.segments.length - 1 && straightAt(alignment.pvi, last + 1)) last += 1; // 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다. if (last === first) return null; const fromM = alignment.segments[first].from_m; @@ -122,12 +106,7 @@ export function detectStraightRun( * (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다. */ export function hasStraightRuns(alignment: ProfileAlignment): boolean { - for (let index = 1; index < alignment.segments.length; index += 1) { - if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) { - return true; - } - } - return false; + return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index)); } /** 두 측점 사이(끝 제외)의 변화점 chainage. */ @@ -177,7 +156,47 @@ export function straightenBetween( const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span; return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; }); - return withOffsets(edits, updates, inner.map(chainageKey)); + return withOffsets( + edits, + [...updates, ...pinEnds(base, current, [fromM, toM])], + inner.map(chainageKey), + ); +} + +/** + * 범위 끝이 꼭짓점 아닌 측점(관 · 구조물 · 덧 · 곡선 측점)이면 지금 높이로 꼭짓점을 세운다(426) — + * 안 세우면 끝 높이가 옮긴 안쪽 점을 따라 바뀌어 직선이 다음 꼭짓점부터 시작했다. + */ +export function pinEnds( + base: AlignmentBase, + current: ProfileAlignment, + ends: number[], +): Array<[string, number]> { + return ends + .filter((at) => !current.pvi.some((node) => Math.abs(node.chainage_m - at) < SAME_STATION_M)) + .map((at) => [chainageKey(at), controlElevationAt(current, at) - baseElevationAt(base, at)]); +} + +/** + * 편집 정리(426) — 직선 위 꼭짓점의 기억 R 을 지우고, 0 으로 돌아와 모양을 안 바꾸는 오프셋을 + * 지운다(자동 꼭짓점 자리 · 또는 직선 위). 모양은 그대로 — 적용 한 곳(`applyEdits`)이 부른다. + */ +export function tidyEdits(base: AlignmentBase, edits: AlignmentEdits): AlignmentEdits { + const current = buildAlignment(base, edits); + const pvi = current.pvi; + const straight = new Set( + pvi + .filter((_node, index) => straightAt(pvi, index)) + .map((node) => chainageKey(node.chainage_m)), + ); + const auto = new Set(base.basePvi.map((node) => chainageKey(node.chainage_m))); + const offsets = { ...current.edits.station_offsets }; + const radii = { ...current.edits.curve_radii }; + Object.keys(radii).forEach((key) => straight.has(key) && delete radii[key]); + Object.entries(offsets).forEach(([key, value]) => { + if (Math.abs(value) < 1e-6 && (auto.has(key) || straight.has(key))) delete offsets[key]; + }); + return { station_offsets: offsets, curve_radii: radii }; } /** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */ @@ -209,9 +228,8 @@ export function tiltStraightRun( ); const startZ = controlElevationAt(current, run.fromM); const endZ = controlElevationAt(current, run.toM); - const span = run.toM - run.fromM; - const pivotZ = - startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta; + // 지금 경첩 높이 + 걸음(426) — 처음 직선 위 높이로 다시 잡으면 ▲ 두 번째가 안 오르고 ▼ 가 두 칸 + const pivotZ = controlElevationAt(current, pivot) + delta; const updates: Array<[string, number]> = [ [chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)], @@ -236,7 +254,16 @@ export function tiltStraightRun( updates, inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey), ); - if (deltaGrade < 1e-9) return next; + const straightPivot = isStraightNode( + { chainage_m: run.fromM, elevation_m: startZ }, + { chainage_m: pivot, elevation_m: pivotZ }, + { chainage_m: run.toM, elevation_m: endZ }, + ); + if (straightPivot) { + const curveRadii = { ...next.curve_radii }; + delete curveRadii[chainageKey(pivot)]; + return { ...next, curve_radii: curveRadii }; + } return { ...next, curve_radii: { diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts index 11c0f1fb2..4ca9fc86e 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts @@ -42,7 +42,7 @@ export interface EditedCurve { } /** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ -const DUPLICATE_TOLERANCE_M = 0.5; +export const DUPLICATE_TOLERANCE_M = 0.5; /** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ const ARC_STEP_DEG = 5.0; /** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts index 30fdf4ddd..abafe855d 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts @@ -18,12 +18,18 @@ * 다만 **내각 155° 이상은 뺀다**(별표2 Ⅰ.2.다.(1) 「내각 155도 이상 되는 장소는 곡선을 * 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로 * 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m). + * + * ⚠ 짝(430) — `flattenServerPlan` 이 읽는 넣은 값 `edits` ↔ `B05_Profile_Route_NodeEdits.py`(쓰기) · + * 거울 시험 `test_430_route_edit_rules.py`. * ========================================================================== */ import { + buildEditedPolyline, + DUPLICATE_TOLERANCE_M, innerAngleDeg, type EditedCurve, type EditedNode, + type EditedPolyline, type Vertex, } from "./B05_Profile_UI_RouteEdit_Curve"; @@ -154,6 +160,97 @@ export function shortfallCrossed(before: readonly number[], after: readonly numb return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6); } +/** 선을 짜는 값 — 기본 반지름 · 못 넘는 하한(R · L). */ +export interface CurveLimits { + minRadiusM: number; + limitRadiusM: number; + limitArcM: number; +} + +/** 편집 창 `markEdited` 의 셈 — 길이를 붙든 자리 R 다시 잡기 → 지정값 하한까지 끌어올림(`curveRadius` + * 를 고침) → 선. 교각이 바뀌었을 수 있어 R 부터 잡고 그린다. */ +export function rebuildLine( + cols: Omit, + limits: CurveLimits, +): EditedPolyline { + applyArcLocks(cols.planned, cols.curveLock, cols.curveArc, cols.curveRadius); + applyCurveLimits( + cols.planned, + cols.curveOn, + cols.curveRadius, + limits.limitRadiusM, + limits.limitArcM, + ); + return buildEditedPolyline(cols.planned, cols.curveOn, cols.curveRadius, limits.minRadiusM); +} + +/** 하한 글 꼬리 — 「(곡선반지름 12m · 곡선 길이 20m)」. */ +export function floorNote(limitRadiusM: number, limitArcM: number): string { + return `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""})`; +} + +/** 자리 번호 셋까지 「IP3 · IP5 외 2」. */ +const ipList = (seats: ReadonlyArray): string => + seats + .slice(0, 3) + .map((seat) => `IP${seat}`) + .join(" · ") + (seats.length > 3 ? ` 외 ${seats.length - 3}` : ""); + +/** + * 이번 편집이 하한을 **새로** 깼으면 까닭 한 줄 · 아니면 ""(430 · 모든 평면 조작이 `applyEdit` 한 곳에서). + * `before` = 지난 걸음 사진 · `nodes` = 지금 노드 요약(스냅 맞춤 전 — 맞춤은 스스로 하한을 지킴). + * 노드를 하나 끼우거나 뺐으면 처음 달라진 자리에서 줄을 맞춰 견줌(새 노드 = 지키던 자리) · 둘 넘게 다르면 안 견줌. + */ +export function floorStop( + before: NodeColumns, + planned: ReadonlyArray, + nodes: ReadonlyArray, + limits: CurveLimits, +): string { + const gap = planned.length - before.planned.length; + if (Math.abs(gap) > 1) return ""; + const built = rebuildLine({ ...before, curveRadius: [...before.curveRadius] }, limits); + const was = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM); + const now = curveShortfalls(nodes, limits.limitRadiusM, limits.limitArcM); + const differ = planned.findIndex( + (vertex, i) => vertex[0] !== before.planned[i]?.[0] || vertex[1] !== before.planned[i]?.[1], + ); + const at = differ < 0 ? planned.length : differ; + const old = (i: number): number => + (gap === 0 || i < at ? was[i] : gap > 0 ? (i === at ? 0 : was[i - 1]) : was[i + 1]) ?? 0; + const seats = now.flatMap((value, i) => (value > 1e-6 && old(i) <= 1e-6 ? [i] : [])); + if (!seats.length) return ""; + return `하한에 걸려 되돌렸습니다 — ${ipList(seats)}${floorNote(limits.limitRadiusM, limits.limitArcM)}.`; +} + +/** 상태줄 표시(430) — 겹친 노드(앞 노드에서 0.5 m 안 · 선을 짤 때 버림 · 짝 `DUPLICATE_TOLERANCE_M`) · + * 못 그린 곡선(켬인데 곡선이 없음 · 곧은 자리 · 겹친 노드는 뺌). */ +export function routeFlags( + planned: ReadonlyArray, + curveOn: ReadonlyArray, + curves: ReadonlyArray, +): { overlap: number[]; undrawn: number[] } { + const overlap: number[] = []; + let last: Vertex | null = null; + planned.forEach((vertex, i) => { + if (last && Math.hypot(vertex[0] - last[0], vertex[1] - last[1]) <= DUPLICATE_TOLERANCE_M) + overlap.push(i); + else last = vertex; + }); + const drawn = new Set(curves.map((curve) => curve.node_first)); + const undrawn = planned.flatMap((vertex, i) => + i === 0 || + i === planned.length - 1 || + curveOn[i] === false || + drawn.has(i) || + overlap.includes(i) || + !(innerAngleDeg(planned[i - 1], vertex, planned[i + 1]) < 179.9) + ? [] + : [i], + ); + return { overlap, undrawn }; +} + export interface CurveSummaryInput { nodeCount: number; curveOn: boolean[]; @@ -170,6 +267,8 @@ export interface CurveSummaryInput { fresh: boolean; /** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */ offStation?: number; + /** 겹친 노드 · 못 그린 곡선 자리(430 · `routeFlags`). */ + flags?: { overlap: number[]; undrawn: number[] }; } /** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */ @@ -187,6 +286,7 @@ export function curveSummary(input: CurveSummaryInput): string { ] .filter(Boolean) .join(" · "); + const { overlap, undrawn } = input.flags ?? { overlap: [], undrawn: [] }; if (input.fresh) { const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [저장] 때 반영` : ""; return edits ? `${base}${base ? " · " : ""}${edits}` : base; @@ -195,6 +295,8 @@ export function curveSummary(input: CurveSummaryInput): string { `곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` + `${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` + `${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` + + `${overlap.length ? ` · 겹친 노드 ${ipList(overlap)}(0.5 m 안 · 선에서 빠짐)` : ""}` + + `${undrawn.length ? ` · 못 그린 곡선 ${ipList(undrawn)}` : ""}` + `${edits ? ` · ${edits}` : ""}` ); } @@ -219,6 +321,15 @@ interface ServerNode { violations?: string[]; } +/** 노드마다 넣은 편집값(430) — 곡선표 `edits` 칸 · 서버 `node_edits`(⚠ 짝 `B05_Profile_Route_NodeEdits.py`). */ +export interface RouteNodeEdit { + curve: boolean; + /** 넣은 R(그린 R 아님 · 접선 자리에 눌렸어도 넣은 값) · null = 자동. */ + radius_m: number | null; + lock: CurveLock; + arc_m: number | null; +} + /** * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). * @@ -231,8 +342,15 @@ interface ServerNode { * ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안 * 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집 * 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다. + * + * `edits`(430 · 노드 수와 같을 때만 서버가 줌)가 있으면 곡선 켬끔 · R · 고정 · 고정 길이는 **넣은 값**으로 — + * 그린 R 로 열면 접선 자리에 눌린 R 이 넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐. */ -export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan { +export function flattenServerPlan( + nodes: ServerNode[], + curves: EditedCurve[], + edits?: ReadonlyArray | null, +): FlattenedPlan { const replaced = new Map(); const dropped = new Set(); curves.forEach((curve) => { @@ -263,11 +381,14 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F tangent_m: null, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); - out.curveOn.push(curve !== undefined); + const edit = edits?.[index]; + out.curveOn.push(edit ? edit.curve : curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. - out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); - out.curveLock.push(null); - out.curveArc.push(null); + out.curveRadius.push( + edit ? edit.radius_m : curve ? Math.round(curve.radius_m * 100) / 100 : null, + ); + out.curveLock.push(edit?.lock ?? null); + out.curveArc.push(edit?.arc_m ?? null); out.apexLock.push(false); if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); }); diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 8d93793f6..6fa61ecd4 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -353,15 +353,14 @@ export function crossPatchesFromCache( } /** - * 캐시에 얹힌 사용자 수정분을 영구저장소에 남긴다(임시저장용). 캐시가 없거나 수정분이 - * 없으면 아무것도 보내지 않는다. 저장 실패는 호출자가 처리한다. + * 캐시에 얹힌 사용자 수정분을 영구저장소에 남긴다(노선 저장 뒤 · 임시저장용). 수정분이 없어도 + * 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈(429 ④ · 노선 뒤에 나간 계획고 초안). + * 저장 실패는 호출자가 처리한다. */ export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise { const detail = cache.get(keyOf(projectId, routeId)); - if (!detail) return 0; - const patches = crossPatchesFromCache(detail, kindChangedChainages(projectId)); - if (!patches.length) return 0; - await saveSections(projectId, routeId, undefined, patches); + const patches = detail ? crossPatchesFromCache(detail, kindChangedChainages(projectId)) : []; + await saveSections(projectId, routeId, undefined, patches.length ? patches : undefined); clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) return patches.length; } diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index 1ee3da8d5..c599bdb60 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -159,9 +159,13 @@ Promise.resolve( out.second = { count: snap.snapOldStations(again), toasts: [...toasts] }; // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) - const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); + const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); + const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, + }); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const gone = [ { id: "pipe-80.17", chainage_m: 80.17 }, diff --git a/resources/tester/helper_344_2_route_pairs.cjs b/resources/tester/helper_344_2_route_pairs.cjs index 222cbd91c..275b27073 100644 --- a/resources/tester/helper_344_2_route_pairs.cjs +++ b/resources/tester/helper_344_2_route_pairs.cjs @@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) { return box.exports; } -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_349_0v_span.cjs b/resources/tester/helper_349_0v_span.cjs index 571141758..473788435 100644 --- a/resources/tester/helper_349_0v_span.cjs +++ b/resources/tester/helper_349_0v_span.cjs @@ -21,9 +21,13 @@ function loadTs(file, stubs = {}) { } const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_398_2_curve_input.cjs b/resources/tester/helper_398_2_curve_input.cjs index 1d6beeccd..659ac5430 100644 --- a/resources/tester/helper_398_2_curve_input.cjs +++ b/resources/tester/helper_398_2_curve_input.cjs @@ -20,6 +20,7 @@ const STUBS = { }, centerDirectionOf: () => null, }, + "@ui/ui_template_elements": { showToast: () => {} }, // 곡선 삭제 경고(430) }; function loadTs(file) { diff --git a/resources/tester/helper_400_profile_carry.cjs b/resources/tester/helper_400_profile_carry.cjs index 1416357d7..758fd809a 100644 --- a/resources/tester/helper_400_profile_carry.cjs +++ b/resources/tester/helper_400_profile_carry.cjs @@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) { return box.exports; } -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_426_profile_straight.cjs b/resources/tester/helper_426_profile_straight.cjs new file mode 100644 index 000000000..e94097ecd --- /dev/null +++ b/resources/tester/helper_426_profile_straight.cjs @@ -0,0 +1,189 @@ +/* 426 종단 직선 판정 한 벌 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · `_Straighten.ts` · + * `_Pivot.ts` · `_Profile_Straight.ts` · `design_profile_free.ts` 를 그 자리에서 트랜스파일해 돌린다. + * 붙은 측점(100/100.4 · 120/120.25) 직선화 · 끝점 꼭짓점 아님 · 자유지정 R · 기울이기 누름 쌓임 · + * 편집 측점 수 · 0 오프셋 정리 + 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); +const pivot = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Pivot.ts")); +const judge = loadTs(path.join(LOGIC, "B05_Profile_Profile_Straight.ts")); +const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts")); + +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, +}; +const regular = Array.from({ length: 11 }, (_, i) => i * 20); +const groundAt = (s) => 100 + 0.015 * s + 0.4 * Math.sin(s / 7) + 0.0371 * Math.cos(s * 3.1); +const all = [...regular, 100.4, 120.25].sort((a, b) => a - b); +/** 붙은 측점까지 꼭짓점(A) · 정규 측점만 꼭짓점(B). */ +function makeBase(pviS) { + return { + policy, + onlyExplicitCurves: false, + basePvi: pviS.map((s) => ({ chainage_m: s, elevation_m: Number(groundAt(s).toFixed(6)) })), + chainage: all, + ground: all.map((s) => Number(groundAt(s).toFixed(6))), + stations: all.map((s) => ({ station_id: `S${s}`, chainage_m: s })), + }; +} +const baseA = makeBase(all); +const baseB = makeBase(regular); +const empty = align.emptyEdits(); +const build = (base, edits) => align.buildAlignment(base, edits); +const curvesIn = (alignment, from, to) => + alignment.curves + .filter((c) => c.chainage_m > from + 1e-6 && c.chainage_m < to - 1e-6) + .map((c) => c.chainage_m); +const planAt = (alignment, s) => align.planElevationAt(alignment, s); +const r6 = (v) => Number(v.toFixed(6)); + +// ① 붙은 측점 직선화 40 → 140 (A) +const sA = straight.straightenBetween(baseA, empty, 40, 140); +const alA = build(baseA, sA); +// 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 안쪽 꼭짓점 수 — 시험이 반올림 흠을 실제로 거치는지 +let noiseBends = 0; +alA.pvi.forEach((node, i) => { + if (i === 0 || i === alA.pvi.length - 1) return; + if (node.chainage_m <= 40 + 1e-6 || node.chainage_m >= 140 - 1e-6) return; + const g = (a, b) => (b.elevation_m - a.elevation_m) / (b.chainage_m - a.chainage_m); + if (Math.abs(g(node, alA.pvi[i + 1]) - g(alA.pvi[i - 1], node)) >= 1e-9) noiseBends += 1; +}); +const runA = straight.detectStraightRun(alA, 70); +const axesA = pivot.pivotAxes(alA, 80); +const upA = align.adjustStation(baseA, sA, 80, 0.1); +const alUp = build(baseA, upA); +const gradesUp = alUp.segments + .filter((seg) => seg.from_m >= 40 - 1e-6 && seg.to_m <= 140 + 1e-6) + .map((seg) => r6(seg.grade_percent)); +const straightened = { + noiseBends, + curvesInside: curvesIn(alA, 40, 140), + radiiKeys: Object.keys(sA.curve_radii), + run: runA ? [runA.fromM, runA.toM] : null, + axes: axesA ? [axesA.fromM, axesA.toM] : null, + pressRise: r6(planAt(alUp, 80) - planAt(alA, 80)), + curvesAfterPress: curvesIn(alUp, 40, 140), + gradesUp, + editedCount: judge.bentEditCount(alA), + offsetCount: Object.keys(sA.station_offsets).length, +}; + +// ② 끝점 꼭짓점 아님(B) — 100.4 → 160 직선화 · 120.25 → 180 자유지정 +const alB = build(baseB, empty); +const sB = straight.straightenBetween(baseB, empty, 100.4, 160); +const alSB = build(baseB, sB); +const line = (a, b, s) => a[1] + ((b[1] - a[1]) * (s - a[0])) / (b[0] - a[0]); +const endA = [100.4, planAt(alB, 100.4)]; +const endB = [160, align.controlElevationAt(alB, 160)]; +const endpoint = { + // 끝 꼭짓점 = 직선화 전 그 자리 계획고(꼭짓점 높이 · 그 라운드는 직선의 접선) + endHeld: r6(align.controlElevationAt(alSB, 100.4) - planAt(alB, 100.4)), + endIsVertex: alSB.pvi.some((n) => Math.abs(n.chainage_m - 100.4) < 1e-6), + curvesInside: curvesIn(alSB, 100.4, 160), + innerResidual: [120, 120.25, 140].map((s) => r6(planAt(alSB, s) - line(endA, endB, s))), +}; +const z140 = planAt(alB, 140) + 1; +const fEdits = free.buildFreeEdits(baseB, empty, [ + { chainage_m: 120.25, elevation_m: align.controlElevationAt(alB, 120.25) }, + { chainage_m: 140, elevation_m: z140 }, + { chainage_m: 180, elevation_m: align.controlElevationAt(alB, 180) }, +]); +const alF = build(baseB, fEdits); +// 자유지정 뒤 ▼ 로 140 을 다시 120.25 · 180 잇는 선 위로 → 직선 → 정리하면 R 없음 +const back = + line( + [120.25, align.controlElevationAt(alF, 120.25)], + [180, align.controlElevationAt(alF, 180)], + 140, + ) - planAt(alF, 140); +const downF = align.adjustStation(baseB, fEdits, 140, back); +const tidyF = straight.tidyEdits(baseB, downF); +const alTidyF = build(baseB, tidyF); +const freeCase = { + endHeld: r6(align.controlElevationAt(alF, 120.25) - align.controlElevationAt(alB, 120.25)), + radiusAt140: fEdits.curve_radii["140.000"] ?? null, + curveAt140: alF.curves.some((c) => Math.abs(c.chainage_m - 140) < 1e-6), + curvesInsideF: curvesIn(alF, 120.25, 180), + radiusKeptRaw: downF.curve_radii["140.000"] !== undefined, + radiusAfterTidy: tidyF.curve_radii["140.000"] ?? null, + curvesAfterTidy: curvesIn(alTidyF, 120.25, 180), + sameShape: r6(planAt(alTidyF, 140) - planAt(build(baseB, downF), 140)), +}; + +// ③ 고른 직선 기울이기 — 누름 쌓임 · 되돌아오면 R 없음 (A) +const run = straight.detectStraightRun(alA, 70); +let e = sA; +const rises = []; +const pivotM = 80; // 40 · 140 가운데 = 90 → 가장 가까운 안쪽 꼭짓점(80 · 100 중 앞선 80) +[0.1, 0.1, -0.1, -0.1].forEach((d) => { + e = straight.tiltStraightRun(baseA, e, run, d); + rises.push(r6(align.controlElevationAt(build(baseA, e), pivotM) - planAt(alA, pivotM))); +}); +const tiltOnce = straight.tiltStraightRun(baseA, sA, run, 0.1); +const tilt = { + rises, + finalRadii: Object.keys(e.curve_radii), + finalCurves: curvesIn(build(baseA, e), 40, 140), + editedAfterOne: judge.bentEditCount(build(baseA, tiltOnce)), +}; + +// ④ 0 오프셋 정리 +const tidyZero = straight.tidyEdits(baseB, { + station_offsets: { "60.000": 0, "80.000": 0.3 }, + curve_radii: {}, +}); + +console.log( + JSON.stringify({ + straightened, + endpoint, + freeCase, + tilt, + tidyZero: Object.keys(tidyZero.station_offsets), + mirror: { + base: baseA, + cases: [ + { edits: sA, curves: alA.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) }, + { edits: upA, curves: alUp.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) }, + { + edits: tiltOnce, + curves: build(baseA, tiltOnce).curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]), + }, + ], + }, + }), +); diff --git a/resources/tester/helper_429_save_path.cjs b/resources/tester/helper_429_save_path.cjs new file mode 100644 index 000000000..bec0a82f2 --- /dev/null +++ b/resources/tester/helper_429_save_path.cjs @@ -0,0 +1,130 @@ +/* PLAN 429 단위검증 헬퍼 — [저장] 길 정합. + * ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(`saveCachedCrossPatches`) + * ⑨ 추가 측점 = 계산 묶음 입력(`plusStations` · stations 부터) · 가게 초안 바뀜 → 묶음에 넣음(`feedFacilities`) + * TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구 · 화면 모듈은 가짜. 짝 = `test_429_save_path.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { sessionStorage: null, localStorage: null, setTimeout, clearTimeout }; + +/** 아무 이름이나 받는 가짜 모듈 — 부르면 undefined. */ +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function loader(stubs, real = () => false) { + const cache = new Map(); + const load = (file) => { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + const target = path.join(path.dirname(full), `${request}.ts`); + if (request.startsWith(".") && real(target)) return load(target); + return anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; + }; + return load; +} + +async function run() { + const out = {}; + + // ④ 고침이 없어도 횡단 저장 한 번 — 서버가 지금 종단으로 횡단을 다시 셈 + const sent = []; + const store = loader({ + "./B06_Section_Api_Fetch": { + saveSections: async (...args) => (sent.push(args.map((a) => a ?? null)), {}), + fetchSectionDetail: async () => null, + }, + })(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); + out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9); + out.noPatchSent = sent; + + // ⑨ 묶음 — 추가 측점이 바뀌면 stations 부터 · 같은 값이면 안 셈 + const DESIGN = path.join(ROOT, "A00_Common", "design"); + const pipeMod = loader({}, () => true)(path.join(DESIGN, "logic", "design_pipeline.ts")); + out.stationsDeps = pipeMod.STAGE_DEPS.stations; + const ran = []; + const stages = {}; + for (const name of [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES]) + stages[name] = (ctx) => (ran.push(name), { name, plus: ctx.inputs.plusStations ?? null }); + stages.follow = () => (ran.push("follow"), null); // 입력 고침 없음 + const pipe = pipeMod.createDesignPipeline({ stages }); + await pipe.start({ + route: null, + alignment: null, + cross: null, + pipes: [], + structures: [], + plusStations: [], + }); + ran.length = 0; + await pipe.update("plus", [40]); + out.plusRan = [...ran]; + out.plusSeen = pipe.output("stations")?.plus ?? null; + ran.length = 0; + out.sameValue = pipe.update("plus", [40]); // 같은 값 = 차례 안 띄움(null) + await new Promise((resolve) => setTimeout(resolve, 0)); + out.sameRan = [...ran]; + + // ⑨ 먹이기 — 가게 추가 측점 초안이 바뀌면 묶음에 · 비면 저장분 · 되돌리기는 입력과 견줘 다를 때만 + let restore = null; + const listeners = new Map(); + const values = new Map(); + const fakeStore = { + get: (key) => values.get(key) ?? null, + set: (key, value) => ( + values.set(key, value), + (listeners.get(key) ?? []).forEach((fn) => fn(value)) + ), + subscribe: (key, fn) => { + listeners.set(key, [...(listeners.get(key) ?? []), fn]); + return () => undefined; + }, + }; + const fed = []; + const inputNow = { plusStations: [12] }; + const feeder = loader({ + "../../B05_Profile/B05_Profile_UI_Profile_History": { + onPageHistory: (hooks) => ((restore = hooks.restore), () => undefined), + }, + "./design_save": { savingDesign: () => false }, + })(path.join(DESIGN, "design_pipeline_facilities.ts")); + feeder.feedFacilities({ + projectId: "p1", + store: fakeStore, + ready: () => true, + gone: () => false, + saved: (key) => (key === "plusStations" ? [12] : []), + input: (key) => inputNow[key] ?? null, + routed: () => false, + feed: (source, value) => fed.push([source, value]), + resync: async () => undefined, + }); + fakeStore.set("plusStations", [12, 30]); + fakeStore.set("plusStations", null); // 초안 비움 = 저장분 + out.fed = [...fed]; + fed.length = 0; + values.set("plusStations", [12, 55]); // 되돌리기 = 세션만 되씀(알림 없음) + restore?.(); + out.restoreFed = fed.filter(([source]) => source === "plus"); + return out; +} + +run().then( + (out) => console.log(JSON.stringify(out)), + (error) => { + console.error(error); + process.exit(1); + }, +); diff --git a/resources/tester/helper_430_route_edit_rules.cjs b/resources/tester/helper_430_route_edit_rules.cjs new file mode 100644 index 000000000..7632c8f85 --- /dev/null +++ b/resources/tester/helper_430_route_edit_rules.cjs @@ -0,0 +1,137 @@ +/* 430 평면 형상 제어 규칙 한 곳 — `logic/B05_Profile_UI_RouteEdit_Edits.ts`(하한 검사 · 겹친 노드 · + * 못 그린 곡선 · 넣은 값 되살림) · `_KeyNudge.fitNote` 를 Node 로 돌려 JSON 한 줄 + * (`test_430_route_edit_rules.py` 가 판정). + * node helper_430_route_edit_rules.cjs <서버 곡선표 JSON 파일> + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); +const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts")); + +const LIMITS = { minRadiusM: 30, limitRadiusM: 20, limitArcM: 0 }; +// IP1 · IP2 · IP3 — 직선 넉넉(곡선 R 30 다 들어감) +const ROUTE = [ + [0, 0], + [100, 0], + [200, 60], + [300, 0], + [400, 0], +]; + +function cols(planned) { + const n = planned.length; + return { + planned: planned.map((v) => [v[0], v[1]]), + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + apexLock: Array(n).fill(false), + }; +} + +/** 지난 걸음 `before` 에서 `change` 로 고친 뒤 하한 검사 글. */ +function stop(before, change) { + const now = cols(before.planned); + change(now); + const built = edits.rebuildLine(now, LIMITS); + return edits.floorStop(before, now.planned, built.nodes, LIMITS); +} + +const base = cols(ROUTE); +const out = { + // 같은 노드 수 — IP2 를 IP1 쪽 8 m 앞으로(IP1 · IP2 접선 자리 모자람 → R 하한 아래) + moveBreak: stop(base, (c) => (c.planned[2] = [108, 4])), + moveOk: stop(base, (c) => (c.planned[2] = [200, 70])), + // 끼우기 — IP1 바로 뒤 4 m 에 새 노드(IP1 접선 자리 2 m) + insertBreak: stop(base, (c) => { + c.planned.splice(2, 0, [104, 1]); + for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"]) + c[k].splice(2, 0, k === "curveOn" ? true : k === "apexLock" ? false : null); + }), + // 지우기 — IP2 를 빼도 IP1 · IP3 넉넉 + deleteOk: stop(base, (c) => { + for (const k of ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"]) + c[k].splice(2, 1); + }), + // 둘 넘게 다르면 안 견줌 + manyGap: stop(base, (c) => { + c.planned.splice(1, 0, [50, 0], [52, 0]); + for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"]) + c[k].splice( + 1, + 0, + ...(k === "curveOn" ? [true, true] : k === "apexLock" ? [false, false] : [null, null]), + ); + }), +}; + +// 이미 하한을 밑돌던 자리는 「새로」가 아님 — 밑돈 노선에서 다시 조금 옮겨도 통과 +const broken = cols(ROUTE); +broken.planned[2] = [108, 4]; +out.alreadyBroken = stop(broken, (c) => (c.planned[2] = [108.5, 4.2])); + +// 겹친 노드 · 못 그린 곡선 +const overlap = [ + [0, 0], + [100, 0], + [100.3, 0.2], + [200, 60], + [300, 0], +]; +out.flags = edits.routeFlags(overlap, Array(5).fill(true), []); +out.flagsOff = edits.routeFlags(ROUTE, [true, false, false, false, true], []); +out.summary = edits.curveSummary({ + nodeCount: 5, + curveOn: Array(5).fill(true), + curveRadius: Array(5).fill(null), + curveLock: Array(5).fill(null), + apexLock: [], + curveCount: 2, + violationCount: 0, + minRadiusM: 30, + fresh: false, + flags: out.flags, +}); + +// fitNote — 손으로 옮긴 고른 IP 는 「밀림」에 안 셈(놓을 때 · 430) · 입력 뒤는 셈(408-1) +const after = ROUTE.map((v, i) => (i === 2 ? [v[0] + 3, v[1]] : i === 3 ? [v[0] + 1, v[1]] : v)); +out.noteHand = nudge.fitNote(ROUTE, after, 2, [], 20, 20, true); +out.noteInput = nudge.fitNote(ROUTE, after, 2, [], 20, 20); + +// 짝 — 서버 곡선표(파이썬이 씀)를 그대로 펴서 넣은 값이 돌아오는가 +const server = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const flat = edits.flattenServerPlan(server.nodes, server.curves, server.edits); +out.flat = { + planned: flat.planned, + curveOn: flat.curveOn, + curveRadius: flat.curveRadius, + curveLock: flat.curveLock, + curveArc: flat.curveArc, +}; +const old = edits.flattenServerPlan(server.nodes, server.curves, null); +out.flatOld = { curveRadius: old.curveRadius, curveLock: old.curveLock }; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 7f3e4aca7..31520794f 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -161,16 +161,16 @@ async function run() { out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") }; out.order = DESIGN_SAVE_ORDER; - // ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감 + // ④ 구조물이 실패하면 그 차례에서 멈춤 · 차례 이름째 알림(429 ⑨) · 노선 없으면 안 나감 calls.length = 0; failStructures = true; - await saveDesign( + out.tolerantOk = await saveDesign( { projectId: "p1", routeId: () => 7, steps: { uphill: record("uphill:screen") }, cross: async (confirm) => calls.push(`cross:${confirm}`), - failed: () => calls.push("failed"), + failed: (error) => (calls.push("failed"), (out.tolerantError = error.message)), }, { confirm: true }, ); diff --git a/resources/tester/test_352_3_analysis_terrain_default.py b/resources/tester/test_352_3_analysis_terrain_default.py index 00aa1b676..eb722d92c 100644 --- a/resources/tester/test_352_3_analysis_terrain_default.py +++ b/resources/tester/test_352_3_analysis_terrain_default.py @@ -49,6 +49,7 @@ def test_편집기_배선() -> None: assert main.count("setAnalysisTerrain(") == 1 # 바꿀 때 한 곳뿐 — 열 때 쓰기 없음 handler = main.split('terrainSelect.addEventListener("change"', 1)[1][:900] assert "setAnalysisTerrain(projectId" in handler - assert "options.draft?.edited(" in handler # 바꾸면 계산 묶음에 알림 + assert "tellDraft()" in handler # 바꾸면 계산 묶음에 알림(430 — 스냅과 한 곳) + assert "options.draft?.edited(" in main.split("function tellDraft()", 1)[1][:400] # 설계 지형이 수치지형도면 확정 모델(수치지형도 서피스) 등고선을 「LAS」로 쓰지 않음 assert 'analysisTerrain(projectId) === "las" && designTerrain === "las"' in main diff --git a/resources/tester/test_390_4_snap_step_text.py b/resources/tester/test_390_4_snap_step_text.py index 3f4766817..70d53fa64 100644 --- a/resources/tester/test_390_4_snap_step_text.py +++ b/resources/tester/test_390_4_snap_step_text.py @@ -58,6 +58,7 @@ def test_간격별_상태줄_글(texts, step, want) -> None: def test_편집기는_고른_간격_글을_씀() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") assert "일반측점에 맞췄" not in edit - assert edit.count("applyEdit(snap.text())") == 2 + assert edit.count("applyEdit(snap.text())") == 1 # 켬 · 간격 바꿈 + assert 'applyEdit(stationSnap ? snap.text() : "옮겼습니다.")' in edit # 놓을 때(430) snap = SNAP.read_text(encoding="utf-8") assert "text: () => snapFitText(select.value)" in snap diff --git a/resources/tester/test_426_profile_straight.py b/resources/tester/test_426_profile_straight.py new file mode 100644 index 000000000..b743d8789 --- /dev/null +++ b/resources/tester/test_426_profile_straight.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""426 B05 종단 직선 판정 한 벌 — 높이 기준(1e-5 m) · 끝점 꼭짓점 · 누름 쌓임 · 편집 측점 수. + +요구 259. + +계산(`logic/B05_Profile_Profile_Straight.ts` · `_Alignment.ts` buildCurves · `_Pivot.ts` · +`_Straighten.ts` · `design_profile_free.ts`)은 Node 헬퍼(`helper_426_profile_straight.cjs`)가 +트랜스파일해 돌리고, 파이썬 짝(`B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend`)은 +같은 base · 편집으로 곡선 자리 · R 이 같은지 거울로 본다. +./venv/Scripts/python.exe -m pytest resources/tester/test_426_profile_straight.py -q +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + STRAIGHT_HEIGHT_M, + AlignmentPolicy, + build_alignment, + is_straight_bend, +) + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_426_profile_straight.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_붙은_측점_직선화_뒤_R_없음_한_직선(out): + got = out["straightened"] + # 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 반올림 흠이 실제로 있음 — 시험이 그 길을 거침 + assert got["noiseBends"] > 0 + assert got["curvesInside"] == [] and got["radiiKeys"] == [] + assert got["run"] == [40, 140] + assert got["axes"] == [40, 140] + + +def test_직선_안_누름_톱니_없음(out): + got = out["straightened"] + assert got["pressRise"] == pytest.approx(0.1, abs=1e-4) + assert got["curvesAfterPress"] == [80] # 누른 점 하나만 꺾임 + left, right = got["gradesUp"][:2], got["gradesUp"][2:] + assert max(left) - min(left) < 1e-3 and max(right) - min(right) < 1e-3 # 0.4 m 조각 톱니 없음 + + +def test_편집_측점_수는_꺾인_꼭짓점(out): + assert out["straightened"]["offsetCount"] > 0 + assert out["straightened"]["editedCount"] == 0 + assert out["tilt"]["editedAfterOne"] == 1 + + +def test_끝점_꼭짓점_아니면_지금_높이로_세움(out): + got = out["endpoint"] + assert got["endHeld"] == pytest.approx(0, abs=1e-6) + assert got["endIsVertex"] + assert got["curvesInside"] == [] + assert all(abs(value) < 1e-5 for value in got["innerResidual"]) + + +def test_자유지정_R_뒤_직선되면_R_지움(out): + got = out["freeCase"] + assert got["endHeld"] == pytest.approx(0, abs=1e-6) + assert got["radiusAt140"] and got["curveAt140"] and got["curvesInsideF"] == [140] + assert got["radiusKeptRaw"] # ▼ 만으로는 기억 R 이 남음 → 적용 한 곳의 정리가 지움 + assert got["radiusAfterTidy"] is None and got["curvesAfterTidy"] == [] + assert got["sameShape"] == pytest.approx(0, abs=1e-9) + + +def test_고른_직선_기울이기_누름_쌓임(out): + got = out["tilt"] + assert got["rises"] == pytest.approx([0.1, 0.2, 0.1, 0.0], abs=1e-6) + assert got["finalRadii"] == [] and got["finalCurves"] == [] + + +def test_0_오프셋_정리(out): + assert out["tidyZero"] == ["80.000"] + + +def test_파이썬_짝_같은_기준(): + assert STRAIGHT_HEIGHT_M == 1e-5 + assert is_straight_bend(2.5e-6, 0.4, 20.0) # 반올림 흠(0.4 m 조각) = 직선 + assert not is_straight_bend(1e-3, 20.0, 20.0) # 0.1 % 꺾임 = 꺾임 + assert "STRAIGHT_HEIGHT_M = 1e-5" in _read("B05_Profile/logic/B05_Profile_Profile_Straight.ts") + + +def test_파이썬_거울_곡선_자리_R(out): + base = out["mirror"]["base"] + policy = AlignmentPolicy.from_dict(base["policy"]) + for case in out["mirror"]["cases"]: + got = build_alignment( + base_s=np.array([n["chainage_m"] for n in base["basePvi"]]), + base_z=np.array([n["elevation_m"] for n in base["basePvi"]]), + chainage=np.array(base["chainage"]), + ground=np.array(base["ground"]), + stations=base["stations"], + policy=policy, + edits=case["edits"], + ) + py = [[c["chainage_m"], c["r_m"]] for c in got["curves"]] + assert [c[0] for c in py] == pytest.approx([c[0] for c in case["curves"]], abs=1e-6) + assert [c[1] for c in py] == pytest.approx([c[1] for c in case["curves"]], rel=1e-6) + + +def test_부르는_곳(): + assert "tidyEdits(base, next)" in _read("A00_Common/design/design_profile.ts") + assert "bentEditCount(alignment)" in _read("B05_Profile/B05_Profile_UI_Profile_Balance.ts") + assert "isStraightBend(delta, spanLeft, spanRight)" in _read( + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" + ) + assert "is_straight_bend(delta," in _read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py") diff --git a/resources/tester/test_429_save_path.py b/resources/tester/test_429_save_path.py new file mode 100644 index 000000000..276fc7854 --- /dev/null +++ b/resources/tester/test_429_save_path.py @@ -0,0 +1,144 @@ +"""PLAN 429 — [저장] 길 정합(분석 424 9장 ④⑤⑧⑨). + +- ④ 노선 [저장] 뒤 횡단 저장 = 고침이 없어도 한 번(서버가 지금 종단으로 횡단을 다시 셈 · 뒤에 + 나간 계획고 초안이 횡단 설계 높이에 실림) · 서버 [저장]이 횡단 설계 높이를 저장 종단에서 냄. +- ⑤ 상단측 차례(`sync_uphill_overrides_into_designs`)가 소단 · 깎기 경사 · 조절 값을 남김. +- ⑨ 추가 측점 = 계산 묶음 입력(stations 부터 다시) · 가게 초안 바뀜 · 되돌리기 → 묶음에 넣음. + [저장] 차례 실패 = 그 차례에서 멈춤(`test_design_store_save.py`). +Node 헬퍼 = `helper_429_save_path.cjs`. +""" + +import asyncio +import json +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Router_Design import enforce_design_lines +from config.config_system import STANDARD_CROSS_SECTION + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_429_save_path.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _ground() -> list[dict]: + """왼쪽이 높은 비탈 — 왼쪽 깎기 · 오른쪽 쌓기(편절).""" + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.25 * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +# ── ④ ──────────────────────────────────────────────────────────────────────── + + +def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out): + assert out["noPatchSent"] == [["p1", 9, None, None]] # 본문 없이 = 서버가 다시 셈만 + assert out["noPatchReturn"] == 0 + + +def test_서버_저장이_횡단_높이를_저장_종단에서(tmp_path): + """초안 계획고(101.2)가 종단 정본에 나간 뒤 [저장] 다시 셈 = 그 높이 · 옛 설계(100.0) 아님.""" + old = compute_cross_design( + _ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil", + section_mode="left_cut", ditch_side="left", + ) # fmt: skip + longitudinal = { + "design_profiles": [ + {"samples": [{"chainage_m": 0.0, "elevation_m": 101.2}, + {"chainage_m": 40.0, "elevation_m": 101.2}]} + ] + } # fmt: skip + sections = [{"chainage_m": 20.0, "samples": _ground(), "design": old}] + assert enforce_design_lines(longitudinal, sections, tmp_path, STANDARD_CROSS_SECTION) == 1 + assert sections[0]["design"]["design_elevation_m"] == pytest.approx(101.2, abs=1e-6) + + +# ── ⑤ ──────────────────────────────────────────────────────────────────────── + + +def test_상단측_차례가_소단_깎기경사_조절값_남김(tmp_path, monkeypatch): + import B05_Profile.B05_Profile_Router_Confirm as confirm + import B06_Section.B06_Section_Repository as repo + import B06_Section.B06_Section_Repository_Bulk as bulk + import B06_Section.B06_Section_Router_Design as design_mod + + user = { + "cut_slope_ratio_user": 0.7, + "berm": {"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0}, + "berm_depth_left_m": 2.0, + "basin_adjust": {"w": 1.5}, + "structure_note": "글", + "shoulder_extend_right_m": 0.3, + } + before = compute_cross_design( + _ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil", + section_mode="right_cut", ditch_side="right", + ) # fmt: skip + stored = {**before, **user} + long_file = tmp_path / "long.json" + long_file.write_text("{}", encoding="utf-8") + + async def designs(_connection, _route_id): + return [{"chainage_m": 20.0, "design": stored}] + + async def longitudinal(*_args): + return {"data": {"options": {"standard_cross_section": STANDARD_CROSS_SECTION}}} + + written: list = [] + + async def merge(_connection, *, route_id, entries, replace, project_id=None): + written.extend(entries) + + monkeypatch.setattr(repo, "get_cross_section_designs", designs) + monkeypatch.setattr(confirm, "get_longitudinal_section", longitudinal) + monkeypatch.setattr(bulk, "merge_cross_section_designs", merge) + monkeypatch.setattr(design_mod, "resolve_longitudinal_path", lambda *_: long_file) + monkeypatch.setattr( + design_mod, "read_cross_design_inputs", lambda *_a: (_ground(), 100.0, False, {}) + ) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda _root: []) + + asyncio.run( + confirm.sync_uphill_overrides_into_designs( + None, "p", 1, tmp_path, "long.json", [{"chainage_m": 20.0, "side": "left"}] + ) + ) + assert len(written) == 1 + chainage, after = written[0] + assert chainage == 20.0 and before["ditch_type"] is None # 측구 끔 저장분도 셈 + for key, value in user.items(): + assert after[key] == value, key # 값 남음 + assert after["cut_slope_ratio"] == pytest.approx(0.7) # 깎기 경사가 기하에 실림(키만 아님) + assert after["shoulder_extend_right_m"] == pytest.approx(0.3) + + +# ── ⑨ ──────────────────────────────────────────────────────────────────────── + + +def test_추가_측점이_묶음_입력(out): + assert "input.plusStations" in out["stationsDeps"] + assert out["plusRan"][0] == "stations" and "route" not in out["plusRan"] # stations 부터 + assert "cross" in out["plusRan"] # 뒤 단계까지 이어짐 + assert out["plusSeen"] == [40] + assert out["sameValue"] is None and out["sameRan"] == [] # 같은 값은 안 셈 + + +def test_가게_추가_측점_초안이_묶음에(out): + assert out["fed"] == [["plus", [12, 30]], ["plus", [12]]] # 초안 · 비우면 저장분 + assert out["restoreFed"] == [["plus", [12, 55]]] # 되돌리기 = 입력과 다르면 넣음 diff --git a/resources/tester/test_430_route_edit_rules.py b/resources/tester/test_430_route_edit_rules.py new file mode 100644 index 000000000..bf24e9aea --- /dev/null +++ b/resources/tester/test_430_route_edit_rules.py @@ -0,0 +1,185 @@ +"""430 평면 형상 제어 규칙 한 곳 — 하한 검사(`applyEdit` 한 곳 · 끼우기 · 지우기 · 손잡이) · +곡선 삭제 155° 경고 · 놓을 때 맞춤 보고 · 스냅 끔 · 간격 즉시 계산 줄 · R · 길이 고정 · +넣은 R 곡선표 저장(짝) · 겹친 노드 · 못 그린 곡선 표시. + +TS 셈은 Node(`helper_430_route_edit_rules.cjs`) · 짝 = 파이썬 `_write_planned_polyline` 이 쓴 +곡선표를 TS `flattenServerPlan` 이 펴서 넣은 값이 돌아오는가. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_430_route_edit_rules.py -q +""" + +import csv +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402 +from B05_Profile.B05_Profile_Route_NodeEdits import node_edits # noqa: E402 +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 + +B05 = ROOT / "B05_Profile" +HELPER = Path(__file__).with_name("helper_430_route_edit_rules.cjs") + +# IP2 뒤 직선 16 m — 넣은 R 200 은 접선 자리에 눌려 그려짐 · IP3 바로 뒤 0.3 m 겹친 점 +NODES = [(0.0, 0.0), (120.0, 0.0), (220.0, 70.0), (236.0, 70.0), (236.2, 70.2), (340.0, 0.0)] +FLAGS = [True, True, True, False, True, True] +RADII = [None, 40.0, 200.0, None, None, None] +LOCKS = [None, "radius", "arc", None, "radius", "bogus"] +ARCS = [None, None, 33.5, None, None, None] + + +@pytest.fixture(scope="module") +def saved(tmp_path_factory) -> dict: + path = tmp_path_factory.mktemp("route") / "planned_route.csv" + replan._write_planned_polyline( + path, + NODES, + 30.0, + simplify=False, + curve_flags=FLAGS, + radii=RADII, + locks=LOCKS, + arcs=ARCS, + ) + with replan._nodes_path(path).open(encoding="utf-8") as handle: + nodes = [(float(row["x"]), float(row["y"])) for row in csv.DictReader(handle)] + outline = build_planned_polyline(nodes, min_radius_m=30.0, simplify=False) + return { + "path": path, + "nodes": [node.as_dict() for node in outline.nodes], + "curves": replan._read_curves(path), + "edits": replan._read_curves(path, "edits"), + } + + +@pytest.fixture(scope="module") +def got(saved, tmp_path_factory) -> dict: + server = tmp_path_factory.mktemp("server") / "plan.json" + payload = {key: saved[key] for key in ("nodes", "curves", "edits")} + server.write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER), str(server)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +# ── 하한 검사 한 곳 ────────────────────────────────────────────────────────── + + +def test_옮겨_새로_깨면_까닭(got) -> None: + assert got["moveBreak"].startswith("하한에 걸려 되돌렸습니다 — IP1") + assert "(곡선반지름 20m)" in got["moveBreak"] + assert got["moveOk"] == "" + + +def test_끼우기_새로_깨면_까닭_지우기_넉넉하면_통과(got) -> None: + assert "IP1" in got["insertBreak"] and "IP2" in got["insertBreak"] + assert got["deleteOk"] == "" + assert got["manyGap"] == "" # 둘 넘게 다르면 안 견줌 + + +def test_이미_밑돈_자리는_새로가_아님(got) -> None: + assert got["alreadyBroken"] == "" + + +# ── 겹친 노드 · 못 그린 곡선 ───────────────────────────────────────────────── + + +def test_겹친_노드_못_그린_곡선(got) -> None: + assert got["flags"] == {"overlap": [2], "undrawn": [1, 3]} + assert got["flagsOff"] == {"overlap": [], "undrawn": []} # 곡선 끈 자리는 「못 그림」 아님 + assert "겹친 노드 IP2(0.5 m 안 · 선에서 빠짐)" in got["summary"] + assert "못 그린 곡선 IP1 · IP3" in got["summary"] + + +def test_놓을_때_손으로_옮긴_IP_는_밀림에_안_셈(got) -> None: + assert got["noteHand"] == " · 함께 밀린 교점 1곳" + assert got["noteInput"] == " · 함께 밀린 교점 2곳" + + +# ── 넣은 R · 고정 곡선표 저장(짝) ──────────────────────────────────────────── + + +def test_곡선표_edits_는_저장_노드와_자리가_같음(saved) -> None: + assert len(saved["edits"]) == len(saved["nodes"]) == len(NODES) - 1 # 겹친 점 하나 빠짐 + assert saved["edits"][2] == {"curve": True, "radius_m": 200.0, "lock": "arc", "arc_m": 33.5} + assert saved["edits"][1]["lock"] == "radius" + assert saved["edits"][3]["curve"] is False + assert saved["edits"][4]["lock"] is None # 모르는 고정 값은 버림 + + +def test_다시_열면_넣은_R_고정이_돌아옴(got, saved) -> None: + flat = got["flat"] + drawn = next(c["radius_m"] for c in saved["curves"] if c["node_first"] == 2) + assert drawn < 200 - 1 # 그린 R 은 눌림 + assert flat["curveRadius"][1:3] == [40, 200] # 넣은 값 + assert flat["curveLock"][1:3] == ["radius", "arc"] + assert flat["curveArc"][2] == 33.5 + assert flat["curveOn"][3] is False + assert got["flatOld"]["curveRadius"][2] == pytest.approx(drawn, abs=0.01) # 옛 곡선표 = 그린 R + + +def test_node_edits_짧은_칸은_기본값() -> None: + edits = node_edits([(0, 0), (10, 0), (20, 5)], None, [None, 15.0]) + assert edits[1] == {"curve": True, "radius_m": 15.0, "lock": None, "arc_m": None} + assert edits[2] == {"curve": True, "radius_m": None, "lock": None, "arc_m": None} + + +def test_편집값_없이_쓴_곡선표는_edits_없음(tmp_path) -> None: + path = tmp_path / "planned_route.csv" + replan._write_planned_polyline(path, NODES, 30.0, simplify=False) + assert replan._read_curves(path, "edits") == [] + + +# ── 배선 ──────────────────────────────────────────────────────────────────── + + +def _read(name: str) -> str: + return (B05 / name).read_text(encoding="utf-8") + + +def test_편집기_배선() -> None: + main = _read("B05_Profile_UI_RouteEdit.ts") + apply_edit = main.split("function applyEdit(", 1)[1][:700] + assert "edits.floorStop(before, planned, nodeInfo, fitLimits())" in apply_edit + assert "restoreSnapshot(before, stop, false)" in apply_edit + assert "curveBar.noteFit(snap.text(), before, index)" in main # 놓을 때 맞춤 보고 + snap = main.split("bindStationSnap(chrome", 1)[1][:400] + assert "tellDraft();" in snap # 끔 · 간격 바꿈 즉시 계산 줄 + assert "snap.changed()" in main.split("function tellDraft()", 1)[1][:400] + assert "plan.node_edits" in main + assert "nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc })" in main + assert len(main.splitlines()) <= 695 + + +def test_손잡이_끌기_하한_검사() -> None: + pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts") + handle = pointer.split("// 곡선 시작·끝점을 끈다", 1)[1][:2200] + assert "shortfallCrossed(" in handle + assert "params.commit(dragHandle?.node ?? dragNode)" in pointer + + +def test_곡선_삭제_155_경고() -> None: + bar = _read("B05_Profile_UI_RouteEdit_CurveBar.ts") + on = bar.split("onCurveOn:", 1)[1][:900] + assert "!curveOptionalAt(inner)" in on and '"warning"' in on + + +def test_저장에_고정_실음_읽기에_node_edits() -> None: + apply = _read("B05_Profile_UI_RouteEdit_Apply.ts") + assert "nodes.curveLock?.[index] ?? null" in apply + assert "nodes.curveArc?.[index] ?? null" in apply + router = _read("B05_Profile_Router_Replan.py") + assert '"node_edits":' in router + assert "locks=list(draft.curveLock) if draft else None" in router diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index b06e24837..44a84ff11 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -77,8 +77,8 @@ def test_저장이_창구를_다섯_다_거친다(): assert token in save, f"{token} 가 저장 한 줄기에서 사라짐" persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐" - # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다. - assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" + # 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게). + assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐" order = save[save.index("DESIGN_SAVE_ORDER = [") :] order = order[: order.index("]")] names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 8a3bf9664..ba80df0fa 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -3,8 +3,8 @@ - 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽. - 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠). -- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 · - 계획고는 실패해도 다음 차례로. +- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 어느 차례든 + 실패하면 거기서 멈추고 차례 이름째 알림(429 ⑨). Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다. """ @@ -76,13 +76,12 @@ def test_저장_차례(out): assert out["afterSave"] == {"dirty": False, "draft": None} -def test_구조물_실패는_알리고_넘어감_확정_노선없음(out): - calls = out["tolerant"] - assert "toast:error" in calls - assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen") - assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음" - assert calls[-2:] == ["cross:true", "overlay:off"] - assert "failed" not in calls +def test_구조물_실패는_멈추고_알림_노선없음(out): + """429 ⑨ — 차례가 실패하면 뒤 차례(상단측 · 횡단)를 안 보냄 · 차례 이름째 알림.""" + assert out["tolerant"] == ["overlay:on", "pipes", "structures", "failed", "overlay:off"] + assert out["tolerantOk"] is False + assert out["tolerantError"].startswith("구조물 차례에서 멈췄습니다") + assert "structures 실패" in out["tolerantError"] # 까닭 그대로 assert out["noRouteSave"] is False and out["noRouteCalls"] == []