From dfd7427c46b82f7d4952ef1aa3f51c6d3868da0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 20:48:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=86=93=EA=B1=B0=EB=82=98=20=EC=98=AE=EA=B8=B4=20?= =?UTF-8?q?=EA=B4=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=98=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20[=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20=EC=A0=84=EC=97=90=20=EB=B3=B4=EC=9E=84=20(273-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses) - 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시 - [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값 - 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈 - 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- A00_Common/design/design_view_cross.ts | 5 +- B05_Profile/B05_Profile_Engine_Sections.py | 63 ++++-- B06_Section/B06_Section_Api_Fetch.ts | 16 ++ B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Router_Design.py | 17 +- B06_Section/B06_Section_Router_Stations.py | 120 +++++++++-- B06_Section/B06_Section_Section_Drafts.ts | 186 ++++++++++++++++++ B06_Section/B06_Section_Section_Store.ts | 72 +------ .../B06_Section_UI_Page_Structures_Panel.ts | 7 +- .../B06_Section_UI_Section_View_Menu.ts | 2 + .../B06_Section_UI_Section_View_Profile.ts | 11 +- .../tester/helper_177_3_station_line_keep.cjs | 1 + .../tester/helper_273_2_preview_stations.cjs | 113 +++++++++++ .../tester/test_273_2_preview_stations.py | 132 +++++++++++++ 14 files changed, 642 insertions(+), 105 deletions(-) create mode 100644 B06_Section/B06_Section_Section_Drafts.ts create mode 100644 resources/tester/helper_273_2_preview_stations.cjs create mode 100644 resources/tester/test_273_2_preview_stations.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 83b10d48e..530de041a 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -343,15 +343,14 @@ export async function mountCrossView( addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), - removeStructure: (structureId) => { - structuresPanel.removeStructureById(structureId); - }, + removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId), movePipe: (fromChainageM, toChainageM) => structuresPanel.movePipeTo(fromChainageM, toChainageM), moveStructure: (structureId, toChainageM) => { structuresPanel.moveStructureTo(structureId, toChainageM); }, selectStructure: (structureId) => structuresPanel.selectStructure(structureId), + pipesChanged: () => void refreshDetailForStructures(), }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 763eb0cd1..fa414226f 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -551,7 +551,7 @@ def run_section_generation( } -def generate_irregular_sections( +def sample_structure_sections( project_root: Path, route_data_path: str, filter_key: str, @@ -561,17 +561,15 @@ def generate_irregular_sections( extra_stations: tuple[tuple[float, str], ...], options: SectionGenerationOptions | None = None, crs: str | None = None, -) -> list[dict[str, Any]]: - """구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음). +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀. - 기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272). - 격자 측점과 똑같은 접선·오프셋 - 샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각 - `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 - 필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요). + 격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점 + (`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기 + (`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2). """ if not extra_stations: - return [] + return [], [] base = options or SectionGenerationOptions() merged_options = SectionGenerationOptions( station_interval_m=base.station_interval_m, @@ -592,19 +590,48 @@ def generate_irregular_sections( # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. design_curves=_load_design_curves(project_root), ) - # 구조물 측점 전부 — 격자 · 시종점과 같은 m 라 그 측점에 얹힌 것(`grid` · 272)도 함께 - irregular_stations = [ - station - for station in result["longitudinal"]["stations"] - if station.get("kind") == "irregular" or station.get("structure") - ] + + def structural(entry: dict[str, Any]) -> bool: + return entry.get("kind") == "irregular" or bool(entry.get("structure")) + + return ( + [station for station in result["longitudinal"]["stations"] if structural(station)], + [cross for cross in result["cross_sections"] if structural(cross)], + ) + + +def generate_irregular_sections( + project_root: Path, + route_data_path: str, + filter_key: str, + method: str, + smooth: bool, + *, + extra_stations: tuple[tuple[float, str], ...], + options: SectionGenerationOptions | None = None, + crs: str | None = None, +) -> list[dict[str, Any]]: + """구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음). + + 기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272). + 각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 + 필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요). + """ + irregular_stations, crosses = sample_structure_sections( + project_root, + route_data_path, + filter_key, + method, + smooth, + extra_stations=extra_stations, + options=options, + crs=crs, + ) if not irregular_stations: return [] cross_dir = project_root / _STAGE_SUBDIR / "cross_sections" cross_dir.mkdir(parents=True, exist_ok=True) - for cross_section in result["cross_sections"]: - if cross_section.get("kind") != "irregular" and not cross_section.get("structure"): - continue + for cross_section in crosses: cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"])) atomic_write_json(cross_file, cross_section) return irregular_stations diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 9b7a7cdaa..75b10640a 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -22,6 +22,7 @@ import type { CrossDesign, CrossDesignRequest, CrossDesignResponse, + CrossSection, CrossSectionPatch, SectionConfirmResponse, SectionContextResponse, @@ -120,6 +121,21 @@ export async function getSections( } /** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */ +/** + * 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음 + * ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2). + */ +export async function previewStructureSections( + projectId: string, + chainages: number[], +): Promise { + const result = await requestJson<{ cross_sections?: CrossSection[] }>( + `/projects/${projectId}/section/preview-stations`, + { method: "POST", body: JSON.stringify({ chainages }) }, + ); + return result.cross_sections ?? []; +} + export async function fetchSectionDetail( projectId: string, routeId: number, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 1de0da091..d00053611 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -318,6 +318,8 @@ export interface BoxSet { export interface CrossSection extends SectionStation { samples: SectionSample[]; + /** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */ + preview?: boolean; /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ design?: CrossDesign; /** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */ diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 294045754..65a26467b 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -582,9 +582,13 @@ def compute_default_designs( chainages: list[float], standard: dict[str, Any] | None = None, carried: dict[float, dict[str, Any]] | None = None, + crosses: dict[float, dict[str, Any]] | None = None, ) -> list[tuple[float, dict[str, Any]]]: """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. + `crosses`(누가거리 round 3 → 횡단 dict)가 오면 파일 대신 그 횡단으로 계산한다 — 아직 정본에 + 없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음). + standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. `carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로 계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다. @@ -602,11 +606,13 @@ def compute_default_designs( ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: + cross = (crosses or {}).get(round(chainage_m, 3)) cross_path = find_cross_file(cross_dir, chainage_m) - if not cross_path.is_file(): + if cross is None and not cross_path.is_file(): continue try: - cross = json.loads(cross_path.read_text(encoding="utf-8")) + if cross is None: + cross = json.loads(cross_path.read_text(encoding="utf-8")) samples = cross.get("samples") if not isinstance(samples, list): continue @@ -617,7 +623,12 @@ def compute_default_designs( samples, design_elevation_from_longitudinal(longitudinal, chainage_m), ground_type=str(user.get("ground_type") or "ripping_rock"), - section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), + section_mode=default_modes.get( + round(chainage_m, 3), + f"{cross['uphill_side']}_cut" + if cross.get("uphill_side") in ("left", "right") + else "left_cut", + ), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage_m, paved_ranges), diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 8b61400dc..9956f49b5 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -27,6 +27,7 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M @@ -165,6 +166,26 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None return Path(resolve_stored_project_path(stored)), route +async def _generation_inputs( + project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any] +) -> tuple[Any, int | None, dict[str, Any] | None]: + """측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌.""" + from B06_Section.B06_Section_Repository import ( + get_latest_section_options, + get_longitudinal_section, + ) + + async def _load(connection): + options = await get_latest_section_options(connection, project_id) + crs_epsg = await get_surface_crs_epsg( + connection, project_id, snapshot.get("surface_model_id") + ) + longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) + return options, crs_epsg, longitudinal + + return await run_with_connection(_load) + + async def _regenerate_structure_stations( project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any] ) -> int: @@ -183,20 +204,8 @@ async def _regenerate_structure_stations( _section_options_from_stored, drop_rows_off_stations, ) - from B06_Section.B06_Section_Repository import ( - get_latest_section_options, - get_longitudinal_section, - ) - async def _load(connection): - options = await get_latest_section_options(connection, project_id) - crs_epsg = await get_surface_crs_epsg( - connection, project_id, snapshot.get("surface_model_id") - ) - longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) - return options, crs_epsg, longitudinal - - stored_options, crs_epsg, longitudinal = await run_with_connection(_load) + stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot) def _regenerate() -> tuple[int, dict[str, Any] | None]: polyline = _load_route_polyline(project_root, str(route["route_data_path"])) @@ -334,3 +343,88 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse: "missing": missing, } ) + + +#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게. +PREVIEW_MAX_STATIONS = 60 + + +class PreviewStationsRequest(BaseModel): + """미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림.""" + + model_config = ConfigDict(extra="forbid") + + chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS) + + +@router.post("/{project_id}/section/preview-stations") +async def preview_structure_sections( + project_id: UUID, request: PreviewStationsRequest +) -> JSONResponse: + """초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168). + + 파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때 + 서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀. + 샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409). + """ + from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections + from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored + from B06_Section.B06_Section_Router_Design import compute_default_designs + + chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0}) + if not chainages: + return JSONResponse(content={"status": "success", "cross_sections": []}) + try: + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 노선이 없습니다."}, + ) + project_root, route = paths + snapshot = await _sampling_conditions(project_id, project_root) + if snapshot is None: + return JSONResponse( + status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING} + ) + stored_options, crs_epsg, longitudinal = await _generation_inputs( + project_id, route, snapshot + ) + if not longitudinal: + return JSONResponse(content={"status": "success", "cross_sections": []}) + + def _preview() -> list[dict[str, Any]]: + _stations, crosses = sample_structure_sections( + project_root, + str(route["route_data_path"]), + str(snapshot["filter_key"]), + str(snapshot["method"]), + bool(snapshot.get("smooth")), + extra_stations=tuple((chainage, "") for chainage in chainages), + options=_section_options_from_stored(stored_options), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + wanted = set(chainages) + picked = { + round(float(cross["chainage_m"]), 3): cross + for cross in crosses + if round(float(cross["chainage_m"]), 3) in wanted + } + designs = compute_default_designs( + project_root, + str(longitudinal["longitudinal_file_path"]), + list(picked), + crosses=picked, + ) + for chainage, design in designs: + picked[round(chainage, 3)]["design"] = design + return list(picked.values()) + + sections = await asyncio.to_thread(_preview) + except Exception: + logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "cross_sections": sections}) diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts new file mode 100644 index 000000000..b4eb74dcc --- /dev/null +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * B06_Section_Section_Drafts.ts + * 초안 측점 한 벌(272 · 273-2) — 관 · 구조물을 놓거나 옮긴 초안을 공유 상세(횡단 카드 · 유토곡선)에 맞춘다. + * + * · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를 + * 심거나 초안이 없어지면 돌려 놓음. + * · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB + * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. + * + * 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙 + * (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`. + * ========================================================================== */ + +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, +} from "@util/common_util_culvert_sets"; +import { + pipeStationMarks, + plantedChainages, + SAME_STATION_M, + structureStationMarks, +} from "../common_util/common_util_structure_station"; +import { + previewStructureSections, + type CrossSection, + type SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; + +type Pipes = PendingPipes; + +const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M; +const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) => + a.chainage_m - b.chainage_m; + +/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */ +const saved = new Map>(); +/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */ +const previews = new Map(); +/** 초안이 빼 둔 옛 자리 측점. */ +const parked = new WeakMap(); +/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */ +const turns = new WeakMap(); + +/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */ +export function forgetDraftStations(projectId: string): void { + saved.delete(projectId); + for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key); +} + +function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> { + let hit = saved.get(projectId); + if (!hit) { + hit = Promise.all([ + fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes), + fetchStructures(projectId).then((res) => res.structures), + ]); + hit.catch(() => saved.delete(projectId)); + saved.set(projectId, hit); + } + return hit; +} + +async function previewOf( + projectId: string, + routeId: number, + chainages: number[], +): Promise { + const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`; + const ask = chainages.filter((at) => !previews.has(key(at))); + if (ask.length) { + const got = await previewStructureSections(projectId, ask); + ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null)); + } + return chainages.flatMap((at) => previews.get(key(at)) ?? []); +} + +/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ +function decorate( + fresh: CrossSection[], + pipes: Pipes, + structures: StructureInstance[], + types: StructureType[], +): void { + const registry = registryDefaults(types); + const byId = new Map(types.map((type) => [type.type_id, type])); + const pipeLabel = (pipe: Pipes[number]): string | null => { + const facility = pipe.facility ?? "pipe"; + if (facility === "ford_pavement") return null; + if (facility !== "pipe") return byId.get(facility)?.name ?? facility; + const values = { ...registry.pipe, ...pipe.options }; + return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; + }; + const labels: Array<[number, string]> = [ + ...pipes.flatMap((pipe) => { + const label = pipeLabel(pipe); + return label + ? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]) + : []; + }), + ...structures.flatMap((structure) => { + const type = byId.get(structure.type_id); + const label = type?.name ?? structure.type_id; + return structureStationMarks(structure, type).map((m): [number, string] => [ + m.chainage_m, + label, + ]); + }), + ]; + for (const section of fresh) { + section.preview = true; + const hit = labels.find(([at]) => near(at, section.chainage_m)); + if (hit) section.structure = hit[1]; + } + const own = pipes.filter((pipe) => + fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), + ); + attachCulvertSets( + fresh as unknown as Array>, + buildCulvertSets(own, registry), + ); +} + +/** + * 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면 + * 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는 + * 종전처럼 [저장] 뒤에 섬. + */ +export async function withDraftStations( + detail: SectionDetailResponse, + projectId: string, + routeId: number, +): Promise { + const turn = (turns.get(detail) ?? 0) + 1; + turns.set(detail, turn); + const pipesDraft = readPendingPipes(projectId); + const structuresDraft = readPendingStructures(projectId); + const all = [ + ...detail.cross_sections.filter((section) => !section.preview), + ...(parked.get(detail) ?? []), + ].sort(byChainage); + let live = all; + let stale: CrossSection[] = []; + let fresh: CrossSection[] = []; + if (pipesDraft || structuresDraft) { + const inputs = await Promise.all([ + pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), + structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), + fetchStructureTypes(true), + ]).catch(() => null); + if (inputs) { + const [pipes, structures, types] = inputs; + const planted = plantedChainages(pipes, structures, types); + const isStale = (section: CrossSection): boolean => + section.kind === "irregular" && + !section.grid && + planted.every((at) => !near(at, section.chainage_m)); + stale = all.filter(isStale); + live = all.filter((section) => !isStale(section)); + const missing = planted.filter( + (at, index) => + planted.findIndex((other) => near(other, at)) === index && + live.every((section) => !near(section.chainage_m, at)), + ); + fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; + if (fresh.length) decorate(fresh, pipes, structures, types); + } + } + if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 + detail.cross_sections.splice( + 0, + detail.cross_sections.length, + ...[...live, ...fresh].sort(byChainage), + ); + parked.set(detail, stale); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b979a8f86..9173d08f4 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,9 +18,7 @@ * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { - fetchStructures, fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -41,11 +39,8 @@ import { pavementSpecsFrom, type PavementStructureInput, } from "@util/common_util_structure_pavement"; -import { - plantedChainages, - SAME_STATION_M, - setGeneralStations, -} from "../common_util/common_util_structure_station"; +import { setGeneralStations } from "../common_util/common_util_structure_station"; +import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -69,9 +64,10 @@ function keyOf(projectId: string, routeId: number): string { async function withDraftWalls( detail: SectionDetailResponse, projectId: string, + routeId: number, ): Promise { setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) - await withDraftStations(detail, projectId); + await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) const drafts = readPendingStructures(projectId); // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 @@ -108,58 +104,6 @@ async function withDraftWalls( return withStructureAreas(detail); } -/** 저장된 관 지점 — 구조물 초안만 있을 때 심는 자리 셈에 씀. 저장 뒤(`invalidateSectionDetail`) - * 다시 받음 · 고칠 때마다 서버에 안 물음. */ -const savedPipes = new Map< - string, - Promise>["pipe_points"]> ->(); - -function savedPipesOf(projectId: string) { - let hit = savedPipes.get(projectId); - if (!hit) { - hit = fetchDetailPipePoints(projectId).then((res) => res.pipe_points); - hit.catch(() => savedPipes.delete(projectId)); - savedPipes.set(projectId, hit); - } - return hit; -} - -/** 초안이 빼 둔 옛 자리 측점 — 초안이 다시 그 자리를 심거나 초안이 없어지면 돌려 놓음. */ -const parkedSections = new WeakMap< - SectionDetailResponse, - SectionDetailResponse["cross_sections"] ->(); - -/** - * 옮긴 관 · 구조물의 옛 자리 측점(서버가 다시 맞추기 전)을 횡단 카드 · 유토곡선에서 뺀다 — 초안이 - * 심는 자리(`plantedChainages` · 서버와 같은 규칙)에 없는 서버 구조물 측점(272 · 측점 한 벌). 초안이 - * 없으면 서버 목록이 지금 것이라 다 돌려 놓음. [저장] 뒤는 서버가 맞춘 새 상세라 뺄 것이 없음. - */ -async function withDraftStations(detail: SectionDetailResponse, projectId: string): Promise { - const pipesDraft = readPendingPipes(projectId); - const structuresDraft = readPendingStructures(projectId); - let planted: number[] | null = null; - if (pipesDraft || structuresDraft) { - const [pipes, structures, types] = await Promise.all([ - pipesDraft ?? savedPipesOf(projectId), - structuresDraft ?? fetchStructures(projectId).then((res) => res.structures), - fetchStructureTypes(true), - ]).catch(() => [null, null, null] as const); - if (pipes && structures && types) planted = plantedChainages(pipes, structures, types); - } - const all = [...detail.cross_sections, ...(parkedSections.get(detail) ?? [])].sort( - (a, b) => a.chainage_m - b.chainage_m, - ); - const stale = (section: (typeof all)[number]): boolean => - planted !== null && - section.kind === "irregular" && - !section.grid && - planted.every((mark) => Math.abs(mark - section.chainage_m) >= SAME_STATION_M); - detail.cross_sections.splice(0, detail.cross_sections.length, ...all.filter((s) => !stale(s))); - parkedSections.set(detail, all.filter(stale)); -} - /** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => @@ -244,21 +188,21 @@ export async function loadSectionDetail( const key = keyOf(projectId, routeId); if (!options?.force) { const cached = cache.get(key); - if (cached) return withDraftWalls(cached, projectId); + if (cached) return withDraftWalls(cached, projectId, routeId); const inFlight = pending.get(key); if (inFlight) return inFlight; // 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다. const stored = readState("section-detail", projectId, routeId); if (stored) { cache.set(key, stored); - return withDraftWalls(stored, projectId); + return withDraftWalls(stored, projectId, routeId); } } const request = fetchSectionDetail(projectId, routeId) .then(async (detail) => { cache.set(key, detail); writeState("section-detail", detail, projectId, routeId); - return withDraftWalls(detail, projectId); + return withDraftWalls(detail, projectId, routeId); }) .finally(() => { pending.delete(key); @@ -283,7 +227,7 @@ export function replaceSectionDetail( * 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다. */ export function invalidateSectionDetail(projectId: string, routeId?: number): void { - savedPipes.delete(projectId); + forgetDraftStations(projectId); if (routeId !== undefined) { cache.delete(keyOf(projectId, routeId)); clearState("section-detail", projectId, routeId); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 29bca82ca..c22c9745a 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -62,13 +62,13 @@ const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; /** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ const PIPE_ADD_NOTICE = - "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; const PIPE_REMOVE_NOTICE = - "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "계곡 통과 시설을 뺐습니다 — 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = - "기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; export interface B06StructuresPanelDeps { projectId: string | null; @@ -493,6 +493,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); + deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index 36f17d038..2ef62ef59 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -33,6 +33,8 @@ export interface SectionStructureEdit { moveStructure: (structureId: string, toChainageM: number) => void; /** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */ selectStructure: (structureId: string) => void; + /** 배수 재계산이 관 종류 · 관경을 바꿈 — 카드(미리 뜬 새 관 그림 포함)를 다시 얹음(273-2). */ + pipesChanged?: () => void; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index d1f0a1767..8dd8478d0 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -146,6 +146,8 @@ export function createSectionProfile( let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; + /** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */ + let pipeSignature: string | null = null; /** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */ const nearestCard = (chainageM: number): CrossSection | null => { @@ -215,9 +217,16 @@ export function createSectionProfile( onBasinSelected: (chainageM) => pickFromMap(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM), // 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270) - onPipesChanged: () => { + onPipesChanged: (pipes) => { if (shared.view() === "cross" && shared.profile?.side) paintMapPick(shared.profile.side, readSelection(shared.projectId)); + // 관 종류 · 관경이 바뀜(새 관 관경은 배수 재계산이 정함) — 카드 관 그림도 다시(273-2) + const signature = JSON.stringify( + pipes.map((p) => [p.chainage_m, p.facility, p.options?.pipe_diameter_mm ?? null]), + ); + const changed = pipeSignature !== null && signature !== pipeSignature; + pipeSignature = signature; + if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.(); }, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). diff --git a/resources/tester/helper_177_3_station_line_keep.cjs b/resources/tester/helper_177_3_station_line_keep.cjs index 70673c23d..d43711fdc 100644 --- a/resources/tester/helper_177_3_station_line_keep.cjs +++ b/resources/tester/helper_177_3_station_line_keep.cjs @@ -22,6 +22,7 @@ let selectedStructureId = null; const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null); const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null); const draw = () => {}; +const revealPick = () => {}; // 화면 밖이면 가운데로(273-1) — 이 시험 몫 아님 module.exports = { line: () => selectedStationId, ${method("setSelectedStation")} diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs new file mode 100644 index 000000000..6f7528072 --- /dev/null +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -0,0 +1,113 @@ +/* 273-2 초안 측점 — 진짜 `B06_Section_Section_Drafts.ts` + 진짜 측점 규칙(`common_util_structure_station`)을 + * 가짜 서버 · 초안으로 돌림. 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (name) => { + if (!stubs[name]) throw new Error(`stub 없음: ${name} (${file})`); + return stubs[name]; + }); + return box.exports; +} + +const station = loadTs("common_util/common_util_structure_station.ts"); +let draftPipes = null; +const asked = []; +let slow = false; +const types = [ + { + type_id: "pipe", + group: "A", + placement: "point", + managed_by: "pipe_points", + name: "배관", + options: [], + }, +]; +const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: [{ chainage_m: 95.38, facility: "pipe" }] }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => types, + readPendingStructures: () => null, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes }, + "@util/common_util_culvert_sets": { + registryDefaults: () => ({ pipe: { pipe_kind: "파형강관", pipe_diameter_mm: 800 } }), + buildCulvertSets: (points) => new Map(points.map((p) => [p.chainage_m, { kind: "pipe" }])), + attachCulvertSets: (sections, sets) => { + for (const [at] of sets) { + const hit = sections.find((s) => Math.abs(s.chainage_m - at) < 0.005); + if (hit) hit.culvert = true; + } + }, + }, + "../common_util/common_util_structure_station": station, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_projectId, chainages) => { + asked.push(chainages); + if (slow) await new Promise((resolve) => setTimeout(resolve, 50)); + return chainages.map((c) => ({ + chainage_m: c, + kind: "irregular", + station_id: `s${c}`, + samples: [], + })); + }, + }, +}); + +/** 서버 상세 — 격자 0 · 20 · 40 · 100 + 옛 관 측점 95.38. */ +const detailOf = () => ({ + cross_sections: [0, 20, 40, 95.38, 100].map((c) => ({ + chainage_m: c, + kind: c === 95.38 ? "irregular" : c === 0 ? "bp" : "regular", + station_id: `s${c}`, + samples: [], + })), +}); +const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m); + +(async () => { + const out = {}; + const detail = detailOf(); + draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95 + await drafts.withDraftStations(detail, "P", 1); + const preview = detail.cross_sections.find((s) => s.chainage_m === 95); + out.moved = { + chainages: chainages(detail), + asked: asked.splice(0), + preview: { preview: preview.preview, structure: preview.structure, culvert: preview.culvert }, + }; + await drafts.withDraftStations(detail, "P", 1); + out.again = { chainages: chainages(detail), asked: asked.splice(0) }; + draftPipes = null; + await drafts.withDraftStations(detail, "P", 1); + out.cleared = { chainages: chainages(detail) }; + + const race = detailOf(); + slow = true; + draftPipes = [{ chainage_m: 96, facility: "pipe", source: "user" }]; + const first = drafts.withDraftStations(race, "P", 1); // 늦게 끝남 + await new Promise((resolve) => setTimeout(resolve, 5)); + slow = false; + draftPipes = [{ chainage_m: 120, facility: "pipe", source: "user" }]; + await drafts.withDraftStations(race, "P", 1); + await first; + out.race = { chainages: chainages(race) }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_273_2_preview_stations.py b/resources/tester/test_273_2_preview_stations.py new file mode 100644 index 000000000..69236dacb --- /dev/null +++ b/resources/tester/test_273_2_preview_stations.py @@ -0,0 +1,132 @@ +"""273-2 B06 횡단도 바로 반영 — 초안 자리 횡단 미리 뜨기(요구 168). + +- 미리 뜨기(`sample_structure_sections`)는 파일을 안 쓰고, [저장] 때 정본 쓰기 + (`generate_irregular_sections`)와 같은 횡단을 낸다(같은 길 · 화면이 본 값 = 저장 값). +- 기본 설계(`compute_default_designs`)는 파일 대신 받은 횡단으로도 셈. +- 화면 쪽: 옛 자리 숨김 · 새 자리 얹음 · 이름 · 관 그림 · 늦게 끝난 옛 맞춤이 새 것을 안 덮음 + (`helper_273_2_preview_stations.cjs`). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +import B05_Profile.B05_Profile_Engine_Sections as sections +from B06_Section.B06_Section_Router_Design import compute_default_designs +from common_util.common_util_drainage_pipes import route_signature +from common_util.common_util_route_geometry import RouteVertex + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]] +ROUTE = "B05_Profile/route/route.geojson" + + +class _Slope: + """옆으로 기운 지표 — 왼쪽이 높음(상단측 = left).""" + + def sample_xy(self, xy): + points = np.asarray(xy, dtype=np.float64).reshape(-1, 2) + return 100.0 + 0.2 * points[:, 1], np.ones(len(points), dtype=bool) + + +def _write(root: Path, rel: str, data) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _project(root: Path) -> None: + _write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}}) + vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE] + _write( + root, + "B04_PreProcess/drainage/edits/pipe_points.json", + {"route_signature": route_signature(vertices), "points": []}, + ) + + +def _files(root: Path) -> list[str]: + return sorted(str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()) + + +def test_미리_뜨기는_안_쓰고_저장과_같은_횡단(tmp_path: Path, monkeypatch) -> None: + _project(tmp_path) + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Slope()) + before = _files(tmp_path) + stations, crosses = sections.sample_structure_sections( + tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, ""),) + ) + assert _files(tmp_path) == before # 파일 · 폴더 하나도 안 생김 + assert [c["chainage_m"] for c in crosses] == [130.0] and len(stations) == 1 + sections.generate_irregular_sections( + tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, "파형강관 D800"),) + ) + written = json.loads( + (tmp_path / "B06_Section/cross_sections/cross_00130m.json").read_text(encoding="utf-8") + ) + assert written["samples"] == crosses[0]["samples"] + assert written["frame"] == crosses[0]["frame"] + + +def test_기본_설계는_받은_횡단으로도_셈(tmp_path: Path) -> None: + rel = "B06_Section/longitudinal/longitudinal.json" + plan = [{"chainage_m": c, "elevation_m": 851.0} for c in (0.0, 1000.0)] + _write(tmp_path, rel, {"stations": [], "design_profiles": [{"samples": plan}]}) + offsets = np.arange(-20.0, 20.5, 0.5) + cross = { + "chainage_m": 610.0, + "uphill_side": "right", + "samples": [ + {"offset_m": float(o), "elevation_m": 852.0 - 0.1 * float(o), "valid": True} + for o in offsets + ], + } + designs = compute_default_designs(tmp_path, rel, [610.0], crosses={610.0: cross}) + assert [chainage for chainage, _ in designs] == [610.0] + design = designs[0][1] + assert design["status"] == "provisional" and design.get("section_mode") + # 파일에도 없고 받은 횡단에도 없는 자리는 건너뜀 + assert compute_default_designs(tmp_path, rel, [611.0], crosses={610.0: cross}) == [] + + +@pytest.fixture(scope="module") +def ts() -> dict: + if not (ROOT / "config" / "node_modules" / "typescript").is_dir(): + pytest.skip("프론트엔드 의존성 미설치") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_273_2_preview_stations.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_화면_옛_자리_숨기고_새_자리_얹음(ts) -> None: + assert ts["moved"]["chainages"] == [0, 20, 40, 95, 100] # 옛 95.38 숨김 · 새 95 미리 뜬 것 + assert ts["moved"]["asked"] == [[95]] + card = ts["moved"]["preview"] + assert card["preview"] is True and card["structure"] == "파형강관 D800" + assert card["culvert"] is True # 관 그림(세트)도 얹힘 + + +def test_한_번_뜬_자리는_다시_안_물음(ts) -> None: + assert ts["again"]["asked"] == [] + assert ts["again"]["chainages"] == [0, 20, 40, 95, 100] + + +def test_초안이_없으면_서버_목록_그대로(ts) -> None: + assert ts["cleared"]["chainages"] == [0, 20, 40, 95.38, 100] + + +def test_늦게_끝난_옛_맞춤이_새_것을_안_덮음(ts) -> None: + assert ts["race"]["chainages"] == [0, 20, 40, 100, 120]