From 0cd3c6489fdd8e8333f7cb19bd7241fca4350bbd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:46:21 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20366-2?= =?UTF-8?q?=20=EB=8F=84=EA=B5=AC=EC=A4=84=20=EC=B0=A8=EB=A1=80=20[=3F]=20|?= =?UTF-8?q?=20=E2=86=B6=20=E2=86=B7=20|=203D=20|=20=ED=9A=8C=EC=A0=84=20?= =?UTF-8?q?=C2=B7=201m=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=C2=B7=20=EC=A0=88/?= =?UTF-8?q?=EC=84=B1=ED=86=A0=EC=84=A0=20=C2=B7=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=9E=AC=EA=B8=B0=20|=20=EC=B8=A1=EC=A0=90=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=20=C2=B7=20=EB=B6=84=EC=84=9D=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EB=B6=84=EC=84=A0=20=EB=84=B7=20=C2=B7=20[?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4]=20=EC=A7=80=EC=9B=80(=EC=B2=98=EC=9D=8C=20?= =?UTF-8?q?=EC=97=B4=20=EB=95=8C=20=EB=A7=9E=EC=B6=A4=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C)=20=C2=B7=20=ED=9A=8C=EC=A0=84=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=EB=8F=84=EA=B5=AC=EC=A4=84=EB=A1=9C?= 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_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 8 --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 51 +++++++++---------- .../B05_Profile_UI_Style_RouteEdit.css | 4 +- .../tester/test_345_5b_plan_visible_area.py | 10 ++-- .../tester/test_b05_routeedit_initial.py | 33 ++++++++++-- 5 files changed, 59 insertions(+), 47 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3d05ffc66..f08e4e559 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -56,7 +56,6 @@ import { captureView, createViewMemory, fitCanvas, - fitView, openingView, restoreSavedView, screenToMetric, @@ -318,13 +317,6 @@ export async function mountRouteEditor( rotation.set(opening.rotationRad); viewReady = true; } - // [맞춤] — 처음 그림과 같은 등고 영역 맞춤(회전 0 · 349-0). 셈은 `_View` 몫. - overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => { - if (!meta || view.width <= 0 || !planned.length) return; - view = fitView(meta, view, planned, contours?.layer ?? null); - rotation.set(0); - draw(); - }); const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 211b0658a..5e8ed273a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -11,6 +11,7 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; * **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추 * (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위). + * 도구줄 차례(366-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · * 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지 @@ -60,8 +61,31 @@ export function createRouteEditChrome(
+ + + + + + + + + + + + - - - - - - - -
-
노드 끌기 = 옮기기노드 클릭 = R 라벨 선 두 번 클릭 = 노드 추가노드 오른쪽 클릭 = 삭제 @@ -97,12 +102,6 @@ export function createRouteEditChrome( 가운데(휠) 버튼 끌기 = 지도 이동휠 = 확대
-
- - -
{design_block}

- 종단설계 화면 열기 + 노선설계 화면 열기

로그인이 필요한 경우 인증 후 같은 화면으로 이동합니다. diff --git a/resources/help/B05/00_설명서.md b/resources/help/B05/00_설명서.md index 89349f545..31c6e3a65 100644 --- a/resources/help/B05/00_설명서.md +++ b/resources/help/B05/00_설명서.md @@ -1,4 +1,4 @@ -# 종단설계 설명서 +# 노선설계 설명서 ## 이 화면에서 하는 일 diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 14d3a32da..17faa20ad 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -125,7 +125,7 @@ const LOCALES = { B06_Profile_Btn_Save: ["저장", "Save"], B06_Profile_Btn_Confirm: ["확정", "Confirm"], Design_Btn_ToCross: ["횡단 보기", "Cross view"], - Design_Btn_ToProfile: ["종단 보기", "Profile view"], + Design_Btn_ToProfile: ["노선 보기", "Route view"], }; const STUBS = { "@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 }, diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index 7618fb22d..b2bcb5af3 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -95,7 +95,7 @@ def test_아래_단추는_네_개_보기_전환_글자만_따라감(results): # [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음). assert views[name]["dockDisabled"] == [False, False, False, False] assert views["profile"]["dockLabels"][3] == "횡단 보기" - assert views["cross"]["dockLabels"][3] == "종단 보기" + assert views["cross"]["dockLabels"][3] == "노선 보기" assert views["back"]["dockLabels"][3] == "횡단 보기" diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3470286fa..639b14279 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -108,7 +108,7 @@ export const ui_locales_b2 = { ], /* --- B05_Profile 경로 설계 --- */ - B05_Route_Title: ["종단설계", "Profile Design"], + B05_Route_Title: ["노선설계", "Route Design"], B05_Route_Group_Points: ["경로 제어점", "Route Control Points"], B05_Route_Point_BP: ["시점 (BP)", "Begin (BP)"], B05_Route_Point_EP: ["종점 (EP)", "End (EP)"], @@ -146,7 +146,7 @@ export const ui_locales_b2 = { B05_Route_Btn_GoCross: ["횡단 이동", "Go to Cross"], /* 좌측 패널 아래 단추 [보기 전환] — 가려는 쪽 이름(계획서 24-2b). */ Design_Btn_ToCross: ["횡단 보기", "Cross view"], - Design_Btn_ToProfile: ["종단 보기", "Profile view"], + Design_Btn_ToProfile: ["노선 보기", "Route view"], B05_Route_TempSave_Success: ["변경 내용을 저장했습니다.", "Changes saved."], B05_Route_TempSave_Failed: ["저장에 실패했습니다.", "Failed to save."], B05_Route_Reset_Confirm: [ @@ -800,7 +800,7 @@ export const ui_locales_b2 = { "Failed to load the material summary.", ], B08_Quantity_Haul_Missing: [ - "운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.", + "운반계획이 아직 없습니다. 노선설계에서 [확정]을 누르면 만들어집니다.", "No haul plan yet. Press [Confirm] on the profile design to build it.", ], B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index ddad8eb97..b7eb7c462 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -80,7 +80,7 @@ export const ui_locales_common = { * 워크플로우 공통 (B04~B09 상단 진행 단계 라벨) * ------------------------------------------------------------------------ */ WF_Step_Surface: ["전처리", "Preprocess"], - WF_Step_Route: ["종단설계", "Profile Design"], + WF_Step_Route: ["노선설계", "Route Design"], WF_Step_ProfileCross: ["횡단설계", "Cross Design"], WF_Step_DesignDetail: ["상세설계", "Detail Design"], WF_Step_Quantity: ["수량산출", "Quantity"], From 6e16817611a360e5e18a55a0ff254c66d4c22c06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:49:16 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20366-1?= =?UTF-8?q?=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=EC=9E=90=EB=AC=BC=EC=87=A0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EB=95=8C=EB=A7=8C=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=EB=82=98=EA=B8=B0=EB=A7=8C=20=ED=95=98=EB=A9=B4=20=EC=95=88=20?= =?UTF-8?q?=EB=9C=B8=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EA=B0=80=EB=8B=A5?= =?UTF-8?q?=EC=9D=84=20=EB=88=84=EB=A5=B8=20=EC=9E=90=EB=A6=AC=20=EC=98=86?= =?UTF-8?q?=20=C2=B7=20=EC=9E=A0=EA=B9=80=EC=9D=B4=EB=A9=B4=20=EC=9E=A0?= =?UTF-8?q?=EA=B7=BC=20=EB=86=92=EC=9D=B4=20=EC=95=84=EB=AC=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8B=A5=20=EB=88=84=EB=A5=BC=20=EB=95=8C=20=ED=92=80=EA=B8=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=8B=A4=EC=9D=8C=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=C2=B7=20=ED=9C=A0=20=C2=B7=20=EB=96=A0=EB=82=A8=20?= =?UTF-8?q?0.7=20s=20=EC=97=90=20=EC=88=A8=EA=B9=80?= 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_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 +- .../B05_Profile_UI_RouteEdit_ContourLock.ts | 61 +++++++++++-------- B05_Profile/logic/B05_Profile_ContourLock.ts | 10 +-- resources/tester/test_353_2_contour_lock.py | 29 ++++++--- 4 files changed, 61 insertions(+), 41 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index f08e4e559..2a9cc77b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -194,7 +194,7 @@ export async function mountRouteEditor( /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ const contourLock = createContourLock( canvas, - () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + () => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour }, (index) => ((pickedContour = index), draw()), ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts index e8e52b173..146717a3c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_ContourLock.ts - * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * 평면 등고선 자물쇠(353-2) — 가닥을 고른 때만 누른 자리 옆에 뜨는 작은 단추(366-1 · 지나기만 = 안 뜸). * - * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 잠근 높이 가닥을 다시 + * 누르면 풀기 단추 · 다음 클릭 · 휠 · 떠남(0.7 s)에 숨김. * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). - * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * 규칙은 `logic/B05_Profile_ContourLock` · 누름 판정은 집기와 같은 `hitPreparedLayer`. * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; @@ -22,8 +23,10 @@ import { import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; /** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ -const HOVER_HIT_PX = 6; -/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIT_PX = 6; +/** 누름 ~ 뗌 사이 이만큼(px) 넘게 움직이면 끌기 — 고르기 아님. */ +const MOVE_PX = 4; +/** 커서가 지도를 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ const HIDE_DELAY_MS = 700; /** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ const OFFSET_PX = 12; @@ -33,12 +36,14 @@ const LOCK_SVG = ``; -/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +/** 누름 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ export interface ContourLockScene { layer: PreparedLayer; view: ViewState; - /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 안 잡힘. */ stepM: number; + /** 고른 가닥 번호(없으면 -1) — 안 잠김이면 이 가닥을 누를 때만 뜸. */ + picked: number; rotation: { unrotate: (px: number, py: number) => [number, number] }; } @@ -68,8 +73,8 @@ export function createContourLock( /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ let target = -1; let hideTimer = 0; - let frame = 0; - let last: PointerEvent | null = null; + let downX = 0; + let downY = 0; const levelOf = (index: number): number | null => index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; @@ -96,17 +101,24 @@ export function createContourLock( hideTimer = 0; }; - const hover = (event: PointerEvent): void => { + const hide = (): void => { + keep(); + button.hidden = true; + }; + + /** 누름을 뗀 자리 — 고른 가닥(잠김이면 잠근 높이 가닥)일 때만 그 옆에 띄움. */ + const showAt = (event: PointerEvent): void => { const now = scene(); - // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 - if (!now || event.buttons !== 0) return hideLater(); + // 끌기 · 화면 이동 · 재기(Shift)는 고르기가 아님 + const moved = Math.hypot(event.clientX - downX, event.clientY - downY) > MOVE_PX; + if (!now || event.button !== 0 || event.shiftKey || moved) return; const rect = canvas.getBoundingClientRect(); const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - const hitPx = pickPx(HOVER_HIT_PX); + const hitPx = pickPx(HIT_PX); const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); - const state = lockButtonState(levelOf(index), lockedLevel); - if (!state.visible) return hideLater(); - keep(); + if (!lockButtonState(levelOf(index), lockedLevel, index >= 0 && index === now.picked).visible) { + return; + } target = index; const origin = wrap.getBoundingClientRect(); button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; @@ -115,17 +127,14 @@ export function createContourLock( paint(); }; - // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. - canvas.addEventListener("pointermove", (event) => { - last = event; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - if (last) hover(last); - }); + // 다음 클릭 · 휠에 숨김 — 집기(`_Pointer`)보다 먼저 달려 있어 고른 뒤 pointerup 이 다시 띄움. + canvas.addEventListener("pointerdown", (event) => { + downX = event.clientX; + downY = event.clientY; + hide(); }); - // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. - canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerup", showAt); + canvas.addEventListener("wheel", hide, { passive: true }); canvas.addEventListener("pointerleave", hideLater); button.addEventListener("pointerenter", keep); button.addEventListener("pointerleave", hideLater); diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts index 85fffd545..62242854a 100644 --- a/B05_Profile/logic/B05_Profile_ContourLock.ts +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -21,13 +21,15 @@ export function featureOfLevel(levels: ContourLevels, level: number | null): num return levels.findIndex((value) => value === level); } -/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +/** 누른 가닥 높이 · 잠근 높이 · 고른 가닥인지 → 단추(366-1) — 안 잠김 = 높이 있는 고른 가닥을 + * 누를 때만 · 잠김 = 잠근 높이의 아무 가닥을 누를 때(풀기). 지나가기만 하면 안 뜸. */ export function lockButtonState( - hoverLevel: number | null, + hitLevel: number | null, lockedLevel: number | null, + selected: boolean, ): LockButtonState { - if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; - return { visible: hoverLevel !== null, pressed: false }; + if (lockedLevel !== null) return { visible: hitLevel === lockedLevel, pressed: true }; + return { visible: hitLevel !== null && selected, pressed: false }; } /** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py index 9dc0a4c58..38ba30392 100644 --- a/resources/tester/test_353_2_contour_lock.py +++ b/resources/tester/test_353_2_contour_lock.py @@ -26,10 +26,11 @@ new Function("exports", "module", js)(m.exports, m); const L = m.exports; const levels = [101, 102, 105, 102, null]; console.log(JSON.stringify({ - hoverFree: L.lockButtonState(102, null), - hoverNone: L.lockButtonState(null, null), - hoverLocked: L.lockButtonState(102, 102), - hoverOther: L.lockButtonState(105, 102), + hitPicked: L.lockButtonState(102, null, true), + hitOnly: L.lockButtonState(102, null, false), + hitNone: L.lockButtonState(null, null, true), + hitLocked: L.lockButtonState(102, 102, false), + hitOther: L.lockButtonState(105, 102, true), lock: L.toggleLock(null, 102), unlock: L.toggleLock(102, 105), pickFree: L.canPickContour(null), @@ -50,14 +51,16 @@ def got() -> dict: return json.loads(run.stdout) -def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None: - assert got["hoverFree"] == {"visible": True, "pressed": False} - assert got["hoverNone"]["visible"] is False +def test_안_잠김_단추는_고른_가닥을_누를_때만(got) -> None: + """366-1 — 지나기만 · 고르지 않은 가닥 = 안 뜸.""" + assert got["hitPicked"] == {"visible": True, "pressed": False} + assert got["hitOnly"]["visible"] is False + assert got["hitNone"]["visible"] is False -def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None: - assert got["hoverLocked"] == {"visible": True, "pressed": True} - assert got["hoverOther"]["visible"] is False +def test_잠김_단추는_잠근_높이_아무_가닥을_누를_때_풀기(got) -> None: + assert got["hitLocked"] == {"visible": True, "pressed": True} + assert got["hitOther"]["visible"] is False def test_누르면_잠금_다시_누르면_풂(got) -> None: @@ -79,3 +82,9 @@ def test_편집기_이음() -> None: lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8") assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만 + # 366-1 — 지나기(pointermove)로 안 띄움 · 뗀 자리 · 다음 클릭 · 휠 · 떠남에 숨김 + assert '"pointermove"' not in lock + assert 'canvas.addEventListener("pointerup", showAt);' in lock + assert 'canvas.addEventListener("wheel", hide' in lock + assert 'canvas.addEventListener("pointerleave", hideLater);' in lock + assert "picked: pickedContour" in edit From ddc6fe23112d62b0fe63c98ac80e6e1c4a2b7aed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:49:27 +0900 Subject: [PATCH 06/30] =?UTF-8?q?fix(B05):=20367-2=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20[=EC=B0=BD=20=EB=B6=84=ED=95=A0]=20?= =?UTF-8?q?=ED=8E=BC=EC=B9=9C=20=EB=95=8C=EB=A7=8C=20=C2=B7=20=EC=A0=91?= =?UTF-8?q?=EC=9D=8C=20=3D=20=EB=86=92=EC=9D=B4=200=20=C2=B7=20=EC=86=90?= =?UTF-8?q?=EC=9E=A1=EC=9D=B4=EB=A7=8C=20=ED=8C=90=20=EC=9C=84=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EC=97=90=20=EB=A7=A4=EB=8B=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 접은 판 높이 0 · 머리줄 bottom 100%(배수유역 접힘 꼴) - 접은 때 [창 분할] 숨김 · 새 창으로 뗀 [창 결합] 그대로 - 시험 354_1 확인 더함 · 224_1c 글 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B05_Profile/B05_Profile_UI_Profile_MassHaul.ts | 6 +++--- B05_Profile/B05_Profile_UI_Style_MassHaul.css | 14 +++++++++----- resources/tester/test_224_1c_subpanel_popout.py | 2 +- resources/tester/test_354_1_masshaul_own_panel.py | 14 +++++++++++++- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index c544caf0d..6a4b5b778 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -145,7 +145,7 @@ export interface RouteMassHaulPanel { overlay: HTMLElement; isOpen(): boolean; setContext(next: RouteMassHaulContext | null): void; - /** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */ + /** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; } @@ -190,7 +190,7 @@ export function createRouteMassHaulPanel( handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); - // 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1). + // 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2). const head = document.createElement("div"); head.className = "b05-profile__masshaul-head"; head.append(handle); @@ -249,7 +249,7 @@ export function createRouteMassHaulPanel( // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); - // 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음) + // 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음) overlay.classList.toggle("is-collapsed", !next); onChanged(); } diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 2af4459c8..1d458de7c 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -88,8 +88,9 @@ pointer-events: auto; } +/* 접음 = 판 높이 0 · 손잡이만 판 위 경계에 매달림(배수유역 접힘 꼴 · 367-2). */ .b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) { - height: auto; + height: 0; } .b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) { @@ -97,10 +98,13 @@ } .b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head { - position: relative; - top: 0; - left: 0; - padding: 2px var(--spacing-8); + top: auto; + bottom: 100%; +} + +/* [창 분할]은 펼친 때만(367-2) — 새 창으로 뗀 [창 결합]은 그대로. */ +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout { + display: none; } /* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */ diff --git a/resources/tester/test_224_1c_subpanel_popout.py b/resources/tester/test_224_1c_subpanel_popout.py index eb8f2f684..2b8632bb1 100644 --- a/resources/tester/test_224_1c_subpanel_popout.py +++ b/resources/tester/test_224_1c_subpanel_popout.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창. -- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. +- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. - 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐). - 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome. - 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝. diff --git a/resources/tester/test_354_1_masshaul_own_panel.py b/resources/tester/test_354_1_masshaul_own_panel.py index ae4531f89..63c9d3601 100644 --- a/resources/tester/test_354_1_masshaul_own_panel.py +++ b/resources/tester/test_354_1_masshaul_own_panel.py @@ -3,7 +3,7 @@ - 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할] (`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나. -- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음). +- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2). - 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤). """ @@ -57,6 +57,18 @@ def test_흐름_칸_자기_높이_접으면_머리줄(): ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)", ) assert "display: none;" in hide + # 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로) + folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)") + assert "height: 0;" in folded + head = css_block( + css, + ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head", + ) + assert "top: auto;" in head and "bottom: 100%;" in head + assert "display: none;" in css_block( + css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout" + ) + assert ".is-popout .b05-subpanel__popout" not in css assert "display: none;" in css_block( css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay" ) From 84876b747be6e7c2fbaca261566120179d6a00a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:53:36 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat(B05):=20367-3=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=90=EC=9C=A0=EC=A7=80=EC=A0=95=20=EC=95=88=20=ED=95=9C=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=EC=94=A9=20=C2=B7=20=ED=81=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20=EB=94=B0=EB=A1=9C=20=C2=B7=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=A7=89=ED=9E=88=EB=A9=B4=20=EC=97=B4=EB=A6=B0=20?= =?UTF-8?q?=EC=B1=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_profile_free past/future(상태 바뀐 클릭만 · 새 클릭이 future 비움) · undo/redo/canUndo/canRedo · reset 비움 · Backspace = undo - 자유지정 동안 ↶ ↷ 단추 · Ctrl+Z · Ctrl+Shift+Z = 자유지정 쪽만(되돌릴 것 없어도 큰 이력 안 감) - applyEdits 성공 여부 돌려줌 · 막히면(기울기 · 흙덮기) 자유지정 열린 채 - 시험 helper_215_1 ⑥ ⑦ · test_215_1 · 345_3 창 넓힘 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_profile.ts | 11 +-- .../design/logic/design_profile_free.ts | 57 ++++++++++---- .../B05_Profile_UI_Profile_Panel_Tools.ts | 19 +++-- .../tester/helper_215_1_free_profile.cjs | 75 ++++++++++++++++++- resources/tester/test_215_1_free_profile.py | 32 ++++++++ resources/tester/test_345_3_route_body.py | 4 +- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index faa2750cb..6e5986cc6 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { refresh: () => renderBalance(), }); - /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ - function applyEdits(next: AlignmentEdits): void { - if (!base || !alignment) return; + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */ + function applyEdits(next: AlignmentEdits): boolean { + if (!base || !alignment) return false; const candidate = buildAlignment(base, next); if ( base.policy.grade_violation_policy === "block" && @@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) { `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, "error", ); - return; + return false; } // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). - if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false; store.replace(next); history.record(); + return true; } /** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */ diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index 4bb03c965..c658fca63 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -4,7 +4,8 @@ * * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 - * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기. + * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) = + * 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음. * * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 * [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L @@ -181,8 +182,8 @@ export interface FreeDrawDeps { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */ - apply: (next: AlignmentEdits) => void; + /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */ + apply: (next: AlignmentEdits) => boolean | void; /** 모드를 끈다(적용 뒤 · Esc). */ close: () => void; /** 단계 · 안내가 바뀜. */ @@ -198,6 +199,11 @@ export interface FreeDraw { hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void; /** Enter · Escape · Backspace — 먹었으면 true. */ key: (key: string) => boolean; + /** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */ + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; marks: () => FreeMarks; hint: () => string; } @@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { let anchor: number | null = null; let points: FreeVertex[] = []; let target: FreeVertex | null = null; + /** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */ + interface Snap { + phase: FreePhase; + pending: number | null; + range: [number, number] | null; + anchor: number | null; + points: FreeVertex[]; + } + let past: Snap[] = []; + let future: Snap[] = []; + const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] }); + function step(from: Snap[], to: Snap[]): void { + const state = from.pop(); + if (!state) return; + to.push(snap()); + ({ phase, pending, range, anchor, points } = state); + target = null; + deps.changed(); + } function reset(): void { phase = "range"; @@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { anchor = null; points = []; target = null; + past = []; + future = []; } /** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */ @@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { if (!base || end === null || z === null) return; const vertices = [...path(), { chainage_m: end, elevation_m: z }]; const next = buildFreeEdits(base, deps.edits(), vertices); + // 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3) + if (deps.apply(next) === false) return; reset(); - deps.apply(next); deps.close(); } @@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { phase: () => phase, reset, click(chainageM, elevationM, stations) { + const before = snap(); if (phase === "range") { const station = snapStation(stations, chainageM); if (station === null) return true; @@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { } points.push(next); } + if (JSON.stringify(snap()) !== JSON.stringify(before)) { + past.push(before); + future = []; + } target = null; deps.changed(); return true; @@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { return true; } if (key === "Backspace") { - if (phase !== "drawing") return false; - if (points.length) points.pop(); - else { - anchor = null; - phase = "armed"; - } - target = null; - deps.changed(); + if (!past.length) return false; + step(past, future); return true; } return false; }, + undo: () => step(past, future), + redo: () => step(future, past), + canUndo: () => past.length > 0, + canRedo: () => future.length > 0, marks() { const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY; const dots = path(); @@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`; } if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; - return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)"; + return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)"; }, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 12bc11376..6372ff738 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -49,7 +49,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - applyEdits: (next: AlignmentEdits) => void; + /** false = 막힘(기울기 · 흙덮기). */ + applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ selectedStationId: () => string | null; /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ @@ -157,10 +158,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { history.record(); }, canResetToSaved: ctx.canRestoreSaved, - onUndo: () => inner.undo(), - onRedo: () => inner.redo(), - canUndo: () => inner.canUndo(), - canRedo: () => inner.canRedo(), + // 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림. + onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()), + onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()), + canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()), + canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()), hasStraightRuns: () => { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; @@ -327,6 +329,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로. if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) { event.preventDefault(); + // 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3) + if (tools.mode() === "free") { + if (event.shiftKey || isCtrlKey(event, "y")) free.redo(); + else free.undo(); + freeView.redraw(); + return; + } flushHold(); if (event.shiftKey || isCtrlKey(event, "y")) history.redo(); else history.undo(); diff --git a/resources/tester/helper_215_1_free_profile.cjs b/resources/tester/helper_215_1_free_profile.cjs index 3c27f333b..e5a7e9181 100644 --- a/resources/tester/helper_215_1_free_profile.cjs +++ b/resources/tester/helper_215_1_free_profile.cjs @@ -26,7 +26,9 @@ function loadTs(file) { } const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts")); -const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts")); +const align = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); // 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다. const stations = Array.from({ length: 11 }, (_, i) => i * 20); @@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]); out.radiiKeys = Object.keys(next.curve_radii).sort(); const built = align.buildAlignment(base, next); const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; - out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) }; + out.pvi = { + 40: pvi(40), + 60: pvi(60), + 80: pvi(80), + 100: pvi(100), + 120: pvi(120), + 140: pvi(140), + 160: pvi(160), + }; out.ground = { 40: z0(40), 160: z0(160) }; const g1 = (103.46 - z0(40)) / 40; const g2 = (101.2 - 103.46) / 40; out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6)); out.r80 = next.curve_radii["80.000"]; out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null; - out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets); + out.outsideUntouched = + !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets); } // ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작. @@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]); out.enterOutsideDrawing = draw.key("Enter"); } +// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로. +{ + const { draw, log } = harness(); + out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") }; + draw.click(40, 0, stations); + draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임 + draw.click(160, 0, stations); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + draw.click(120, 103, stations); + const dots = () => draw.marks().dots.map((d) => d.chainage_m); + draw.undo(); + out.undo1 = { dots: dots(), canRedo: draw.canRedo() }; + draw.redo(); + out.redo1 = { dots: dots(), canRedo: draw.canRedo() }; + draw.undo(); + draw.click(100, 104, stations); // 새 클릭 = future 비움 + out.newClick = { dots: dots(), canRedo: draw.canRedo() }; + const phases = []; + while (draw.canUndo()) { + draw.undo(); + phases.push(`${draw.phase()}:${draw.marks().edges.length}`); + } + out.undoAll = phases; + out.undoApplied = log.applied.length; + out.undoClosed = log.closed; + draw.redo(); + draw.redo(); + out.redoTwice = { phase: draw.phase(), band: draw.marks().band }; + draw.reset(); + out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() }; +} + +// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로. +{ + let tries = 0; + const draw = free.createFreeDraw({ + base: () => base, + alignment: () => align.buildAlignment(base, align.emptyEdits()), + edits: () => align.emptyEdits(), + apply: () => ((tries += 1), false), + close: () => (tries += 100), + changed: () => undefined, + }); + draw.click(40, 0, stations); + draw.click(160, 0, stations); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + draw.key("Enter"); + draw.click(160, 0, stations); + out.blocked = { + tries, + phase: draw.phase(), + dots: draw.marks().dots.length, + canUndo: draw.canUndo(), + }; +} + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index 2c626af39..1166efad7 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -115,6 +115,38 @@ def test_Backspace_와_Esc_취소(out): assert out["enterOutsideDrawing"] is False +def test_자유지정_되돌리기_다시하기(out): + # 367-3 ⑥ 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · 큰 이력(apply · close) 안 건드림 + assert out["undoEmpty"] == {"can": False, "backspace": False} + assert out["undo1"] == {"dots": [40, 80], "canRedo": True} + assert out["redo1"] == {"dots": [40, 80, 120], "canRedo": False} + assert out["newClick"] == {"dots": [40, 80, 100], "canRedo": False} + # 한 걸음 = 점 · 시작 · 범위 끝 · 범위 첫 · 같은 세로선 다시 누름은 안 쌓임 + assert out["undoAll"] == ["drawing:2", "drawing:2", "armed:2", "range:1", "range:0"] + assert out["undoApplied"] == 0 and out["undoClosed"] == 0 + assert out["redoTwice"] == {"phase": "armed", "band": [40, 160]} + assert out["resetClears"] == {"undo": False, "redo": False} + + +def test_적용_막히면_열린_채(out): + # 367-3 ⑦ Enter · 끝 세로선 둘 다 apply 시도(2) · close 없음 · 점 그대로 + assert out["blocked"] == {"tries": 2, "phase": "drawing", "dots": 2, "canUndo": True} + + +def test_되돌리기_배선_자유지정_먼저(): + panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel + assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel + keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :] + free_at = keys.index('if (tools.mode() === "free") {') + assert ( + free_at < keys.index("history.undo()") + and "return;" in keys[free_at : keys.index("flushHold();")] + ) + profile = _read("A00_Common/design/design_profile.ts") + assert "function applyEdits(next: AlignmentEdits): boolean {" in profile + + def test_도구줄_직선화_오른쪽_자유지정(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts") assert '"자유지정"' in tools diff --git a/resources/tester/test_345_3_route_body.py b/resources/tester/test_345_3_route_body.py index 2a038790d..18cdbd0de 100644 --- a/resources/tester/test_345_3_route_body.py +++ b/resources/tester/test_345_3_route_body.py @@ -48,7 +48,9 @@ def test_Ctrl_Z_는_사진첩_한_줄(out): def test_종단도_Ctrl_Z_줄이_사진첩_하나(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {') - assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240] + assert ( + "undoOwner" not in tools and "history.undo()" in tools[at : at + 600] + ) # 367-3 자유지정 분기 앞에 섬 history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts") assert '"keydown"' not in history and "onPageHistory" in history From c52a98a115f38e94eccb7e12f55ef6bbbd66e9ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:54:30 +0900 Subject: [PATCH 08/30] =?UTF-8?q?feat(B04=C2=B7B05=20=EB=B0=B0=EC=88=98):?= =?UTF-8?q?=20369-1=20=EA=B4=80=20=EC=9E=90=EB=A6=AC=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=3D=20=EC=84=B8=EB=A5=98=EC=84=A0=20=EA=B5=90=EC=B0=A8?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20auto=20=EC=B9=B8(all=20=C2=B7=20add)=20?= =?UTF-8?q?=C2=B7=20=EB=85=B8=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=EC=98=A4?= =?UTF-8?q?=EB=AA=A9=20=EB=B3=B4=EC=B6=A9=20=EB=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_detail auto — 없음 = 세류선 교차만 · all = 세류선 + 100 m 보충으로 다시 · add = 지금 목록 + 빈 구간 보충 · 후보는 늘 셈 - 배수유역 라우터 — 요청 auto 칸(틀리면 400) · 같은 자리(0.05 m) 관만 속성 · id · 사유 이어받음 · 나머지 새 관 = 등록부 기본값 · 저장본 열쇠에 auto - 노선 [저장] · 추천 [적용] — 오목 지점 보충 지움(응답 concave 칸 · 화면 한 줄 같이) - 시험 — test_369_drainage_auto 세 모드 · test_346_3 · 312 · 342_3 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B03_FileInput/B03_FileInput_Service_Chain.py | 2 +- ...B04_PreProcess_Engine_Watershed_Analyze.py | 2 +- .../B04_PreProcess_Router_Basins.py | 63 +++++++--- B05_Profile/B05_Profile_Api_Replan.ts | 4 +- B05_Profile/B05_Profile_Route_Save.py | 45 +------ B05_Profile/B05_Profile_Route_Save_Parts.py | 63 +--------- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 1 - common_util/common_util_drainage_detail.py | 27 ++-- resources/tester/helper_346_3_route_save.cjs | 2 +- resources/tester/test_312_empty_pipes.py | 2 +- resources/tester/test_342_3_detail_cache.py | 2 +- resources/tester/test_342_3_drainage_stale.py | 4 +- resources/tester/test_346_3_route_save.py | 37 ++---- .../tester/test_346_3_route_save_screen.py | 2 +- resources/tester/test_369_drainage_auto.py | 117 ++++++++++++++++++ 15 files changed, 208 insertions(+), 165 deletions(-) create mode 100644 resources/tester/test_369_drainage_auto.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index c4902a77c..afdcc4f3f 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -105,7 +105,7 @@ async def _prepare_drainage_pipes_and_reprofile( marks.append(("배수유역 분석", time.perf_counter())) - # 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다. + # 2) 기본 관(도로 × 상류 세류선 교차)만 정본으로 저장한다 — 간격 보충은 [자동 제안](369). pipes = await put_pipe_points(project_id, None) if isinstance(pipes, JSONResponse): logger.error( diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py index 4d8981143..eb44d6739 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py @@ -342,7 +342,7 @@ def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, floa def _base_pipes( vertices: list[RouteVertex], stream_features: list[dict[str, Any]] ) -> list[StructureCandidate]: - """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 간격 보충은 [자동 제안] 때만(369).""" pipes: list[StructureCandidate] = [] for candidate in find_stream_crossings(vertices, stream_features): if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 2b27942d4..6130b2962 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -2,7 +2,7 @@ 격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어 - · 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고 + · 기본 관(도로 × 상류 세류선 교차점)으로 관 목록을 만들고([자동 제안] = 최대 간격 보충 · 369) · 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다 계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의 @@ -67,6 +67,8 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"]) _NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요." # 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3). +# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add +AUTO_MODES = (None, "all", "add") STALE_MESSAGE = ( "배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다." ) @@ -78,20 +80,22 @@ def _build( points: list[PipePoint] | None, routing: Any = None, upstream_lonlat: list | None = None, + auto: str | None = None, ) -> tuple[DrainageDetail | None, list[PipePoint]]: """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. - `points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. - 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 - 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. + `points`가 None 이면 B04 기본 관(세류선 교차)만. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 + 있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 + 두면 전부 "수동"이 된다. `auto`([자동 제안] · 369) = "all" 자리 새로 · "add" 빈 구간 보충 — + 같은 자리(0.05 m) 관만 속성 · id 를 이어받고 나머지는 새 관(등록부 기본값). """ directory = drainage_dir(stored_path) chainages = [point.chainage_m for point in points] if points is not None else None - detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat) + detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto) if detail is None: return None, [] if points is not None: - _retag(detail.pipes, points) + _retag(detail.pipes, points, keep_new=auto is not None) rebuilt = _rebuilt(detail) # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 fresh = [ @@ -132,11 +136,14 @@ _DETAIL_CACHE_SIZE = 4 DETAIL_CACHE_FILENAME = "04_detail_cache.pkl" -def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str: +def _detail_key( + context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None +) -> str: directory = drainage_dir(context.stored_path) digest = hashlib.sha1(usedforsecurity=False) listed = None if points is None else [point.as_dict() for point in points] - digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode()) + # auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감) + digest.update(json.dumps([listed, auto], sort_keys=True, ensure_ascii=False).encode()) for vertex in context.vertices: digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode()) routing = STAGES["road_routing"] @@ -167,10 +174,10 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None: def _build_cached( - context: DrainageContext, points: list[PipePoint] | None + context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None ) -> tuple[DrainageDetail | None, list[PipePoint]]: """`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로).""" - key = _detail_key(context, points) + key = _detail_key(context, points, auto) hit = _DETAIL_CACHE.get(key) if hit is None: try: @@ -180,7 +187,7 @@ def _build_cached( except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError): hit = None if hit is None: - detail, carried = _build(context.stored_path, context, points) + detail, carried = _build(context.stored_path, context, points, auto=auto) if detail is None: return None, [] hit = (detail, carried) @@ -276,8 +283,20 @@ def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None: return math.ceil(depth * 100 - 1e-9) / 100 -def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: - """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.""" +def _retag( + pipes: list[StructureCandidate], points: list[PipePoint], keep_new: bool = False +) -> None: + """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다. + + `keep_new`([자동 제안]) = 같은 자리(0.05 m) 관만 · 새로 놓인 관은 계산기 사유 + (stream · spacing) 그대로. + """ + if keep_new: + picks = carry_indices([pipe.chainage_m for pipe in pipes], [p.chainage_m for p in points]) + for pipe, pick in zip(pipes, picks): + if pick is not None: + pipe.reason = points[pick].source + return by_chainage = {round(point.chainage_m, 2): point.source for point in points} for pipe in pipes: source = by_chainage.get(round(pipe.chainage_m, 2)) @@ -452,11 +471,16 @@ async def _resolve( 돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음. 빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312). + 요청 `auto`([자동 제안] · 369) = "all" · "add" — `_build` 참고 · 없으면 None. """ context, reason = await load_drainage_context(project_id) if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + auto = (payload or {}).get("auto") + if auto not in AUTO_MODES: + message = f"auto 는 {', '.join(m for m in AUTO_MODES if m)} 중 하나입니다." + return JSONResponse(status_code=400, content={"status": "error", "message": message}) try: raw = (payload or {}).get("points") requested = None if raw is None else parse_pipe_points(raw) @@ -479,9 +503,11 @@ async def _resolve( # [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만 if fresh: - result = await asyncio.to_thread(_build, context.stored_path, context, points) + result = await asyncio.to_thread( + lambda: _build(context.stored_path, context, points, auto=auto) + ) else: - result = await asyncio.to_thread(_build_cached, context, points) + result = await asyncio.to_thread(_build_cached, context, points, auto) detail, resolved = result if detail is None: return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) @@ -511,8 +537,9 @@ async def post_detail_basins( ) -> dict[str, Any] | JSONResponse: """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). - `points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — - 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312). + `points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 — + [배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312). + `auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충. """ resolved = await _resolve(project_id, payload, use_stored=False) if isinstance(resolved, JSONResponse): @@ -523,7 +550,7 @@ async def post_detail_basins( @router.delete("/{project_id}/drainage/pipe-points", response_model=None) async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: - """저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화"). + """저장된 관 지점을 버리고 기본 관(세류선 교차) 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아난다 — 저장분과 파생 산출물까지 지운다 (2026-08-02 사용자 보고). diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index d8e10a59b..bb7a7fd96 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -129,8 +129,8 @@ export interface RouteReplanResponse { route_id: number | null; total_length_m: number | null; vertex_count?: number; - /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */ - pipes?: { kept: number; crossing: number; concave: number }; + /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */ + pipes?: { kept: number; crossing: number }; structures?: { moved: number; far: number }; /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ cross_dropped?: number; diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index fc4b175bb..dbb9a5f2b 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -9,14 +9,14 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것. `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, - initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped, + initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped, drainage_stale: true, seconds}`. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함 + - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과 @@ -37,15 +37,12 @@ from pathlib import Path from typing import Any, Literal from uuid import UUID -import numpy as np from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from B05_Profile.B05_Profile_Route_Save_Parts import ( add_crossing_pipes, carry_cross_inputs, - concave_chainages, - fill_concave_pipes, follow_pipes, station_keys, vertices_of, @@ -161,40 +158,14 @@ def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]: return [] -def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple: - """새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀).""" - from shapely.geometry import LineString - - from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M - from common_util.common_util_surface_sampler import build_surface_sampler - - try: - sampler = build_surface_sampler( - project_root / "B04_PreProcess" / "models", - str(selection.get("source_filter")), - str(selection.get("method") or "dtm"), - bool(selection.get("smooth")), - ) - except (FileNotFoundError, KeyError, OSError, ValueError): - logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀") - return np.zeros(0), np.zeros(0) - line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M) - xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64) - z, valid = sampler.sample_xy(xy) - return chainage[valid], np.asarray(z, dtype=np.float64)[valid] - - async def _place_pipes( project_id: UUID, - project_root: Path, stored_path: str, - selection: dict[str, Any], old_pipes: list, old_xy: list[list[float]], points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: - """관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문).""" + """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).""" from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare from common_util.common_util_drainage_pipes import route_signature, save_pipe_points @@ -207,8 +178,6 @@ async def _place_pipes( pairs = follow_pipes(old_pipes, vertices) total = float(vertices[-1].chainage_m) crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total) - chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices) - concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy) await asyncio.to_thread( save_pipe_points, stored_path, @@ -216,7 +185,7 @@ async def _place_pipes( [pipe for _, pipe in pairs], vertices, ) - return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave} + return pairs, {"kept": len(old_pipes), "crossing": crossing} async def _carry_profile( @@ -423,10 +392,8 @@ async def run_route_save_chain( new_xy = _xy(project_root, solved.route_data_path) marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) - pairs, pipes = await _place_pipes( - project_id, project_root, stored_path, selection, old_pipes, old_xy, points - ) - marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter())) + pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points) + marks.append(("관 따라감 · 교차점", time.perf_counter())) seed = asdict(_section_options(request, old_section_options)) seed.pop("extra_stations", None) diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index d31a7277a..c4f37273d 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -1,4 +1,4 @@ -"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 · +"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 측점 열쇠 · 횡단 이월. DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은 @@ -7,7 +7,7 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름 - 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음 (사용자 관 편집 지킴). - 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음). - - 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음. + - 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만. - 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` · 관 `pipe:` · 구조물 `structure:` · 시종점 `end:BP` · `end:EP` · id 없으면 `at:<누가거리 0.01 m>`. @@ -22,11 +22,9 @@ import copy import math from typing import Any, Sequence -import numpy as np from shapely.geometry import LineString, Point from common_util.common_util_drainage_pipes import ( - PIPE_SOURCE_SPACING, PIPE_SOURCE_STREAM, PipePoint, project_pipe_points, @@ -35,14 +33,10 @@ from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_station_key import chainage_key as station_chainage_key from common_util.common_util_station_key import station_key from common_util.common_util_structure_station import plan_stations, snap_structure_station -from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M +from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M # 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상). MOVED_AWAY_M = 0.5 -# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게. -# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것. -CONCAVE_STEP_M = 2.0 -CONCAVE_WINDOW = 5 _SAME_M = 0.01 @@ -96,57 +90,6 @@ def add_crossing_pipes( return added -def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]: - """지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)].""" - if ground.size < 3: - return [] - kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW - pad = CONCAVE_WINDOW // 2 - smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid") - return [ - (float(chainage[i]), float(smooth[i])) - for i in range(1, smooth.size - 1) - if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1] - ] - - -def fill_concave_pipes( - pairs: list[tuple[int, PipePoint]], - concave: list[tuple[float, float]], - vertices: list[RouteVertex], - old_xy: Sequence[Sequence[float]] | None, -) -> int: - """관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관.""" - line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None - total = float(line.length) - added = 0 - while True: - edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total] - best: tuple[float, float] | None = None - for start, end in zip(edges, edges[1:]): - if end - start <= DRAINAGE_PIPE_MAX_SPACING_M: - continue - for at, z in concave: - if not ( - start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M - ): - continue - point = line.interpolate(at) - if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]): - best = (at, z) - if best is None: - return added - at = snap_structure_station(best[0], [total]) - point = line.interpolate(at) - pairs.append( - (-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y)) - ) - pairs.sort(key=lambda pair: pair[1].chainage_m) - concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M] - added += 1 - - def _facility_owners( pipes: Sequence[PipePoint], structures: Sequence[Any] ) -> dict[float, tuple[str, str | None]]: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 8e19191ef..796b9022a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -128,7 +128,6 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin `구조물 옮김 ${saved.structures?.moved ?? 0}`, `멀어짐 ${saved.structures?.far ?? 0}`, ]; - if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`); const dropped = (saved.cross_dropped ?? 0) + outside; if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`); if (saved.drainage_stale) parts.push("배수유역 낡음"); diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 05e215273..0f3961ea0 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -3,7 +3,8 @@ **격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만 처리한다(2026-07-31 사용자 지시). - ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 + ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충([자동 제안] 때만 · 기본 = 세류선 + 교차만) ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다 ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 @@ -167,21 +168,25 @@ def build_detail( confirmed_chainages: list[float] | None = None, routing: RoadRouting | None = None, upstream_lonlat: list[list[list[float]]] | None = None, + auto: str | None = None, ) -> DrainageDetail | None: - """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. + """B04 분석 결과를 읽어 관 자리를 정하고 세부유역을 나눈다. `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집 · 빈 목록 = 관 0개), None 이면 - B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. + B04의 기본 관(세류선 교차)만. 어느 쪽이든 격자 해석은 하지 않는다. + `auto`([자동 제안] · 369) — None = 위 그대로 · "all" = 기본 관 + 최대 간격 보충으로 다시 + (확정 목록 무시) · "add" = 확정 목록 + 빈 구간 보충. 관 자리 후보는 어느 길이든 셈. `routing` · `upstream_lonlat` 을 주면 파일 대신 그것(「지금 노선으로 셈」 메모리 결과 · 347-2). """ routing = routing or read_road_routing(directory) if routing is None or len(vertices) < 2: return None - candidates: list[StructureCandidate] = [] - if confirmed_chainages is not None: + if confirmed_chainages is not None and auto != "all": pipes = pipes_from_chainages(vertices, confirmed_chainages) - candidates = suggest_pipe_positions(vertices, pipes, routing.strength_curve) + if auto == "add": + pipes = [*pipes, *suggest_pipe_positions(vertices, pipes, routing.strength_curve)] + pipes.sort(key=lambda pipe: pipe.chainage_m) else: # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. base = [ @@ -194,7 +199,9 @@ def build_detail( for pipe in routing.base_pipes ] # 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만 - pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices) + placed = place_pipes(vertices, base, routing.strength_curve, fill=auto is not None) + pipes = snap_candidates(placed, vertices) + candidates = suggest_pipe_positions(vertices, pipes, routing.strength_curve) detail = DrainageDetail( route_lonlat=routing.route_lonlat, @@ -386,10 +393,12 @@ def place_pipes( vertices: list[RouteVertex], base_pipes: list[StructureCandidate], strength_curve: np.ndarray, + fill: bool = True, ) -> list[StructureCandidate]: """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. - 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. + 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. `fill=False` = 보충 없이 + 기본 관만(최소 간격 겹침만 걸러 냄 · 369 기본). """ total_length = vertices[-1].chainage_m base: list[StructureCandidate] = [] @@ -397,6 +406,8 @@ def place_pipes( if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: continue base.append(candidate) + if not fill: + return base filled: list[StructureCandidate] = [] previous = 0.0 diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index ac42504f0..173ab21be 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -56,7 +56,7 @@ const STUBS = { if (failReplan) throw new Error("체인 실패"); return { route_id: 9, - pipes: { kept: 3, crossing: 1, concave: 2 }, + pipes: { kept: 3, crossing: 1 }, structures: { moved: 1, far: 0 }, cross_dropped: 1, drainage_stale: true, diff --git a/resources/tester/test_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py index 44ff61c69..7158eafdc 100644 --- a/resources/tester/test_312_empty_pipes.py +++ b/resources/tester/test_312_empty_pipes.py @@ -148,7 +148,7 @@ def test_칸_없음_파일_없음_DELETE_노선_바뀜은_자동_배치(api) -> def test_빈_목록과_None_을_가르는_자리() -> None: src = _read("common_util/common_util_drainage_detail.py") - assert "if confirmed_chainages is not None:" in src + assert 'if confirmed_chainages is not None and auto != "all":' in src router = _read("B04_PreProcess/B04_PreProcess_Router_Basins.py") assert "points = requested if requested is not None else stored" in router assert "requested or stored" not in router diff --git a/resources/tester/test_342_3_detail_cache.py b/resources/tester/test_342_3_detail_cache.py index 8ea80935b..661d712aa 100644 --- a/resources/tester/test_342_3_detail_cache.py +++ b/resources/tester/test_342_3_detail_cache.py @@ -24,7 +24,7 @@ def setup(tmp_path, monkeypatch): basins._DETAIL_CACHE.clear() calls = SimpleNamespace(count=0) - def build(stored_path, context, points): + def build(stored_path, context, points, auto=None): calls.count += 1 chainages = [p.chainage_m for p in points] if points else [50.0] return DrainageDetail(), [PipePoint(chainage_m=c, source="stream") for c in chainages] diff --git a/resources/tester/test_342_3_drainage_stale.py b/resources/tester/test_342_3_drainage_stale.py index 18d096861..c252f6c35 100644 --- a/resources/tester/test_342_3_drainage_stale.py +++ b/resources/tester/test_342_3_drainage_stale.py @@ -52,13 +52,13 @@ def _patch(monkeypatch, analysis_signature, stored): monkeypatch.setattr(basins, "load_pipe_points", lambda *args: stored) built = SimpleNamespace(called=False) - def build(stored_path, context, points): + def build(stored_path, context, points, auto=None): built.called = True return SimpleNamespace(pipes=[], basins=["유역"]), points or [] monkeypatch.setattr(basins, "_build", build) monkeypatch.setattr( - basins, "_build_cached", lambda context, points: build("x", context, points) + basins, "_build_cached", lambda context, points, auto=None: build("x", context, points) ) return built diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 31624e733..2dc07d578 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 · +"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 보충 없음(369) · 이월 열쇠 · 초기값 규칙 · 배수유역 낡음 · 실패 때 옛 노선 되살림. @@ -41,14 +41,11 @@ import common_util.common_util_workflow_state as workflow # noqa: E402 from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 add_crossing_pipes, carry_cross_inputs, - concave_chainages, - fill_concave_pipes, follow_pipes, station_keys, vertices_of, ) from common_util.common_util_drainage_pipes import ( # noqa: E402 - PIPE_SOURCE_SPACING, PIPE_SOURCE_STREAM, PipePoint, analysis_route_signature, @@ -60,7 +57,7 @@ OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)] NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)] -# ── 관 따라감 · 교차점 · 오목 보충 ───────────────────────────────────────────── +# ── 관 따라감 · 교차점 ───────────────────────────────────────────── def test_관은_지우지_않고_좌표를_따라감(): @@ -91,29 +88,11 @@ def test_새_교차점에만_기본_관(): assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0 -def test_오목_지점_보충은_새_자리의_오목만(): - vertices = vertices_of(NEW) - total = vertices[-1].chainage_m - chainage = np.arange(0.0, total, 2.0) - ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리) - ground[chainage < 60] = ( - 100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05 - ) # 30 m 골(옛 자리) - concave = concave_chainages(chainage, ground) - assert any(abs(at - 220.0) < 3 for at, _ in concave) - pairs: list = [] - added = fill_concave_pipes(pairs, concave, vertices, OLD) - places = [pipe.chainage_m for _, pipe in pairs] - assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs) - assert any(abs(at - 220.0) <= 3 for at in places) - assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠 +def test_노선_저장은_간격_보충_없음(): + """369 — 오목 · 100 m 간격 보충은 배수유역 [자동 제안]만 · 노선 저장 부품에 없음.""" + import B05_Profile.B05_Profile_Route_Save_Parts as parts - -def test_간격이_좁으면_보충_없음(): - vertices = vertices_of(NEW) - pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)] - concave = [(220.0, 99.0)] - assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0 + assert not hasattr(parts, "fill_concave_pipes") and not hasattr(save, "_ground_along") # ── 측점 열쇠 · 횡단 이월 ────────────────────────────────────────────────────── @@ -366,7 +345,6 @@ def chain_world(monkeypatch, tmp_path): monkeypatch.setattr(watershed, "_prepare", prepare) monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run) monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain) - monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0))) monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate) monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile")) monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross")) @@ -416,7 +394,8 @@ def test_차례와_배수유역_뗌(chain_world): "confirm", ] assert result["cross_dropped"] == 2 and result["drainage_stale"] is True - assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0} + # 456 m 노선 · 관 하나 — 100 m 간격 보충 없음(369) + assert result["pipes"] == {"kept": 1, "crossing": 0} # 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」 saved = json.loads(chain_world.pipes_file.read_text("utf-8")) assert saved["route_signature"] == route_signature(vertices_of(NEW)) diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index 09367553f..53ea15f9d 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -67,7 +67,7 @@ def test_저장_뒤_가게_초안_비움_결과_한_줄(out): assert out["storeAfterSave"] is None assert out["keptAfterSave"] == 1 assert out["summary"] == ( - "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · " + "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 구조물 옮김 1 · 멀어짐 0 · " "노선 밖 고침 3 건 · 배수유역 낡음" ) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄 diff --git a/resources/tester/test_369_drainage_auto.py b/resources/tester/test_369_drainage_auto.py new file mode 100644 index 000000000..7c9109869 --- /dev/null +++ b/resources/tester/test_369_drainage_auto.py @@ -0,0 +1,117 @@ +"""369 배수 구조물 자리 — 기본 = 세류선 교차만 · [자동 제안] 두 방식(all · add). + +- 기본(auto 없음) = 세류선 교차 관만 · 100 m 보충 없음 · 관 자리 후보는 셈. +- all = 세류선 + 100 m 보충으로 다시(지금 목록 무시) · 같은 자리(0.05 m) 관은 속성 · id 유지 · + 나머지 = 새 관(등록부 기본값). +- add = 지금 목록 그대로 + 빈 구간 보충 · 더한 관 = 새 관(등록부 기본값). +- 틀린 auto = 400 · 저장본 열쇠에 auto. +""" + +import asyncio +from pathlib import Path +from uuid import uuid4 + +import numpy as np + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec +from common_util.common_util_drainage_context import DrainageContext +from common_util.common_util_drainage_detail import RoadRouting, build_detail +from common_util.common_util_drainage_pipes import PipePoint +from common_util.common_util_route_geometry import RouteVertex, StructureCandidate + +VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)] +CONTEXT = DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES) + + +def _routing() -> RoadRouting: + spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310) + road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32) + road_slot = np.full(spec.size, -1, dtype=np.int32) + road_slot[road_index] = np.arange(road_index.size) + return RoadRouting( + spec=spec, + road_slot=road_slot, + path_length=np.zeros(spec.size, np.float32), + elevation=np.full(spec.size, 95.0, np.float32), + road_cell_index=road_index, + road_chainage=np.arange(road_index.size, dtype=np.float64), + strength=np.ones(road_index.size, np.int64), + base_pipes=[StructureCandidate(chainage_m=150.0, x=150.0, y=0.0, reason="stream")], + ) + + +def _detail(tmp_path, chainages=None, auto=None): + return build_detail(tmp_path / "없음", VERTICES, chainages, _routing(), [], auto) + + +def test_기본은_세류선_교차만(tmp_path): + detail = _detail(tmp_path) + assert [(p.chainage_m, p.reason) for p in detail.pipes] == [(150.0, "stream")] + assert len(detail.pipe_candidates) == 2 # 0~150 · 150~300 이 100 m 넘음 — 제안만 + + +def test_all_은_세류선_더하기_100m_보충_지금_목록_무시(tmp_path): + detail = _detail(tmp_path, [10.0, 150.02], "all") + reasons = [p.reason for p in detail.pipes] + places = [p.chainage_m for p in detail.pipes] + assert reasons.count("stream") == 1 and reasons.count("spacing") == 2 + assert 150.0 in places and 10.0 not in places + gaps = np.diff([0.0, *places, 300.0]) + assert gaps.max() <= 100.0 and not detail.pipe_candidates + + +def test_add_는_지금_목록_더하기_빈_구간_보충(tmp_path): + detail = _detail(tmp_path, [20.0], "add") + places = [p.chainage_m for p in detail.pipes] + assert 20.0 in places and len(places) == 3 # 20 ~ 300 = 280 m → 보충 둘 + assert np.diff([0.0, *places, 300.0]).max() <= 100.0 + # auto 없음 = 목록 그대로(보충은 후보만) + plain = _detail(tmp_path, [20.0]) + assert [p.chainage_m for p in plain.pipes] == [20.0] and len(plain.pipe_candidates) == 2 + + +def _build(tmp_path, monkeypatch, points, auto): + monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path / "없음") + return basins._build("x", CONTEXT, points, _routing(), [], auto=auto) + + +def test_all_같은_자리_관은_속성_id_유지_새_관은_기본값(tmp_path, monkeypatch): + kept = PipePoint( + chainage_m=150.03, + source="user", + facility="ford_bridge", + options={"ford_width_m": 4.0}, + id="keep", + ) + gone = PipePoint(chainage_m=10.0, source="user", options={"pipe_diameter_mm": 600}, id="gone") + _, carried = _build(tmp_path, monkeypatch, [gone, kept], "all") + ford = [p for p in carried if p.id == "keep"] + assert len(ford) == 1 and ford[0].facility == "ford_bridge" and ford[0].source == "user" + assert all(p.id != "gone" for p in carried) + fresh = [p for p in carried if p.id != "keep"] + assert len(fresh) == 2 and all(p.source == "spacing" for p in fresh) + assert all((p.options or {}).get("pipe_diameter_mm") for p in fresh) # 등록부 관경 + + +def test_add_더한_관은_기본값_지금_관은_그대로(tmp_path, monkeypatch): + mine = PipePoint(chainage_m=20.0, source="user", id="mine") + _, carried = _build(tmp_path, monkeypatch, [mine], "add") + assert [p.id for p in carried].count("mine") == 1 + assert next(p for p in carried if p.id == "mine").options is None # 옛 빈칸 = 미확정 유지 + added = [p for p in carried if p.id != "mine"] + assert len(added) == 2 and all(p.source == "spacing" for p in added) + assert all((p.options or {}).get("pipe_diameter_mm") for p in added) + + +def test_틀린_auto_는_400_열쇠에_auto(tmp_path, monkeypatch): + async def load(project_id): + return CONTEXT, "" + + monkeypatch.setattr(basins, "load_drainage_context", load) + monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path) + result = asyncio.run(basins._resolve(uuid4(), {"auto": "spacing"}, use_stored=False)) + assert result.status_code == 400 + points = [PipePoint(chainage_m=20.0)] + keys = {basins._detail_key(CONTEXT, points, auto) for auto in basins.AUTO_MODES} + assert len(keys) == 3 From 04dd53e5e69d45173004a74b9c13228675487de8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:54:49 +0900 Subject: [PATCH 09/30] =?UTF-8?q?fix(B05):=20367-4=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EC=84=A0=20=C2=B7=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=A0=90=20=EB=85=B9=EC=83=89=20?= =?UTF-8?q?=C2=B7=20OS=20=EC=9E=90=EB=8F=99=20=EC=96=B4=EB=91=90=EC=9B=80?= =?UTF-8?q?=EC=97=90=20success=20=C2=B7=20danger=20=ED=86=A0=ED=81=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - freemark 선 · 점 stroke = --color-success · 넘음 빨강 · 범위 띠 · 경계 보라 그대로 - ui_template_theme OS 자동 어두움 블록에 --color-success · --color-danger 채움 - 시험 test_215_1 색 확인 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B05_Profile/B05_Profile_UI_Style_Table.css | 5 +++-- resources/tester/test_215_1_free_profile.py | 22 +++++++++++++++++++++ ui_template/ui_template_theme.css | 3 +++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 1806537e8..3f1dedf96 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -352,8 +352,9 @@ stroke-width: 1.5; } +/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */ .b05-profile-freemark__line { - stroke: var(--color-primary); + stroke: var(--color-success); stroke-width: 2; stroke-dasharray: 6 4; } @@ -368,7 +369,7 @@ .b05-profile-freemark__dot { fill: var(--color-surface); - stroke: var(--color-primary); + stroke: var(--color-success); stroke-width: 1.5; } diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index 1166efad7..06c5a458a 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -174,6 +174,28 @@ def test_오버레이_점선_빨강_글(): assert ".b05-profile-freemark__line.is-over" in css +def _css_rule(css: str, selector: str) -> str: + at = css.index(selector + " {") + return css[at : css.index("}", at)] + + +def test_자유지정_녹색_넘음_빨강_띠_보라(): + # 367-4 선 · 미리 보기 · 점 = --color-success · 넘음 빨강 · 범위 띠 · 경계 보라 그대로 + css = _read("B05_Profile/B05_Profile_UI_Style_Table.css") + assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__line") + assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__dot") + assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__line.is-over") + assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__grade.is-over") + assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__band") + assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__edge") + # 밝음 · 어두움(명시 · OS 자동) 모두 success · danger 토큰 있음 + theme = _read("ui_template/ui_template_theme.css") + auto = theme[theme.index("@media (prefers-color-scheme: dark)") :] + auto = auto[: auto.index("\n}\n")] + for block in (_css_rule(theme, ":root"), _css_rule(theme, ':root[data-theme="dark"]'), auto): + assert "--color-success:" in block and "--color-danger:" in block + + def test_줄_수_늘리지_않음(): for relative in ( "A00_Common/design/design_profile.ts", diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index c670ebc04..fc1bd6fe8 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -370,6 +370,9 @@ --color-primary-text: #16121f; --color-accent: var(--color-lavender-wash); --color-focus-ring: var(--color-lavender-wash); + + --color-success: #3fbb6f; + --color-danger: #e8695a; --panel-handle-border: #e0b64a; --map-route-locked: #f472b6; } From 141bf31731eee544b6d638a30d673a3bacf7ed0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:55:50 +0900 Subject: [PATCH 10/30] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20366-3?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EC=8A=A4=EB=83=85=20=EC=BC=AC=20=3D=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EA=B0=80=EC=9A=B4=EB=8D=B0(MC)=EB=A5=BC?= =?UTF-8?q?=20=EC=9D=BC=EB=B0=98=EC=B8=A1=EC=A0=90=EC=97=90=20=E2=80=94=20?= =?UTF-8?q?=EC=83=88=20logic=20StationFit(=EA=B3=A0=EC=B9=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=95=9E=20=EA=B3=A1=EC=84=A0=EB=B6=80=ED=84=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EC=A0=95=20=EC=95=84=EB=8B=8C=20IP=20?= =?UTF-8?q?=EB=A5=BC=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=8A=94=20=EC=A0=91?= =?UTF-8?q?=EC=84=A0=20=EB=94=B0=EB=9D=BC=20=C2=B1=EA=B0=84=EA=B2=A9/2=20?= =?UTF-8?q?=C2=B7=20=ED=95=98=ED=95=9C=20=EC=83=88=EB=A1=9C=20=EA=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EC=95=9E=20MC=20=ED=9D=94=EB=93=A6=20=3D=20=EB=B2=84?= =?UTF-8?q?=EB=A6=BC)=20=C2=B7=20=EB=86=93=EC=9D=84=20=EB=95=8C=20=C2=B7?= =?UTF-8?q?=20applyEdit=20=C2=B7=20=EC=BC=A4=20=EB=95=8C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=95=9C=20=EA=B1=B8=EC=9D=8C=20=C2=B7=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=C2=B7=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=C2=B7=20=EC=B4=88=EC=95=88=20=EB=90=98?= =?UTF-8?q?=EC=82=B4=EB=A6=BC=EC=9D=80=20=EC=95=88=20=EB=A7=9E=EC=B6=A4=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=ED=83=9C=EC=A4=84=20=E3=80=8C=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=B0=96=20=EA=B3=A1=EC=84=A0=20N=EA=B0=9C?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20=EB=8B=A8=EC=B6=94=20title=20=EC=83=88=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=C2=B7=20Node=20=EC=8B=9C=ED=97=98?= 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_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 16 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 2 +- .../logic/B05_Profile_RouteEdit_StationFit.ts | 171 ++++++++++++++++++ .../logic/B05_Profile_UI_RouteEdit_Edits.ts | 3 + resources/tester/helper_366_3_station_fit.cjs | 114 ++++++++++++ .../tester/test_345_4_recommend_panel.py | 4 +- resources/tester/test_366_3_station_fit.py | 85 +++++++++ 7 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts create mode 100644 resources/tester/helper_366_3_station_fit.cjs create mode 100644 resources/tester/test_366_3_station_fit.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 2a9cc77b8..964be9050 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -91,6 +91,7 @@ import { import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; +import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -159,7 +160,7 @@ export async function mountRouteEditor( let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; - /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */ + /** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */ let stationSnap = false; let savedStationSnap = false; /** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로. @@ -265,6 +266,7 @@ export async function mountRouteEditor( const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); stationSnapButton.addEventListener("click", () => { setStationSnap(!stationSnap); + if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음 void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. draw(); }); @@ -415,6 +417,7 @@ export async function mountRouteEditor( violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, + offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0, }); // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── @@ -445,8 +448,12 @@ export async function mountRouteEditor( const curveLabelBox = curveBar.label; const syncCurveBar = curveBar.sync; - /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ - function applyEdit(message: string, record = true): void { + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. + * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */ + function applyEdit(message: string, record = true, fitting = true): void { + const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM }; + const from = fit.fitStart(history?.current?.(), snapshotNow()); + if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); @@ -476,7 +483,7 @@ export async function mountRouteEditor( const locks = record ? routeLocksOf(planned, apexLock) : null; ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); if (locks) apexLock = lockColumn(planned, locks); - applyEdit(message, record); + applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3) } const historyControls = bindHistoryControls({ @@ -532,6 +539,7 @@ export async function mountRouteEditor( curveHint, contourHint, commit: () => { + if (stationSnap) return applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 놓을 때(366-3) history?.commit(snapshotNow()); historyControls.sync(); }, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 5e8ed273a..13021c9e8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -85,7 +85,7 @@ export function createRouteEditChrome( + title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅