From bd770fc351f680f13c866eb01fcbd11688fb0629 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:26:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(b06):=20=EA=B3=A1=EC=84=A0=EB=B6=80=20?= =?UTF-8?q?=ED=99=95=ED=8F=AD=EC=9D=84=20=EC=84=A4=EA=B3=84=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=ED=91=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EA=B3=A0=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=EC=97=90=20=EB=85=B8=EA=B2=AC=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확폭을 측점마다 앞뒤 10m 세 점의 외접원 반경으로 다시 재던 것을 계획노선 곡선표 (시·종점·설계 반경)로 바꿈. 종전에는 같은 곡선 안에서도 측점마다 확폭이 갈리고, 곡선 밖 직선까지 확폭이 흘러나가며, 측점 간격보다 짧은 곡선은 통째로 빠졌음. 곡선 안은 설계 반경의 표값 한 값, 앞뒤 10m 는 0 으로 잇고, 그 밖은 확폭 없음. 곡선표가 없는 옛 프로젝트는 종전 방식으로 물러섬. 횡단도는 차도 끝에만 눈금이 있어 노면이 넓어져도 차도가 는 것인지 노견이 는 것인지 가릴 수 없었음. 노면 끝에도 옅은 눈금을 세우고 노폭 라벨에 노견 폭을 덧붙임. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- B05_Profile/B05_Profile_Engine_Sections.py | 39 +++++- .../B05_Profile_Engine_Sections_Core.py | 116 +++++++++++++++++- B06_Section/B06_Section_UI_Cross_Design.ts | 39 ++++-- .../B06_Section_UI_Style_Cross_Areas.css | 8 ++ resources/tester/test_b06_curve_widening.py | 61 +++++++++ 5 files changed, 249 insertions(+), 14 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 491a76fc..2920c5b3 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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 diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 9c1274e9..73b67199 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 1fc693c5..7a340cb1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -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); } } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 4ee815e5..480c8b27 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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); diff --git a/resources/tester/test_b06_curve_widening.py b/resources/tester/test_b06_curve_widening.py index b2be95b6..ae6be6f0 100644 --- a/resources/tester/test_b06_curve_widening.py +++ b/resources/tester/test_b06_curve_widening.py @@ -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