diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index d8e10a59b..22783377d 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -229,7 +229,7 @@ export async function fetchRouteElevations( return payload.z; } -/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +/** 횡단 미리보기 한 장(평면 오른쪽 판) — 계산 묶음 그 측점을 이 꼴로 그림(365-3 · 서버 길 없음). */ export interface CrossPreviewResponse { status: string; chainage_m: number; @@ -241,7 +241,7 @@ export interface CrossPreviewResponse { cross_half_width_m: number; /** 원지반 횡단 샘플. */ samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; - /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + /** 계획 횡단 — 묶음 cross 단계 설계(B06 카드와 같음). 계획고를 못 세우면 null. */ design: { design_line: Array<{ offset_m: number; elevation_m: number }>; cut_area_m2: number; @@ -250,32 +250,6 @@ export interface CrossPreviewResponse { } | null; } -export interface CrossPreviewRequest { - vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; - chainage_m: number; - min_radius_m: number; - station_interval_m: number; - /** 횡단 반폭(m) — 없으면 서버 기본값. */ - cross_half_width_m?: number; - /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ - station_snap?: boolean; -} - -/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ -export async function fetchCrossPreview( - projectId: string, - request: CrossPreviewRequest, -): Promise { - return requestJson( - `/projects/${projectId}/route/cross-preview`, - { - method: "POST", - body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), - }, - 120000, - ); -} - /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 379907567..bbf439ac5 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -1,15 +1,14 @@ """계획노선 편집 중 **지반고만** 묻는 가벼운 통로. 편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어 -**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가 -있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 +**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)는 지반고가 있어야 한다(측점 횡단 미리보기 = 계산 +묶음 · 365-3). 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다. 표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. 두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 - POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag) @@ -30,13 +29,7 @@ from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Engine_Sections_Core import ( - SectionGenerationOptions, - generate_sections, -) -from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( AnalysisTerrain, @@ -121,143 +114,6 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> } -class PreviewVertex(BaseModel): - """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" - - x: float - y: float - curve: bool = True - radius_m: float | None = None - - -class CrossPreviewRequest(BaseModel): - """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" - - vertices: list[PreviewVertex] = Field(..., min_length=2) - chainage_m: float = Field(..., ge=0) - #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. - min_radius_m: float = Field(12.0, gt=0) - station_interval_m: float | None = None - #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. - cross_half_width_m: float | None = Field(None, gt=0) - terrain: AnalysisTerrain | None = None - #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. - station_snap: bool = False - - -def _cross_preview( - project_root: Path, - params: dict, - request: CrossPreviewRequest, -) -> dict | None: - """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. - - **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 - 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` - 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. - - ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 - 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 - 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. - """ - try: - sampler = build_surface_sampler( - project_root / _MODELS_SUBDIR, - str(params["source_filter"]), - str(params["method"]), - bool(params["smooth"]), - ) - except (FileNotFoundError, KeyError, OSError, ValueError) as exc: - logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc) - return None - - built = build_planned_polyline( - [(vertex.x, vertex.y) for vertex in request.vertices], - min_radius_m=request.min_radius_m, - # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). - simplify=False, - curve_flags=[vertex.curve for vertex in request.vertices], - radii=[vertex.radius_m for vertex in request.vertices], - ) - interval = request.station_interval_m - defaults = SectionGenerationOptions() - options = SectionGenerationOptions( - station_interval_m=float(interval) - if interval and interval > 0 - else defaults.station_interval_m, - cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, - ) - # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. - result = generate_sections( - built.vertices, - sampler, - options, - design_curves=[curve.as_dict() for curve in built.curves], - station_snap=request.station_snap, - ) - sections = result["cross_sections"] - if not sections: - return None - section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) - - design = None - center_z = section.get("center_z") - if center_z is not None: - # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. - section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" - design = compute_cross_design( - section["samples"], - float(center_z), - ground_type="soil", - section_mode=section_mode, - **curve_widening_args(section), - ) - return { - "chainage_m": round(float(section["chainage_m"]), 3), - "label": section.get("label"), - "uphill_side": section.get("uphill_side"), - "plan_radius_m": section.get("plan_radius_m"), - "curve_widening_m": section.get("curve_widening_m"), - # 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다. - "cross_half_width_m": options.cross_half_width_m, - "samples": section["samples"], - "design": design, - "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) - if result.get("longitudinal", {}).get("total_length_m") is not None - else None, - } - - -@router.post("/{project_id}/route/cross-preview", response_model=None) -async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: - """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). - - 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. - """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored = await get_project_storage_relative_path(connection, project_id) - if not stored: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, - ) - params = await get_surface_confirmation_params(connection, str(project_id)) - params = analysis_terrain_params(params, request.terrain) - - project_root = Path(resolve_stored_project_path(stored)) - preview = await asyncio.to_thread(_cross_preview, project_root, params, request) - if preview is None: - return JSONResponse( - status_code=409, - content={ - "status": "error", - "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", - }, - ) - return {"status": "success", "project_id": str(project_id), **preview} - - @router.get("/{project_id}/route/terrain-sources", response_model=None) async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: """편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3d05ffc66..7a091bca8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -266,7 +266,6 @@ export async function mountRouteEditor( const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); stationSnapButton.addEventListener("click", () => { setStationSnap(!stationSnap); - void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. draw(); }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ @@ -684,7 +683,6 @@ export async function mountRouteEditor( useBackground(background); measure.clear(); slope.refresh(); - void crossPreview.refresh(); showStatus(); draw(); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index c0b491141..c93d4d2b9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -2,22 +2,24 @@ * B05_Profile_UI_RouteEdit_Cross.ts * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲). * - * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다. + * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다. * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** - * 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다. + * 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다. * - * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · + * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 · * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. * - * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 - * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. - * - * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 - * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. + * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 + * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · + * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { useDesignStore } from "../A00_Common/design/design_store"; +import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { buildCrossSvg, CROSS_PLOT_PAD, @@ -30,7 +32,7 @@ export interface CrossPreviewParams { projectId: string; /** 두 판이 들어앉을 오른쪽 세로 칸. */ side: HTMLElement; - /** 지금 편집값 — 셈을 부르는 순간에 읽는다. */ + /** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */ request: () => { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; @@ -51,7 +53,7 @@ export interface CrossWatch { export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; - /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ + /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */ watched: () => CrossWatch | null; @@ -158,54 +160,96 @@ function createPane(title: string, empty: string): CrossPane { }; } +/** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */ +export function pipelineCrossAt( + pipe: DesignPipeline | null, + chainageM: number, + intervalM: number, + halfWidthM: number | undefined, +): CrossPreviewResponse | null { + const stations = pipe?.output("ground")?.stations ?? []; + if (!pipe || !stations.length) return null; + const station = stations.reduce((best, next) => + Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best, + ); + const section = stationSection(station, pipe.output("cross"), {}, intervalM); + const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0))); + return { + status: "success", + chainage_m: section.chainage_m, + label: section.label ?? null, + uphill_side: section.uphill_side ?? null, + plan_radius_m: section.plan_radius_m ?? null, + curve_widening_m: section.curve_widening_m ?? null, + cross_half_width_m: halfWidthM ?? reach, + samples: section.samples, + design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null, + }; +} + export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다."); const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다."); params.side.append(current.root, previous.root); + const interval = (): number => params.request().station_interval_m; /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ let watching: number | null = null; - /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */ + /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; - /** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */ - let ticket = 0; + let drawnRoute: unknown = null; - async function load(chainageM: number, keepPrevious: boolean): Promise { - const mine = ++ticket; - const request = params.request(); - if (keepPrevious && shown) previous.show(shown, request.station_interval_m); - current.wait("읽는 중…"); - try { - const preview = await fetchCrossPreview(params.projectId, { - ...request, - chainage_m: chainageM, - cross_half_width_m: appliedHalfWidth(params.projectId), - }); - if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다. - shown = preview; - current.show(preview, request.station_interval_m); - } catch (error) { - if (mine !== ticket) return; - shown = null; - current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다."); - } + function redraw(): void { + if (watching === null) return; + const route = pipe?.input("route") ?? null; + if (route !== drawnRoute && shown) previous.show(shown, interval()); + drawnRoute = route; + shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId)); + if (shown) current.show(shown, interval()); + else current.wait("계산 중…"); params.onShown?.(); } + // 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음 + let pipe: DesignPipeline | null = null; + let stopPipe = (): void => undefined; + let connected = false; + const attach = (): void => { + if (current.root.isConnected) connected = true; + else if (connected) return stop(); // 편집 창이 닫혔다 + const now = pagePipeline(params.projectId); + if (now === pipe) return; + stopPipe(); + pipe = now; + stopPipe = + now?.subscribe((event) => { + if (event.kind === "stage" && event.stage === "cross") redraw(); + }, "평면 횡단 판") ?? ((): void => undefined); + }; + const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach); + const stop = (): void => { + stopStore(); + stopPipe(); + }; + attach(); + return { async open(chainageM) { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. - previous.show(null, params.request().station_interval_m); + previous.show(null, interval()); watching = chainageM; - await load(chainageM, false); + attach(); + drawnRoute = pipe?.input("route") ?? null; + shown = null; + redraw(); }, async refresh() { - if (watching === null) return; - await load(watching, true); + attach(); + redraw(); }, // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. - // 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다. + // 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음. watched: () => watching === null || !shown ? null diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 53f7ec7cd..12331fdd2 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -102,6 +102,53 @@ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { return out as SectionSpecs; } +/** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */ +export function stationSection( + station: PipelineStation, + cross: CrossOutput | null, + own: SectionSpecs, + intervalM: number, +): CrossSection { + const center = station.ground?.centerZ ?? null; + const head: SectionStation = { + station_id: stationIdOf(station.chainageM), + chainage_m: station.chainageM, + label: stationLabel(station.chainageM, intervalM), + kind: kindOf(station), + ...(own.structure ? { structure: own.structure } : {}), + center_z: center, + azimuth_deg: station.azimuthDeg, + plan_radius_m: station.planRadiusM, + curve_outer_side: station.curveOuterSide, + curve_widening_m: station.curveWideningM, + center_x: station.x, + center_y: station.y, + uphill_side: uphillSide(station), + frame: { left_xy: station.left }, + }; + const sample = (offset: number, z: number | null) => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null && Number.isFinite(z), + }); + const samples = [ + ...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)), + sample(0, center), + ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), + ]; + const entry = cross?.byKey[station.key] as { detail?: unknown; samples?: unknown[] } | undefined; + const design = entry?.detail as CrossSection["design"] | undefined; + // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) + return { + ...head, + ...own, + // 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n) + samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples, + ...(design ? { design: { ...design } } : {}), + preview: true, + }; +} + /** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */ export function draftSectionDetail( saved: SectionDetailResponse, @@ -111,48 +158,14 @@ export function draftSectionDetail( specs: ReadonlyMap, intervalM: number, ): SectionDetailResponse { - const sections: CrossSection[] = ground.stations.map((station) => { - const own = atChainage(specs.get(station.key) ?? {}, station.chainageM); - const center = station.ground?.centerZ ?? null; - const head: SectionStation = { - station_id: stationIdOf(station.chainageM), - chainage_m: station.chainageM, - label: stationLabel(station.chainageM, intervalM), - kind: kindOf(station), - ...(own.structure ? { structure: own.structure } : {}), - center_z: center, - azimuth_deg: station.azimuthDeg, - plan_radius_m: station.planRadiusM, - curve_outer_side: station.curveOuterSide, - curve_widening_m: station.curveWideningM, - center_x: station.x, - center_y: station.y, - uphill_side: uphillSide(station), - frame: { left_xy: station.left }, - }; - const sample = (offset: number, z: number | null) => ({ - offset_m: offset, - elevation_m: z, - valid: z !== null && Number.isFinite(z), - }); - const samples = [ - ...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)), - sample(0, center), - ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), - ]; - const entry = cross?.byKey[station.key] as - { detail?: unknown; samples?: unknown[] } | undefined; - const design = entry?.detail as CrossSection["design"] | undefined; - // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) - return { - ...head, - ...own, - // 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n) - samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples, - ...(design ? { design: { ...design } } : {}), - preview: true, - }; - }); + const sections: CrossSection[] = ground.stations.map((station) => + stationSection( + station, + cross, + atChainage(specs.get(station.key) ?? {}, station.chainageM), + intervalM, + ), + ); const { chainageM, z } = ground.longitudinal; return { longitudinal: { diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index c65ca075d..02fdb566e 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -177,6 +177,10 @@ async function savedBaseOf() { } const store = load("A00_Common", "design", "design_store.ts"); +// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜 +STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; +STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; +const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); /** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */ @@ -274,6 +278,26 @@ async function run() { runs: runs().length - mark, sources: runs()[runs().length - 1].sources, }; + // ⑦ B05 오른쪽 횡단 판 = 묶음 그 측점(카드와 같은 계획고 · 면적) + const card = pageMod.pipelineDetail(P).cross_sections; + const panes = card.map((section) => { + const shown = pane.pipelineCrossAt(pipe, section.chainage_m + 0.4, job.interval, undefined); + return [ + r3(shown?.chainage_m) === r3(section.chainage_m), + r3(shown?.design?.design_elevation_m) === r3(section.design?.design_elevation_m), + r3(shown?.design?.cut_area_m2) === r3(section.design?.cut_area_m2), + shown?.samples.length === section.samples.length, + ]; + }); + const paneCheck = { + count: card.length, + same: panes.filter((row) => row.every(Boolean)).length, + elevation: r3( + pane.pipelineCrossAt(pipe, target.chainageM, job.interval, undefined)?.design + ?.design_elevation_m, + ), + }; + // ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림 mark = runs().length; const same = await pageMod.reconcilePagePipeline(P); @@ -312,7 +336,7 @@ async function run() { }; pageMod.disposePagePipeline(P); page.clearDrafts(P); - return { kept, b05, b06, dropped, edit, loop }; + return { kept, b05, b06, dropped, edit, loop, pane: paneCheck }; } run() diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index a610047b4..baa25097f 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -1,6 +1,7 @@ /* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 — * `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다. - * 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다. + * 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 · + * 묶음 그 측점 원지반 표본 = 좌우 20 m). */ const fs = require("fs"); const path = require("path"); @@ -56,7 +57,6 @@ globalThis.ResizeObserver = class { disconnect() {} }; -const asked = []; const STUBS = { "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} }, "../common_util/common_util_station": { stationLabel: (m) => String(m) }, @@ -65,17 +65,24 @@ const STUBS = { CROSS_PLOT_PAD: 0, summarizeCross: () => "", }, - // 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20. - "./B05_Profile_Api_Replan": { - fetchCrossPreview: async (_project, request) => { - asked.push(request.cross_half_width_m ?? null); - return { - chainage_m: request.chainage_m, - cross_half_width_m: request.cross_half_width_m ?? 20, - samples: [], - design: null, - }; - }, + // 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m + "../A00_Common/design/design_pipeline_page": { + pagePipeline: () => ({ + output: (name) => + name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null, + input: () => null, + subscribe: () => () => undefined, + }), + }, + "../A00_Common/design/design_store": { + useDesignStore: () => ({ subscribe: () => () => undefined }), + }, + "./logic/B05_Profile_Pipeline_Detail": { + stationSection: (station) => ({ + chainage_m: station.chainageM, + label: null, + samples: [-20, 0, 20].map((offset_m) => ({ offset_m, elevation_m: 100, valid: true })), + }), }, }; @@ -111,17 +118,17 @@ async function run() { const out = {}; out.before = cross.watched(); await cross.open(100); - out.empty = { asked: asked.at(-1), watched: cross.watched() }; + out.empty = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:3", "15"); await cross.refresh(); - out.applied15 = { asked: asked.at(-1), watched: cross.watched() }; + out.applied15 = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:12", "8.5"); session.setItem("aislo:draft:cross-display:p-2:99", "40"); await cross.refresh(); - out.latestRoute = { asked: asked.at(-1), watched: cross.watched() }; + out.latestRoute = { watched: cross.watched() }; session.setItem("aislo:draft:cross-display:p-1:12", "abc"); await cross.refresh(); - out.badValue = { asked: asked.at(-1), watched: cross.watched() }; + out.badValue = { watched: cross.watched() }; return out; } run().then((out) => console.log(JSON.stringify(out))); diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py index eba2b8ab9..896998426 100644 --- a/resources/tester/test_133_1_analysis_terrain.py +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -138,10 +138,6 @@ def test_수치지형도_서피스_없으면_라이다만(tmp_path): def test_허용_목록_밖_거절(bad): with pytest.raises(ValidationError): terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad) - with pytest.raises(ValidationError): - terrain_router.CrossPreviewRequest( - vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad - ) with pytest.raises(ValidationError): replan.RouteInitialRequest(terrain=bad) @@ -167,8 +163,8 @@ def test_화면_고르기_배선(): assert '' in chrome assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api - # 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음 - assert api.count("terrain: analysisTerrain(projectId)") == 2 + # 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음 + assert api.count("terrain: analysisTerrain(projectId)") == 1 assert "terrain: analysisTerrain(projectId)" in initial_api replan_fn = api.split("export async function replanRoute")[1].split("export ")[0] assert "terrain" not in replan_fn diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py index a779c07df..dfb43a618 100644 --- a/resources/tester/test_224_2_station_snap.py +++ b/resources/tester/test_224_2_station_snap.py @@ -359,30 +359,3 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non with pytest.raises(RuntimeError): sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False) assert seen["station_snap"] is True - - -def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None: - from B05_Profile import B05_Profile_Router_Terrain as terrain - from common_util.common_util_route_polyline import build_planned_polyline - - monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat()) - built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False) - points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64)) - spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves]) - assert len(spans) == 1 - target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5) - assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음 - - def ask(snap: bool) -> float: - request = terrain.CrossPreviewRequest( - vertices=[{"x": x, "y": y} for x, y in _NODES], - chainage_m=target, - min_radius_m=40.0, - station_interval_m=20.0, - station_snap=snap, - ) - params = {"source_filter": "f", "method": "m", "smooth": False} - return terrain._cross_preview(tmp_path, params, request)["chainage_m"] - - assert ask(True) == target - assert ask(False) % 20 == 0 diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 45924176d..4bec14fa8 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -8,6 +8,8 @@ 못박는 것(365-2) - 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임. - 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번. +못박는 것(365-3) + - B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음. 짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · `helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). @@ -116,6 +118,20 @@ def test_365_2_고리_바퀴_수(r): assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1 +def test_365_3_평면_횡단_판_카드와_같음(r): + p = r["pane"] + assert p["count"] > 5 and p["same"] == p["count"] + assert p["elevation"] is not None + + +def test_365_3_서버_횡단_미리보기_길_없음(): + router = (ROOT / "B05_Profile/B05_Profile_Router_Terrain.py").read_text(encoding="utf-8") + api = (ROOT / "B05_Profile/B05_Profile_Api_Replan.ts").read_text(encoding="utf-8") + edit = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "cross-preview" not in router and "cross-preview" not in api + assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림 + + @pytest.fixture(scope="module") def swap(tmp_path_factory) -> dict: work = tmp_path_factory.mktemp("b365") diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 2242aaf25..4f7420a55 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증. -파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값. -선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다. +파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위 +(계산 묶음 그 측점 표본 · 365-3). Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다. """ @@ -31,21 +31,17 @@ def test_측점을_고르기_전에는_선이_없다(results): assert results["before"] is None -def test_적용한_적_없으면_서버_기본값_양쪽(results): - assert results["empty"]["asked"] is None +def test_적용한_적_없으면_원지반_범위_양쪽(results): assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20} def test_적용값이_있으면_그_값_양쪽(results): - assert results["applied15"]["asked"] == 15 assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15} def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results): - assert results["latestRoute"]["asked"] == 8.5 assert results["latestRoute"]["watched"]["leftM"] == 8.5 def test_망가진_값은_기본값으로(results): - assert results["badValue"]["asked"] is None assert results["badValue"]["watched"]["rightM"] == 20