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/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 766587b8..fa5576f8 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -16,6 +16,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { drawBaseDataTab, drawFactorChoices, @@ -175,6 +176,14 @@ function injectStyles(): void { style.textContent = ` .b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } .b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } +/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담). + .ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */ +.b09-panel__group.ui-sidebar-section { + margin: 0; + padding: calc(var(--spacing-8) + var(--spacing-4)); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} .b09-panel__legend { font-size: var(--font-size-xs, 12px); letter-spacing: .06em; color: var(--color-text-secondary); text-transform: uppercase; @@ -184,7 +193,6 @@ function injectStyles(): void { display: flex; justify-content: space-between; gap: var(--space-sm, 8px); border-bottom: 1px solid var(--color-border); padding: 2px 0; } -.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } .b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } /* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */ .b09-hint--warn { color: var(--color-warning-text, #8a5a00); } @@ -483,10 +491,10 @@ function buildSidePanel( legendKey: keyof typeof ui_locales, fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, ): void => { - const group = document.createElement("div"); - group.className = "b09-panel__group"; + const group = document.createElement("section"); + group.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const legend = document.createElement("span"); - legend.className = "b09-panel__legend"; + legend.className = "b09-panel__legend ui-collapsible__title"; legend.textContent = L(legendKey); group.append(legend); for (const [field, labelKey] of fields) { @@ -512,10 +520,10 @@ function buildSidePanel( ]); // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. - const rateGroup = document.createElement("div"); - rateGroup.className = "b09-panel__group"; + const rateGroup = document.createElement("section"); + rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const rateLegend = document.createElement("span"); - rateLegend.className = "b09-panel__legend"; + rateLegend.className = "b09-panel__legend ui-collapsible__title"; rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); const rateVersionBox = document.createElement("div"); rateGroup.append(rateLegend, rateVersionBox); @@ -532,10 +540,10 @@ function buildSidePanel( ]); // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. - const quantityGroup = document.createElement("div"); - quantityGroup.className = "b09-panel__group"; + const quantityGroup = document.createElement("section"); + quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const quantityLegend = document.createElement("span"); - quantityLegend.className = "b09-panel__legend"; + quantityLegend.className = "b09-panel__legend ui-collapsible__title"; quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); const quantityLabel = document.createElement("label"); quantityLabel.className = "ui-field__label"; @@ -555,7 +563,8 @@ function buildSidePanel( root.append(hintBox); const actions = document.createElement("div"); - actions.className = "b09-panel__actions"; + // 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다. + actions.className = "ui-sidebar-actions"; actions.append( createButton({ label: L("B09_Estimation_Btn_Recalc"), @@ -569,6 +578,9 @@ function buildSidePanel( ); root.append(actions); + // 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다. + attachCollapsible(root); + return { root, rateVersionBox, hintBox }; } 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