diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 7ce332dd7..748436186 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -328,13 +328,15 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심 * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. * `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6) - * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */ + * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다 · `bbox` = route 의 노선 대신 편집 중 노선 + * bbox(사업지 m `x0,y0,x1,y1` · 508-2). */ export async function fetchCachedSheetLayer( projectId: string, layer: string, scope?: SheetScope, + bbox?: string, ): Promise { - const range = scope ? `&scope=${scope}` : ""; + const range = (scope ? `&scope=${scope}` : "") + (bbox ? `&bbox=${bbox}` : ""); return fetchCachedJson( projectId, `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`, diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 5b6058c80..9f86cdfd4 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -17,6 +17,7 @@ import type { DesignPageHost } from "./design_page"; import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; import { createViewer3dModal } from "./design_view_profile_3d_modal"; import { pageRouteId } from "./design_page_route"; +import { calcOnServer } from "./design_calc_sites"; export interface Profile3dDeps { projectId: string; @@ -67,7 +68,7 @@ export function createProfile3d(deps: Profile3dDeps) { if (!detail || !latest?.route || routeId === null) return; const routePoints = latest.route_points ?? []; if (routePoints.length <= 1) return; - await refreshCorridor( + const fresh = await refreshCorridor( viewer, projectId, routeId, @@ -77,7 +78,7 @@ export function createProfile3d(deps: Profile3dDeps) { ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). deps.sections().renderStationLines(detail); - deps.sections().setCorridorPending(false); + deps.sections().setCorridorPending(!fresh); // D3 = 서버 짓기 실패 = 늦음 남김(508-2) } async function applyContours(interval: number): Promise { @@ -134,8 +135,10 @@ export function createProfile3d(deps: Profile3dDeps) { loadTerrain, visible: () => deps.host.visible(), // 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다. + // D3 = 서버면 [3D 업데이트]만 다시 지음(요구 324) — 열 때는 옛 모형 그대로. onOpen: () => { - if (state.corridorPending) void refreshOnce(); + if (state.corridorPending) + void calcOnServer("corridor_mesh").then((server) => void (server || refreshOnce())); }, }); diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py index cde050a88..92526711a 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py @@ -6,7 +6,8 @@ - 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠) - `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로 - 나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정). + 나가면 화면이 놓은 뒤 편집 중 노선 bbox(`route_bounds` · 508-2)로 다시 받는다 + (`clip_lonlat` 으로 판정). - `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2) + `DRAIN_SPARE_M`. - 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위) @@ -144,9 +145,25 @@ def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float, return points -def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None: - """범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None.""" - route = _bounds(_route_points(project_root)) +def parse_route_bbox(text: str | None) -> Rect | None: + """화면이 보낸 편집 중 노선 bbox(`x0,y0,x1,y1` m) — 숫자 넷 · 차례 맞음만 · 아니면 None.""" + try: + values = [float(part) for part in (text or "").split(",")] + except ValueError: + return None + if len(values) != 4 or not all(math.isfinite(v) for v in values): + return None + if values[0] > values[2] or values[1] > values[3]: + return None + return values[0], values[1], values[2], values[3] + + +def clip_rect_m( + project_root: Path, scope: str, meta: dict[str, Any], route_bounds: Rect | None = None +) -> Rect | None: + """범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None. + `route_bounds` = 저장 노선 대신 쓸 노선 bbox(편집 중 · 508-2).""" + route = route_bounds or _bounds(_route_points(project_root)) if route is None: return None if scope == "route": @@ -214,11 +231,18 @@ def _write_compact(path: Path, value: Any) -> None: raise -def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path: - """`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로.""" +def clipped_sheet( + project_root: Path, source: Path, scope: str, route_bounds: Rect | None = None +) -> Path: + """`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로. + 열쇠 = 사본 지문 + 범위 — 같은 범위면 노선을 어디서 재든 같은 저장본(다시 안 셈).""" try: meta = _read_meta(project_root / "B04_PreProcess" / "processed") - rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None + rect_m = ( + clip_rect_m(project_root, scope, meta, route_bounds) + if meta and scope in SCOPES + else None + ) if rect_m is None or meta is None: return source west, south = _to_lonlat(meta, rect_m[0], rect_m[1]) diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index db067e4ab..42ad22b43 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -18,7 +18,10 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) -from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402 +from B04_PreProcess.B04_PreProcess_Engine_SheetClip import ( # noqa: E402 + clipped_sheet, + parse_route_bbox, +) # 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일) from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402 @@ -98,13 +101,18 @@ async def get_vworld_map( # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) async def get_project_geojson( - project_id: UUID, layer: str, request: Request, scope: str | None = None + project_id: UUID, + layer: str, + request: Request, + scope: str | None = None, + bbox: str | None = None, ) -> dict[str, Any] | Response | JSONResponse: """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. `scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6). + `bbox`(route 만 · 사업지 m `x0,y0,x1,y1`) = 저장 노선 대신 편집 중 노선 bbox(508-2). """ pool = get_db_pool() try: @@ -158,7 +166,10 @@ async def get_project_geojson( # 그대로고 뜻 없는 자리만 사라진다. display = await asyncio.to_thread(_display_copy, filepath) if scope: - display = await asyncio.to_thread(clipped_sheet, project_root, display, scope) + bounds = parse_route_bbox(bbox) if scope == "route" else None + display = await asyncio.to_thread( + clipped_sheet, project_root, display, scope, bounds + ) return cached_file_response(request, display, "application/geo+json") if layer == "등고선": diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index cd1ef7aed..7549b8d43 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -9,6 +9,8 @@ * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가 * 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 · * 올리지 않음. + * 4) 계산 자리 D3 = 서버(508-2 · 요구 324) — 낡은 저장본도 받아 옛 모형으로 세워 둠(빈 3D 아님) · + * [3D 업데이트]만 서버가 지금 입력으로 지어 바꿔 끼움(지문 같으면 그대로) · 짓기 실패 = 옛 모형 · 「늦음」 남김. * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). * ========================================================================== */ @@ -21,6 +23,7 @@ import { } from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; @@ -201,8 +204,29 @@ function withSections( return build; } +/** 낡은 저장본 그대로(열쇠 안 맞춤 · D3 = 서버 · 508-2) — 보관함에 안 담음(낡은 17MB 가 안 쌓이게). */ +async function fetchStaleStored( + projectId: string, + routeId: number, +): Promise { + try { + const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { + method: "GET", + }); + if (!response.ok) return null; + const payload = (await response.json()) as CorridorEnvelope; + if (payload?.version !== ENVELOPE_VERSION || !Array.isArray(payload.ribbons)) return null; + const build = deserialize(payload); + remember(keyOf(projectId, routeId), { hash: payload.hash, build }); + return build; + } catch { + return null; + } +} + /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). - * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ + * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). + * 돌려줌 = 3D 가 지금 입력과 맞나 — D3 = 서버 짓기 실패면 거짓 · 옛 모형 그대로(508-2). */ export async function refreshCorridor( viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, projectId: string, @@ -211,13 +235,18 @@ export async function refreshCorridor( routePoints: RoutePoint[], designSamples?: ProfileSamples, rebuild = true, -): Promise { - if (!routeId) return; +): Promise { + if (!routeId) return false; // [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1) if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0); + const keepOld = await calcOnServer("corridor_mesh"); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(withSections(build, detail))) - .catch(() => viewer.setCorridor(null)); + .then((build) => { + if (!build && keepOld) return false; + viewer.setCorridor(withSections(build, detail)); + return true; + }) + .catch(() => (keepOld ? false : (viewer.setCorridor(null), true))); } /** @@ -235,8 +264,13 @@ export function loadCorridorIfFresh( ): Promise { if (!routeId) return Promise.resolve(false); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) - .then((build) => { + .then(async (build) => { if (build) viewer.setCorridor(withSections(build, detail)); + // D3 = 서버 — 낡았으면 옛 저장본을 세워 둠(빈 3D 대신 · 「늦음」 표시는 부르는 쪽 · 508-2) + else if (await calcOnServer("corridor_mesh")) { + const stale = await fetchStaleStored(projectId, routeId); + if (stale) viewer.setCorridor(withSections(stale, detail)); + } return build !== null; }) .catch(() => false); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts index fe9cf0958..0f2629266 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -3,12 +3,14 @@ * 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드 * 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다. * 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다. + * 계산 자리 D3 = 서버면 워커 대신 서버 창구(`/calc` corridor.mesh · 같은 TS)가 지음(508-2 · [3D 업데이트] 때만). * ========================================================================== */ import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { deserialize, type CorridorEnvelope } from "./B05_Profile_UI_Corridor_Envelope"; import type { CorridorWorkerRequest, CorridorWorkerResponse, @@ -49,14 +51,40 @@ function getWorker(): Worker | null { return worker; } +/** 서버가 같은 입력으로 지음(508-2) — 계획선 샘플 = 넘긴 값(라이브 편집분) · 실패 = null(부르는 쪽이 옛 모형을 둠). */ +async function buildOnServer( + crossSections: CrossSection[], + routePoints: RoutePoint[], + designSamples?: Samples, +): Promise { + const detail = { + cross_sections: crossSections, + longitudinal: { design_profiles: [{ samples: designSamples }] }, + }; + try { + // 서버 갈래만 부름(both 묶음 · 워커 시험에 안 실림) + const { serverCalc } = await import("../A00_Common/design/design_calc_server"); + const results = await serverCalc("corridor.mesh", ["corridor.mesh"], { + detail, + route_points: routePoints, + }); + const envelope = results["corridor.mesh"] as CorridorEnvelope | null; + return envelope ? deserialize(envelope) : null; + } catch (error) { + console.warn("[corridor] 서버 짓기 실패", error); + return null; + } +} + /** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). - * 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */ + * 계산 자리 판(504-2) D3 = server 면 서버 창구가 지음(508-2 · 워커 안 띄움). */ export async function buildCorridorOffThread( crossSections: CrossSection[], routePoints: RoutePoint[], designSamples?: Samples, ): Promise { - if (await calcOnServer("corridor_mesh")) return null; + if (await calcOnServer("corridor_mesh")) + return buildOnServer(crossSections, routePoints, designSamples); const onMain = (): CorridorBuildResult | null => buildCorridor(crossSections, routePoints, designSamples); const target = getWorker(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 042f952c1..5bef30fc6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -449,6 +449,7 @@ export async function fetchDrainageLayers( projectId: string, onProgress: (ratio: number) => void, scope: SheetScope | null = "drainage", + bbox?: string, ): Promise<{ meta: VWorldMeta; layers: Array; @@ -463,6 +464,7 @@ export async function fetchDrainageLayers( projectId, layer, scope ?? undefined, + bbox, ); return [layer, collection] as const; } catch { diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index f754a64f9..1997915b0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ @@ -81,7 +82,10 @@ export function createViewControls(callbacks: ViewControlCallbacks): { "3D 업데이트", () => { corridorRefresh.disabled = true; - corridorRefresh.textContent = "갱신 중…"; + // D3 = 서버 = 서버가 짓는 동안 옛 모형 그대로(508-2) + corridorRefresh.textContent = calcOnServerNow("corridor_mesh") + ? "서버에서 짓는 중…" + : "갱신 중…"; void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { corridorRefresh.disabled = false; corridorRefresh.textContent = "3D 업데이트"; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 328e5c691..fc9e6b628 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -25,9 +25,11 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { showToast } from "@ui/ui_template_elements"; import { bindMinorContourToggle, + createSheetFollow, fetchRouteSheets, loadRouteEditBackground, type RouteEditContours, + type SheetLayers, } from "./B05_Profile_UI_RouteEdit_Contour"; import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { @@ -227,6 +229,8 @@ export async function mountRouteEditor( mapRect: computeMapRect(null, 0, 0), }; let closed = false; + /** 놓은 뒤 노선이 도엽 범위 밖이면 다시 받음(508-2 ③) — 자료를 읽은 뒤 섬. */ + let sheetFollow: ReturnType | null = null; /** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */ const rotation = createMapRotation({ overlay, @@ -251,6 +255,7 @@ export async function mountRouteEditor( unbindApply(); // 노선 저장 등록부에서 풂(445-2) viewMemory.flush(); closed = true; + sheetFollow?.dispose(); sizeWatch.disconnect(); themeWatch(); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다. @@ -449,6 +454,7 @@ export async function mountRouteEditor( draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); + sheetFollow?.poke(); } /** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */ @@ -607,7 +613,7 @@ export async function mountRouteEditor( try { const [plan, drainage, sources] = await Promise.all([ fetchRoutePlan(projectId), - fetchRouteSheets(projectId, () => plannedLine), + fetchRouteSheets(projectId, () => (planned.length ? planned : plannedLine)), // 띠 = 편집 노드 fetchTerrainSources(projectId).catch(() => null), ]); if (closed) return; @@ -642,13 +648,20 @@ export async function mountRouteEditor( terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`; // 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. // 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽). - const loadBackground = () => - loadRouteEditBackground(projectId, drainage, { - surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null, - intervalM: options.contourIntervalM ?? 1, - smooth: options.smooth ?? false, - }); + let sheets: SheetLayers = drainage; + const loadBackground = (reuse: RouteEditContours | null = null) => + loadRouteEditBackground( + projectId, + sheets, + { + surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null, + intervalM: options.contourIntervalM ?? 1, + smooth: options.smooth ?? false, + }, + reuse, + ); const useBackground = (background: Awaited>): void => { + sheets = background.sheets; otherSheets = background.otherSheets; contours = background.contours; pickedContour = contourLock.reattach(contours?.layer ?? null); @@ -667,6 +680,15 @@ export async function mountRouteEditor( }; useBackground(await loadBackground()); if (closed) return; + sheetFollow = createSheetFollow( + projectId, + () => sheets, + async (next) => { + sheets = next; + const background = await loadBackground(contours); + if (!closed) (useBackground(background), draw()); + }, + ); viewWanted = true; resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 0805c6e14..1ae97349d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -23,6 +23,7 @@ import { prepareMetricPolylines, } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { QUIET_MS } from "../A00_Common/design/design_calc_server"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render"; @@ -128,7 +129,21 @@ export function sheetCoversBand( ); } -type SheetLayers = { +/** 범위 눈금(m) — 서버 `SNAP_M` 과 같음 · 노선을 조금 옮겨도 같은 주소 · 같은 저장본. */ +const SHEET_SNAP_M = 50; + +/** 편집 중 노선 bbox(`x0,y0,x1,y1` m · 눈금 바깥 맞춤) — 서버가 띠 + 여유를 더해 자름(508-2). */ +export function routeBboxParam(line: ReadonlyArray): string { + const xs = line.map((point) => point[0]); + const ys = line.map((point) => point[1]); + const down = (value: number): number => Math.floor(value / SHEET_SNAP_M) * SHEET_SNAP_M; + const up = (value: number): number => Math.ceil(value / SHEET_SNAP_M) * SHEET_SNAP_M; + return [down(Math.min(...xs)), down(Math.min(...ys)), up(Math.max(...xs)), up(Math.max(...ys))] + .map(String) + .join(","); +} + +export type SheetLayers = { meta: VWorldMeta; layers: ReadonlyArray; /** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */ @@ -143,36 +158,84 @@ export async function fetchRouteSheets( return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line }; } +/** 지금 노선 띠를 덮는 도엽 — 덮으면 `clipped` 그대로 · 밖이면 지금 노선 범위로 잘라 다시 받음(508-2 ③). */ +export async function coveringSheets( + projectId: string, + clipped: SheetLayers, +): Promise { + const line = clipped.line?.() ?? []; + const covered = clipped.layers.every(([, collection]) => + sheetCoversBand(collection, clipped.meta, line, CONTOUR_BAND_M), + ); + if (covered) return clipped; + const bbox = routeBboxParam(line); + return { ...(await fetchDrainageLayers(projectId, () => {}, "route", bbox)), line: clipped.line }; +} + /** * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). * 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의 - * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다. + * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 지금 노선 범위로 다시 받는다(508-2). + * `reuse` = 앞 등고선 — LAS 것이면 그대로(도엽 범위와 상관없음 · 다시 안 읽음). */ export async function loadRouteEditBackground( projectId: string, clipped: SheetLayers, options: { surfaceModelId: number | null; intervalM: number; smooth: boolean }, -): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> { - // ponytail: 여는 때 · 지형 바꿀 때만 잼 — 편집 도중 여유 밖으로 끌면 다음 열 때 통째로 받음. - const drainage = clipped.layers.every(([, collection]) => - sheetCoversBand(collection, clipped.meta, clipped.line?.() ?? [], CONTOUR_BAND_M), - ) - ? clipped - : await fetchDrainageLayers(projectId, () => {}, null); + reuse: RouteEditContours | null = null, +): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> { + const drainage = await coveringSheets(projectId, clipped); const normalizer = createNormalizer(drainage.meta); const otherSheets = drainage.layers .filter(([layer]) => layer !== "도엽_등고선") .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) .filter((layer): layer is PreparedLayer => layer !== null); const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null; - const contours = await loadRouteEditContours( - projectId, - drainage.meta, - normalizer, - sheet, - options, - ); - return { otherSheets, contours }; + const contours = + reuse?.source === "las" + ? reuse + : await loadRouteEditContours(projectId, drainage.meta, normalizer, sheet, options); + return { otherSheets, contours, sheets: drainage }; +} + +/** + * 놓은 뒤(잠잠 `QUIET_MS`) 편집 노선 띠가 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아 + * 바꿔 끼움(508-2 ③) — 바꿔 끼우는 곳 = `swap` 한 자리. 받는 중 새 걸음은 끝난 뒤 다시 잼. + */ +export function createSheetFollow( + projectId: string, + current: () => SheetLayers | null, + swap: (next: SheetLayers) => Promise, +): { poke: () => void; dispose: () => void } { + let timer: number | undefined; + let running = false; + let again = false; + let disposed = false; + const check = async (): Promise => { + const sheets = current(); + if (!sheets || disposed) return; + if (running) return void (again = true); + running = true; + try { + const next = await coveringSheets(projectId, sheets); + if (next !== sheets && !disposed) await swap(next); + } catch { + /* 못 받으면 지금 바탕 그대로 — 다음 놓기에 다시 잼. */ + } finally { + running = false; + if (again && !disposed) ((again = false), void check()); + } + }; + return { + poke: () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void check(), QUIET_MS); + }, + dispose: () => { + disposed = true; + window.clearTimeout(timer); + }, + }; } /** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index a58cbe090..8033af8c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -4,7 +4,7 @@ * 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 · * 못 지킨 구간 · 오른쪽 위 [적용] · [닫기]. * 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값. - * [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`). + * [재계산] = 저장본 없음(409) 안내 줄 옆 · 칸을 바꾼 뒤 종단 반영 오른쪽에만(508-2) — 서버 저장본을 지우고 새로 셈(`recompute`). * * 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은 * 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는 @@ -129,6 +129,8 @@ const CRITERIA_FIELD: Record< }; const TERRAIN_NAME: Record = { las: "라이다", sheet: "수치지형도" }; +/** 서버 `NOT_PREPARED`(409 · 505-1) 글 머리 — 이 글이면 [재계산]을 안내 줄 옆에. */ +const NOT_PREPARED_HEAD = "추천 저장본이 없습니다"; export interface RecommendParams { /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ @@ -184,6 +186,8 @@ export function openRecommendModal(params: RecommendParams): void { let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; + /** 마지막 셈이 저장본 없음(409 · 505-1) — [재계산]을 안내 줄 옆에 보임(508-2). */ + let missing = false; let closed = false; let seq = 0; /** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */ @@ -439,7 +443,10 @@ export function openRecommendModal(params: RecommendParams): void { input.disabled = busy; }); q("[data-target-row]").hidden = logic !== "gradeline"; - // 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421) + // 저장본 없음 = 안내 줄 옆 · 칸을 바꿈 = 종단 반영 오른쪽 · 그 밖엔 숨김(508-2) + rerun.hidden = !(missing || dirty); + if (missing) stateEl.after(rerun); + else profileToggle.after(rerun); rerun.disabled = busy || invalid.size > 0; terrainSelect.disabled = busy; profileToggle.disabled = busy; @@ -517,6 +524,7 @@ export function openRecommendModal(params: RecommendParams): void { const withProfile = profileOn; busy = true; dirty = false; + missing = false; preview = null; // 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4). const started = Date.now(); @@ -573,6 +581,7 @@ export function openRecommendModal(params: RecommendParams): void { // 서버 사유를 판에 남김 — 저장본 없음이면 「전처리를 다시 하면 생깁니다」(505-1) const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다."; stateEl.textContent = message; + missing = message.includes(NOT_PREPARED_HEAD); showToast(message, "error"); } finally { if (mine === seq) window.clearInterval(ticker); diff --git a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts index dbbe75cd1..c2240f779 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts @@ -1,8 +1,18 @@ +import type { Layer } from '../App.types'; + +/** 도면층 목록 + 현재 층 — LAYMRG · LAYDEL 이 객체 변경과 함께 담는다 */ +export interface LayerSnapshot { + layers: Layer[]; + activeLayerId: string; +} + export interface UndoState { variable: StateVariable; // biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types value: any; + /** 이 단계가 도면층도 바꿨다면 전 · 후 — 되돌리면 before · 다시 하면 after 를 복원 */ + layerChange?: { before: LayerSnapshot; after: LayerSnapshot }; } export enum StateVariable { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index ad64ff267..57e005795 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -12,7 +12,12 @@ import { import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; import { bumpSceneVersion } from './helpers/scene-version'; -import { StateVariable, type UndoState, createStack } from './helpers/undo-stack'; +import { + type LayerSnapshot, + StateVariable, + type UndoState, + createStack, +} from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt'; @@ -626,20 +631,43 @@ export function resetUndoBaseline() { drawingDirty = false; } +export const getLayerSnapshot = (): LayerSnapshot => ({ layers, activeLayerId }); + +/** + * 방금 쌓은 객체 되돌리기 단계에 도면층 전 · 후를 붙인다(LAYMRG · LAYDEL) — + * Ctrl+Z 한 번에 객체와 도면층이 함께 돌아오고 Ctrl+Y 도 짝으로 간다. + * 맨 위 단계가 지금 객체 목록이 아니면(쌓이지 않음) 붙이지 않는다. + */ +export function attachLayerChangeToUndo(before: LayerSnapshot, after: LayerSnapshot) { + const top = undoStack.peek(); + if (top?.variable === StateVariable.entities && top.value === entities) { + top.layerChange = { before, after }; + } +} + +function applyLayerSnapshot(snapshot: LayerSnapshot) { + setLayers(snapshot.layers); + setActiveLayerId(snapshot.activeLayerId); +} + export function undo() { + const undone = undoStack.peek(); const undoState = undoStack.undo(); if (!undoState) return; updateStates(undoState); + if (undone !== undoState && undone?.layerChange) applyLayerSnapshot(undone.layerChange.before); drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function redo() { + const current = undoStack.peek(); const redoState = undoStack.redo(); if (!redoState) return; updateStates(redoState); + if (current !== redoState && redoState.layerChange) applyLayerSnapshot(redoState.layerChange.after); drawingDirty = true; notifyWindow(HtmlEvent.DRAWING_CHANGED); } diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts new file mode 100644 index 000000000..75d042686 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.test.ts @@ -0,0 +1,88 @@ +import { Point } from '@flatten-js/core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { createActor } from 'xstate'; +import type { Layer } from '../../App.types'; +import { LineEntity } from '../../entities/LineEntity.ts'; +import { + getActiveLayerId, + getEntities, + getLayers, + redo, + resetUndoBaseline, + setActiveLayerId, + setEntities, + setLayers, + setScreenCanvasDrawController, + undo, +} from '../../state.ts'; +import { ActorEvent } from '../tool.types.ts'; +import { layerDeleteToolStateMachine, layerMergeToolStateMachine } from './layer-tools.ts'; + +const layer = (id: string): Layer => ({ id, name: id, isVisible: true, isLocked: false }); + +/** 선 A(레이어 A · y=0) · 선 B(레이어 B · y=100) — 활성층은 A */ +function setup() { + const a = new LineEntity('A', new Point(0, 0), new Point(10, 0)); + const b = new LineEntity('B', new Point(0, 100), new Point(10, 100)); + setLayers([layer('A'), layer('B')]); + setActiveLayerId('A'); + setEntities([a, b]); + resetUndoBaseline(); +} + +const click = (actor: ReturnType, y: number) => + actor.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation: new Point(5, y), + screenMouseLocation: new Point(0, 0), + holdingCtrl: false, + holdingShift: false, + }); + +const layerOfEntities = () => getEntities().map((entity) => entity.layerId); +const layerIds = () => getLayers().map((l) => l.id); + +describe('LAYMRG · LAYDEL 되돌리기 (508-3a)', () => { + beforeAll(() => { + (globalThis as { window?: unknown }).window = { dispatchEvent: () => true }; + // 집기 반경만 필요 — 화면 배율 1 + setScreenCanvasDrawController({ getScreenScale: () => 1, getWorldMouseLocation: () => new Point(0, 0) } as never); + }); + + it('LAYMRG A→B: Ctrl+Z 한 번에 도형 레이어 · 레이어 목록 · 현재 층이 처음대로, Ctrl+Y 로 다시 합쳐짐', () => { + setup(); + const actor = createActor(layerMergeToolStateMachine).start(); + click(actor, 0); + click(actor, 100); + expect(layerOfEntities()).toEqual(['B', 'B']); + expect(layerIds()).toEqual(['B']); + expect(getActiveLayerId()).toBe('B'); + + undo(); + expect(layerOfEntities()).toEqual(['A', 'B']); + expect(layerIds()).toEqual(['A', 'B']); + expect(getActiveLayerId()).toBe('A'); + + redo(); + expect(layerOfEntities()).toEqual(['B', 'B']); + expect(layerIds()).toEqual(['B']); + expect(getActiveLayerId()).toBe('B'); + }); + + it('LAYDEL A: Ctrl+Z 한 번에 지운 도형 · 레이어 A 가 돌아오고, Ctrl+Y 로 다시 지워짐', () => { + setup(); + const actor = createActor(layerDeleteToolStateMachine).start(); + click(actor, 0); + expect(layerOfEntities()).toEqual(['B']); + expect(layerIds()).toEqual(['B']); + + undo(); + expect(layerOfEntities()).toEqual(['A', 'B']); + expect(layerIds()).toEqual(['A', 'B']); + expect(getActiveLayerId()).toBe('A'); + + redo(); + expect(layerOfEntities()).toEqual(['B']); + expect(layerIds()).toEqual(['B']); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts index e80a22ae0..6fd8c7b81 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts @@ -11,9 +11,11 @@ import { saveLayerState, } from '../../helpers/layer-history'; import { + attachLayerChangeToUndo, deleteEntities, getActiveLayerId, getEntities, + getLayerSnapshot, getLayers, setActiveLayerId, setLayers, @@ -29,6 +31,13 @@ function updateLayers(mutate: (layers: Layer[]) => Layer[]): void { setLayers(mutate(current.map((layer) => ({ ...layer })))); } +/** 객체 변경 + 도면층 변경을 되돌리기 한 단계로 묶는다(Ctrl+Z 한 번에 함께 · Ctrl+Y 도 짝) */ +function withLayerUndo(change: () => void): void { + const before = getLayerSnapshot(); + change(); + attachLayerChangeToUndo(before, getLayerSnapshot()); +} + const layerNameOf = (layerId: string): string => getLayers().find((layer) => layer.id === layerId)?.name ?? layerId; @@ -222,15 +231,17 @@ export const layerMergeToolStateMachine = createSequenceTool({ toast.warn('같은 레이어입니다.'); return; } - // 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) · 층 목록 = LAYERP - restyle( - getEntities().filter((entity) => entity.layerId === sourceId), - (entity) => { - entity.layerId = targetId; - } - ); - updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId)); - if (getActiveLayerId() === sourceId) setActiveLayerId(targetId); + // 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) + withLayerUndo(() => { + restyle( + getEntities().filter((entity) => entity.layerId === sourceId), + (entity) => { + entity.layerId = targetId; + } + ); + updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId)); + if (getActiveLayerId() === sourceId) setActiveLayerId(targetId); + }); toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`); }, }); @@ -246,9 +257,11 @@ export const layerDeleteToolStateMachine = createSequenceTool({ return; } const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId); - deleteEntities(doomed, true); - updateLayers((layers) => layers.filter((layer) => layer.id !== layerId)); - if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id); + withLayerUndo(() => { + deleteEntities(doomed, true); + updateLayers((layers) => layers.filter((layer) => layer.id !== layerId)); + if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id); + }); toast.success(`레이어와 객체 ${doomed.length}개를 삭제했습니다.`); }, }); diff --git a/resources/tester/helper_508_2_corridor_server.cjs b/resources/tester/helper_508_2_corridor_server.cjs new file mode 100644 index 000000000..479a8a426 --- /dev/null +++ b/resources/tester/helper_508_2_corridor_server.cjs @@ -0,0 +1,171 @@ +/* 508-2 ① 3D 길 모형 계산 자리 D3 = 서버 — 낡은 저장본 = 옛 모형 세움 · [3D 업데이트] = 서버 짓기 → 바꿔 끼움 · + * 같은 입력 = 다시 안 지음 · 서버 실패 = 옛 모형 그대로 · both = 지금 그대로(옛 모형 안 받음 · 브라우저 짓기). + * 서버 창구(`serverCalc`)는 같은 TS(`buildCorridor` + `serialize`)로 흉내 · 저장본 GET 은 fetch 흉내. + * 측점 꼴 = helper_345_4_corridor_worker.cjs 와 같음. 결과는 JSON 한 벌. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const state = { server: true, fail: false, calls: [], gets: [] }; +globalThis.window = globalThis; +const STUBS = { + design_calc_sites: { + calcOnServer: async (key) => key === "corridor_mesh" && state.server, + calcOnServerNow: (key) => key === "corridor_mesh" && state.server, + }, + design_calc_server: { + QUIET_MS: 30, + serverCalc: async (key, run, inputs) => { + state.calls.push({ + key, + run, + samples: inputs.detail.longitudinal.design_profiles[0].samples, + }); + if (state.fail) throw new Error("HTTP 503"); + const { detail, route_points } = inputs; + const build = buildCorridor( + detail.cross_sections, + route_points, + detail.longitudinal.design_profiles[0].samples, + ); + return { "corridor.mesh": build ? serialize(build, "srv") : null }; + }, + }, + design_pipeline_page: { reconcilePagePipeline: async () => 0 }, + b_asset_cache: { + readCachedBytes: async () => null, + writeCachedBytes: async () => undefined, + purgeAssetsWithPrefix: async () => undefined, + }, +}; +const { loadTs } = makeLoader({ + root: ROOT, + stubs: STUBS, + rewrite: (js) => js.replace(/import\.meta\.url/g, '"file:///x.js"'), +}); +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { serialize, corridorHash } = loadTs(B05("B05_Profile_UI_Corridor_Envelope")); +const corridor = loadTs(B05("B05_Profile_UI_Corridor")); + +const HALF = 3; +const ROAD_Z = 100; +function station(chainage, cx, groundZ, ratio, roadZ = ROAD_Z) { + const w = Math.abs(groundZ - roadZ) * ratio; + const pts = [ + [-40, groundZ], + [-(HALF + w), groundZ], + [-HALF, roadZ], + [HALF, roadZ], + [HALF + w, groundZ], + [40, groundZ], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: cx, + center_y: 0, + center_z: groundZ, + frame: { left_xy: [0, 1] }, + samples, + design: { + design_elevation_m: roadZ, + section_mode: groundZ < roadZ ? "both_fill" : "both_cut", + road_edges: { + left: { offset_m: HALF, elevation_m: roadZ }, + right: { offset_m: -HALF, elevation_m: roadZ }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: roadZ }, + right: { offset_m: -2.5, elevation_m: roadZ }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} +const detailOf = (roadZ) => ({ + cross_sections: [ + station(0, 0, 96, 1.5, roadZ), + station(20, 20, 104, 1.0, roadZ), + station(40, 40, 96, 1.5, roadZ), + ], + longitudinal: { design_profiles: [] }, +}); +const route = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; +const samplesOf = (z) => [0, 20, 40].map((c) => ({ chainage_m: c, elevation_m: z })); +const oldDetail = detailOf(100); +const oldEnvelope = serialize( + buildCorridor(oldDetail.cross_sections, route, samplesOf(100)), + corridorHash(oldDetail, route), +); + +// 저장본 GET 흉내 — 열쇠가 다르면 stale · 열쇠 없으면 저장본 그대로(옛 모형) +globalThis.fetch = async (url) => { + state.gets.push(String(url)); + const hash = new URL(String(url)).searchParams.get("hash"); + const body = + hash && hash !== oldEnvelope.hash ? { status: "stale", hash: oldEnvelope.hash } : oldEnvelope; + return { ok: true, text: async () => JSON.stringify(body), json: async () => body }; +}; + +const viewer = () => { + const shown = []; + return { shown, setCorridor: (build) => shown.push(build ? build.ribbons.length : null) }; +}; +const zOf = (build) => build.ribbons[0].positions[2]; + +(async () => { + const out = {}; + const newDetail = detailOf(101); // 계획고 1 m 올린 초안 — 열쇠 다름 + // ── D3 = 서버 ── + let v = viewer(); + let lastBuild = null; + const spy = { setCorridor: (build) => ((lastBuild = build), v.setCorridor(build)) }; + out.serverFresh = await corridor.loadCorridorIfFresh(spy, "p", 7, newDetail, route); + out.serverEntryShown = v.shown.slice(); + out.serverEntryOldZ = lastBuild ? zOf(lastBuild) : null; + out.serverStaleGet = state.gets.some((url) => !url.includes("hash=")); + out.refreshOk = await corridor.refreshCorridor(spy, "p", 7, newDetail, route, samplesOf(101)); + out.refreshCalls = state.calls.length; + out.refreshSamples = state.calls[0]?.samples?.[0]?.elevation_m ?? null; + out.refreshNewZ = lastBuild ? zOf(lastBuild) : null; + out.refreshShown = v.shown.slice(); + out.againOk = await corridor.refreshCorridor(spy, "p", 7, newDetail, route, samplesOf(101)); + out.againCalls = state.calls.length; // 같은 입력 = 다시 안 지음 + state.fail = true; + const before = v.shown.length; + out.failOk = await corridor.refreshCorridor(spy, "p", 7, detailOf(102), route, samplesOf(102)); + out.failSetCalls = v.shown.length - before; // 0 = 옛 모형 그대로 + out.failZ = zOf(lastBuild); + // ── D3 = both(기본) — 지금 그대로 ── + state.server = false; + state.fail = false; + state.calls = []; + state.gets = []; + v = viewer(); + out.bothFresh = await corridor.loadCorridorIfFresh(v, "p", 8, detailOf(103), route); + out.bothEntryShown = v.shown.slice(); + out.bothStaleGet = state.gets.some((url) => !url.includes("hash=")); + out.bothRefreshOk = await corridor.refreshCorridor( + v, + "p", + 8, + detailOf(103), + route, + samplesOf(103), + ); + out.bothCalls = state.calls.length; + out.bothShown = v.shown.slice(); + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_508_2_sheet_follow.cjs b/resources/tester/helper_508_2_sheet_follow.cjs new file mode 100644 index 000000000..1962a0b45 --- /dev/null +++ b/resources/tester/helper_508_2_sheet_follow.cjs @@ -0,0 +1,102 @@ +/* 508-2 ③ 노선 편집 — 놓은 뒤(잠잠) 노선 띠가 잘라 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아 바꿔 끼움. + * `fetchDrainageLayers` 는 가짜(부른 범위 기록 · 서버처럼 bbox + 600 m 를 clip_lonlat 로) · 잠잠 = 20 ms. + * 메타 = 509-1 헬퍼와 같음(1 m = 0.00001 도). 결과는 JSON 한 벌. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = globalThis; +const META = { + x_min: 0, + y_min: 0, + width_meters: 10000, + height_meters: 10000, + lon_min: 127, + lon_max: 127.1, + lat_min: 37, + lat_max: 37.1, +}; +const lon = (x) => 127 + x * 1e-5; +const lat = (y) => 37 + y * 1e-5; +const clipOf = ([x0, y0, x1, y1]) => [lon(x0 - 600), lat(y0 - 600), lon(x1 + 600), lat(y1 + 600)]; +const calls = []; +const STUBS = { + b_asset_cache: {}, + B04_PreProcess_UI_MapRender: { createNormalizer: () => ({}) }, + B04_PreProcess_UI_MapRender_Prepare: { prepareLayer: () => ({ features: [] }) }, + B05_Profile_UI_Drainage_Parts: { + fetchDrainageLayers: async (_projectId, _progress, scope, bbox) => { + calls.push({ scope, bbox: bbox ?? null }); + const rect = bbox.split(",").map(Number); + return { meta: META, layers: [["도엽_등고선", { features: [], clip_lonlat: clipOf(rect) }]] }; + }, + }, + B05_Profile_UI_RouteEdit_Render: { CONTOUR_BAND_M: 300 }, + design_calc_server: { QUIET_MS: 20 }, +}; +const { load } = makeLoader({ root: ROOT, stubs: STUBS }); +const contour = load("B05_Profile", "B05_Profile_UI_RouteEdit_Contour.ts"); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let line = [ + [5000, 5000], + [5500, 5200], +]; +// 저장 노선으로 자른 첫 도엽(509-1 서버와 같은 범위) +let sheets = { + meta: META, + layers: [["도엽_등고선", { features: [], clip_lonlat: clipOf([5000, 5000, 5500, 5200]) }]], + line: () => line, +}; +const swaps = []; + +(async () => { + const out = {}; + out.bbox = contour.routeBboxParam([ + [5012.3, 4987.1], + [5499.9, 5200.0], + ]); + const follow = contour.createSheetFollow( + "p", + () => sheets, + async (next) => { + swaps.push(next.layers[0][1].clip_lonlat); + sheets = next; + }, + ); + // 범위 안 끌기 — 안 받음 + line = line.map(([x, y]) => [x + 250, y - 250]); + follow.poke(); + await sleep(60); + out.insideCalls = calls.length; + // 범위 밖으로 크게 끌며 여러 걸음 — 잠잠 뒤 한 번만 받음 · 지금 노선 범위 + for (const dx of [400, 800, 1200]) { + line = [ + [5000 + dx, 5000], + [5500 + dx, 5200], + ]; + follow.poke(); + await sleep(5); + } + out.beforeQuiet = calls.length; + await sleep(60); + out.outsideCalls = calls.slice(); + out.swaps = swaps.length; + out.swappedCovers = contour.sheetCoversBand(sheets.layers[0][1], META, line, 300); + out.lineKept = sheets.line === undefined ? false : sheets.line() === line; + // 새 범위 안에서 다시 놓음 — 안 받음 + line = line.map(([x, y]) => [x + 100, y]); + follow.poke(); + await sleep(60); + out.againCalls = calls.length; + // 여는 때(coveringSheets) — 덮으면 같은 것 그대로 + out.coveringSame = (await contour.coveringSheets("p", sheets)) === sheets; + // 떠난 뒤 — 안 받음 + line = line.map(([x, y]) => [x + 3000, y]); + follow.poke(); + follow.dispose(); + await sleep(60); + out.disposedCalls = calls.length; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index da098f250..ab8693852 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -249,9 +249,9 @@ def test_연결_자리_cross_격자_그대로(): assert "calc_sites" not in page and "calcOnServer" not in page worker = _text("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") body = worker.split("export async function buildCorridorOffThread(")[1] - assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( - "getWorker()" - ) + # 508-2 — D3 = 서버면 워커 대신 서버 창구가 지음(옛 기대 = 안 지음 null) + assert 'if (await calcOnServer("corridor_mesh"))' in body + assert body.index("return buildOnServer(") < body.index("getWorker()") assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts") # 저장 길은 판을 안 읽음 for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): diff --git a/resources/tester/test_345_4_corridor_worker.py b/resources/tester/test_345_4_corridor_worker.py index 4a378e427..6c039d498 100644 --- a/resources/tester/test_345_4_corridor_worker.py +++ b/resources/tester/test_345_4_corridor_worker.py @@ -62,9 +62,10 @@ def test_저장본_먼저_초안만_워커(): def test_모달_열_때_낡았으면_다시_짓기(): view3d = _read("A00_Common/design/design_view_profile_3d.ts") - assert "if (state.corridorPending) void refreshOnce();" in view3d + # 508-2 — both 면 열 때 다시 짓기 그대로 · D3 = 서버면 [3D 업데이트]만(요구 324) + assert "void (server || refreshOnce())" in view3d and "if (state.corridorPending)" in view3d assert "onCorridorRefresh: () => refreshOnce()," in view3d - assert "deps.sections().setCorridorPending(false);" in view3d + assert "deps.sections().setCorridorPending(!fresh);" in view3d # 서버 짓기 실패 = 늦음 sections = _read("A00_Common/design/design_view_profile_sections.ts") assert ".then((fresh) => setCorridorPending(!fresh));" in sections assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로 diff --git a/resources/tester/test_383_3_rerun_row.py b/resources/tester/test_383_3_rerun_row.py index e67a07a16..859aef478 100644 --- a/resources/tester/test_383_3_rerun_row.py +++ b/resources/tester/test_383_3_rerun_row.py @@ -1,4 +1,4 @@ -"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 보임 · 늘 켜짐(421). +"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 켜짐(421) · 보임 = 저장본 없음 · 칸 바꿈 때만(508-2). ./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q """ @@ -10,10 +10,10 @@ ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" -def test_재계산_한_줄_늘_보임_늘_켜짐() -> None: +def test_재계산_한_줄_늘_켜짐() -> None: ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8") assert re.search(r'
]*data-act="rerun">재계산', ts) - assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts + assert "rerun.hidden = !(missing || dirty);" in ts # 508-2 — 저장본 없음(409) · 칸 바꿈 때만 assert "rerun.before(profileToggle)" in ts # 종단 반영 토글이 같은 줄 왼쪽(421) assert "rerun.disabled = busy || invalid.size > 0;" in ts css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8") diff --git a/resources/tester/test_508_2_browser.py b/resources/tester/test_508_2_browser.py new file mode 100644 index 000000000..b879b6a3e --- /dev/null +++ b/resources/tester/test_508_2_browser.py @@ -0,0 +1,334 @@ +"""PLAN 508-2 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. + +① 판 D3 = 서버 · 저장 3D 모형 열쇠를 낡게(머리 hash 만 바꾼 사본 · 끝나면 바이트 되돌림) + → 들어오면 옛 모형이 섬 · [3D 업데이트] 「늦음」 · 3D 창을 열어도 안 지음 + · [3D 업데이트] = /calc corridor.mesh 한 번 → 새 모형 · 늦음 꺼짐 · 한 번 더 = /calc 0(같은 입력) +② 추천 노선 창 — 저장본 있는 갈래 = [재계산] 숨김 · 저장본 없음(409) = 안내 줄 옆 [재계산] + · 제약 칸 바꿈 = 종단 반영 오른쪽 [재계산] · [재계산] · [적용]은 안 누름 +③ 노선 편집 — 교각점을 범위(저장 노선 띠 300 + 여유 300 m) 밖으로 끌어 놓으면 잠잠 뒤 bbox 로 + 잘린 도엽 다시 받음 · 받는 양 · 밝음 · 어두움 한 장씩(`tmp/5082/`) · 측점 스냅 끔은 세션만 +- 저장 안 함(세션 초안만) · 판 = 파일 바이트 되돌림 · 시험 브라우저 표식 시스템 로그 행만 지움 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_browser.py -q -s +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 +from test_505_3_cross_card_screen import JS_NODES # noqa: E402 +from test_511_a_calc_sites_browser import _UP, _started # noqa: E402 + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) +MARK = "AisloTest-508-2" +SHOTS = ROOT / "tmp" / "5082" + +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +JS_CORRIDOR = r"""() => { + const s = window.__corridorScene; + const g = s && s.scene.getObjectByName('corridor'); + if (!g) return null; + g.userData.mark ??= Math.random().toString(36).slice(2); + return { mark: g.userData.mark, children: g.children.length }; +}""" +JS_PENDING = r"""() => { + const b = [...document.querySelectorAll('button')] + .find((x) => /3D 업데이트|짓는 중|갱신 중/.test(x.textContent)); + return b ? { text: b.textContent.trim(), pending: b.classList.contains('is-pending') } : null; +}""" +JS_BUILT = ( + "() => ![...document.querySelectorAll('button')].some((b) => /짓는 중/.test(b.textContent))" +) +JS_IDLE = ( + "() => !/셈하는 중/.test(document.querySelector('.b05-routerec [data-state]').textContent)" +) +JS_SHOWN = ( + "() => /미리 보기/.test(document.querySelector('.b05-routerec [data-state]').textContent)" +) +JS_REC = r"""() => { + const m = document.querySelector('.b05-routerec'); + const r = m?.querySelector('[data-act="rerun"]'); + return m && { + state: m.querySelector('[data-state]').textContent, + shown: !!r && !r.hidden, + beside: !!r && r.previousElementSibling?.matches('[data-state]'), + inRow: !!r && !!r.closest('.b05-routerec__row'), + }; +}""" + + +def _project_root() -> Path: + import asyncio + + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection: + return await get_project_storage_relative_path(connection, PROJECT) + finally: + await close_db_pool() + + return Path(resolve_stored_project_path(asyncio.run(run()))) + + +# 플레이라이트 고리 밖에서 한 번(그 안에서는 asyncio.run 못 씀) +PROJECT_ROOT = _project_root() if _UP and _UP == _started(API) else None + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + committed = BOARD.read_bytes() + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + SHOTS.mkdir(parents=True, exist_ok=True) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + try: + yield context + finally: + chrome.close() + finally: + BOARD.write_bytes(committed) + _drop_logs() + + +def _drop_logs() -> None: + import asyncio + + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) + await connection.commit() + finally: + await close_db_pool() + + asyncio.run(run()) + + +def _board(corridor: str | None) -> None: + board = json.loads(BOARD.read_text("utf-8")) + board["sites"]["corridor_mesh"] = corridor or "both" + BOARD.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _open(context, on_response=None): + page = context.new_page() + page.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}');") + if on_response: + page.on("response", on_response) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + return page + + +def test_3D_서버_낡음_옛_모형_단추로_바꿔_끼움(ctx): + root = PROJECT_ROOT + route_id = max( + int(p.stem.split("_")[1]) + for p in (root / "B05_Profile" / "corridor").glob("corridor_*.json") + ) + stored = root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json" + original = stored.read_bytes() + head = original[:4096] + found = re.search(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"', head) + assert found + stale = original.replace(found.group(0), b'"hash":"0badbeef"', 1) + calls: list[str] = [] + + def on_response(res): + if res.url.endswith("/calc") and res.request.method == "POST": + body = json.loads(res.request.post_data) + calls.append("+".join(body["run"])) + + _board("server") + stored.write_bytes(stale) + try: + page = _open(ctx, on_response) + page.get_by_role("button", name="3D", exact=True).first.click() + page.wait_for_timeout(6000) + entry = {"model": page.evaluate(JS_CORRIDOR), "button": page.evaluate(JS_PENDING)} + opened_calls = list(calls) + page.screenshot(path=str(SHOTS / "d3_stale_entry.png")) + button = page.locator("button", has_text="3D 업데이트").first + button.click() + page.wait_for_timeout(300) + during = page.evaluate(JS_PENDING) + page.wait_for_function(JS_BUILT, timeout=120000) + page.wait_for_timeout(1000) + after = { + "model": page.evaluate(JS_CORRIDOR), + "button": page.evaluate(JS_PENDING), + "source": page.evaluate("() => window.__corridorSource?.source ?? null"), + } + page.screenshot(path=str(SHOTS / "d3_rebuilt.png")) + built_calls = list(calls) + page.locator("button", has_text="3D 업데이트").first.click() + page.wait_for_timeout(4000) + again_calls = list(calls) + page.close() + finally: + stored.write_bytes(original) + _board(None) + print("entry", entry, "during", during, "after", after) + print("calls", opened_calls, built_calls, again_calls) + assert entry["model"] and entry["model"]["children"] > 0 # 옛 모형(빈 3D 아님) + assert entry["button"]["pending"] is True + assert "corridor.mesh" not in opened_calls # 창 열 때 안 지음 + assert during["text"] == "서버에서 짓는 중…" + assert built_calls.count("corridor.mesh") == 1 + assert after["model"]["mark"] != entry["model"]["mark"] # 바꿔 끼움 + assert after["button"]["pending"] is False and after["source"] == "built" + assert again_calls.count("corridor.mesh") == 1 # 같은 입력 = 다시 안 지음 + assert stored.read_bytes() == original + + +def test_추천_창_재계산_409_때만(ctx): + page = _open(ctx) + page.get_by_text("노선 추천", exact=True).first.click() + page.wait_for_timeout(800) + page.locator("button", has_text="노선 추천").last.click() + page.wait_for_selector(".b05-routerec", timeout=30000) + seen = [] + for logic in ("follow", "gradeline", "earthwork", "gentle", "weighted"): + page.click(f'.b05-routerec [data-logic="{logic}"]') + page.wait_for_function(JS_IDLE, timeout=120000) + seen.append({"logic": logic, **page.evaluate(JS_REC)}) + terrain = page.locator('.b05-routerec [data-act="terrain"]') + if terrain.is_visible(): + other = "sheet" if terrain.input_value() == "las" else "las" + terrain.select_option(other) + page.wait_for_function(JS_IDLE, timeout=120000) + seen.append({"logic": f"terrain:{other}", **page.evaluate(JS_REC)}) + print(json.dumps(seen, ensure_ascii=False, indent=1)) + missing = [s for s in seen if "저장본이 없습니다" in s["state"]] + if missing: + page.screenshot(path=str(SHOTS / "rec_409_rerun.png")) + # 제약 칸 바꿈(저장본 있는 갈래로 돌아가서) — 종단 반영 오른쪽 + stored = [s for s in seen if s["state"].startswith("미리 보기")] + if stored: + if terrain.is_visible(): + terrain.select_option("las") # 지형 되돌림 — 마지막 갈래(비중) 라이다 저장본 + else: + page.click(f'.b05-routerec [data-logic="{stored[0]["logic"]}"]') + page.wait_for_function(JS_SHOWN, timeout=120000) + field = page.locator(".b05-routerec__criteria input").first + field.fill("7") + page.wait_for_timeout(300) + dirty = page.evaluate(JS_REC) + page.screenshot(path=str(SHOTS / "rec_dirty_rerun.png")) + page.close() + print(json.dumps(seen, ensure_ascii=False, indent=1)) + for s in seen: + if s["state"].startswith("미리 보기"): + assert s["shown"] is False, s + if "저장본이 없습니다" in s["state"]: + assert s["shown"] and s["beside"] and not s["inRow"], s + assert stored, "저장본 있는 갈래 없음" + assert dirty["shown"] and dirty["inRow"] and not dirty["beside"], dirty + if not missing: + pytest.skip("저장본 없는 갈래 없음 — 409 줄은 단위 시험 몫") + + +def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): + got: list[dict] = [] + + def on_response(res): + if "/geojson?" in res.url and "scope=route" in res.url: + got.append({"bbox": "bbox=" in res.url, "status": res.status, "bytes": len(res.body())}) + + # 새 문맥 — 앞 시험 페이지가 남긴 보던 화면 · 보관함 없이(축척 · 받기가 늘 같게) + fresh_ctx = ctx.browser.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + login = {k: cred[k] for k in ("email", "password")} + assert fresh_ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + page = _open(fresh_ctx, on_response) + handle = page.locator(".ui-workflow-overlay__handle--bottom", has_text="종단").first + for _ in range(6): # 종단 접음 — 평면 넓게(배치가 늦게 되살아나면 다시 접음) + if handle.bounding_box()["y"] > 900: + break + handle.click() + page.wait_for_timeout(2500) + page.mouse.move(960, 500) + for _ in range(3): # 줄여 봄 — 오른쪽 끝 교각점을 범위 밖까지 끌 자리 + page.mouse.wheel(0, -300) + page.wait_for_timeout(300) + first = [g for g in got if not g["bbox"]] # 앞 시험이 담아 두었으면 보관함 — 0 일 수 있음 + nodes = [] + for _ in range(20): # 접은 뒤 다시 그릴 때까지(사진 한 장 = 그림 한 번 내보냄) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + nodes = page.evaluate(JS_NODES) + if nodes: + break + page.wait_for_timeout(500) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + assert nodes, "교각점 못 찾음" + page.get_by_role("button", name="측점 스냅", exact=True).click() # 끔(세션만) — 큰 끌기 막음 풂 + page.wait_for_timeout(500) + during = None + drops = 0 + for _ in range(4): # 끌어 놓기 · 화면을 왼쪽으로 옮김 — 범위 밖까지 + node = max(nodes, key=lambda n: n["x"]) # 오른쪽 끝 교각점 — 오른쪽 바깥으로 + page.mouse.move(node["x"], node["y"]) + page.mouse.down() + page.mouse.move(node["x"] + 150, node["y"], steps=8) + page.wait_for_timeout(600) + if during is None: + during = sum(g["bbox"] for g in got) # 끄는 동안(잠잠 시간보다 오래 쥠) + page.mouse.move(1590, node["y"], steps=8) + page.mouse.up() + drops += 1 + page.wait_for_timeout(4000) + if any(g["bbox"] for g in got): + break + page.mouse.move(1500, 900) + page.mouse.down(button="middle") + page.mouse.move(900, 900, steps=10) + page.mouse.up(button="middle") + page.wait_for_timeout(800) + page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) + nodes = page.evaluate(JS_NODES) + assert nodes, "옮긴 뒤 교각점 못 찾음" + status = page.locator(".b05-routeedit__status").first.text_content() + after = list(got) + page.screenshot(path=str(SHOTS / "sheet_follow_light.png")) + page.evaluate("() => document.documentElement.setAttribute('data-theme', 'dark')") + page.wait_for_timeout(1500) + page.screenshot(path=str(SHOTS / "sheet_follow_dark.png")) + page.evaluate("() => document.documentElement.setAttribute('data-theme', 'light')") + page.close() + fresh_ctx.close() + fresh = [g for g in after if g["bbox"]] + print("first", first, "fresh", fresh, "drops", drops, "status", status) + assert during == 0 # 끄는 동안 = 안 받음 + assert len(fresh) == 2 and all(g["bbox"] and g["status"] == 200 for g in fresh) + assert all(g["bytes"] < 5_000_000 for g in fresh) # 통째 사본(36.8 MB) 아님 diff --git a/resources/tester/test_508_2_corridor_server.py b/resources/tester/test_508_2_corridor_server.py new file mode 100644 index 000000000..4c7e87351 --- /dev/null +++ b/resources/tester/test_508_2_corridor_server.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""508-2 ① 3D 길 모형 계산 자리 D3 = 서버(요구 323 · 324). + +- 들어올 때 저장본이 낡았으면 옛 저장본을 세워 둠(빈 3D 아님 · 「늦음」 표시) +- [3D 업데이트] = 서버 창구(`/calc` corridor.mesh · 같은 TS)가 지금 입력(라이브 계획선 샘플)으로 + 지어 바꿔 끼움 +- 같은 입력이면 다시 안 지음 · 서버 실패 = 옛 모형 그대로 · 거짓(늦음 남김) +- 3D 창을 열 때 자동으로 안 지음(서버) · 기본 both = 지금 그대로(옛 저장본 안 받음 · 브라우저 짓기) + +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_corridor_server.py -q +""" + +import json +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _run() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_508_2_corridor_server.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_서버_낡음_옛_모형_뒤_바꿔_끼움() -> None: + out = _run() + assert out["serverFresh"] is False # 늦음 표시 + assert out["serverEntryShown"] == [9] and out["serverEntryOldZ"] == 100 # 옛 모형 + assert out["serverStaleGet"] is True + assert out["refreshOk"] is True and out["refreshCalls"] == 1 + assert out["refreshSamples"] == 101 # 라이브 계획선 샘플을 서버에 실음 + assert out["refreshNewZ"] == 101 and out["refreshShown"] == [9, 9] + assert out["againOk"] is True and out["againCalls"] == 1 # 같은 입력 = 다시 안 지음 + assert out["failOk"] is False and out["failSetCalls"] == 0 and out["failZ"] == 101 + + +def test_both_는_지금_그대로() -> None: + out = _run() + assert out["bothFresh"] is False and out["bothEntryShown"] == [] + assert out["bothStaleGet"] is False + assert out["bothRefreshOk"] is True and out["bothCalls"] == 0 and out["bothShown"] == [9] + + +def test_창_열_때_서버면_자동_안_지음_단추_글() -> None: + three = (ROOT / "A00_Common/design/design_view_profile_3d.ts").read_text(encoding="utf-8") + assert 'calcOnServer("corridor_mesh").then((server) => void (server || refreshOnce()))' in three + assert "setCorridorPending(!fresh)" in three + view = (ROOT / "B05_Profile/B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") + assert '? "서버에서 짓는 중…"' in view and 'calcOnServerNow("corridor_mesh")' in view diff --git a/resources/tester/test_508_2_recommend_rerun.py b/resources/tester/test_508_2_recommend_rerun.py new file mode 100644 index 000000000..dcc124d27 --- /dev/null +++ b/resources/tester/test_508_2_recommend_rerun.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""508-2 ② B05 추천 노선 창 [재계산] — 저장본 없음(409) 안내 줄 옆 · 칸(제약 · 비중) 바꾼 뒤만 보임. + +505-1 · 509-5 규칙 그대로 — 접근 = 저장본만 · 사용자가 누른 셈만 그 자리. + +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_recommend_rerun.py -q +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +REC = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Recommend.ts" + + +def test_보임_규칙() -> None: + ts = REC.read_text(encoding="utf-8") + assert "rerun.hidden = !(missing || dirty);" in ts + assert "if (missing) stateEl.after(rerun);" in ts # 안내 줄(상태 줄) 옆 + assert "else profileToggle.after(rerun);" in ts # 칸 바꿈 = 종단 반영 오른쪽(지금처럼) + run = ts.split("async function run(recompute = false)")[1].split("pills.forEach")[0] + assert "missing = false;" in run.split("try {")[0] # 새 셈마다 지움 + assert "missing = message.includes(NOT_PREPARED_HEAD);" in run + assert 'rerun.addEventListener("click", () => void run(true));' in ts + + +def test_안내_글_머리_서버와_같음() -> None: + ts = REC.read_text(encoding="utf-8") + head = re.search(r'const NOT_PREPARED_HEAD = "([^"]+)";', ts).group(1) + py = (ROOT / "B05_Profile" / "B05_Profile_Router_RouteInitial.py").read_text(encoding="utf-8") + message = re.search(r'^NOT_PREPARED = "([^"]+)"', py, re.M).group(1) + assert message.startswith(head) diff --git a/resources/tester/test_508_2_sheet_follow.py b/resources/tester/test_508_2_sheet_follow.py new file mode 100644 index 000000000..97b43cb3d --- /dev/null +++ b/resources/tester/test_508_2_sheet_follow.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""508-2 ③ 노선 편집 평면 도엽 등고선 — 편집 중 노선을 범위(저장 노선 띠 300 + 여유 300 m) 밖으로 +끌어 놓으면 잠잠 뒤 그 초안 노선 범위로 서버에 잘린 등고선을 다시 받아 바꿔 끼움(통째 사본 안 받음). + +- 서버 `bbox`(route 만 · 사업지 m) = 저장 노선 대신 그 bbox 로 범위 · 열쇠 = 사본 지문 + 범위 · + 같은 범위 = 다시 안 셈 +- 화면 `createSheetFollow` — 범위 안 = 안 받음 · 밖 = 잠잠 뒤 한 번 · 지금 노선 범위 · + 떠난 뒤 = 안 받음 +- 배수유역도 범위(509-6)는 그대로(bbox 안 받음) + +임시 폴더 사업지(509-1 과 같음) — 공유 storage · DB 안 씀. +./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_sheet_follow.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest +from test_509_1_sheet_clip_route import META, project # noqa: F401 — 같은 사업지 + +from B04_PreProcess import B04_PreProcess_Engine_SheetClip as clip + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def test_bbox_읽기() -> None: + assert clip.parse_route_bbox("6200,5000,6700,5200") == (6200, 5000, 6700, 5200) + for bad in (None, "", "1,2,3", "a,b,c,d", "5,0,1,0", "nan,0,1,1", "0,0,inf,1"): + assert clip.parse_route_bbox(bad) is None + + +def test_편집_노선_범위로_자름_같은_범위_다시_안_셈(project) -> None: # noqa: F811 + root, source = project + saved = clip.clipped_sheet(root, source, "route") + moved = clip.clipped_sheet(root, source, "route", (6200, 5000, 6700, 5200)) + assert moved != saved and moved.parent == saved.parent + data = json.loads(moved.read_text(encoding="utf-8")) + assert data["clip_lonlat"] == pytest.approx([127.056, 37.044, 127.073, 37.058]) + stamp = moved.stat().st_mtime_ns + again = clip.clipped_sheet(root, source, "route", (6210, 5010, 6690, 5190)) # 같은 눈금 + assert again == moved and again.stat().st_mtime_ns == stamp + # 저장 노선 bbox 를 그대로 보내면 저장 노선 범위와 같은 저장본 + assert clip.clipped_sheet(root, source, "route", (5000, 5000, 5500, 5200)) == saved + + +def test_라우터_bbox_는_route_만() -> None: + text = (ROOT / "B04_PreProcess" / "B04_PreProcess_Router_GIS.py").read_text(encoding="utf-8") + assert 'bounds = parse_route_bbox(bbox) if scope == "route" else None' in text + assert "clipped_sheet, project_root, display, scope, bounds" in text + + +def test_화면_놓은_뒤_다시_받음() -> None: + proc = subprocess.run( + ["node", str(HERE / "helper_508_2_sheet_follow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout.strip().splitlines()[-1]) + assert out["bbox"] == "5000,4950,5500,5200" # 50 m 눈금 바깥 맞춤 + assert out["insideCalls"] == 0 # 범위 안 끌기 = 안 받음 + assert out["beforeQuiet"] == 0 # 끄는 동안 = 안 받음 + assert out["outsideCalls"] == [{"scope": "route", "bbox": "6200,5000,6700,5200"}] # 한 번 + assert out["swaps"] == 1 and out["swappedCovers"] is True and out["lineKept"] is True + assert out["againCalls"] == 1 and out["coveringSame"] is True + assert out["disposedCalls"] == 1 + + +def test_여는_때도_통째_사본_안_받음() -> None: + ts = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Contour.ts").read_text(encoding="utf-8") + assert "fetchDrainageLayers(projectId, () => {}, null)" not in ts + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert len(edit.splitlines()) <= 700 + assert "sheetFollow?.poke();" in edit and "sheetFollow?.dispose();" in edit