From cfeca9c647e628fa49107796d4a3aad3af683b67 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:59:27 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EF=BB=BFfeat(B05=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=ED=8E=B8=EC=A7=91):=20345-5=20=ED=8F=89=EB=A9=B4=20=EC=95=88?= =?UTF-8?q?=20=EC=A2=85=EB=8B=A8=20=EB=9D=A0=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=E2=80=94=20=EC=9B=90=EC=A7=80=EB=B0=98=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=A2=85=EB=8B=A8=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20=EB=9D=A0(=5FRouteEdit=5FProfileStrip)=20?= =?UTF-8?q?=C2=B7=20=EB=9D=A0=20=EC=B9=B8=20=C2=B7=20CSS=20=C2=B7=20?= =?UTF-8?q?=EB=9D=A0=20=EC=A0=84=EC=9A=A9=20=EC=9A=94=EC=B2=AD(/route/prof?= =?UTF-8?q?ile-preview=20=C2=B7=20fetchProfilePreview=20=C2=B7=20balanced?= =?UTF-8?q?=5Fprofile=5Fof=20terrain)=20=C2=B7=20=EB=88=88=EA=B8=88=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=EC=A2=85=EB=8B=A8=20=ED=8C=90=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [노선 반영] 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙) - 시험: test_75 → 띠 없음 시험 · test_133_1 분석 지형 셋 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Api_Replan.ts | 21 +--- .../B05_Profile_Router_RouteInitial.py | 61 +-------- B05_Profile/B05_Profile_UI_RouteEdit.ts | 23 +--- .../B05_Profile_UI_RouteEdit_Chrome.ts | 8 +- .../B05_Profile_UI_RouteEdit_ProfileStrip.ts | 116 ------------------ ..._Profile_UI_RouteEdit_Recommend_Profile.ts | 12 -- .../B05_Profile_UI_Style_RouteEdit.css | 56 --------- .../tester/test_133_1_analysis_terrain.py | 7 +- resources/tester/test_75_profile_strip.py | 98 +++------------ 9 files changed, 28 insertions(+), 374 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 90e775521..fcfcd16e7 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -5,10 +5,9 @@ * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - * POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠) * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) * - * 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 + * 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 * 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음. * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) @@ -260,21 +259,3 @@ export async function resetRoutePlan(projectId: string): Promise { - const payload = await requestJson<{ profile: unknown }>( - `/projects/${projectId}/route/profile-preview`, - { - method: "POST", - body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }), - }, - 120000, - ); - return payload.profile; -} diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 2da4ffe04..7a7de9076 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -9,11 +9,10 @@ import functools import logging import time from pathlib import Path -from typing import Any, Literal +from typing import Any from uuid import UUID from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice @@ -277,19 +276,12 @@ async def balanced_profile_of( points: list[dict[str, float]], criteria: Any, design_curves: list[dict[str, Any]] | None = None, - terrain: Literal["las", "sheet"] | None = None, ) -> dict[str, Any] | None: - """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀. - - `terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인). - """ + """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 몫(지금 설계 지형).""" from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR - from common_util.common_util_surface_confirmation import ( - analysis_terrain_params, - get_surface_confirmation_params, - ) + from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler grade_class, design_speed, terrain_type = await replan._road_settings(project_id) @@ -299,9 +291,8 @@ async def balanced_profile_of( pool = get_db_pool() async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) - selection = analysis_terrain_params(selection, terrain) try: - # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값 + # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) surface = await asyncio.to_thread( build_surface_sampler, project_root / _MODELS_SUBDIR, @@ -358,47 +349,3 @@ async def refresh_initial_snapshot( logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id) except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다 logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id) - - -class ProfilePreviewRequest(BaseModel): - """편집 창 종단 띠(PLAN 75-1) — 지금 노드 · 지금 추천 제약.""" - - vertices: list[replan.RouteVertexInput] = Field(..., min_length=2) - criteria: replan.RouteInitialCriteria | None = None - terrain: Literal["las", "sheet"] | None = None - - -@replan.router.post("/{project_id}/route/profile-preview", response_model=None) -async def profile_preview( - project_id: UUID, request: ProfilePreviewRequest -) -> dict[str, Any] | JSONResponse: - """지금 노드의 균형 종단 — [확인]과 같은 폴리라인(같은 R · CSV 자릿수) · 같은 셈. - - 파일 · DB 를 쓰지 않는다. 「종단 반영」 켬일 때 편집 창 띠가 노드를 놓을 때마다 부른다. - """ - from common_util.common_util_route_polyline import build_planned_polyline - - paths = await replan._project_paths(project_id) - if paths is None: - return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING) - project_root, _ = paths - radius_m, _, _ = await replan._plan_criteria(project_id) - built = build_planned_polyline( - [(v.x, v.y) for v in request.vertices], - min_radius_m=radius_m, - simplify=False, - curve_flags=[bool(v.curve) for v in request.vertices], - radii=[v.radius_m for v in request.vertices], - ) - # [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로. - points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices] - curves = [curve.as_dict() for curve in built.curves] - profile = await balanced_profile_of( - project_id, project_root, points, request.criteria, curves, request.terrain - ) - if profile is None: - return JSONResponse( - status_code=409, - content={"status": "error", "message": "이 노선으로 균형 종단을 세우지 못했습니다."}, - ) - return {"status": "success", "project_id": str(project_id), "profile": profile} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index b2a88106c..84143a10a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -68,7 +68,6 @@ import { writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial"; -import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip"; import { applyArcLocks, applyCurveLimits, @@ -277,22 +276,6 @@ export async function mountRouteEditor( planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); - /** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */ - const strip = createProfileStrip({ - root: overlay.querySelector("[data-strip]")!, - projectId, - withProfile: initial.withProfile, - now: () => ({ - vertices: planned.map(([x, y], index) => ({ - x, - y, - curve: curveOn[index] !== false, - radius_m: curveRadius[index] ?? null, - })), - line: plannedLine.length ? plannedLine : planned, - criteria: initial.current().criteria, - }), - }); // [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림. overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => { const snapshot = history?.reset(); @@ -463,7 +446,6 @@ export async function mountRouteEditor( draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); - strip.refresh(); } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ @@ -544,7 +526,6 @@ export async function mountRouteEditor( commit: () => { history?.commit(snapshotNow()); historyControls.sync(); - strip.refresh(); }, }); @@ -594,7 +575,7 @@ export async function mountRouteEditor( terrain: pickedTerrain, stationSnap: () => stationSnap, initial, - profileShown: strip.settle, + profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함 onApplied, }); @@ -682,7 +663,6 @@ export async function mountRouteEditor( useBackground(background); measure.clear(); slope.refresh(); - strip.refresh(); void crossPreview.refresh(); showStatus(); draw(); @@ -692,7 +672,6 @@ export async function mountRouteEditor( resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); draw(); - strip.refresh(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 08f3e7d91..cf85dcb7a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -59,7 +59,7 @@ export function createRouteEditChrome( + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts index 4a0c83b1e..6b0563340 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts @@ -3,8 +3,9 @@ * 노선 편집 평면 아래를 **뜬 종단 판**이 덮은 높이를 잰다(345-5 덧). * * 칸 · 판 크기가 바뀔 때마다(창 · 판 높이 끌기 · 접기 · 새 창 떼기) 다시 재서 - * 칸에 `--b05-routeedit-cover`(px)를 적는다 — 상태 · 범례 · 잰 값 글판이 판 바로 위로 오름. - * 맞춤(`_View.openingView`)은 `coveredPx()` 를 읽는다. 셈은 `logic/_RouteEdit_Visible`. + * 칸에 `--b05-routeedit-cover`(px)를 적는다 — 상태 · 범례 · 잰 값 글판이 판 바로 위로 오름 · + * 곡선 바는 `onCoverChange` 로 다시 자리 잡음. + * 맞춤(`_View.fitView`)은 `coveredPx()` 를 읽는다. 셈은 `logic/_RouteEdit_Visible`. * ========================================================================== */ import { coveredBottomPx } from "./logic/B05_Profile_RouteEdit_Visible"; @@ -12,12 +13,18 @@ import { coveredBottomPx } from "./logic/B05_Profile_RouteEdit_Visible"; // ponytail: 편집기는 페이지에 하나 — 덮인 높이 한 값 · 둘이 되면 칸마다 값으로. let covered = 0; const watching = new WeakSet(); +const listeners = new Set<() => boolean>(); /** 지금 평면 아래 덮인 높이(px). */ export function coveredPx(): number { return covered; } +/** 덮인 높이가 바뀌면 부름(곡선 바 자리 · 345-5 덧 2) — `false` 를 돌려주면 뗌(떠난 편집기). */ +export function onCoverChange(listener: () => boolean): void { + listeners.add(listener); +} + /** 같은 가운데 칸의 종단 판 — 새 창으로 떼면 없음. */ function panelOf(wrap: HTMLElement): HTMLElement | null { return ( @@ -31,8 +38,11 @@ function span(element: HTMLElement): { top: number; bottom: number } { } function measure(wrap: HTMLElement, panel: HTMLElement | null): void { + const before = covered; covered = coveredBottomPx(span(wrap), panel ? span(panel) : null); wrap.style.setProperty("--b05-routeedit-cover", `${covered}px`); + if (covered !== before) + for (const listener of listeners) if (!listener()) listeners.delete(listener); } /** 칸을 지켜본다(여러 번 불러도 한 번만 붙음) · 부를 때마다 한 번 잰다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index b10a00c54..06483dc4b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -22,6 +22,7 @@ import { createCurveLabel, type CurveLabel, } from "./B05_Profile_UI_RouteEdit_Label"; +import { coveredPx, onCoverChange } from "./B05_Profile_UI_RouteEdit_Cover"; /** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ export interface CurveBarState { @@ -145,7 +146,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar { left: rect.left + 8, top: rect.top + 8, right: rect.right - 8, - bottom: rect.bottom - 8, + // 뜬 종단 판 밑은 안 보임 — 판 바로 위까지(345-5 덧 2 · 접으면 0). + bottom: rect.bottom - 8 - coveredPx(), }; const last = planned.length - 1; if (picked === 0 || picked === last) { @@ -202,5 +204,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar { }); } + // 판 끌기 · 접기 — 덮인 높이가 바뀌면 다시 자리 잡음(그리기 없이 · 떠나면 뗌). + onCoverChange(() => (params.container.isConnected ? (sync(), true) : false)); + return { label, sync }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts index 406740ed5..9498c0d06 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts @@ -124,6 +124,15 @@ export function openingView( }); if (seen) return { view: restored, rotationRad }; } + return { view: fitView(meta, view, nodes), rotationRad: 0 }; +} + +/** 노선 전체를 뜬 종단 판 위 보이는 영역 가운데에(회전 0 기준) — 처음 열기 · [맞춤](345-5 덧). */ +export function fitView( + meta: VWorldMeta, + view: ViewState, + nodes: ReadonlyArray, +): ViewState { const xs = nodes.map((vertex) => vertex[0]); const ys = nodes.map((vertex) => vertex[1]); const [xMin, xMax, yMin, yMax] = [ @@ -145,7 +154,7 @@ export function openingView( wantWidthM: Math.max(xMax - xMin, 1) + ROUTE_VIEW_MARGIN_M * 2, wantHeightM: Math.max(yMax - yMin, 1) + ROUTE_VIEW_MARGIN_M * 2, }); - return { view: { ...view, ...fitted }, rotationRad: 0 }; + return { ...view, ...fitted }; } /** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */ diff --git a/resources/tester/test_345_5b_plan_visible_area.py b/resources/tester/test_345_5b_plan_visible_area.py index 1c86636e7..4fc02a0c2 100644 --- a/resources/tester/test_345_5b_plan_visible_area.py +++ b/resources/tester/test_345_5b_plan_visible_area.py @@ -1,4 +1,4 @@ -"""345-5 덧 — 노선 편집 평면의 보이는 영역(뜬 종단 판 위) · 노선 맞춤 · 글판 자리. +"""345-5 덧 — 노선 편집 평면의 보이는 영역(뜬 종단 판 위) · 노선 맞춤 · 글판 · 곡선 바 자리. `logic/B05_Profile_RouteEdit_Visible.ts` 를 tsc 로 옮겨 node 로 돌림 · 배선 · CSS 는 글로 봄. ./venv/Scripts/python.exe -m pytest resources/tester/test_345_5b_plan_visible_area.py -q @@ -130,3 +130,21 @@ def test_배선과_글판_자리() -> None: # 접힌 판은 높이 0 — 덮인 높이도 0 이 됨 style = (B05 / "B05_Profile_UI_Style.css").read_text(encoding="utf-8") assert ".b05-route-profile.is-collapsed {\n height: 0;" in style + + +def test_곡선_바는_판_위_맞춤_단추(): + """345-5 덧 2 — 곡선 바 아래 한계 = 판 바로 위 · 판 끌기 · 접기 따라감 · [맞춤] 단추.""" + bar = (B05 / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text(encoding="utf-8") + assert "bottom: rect.bottom - 8 - coveredPx()," in bar + assert "onCoverChange(() => (params.container.isConnected ? (sync(), true) : false));" in bar + cover = (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").read_text(encoding="utf-8") + assert "if (covered !== before)" in cover and "listeners.delete(listener)" in cover + chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") + assert 'data-act="fit"' in chrome and ">맞춤" in chrome + editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + at = editor.index("""overlay.querySelector('[data-act="fit"]')!""") + handler = editor[at : at + 260] + assert "view = fitView(meta, view, planned);" in handler and "rotation.set(0);" in handler + assert len(editor.splitlines()) <= 700 + view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8") + assert "return { view: fitView(meta, view, nodes), rotationRad: 0 };" in view