Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-22 19:25:11 +09:00
24 changed files with 1187 additions and 71 deletions
+4 -2
View File
@@ -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<T>(
projectId: string,
layer: string,
scope?: SheetScope,
bbox?: string,
): Promise<T> {
const range = scope ? `&scope=${scope}` : "";
const range = (scope ? `&scope=${scope}` : "") + (bbox ? `&bbox=${bbox}` : "");
return fetchCachedJson<T>(
projectId,
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`,
+6 -3
View File
@@ -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<void> {
@@ -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()));
},
});
@@ -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])
+14 -3
View File
@@ -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 == "등고선":
+40 -6
View File
@@ -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<CorridorBuildResult | null> {
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<void> {
if (!routeId) return;
): Promise<boolean> {
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<boolean> {
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);
@@ -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<CorridorBuildResult | null> {
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<CorridorBuildResult | null> {
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();
@@ -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<readonly [DrainageLayer, GeoJsonCollection | null]>;
@@ -463,6 +464,7 @@ export async function fetchDrainageLayers(
projectId,
layer,
scope ?? undefined,
bbox,
);
return [layer, collection] as const;
} catch {
+5 -1
View File
@@ -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 업데이트";
+29 -7
View File
@@ -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<typeof createSheetFollow> | 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<ReturnType<typeof loadBackground>>): 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();
+80 -17
View File
@@ -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<readonly [number, number]>): 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<readonly [string, GeoJsonCollection | null]>;
/** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */
@@ -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<SheetLayers> {
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<void>,
): { poke: () => void; dispose: () => void } {
let timer: number | undefined;
let running = false;
let again = false;
let disposed = false;
const check = async (): Promise<void> => {
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 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */
@@ -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<AnalysisTerrain, string> = { 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<HTMLElement>("[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);
@@ -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 {
+29 -1
View File
@@ -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);
}
@@ -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<typeof createActor>, 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']);
});
});
@@ -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}개를 삭제했습니다.`);
},
});
@@ -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));
})();
@@ -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));
})();
+3 -3
View File
@@ -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"):
@@ -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 늦음」 표시 그대로
+3 -3
View File
@@ -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'<div class="b05-routerec__row"><button[^>]*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")
+334
View File
@@ -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) 아님
@@ -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
@@ -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)
@@ -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