diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index dae2d46c4..aa8b9045b 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -152,8 +152,8 @@ export interface FollowPatch { alignment?: AlignmentEdits; } -/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점). */ -export type StationKind = "grid" | "structure" | "pipe" | "end"; +/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */ +export type StationKind = "grid" | "structure" | "pipe" | "end" | "curve"; /** 횡단 지반 표본 한 점 — 중심에서 거리(m · 좌 +) · 높이(null = 빈칸). */ export interface GroundSample { diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 8cbc5c527..2f86f503d 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -214,6 +214,8 @@ export interface StationTickOptions { /** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). * 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */ chainages?: ReadonlyArray; + /** 곡선 측점(MC · B05 요구 252-2) 누가거리 — 그 눈금 이름 뒤 「 (MC)」 · 늘 그림(솎지 않음). */ + mcChainages?: ReadonlyArray; } /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ @@ -260,8 +262,9 @@ export function drawStationTicks( if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total)); else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage); for (const chainage of marks) { + const mc = options.mcChainages?.includes(chainage) ?? false; const stationNo = Math.round(chainage / interval); - if (stationNo % step !== 0) continue; + if (stationNo % step !== 0 && !mc) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = points[cursor - 1]; const front = points[cursor]; @@ -288,7 +291,7 @@ export function drawStationTicks( context.strokeStyle = "rgba(40, 40, 40, 0.85)"; context.stroke(); - const label = stationLabel(chainage, interval, options.chainages ? 1 : 0); + const label = stationLabel(chainage, interval, options.chainages ? 1 : 0) + (mc ? " (MC)" : ""); const width = context.measureText(label).width + 6; const half = width / 2; // 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를 diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 87d7080f1..cdeedbc5e 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -11,7 +11,7 @@ `snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` · 간격별 MC 값 `test_390_5_snap_step_ops.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`. -⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) · +⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) · `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts` (`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` @@ -324,6 +324,27 @@ def snap_curve_stations( return np.unique(np.asarray(values, dtype=np.float64)) +def curve_stations( + stations: np.ndarray | list[float], + spans: list[tuple[float, ...]], + total: float, + step_m: float | None = None, +) -> list[float]: + """곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이 + 없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 · + BC · EC 는 안 더함. `curveStations` 짝.""" + scale = _mc_scale(step_m) + taken = [float(value) for value in stations] + out: list[float] = [] + for span in spans: + value = math.floor((float(span[0]) + float(span[1])) / 2.0 * scale + 0.5) / scale + if not 0.0 < value < total or any(abs(value - other) < 0.005 for other in taken): + continue + taken.append(value) + out.append(value) + return sorted(out) + + def _design_curve_widenings( station_chainage: np.ndarray, spans: list[tuple[float, float, float, str]], @@ -375,6 +396,7 @@ def generate_sections( `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) · `station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.). + MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함. """ options = options or SectionGenerationOptions() options.validate() @@ -413,6 +435,11 @@ def generate_sections( station_chainage = np.unique( np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)] ) + # 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) + step = station_snap_step if station_snap else None + curves = {round(value, 6) for value in curve_stations(station_chainage, spans, total, step)} + if curves: + station_chainage = np.unique(np.r_[station_chainage, np.array(sorted(curves))]) station_xy = _interpolate_xy(points, route_chainage, station_chainage) tangents = np.vstack( [ @@ -477,6 +504,8 @@ def generate_sections( # 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림 # (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`). kind = "irregular" + elif rounded in curves: + kind = "curve" # 곡선 측점(252-2) — 격자 번호 없음 · 열쇠 `curve:<누가거리>` else: kind = "regular" center_index = int(np.argmin(np.abs(offsets))) @@ -509,7 +538,7 @@ def generate_sections( station = { "station_id": station_id, "chainage_m": round(float(value), 6), - "label": format_station(float(value)), + "label": format_station(float(value)) + (" (MC)" if kind == "curve" else ""), "kind": kind, "center_x": round(float(station_xy[index, 0]), 6), "center_y": round(float(station_xy[index, 1]), 6), diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index c4f37273d..01422067f 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -117,16 +117,20 @@ def station_keys( ) -> dict[str, float]: """측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리. - 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` · + 관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) · + id 없는 관 · 구조물은 `at:` · 같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음). """ owners = _facility_owners(pipes, structures) - values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] + rows = [row for row in stations if row.get("chainage_m") is not None] + values = [float(row["chainage_m"]) for row in rows] out: dict[str, float] = {} for index, chainage in enumerate(values): owner = owners.get(round(chainage, 2)) if owner is not None: key = station_key(owner[0], chainage, interval_m, owner[1]) + elif rows[index].get("kind") == "curve": + key = station_key("curve", chainage, interval_m) elif index == 0: key = station_key("bp", chainage, interval_m) elif index == len(values) - 1: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 7a7aed022..b85081c91 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -348,7 +348,7 @@ export async function mountRouteEditor( locked: apexLock, picked, stationIntervalM, - stations: stations(), + ...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo), }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 517b04eb4..6479e1049 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -82,9 +82,9 @@ export interface CrossWatch { /** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ export const PLAN_PICK_FROM = "b05-plan"; -/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */ +/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 · 곡선 측점 포함). */ const isGridStation = (station: PipelineStation): boolean => - station.kind === "grid" || station.kind === "end"; + station.kind === "grid" || station.kind === "end" || station.kind === "curve"; export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 21268d853..1f88a7a86 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -98,6 +98,8 @@ export interface RouteEditScene { stationIntervalM: number; /** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ stations: ReadonlyArray | null; + /** 곡선 측점(MC · 252-2) 누가거리 — 눈금 이름 뒤 「(MC)」. */ + curveStations?: ReadonlyArray; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray; /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ @@ -484,6 +486,7 @@ function drawStationMarks( uprightRad: scene.uprightRad, onLabel: (box) => labels.push(box), chainages: scene.stations ?? undefined, + mcChainages: scene.curveStations, }, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts index 8ca3e6c87..cef28847c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts @@ -8,6 +8,7 @@ * 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로. * 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음. * 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴. + * 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -16,6 +17,7 @@ import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { curveSpans, + curveStations, snapCurveStations, stationsAlong, } from "./logic/B05_Profile_UI_RouteEdit_Frame"; @@ -52,6 +54,11 @@ export type SnapStep = { changed: () => boolean; /** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */ stations: (line: ReadonlyArray, curves: ReadonlyArray) => number[] | null; + /** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */ + ticks: ( + line: ReadonlyArray, + curves: ReadonlyArray, + ) => { stations: number[] | null; curveStations: number[] }; }; /** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */ @@ -73,6 +80,14 @@ export function bindStationSnap( const step = (): number | null => (select.value === "station" ? null : Number(select.value)); const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null }); let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null }; + const stations = (line: ReadonlyArray, curves: ReadonlyArray) => + on() + ? snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curves), + step(), + ) + : null; const stepM = () => step() ?? stationIntervalM; return Object.assign(stepM, { text: () => snapFitText(select.value), @@ -90,13 +105,15 @@ export function bindStationSnap( now.stationSnap !== loaded.stationSnap || now.stationSnapStep !== loaded.stationSnapStep ); }, - stations: (line: ReadonlyArray, curves: ReadonlyArray) => - on() - ? snapCurveStations( - stationsAlong(line, stationIntervalM, false), - curveSpans(line, curves), - step(), - ) - : null, + stations, + ticks: (line: ReadonlyArray, curves: ReadonlyArray) => { + const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false); + // 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안 + const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null); + return { + stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves), + curveStations: curve, + }; + }, }); } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 3e33fdf8b..46372b979 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -121,7 +121,9 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] => ? "ep" : station.kind === "grid" ? "regular" - : "irregular"; + : station.kind === "curve" + ? "curve" + : "irregular"; /** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index c9b28861c..ff215d4ff 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -5,7 +5,7 @@ * 계약 = `A00_Common/design/logic/design_pipeline_types.ts` 머리). * * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 측점 부분 - * (격자 · 스냅 · 구조물 측점 병합 · 종류 · 틀 · 방위각) ↔ `buildStations` · + * (격자 · 스냅 · 구조물 측점 병합 · 곡선 측점 · 종류 · 틀 · 방위각) ↔ `buildStations` · * `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `planRadii` · `curveWidenings` · * `designCurveWidenings` · `curve_widening_m` · `CURVE_WIDENING_TABLE_M` 등(config_system_design) ↔ * 같은 이름 상수 · `circumradius_2d`(Engine_Geometry) ↔ `circumradius`. @@ -17,6 +17,7 @@ import { curveSpans, + curveStations, snapCurveStations, stationFrameAt, stationsAlong, @@ -67,7 +68,8 @@ const round = (value: number, digits: number): number => Number(value.toFixed(di export const stationText = (chainageM: number): string => chainageM.toFixed(2); /** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:` · - * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. + * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP` · + * 곡선 = `curve:<누가거리>`(ref = 누가거리). * 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */ export function stationKey( kind: StationKind, @@ -76,6 +78,7 @@ export function stationKey( ): string { if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null); if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1) + if (kind === "curve") return commonStationKey("curve", Number(ref), null); const base = commonStationKey(kind, 0, null, String(ref)); // ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김 return role === "anchor" ? base : `${base}:${role}`; @@ -308,7 +311,15 @@ export function buildStations( role: station.role, }); } - const chainages = [...new Set([...grid, ...planted.keys()])].sort((a, b) => a - b); + const withPlanted = [...new Set([...grid, ...planted.keys()])]; + // 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음 + const curved = curveStations( + withPlanted, + spans.map(([bc, ec]) => [bc, ec] as const), + total, + options.stationSnap ? options.stationSnapStep : null, + ); + const chainages = [...withPlanted, ...curved].sort((a, b) => a - b); const fromSpans = spans.length ? designCurveWidenings(chainages, spans) : null; const measured = fromSpans ? null : planRadii(line, chainages, total); @@ -329,6 +340,9 @@ export function buildStations( } else if (mark) { kind = mark.kind; key = stationKey(mark.kind, mark.id, mark.role as "start" | "anchor" | "end"); + } else if (curved.includes(chainage)) { + kind = "curve"; + key = stationKey("curve", chainage); } return { key, diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts index 5d33896c6..7d905afe3 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts @@ -181,3 +181,24 @@ export function snapCurveStations( } return [...new Set(values)].sort((a, b) => a - b); } + +/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이 + * 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */ +export function curveStations( + stations: ReadonlyArray, + spans: ReadonlyArray, + total: number, + stepM?: number | null, +): number[] { + const scale = mcScale(stepM); + const taken = [...stations]; + const out: number[] = []; + for (const [start, end] of spans) { + const value = Math.floor(((start + end) / 2) * scale + 0.5) / scale; + if (!(value > 0 && value < total) || taken.some((other) => Math.abs(value - other) < 0.005)) + continue; + taken.push(value); + out.push(value); + } + return out.sort((a, b) => a - b); +} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 927d12fcd..1f7f323f4 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -115,8 +115,8 @@ export interface SectionStation { station_id: string; chainage_m: number; label: string; - /** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */ - kind: "bp" | "ep" | "regular" | "irregular"; + /** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속) · curve = 곡선 측점(MC · 252-2). */ + kind: "bp" | "ep" | "regular" | "irregular" | "curve"; /** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */ structure?: string; /** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */ diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 777744dba..118d43b52 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -92,7 +92,8 @@ export function appendCardHeader( const title = document.createElement("div"); title.className = "b06-cross-card__title"; const label = document.createElement("strong"); - label.textContent = stationLabel(section.chainage_m, stationInterval); + label.textContent = + stationLabel(section.chainage_m, stationInterval) + (section.kind === "curve" ? " (MC)" : ""); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 19f1f1236..86335e0fc 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -428,12 +428,16 @@ export function createLongitudinalProfile( }), // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). - svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), { - x: stationX, - y: heightPx - bottomInsetPx - 5, - "text-anchor": "middle", - class: "b06-chart__station-label", - }), + svgText( + offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) + + (station.kind === "curve" ? " (MC)" : ""), + { + x: stationX, + y: heightPx - bottomInsetPx - 5, + "text-anchor": "middle", + class: "b06-chart__station-label", + }, + ), ); // 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다. // 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가 diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 6ac2324fa..d463e6851 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -482,7 +482,8 @@ html.is-bm-route .b06-section__notice-bar { } .b06-chart__station-line--bp, -.b06-chart__station-line--regular { +.b06-chart__station-line--regular, +.b06-chart__station-line--curve { stroke: var(--color-warning); } diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index f32c8be10..8c007f05c 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -62,6 +62,7 @@ const freshDesign = () => function kindOf(station: PipelineStation): SectionStation["kind"] { if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp"; + if (station.kind === "curve") return "curve"; return station.kind === "grid" ? "regular" : "irregular"; } diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index 101121e31..f6c0194b9 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -1,7 +1,8 @@ """측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. 일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · -시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. +시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>`` · +곡선 측점(MC · 252-2) = ``curve:<누가거리 0.01 m>``. 구간형 구조물 시작 · 끝 측점 = ``structure::start`` · ``:end``(기준 측점은 ``structure:``). 한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``). @@ -50,6 +51,8 @@ def station_key( return ( f"end:{station_id if station_id is not None else ('BP' if chainage_m <= 0 else 'EP')}" ) + if kind == "curve": + return f"curve:{chainage_key(chainage_m)}" if kind in ("pipe", "structure"): return f"{kind}:{station_id}{suffix}" if station_id else loose if kind == "irregular": diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts index ad6809f2f..afd0e4cd1 100644 --- a/common_util/common_util_station_key.ts +++ b/common_util/common_util_station_key.ts @@ -3,6 +3,7 @@ * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * 곡선 측점(MC · 252-2) = `curve:<누가거리 0.01 m>`. * 구간형 구조물의 시작 · 끝 측점 = `structure::start` · `:end`(기준 측점은 `structure:` 그대로). * 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>`(`dedupeStationKeys`). * 쓰는 곳 = 서버 `B05_Profile_Route_Save_Parts` · 묶음 stations 단계 · B06 세션 횡단 초안(`stationKeysOf`). @@ -11,9 +12,9 @@ * 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`. * ========================================================================== */ -/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) · 묶음 측점 `kind`(grid · end · pipe · structure). */ +/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular · curve) · 묶음 측점 `kind`(grid · end · pipe · structure · curve). */ export type StationKeyKind = - "bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure"; + "bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure" | "curve"; /** 구간형 구조물 측점 자리 — 기준(anchor) · 시작 · 끝. */ export type StationKeyRole = "anchor" | "start" | "end"; @@ -40,6 +41,7 @@ export function stationKey( if (kind === "bp") return "end:BP"; if (kind === "ep") return "end:EP"; if (kind === "end") return `end:${id ?? (chainageM <= 0 ? "BP" : "EP")}`; + if (kind === "curve") return `curve:${chainageKey(chainageM)}`; if (kind === "pipe" || kind === "structure") return id ? `${kind}:${id}${suffix}` : loose; if (kind === "irregular") return id ? `${idKind}:${id}${suffix}` : loose; if (kind === "grid" && id !== null) return `grid:${id}`; diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs index d5dd84ffd..45d4a74a5 100644 --- a/resources/tester/helper_344_3_pipeline_stations.cjs +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -46,6 +46,7 @@ const out = { return logic.buildStations(c.line, c.curves, job.pipes, job.structures, { types: job.types, stationSnap: c.snap, + stationSnapStep: c.step ?? null, // 390-5 · 252-2 곡선 측점 값 자릿수 }); }), text: [0, 12.345, 40, 699.996].map(logic.stationText), diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 03151b31f..09ad8904d 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -116,7 +116,11 @@ function realStages(savedBase, remote = null) { return { route: routeGround.createRouteStage({ minRadiusM: () => job.minRadius, - savedLine: () => ({ line: job.line, curves: [] }), + // 저장 노선 곡선표 = 같은 노드의 편집 곡선(407 곡선 측점 · 빈 표면 옮긴 뒤에만 MC 측점이 생김) + savedLine: () => ({ + line: job.line, + curves: routeGround.draftLine(draftOf(job.nodes), job.minRadius).curves, + }), }), follow: grade.followStage, stations: stationsMod.createStationsStage({ types: () => job.types }), diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py index dfb43a618..70cfa16b1 100644 --- a/resources/tester/test_224_2_station_snap.py +++ b/resources/tester/test_224_2_station_snap.py @@ -241,7 +241,15 @@ def test_켬은_640_없고_631_있음() -> None: assert 640.0 not in by_chainage and 631.0 in by_chainage assert by_chainage[631.0]["kind"] == "regular" assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 - assert len(by_chainage) == len(_stations(False)) + # 끔 = 640 남고 MC(631)에 곡선 측점 하나 더(252-2) · 켬 = 640 을 631 로 옮김(측점 수 그대로) + assert len(by_chainage) == len(_stations(False)) - 1 + + +def test_끔은_MC_에_곡선_측점() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(False)} + assert by_chainage[640.0]["kind"] == "regular" + assert by_chainage[631.0]["kind"] == "curve" + assert by_chainage[631.0]["label"].endswith(" (MC)") def test_631_에_관이면_grid() -> None: diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py index 60efa31d6..196fcfe58 100644 --- a/resources/tester/test_344_3_pipeline_stations_mirror.py +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -192,7 +192,13 @@ def _close(a, b) -> bool: return abs(float(a) - float(b)) <= _TOL -_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}} +_KIND = { + "bp": {"end"}, + "ep": {"end"}, + "regular": {"grid"}, + "irregular": {"pipe", "structure"}, + "curve": {"curve"}, # 252-2 곡선 측점(MC) +} @pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py index 3b2587fb2..4f2316b76 100644 --- a/resources/tester/test_344_3_station_key_mirror.py +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -47,6 +47,9 @@ CASES = [ ("irregular", 1.005, 20.0, None, "structure"), ("pipe", 12.344, 20.0, "pipe-s1", "structure"), ("structure", 12.345, 20.0, None, "structure"), + # 252-2 곡선 측점(MC) — curve:<누가거리 0.01 m> + ("curve", 107.0, 20.0, None, "structure"), + ("curve", 107.35, 20.0, None, "structure"), ] DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006]) LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] diff --git a/resources/tester/test_407_curve_station.py b/resources/tester/test_407_curve_station.py new file mode 100644 index 000000000..039f1aa8f --- /dev/null +++ b/resources/tester/test_407_curve_station.py @@ -0,0 +1,173 @@ +"""PLAN 407 · 요구 252-2 — 곡선 측점(MC 에 측점이 없으면 하나 더함). + +짝: 파이썬 `B05_Profile_Engine_Sections_Core.curve_stations` · `generate_sections` ↔ TS +`logic/B05_Profile_UI_RouteEdit_Frame.ts` `curveStations` · `logic/B05_Profile_Sections_Stations.ts` +`buildStations`(헬퍼 `helper_344_3_pipeline_stations.cjs`). 열쇠 `curve:<누가거리>` · +이름 뒤 「 (MC)」 · +값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 일반측점 값과 같으면 안 더함 · +스냅 켬 · 호 안 일반측점 = 224-2 대로 옮김(곡선 측점 없음) · BC · EC 안 더함. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from common_util.common_util_station_key import station_key # noqa: E402 + +LINE = [[0.0, 0.0], [1000.3, 0.0]] +LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0} +SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0} +ON_GRID = {"start": [195.0, 0.0], "apex": [200.1, 2.0], "end": [205.2, 0.0], "radius_m": 30.0} +CURVES = [LONG, SHORT, ON_GRID] +GRID = [float(value) for value in range(0, 1001, 20)] + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +def _server(snap: bool, step: float | None = None, extras: tuple = ()) -> list[dict]: + data = core.generate_sections( + LINE, + _Flat(), + core.SectionGenerationOptions(extra_stations=extras), + design_curves=CURVES, + station_snap=snap, + station_snap_step=step, + ) + return data["longitudinal"]["stations"] + + +def _curves(stations: list[dict]) -> list[float]: + return [s["chainage_m"] for s in stations if s["kind"] == "curve"] + + +# ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── + + +def test_정수_MC_하나() -> None: + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3) == [107.0] + + +def test_스냅_간격_0_1_이면_소수_한_자리() -> None: + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 0.1) == [107.3] + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 10.0) == [107.0] + + +def test_일반측점_값이면_안_더함() -> None: + assert core.curve_stations(GRID, [(195.0, 205.2)], 1000.3) == [] + + +def test_MC_옆_관_측점이면_안_더함() -> None: + assert core.curve_stations([*GRID, 107.003], [(101.0, 113.6)], 1000.3) == [] + + +def test_노선_밖_MC_는_뺌() -> None: + assert core.curve_stations([0.0], [(995.0, 1010.0)], 1000.3) == [] # MC 1003 + + +def test_스냅_끔_MC_마다_곡선_측점() -> None: + stations = _server(False) + assert _curves(stations) == [107.0, 631.0] # 200 = 일반측점 위 → 안 더함 + by = {s["chainage_m"]: s for s in stations} + assert by[640.0]["kind"] == "regular" and by[200.0]["kind"] == "regular" + assert by[107.0]["label"] == "STA.0+107.000 (MC)" + assert len(stations) == len(_server(False, extras=())) == 51 + 1 + 2 # 격자 · EP · MC 둘 + assert 101.0 not in by and 113.6 not in by # BC · EC 안 더함 + + +def test_스냅_켬_짧은_호만_곡선_측점() -> None: + stations = _server(True) + by = {s["chainage_m"]: s for s in stations} + assert _curves(stations) == [107.0] + assert by[631.0]["kind"] == "regular" and 640.0 not in by # 224-2 옮김 그대로 + + +def test_스냅_켬_0_1_간격() -> None: + stations = _server(True, 0.1) + by = {s["chainage_m"]: s for s in stations} + assert _curves(stations) == [107.3] + assert 631.3 in by and by[200.1]["kind"] == "regular" # 옮긴 일반측점 + + +def test_MC_에_구조물이면_구조물_측점_하나() -> None: + stations = _server(False, extras=((107.0, "관"),)) + by = {s["chainage_m"]: s for s in stations} + assert by[107.0]["kind"] == "irregular" and _curves(stations) == [631.0] + + +def test_열쇠() -> None: + assert station_key("curve", 107.0, 20.0) == "curve:107.00" + assert station_key("curve", 107.3, 20.0) == "curve:107.30" + + +# ── 파이썬 ↔ TS 같은 목록 ───────────────────────────────────────────────────── + +CASES = [ + {"name": "끔", "snap": False, "step": None}, + {"name": "켬", "snap": True, "step": None}, + {"name": "켬0.1", "snap": True, "step": 0.1}, + {"name": "끔0.1", "snap": False, "step": 0.1}, # 끔이면 간격 무시 = 정수 +] + + +@pytest.fixture(scope="module") +def ts_rows() -> list[list[dict]]: + job = { + "cases": [{"line": LINE, "curves": CURVES, **case} for case in CASES], + "pipes": [], + "structures": [], + "types": [], + "move": { + "line": LINE, + "curves": CURVES, + "movedLine": LINE, + "movedCurves": CURVES, + "fromM": 0.0, + }, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["cases"] + + +@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) +def test_파이썬과_TS_같은_목록(ts_rows, index) -> None: + case = CASES[index] + server = _server(case["snap"], case["step"]) + ours = ts_rows[index] + assert [s["chainage_m"] for s in server] == pytest.approx( + [s["chainageM"] for s in ours], abs=1e-6 + ) + for py, ts in zip(server, ours, strict=True): + assert (py["kind"] == "curve") == (ts["kind"] == "curve"), py["chainage_m"] + if ts["kind"] == "curve": + assert ts["key"] == station_key("curve", py["chainage_m"], 20.0) + assert any(s["kind"] == "curve" for s in ours) + # 곡선 측점은 격자 번호를 안 가져감 — 격자 열쇠는 그대로 1 ~ 50 하나씩(0 = BP) + grid = sorted(int(s["key"][5:]) for s in ours if s["kind"] == "grid") + assert grid == list(range(1, 51))