diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index d5f712bcd..d0c57e1e7 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -31,6 +31,7 @@ import { type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry"; +import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; const xy = (line: ReadonlyArray): Array<{ x: number; y: number }> => @@ -93,9 +94,12 @@ export function createFollowStage(deps: { if (Array.isArray(pipes) && pipes.length) patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); if (Array.isArray(structures) && structures.length) - patch.structures = followStructures(structures as FollowingStructure[], line).map( - (moved) => moved.item, - ); + patch.structures = followStructures( + structures as FollowingStructure[], + line, + undefined, + SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3) + ).map((moved) => moved.item); const span = ctx.before.route ? changedSpan(xy(before), line) : null; if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; return patch; diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index cd5118470..6d4acddcf 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -19,7 +19,11 @@ from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point -from common_util.common_util_structure_station import snap_structure_station +from common_util.common_util_structure_station import ( + route_general_stations, + snap_existing_station, + snap_structure_station, +) from config.config_system import STRUCTURE_ROUTE_DRIFT_MAX_M _POSITION_KEYS = ("chainage_m", "start_m", "end_m") @@ -79,6 +83,7 @@ def follow_structures( 기준 자리 = 투영 → 정수 m(노선 끝 안) · 구간 끝(`start_m` · `end_m`)은 같은 만큼 밀고 0 ~ 연장 안으로 자름. 좌표가 없는 옛 자료는 그대로(거리 None). + site 꼴(사토장 · 토취장 · 새 측점 안 심음) = 새 노선 일반 측점 중 가장 가까운 곳(380-3). """ items = list(items) line = _line(xy) @@ -93,7 +98,13 @@ def follow_structures( result.append((None, False)) continue point = Point(float(x), float(y)) - shift = snap_structure_station(float(line.project(point)), [length]) - float(anchor) + at = float(line.project(point)) + target = ( + snap_existing_station(at, route_general_stations(length)) + if getattr(item, "placement", None) == "site" + else snap_structure_station(at, [length]) + ) + shift = target - float(anchor) for key in _POSITION_KEYS: value = getattr(item, key, None) if value is not None: diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 5441ef643..180a79279 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -14,7 +14,12 @@ * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ -import { SAME_STATION_M, snapStructureStation } from "./common_util_structure_station"; +import { + gridStations, + SAME_STATION_M, + snapExistingStation, + snapStructureStation, +} from "./common_util_structure_station"; /** 「노선에서 멀어짐」 거리(m) — 파이썬 `config_system.STRUCTURE_ROUTE_DRIFT_MAX_M` 짝(거울 시험이 대조). */ export const STRUCTURE_ROUTE_DRIFT_MAX_M = 20; @@ -146,6 +151,7 @@ export function projectPipePoints

( } export interface FollowingStructure { + placement?: string | null; chainage_m?: number | null; start_m?: number | null; end_m?: number | null; @@ -153,18 +159,24 @@ export interface FollowingStructure { y?: number | null; } -/** 구조물이 좌표를 따라감 — 사본 · 노선과 거리(좌표 없으면 null) · 멀어짐(차례는 그대로). */ +/** 구조물이 좌표를 따라감 — 사본 · 노선과 거리(좌표 없으면 null) · 멀어짐(차례는 그대로) · + * `intervalM` 주면 site 꼴(사토장 · 토취장) = 새 노선 일반 측점 중 가장 가까운 곳(380-3 · 파이썬은 서버 간격). */ export function followStructures( items: ReadonlyArray, line: ReadonlyArray, maxDriftM: number = STRUCTURE_ROUTE_DRIFT_MAX_M, + intervalM: number | null = null, ): Array<{ item: S; drift_m: number | null; far: boolean }> { return items.map((item) => { const hit = item.x != null && item.y != null ? projectOnPolyline(line, { x: item.x, y: item.y }) : null; if (hit === null || item.chainage_m == null) return { item: { ...item }, drift_m: null, far: false }; - const shift = snapStructureStation(hit.chainage_m, [hit.length_m]) - item.chainage_m; + const target = + intervalM && item.placement === "site" + ? snapExistingStation(hit.chainage_m, gridStations(hit.length_m, intervalM)) + : snapStructureStation(hit.chainage_m, [hit.length_m]); + const shift = target - item.chainage_m; const clamp = (value: number): number => Math.min(Math.max(value + shift, 0), hit.length_m); return { item: { diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 1a4af5c82..9382b262c 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -45,6 +45,14 @@ def snap_structure_station(chainage_m: float, stations: Sequence[float]) -> floa return max(0.0, min(whole, float(math.floor(end + _EPS)))) +def snap_existing_station(chainage_m: float, stations: Sequence[float]) -> float: + """가장 가까운 기존 측점(가운데면 앞) · 비면 정수 m — TS `snapExistingStation` 짝.""" + value = float(chainage_m) + if not math.isfinite(value) or not stations: + return snap_structure_station(value, stations) + return float(min(stations, key=lambda at: (round(abs(float(at) - value), 6), float(at)))) + + def route_general_stations(total_m: float) -> list[float]: """노선 길이의 일반 측점 — 서버 측점 간격(`SECTION_STATION_INTERVAL_M`).""" return general_stations(total_m, SECTION_STATION_INTERVAL_M) diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index f0b2f8c99..010b54541 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -115,6 +115,17 @@ export function snapExistingStation( }); } +/** 격자 측점 + 끝 측점 — 파이썬 `general_stations` 짝(노선 따라감 site 맞춤 · 380-3). */ +export function gridStations(totalM: number, intervalM: number): number[] { + if (!(totalM > 0 && intervalM > 0)) return [0]; + const values = Array.from( + { length: Math.floor(totalM / intervalM) + 1 }, + (_, i) => i * intervalM, + ); + if (totalM - values[values.length - 1] > EPS) values.push(totalM); + return values; +} + /** 놓는 꼴 따라 맞춤 — site = 가장 가까운 기존 측점 · 그 밖 = 정수 m. */ export const snapStationFor = (chainageM: number, placement: string | null | undefined): number => placement === "site" ? snapExistingStation(chainageM) : snapStructureStation(chainageM); diff --git a/resources/tester/helper_380_3_site_follow.cjs b/resources/tester/helper_380_3_site_follow.cjs new file mode 100644 index 000000000..e8e70aeec --- /dev/null +++ b/resources/tester/helper_380_3_site_follow.cjs @@ -0,0 +1,38 @@ +/* 380-3 노선 따라간 사토장 · 토취장(site) = 새 노선 일반 측점 중 가장 가까운 곳 — 진짜 + * `common_util_route_follow` · `common_util_structure_station` 을 Node 로 돌림. 입력 argv[2] JSON · 표준출력 JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": require("./helper_322_catalog_module.cjs"), +}); +const follow = loadTs("common_util/common_util_route_follow.ts", { + "./common_util_structure_station": station, +}); +const out = { + grid: input.grids.map(([total, interval]) => station.gridStations(total, interval)), + snap: input.snaps.map(([value, stations]) => station.snapExistingStation(value, stations)), + follow: follow + .followStructures(input.items, input.line, undefined, input.interval) + .map((entry) => entry.item.chainage_m), + plain: follow.followStructures(input.items, input.line).map((entry) => entry.item.chainage_m), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py index 819de5f30..518b48d31 100644 --- a/resources/tester/test_322_2_check_mirror.py +++ b/resources/tester/test_322_2_check_mirror.py @@ -182,7 +182,8 @@ def test_save_writes_list_and_enabled_together(ts: dict) -> None: assert sync["same"] is True rows = catalog() assert set(sync["enabled"]) == {t for t, row in rows.items() if row["list"] == "both"} - assert len(sync["enabled"]) == 23 # B05 목록 23(브레인 화면 확인) + # 개수는 마스터 판(M02 사용자 켬끔)마다 다름 — 고정 수 대신 B05 목록(both) 수와 같음만(380-3) + assert len(sync["enabled"]) == sum(1 for row in rows.values() if row["list"] == "both") > 0 assert ts["partOff"] == [{"형상.wall": False}, "none"] and ts["partOn"] == {} diff --git a/resources/tester/test_380_3_site_follow.py b/resources/tester/test_380_3_site_follow.py new file mode 100644 index 000000000..0e1a000a2 --- /dev/null +++ b/resources/tester/test_380_3_site_follow.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""380-3 노선이 바뀌어 좌표로 따라간 사토장 · 토취장(site) = 새 노선 일반 측점 중 가장 가까운 곳. + +요구 241-8 「기존에 있던 측점들을 기준으로」 — 따라간 뒤에도 기존 측점에 섬(CAD 글 ±0.51 m). +그 밖 꼴은 종전대로 정수 m. 짝 = 파이썬 `follow_structures` · `snap_existing_station` · +`general_stations` ↔ TS `followStructures(…, intervalM)` · `snapExistingStation` · `gridStations` +(`helper_380_3_site_follow.cjs`). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +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 common_util.common_util_route_follow import follow_structures # noqa: E402 +from common_util.common_util_structure_station import ( # noqa: E402 + general_stations, + snap_existing_station, +) +from config.config_system_design import SECTION_STATION_INTERVAL_M # noqa: E402 + +# 새 노선 = 길이 130.0 m(50 + 50 + 30) — 일반 측점 0 · 20 · … · 120 · 130 +LINE = [(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (80.0, 50.0)] +ITEMS = [ + {"placement": "site", "chainage_m": 33.0, "x": 33.0, "y": 1.0}, # 투영 33 → 40 + {"placement": "site", "chainage_m": 75.0, "x": 51.0, "y": 25.0}, # 투영 75 → 80(가까운 쪽) + {"placement": "site", "chainage_m": 10.0, "x": 10.0, "y": -2.0}, # 가운데 10 → 앞 측점 0 + {"placement": "site", "chainage_m": 127.0, "x": 78.0, "y": 50.0}, # 투영 128 → 끝 130 + {"placement": "point", "chainage_m": 33.0, "x": 33.0, "y": 1.0}, # site 아님 = 정수 m 33 + {"placement": "site", "chainage_m": 40.0}, # 좌표 없음 — 그대로 +] +WANT = [40.0, 80.0, 0.0, 130.0, 33.0, 40.0] +GRIDS = [(130.0, 20.0), (120.0, 20.0), (0.0, 20.0), (5.5, 2.0)] +SNAPS = [(25.0, [0.0, 20.0, 40.0]), (30.0, [0.0, 20.0, 40.0]), (7.0, [0.0, 10.0])] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s3803") / "in.json" + source.write_text( + json.dumps( + { + "grids": GRIDS, + "snaps": SNAPS, + "items": ITEMS, + "line": [{"x": x, "y": y} for x, y in LINE], + "interval": SECTION_STATION_INTERVAL_M, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_380_3_site_follow.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _python_follow() -> list[float]: + items = [ + SimpleNamespace(**{"x": None, "y": None, "start_m": None, "end_m": None, **item}) + for item in ITEMS + ] + follow_structures(items, LINE) + return [item.chainage_m for item in items] + + +def test_따라간_site_는_새_노선_일반_측점() -> None: + assert SECTION_STATION_INTERVAL_M == 20.0 + assert _python_follow() == WANT + + +def test_거울_파이썬_TS(ts) -> None: + assert ts["follow"] == _python_follow() + assert ts["grid"] == [general_stations(total, interval) for total, interval in GRIDS] + assert ts["snap"] == [snap_existing_station(value, stations) for value, stations in SNAPS] + assert ts["snap"] == [20.0, 20.0, 10.0] # 가운데면 앞 측점 + + +def test_간격_안_주면_종전_정수_m(ts) -> None: + assert ts["plain"] == [33.0, 75.0, 10.0, 128.0, 33.0, 40.0]