Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1

This commit is contained in:
2026-09-12 14:58:20 +09:00
5 changed files with 249 additions and 14 deletions
+38 -1
View File
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
route_signature,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_route_geometry import (
RouteVertex,
planned_route_initial_path,
planned_route_working_path,
)
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`…_curves.json`). 정점 목록만으로는
어디부터 어디까지가 한 곡선이고 반지름이 얼마인지 알 수 없어, 곡선부 확폭을 측점마다
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
수정본(`planned_route.csv`)이 있으면 **그 곡선표만** 쓴다 — 노선을 고쳤는데 초기본
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
"""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
@@ -361,6 +393,9 @@ def run_section_generation(
replace(options or SectionGenerationOptions(), extra_stations=extras),
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -473,6 +508,8 @@ def generate_irregular_sections(
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
irregular_stations = [
station
+111 -5
View File
@@ -213,6 +213,101 @@ def _curve_widenings(
return widenings, sides
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
def _project_chainage(
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
) -> tuple[float, float]:
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
starts = points[:-1, :2]
vectors = points[1:, :2] - starts
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
target = np.asarray(xy, dtype=np.float64)
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
feet = starts + vectors * ratios[:, None]
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
index = int(np.argmin(distances))
span = route_chainage[index + 1] - route_chainage[index]
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
def _design_curve_spans(
points: np.ndarray,
route_chainage: np.ndarray,
curves: list[dict[str, Any]],
) -> list[tuple[float, float, float, str]]:
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
바깥쪽은 회전 방향으로 가른다 — 시점→교점→종점의 외적 z가 양수면 좌회전이라 안쪽이
좌측이고 바깥은 우측이다(`_plan_radii` 와 같은 규약). 노선에서 멀리 떨어진 곡선과
방향을 못 재는 곡선은 버린다.
"""
spans: list[tuple[float, float, float, str]] = []
for curve in curves:
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
radius = curve.get("radius_m")
if not (start and apex and end) or not isinstance(radius, (int, float)):
continue
start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
continue
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
end[0] - apex[0]
)
if abs(cross) < 1e-12:
continue
spans.append(
(
min(start_chainage, end_chainage),
max(start_chainage, end_chainage),
float(radius),
"right" if cross > 0 else "left",
)
)
return sorted(spans)
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
) -> tuple[list[float | None], list[str | None], list[float]]:
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
곡선 **안**은 그 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 한 값이다. 곡선 앞뒤
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 그 밖은 확폭이
없다. 곡선이 겹치면 **확폭이 큰 쪽**을 따르고, 반경은 작은 쪽(급한 쪽)을 남긴다.
"""
count = len(station_chainage)
radii: list[float | None] = [None] * count
sides: list[str | None] = [None] * count
widenings: list[float] = [0.0] * count
for start, end, radius, side in spans:
table = curve_widening_m(radius)
for index, value in enumerate(station_chainage):
chainage = float(value)
inside = start - 1e-9 <= chainage <= end + 1e-9
if inside and (radii[index] is None or radius < radii[index]):
radii[index] = round(radius, 3)
if table <= 0.0:
continue
if inside:
amount = table
else:
distance = start - chainage if chainage < start else chainage - end
if distance > CURVE_WIDENING_TAPER_M:
continue
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
if amount > widenings[index]:
widenings[index] = amount
sides[index] = side
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -220,6 +315,7 @@ def generate_sections(
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
options = options or SectionGenerationOptions()
@@ -267,11 +363,21 @@ def generate_sections(
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
)
else:
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
+31 -8
View File
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
for (const edge of [edges.left, edges.right]) {
const cx = x(edge.offset_m);
const cy = toDisplayY(edge.elevation_m);
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - 3.6));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + 3.6));
tick.setAttribute("class", "b06-chart__carriageway-tick");
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
label.textContent =
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
svg.append(label);
}
}
@@ -152,6 +152,14 @@
stroke-width: 1.2;
}
/* 노견 바깥 끝 틱(2026-09-12) — 차도 틱보다 짧고 옅다. 차도 틱과 이 틱 사이가 노견이라,
노면이 넓어졌을 때 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
.b06-chart__shoulder-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
opacity: 0.55;
}
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
.b06-chart__carriageway-label {
fill: var(--color-royal-amethyst);
@@ -148,3 +148,64 @@ def test_widening_without_curve_stays_zero() -> None:
widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3)
assert widenings == [0.0, 0.0, 0.0]
assert sides == [None, None, None]
def test_design_curve_spans_from_curve_table() -> None:
"""설계 곡선표의 시·종점이 노선 누가거리로 바뀌고, 회전 방향으로 바깥쪽이 갈린다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_spans
# ㄱ자 노선 — (0,0) → (100,0) → (100,100). 교점 (100,0) 에서 좌회전.
line = np.array([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [100.0, 100.0, 0.0]])
chainage = np.array([0.0, 100.0, 200.0])
curves = [
{
"start": [90.0, 0.0],
"apex": [100.0, 0.0],
"end": [100.0, 10.0],
"radius_m": 12.0,
}
]
spans = _design_curve_spans(line, chainage, curves)
assert len(spans) == 1
start, end, radius, side = spans[0]
assert abs(start - 90.0) < 1e-6 and abs(end - 110.0) < 1e-6
assert radius == 12.0
# 좌회전이면 안쪽이 좌측이라 바깥은 우측이다.
assert side == "right"
# 노선에서 멀리 떨어진 곡선은 버린다.
assert _design_curve_spans(line, chainage, [{**curves[0], "start": [90.0, 500.0]}]) == []
def test_design_curve_widening_is_one_value_inside_the_curve() -> None:
"""같은 곡선 안 측점은 설계 반경의 표값 하나를 쓰고, 앞뒤 10m 만 이어 준다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
# 곡선 100~140m, 설계 반경 16m(표값 1.5m). 측점 5m 간격.
spans = [(100.0, 140.0, 16.0, "left")]
chainage = np.arange(80.0, 165.0, 5.0)
radii, sides, widenings = _design_curve_widenings(chainage, spans)
table = dict(zip(chainage.tolist(), widenings, strict=True))
# 곡선 안은 어디서나 같은 값 — 종전에는 측점마다 반경을 다시 재 값이 갈렸다.
for station in (100.0, 110.0, 120.0, 130.0, 140.0):
assert table[station] == 1.5, station
# 앞뒤 10m 는 0 으로 잇는다.
assert table[95.0] == 0.75 and table[145.0] == 0.75
assert table[90.0] == 0.0 and table[150.0] == 0.0
# 반경은 곡선 안에서만 남고, 테이퍼·직선 자리는 비어 있다.
by_index = dict(zip(chainage.tolist(), radii, strict=True))
assert by_index[120.0] == 16.0
assert by_index[95.0] is None and by_index[90.0] is None
# 방향은 곡선 것을 따라간다.
assert sides[chainage.tolist().index(95.0)] == "left"
def test_design_curve_widening_takes_the_wider_of_overlapping_curves() -> None:
"""곡선이 겹치거나 붙어 있으면 확폭이 큰 쪽을 따른다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
spans = [(100.0, 120.0, 35.0, "left"), (118.0, 140.0, 12.0, "right")]
chainage = np.array([110.0, 119.0, 130.0])
_, sides, widenings = _design_curve_widenings(chainage, spans)
assert widenings[0] == 0.5 # R=35 → 0.5m
assert widenings[1] == 2.25 and sides[1] == "right" # 겹친 자리는 급한 곡선 값
assert widenings[2] == 2.25