From a5a9a7cec0e7b2bc9f161d1f8cd89d954fe32469 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 20:57:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b05,b06):=20=EB=AC=BC=EB=84=98?= =?UTF-8?q?=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=EC=BD=98=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=97=EB=A9=B4=20=3D=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=85=B8=EB=A9=B4=20=C2=B7=20=ED=8F=AC=EC=9E=A5=EC=B8=B5=20?= =?UTF-8?q?=EB=85=B8=EA=B2=AC=EA=B9=8C=EC=A7=80=20=C2=B7=20=EC=9B=94?= =?UTF-8?q?=EB=A5=98=20=EB=86=92=EC=9D=B4=20=EC=A0=90=EC=84=A0=EC=9D=80=20?= =?UTF-8?q?=EB=85=B8=EB=A9=B4=20=EC=9C=84=20(287-2=20=C2=B7=20287-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계 계산 tilted · tilt_pct(파이썬 · TS 짝) — 물넘이 범위 노면 = 경사각도로 지대 낮은 쪽 한 경사 · 측구 없음 · 중심점 = 계획고(콘크리트 윗면) · 노면 끝에서 사면이 이어짐(짧으면 성토) - 서버 tilt_ranges · tilt_args · stored_tilt — 기본 설계 · [저장] 재계산 · 포장 범위 맞춤 · 저장분 재계산이 같은 값(옛 ford_paved 갈음) - 횡단 그림 — 포장층 = 노면공 포장 형상(appendPavementOverlay)을 노견까지 · 물넘이 표식 · 월류 높이선 = 노면 + 높이 같은 각도 · 옛 파임 · 노면 걷기 그림 걷음 - 3D 노면 파기 걷음(설계 노면이 곧 윗면) · 코리도 빌드 판 94 - 시험 test_287 · 286_1 · 횡단 설계 거울(기울임 갈래) · 물넘이 명세 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_Router_Confirm.py | 3 +- .../B05_Profile_UI_Corridor_Envelope.ts | 3 +- .../B05_Profile_UI_Corridor_Station.ts | 19 +- B06_Section/B06_Section_Api_Types.ts | 3 + B06_Section/B06_Section_Cross_Refresh.ts | 5 +- B06_Section/B06_Section_Engine_Culvert.py | 9 +- B06_Section/B06_Section_Engine_Design.py | 25 ++- .../B06_Section_Engine_Design_Geometry.py | 12 +- .../B06_Section_Engine_SampleExtend.py | 3 +- B06_Section/B06_Section_Engine_SpoilFill.py | 8 +- B06_Section/B06_Section_Router.py | 5 +- B06_Section/B06_Section_Router_Design.py | 48 +++-- .../B06_Section_UI_Cross_Ford_Pavement.ts | 202 ++++-------------- B06_Section/B06_Section_UI_Cross_View.ts | 11 - .../B06_Section_UI_Style_Cross_Areas.css | 15 +- common_util/common_util_cross_design.ts | 20 +- .../common_util_cross_design_geometry.ts | 14 +- common_util/common_util_culvert_sets.ts | 2 +- .../tester/helper_286_1_ford_pavement.cjs | 20 +- resources/tester/helper_287_ford_pavement.cjs | 58 +++++ .../tester/test_286_1_ford_pavement_design.py | 31 ++- .../tester/test_287_ford_pavement_followup.py | 26 +++ .../tester/test_b06_cross_design_mirror.py | 19 +- 23 files changed, 273 insertions(+), 288 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 32ae9dd84..0fde4d5a6 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -107,6 +107,7 @@ async def sync_uphill_overrides_into_designs( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs @@ -175,7 +176,7 @@ async def sync_uphill_overrides_into_designs( surface_drop_m=ford_drop_at(float(chainage), ford_drops), **curve_widening_args(cross_record), **stored_shoulder_extend(design), # 노견 확장(139-6) - ford_paved=bool(design.get("ford_paved")), # 물넘이 자리 측구 없음(286-2) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts index 771f51404..3530d33cc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts @@ -181,7 +181,8 @@ function fnv1a(text: string): string { // (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. // 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이 // 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다. -const BUILD_VERSION = 93; +// 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다. +const BUILD_VERSION = 94; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 4ef2cdb40..400218fa7 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -9,7 +9,6 @@ * (2026-08-23 사용자 지시)의 핵심. * ========================================================================== */ -import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; @@ -227,22 +226,8 @@ export function classifyStation(section: CrossSection): StationPieces | null { if (!design || !line || line.length < 2) return null; const roadL = design.road_edges.left.offset_m; const roadR = design.road_edges.right.offset_m; - // 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 - // 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 - // 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다. - const ford = section.ford_pavement; - const carved = - ford && ford.depth_m - ? line.map((point) => - point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9 - ? { - ...point, - elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m), - } - : point, - ) - : line; - const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m); + // 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음 + const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); const cwL = design.carriageway_edges?.left.offset_m ?? roadL; const cwR = design.carriageway_edges?.right.offset_m ?? roadR; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 5144da188..45a42aa34 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -495,6 +495,9 @@ export interface CrossDesign { right: { offset_m: number; elevation_m: number }; }; design_elevation_m: number; + /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(`tilt_pct`). */ + tilted?: boolean; + tilt_pct?: number; cut_area_m2: number; /** * 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다. diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 437e948a4..d4fd143f2 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -359,8 +359,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), - // 물넘이 자리 = 측구 없음(286-2 · 서버 `ford_pavement_ranges` 짝 = 얹힌 제원 범위) - fordPaved: Boolean(section.ford_pavement), + // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) + tilted: Boolean(section.ford_pavement), + tiltPct: section.ford_pavement?.slope_pct ?? null, standard, berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 1ccc86b17..43540c9f2 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -513,11 +513,10 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: - """물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리. + """물넘이포장 1개소 — 월류 폭 범위의 노면 = 콘크리트 윗면(287-4). - 구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다. - 바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면 - 화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭. + 설계 노면을 경사각도(`slope_pct`)로 지대 낮은 쪽 한 경사로 기울임(`tilt_ranges` → + `compute_cross_design(tilted=…)`) · 월류 높이(`depth_m`) = 노면 위 점선(그림 전용). """ defaults = _registry_defaults("ford_pavement") values = dict(options or {}) @@ -536,7 +535,7 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: { "type": "ford_pavement", "span_m": span, - # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + # 월류 높이 — 노면 위 점선. 없으면 안 그린다(수치를 지어내지 않는다). "depth_m": depth if depth and depth > 0 else None, "slope_pct": slope, "defaulted": _defaulted( diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 53d9f7efb..3f9e27daa 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -155,6 +155,13 @@ def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]: return out +def stored_tilt(stored: dict[str, Any] | None) -> dict[str, Any]: + """저장분의 구조물 자리 기울기(287-4 · 288-1) — `compute_cross_design(**...)` 로 넘긴다.""" + value = (stored or {}).get("tilt_pct") + pct = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None + return {"tilted": bool((stored or {}).get("tilted")), "tilt_pct": pct} + + def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. @@ -194,7 +201,8 @@ def compute_cross_design( spoil_fill: dict[str, Any] | None = None, shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, - ford_paved: bool = False, + tilted: bool = False, + tilt_pct: float | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -211,8 +219,9 @@ def compute_cross_design( 입력이며, None·45m 이상이면 확폭이 없다. curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 (2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다. - ford_paved: 물넘이포장 범위 안 측점(286-2) — 측구 없음 · 결과에 `ford_paved` 로 되실음. - TS 짝 `fordPaved`. + tilted · tilt_pct: 물넘이포장 범위 · 맹암거 측점(286-2 · 287-4 · 288-1) — 측구 없음 · + 노면 = `tilt_pct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사. 결과에 `tilted` · `tilt_pct` + 로 되실음(`stored_tilt`). TS 짝 `tilted` · `tiltPct`. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). @@ -246,6 +255,8 @@ def compute_cross_design( paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] + if tilted and tilt_pct is not None and float(tilt_pct) > 0: + cross_slope_pct = float(tilt_pct) # 구조물 자리 경사(287-4 · 288-1) resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) valid = sorted( @@ -302,7 +313,7 @@ def compute_cross_design( berm=berm, shoulder_extend_left_m=shoulder_extend_left_m, shoulder_extend_right_m=shoulder_extend_right_m, - no_ditch=bool(ford_paved), + tilted=bool(tilted), ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -533,8 +544,10 @@ def compute_cross_design( # 뒤에 쓸 자리가 있다. **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** "cut_slope_segments": geometry.cut_slope_segments(), } - if ford_paved: - result["ford_paved"] = True + if tilted: + result["tilted"] = True + if tilt_pct is not None: + result["tilt_pct"] = round(float(tilt_pct), 4) if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 5b554b273..6131e19f7 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -62,7 +62,7 @@ class _SectionGeometry: berm: BermSpec | None = None, shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, - no_ditch: bool = False, + tilted: bool = False, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). @@ -96,6 +96,11 @@ class _SectionGeometry: # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 self.slope_per_offset = -slope if ditch_side == "left" else slope + # 물넘이포장 · 맹암거 자리(287-4 · 288-1) — 노면 = 한 경사로 지대 낮은 쪽(노면 끝 + # 원지반이 낮은 쪽)으로 기움 · 측구 없음(아래). ⚠ TS 짝 `SectionGeometry`. + if tilted and ground_at is not None: + low_left = ground_at(self.left_extent) < ground_at(-self.right_extent) + self.slope_per_offset = -slope if low_left else slope # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 @@ -145,8 +150,9 @@ class _SectionGeometry: self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. self.ditch_choice = None if both_fill else choice - # 물넘이포장 자리(286-2) — 파인 노면이라 측구가 안 섬 · 선택은 그대로(시설을 걷으면 되살아남). - if no_ditch: + # 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(시설을 걷으면 + # 되살아남). + if tilted: self.has_ditch = False # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 29eac4fb1..e2dc0da03 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -25,6 +25,7 @@ from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) from common_util.common_util_json import atomic_write_json @@ -158,7 +159,7 @@ def extend_unclosed_sections( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) - ford_paved=bool(design.get("ford_paved")), # 물넘이 자리 측구 없음(286-2) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) try: diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index c7b0c2d30..30d267132 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -23,7 +23,11 @@ from __future__ import annotations from pathlib import Path from typing import Any -from B06_Section.B06_Section_Engine_Design import compute_cross_design, stored_shoulder_extend +from B06_Section.B06_Section_Engine_Design import ( + compute_cross_design, + stored_shoulder_extend, + stored_tilt, +) from common_util.common_util_structure_face_role import structure_face_role #: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다. @@ -217,7 +221,7 @@ def enforce_spoil_fills( }, **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) - ford_paved=bool(design.get("ford_paved")), # 물넘이 자리 측구 없음(286-2) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) except (ValueError, KeyError): return None diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 6106b23c8..d640c495b 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -46,12 +46,13 @@ from B06_Section.B06_Section_Router_Design import ( from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, - ford_pavement_ranges, ford_surface_drops, paved_at, pavement_ranges, stored_berm, stored_cut_slope, + tilt_args, + tilt_ranges, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -601,7 +602,7 @@ async def compute_cross_section_design( berm=stored_berm(stored_design or {}), **curve_widening_args(cross_record), **stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6) - ford_paved=paved_at(request.chainage_m, ford_pavement_ranges(project_root)), + **tilt_args(request.chainage_m, tilt_ranges(project_root)), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 392600390..878399892 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -13,6 +13,7 @@ from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) from common_util.common_util_cross_berm import ( BERM_DEFAULT_DEPTH_M, @@ -93,6 +94,27 @@ def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]] return [(at - half, at + half) for at, half in halves] +def tilt_ranges(project_root: Path | None) -> list[tuple[float, float, float | None]]: + """측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리(287-4) — 물넘이포장 범위 · + 경사 = 경사각도(%).""" + if project_root is None: + return [] + out: list[tuple[float, float, float | None]] = [] + for at, spec in load_culvert_sets(project_root).items(): + if spec.get("type") == "ford_pavement": + half = float(spec.get("span_m") or 0.0) / 2.0 + out.append((at - half, at + half, spec.get("slope_pct"))) + return out + + +def tilt_args(chainage_m: float, ranges: list[tuple[float, float, float | None]]) -> dict[str, Any]: + """그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 · + 저장분 짝 `stored_tilt`.""" + hit = next((r for r in ranges if paved_at(chainage_m, [(r[0], r[1])])), None) + pct = hit[2] if hit else None + return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None} + + def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. @@ -215,7 +237,7 @@ def enforce_pavement_ranges( 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) - ford_ranges = ford_pavement_ranges(project_root) + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: @@ -224,8 +246,8 @@ def enforce_pavement_ranges( continue chainage = float(section.get("chainage_m", 0.0)) wanted = paved_at(chainage, ranges) - ford_paved = paved_at(chainage, ford_ranges) # 물넘이 자리 = 측구 없음(286-2) - if bool(design.get("paved")) == wanted and bool(design.get("ford_paved")) == ford_paved: + tilt = tilt_args(chainage, tilts) # 물넘이 자리 = 측구 없음 · 기울기(286-2 · 287-4) + if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt: continue try: recomputed = compute_cross_design( @@ -248,7 +270,7 @@ def enforce_pavement_ranges( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) - ford_paved=ford_paved, + **tilt, ) except (ValueError, KeyError): continue @@ -277,7 +299,7 @@ def enforce_design_lines( from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans ford_drops = ford_surface_drops(project_root) - ford_ranges = ford_pavement_ranges(project_root) + tilts = tilt_ranges(project_root) spans = load_berm_spans(project_root) changed = 0 for section in cross_sections: @@ -308,7 +330,7 @@ def enforce_design_lines( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), - ford_paved=paved_at(chainage, ford_ranges), + **tilt_args(chainage, tilts), ) except (ValueError, KeyError): continue @@ -364,7 +386,7 @@ def enforce_ford_surface_drops( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) - ford_paved=bool(design.get("ford_paved")), + **stored_tilt(design), ) except (ValueError, KeyError): continue @@ -437,7 +459,7 @@ def attach_default_designs( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] - ford_ranges = ford_pavement_ranges(project_root) + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) for section in cross_sections: if section.get("design"): @@ -456,7 +478,7 @@ def attach_default_designs( rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage, ford_drops), **curve_widening_args(section), - ford_paved=paved_at(chainage, ford_ranges), + **tilt_args(chainage, tilts), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design @@ -517,7 +539,7 @@ def recompute_designs_for_alignment( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] - ford_ranges = ford_pavement_ranges(project_root) + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) stored_by_chainage = { round(float(record["chainage_m"]), 3): (record.get("design") or {}) @@ -576,7 +598,7 @@ def recompute_designs_for_alignment( berm=session_berms.get(key) or stored_berm(stored), **curve_widening_args(section), **stored_shoulder_extend(stored), # 노견 확장(139-6) - ford_paved=paved_at(chainage, ford_ranges), + **tilt_args(chainage, tilts), ) except (ValueError, KeyError): continue @@ -621,7 +643,7 @@ def compute_default_designs( default_modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(root) - ford_ranges = ford_pavement_ranges(root) + tilts = tilt_ranges(root) ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: @@ -661,7 +683,7 @@ def compute_default_designs( surface_drop_m=ford_drop_at(chainage_m, ford_drops), berm=stored_berm(user), **curve_widening_args(cross), - ford_paved=paved_at(chainage_m, ford_ranges), + **tilt_args(chainage_m, tilts), ) for key in ("berm", "cut_slope_ratio_user"): if user.get(key) is not None: diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 89d038df6..ea30b9e4d 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -1,36 +1,28 @@ /* ============================================================================= * B06_Section_UI_Cross_Ford_Pavement.ts - * 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정). + * 물넘이포장 — 횡단도 그림(287-2 · 287-4 · 그림_183). * - * 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다. - * · 파임 범위 = 노견까지 **전폭**. - * · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`). - * · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면 - * 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다. - * · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**. - * · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다. - * - * 유입측 = 표고가 높은 쪽 — 설계선 양 끝(비탈이 원지반을 만나는 자리) 표고로 가름 · 노면은 경사각도 - * (`ford_slope_deg` → `slope_pct`)만큼 표고 낮은 쪽(유출)으로 기움(286-2). 양 끝이 같으면 `section_mode` - * (상단측 절토 = 상류). + * · 콘크리트 윗면 = 설계 노면 — 서버(`compute_cross_design(tilted=…)`) · 짝 TS 가 노면을 경사각도 + * (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위. + * 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토). + * · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지. + * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도. + * · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면. * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; -const SVG_NS = "http://www.w3.org/2000/svg"; -/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ -let hatchSeq = 0; - /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { /** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */ chainage_m?: number; span_m: number; - /** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */ + /** 월류 높이(m) — 노면 위 점선. 없으면 점선을 안 그림(수치를 지어내지 않는다). */ depth_m: number | null; - /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ + /** 노면 경사(%) — 경사각도 환산 · 서버 설계 노면이 이 값으로 기욺. */ slope_pct: number | null; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; @@ -38,46 +30,19 @@ export interface FordPavementSpec { chute?: { structure: string; thickness_m: number; length_m?: number }; } -interface Edge { - offset_m: number; - elevation_m: number; -} - -/** 유입(상류)이 좌측(+offset)인가 — 설계선 양 끝 표고가 높은 쪽(286-2) · 같으면 단면유형. */ +/** 유입(상류)이 좌측(+offset)인가 — 기울인 노면이면 높은 노면 끝 · 아니면 설계선 양 끝 표고가 높은 + * 쪽(286-2) · 같으면 단면유형. */ export function inflowLeft(design: CrossDesign): boolean { + const edges = design.road_edges; + if (design.tilted && edges && Math.abs(edges.left.elevation_m - edges.right.elevation_m) > 1e-6) + return edges.left.elevation_m > edges.right.elevation_m; const line = [...(design.design_line ?? [])].sort((a, b) => a.offset_m - b.offset_m); const gap = line.length >= 2 ? line[line.length - 1].elevation_m - line[0].elevation_m : 0; return Math.abs(gap) > 1e-3 ? gap > 0 : design.section_mode !== "right_cut"; } -/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */ -function inflowSign(design: CrossDesign, offsetM: number): number { - const towardLeft = offsetM > 0; - return towardLeft === inflowLeft(design) ? 1 : -1; -} - -/** - * 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다. - * 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다. - */ -export function fordDeckElevationAt( - design: CrossDesign, - spec: FordPavementSpec, - offsetM: number, - fallbackElevationM = 0, -): number { - const depth = spec.depth_m ?? 0; - const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100; - const center = design.design_elevation_m ?? fallbackElevationM; - return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM); -} - -function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { - return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m); -} - function line(points: string[], className: string): SVGPolylineElement { - const polyline = document.createElementNS(SVG_NS, "polyline"); + const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("points", points.join(" ")); polyline.setAttribute("class", className); return polyline; @@ -127,8 +92,9 @@ export function appendFordSurfaceDropPlan( } /** - * 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다 - * (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다). + * 물넘이포장을 그린다(287-2 · 287-4) — 포장층(노면공 형상 · 노견까지) · 월류 높이선 · 도수로 띠. + * 그렸으면 true — 호출부는 일반 포장층을 건너뜀(두 겹으로 안 깔림). 포장층에 물넘이 표식 + * (`b06-chart__ford-pavement` · 287-5 고름이 노면공 포장 고름과 가름). */ export function appendFordPavementOverlay( svg: SVGElement, @@ -137,66 +103,34 @@ export function appendFordPavementOverlay( x: (offset: number) => number, y: (elevation: number) => number, ): boolean { - if (!spec || !spec.depth_m || !design.road_edges) return false; - const { left, right } = design.road_edges; - const bottomLeft = bottomAt(design, spec, left); - const bottomRight = bottomAt(design, spec, right); - - // ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다. - svg.append( - line( - [ - `${x(left.offset_m)},${y(left.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m)}`, - ], - "b06-chart__ford-deck-plan", - ), - ); - - // ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다. - const thickness = design.pavement_thickness_m ?? 0.2; - const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`; - const defs = document.createElementNS(SVG_NS, "defs"); - const pattern = document.createElementNS(SVG_NS, "pattern"); - pattern.setAttribute("id", hatchId); - pattern.setAttribute("patternUnits", "userSpaceOnUse"); - pattern.setAttribute("width", "6"); - pattern.setAttribute("height", "6"); - const stroke = document.createElementNS(SVG_NS, "path"); - stroke.setAttribute("d", "M0,6 L6,0"); - stroke.setAttribute("class", "b06-chart__ford-pavement-hatch"); - pattern.append(stroke); - defs.append(pattern); - svg.append(defs); - - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute( - "points", - [ - `${x(left.offset_m)},${y(bottomLeft)}`, - `${x(right.offset_m)},${y(bottomRight)}`, - `${x(right.offset_m)},${y(bottomRight - thickness)}`, - `${x(left.offset_m)},${y(bottomLeft - thickness)}`, - ].join(" "), - ); - polygon.setAttribute("class", "b06-chart__ford-pavement"); - polygon.setAttribute("fill", `url(#${hatchId})`); - svg.append(polygon); - - // ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다. - svg.append( - line( - [`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`], - "b06-chart__ford-deck", - ), - ); + const edges = design.road_edges; + if (!spec || !edges) return false; + const from = svg.children.length; + // 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임) + appendPavementOverlay(svg, { ...design, paved: true, carriageway_edges: edges }, x, y, null); + [...svg.children] + .slice(from) + .filter((el) => el.getAttribute("class") === "b06-chart__pavement") + .forEach((el) => el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement")); + // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) + const depth = spec.depth_m ?? 0; + if (depth > 0) + svg.append( + line( + [ + `${x(edges.left.offset_m)},${y(edges.left.elevation_m + depth)}`, + `${x(edges.right.offset_m)},${y(edges.right.elevation_m + depth)}`, + ], + "b06-chart__ford-deck-plan", + ), + ); if (spec.chute) appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute) }], spec.chute, x, y); return true; } /** - * 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 + * 유출측(`inflowLeft` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). */ export function outletFillLine(design: CrossDesign): OffsetPoint[] { @@ -242,60 +176,6 @@ export function fordChuteLine(design: CrossDesign, chute?: { length_m?: number } return out; } -/** - * 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가 - * 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다). - * 측구 자리는 바닥 끝에서 절토 비탈 발까지 실선으로 잇는다. 면적 · 서버 설계선은 그대로(그림만). - */ -export function hideFordPavedRoad( - layer: SVGElement, - from: number, - design: CrossDesign, - spec: FordPavementSpec, - x: (offset: number) => number, - y: (elevation: number) => number, -): void { - const edges = design.road_edges; - const designLine = design.design_line; - if (!edges || !designLine?.length) return; - const ditch = design.ditch; - const ditchWidth = - design.ditch_enabled === false || ditch.type === "none" - ? 0 - : ditch.type === "standard" - ? ditch.top_width_m - : ditch.width_m; - // +offset = 좌측 · 측구는 노견 바깥 그 쪽에 붙는다 - const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m); - const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m); - const hi = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); - const lo = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); - const [xa, xb] = [x(lo), x(hi)]; - const inside = (px: number): boolean => - px >= Math.min(xa, xb) - 0.5 && px <= Math.max(xa, xb) + 0.5; - for (const node of [...layer.children].slice(from)) { - if (node.tagName !== "line" || !node.classList.contains("b06-chart__design-cross")) continue; - if (inside(Number(node.getAttribute("x1"))) && inside(Number(node.getAttribute("x2")))) - node.remove(); - } - if (!(ditchWidth > 0) || !design.ditch_side) return; - const edge = design.ditch_side === "left" ? leftEdge : rightEdge; - const foot = design.ditch_side === "left" ? hi : lo; - const sorted = [...designLine].sort((a, b) => a.offset_m - b.offset_m); - const at = sorted.find((point) => Math.abs(point.offset_m - foot) < 1e-3); - if (!at) return; - const edgeElevation = design.ditch_side === "left" ? edges.left : edges.right; - layer.append( - line( - [ - `${x(edge)},${y(fordDeckElevationAt(design, spec, edge, edgeElevation.elevation_m))}`, - `${x(foot)},${y(at.elevation_m)}`, - ], - "b06-chart__ford-deck", - ), - ); -} - /** * 좌측 패널의 물넘이 칸을 캐시 제원에 얹는다(66-2 · 66-3) — 월류 높이 = 파임 깊이 · 경사각(°) → %. * 서버 `_ford_pavement_set` 과 같은 환산(저장 뒤 서버가 정본으로 다시 냄). 바꿨으면 true. diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9571b8df4..da538e6af 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -17,7 +17,6 @@ import { } from "./B06_Section_UI_Cross_Areas"; import { appendFordPavementOverlay, - hideFordPavedRoad, appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -513,16 +512,6 @@ export function createCrossSectionCard( [...plotLayer.children] .slice(designFrom) .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); - // 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남) - if (fordPaved && section.ford_pavement) - hideFordPavedRoad( - plotLayer, - designFrom, - section.design, - section.ford_pavement, - x, - toDisplayY, - ); // 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서 // 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다. appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index a724bc1cc..c24ef3860 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -254,7 +254,7 @@ stroke-width: 2.2; } -/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */ +/* 물넘이포장(287-2 · 287-4): 노면공 포장층(노견까지) + 월류 높이 점선 */ .b06-chart__ford-deck-plan { fill: none; stroke: var(--color-text-secondary); @@ -262,24 +262,11 @@ stroke-dasharray: 5 4; } -.b06-chart__ford-deck { - fill: none; - stroke: var(--color-text-body); - stroke-width: 1.8; - stroke-linejoin: round; -} - .b06-chart__ford-pavement { stroke: var(--color-text-body); stroke-width: 1; } -.b06-chart__ford-pavement-hatch { - stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent); - stroke-width: 1.2; - fill: none; -} - /* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */ /* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */ .b06-chart__culvert-pipe { diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b80dbd405..f57a47453 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -98,8 +98,10 @@ export interface CrossDesignOptions { ditchChoice?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; - /** 물넘이포장 범위 안 측점 — 측구 없음(286-2). 짝: 파이썬 `compute_cross_design(ford_paved=…)`. */ - fordPaved?: boolean; + /** 물넘이포장 범위 · 맹암거 측점 — 측구 없음 · 노면 = `tiltPct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사 + * (286-2 · 287-4 · 288-1). 짝: 파이썬 `compute_cross_design(tilted=…, tilt_pct=…)`. */ + tilted?: boolean; + tiltPct?: number | null; /** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */ planRadiusM?: number | null; /** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */ @@ -189,8 +191,9 @@ export interface CrossDesignResult { /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; surface_drop_m?: number; - /** 물넘이포장 범위 안 측점(286-2) — 측구 없음. */ - ford_paved?: boolean; + /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 지대 낮은 쪽 한 경사. */ + tilted?: boolean; + tilt_pct?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; } @@ -316,7 +319,9 @@ export function computeCrossDesign( } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. - const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; + const tiltPct = options.tilted && (options.tiltPct ?? 0) > 0 ? options.tiltPct! : null; + // 구조물 자리 경사(287-4 · 288-1 · 파이썬 짝) + const crossSlopePct = tiltPct ?? (paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct); const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide); const valid: Array<[number, number]> = samples @@ -371,7 +376,7 @@ export function computeCrossDesign( berm: options.berm ?? null, shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0, shoulderExtendRightM: options.shoulderExtendRightM ?? 0, - noDitch: Boolean(options.fordPaved), + tilted: Boolean(options.tilted), }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -586,7 +591,8 @@ export function computeCrossDesign( design_line: designLine, cut_slope_segments: geometry.cutSlopeSegments(), }; - if (options.fordPaved) result.ford_paved = true; + if (options.tilted) result.tilted = true; + if (options.tilted && options.tiltPct != null) result.tilt_pct = round4(options.tiltPct); if (drop > 0) result.surface_drop_m = round4(drop); if (options.berm) { // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index e6704af6a..c32f80cfb 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -145,8 +145,8 @@ export class SectionGeometry { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). */ shoulderExtendLeftM?: number; shoulderExtendRightM?: number; - /** 물넘이포장 자리 — 측구 없음(286-2 · 파이썬 짝 `no_ditch`). */ - noDitch?: boolean; + /** 물넘이포장 · 맹암거 자리 — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(286-2 · 287-4 · 파이썬 짝 `tilted`). */ + tilted?: boolean; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -171,6 +171,12 @@ export class SectionGeometry { // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). const slope = params.crossSlopePct / 100; this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + // 물넘이포장 · 맹암거 자리(287-4 · 288-1) — 노면 = 한 경사로 지대 낮은 쪽(노면 끝 원지반이 낮은 쪽) + // 으로 기움 · 측구 없음(아래 · 파이썬 짝) + if (params.tilted && params.groundAt) { + const lowLeft = params.groundAt(this.leftExtent) < params.groundAt(-this.rightExtent); + this.slopePerOffset = lowLeft ? -slope : slope; + } // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. const groundAt = params.groundAt; @@ -215,8 +221,8 @@ export class SectionGeometry { this.hasDitch = choice === null || bothFill ? autoDitch : choice; /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ this.ditchChoice = bothFill ? null : choice; - // 물넘이포장 자리(286-2) — 파인 노면이라 측구가 안 섬 · 선택은 그대로(파이썬 짝) - if (params.noDitch) this.hasDitch = false; + // 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝) + if (params.tilted) this.hasDitch = false; // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 7cf5c21bc..b58a26deb 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -505,7 +505,7 @@ function fordPavementSet( { type: "ford_pavement", span_m: span, - // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + // 월류 높이 — 노면 위 점선(287-4). 없으면 안 그린다(수치를 지어내지 않는다). depth_m: depth && depth > 0 ? depth : null, slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, defaulted: defaulted(values, [ diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index 1327b58f8..dc98731df 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -25,23 +25,8 @@ const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_sta const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts"), { "./B06_Section_UI_Cross_Wall_Hatch": { appendChuteBand() {} }, + "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, }); -// 286-2 노면 기울기 — 설계선 양 끝 표고가 높은 쪽 = 유입 · 표고 낮은 쪽(유출)으로 기움 -const SPEC = { span_m: 6, depth_m: 0.3, slope_pct: 5 }; -const designOf = (left, right, mode) => ({ - design_elevation_m: 100, - cross_slope_pct: 2, - section_mode: mode, - design_line: [ - { offset_m: -10, elevation_m: right }, - { offset_m: 10, elevation_m: left }, - ], -}); -const deck = (design) => ({ - left: ford.fordDeckElevationAt(design, SPEC, 3), - right: ford.fordDeckElevationAt(design, SPEC, -3), -}); - // 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts")); const slopeDesign = { @@ -73,8 +58,5 @@ const out = { lineTooLong: ford.fordChuteLine(slopeDesign, chuteOf({ ford_chute_1_length_m: 50 })), fordMarks: station.pipeStationMarks({ chainage_m: 230, facility: "ford_pavement" }), planted: station.plantedChainages(input.pipes, input.structures, input.types), - deckLeftHigh: deck(designOf(105, 95, "right_cut")), - deckRightHigh: deck(designOf(95, 105, "left_cut")), - deckFlat: deck(designOf(100, 100, "right_cut")), }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index 67e3847c5..fbf56b8de 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -36,7 +36,65 @@ const change = (form, c, i) => { return form.state().chute[0].area; }; +// 287-2 · 287-4 그림 — DOM 흉내(속성 · 자식만) +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; +const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +// 서버가 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 노견 끝 ±3 · 차도 끝 ±2.5 +const tilted = { + section_mode: "left_cut", + tilted: true, + cross_slope_pct: 5, + design_elevation_m: 100, + pavement_thickness_m: 0.2, + paved: true, + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: 100.125 }, + right: { offset_m: -2.5, elevation_m: 99.875 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 103 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +const svg = new Node("g"); +const drew = pave.appendFordPavementOverlay( + svg, + { span_m: 8, depth_m: 0.4, slope_pct: 5 }, + tilted, + (o) => o, + (e) => e, +); +const ofClass = (c) => svg.children.filter((k) => (k.attrs.class ?? "").split(" ").includes(c)); + const out = { + drew, + pavePoints: ofClass("b06-chart__ford-pavement").map((k) => k.attrs.points), + planPoints: ofClass("b06-chart__ford-deck-plan").map((k) => k.attrs.points), + inflowLeft: pave.inflowLeft(tilted), // 287-1 길이 넣음 = 월류 폭 × 길이 · 비움 = 기본값 · 폭 바꿈 = 길이 있을 때만 다시 셈 · 폭 비면 기본 폭 areaByLength: change(formOf({ width: "6" }, { length_m: "4", area: "30" }), "chute", "length_m"), areaCleared: change(formOf({ width: "6" }, { length_m: "", area: "24" }), "chute", "length_m"), diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py index b19354ba6..68c52322a 100644 --- a/resources/tester/test_286_1_ford_pavement_design.py +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -103,14 +103,12 @@ def _ground(slope: float) -> list[dict]: def test_물넘이_자리_측구_없음(): kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} plain = compute_cross_design(_ground(0.35), 100.4, **kwargs) - ford = compute_cross_design(_ground(0.35), 100.4, ford_paved=True, **kwargs) - assert plain["ditch_enabled"] is True and "ford_paved" not in plain - assert ford["ditch_enabled"] is False and ford["ford_paved"] is True + ford = compute_cross_design(_ground(0.35), 100.4, tilted=True, **kwargs) + assert plain["ditch_enabled"] is True and "tilted" not in plain + assert ford["ditch_enabled"] is False and ford["tilted"] is True assert ford["ditch_area_m2"] == 0 # 사용자가 측구를 켜도 물넘이 자리는 안 섬 - forced = compute_cross_design( - _ground(0.35), 100.4, ford_paved=True, ditch_choice=True, **kwargs - ) + forced = compute_cross_design(_ground(0.35), 100.4, tilted=True, ditch_choice=True, **kwargs) assert forced["ditch_enabled"] is False @@ -125,21 +123,22 @@ def test_물넘이_범위_서버_판정(tmp_path): assert paved_at(230.0, ranges) and paved_at(226.0, ranges) and not paved_at(240.0, ranges) -def test_노면은_표고_낮은_쪽으로(ts): - # 좌 높음(입력 단면유형이 반대여도 지형) · 우 높음 · 같으면 단면유형(right_cut = 우 상류) - assert ts["deckLeftHigh"]["left"] > ts["deckLeftHigh"]["right"] - assert ts["deckRightHigh"]["right"] > ts["deckRightHigh"]["left"] - assert ts["deckFlat"]["right"] > ts["deckFlat"]["left"] - # 경사 5 % · 노선 중심 100 − 깊이 0.3 · 3 m 에서 ±0.15 - assert ts["deckLeftHigh"]["left"] == pytest.approx(99.85) - assert ts["deckLeftHigh"]["right"] == pytest.approx(99.55) +def test_노면은_표고_낮은_쪽으로(): + # 287-4 — 설계 노면 = 경사각도(%)로 지대 낮은 쪽 한 경사 · 중심 = 계획고(콘크리트 윗면) + kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} + for slope, high in ((0.35, "left"), (-0.35, "right")): + got = compute_cross_design(_ground(slope), 100.4, tilted=True, tilt_pct=5.0, **kwargs) + left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right")) + assert (left > right) == (high == "left") + assert got["design_elevation_m"] == 100.4 and got["cross_slope_pct"] == 5.0 + assert got["tilt_pct"] == 5.0 def test_저장_뒤_서버가_물넘이_측구를_맞춤(): router = _read("B06_Section", "B06_Section_Router_Design.py") - assert 'bool(design.get("ford_paved")) == ford_paved' in router + assert "stored_tilt(design) == tilt" in router refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") - assert "fordPaved: Boolean(section.ford_pavement)," in refresh + assert "tilted: Boolean(section.ford_pavement)," in refresh CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30} diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 1ee9e5f15..94b01daa2 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -3,6 +3,9 @@ 못박는 것 - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. + - 287-2 · 287-4 콘크리트 윗면 = 설계 노면(서버 · TS 짝이 경사각도로 지대 낮은 쪽 한 경사 · 중심점이 그 위) + · 포장층 = 노면공 포장 형상 노견까지(노면공 항목과 안 섞임) · 월류 높이 점선 = 노면 위 같은 각도 · + 3D 노면을 파지 않음 · 설계 측구 없음. 진짜 TS 는 `helper_287_ford_pavement.cjs` 가 Node 로 돌림. 화면은 사용자 확인. """ @@ -55,3 +58,26 @@ def test_도수로_길이_면적(ts): def test_물넘이_폼이_길이_면적을_잇는다(): form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") assert "syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)" in form + + +def _pts(text: str) -> list[float]: + return [float(v) for pair in text.split() for v in pair.split(",")] + + +def test_포장층_노견까지_점선은_노면_위(ts): + assert ts["drew"] is True + # 포장층 하나 = 노견 끝(±3)까지 · 윗면 = 설계 노면 · 두께 0.2 + assert len(ts["pavePoints"]) == 1 + assert _pts(ts["pavePoints"][0]) == pytest.approx([3, 100.15, -3, 99.85, -3, 99.65, 3, 99.95]) + # 월류 높이선 = 노면 + 0.4 · 같은 각도 + assert _pts(ts["planPoints"][0]) == pytest.approx([3, 100.55, -3, 100.25]) + assert ts["inflowLeft"] is True # 노면 높은 쪽 = 유입 · 도수로 띠 = 낮은 쪽 + + +def test_3D_노면을_파지_않고_옛_파임_그림을_걷음(): + station = _read("B05_Profile", "B05_Profile_UI_Corridor_Station.ts") + assert "fordDeckElevationAt" not in station + pave = _read("B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts") + assert "hideFordPavedRoad" not in pave and "fordDeckElevationAt" not in pave + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + assert "hideFordPavedRoad" not in view diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index b3fe1763d..229e8406b 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -228,7 +228,21 @@ CASES = [ "section_mode": "left_cut", "ditch_side": "left", "paved": True, - "ford_paved": True, + "tilted": True, + }, + }, + { + # 287-4 — 물넘이포장 자리 노면 = 경사각도로 지대 낮은 쪽 한 경사(지반이 좌로 오름 = 우가 낮음) + "name": "물넘이포장 자리 노면 기울임", + "samples": _ground(0.35), + "design_elevation_m": 100.4, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.2408, }, }, ] @@ -254,7 +268,8 @@ const camel = { curve_widening_m: "curveWideningM", shoulder_extend_left_m: "shoulderExtendLeftM", shoulder_extend_right_m: "shoulderExtendRightM", - ford_paved: "fordPaved", + tilted: "tilted", + tilt_pct: "tiltPct", }; // 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다. const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" }; From 2b9440a44e35db00569a3ae6f108056717728495 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 20:59:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(b05):=20=EB=AC=BC=EB=84=98=EC=9D=B4?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=EC=9B=94=EB=A5=98=20=ED=8F=AD=20=E2=86=92?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EC=9E=90=EB=8F=99=20=3D=20=EC=84=B8?= =?UTF-8?q?=EC=9B=94=EA=B5=90=20=ED=95=9C=20=EB=B2=8C=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98=EC=9C=BC=EB=A1=9C=20=EB=AA=BB?= =?UTF-8?q?=EB=B0=95=EC=9D=8C=20(287-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 물넘이 「경사각도」 상자 폭 칸이 세월교와 같은 overflow · fordHeightM 길을 탐(이미 이어짐 · 코드 변경 없음) - 설계유량이 없는 지점은 손 입력(높이 칸 그대로) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- resources/tester/helper_287_ford_pavement.cjs | 6 ++++++ resources/tester/test_287_ford_pavement_followup.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index fbf56b8de..124043eb7 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -95,6 +95,12 @@ const out = { pavePoints: ofClass("b06-chart__ford-pavement").map((k) => k.attrs.points), planPoints: ofClass("b06-chart__ford-deck-plan").map((k) => k.attrs.points), inflowLeft: pave.inflowLeft(tilted), + // 287-3 월류 폭 → 높이 = 세월교와 같은 풀이(유량 1.2 ㎥/s · 폭 10 m) · 유량 없으면 null(손 입력) + heightByWidth: calc.fordHeightM(1.2, 10), + heightNoFlow: calc.fordHeightM(null, 10), + paveSlopeItems: load(D("design_structure_ford_pavement.ts")) + .FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "slope") + .items.map((i) => i.id), // 287-1 길이 넣음 = 월류 폭 × 길이 · 비움 = 기본값 · 폭 바꿈 = 길이 있을 때만 다시 셈 · 폭 비면 기본 폭 areaByLength: change(formOf({ width: "6" }, { length_m: "4", area: "30" }), "chute", "length_m"), areaCleared: change(formOf({ width: "6" }, { length_m: "", area: "24" }), "chute", "length_m"), diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 94b01daa2..18af864ed 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -3,6 +3,7 @@ 못박는 것 - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. + - 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력. - 287-2 · 287-4 콘크리트 윗면 = 설계 노면(서버 · TS 짝이 경사각도로 지대 낮은 쪽 한 경사 · 중심점이 그 위) · 포장층 = 노면공 포장 형상 노견까지(노면공 항목과 안 섞임) · 월류 높이 점선 = 노면 위 같은 각도 · 3D 노면을 파지 않음 · 설계 측구 없음. @@ -81,3 +82,12 @@ def test_3D_노면을_파지_않고_옛_파임_그림을_걷음(): assert "hideFordPavedRoad" not in pave and "fordDeckElevationAt" not in pave view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") assert "hideFordPavedRoad" not in view + + +def test_월류_폭_넣으면_높이_자동_세월교_한_벌(ts): + assert ts["paveSlopeItems"] == ["angle", "width", "height"] + assert ts["heightByWidth"] and ts["heightByWidth"] > 0 + assert ts["heightNoFlow"] is None + form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") + assert 'const paveFlowBox = overflow(() => paveForm, "slope");' in form + assert 'if (facility === "ford_pavement") paveFlowBox.fill();' in form From 75050815fa6b28c97db8fd173603e19328be7ef9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:07:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b06):=20=EB=AC=BC=EB=84=98=EC=9D=B4?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=ED=98=95=EC=83=81=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=3D=20=EB=8B=A4=EB=A5=B8=20=EC=8B=9C=EC=84=A4=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=95=9C=20=EA=B3=A0=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=9D=BC=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EB=81=8C=EA=B8=B0=EB=8A=94=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=EC=95=84=EB=8B=98=20(287-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 형상 고름 판정면(B06_Section_UI_Cross_Shape_Pick) — data-structure-id 단 형상마다 · 구조물 판정면 앞(아래)에 꽂아 보라 손잡이 판정이 위(요구 185 ①) - 누름만 고름 — 3 px 넘게 움직이면 끌기라 고름 아님(②) · 노면공 포장층(범위 표식)은 밖 · 포장 고름과 안 섞임(③) - 누름 → toggleRevet("pipe") = 세월교 · BOX 와 같은 창구(폼 · 알약 · 측점선) · 물넘이 범위 옆 카드도 그 물넘이 자리로 - 목록 · 알약 · 폼 고름 → 카드 형상 강조(highlightShapes) · 물넘이 포장층 표식 = 관 지점 알약 id - 시험 test_287(판정면 차례 · 누름 · 끌기 · 강조) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../B06_Section_UI_Cross_Ford_Pavement.ts | 7 +- .../B06_Section_UI_Cross_Shape_Pick.ts | 60 +++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 11 ++ ...06_Section_UI_Page_Structures_Selection.ts | 8 +- B06_Section/B06_Section_UI_Section_View.ts | 5 + .../B06_Section_UI_Style_Cross_Areas.css | 7 ++ resources/tester/helper_287_ford_pavement.cjs | 100 ++++++++++++++++++ .../tester/test_287_ford_pavement_followup.py | 44 ++++++++ 8 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Shape_Pick.ts diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index ea30b9e4d..38c948fb4 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -111,7 +111,12 @@ export function appendFordPavementOverlay( [...svg.children] .slice(from) .filter((el) => el.getAttribute("class") === "b06-chart__pavement") - .forEach((el) => el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement")); + .forEach((el) => { + el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); + // 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글 + if (typeof spec.chainage_m === "number") + el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); + }); // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) const depth = spec.depth_m ?? 0; if (depth > 0) diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts new file mode 100644 index 000000000..17e960758 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Shape_Pick.ts + * 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2 · 요구 185). + * + * 그린 형상(`data-structure-id` 단 포장층 · 돌 무늬 둘레)마다 투명 판정면 — 누르면 그 구조물을 고름 + * (다른 시설 형상 고름과 같은 창구 `onPick`). 지킬 것: + * ① 보라 손잡이(설계선 판정 · 뒤에 붙음)가 위 — 판정면은 구조물 판정면 바로 앞(아래)에 꽂고 + * 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴). + * ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님. + * ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음. + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */ +export const SHAPE_DRAG_PX = 3; + +/** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */ +export function attachShapePick( + layer: SVGElement, + onPick?: (structureId: string) => void, +): ((active: boolean) => void) | null { + const shapes = [...layer.querySelectorAll("[data-structure-id]")].filter( + (el) => !el.hasAttribute("data-pave-type"), + ); + if (!shapes.length) return null; + const kids = [...layer.children]; + const firstStructureHit = kids.find((el) => + el.classList.contains("b06-chart__culvert-revet-hit"), + ); + for (const shape of onPick ? shapes : []) { + const id = shape.getAttribute("data-structure-id") ?? ""; + const hit = document.createElementNS(SVG_NS, "polygon"); + hit.setAttribute("points", shape.getAttribute("points") ?? ""); + hit.setAttribute("class", "b06-chart__shape-hit"); + let down: { x: number; y: number } | null = null; + hit.addEventListener("pointerdown", (event) => { + down = { x: event.clientX, y: event.clientY }; + }); + hit.addEventListener("click", (event) => { + const moved = + down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > SHAPE_DRAG_PX; + down = null; + if (moved) return; // 끌기 = 고름 아님(②) + event.stopPropagation(); + onPick?.(id); + }); + layer.insertBefore(hit, firstStructureHit ?? null); + } + return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active)); +} + +/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */ +export function highlightShapes(root: ParentNode, structureId: string | null): void { + for (const shape of root.querySelectorAll("[data-structure-id]")) { + shape.classList.toggle( + "is-active", + !!structureId && shape.getAttribute("data-structure-id") === structureId, + ); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index da538e6af..e8178a2a2 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -20,6 +20,7 @@ import { appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { attachShapePick } from "./B06_Section_UI_Cross_Shape_Pick"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; @@ -574,6 +575,16 @@ export function createCrossSectionCard( }; setChuteActive(highlightRevet === "chute"); } + // 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만 + const setShapeActive = attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe"))); + if (setShapeActive) { + const baseSetActive = setRevetActive; + setRevetActive = (key) => { + baseSetActive(key); + setShapeActive(key === "pipe"); + }; + setShapeActive(highlightRevet === "pipe"); + } // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). const lineDesign = section.design; linePick = attachDesignLinePick({ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index fc8ef4475..20771b942 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -49,7 +49,13 @@ export function wireStructureSelection( const sectionAt = detail()?.cross_sections.find( (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, ); - return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; + // 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶) + return ( + (sectionAt && + (stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m ?? + sectionAt.ford_pavement?.chainage_m)) ?? + chainageM + ); }; // 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index dc327f46f..f6efcaad2 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -69,6 +69,7 @@ import { import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface"; +import { highlightShapes } from "./B06_Section_UI_Cross_Shape_Pick"; import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; import type { DesignPageShared } from "../A00_Common/design/design_shared"; @@ -243,6 +244,8 @@ export function createSectionView( let spanMarks: ReadonlyArray = []; let spanTypes: ReadonlyArray = []; let spanMarkId: string | null = null; + /** 고른 구조물(알약 · 목록 · 폼) — 물넘이포장 · 맹암거 형상 강조(287-5 · 288-2). */ + let pickedMarkId: string | null = null; const inSpan = (section: CrossSection, mark: StructureInstance | undefined): boolean => { const { start_m: start, end_m: end } = mark ?? {}; if (start == null || end == null || start === end) return false; @@ -261,6 +264,7 @@ export function createSectionView( const pave = span && isPavement(spanMarkId); card.classList.toggle("b06-cross-card--span", span); highlightPavement(card, pave ? mark : undefined); + highlightShapes(card, pickedMarkId); }; const syncSpanHighlights = (): void => { for (const section of currentDetail?.cross_sections ?? []) { @@ -606,6 +610,7 @@ export function createSectionView( selectMark: (structureId, span = true) => { // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) spanMarkId = span || isPavement(structureId) ? structureId : null; + pickedMarkId = structureId; syncSpanHighlights(); profile.selectMark(structureId); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index c24ef3860..b970a0463 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -324,6 +324,13 @@ cursor: pointer; } +/* 물넘이포장 · 맹암거 형상 고름 판정면(287-5 · 288-2) — 보라 손잡이 아래 */ +.b06-chart__shape-hit { + fill: transparent; + stroke: none; + cursor: pointer; +} + /* BOX암거 끝 선택 강조 — 고른 절반을 옅게 칠하고 테두리를 두른다 (2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와 같은 계열이되, 면이 넓어 채도를 낮춘다. */ diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index 124043eb7..9f2a35521 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -51,9 +51,35 @@ class Node { getAttribute(k) { return this.attrs[k] ?? null; } + hasAttribute(k) { + return k in this.attrs; + } append(...kids) { this.children.push(...kids); } + insertBefore(kid, ref) { + const at = ref ? this.children.indexOf(ref) : -1; + if (at < 0) this.children.push(kid); + else this.children.splice(at, 0, kid); + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + return { + contains: (c) => names().includes(c), + toggle: (c, on) => { + const rest = names().filter((n) => n !== c); + this.attrs.class = (on ? [...rest, c] : rest).join(" "); + }, + }; + } + addEventListener(type, fn) { + (this.listeners ??= {})[type] = fn; + } + querySelectorAll(selector) { + const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1]; + const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]); + return all.filter((k) => attr && k.hasAttribute(attr)); + } } globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); @@ -90,7 +116,81 @@ const drew = pave.appendFordPavementOverlay( ); const ofClass = (c) => svg.children.filter((k) => (k.attrs.class ?? "").split(" ").includes(c)); +// 287-5 형상 고름 — 판정면 차례 · 누름 = 고름 · 끌기 = 고름 아님 · 노면공 포장층 안 섞임 · 강조 +const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")); +const layer = new Node("g"); +const fordShape = new Node("polygon"); +fordShape.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); +fordShape.setAttribute("data-structure-id", "pipe-230.00"); +fordShape.setAttribute("points", "0,0 1,0 1,1"); +const paveShape = new Node("polygon"); // 노면공 포장층 — 범위 표식이 있어 형상 고름 밖 +paveShape.setAttribute("data-structure-id", "pave-1"); +paveShape.setAttribute("data-pave-type", "pavement_concrete"); +const structureHit = new Node("polygon"); +structureHit.setAttribute("class", "b06-chart__culvert-revet-hit"); +layer.append(fordShape, paveShape, structureHit); +const picked = []; +const setShape = shapePick.attachShapePick(layer, (id) => picked.push(id)); +// 보라 손잡이 판정은 뒤에 붙음 — 설계선 고름과 같은 자리(구조물 판정면 앞)에 꽂음 +const purple = new Node("polyline"); +purple.setAttribute("class", "b06-chart__design-hit"); +layer.insertBefore( + purple, + layer.children.find((k) => k.classList.contains("b06-chart__culvert-revet-hit")), +); +const shapeHit = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit")); +const press = (dx) => { + const ev = { + clientX: 10, + clientY: 10, + stopped: false, + stopPropagation() { + this.stopped = true; + }, + }; + shapeHit.listeners.pointerdown(ev); + const up = { + ...ev, + clientX: 10 + dx, + stopPropagation() { + up.stopped = true; + }, + }; + shapeHit.listeners.click(up); + return up.stopped; +}; +const clickStops = press(0); +const dragStops = press(12); +setShape(true); +const activeAfterSet = fordShape.classList.contains("is-active"); +shapePick.highlightShapes(layer, "other"); +const activeAfterOther = fordShape.classList.contains("is-active"); +shapePick.highlightShapes(layer, "pipe-230.00"); +const activeAfterSame = fordShape.classList.contains("is-active"); +// 물넘이 포장층 표식 = 관 지점 알약 id +const tagged = new Node("g"); +pave.appendFordPavementOverlay( + tagged, + { chainage_m: 230, span_m: 8, depth_m: 0.4, slope_pct: 5 }, + tilted, + (o) => o, + (e) => e, +); + const out = { + shapeOrder: layer.children.map((k) => + (k.attrs.class ?? k.attrs["data-structure-id"]).split(" ").pop(), + ), + picked, + clickStops, + dragStops, + activeAfterSet, + activeAfterOther, + activeAfterSame, + pavedShapeIsPicked: picked.includes("pave-1"), + fordTag: tagged.children + .filter((k) => (k.attrs.class ?? "").includes("ford-pavement")) + .map((k) => k.attrs["data-structure-id"]), drew, pavePoints: ofClass("b06-chart__ford-pavement").map((k) => k.attrs.points), planPoints: ofClass("b06-chart__ford-deck-plan").map((k) => k.attrs.points), diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 18af864ed..67a4a1f7f 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -4,6 +4,9 @@ - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. - 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력. + - 287-5 물넘이포장 형상 누름 = 다른 시설과 같은 고름 창구(`toggleRevet("pipe")` → 폼 · 알약 · 측점선) · + 판정면은 보라 손잡이 아래(요구 185 ①) · 끌기(3 px 넘게 움직임) = 고름 아님(②) · 노면공 포장층 + (범위 표식)은 형상 고름 밖(③) · 목록 · 알약 고름이 형상 강조를 따라옴. - 287-2 · 287-4 콘크리트 윗면 = 설계 노면(서버 · TS 짝이 경사각도로 지대 낮은 쪽 한 경사 · 중심점이 그 위) · 포장층 = 노면공 포장 형상 노견까지(노면공 항목과 안 섞임) · 월류 높이 점선 = 노면 위 같은 각도 · 3D 노면을 파지 않음 · 설계 측구 없음. @@ -91,3 +94,44 @@ def test_월류_폭_넣으면_높이_자동_세월교_한_벌(ts): form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") assert 'const paveFlowBox = overflow(() => paveForm, "slope");' in form assert 'if (facility === "ford_pavement") paveFlowBox.fill();' in form + + +def test_형상_판정면은_보라_손잡이_아래(ts): + order = ts["shapeOrder"] + # 그림 → 형상 판정면 → 보라 손잡이 판정 → 구조물 판정면(위가 이김) + assert ( + order.index("b06-chart__shape-hit") + < order.index("b06-chart__design-hit") + < order.index("b06-chart__culvert-revet-hit") + ) + + +def test_형상_누름은_고름_끌기는_고름_아님(ts): + assert ts["picked"] == ["pipe-230.00"] # 누름 한 번만 고름 · 끌기는 안 셈 + assert ts["clickStops"] is True and ts["dragStops"] is False # 끌기는 카드로 그대로 + assert ts["pavedShapeIsPicked"] is False # 노면공 포장층은 형상 고름 밖(포장 고름 몫) + + +def test_형상_강조와_표식(ts): + assert ts["activeAfterSet"] is True + assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True + assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id + + +def test_보라_손잡이는_계획고만_형상_누름은_고름만(): + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + # 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위) + assert 'attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")))' in view + assert view.index("attachShapePick(plotLayer") < view.index("attachDesignLinePick({") + pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") + assert "onDesignChange" not in pick and "design_elevation" not in pick + assert '"b06-chart__culvert-revet-hit"' in pick # 구조물 판정면 앞(아래)에 꽂을 기준만 + assert 'hit.setAttribute("class", "b06-chart__shape-hit")' in pick + # 보라 손잡이 판정 = 계획고 · 사면 이동 창구(설계선 고름 · 구조물 판정면 아래) + design_pick = _read("B06_Section", "B06_Section_UI_Cross_Design_Pick.ts") + assert 'el.classList.contains("b06-chart__culvert-revet-hit")' in design_pick + # 목록 · 알약 · 폼 고름 → 형상 강조 + section_view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert "highlightShapes(card, pickedMarkId);" in section_view + selection = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts") + assert "sectionAt.ford_pavement?.chainage_m" in selection