From 64a64068e0b1131cb9b3da1b5a5260325e8b6cab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:24:35 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20210=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=83=9D=EC=84=B1=20=EB=92=A4=20?= =?UTF-8?q?=EC=B8=A1=EA=B5=AC=20=EC=83=9D=EA=B9=80=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EB=B0=94=EA=BF=80=20=EB=95=8C=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EC=95=88=20=EB=B9=84=EC=9B=80=20=C2=B7=20=EC=9B=94=EB=A5=98?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EC=B2=AB=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=E2=80=94=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=C2=B7=20=EC=A0=84=EC=97=AD=20=EC=8B=B1?= =?UTF-8?q?=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 46666be65..8537cb035 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -238,3 +238,4 @@ 207. (원문 · 10-13) 구조물 전체 삭제 버튼이 상단 구조물 버튼의 높이와 다름. 변경하고 다시 전역 깃 싱크 208. (원문 · 10-13) 1. 정션걸면 되는거 아닌지. 2. 제안 방안 제시 3. 일하는 창이 있으면 당연히 깃을 하면안되지 누락하고 싱크하는게 안되는거잖아. 209. (원문 · 10-13) 수정하자 +210. (원문 · 10-13) 1. 물넘이포장과 맹암거 생성 한후 측구가 생성됨. 두항목다 측구없는 형상. 저장하면 정상 저장되는걸로 보임. 2. 물넘이포장과 맹암거 생성후 다른 구조물로 변경할 경우 캐시 비우기 안됨(예상). 나는 박스암거로 변경했는데 물넘이포장이 남아있었음. 확인 후 해당부분만 개선하지말고 동일한 문제가 있는지 확인하고 다른 정상인 구조물들이 어던게 있는지 확인. 3. 물넘이포장의 월류높이가 초기에 자동계산안됨. 세월교 확인할 것. 4. 이부분은 너가 작업하고 전역 깃 싱크하자. From b72ab3a3edc3dd298856907777878c7701ac9b92 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:33:08 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20211=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=EB=8F=84?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=B8=A1=EC=A0=90=20=EC=9D=B4=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EC=A0=90=EC=84=A0=20=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=9D=B4=EB=8F=99=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B5=ED=95=A9=EA=B2=BD=EC=82=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=BA=BC=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 8537cb035..f58f9b124 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -239,3 +239,4 @@ 208. (원문 · 10-13) 1. 정션걸면 되는거 아닌지. 2. 제안 방안 제시 3. 일하는 창이 있으면 당연히 깃을 하면안되지 누락하고 싱크하는게 안되는거잖아. 209. (원문 · 10-13) 수정하자 210. (원문 · 10-13) 1. 물넘이포장과 맹암거 생성 한후 측구가 생성됨. 두항목다 측구없는 형상. 저장하면 정상 저장되는걸로 보임. 2. 물넘이포장과 맹암거 생성후 다른 구조물로 변경할 경우 캐시 비우기 안됨(예상). 나는 박스암거로 변경했는데 물넘이포장이 남아있었음. 확인 후 해당부분만 개선하지말고 동일한 문제가 있는지 확인하고 다른 정상인 구조물들이 어던게 있는지 확인. 3. 물넘이포장의 월류높이가 초기에 자동계산안됨. 세월교 확인할 것. 4. 이부분은 너가 작업하고 전역 깃 싱크하자. +211. (원문 · 10-13) 추가 검토 사항 1. 물넘이 포장 횡단도 형상 개선 - 추가 도수로/ 돌붙임의 경우 길이가 없는 경우에는 성토는 원지반까지 이으면 됨. 하지만 길이값이 들어간 경우에는 길이값만큼 성토부가 짧더라도 원지반까지 이어서라고 지정 길이 확보, 절토인경우에는 현재가 맞음. - 물넘이 포장의 기준측점을 이동하는 경우 반영안되는걸로 보임. - 월류높이에 따라 접선 생성이 되는데 절토부까지는 문제없이 이어지나 성토부에는 성토선을 점선으로 연장해서 이어야함. 세월교 참고. 2. 맹암거 - 기준측점 이동 안될것으로 예상. 3. 횡단도의 복합경사 기본값을 꺼짐으로 반영 From 1dc815eae3d248c6e5a2ed5cc64a20f58c7eb66d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:36:49 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20212=20B05=203D?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=20=EA=B8=B0=EB=B3=B8=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=3D=20=EC=9B=90=EA=B7=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index f58f9b124..485e71deb 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -240,3 +240,4 @@ 209. (원문 · 10-13) 수정하자 210. (원문 · 10-13) 1. 물넘이포장과 맹암거 생성 한후 측구가 생성됨. 두항목다 측구없는 형상. 저장하면 정상 저장되는걸로 보임. 2. 물넘이포장과 맹암거 생성후 다른 구조물로 변경할 경우 캐시 비우기 안됨(예상). 나는 박스암거로 변경했는데 물넘이포장이 남아있었음. 확인 후 해당부분만 개선하지말고 동일한 문제가 있는지 확인하고 다른 정상인 구조물들이 어던게 있는지 확인. 3. 물넘이포장의 월류높이가 초기에 자동계산안됨. 세월교 확인할 것. 4. 이부분은 너가 작업하고 전역 깃 싱크하자. 211. (원문 · 10-13) 추가 검토 사항 1. 물넘이 포장 횡단도 형상 개선 - 추가 도수로/ 돌붙임의 경우 길이가 없는 경우에는 성토는 원지반까지 이으면 됨. 하지만 길이값이 들어간 경우에는 길이값만큼 성토부가 짧더라도 원지반까지 이어서라고 지정 길이 확보, 절토인경우에는 현재가 맞음. - 물넘이 포장의 기준측점을 이동하는 경우 반영안되는걸로 보임. - 월류높이에 따라 접선 생성이 되는데 절토부까지는 문제없이 이어지나 성토부에는 성토선을 점선으로 연장해서 이어야함. 세월교 참고. 2. 맹암거 - 기준측점 이동 안될것으로 예상. 3. 횡단도의 복합경사 기본값을 꺼짐으로 반영 +212. (원문 · 10-13) 추가 검토사항 - 3D영역의 기본보기를 원근으로 변경 From bcca4163dfc81bc880fa08b03a26ee6e56dc4b79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:44:45 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(b05=C2=B7b06):=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EB=91=98=20=E2=80=94=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=B3=B5=ED=95=A9=EA=B2=BD=EC=82=AC=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=BA=BC=EC=A7=90(=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=C2=B7=20TS=20?= =?UTF-8?q?=EC=A7=9D=20=ED=95=A8=EA=BB=98=20=C2=B7=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=9C=20=EA=B3=A0=EB=A5=B8=20=EA=B0=92=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EA=B0=92=20=EC=97=86=EB=8A=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=83=88=20=EC=B8=A1=EC=A0=90=EB=A7=8C=20?= =?UTF-8?q?=EA=BA=BC=EC=A7=90)=20=C2=B7=20B05=203D=20=EC=B2=AB=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=9B=90=EA=B7=BC(=ED=99=94=EB=A9=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20b05-3d-projection=20=EA=B8=B0=EC=96=B5=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0)=20(316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/b_page_state.ts | 2 + B05_Profile/B05_Profile_Router_Confirm.py | 2 +- B05_Profile/B05_Profile_UI_Panel_View.ts | 10 +- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 6 +- B06_Section/B06_Section_Cross_Refresh.ts | 4 +- B06_Section/B06_Section_Engine_Design.py | 4 +- .../B06_Section_Engine_SampleExtend.py | 2 +- B06_Section/B06_Section_Engine_SpoilFill.py | 2 +- B06_Section/B06_Section_Router_Design.py | 10 +- B06_Section/B06_Section_Schema.py | 2 +- B06_Section/B06_Section_UI_Cross_Design.ts | 4 +- .../B06_Section_UI_Page_Design_Sync.ts | 2 +- common_util/common_util_cross_design.ts | 4 +- resources/tester/test_316_defaults.py | 105 ++++++++++++++++++ .../tester/test_b06_cross_design_mirror.py | 12 ++ 15 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 resources/tester/test_316_defaults.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 177a0ec17..a6c86b1af 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -103,6 +103,8 @@ export const STATE_REGISTRY = { "cross-columns": { bucket: "pref", scope: "global" }, /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, + /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ + "b05-3d-projection": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 0fde4d5a6..31f2c8a4f 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -170,7 +170,7 @@ async def sync_uphill_overrides_into_designs( paved=bool(design.get("paved", False)), standard=stored_standard, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), surface_drop_m=ford_drop_at(float(chainage), ford_drops), diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 93e751618..f754a64f9 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -4,6 +4,7 @@ * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). * ========================================================================== */ +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ @@ -33,7 +34,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 원근 · 고른 값은 화면 설정에 기억. + const initialProjection = readStateRaw("b05-3d-projection") === "ortho" ? "ortho" : "perspective"; const projectionButtons = ( [ ["perspective", "원근"], @@ -47,15 +49,17 @@ export function createViewControls(callbacks: ViewControlCallbacks): { el.classList.toggle("is-active", other === kind); el.setAttribute("aria-pressed", String(other === kind)); }); + writeStateRaw("b05-3d-projection", kind); callbacks.onProjection(kind); }, "glass", ); - element.classList.toggle("is-active", kind === "ortho"); - element.setAttribute("aria-pressed", String(kind === "ortho")); + element.classList.toggle("is-active", kind === initialProjection); + element.setAttribute("aria-pressed", String(kind === initialProjection)); return { kind, element }; }); viewButtons.append(...projectionButtons.map(({ element }) => element)); + if (initialProjection === "ortho") callbacks.onProjection("ortho"); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 7dbe1cdd7..0e9f76dcf 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,8 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· - * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. + * 기본은 원근(시야각 45°) — 직교는 단추로 고른다(고른 값은 보기 줄이 화면 설정에 기억). + * 갈아 끼울 때 위치·시선·근평면·먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. * ========================================================================== */ @@ -21,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "ortho"; + let kind: ProjectionKind = "perspective"; let aspect = 1; let halfHeight = 100; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95b11ad4d..f74e19f97 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -366,9 +366,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, cutSlopeRatio: cutSlopeAt(section.chainage_m, design), - twoStageSlope: - choice?.two_stage_slope ?? - (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), + twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. ditchChoice: choice diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a83fe08e0..ca0386692 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -189,7 +189,7 @@ def compute_cross_design( paved: bool = False, standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, - two_stage_slope: bool = True, + two_stage_slope: bool = False, cut_slope_ratio: float | None = None, ditch_enabled: bool | None = None, ditch_choice: bool | None = None, @@ -212,7 +212,7 @@ def compute_cross_design( paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형). standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. - two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 False, 토글로 켬). cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index e2dc0da03..62ca91726 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -151,7 +151,7 @@ def extend_unclosed_sections( rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 30d267132..5ee2cd263 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -208,7 +208,7 @@ def enforce_spoil_fills( paved=bool(design.get("paved", False)), standard=standard_spec, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 035ec26ff..b9ba394cc 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -242,7 +242,7 @@ def enforce_pavement_ranges( rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -302,7 +302,7 @@ def enforce_design_lines( rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -358,7 +358,7 @@ def enforce_ford_surface_drops( rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -566,7 +566,7 @@ def recompute_designs_for_alignment( key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), - two_stage_slope=bool(stored.get("two_stage_slope", True)), + two_stage_slope=bool(stored.get("two_stage_slope", False)), cut_slope_ratio=( (session_cut_slopes[key] or None) if key in session_cut_slopes @@ -657,7 +657,7 @@ def compute_default_designs( rock_boundary_offset_m=( STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset ), - two_stage_slope=bool(user.get("two_stage_slope", True)), + two_stage_slope=bool(user.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(user), ditch_choice=user.get("ditch_choice"), surface_drop_m=ford_drop_at(chainage_m, ford_drops), diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index c0434106b..0a00c7ec8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -51,7 +51,7 @@ class CrossDesignRequest(BaseModel): # 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산에 쓴다. rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. - two_stage_slope: bool = True + two_stage_slope: bool = False # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). # None 이면 표준 횡단면 설정값을 쓴다. cut_slope_ratio: float | None = Field(default=None, gt=0) diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 0c7e07f68..88b037ea8 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -338,8 +338,8 @@ export function buildDesignControls( ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", paved: design?.paved ?? false, - // 암 design일 때만 저장값을 신뢰(토사는 two_stage=false echo가 무의미) — 암 전환 시 기본 복합경사(4번). - twoStage: design && isRock(design.ground_type) ? (design.two_stage_slope ?? true) : true, + // 저장된 선택은 지반유형과 상관없이 그대로 · 값이 없으면 기본 꺼짐(복합경사 기본 = 꺼짐). + twoStage: design?.two_stage_slope ?? false, // ⚠ **결과가 아니라 선택을 읽는다.** 결과를 읽으면 한 번 저장된 뒤로 // 자동 판정이 영영 안 돈다(2026-09-09에 갈라낸 자리). ditchEnabled: design?.ditch_choice ?? null, diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 18b92f1bd..f1836b9c0 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -131,7 +131,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { ditch_side: design.ditch_side ?? null, ditch_type: design.ditch_type ?? "standard", paved: design.paved, - two_stage_slope: design.two_stage_slope ?? true, + two_stage_slope: design.two_stage_slope ?? false, ditch_choice: design.ditch_choice ?? null, }; } diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 2c6b95a76..f33151829 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -345,7 +345,7 @@ export function computeCrossDesign( const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null; // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. const enableTwoStage = - presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; + presetKey === "rock" && (options.twoStageSlope ?? false) && rockBoundaryOffsetM !== null; // 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. // 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다. const outerSide = options.curveOuterSide; @@ -528,7 +528,7 @@ export function computeCrossDesign( // 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사 // 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져 // 사용자의 「켬」이 영구히 사라졌다. - two_stage_slope: options.twoStageSlope ?? true, + two_stage_slope: options.twoStageSlope ?? false, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py new file mode 100644 index 000000000..086d0d9b5 --- /dev/null +++ b/resources/tester/test_316_defaults.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""PLAN 316 — 기본값 둘. + +316-1 B06 횡단 「복합경사」(two_stage_slope) 기본 = 꺼짐 · 저장된 고른 값은 그대로. + 두 짝(파이썬 `compute_cross_design` ↔ TS `computeCrossDesign`)의 같은 기본은 + `test_b06_cross_design_mirror.py` 「발파암 복합경사 값 없음」 갈래가 실제 대조. +316-2 B05 3D 영역 첫 보기 = 원근 · 화면 설정에 기억한 값이 있으면 그 값. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from B06_Section.B06_Section_Schema import CrossDesignRequest # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _samples() -> list[dict]: + offsets = [round(-12 + i * 0.5, 3) for i in range(49)] + return [{"offset_m": x, "elevation_m": 100.0 - 0.55 * x, "valid": True} for x in offsets] + + +def _rock(**extra) -> dict: + return compute_cross_design( + _samples(), + 99.0, + ground_type="blasting_rock", + section_mode="left_cut", + ditch_side="left", + standard=STANDARD_CROSS_SECTION, + rock_boundary_offset_m=-1.4, + **extra, + ) + + +def test_engine_default_off(): + plain, off, on = _rock(), _rock(two_stage_slope=False), _rock(two_stage_slope=True) + assert plain["two_stage_slope"] is False + assert plain["cut_area_m2"] == off["cut_area_m2"] + # 고른 「켬」은 그대로 — 면적이 실제로 달라야 기본값 시험이 뜻이 있음 + assert on["two_stage_slope"] is True + assert on["cut_area_m2"] != off["cut_area_m2"] + + +def test_request_schema_default_off(): + field = CrossDesignRequest.model_fields["two_stage_slope"] + assert field.default is False + + +def test_python_stored_reads_default_off(): + for rel in ( + "B06_Section/B06_Section_Router_Design.py", + "B06_Section/B06_Section_Engine_SpoilFill.py", + "B06_Section/B06_Section_Engine_SampleExtend.py", + "B05_Profile/B05_Profile_Router_Confirm.py", + ): + src = _read(rel) + assert '"two_stage_slope", True' not in src, rel + assert '"two_stage_slope", False' in src, rel + + +def test_ts_defaults_off_and_stored_kept(): + engine = _read("common_util/common_util_cross_design.ts") + assert engine.count("options.twoStageSlope ?? false") == 2 + assert "twoStageSlope ?? true" not in engine + # 카드 토글 — 저장된 고른 값 그대로 · 없으면 꺼짐 + assert "twoStage: design?.two_stage_slope ?? false," in _read( + "B06_Section/B06_Section_UI_Cross_Design.ts" + ) + # 브라우저 재계산 인자 — 세션 선택 → 저장값 → 꺼짐 + assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( + "B06_Section/B06_Section_Cross_Refresh.ts" + ) + assert "two_stage_slope: design.two_stage_slope ?? false," in _read( + "B06_Section/B06_Section_UI_Page_Design_Sync.ts" + ) + + +def test_3d_first_view_perspective(): + camera = _read("B05_Profile/B05_Profile_UI_Viewer_Camera.ts") + assert 'let kind: ProjectionKind = "perspective";' in camera + state = _read("A00_Common/b_page_state.ts") + assert re.search(r'"b05-3d-projection": \{ bucket: "pref", scope: "global" \}', state) + + +def test_3d_remembered_projection_wins(): + src = _read("B05_Profile/B05_Profile_UI_Panel_View.ts") + # 기억 = ortho 일 때만 직교 · 그 밖(없음 · 손상)은 원근 + assert re.search( + r'const initialProjection = readStateRaw\("b05-3d-projection"\) === "ortho"\s*\?\s*"ortho"\s*:\s*"perspective";', + src, + ) + assert 'writeStateRaw("b05-3d-projection", kind);' in src + assert 'if (initialProjection === "ortho") callbacks.onProjection("ortho");' in src + # 단추 강조도 처음 값 따라 + assert 'element.classList.toggle("is-active", kind === initialProjection);' in src diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 22f84dad6..c3789c971 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -95,6 +95,18 @@ CASES = [ "two_stage_slope": False, }, }, + { + # 316-1 — 복합경사 값 없음 = 기본 꺼짐(두 짝 같은 기본) + "name": "발파암 복합경사 값 없음", + "samples": _ground(-0.55), + "design_elevation_m": 99.0, + "options": { + "ground_type": "blasting_rock", + "section_mode": "left_cut", + "ditch_side": "left", + "rock_boundary_offset_m": -1.4, + }, + }, { "name": "포장 + 측구 없음", "samples": _ground(0.12), From b7eb44cd5c1f1d636f98b83cb2cbf8a31ef9911c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:46:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(b06):=20=EB=AC=BC=EB=84=98=EC=9D=B4?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=EB=8F=84=EC=88=98=EB=A1=9C/=EB=8F=8C?= =?UTF-8?q?=EB=B6=99=EC=9E=84=20=EA=B8=B8=EC=9D=B4=20=3D=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EC=82=AC=EB=A9=B4=EB=B3=B4=EB=8B=A4=20=EA=B8=B8?= =?UTF-8?q?=EB=A9=B4=20=EC=9B=90=EC=A7=80=EB=B0=98=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=EA=B9=8C=EC=A7=80=20=ED=95=9C=20=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9B=94=EB=A5=98=20=EB=86=92=EC=9D=B4=EC=84=A0=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EC=AA=BD=20=3D=20=EC=84=B1=ED=86=A0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=90=EC=84=A0=20=EC=97=B0=EC=9E=A5=EA=B3=BC=20?= =?UTF-8?q?=EA=B5=90=EC=A0=90=20(315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 315-1 fordChuteLine — 길이 맞춘 선의 첫 조각만 쓰던 흠(원지반 이음 조각 버림) · 이은 조각까지 한 선 · 길이 없음 · 짧은 길이 · 절토 쪽 그대로 - 315-2 overflowLine — 성토 쪽(노면 끝 밖 설계선이 내려감) = 성토 사면선을 노면 끝 너머 위로 연장해 월류 높이선과 만나는 자리에서 끝 · 절토 · 늘인 노면 끝(294-3) 그대로 · 같은 점선 양식(B06 카드 · B07 [상세] 같은 그림) - 시험 test_315_ford_chute_overflow 새로 · test_287 월류선 기대값 315 규칙 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../B06_Section_UI_Cross_Ford_Pavement.ts | 47 +++-- .../tester/helper_315_ford_chute_overflow.cjs | 83 +++++++++ .../tester/test_287_ford_pavement_followup.py | 6 +- .../tester/test_315_ford_chute_overflow.py | 161 ++++++++++++++++++ 4 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 resources/tester/helper_315_ford_chute_overflow.cjs create mode 100644 resources/tester/test_315_ford_chute_overflow.py diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index f6e241919..1f12fa437 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -6,7 +6,8 @@ * (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위. * 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토). * · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지. - * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도. + * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도 · 성토 쪽은 성토선을 + * 점선으로 늘여 이음(315-2). * · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면. * ========================================================================== */ @@ -119,15 +120,12 @@ export function appendFordPavementOverlay( if (typeof spec.chainage_m === "number") el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); }); - // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) + // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2) 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)}`, - ], + overflowLine(design, depth).map((p) => `${x(p.offset)},${y(p.elevation)}`), "b06-chart__ford-deck-plan", ), ); @@ -145,6 +143,35 @@ export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | unde return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end }; } +/** + * 월류 높이선(315-2) — 노면 양 끝 위 월류 높이 · 노면과 같은 각도. 성토 쪽(노면 끝 밖 설계선이 내려감)은 + * 성토 사면선을 노면 끝 너머 위 · 안쪽으로 늘여 월류 높이선과 만나는 자리에서 끝 → 노면 끝까지 그 연장선. + * 절토 쪽 · 늘인 노면 끝(294-3)은 노면 끝 위 그대로. 좌(+offset) → 우 차례. + */ +export function overflowLine(design: CrossDesign, depth: number): OffsetPoint[] { + const edges = fordEdges(design); + if (!edges) return []; + const { left, right } = edges; + const span = Math.abs(left.offset_m - right.offset_m); + const side = (edge: typeof left, other: typeof left): OffsetPoint[] => { + const top = { offset: edge.offset_m, elevation: edge.elevation_m + depth }; + const outward = Math.sign(edge.offset_m - other.offset_m) || 1; + const next = (design.design_line ?? []) + .filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward)[0]; + const cut = edge.offset_m === design.tilt_reach?.offset_m; // 늘인 끝 = 원지반(294-3 · 절토) + if (cut || !next || next.elevation_m >= edge.elevation_m - 1e-6 || span < 1e-9) return [top]; + // 안쪽 d m 에서 성토선 = edge + fill·d · 월류선 = edge + depth + road·d → d = depth / (fill − road) + const fill = (edge.elevation_m - next.elevation_m) / Math.abs(next.offset_m - edge.offset_m); + const road = (other.elevation_m - edge.elevation_m) / span; + if (fill <= road + 1e-9) return [top]; + const d = Math.min(depth / (fill - road), span); + const meet = { offset: edge.offset_m - outward * d, elevation: edge.elevation_m + fill * d }; + return [{ offset: edge.offset_m, elevation: edge.elevation_m }, meet]; + }; + return [...side(left, right), ...side(right, left).reverse()]; +} + /** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */ function groundZ(ground: readonly OffsetPoint[], offset: number): number | null { if (!ground.length) return null; @@ -193,16 +220,16 @@ export function outletFillLine( /** * 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 · - * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림. - * 횡단 띠 · 3D 띠가 같이 씀(그림 전용). + * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어 · 315-1 이은 조각까지 한 선) · 양측 절토(늘인 노면 끝이 + * 원지반) = 길이 없으면 안 그림. 횡단 띠 · 3D 띠가 같이 씀(그림 전용). */ export function fordChuteLine( design: CrossDesign, chute?: { length_m?: number }, ground?: readonly OffsetPoint[], ): OffsetPoint[] { - return ( - chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? [] + return chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground).flatMap( + (run, i) => (i ? run.points.slice(1) : run.points), ); } diff --git a/resources/tester/helper_315_ford_chute_overflow.cjs b/resources/tester/helper_315_ford_chute_overflow.cjs new file mode 100644 index 000000000..f32a65852 --- /dev/null +++ b/resources/tester/helper_315_ford_chute_overflow.cjs @@ -0,0 +1,83 @@ +/* 315 물넘이포장 도수로 · 돌붙임 길이(성토) · 월류 높이선 성토 쪽 연장 헬퍼 — 진짜 TS 를 Node 로 돌려 JSON 한 벌로. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +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 input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +const hatch = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts")); +const fillGround = hatch.groundLine(input.fillSamples); +const dipGround = hatch.groundLine(input.dipSamples); +const chute = (design, ground, length) => + pave.fordChuteLine(design, length ? { length_m: length } : {}, ground); +// 그린 SVG — 띠(돌붙임) · 월류 높이선 점 수 +const svg = new Node("g"); +pave.appendFordPavementOverlay( + svg, + { + span_m: 8, + depth_m: 0.3, + slope_pct: 5, + chute: { structure: "돌붙임", thickness_m: 0.3, length_m: 6 }, + }, + input.fillDesign, + (o) => o, + (e) => e, + fillGround, +); +const deck = svg.children.find((k) => k.attrs.class === "b06-chart__ford-deck-plan"); +const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching"); + +const out = { + fillNone: chute(input.fillDesign, fillGround), + fillShort: chute(input.fillDesign, fillGround, 1), + fillLong: chute(input.fillDesign, fillGround, 6), + dipNone: chute(input.dipDesign, dipGround), + dipLong: chute(input.dipDesign, dipGround, 2), + fillOverflow: pave.overflowLine(input.fillDesign, 0.3), + dipOverflow: pave.overflowLine(input.dipDesign, 0.3), + fillEdges: pave.fordEdges(input.fillDesign), + dipEdges: pave.fordEdges(input.dipDesign), + deckPoints: deck?.attrs.points ?? null, + bandPoints: band?.attrs.points ?? null, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 0762b3afe..326a53977 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -75,8 +75,10 @@ def test_포장층_노견까지_점선은_노면_위(ts): # 포장층 하나 = 노견 끝(±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]) + # 월류 높이선 = 노면 + 0.4 · 같은 각도 · 우(성토) = 성토선 연장과 만난 자리 → 노면 끝(315-2) + d = 0.4 / (2.65 / 4 - 0.3 / 6) + meet = [-3 + d, 99.85 + 2.65 / 4 * d] + assert _pts(ts["planPoints"][0]) == pytest.approx([3, 100.55, *meet, -3, 99.85]) assert ts["inflowLeft"] is True # 노면 높은 쪽 = 유입 · 도수로 띠 = 낮은 쪽 diff --git a/resources/tester/test_315_ford_chute_overflow.py b/resources/tester/test_315_ford_chute_overflow.py new file mode 100644 index 000000000..9dea00363 --- /dev/null +++ b/resources/tester/test_315_ford_chute_overflow.py @@ -0,0 +1,161 @@ +"""315 물넘이포장 횡단 — 도수로 · 돌붙임 길이(성토) · 월류 높이선 성토 쪽 연장(요구 211-1). + +못박는 것 + - 315-1 길이 없음 = 성토는 원지반까지(그대로) · 길이 > 성토 사면 = 사면 끝에서 원지반 따라 이어 그 길이 + (이은 조각까지 한 선 · 옛 흠 = 첫 조각만 그림) · 길이 < 사면 = 사면 따라 그 길이 · 절토(늘인 노면 끝이 + 원지반) = 그대로(길이 없음 = 안 그림 · 길이 = 원지반 따라). + - 315-2 월류 높이선 — 절토 쪽 = 노면 끝 위 월류 높이(그대로) · 성토 쪽 = 성토 사면선을 노면 끝 너머 위 · + 안쪽으로 점선 연장해 월류 높이선과 만나는 자리에서 끝(같은 점선 양식 한 벌). + +진짜 TS 는 `helper_315_ford_chute_overflow.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from test_b06_cross_design_mirror import _dip_ground # noqa: E402 + +KW = { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + "standard": STANDARD_CROSS_SECTION, +} +DEPTH = 0.3 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _fill_samples() -> list[dict]: + """좌 = 절토(원지반이 노면 위) · 우(낮은 쪽) = 성토 — 원지반 100 + 0.35·o.""" + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s315") / "in.json" + payload = { + "fillSamples": _fill_samples(), + "fillDesign": compute_cross_design(_fill_samples(), 100.4, **KW), + "dipSamples": _dip_ground(), + "dipDesign": compute_cross_design(_dip_ground(), 100.4, **KW), + } + source.write_text(json.dumps(payload), encoding="utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_315_ford_chute_overflow.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _length(line: list[dict]) -> float: + return sum( + math.hypot(b["offset"] - a["offset"], b["elevation"] - a["elevation"]) + for a, b in zip(line, line[1:]) + ) + + +def _on_ground(p: dict) -> bool: + return p["elevation"] == pytest.approx(100.0 + 0.35 * p["offset"], abs=1e-3) + + +def test_길이_없음_성토는_원지반까지(ts): + line = ts["fillNone"] + assert line[0]["offset"] == pytest.approx(-2.0) # 낮은 쪽(우) 노견 끝 + assert _on_ground(line[-1]) and not _on_ground(line[0]) + assert all(b["elevation"] < a["elevation"] for a, b in zip(line, line[1:])) + + +def test_성토보다_긴_길이는_원지반_따라_이어_그_길이(ts): + slope = _length(ts["fillNone"]) + line = ts["fillLong"] + assert slope < 6 + assert _length(line) == pytest.approx(6.0, abs=1e-6) # 옛 흠 = 사면 길이에서 멈춤 + toe = ts["fillNone"][-1] + beyond = [p for p in line if p["offset"] < toe["offset"] - 1e-6] + assert beyond and all(_on_ground(p) for p in beyond) # 사면 끝 너머 = 원지반 위 + assert line[: len(ts["fillNone"])] == ts["fillNone"] # 앞은 사면 그대로 · 한 선 + + +def test_짧은_길이는_사면_따라_그_길이(ts): + line = ts["fillShort"] + assert _length(line) == pytest.approx(1.0, abs=1e-6) + assert not any(_on_ground(p) for p in line) + + +def test_절토_쪽_그대로(ts): + assert ts["dipNone"] == [] # 늘인 노면 끝이 원지반 · 길이 없음 = 안 그림 + line = ts["dipLong"] # 길이 = 늘인 끝에서 원지반 따라 그 길이 + assert line[0]["offset"] == pytest.approx(ts["dipEdges"]["right"]["offset_m"], abs=1e-6) + assert _length(line) == pytest.approx(2.0, abs=1e-6) + + +def test_그린_띠도_긴_길이(ts): + nums = [float(v) for pair in ts["bandPoints"].split() for v in pair.split(",")] + offsets = nums[0::2] + assert min(offsets) < ts["fillNone"][-1]["offset"] - 1 # 띠가 사면 끝 너머 원지반까지 + + +def test_월류선_절토_쪽은_노면_끝_위(ts): + edges = ts["fillEdges"] + first = ts["fillOverflow"][0] # 좌 = 절토 + assert first["offset"] == pytest.approx(edges["left"]["offset_m"]) + assert first["elevation"] == pytest.approx(edges["left"]["elevation_m"] + DEPTH) + dip = ts["dipOverflow"] # 양측 절토 = 두 점 그대로 + assert len(dip) == 2 + assert dip[-1]["offset"] == pytest.approx(ts["dipEdges"]["right"]["offset_m"]) + assert dip[-1]["elevation"] == pytest.approx(ts["dipEdges"]["right"]["elevation_m"] + DEPTH) + + +def test_월류선_성토_쪽은_성토선_점선_연장과_만남(ts): + edges = ts["fillEdges"] + left, right = edges["left"], edges["right"] + *_, meet, end = ts["fillOverflow"] + # 끝 = 우 노면 끝(성토 사면 시작) · 그 앞 교점 = 노면 안쪽 + assert end == pytest.approx({"offset": right["offset_m"], "elevation": right["elevation_m"]}) + assert right["offset_m"] < meet["offset"] < left["offset_m"] + # 교점은 월류 높이선 위 + road = (left["elevation_m"] - right["elevation_m"]) / (left["offset_m"] - right["offset_m"]) + over = right["elevation_m"] + DEPTH + road * (meet["offset"] - right["offset_m"]) + assert meet["elevation"] == pytest.approx(over, abs=1e-9) + # 교점 → 노면 끝 = 성토 사면과 같은 물매(사면선의 연장) + slope = ts["fillNone"] + fill = (slope[0]["elevation"] - slope[1]["elevation"]) / ( + slope[0]["offset"] - slope[1]["offset"] + ) + ext = (meet["elevation"] - end["elevation"]) / (meet["offset"] - end["offset"]) + assert ext == pytest.approx(fill, abs=1e-9) + # 그린 점선 = 같은 점(같은 양식 한 벌) + assert ts["deckPoints"].split()[-2:] == [ + f"{meet['offset']},{meet['elevation']}", + f"{end['offset']},{end['elevation']}", + ] From fbaa5b4838f66f8edd012ea21a3535b4f0ed3edb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:54:27 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(b05=C2=B7b06):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=92=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=E2=80=94=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=B8=A1=EA=B5=AC=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EC=B8=A1=EC=A0=90=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80=20(314=20=C2=B7=20=EC=9A=94=EA=B5=AC=20210=20=C2=B7?= =?UTF-8?q?=20211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 낡음 판정(B05 · B06 공용 isStaleSection)에 서버 _enforce_stored_designs 짝 셋: 포장 · 기울기(물넘이 · 맹암거) · 세월교 노면 하강 - 구조물 바뀐 뒤 횡단 다시 받으면 낡은 측점 다시 셈(refreshDetailForStructures) - 다시 셈의 세월교 노면 하강 = 그 측점에 얹힌 세월교 제원(옛 저장값 안 이음) - 물넘이포장: 옮기거나 지운 옛 자리 걷고 새 자리 · 걸친 기존 측점에 얹음(빈 관 목록도) - 월류 높이 기다림: 빈 글자도 빈칸(채우는 쪽과 같은 규칙) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- A00_Common/design/design_view_cross.ts | 3 +- .../B05_Profile_UI_Drainage_Ford_Fill.ts | 4 +- B06_Section/B06_Section_Cross_Refresh.ts | 5 +- B06_Section/B06_Section_Section_Store.ts | 13 ++- B06_Section/B06_Section_UI_Section_Common.ts | 16 ++++ .../tester/helper_314_stale_structure.cjs | 63 +++++++++++++++ resources/tester/test_314_stale_structure.py | 81 +++++++++++++++++++ 7 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_314_stale_structure.cjs create mode 100644 resources/tester/test_314_stale_structure.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index e05df96aa..f94e999ee 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -235,6 +235,7 @@ export async function mountCrossView( ); } renderSectionDetail(); + void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314) } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); @@ -562,7 +563,7 @@ export async function mountCrossView( // 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38) if (!readStructurePick(projectId)) releaseCross(); void structuresPanel.load().then(restoreStructurePick); - void refreshDetailForStructures().then(() => reconcileStaleDesigns()); + void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index 5a0b67e48..5beac148f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -32,7 +32,9 @@ export function waitsFordHeight( facility: string | undefined, options?: Record, ): boolean { - return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined; + // 빈 글자도 빈칸 — 채우는 쪽(`fillFordHeights`)과 같은 규칙(314) + const height = options?.ford_height_m; + return !!facility && facility in FORD_KINDS && (height === undefined || height === ""); } /** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌. diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95b11ad4d..83ef1a82f 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -377,8 +377,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ? design.ditch_choice : null, ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, - // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. - surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0, + // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) + surfaceDropM: + Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). planRadiusM: section.plan_radius_m ?? null, curveOuterSide: diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 4af1cb327..fdbb34314 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -132,10 +132,17 @@ const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => */ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { const pipes = readPendingPipes(projectId); - if (!pipes?.length) return; + if (!pipes) return; const sections = detail.cross_sections as unknown as Array>; const near = (spec: Record, section: Record, at: number) => Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; + // 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314) + for (const section of sections) { + const spec = section.ford_pavement as Record | undefined; + const kept = (pipe: (typeof pipes)[number]) => + pipe.facility === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m); + if (spec && !pipes.some(kept)) delete section.ford_pavement; + } /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => key === facilityKey(pipe.facility ?? "pipe") && @@ -147,7 +154,9 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; }), ); - return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec)); + // 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314) + if (!here.length) return pipe.facility === "ford_pavement"; + return !here.some(([key, spec]) => fits(pipe, key, spec)); }); if (!stale.length) return; const types = await fetchStructureTypes().catch(() => []); diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 105bcddb9..0ffbf01ad 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -101,6 +101,11 @@ export function hasStaleDesigns( interface StaleCandidate { chainage_m: number; + // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) + pavement?: unknown[] | null; + ford_pavement?: unknown; + underdrain?: unknown; + ford?: { overflow_depth_m?: number } | null; design?: { design_elevation_m: number; geometry_preset?: string; @@ -108,6 +113,8 @@ interface StaleCandidate { surface_drop_m?: number; section_mode?: string; ditch_enabled?: boolean; + paved?: boolean; + tilted?: boolean; } | null; } @@ -124,6 +131,15 @@ function isStaleSection( if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; // 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈. if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true; + // 얹힌 구조물 제원과 저장분 설계값이 다르면 낡음 — 서버 `_enforce_stored_designs` 짝(314). + // 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 물넘이포장 · 맹암거 · 세월교 · 노면공이 [저장] 전에도 + // 측구 없음 · 포장 · 노면 하강으로 다시 셈(포장 = `pavedBySpecs` · 기울기 = `Cross_Refresh` 와 같은 규칙). + const paved = Boolean(section.pavement?.length) || Boolean(section.ford_pavement); + if (Boolean(design.paved) !== paved) return true; + if (Boolean(design.tilted) !== Boolean(section.ford_pavement || section.underdrain)) return true; + const drop = + Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0; + if (Math.abs((design.surface_drop_m ?? 0) - drop) > 1e-6) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/resources/tester/helper_314_stale_structure.cjs b/resources/tester/helper_314_stale_structure.cjs new file mode 100644 index 000000000..a660a2372 --- /dev/null +++ b/resources/tester/helper_314_stale_structure.cjs @@ -0,0 +1,63 @@ +/* PLAN 314 단위검증 헬퍼 — 저장분 횡단 값(포장 · 기울기 · 노면 하강) 낡음 판정 · 월류 높이 기다림. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_314_stale_structure.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +const { hasStaleDesigns } = load( + path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), +); +const { waitsFordHeight } = load( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Ford_Fill.ts"), +); + +const stale = (section) => + hasStaleDesigns({ longitudinal: { design_profiles: [] }, cross_sections: [section] }); +const design = (extra) => ({ design_elevation_m: 100, ...extra }); + +const out = { + ford_untilted: stale({ chainage_m: 10, ford_pavement: {}, design: design({ paved: true }) }), + ford_tilted: stale({ + chainage_m: 10, + ford_pavement: {}, + design: design({ paved: true, tilted: true }), + }), + left_tilted: stale({ chainage_m: 10, design: design({ tilted: true }) }), + underdrain_tilted: stale({ chainage_m: 10, underdrain: {}, design: design({ tilted: true }) }), + pavement_unpaved: stale({ chainage_m: 10, pavement: [{}], design: design({ paved: false }) }), + ford_drop_missing: stale({ chainage_m: 10, ford: { overflow_depth_m: 0.3 }, design: design({}) }), + ford_drop_kept: stale({ + chainage_m: 10, + ford: { overflow_depth_m: 0.3 }, + design: design({ surface_drop_m: 0.3 }), + }), + plain: stale({ chainage_m: 10, design: design({}) }), + waits: { + pave_empty: waitsFordHeight("ford_pavement", {}), + pave_blank: waitsFordHeight("ford_pavement", { ford_height_m: "" }), + pave_set: waitsFordHeight("ford_pavement", { ford_height_m: 0.5 }), + bridge_none: waitsFordHeight("ford_bridge", undefined), + pipe: waitsFordHeight("pipe", {}), + }, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py new file mode 100644 index 000000000..0b680b005 --- /dev/null +++ b/resources/tester/test_314_stale_structure.py @@ -0,0 +1,81 @@ +"""PLAN 314 — 물넘이포장 · 맹암거 후속(요구 210 · 211). + +- 314-1 · 314-2 · 314-4 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 구조물 자리 = 저장분 횡단 값(포장 · 기울기 · + 세월교 노면 하강)과 얹힌 제원이 다르면 낡음 → 다시 셈(서버 `_enforce_stored_designs` 짝) · + 물넘이포장 옛 자리 걷고 새 자리(걸친 기존 측점까지) 얹음 +- 314-3 월류 높이 기다림 = 빈 글자도 빈칸(채우는 쪽과 같은 규칙) + +실제 TS 를 `helper_314_stale_structure.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def result() -> dict: + out = subprocess.run( + ["node", os.path.join(HERE, "helper_314_stale_structure.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_tilt_mismatch_is_stale(result: dict) -> None: + # 물넘이포장 · 맹암거 자리인데 저장분이 안 기울었음(측구 있음) = 낡음 · 맞으면 그대로 + assert result["ford_untilted"] is True + assert result["ford_tilted"] is False + assert result["underdrain_tilted"] is False + # 구조물이 떠났는데 저장분이 아직 기울었음(옮김 · 종류 바꿈) = 낡음 + assert result["left_tilted"] is True + + +def test_paved_and_surface_drop_mismatch_is_stale(result: dict) -> None: + assert result["pavement_unpaved"] is True + assert result["ford_drop_missing"] is True + assert result["ford_drop_kept"] is False + assert result["plain"] is False + + +def test_ford_height_wait_treats_blank_as_empty(result: dict) -> None: + waits = result["waits"] + assert waits["pave_empty"] is True + assert waits["pave_blank"] is True + assert waits["pave_set"] is False + assert waits["bridge_none"] is True + assert waits["pipe"] is False + + +def test_structure_change_reconciles_and_ford_reattach() -> None: + view = _read("A00_Common", "design", "design_view_cross.ts") + body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0] + assert "void reconcileStaleDesigns();" in body + refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + assert "Number(section.ford?.overflow_depth_m) > 0" in refresh + store = _read("B06_Section", "B06_Section_Section_Store.ts") + kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0] + assert "if (!pipes) return;" in kinds + assert "delete section.ford_pavement" in kinds + assert 'if (!here.length) return pipe.facility === "ford_pavement";' in kinds