feat(508-2 B05 · B06 남은 셋): 3D 길 모형 D3 = 서버 — 낡은 저장본 = 옛 모형 세움(빈 3D 아님) · [3D 업데이트]만 서버 창구(/calc corridor.mesh · 같은 TS · 라이브 계획선 샘플)가 지어 바꿔 끼움 · 같은 입력 다시 안 지음 · 실패 = 옛 모형 · 늦음 남김 · 창 열 때 자동 안 지음(요구 324) · both 그대로 · 추천 창 [재계산] = 저장본 없음(409) 안내 줄 옆 · 칸 바꿈 뒤만 · 노선 편집 도엽 = 놓은 뒤(잠잠) 범위 밖이면 지금 노선 bbox 로 잘린 사본 다시 받아 바꿔 끼움(서버 bbox · 열쇠 = 사본 지문 + 범위 · 여는 때도 통째 사본 안 받음 · 배수유역 범위 그대로) · 시험 test_508_2 셋 · 낡은 기대 셋(383_3 · 309_2 · 345_4) 고침 — 요구 323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 17:13:41 +09:00
co-authored by Claude Opus 5.5
parent 4cf9e5d0a0
commit f1a6de017e
19 changed files with 701 additions and 58 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);
@@ -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")
@@ -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