feat(365-3): B05 오른쪽 횡단 판 = 묶음 횡단 · 서버 cross-preview 길 지움

- 평면 오른쪽 횡단 판 = 계산 묶음 그 측점(stationSection — B06 카드 · 새 노선 상세와 같은 함수) · cross 차례마다 다시 그림(노선 · 계획고) · 노선이 바뀌면 전후
- 서버 POST route/cross-preview · fetchCrossPreview · 편집기 안 refresh 두 줄 지움
- 시험 test_365(판 계획고 · 면적 · 표본 = 카드 · 서버 길 없음) · cross_width · 133-1 · 224-2 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 12:14:19 +09:00
co-authored by Claude Opus 5.5
parent 7efee22ab4
commit 495a3cd2d9
11 changed files with 211 additions and 314 deletions
+2 -28
View File
@@ -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<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{
method: "POST",
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
},
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+2 -146
View File
@@ -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:
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
-2
View File
@@ -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();
});
+82 -38
View File
@@ -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<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 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<void> {
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
@@ -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<string, SectionSpecs>,
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: {
+25 -1
View File
@@ -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()
@@ -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)));
@@ -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 '<option value="sheet">수치지형도</option>' 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
@@ -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
@@ -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")
@@ -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