Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-17 15:58:05 +09:00
8 changed files with 195 additions and 9 deletions
@@ -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<Vertex>): 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;
+13 -2
View File
@@ -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:
+15 -3
View File
@@ -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<P extends FollowingPoint>(
}
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<S extends FollowingStructure>(
items: ReadonlyArray<S>,
line: ReadonlyArray<XY>,
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: {
@@ -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)
@@ -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);
@@ -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));
+2 -1
View File
@@ -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"] == {}
+101
View File
@@ -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]